2026-07-02 17:14:11 +08:00

A lightweight Express service that schedules and sends Firebase Cloud Messaging (FCM) & text (SMS) push notifications to wake up registered devices.

Device registrations are stored in a local SQLite database.

Quick Start

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.

FCM

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.

SMS

/notify-sms delivers the daily alertSearch digest by text as well as by push. A user registers a phone number, proves possession of it with a 6-digit code, authorizes a batch of delegated alertSearch JWTs for the SMS channel, and receives at most one text per local day when that day's retrieval finds updates.

The whole surface is off unless SMS_ENABLED is true; every route answers 503 SMS_DISABLED otherwise, and the SMS scheduler does not start.

Endpoints

Method Path Purpose
GET /notify-sms/phone List this DID's registrations; with ?phoneNumber=, the other DIDs on a number this DID has verified
POST /notify-sms/phone Record a phone for the DID, unverified, and text it a 6-digit code
PUT /notify-sms/phone Match the code and mark the registration verified
DELETE /notify-sms/phone Remove the phone entirely
POST, PUT /notify-sms/alert-authorization Store a delegated JWT batch for the SMS channel
POST /notify-sms/inbound Twilio's webhook for STOP / START / HELP

GET /notify-sms/phone returns the caller's own numbers in full. Adding ?phoneNumber= also returns dids, every DID holding a verified registration of that number — but only to a caller who has itself verified that number. Otherwise 403 SMS_PHONE_NOT_VERIFIED_BY_CALLER, with no count and no identities.

POST /notify-sms/phone takes { "phoneNumber": "+15555550123" }. A number the DID has already verified returns 200 with verified: true and sends nothing. Otherwise it mints a code, stores the code's HMAC, and texts the plaintext. The response carries { success, phoneNumber: "+1555*****23", verified: false, expiresAt } and never the code. Sends are throttled to three per phone per hour (counted across every DID) and ten per DID per day.

PUT /notify-sms/phone takes { "phoneNumber", "code" }. A miss returns 400 SMS_CODE_MISMATCH with attemptsRemaining; SMS_CODE_MAX_ATTEMPTS misses return 429 SMS_CODE_ATTEMPTS_EXHAUSTED and clear the code, so recovery is another POST.

DELETE /notify-sms/phone accepts the number in the body or as ?phoneNumber=, because a fair number of proxies drop bodies on DELETE. It removes the row matching (user_id, phone_e164) and sets phone_e164 to null on that DID's sms_phone_log rows for that number, leaving phone_hash and the action history intact. Deleting a number that is not registered returns { success: true, deleted: false }, not an error.

POST /notify-sms/alert-authorization takes the same body as the FCM twin and requires at least one verified phone for the DID; without one it returns 409 SMS_NO_VERIFIED_PHONE. PUT is accepted as an alias, since the FCM twin is PUT and the semantics are replace-not-append either way.

Action claim

Every /notify-sms call is authorized by a Bearer JWT whose claim names the action and the phone number it applies to:

{
  "iss": "did:ethr:0x…",
  "iat": 1756270800,
  "exp": 1756271100,
  "claim": {
    "@context": "https://giftopia.tech",
    "@type": "SmsNotificationAction",
    "action": "register-phone",
    "phoneNumber": "+15555550123"
  }
}

action is one of list-phones, register-phone, verify-phone, delete-phone, authorize-alert-search. phoneNumber is required for register-phone, verify-phone and delete-phone, and required for list-phones only when the request carries the query parameter. The claim holds no DID — the authenticated identity is iss — and no batchId.

@context is https://giftopia.tech with no trailing path, shared with the FCM setup claim; @type is what separates them. https://giftopia.me is the app link that appears in messages and is never the claim namespace.

The middleware chain is requireAuth (Bearer JWT, signature verified against the issuer DID), requireEndorserAuth (Endorser accepts the same token), then requireSmsActionJwt(action), then the handler. The handler performs no authorization checks of its own. There is no testMode bypass: no local-test path sends real texts to real handsets.

Failure Response
No claim in the token 403 SMS_ACTION_JWT_MISSING_CLAIM
Claim names a different action 403 SMS_ACTION_JWT_WRONG_ACTION
Claim names a different number 403 SMS_ACTION_JWT_PHONE_MISMATCH
iat outside SMS_ACTION_JWT_MAX_AGE_SEC 401 SMS_ACTION_JWT_STALE
exp has passed 401 SMS_ACTION_JWT_EXPIRED
Token already used 401 SMS_ACTION_JWT_REPLAYED

The token's sha256 is recorded before the handler runs, so one token buys one action. A handler that fails afterward does not release the hash; the client mints a fresh JWT, which it can do freely. SMS_REQUIRE_ACTION_CLAIM=false turns the stage off entirely.

This is a client change. The TimeSafari app sends a plain identity JWT to /notifications/*; a /notify-sms call carrying no claim returns SMS_ACTION_JWT_MISSING_CLAIM.

One phone, several DIDs

Every read and write is scoped by (user_id, phone_e164), so two identities sharing one handset stay independent: registering, verifying, or deleting under one DID does not touch the other's row, and each identity runs its own alertSearch against its own cursor. Two verified DIDs on one handset therefore receive two texts a day.

SMS_MAX_DIDS_PER_PHONE bounds how many DIDs one handset can carry, counting verified rows only — counting every row would let five throwaway DIDs lock the handset's owner out by registering and never verifying. The count is checked at POST as an early rejection and again at PUT, which is the check that holds, since PUT is the moment a row starts consuming a slot.

A blocked POST returns 409 SMS_PHONE_DID_LIMIT with limit and verifiedCount and no identities: a POST names any phone number on earth and proves nothing about it, so answering with DIDs would make the endpoint a phone-number-to-identity lookup oracle. A blocked PUT returns the same code plus dids, because a PUT that reaches the limit check has already matched a correct code and the caller is holding the handset. The code is consumed on a limit rejection exactly as on success, so a fresh answer costs a fresh POST.

Carrier opt-out is the one place the DID boundary is crossed on purpose: STOP arrives with a phone number and no identity, so it marks every registration of that number unverified. START does not restore anything; possession has to be proved again with a fresh POST and code.

SMS delivery

startSmsAlertSearchScheduler() is a third scheduler alongside the FCM wakeup and FCM alertSearch passes, with its own interval (SMS_ALERT_SEARCH_INTERVAL_MS, default 5 minutes) and a 150-second initial offset so the two alertSearch passes do not hit Endorser in the same instant. It lists distinct user_id from sms_alert_authorization_batches, calls runDailyAlertSearch(userId, now, {}, "sms"), and then deliverAlertSearchSms. It logs [SmsAlertSearchScheduler] Pass started / Pass completed in. The in-flight guard is process-local, exactly like the other two, so a second replica double-texts.

Eligibility is the same predicate the FCM path uses: the run consumed today's JWT and the digest is complete with updates. Consumption is what makes later ticks on the same local day no-ops. The message is Gift Economies: you have N new updates. https://giftopia.me Reply STOP to end., kept inside one 160-character GSM-7 segment, since a second segment is a second charge. Underneath the JWT rule, an sms_phone_log count caps sends at one per handset per identity per day. Send failures are logged and do not roll back cursor advancement or JWT consumption.

The SMS channel keeps its own JWT inventory and its own cursor table. A user on both channels produces two Endorser and two Partner queries per day and needs 200 minted JWTs; the counts can differ transiently when one channel's retrieval fails and the other's succeeds. A shared cursor was rejected: two independent daily runs against one row means whichever fires first consumes the delta and the other reports nothing.

Provider

Sends go to Twilio over plain fetch against https://api.twilio.com/2010-04-01/Accounts/{SID}/Messages.json with HTTP Basic auth and a form-encoded To / From (or MessagingServiceSid) / Body. There is no twilio SDK dependency. With configuration absent or incomplete, sends return SMS_NOT_CONFIGURED and the process still boots: a texting outage must not take push down with it. Under NODE_ENV=test-local with no Twilio credentials, a console adapter prints the message instead of sending it.

SMS_DEV_ECHO_CODE adds a devCode field to the POST response holding the plaintext six digits, so a developer with no Twilio account or no carrier coverage can still exercise POST-then-PUT. It is honored only when NODE_ENV is also test-local, checked first, so a production process with the flag set by accident echoes nothing.

US A2P 10DLC registration is required before Twilio will carry application-to-person traffic on a long code. Brand and campaign registration take days and carry per-campaign fees. Unregistered traffic gets filtered by carriers silently, with a sent status from the API.

The inbound webhook authenticates by Twilio's X-Twilio-Signature over the exact URL Twilio posted to, not by JWT — it is Twilio calling, not a user. Set TWILIO_WEBHOOK_URL to that public URL; behind a proxy or tunnel the request's own headers do not reliably reproduce it.

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

Table sms_registrations holds one row per (DID, phone) pair:

  • Identity: id, user_id, phone_e164 (E.164 normalized)
  • Verification: verified, code_hash (HMAC of the pending code), code_expires_at, code_attempts, last_code_sent_at, verified_at
  • Timestamps: created_at, updated_at

Unique on (user_id, phone_e164). Indexes also exist on user_id, on phone_e164 (opt-out arrives with the number, not the DID), and on (user_id, verified).

Table sms_phone_log records every phone action: user_id, phone_e164 (nullable, nulled by DELETE), phone_hash (HMAC, never nulled), action, result (ok / rejected / failed), detail, jwt_hash, provider_message_id, created_at. Actions are register-requested, code-sent, code-send-failed, verify-succeeded, verify-failed, did-limit-blocked, did-limit-disclosed, deleted, alert-authorization-stored, alert-sent, alert-send-failed, and opt-out. Indexes on (user_id, created_at), (phone_hash, created_at), and (action, created_at); the throttle counts read the second.

This is the first table in this database with directly identifying personal data. phone_e164 is nulled on delete; a prune job for rows older than 400 days is a follow-up.

Table sms_action_jwt_use holds the replay guard: jwt_hash (unique), user_id, action, used_at. Rows older than SMS_ACTION_JWT_MAX_AGE_SEC × 10 are pruned on each SMS scheduler pass; a token that stale fails the freshness check anyway.

Tables sms_alert_authorization_batches / sms_alert_authorization_jwts and sms_alert_search_cursors are column-for-column mirrors of their FCM counterparts, holding the SMS channel's independent JWT inventory and cursor.

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.

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

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):

  1. FIREBASE_SERVICE_ACCOUNT_JSON — inline service-account JSON (one line). The application reads this variable.
  2. 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 read GOOGLE_APPLICATION_CREDENTIALS itself; 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
SMS_ENABLED Optional; /notify-sms returns 503 SMS_DISABLED while off false
SMS_CODE_SECRET Required when SMS_ENABLED (startup fails without it) None
TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN Required to send; absent means SMS_NOT_CONFIGURED per send None
TWILIO_FROM_NUMBER or TWILIO_MESSAGING_SERVICE_SID One of the two required to send None
TWILIO_WEBHOOK_URL The public URL Twilio posts /notify-sms/inbound to; it signs that exact string Derived from request headers
SMS_CODE_TTL_SEC Optional 600
SMS_CODE_MAX_ATTEMPTS Optional 5
SMS_ACTION_JWT_MAX_AGE_SEC Optional 300
SMS_MAX_DIDS_PER_PHONE Optional 5
SMS_ALERT_SEARCH_INTERVAL_MS Optional 300000
SMS_REQUIRE_ACTION_CLAIM Must not be false in production true
SMS_DEV_ECHO_CODE Must be unset or false; honored only under NODE_ENV=test-local false
Replicas One process Not enforced in code
Persistent volume Required for Docker so SQLite survives replace None unless you pass -v

Rotating SMS_CODE_SECRET invalidates every pending verification code and orphans every stored phone_hash. Rotate between deploys, not casually.

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

Every line this service prints is prefixed with an ISO-8601 UTC timestamp (src/util/log.ts).

/health only means the HTTP server is up. It does not prove Firebase, Endorser, Partner, SQLite durability, or AlertSearch.

  1. Listening: curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3003/health (or the host/port you published). Expect 200.
  2. Health body: curl -sS http://127.0.0.1:3003/health{"ok":true}.
  3. The schedulers are started from src/index.ts (startScheduler(), then startAlertSearchScheduler(), then startSmsAlertSearchScheduler() when SMS_ENABLED) as the process reaches * Running backend. None of them runs a pass on startup; the first pass is on the timer, and the SMS pass waits an extra 150 seconds.
  4. AlertSearch activity in logs: look for [AlertSearchScheduler] Pass started and [AlertSearchScheduler] Pass completed in (or Pass 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.
  5. SMS activity in logs (when SMS_ENABLED): look for [SmsAlertSearchScheduler] Pass started / Pass completed in. Route activity logs under [NotifySms], and rejected authorizations under [SmsActionJwt] Rejected.
  6. SQLite location: after the process has handled a request or a scheduler pass that opens the DB, confirm {NOTIFY_DATA_DIR}/notify.sqlite exists (Docker default: /app/data/notify.sqlite on the volume). WAL sidecars may appear while the process is running.
S
Description
No description provided
Readme
1.1 MiB
Languages
TypeScript 98.1%
Dockerfile 1.9%