OneBusAway Sidecar server reference implementation written in Golang.
OneBusAway sidecar services: the region-scoped HTTP APIs that the OneBusAway mobile apps use for features the core OneBusAway REST API server does not provide — service alerts, tripdeparture alarms, iOS Live Activities, rider surveys, ghost bus reports, push notification registration, weather, vehicle search, and donations.
See specification.md for the complete specification of the Sidecar server and openapi.yaml for the OpenAPI spec.
The reference implementation is a full implementation of the sidecar services spec in Golang.
The sidecar server requires a few other services to function properly: a job queue, a database (probably PostgreSQL), and a functioning instance of the gorush push notification server to actually send push notifications.
The sidecar serves a per-region GTFS-realtime service alerts feed, authored through a companion CLI that writes the same database directly.
GET /api/v1/regions/{regionId}/alerts— the feed as binary protobuf (application/octet-stream), the format apps consume.GET /api/v1/regions/{regionId}/alerts.pbtext— the same feed rendered as protobuf JSON (text/plain), for debugging in a browser or withcurl.
{regionId} accepts either a bare id (1) or an id-prefixed slug (1-puget-sound), and
0 is a real region (Tampa Bay), not "unset". Pass ?test=1 (any non-blank value) to
include alerts authored with --test; omit it to see what riders see.
A translation (alert translate) is served only while it still describes the English it
was made from. Every language in an alert's admin JSON carries "stale": true|false: a
language is reported stale when any of its translated fields no longer matches the
current English text, while the feed withholds each stale field individually -- so a
review UI can show what riders will not see and offer retranslation.
sidecar-admin is the CLI for creating and publishing alerts. It never talks to the
server over HTTP — it opens the same SQLite database file directly, so run it against a
copy of (or the same file as) whatever --db/SIDECAR_DB the server uses.
The steps below are sequential, not independent examples: region sync populates the
regions table that region set updates, and region set must run before the first
alert create in a fresh database — the regions directory carries no agency id, so
alert create has nothing to fall back on and refuses to guess.
go build -o bin/sidecar-admin ./cmd/sidecar-admin
# Pull the regions directory (id, name, base URLs) into the database.
./bin/sidecar-admin --db ./sidecar.db region sync
# Configure the two locally-managed fields the directory doesn't carry: the
# default agency id stamped onto alerts that don't specify one, and the
# timezone `alert create`/`alert edit` interpret naive-looking times against
# (an explicit UTC offset is still required; the timezone is only used to
# report a helpful error). Required before the first `alert create` below.
./bin/sidecar-admin --db ./sidecar.db region set --id 1 \
--agency-id 1 --timezone America/Los_Angeles
# Author, then publish, an alert. --start/--end always require an explicit
# RFC 3339 offset. `alert create` prints `created alert <id>`; publish that
# id -- it's only 1 on a fresh database.
id=$(./bin/sidecar-admin --db ./sidecar.db alert create --region 1 \
--header "Route 44 detoured" --start 2026-08-15T14:00:00-07:00 \
--cause CONSTRUCTION --effect DETOUR | awk '{print $3}')
echo "created alert $id"
./bin/sidecar-admin --db ./sidecar.db alert publish "$id"
# Alerts stay drafts -- absent from the feed -- until published.
./bin/sidecar-admin --db ./sidecar.db alert list --region 1Full command surface:
sidecar-admin region list
set --id N [--agency-id ID] [--timezone TZ] [--oba-api-key KEY]
sync
sidecar-admin alert create --region N --header TEXT --start RFC3339
[--description TEXT] [--url URL] [--end RFC3339]
[--agency-id ID] [--cause C] [--effect E]
[--severity S] [--test]
list [--region N] [--all]
show ID
edit ID [--header TEXT] [--description TEXT] [--url URL]
[--start RFC3339] [--end RFC3339 | --no-end]
[--agency-id ID] [--cause C] [--effect E]
[--severity S] [--test | --no-test]
publish ID | unpublish ID | delete ID
translate ID --language es [--header TEXT] [--description TEXT]
push ID [--audience all|test] # queue a push notification
pushes ID # that alert's pushes
sidecar-admin study create --region N --name S [--description S]
list --region N
sidecar-admin survey create --study N --file <path|->
list --region N
show ID
edit ID --file <path|->
delete ID
responses ID # long-format CSV, one row per answer
sidecar-admin ghostbus export --region N [--since RFC3339] # CSV, one row per report
sidecar-admin key create --region N --name S [--scope push] # prints the raw key once
list --region N | --minted-by-principal N
revoke --region N --id N
sidecar-admin principal create --name S # prints the raw key once
list
revoke --id N [--keep-keys]
sidecar-admin user create --username NAME [--password-stdin]
passwd --username NAME [--password-stdin]
list
delete --username NAME [--force]
sidecar-admin import --file <path|-> [--dry-run] # an OBACloud region export
sidecar-admin sequence show | bump --min N # id headroom before a migration
sidecar-admin migrate up | status
A published alert can also be sent to the region's registered devices as a push
notification. A push is a record, not a request: enqueueing one inserts a row and
returns immediately, and the server's dispatcher performs the fan-out in the
background, paging through the audience so a large send survives a restart. Both
trigger surfaces — the admin API (and the SPA's push card) and sidecar-admin —
go through the same preconditions, so they cannot drift.
A push is accepted only when the alert is published, no other push for that
alert is still queued or sending, and the audience is not empty. Several
completed pushes per alert are fine: sending to test devices, checking the
result, then sending to everyone is the intended workflow.
Audiences. all is every registration in the alert's region; test is only
those registered with test_device. An alert authored with --test can only
ever reach test devices — the server forces the test audience whatever was
asked for, the same rule that keeps test alerts out of the rider-facing feed
unless ?test=1 is passed.
Copy is derived from the alert mechanically and snapshotted into the push row
when it is enqueued, so editing the alert mid-send does not change what the rest
of the audience receives, and the record shows what was actually sent. The
English title is the header and the body is the description; when the description
is blank the title is empty and the header becomes the body, because a push with
no body is invisible on the lock screen. Titles are clamped to 48 runes and
bodies to 120, truncated on a rune boundary with a trailing …. A translation is
used only while it is fresh — the same staleness rule the feed applies — and the
field it does not cover falls back to English. Each device's locale is normalized
against the languages in the snapshot; anything that does not match gets English.
POST /api/admin/v1/regions/{regionId}/alerts/{id}/pushes {"audience":"all"|"test","messages":{...}?}
GET /api/admin/v1/regions/{regionId}/alerts/{id}/pushes
DELETE /api/admin/v1/regions/{regionId}/alerts/{id}/pushes/{pushId}
GET /api/admin/v1/regions/{regionId}/alerts/{id}/push_audience
Authenticated like the rest of /api/admin/v1 -- a session cookie or a bearer
credential (see Region API keys and service principals below) -- except that
POST and DELETE take an operator or a region key minted with the push
scope: an ordinary region key must not be able to deliver attacker-controlled
text as a push to every device in the region, so sending and canceling answer
it with 403 even though it can read everything else about the region's
alerts, while OBACloud's key for a migrated region carries the scope on purpose.
POST answers 202 with the queued push and wakes the dispatcher immediately;
GET …/pushes answers 200 with that alert's pushes, newest first; DELETE
cancels one and answers 204; GET …/push_audience answers 200 with the
all and test device counts (split by platform) and a forced_test flag for
a test alert. POST may carry messages in the same per-language
{"en": {"title","body"}, …} shape the response emits; when present it is
stored as the push's copy snapshot verbatim instead of being derived from the
alert, and it must include en, use trimmed lowercase language tags, have a
non-blank body in every language, and respect the same 48-rune title and
120-rune body caps the derivation applies. Errors are {"error": "..."}:
400 for a malformed body, an audience that is neither all nor test, or
messages that break one of those rules, 404 for an unknown alert, an
unknown push, or a pushId belonging to a different alert, and 409 for each
precondition (unpublished alert, a push already in flight, empty audience) and
for canceling a push that has already finished.
These four routes are registered only when SIDECAR_GORUSH_URL is set.
Without a transport the admin UI reports that push notifications are not
configured on this server, rather than letting an operator queue a send that
could only fail. The feedback accounting below keeps working either way.
/api/admin/v1 covers seven more region-scoped route families beyond alerts
and pushes above. Each one registers only when its backing repository is
wired into Deps, exactly what GET /api/admin/v1/regions/{regionId}
reports back as "features", so a 404 on one of these can be told apart
from "not enabled on this deployment" by checking that list first:
GET /api/admin/v1/regions/{regionId}/studies studies: also POST, GET .../{id}, PATCH .../{id}
GET /api/admin/v1/regions/{regionId}/surveys surveys: also POST, GET/PUT/DELETE .../{id}
GET /api/admin/v1/regions/{regionId}/surveys/{id}/responses survey responses, JSON (also .../responses.csv); one by public id at regions/{regionId}/survey_responses/{publicId}
GET /api/admin/v1/regions/{regionId}/ghost_bus_reports ghost bus reports, JSON (also .../ghost_bus_reports.csv, .../{publicId})
GET /api/admin/v1/regions/{regionId}/alarms alarms, read-only: also GET .../{id}
GET /api/admin/v1/regions/{regionId}/push_registrations/count push registration audience counts, aggregate only
GET /api/admin/v1/regions/{regionId}/api_keys region API keys: list with scopes (also POST {name, scopes?} to mint, DELETE .../{keyId} to revoke)
Studies and surveys are the CRUD family behind sidecar-admin study/survey
below, gated on Deps.Surveys (feature "surveys", covering both). Survey
responses are read-only, reachable through the survey's own routes above.
Ghost bus reports are read-only -- reports are rider-submitted, so there is
no admin write route -- gated on Deps.GhostBus (feature
"ghost_bus_reports"). Alarms are read-only and omit token and
user_push_id from the JSON, since those are push credentials, not admin UI
data, gated on Deps.Alarms (feature "alarms"). Push registration counts
are aggregate only, never a token listing, gated on Deps.PushRegs (feature
"push_registrations"). Region API keys are covered in their own section
below, gated on Deps.APIKeys (feature "api_keys", which also gates
bearer authentication itself).
./bin/sidecar-admin --db ./sidecar.db alert push 3 --audience test
./bin/sidecar-admin --db ./sidecar.db alert pushes 3alert push prints the id of the row it queued. The CLI never talks to the
running server, so nothing is sent until the dispatcher's next tick — within
about 15 seconds. A sidecar started without a gorush URL still runs the
dispatcher, and marks each push it claims failed with no push transport configured, so a CLI-queued push never sits queued forever.
device_countis the audience size measured when the send started. It is 0 while the push is still queued.submitted_countis how many tokens gorush accepted. That is the only positive state there is: nothing in the chain confirms end delivery.failed_countis how many were later reported undeliverable. It is never subtracted fromsubmitted_count— a device can be in both, which is more honest than claiming to know the outcome.
gorush runs asynchronously in both compose.yaml and render.yaml (it forces
core.sync off unless its queue engine is local), so a batch returns success: ok with an empty logs array and every failure arrives later on the feedback
webhook. Each batch is stamped with a notif_id of alertpush:<push id>, which
is what lets a bounce be counted against the push that caused it. Under
core.sync the rejections come back inline instead and are counted at send time;
both paths feed the same counter, and a webhook gorush replays is deduplicated
rather than double-counted.
Failures are stored as a SHA-256 hash of the token and a reason, never the token itself: the hash is only a deduplication key, nothing reads it back, and a plaintext copy would outlive the registration's retention window. Those rows cascade-delete with their push, and the pushes cascade with the alert.
The dispatcher ticks every 15 seconds and commits a cursor after each page of
500 registrations, so a crash mid-send resumes at the last committed page and
re-sends at most that one page. A row left sending and untouched for 15 minutes
is treated as stuck and reclaimed by the next cycle. (A restart does not wait
that out: the server's first cycle after boot adopts every in-flight row at
once, so a deploy mid-send resumes in seconds.) The stuck clock is also what paces
retries: a transport error leaves the push sending with its attempt counted and
its cursor where it was, and after five consecutive failures the push is marked
failed. A page that succeeds resets the counter, so a long send is not killed
by five scattered errors over its lifetime. Store read errors are not counted as
attempts; they say nothing about the transport. A store write failure after a
page has gone out — the cursor commit that records it — does count, because
otherwise a store that never accepts that write would re-send the same page on
every reclaim forever.
Canceling a queued push stops it before it starts. Canceling one mid-send stops
it at the next page boundary — batches already handed to gorush are on their way
and cannot be recalled. Canceling an already-finished push is a 409. The
dispatcher also cancels a push on its own if the alert was deleted or unpublished
between enqueue and send.
The sidecar serves spec §7: a per-region list of active rider surveys and the survey response endpoints the apps submit to.
GET /api/v1/regions/{regionId}/surveys?user_id=<device uuid>(also…/surveys.json, which both shipped apps call) — surveys that are available and inside their window.user_idis required.POST /api/v1/survey_responses(also with a trailing slash) — creates a response;responsesis a JSON-array-encoded string. Returnssurvey_response.idandupdate_path.POST|PUT|PATCH /api/v1/survey_responses/{id}— merges more answers into a response byquestion_id.
The write endpoints share one throttle of 60 requests per minute per source
address. The throttle keys on the connection's remote address: behind a
reverse proxy that does not preserve client addresses, every rider shares one
bucket, so configure the proxy (or raise surveyWritesPerMinute) accordingly.
Surveys belong to a study, and a study belongs to a region:
id=$(./bin/sidecar-admin --db ./sidecar.db study create --region 1 \
--name "Rider satisfaction" --description "Fall 2026" | awk '{print $3}')
cat > survey.json <<'EOF'
{
"name": "Rider satisfaction",
"available": true,
"start_date": "2026-09-01T00:00:00-07:00",
"end_date": "2026-09-30T23:59:00-07:00",
"show_on_map": false,
"show_on_stops": true,
"always_visible": false,
"allows_multiple_responses": false,
"visible_stop_list": ["1_570", "1_578"],
"visible_route_list": null,
"questions": [
{ "required": true,
"content": { "type": "radio", "label_text": "How was your trip?",
"options": ["Great", "Fine", "Bad"] } },
{ "content": { "type": "text", "label_text": "Anything else?" } }
]
}
EOF
./bin/sidecar-admin --db ./sidecar.db survey create --study "$id" --file survey.jsonQuestion content.type is one of text, label, radio, checkbox,
external_survey (the last takes url, survey_provider,
embedded_data_fields, sdk_configuration_values). Questions are displayed in
document order. Dates require an explicit UTC offset. Absent booleans are
false, except available, which defaults to true; an absent or empty
targeting list means "everywhere".
survey show <id> prints the same document (plus id, study, timestamps),
so survey show 3 | sidecar-admin --db ./sidecar.db survey edit 3 --file - is
a round trip. Once a survey has responses its questions are frozen — edit only
the name, dates, flags, and targeting — and it cannot be deleted.
An edit — survey edit, or PUT /api/admin/v1/regions/{regionId}/surveys/{id}
— honours the id on each question in the document, since the apps persist
question ids: a question that names one keeps it, one without gets a fresh id, a
stored question the document omits is deleted, and positions are renumbered from
document order. An id that belongs to another survey, or is named twice, is
refused (422) before any row is touched, as is any question id on a create — so
survey show 3 | survey create --file - errors on the ids it carries, while
survey show 3 | survey edit 3 --file - round-trips.
sidecar-admin study create --region N --name S [--description S]
list --region N
sidecar-admin survey create --study N --file <path|->
list --region N
show <id>
edit <id> --file <path|->
delete <id>
responses <id> # long-format CSV, one row per answer
survey responses writes rider-sourced cells (identifiers, question type/label,
answer) with a leading apostrophe when the cell would otherwise open with a
spreadsheet formula character (=, +, -, @, a tab, or a carriage return), so
opening the export can't execute a formula a rider embedded in their answer.
The sidecar serves spec §8: a write-only endpoint for riders to report a bus that the app predicted but that never arrived, plus a background worker that enriches each report with a snapshot of the trip's state at report time. There is deliberately no rider-facing read API -- the CSV export below is the read surface, and reports are agency data kept indefinitely (no retention sweep, unlike push token registrations or alarms above).
POST /api/v2/regions/{regionId}/ghost_bus_reports-- creates a report. Requiresuser_identifier,trip_identifier,service_date, andwait_duration_minutes(one of5,10,15,20,30); accepts optional route/stop/vehicle identifiers, a coordinate pair, timing fields, and a comment up to 1,000 runes. Accepts JSON, form-encoded, or query parameters, matching every other write endpoint in this repo.{regionId}accepts either a bare id (1) or an id-prefixed slug (1-puget-sound), the same as the alerts feed; there is no.jsonvariant, since the shipped client doesn't send one here.- On success:
201 {"id": "<public identifier>"}. One report per(region, user_identifier, trip_identifier, service_date)-- a duplicate returns422 {"error": "already_reported", ...}. The shipped iOS app treats any422as "already reported" regardless of error code, so validation failures and duplicates both read as a soft failure to riders; the distinctalready_reportedcode is still emitted for future or third-party clients that want to tell the two apart. - A JSON body over 8 KB (by declared
Content-Lengthor by streaming past the cap) is rejected with a bodyless403, before or without reading the body. This cap is JSON-only: form bodies keep the shared 64 KB request limit, because iOS percent-encodes comments with an allow-list, and a rider-legal emoji-heavy comment can legally push a form body past 8 KB.
Two independent limiters, per spec §2.6:
- 10 reports per hour per IP, keyed on the request's TCP peer address -- the same reverse-proxy caveat as push registrations applies here (see the Deployment section below): a proxy that re-originates the TCP connection merges every client into one shared bucket.
- 20 reports per day per
user_identifier, read from whichever of query/form/JSON the request actually used, so the bucket always matches the identifier the report is filed under. A blank or missinguser_identifierskips the counter and fails validation instead; an over-length identifier is rejected before the throttle is consulted, so it never becomes a limiter key.
A background worker polls every 30 seconds for reports still awaiting
enrichment, fetches trip details from the region's OneBusAway REST API (its
own key, falling back to SIDECAR_OBA_API_KEY), and stores a pruned snapshot
of the trip's status and display fields at the time of capture. Each report
is tried up to 3 times total before it's given up on; snapshot_status is
one of pending, captured, or unavailable. Enrichment never blocks or
fails the rider's 201 -- a region with no resolvable OBA key, or a trip
details lookup that never succeeds, just leaves the report's snapshot
unavailable.
./bin/sidecar-admin --db ./sidecar.db ghostbus export --region 1 \
--since 2026-09-01T00:00:00-07:00 > reports.csvghostbus export writes CSV to stdout, one row per report, with columns for
the report itself, its trip/route/stop display fields, and the snapshot
(vehicle position, distance from stop, trip phase, and the raw snapshot
JSON). --since is optional and, like every other CLI instant in this repo,
requires an explicit UTC offset. Rider-sourced and GTFS-derived cells go
through the same formula-injection guard as survey responses: a leading
apostrophe when a cell would otherwise open with =, +, -, @, a tab,
or a carriage return.
iOS Lock Screen widgets showing the next departures for one bookmarked route +
headsign at one stop (spec §6). The sidecar owns the update cadence: once a
minute per subscription it fetches the stop's arrivals from the region's OBA
server, builds the §6.2 content-state, and pushes it through gorush as an
APNs liveactivity push (priority 10, topic <bundle id>.push-type.liveactivity,
derived from SIDECAR_APNS_TOPIC).
POST /api/v2/regions/{regionId}/live_activities → 201 {"url": "…/live_activities/<token>"}
DELETE /api/v2/regions/{regionId}/live_activities/{token} → 204 | 404
- Registration is an upsert on
(region, activity_id): ActivityKit rotates push tokens and the app re-POSTs the same activity with each one. The URL is the same every time; every field, includingapns_sandbox, is re-read. - Required:
activity_id,push_token(≤ 4096 chars — a sidecar addition),stop_id,route_short_name,trip_headsign. Optional trip metadata:trip_id,service_date(epoch ms),vehicle_id,stop_sequence. apns_sandboxfollows the §2.7 allow-list. The stakes are highest here: a misrouted push bouncesBadDeviceToken, which the feedback webhook treats as terminal and deletes the subscription.- POST is throttled at 30/minute per TCP peer (a sidecar-specific addition — every distinct stop costs one upstream call per minute for eight hours); DELETE is not.
- Subscriptions end at 8 hours, after 3 consecutive empty/error cycles, on
client DELETE, or on terminal APNs feedback. The first two send a best-effort
endpush (dismissal 15 minutes out); the last two do not. - Without
SIDECAR_GORUSH_URLthe updater runs in store-only mode: rows expire and reap but nothing is pushed.
Deviations from OBACloud, all deliberate: route/headsign matching resolves
names through the response references like the app does; the push-token
length cap and the POST throttle are new.
The sidecar also serves two rider-facing lookups that proxy and cache an upstream source per region: current conditions for the region's map, and a fuzzy search over its live vehicle ids.
GET /api/v1/regions/{regionId}/weather— current conditions plus an hourly forecast for the region's centroid. A403means "unavailable" and covers every failure mode alike: no Pirate Weather key configured, the region has no centroid yet, or the upstream provider errored or timed out. This is deliberate, not a placeholder -- shipped apps treat any non-200 response as "hide the weather UI" and have been tested against403specifically, so it must never be "improved" to a 5xx. An unknown{regionId}is still a plain404.GET /api/v1/regions/{regionId}/vehicles?query=— substring search over the region's live vehicle ids.querymust be between 3 and 64 characters (runes, not bytes); shorter or longer queries return[]without an upstream call. Only the query is lowercased before matching -- vehicle ids are compared raw -- so a fleet with uppercase ids is effectively case-sensitive; this replicates the reference implementation's behavior and is a deliberate compatibility quirk, not a bug. Results are capped at 250. An upstream failure is a502, not an empty200: an empty list is indistinguishable from "no such vehicle", so a rider searching for a bus that exists would otherwise be told, confidently, that it doesn't. An unknown{regionId}is a404.
Both endpoints depend on process-wide keys, set as flags or environment variables:
# Default OneBusAway REST API key, used for any region with no key of its own.
# Without it, vehicle search returns 502 for regions with no key of their own.
export SIDECAR_OBA_API_KEY=...
# Pirate Weather API key. Without it, the weather endpoint returns 403 for
# every region.
export SIDECAR_PIRATE_WEATHER_KEY=...
# Base URL of the gorush push gateway. Without it, departure alarms are
# still created, stored, and reaped on schedule -- they just never fire:
# the alarm scheduler always runs (spec §5.3), but the fire step is skipped
# when no transport is configured.
export SIDECAR_GORUSH_URL=...
# APNs topic (the iOS app's bundle id), required alongside SIDECAR_GORUSH_URL:
# under .p8 token auth Apple rejects every push without one (MissingTopic).
# The server warns at boot if gorush is configured and this is not.
export SIDECAR_APNS_TOPIC=org.onebusaway.iphone
# Shared secret gorush must send on the feedback webhook, as either
# `Authorization: Bearer <secret>` or a bare `Authorization: <secret>`.
# Leave it unset only if your gorush cannot send a header: the webhook then
# stays open (and rate limited), and should be restricted at the proxy.
# gorush splits its header setting on ':', so the value must contain no ':'
# or whitespace; the server refuses to start otherwise.
export SIDECAR_GORUSH_WEBHOOK_SECRET=...(--oba-api-key/--pirate-weather-key/--gorush-url/--apns-topic/--gorush-webhook-secret are the equivalent sidecar flags.)
A region can also carry its own OneBusAway REST API key, overriding
SIDECAR_OBA_API_KEY for that region alone -- set it the same way as the other
per-region fields:
# A region's own key overrides SIDECAR_OBA_API_KEY for that region. Pass an
# empty value to clear it and fall back to the process default.
./bin/sidecar-admin --db ./sidecar.db region set --id 1 --oba-api-key <key>The key is write-only: region set accepts it but never prints it back, region list reports only whether a region has "own key" or "none (may inherit server
default)", and the admin API's region endpoints report the same status word
(region/default/none) instead of the key's value. Passing the key as a
command-line argument puts it in shell history and in ps output visible to
other users on the same host; a future revision will offer a stdin form so the
key never appears on the command line at all.
Weather needs no separate coordinate configuration -- a region's centroid arrives
automatically as part of region sync, computed from the regions directory's
bounds rectangles for that region.
POST /api/v1/payment_intents (spec section 11) powers the apps' in-app
donations through Stripe's PaymentSheet. The route exists only when
SIDECAR_STRIPE_SECRET_KEY is set; without it the apps get a 404 and hide
the donations UI (note that the iOS app decides whether to offer
donations per app bundle, not per region, so a deployment serving the
OneBusAway app should set this up before taking over a region). The body
is raw JSON -- the one endpoint that does not also accept form encoding --
and the app's test_mode: "1" routes the request to
SIDECAR_STRIPE_TEST_SECRET_KEY, so TestFlight builds exercise the flow
against production. Recurring donations create a monthly price under
SIDECAR_STRIPE_RECURRING_PRODUCT_ID (and the test-mode
SIDECAR_STRIPE_TEST_RECURRING_PRODUCT_ID; each is required with its key,
checked at boot); the OBACloud deployment used
prod_OqlLl6mR66dLVQ and prod_P1xUtsgjEfkGgu respectively. A new Stripe
customer is created per donation -- deliberately not matched by email,
since the route is unauthenticated and a recurring response hands the
caller that customer's id and an ephemeral key to its saved payment
methods. A Stripe failure is a 500 with an empty
body, which is what the shipped apps expect; a malformed request is a
400. The endpoint is throttled at 10/minute per client address.
The sidecar server also serves a small admin single-page app at /admin for
authoring alerts through a browser instead of the CLI above. It reads and
writes the same database as sidecar-admin and sidecar itself -- there is
no separate admin database, and no web signup: the only way to create an
account is sidecar-admin user create.
The steps below are sequential, like the CLI quickstart above: user create
bootstraps the one account that can sign in, and make build embeds the SPA
into the binary that then serves it.
# Create the first admin user (prompts for a password; 12 char minimum).
./bin/sidecar-admin --db ./sidecar.db user create --username admin
# Build the admin SPA into the server binary, then run it.
make build
./bin/sidecar --db ./sidecar.db
# open http://localhost:8080/admin and sign inmake run does not build the SPA -- it runs go run directly and skips the
web prerequisite that make build has. A server started with make run
(or from a go build run before the first make web) serves a 503 "admin
UI not built; run make web" response at /admin instead of a login page.
That is expected, not a bug -- run make web (or make build, which
includes it) once first.
/admin itself is now a region picker rather than the alerts list: it
auto-forwards when the database has exactly one region and otherwise
remembers the last region chosen (in localStorage). Every other page is
region-scoped in its own URL, e.g. /admin/regions/1/alerts/42, so a reload
or a deep link always has a region to put in the API path. There is no
compatibility shim for the old shape -- a bookmark to /admin/alerts/42
from before this change shows the SPA's ordinary not-found page rather than
redirecting.
The admin API above is session-authenticated when a browser is driving it,
but a server-to-server integration -- OBACloud's Rails app, most concretely
-- has no browser and no operator to log in as. /api/admin/v1 also accepts
Authorization: Bearer <key> carrying one of two credentials, checked in
place of the session cookie (a request carrying both is a bearer request;
cookies are ignored entirely once an Authorization header is present):
- A region API key (
obask_<regionID>_<43 base64url chars>, e.g.obask_1_Qm9…) is scoped to exactly one region. Almost everything an operator can do to that region's own resources, it can also do -- alerts, studies, surveys and their responses, ghost bus reports, alarms, push registration counts, and reading or setting that region'sPATCH /regions/{id}fields, including its OBA API key, which redirects that region's sidecar-side OBA calls (ghost bus snapshots, vehicle search, alarms) to a key the holder controls. Two things stay off limits regardless of region: another region 404s, and the…/api_keysfamily is refused (403), since a region key is not one of the principal kinds it accepts. Sending or canceling a push notification is gated on a scope: a key minted without one answers403there too -- a leaked ordinary key must not be able to page every device in the region -- while a key minted with"scopes": ["push"](what OBACloud holds for each migrated region) can send and cancel pushes for its own region. A leaked region key therefore reaches one region's tenant data and, through its OBA key, that region's own OBA traffic; a leaked push-scoped key can also deliver notifications to every device in that region. It cannot reach another tenant or mint or revoke anything. The remedy for a leaked region key, scoped or not, is to revoke it (below) and mint a replacement. - A service principal (
obasp_<43 base64url chars>) is deployment-wide but single-purpose: it can only mint, list, and revoke region API keys, through the…/api_keysfamily, and nothing else -- every other admin route answers it with403. That is a deliberate trade. A leaked service principal can mint itself a live key for any published region -- including a push-scoped one, so it can deliver notifications to every device in every region -- and then use it, with the region-key exposure above; revoke every region key in the deployment (a deployment-wide denial of service for whatever integration depends on them), and enumerate which region ids exist along with every key's metadata (names, creator ids, timestamps). It cannot read a single alert, survey response, or ghost bus report -- no tenant data is reachable through a service principal at all. Recovery does not require hunting for which keys are legitimate:principal revoke(below) takes every key the principal minted with it, so the fix is revoke the principal, mint a new one, and re-provision every region from scratch.
Only a hex SHA-256 hash of each key is stored, the same posture as browser sessions; the raw value is shown exactly once, at mint time, and never appears in a list, a log line, or an error message afterward.
sidecar-admin mints and revokes both kinds directly against the database
(created_by/revoked_by records cli for these). A service principal
mints region keys the same way OBACloud will, over HTTP:
# An operator mints the deployment-wide principal once, up front.
./bin/sidecar-admin --db ./sidecar.db principal create --name "obacloud"
# obasp_972so11ncVZAgGSH…
# id: 1 name: obacloud
# The consumer holding that principal mints a key scoped to one region by
# calling the sidecar itself -- this is the provisioning flow OBACloud's
# integration automates (see below):
curl -s -X POST https://sidecar.example.org/api/admin/v1/regions/1/api_keys \
-H "Authorization: Bearer obasp_972so11ncVZAgGSH…" \
-H "Content-Type: application/json" \
-d '{"name":"obacloud rails1","scopes":["push"]}'
# {"id":1,"name":"obacloud rails1","scopes":["push"],"key":"obask_1_L_RvltB_P6G8UwZ9…", …}An operator can also mint a region key directly, without a principal, for manual testing or a deployment with no external consumer yet:
./bin/sidecar-admin --db ./sidecar.db key create --region 1 --name "manual test key"
# obask_1_WlDL9LeQxtC1KYww…
# id: 2 name: manual test key scopes: —
./bin/sidecar-admin --db ./sidecar.db key list --region 1
# 2 manual test key cli 2026-08-28T00:39:13Z — — — —
# 1 obacloud rails1 principal:1 2026-08-28T00:39:05Z — — — pushA key carries scopes: named capabilities on top of the ordinary
region-scoped authoring surface. --scope push is repeatable and is the only
one defined; key list renders a key's scopes in its last column, — for a
key with none. Existing keys have no scopes and so keep exactly the reach
they had. An unknown scope name is refused before anything is written, and
the admin API's POST …/api_keys answers 400 for the same reason.
./bin/sidecar-admin --db ./sidecar.db key create --region 1 --name rails --scope push
# obask_1_9nQ2sVb7pKdT0mXe…
# id: 3 name: rails scopes: pushA key is never scoped to more than one region, and nothing limits a region
to one live key, so rotation is mint, swap, revoke: mint a new key,
update whatever holds the old one, then revoke the old one once the new one
is confirmed working. key list's last_used_at column is what tells an
operator whether the old key is still in use before revoking it -- but it is
touched at most once an hour (a deliberate write-avoidance trade, not a
bug), so a value can be up to an hour stale; a key that looks idle for the
last twenty minutes may simply not have been used and touched yet.
Every key and principal records who created it and who revoked it
(created_by_kind/created_by_id, revoked_by_kind/revoked_by_id --
operator, principal, or cli, with the CLI's own actor carrying no id).
After a suspected principal compromise, key list --minted-by-principal N
is the triage query: it lists every key that principal minted, across every
region -- distinguishing them from keys an operator minted directly, which
carry cli and are never touched by a principal revoke -- so an operator
can see at a glance what a leaked principal could have touched, which is
also exactly the set principal revoke clears out by default:
./bin/sidecar-admin --db ./sidecar.db key list --minted-by-principal 1
# 1 obacloud rails1 principal:1 2026-08-28T00:39:05Z — — — push
./bin/sidecar-admin --db ./sidecar.db principal revoke --id 1
# revoked keys: 1
# revoked principal 1
./bin/sidecar-admin --db ./sidecar.db key list --region 1
# 2 manual test key cli 2026-08-28T00:39:13Z — — — —
# 1 obacloud rails1 principal:1 2026-08-28T00:39:05Z — 2026-08-28T00:39:21Z cli pushThe manually-minted key (2) is untouched -- only the key the principal
itself minted (1) went away. principal revoke takes every live key the
principal minted with it and prints their ids, so the
operator on the other end knows exactly which credentials just went dead;
pass --keep-keys for a planned rotation of a principal whose existing keys
are known to be fine.
OBACloud (the Rails app behind onebusawaycloud.com) is the intended consumer
of the bearer credentials above: it re-plumbs its own server-rendered admin
pages to read and write the sidecar instead of its own Postgres tables,
region by region, then flips each region's sidecar_base_url to a Go host.
The Rails side holds one service principal per sidecar deployment it talks
to and mints one push-scoped region API key per migrated region on
demand, storing the key alongside the region row; it keeps its own push
wizard, copywriter, and scheduling, and sends through POST …/pushes with
its own messages. Un-migrated regions keep the Rails-hosted default
sidecar_base_url and need no principal. That client, its provisioning
triggers, its rotation and bulk-reprovisioning rake tasks, the cutover
task, and its error mapping from sidecar status codes to Rails-side behavior
are a contract this repository documents but does not implement -- see
the design spec, §7
for the sidecar-side contract and OBACloud's own
docs/superpowers/specs/2026-08-28-sidecar-migration-design.md for the
cutover plan.
Sessions rely on the request's Host header and TLS status to reject
cross-site writes and to mark the session cookie Secure. A reverse proxy
in front of sidecar must:
- Preserve the public
Hostheader (nginx:proxy_set_header Host $host;-- nginx's default rewritesHostto the upstream address, which makes every admin write look cross-site and get rejected with a 403). - Set
X-Forwarded-Proto: httpswhen terminating TLS, or the session cookie is issued withoutSecure, so it will also be sent over any plain-HTTP connection to the same host instead of being restricted to HTTPS. - Must not log the
Authorizationheader. Region API keys and service principals (see Region API keys and service principals above) are bearer credentials sent on every request a server-to-server consumer makes, not a one-time login. An access log that captures request headers turns ordinary log retention into credential retention -- anyone who can read the logs can read every live key that ever made a request.
Every per-IP throttle (push registrations, Live Activities, surveys, ghost
bus reports, failed bearer attempts) keys on the TCP peer address of the
request by default -- it does not read X-Forwarded-For or similar
headers, since those are trivially spoofable by the client they're meant to
throttle. A reverse proxy in front of sidecar must either preserve the real
client address as the TCP peer (PROXY protocol, or a transparent/passthrough
L4 proxy), or be one that overwrites a client-address header of its own on
every request, in which case set SIDECAR_TRUSTED_PROXY (--trusted-proxy)
to name it: cloudflare reads CF-Connecting-IP, render reads
True-Client-IP, and header:<Name> covers any other proxy with the same
guarantee. The header is honoured only on requests proven to have come
through that proxy: either the peer address is inside
SIDECAR_TRUSTED_PROXY_CIDRS (render defaults to the private ranges,
which is all that reaches a Render service), or the request carries
X-Sidecar-Proxy-Secret equal to SIDECAR_TRUSTED_PROXY_SECRET -- for
Cloudflare, add a Transform Rule (Modify Request Header) on the proxied
hostname that sets it. Every other request -- including one that reaches
Render's *.onrender.com hostname directly, where anyone could set
CF-Connecting-IP to a fresh value per request and mint unlimited
buckets -- keys on the peer, as with the setting off. Requests with the
proof but no header, or a value that is not an IP address, also fall back
to the peer. There is deliberately no X-Forwarded-For mode: its first
entry is whatever the client sent. Leaving the setting off
behind a proxy that terminates and re-originates TCP means every request
throttles against the proxy's own address -- one shared bucket for all
clients, which at any real traffic level is a 429 for nearly everyone.
gorush's feedback webhook (spec §6.5, terminal APNs failures) should be
pointed at POST /webhooks/gorush on this server. That endpoint deletes a
token's registrations in every region on a caller-supplied value, so set
SIDECAR_GORUSH_WEBHOOK_SECRET and configure gorush to send it. An
authenticated webhook is not rate limited -- gorush reports one failure per
dead token, and a mass uninstall arrives as a burst that a throttle would
turn into lost prune signals.
The same endpoint is where an alert push's asynchronous failures are counted:
a payload carrying a notif_id of alertpush:<push id> increments that
push's failed_count and records the token's hash, whether or not the
failure reason is a terminal one (see Sending alerts as push notifications).
Without the secret the endpoint still works, but is rate limited per client
IP as an abuse ceiling and should be restricted to the gorush host at the
proxy. A dropped prune is not lost data: the token is cleaned up by the
180-day sweep, or by the next failure gorush reports. The push accounting is
the one thing an open webhook lets an anonymous caller write: someone who
guesses a notif_id can inflate a push's failure counters. What bounds that
is the throttle and the fact that there is nothing there to corrupt beyond a
report -- the rows hold a counter and token hashes, nothing reads either back
to make a delivery decision, and both cascade away with the push and its
alert. A deployment that cares about the accuracy of its own fan-out numbers
sets the shared secret; that is what the setting is for.
render.staging.yaml is the production Blueprint with every service and
disk renamed (sidecar-staging, gorush-staging), gorush pointed at the
APNs sandbox, Sentry tagged staging, the Litestream replica under its
own key prefix, and SIDECAR_REGIONS_URL left for you to set. Point it at
a hand-maintained directory file whose regions all carry the staging host
as sidecarBaseUrl -- deploy/regions-staging.example.json is a starting
point (Davis plus a synthetic region) to upload to the regions bucket --
so TestFlight and debug builds that set the app's custom regions URL land
on staging and production devices never do. Production runs the same
Blueprint (render.yaml) behind the custom domain
sidecar2.onebusaway.org, Cloudflare-proxied, with
SIDECAR_TRUSTED_PROXY=cloudflare and the Transform Rule secret so per-IP
throttles key on CF-Connecting-IP, and with SIDECAR_REGIONS_URL pointed
at the directory's regions-v3.json so experimental regions are
addressable. sidecar.onebusaway.org keeps resolving to OBACloud's Rails
app until the last region has migrated and drained; only then does it
become a second custom domain here. Proxy the staging custom
domain through Cloudflare like production's so the trusted-proxy path and
the feed cache rule are exercised there too. Rehearse the export/import
and a Litestream restore against staging before the first region flips.
sidecar-admin import --file <export.json> loads one region's content and
rider state from an export document (internal/export, format
sidecar-export/1): alerts with their translations, studies, surveys and
questions, survey responses, push registrations, and ghost bus reports
with their enrichment snapshots. OBACloud produces the document with
bin/rails "sidecar:export[<region id>,<path>]". --file - reads it from
stdin, so the cutover pipes it straight over render ssh with no file
transfer step:
cat export.json | render ssh sidecar -- sidecar-admin --db /data/sidecar.db import --file -Ids are preserved -- the feed's Alert_<id> entity ids and the survey and
question ids the apps persist locally must not change under riders -- and
every row is checked with the domain packages' own rules (enum names,
question content, answer shape, time windows; not the HTTP layer's length
caps) before anything is written, so a bad row rejects the whole document.
Rows that already exist under this region (same id, public id, or
region+token) are skipped and counted, which makes the migration two runs
of the same command: a bulk import the day before the region's
sidecar_base_url flips, and a delta from a fresh export right after --
translations added to an already migrated alert land on the delta run. An
id that already belongs to another region's content (alerts, studies,
surveys, and questions share one id sequence) is an error naming the row,
never a silent skip. --dry-run runs the same checks, including that the
region exists, without writing. The region itself must already be present
(sidecar-admin region sync); alarms and Live Activities are deliberately
not part of the document -- OBACloud keeps firing the ones it owns until
they expire.
Id headroom. Once the first region has migrated, this database mints alert, study, survey, and question ids from that region's maximum upward while OBACloud keeps minting for un-migrated regions from its own sequences, so a later region's export can collide with content authored here in between. Before the first cutover, run
sidecar-admin --db /data/sidecar.db sequence bump --min 1000000
sidecar-admin --db /data/sidecar.db sequence showonce per deployment: bump raises the alerts, studies, surveys, and
survey_questions sequences to at least the floor (re-running with the same
or a lower floor changes nothing), and show prints each current value so
the cutover runbook can verify the headroom is still there. 1,000,000 is
comfortably above any OBACloud id and its growth during the migration.
The image ships Litestream. Set
SIDECAR_BACKUP_BUCKET and the container entrypoint (deploy/entrypoint.sh)
streams every committed SQLite transaction to that S3-compatible bucket
for as long as the server runs, and -- when the local database file is
missing, as on a fresh or replaced disk -- restores the latest replica
before starting. Leave the variable empty and the entrypoint execs the
server directly, as before. The other settings: SIDECAR_BACKUP_ENDPOINT
(required for anything but AWS; for Cloudflare R2,
https://<account id>.r2.cloudflarestorage.com; must be https:// unless
SIDECAR_BACKUP_ALLOW_INSECURE=1 says a local test store is in use),
SIDECAR_BACKUP_REGION
(default auto, which R2 wants), SIDECAR_BACKUP_ACCESS_KEY_ID /
SIDECAR_BACKUP_SECRET_ACCESS_KEY, SIDECAR_BACKUP_PATH (key prefix,
default sidecar; give staging and production different prefixes or
buckets), and SIDECAR_BACKUP_RETENTION (how far back a point-in-time
restore can reach, default 168h). The config is deploy/litestream.yml.
To restore by hand -- to inspect a backup, or to move to a new host -- run, with the same environment:
litestream restore -config /etc/litestream.yml -o /tmp/restored.db /data/sidecar.db
litestream restore -config /etc/litestream.yml -timestamp 2026-08-28T12:00:00Z -o /tmp/before.db /data/sidecar.dbThe entrypoint fills in SIDECAR_BACKUP_PATH=sidecar,
SIDECAR_BACKUP_REGION=auto, and SIDECAR_BACKUP_RETENTION=168h when
unset; export the same three before running litestream restore from a
shell, since deploy/litestream.yml expands them.
Rehearse this on staging before relying on it. Render's own daily disk
snapshots are a second, coarser line of defence; Litestream is the one
that loses seconds rather than a day. Known gap: once replication is
running, a revoked key or a changed bucket policy makes every upload fail
while /healthz stays green and nothing is reported -- Litestream logs it
and carries on. Until replica lag is surfaced, check the bucket's newest
object age from outside (a cron against the bucket, or a periodic
litestream restore -o rehearsal on staging).
Both renderings of the alerts feed answer with
Cache-Control: public, max-age=60, stale-if-error=600. A CDN in front of
the host (Cloudflare, when the custom domain is proxied) can therefore
serve the feed for a minute per region and keep serving the last good
copy for ten minutes when the origin is down -- which covers the restart
every deploy costs a single-instance service. Cloudflare honours
Cache-Control for cacheable responses only when a cache rule marks the
path eligible; add one for /api/v1/regions/*/alerts*. Error responses
are no-store, so a cached miss cannot shadow a region added later even
under the CDN's default TTL for 404s.
render.yaml declares the same two services as compose.yaml: sidecar as
a web service with a persistent disk at /data for SQLite, and gorush as
a private service. Both run prebuilt images: gorush's from Docker Hub and the
sidecar's from GHCR, where .github/workflows/image.yml publishes
ghcr.io/onebusaway/sidecar on every push to main (tags main and an
immutable sha-<short>) and on every vX.Y.Z tag (X.Y.Z, X.Y,
latest). The Blueprint pulls :main. Render re-pulls only when told to:
set the repository secret RENDER_DEPLOY_HOOK_URL (Dashboard -> sidecar ->
Settings -> Deploy Hook) and each main build deploys itself; leave it
unset and deploy by hand. To roll back, set the service's image URL to the
previous build's sha-<short> tag and deploy; set it back to :main when
the fix lands. The package must be public (Packages -> sidecar -> Package
settings -> Change visibility) or the service needs a registry credential.
First deploy:
- New → Blueprint, pick this repo. Render creates both services and the
sidecar-sharedenv group, which holdsSIDECAR_GORUSH_WEBHOOK_SECRET. That secret is auto-generated by Render, not entered by hand (Blueprint'ssync: falseisn't allowed inside an env group). Verify: the Blueprint has a two-wayfromServicereference (sidecar'sSIDECAR_GORUSH_URLreadsgorush'shostport, andgorush'sGORUSH_CORE_FEEDBACK_HOOK_HOSTPORTreadssidecar's). If Render rejects the Blueprint for a dependency cycle, delete theGORUSH_CORE_FEEDBACK_HOOK_HOSTPORTentry fromrender.yamland instead read the sidecar's internal address from Dashboard → sidecar → Connect → Internal. - Fill the
sync: falsesecrets (the webhook secret is generated for you and is not among them): onsidecar,SIDECAR_APNS_TOPIC,SIDECAR_OBA_API_KEY, andSIDECAR_PIRATE_WEATHER_KEY; ongorush, the threeGORUSH_IOS_*credential values. LeaveGORUSH_CORE_FEEDBACK_HOOK_URLandGORUSH_CORE_FEEDBACK_HEADERblank at creation time; they are set in the next step once the services exist. Verify: if the generatedSIDECAR_GORUSH_WEBHOOK_SECRETin groupsidecar-sharedcomes up empty (Render does not documentgenerateValueinside env groups), set it by hand to the output ofopenssl rand -hex 32and make sure both services show the same value. - On the
gorushservice, set the two values Blueprint cannot derive: copy the generated value from Dashboard → Environment Groups →sidecar-shared, then setGORUSH_CORE_FEEDBACK_HEADERtoauthorization:followed by that value, andGORUSH_CORE_FEEDBACK_HOOK_URLtohttp://followed by the sidecar's internalhost:portfollowed by/webhooks/gorush. Take thathost:portfrom theGORUSH_CORE_FEEDBACK_HOOK_HOSTPORTvalue Render populated on the gorush service (a staging var gorush itself never reads); if you removed that entry under the dependency-cycle fallback in step 1, read it instead from Dashboard → sidecar → Connect → Internal. - Wait for
/healthzto go green, thenrender ssh sidecarand runsidecar-admin region set …andsidecar-admin user create …(SIDECAR_DBis already set in the image). Verify: if the sidecar crash-loops instead with a permission error on/data, Render mounted the disk root-owned over the image'ssidecar-owned directory; the fix is a root entrypoint thatchowns/dataand drops tosidecar, or removingUSER sidecarfrom the Dockerfile (not changed here). - Sign in at
https://<service>.onrender.com/admin; confirm the session cookie carriesSecureand that an admin write succeeds. This verifies Render preservesHostand setsX-Forwarded-Proto, which its docs do not state explicitly.
Logs and error reporting. The server writes one line per request to
stderr (method, matched route pattern with its {placeholders}, status,
bytes, client address, duration -- never the concrete path, the query
string, or any header, since alarm and Live Activity tokens travel in the
path and rider identifiers and bearer keys in the others), one summary
line per background cycle (alarms: cycle, liveactivities: cycle, with
row counts and elapsed time), and a httpapi: panic line with a stack
when a handler panics (the client gets a 500 if nothing had been written
yet; otherwise the connection is closed so a truncated body is not
mistaken for a complete one). Set SIDECAR_LOG_FORMAT=json for log
aggregators. Set SIDECAR_SENTRY_DSN to have every error-level line --
panics, failed pushes, loop failures, the store and upstream errors
handlers log before answering 5xx, and a failed boot -- reported to Sentry
as well, tagged with SIDECAR_SENTRY_ENVIRONMENT and, for the published
image, the git sha it was built from; Sentry's own delivery failures are
logged as errreport: sentry warnings. Leave the DSN unset and nothing
leaves the box. /healthz is logged at debug level only.
SIDECAR_GORUSH_URL on the sidecar service is populated from gorush's
hostport, which Render supplies with no scheme; the server assumes
http:// for a scheme-less value, which is correct here since Render's
private network is plain HTTP.
Background loops and leases. Every background loop (regions directory
sync, push registration pruning, the alert push dispatcher, the alarm
checker, the Live Activity updater, ghost bus snapshot enrichment) runs
under a named lease in the leases table: a process ticks a loop only
while it holds that loop's lease, renews it once a minute, and releases
every lease on a clean shutdown. A holder that dies loses its leases three
minutes later and the next process to poll takes them over (logged as
lease: acquired / lease: lost to another process). On the single
SQLite instance this is bookkeeping; it is what makes a second process --
a rolling deploy, or a future multi-instance deployment on Postgres --
safe to run against the same database without doubling every OBA lookup
and push. The alarm checker also does not re-check every alarm every
minute: after a lookup that says the fire window is still far off, the
alarm is deferred (alarms.check_after) halfway to that window -- three
hours out becomes 90 minutes, then 45, 22, ... -- and returns to the
once-a-minute cadence once under four minutes of slack remain (a
deferral is never shorter than two minutes nor longer than an hour), so
an alarm set hours ahead
costs a handful of lookups rather than hundreds. A clean shutdown hands
each loop to the replacement at its next poll, within a minute. An admin
"send now" that reaches a process not holding the alert-pushes lease
is covered by the holder's next 15-second tick.
Because the disk pins the service to one instance, deploys restart rather
than roll. Render's proxy re-originates TCP, so set
SIDECAR_TRUSTED_PROXY=render on the service (or cloudflare plus
SIDECAR_TRUSTED_PROXY_SECRET and the matching Cloudflare Transform Rule
when the custom domain is proxied through Cloudflare, which is then the
hop that sets the header); without it every per-IP throttle shares one
bucket.
The once-per-deployment sequence that stands a host up and hands OBACloud
the credentials it needs, before any region's sidecar_base_url flips.
Steps 2-5 are the same on staging; run them there first. Every command
below was run against the binaries in this tree, and the commented output
is what they actually print.
1. Deploy. Apply render.yaml as a Blueprint in the production
workspace from the Dashboard (New -> Blueprint; the CLI has no blueprint
command as of v2.5.0), which creates sidecar, gorush, and the
sidecar-data disk at /data. Fill the sync: false secrets and the two
hand-derived gorush values exactly as Render's first-deploy list above
describes; production additionally takes the Stripe keys,
SIDECAR_TRUSTED_PROXY_SECRET, SIDECAR_SENTRY_DSN, and the Litestream
SIDECAR_BACKUP_* values (Backups). Then add SIDECAR_REGIONS_URL =
https://regions.onebusaway.org/regions-v3.json by hand: render.yaml
does not declare it (only render.staging.yaml does), and although that
URL is also the binary's compiled-in default, setting it explicitly means
a later change to that default cannot move production. Add the custom
domain sidecar2.onebusaway.org to the sidecar service; in Cloudflare,
create the proxied CNAME to the *.onrender.com host, the Transform Rule
that sets X-Sidecar-Proxy-Secret to the value of
SIDECAR_TRUSTED_PROXY_SECRET (Deployment above --
SIDECAR_TRUSTED_PROXY=cloudflare is already in the Blueprint), and the
cache rule for /api/v1/regions/*/alerts* (Feed caching). Then:
deploy/smoke.sh https://sidecar2.onebusaway.org
# ok /healthz -> 200
# ok /admin -> 200
# ok /api/v1/regions/1/alerts.pbtext -> 200The feed line reports skip … -> 404 (no regions synced yet) until the
directory has been pulled. The server pulls it once at startup, in the
background, so a smoke run in the first seconds after a deploy can lose
that race; re-run the script rather than reading the skip as a failure.
Repeat the whole step for staging with render.staging.yaml, its own
custom domain (proxied through Cloudflare too, so the trusted-proxy path
and the cache rule are exercised there), and SIDECAR_REGIONS_URL set to
the hand-maintained staging directory (Staging).
2. Bootstrap the database on each host.
render ssh sidecar -- sidecar-admin --db /data/sidecar.db region sync
render ssh sidecar -- sidecar-admin --db /data/sidecar.db region list
# 0 Tampa Bay active=true agency= tz=UTC centroid=27.9553,-82.5231 oba-key=none (may inherit server default)
# 1 Puget Sound active=true agency= tz=UTC centroid=47.7528,-122.4924 oba-key=none (may inherit server default)region sync prints nothing when it succeeds -- region list is how you
confirm the directory landed, and it is also the fastest way to see what
the server's own startup sync already did. --db is spelled out on every
line rather than leaning on the image's SIDECAR_DB, and on staging
region sync needs --regions-url <staging directory> too: the CLI reads
SIDECAR_REGIONS_URL from its own environment, and a non-interactive
ssh host command is not guaranteed to inherit the service's, so a bare
region sync there can quietly pull the production directory over the
staging one. --regions-url is a flag on sidecar-admin itself, not on
region sync, so it has to come before the subcommand:
render ssh sidecar-staging -- sidecar-admin --db /data/sidecar.db --regions-url https://<staging directory>/regions-v3.json region syncThe first admin user needs a real terminal, because the password is prompted for twice and deliberately cannot be passed as an argument:
render ssh sidecar # interactive shell
sidecar-admin --db /data/sidecar.db user create --username <operator>
# Password:
# Confirm password:
# created user <operator>The one-liner form (render ssh sidecar -- sidecar-admin … user create …) fails with stdin is not a terminal; use --password-stdin.
--password-stdin does work, but it puts the password through the local
shell, which is what the prompt exists to avoid.
3. Mint the service principal, one per deployment.
render ssh sidecar -- sidecar-admin --db /data/sidecar.db principal create --name obacloud-production
# obasp_9klpGIRDdZPsCDXG59FjTN9tC6dk…
# id: 1 name: obacloud-production
render ssh sidecar-staging -- sidecar-admin --db /data/sidecar.db principal create --name obacloud-stagingThe first line is the raw credential, stored only as a hash and never
recoverable from the database; a lost one is re-minted, not looked up.
Paste it into OBACloud's credentials under
sidecar.principals["https://sidecar2.onebusaway.org"], and staging's
under its own base URL.
4. Id-sequence headroom, before the first cutover.
render ssh sidecar -- sidecar-admin --db /data/sidecar.db sequence bump --min 1000000
# alerts: 0 -> 1000000
# studies: 0 -> 1000000
# surveys: 0 -> 1000000
# survey_questions: 0 -> 1000000
render ssh sidecar -- sidecar-admin --db /data/sidecar.db sequence show
# alerts 1000000
# studies 1000000
# surveys 1000000
# survey_questions 1000000Record the show output in the cutover ticket; the cutover re-checks it.
Migrating a region from OBACloud above explains why the floor is there
and why re-running bump is safe.
5. Prove the OBACloud contract end-to-end. With the principal from
step 3 in $P, mint and revoke a push-scoped key exactly the way OBACloud
will:
curl -s -w '\n%{http_code}\n' -X POST https://sidecar2.onebusaway.org/api/admin/v1/regions/1/api_keys \
-H "Authorization: Bearer $P" -H 'Content-Type: application/json' \
-d '{"name":"runbook check","scopes":["push"]}'
# {"id":1,"name":"runbook check","scopes":["push"],"key":"obask_1_i6iK4jtmlst4Z_SLs59uHH…",
# "created_by":{"kind":"principal","id":1},"created_at":"2026-08-29T08:45:36Z"}
# 201
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE \
https://sidecar2.onebusaway.org/api/admin/v1/regions/1/api_keys/1 \
-H "Authorization: Bearer $P"
# 204A 403 on the POST means the principal is wrong or revoked; a 400 of
{"error":"unknown scope \"…\""} means the scope name was not push.
The minted key is live until the DELETE, so do not skip it -- the
revoked_at and revoked_by that appear in GET …/api_keys are the
receipt.
Then rehearse the export and import against staging, before any production cutover:
bin/rails "sidecar:export[1,export.json]" # run in OBACloud
cat export.json | render ssh sidecar-staging -- \
sidecar-admin --db /data/sidecar.db import --file - --dry-run
# dry run: stdin is a valid sidecar-export/1 document for region 1: 1 alerts, 0 studies, 0 survey responses, 0 push registrations, 0 ghost bus reportsDrop --dry-run to apply it. If the CLI stops to prompt on that pipeline
-- it defaults to interactive output -- pass --confirm and -o text
before the --, so nothing of the CLI's own can read the document out of
stdin.
Run the SPA against a live server with hot reload instead of rebuilding the embedded copy on every change:
cd web/admin && npm run devThis proxies /api requests to localhost:8080 (see
web/admin/vite.config.ts), so start ./bin/sidecar (or make run)
alongside it. There is deliberately no CORS configuration anywhere in this
repo -- every legitimate client is same-origin, either directly or through
this dev proxy, and blocking cross-origin admin API access is exactly what
the cross-site guard exists to do.
make web rebuilds the embedded copy that ships inside the sidecar
binary from web/admin's current source; make build and the test/
test-tz/test-race/check targets all run it for you first (see the CI
note in the "## Development" section below if you're wiring up a workflow).
The quickest way to exercise the whole thing -- including real pushes to a phone -- is the compose stack, which runs the sidecar beside gorush exactly as production does.
cp .env.example .env # fill in the webhook secret and your APNs key
make up # builds the image, starts sidecar + gorush
deploy/smoke.sh # /healthz, /admin, and the alerts feed
make admin ARGS="region set --id 1 --agency-id 1 --timezone America/Los_Angeles"
make admin ARGS="user create --username admin"If you already have a .env from an earlier setup, merge the new keys from
.env.example into it instead of copying over it.
The server syncs the regions directory itself at boot, so region sync is
not needed; region set is, because the directory carries no agency id.
Point a debug build of the app at http://<your-mac-ip>:8080; its tokens
register with apns_sandbox=1 and gorush routes them to the APNs sandbox
per push, so one gorush serves debug and release builds alike.
To iterate on Go code without rebuilding the image, run only gorush in Docker and the sidecar on the host:
make up-gorush
make run.env already points SIDECAR_GORUSH_URL at http://localhost:8088, and gorush's
feedback hook is redirected to host.docker.internal:8080 so token prunes
still arrive. make down stops either arrangement; the SQLite volume
(sidecar-data) survives. GORUSH_IOS_ENABLED and GORUSH_ANDROID_ENABLED
in .env control which gorush platforms are configured; Android additionally
needs GORUSH_ANDROID_CREDENTIAL set to the raw FCM service-account JSON,
not a path or base64 encoding of it. Running make up after make up-gorush re-points gorush's hook at the container, and doing the reverse
leaves a running sidecar container whose prunes go to the host -- run make down between the two arrangements.
APNs under .p8 token auth requires a topic on every push, so
SIDECAR_APNS_TOPIC (the iOS bundle id) must be set wherever
SIDECAR_GORUSH_URL is; the server warns at boot if it is not.
A complete runbook for getting an APNs push from this stack onto an iPhone.
1. Fill in .env. A base64 .p8 key is not enough on its own — token
auth also needs the key id and team id, and every push needs the app's bundle
id as its topic. All five of these must be set (get the first three from
developer.apple.com → Keys / Membership):
# gorush — the APNs credential
GORUSH_IOS_KEY_BASE64=... # base64 -i AuthKey_XXXXXXXXXX.p8 | tr -d '\n'
GORUSH_IOS_KEY_ID=XXXXXXXXXX # 10 chars, the key's ID
GORUSH_IOS_TEAM_ID=XXXXXXXXXX # 10 chars, your team ID
# sidecar
SIDECAR_APNS_TOPIC=org.onebusaway.iphone # the EXACT bundle id of the build on the phone
SIDECAR_GORUSH_WEBHOOK_SECRET=... # openssl rand -hex 32 (needed only for the prune half)The topic must be the bundle id of the build actually installed on the
device. A debug build often has a different id than the App Store build
(e.g. a .debug suffix); the wrong one bounces every push with
DeviceTokenNotForTopic.
2. Start the stack and confirm the key loaded.
make up
deploy/smoke.sh # /healthz, /admin, alerts feed
curl -s localhost:8088/api/config | sed -n '/^ios:/,/^queue:/p'The ios: block must show enabled: true, key_type: p8, and a
[REDACTED] key_id/team_id (gorush hides their values). If enabled is
false or the container is restart-looping, gorush rejected the key — check
docker compose logs gorush.
3. Get the device token and your Mac's LAN address.
ipconfig getifaddr en0 # your Mac's LAN IP; the phone must be on the same Wi-FiPoint the debug build at http://<that-ip>:8080 (a debug build allows
plain-HTTP via its ATS exception) and let it register — it will POST its
APNs token to /api/v2/regions/1/push_registrations with apns_sandbox=1.
Grab that 64-hex-character token from the Xcode console or the app's debug UI.
4a. Fastest check — push straight through gorush. This skips the
sidecar's trip lookup and proves the credential + topic + token + sandbox
routing all work. Use "development": true for a debug build (sandbox
token); false for a TestFlight/App Store build (production token):
curl -s -X POST localhost:8088/api/push -H 'Content-Type: application/json' -d '{
"notifications": [{
"tokens": ["<the 64-hex device token>"],
"platform": 1,
"topic": "org.onebusaway.iphone",
"title": "OneBusAway",
"message": "Sidecar test push",
"development": true
}]
}'
# → {"counts":1,"logs":[],"success":"ok"} (queued, not yet delivered)
docker compose logs -f gorush # watch the async APNs resultA 200/success: ok only means gorush accepted the request; delivery
happens asynchronously. Watch the gorush log for the outcome (see the error
table below).
4b. Full end-to-end through the sidecar (exercises the real alarm path).
Register the token, then create an alarm on a live upcoming departure; the
push fires when the trip is seconds_before seconds from leaving:
IP=$(ipconfig getifaddr en0)
curl -s -X POST http://$IP:8080/api/v2/regions/1/push_registrations \
-d token=<device token> -d operating_system=ios -d apns_sandbox=1
curl -s -X POST http://$IP:8080/api/v2/regions/1/alarms \
-d user_push_id=<device token> -d operating_system=ios -d apns_sandbox=1 \
-d stop_id=<stop> -d trip_id=<trip> -d service_date=<epoch ms> \
-d stop_sequence=<n> -d seconds_before=60See specification.md §5.2 for the field
semantics. service_date is epoch milliseconds. Only user_push_id and
operating_system are validated; a bogus trip still returns 201 but its
push carries a generic message and never resolves a real departure, so use a
trip that is genuinely a minute or two out.
Reading the result. gorush returns immediately; the real outcome is in
docker compose logs gorush and, for terminal failures, on the feedback
webhook (which prunes the dead token). Common APNs errors:
| gorush log message | Cause | Fix |
|---|---|---|
InvalidProviderToken |
GORUSH_IOS_KEY_ID/TEAM_ID/key don't match, or the host clock is skewed |
recheck all three; ensure the Mac's clock is correct |
MissingTopic |
no topic sent | set SIDECAR_APNS_TOPIC (4a sends topic inline) |
DeviceTokenNotForTopic |
topic ≠ the build's bundle id | set the topic to the installed build's exact bundle id |
BadDeviceToken |
development flag doesn't match the build's environment |
true for a debug/sandbox build, false for TestFlight/release |
TopicDisallowed |
the key isn't enabled for this topic | enable the key for the app in the Apple developer portal |
The runbook above applies; a few things are specific to the Live Activity path (spec §6) and worth checking separately before assuming the credential is bad:
- The topic is derived, not sent by the client or configured in gorush.
The sidecar appends
.push-type.liveactivitytoSIDECAR_APNS_TOPICitself on every push (gorush does not derive this suffix — a bare bundle id would bounceBadTopic). WithSIDECAR_GORUSH_URLset butSIDECAR_APNS_TOPICempty, the sidecar runs Live Activities in store-only mode (rows expire and reap, nothing is pushed) and says so in a boot warning — nothing ever shows up indocker compose logs gorushbecause no request was sent. Alarms still go out, and APNs rejects them withMissingTopic. - The push token is not the device alert token. ActivityKit hands the app
a separate token per Live Activity (from
Activity.pushTokenUpdates), not theUNUserNotificationCentertoken used forpush_registrations/alarms. Registering the alert token by mistake bounces every push withBadDeviceToken; the feedback webhook treats that as terminal and deletes the subscription outright. - Priority must be 10, not 5:
SendLiveActivityalways sends gorush"priority": "high"(APNs 10). At 5 an idle phone queues the push instead of delivering it and the Lock Screen card visibly freezes. - Watching it work: register (below), then tail
docker compose logs -f gorushand the sidecar's own log forliveactivities:lines — one cycle runs per minute, so expect anupdatepush within about 60 seconds.TopicDisallowedin the gorush log points at the topic (wrong bundle id, or the key isn't enabled for this app);BadDeviceTokenpoints at the token (alert token used instead of the ActivityKit push token, or aapns_sandbox/build mismatch).
IP=$(ipconfig getifaddr en0)
curl -s -X POST http://$IP:8080/api/v2/regions/1/live_activities \
-d activity_id=<activitykit activity id> \
-d push_token=<activitykit push token, NOT the device alert token> \
-d apns_sandbox=1 \
-d stop_id=<stop> -d route_short_name=<route> -d trip_headsign=<headsign>
# → 201 {"url": "http://.../live_activities/<token>"}
curl -s -X DELETE http://$IP:8080/api/v2/regions/1/live_activities/<token>
# → 204Requires Go 1.26+, golangci-lint 2.12+, sqlc 1.31+, and Node -- all declared in mise.toml (golangci-lint and sqlc as exact pins; Go and Node as major/minor selectors), so mise install (or make tools) sets up everything once mise is activated in your shell (Node is for the admin SPA in web/admin: make web and make check run npm ci there).
make tools # install pinned dev tooling
make check # fmt-check + vet + lint + generate-check + test + test-tz + test-race + web-check — everything CI runs
make run # go run the server (/admin serves 503 until `make web`)
make up # start sidecar + gorush in Docker (see "Running locally with Docker")
make help # list all targets, including the rest of the Docker onesRun make check before opening a pull request. CI
(.github/workflows/ci.yml) runs one job per make check target, so a green
make check locally means green CI. Keep any go test step routed through
make: the Go suite includes an embed regression test
(internal/httpapi/adminui/adminui_test.go) that needs a populated
internal/httpapi/adminui/dist/, and a bare go test ./... against the
empty, gitignored dist/ fails it.
This project is made available under the terms of the Apache 2.0 license. (c) Open Transit Software Foundation.