Files
notification-wakeup-service/README.md
T
Jose Olarte III 131d2fc9d9 Add an explicit alertSearch cycle that loads cursors, retrieves, and advances only complete sources so scheduler integration can reuse a proven path.
Treat Partner LIMIT-50 pages with a tied oldest updatedAt as incomplete so exclusive beforeDate paging cannot skip remaining rows.
2026-08-27 20:55:13 +08:00

7.0 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.

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

retrieveAlertSearch (not yet wired to the scheduler) GETs:

  • {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. They are not wired to the scheduler yet.

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.

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 null
  • partner_after_at — last complete Partner updatedAt bound (afterDate), or null
  • created_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:

  1. Prefer stopping the service, then copy notify.sqlite (and any -wal / -shm sidecars if present).
  2. Or, while the service is running, copy all three files (notify.sqlite, -wal, -shm) together so the backup stays consistent under WAL mode.
  3. For Docker, mount a volume at the data directory (or set NOTIFY_DATA_DIR to a mounted path) so registrations survive container recreation.

Production

Runs TypeScript directly via tsx (no compile step).

pnpm install --prod
pnpm start

Ensure NOTIFY_DATA_DIR points at a durable location (or accept the default ./data next to the process cwd).

Or with Docker:

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

Mount a volume over /app/data (or whatever path you set with NOTIFY_DATA_DIR) so the SQLite database is not lost when the container is replaced.

Required environment variables:

Variable Description
FIREBASE_SERVICE_ACCOUNT_JSON Inline service account JSON (one line). If unset, falls back to Application Default Credentials.
PORT HTTP port (default: 3003).
ENDORSER_URL Endorser API base URL used for auth checks and alertSearch (default: https://api.endorser.ch).
PARTNER_URL Partner API base URL used for alertSearch (default: https://partner-api.endorser.ch).
NOTIFY_DATA_DIR Directory for the SQLite database file notify.sqlite (default: ./data).