12 KiB
A lightweight Express service that schedules and sends Firebase Cloud Messaging (FCM) push notifications to wake up registered devices.
Device registrations are stored in a local SQLite database (not JSON).
Dev
cp .env.example .env
Edit .env — set FIREBASE_SERVICE_ACCOUNT_JSON.
Here is one way to generate the contents: cat your-downloaded-key.json | jq -c .
Optionally set ENDORSER_URL / PARTNER_URL if you are not using the production Endorser (https://api.endorser.ch) and Partner (https://partner-api.endorser.ch) hosts.
Optionally set NOTIFY_DATA_DIR if you want the SQLite database somewhere other than ./data.
pnpm install
pnpm run dev
pnpm test
The server starts on http://localhost:3003 (or the port in PORT). Hot-reloads on file changes. pnpm start is the same tsx entry without watch. These commands are not the production Docker path (node dist/index.js).
On first use, the service creates NOTIFY_DATA_DIR (default ./data) and the SQLite file notify.sqlite with the required schema.
Authentication
POST /notifications/register and POST /notifications/refresh require a Bearer JWT. After local JWT verification, the service checks the token with Endorser (GET /api/report/rateLimits on ENDORSER_URL). Registration and refresh continue only if Endorser accepts the JWT.
PUT /notifications/alert-authorization uses the same current-user Bearer JWT + Endorser check. It does not accept the testMode local bypass. The 100 delegated JWTs in the body are stored credentials, not the request authenticator.
Local notification test bypass: send testMode: true in the JSON body and omit the Authorization header. The request skips JWT and Endorser checks and uses a synthetic local test user, same as before. This applies to register/refresh only.
Set NODE_ENV=test-local in .env to bypass ethr JWT expiry verification during local development (this is separate from the testMode bypass above).
Alert authorization
PUT /notifications/alert-authorization
Authorization: Bearer <current-user-JWT>
{
"batchId": "client-batch-id",
"timezone": "America/Denver",
"jwts": [
{
"sequence": 0,
"day": "2026-08-27",
"nbf": 1756270800,
"exp": 1756357200,
"jwt": "eyJ..."
}
]
}
timezone is the IANA zone used when minting the 100 validity windows; it is stored as batch metadata, not live device-timezone tracking. A successful call replaces that user's previous unused JWTs atomically. Passkey (did:peer) identities cannot mint this batch and receive DELEGATED_JWT_UNSUPPORTED_IDENTITY.
Alert search retrieval
The daily scheduler runs retrieveAlertSearch against:
{ENDORSER_URL}/api/v2/report/alertSearch{PARTNER_URL}/api/partner/alertSearch
The delegated JWT is sent as Authorization: Bearer. Pass independent endorserAfterId / partnerAfterDate (or omit them on first run). Nearby search uses the alertSearch bbox (minLocLat, maxLocLat, minLocLon, maxLocLon).
loadAlertSearchCursors / retrieveAlertSearch / advanceAlertSearchCursors (or runAlertSearchCycle) persist those bounds per user DID in SQLite. Cursors advance only after a complete success retrieval (not empty, pagination, or errors). A Partner page of 50 rows that share the oldest updatedAt is pagination because exclusive beforeDate cannot drain timestamp ties.
runDailyAlertSearch(userId, now?) uses the latest batch's stored IANA timezone to pick today's unused delegated JWT, runs runAlertSearchCycle with that JWT, and marks that specific JWT consumed only when both Endorser and Partner complete (success or empty, including both empty). Pagination or source failures leave the JWT unused so the same day can be retried. An invalid stored timezone is an error; there is no fallback to the server timezone. After a retrieve, the result includes digest from buildAlertSearchDigest (six bucket records and counts). digest is null when there is no batch or no unused JWT for today. Consumption does not depend on digest.hasUpdates.
startAlertSearchScheduler() (started from src/index.ts next to the FCM scheduler) is a separate user-level job. It lists distinct userIds from alert_authorization_batches and calls runDailyAlertSearch once per user. After each run, if the digest is complete with updates and today's JWT was consumed, it sends a user-visible FCM message (title: TimeSafari, body You have N new updates., data type: alert_search) to that user's registered tokens. It does not call sendPushToDevice or change WAKEUP_PING. Subsequent ticks the same local day see no unused JWT (digest: null) and do not resend. FCM send failures are logged and do not roll back cursors or JWT consumption. A process-local in-flight flag skips a tick if a pass is still running.
buildAlertSearchDigest maps a retrieve result into structured payload data: per-bucket record arrays, counts, totalCount, hasUpdates, and Endorser/Partner completion status. It does not invent a notification string; FCM uses only totalCount for the short body. Incomplete outcomes (pagination, auth, network, etc.) yield completed: false and hasUpdates: false. Empty successful retrieves are complete with hasUpdates: false and do not send FCM.
Storage
Database location
| Path | Description |
|---|---|
{NOTIFY_DATA_DIR}/notify.sqlite |
Primary SQLite database (default dir: ./data) |
{NOTIFY_DATA_DIR}/notify.sqlite-wal |
WAL journal (present while the process is running) |
{NOTIFY_DATA_DIR}/notify.sqlite-shm |
Shared-memory file used with WAL mode |
NOTIFY_DATA_DIR defaults to ./data (relative to the process working directory). The data/ directory is gitignored.
Production must keep these files on durable storage (disk or a Docker volume). A container or VM rebuild that drops NOTIFY_DATA_DIR loses FCM registrations, delegated JWT batches, and alertSearch cursors. Back up all three files together when using WAL.
Schema (high level)
Table fcm_registrations holds one row per registered device:
- Identity:
id,user_id,device_id,fcm_token,platform - Flags:
test_mode - Timestamps:
created_at,updated_at,last_notified_at
Unique on (user_id, device_id). Indexes also exist on user_id, device_id, fcm_token, and (user_id, fcm_token).
Tables alert_authorization_batches and alert_authorization_jwts hold a user's delegated notification-JWT inventory (separate from device registration):
- Batch:
id,user_id(authenticated DID),batch_id,timezone(IANA name at mint time),created_at - JWT:
batch_pk,sequence,day(YYYY-MM-DD),jwt,nbf,exp,status(unused/consumed),consumed_at, timestamps
Unique on (batch_pk, sequence) and on (user_id, day) for unused rows. Indexes also exist on (user_id, status), (user_id, day), and batch_pk.
Table alert_search_cursors holds one row per user DID:
endorser_after_id— last complete Endorser ULID (afterId), or nullpartner_after_at— last complete PartnerupdatedAtbound (afterDate), or nullcreated_at,updated_at
The schema is created automatically on startup if the database or tables do not already exist. New tables are added with CREATE TABLE IF NOT EXISTS; existing fcm_registrations rows are not migrated or altered.
JSON → SQLite
There is no automatic migration from the old JSON file (fcm-tokens.json). That format is no longer used. If you still have a local fcm-tokens.json from earlier development, it is ignored; re-register devices or import data manually if you need it.
Backup
Persist or back up the SQLite files under NOTIFY_DATA_DIR:
- Prefer stopping the service, then copy
notify.sqlite(and any-wal/-shmsidecars if present). - Or, while the service is running, copy all three files (
notify.sqlite,-wal,-shm) together so the backup stays consistent under WAL mode. - For Docker, mount a volume at the data directory (or set
NOTIFY_DATA_DIRto a mounted path) so registrations survive container recreation.
Production
Canonical production deployment is the Docker image: pnpm build then node dist/index.js (Dockerfile CMD). That is not the same as the development commands pnpm run dev / pnpm start, which run TypeScript through tsx and are for local development only.
Single replica
Run exactly one Node process / one production replica. AlertSearch scheduling and its in-flight overlap guard are process-local. There is no distributed scheduler lock. SQLite is a local file. Two processes will double-run AlertSearch and FCM wakeup and can corrupt or fork the database.
Firebase credentials
Working Firebase Admin credentials are required for both WAKEUP_PING and AlertSearch FCM (type: alert_search). The app initializes Firebase once at process start (src/services/firebase.ts).
Supported paths (in this order):
FIREBASE_SERVICE_ACCOUNT_JSON— inline service-account JSON (one line). The application reads this variable.- If that variable is unset or empty, Application Default Credentials. ADC may use
GOOGLE_APPLICATION_CREDENTIALS(a file path to a key JSON). The application does not readGOOGLE_APPLICATION_CREDENTIALSitself; the Google/Firebase ADC stack does.
Invalid FIREBASE_SERVICE_ACCOUNT_JSON prevents the process from starting. Missing ADC typically allows listen//health but FCM sends fail later.
Persistent SQLite
Set NOTIFY_DATA_DIR to a durable directory, or keep the Docker default /app/data on a persistent volume. The service uses:
{NOTIFY_DATA_DIR}/notify.sqlite{NOTIFY_DATA_DIR}/notify.sqlite-wal{NOTIFY_DATA_DIR}/notify.sqlite-shm
Environment checklist
| Variable / constraint | Production requirement | Default if unset |
|---|---|---|
PORT |
Optional | 3003 |
ENDORSER_URL |
Optional if using production Endorser | https://api.endorser.ch |
PARTNER_URL |
Optional if using production Partner | https://partner-api.endorser.ch |
FIREBASE_SERVICE_ACCOUNT_JSON or working ADC |
Required for FCM (wakeup and AlertSearch) | ADC if JSON unset |
NOTIFY_DATA_DIR |
Durable path (or volume on /app/data) |
./data (cwd-relative; in Docker that is /app/data) |
NODE_ENV |
Must not be test-local (that bypasses ethr JWT expiry) |
Docker image sets production |
| Replicas | One process | Not enforced in code |
| Persistent volume | Required for Docker so SQLite survives replace | None unless you pass -v |
Optional: DEFAULT_ENDORSER_API_SERVER / DEFAULT_PARTNER_API_SERVER are honored only if the corresponding ENDORSER_URL / PARTNER_URL is unset (src/env.ts).
Docker (canonical)
docker build --no-cache -t notify-wakeup-api:amd-$NOTIFY_WAKEUP_API_VERSION --platform linux/amd64 .
docker run --env-file notify-wakeup-api.env -p 3003:3003 \
-v notify-wakeup-data:/app/data \
notify-wakeup-api
The image runs node dist/index.js. Mount a volume at /app/data (or set NOTIFY_DATA_DIR to another mounted path). Do not scale this container to multiple replicas.
Smoke test
/health only means the HTTP server is up. It does not prove Firebase, Endorser, Partner, SQLite durability, or AlertSearch.
- Listening:
curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3003/health(or the host/port you published). Expect200. - Health body:
curl -sS http://127.0.0.1:3003/health→{"ok":true}. - Both schedulers are started from
src/index.ts(startScheduler()thenstartAlertSearchScheduler()) when the process reaches* Running backend. Neither scheduler runs a pass on startup; the first pass is on the 5-minute timer. - AlertSearch activity in logs: look for
[AlertSearchScheduler] Pass startedand[AlertSearchScheduler] Pass completed in(orPass skipped (already in flight)). User failures log as[AlertSearchScheduler] User failed. FCM wakeup passes log[Scheduler] Pass started/[Scheduler] Pass completed in. These lines appear after the first interval, not at boot. - SQLite location: after the process has handled a request or a scheduler pass that opens the DB, confirm
{NOTIFY_DATA_DIR}/notify.sqliteexists (Docker default:/app/data/notify.sqliteon the volume). WAL sidecars may appear while the process is running.