75 Commits
Author SHA1 Message Date
Jose Olarte III c75060a7f1 Use singular “update” when the digest has one item so FCM and SMS bodies stay grammatical. 2026-09-18 16:54:09 +08:00
Jose Olarte III 77df676d6e Accept POST on /notifications/alert-authorization instead of PUT.
The path and payload stay the same; this matches how the client submits a batch.
2026-09-18 15:05:30 +08:00
Jose Olarte III 226925278f Move remaining alertSearch tests under test/.
Keep them beside the existing SMS alertSearch tests and update imports so they still load src/.
2026-09-18 14:53:41 +08:00
trentlarson 93168ae360 make the boolean checks consistent, and guard against mistakes 2026-09-14 11:43:22 -06:00
trentlarson 885c6ced42 bump version and add '-beta' 2026-09-09 21:09:44 -06:00
trentlarson 4c2e6491db bump to version 0.2.1, for relase on test server 2026-09-09 21:09:09 -06:00
Jose Olarte III 657fd9c530 Count the SMS digest cap by UTC calendar day so a send just before midnight does not block the next day’s text. 2026-09-09 21:19:49 +08:00
trentlarson 65eb1bee1e add notify hour-minute setting, and a DELETE for an existing alert 2026-09-07 18:55:52 -06:00
trentlarson 9e5b8bbd59 Merge branch 'endpoint-query' into endpoint-query-sms 2026-09-07 17:02:18 -06:00
trentlarson 6cea0e9b67 sms: create explicit allow & block management 2026-09-06 12:20:04 -06:00
trentlarson 2e34bed700 sms: more debugging info 2026-09-06 09:32:03 -06:00
trentlarson bfac283690 add tests -- which have worked on Trent's machine... SMS sent! 2026-09-06 09:22:15 -06:00
trentlarson f967db7373 Add SMS notifications. The PLAN is executed but this isn't tested yet. 2026-09-05 20:52:34 -06:00
trentlarson adf5e6166d add a flag for the debug endpoints, and distinguish unavailable errors 2026-09-05 16:54:23 -06:00
trentlarson 776558b230 remove debug endpoints that are unsafe in final version 2026-09-05 13:21:03 -06:00
Jose Olarte III d0a3951689 Allow the ngrok browser-warning skip header in CORS so a WebView can reach the local tunnel without failing preflight. 2026-09-03 21:12:59 +08:00
Jose Olarte III c9df679ac2 Document the real Docker/node dist/index.js production path, durable SQLite, single replica, and Firebase credential options so deploys are not treated as equivalent to tsx. 2026-09-01 16:07:21 +08:00
Jose Olarte III cfc2cdefa3 Send a TimeSafari FCM digest after a completed daily alertSearch with updates, using JWT consumption to avoid repeat sends without changing WAKEUP_PING. 2026-08-31 21:41:34 +08:00
Jose Olarte III 10e80c6f41 Attach the alertSearch digest to each daily run so a completed retrieve is notification-ready without changing JWT consumption or cursor rules. 2026-08-31 20:44:04 +08:00
Jose Olarte III 49f50e36b7 Add a structured alertSearch digest of the six result buckets so the app can build a daily notification from records and counts, not a server-hardcoded message. 2026-08-28 20:58:13 +08:00
Jose Olarte III 209b346d16 Add a user-level alertSearch scheduler that runs each authorization-batch user once per pass, with an in-flight guard, without changing FCM wakeup. 2026-08-28 16:39:22 +08:00
Jose Olarte III d45ea26bca Add a callable daily alertSearch run that picks today’s unused delegated JWT from the batch timezone and consumes it only after both sources complete. 2026-08-28 15:46:39 +08:00
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
Jose Olarte III eb5b286476 Persist independent Endorser and Partner alertSearch cursors per user so later daily runs can resume after the last complete retrieval.
Advance each cursor only on success, leave empty/pagination/error results unchanged, and keep this unwired from the scheduler.
2026-08-27 20:20:51 +08:00
Jose Olarte III caeca10910 Fix Endorser alertSearch pagination so the next beforeId is the max of per-bucket minima, not the global min.
Treat a full plansNearby page as incomplete because the server orders that bucket by rowid while filtering jwtId.
2026-08-27 16:34:36 +08:00
Jose Olarte III 74e9c02669 Add an alertSearch retrieval layer so notify-api can query Endorser and Partner with a delegated JWT.
Keep Endorser and Partner cursors independent, paginate with the real beforeId/beforeDate contract, and leave scheduling and notification delivery for later.
2026-08-27 15:16:04 +08:00
Jose Olarte III 00cd0cf8f4 Add PUT /notifications/alert-authorization so the app can install a 100-JWT delegated batch without overloading device registration.
Store the batch atomically under the authenticated DID, replace unused JWTs on each setup, and reject Passkey identities that cannot mint future credentials.
2026-08-26 20:40:52 +08:00
Jose Olarte IIIandCursor 9c7c7fa271 bump version to 0.1.4 and add to CHANGELOG
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 15:49:28 +08:00
jose 76d3447d78 Merge pull request 'chore: rename FCM_TOKEN_DATA_DIR env var to NOTIFY_DATA_DIR' (#4) from data-dir-rename into master
Reviewed-on: #4
2026-08-07 06:26:36 +00:00
Jose Olarte III 6e4e1e7f1b chore: rename FCM_TOKEN_DATA_DIR env var to NOTIFY_DATA_DIR
Align the data-directory env name with notify.sqlite; default path remains ./data.
2026-08-04 17:31:14 +08:00
trentlarson a45150e25f bump version to 0.1.3 and add to CHANGELOG 2026-07-30 20:21:03 -06:00
jose d8989f587e Merge pull request 'Convert JSON storage to SQLite' (#3) from sqlite-storage into master
Reviewed-on: #3
2026-07-29 11:40:59 +00:00
Jose Olarte III 9e6597638e chore(db): rename SQLite database file to notify.sqlite
Use a generic database filename for future notification-related storage, and update docs and env comments to match.
2026-07-27 16:53:53 +08:00
Jose Olarte III 06ec0f63e4 docs: document SQLite storage for FCM registrations
Update README and .env.example for the SQLite database location, schema overview, backup/WAL notes, and clarify that JSON data is not migrated.
2026-07-24 20:52:38 +08:00
Jose Olarte III ddb969c26d chore(db): remove obsolete JSON FCM token repository
Delete the unused JSON storage implementation now that SQLite is the sole persistence backend.
2026-07-24 17:57:44 +08:00
Jose Olarte III e79d55fac6 perf(db): tighten SQLite FCM registration queries
Drop the upsert pre-read, project only needed columns, and add a (user_id, fcm_token) index for token lookups and dedupe deletes.
2026-07-24 17:31:23 +08:00
Jose Olarte III 11bd45b460 feat(db): switch runtime storage to SQLite repository
Point routes, scheduler, and push service at fcmTokensSqlite so the service no longer reads or writes the JSON store.
2026-07-23 18:55:17 +08:00
Jose Olarte III 5a3c22a4e3 feat(db): add SQLite FCM registration repository
Mirror the JSON repository API on SQLite without switching callers; clarify that SQLite is the storage backend and there is no JSON data migration.
2026-07-23 18:36:10 +08:00
Jose Olarte III fa8ab86cdb feat(db): add SQLite foundation for FCM registration storage
Introduce better-sqlite3 and a schema-init module so we can migrate off JSON storage without changing runtime behavior yet.
2026-07-23 17:44:27 +08:00
trentlarson 55d43a1a20 bump version to 0.1.2 2026-07-16 22:46:09 -06:00
jose c4f34c8882 Merge pull request 'endorser-authentication' (#2) from endorser-authentication into master
Reviewed-on: #2
2026-07-16 12:32:40 +00:00
Jose Olarte III 9634c3422e docs: document Endorser auth flow and ENDORSER_URL
Describe when register/refresh verify with Endorser, keep the testMode bypass notes, and list ENDORSER_URL in the env table.
2026-07-10 21:09:22 +08:00
Jose Olarte III 2343cece5c fix(auth): distinguish Endorser unavailability from auth rejection
Return 503 when Endorser cannot be reached and keep 401 for rejected JWTs, with clearer server-side diagnostics and generic client messages.
2026-07-10 19:06:40 +08:00
Jose Olarte III f2f0732ef5 feat(auth): share Endorser verification across register and refresh
Extract requireEndorserAuth middleware and apply it to /notifications/refresh so both endpoints use the same check while keeping the local test bypass.
2026-07-10 18:58:52 +08:00
Jose Olarte III 764c89c071 feat(auth): require Endorser verification on device registration
Gate /notifications/register on Endorser JWT acceptance while leaving the local testMode bypass unchanged.
2026-07-10 18:44:35 +08:00
Jose Olarte III e5415280bd feat(endorser): add client helper to check JWT via rateLimits
Introduce checkAuth so callers can verify Endorser acceptance of a JWT without dealing with HTTP details.
2026-07-10 18:05:16 +08:00
Jose Olarte III 0758c4192f feat(config): add ENDORSER_URL for upcoming Endorser auth
Expose the Endorser API base URL from the environment (defaulting to production) so later auth work can call it without hardcoding.
2026-07-10 16:09:57 +08:00
Jose Olarte III fcf3fb9aa0 chore: ignore .DS_Store files 2026-07-02 17:14:11 +08:00
trentlarson a99117dc98 bump to v 0.1.1, change default port to 3003, fix Dockerfile to build & run separately 2026-06-28 17:29:21 -06:00
trentlarson 85301135c7 fix type errors 2026-06-28 17:28:54 -06:00
Jose Olarte III 7a2bb88207 feat(config): load .env and log Firebase credential source at startup
Add dotenv via src/env.ts so dev/start read .env before Firebase init,
and log whether credentials come from FIREBASE_SERVICE_ACCOUNT_JSON or ADC.
2026-06-11 17:50:16 +08:00
Jose Olarte III 6261f1baa0 feat(debug): allow local testMode auth on send-wakeup
Move requireAuthOrNotificationLocalTest into shared auth middleware and
apply it only to POST /debug/send-wakeup so local testing matches
register/refresh without changing JWT-authenticated behavior.
2026-06-11 17:24:06 +08:00
trentlarson bb0927ad92 Merge pull request 'Dockerize' (#1) from docker into master
Reviewed-on: #1
2026-06-06 01:31:52 +00:00
trentlarson 2dba6c3597 Merge branch 'master' into docker 2026-06-05 19:30:50 -06:00
Jose Olarte III 6ba7d678c6 feat(notifications): allow local debug register/refresh without JWT
When the Notification Debug Panel sends testMode: true and omits
Authorization, skip requireAuth on /notifications/register and /refresh
and scope devices under a synthetic local-test user id. Requests with
a Bearer token or without testMode still use full JWT auth unchanged.
2026-06-04 20:32:18 +08:00
Jose Olarte III dffb86007e fix(cors): enable preflight for Capacitor WebView requests
Add express cors middleware with reflected origin so Android clients
from https://localhost receive Access-Control-Allow-* headers on
OPTIONS and can proceed with POST requests.
2026-06-04 18:26:22 +08:00
trentlarson a2e5fa0ab9 fix the pnpm build -- 'docker build' now works 2026-06-02 20:37:29 -06:00
trentlarson c010c861b4 attempt a Docker file but it fails on step 5 2026-05-27 20:37:33 -06:00
trentlarson df442df869 add a README.md with basics, and target pnpm instead of npm 2026-05-27 20:02:24 -06:00
Jose Olarte III 03ebe03021 chore: add .env.example for local setup
Document PORT, Firebase credentials, FCM token data dir, and test-local
NODE_ENV so developers can copy the file to .env without guessing vars.
2026-05-24 10:25:45 +08:00
Jose Olarte III f12dd03725 chore(logging): normalize wakeup flow observability with timings and summaries
Standardize console prefixes across scheduler, push, refresh, register,
auth, and debug endpoints. Add pass-level scheduler summaries, elapsed-time
logs, and masked-token-only push failure messages while reducing per-device
noise in scheduler loops.
2026-05-21 19:18:28 +08:00
Jose Olarte III e82c3ae5bc feat(debug): expose nextEligibleAt and structured send-wakeup results
Extend authenticated debug endpoints for local iOS notification testing:
add nextEligibleAt (23h prod / 10m test) to device lookup, return success
and failureReason from send-wakeup with masked tokens only, reuse
resolveOwnedDevice for ownership checks, and standardize [DebugEndpoint] logs.
2026-05-21 18:23:50 +08:00
Jose Olarte III 9764b30aed fix(auth): harden refresh ownership and scheduler after auth migration
Restore /health to { ok: true }. Scope refresh to owned devices via
deviceId/fcmToken, improve register upsert logging, skip legacy rows in
the scheduler with per-token dedupe, and prefer non-legacy rows for push.
2026-05-19 19:53:21 +08:00
Jose Olarte III afbc2e9a57 feat(debug): harden debug routes with auth and user-scoped token access
Add GET /debug/device/:token and POST /debug/send-wakeup behind requireAuth,
scope lookups to the authenticated user (404 otherwise), and mask FCM tokens
in logs via maskToken. Mark routes for further restriction before production.
2026-05-19 19:42:22 +08:00
Jose Olarte III 8e502a2335 feat(notifications): bind device registrations to authenticated user DID
Scope register and refresh to verified JWT identity (req.did). Persist
devices under userId::deviceId, reject client-supplied userId, and dedupe
FCM tokens per user.
2026-05-19 19:02:42 +08:00
Jose Olarte III 4bf57d26fd Add Bearer JWT auth middleware for notification routes
Mirror image-api’s DID JWT verification (src/vc + requireAuth) so
/notifications/* require a valid Authorization header while /health
stays public. Attach req.did, req.jwt, and req.auth for downstream use.
2026-05-19 18:23:41 +08:00
Jose Olarte III fc0cad4f2e feat(register): key devices by deviceId and replace FCM tokens in place
Require deviceId on POST /notifications/register, upsert by deviceId
while preserving lastNotifiedAt and internal id, prune duplicate token
rows, migrate legacy fcmToken-keyed JSON, and add register logs.
Extend StoredRow and Device with deviceId; resolve pushes by scanning
fcmToken.
2026-05-12 21:44:59 +08:00
Jose Olarte III e92ddb7da9 chore(obs): add lightweight console logs for scheduler and push
Log scheduler ticks, refresh requests, dedupe skips by device id,
push attempt/success with token hints, and push failures without
extra sensitive fields.
2026-05-12 18:41:41 +08:00
Jose Olarte III 86d589d0e8 feat(db): track last push time by device id with numeric timestamps
Assign stable ids to stored tokens, migrate legacy ISO lastNotifiedAt
to epoch ms, replace setLastNotifiedAt with db.update, and persist
lastNotifiedAt only after a successful FCM send. Extend Device with
optional lastNotifiedAt (ms).
2026-05-11 21:23:10 +08:00
Jose Olarte III 096f393df9 feat(scheduler): run wake pushes every five minutes
Add db.getAll for registered tokens and tick the interval with
sendPushToDevice per device, with error logging on tick failure.
2026-05-11 18:35:45 +08:00
Jose Olarte III a5266615eb feat(push): send silent iOS wakeups via FCM APNs background
Use apns-push-type background, priority 5, contentAvailable-only aps
payload, and WAKEUP_PING data without alert or sound.
2026-05-11 17:09:01 +08:00
Jose Olarte III 1115929437 feat(push): dedupe FCM sends with 23h / 10m windows
Track lastNotifiedAt on stored tokens, preserve it on register upsert,
and skip messaging.send when inside the production or test-mode window.
2026-05-11 16:56:07 +08:00
Jose Olarte III 2b57ec0e1c feat(notifications): add POST /refresh schedule response
Return shouldNotify and nextNotifications with a 10-minute lookahead
timestamp for the app schedule API.
2026-05-11 15:59:26 +08:00
Jose Olarte III 64ea7d2f98 feat(notifications): persist FCM tokens on POST /register
Add JSON-backed upsert store (data/fcm-tokens.json, optional
FCM_TOKEN_DATA_DIR), validate body fields, and gitignore data/.
2026-05-11 14:52:09 +08:00
Jose Olarte III d311b6a504 feat(fcm): add Firebase Admin SDK and messaging export
Wire firebase-admin with ADC or FIREBASE_SERVICE_ACCOUNT_JSON,
export messaging from src/services/firebase.ts, and load it at
server startup.
2026-05-11 14:47:05 +08:00
90 changed files with 22155 additions and 162 deletions
+77
View File
@@ -0,0 +1,77 @@
# HTTP port (default: 3003)
# PORT=3003
# Endorser API base URL (default: https://api.endorser.ch)
# ENDORSER_URL=https://api.endorser.ch
# DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
# Partner API base URL (default: https://partner-api.endorser.ch)
# PARTNER_URL=https://partner-api.endorser.ch
# DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch
# Firebase Admin (required for WAKEUP_PING and AlertSearch FCM).
# Path 1: inline service account JSON (one line). The application reads this variable.
# FIREBASE_SERVICE_ACCOUNT_JSON={"type":"service_account",...}
# Path 2: if unset/empty, Application Default Credentials (ADC).
# ADC may use GOOGLE_APPLICATION_CREDENTIALS; the application does not read that name itself.
# Durable directory for SQLite: notify.sqlite plus -wal/-shm while running (default: ./data).
# In production this must be persistent storage (Docker: volume on /app/data).
# NOTIFY_DATA_DIR=./data
# Do not set NODE_ENV=test-local in production (bypasses ethr JWT expiry).
# NODE_ENV=test-local
# Mount the /debug router (device lookup, manual wakeup send). Default: false.
# Leave off in production; the routes expose per-device state to any accepted JWT.
# DEBUG_ENDPOINT=false
# --- SMS notifications (/notify-sms) ---
# Master switch. While false, every /notify-sms route returns 503 SMS_DISABLED
# and the SMS scheduler does not start.
# SMS_ENABLED=false
# HMAC key for verification codes and phone hashes. Required when SMS_ENABLED;
# the process refuses to start without it. Rotating it invalidates every pending
# code and orphans every stored phone_hash.
# SMS_CODE_SECRET=
# Twilio. Sends fail with SMS_NOT_CONFIGURED until the account, the token, and
# one of the two "from" values are all present.
# TWILIO_ACCOUNT_SID=
# TWILIO_AUTH_TOKEN=
# Identifier for sender whom this is from.
# Prefer the Messaging Service once an A2P 10DLC campaign is approved: the
# campaign lives on it, and it wins when both are set.
# TWILIO_MESSAGING_SERVICE_SID=
# TWILIO_FROM_NUMBER=+15550000000
# The public URL Twilio posts /notify-sms/inbound to. Twilio signs that exact
# string, and behind a proxy or tunnel the request headers do not reproduce it.
# TWILIO_WEBHOOK_URL=https://example.com/notify-sms/inbound
# SMS_CODE_TTL_SEC=600
# SMS_CODE_MAX_ATTEMPTS=5
# SMS_ACTION_JWT_MAX_AGE_SEC=300
# SMS_MAX_DIDS_PER_PHONE=5
# SMS_ALERT_SEARCH_INTERVAL_MS=300000
# Requires the SmsNotificationAction claim on every /notify-sms Bearer JWT.
# Setting this false removes the action authorization stage.
# SMS_REQUIRE_ACTION_CLAIM=true
# Returns the plaintext verification code in the POST /notify-sms/phone response.
# Honored only when NODE_ENV=test-local, which is checked first. Never in production.
# SMS_DEV_ECHO_CODE=false
# The only DIDs this instance may text, comma-separated. Unset means no
# restriction, which is the production setting. Set but empty blocks everyone.
# Intended for a test server that holds live Twilio credentials: without it, a
# production database restored into staging texts every verified user daily.
# SMS_ALLOWED_RECIPIENT_DIDS=did:ethr:0xabc,did:ethr:0xdef
# Numbers this service must never text, comma-separated, on top of the
# sms_blocked_numbers table that the STOP webhook writes to. Normalized before
# comparison, so formatting does not defeat them.
# SMS_BLOCKED_NUMBERS=+15555550123,+15555550124
+2
View File
@@ -2,3 +2,5 @@ node_modules/
dist/
.env
*.log
data/
.DS_Store
Binary file not shown.
+105
View File
@@ -0,0 +1,105 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Changed
- `SMS_ENABLED`, `SMS_REQUIRE_ACTION_CLAIM`, and `SMS_DEV_ECHO_CODE` parse like `DEBUG_ENDPOINT`: trimmed, case-insensitive, accepting `true`/`1`/`yes`/`on` and `false`/`0`/`no`/`off`. (A trailing space from `docker run --env-file` no longer silently turns SMS off, and an unrecognized `SMS_REQUIRE_ACTION_CLAIM` keeps the claim required instead of disabling it.)
## [0.2.1] - 2026.09.09
### Added
- `notifyHourUtc` (0-23) and `notifyMinuteUtc` (0-59) are **required** on an alert-authorization batch, both integers and both UTC — the field names carry the frame, so no offset or zone travels with them. Stored zero-padded as one `HH:MM` value in `notify_hour_min_utc`. Required rather than optional because the alternative default is not "no gate" but "the first tick after midnight UTC", which puts every user in one five-minute window. The scheduler holds a user's whole daily run, search included, until that instant, and reports the held users as `deferred`. A stored UTC time does not follow the user through a daylight-saving change; a fresh batch carrying the new offset corrects it
- Both alertSearch schedulers pick their users with one query per pass (`listPendingForDay`) instead of one per user: it returns everyone holding an unused JWT for the current UTC day, flagged by whether their `notify_hour_min_utc` has arrived. A user who has already run that day no longer appears, so an idle tick over 2000 users costs 0 queries and ~1ms, down from 3 queries per user and ~141ms
- Both passes work on `ALERT_SEARCH_USER_CONCURRENCY` users at a time (`src/util/concurrency.ts`, default 8) instead of serially; at 50ms of API latency a 2000-user pass drops from ~106s to ~13s, which is what keeps it inside its own tick
- The notify hour and `timezone` are accepted on `PUT /notifications/alert-authorization` as well, and the push scheduler honours the stored hour
- Optional `timezone` (IANA name) on an alert-authorization batch, validated and stored beside `notify_hour_min_utc` and read by nothing. It is recorded for a future mechanism that would re-derive the UTC time across a DST change; the column carries that reason as a SQL comment
- `DELETE /notifications/alert-authorization` and `DELETE /notify-sms/alert-authorization` remove every batch and JWT a DID holds in that channel, so a user can turn alerts off; verified phone numbers and alertSearch cursors survive, and the SMS revocation is logged as `alert-authorization-deleted` under the `revoke-alert-search` action claim
- `/notify-sms` SMS channel: phone registration with a 6-digit possession check, a per-channel delegated-JWT inventory and cursor, a Twilio sender, an `STOP`/`START`/`HELP` webhook, and a daily digest text alongside the FCM one; off unless `SMS_ENABLED`
- Blocks record their origin (`opt-out` / `provider-opt-out` / `manual`) and are never downgraded; a Twilio `21610` refusal auto-blocks as `provider-opt-out`, and `pnpm run sms:blocks` lists and manages the list by origin
- `sms_blocked_numbers` suppression list: `STOP` now blocks durably instead of only unverifying, `START` lifts the block, and a blocked number is refused at registration and verification and skipped by the digest; `SMS_BLOCKED_NUMBERS` blocks by configuration
- `SMS_ALLOWED_RECIPIENT_DIDS` restricts which DIDs an instance may text, guarding both the verification code and the daily digest, so a test server with live Twilio credentials cannot message a restored production database
- Every log line is prefixed with an ISO-8601 UTC timestamp (`src/util/log.ts`)
- `pnpm run sms:send` and `pnpm run sms:smoke` exercise the Twilio path and the full `/notify-sms` route flow against a stub Endorser and unsigned test-local JWTs
- `pnpm run twilio:whoami` reports which Twilio account a send would bill and distinguishes a mismatched credential pair from a valid test pair
### Changed
- Alert-authorization batches no longer schedule from a `timezone`. Days are UTC days: each delegated JWT must be valid for the whole UTC day it names (`nbf` at or before its midnight, `exp` at or after the next), the daily run selects by UTC day and reports it as `utcDay`, and `notifyTime` supplies its own offset. Removes `InvalidAlertAuthorizationTimezoneError`
- `loadAlertSearchCursors`, `advanceAlertSearchCursors`, `runAlertSearchCycle`, and `runDailyAlertSearch` take a channel (`"fcm"` default), selecting the JWT inventory and cursor table
## [0.1.14] - 2026.09.01
### Changed
- Production runbook: Docker/`node dist/index.js` is the canonical deploy path; document durable SQLite, single replica, Firebase credential options, and smoke checks
## [0.1.13] - 2026.08.31
### Added
- AlertSearch scheduler delivers a TimeSafari FCM digest (`type: alert_search`) when a completed run consumed today's JWT and `digest.hasUpdates` is true; WAKEUP_PING and JWT/cursor rules are unchanged
## [0.1.12] - 2026.08.31
### Added
- `runDailyAlertSearch` includes a Phase 6A `digest` on retrieval attempts (`null` when no batch or today's JWT is missing); JWT consumption is still based on both sources completing, not `hasUpdates`
## [0.1.11] - 2026.08.28
### Added
- `buildAlertSearchDigest` turns a completed Endorser+Partner alertSearch retrieve into structured per-bucket records and counts for a future app/plugin payload (no notification message or FCM send)
## [0.1.10] - 2026.08.28
### Added
- Dedicated user-level alertSearch scheduler (`startAlertSearchScheduler`) that calls `runDailyAlertSearch` once per authorization-batch user, with a process-local in-flight guard; independent of FCM `WAKEUP_PING`
## [0.1.9] - 2026.08.28
### Added
- `runDailyAlertSearch` selects today's unused delegated JWT from the stored batch timezone, runs `runAlertSearchCycle`, and consumes that JWT only when both Endorser and Partner complete (`success` or `empty`)
## [0.1.8] - 2026.08.27
### Added
- `runAlertSearchCycle` integration of cursor load, alertSearch retrieve, and independent cursor advance
### Changed
- Partner pages of 50 profiles that share the oldest `updatedAt` are classified incomplete so the cursor cannot skip tied rows
## [0.1.7] - 2026.08.27
### Added
- Persistent per-user Endorser and Partner alertSearch cursors (advanced only after complete retrievals)
## [0.1.6] - 2026.08.27
### Added
- AlertSearch retrieval layer for Endorser and Partner GET endpoints (pagination, independent cursors; not scheduled yet)
### Changed
- Endorser in-run beforeId is MAX of per-bucket minima; plansNearby hitting the row limit is classified incomplete
## [0.1.5] - 2026.08.26
### Added
- `PUT /notifications/alert-authorization` stores a 100-JWT delegated alert-authorization batch per user (SQLite), authenticated with the current user JWT
## [0.1.4] - 2026.08.07
### Changed
- Renamed `FCM_TOKEN_DATA_DIR` env var to `NOTIFY_DATA_DIR`
## [0.1.3] - 2026.07.30
### Changed
- Persistence changed from file to SQLite
## [0.1.2] - 2026.07.16
### Added
- Validation via Endorser service, with new ENDORSER_URL variable
## [0.1.1] - 2026.06.28
### Added
- Base functionality
+974
View File
@@ -0,0 +1,974 @@
# PLAN: SMS notifications (`/notify-sms`)
**Status:** implemented. Phases 1-15 are code-complete except the
four deployment steps noted under phase 12, which need a real Twilio account and
a real handset.
## Goal
Deliver the daily alertSearch digest by SMS as well as by FCM. 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 — at an hour of their
choosing, and only until they revoke the authorization.
Every phone action is authorized by a JWT that names the action and the phone
number it applies to, and every phone action is recorded.
## Scope
In scope: six HTTP endpoints, the middleware chain behind them, five SQLite
tables, a Twilio sender, a per-channel cursor split, and an SMS delivery pass
next to the FCM one. Plus one FCM-side addition: `DELETE
/notifications/alert-authorization`, the twin of the SMS revoke route, because a
user turning alerts off means both channels and only one of them had a way to
say so.
Out of scope: changing how the FCM channel searches or delivers, changing
`WAKEUP_PING`, MMS, inbound conversational SMS beyond opt-out keywords, and
international sender registration beyond US A2P 10DLC. The notification hour is
an SMS-channel setting; the FCM scheduler does not read it.
## Endpoints
Mounted at `/notify-sms` in `src/index.ts`, a sibling of `/notifications`, in a
new `src/routes/notifySms.ts`.
| 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 flip `verified` to true |
| DELETE | `/notify-sms/phone` | Remove the phone entirely |
| POST | `/notify-sms/alert-authorization` | Store a delegated JWT batch for the SMS channel |
| DELETE | `/notify-sms/alert-authorization` | Remove every SMS batch and JWT for the DID, turning the channel off |
`src/index.ts` CORS lists `["GET", "POST", "PUT", "OPTIONS"]`. **`DELETE` must be
added to that list**, or the browser preflight for the delete route fails before
Express ever sees it.
### GET /notify-sms/phone
No body. Optional query parameter `phoneNumber`.
- Without the parameter: this DID's own registrations —
`{ phoneNumber, verified, verifiedAt, createdAt }` each. Numbers are returned
in full; they are the caller's own.
- With `phoneNumber`: additionally `dids`, the full DID of every verified
registration of that number, **only when the calling DID holds a verified
registration of it**. Otherwise `403 SMS_PHONE_NOT_VERIFIED_BY_CALLER`, with
no count and no identities.
- Response `200`. A DID with no registrations gets an empty list, not a `404`.
The verified-registration gate is the same possession rule §Telling the caller
who holds the slots applies to PUT: a DID that has verified the number has
already proved it holds the handset. A DID that has not, learns nothing — which
is why the blocked-at-PUT response carries the list inline rather than telling
the caller to come back with a GET they are not yet entitled to make.
### POST /notify-sms/phone
Body: `{ "phoneNumber": "+15555550123" }`
- Normalize to E.164. A number that does not normalize is `400`
`SMS_PHONE_INVALID`.
- If the DID already has this exact number and it is verified, return `200`
with `verified: true` and send nothing. Idempotent, costs no money, and
removes the obvious SMS-bombing lever.
- Otherwise upsert the `sms_registrations` row with `verified = 0`, mint a code,
store its HMAC, and send the text.
- Code: `crypto.randomInt(0, 1000000)` zero-padded to six digits. Not
`Math.random()`.
- Stored as `code_hash = HMAC-SHA256(code, SMS_CODE_SECRET)`. The plaintext code
exists in memory and in the outbound message, nowhere else.
- `code_expires_at = now + SMS_CODE_TTL_SEC` (default 600). `code_attempts = 0`.
- Send throttle, counted from `sms_phone_log`: 3 code sends per phone per hour,
10 per DID per day. Over the limit is `429` `SMS_CODE_RATE_LIMITED`.
- Response `200`: `{ success, phoneNumber: "+1555*****23", verified: false, expiresAt }`.
The response never carries the code.
`SMS_DEV_ECHO_CODE` exists so a developer with no Twilio account, or no carrier
coverage at the desk, can still exercise POST-then-PUT. With
`NODE_ENV=test-local` **and** `SMS_DEV_ECHO_CODE=true`, the POST response gains
one extra field, `devCode`, holding the plaintext six digits that would have
been texted. The console `SmsSender` prints the same message instead of sending
it, so no carrier is involved and no money is spent.
Both conditions are required, and `NODE_ENV` is checked first. A production
process with `SMS_DEV_ECHO_CODE=true` set by accident echoes nothing, because
its `NODE_ENV` is not `test-local`. Without that pairing the flag is a
one-variable path to handing every verification code back to whoever asked for
it, which defeats the entire point of sending the code out-of-band to a phone
the caller must physically hold.
A DID that wants to re-verify a number it already verified calls DELETE, then
POST. That keeps a fumbled re-registration from silently switching off a working
alert channel.
### PUT /notify-sms/phone
Body: `{ "phoneNumber": "+15555550123", "code": "483920" }`
- Already verified: `200`, `verified: true`, no attempt counted.
- No pending code, or `code_expires_at` passed: `400` `SMS_CODE_EXPIRED`.
- `code_attempts >= SMS_CODE_MAX_ATTEMPTS` (default 5): `429`
`SMS_CODE_ATTEMPTS_EXHAUSTED`. The code is cleared; recovery is another POST.
- Compare with `crypto.timingSafeEqual` over the HMACs.
- Match: `verified = 1`, `verified_at`, `code_hash = NULL`, `code_attempts = 0`.
- Miss: increment `code_attempts`, `400` `SMS_CODE_MISMATCH` with
`attemptsRemaining`.
### DELETE /notify-sms/phone
Body `{ "phoneNumber": "+15555550123" }`, and also accepted as
`?phoneNumber=`, because a fair number of proxies drop bodies on DELETE.
- Deletes the `sms_registrations` row matching `WHERE user_id = ? AND
phone_e164 = ?`. Never the number alone.
- Sets `phone_e164 = NULL` on that DID's `sms_phone_log` rows for that number,
leaving `phone_hash` and the action history intact. "Totally removes the
phone" and "keeps an audit trail" are both satisfied: the log says what
happened and when, without naming whose number it was.
- Response `200` `{ success: true, deleted: true|false }`. Deleting a number
that is not registered is `deleted: false`, not an error.
- Deleting the last verified phone leaves any stored SMS JWT batch in place; the
delivery pass finds no verified recipients and sends nothing.
### POST /notify-sms/alert-authorization
Body is the FCM twin's shape plus a required UTC hour and an optional zone:
`{ batchId, notifyHourUtc, notifyMinuteUtc, timezone?, jwts: [100] }`.
- Validation reuses `validateAlertAuthorizationBatch`. It verifies each
delegated JWT's signature, matches `iss` to the authenticated DID, requires
100 consecutive sequences and distinct days, and requires each JWT to be valid
for the whole UTC day it names — `nbf` at or before that day's midnight UTC,
`exp` at or after the next. The daily run selects by UTC day and may fire at
any moment inside it, so a partial window would hand Endorser a credential
outside its own validity period.
- `notifyHourUtc` (0-23) and `notifyMinuteUtc` (0-59) are both required, both
integers, and both UTC — the field names carry the frame, so nothing else has
to. Absent, out of range, or the wrong type is rejected with the rest of the
batch, and one arriving without the other names the missing field. Every batch
therefore states its own hour, and no client silently inherits the first tick
after midnight UTC — the setting that puts every user on one tick.
§Notification hour covers what the value does.
- `timezone` is optional: an IANA name, validated against `Intl` and stored.
Nothing reads it. It may be omitted from a batch that states its hour, but not
the reverse. §Notification hour says what it is being kept for.
- Requires at least one verified phone for the DID. Without one: `409`
`SMS_NO_VERIFIED_PHONE`. Storing 100 credentials for a channel with no
reachable address is inventory nobody asked for.
- Stores into `sms_alert_authorization_batches` / `sms_alert_authorization_jwts`
through `smsAlertAuthorizationDb.replaceUnusedBatch`, which is
`alertAuthorizationDb.replaceUnusedBatch` pointed at the SMS tables: drop this
user's unused SMS JWTs, drop orphaned SMS batch rows, insert the new batch, in
one transaction.
- Response mirrors the FCM one plus the stored hour and zone:
`{ success, batchId, notifyHourUtc, notifyMinuteUtc, timezone, storedCount,
unusedCount }`.
- The route registers on `POST` and on `PUT` with the same handler. The FCM twin
is `PUT`, the semantics are replace-not-append, and an app that reaches for
`PUT` out of symmetry should not get a 404 for its trouble.
### DELETE /notify-sms/alert-authorization
No body. The action claim is `revoke-alert-search`, which binds to no phone
number.
- Removes every SMS batch and every SMS JWT for the DID, consumed rows included,
in one transaction, so the SMS scheduler stops listing that identity at all.
- Response `200` `{ success: true, deletedBatches, deletedJwts }`. Nothing
stored is zeros, not a `404`: the caller asked for a state, and that state is
what they get.
- Registered phone numbers survive. Silencing alerts is not a request to redo
the possession check on return; `DELETE /notify-sms/phone` is the route that
forgets a number, and `STOP` is the route that blocks one.
- The alertSearch cursors survive too, so a later re-authorization resumes where
this one stopped rather than replaying months of history.
- Logged as `alert-authorization-deleted`. For a DID whose handset is already
gone there is no number to log against, so `phone_hash` holds a hash of the
identity instead — the column is `NOT NULL` and the revocation is worth a row.
`DELETE /notifications/alert-authorization` is the same operation on the FCM
inventory, authorized the way its `PUT` twin is: Bearer JWT plus the Endorser
check, no action claim. Device registrations and `WAKEUP_PING` are untouched.
## One phone, several DIDs
Nothing keys off a phone number alone except carrier opt-out. Every other read
and write is scoped by `(user_id, phone_e164)`, so two identities sharing one
handset stay independent:
| Action by DID B | Effect on DID A's registration of the same number |
|---|---|
| POST (register) | None. DID B gets its own row, `verified = 0`, its own code. |
| PUT (verify) | None. The code is matched against DID B's row only. |
| DELETE | None. The `WHERE` clause carries both columns. |
| DELETE's log scrub | None. Only DID B's `sms_phone_log` rows are nulled. |
| Daily alert send | None. Recipients are that DID's own verified rows. |
Verification is per `(DID, phone)` and has to be. Possession of the handset is
what the code proves, and DID B has not proved it by watching DID A do so.
Three consequences worth stating rather than discovering:
- **Two verified DIDs on one handset receive two texts a day**, one per
identity, because each identity runs its own alertSearch against its own
cursor and sees its own results. The per-DID daily cap does not merge them.
- **The per-phone send throttle is deliberately cross-DID.** Three code sends
per hour is counted from `phone_hash`, not from `user_id`, because a
per-identity counter is trivially defeated by minting more identities. Two
legitimate registrations minutes apart both fit; a bombing run does not.
- **DELETE forgets a number per DID; `STOP` forgets it everywhere.** A carrier
opt-out arrives with a phone number and no identity attached, and stopping
traffic to that handset is not optional, so phase 9 sets `verified = 0` on
every registration of that number regardless of DID. That is the single place
in the design where the DID boundary is crossed on purpose.
### The cap
`SMS_MAX_DIDS_PER_PHONE` bounds how many DIDs one handset can carry. Default 5,
configurable up or down without a code change.
**The cap counts verified rows only.** Counting every row would hand an attacker
a registration lock: five POSTs from five throwaway DIDs, never verified, and
the handset's actual owner can no longer register it. Unverified rows cost
nothing to hold and cannot receive an alert, so they are not what the cap is
protecting against.
Checked in two places:
- **POST**, as an early rejection when the number already has
`SMS_MAX_DIDS_PER_PHONE` verified registrations: `409 SMS_PHONE_DID_LIMIT`.
Cheaper and clearer than letting someone verify a code and then be told no.
- **PUT**, immediately before flipping `verified` to 1, counting rows other than
this one. This is the check that actually holds: several registrations can
clear the POST check while the count sits under the limit, and PUT is the
moment a row starts consuming a slot. Same `409 SMS_PHONE_DID_LIMIT`.
The count reads the `phone_e164` index, filtered on `verified = 1`.
What stops unverified rows from accumulating is not this cap but the per-phone
send throttle: every POST that creates one also sends a text, and three texts
per phone per hour is the actual brake. The cap governs how many identities can
be *reached* at one number; the throttle governs how fast anyone can try.
### Telling the caller who holds the slots
A blocked caller needs to know which DIDs occupy the number, and in the ordinary
case the answer is "your own other identities, which you have lost track of."
Disclosing that list is gated on **proof of possession of the handset**, never
on the request alone.
| Rejection point | Body |
|---|---|
| POST at the limit | `409 SMS_PHONE_DID_LIMIT`, `limit` and `verifiedCount`. **No identities.** |
| PUT at the limit | `409 SMS_PHONE_DID_LIMIT`, `limit`, `verifiedCount`, and `dids`: the full DID of every verified registration of that number. |
The asymmetry is the whole design. A POST names any phone number on earth and
proves nothing about it. If it answered with identities, the endpoint would be a
phone-number-to-identity lookup oracle: feed it numbers, harvest the DIDs of
whoever holds them, authenticated by nothing more than possessing some DID of
one's own. A PUT that reaches the limit check has already matched a correct
six-digit code, which means the caller is holding the handset and can read every
message sent to it. Nothing is disclosed that possession did not already grant.
The code is consumed on a limit rejection exactly as it is on success. One code
buys one answer; a fresh answer costs a fresh POST, which the per-phone throttle
meters at three per hour. The rejection is logged as `did-limit-disclosed` in
`sms_phone_log`.
`sms_action_jwt_use` is keyed on the token hash, so two identities acting on one
number never collide there.
## Middleware chain
Four stages, in this order, on all five routes:
1. **`requireAuth`** — existing. Bearer JWT, signature verified against the
issuer DID, `req.did` / `req.jwt` set.
2. **`requireEndorserAuth`** — existing. `GET {ENDORSER_URL}/api/report/rateLimits`
with the same token. This is the "rateLimits" stage: it proves Endorser knows
and accepts this DID.
3. **`requireSmsActionJwt(action)`** — added by this plan. Confirms the verified token
authorizes *this specific action on this specific phone*.
4. **The route handler itself** — the Express callback that does the work
described under §Endpoints: normalize, read and write SQLite, call the
`SmsSender`, write `sms_phone_log`, send the response. It is listed as a
stage because Express treats it as one more function in the same chain, and
because of what that ordering guarantees: by the time the handler's first
line runs, the caller is authenticated, accepted by Endorser, and proven to
have authorized this exact action on this exact number. The handler performs
no authorization checks of its own and never re-reads `req.headers`; it
trusts `req.did` and the validated body, and nothing else.
Stages 13 either call `next()` or send a response and return. A stage that
sends a response ends the chain, so no later stage — the handler included —
runs. That is the whole mechanism preventing an unauthorized request from
reaching a `sendSms` call.
`testMode` has no bypass here. There is no local-test path that sends real texts
to real handsets on someone else's dime.
### Stage 3, in detail
There is one JWT. The client mints it with the claim inside, sends it as the
Bearer token, and the signature over that token is the authorization — the same
arrangement Endorser uses. Nothing is sent alongside it.
`decodeAndVerifyJwt` already returns `{ issuer, payload, verified }`, and
`requireAuth` keeps only `issuer` (as `req.did`) and the raw token string (as
`req.jwt`), discarding the decoded payload. Stage 3 needs the claim out of that
payload, so `requireAuth` stops discarding it: `req.auth = { did, jwt, payload }`.
`payload` is the verifier's own output for that one token, not a second
document.
Stage 3 reads that object and does not decode the token again. For `did:peer`
JWANT identities, re-decoding would be wrong, not merely wasteful: the outer
payload of a passkey token is a WebAuthn envelope
(`AuthenticationDataB64URL`, `ClientDataJSONB64URL`), and the actual claim sits
base64url-encoded inside `clientData.challenge`. `peerVerifyJwt` unwraps it and
that inner object is what `decodeAndVerifyJwt` returns. A stage that re-parsed
segment two would find the envelope and no claim at all, failing every passkey
user.
Stage 3 does not compare an issuer against `req.did`. `requireAuth` assigns
`req.did` from the verified issuer, so the two are the same value read twice —
a check that cannot fail proves nothing. Identity comes from `payload.iss`, and
the claim carries no DID of its own to disagree with it.
The one structural guard stage 3 does keep is that `req.auth` is present.
`requireAuth` sets it only after `verified.verified` is true, so its presence
means verification happened. Its absence means the route was mounted without
stage 1, or mounted with `requireAuthOrNotificationLocalTest`, whose `testMode`
path sets a synthetic `req.did` and no `req.auth`. Either is a wiring mistake
that must not fall through to a handler that sends texts. It is a presence
check, not a second verification, and it carries no timestamp — the signature
already did the verifying.
Checks, each with its own error code:
| Check | Failure |
|---|---|
| `req.auth` present | `500` — route wiring bug, not a client error |
| `payload.claim` is an object | `403 SMS_ACTION_JWT_MISSING_CLAIM` |
| `claim.action` equals the route's action | `403 SMS_ACTION_JWT_WRONG_ACTION` |
| `claim.phoneNumber` normalizes equal to the body's phone (phone routes) | `403 SMS_ACTION_JWT_PHONE_MISMATCH` |
| `payload.iat` within `SMS_ACTION_JWT_MAX_AGE_SEC` (default 300) | `401 SMS_ACTION_JWT_STALE` |
| `payload.exp`, when present, not passed | `401 SMS_ACTION_JWT_EXPIRED` |
| `sha256(jwt)` absent from `sms_action_jwt_use` | `401 SMS_ACTION_JWT_REPLAYED` |
On success the hash is inserted into `sms_action_jwt_use` 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.
### Action claim contract
The app mints the Bearer JWT with a claim this service defines and consumes:
```json
{
"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`, `revoke-alert-search`. `phoneNumber`
is required for `register-phone`, `verify-phone` and `delete-phone`, and
optional for `list-phones` — when the request carries the query parameter, the
claim must carry the matching number
and is the only binding the claim carries. The claim holds no DID — the
authenticated identity is `payload.iss` — and no `batchId`.
A batch upload or revocation is bound by `action` alone. Both act on the DID's
whole inventory rather than on one handset, so there is no number to bind to. Binding it to a batch id would add
nothing: the client invents the id, and the 100 delegated JWTs in the body must
each verify against the authenticated DID, so a stolen bearer token cannot
upload a batch it did not already have the user's signing key to produce. The
worst a replay achieves is replacing that user's own unused batch with that
user's own other batch, which is the endpoint's declared semantics anyway. The
body's `batchId` is recorded on the `alert-authorization-stored` row in
`sms_phone_log`, which is where an id useful for tracing belongs.
`@context` is `https://giftopia.tech` with no trailing path, and it is the same
context for the FCM setup claim as for the SMS one. `@type` is what separates
them, so one context URL covers both channels and any later setup claim without
minting a new namespace per endpoint.
**This is a client change.** The TimeSafari app sends a plain identity JWT to
`/notifications/*` and mints no such claim. A `/notify-sms` call carrying no
claim returns `SMS_ACTION_JWT_MISSING_CLAIM`.
`SMS_REQUIRE_ACTION_CLAIM` is the switch for that stage, and it defaults to
`false` for one reason only: stage 3 is built in phase 11, after the routes it
guards. Between phase 5 and phase 11 the routes exist with the check absent, and
the flag names that gap instead of hiding it. It is not a compatibility flag —
no deployed client calls `/notify-sms`, so there is nothing to stay compatible
with. `SMS_ENABLED` (also `false` by default) keeps the whole surface off the
public internet during that window; the two flags together mean a half-built
`/notify-sms` cannot be reached by anyone.
Phase 12 flips the default to `true`. A flag that weakens authorization and has
no expiry date will be found years from now, still `false`, in a config nobody
has opened since. *Aside: "temporary" is the longest-lived word in software,
right after "TODO".*
## Data model
All tables are added to `SCHEMA_SQL` in `src/db/sqlite.ts` with
`CREATE TABLE IF NOT EXISTS`. No existing table is altered and no data is
migrated.
### `sms_registrations`
| Column | Notes |
|---|---|
| `id` | TEXT PK, `randomUUID()` |
| `user_id` | authenticated DID |
| `phone_e164` | normalized number |
| `verified` | INTEGER 0/1, default 0 |
| `code_hash` | HMAC of the pending code, NULL once verified |
| `code_expires_at` | ISO string, NULL once verified |
| `code_attempts` | INTEGER, default 0 |
| `last_code_sent_at` | ISO string |
| `verified_at` | ISO string |
| `created_at`, `updated_at` | ISO strings |
`UNIQUE (user_id, phone_e164)`. Indexes on `user_id`, on `phone_e164` (opt-out
keyword lookup arrives with the number, not the DID), and on
`(user_id, verified)`.
### `sms_phone_log`
| Column | Notes |
|---|---|
| `id` | TEXT PK |
| `user_id` | DID |
| `phone_e164` | nullable; nulled by DELETE |
| `phone_hash` | HMAC of the number, never nulled |
| `action` | see list below |
| `result` | `ok` / `rejected` / `failed` |
| `detail` | short reason, no code and no full JWT |
| `jwt_hash` | sha256 of the authorizing token, when there was one |
| `provider_message_id` | Twilio SID for sends |
| `created_at` | ISO string |
Actions: `register-requested`, `code-sent`, `code-send-failed`,
`verify-succeeded`, `verify-failed`, `did-limit-blocked`,
`did-limit-disclosed`, `deleted`, `alert-authorization-stored`,
`alert-authorization-deleted`, `alert-sent`, `alert-send-failed`,
`recipient-not-allowed`, `number-blocked`, `number-unblocked`, `opt-out`.
`did-limit-blocked` records a POST refused at the limit; `did-limit-disclosed`
records a PUT refused at the limit, where the DID list left the building.
Indexes on `(user_id, created_at)`, `(phone_hash, created_at)`,
`(action, created_at)`. The throttle counts read the second one.
### `sms_action_jwt_use`
`id`, `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, so the row has nothing left to
protect.
### `sms_alert_authorization_batches` / `sms_alert_authorization_jwts`
Column-for-column mirrors of `alert_authorization_batches` /
`alert_authorization_jwts`, including the partial unique index on
`(user_id, day) WHERE status = 'unused'`. `day` is a UTC calendar day. Both
batch tables carry nullable `notify_hour_min_utc` and `timezone` because one store
implementation writes both; only the SMS side ever fills them in. `timezone`
carries its purpose as a SQL comment, which SQLite preserves verbatim in
`sqlite_master`, so `.schema` shows the reason beside the column.
### `sms_alert_search_cursors`
`user_id` PK, `endorser_after_id`, `partner_after_at`, `created_at`,
`updated_at`. Same shape as `alert_search_cursors`.
This table is not optional decoration. `alert_search_cursors` holds one row per
DID. Two independent daily runs against one cursor row means the first run
advances past the delta and the second sees an empty result — the FCM digest and
the SMS digest would disagree at random depending on which timer fired first.
Separate JWT inventories force separate cursors.
## Daily send path
### Per-channel plumbing
Introduce `AlertSearchChannel = "fcm" | "sms"`, defaulting to `"fcm"`
everywhere, so no existing call site or test changes.
- `src/alertSearch/cursors.ts`: `loadAlertSearchCursors(userId, channel = "fcm")`
and `advanceAlertSearchCursors(userId, result, channel = "fcm")` pick the table
from a `{ fcm: "alert_search_cursors", sms: "sms_alert_search_cursors" }` map.
- `src/alertSearch/cycle.ts`: threads `channel` through to the cursor calls.
- `src/alertSearch/daily.ts`: `runDailyAlertSearch(userId, now, cycleInput, channel = "fcm")`
selects the JWT inventory — `alertAuthorizationDb` or `smsAlertAuthorizationDb` —
from the same channel value. Selection, retrieval, digest building, and
consume-only-when-both-sources-complete are otherwise untouched.
### `src/alertSearch/smsNotify.ts`
`deliverAlertSearchSms(result)` mirrors `deliverAlertSearchNotification`:
- Eligibility is the identical predicate: `consumed && digest.completed &&
digest.hasUpdates && digest.totalCount > 0`. Consumption of the day's SMS JWT
is what makes later ticks on the same local day no-ops, so no separate
"already texted today" flag is needed.
- Recipients: `sms_registrations` rows for the DID with `verified = 1`,
deduplicated by number.
- Body: `Gift Economies: you have N new updates. https://giftopia.me` plus
`Reply STOP to end.` Kept inside 160 GSM-7 characters — a second segment is a
second charge for the privilege of a longer sentence. The link is
`https://giftopia.me`; `https://giftopia.tech` is the claim `@context`
namespace and never appears in a message.
- Each send writes `alert-sent` or `alert-send-failed` to `sms_phone_log` with
the provider message id. Send failures are logged and do not roll back cursor
advancement or JWT consumption, matching the FCM path.
### `src/alertSearch/smsScheduler.ts`
`startSmsAlertSearchScheduler()`, started from `src/index.ts` alongside the
other two, with its own interval and its own process-local in-flight guard.
- Selects users with one query per pass, `listPendingForDay({ day, hourMinute })`:
everyone holding an unused JWT for the current UTC day, each flagged by whether
their `notify_hour_min_utc` has arrived. A user who has already run that day holds
no unused JWT for it and does not appear (§Scheduler selection).
- Calls `runDailyAlertSearch(userId, now, {}, "sms")` on the due ones, at most
`ALERT_SEARCH_USER_CONCURRENCY` in flight, then `deliverAlertSearchSms`.
- Interval `SMS_ALERT_SEARCH_INTERVAL_MS`, default 5 minutes, with a 150-second
initial offset so the SMS pass and the FCM pass do not hit Endorser in the
same instant.
- Logs `[SmsAlertSearchScheduler] Pass started` / `Pass completed in …`, matching
the existing prefix convention.
### Scheduler selection
Both passes choose their users in SQL rather than by asking about each one.
`listPendingForDay` joins the newest batch per user — picked with a window
function, because a batch holding a consumed JWT survives the next upload, so
"the user's notify time" is not a plain join — against that day's unused JWTs,
and returns a `due` flag per row. Two properties fall out: a user who has
already run today is absent rather than queried and discarded, and `deferred`
stays countable because held users still appear.
The comparison is text, which is chronological only because `HH:MM` is
zero-padded. A stored value that is not `HH:MM` would sort above every real one
and defer that user forever, so the query guards it with `GLOB` and treats an
unreadable value as due — the same fail-open the column's own documentation
promises.
Due users are worked `ALERT_SEARCH_USER_CONCURRENCY` at a time. The per-user
work is two external round trips against independent cursors, so a serial loop
spends the pass waiting rather than working; measured at 50ms of latency, a
2000-user pass falls from ~106s to ~13s, which is the difference between fitting
in a five-minute tick and being skipped by the in-flight guard. The bound stays
low on purpose: the ceiling is latency, and Endorser and Partner are shared
infrastructure that a wide fan-out would only move the queue into.
### Notification hour
A batch's `notify_hour_min_utc` is the hour the user asked to hear from the
service, sent as two UTC integers and stored zero-padded as one `HH:MM` value.
The SMS scheduler holds that user's whole daily pass until the clock reaches it.
A batch cannot omit the hour, so this gate applies to every user rather than to
the subset who expressed a preference. That is deliberate: the alternative
default is "the first tick after midnight UTC", which is one tick for everybody.
The gate sits ahead of the search, not ahead of the text. Running the search
consumes that UTC day's JWT, and `isAlertSearchSmsEligible` fires only for the
run that consumed it, so a run at the top of the day would spend the credential
and reach the chosen hour with nothing left to send.
The hour lives on the batch rather than inside the delegated JWTs because those
are the alertSearch credential: their `nbf`/`exp` bound a whole UTC day, and
narrowing them to an hour would narrow when the search may run against Endorser,
not when the user hears about it.
The stored value is an instant of the UTC day, not a wall clock, so it does not
follow the user through a daylight-saving change: a Denver user wanting 18:00
local sends `00:30` UTC in summer, and when that region returns to `-07:00` the
same UTC instant reads 17:00 locally. The correction available today is a fresh
batch carrying the new UTC hour, which a client uploads roughly every 100 days
anyway.
The batch's optional `timezone` is recorded against a mechanism that would close
that gap sooner by re-deriving `notify_hour_min_utc` from the zone's rules. None
runs, and nothing reads the column. Two candidates are open, and §Rejected
records what is known about each: a job that sweeps zones whose rules changed,
or a stored next-firing instant that each send recomputes, which needs no
scheduled job. Measured cost is the reason neither is urgent — a formatter
cached per zone resolves a user's local wall clock in about 1.4µs, half the cost
of the per-user SQLite lookup the pass already performs.
There is no upper bound within the day. A service that was down at the chosen
hour and comes back six hours later still runs that UTC day; a silent day is the
worse failure. The text lands on the first tick at or after the hour, so within
one `SMS_ALERT_SEARCH_INTERVAL_MS` in the ordinary case. An hour late in the UTC
day leaves a correspondingly short window before the day key rolls.
Every batch that arrives through the route carries an hour, so the ungated path
is a floor rather than a setting: a row whose `notify_hour_min_utc` is NULL or
unreadable is treated as due, because a value nobody can read must not silence a
channel the user asked for. Only a write that bypasses the route can produce
one.
### Cost of two inventories
A user on both channels produces two Endorser and two Partner queries per day
and needs 200 minted JWTs. Both channels report the same underlying updates from
their own cursor, so the counts can differ transiently when one channel's
retrieval fails and the other's succeeds. This is inherent to separate
inventories; §Rejected records the shared-inventory alternative that avoids it.
## Provider
`src/services/smsService.ts` defines the port:
```ts
export type SmsSendResult =
| { status: "sent"; messageId: string }
| { status: "failed"; error: string };
export type SmsSender = (to: string, body: string) => Promise<SmsSendResult>;
```
Default implementation is Twilio over plain `fetch` against
`https://api.twilio.com/2010-04-01/Accounts/{SID}/Messages.json` with HTTP Basic
auth, form-encoded body, `To` / `From` (or `MessagingServiceSid`) / `Body`. No
`twilio` SDK dependency: the repo already talks to Endorser and Partner with
`fetch`, and the one call needed here is a form POST.
Configuration absent or incomplete: sends return
`{ status: "failed", error: "SMS_NOT_CONFIGURED" }` and log once at startup. The
process boots and FCM keeps working. A texting outage should not take
push down with it.
`NODE_ENV=test-local` with no Twilio credentials uses a console adapter that
prints the message instead of sending it.
## Configuration
New variables, added to `.env.example` and the README environment table:
| Variable | Required | Default |
|---|---|---|
| `SMS_ENABLED` | no | `false` — routes return `503 SMS_DISABLED`, scheduler does not start |
| `SMS_CODE_SECRET` | yes when enabled | none — HMAC key for code and phone hashes; absent is a startup failure |
| `TWILIO_ACCOUNT_SID` | yes when enabled | none |
| `TWILIO_AUTH_TOKEN` | yes when enabled | none |
| `TWILIO_FROM_NUMBER` or `TWILIO_MESSAGING_SERVICE_SID` | one of the two | none |
| `SMS_CODE_TTL_SEC` | no | `600` |
| `SMS_CODE_MAX_ATTEMPTS` | no | `5` |
| `SMS_ACTION_JWT_MAX_AGE_SEC` | no | `300` |
| `SMS_MAX_DIDS_PER_PHONE` | no | `5` |
| `SMS_ALERT_SEARCH_INTERVAL_MS` | no | `300000` |
| `SMS_REQUIRE_ACTION_CLAIM` | no | `false` until phase 12, `true` after |
| `SMS_DEV_ECHO_CODE` | no | `false`; honored only under `NODE_ENV=test-local` |
Rotating `SMS_CODE_SECRET` invalidates every pending code and orphans every
stored `phone_hash`. Rotate between deploys, not casually.
## Files
Placement follows the layer each file belongs to, not the feature it serves.
`src/` is organized by role — `routes`, `middleware`, `db`, `services`, `util`,
`models`, `vc` — and `src/alertSearch` is the one pipeline folder, which the SMS
delivery path joins rather than duplicates. No `src/sms` directory.
New:
| File | Why here |
|---|---|
| `src/routes/notifySms.ts` | HTTP surface, beside `notifications.ts` |
| `src/middleware/smsActionJwt.ts` | Request-chain stage, beside `auth.ts` |
| `src/db/smsRegistrationsSqlite.ts` | Table access, beside `fcmTokensSqlite.ts` |
| `src/db/smsPhoneLogSqlite.ts` | Table access |
| `src/db/smsAlertAuthorizationSqlite.ts` | Table access, mirrors `alertAuthorizationSqlite.ts` |
| `src/services/smsService.ts` | Outbound transport with credentials, the `SmsSender` port and its Twilio adapter — the SMS counterpart of `firebase.ts` plus `pushService.ts` |
| `src/util/smsPhoneNumber.ts` | Pure string work: E.164 normalization, masking. Same shape as `maskToken.ts` |
| `src/util/smsVerificationCode.ts` | Pure crypto: mint, HMAC, timing-safe compare. Takes the secret as an argument so it stays testable; the caller reads `SMS_CODE_SECRET` from `env.ts` |
| `src/models/smsRegistration.ts` | Row-shape interfaces, beside `device.ts` |
| `src/alertSearch/smsNotify.ts` | Digest-to-message orchestration, the exact twin of `alertSearch/notify.ts` |
| `src/alertSearch/smsScheduler.ts` | Interval pass, the exact twin of `alertSearch/scheduler.ts` |
Tests for this work sit in `test/`, mirroring the `src/` layout
(`test/routes/notifySms.test.ts` for `src/routes/notifySms.ts`). The existing
`src/alertSearch/*.test.ts` files stay where they are.
The split between `services/smsService.ts` and `alertSearch/smsNotify.ts` copies
the split the FCM path already uses: `services/pushService.ts` knows how to send
one message and nothing about why, while `alertSearch/notify.ts` knows which
users deserve a message and what it should say. Twilio credentials never appear
above the services layer, and digest logic never appears below it.
Modified:
- `src/db/sqlite.ts` — five tables, their indexes, and `notify_hour_min_utc` in
place of `timezone` on both alert-authorization batch tables
- `src/db/alertAuthorizationSqlite.ts` — `notify_hour_min_utc` through the shared
store, `timezone` dropped, plus `deleteAllForUser`
- `src/services/alertAuthorization.ts` — notify-hour and UTC-day JWT validation,
and the clock helpers (`formatHourMinuteUtc`, `parseHourMinuteUtc`,
`storedNotifyLabel`, `utcCalendarDay`, `utcDayStartSeconds`, `utcHourMinute`)
- `src/alertSearch/daily.ts` — selects by UTC day and reports `utcDay`;
`InvalidAlertAuthorizationTimezoneError` removed
- `src/alertSearch/scheduler.ts` — set-based selection, bounded concurrency, and
the notify hour on the push channel
- `src/util/concurrency.ts` — the bounded pool both passes run users through
- `src/routes/notifications.ts` — the FCM twin of the revoke route
- `src/middleware/auth.ts` — carry the decoded payload on `req.auth`
- `src/types/express.d.ts` — the widened `auth` type
- `src/env.ts` — the SMS variables, read the same way `ENDORSER_URL` is
- `src/index.ts` — mount `/notify-sms`, add `DELETE` to CORS, start the SMS scheduler
- `src/alertSearch/cursors.ts`, `cycle.ts`, `daily.ts` — channel parameter
- `package.json` — test glob
- `README.md`, `.env.example`, `CHANGELOG.md`
## Phases
- [x] **1. Schema and db modules.** Five tables in `SCHEMA_SQL`;
`smsRegistrationsSqlite`, `smsPhoneLogSqlite`, `smsAlertAuthorizationSqlite`.
Tests: insert/read round trips, the `(user_id, phone_e164)` uniqueness, the
partial unique index on unused SMS JWTs.
- [x] **2. Phone and code utilities.** `util/smsPhoneNumber.ts` and
`util/smsVerificationCode.ts`: E.164 normalization, masking, code mint, HMAC,
timing-safe compare. Tests: normalization table including the
rejections, code is always six digits, compare does not short-circuit.
- [x] **3. Stop discarding the decoded payload.** `requireAuth` sets
`req.auth.payload` from the value `decodeAndVerifyJwt` already returns;
`src/types/express.d.ts` updated. No behavior change to existing routes.
- [x] **4. Twilio sender.** `SmsSender` port, Twilio adapter, console adapter,
unconfigured path. Tests use an injected sender; no test touches the
network.
- [x] **5. Phone routes.** GET / POST / PUT / DELETE wired to stages 12,
mounted in `index.ts`, `DELETE` added to CORS. Enforce
`SMS_MAX_DIDS_PER_PHONE` on both POST and PUT, counting verified rows.
Tests: happy path, wrong code, expired code, attempt exhaustion, re-POST
on a verified number sends nothing, DELETE nulls `phone_e164` in the log
but keeps `phone_hash`, the sixth DID is refused at PUT even when its POST
was accepted under the limit, and unverified rows from other DIDs do not
count toward it. Disclosure tests carry their own weight: a POST rejected
at the limit returns **no** `dids`, a PUT rejected at the limit returns
them, a GET with `?phoneNumber=` returns them only to a DID verified on
that number, and the code is consumed either way so a second PUT with the
same code cannot re-ask.
- [x] **6. SMS alert-authorization route.** POST plus the PUT alias, reusing
`validateAlertAuthorizationBatch`, requiring a verified phone, storing to
the SMS tables. Tests: batch of 100 stored, second batch replaces unused
rows and leaves consumed ones, `409` with no verified phone.
- [x] **7. Per-channel cursors and daily run.** Channel parameter through
`cursors.ts`, `cycle.ts`, `daily.ts`, defaulting to `"fcm"`. Tests: an SMS
run advances only `sms_alert_search_cursors`; the FCM suite passes
unchanged.
- [x] **8. Delivery and scheduler.** `alertSearch/smsNotify.ts` and
`alertSearch/smsScheduler.ts`, started in `index.ts`. Tests: eligibility
predicate, verified-only recipients, one send per number, failures logged
without rolling back consumption, second tick the same day sends nothing.
- [x] **9. Opt-out and caps.** `POST /notify-sms/inbound` for Twilio's webhook:
`STOP` / `UNSUBSCRIBE` marks every registration for that number
`verified = 0` and logs `opt-out`; `START` requires a fresh POST + code;
`HELP` returns a fixed reply. Enforce the per-DID daily send cap. The
webhook authenticates by Twilio's `X-Twilio-Signature`, not by JWT — it is
Twilio calling, not a user.
- [x] **10. Docs.** README sections for the endpoints, the action claim, the SMS
tables, and the environment table; `.env.example`; `CHANGELOG.md`.
- [x] **11. `requireSmsActionJwt`.** The action-authorization stage, built last.
Every check in the stage-3 table, each with its own code, plus the replay
insert and the `sms_action_jwt_use` table. Add the middleware to all five
routes. Tests: one per failure mode, plus the same token rejected on second
use.
- [x] **12. Wrap-up.**
- [x] Prefix **every** line this service prints with an ISO-8601 UTC timestamp.
A `src/util/log.ts` wrapper (`log.info` / `log.error`) that prepends
`new Date().toISOString()` and forwards to `console`, applied to every
existing `console.log` / `console.error` call site in `src/` — routes,
middleware, services, db modules, and all three schedulers. A log line without a timestamp cannot answer "when", which is
the only question anyone asks a log at 3am.
- [x] Flip `SMS_REQUIRE_ACTION_CLAIM` to default `true` and record the flip in
`CHANGELOG.md`. Phase 11 is what makes that default safe.
- [ ] Turn `SMS_ENABLED` on in the deployment environment, with Twilio
credentials, `SMS_CODE_SECRET`, and completed A2P 10DLC registration all
in place.
- [ ] End-to-end pass against a real handset: register, verify, authorize a
batch, receive one daily digest text, `STOP`, confirm no further sends.
- [ ] Confirm `sms_phone_log` holds one row per action taken during that pass,
and that DELETE nulled `phone_e164` while leaving `phone_hash`.
- [ ] Confirm `SMS_DEV_ECHO_CODE` is unset (or `false`) and `NODE_ENV` is not
`test-local` in the deployed environment.
- [ ] Prune-job follow-up filed for `sms_phone_log` retention and
`sms_action_jwt_use` rows.
- [x] **13. Notification hour and revocation.** `notify_hour_min_utc` on both batch
tables and through `validateAlertAuthorizationBatch`; `isNotifyTimeReached`
gating the SMS pass ahead of the search; `deleteAllForUser` on the shared
store behind `DELETE /notify-sms/alert-authorization` (action
`revoke-alert-search`) and `DELETE /notifications/alert-authorization`.
Tests: the hour/minute validation and its ranges; a pass
that defers before the hour, runs after it, and closes again when the UTC
day rolls; a batch with no hour running unchanged; a failing due-check
counted as failed rather than deferred; the round trip of a stored hour;
deletion of consumed and unused rows together while the other channel and
the other DIDs are untouched; the verified phone surviving a revocation;
and the wrong action claim refused.
- [x] **14. Drop the batch timezone.** `timezone` out of the request body, both
batch tables, the store record, and the response. `day` becomes a UTC
calendar day, each JWT must cover the whole of the day it names, and
`runDailyAlertSearch` selects by UTC day and reports `utcDay`. Tests: the
UTC-day selector including the rollover at midnight, a real-date check
that refuses `2026-02-30` rather than rolling it into March, and a
partial-window JWT refused at upload.
- [ ] End-to-end pass of the hour against a real handset: authorize with a
UTC hour an hour out, confirm no text before it and one after, then
`DELETE` and confirm the next day is silent.
- [ ] Confirm the app mints its 100 JWTs on UTC-day boundaries. The batch is
refused otherwise, so this is the one client change this phase forces.
- [x] **15. Set-based selection, bounded concurrency, and the hour on FCM.**
`listPendingForDay` on the shared store replaces per-user `getLatestBatch`
polling in both passes; `forEachWithConcurrency` bounds the per-user work;
the notify hour and `timezone` are accepted on the FCM route and both
schedulers gate on the stored hour; the hour is required on both channels,
so every batch states its own. `isNotifyTimeReached` is deleted rather
than left beside the SQL that now decides the same question. Tests: the
query's day filter, spent-user exclusion, hour boundary, newest-batch
selection, channel isolation, and the unreadable-value fail-open; the pool's
limit, ordering at a limit of one, and error propagation; both schedulers
deferring and then running a user as the hour passes.
## Tests
`package.json` runs `tsx --test src/alertSearch/*.test.ts`. That glob will not
pick up a single SMS test. Change it in phase 1, before there is anything to
miss, to cover both trees:
`tsx --test "test/**/*.test.ts" "src/**/*.test.ts"`.
`tsconfig.json` excludes `src/**/*.test.ts`, so no test is type-checked by the
build. `tsconfig.test.json` covers `src` and `test` together with `noEmit`, and
`pnpm run typecheck` runs it. The build config keeps `rootDir: "src"`, so no
test code reaches `dist`.
Route tests inject the `SmsSender` and the db modules the way
`deliverAlertSearchNotification` already accepts `listTokens` / `send`. No test
sends a real message.
## Operational notes
- **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. *Aside: the message
reports success, the carrier drops it, and everyone is happy except the person
who never got the text — the telecom industry's version of a participation
trophy.*
- **`STOP` handling is mandatory**, not a nicety. Twilio auto-replies and blocks
the number at its end; phase 10 keeps this service's own state in agreement so
it stops burning sends on a blocked number.
- **Every send costs money.** The per-day cap is enforced by JWT consumption,
the same mechanism the FCM path uses, plus an explicit per-DID daily counter
read from `sms_phone_log` as a backstop.
- **`sms_phone_log` holds phone numbers**, which the FCM tables never did. It is
the first table in this database with directly identifying personal data.
Retention: `phone_e164` is nulled on delete, and a prune job for log rows older
than 400 days belongs in a follow-up.
- **Single replica.** The SMS scheduler's in-flight guard is process-local,
exactly like the other two. A second replica double-texts.
## Rejected
**One shared JWT inventory across channels.** `/notify-sms/alert-authorization`
would write into `alert_authorization_*`, one daily retrieval per user would
build one digest, and delivery would fan out to FCM tokens and verified phones.
Half the Endorser traffic, half the minted JWTs, one cursor, and both channels
always agreeing. Rejected in favor of separate SMS tables so the SMS channel's
credentials, cursor, and failures are fully independent of the push channel's —
at the cost of the duplicate retrieval documented in §Cost of two inventories.
**Separate SMS tables sharing `alert_search_cursors`.** Cheapest to build and
silently wrong: two independent daily runs against one per-DID cursor row means
whichever fires first consumes the delta and the other reports nothing.
**Storing verification codes in plaintext.** A database file that leaks would
hand over live codes for every pending registration. HMAC costs one function
call.
**Trusting `req.did` alone in stage 3.** The DID says who is calling, not what
they authorized. Without the claim check, any valid identity JWT for that DID —
including one minted for an unrelated purpose and captured — deletes a phone
registration.
**The `twilio` SDK.** One form POST does not justify a dependency tree.
**Gating the notification hour at the send instead of the search.** The natural
reading of "send at 18:00" is a check inside `deliverAlertSearchSms`. It would
never fire: the daily run consumes that local day's JWT, and eligibility depends
on the run that consumed it, so the midnight tick would spend the day's
credential and 18:00 would find `digest: null`. Holding the whole pass costs one
indexed batch lookup per user per tick.
**Carrying the hour inside the delegated JWTs.** They are the alertSearch
credential; `nbf`/`exp` bound a whole local day. Narrowing them to an hour would
narrow when the search may run against Endorser, not when the user hears about
it, and would make the hour unchangeable without re-minting all 100.
**A window rather than a floor.** "Send between 18:00 and 19:00" would leave a
user silent for the day whenever the service was down across that hour, which is
the failure the daily digest exists to avoid. The hour is a floor, and a late
text beats none.
**An ISO-8601 `notifyTime` carrying its own `±HH:MM` offset.** It was
self-describing — `"18:00-06:00"` cannot be misread as a wall clock — which is
the property a bare pair of integers lacks. Rejected once the fields were named
`notifyHourUtc` and `notifyMinuteUtc`: a name that states the frame carries it
as surely as an offset does, and unlike an offset it cannot disagree with the
value beside it. Two integers also validate by range rather than by a 16-line
regex that had to accept basic and extended offsets, optional seconds, optional
fractions, and an optional leading date that changed nothing.
**Deleting phone registrations along with the authorization.** Turning alerts
off would then cost a fresh 6-digit round trip to turn them back on. The two are
separate routes because they are separate decisions.
**Scheduling from an IANA `timezone` instead of a stored UTC instant.** The zone
is the only value that tracks a local hour across a daylight-saving transition,
so scheduling from it would make the notification hour survive one. Rejected for
now on the strength of the drift being corrected by the next batch upload, which
the 100-day inventory forces anyway. The zone is accepted and stored regardless,
unread, so that whichever mechanism wins has the value it needs.
Not a reason: cost. Resolving a user's local wall clock with an
`Intl.DateTimeFormat` cached per zone measures ~1.4µs, against ~2.8µs for the
per-user SQLite lookup the pass already performs — the zone math is half the
price of a query the scheduler pays today, and 100k users cost ~137ms once per
tick. The 34µs/user figure that makes it look expensive comes from constructing
the formatter inside the loop; a `Map` keyed by zone removes it, and a
deployment sees a handful of distinct zones rather than the full IANA set.
**A weekend job that shifts stored UTC times for zones whose DST changed.** It
would keep the indexed `WHERE notify_hour_min_utc <= ?` shape while staying correct
across transitions. Weighed against a stored *next-firing instant* that each
send recomputes from the zone: the job has to run forever or users drift
silently for up to six months; it consults `Intl` per zone anyway, so it is the
same computation merely batched and delayed; and it has to be driven by the
rules rather than a calendar, because Lord Howe shifts 30 minutes, the southern
hemisphere runs opposite, and legislatures abolish DST on short notice (Mexico,
2022). The next-firing-instant design does its maintenance at the moment each
row is used, which is when the answer is needed and when it is cheapest to be
sure of. Neither is built; the column that either would need is.
**Keeping local calendar days with the timezone removed.** There would be no
frame left to resolve them in: `day` would be a label the server could not
check, and the daily run would have no way to know which label meant today. UTC
days give both the check and the selector one frame, at the cost of a client
that must mint its windows on UTC midnights.
+35
View File
@@ -0,0 +1,35 @@
# ---- build stage: install everything, type-check + compile to JS ----
FROM node:22-alpine AS build
RUN apk add --no-cache python3 make g++
RUN corepack enable && corepack prepare pnpm@11.4.0 --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
COPY tsconfig.json ./
COPY src ./src
RUN pnpm build
# ---- runtime stage: prod deps + compiled JS only, no tsx/esbuild ----
FROM node:22-alpine AS runtime
RUN apk add --no-cache python3 make g++
RUN corepack enable && corepack prepare pnpm@11.4.0 --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --prod --frozen-lockfile
COPY --from=build /app/dist ./dist
ENV NODE_ENV=production
ENV PORT=3003
EXPOSE 3003
CMD ["node", "dist/index.js"]
+789
View File
@@ -0,0 +1,789 @@
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
```bash
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`.
```bash
pnpm install
pnpm run dev
```
```bash
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. Registration and refresh continue only if Endorser accepts the JWT.
`POST /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
`POST /notifications/alert-authorization`
```
Authorization: Bearer <current-user-JWT>
```
```json
{
"batchId": "client-batch-id",
"notifyHourUtc": 18,
"notifyMinuteUtc": 30,
"timezone": "America/Denver",
"jwts": [
{
"sequence": 0,
"day": "2026-08-27",
"nbf": 1756252800,
"exp": 1756339200,
"jwt": "eyJ..."
}
]
}
```
`day` is a UTC calendar day, and each JWT must be valid for the whole of the day it names: `nbf` at or before midnight UTC that opens it, `exp` at or after midnight UTC that closes it. The daily run selects by UTC day and may fire at any moment inside it, catch-up runs included, so a window covering only part of that day would hand Endorser a credential outside its own validity period. Days must be distinct and the 100 `sequence` values consecutive.
`notifyHourUtc` and `notifyMinuteUtc` are **required** and `timezone` is optional; all three behave exactly as on [the SMS twin](#endpoints). Both channels gate on the stored hour ([Notification hour](#notification-hour)).
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`.
### Removing an authorization
`DELETE /notifications/alert-authorization`
Removes every batch and every JWT this DID holds for the push channel, consumed rows included, so the alertSearch scheduler stops listing the identity. It answers `{ success: true, deletedBatches, deletedJwts }`, with zeros when there was nothing stored. Device registrations and `WAKEUP_PING` are untouched, and so are the alertSearch cursors: a later re-authorization resumes where this one stopped rather than replaying months of history.
### 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?)` picks the unused delegated JWT for the current **UTC** day, 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. The result reports the day it used as `utcDay`. 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. One query per pass asks for the users who hold an unused JWT for the current UTC day, flagged by whether their `notify_hour_min_utc` has arrived ([Scheduler selection](#scheduler-selection)); it calls `runDailyAlertSearch` on the due ones, up to `ALERT_SEARCH_USER_CONCURRENCY` at a time. 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 UTC 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 UTC 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 |
| DELETE | `/notify-sms/alert-authorization` | Remove every SMS batch and JWT for the DID, turning the channel off |
| 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 FCM twin's body plus a
required `notifyHourUtc` (integer 0-23) and `notifyMinuteUtc` (integer 0-59):
the hour the user wants to hear from the service. **Both are UTC**, which the
field names carry, so no offset and no zone travel with them and there is
nothing for the server to misread. A client sends `date.getUTCHours()` and
`date.getUTCMinutes()` with no formatting step.
Both are required, and one without the other is refused naming the missing
field, so every batch states its own hour rather than inheriting one
([Notification hour](#notification-hour)). Out of range, fractional, or the
wrong type is rejected with the rest of the batch. The response echoes both
integers; the server composes the zero-padded `HH:MM` it stores.
An optional `timezone` (an IANA name such as `"America/Denver"`) is validated
and stored beside them. **Nothing reads it**: the two UTC integers are what
schedule a send. It is recorded against the day something needs to re-derive
that hour across a DST change — see [Notification
hour](#notification-hour). A batch may carry the hour without it, but not the
reverse. A name `Intl` cannot resolve is rejected with the rest of the
batch, because a zone this service cannot resolve would be worth nothing to
whatever reads the column later.
The route 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 `POST` and the semantics are replace-not-append
either way.
`DELETE /notify-sms/alert-authorization` is how a user turns texts off. It
removes every SMS batch and JWT for the DID, used or not, so the SMS scheduler
stops listing that identity, and answers `{ success: true, deletedBatches,
deletedJwts }` — zeros when there was nothing stored. Registered phone numbers
survive: silencing alerts is not a request to redo the possession check later.
`DELETE /notify-sms/phone` is the route that forgets a number, and `STOP` is the
route that blocks one. The revocation is recorded in `sms_phone_log` as
`alert-authorization-deleted`; for a DID whose handset is already gone, the
`phone_hash` on that row stands in for the number the column expects.
### Action claim
Every `/notify-sms` call is authorized by a Bearer JWT whose claim names the
action and the phone number it applies to:
```json
{
"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`, `revoke-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.
`authorize-alert-search` and `revoke-alert-search` act on the DID's whole
inventory rather than on one handset, so they bind to no number. 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.
### Blocking a number
`sms_blocked_numbers` is the service's suppression list. A number on it is
refused at registration (`403 SMS_PHONE_BLOCKED`), refused at verification even
with a correct code, and skipped by the daily digest. Deleting a registration
still works — removing yourself is always allowed.
#### Who blocked a number
The `reason` column records the origin, and nothing ever downgrades it:
| `reason` | Origin |
|---|---|
| `opt-out` | The handset texted `STOP` to this service |
| `provider-opt-out` | Twilio refused a send with `21610`, so the number is on its own opt-out list — it opted out somewhere this service did not observe |
| `manual` | An operator added it |
A re-block keeps whichever reason carries the stronger claim
(`opt-out` > `provider-opt-out` > `manual`). An operator re-blocking a number
that had already opted out must not erase the opt-out, because that record is
what says the block may not simply be lifted again.
`pnpm run sms:blocks` lists the table grouped by origin, `sms:blocks list manual`
filters to one, and `sms:blocks block <number> "<why>"` / `sms:blocks unblock
<number>` manage entries without hand-written SQL — which also keeps `reason`
honest, since a manual `INSERT` can claim any origin it likes. Unblocking
anything other than a `manual` entry requires `--force`.
```bash
SMS_CODE_SECRET=... pnpm run sms:blocks list
```
Two sources are consulted. The table is written by the `STOP` webhook, and
`SMS_BLOCKED_NUMBERS` in the environment blocks a number by configuration without waiting for the
handset to ask:
```bash
SMS_BLOCKED_NUMBERS=+15555550123,+15555550124
```
Entries are normalized before comparison, so formatting cannot defeat them.
Rows are keyed on `phone_hash`, so a block outlives the `DELETE` that nulls
`phone_e164` elsewhere. This table does retain the number itself: a suppression
list nobody can read is one nobody can audit or correct, and honoring an
opt-out means remembering who asked.
`STOP` blocks as well as unverifying. Unverifying alone left the number free to
register again minutes later and resume queueing messages, which Twilio then
refuses with `21610` — the service would retry forever on behalf of someone who
asked to be left alone. `START` lifts the block, matching Twilio, which clears
its own opt-out entry on the same keyword. It does not restore verification:
possession was proved by a code and that code is gone, so a fresh POST and a
fresh code are the way back.
Twilio keeps its own opt-out list regardless, and it is the authority for
compliance. A send refused with `21610` is Twilio saying the number is on it, so
both send paths record that as a `provider-opt-out` block and stop retrying.
Twilio can also report `21610` asynchronously; catching those needs the
`StatusCallback` handling this service does not yet do, so synchronous refusals
are what get captured today.
An ordinary send failure never blocks — only `21610` does, so a carrier blip
stays retryable.
### Restricting who a server may text
`SMS_ALLOWED_RECIPIENT_DIDS` is a comma-separated list of the only DIDs an
instance may send to. Unset — the production case — there is no restriction.
```bash
SMS_ALLOWED_RECIPIENT_DIDS=did:ethr:0xabc,did:ethr:0xdef
```
It guards both paths that spend money: the verification code on
`POST /notify-sms/phone`, which returns `403 SMS_RECIPIENT_NOT_ALLOWED` and
registers nothing, and the daily digest, which is withheld before any send.
Either way an `recipient-not-allowed` row lands in `sms_phone_log`, one per
number, so the log records exactly what was held back. Matching is
case-insensitive, since a checksummed `did:ethr` address and its lowercase form
name one identity.
Set but **empty** blocks every recipient rather than allowing all of them. An
operator who sets the variable meant to restrict something, so the blank case
fails closed.
This exists because the dangerous configuration is a test server holding live
Twilio credentials and a copy of the production database: every
`sms_registrations` row with `verified = 1` is a real handset, and the scheduler
texts all of them daily. The allowlist turns that from an incident into a log
line. `SMS_ENABLED=false` remains the blunter switch — it returns `503` from
every route and stops the SMS scheduler from starting at all.
### Scheduler selection
Both alertSearch passes choose their users with one query, not one query per
user. `listPendingForDay({ day, hourMinute })` returns every user holding an
unused JWT for that UTC day, each flagged `due` by whether their batch's
`notify_hour_min_utc` has arrived:
- A user who has already run today holds no unused JWT for it and does not
appear at all, so the ~287 ticks a day that have nothing to do for them cost
nothing.
- A user whose hour has not arrived appears with `due: false` and is counted in
`deferred`, which is what keeps that number in the log line. Every batch has an
hour, so this applies to every user rather than to a subset.
- The batch consulted is the newest one per user, picked with a window function.
A user accumulates batch rows, because a batch with a consumed JWT survives the
next upload, so "the user's notify time" is not a plain join.
- `HH:MM` is zero-padded, so the text comparison SQLite performs is
chronological.
Due users are then worked on `ALERT_SEARCH_USER_CONCURRENCY` at a time
(`src/util/concurrency.ts`, currently 8). The per-user work is two external
round trips against that user's own cursors, so a serial loop spends the pass
waiting: at 200ms per user it stops fitting inside a five-minute tick at roughly
1,500 users, after which the in-flight guard skips passes and users miss days.
The bound is held low deliberately — the ceiling it relieves is latency, and the
two APIs on the other end are shared infrastructure that a wide fan-out would
only move the queue into.
Measured on this codebase at 2000 users, one tick: a pass where every user has
already run costs 0 queries and ~1ms (it was 3 queries per user and ~141ms when
the scheduler asked per user), and a pass where every user is due drops from
~106s to ~13s at 50ms of API latency.
### 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
selects its users with the one query described in [Scheduler
selection](#scheduler-selection), calls `runDailyAlertSearch(userId, now, {},
"sms")` on the due ones with a bounded number in flight, and then
`deliverAlertSearchSms`. It logs `[SmsAlertSearchScheduler] Pass started` /
`Pass completed in`, the latter with `attempted`, `deferred`, and `failed`
counts. 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 UTC 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 UTC calendar 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.
### Notification hour
Every batch on either channel carries a UTC hour and minute. The server stores
them zero-padded as one `HH:MM` value in `notify_hour_min_utc`, which is the
form SQLite compares chronologically. Both schedulers hold a
user's whole daily run until that UTC time arrives, and count the held users as
`deferred` in the pass result. The decision is made in SQL, not per user — see
[Scheduler selection](#scheduler-selection).
The field is required rather than optional because the alternative default is
not "no gate" but "the first tick after midnight UTC" — a user's JWT for a new
UTC day becomes selectable at that moment, so every user without an hour runs in
the same five-minute window. Making each batch name its own hour spreads that
load as a side effect of asking the question.
The gate sits ahead of the search rather than ahead of the text. Running the
search consumes that UTC day's JWT, and a digest is sent only for the run that
consumed it, so a search that ran at the top of the day would leave nothing to
send at the chosen hour.
The hour lives on the batch rather than inside the delegated JWTs. Those are the
alertSearch credential, and their `nbf`/`exp` bound a whole UTC day; narrowing
them to an hour would narrow when the search may run against Endorser, not when
the user hears about it.
**A stored UTC time does not follow the user through a daylight-saving change.**
Someone in Denver who wants 18:00 local sends `00:30` UTC in summer, and when
their region returns to `-07:00` that same UTC instant reads 17:00 on their wall
clock. The correction available today is a fresh batch carrying the new UTC
hour — which a
client uploads roughly every 100 days anyway, since that is how long an
inventory lasts.
The optional `timezone` on the batch exists for a mechanism that would close
that gap without waiting for the next upload, by re-deriving `notify_hour_min_utc`
from the zone's current rules. No such mechanism runs: the column is recorded
and unread, and the schema says so in a comment SQLite keeps, so `.schema` shows
the reason next to the column. Which mechanism it should be is open — a job that
sweeps changed zones, or a stored next-firing instant recomputed each time a
user fires, which needs no scheduled job at all.
There is no upper bound within the day. A service that was down at the chosen
hour and comes back six hours later still runs that UTC day; a silent day is the
worse failure. The text lands on the first tick at or after the hour, so within
one `SMS_ALERT_SEARCH_INTERVAL_MS` in the ordinary case. An hour late in the UTC
day leaves a correspondingly short window before the day key rolls and that
day's JWT is skipped.
A row whose `notify_hour_min_utc` is NULL or not `HH:MM` is treated as due: a value
nobody can read must not silence a channel the user asked for. The selection
query enforces that with a `GLOB` guard, because text ordering alone would rank
an unreadable value above every real `HH:MM` and defer such a user permanently
rather than once. The route cannot produce either state — only a write that
bypasses it can.
### Testing SMS locally
Two scripts cover the two things worth checking separately. Both default to
fake data and neither needs a real handset, a purchased number, or 10DLC
registration.
`pnpm run sms:send [to] [body]` makes one send and prints the result. No server,
no database, no auth — just the Twilio path. The destination can also come from
`SMS_SEND_TO`.
```bash
TWILIO_ACCOUNT_SID=ACxxxx TWILIO_AUTH_TOKEN=xxxx \
TWILIO_MESSAGING_SERVICE_SID=MGxxxx \
pnpm run sms:send +15551234567 "test from my Mac"
```
`pnpm run sms:smoke [to]` runs the whole route flow: POST, PUT with the echoed
code, both GET forms, DELETE, then a dump of `sms_phone_log`. The destination
can also come from `SMS_SMOKE_TO`. It stubs the two things
that otherwise need the real world — it starts a throwaway Endorser that answers
`/api/report/rateLimits` with `200`, and mints unsigned `did:ethr` JWTs, which
`decodeAndVerifyJwt` accepts under `NODE_ENV=test-local` without checking a
signature. The middleware chain, the claim check, the throttles and the database
are all real. Each run gets a fresh `NOTIFY_DATA_DIR`, so the three-codes-per-
hour throttle never interferes.
With no Twilio credentials set, sends go to the console adapter and nothing
leaves the machine.
`pnpm run twilio:whoami` answers "whose account am I about to bill?" — it
fetches the Account resource with the configured SID and token, which separates
a mismatched credential pair from a working one before any message is involved.
It sends nothing and costs nothing.
```bash
TWILIO_ACCOUNT_SID=ACxxxx TWILIO_AUTH_TOKEN=xxxx pnpm run twilio:whoami
```
`200` prints the account's friendly name, status and type, and that is the
account a send would bill — note `type: Trial` can only reach verified numbers.
`401` / `20003` means the SID and token are not a matching pair. `403` / `20008`
("Resource not accessible with Test Account Credentials") means the pair is a
valid **test** pair: test credentials may not read the Accounts resource, so
that refusal is a pass, not a fault.
**Twilio test credentials** are the cheapest way to exercise the real API, and
they behave the same whether or not a 10DLC campaign is approved — nothing they
send reaches a carrier. They are a **separate Account SID and Auth Token** from
the live pair, under Console → API keys & tokens → Test credentials; a live SID
with a live token sends real, billable messages. They
are a second Account SID / Auth Token pair in the Twilio console, separate from
the live ones; they need a (free) account but no purchased number, they deliver
no message, they trigger no status callbacks, and they cost nothing.
```bash
TWILIO_ACCOUNT_SID=ACxxxxtest TWILIO_AUTH_TOKEN=xxxx \
TWILIO_FROM_NUMBER=+15005550006 pnpm run sms:smoke +15551234567
```
`+15005550006` is the only `From` that passes validation; every other number
returns `21606`.
The `To` is validated even under test credentials, so a reserved fictional
number such as `+15555550123` is rejected with `21211`. Both scripts refuse to
run with that placeholder once Twilio is configured, rather than spending a
round trip to learn it.
The destination is normalized before anything is sent, and both scripts echo
the result — check that line first when Twilio rejects a number. Ten digits with
no `+` are assumed US, so `8015601471`, `801-560-1471` and `+18015601471` all
reach the same place. A `+` prefix is taken at its word: `+8015601471` is
syntactically valid E.164 with country code `80`, so it passes normalization and
is rejected by Twilio rather than here.
A **real** `To` under test credentials is less predictable: some accounts accept
it and return a synthetic SID, others answer `20404`
(`resource ... Messages.json was not found`) despite the credentials being
valid. Treat the magic `To` numbers below as the dependable path for test
credentials, and use live credentials when a text has to actually arrive.
These magic `To` numbers force specific failures, useful for exercising the
`code-send-failed` path on purpose:
| `To` | Twilio error |
|---|---|
| `+15005550001` | `21211` invalid number |
| `+15005550002` | `21612` cannot route |
| `+15005550003` | `21408` no permission for that region |
| `+15005550004` | `21610` blocklisted |
| `+15005550009` | `21614` not SMS-capable |
Even with deliberately wrong credentials the round trip is worth running once:
Twilio answers `Authentication Error - invalid username`, which proves the URL,
the Basic auth header, the form encoding and the response parsing all work and
only the credentials are missing.
Sending to a real handset needs a real (trial or paid) account, a real `From`
number, and — for a US long code — completed A2P 10DLC registration.
### 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.
A registered campaign lives on a Messaging Service, and every number in that
service's sender pool inherits the campaign — including numbers added later.
**Set `TWILIO_MESSAGING_SERVICE_SID` rather than `TWILIO_FROM_NUMBER`** once a
campaign is approved. Both deliver, since the pool carries the registration
either way, but a bare `From` leaves the Messaging Service off the message
record in Twilio's logs and Insights, and it makes it possible to point at a
number outside the pool and quietly send unregistered traffic. The Messaging
Service also picks the sender for each destination. When both variables are set
the Messaging Service wins and `TWILIO_FROM_NUMBER` is ignored.
A `sent` status means Twilio accepted the message, not that a handset received
it. This service records `alert-sent` on that acceptance and does not register a
`StatusCallback`, so `delivered` / `undelivered` / `failed` outcomes are not
tracked. That is a gap to close if delivery receipts matter.
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`, `notify_hour_min_utc` (zero-padded `HH:MM` UTC, nullable), `timezone` (IANA name, nullable, recorded but unread), `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-authorization-deleted`, `alert-sent`,
`alert-send-failed`,
`recipient-not-allowed`, `number-blocked`, `number-unblocked`, 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_blocked_numbers` is the suppression list: `phone_hash` (unique, the
matching key), `phone_e164`, `reason` (`opt-out` / `provider-opt-out` / `manual`), `detail`, and
timestamps. It is the one table that deliberately retains a number after the
owner has asked to be left alone, because honoring that request means
remembering it.
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_MESSAGING_SERVICE_SID` **or** `TWILIO_FROM_NUMBER` | One of the two required to send; prefer the Messaging Service, which wins when both are set | 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 a false value in production | `true` |
| `SMS_ALLOWED_RECIPIENT_DIDS` | Leave **unset** in production; on a test server, set it to the DIDs that server may text | Unset (no restriction) |
| `SMS_BLOCKED_NUMBERS` | Optional; numbers blocked by configuration, on top of the `sms_blocked_numbers` table | Empty |
| `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` |
Boolean flags (`SMS_ENABLED`, `SMS_REQUIRE_ACTION_CLAIM`, `SMS_DEV_ECHO_CODE`, `DEBUG_ENDPOINT`) accept `true`/`1`/`yes`/`on` and `false`/`0`/`no`/`off`, case-insensitive, with surrounding whitespace ignored. Any other value, including a quoted one like `"true"` (which `docker run --env-file` passes through with its quotes), leaves the flag at its default.
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)
```bash
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.
+2440 -138
View File
File diff suppressed because it is too large Load Diff
+25 -7
View File
@@ -1,20 +1,38 @@
{
"name": "notification-wakeup-service",
"version": "0.1.0",
"version": "0.2.2-beta",
"private": true,
"type": "module",
"packageManager": "pnpm@11.4.0",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"build": "tsc"
"build": "tsc",
"test": "tsx --test \"test/**/*.test.ts\" \"src/**/*.test.ts\"",
"typecheck": "tsc --noEmit -p tsconfig.test.json",
"sms:send": "tsx scripts/sms-send.ts",
"sms:smoke": "tsx scripts/sms-smoke.ts",
"twilio:whoami": "tsx scripts/twilio-whoami.ts",
"sms:blocks": "tsx scripts/sms-blocks.ts"
},
"dependencies": {
"express": "^5.1.0"
"@peculiar/asn1-ecc": "^2.7.0",
"@peculiar/asn1-schema": "^2.7.0",
"better-sqlite3": "^13.0.1",
"cbor-x": "^1.6.4",
"cors": "^2.8.6",
"did-jwt": "^7.4.7",
"did-resolver": "^4.1.0",
"dotenv": "^16.6.1",
"express": "^5.2.1",
"firebase-admin": "^13.10.0"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.10.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/node": "^22.19.19",
"tsx": "^4.22.3",
"typescript": "^5.9.3"
}
}
+2
View File
@@ -0,0 +1,2 @@
dependencies:
pnpm.io: ^11
+2560
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
strictDepBuilds: false
onlyBuiltDependencies:
- "@firebase/util"
- better-sqlite3
- cbor-extract
- esbuild
- protobufjs
allowBuilds:
'@firebase/util': set this to true or false
better-sqlite3: set this to true or false
cbor-extract: set this to true or false
esbuild: set this to true or false
protobufjs: set this to true or false
+47
View File
@@ -0,0 +1,47 @@
/**
* Choosing the destination number for the manual scripts.
*
* 555-01xx numbers are reserved as fictional and do not exist, so they are a
* fine placeholder for the console adapter and a guaranteed failure against the
* real API: Twilio validates the destination even under test credentials and
* answers "The 'To' number ... is not a valid phone number."
*/
import { missingTwilioConfig } from "../src/services/smsService.js";
import { normalizePhoneNumber } from "../src/util/smsPhoneNumber.js";
export const FICTIONAL_PLACEHOLDER = "+15555550123";
export function twilioIsConfigured(): boolean {
return missingTwilioConfig().length === 0;
}
/**
* Resolves the destination, or explains why the placeholder cannot be used.
* Exits rather than spending a round trip discovering it at the provider.
*/
export function resolveTarget(explicit: string | undefined, envVar: string): string {
const raw = explicit ?? process.env[envVar];
const normalized = normalizePhoneNumber(raw ?? FICTIONAL_PLACEHOLDER);
if (normalized === undefined) {
console.error(`Not a phone number: ${raw}`);
process.exit(1);
}
if (twilioIsConfigured() && normalized === FICTIONAL_PLACEHOLDER) {
console.error(
`Twilio is configured, so the destination has to be a number Twilio can\n` +
`validate. ${FICTIONAL_PLACEHOLDER} is a reserved fictional number and is\n` +
`rejected with error 21211, even under test credentials.\n\n` +
`Pass a real number instead — your own is the obvious choice:\n\n` +
` ${envVar}=+15551234567 pnpm run <script>\n\n` +
`Under test credentials nothing is delivered and nothing is charged, so\n` +
`using your own number here is safe. Under live credentials it is a real\n` +
`text. Test credentials have their own Account SID: Twilio Console ->\n` +
`API keys & tokens -> Test credentials.`
);
process.exit(1);
}
return normalized;
}
+119
View File
@@ -0,0 +1,119 @@
/**
* Inspect and manage the suppression list without hand-writing SQL.
*
* pkgx pnpm run sms:blocks list every block
* pkgx pnpm run sms:blocks list manual list one origin only
* pkgx pnpm run sms:blocks block +15551234567 "spam complaint"
* pkgx pnpm run sms:blocks unblock +15551234567
*
* Blocking here is always recorded as `manual`, and never downgrades an
* existing opt-out. Needs SMS_CODE_SECRET, since rows are keyed on the HMAC of
* the number.
*/
import {
smsBlockedNumbersDb,
type SmsBlockReason,
} from "../src/db/smsBlockedNumbersSqlite.js";
import { smsConfig } from "../src/env.js";
import { normalizePhoneNumber } from "../src/util/smsPhoneNumber.js";
import { hashPhoneNumber } from "../src/util/smsVerificationCode.js";
const REASONS: SmsBlockReason[] = ["opt-out", "provider-opt-out", "manual"];
const ORIGIN_NOTE: Record<SmsBlockReason, string> = {
"opt-out": "the handset texted STOP to this service",
"provider-opt-out": "Twilio refused a send with 21610",
manual: "an operator added it here",
};
const secret = smsConfig().codeSecret;
if (secret === undefined) {
console.error("Set SMS_CODE_SECRET; blocks are keyed on the HMAC of the number.");
process.exit(1);
}
function requireNumber(raw: string | undefined): string {
const normalized = normalizePhoneNumber(raw);
if (normalized === undefined) {
console.error(`Not a phone number: ${raw ?? "(missing)"}`);
process.exit(1);
}
return normalized;
}
const [command = "list", ...rest] = process.argv.slice(2);
if (command === "list") {
const filter = rest[0] as SmsBlockReason | undefined;
if (filter !== undefined && !REASONS.includes(filter)) {
console.error(`Unknown origin: ${filter}. One of: ${REASONS.join(", ")}`);
process.exit(1);
}
const rows = (await smsBlockedNumbersDb.list(1000)).filter(
(row) => filter === undefined || row.reason === filter
);
if (rows.length === 0) {
console.log(filter === undefined ? "No blocked numbers." : `No ${filter} blocks.`);
process.exit(0);
}
for (const reason of REASONS) {
const group = rows.filter((row) => row.reason === reason);
if (group.length === 0) continue;
console.log(`\n${reason} (${group.length}) — ${ORIGIN_NOTE[reason]}`);
for (const row of group) {
console.log(
` ${(row.phoneE164 ?? "(hash only)").padEnd(16)} ${row.createdAt}` +
(row.detail === undefined ? "" : ` ${row.detail}`)
);
}
}
console.log(`\n${rows.length} total.`);
process.exit(0);
}
if (command === "block") {
const phoneE164 = requireNumber(rest[0]);
const stored = await smsBlockedNumbersDb.block({
phoneHash: hashPhoneNumber(phoneE164, secret),
phoneE164,
reason: "manual",
detail: rest[1],
});
console.log(`Blocked ${phoneE164} — recorded as ${stored.reason}.`);
if (stored.reason !== "manual") {
console.log(
"It already carried a stronger origin, which is kept: an opt-out record\n" +
"is what says the block may not simply be lifted again."
);
}
process.exit(0);
}
if (command === "unblock") {
const phoneE164 = requireNumber(rest[0]);
const phoneHash = hashPhoneNumber(phoneE164, secret);
const existing = await smsBlockedNumbersDb.get(phoneHash);
if (existing === undefined) {
console.log(`${phoneE164} is not blocked.`);
process.exit(0);
}
if (existing.reason !== "manual" && rest[1] !== "--force") {
console.error(
`${phoneE164} is blocked as "${existing.reason}" — ${ORIGIN_NOTE[existing.reason]}.\n` +
"Lifting it would resume messaging someone who asked to stop, and Twilio\n" +
"will refuse the send anyway until they text START. Pass --force if you\n" +
"are certain this is a mistaken record."
);
process.exit(1);
}
await smsBlockedNumbersDb.unblock(phoneHash);
console.log(`Unblocked ${phoneE164} (was ${existing.reason}).`);
process.exit(0);
}
console.error(`Unknown command: ${command}. Use list, block, or unblock.`);
process.exit(1);
+38
View File
@@ -0,0 +1,38 @@
/**
* One Twilio send, nothing else. No server, no database, no auth.
*
* pkgx npx tsx scripts/sms-send.ts [toNumber] [body]
*
* With Twilio test credentials this is a real API round trip that delivers no
* message and costs nothing: From must be +15005550006, and the To has to be a
* number Twilio can validate — a real one, not a fictional 555-01xx. The
* +1500555000x magic numbers force specific failures.
*/
import { missingTwilioConfig, sendSms } from "../src/services/smsService.js";
import { resolveTarget } from "./smokeTarget.js";
const body = process.argv[3] ?? "Gift Economies: smoke test. Reply STOP to end.";
const missing = missingTwilioConfig();
if (missing.length > 0) {
console.error("Twilio is not configured. Missing: " + missing.join(", "));
console.error(
"\nAn account and a token are not enough; a send needs a sender too.\n" +
"With Twilio test credentials, use the magic From number:\n\n" +
" TWILIO_ACCOUNT_SID=ACxxxx TWILIO_AUTH_TOKEN=xxxx \\\n" +
" TWILIO_FROM_NUMBER=+15005550006 pnpm run sms:send " +
(process.argv[2] ?? "+15555550123") +
"\n\nWith a live 10DLC campaign, set TWILIO_MESSAGING_SERVICE_SID=MGxxxx instead."
);
process.exit(1);
}
const to = resolveTarget(process.argv[2], "SMS_SEND_TO");
console.log(`To: ${to}`);
console.log(`From: ${process.env.TWILIO_FROM_NUMBER ?? process.env.TWILIO_MESSAGING_SERVICE_SID ?? "(unset)"}`);
console.log(`Body: ${body} (${body.length} chars)`);
const result = await sendSms(to, body);
console.log("Result:", JSON.stringify(result));
process.exit(result.status === "sent" ? 0 : 1);
+210
View File
@@ -0,0 +1,210 @@
/**
* End-to-end /notify-sms smoke run against fake everything.
*
* pkgx npx tsx scripts/sms-smoke.ts
*
* pkgx npx tsx scripts/sms-smoke.ts +15551234567
*
* Stubs the two things that normally need the real world — Endorser and the
* signing key — and leaves the SMS path itself completely real. With Twilio
* test credentials in the environment it makes a genuine API round trip that
* sends no message and costs nothing; without them it prints to the console.
*/
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import express from "express";
const USER_DID = "did:ethr:0x0000000000000000000000000000000000c0ffee";
/** Resolved after the environment is set, since the guard reads Twilio config. */
let TO_NUMBER = "";
// A fresh database each run, so the 3-per-hour code throttle never bites.
process.env.NOTIFY_DATA_DIR = mkdtempSync(path.join(tmpdir(), "sms-smoke-"));
// Accepts unsigned did:ethr JWTs and unlocks devCode. Never set in production.
process.env.NODE_ENV = "test-local";
process.env.SMS_ENABLED = "true";
process.env.SMS_CODE_SECRET ??= "sms-smoke-secret";
process.env.SMS_DEV_ECHO_CODE = "true";
/** Answers the one call requireEndorserAuth makes. */
function startStubEndorser(): Promise<string> {
const server = createServer((req, res) => {
console.log(` [stub-endorser] ${req.method} ${req.url} -> 200`);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ doneClaimsThisWeek: 0, maxClaimsPerWeek: 100 }));
});
return new Promise((resolve) => {
server.listen(0, () => {
const { port } = server.address() as AddressInfo;
resolve(`http://127.0.0.1:${port}`);
});
});
}
/**
* An unsigned JWT. decodeAndVerifyJwt returns verified:true for a did:ethr
* issuer under NODE_ENV=test-local without checking the signature, so no key
* material is needed to exercise the real middleware chain.
*/
function mintActionJwt(action: string, phoneNumber?: string): string {
const b64 = (value: unknown) =>
Buffer.from(JSON.stringify(value)).toString("base64url");
const now = Math.floor(Date.now() / 1000);
return [
b64({ alg: "ES256K", typ: "JWT" }),
b64({
iss: USER_DID,
iat: now,
exp: now + 300,
claim: {
"@context": "https://giftopia.tech",
"@type": "SmsNotificationAction",
action,
...(phoneNumber === undefined ? {} : { phoneNumber }),
},
}),
"unsigned",
].join(".");
}
async function main(): Promise<void> {
// ENDORSER_URL is a module-level const in env.ts, so it has to be set before
// anything that imports env.ts is loaded — including the target guard.
process.env.ENDORSER_URL = await startStubEndorser();
console.log("Stub Endorser at", process.env.ENDORSER_URL);
const { resolveTarget } = await import("./smokeTarget.js");
TO_NUMBER = resolveTarget(process.argv[2], "SMS_SMOKE_TO");
// Imported after the environment is set, since env.ts reads it at load.
const { notifySmsRouter } = await import("../src/routes/notifySms.js");
const { smsConfig } = await import("../src/env.js");
const configured =
smsConfig().twilioAccountSid !== undefined &&
smsConfig().twilioAuthToken !== undefined &&
(smsConfig().twilioFromNumber !== undefined ||
smsConfig().twilioMessagingServiceSid !== undefined);
console.log(
configured
? `Twilio credentials present: real API call, From=${smsConfig().twilioFromNumber ?? smsConfig().twilioMessagingServiceSid}`
: "No Twilio credentials: console adapter, nothing leaves the machine"
);
console.log("To:", TO_NUMBER);
console.log("Data dir:", process.env.NOTIFY_DATA_DIR, "\n");
const app = express();
app.use(express.json());
app.use("/notify-sms", notifySmsRouter);
const server = app.listen(0);
await new Promise((resolve) => server.once("listening", resolve));
const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
async function call(
label: string,
method: string,
urlPath: string,
action: string,
phoneNumber?: string,
body?: unknown
): Promise<Record<string, unknown>> {
const response = await fetch(base + urlPath, {
method,
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + mintActionJwt(action, phoneNumber),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const parsed = (await response.json()) as Record<string, unknown>;
console.log(`${label}: ${response.status}`, JSON.stringify(parsed));
return parsed;
}
const posted = await call(
"POST /phone ",
"POST",
"/notify-sms/phone",
"register-phone",
TO_NUMBER,
{ phoneNumber: TO_NUMBER }
);
const { smsPhoneLogDb } = await import("../src/db/smsPhoneLogSqlite.js");
async function dumpLog(): Promise<void> {
const rows = await smsPhoneLogDb.listByUserId(USER_DID);
console.log("\nsms_phone_log (newest first):");
for (const row of rows) {
console.log(
` ${row.action.padEnd(26)} ${row.result.padEnd(9)} ` +
`phone=${row.phoneE164 ?? "(scrubbed)"} ` +
`sid=${row.providerMessageId ?? "-"}` +
(row.detail === undefined ? "" : ` detail=${row.detail}`)
);
}
}
const code = posted.devCode;
if (typeof code !== "string") {
await dumpLog();
console.error(
"\nNo devCode in the response, so the code never went out. The " +
"code-send-failed detail above is Twilio's own words.\n" +
` \u2022 'To' rejected (${TO_NUMBER}): check the country code — a US ` +
"number is +1 and ten digits (+18015601471, not +8015601471). Passing " +
"the ten digits with no + at all assumes US. Fictional 555-01xx numbers " +
"are rejected too.\n" +
" \u2022 'From' rejected (21606): under test credentials it must be " +
"+15005550006; under live credentials it must be a number you own, or " +
"a Messaging Service SID.\n" +
" \u2022 'resource ... was not found' (20404): the token authenticated " +
"but that account is not reachable. Run `pnpm run twilio:whoami`."
);
server.close();
process.exit(1);
}
await call(
"PUT /phone ",
"PUT",
"/notify-sms/phone",
"verify-phone",
TO_NUMBER,
{ phoneNumber: TO_NUMBER, code }
);
await call("GET /phone ", "GET", "/notify-sms/phone", "list-phones");
await call(
"GET /phone? ",
"GET",
`/notify-sms/phone?phoneNumber=${encodeURIComponent(TO_NUMBER)}`,
"list-phones",
TO_NUMBER
);
// No batch was ever stored here (that needs 100 signed delegated JWTs), so
// this reports zeros. It still proves the route, the claim, and the log line.
await call(
"DELETE /alert-a",
"DELETE",
"/notify-sms/alert-authorization",
"revoke-alert-search"
);
await call(
"DELETE /phone ",
"DELETE",
"/notify-sms/phone",
"delete-phone",
TO_NUMBER,
{ phoneNumber: TO_NUMBER }
);
await dumpLog();
server.close();
process.exit(0);
}
void main();
+74
View File
@@ -0,0 +1,74 @@
/**
* Who do these credentials say I am?
*
* pkgx pnpm run twilio:whoami
*
* Fetches the Account resource with the configured SID and token, which
* separates "wrong credential pair" from "credentials fine, problem is
* elsewhere" in one call. Sends nothing and costs nothing.
*/
import { smsConfig } from "../src/env.js";
const config = smsConfig();
const sid = config.twilioAccountSid;
const token = config.twilioAuthToken;
if (sid === undefined || token === undefined) {
console.error("Set TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN first.");
process.exit(1);
}
console.log(`Account SID: ${sid} (${sid.length} chars, prefix ${sid.slice(0, 2)})`);
console.log(`Auth token: ${token.length} chars, ends ...${token.slice(-4)}`);
const response = await fetch(
`https://api.twilio.com/2010-04-01/Accounts/${encodeURIComponent(sid)}.json`,
{
headers: {
Authorization: "Basic " + Buffer.from(`${sid}:${token}`).toString("base64"),
},
}
);
const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
console.log(`\nHTTP ${response.status}`);
// Test credentials authenticate fine and are then refused most resources.
// That refusal is itself proof the pair is valid, so report it as a pass.
if (response.status === 403 && body.code === 20008) {
console.log(` code: ${String(body.code)}`);
console.log(` message: ${String(body.message)}`);
console.log(
"\nThese are valid Twilio Test Credentials. The pair authenticated; the\n" +
"Accounts resource is simply not one that test credentials may read, so\n" +
"this 403 is the expected answer and not a misconfiguration.\n\n" +
"What they can do: create Messages with From=+15005550006. Nothing is\n" +
"delivered and nothing is billed. The +1500555000x magic To numbers force\n" +
"specific failures; a real To number is accepted by some accounts and\n" +
"answered with 20404 on others, so treat the magic numbers as the reliable\n" +
"path and use live credentials for a text that actually arrives.\n\n" +
"To see account details here, run this with the live pair from the Twilio\n" +
"Console dashboard instead."
);
process.exit(0);
}
if (response.ok) {
console.log(` friendly_name: ${String(body.friendly_name)}`);
console.log(` status: ${String(body.status)}`);
console.log(` type: ${String(body.type)}`);
console.log(
"\nThe pair is valid and this is the account the send would bill. " +
'"type: Trial" can only send to verified numbers.'
);
} else {
console.log(` code: ${String(body.code)}`);
console.log(` message: ${String(body.message)}`);
console.log(
"\n401/20003 means the SID and token are not a matching pair. Test\n" +
"credentials are a matched pair of their own: Twilio Console -> Account ->\n" +
"API keys & tokens -> Test credentials. Take BOTH values from that block;\n" +
"the SID in the dashboard header belongs to the live pair."
);
}
process.exit(response.ok ? 0 : 1);
+366
View File
@@ -0,0 +1,366 @@
import { errorMessage } from "../util/formatElapsed.js";
import { maskToken } from "../util/maskToken.js";
import {
alertSearchUrl,
buildAlertSearchQuery,
type AlertSearchQueryInput,
} from "./params.js";
import {
ALERT_SEARCH_PAGE_SIZE,
type AlertSearchFailureReason,
type AlertSearchSource,
type EndorserAlertSearchData,
type EndorserAlertSearchResponse,
type PartnerAlertSearchData,
type PartnerAlertSearchResponse,
} from "./types.js";
import { log } from "../util/log.js";
export type FetchLike = (
input: string,
init?: RequestInit
) => Promise<Response>;
export type AlertSearchHttpConfig = {
fetch?: FetchLike;
timeoutMs?: number;
};
export type AlertSearchHttpOk<T> = {
ok: true;
status: number;
body: T;
url: string;
};
export type AlertSearchHttpErr = {
ok: false;
reason: AlertSearchFailureReason;
message: string;
status?: number;
url: string;
};
export type AlertSearchHttpResult<T> = AlertSearchHttpOk<T> | AlertSearchHttpErr;
const DEFAULT_TIMEOUT_MS = 15_000;
export function emptyEndorserData(): EndorserAlertSearchData {
return {
claims: [],
personalPlanContributions: [],
trackedPlanUpdates: [],
trackedPlanClaims: [],
plansNearby: [],
};
}
export function emptyPartnerData(): PartnerAlertSearchData {
return { profilesNearby: [] };
}
function classifyHttpStatus(status: number): AlertSearchFailureReason {
if (status === 400 || status === 401 || status === 403) return "auth";
return "http";
}
function isAbortError(err: unknown): boolean {
if (err instanceof Error) {
if (err.name === "AbortError" || err.name === "TimeoutError") return true;
if (err.message.toLowerCase().includes("aborted")) return true;
}
return false;
}
async function getJson(
url: string,
jwt: string,
source: AlertSearchSource,
config: AlertSearchHttpConfig
): Promise<AlertSearchHttpResult<unknown>> {
const fetchImpl = config.fetch ?? fetch;
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const headers = {
Authorization: `Bearer ${jwt}`,
Accept: "application/json",
};
let response: Response;
try {
response = await fetchImpl(url, {
method: "GET",
headers,
signal: AbortSignal.timeout(timeoutMs),
});
} catch (err) {
const reason: AlertSearchFailureReason = isAbortError(err)
? "timeout"
: "network";
log.error(
"[AlertSearch]",
source,
reason,
url,
"jwt suffix=" + maskToken(jwt) + ":",
errorMessage(err)
);
return { ok: false, reason, message: errorMessage(err), url };
}
let parsed: unknown;
try {
parsed = await response.json();
} catch (err) {
if (!response.ok) {
const reason = classifyHttpStatus(response.status);
return {
ok: false,
reason,
message: `HTTP ${response.status}`,
status: response.status,
url,
};
}
return {
ok: false,
reason: "malformed",
message: errorMessage(err),
status: response.status,
url,
};
}
if (!response.ok) {
const reason = classifyHttpStatus(response.status);
return {
ok: false,
reason,
message: `HTTP ${response.status}`,
status: response.status,
url,
};
}
return { ok: true, status: response.status, body: parsed, url };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function asObjectArray(value: unknown): Record<string, unknown>[] | undefined {
if (!Array.isArray(value)) return undefined;
const out: Record<string, unknown>[] = [];
for (const item of value) {
if (!isRecord(item)) return undefined;
out.push(item);
}
return out;
}
export function parseEndorserAlertSearchResponse(
body: unknown
): EndorserAlertSearchResponse | undefined {
if (!isRecord(body) || !isRecord(body.data)) return undefined;
const data = body.data;
const claims = asObjectArray(data.claims);
const personalPlanContributions = asObjectArray(
data.personalPlanContributions
);
const trackedPlanUpdates = asObjectArray(data.trackedPlanUpdates);
const trackedPlanClaims = asObjectArray(data.trackedPlanClaims);
const plansNearby = asObjectArray(data.plansNearby);
if (
claims === undefined ||
personalPlanContributions === undefined ||
trackedPlanUpdates === undefined ||
trackedPlanClaims === undefined ||
plansNearby === undefined
) {
return undefined;
}
const parsed: EndorserAlertSearchResponse = {
data: {
claims: claims as EndorserAlertSearchResponse["data"]["claims"],
personalPlanContributions:
personalPlanContributions as EndorserAlertSearchResponse["data"]["personalPlanContributions"],
trackedPlanUpdates:
trackedPlanUpdates as EndorserAlertSearchResponse["data"]["trackedPlanUpdates"],
trackedPlanClaims:
trackedPlanClaims as EndorserAlertSearchResponse["data"]["trackedPlanClaims"],
plansNearby:
plansNearby as EndorserAlertSearchResponse["data"]["plansNearby"],
},
};
if (typeof body.userMessage === "string") {
parsed.userMessage = body.userMessage;
}
return parsed;
}
export function parsePartnerAlertSearchResponse(
body: unknown
): PartnerAlertSearchResponse | undefined {
if (!isRecord(body) || !isRecord(body.data)) return undefined;
const profilesNearby = asObjectArray(body.data.profilesNearby);
if (profilesNearby === undefined) return undefined;
const parsed: PartnerAlertSearchResponse = {
data: {
profilesNearby:
profilesNearby as PartnerAlertSearchResponse["data"]["profilesNearby"],
},
};
if (typeof body.userMessage === "string") {
parsed.userMessage = body.userMessage;
}
return parsed;
}
export async function fetchEndorserAlertSearchPage(input: {
baseUrl: string;
jwt: string;
query: AlertSearchQueryInput;
config?: AlertSearchHttpConfig;
}): Promise<AlertSearchHttpResult<EndorserAlertSearchResponse>> {
const url = alertSearchUrl(
input.baseUrl,
"/api/v2/report/alertSearch",
buildAlertSearchQuery(input.query)
);
const raw = await getJson(url, input.jwt, "endorser", input.config ?? {});
if (!raw.ok) return raw;
const parsed = parseEndorserAlertSearchResponse(raw.body);
if (parsed === undefined) {
return {
ok: false,
reason: "malformed",
message: "Endorser alertSearch response is missing required data buckets.",
status: raw.status,
url,
};
}
return { ok: true, status: raw.status, body: parsed, url };
}
export async function fetchPartnerAlertSearchPage(input: {
baseUrl: string;
jwt: string;
query: AlertSearchQueryInput;
config?: AlertSearchHttpConfig;
}): Promise<AlertSearchHttpResult<PartnerAlertSearchResponse>> {
const url = alertSearchUrl(
input.baseUrl,
"/api/partner/alertSearch",
buildAlertSearchQuery(input.query)
);
const raw = await getJson(url, input.jwt, "partner", input.config ?? {});
if (!raw.ok) return raw;
const parsed = parsePartnerAlertSearchResponse(raw.body);
if (parsed === undefined) {
return {
ok: false,
reason: "malformed",
message: "Partner alertSearch response is missing profilesNearby.",
status: raw.status,
url,
};
}
return { ok: true, status: raw.status, body: parsed, url };
}
export function endorserBucketHitPageLimit(
data: EndorserAlertSearchData
): boolean {
return (
data.claims.length >= ALERT_SEARCH_PAGE_SIZE ||
data.personalPlanContributions.length >= ALERT_SEARCH_PAGE_SIZE ||
data.trackedPlanUpdates.length >= ALERT_SEARCH_PAGE_SIZE ||
data.trackedPlanClaims.length >= ALERT_SEARCH_PAGE_SIZE ||
data.plansNearby.length >= ALERT_SEARCH_PAGE_SIZE
);
}
export function partnerBucketHitPageLimit(
data: PartnerAlertSearchData
): boolean {
return data.profilesNearby.length >= ALERT_SEARCH_PAGE_SIZE;
}
function minUlid(ids: unknown[]): string | undefined {
const values = ids.filter(
(id): id is string => typeof id === "string" && id.length > 0
);
if (values.length === 0) return undefined;
values.sort();
return values[0];
}
/**
* Next shared Endorser beforeId: MAX of the per-bucket minima for buckets
* that hit LIMIT 50. A global min would skip remaining rows in higher-ID
* truncated buckets.
*/
export function nextEndorserBeforeId(
data: EndorserAlertSearchData
): string | undefined {
const truncatedMins: string[] = [];
const truncatedBuckets: unknown[][] = [];
if (data.claims.length >= ALERT_SEARCH_PAGE_SIZE) {
truncatedBuckets.push(data.claims.map((r) => r.id));
}
if (data.personalPlanContributions.length >= ALERT_SEARCH_PAGE_SIZE) {
truncatedBuckets.push(data.personalPlanContributions.map((r) => r.id));
}
if (data.trackedPlanClaims.length >= ALERT_SEARCH_PAGE_SIZE) {
truncatedBuckets.push(data.trackedPlanClaims.map((r) => r.id));
}
if (data.trackedPlanUpdates.length >= ALERT_SEARCH_PAGE_SIZE) {
truncatedBuckets.push(data.trackedPlanUpdates.map((r) => r.jwtId));
}
if (data.plansNearby.length >= ALERT_SEARCH_PAGE_SIZE) {
truncatedBuckets.push(data.plansNearby.map((r) => r.jwtId));
}
for (const ids of truncatedBuckets) {
const min = minUlid(ids);
if (min !== undefined) truncatedMins.push(min);
}
if (truncatedMins.length === 0) return undefined;
truncatedMins.sort();
return truncatedMins[truncatedMins.length - 1];
}
export function plansNearbyHitPageLimit(
data: EndorserAlertSearchData
): boolean {
return data.plansNearby.length >= ALERT_SEARCH_PAGE_SIZE;
}
export function minPartnerBeforeDate(
data: PartnerAlertSearchData
): string | undefined {
if (data.profilesNearby.length < ALERT_SEARCH_PAGE_SIZE) return undefined;
const dates = data.profilesNearby
.map((p) => p.updatedAt)
.filter((d): d is string => typeof d === "string" && d.length > 0);
if (dates.length === 0) return undefined;
dates.sort();
return dates[0];
}
/**
* A LIMIT-50 page whose oldest updatedAt appears more than once cannot be
* drained with exclusive beforeDate: remaining rows at that timestamp are
* skipped. Detectable from the page without a rowid API.
*/
export function partnerPageHasTiedBeforeDate(
data: PartnerAlertSearchData
): boolean {
if (data.profilesNearby.length < ALERT_SEARCH_PAGE_SIZE) return false;
const min = minPartnerBeforeDate(data);
if (min === undefined) return false;
let count = 0;
for (const row of data.profilesNearby) {
if (row.updatedAt === min) count += 1;
}
return count > 1;
}
+150
View File
@@ -0,0 +1,150 @@
import {
alertSearchCursorsDb,
smsAlertSearchCursorsDb,
type AlertSearchCursorsStore,
} from "../db/alertSearchCursorsSqlite.js";
import { isAlertSearchCursorUlid } from "./params.js";
import type {
AlertSearchSourceResult,
RetrieveAlertSearchResult,
} from "./retrieve.js";
import type {
AlertSearchChannel,
EndorserAlertSearchData,
PartnerAlertSearchData,
} from "./types.js";
export type StoredAlertSearchCursors = {
endorserAfterId?: string;
partnerAfterDate?: string;
};
export type CursorAdvanceResult = {
endorserAdvanced: boolean;
partnerAdvanced: boolean;
endorserAfterId: string | null;
partnerAfterAt: string | null;
};
const CURSOR_STORES: Record<AlertSearchChannel, AlertSearchCursorsStore> = {
fcm: alertSearchCursorsDb,
sms: smsAlertSearchCursorsDb,
};
const COMPLETE_OUTCOMES = new Set(["success"]);
function isCompleteOutcome(outcome: string): boolean {
return COMPLETE_OUTCOMES.has(outcome);
}
/** MAX ULID across Endorser alertSearch identifier fields. */
export function maxEndorserAlertSearchUlid(
data: EndorserAlertSearchData
): string | undefined {
const ids: string[] = [];
for (const row of data.claims) {
if (isAlertSearchCursorUlid(row.id)) ids.push(row.id);
}
for (const row of data.personalPlanContributions) {
if (isAlertSearchCursorUlid(row.id)) ids.push(row.id);
}
for (const row of data.trackedPlanClaims) {
if (isAlertSearchCursorUlid(row.id)) ids.push(row.id);
}
for (const row of data.trackedPlanUpdates) {
if (isAlertSearchCursorUlid(row.jwtId)) ids.push(row.jwtId);
}
for (const row of data.plansNearby) {
if (isAlertSearchCursorUlid(row.jwtId)) ids.push(row.jwtId);
}
if (ids.length === 0) return undefined;
ids.sort();
return ids[ids.length - 1];
}
/** MAX profilesNearby.updatedAt (ISO). Not rowId. */
export function maxPartnerUpdatedAt(
data: PartnerAlertSearchData
): string | undefined {
let best: string | undefined;
let bestMs = Number.NEGATIVE_INFINITY;
for (const row of data.profilesNearby) {
if (typeof row.updatedAt !== "string" || row.updatedAt.length === 0) {
continue;
}
const ms = Date.parse(row.updatedAt);
if (Number.isNaN(ms)) continue;
if (ms > bestMs) {
bestMs = ms;
best = row.updatedAt;
}
}
return best;
}
export function nextEndorserCursorFromResult(
endorser: AlertSearchSourceResult<EndorserAlertSearchData>
): string | undefined {
if (!isCompleteOutcome(endorser.outcome)) return undefined;
return maxEndorserAlertSearchUlid(endorser.data);
}
export function nextPartnerCursorFromResult(
partner: AlertSearchSourceResult<PartnerAlertSearchData>
): string | undefined {
if (!isCompleteOutcome(partner.outcome)) return undefined;
return maxPartnerUpdatedAt(partner.data);
}
/**
* Load stored cursors for a retrieval query.
* Omits missing values so first run sends no afterId / afterDate / "0".
*/
export async function loadAlertSearchCursors(
userId: string,
channel: AlertSearchChannel = "fcm"
): Promise<StoredAlertSearchCursors> {
const row = await CURSOR_STORES[channel].get(userId);
const out: StoredAlertSearchCursors = {};
if (row?.endorserAfterId && isAlertSearchCursorUlid(row.endorserAfterId)) {
out.endorserAfterId = row.endorserAfterId;
}
if (row?.partnerAfterAt && row.partnerAfterAt.length > 0) {
out.partnerAfterDate = row.partnerAfterAt;
}
return out;
}
/**
* Advance each cursor independently after retrieveAlertSearch.
* Does not persist on empty, pagination, or error outcomes.
*/
export async function advanceAlertSearchCursors(
userId: string,
result: RetrieveAlertSearchResult,
channel: AlertSearchChannel = "fcm"
): Promise<CursorAdvanceResult> {
const store = CURSOR_STORES[channel];
let endorserAdvanced = false;
let partnerAdvanced = false;
const nextEndorser = nextEndorserCursorFromResult(result.endorser);
if (nextEndorser !== undefined) {
await store.setEndorserAfterId(userId, nextEndorser);
endorserAdvanced = true;
}
const nextPartner = nextPartnerCursorFromResult(result.partner);
if (nextPartner !== undefined) {
await store.setPartnerAfterAt(userId, nextPartner);
partnerAdvanced = true;
}
const stored = await store.get(userId);
return {
endorserAdvanced,
partnerAdvanced,
endorserAfterId: stored?.endorserAfterId ?? null,
partnerAfterAt: stored?.partnerAfterAt ?? null,
};
}
+41
View File
@@ -0,0 +1,41 @@
import {
advanceAlertSearchCursors,
loadAlertSearchCursors,
type CursorAdvanceResult,
} from "./cursors.js";
import type { AlertSearchChannel } from "./types.js";
import {
retrieveAlertSearch,
type RetrieveAlertSearchInput,
type RetrieveAlertSearchResult,
} from "./retrieve.js";
export type AlertSearchCycleInput = Omit<
RetrieveAlertSearchInput,
"endorserAfterId" | "partnerAfterId" | "partnerAfterDate"
>;
export type AlertSearchCycleResult = {
loaded: Awaited<ReturnType<typeof loadAlertSearchCursors>>;
retrieved: RetrieveAlertSearchResult;
advanced: CursorAdvanceResult;
};
/**
* Load stored cursors, retrieve Endorser+Partner alertSearch, then advance
* only complete source cursors. Not invoked by FCM, register, or startup.
*/
export async function runAlertSearchCycle(
userId: string,
input: AlertSearchCycleInput,
channel: AlertSearchChannel = "fcm"
): Promise<AlertSearchCycleResult> {
const loaded = await loadAlertSearchCursors(userId, channel);
const retrieved = await retrieveAlertSearch({
...input,
endorserAfterId: loaded.endorserAfterId,
partnerAfterDate: loaded.partnerAfterDate,
});
const advanced = await advanceAlertSearchCursors(userId, retrieved, channel);
return { loaded, retrieved, advanced };
}
+116
View File
@@ -0,0 +1,116 @@
import {
alertAuthorizationDb,
type AlertAuthorizationStore,
} from "../db/alertAuthorizationSqlite.js";
import { smsAlertAuthorizationDb } from "../db/smsAlertAuthorizationSqlite.js";
import { utcCalendarDay } from "../services/alertAuthorization.js";
import {
runAlertSearchCycle,
type AlertSearchCycleInput,
} from "./cycle.js";
import {
buildAlertSearchDigest,
sourceCompletedDailyRun,
type AlertSearchDigest,
} from "./digest.js";
import type {
AlertSearchChannel,
AlertSearchQueryOutcome,
} from "./types.js";
const JWT_INVENTORIES: Record<AlertSearchChannel, AlertAuthorizationStore> = {
fcm: alertAuthorizationDb,
sms: smsAlertAuthorizationDb,
};
export { sourceCompletedDailyRun };
export type DailyAlertSearchCycleInput = Omit<AlertSearchCycleInput, "jwt">;
export type DailyAlertSearchResult = {
userId: string;
/** The UTC day whose JWT this run used, or null when none was selected. */
utcDay: string | null;
batchId: string | null;
jwtSequence: number | null;
endorserOutcome: AlertSearchQueryOutcome | null;
partnerOutcome: AlertSearchQueryOutcome | null;
completed: boolean;
consumed: boolean;
/** Phase 6A digest after a retrieve; null when no batch/JWT was used. */
digest: AlertSearchDigest | null;
};
function noJwtResult(
userId: string,
utcDay: string | null,
batchId: string | null
): DailyAlertSearchResult {
return {
userId,
utcDay,
batchId,
jwtSequence: null,
endorserOutcome: null,
partnerOutcome: null,
completed: false,
consumed: false,
digest: null,
};
}
/**
* Select today's unused delegated JWT by UTC day, run the existing alertSearch
* cycle, and consume that JWT only when both required sources completed
* (success or empty). Not invoked by the scheduler.
*/
export async function runDailyAlertSearch(
userId: string,
now: Date = new Date(),
cycleInput: DailyAlertSearchCycleInput = {},
channel: AlertSearchChannel = "fcm"
): Promise<DailyAlertSearchResult> {
const inventory = JWT_INVENTORIES[channel];
const batch = await inventory.getLatestBatch(userId);
if (batch === undefined) {
return noJwtResult(userId, null, null);
}
const utcDay = utcCalendarDay(Math.floor(now.getTime() / 1000));
const selected = await inventory.getUnusedForDay(userId, utcDay);
if (selected === undefined) {
return noJwtResult(userId, utcDay, batch.batchId);
}
const cycle = await runAlertSearchCycle(
userId,
{ ...cycleInput, jwt: selected.jwt },
channel
);
const digest = buildAlertSearchDigest(cycle.retrieved);
const endorserOutcome = cycle.retrieved.endorser.outcome;
const partnerOutcome = cycle.retrieved.partner.outcome;
const completed =
sourceCompletedDailyRun(endorserOutcome) &&
sourceCompletedDailyRun(partnerOutcome);
let consumed = false;
if (completed) {
consumed = await inventory.consumeUnusedJwt({
id: selected.id,
userId,
});
}
return {
userId,
utcDay,
batchId: selected.batchId,
jwtSequence: selected.sequence,
endorserOutcome,
partnerOutcome,
completed,
consumed,
digest,
};
}
+151
View File
@@ -0,0 +1,151 @@
import type {
AlertSearchSourceResult,
RetrieveAlertSearchResult,
} from "./retrieve.js";
import type {
AlertSearchFailureReason,
AlertSearchQueryOutcome,
CombinedAlertSearchData,
EndorserAlertSearchData,
PartnerAlertSearchData,
} from "./types.js";
/** Retrieval finished for the daily run (cursor advance still follows Phase 4B). */
export function sourceCompletedDailyRun(
outcome: AlertSearchQueryOutcome
): boolean {
return outcome === "success" || outcome === "empty";
}
export const ALERT_SEARCH_DIGEST_BUCKETS = [
"claims",
"personalPlanContributions",
"trackedPlanUpdates",
"trackedPlanClaims",
"plansNearby",
"profilesNearby",
] as const;
export type AlertSearchDigestBucket =
(typeof ALERT_SEARCH_DIGEST_BUCKETS)[number];
export type AlertSearchDigestCounts = {
[K in AlertSearchDigestBucket]: number;
};
export type AlertSearchDigestSourceStatus = {
outcome: AlertSearchQueryOutcome;
completed: boolean;
reason?: AlertSearchFailureReason;
message?: string;
};
/**
* Structured payload for a future app/plugin notification.
* Not a human-facing message string.
*/
export type AlertSearchDigest = {
completed: boolean;
hasUpdates: boolean;
totalCount: number;
counts: AlertSearchDigestCounts;
records: CombinedAlertSearchData;
endorser: AlertSearchDigestSourceStatus;
partner: AlertSearchDigestSourceStatus;
};
function copyEndorserRecords(
data: EndorserAlertSearchData
): EndorserAlertSearchData {
return {
claims: [...data.claims],
personalPlanContributions: [...data.personalPlanContributions],
trackedPlanUpdates: [...data.trackedPlanUpdates],
trackedPlanClaims: [...data.trackedPlanClaims],
plansNearby: [...data.plansNearby],
};
}
function copyPartnerRecords(
data: PartnerAlertSearchData
): Pick<CombinedAlertSearchData, "profilesNearby"> {
return { profilesNearby: [...data.profilesNearby] };
}
function sourceStatus(
result: AlertSearchSourceResult<EndorserAlertSearchData | PartnerAlertSearchData>
): AlertSearchDigestSourceStatus {
const completed = sourceCompletedDailyRun(result.outcome);
const status: AlertSearchDigestSourceStatus = {
outcome: result.outcome,
completed,
};
if (result.reason !== undefined) status.reason = result.reason;
if (result.message !== undefined) status.message = result.message;
return status;
}
export function countAlertSearchDigestRecords(
records: CombinedAlertSearchData
): AlertSearchDigestCounts {
return {
claims: records.claims.length,
personalPlanContributions: records.personalPlanContributions.length,
trackedPlanUpdates: records.trackedPlanUpdates.length,
trackedPlanClaims: records.trackedPlanClaims.length,
plansNearby: records.plansNearby.length,
profilesNearby: records.profilesNearby.length,
};
}
export function totalAlertSearchDigestCount(counts: AlertSearchDigestCounts): number {
return ALERT_SEARCH_DIGEST_BUCKETS.reduce(
(sum, bucket) => sum + counts[bucket],
0
);
}
/**
* Pure transform of retrieveAlertSearch output. Does not fetch, persist, or notify.
* A completed digest requires both sources success or empty (Phase 5A).
*/
export function buildAlertSearchDigest(
retrieved: RetrieveAlertSearchResult
): AlertSearchDigest {
const endorser = sourceStatus(retrieved.endorser);
const partner = sourceStatus(retrieved.partner);
const completed = endorser.completed && partner.completed;
const records: CombinedAlertSearchData = {
...copyEndorserRecords(retrieved.endorser.data),
...copyPartnerRecords(retrieved.partner.data),
};
const counts = countAlertSearchDigestRecords(records);
const totalCount = totalAlertSearchDigestCount(counts);
const hasUpdates = completed && totalCount > 0;
return {
completed,
hasUpdates,
totalCount,
counts,
records,
endorser,
partner,
};
}
/** Debug/preview only. Not the notification payload. */
export function alertSearchDigestDebugSummary(digest: AlertSearchDigest): string {
const buckets = ALERT_SEARCH_DIGEST_BUCKETS.map(
(bucket) => `${bucket}=${digest.counts[bucket]}`
).join(" ");
return [
`completed=${digest.completed}`,
`hasUpdates=${digest.hasUpdates}`,
`totalCount=${digest.totalCount}`,
`endorser=${digest.endorser.outcome}`,
`partner=${digest.partner.outcome}`,
buckets,
].join(" ");
}
+84
View File
@@ -0,0 +1,84 @@
export {
ALERT_SEARCH_PAGE_SIZE,
ENDORSER_ALERT_SEARCH_PATH,
PARTNER_ALERT_SEARCH_PATH,
} from "./types.js";
export type {
AlertSearchCursorUlid,
AlertSearchFailureReason,
AlertSearchLocationBBox,
AlertSearchQueryOutcome,
CombinedAlertSearchData,
EndorserAlertSearchData,
EndorserAlertSearchResponse,
PartnerAlertSearchData,
PartnerAlertSearchResponse,
} from "./types.js";
export {
alertSearchUrl,
buildAlertSearchQuery,
isAlertSearchCursorUlid,
isCompleteLocationBBox,
} from "./params.js";
export {
fetchEndorserAlertSearchPage,
fetchPartnerAlertSearchPage,
nextEndorserBeforeId,
parseEndorserAlertSearchResponse,
parsePartnerAlertSearchResponse,
partnerPageHasTiedBeforeDate,
} from "./client.js";
export { retrieveAlertSearch } from "./retrieve.js";
export type { RetrieveAlertSearchInput, RetrieveAlertSearchResult } from "./retrieve.js";
export {
advanceAlertSearchCursors,
loadAlertSearchCursors,
maxEndorserAlertSearchUlid,
maxPartnerUpdatedAt,
} from "./cursors.js";
export type { CursorAdvanceResult, StoredAlertSearchCursors } from "./cursors.js";
export { runAlertSearchCycle } from "./cycle.js";
export type { AlertSearchCycleInput, AlertSearchCycleResult } from "./cycle.js";
export {
runDailyAlertSearch,
sourceCompletedDailyRun,
} from "./daily.js";
export type {
DailyAlertSearchCycleInput,
DailyAlertSearchResult,
} from "./daily.js";
export {
ALERT_SEARCH_SCHEDULER_INTERVAL_MS,
runAlertSearchSchedulerPass,
startAlertSearchScheduler,
stopAlertSearchScheduler,
} from "./scheduler.js";
export type {
AlertSearchSchedulerPassInput,
AlertSearchSchedulerPassResult,
} from "./scheduler.js";
export {
ALERT_SEARCH_FCM_TYPE,
ALERT_SEARCH_NOTIFICATION_TITLE,
alertSearchNotificationBody,
buildAlertSearchNotificationContent,
deliverAlertSearchNotification,
isAlertSearchNotificationEligible,
} from "./notify.js";
export type {
AlertSearchNotificationContent,
AlertSearchNotifyResult,
} from "./notify.js";
export {
ALERT_SEARCH_DIGEST_BUCKETS,
alertSearchDigestDebugSummary,
buildAlertSearchDigest,
countAlertSearchDigestRecords,
totalAlertSearchDigestCount,
} from "./digest.js";
export type {
AlertSearchDigest,
AlertSearchDigestBucket,
AlertSearchDigestCounts,
AlertSearchDigestSourceStatus,
} from "./digest.js";
+120
View File
@@ -0,0 +1,120 @@
import { db } from "../db/fcmTokensSqlite.js";
import { errorMessage } from "../util/formatElapsed.js";
import type { DailyAlertSearchResult } from "./daily.js";
import { log } from "../util/log.js";
export const ALERT_SEARCH_NOTIFICATION_TITLE = "TimeSafari";
export const ALERT_SEARCH_FCM_TYPE = "alert_search";
export type AlertSearchNotificationContent = {
title: string;
body: string;
type: typeof ALERT_SEARCH_FCM_TYPE;
};
export type AlertSearchTokenSender = (
fcmToken: string,
content: AlertSearchNotificationContent
) => Promise<"sent" | "failed">;
export type AlertSearchNotifyDeps = {
listTokens?: (userId: string) => Promise<string[]>;
send?: AlertSearchTokenSender;
};
export type AlertSearchNotifyResult = {
eligible: boolean;
sent: number;
failed: number;
};
export function alertSearchNotificationBody(totalCount: number): string {
const noun = totalCount === 1 ? "update" : "updates";
return `You have ${totalCount} new ${noun}.`;
}
export function buildAlertSearchNotificationContent(
totalCount: number
): AlertSearchNotificationContent {
return {
title: ALERT_SEARCH_NOTIFICATION_TITLE,
body: alertSearchNotificationBody(totalCount),
type: ALERT_SEARCH_FCM_TYPE,
};
}
/**
* Notify only after a completed digest with updates, and only when this run
* consumed today's JWT so later 5-minute ticks (digest null) cannot resend.
*/
export function isAlertSearchNotificationEligible(
result: DailyAlertSearchResult
): boolean {
const digest = result.digest;
return (
result.consumed &&
digest !== null &&
digest.completed &&
digest.hasUpdates &&
digest.totalCount > 0
);
}
async function defaultListTokens(userId: string): Promise<string[]> {
const rows = await db.getByUserId(userId);
const seen = new Set<string>();
const tokens: string[] = [];
for (const row of rows) {
if (seen.has(row.fcmToken)) continue;
seen.add(row.fcmToken);
tokens.push(row.fcmToken);
}
return tokens;
}
async function defaultSend(
fcmToken: string,
content: AlertSearchNotificationContent
): Promise<"sent" | "failed"> {
const { sendAlertSearchPushToDevice } = await import(
"../services/pushService.js"
);
return sendAlertSearchPushToDevice(fcmToken, {
title: content.title,
body: content.body,
});
}
export async function deliverAlertSearchNotification(
result: DailyAlertSearchResult,
deps: AlertSearchNotifyDeps = {}
): Promise<AlertSearchNotifyResult> {
if (!isAlertSearchNotificationEligible(result)) {
return { eligible: false, sent: 0, failed: 0 };
}
const digest = result.digest;
if (digest === null) {
return { eligible: false, sent: 0, failed: 0 };
}
const content = buildAlertSearchNotificationContent(digest.totalCount);
const listTokens = deps.listTokens ?? defaultListTokens;
const send = deps.send ?? defaultSend;
const tokens = await listTokens(result.userId);
let sent = 0;
let failed = 0;
for (const token of tokens) {
try {
const status = await send(token, content);
if (status === "sent") sent += 1;
else failed += 1;
} catch (err) {
failed += 1;
log.error("[AlertSearchNotify] Send threw:", errorMessage(err));
}
}
return { eligible: true, sent, failed };
}
+82
View File
@@ -0,0 +1,82 @@
import type { AlertSearchCursorUlid, AlertSearchLocationBBox } from "./types.js";
/** Crockford ULID, same regex as endorser-ch alert-search.service.js */
const ULID_REGEX = /^[0-9A-HJKMNP-TV-Z]{26}$/;
export function isAlertSearchCursorUlid(
value: unknown
): value is AlertSearchCursorUlid {
return typeof value === "string" && ULID_REGEX.test(value);
}
/**
* Only a complete bbox is sent. Partial boxes are omitted rather than guessing.
* Shape matches endorser-ch `alertSearchParamsFromRequest` (not the app's
* eastLong/westLong BoundingBox).
*/
export function isCompleteLocationBBox(
value: unknown
): value is AlertSearchLocationBBox {
if (value === null || typeof value !== "object") return false;
const box = value as Record<string, unknown>;
return (
typeof box.minLocLat === "number" &&
Number.isFinite(box.minLocLat) &&
typeof box.maxLocLat === "number" &&
Number.isFinite(box.maxLocLat) &&
typeof box.minLocLon === "number" &&
Number.isFinite(box.minLocLon) &&
typeof box.maxLocLon === "number" &&
Number.isFinite(box.maxLocLon)
);
}
export type AlertSearchQueryInput = {
afterId?: string;
beforeId?: string;
afterDate?: string;
beforeDate?: string;
location?: AlertSearchLocationBBox;
planHandleIds?: string[];
};
/**
* GET query params for alertSearch.
* Omits afterId/beforeId unless they are valid ULIDs (never sends "0").
* Location is flattened to minLocLat/maxLocLat/minLocLon/maxLocLon for GET.
*/
export function buildAlertSearchQuery(
input: AlertSearchQueryInput
): URLSearchParams {
const params = new URLSearchParams();
if (isAlertSearchCursorUlid(input.afterId)) {
params.set("afterId", input.afterId);
}
if (isAlertSearchCursorUlid(input.beforeId)) {
params.set("beforeId", input.beforeId);
}
if (typeof input.afterDate === "string" && input.afterDate.length > 0) {
params.set("afterDate", input.afterDate);
}
if (typeof input.beforeDate === "string" && input.beforeDate.length > 0) {
params.set("beforeDate", input.beforeDate);
}
if (input.location !== undefined && isCompleteLocationBBox(input.location)) {
params.set("minLocLat", String(input.location.minLocLat));
params.set("maxLocLat", String(input.location.maxLocLat));
params.set("minLocLon", String(input.location.minLocLon));
params.set("maxLocLon", String(input.location.maxLocLon));
}
if (input.planHandleIds !== undefined && input.planHandleIds.length > 0) {
params.set("planHandleIds", JSON.stringify(input.planHandleIds));
}
return params;
}
export function alertSearchUrl(baseUrl: string, path: string, query: URLSearchParams): string {
const base = baseUrl.replace(/\/+$/, "");
const qs = query.toString();
return qs.length > 0 ? `${base}${path}?${qs}` : `${base}${path}`;
}
+383
View File
@@ -0,0 +1,383 @@
import {
DEFAULT_ENDORSER_API_SERVER,
DEFAULT_PARTNER_API_SERVER,
ENDORSER_URL,
PARTNER_URL,
} from "../env.js";
import {
emptyEndorserData,
emptyPartnerData,
endorserBucketHitPageLimit,
fetchEndorserAlertSearchPage,
fetchPartnerAlertSearchPage,
minPartnerBeforeDate,
nextEndorserBeforeId,
partnerBucketHitPageLimit,
partnerPageHasTiedBeforeDate,
plansNearbyHitPageLimit,
type AlertSearchHttpConfig,
} from "./client.js";
import type { AlertSearchQueryInput } from "./params.js";
import type {
AlertSearchFailureReason,
AlertSearchLocationBBox,
AlertSearchQueryOutcome,
CombinedAlertSearchData,
EndorserAlertSearchData,
PartnerAlertSearchData,
} from "./types.js";
const DEFAULT_MAX_PAGES = 20;
const TRUNCATION_HINT = "Some data was not available in this search";
const PLANS_NEARBY_INCOMPLETE_MESSAGE =
"plansNearby reached the server row limit. The alertSearch API orders that bucket by rowid while filtering jwtId, so remaining nearby plans cannot be proven complete.";
const PARTNER_TIMESTAMP_TIE_MESSAGE =
"Partner alertSearch page hit the row limit with multiple profiles sharing the oldest updatedAt. Exclusive beforeDate pagination cannot retrieve remaining rows at that timestamp.";
export type AlertSearchSourceResult<TData> = {
outcome: AlertSearchQueryOutcome;
reason?: AlertSearchFailureReason;
message?: string;
userMessage?: string;
pageCount: number;
data: TData;
};
export type RetrieveAlertSearchInput = {
/** Delegated notification JWT for this scheduled day. Not the setup session JWT. */
jwt: string;
/** Endorser cursor from a previous successful daily run. Omit on first run. */
endorserAfterId?: string;
/** Partner afterId ULID (decoded to a timestamp by the server). Prefer partnerAfterDate. */
partnerAfterId?: string;
/** Partner exclusive lower bound: updatedAt > this ISO time. Omit on first run. */
partnerAfterDate?: string;
/** User-selected nearby-search bbox (alertSearch minLoc* / maxLoc* shape). */
location?: AlertSearchLocationBBox;
planHandleIds?: string[];
endorserBaseUrl?: string;
partnerBaseUrl?: string;
maxPages?: number;
config?: AlertSearchHttpConfig;
};
export type RetrieveAlertSearchResult = {
data: CombinedAlertSearchData;
empty: boolean;
endorser: AlertSearchSourceResult<EndorserAlertSearchData>;
partner: AlertSearchSourceResult<PartnerAlertSearchData>;
};
function recordKey(row: Record<string, unknown>, keys: string[]): string | undefined {
for (const key of keys) {
const value = row[key];
if (typeof value === "string" && value.length > 0) return `${key}:${value}`;
if (typeof value === "number") return `${key}:${value}`;
}
return undefined;
}
function mergeRows<T extends Record<string, unknown>>(
existing: T[],
incoming: T[],
keys: string[]
): T[] {
const seen = new Set<string>();
const out: T[] = [];
for (const row of [...existing, ...incoming]) {
const key = recordKey(row, keys);
if (key !== undefined) {
if (seen.has(key)) continue;
seen.add(key);
}
out.push(row);
}
return out;
}
function mergeEndorser(
acc: EndorserAlertSearchData,
page: EndorserAlertSearchData
): EndorserAlertSearchData {
return {
claims: mergeRows(acc.claims, page.claims, ["id"]),
personalPlanContributions: mergeRows(
acc.personalPlanContributions,
page.personalPlanContributions,
["id"]
),
trackedPlanUpdates: mergeRows(acc.trackedPlanUpdates, page.trackedPlanUpdates, [
"jwtId",
"handleId",
]),
trackedPlanClaims: mergeRows(acc.trackedPlanClaims, page.trackedPlanClaims, [
"id",
]),
plansNearby: mergeRows(acc.plansNearby, page.plansNearby, ["jwtId", "handleId"]),
};
}
function mergePartner(
acc: PartnerAlertSearchData,
page: PartnerAlertSearchData
): PartnerAlertSearchData {
return {
profilesNearby: mergeRows(acc.profilesNearby, page.profilesNearby, [
"rowId",
"issuerDid",
"updatedAt",
]),
};
}
function endorserEmpty(data: EndorserAlertSearchData): boolean {
return (
data.claims.length === 0 &&
data.personalPlanContributions.length === 0 &&
data.trackedPlanUpdates.length === 0 &&
data.trackedPlanClaims.length === 0 &&
data.plansNearby.length === 0
);
}
function partnerEmpty(data: PartnerAlertSearchData): boolean {
return data.profilesNearby.length === 0;
}
async function retrieveEndorserPages(input: {
jwt: string;
afterId?: string;
location?: AlertSearchLocationBBox;
planHandleIds?: string[];
baseUrl: string;
maxPages: number;
config?: AlertSearchHttpConfig;
}): Promise<AlertSearchSourceResult<EndorserAlertSearchData>> {
let data = emptyEndorserData();
let beforeId: string | undefined;
let userMessage: string | undefined;
let pageCount = 0;
let plansNearbyTruncated = false;
for (let page = 0; page < input.maxPages; page++) {
const query: AlertSearchQueryInput = {
afterId: input.afterId,
beforeId,
location: input.location,
planHandleIds: input.planHandleIds,
};
const result = await fetchEndorserAlertSearchPage({
baseUrl: input.baseUrl,
jwt: input.jwt,
query,
config: input.config,
});
pageCount += 1;
if (!result.ok) {
return {
outcome: result.reason,
reason: result.reason,
message: result.message,
userMessage,
pageCount,
data,
};
}
if (result.body.userMessage) {
userMessage = userMessage
? `${userMessage} ${result.body.userMessage}`
: result.body.userMessage;
}
if (result.body.userMessage?.includes(TRUNCATION_HINT)) {
data = mergeEndorser(data, result.body.data);
return {
outcome: "pagination",
reason: "pagination",
message: result.body.userMessage,
userMessage,
pageCount,
data,
};
}
data = mergeEndorser(data, result.body.data);
if (plansNearbyHitPageLimit(result.body.data)) {
plansNearbyTruncated = true;
}
if (!endorserBucketHitPageLimit(result.body.data)) {
if (plansNearbyTruncated) {
return {
outcome: "pagination",
reason: "pagination",
message: PLANS_NEARBY_INCOMPLETE_MESSAGE,
userMessage,
pageCount,
data,
};
}
return {
outcome: endorserEmpty(data) ? "empty" : "success",
userMessage,
pageCount,
data,
};
}
const nextBefore = nextEndorserBeforeId(result.body.data);
if (nextBefore === undefined) {
return {
outcome: "pagination",
reason: "pagination",
message:
"Endorser page was at the server row limit but no beforeId ULID could be derived.",
userMessage,
pageCount,
data,
};
}
beforeId = nextBefore;
}
return {
outcome: "pagination",
reason: "pagination",
message: plansNearbyTruncated
? PLANS_NEARBY_INCOMPLETE_MESSAGE
: `Endorser alertSearch stopped after ${input.maxPages} pages.`,
userMessage,
pageCount,
data,
};
}
async function retrievePartnerPages(input: {
jwt: string;
afterId?: string;
afterDate?: string;
location?: AlertSearchLocationBBox;
baseUrl: string;
maxPages: number;
config?: AlertSearchHttpConfig;
}): Promise<AlertSearchSourceResult<PartnerAlertSearchData>> {
let data = emptyPartnerData();
let beforeDate: string | undefined;
let userMessage: string | undefined;
let pageCount = 0;
for (let page = 0; page < input.maxPages; page++) {
const query: AlertSearchQueryInput = {
afterId: input.afterId,
afterDate: input.afterDate,
beforeDate,
location: input.location,
};
const result = await fetchPartnerAlertSearchPage({
baseUrl: input.baseUrl,
jwt: input.jwt,
query,
config: input.config,
});
pageCount += 1;
if (!result.ok) {
return {
outcome: result.reason,
reason: result.reason,
message: result.message,
userMessage,
pageCount,
data,
};
}
if (result.body.userMessage) {
userMessage = userMessage
? `${userMessage} ${result.body.userMessage}`
: result.body.userMessage;
}
data = mergePartner(data, result.body.data);
if (partnerBucketHitPageLimit(result.body.data)) {
if (partnerPageHasTiedBeforeDate(result.body.data)) {
return {
outcome: "pagination",
reason: "pagination",
message: PARTNER_TIMESTAMP_TIE_MESSAGE,
userMessage,
pageCount,
data,
};
}
}
if (!partnerBucketHitPageLimit(result.body.data)) {
return {
outcome: partnerEmpty(data) ? "empty" : "success",
userMessage,
pageCount,
data,
};
}
const nextBefore = minPartnerBeforeDate(result.body.data);
if (nextBefore === undefined) {
return {
outcome: "pagination",
reason: "pagination",
message:
"Partner page was at the server row limit but no beforeDate could be derived.",
userMessage,
pageCount,
data,
};
}
beforeDate = nextBefore;
}
return {
outcome: "pagination",
reason: "pagination",
message: `Partner alertSearch stopped after ${input.maxPages} pages.`,
userMessage,
pageCount,
data,
};
}
/**
* Query Endorser and Partner alertSearch independently with the same delegated JWT.
* Does not persist cursors; the caller supplies afterId values.
*/
export async function retrieveAlertSearch(
input: RetrieveAlertSearchInput
): Promise<RetrieveAlertSearchResult> {
const endorserBaseUrl =
input.endorserBaseUrl ?? ENDORSER_URL ?? DEFAULT_ENDORSER_API_SERVER;
const partnerBaseUrl =
input.partnerBaseUrl ?? PARTNER_URL ?? DEFAULT_PARTNER_API_SERVER;
const maxPages = input.maxPages ?? DEFAULT_MAX_PAGES;
const [endorser, partner] = await Promise.all([
retrieveEndorserPages({
jwt: input.jwt,
afterId: input.endorserAfterId,
location: input.location,
planHandleIds: input.planHandleIds,
baseUrl: endorserBaseUrl,
maxPages,
config: input.config,
}),
retrievePartnerPages({
jwt: input.jwt,
afterId: input.partnerAfterId,
afterDate: input.partnerAfterDate,
location: input.location,
baseUrl: partnerBaseUrl,
maxPages,
config: input.config,
}),
]);
const data: CombinedAlertSearchData = {
...endorser.data,
...partner.data,
};
const empty = endorserEmpty(endorser.data) && partnerEmpty(partner.data);
return { data, empty, endorser, partner };
}
+172
View File
@@ -0,0 +1,172 @@
import {
alertAuthorizationDb,
type PendingDayUser,
} from "../db/alertAuthorizationSqlite.js";
import {
utcCalendarDay,
utcHourMinute,
} from "../services/alertAuthorization.js";
import {
ALERT_SEARCH_USER_CONCURRENCY,
forEachWithConcurrency,
} from "../util/concurrency.js";
import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js";
import {
runDailyAlertSearch,
type DailyAlertSearchResult,
} from "./daily.js";
import { deliverAlertSearchNotification } from "./notify.js";
import { log } from "../util/log.js";
/** Independent of the FCM wakeup interval; does not share that timer. */
export const ALERT_SEARCH_SCHEDULER_INTERVAL_MS = 5 * 60 * 1000;
export type AlertSearchUserRunner = (
userId: string
) => Promise<DailyAlertSearchResult>;
export type AlertSearchNotifyRunner = (
result: DailyAlertSearchResult
) => Promise<unknown>;
export type AlertSearchSchedulerPassInput = {
listPending?: (input: {
day: string;
hourMinute: string;
}) => Promise<PendingDayUser[]>;
runDaily?: AlertSearchUserRunner;
notify?: AlertSearchNotifyRunner;
/** The instant this pass represents. Defaults to now. */
now?: Date;
/** Users worked on at once. Set to 1 for a deterministic order in tests. */
concurrency?: number;
};
export type AlertSearchSchedulerPassResult = {
skipped: boolean;
/** The users this pass ran, in no guaranteed order. */
userIds: string[];
attempted: number;
failed: number;
/** Users holding an unused JWT whose chosen UTC time has not arrived yet. */
deferred: number;
};
let intervalId: ReturnType<typeof setInterval> | undefined;
let passInFlight = false;
export function isAlertSearchSchedulerPassInFlight(): boolean {
return passInFlight;
}
/**
* One user-oriented alertSearch pass over the users who have work today and
* whose chosen hour has arrived. Skips if a pass is already running. After each
* daily run, may send an AlertSearch FCM digest. Does not use the device wakeup
* ping path.
*/
export async function runAlertSearchSchedulerPass(
input: AlertSearchSchedulerPassInput = {}
): Promise<AlertSearchSchedulerPassResult> {
if (passInFlight) {
log.info("[AlertSearchScheduler] Pass skipped (already in flight)");
return { skipped: true, userIds: [], attempted: 0, failed: 0, deferred: 0 };
}
passInFlight = true;
const passStarted = Date.now();
log.info("[AlertSearchScheduler] Pass started");
try {
const now = input.now ?? new Date();
const runDaily =
input.runDaily ?? ((userId: string) => runDailyAlertSearch(userId, now));
const notify = input.notify ?? deliverAlertSearchNotification;
const listPending =
input.listPending ??
((query: { day: string; hourMinute: string }) =>
alertAuthorizationDb.listPendingForDay(query));
const nowSec = Math.floor(now.getTime() / 1000);
const pending = await listPending({
day: utcCalendarDay(nowSec),
hourMinute: utcHourMinute(nowSec),
});
const userIds = pending.filter((row) => row.due).map((row) => row.userId);
const deferred = pending.length - userIds.length;
let failed = 0;
await forEachWithConcurrency(
userIds,
input.concurrency ?? ALERT_SEARCH_USER_CONCURRENCY,
async (userId) => {
try {
const daily = await runDaily(userId);
try {
await notify(daily);
} catch (err) {
log.error(
"[AlertSearchScheduler] Notification failed",
userId + ":",
errorMessage(err)
);
}
} catch (err) {
failed += 1;
log.error(
"[AlertSearchScheduler] User failed",
userId + ":",
errorMessage(err)
);
}
}
);
log.info(
"[AlertSearchScheduler] Pass completed in",
formatElapsedMs(Date.now() - passStarted) + ",",
`attempted ${userIds.length}, deferred ${deferred}, failed ${failed}`
);
return {
skipped: false,
userIds,
attempted: userIds.length,
failed,
deferred,
};
} catch (err) {
log.error(
"[AlertSearchScheduler] Pass failed in",
formatElapsedMs(Date.now() - passStarted) + ":",
errorMessage(err)
);
throw err;
} finally {
passInFlight = false;
}
}
/**
* Starts a dedicated interval. Does not run a pass immediately (same as FCM).
* Calling twice is a no-op. Independent of the FCM device wakeup timer.
*/
export function startAlertSearchScheduler(): boolean {
if (intervalId !== undefined) return false;
intervalId = setInterval(() => {
void runAlertSearchSchedulerPass();
}, ALERT_SEARCH_SCHEDULER_INTERVAL_MS);
return true;
}
export function stopAlertSearchScheduler(): void {
if (intervalId !== undefined) {
clearInterval(intervalId);
intervalId = undefined;
}
}
/** Test helper: drop the in-flight flag after an interrupted pass. */
export function resetAlertSearchSchedulerPassGuard(): void {
passInFlight = false;
}
+253
View File
@@ -0,0 +1,253 @@
import { smsPhoneLogDb } from "../db/smsPhoneLogSqlite.js";
import { smsRegistrationsDb } from "../db/smsRegistrationsSqlite.js";
import { smsConfig } from "../env.js";
import { utcCalendarDay } from "../services/alertAuthorization.js";
import {
TWILIO_UNSUBSCRIBED_CODE,
isPhoneNumberBlocked,
isSmsRecipientAllowed,
sendSms,
type SmsSender,
} from "../services/smsService.js";
import { smsBlockedNumbersDb } from "../db/smsBlockedNumbersSqlite.js";
import { errorMessage } from "../util/formatElapsed.js";
import { maskPhoneNumber } from "../util/smsPhoneNumber.js";
import { hashPhoneNumber } from "../util/smsVerificationCode.js";
import type { DailyAlertSearchResult } from "./daily.js";
import { log } from "../util/log.js";
/** The app link. giftopia.tech is the claim namespace and never appears in a message. */
export const ALERT_SEARCH_SMS_LINK = "https://giftopia.me";
/** One GSM-7 segment. A second segment is a second charge for a longer sentence. */
export const SMS_SINGLE_SEGMENT_LIMIT = 160;
/**
* Backstop on top of JWT consumption: at most one digest per handset per
* identity per UTC calendar day. Scoped to the pair rather than the identity
* alone, because a DID with two verified handsets legitimately receives two
* texts.
*/
export const ALERT_SENDS_PER_PHONE_PER_DAY = 1;
export type AlertSearchSmsNotifyDeps = {
listPhones?: (userId: string) => Promise<string[]>;
send?: SmsSender;
/** Cap window uses this instant's UTC day. Tests pass a fixed clock. */
now?: Date;
};
export type AlertSearchSmsNotifyResult = {
eligible: boolean;
sent: number;
failed: number;
/** Recipients suppressed by SMS_ALLOWED_RECIPIENT_DIDS. */
blocked: number;
};
export function alertSearchSmsBody(totalCount: number): string {
const noun = totalCount === 1 ? "update" : "updates";
return (
`Gift Economies: you have ${totalCount} new ${noun}. ` +
`${ALERT_SEARCH_SMS_LINK} Reply STOP to end.`
);
}
/**
* The same predicate the FCM path uses. Consumption of the day's SMS JWT is what
* makes later ticks on the same UTC day no-ops, so no separate flag is needed.
*/
export function isAlertSearchSmsEligible(
result: DailyAlertSearchResult
): boolean {
const digest = result.digest;
return (
result.consumed &&
digest !== null &&
digest.completed &&
digest.hasUpdates &&
digest.totalCount > 0
);
}
/** Syncs Twilio's opt-out list into ours, one refusal at a time. */
async function recordProviderOptOut(
phoneHash: string,
phoneE164: string,
userId: string
): Promise<void> {
try {
await smsBlockedNumbersDb.block({
phoneHash,
phoneE164,
reason: "provider-opt-out",
detail: `Twilio ${TWILIO_UNSUBSCRIBED_CODE}`,
});
await smsPhoneLogDb.append({
userId,
phoneHash,
action: "number-blocked",
result: "rejected",
detail: `Twilio ${TWILIO_UNSUBSCRIBED_CODE}`,
});
} catch (err) {
log.error(
"[SmsAlertSearchNotify] Could not record provider opt-out:",
errorMessage(err)
);
}
}
async function defaultListPhones(userId: string): Promise<string[]> {
const rows = await smsRegistrationsDb.listVerifiedByUserId(userId);
const seen = new Set<string>();
const numbers: string[] = [];
for (const row of rows) {
if (seen.has(row.phoneE164)) continue;
seen.add(row.phoneE164);
numbers.push(row.phoneE164);
}
return numbers;
}
/**
* Send at most one text per verified number for a completed digest with updates.
* Send failures are logged and do not roll back cursor advancement or JWT
* consumption, matching the FCM path.
*/
export async function deliverAlertSearchSms(
result: DailyAlertSearchResult,
deps: AlertSearchSmsNotifyDeps = {}
): Promise<AlertSearchSmsNotifyResult> {
const digest = result.digest;
if (!isAlertSearchSmsEligible(result) || digest === null) {
return { eligible: false, sent: 0, failed: 0, blocked: 0 };
}
const secret = smsConfig().codeSecret;
if (secret === undefined) {
log.error("[SmsAlertSearchNotify] SMS_CODE_SECRET is not set; skipping");
return { eligible: false, sent: 0, failed: 0, blocked: 0 };
}
const body = alertSearchSmsBody(digest.totalCount);
const listPhones = deps.listPhones ?? defaultListPhones;
const send = deps.send ?? sendSms;
const now = deps.now ?? new Date();
const utcDayStartIso =
utcCalendarDay(Math.floor(now.getTime() / 1000)) + "T00:00:00.000Z";
const phones = await listPhones(result.userId);
// The digest was worth sending; this instance is simply not allowed to send
// it. Recorded per number, so the log shows exactly what was withheld.
if (!isSmsRecipientAllowed(result.userId)) {
log.info(
"[SmsAlertSearchNotify] Recipient not on SMS_ALLOWED_RECIPIENT_DIDS:",
result.userId + ",",
"withheld=" + String(phones.length)
);
for (const phoneE164 of phones) {
await smsPhoneLogDb
.append({
userId: result.userId,
phoneE164,
phoneHash: hashPhoneNumber(phoneE164, secret),
action: "recipient-not-allowed",
result: "rejected",
detail: "SMS_ALLOWED_RECIPIENT_DIDS",
})
.catch(() => undefined);
}
return { eligible: true, sent: 0, failed: 0, blocked: phones.length };
}
let sent = 0;
let failed = 0;
let blocked = 0;
for (const phoneE164 of phones) {
const phoneHash = hashPhoneNumber(phoneE164, secret);
if (await isPhoneNumberBlocked({ phoneE164, phoneHash })) {
blocked += 1;
log.info(
"[SmsAlertSearchNotify] Number is blocked, withholding:",
maskPhoneNumber(phoneE164)
);
await smsPhoneLogDb
.append({
userId: result.userId,
phoneHash,
action: "number-blocked",
result: "rejected",
detail: "number is on the block list",
})
.catch(() => undefined);
continue;
}
const alreadySent = await smsPhoneLogDb.countByUserAndPhoneHashSince(
result.userId,
phoneHash,
["alert-sent"],
utcDayStartIso
);
if (alreadySent >= ALERT_SENDS_PER_PHONE_PER_DAY) {
log.info(
"[SmsAlertSearchNotify] Daily cap already met for",
maskPhoneNumber(phoneE164)
);
continue;
}
try {
const outcome = await send(phoneE164, body);
if (outcome.status === "sent") {
sent += 1;
await smsPhoneLogDb.append({
userId: result.userId,
phoneE164,
phoneHash,
action: "alert-sent",
result: "ok",
providerMessageId: outcome.messageId,
});
} else {
failed += 1;
await smsPhoneLogDb.append({
userId: result.userId,
phoneE164,
phoneHash,
action: "alert-send-failed",
result: "failed",
detail: outcome.error,
});
// Twilio is the authority on its own opt-out list. Recording its
// refusal stops this number being retried every day thereafter.
if (outcome.code === TWILIO_UNSUBSCRIBED_CODE) {
await recordProviderOptOut(phoneHash, phoneE164, result.userId);
}
}
} catch (err) {
failed += 1;
const detail = errorMessage(err);
log.error(
"[SmsAlertSearchNotify] Send threw for",
maskPhoneNumber(phoneE164) + ":",
detail
);
await smsPhoneLogDb
.append({
userId: result.userId,
phoneE164,
phoneHash,
action: "alert-send-failed",
result: "failed",
detail,
})
.catch(() => undefined);
}
}
return { eligible: true, sent, failed, blocked };
}
+198
View File
@@ -0,0 +1,198 @@
import { smsActionJwtUseDb } from "../db/smsActionJwtUseSqlite.js";
import type { PendingDayUser } from "../db/alertAuthorizationSqlite.js";
import { smsAlertAuthorizationDb } from "../db/smsAlertAuthorizationSqlite.js";
import { smsConfig } from "../env.js";
import {
utcCalendarDay,
utcHourMinute,
} from "../services/alertAuthorization.js";
import {
ALERT_SEARCH_USER_CONCURRENCY,
forEachWithConcurrency,
} from "../util/concurrency.js";
import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js";
import {
runDailyAlertSearch,
type DailyAlertSearchResult,
} from "./daily.js";
import { deliverAlertSearchSms } from "./smsNotify.js";
import { log } from "../util/log.js";
/** Keeps the SMS pass off the same instant as the FCM pass at Endorser. */
export const SMS_ALERT_SEARCH_INITIAL_OFFSET_MS = 150 * 1000;
/** Beyond this a token fails the freshness check anyway, so the row protects nothing. */
export const SMS_ACTION_JWT_RETENTION_MULTIPLE = 10;
export type SmsAlertSearchSchedulerPassInput = {
listPending?: (input: {
day: string;
hourMinute: string;
}) => Promise<PendingDayUser[]>;
runDaily?: (userId: string) => Promise<DailyAlertSearchResult>;
notify?: (result: DailyAlertSearchResult) => Promise<unknown>;
prune?: () => Promise<unknown>;
/** The instant this pass represents. Defaults to now. */
now?: Date;
/** Users worked on at once. Set to 1 for a deterministic order in tests. */
concurrency?: number;
};
export type SmsAlertSearchSchedulerPassResult = {
skipped: boolean;
/** The users this pass ran, in no guaranteed order. */
userIds: string[];
attempted: number;
failed: number;
/** Users holding an unused JWT whose chosen UTC time has not arrived yet. */
deferred: number;
};
let intervalId: ReturnType<typeof setInterval> | undefined;
let startTimeoutId: ReturnType<typeof setTimeout> | undefined;
let passInFlight = false;
export function isSmsAlertSearchSchedulerPassInFlight(): boolean {
return passInFlight;
}
async function defaultPrune(): Promise<void> {
const cutoff = new Date(
Date.now() -
smsConfig().actionJwtMaxAgeSec *
SMS_ACTION_JWT_RETENTION_MULTIPLE *
1000
).toISOString();
await smsActionJwtUseDb.pruneOlderThan(cutoff);
}
/**
* One SMS-channel alertSearch pass over the users who have work today and whose
* chosen hour has arrived. Skips if a pass is already running; the guard is
* process-local, so a second replica double-texts.
*/
export async function runSmsAlertSearchSchedulerPass(
input: SmsAlertSearchSchedulerPassInput = {}
): Promise<SmsAlertSearchSchedulerPassResult> {
if (passInFlight) {
log.info("[SmsAlertSearchScheduler] Pass skipped (already in flight)");
return { skipped: true, userIds: [], attempted: 0, failed: 0, deferred: 0 };
}
passInFlight = true;
const passStarted = Date.now();
log.info("[SmsAlertSearchScheduler] Pass started");
try {
const now = input.now ?? new Date();
const runDaily =
input.runDaily ??
((userId: string) => runDailyAlertSearch(userId, now, {}, "sms"));
const notify = input.notify ?? deliverAlertSearchSms;
const prune = input.prune ?? defaultPrune;
const listPending =
input.listPending ??
((query: { day: string; hourMinute: string }) =>
smsAlertAuthorizationDb.listPendingForDay(query));
try {
await prune();
} catch (err) {
log.error(
"[SmsAlertSearchScheduler] Prune failed:",
errorMessage(err)
);
}
const nowSec = Math.floor(now.getTime() / 1000);
const pending = await listPending({
day: utcCalendarDay(nowSec),
hourMinute: utcHourMinute(nowSec),
});
const userIds = pending.filter((row) => row.due).map((row) => row.userId);
const deferred = pending.length - userIds.length;
let failed = 0;
await forEachWithConcurrency(
userIds,
input.concurrency ?? ALERT_SEARCH_USER_CONCURRENCY,
async (userId) => {
try {
const daily = await runDaily(userId);
try {
await notify(daily);
} catch (err) {
log.error(
"[SmsAlertSearchScheduler] Notification failed",
userId + ":",
errorMessage(err)
);
}
} catch (err) {
failed += 1;
log.error(
"[SmsAlertSearchScheduler] User failed",
userId + ":",
errorMessage(err)
);
}
}
);
log.info(
"[SmsAlertSearchScheduler] Pass completed in",
formatElapsedMs(Date.now() - passStarted) + ",",
`attempted ${userIds.length}, deferred ${deferred}, failed ${failed}`
);
return {
skipped: false,
userIds,
attempted: userIds.length,
failed,
deferred,
};
} catch (err) {
log.error(
"[SmsAlertSearchScheduler] Pass failed in",
formatElapsedMs(Date.now() - passStarted) + ":",
errorMessage(err)
);
throw err;
} finally {
passInFlight = false;
}
}
/**
* Starts a dedicated interval, offset from the FCM pass. Does not run a pass
* immediately. Calling twice is a no-op.
*/
export function startSmsAlertSearchScheduler(): boolean {
if (intervalId !== undefined || startTimeoutId !== undefined) return false;
const intervalMs = smsConfig().alertSearchIntervalMs;
startTimeoutId = setTimeout(() => {
startTimeoutId = undefined;
intervalId = setInterval(() => {
void runSmsAlertSearchSchedulerPass();
}, intervalMs);
}, SMS_ALERT_SEARCH_INITIAL_OFFSET_MS);
startTimeoutId.unref?.();
return true;
}
export function stopSmsAlertSearchScheduler(): void {
if (startTimeoutId !== undefined) {
clearTimeout(startTimeoutId);
startTimeoutId = undefined;
}
if (intervalId !== undefined) {
clearInterval(intervalId);
intervalId = undefined;
}
}
/** Test helper: drop the in-flight flag after an interrupted pass. */
export function resetSmsAlertSearchSchedulerPassGuard(): void {
passInFlight = false;
}
+115
View File
@@ -0,0 +1,115 @@
/**
* Which delivery channel a run belongs to. Each channel keeps its own JWT
* inventory and its own cursor, so the two never consume each other's delta.
*/
export type AlertSearchChannel = "fcm" | "sms";
/**
* Raw alertSearch contract types, aligned with endorser-ch and the app
* `interfaces/alertSearch` module. These are API envelopes, not a digest model.
*/
/** Server-issued ULID on a stored JWT/plan record. Not an auth or delegated JWT. */
export type AlertSearchCursorUlid = string;
export type AlertSearchLocationBBox = {
minLocLat: number;
maxLocLat: number;
minLocLon: number;
maxLocLon: number;
};
export type AlertSearchClaimRecord = {
id: AlertSearchCursorUlid;
issuedAt: string;
issuer: string;
subject?: string;
claimType?: string;
handleId?: string;
fromEntity?: string;
toEntity?: string;
[key: string]: unknown;
};
export type AlertSearchJwtWithClaimRecord = AlertSearchClaimRecord & {
claim?: string;
};
export type AlertSearchPlanRecord = {
handleId: string;
jwtId: AlertSearchCursorUlid;
issuerDid?: string;
agentDid?: string;
fulfillsLinkConfirmed?: boolean | number;
fulfillsPlanClaimId?: string;
fulfillsPlanHandleId?: string;
name?: string;
description?: string;
image?: string;
endTime?: string;
startTime?: string;
locLat?: number;
locLon?: number;
resultDescription?: string;
resultIdentifier?: string;
url?: string;
[key: string]: unknown;
};
export type AlertSearchProfileRecord = {
rowId?: number;
issuerDid: string;
updatedAt?: string;
description: string;
locLat?: number;
locLon?: number;
locLat2?: number;
locLon2?: number;
[key: string]: unknown;
};
export type EndorserAlertSearchData = {
claims: AlertSearchClaimRecord[];
personalPlanContributions: AlertSearchJwtWithClaimRecord[];
trackedPlanUpdates: AlertSearchPlanRecord[];
trackedPlanClaims: AlertSearchJwtWithClaimRecord[];
plansNearby: AlertSearchPlanRecord[];
};
export type PartnerAlertSearchData = {
profilesNearby: AlertSearchProfileRecord[];
};
export type CombinedAlertSearchData = EndorserAlertSearchData &
PartnerAlertSearchData;
export type EndorserAlertSearchResponse = {
data: EndorserAlertSearchData;
userMessage?: string;
};
export type PartnerAlertSearchResponse = {
data: PartnerAlertSearchData;
userMessage?: string;
};
export const ENDORSER_ALERT_SEARCH_PATH = "/api/v2/report/alertSearch";
export const PARTNER_ALERT_SEARCH_PATH = "/api/partner/alertSearch";
/** Matches endorser-ch / partner DEFAULT_LIMIT. Not a JSON envelope field. */
export const ALERT_SEARCH_PAGE_SIZE = 50;
export type AlertSearchFailureReason =
| "auth"
| "timeout"
| "network"
| "malformed"
| "pagination"
| "http";
export type AlertSearchSource = "endorser" | "partner";
export type AlertSearchQueryOutcome =
| "success"
| "empty"
| AlertSearchFailureReason;
+457
View File
@@ -0,0 +1,457 @@
import { randomUUID } from "node:crypto";
import {
formatHourMinuteUtc,
parseHourMinuteUtc,
} from "../services/alertAuthorization.js";
import { getDatabase } from "./sqlite.js";
export const ALERT_JWT_STATUS_UNUSED = "unused";
export const ALERT_JWT_STATUS_CONSUMED = "consumed";
export const EXPECTED_ALERT_JWT_BATCH_SIZE = 100;
export type AlertAuthorizationBatchRecord = {
id: string;
userId: string;
batchId: string;
/**
* UTC hour and minute the user asked to be notified at, or undefined for no
* gate. The column holds them as one `HH:MM` string, because that is the form
* SQLite compares chronologically; TypeScript never sees that string.
*/
notifyHourUtc?: number;
notifyMinuteUtc?: number;
/** IANA zone the caller's offset came from. Recorded only; nothing reads it. */
timezone?: string;
createdAt: string;
};
export type AlertAuthorizationJwtRecord = {
id: string;
batchPk: string;
userId: string;
batchId: string;
sequence: number;
day: string;
jwt: string;
nbf: number;
exp: number;
status: string;
consumedAt?: string;
createdAt: string;
};
/** One user with an unused JWT for a given day, and whether their hour has come. */
export type PendingDayUser = {
userId: string;
due: boolean;
};
export type AlertAuthorizationJwtInput = {
sequence: number;
day: string;
jwt: string;
nbf: number;
exp: number;
};
type BatchDbRow = {
id: string;
user_id: string;
batch_id: string;
notify_hour_min_utc: string | null;
timezone: string | null;
created_at: string;
};
type JwtDbRow = {
id: string;
batch_pk: string;
user_id: string;
batch_id: string;
sequence: number;
day: string;
jwt: string;
nbf: number;
exp: number;
status: string;
consumed_at: string | null;
created_at: string;
};
const JWT_COLUMNS =
"id, batch_pk, user_id, batch_id, sequence, day, jwt, nbf, exp, status, consumed_at, created_at";
function toJwtRecord(row: JwtDbRow): AlertAuthorizationJwtRecord {
return {
id: row.id,
batchPk: row.batch_pk,
userId: row.user_id,
batchId: row.batch_id,
sequence: row.sequence,
day: row.day,
jwt: row.jwt,
nbf: row.nbf,
exp: row.exp,
status: row.status,
consumedAt: row.consumed_at ?? undefined,
createdAt: row.created_at,
};
}
const BATCH_COLUMNS =
"id, user_id, batch_id, notify_hour_min_utc, timezone, created_at";
/** The stored column value for a batch, or null when it names no hour. */
function storedHourMinute(input: {
notifyHourUtc?: number;
notifyMinuteUtc?: number;
}): string | null {
if (input.notifyHourUtc === undefined || input.notifyMinuteUtc === undefined) {
return null;
}
return formatHourMinuteUtc(input.notifyHourUtc, input.notifyMinuteUtc);
}
/**
* The column back into a pair. An unreadable value yields neither half rather
* than a plausible-looking one, matching the selection query, which treats a
* value it cannot read as ungated rather than deferring that user forever.
*/
function splitStoredHourMinute(
value: string | null
): { notifyHourUtc?: number; notifyMinuteUtc?: number } {
const parsed = value === null ? undefined : parseHourMinuteUtc(value);
if (parsed === undefined) return {};
return { notifyHourUtc: parsed.hour, notifyMinuteUtc: parsed.minute };
}
function toBatchRecord(row: BatchDbRow): AlertAuthorizationBatchRecord {
return {
id: row.id,
userId: row.user_id,
batchId: row.batch_id,
...splitStoredHourMinute(row.notify_hour_min_utc),
timezone: row.timezone ?? undefined,
createdAt: row.created_at,
};
}
/**
* Which pair of tables a store instance reads and writes. The SMS channel keeps
* its own inventory so its credentials and failures are independent of push.
*/
export type AlertAuthorizationTables = {
batches: string;
jwts: string;
};
export const FCM_ALERT_AUTHORIZATION_TABLES: AlertAuthorizationTables = {
batches: "alert_authorization_batches",
jwts: "alert_authorization_jwts",
};
export type AlertAuthorizationStore = ReturnType<
typeof createAlertAuthorizationStore
>;
/**
* Builds a store over one pair of tables. Table names come from this module's
* own constants, never from request input.
*/
export function createAlertAuthorizationStore(tables: AlertAuthorizationTables) {
return {
/**
* Atomically drop this user's unused JWTs (and empty batch rows), then install
* a new batch. Consumed JWTs from prior batches are left in place.
*/
async replaceUnusedBatch(input: {
userId: string;
batchId: string;
notifyHourUtc?: number;
notifyMinuteUtc?: number;
timezone?: string;
jwts: AlertAuthorizationJwtInput[];
}): Promise<{
batch: AlertAuthorizationBatchRecord;
storedCount: number;
unusedCount: number;
}> {
const connection = getDatabase();
const now = new Date().toISOString();
const batchPk = randomUUID();
const run = connection.transaction(() => {
connection
.prepare(
`
DELETE FROM ${tables.jwts}
WHERE user_id = ? AND status = ?
`
)
.run(input.userId, ALERT_JWT_STATUS_UNUSED);
connection
.prepare(
`
DELETE FROM ${tables.batches}
WHERE user_id = ?
AND id NOT IN (
SELECT DISTINCT batch_pk FROM ${tables.jwts}
WHERE user_id = ?
)
`
)
.run(input.userId, input.userId);
connection
.prepare(
`
INSERT INTO ${tables.batches} (
id, user_id, batch_id, notify_hour_min_utc, timezone, created_at
) VALUES (?, ?, ?, ?, ?, ?)
`
)
.run(
batchPk,
input.userId,
input.batchId,
storedHourMinute(input),
input.timezone ?? null,
now
);
const insertJwt = connection.prepare(
`
INSERT INTO ${tables.jwts} (
id, batch_pk, user_id, batch_id, sequence, day, jwt,
nbf, exp, status, consumed_at, created_at
) VALUES (
@id, @batch_pk, @user_id, @batch_id, @sequence, @day, @jwt,
@nbf, @exp, @status, @consumed_at, @created_at
)
`
);
for (const item of input.jwts) {
insertJwt.run({
id: randomUUID(),
batch_pk: batchPk,
user_id: input.userId,
batch_id: input.batchId,
sequence: item.sequence,
day: item.day,
jwt: item.jwt,
nbf: item.nbf,
exp: item.exp,
status: ALERT_JWT_STATUS_UNUSED,
consumed_at: null,
created_at: now,
});
}
});
run();
const unusedCount = await this.countUnused(input.userId);
return {
batch: {
id: batchPk,
userId: input.userId,
batchId: input.batchId,
notifyHourUtc: input.notifyHourUtc,
notifyMinuteUtc: input.notifyMinuteUtc,
timezone: input.timezone,
createdAt: now,
},
storedCount: input.jwts.length,
unusedCount,
};
},
async getUnusedForDay(
userId: string,
day: string
): Promise<AlertAuthorizationJwtRecord | undefined> {
const row = getDatabase()
.prepare(
`
SELECT ${JWT_COLUMNS} FROM ${tables.jwts}
WHERE user_id = ? AND day = ? AND status = ?
LIMIT 1
`
)
.get(userId, day, ALERT_JWT_STATUS_UNUSED) as JwtDbRow | undefined;
return row === undefined ? undefined : toJwtRecord(row);
},
async countUnused(userId: string): Promise<number> {
const row = getDatabase()
.prepare(
`
SELECT COUNT(*) AS n FROM ${tables.jwts}
WHERE user_id = ? AND status = ?
`
)
.get(userId, ALERT_JWT_STATUS_UNUSED) as { n: number };
return row.n;
},
/**
* The users this channel has work for on `day`, each flagged with whether
* their chosen hour has arrived. One query for the whole pass: a scheduler
* that asked per user would spend most of its ticks paying a round trip to
* be told "nothing to do".
*
* A user appears only while holding an unused JWT for that day, so one that
* has already run drops out until the day rolls. `hourMinute` is compared as
* text, which is chronological because `HH:MM` is zero-padded.
*
* `rowid` breaks a `created_at` tie: two batches uploaded inside the same
* millisecond carry the same ISO timestamp, and `id` is a random UUID, so
* ordering by that would pick the newest batch by coin flip.
*/
async listPendingForDay(input: {
day: string;
hourMinute: string;
}): Promise<PendingDayUser[]> {
const rows = getDatabase()
.prepare(
`
WITH latest AS (
SELECT
user_id,
notify_hour_min_utc,
ROW_NUMBER() OVER (
PARTITION BY user_id ORDER BY created_at DESC, rowid DESC
) AS rn
FROM ${tables.batches}
)
SELECT DISTINCT
latest.user_id AS user_id,
CASE
WHEN latest.notify_hour_min_utc IS NULL THEN 1
-- A value this comparison cannot read must not silence the user.
-- Text ordering would rank anything non-numeric above every
-- 'HH:MM', which would defer such a row forever rather than once.
WHEN latest.notify_hour_min_utc NOT GLOB '[0-2][0-9]:[0-5][0-9]' THEN 1
WHEN latest.notify_hour_min_utc <= @hourMinute THEN 1
ELSE 0
END AS due
FROM latest
JOIN ${tables.jwts} AS jwts
ON jwts.user_id = latest.user_id
AND jwts.day = @day
AND jwts.status = @unused
WHERE latest.rn = 1
ORDER BY latest.user_id
`
)
.all({
day: input.day,
hourMinute: input.hourMinute,
unused: ALERT_JWT_STATUS_UNUSED,
}) as { user_id: string; due: number }[];
return rows.map((row) => ({ userId: row.user_id, due: row.due === 1 }));
},
async listDistinctUserIds(): Promise<string[]> {
const rows = getDatabase()
.prepare(
`
SELECT DISTINCT user_id
FROM ${tables.batches}
ORDER BY user_id
`
)
.all() as { user_id: string }[];
return rows.map((row) => row.user_id);
},
async getLatestBatch(
userId: string
): Promise<AlertAuthorizationBatchRecord | undefined> {
const row = getDatabase()
.prepare(
`
SELECT ${BATCH_COLUMNS}
FROM ${tables.batches}
WHERE user_id = ?
ORDER BY created_at DESC, rowid DESC
LIMIT 1
`
)
.get(userId) as BatchDbRow | undefined;
return row === undefined ? undefined : toBatchRecord(row);
},
async getJwtById(
id: string
): Promise<AlertAuthorizationJwtRecord | undefined> {
const row = getDatabase()
.prepare(
`
SELECT ${JWT_COLUMNS} FROM ${tables.jwts}
WHERE id = ?
`
)
.get(id) as JwtDbRow | undefined;
return row === undefined ? undefined : toJwtRecord(row);
},
/**
* Drop every batch and every JWT this user holds in this channel, consumed
* rows included, so the scheduler stops listing them. Alert-search cursors
* are left alone: a later re-authorization resumes where this one stopped
* instead of replaying months of history.
*/
async deleteAllForUser(
userId: string
): Promise<{ deletedBatches: number; deletedJwts: number }> {
const connection = getDatabase();
let deletedJwts = 0;
let deletedBatches = 0;
const run = connection.transaction(() => {
deletedJwts = connection
.prepare(`DELETE FROM ${tables.jwts} WHERE user_id = ?`)
.run(userId).changes;
deletedBatches = connection
.prepare(`DELETE FROM ${tables.batches} WHERE user_id = ?`)
.run(userId).changes;
});
run();
return { deletedBatches, deletedJwts };
},
/**
* Mark one unused JWT consumed. Matches the specific row, not "any unused for today".
*/
async consumeUnusedJwt(input: {
id: string;
userId: string;
}): Promise<boolean> {
const now = new Date().toISOString();
const result = getDatabase()
.prepare(
`
UPDATE ${tables.jwts}
SET status = ?, consumed_at = ?
WHERE id = ? AND user_id = ? AND status = ?
`
)
.run(
ALERT_JWT_STATUS_CONSUMED,
now,
input.id,
input.userId,
ALERT_JWT_STATUS_UNUSED
);
return result.changes === 1;
},
};
}
export const alertAuthorizationDb: AlertAuthorizationStore =
createAlertAuthorizationStore(FCM_ALERT_AUTHORIZATION_TABLES);
+114
View File
@@ -0,0 +1,114 @@
import { getDatabase } from "./sqlite.js";
export type AlertSearchCursorRecord = {
userId: string;
endorserAfterId: string | null;
partnerAfterAt: string | null;
createdAt: string;
updatedAt: string;
};
type CursorDbRow = {
user_id: string;
endorser_after_id: string | null;
partner_after_at: string | null;
created_at: string;
updated_at: string;
};
function toRecord(row: CursorDbRow): AlertSearchCursorRecord {
return {
userId: row.user_id,
endorserAfterId: row.endorser_after_id,
partnerAfterAt: row.partner_after_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export const FCM_ALERT_SEARCH_CURSORS_TABLE = "alert_search_cursors";
export const SMS_ALERT_SEARCH_CURSORS_TABLE = "sms_alert_search_cursors";
export type AlertSearchCursorsStore = ReturnType<
typeof createAlertSearchCursorsStore
>;
function ensureRow(table: string, userId: string, now: string): void {
getDatabase()
.prepare(
`
INSERT INTO ${table} (
user_id, endorser_after_id, partner_after_at, created_at, updated_at
) VALUES (?, NULL, NULL, ?, ?)
ON CONFLICT(user_id) DO NOTHING
`
)
.run(userId, now, now);
}
/**
* Builds a store over one cursor table. Each channel runs its own daily
* retrieval, so sharing one row would let whichever fired first consume the
* delta and leave the other reporting nothing.
*/
export function createAlertSearchCursorsStore(table: string) {
return {
async get(
userId: string
): Promise<AlertSearchCursorRecord | undefined> {
const row = getDatabase()
.prepare(
`
SELECT user_id, endorser_after_id, partner_after_at, created_at, updated_at
FROM ${table}
WHERE user_id = ?
`
)
.get(userId) as CursorDbRow | undefined;
return row === undefined ? undefined : toRecord(row);
},
async setEndorserAfterId(userId: string, afterId: string): Promise<void> {
const now = new Date().toISOString();
const connection = getDatabase();
const run = connection.transaction(() => {
ensureRow(table, userId, now);
connection
.prepare(
`
UPDATE ${table}
SET endorser_after_id = ?, updated_at = ?
WHERE user_id = ?
`
)
.run(afterId, now, userId);
});
run();
},
async setPartnerAfterAt(userId: string, afterAt: string): Promise<void> {
const now = new Date().toISOString();
const connection = getDatabase();
const run = connection.transaction(() => {
ensureRow(table, userId, now);
connection
.prepare(
`
UPDATE ${table}
SET partner_after_at = ?, updated_at = ?
WHERE user_id = ?
`
)
.run(afterAt, now, userId);
});
run();
},
};
}
export const alertSearchCursorsDb: AlertSearchCursorsStore =
createAlertSearchCursorsStore(FCM_ALERT_SEARCH_CURSORS_TABLE);
/** The SMS channel's own cursor row per DID. */
export const smsAlertSearchCursorsDb: AlertSearchCursorsStore =
createAlertSearchCursorsStore(SMS_ALERT_SEARCH_CURSORS_TABLE);
+219
View File
@@ -0,0 +1,219 @@
import { randomUUID } from "node:crypto";
import { getDatabase } from "./sqlite.js";
/**
* SQLite-backed FCM registration repository.
* This is the service's storage backend.
*/
export type StoredRow = {
id: string;
userId: string;
deviceId: string;
fcmToken: string;
platform: string;
testMode?: boolean;
createdAt: string;
updatedAt: string;
lastNotifiedAt?: number;
};
type DbRow = {
id: string;
user_id: string;
device_id: string;
fcm_token: string;
platform: string;
test_mode: number | null;
created_at: string;
updated_at: string;
last_notified_at: number | null;
};
function toStoredRow(row: DbRow): StoredRow {
return {
id: row.id,
userId: row.user_id,
deviceId: row.device_id,
fcmToken: row.fcm_token,
platform: row.platform,
testMode: row.test_mode === null ? undefined : row.test_mode !== 0,
createdAt: row.created_at,
updatedAt: row.updated_at,
lastNotifiedAt:
row.last_notified_at === null ? undefined : row.last_notified_at,
};
}
function testModeToDb(testMode: boolean | undefined): number | null {
if (testMode === undefined) return null;
return testMode ? 1 : 0;
}
/** Columns required to build a StoredRow. */
const ROW_COLUMNS =
"id, user_id, device_id, fcm_token, platform, test_mode, created_at, updated_at, last_notified_at";
export const db = {
async upsert(row: {
userId: string;
deviceId: string;
fcmToken: string;
platform: string;
testMode?: boolean;
updatedAt: Date;
}): Promise<void> {
const connection = getDatabase();
const now = row.updatedAt.toISOString();
// No pre-read: ON CONFLICT preserves id, created_at, and last_notified_at.
const run = connection.transaction(() => {
connection
.prepare(
`
INSERT INTO fcm_registrations (
id, user_id, device_id, fcm_token, platform, test_mode,
created_at, updated_at, last_notified_at
) VALUES (
@id, @user_id, @device_id, @fcm_token, @platform, @test_mode,
@created_at, @updated_at, @last_notified_at
)
ON CONFLICT(user_id, device_id) DO UPDATE SET
fcm_token = excluded.fcm_token,
platform = excluded.platform,
test_mode = excluded.test_mode,
updated_at = excluded.updated_at
`
)
.run({
id: randomUUID(),
user_id: row.userId,
device_id: row.deviceId,
fcm_token: row.fcmToken,
platform: row.platform,
test_mode: testModeToDb(row.testMode),
created_at: now,
updated_at: now,
last_notified_at: null,
});
connection
.prepare(
`
DELETE FROM fcm_registrations
WHERE user_id = ? AND fcm_token = ? AND device_id != ?
`
)
.run(row.userId, row.fcmToken, row.deviceId);
});
run();
},
async getAll(): Promise<StoredRow[]> {
const rows = getDatabase()
.prepare(`SELECT ${ROW_COLUMNS} FROM fcm_registrations`)
.all() as DbRow[];
return rows.map(toStoredRow);
},
/** Scheduler iteration; excludes `__legacy__` rows. */
async getAllForScheduler(): Promise<StoredRow[]> {
const rows = getDatabase()
.prepare(
`SELECT ${ROW_COLUMNS} FROM fcm_registrations WHERE user_id != '__legacy__'`
)
.all() as DbRow[];
return rows.map(toStoredRow);
},
/**
* Resolve a device owned by userId via deviceId and/or fcmToken.
* When both are given, they must refer to the same row.
*/
async resolveOwnedDevice(
userId: string,
query: { deviceId?: string; fcmToken?: string }
): Promise<StoredRow | undefined> {
const deviceId = query.deviceId?.trim();
const fcmToken = query.fcmToken;
if (deviceId !== undefined && deviceId.length > 0) {
const byDevice = await this.getByDeviceId(userId, deviceId);
if (byDevice === undefined) return undefined;
if (
fcmToken !== undefined &&
fcmToken.length > 0 &&
byDevice.fcmToken !== fcmToken
) {
return undefined;
}
return byDevice;
}
if (fcmToken !== undefined && fcmToken.length > 0) {
return this.getByFcmTokenForUser(userId, fcmToken);
}
return undefined;
},
async getByUserId(userId: string): Promise<StoredRow[]> {
const rows = getDatabase()
.prepare(
`SELECT ${ROW_COLUMNS} FROM fcm_registrations WHERE user_id = ?`
)
.all(userId) as DbRow[];
return rows.map(toStoredRow);
},
async getByDeviceId(
userId: string,
deviceId: string
): Promise<StoredRow | undefined> {
const row = getDatabase()
.prepare(
`SELECT ${ROW_COLUMNS} FROM fcm_registrations WHERE user_id = ? AND device_id = ?`
)
.get(userId, deviceId) as DbRow | undefined;
return row === undefined ? undefined : toStoredRow(row);
},
async getByFcmToken(fcmToken: string): Promise<StoredRow | undefined> {
// Prefer non-legacy rows; within that pool, newest updated_at wins.
const row = getDatabase()
.prepare(
`
SELECT ${ROW_COLUMNS} FROM fcm_registrations
WHERE fcm_token = ?
ORDER BY (user_id = '__legacy__') ASC, updated_at DESC
LIMIT 1
`
)
.get(fcmToken) as DbRow | undefined;
return row === undefined ? undefined : toStoredRow(row);
},
async getByFcmTokenForUser(
userId: string,
fcmToken: string
): Promise<StoredRow | undefined> {
const row = getDatabase()
.prepare(
`
SELECT ${ROW_COLUMNS} FROM fcm_registrations
WHERE user_id = ? AND fcm_token = ?
LIMIT 1
`
)
.get(userId, fcmToken) as DbRow | undefined;
return row === undefined ? undefined : toStoredRow(row);
},
async update(id: string, patch: { lastNotifiedAt: number }): Promise<void> {
getDatabase()
.prepare(
`UPDATE fcm_registrations SET last_notified_at = ? WHERE id = ?`
)
.run(patch.lastNotifiedAt, id);
},
};
+57
View File
@@ -0,0 +1,57 @@
import { randomUUID } from "node:crypto";
import { getDatabase } from "./sqlite.js";
/** Keyed on the token hash, so two identities acting on one number never collide. */
export const smsActionJwtUseDb = {
/**
* Claim a token for one action. Returns false when the hash is already
* present. The insert itself is the check, so two concurrent requests
* carrying one token cannot both win.
*/
async claim(input: {
jwtHash: string;
userId: string;
action: string;
}): Promise<boolean> {
try {
getDatabase()
.prepare(
`
INSERT INTO sms_action_jwt_use (id, jwt_hash, user_id, action, used_at)
VALUES (?, ?, ?, ?, ?)
`
)
.run(
randomUUID(),
input.jwtHash,
input.userId,
input.action,
new Date().toISOString()
);
return true;
} catch (err) {
if (
err instanceof Error &&
err.message.includes("UNIQUE constraint failed")
) {
return false;
}
throw err;
}
},
/** A token this stale fails the freshness check anyway, so the row protects nothing. */
async pruneOlderThan(cutoffIso: string): Promise<number> {
const result = getDatabase()
.prepare(`DELETE FROM sms_action_jwt_use WHERE used_at < ?`)
.run(cutoffIso);
return result.changes;
},
async count(): Promise<number> {
const row = getDatabase()
.prepare(`SELECT COUNT(*) AS n FROM sms_action_jwt_use`)
.get() as { n: number };
return row.n;
},
};
+17
View File
@@ -0,0 +1,17 @@
import {
createAlertAuthorizationStore,
type AlertAuthorizationStore,
type AlertAuthorizationTables,
} from "./alertAuthorizationSqlite.js";
export const SMS_ALERT_AUTHORIZATION_TABLES: AlertAuthorizationTables = {
batches: "sms_alert_authorization_batches",
jwts: "sms_alert_authorization_jwts",
};
/**
* The SMS channel's own JWT inventory. Same behavior as the FCM store over its
* own tables, so an SMS batch upload never disturbs push credentials.
*/
export const smsAlertAuthorizationDb: AlertAuthorizationStore =
createAlertAuthorizationStore(SMS_ALERT_AUTHORIZATION_TABLES);
+158
View File
@@ -0,0 +1,158 @@
import { randomUUID } from "node:crypto";
import { getDatabase } from "./sqlite.js";
/**
* Who put a number on the list.
*
* `opt-out` the handset texted STOP to this service
* `provider-opt-out` Twilio refused a send with 21610, so the number is on its
* opt-out list — it opted out somewhere we did not observe
* `manual` an operator decided
*/
export type SmsBlockReason = "opt-out" | "provider-opt-out" | "manual";
/**
* A re-block never weakens an existing claim. An operator re-blocking a number
* that had already opted out must not erase the opt-out, because that is the
* record that says the block may not simply be lifted again.
*/
const REASON_RANK: Record<SmsBlockReason, number> = {
"opt-out": 2,
"provider-opt-out": 1,
manual: 0,
};
export function strongerBlockReason(
existing: SmsBlockReason | undefined,
incoming: SmsBlockReason
): SmsBlockReason {
if (existing === undefined) return incoming;
return REASON_RANK[incoming] > REASON_RANK[existing] ? incoming : existing;
}
export type SmsBlockedNumber = {
id: string;
/** The matching key, so a block survives the DELETE scrub of phone_e164. */
phoneHash: string;
/**
* Retained deliberately. A suppression list nobody can read is one nobody can
* audit or correct, and honoring an opt-out means remembering who asked.
*/
phoneE164?: string;
reason: SmsBlockReason;
detail?: string;
createdAt: string;
updatedAt: string;
};
type DbRow = {
id: string;
phone_hash: string;
phone_e164: string | null;
reason: string;
detail: string | null;
created_at: string;
updated_at: string;
};
const ROW_COLUMNS =
"id, phone_hash, phone_e164, reason, detail, created_at, updated_at";
function toRecord(row: DbRow): SmsBlockedNumber {
return {
id: row.id,
phoneHash: row.phone_hash,
phoneE164: row.phone_e164 ?? undefined,
reason: row.reason as SmsBlockReason,
detail: row.detail ?? undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
/**
* Numbers this service must not text, whatever any registration says. Keyed on
* the HMAC of the number so the entry outlives a DELETE, and so a lookup needs
* only the same secret every other phone hash uses.
*/
export const smsBlockedNumbersDb = {
async isBlocked(phoneHash: string): Promise<boolean> {
const row = getDatabase()
.prepare(`SELECT 1 AS n FROM sms_blocked_numbers WHERE phone_hash = ?`)
.get(phoneHash) as { n: number } | undefined;
return row !== undefined;
},
async get(phoneHash: string): Promise<SmsBlockedNumber | undefined> {
const row = getDatabase()
.prepare(
`SELECT ${ROW_COLUMNS} FROM sms_blocked_numbers WHERE phone_hash = ?`
)
.get(phoneHash) as DbRow | undefined;
return row === undefined ? undefined : toRecord(row);
},
/**
* Idempotent. A second block refreshes the row, keeping whichever reason
* carries the stronger claim, so origin is never downgraded.
*/
async block(input: {
phoneHash: string;
phoneE164?: string;
reason: SmsBlockReason;
detail?: string;
}): Promise<SmsBlockedNumber> {
const now = new Date().toISOString();
const existing = await this.get(input.phoneHash);
const reason = strongerBlockReason(existing?.reason, input.reason);
getDatabase()
.prepare(
`
INSERT INTO sms_blocked_numbers (
id, phone_hash, phone_e164, reason, detail, created_at, updated_at
) VALUES (@id, @phone_hash, @phone_e164, @reason, @detail, @created_at, @updated_at)
ON CONFLICT(phone_hash) DO UPDATE SET
phone_e164 = COALESCE(excluded.phone_e164, phone_e164),
reason = excluded.reason,
-- excluded.reason is already the stronger of the two, resolved above.
detail = excluded.detail,
updated_at = excluded.updated_at
`
)
.run({
id: randomUUID(),
phone_hash: input.phoneHash,
phone_e164: input.phoneE164 ?? null,
reason,
detail: input.detail ?? null,
created_at: now,
updated_at: now,
});
const stored = await this.get(input.phoneHash);
if (stored === undefined) {
throw new Error("sms_blocked_numbers insert did not produce a row");
}
return stored;
},
/** Returns whether a row was actually removed. */
async unblock(phoneHash: string): Promise<boolean> {
const result = getDatabase()
.prepare(`DELETE FROM sms_blocked_numbers WHERE phone_hash = ?`)
.run(phoneHash);
return result.changes > 0;
},
async list(limit = 200): Promise<SmsBlockedNumber[]> {
const rows = getDatabase()
.prepare(
`
SELECT ${ROW_COLUMNS} FROM sms_blocked_numbers
ORDER BY created_at DESC
LIMIT ?
`
)
.all(limit) as DbRow[];
return rows.map(toRecord);
},
};
+191
View File
@@ -0,0 +1,191 @@
import { randomUUID } from "node:crypto";
import type {
SmsPhoneLogAction,
SmsPhoneLogEntry,
SmsPhoneLogResult,
} from "../models/smsRegistration.js";
import { getDatabase } from "./sqlite.js";
type DbRow = {
id: string;
user_id: string;
phone_e164: string | null;
phone_hash: string;
action: string;
result: string;
detail: string | null;
jwt_hash: string | null;
provider_message_id: string | null;
created_at: string;
};
const ROW_COLUMNS =
"id, user_id, phone_e164, phone_hash, action, result, detail, " +
"jwt_hash, provider_message_id, created_at";
function toRecord(row: DbRow): SmsPhoneLogEntry {
return {
id: row.id,
userId: row.user_id,
phoneE164: row.phone_e164 ?? undefined,
phoneHash: row.phone_hash,
action: row.action as SmsPhoneLogAction,
result: row.result as SmsPhoneLogResult,
detail: row.detail ?? undefined,
jwtHash: row.jwt_hash ?? undefined,
providerMessageId: row.provider_message_id ?? undefined,
createdAt: row.created_at,
};
}
export type SmsPhoneLogInput = {
userId: string;
phoneE164?: string;
phoneHash: string;
action: SmsPhoneLogAction;
result: SmsPhoneLogResult;
detail?: string;
jwtHash?: string;
providerMessageId?: string;
};
/** Append-only record of every phone action, plus the counts the throttles read. */
export const smsPhoneLogDb = {
async append(input: SmsPhoneLogInput): Promise<SmsPhoneLogEntry> {
const now = new Date().toISOString();
const id = randomUUID();
getDatabase()
.prepare(
`
INSERT INTO sms_phone_log (
id, user_id, phone_e164, phone_hash, action, result,
detail, jwt_hash, provider_message_id, created_at
) VALUES (
@id, @user_id, @phone_e164, @phone_hash, @action, @result,
@detail, @jwt_hash, @provider_message_id, @created_at
)
`
)
.run({
id,
user_id: input.userId,
phone_e164: input.phoneE164 ?? null,
phone_hash: input.phoneHash,
action: input.action,
result: input.result,
detail: input.detail ?? null,
jwt_hash: input.jwtHash ?? null,
provider_message_id: input.providerMessageId ?? null,
created_at: now,
});
return {
id,
userId: input.userId,
phoneE164: input.phoneE164,
phoneHash: input.phoneHash,
action: input.action,
result: input.result,
detail: input.detail,
jwtHash: input.jwtHash,
providerMessageId: input.providerMessageId,
createdAt: now,
};
},
async listByUserId(
userId: string,
limit = 100
): Promise<SmsPhoneLogEntry[]> {
const rows = getDatabase()
.prepare(
`
SELECT ${ROW_COLUMNS} FROM sms_phone_log
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT ?
`
)
.all(userId, limit) as DbRow[];
return rows.map(toRecord);
},
/**
* Counted from phone_hash, not user_id: a per-identity counter is defeated by
* minting more identities, so this throttle is deliberately cross-DID.
*/
async countByPhoneHashSince(
phoneHash: string,
actions: SmsPhoneLogAction[],
sinceIso: string
): Promise<number> {
if (actions.length === 0) return 0;
const placeholders = actions.map(() => "?").join(", ");
const row = getDatabase()
.prepare(
`
SELECT COUNT(*) AS n FROM sms_phone_log
WHERE phone_hash = ? AND created_at >= ?
AND action IN (${placeholders})
`
)
.get(phoneHash, sinceIso, ...actions) as { n: number };
return row.n;
},
/** The backstop on daily alert sends: one handset, one identity, one UTC day. */
async countByUserAndPhoneHashSince(
userId: string,
phoneHash: string,
actions: SmsPhoneLogAction[],
sinceIso: string
): Promise<number> {
if (actions.length === 0) return 0;
const placeholders = actions.map(() => "?").join(", ");
const row = getDatabase()
.prepare(
`
SELECT COUNT(*) AS n FROM sms_phone_log
WHERE user_id = ? AND phone_hash = ? AND created_at >= ?
AND action IN (${placeholders})
`
)
.get(userId, phoneHash, sinceIso, ...actions) as { n: number };
return row.n;
},
async countByUserSince(
userId: string,
actions: SmsPhoneLogAction[],
sinceIso: string
): Promise<number> {
if (actions.length === 0) return 0;
const placeholders = actions.map(() => "?").join(", ");
const row = getDatabase()
.prepare(
`
SELECT COUNT(*) AS n FROM sms_phone_log
WHERE user_id = ? AND created_at >= ?
AND action IN (${placeholders})
`
)
.get(userId, sinceIso, ...actions) as { n: number };
return row.n;
},
/**
* Forget whose number it was, keep what happened. DELETE nulls phone_e164 for
* one DID's rows; phone_hash and the action history are left intact.
*/
async scrubPhoneNumber(userId: string, phoneE164: string): Promise<number> {
const result = getDatabase()
.prepare(
`
UPDATE sms_phone_log
SET phone_e164 = NULL
WHERE user_id = ? AND phone_e164 = ?
`
)
.run(userId, phoneE164);
return result.changes;
},
};
+261
View File
@@ -0,0 +1,261 @@
import { randomUUID } from "node:crypto";
import type { SmsRegistration } from "../models/smsRegistration.js";
import { getDatabase } from "./sqlite.js";
type DbRow = {
id: string;
user_id: string;
phone_e164: string;
verified: number;
code_hash: string | null;
code_expires_at: string | null;
code_attempts: number;
last_code_sent_at: string | null;
verified_at: string | null;
created_at: string;
updated_at: string;
};
const ROW_COLUMNS =
"id, user_id, phone_e164, verified, code_hash, code_expires_at, " +
"code_attempts, last_code_sent_at, verified_at, created_at, updated_at";
function toRecord(row: DbRow): SmsRegistration {
return {
id: row.id,
userId: row.user_id,
phoneE164: row.phone_e164,
verified: row.verified !== 0,
codeHash: row.code_hash ?? undefined,
codeExpiresAt: row.code_expires_at ?? undefined,
codeAttempts: row.code_attempts,
lastCodeSentAt: row.last_code_sent_at ?? undefined,
verifiedAt: row.verified_at ?? undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
/**
* Every read and write is scoped by (user_id, phone_e164). Two DIDs sharing one
* handset stay independent; only carrier opt-out crosses the DID boundary.
*/
export const smsRegistrationsDb = {
async get(
userId: string,
phoneE164: string
): Promise<SmsRegistration | undefined> {
const row = getDatabase()
.prepare(
`
SELECT ${ROW_COLUMNS} FROM sms_registrations
WHERE user_id = ? AND phone_e164 = ?
`
)
.get(userId, phoneE164) as DbRow | undefined;
return row === undefined ? undefined : toRecord(row);
},
async listByUserId(userId: string): Promise<SmsRegistration[]> {
const rows = getDatabase()
.prepare(
`
SELECT ${ROW_COLUMNS} FROM sms_registrations
WHERE user_id = ?
ORDER BY created_at
`
)
.all(userId) as DbRow[];
return rows.map(toRecord);
},
async listVerifiedByUserId(userId: string): Promise<SmsRegistration[]> {
const rows = getDatabase()
.prepare(
`
SELECT ${ROW_COLUMNS} FROM sms_registrations
WHERE user_id = ? AND verified = 1
ORDER BY created_at
`
)
.all(userId) as DbRow[];
return rows.map(toRecord);
},
/** DIDs holding a verified registration of this number, ordered for stable output. */
async listVerifiedDidsForPhone(phoneE164: string): Promise<string[]> {
const rows = getDatabase()
.prepare(
`
SELECT DISTINCT user_id FROM sms_registrations
WHERE phone_e164 = ? AND verified = 1
ORDER BY user_id
`
)
.all(phoneE164) as { user_id: string }[];
return rows.map((row) => row.user_id);
},
/**
* Verified rows only. Counting unverified rows would let five throwaway DIDs
* lock the handset's actual owner out of registering it.
*/
async countVerifiedForPhone(
phoneE164: string,
excludeUserId?: string
): Promise<number> {
const row =
excludeUserId === undefined
? (getDatabase()
.prepare(
`
SELECT COUNT(*) AS n FROM sms_registrations
WHERE phone_e164 = ? AND verified = 1
`
)
.get(phoneE164) as { n: number })
: (getDatabase()
.prepare(
`
SELECT COUNT(*) AS n FROM sms_registrations
WHERE phone_e164 = ? AND verified = 1 AND user_id != ?
`
)
.get(phoneE164, excludeUserId) as { n: number });
return row.n;
},
/** Insert or refresh the pending code on an existing unverified row. */
async upsertPendingCode(input: {
userId: string;
phoneE164: string;
codeHash: string;
codeExpiresAt: string;
sentAt: string;
}): Promise<SmsRegistration> {
const connection = getDatabase();
const now = input.sentAt;
connection
.prepare(
`
INSERT INTO sms_registrations (
id, user_id, phone_e164, verified, code_hash, code_expires_at,
code_attempts, last_code_sent_at, verified_at, created_at, updated_at
) VALUES (
@id, @user_id, @phone_e164, 0, @code_hash, @code_expires_at,
0, @last_code_sent_at, NULL, @created_at, @updated_at
)
ON CONFLICT(user_id, phone_e164) DO UPDATE SET
code_hash = excluded.code_hash,
code_expires_at = excluded.code_expires_at,
code_attempts = 0,
last_code_sent_at = excluded.last_code_sent_at,
updated_at = excluded.updated_at
`
)
.run({
id: randomUUID(),
user_id: input.userId,
phone_e164: input.phoneE164,
code_hash: input.codeHash,
code_expires_at: input.codeExpiresAt,
last_code_sent_at: now,
created_at: now,
updated_at: now,
});
const stored = await this.get(input.userId, input.phoneE164);
if (stored === undefined) {
throw new Error("sms_registrations upsert did not produce a row");
}
return stored;
},
/** Flip to verified and clear the code. Only the matching pending row moves. */
async markVerified(userId: string, phoneE164: string): Promise<boolean> {
const now = new Date().toISOString();
const result = getDatabase()
.prepare(
`
UPDATE sms_registrations
SET verified = 1, verified_at = ?, code_hash = NULL,
code_expires_at = NULL, code_attempts = 0, updated_at = ?
WHERE user_id = ? AND phone_e164 = ?
`
)
.run(now, now, userId, phoneE164);
return result.changes === 1;
},
async incrementCodeAttempts(
userId: string,
phoneE164: string
): Promise<number> {
const now = new Date().toISOString();
const connection = getDatabase();
const run = connection.transaction(() => {
connection
.prepare(
`
UPDATE sms_registrations
SET code_attempts = code_attempts + 1, updated_at = ?
WHERE user_id = ? AND phone_e164 = ?
`
)
.run(now, userId, phoneE164);
return connection
.prepare(
`
SELECT code_attempts AS n FROM sms_registrations
WHERE user_id = ? AND phone_e164 = ?
`
)
.get(userId, phoneE164) as { n: number } | undefined;
});
return run()?.n ?? 0;
},
/** Drop the pending code without verifying. Recovery is another POST. */
async clearPendingCode(userId: string, phoneE164: string): Promise<void> {
const now = new Date().toISOString();
getDatabase()
.prepare(
`
UPDATE sms_registrations
SET code_hash = NULL, code_expires_at = NULL, updated_at = ?
WHERE user_id = ? AND phone_e164 = ?
`
)
.run(now, userId, phoneE164);
},
async delete(userId: string, phoneE164: string): Promise<boolean> {
const result = getDatabase()
.prepare(
`
DELETE FROM sms_registrations
WHERE user_id = ? AND phone_e164 = ?
`
)
.run(userId, phoneE164);
return result.changes > 0;
},
/**
* Carrier opt-out arrives with a number and no identity, so it crosses the DID
* boundary on purpose. Returns how many registrations were switched off.
*/
async unverifyAllForPhone(phoneE164: string): Promise<number> {
const now = new Date().toISOString();
const result = getDatabase()
.prepare(
`
UPDATE sms_registrations
SET verified = 0, verified_at = NULL, code_hash = NULL,
code_expires_at = NULL, updated_at = ?
WHERE phone_e164 = ? AND verified = 1
`
)
.run(now, phoneE164);
return result.changes;
},
};
+294
View File
@@ -0,0 +1,294 @@
import { mkdirSync } from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
function dataDirPath(): string {
return process.env.NOTIFY_DATA_DIR ?? path.join(process.cwd(), "data");
}
const SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS fcm_registrations (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL,
device_id TEXT NOT NULL,
fcm_token TEXT NOT NULL,
-- The client's self-declared platform, in practice "ios", "android" or
-- "web", though the route accepts any non-empty string.
platform TEXT NOT NULL,
test_mode INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_notified_at INTEGER
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_fcm_registrations_user_device
ON fcm_registrations (user_id, device_id);
CREATE INDEX IF NOT EXISTS idx_fcm_registrations_user_id
ON fcm_registrations (user_id);
CREATE INDEX IF NOT EXISTS idx_fcm_registrations_device_id
ON fcm_registrations (device_id);
CREATE INDEX IF NOT EXISTS idx_fcm_registrations_fcm_token
ON fcm_registrations (fcm_token);
CREATE INDEX IF NOT EXISTS idx_fcm_registrations_user_fcm_token
ON fcm_registrations (user_id, fcm_token);
CREATE TABLE IF NOT EXISTS alert_authorization_batches (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL,
batch_id TEXT NOT NULL,
-- A 24-hour UTC clock time as zero-padded "HH:MM", which is what lets the
-- scheduler's plain text comparison against it come out chronological.
notify_hour_min_utc TEXT,
-- An IANA zone name such as "America/Denver", read by nothing and recorded
-- only so a later mechanism could re-derive notify_hour_min_utc when a zone's
-- DST rules move the offset out from under the stored time.
timezone TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_alert_auth_batches_user_id
ON alert_authorization_batches (user_id);
CREATE INDEX IF NOT EXISTS idx_alert_auth_batches_user_batch
ON alert_authorization_batches (user_id, batch_id);
CREATE TABLE IF NOT EXISTS alert_authorization_jwts (
id TEXT PRIMARY KEY NOT NULL,
batch_pk TEXT NOT NULL,
user_id TEXT NOT NULL,
batch_id TEXT NOT NULL,
sequence INTEGER NOT NULL,
-- The UTC calendar day this JWT covers, as "YYYY-MM-DD".
day TEXT NOT NULL,
jwt TEXT NOT NULL,
nbf INTEGER NOT NULL,
exp INTEGER NOT NULL,
-- Either "unused" or "consumed", the value the partial unique index below
-- keys on.
status TEXT NOT NULL,
consumed_at TEXT,
created_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_auth_jwts_batch_seq
ON alert_authorization_jwts (batch_pk, sequence);
CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_auth_jwts_user_day_unused
ON alert_authorization_jwts (user_id, day) WHERE status = 'unused';
CREATE INDEX IF NOT EXISTS idx_alert_auth_jwts_user_status
ON alert_authorization_jwts (user_id, status);
CREATE INDEX IF NOT EXISTS idx_alert_auth_jwts_user_day
ON alert_authorization_jwts (user_id, day);
CREATE INDEX IF NOT EXISTS idx_alert_auth_jwts_batch_pk
ON alert_authorization_jwts (batch_pk);
CREATE TABLE IF NOT EXISTS alert_search_cursors (
user_id TEXT PRIMARY KEY NOT NULL,
endorser_after_id TEXT,
partner_after_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sms_registrations (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL,
phone_e164 TEXT NOT NULL,
verified INTEGER NOT NULL DEFAULT 0,
code_hash TEXT,
code_expires_at TEXT,
code_attempts INTEGER NOT NULL DEFAULT 0,
last_code_sent_at TEXT,
verified_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_sms_registrations_user_phone
ON sms_registrations (user_id, phone_e164);
CREATE INDEX IF NOT EXISTS idx_sms_registrations_user_id
ON sms_registrations (user_id);
CREATE INDEX IF NOT EXISTS idx_sms_registrations_phone_e164
ON sms_registrations (phone_e164);
CREATE INDEX IF NOT EXISTS idx_sms_registrations_user_verified
ON sms_registrations (user_id, verified);
CREATE TABLE IF NOT EXISTS sms_phone_log (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL,
phone_e164 TEXT,
phone_hash TEXT NOT NULL,
-- One of the SmsPhoneLogAction names in src/models/smsRegistration.ts, such
-- as "code-sent" or "alert-authorization-deleted".
action TEXT NOT NULL,
-- Either "ok", "rejected" or "failed".
result TEXT NOT NULL,
detail TEXT,
jwt_hash TEXT,
provider_message_id TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sms_phone_log_user_created
ON sms_phone_log (user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_sms_phone_log_hash_created
ON sms_phone_log (phone_hash, created_at);
CREATE INDEX IF NOT EXISTS idx_sms_phone_log_action_created
ON sms_phone_log (action, created_at);
CREATE TABLE IF NOT EXISTS sms_action_jwt_use (
id TEXT PRIMARY KEY NOT NULL,
jwt_hash TEXT NOT NULL,
user_id TEXT NOT NULL,
-- One of the SMS_ACTIONS names in src/middleware/smsActionJwt.ts, such as
-- "register-phone" or "revoke-alert-search".
action TEXT NOT NULL,
used_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_sms_action_jwt_use_hash
ON sms_action_jwt_use (jwt_hash);
CREATE INDEX IF NOT EXISTS idx_sms_action_jwt_use_used_at
ON sms_action_jwt_use (used_at);
CREATE TABLE IF NOT EXISTS sms_alert_authorization_batches (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL,
batch_id TEXT NOT NULL,
-- A 24-hour UTC clock time as zero-padded "HH:MM", which is what lets the
-- scheduler's plain text comparison against it come out chronological.
notify_hour_min_utc TEXT,
-- An IANA zone name such as "America/Denver", read by nothing and recorded
-- only so a later mechanism could re-derive notify_hour_min_utc when a zone's
-- DST rules move the offset out from under the stored time.
timezone TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sms_alert_auth_batches_user_id
ON sms_alert_authorization_batches (user_id);
CREATE INDEX IF NOT EXISTS idx_sms_alert_auth_batches_user_batch
ON sms_alert_authorization_batches (user_id, batch_id);
CREATE TABLE IF NOT EXISTS sms_alert_authorization_jwts (
id TEXT PRIMARY KEY NOT NULL,
batch_pk TEXT NOT NULL,
user_id TEXT NOT NULL,
batch_id TEXT NOT NULL,
sequence INTEGER NOT NULL,
-- The UTC calendar day this JWT covers, as "YYYY-MM-DD".
day TEXT NOT NULL,
jwt TEXT NOT NULL,
nbf INTEGER NOT NULL,
exp INTEGER NOT NULL,
-- Either "unused" or "consumed", the value the partial unique index below
-- keys on.
status TEXT NOT NULL,
consumed_at TEXT,
created_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_sms_alert_auth_jwts_batch_seq
ON sms_alert_authorization_jwts (batch_pk, sequence);
CREATE UNIQUE INDEX IF NOT EXISTS idx_sms_alert_auth_jwts_user_day_unused
ON sms_alert_authorization_jwts (user_id, day) WHERE status = 'unused';
CREATE INDEX IF NOT EXISTS idx_sms_alert_auth_jwts_user_status
ON sms_alert_authorization_jwts (user_id, status);
CREATE INDEX IF NOT EXISTS idx_sms_alert_auth_jwts_user_day
ON sms_alert_authorization_jwts (user_id, day);
CREATE INDEX IF NOT EXISTS idx_sms_alert_auth_jwts_batch_pk
ON sms_alert_authorization_jwts (batch_pk);
CREATE TABLE IF NOT EXISTS sms_blocked_numbers (
id TEXT PRIMARY KEY NOT NULL,
phone_hash TEXT NOT NULL,
phone_e164 TEXT,
-- Either "opt-out", "provider-opt-out" or "manual", ranked in that order of
-- strength so a block is never quietly downgraded.
reason TEXT NOT NULL,
detail TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_sms_blocked_numbers_hash
ON sms_blocked_numbers (phone_hash);
CREATE TABLE IF NOT EXISTS sms_alert_search_cursors (
user_id TEXT PRIMARY KEY NOT NULL,
endorser_after_id TEXT,
partner_after_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
`;
let database: Database.Database | null = null;
function ensureSchema(connection: Database.Database): void {
connection.exec(SCHEMA_SQL);
}
/**
* Returns a singleton SQLite connection with schema initialized.
* SQLite is the service's storage backend.
*/
export function getDatabase(): Database.Database {
if (database === null) {
const dataDir = dataDirPath();
mkdirSync(dataDir, { recursive: true });
database = new Database(path.join(dataDir, "notify.sqlite"));
database.pragma("journal_mode = WAL");
ensureSchema(database);
}
return database;
}
/** Closes the singleton connection. Intended for tests and graceful shutdown. */
export function closeDatabase(): void {
if (database !== null) {
database.close();
database = null;
}
}
+138
View File
@@ -0,0 +1,138 @@
import { config } from "dotenv";
config();
/** Production Endorser host (same default as the app DEFAULT_ENDORSER_API_SERVER). */
export const DEFAULT_ENDORSER_API_SERVER = "https://api.endorser.ch";
/** Production Partner host (same default as the app DEFAULT_PARTNER_API_SERVER). */
export const DEFAULT_PARTNER_API_SERVER = "https://partner-api.endorser.ch";
/** Base URL for the Endorser API server. */
export const ENDORSER_URL =
process.env.ENDORSER_URL ??
process.env.DEFAULT_ENDORSER_API_SERVER ??
DEFAULT_ENDORSER_API_SERVER;
/** Base URL for the Partner API server. Separate from Endorser. */
export const PARTNER_URL =
process.env.PARTNER_URL ??
process.env.DEFAULT_PARTNER_API_SERVER ??
DEFAULT_PARTNER_API_SERVER;
/** NODE_ENV value that unlocks developer conveniences. Never set in production. */
export const TEST_LOCAL_ENV = "test-local";
/**
* Every boolean env flag goes through here. Accepts "true"/"1"/"yes"/"on" and
* "false"/"0"/"no"/"off", case-insensitive and trimmed, because `docker run
* --env-file` keeps trailing whitespace. Anything else yields the fallback, so
* a typo leaves a flag at its documented default rather than flipping it.
*/
function envFlag(value: string | undefined, fallback: boolean): boolean {
if (value === undefined || value.trim().length === 0) return fallback;
const normalized = value.trim().toLowerCase();
if (["true", "1", "yes", "on"].includes(normalized)) return true;
if (["false", "0", "no", "off"].includes(normalized)) return false;
return fallback;
}
/** Mounts /debug when true. Off unless explicitly enabled. */
export const DEBUG_ENDPOINT = envFlag(process.env.DEBUG_ENDPOINT, false);
function intEnv(name: string, fallback: number): number {
const raw = process.env[name];
if (raw === undefined || raw.length === 0) return fallback;
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
/**
* A DID allowlist, or undefined when the variable is absent entirely. Present
* but empty yields an empty list, which blocks every recipient: an operator who
* sets the variable meant to restrict something, so the blank case fails closed.
*/
function didListEnv(name: string): string[] | undefined {
const raw = process.env[name];
if (raw === undefined) return undefined;
return raw
.split(",")
.map((did) => did.trim().toLowerCase())
.filter((did) => did.length > 0);
}
function stringEnv(name: string): string | undefined {
const raw = process.env[name];
return raw !== undefined && raw.length > 0 ? raw : undefined;
}
export type SmsConfig = {
enabled: boolean;
codeSecret?: string;
twilioAccountSid?: string;
twilioAuthToken?: string;
twilioFromNumber?: string;
twilioMessagingServiceSid?: string;
/** The public URL Twilio posts the inbound webhook to; it signs that exact string. */
twilioWebhookUrl?: string;
codeTtlSec: number;
codeMaxAttempts: number;
actionJwtMaxAgeSec: number;
maxDidsPerPhone: number;
alertSearchIntervalMs: number;
requireActionClaim: boolean;
/** Echo the verification code in the POST response. Both conditions required. */
devEchoCode: boolean;
/**
* When present, the only DIDs this instance may text. Undefined means no
* restriction, which is what production runs with.
*/
allowedRecipientDids?: string[];
/**
* Numbers blocked by configuration, on top of whatever the database holds.
* Normalized at read time so the comparison matches stored E.164.
*/
blockedNumbers: string[];
};
/**
* Read on each call rather than frozen at import, so a process that has its
* environment adjusted (and every test) sees the value it just set.
*/
export function smsConfig(): SmsConfig {
const isTestLocal = process.env.NODE_ENV === TEST_LOCAL_ENV;
return {
enabled: envFlag(process.env.SMS_ENABLED, false),
codeSecret: stringEnv("SMS_CODE_SECRET"),
twilioAccountSid: stringEnv("TWILIO_ACCOUNT_SID"),
twilioAuthToken: stringEnv("TWILIO_AUTH_TOKEN"),
twilioFromNumber: stringEnv("TWILIO_FROM_NUMBER"),
twilioMessagingServiceSid: stringEnv("TWILIO_MESSAGING_SERVICE_SID"),
twilioWebhookUrl: stringEnv("TWILIO_WEBHOOK_URL"),
codeTtlSec: intEnv("SMS_CODE_TTL_SEC", 600),
codeMaxAttempts: intEnv("SMS_CODE_MAX_ATTEMPTS", 5),
actionJwtMaxAgeSec: intEnv("SMS_ACTION_JWT_MAX_AGE_SEC", 300),
maxDidsPerPhone: intEnv("SMS_MAX_DIDS_PER_PHONE", 5),
alertSearchIntervalMs: intEnv("SMS_ALERT_SEARCH_INTERVAL_MS", 300000),
requireActionClaim: envFlag(process.env.SMS_REQUIRE_ACTION_CLAIM, true),
// NODE_ENV is checked first: a production process with the flag set by
// accident echoes nothing, because its NODE_ENV is not test-local.
devEchoCode: isTestLocal && envFlag(process.env.SMS_DEV_ECHO_CODE, false),
allowedRecipientDids: didListEnv("SMS_ALLOWED_RECIPIENT_DIDS"),
blockedNumbers: (process.env.SMS_BLOCKED_NUMBERS ?? "")
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0),
};
}
/**
* The code secret keys every pending code and every phone hash. Running enabled
* without it would store codes no PUT could ever match.
*/
export function assertSmsConfigured(config: SmsConfig = smsConfig()): void {
if (!config.enabled) return;
if (config.codeSecret === undefined) {
throw new Error("SMS_ENABLED is set but SMS_CODE_SECRET is missing.");
}
}
+35 -4
View File
@@ -1,20 +1,51 @@
import { assertSmsConfigured, smsConfig } from "./env.js";
import cors from "cors";
import express from "express";
import "./services/firebase.js";
import { DEBUG_ENDPOINT } from "./env.js";
import { debugRouter } from "./routes/debug.js";
import { notificationsRouter } from "./routes/notifications.js";
import { notifySmsRouter } from "./routes/notifySms.js";
import { startAlertSearchScheduler } from "./alertSearch/scheduler.js";
import { startSmsAlertSearchScheduler } from "./alertSearch/smsScheduler.js";
import { startScheduler } from "./scheduler.js";
import { log } from "./util/log.js";
const app = express();
const port = Number(process.env.PORT) || 3000;
const port = Number(process.env.PORT) || 3003;
app.use(express.json());
app.use(
cors({
origin: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "ngrok-skip-browser-warning"],
}),
);
app.use(express.json({ limit: "1mb" }));
// Keep stable for diagnostics tooling compatibility
app.get("/health", (_req, res) => {
res.status(200).json({ status: "ok" });
res.status(200).json({ ok: true });
});
app.use("/notifications", notificationsRouter);
app.use("/notify-sms", notifySmsRouter);
// Only include on test environments (DEBUG_ENDPOINT=true)
if (DEBUG_ENDPOINT) {
app.use("/debug", debugRouter);
console.log("* Debug endpoints enabled at /debug (DEBUG_ENDPOINT=true)");
}
assertSmsConfigured();
startScheduler();
startAlertSearchScheduler();
if (smsConfig().enabled) {
startSmsAlertSearchScheduler();
}
app.listen(port, () => {
console.log("* Running backend");
log.info("* Running backend");
});
+177
View File
@@ -0,0 +1,177 @@
import type { NextFunction, Request, Response } from "express";
import { checkAuth } from "../services/endorserClient.js";
import { decodeAndVerifyJwt } from "../vc/index.js";
import { log } from "../util/log.js";
export type AuthContext = {
did: string;
jwt: string;
/**
* The verifier's own output for this one token. Carried because a passkey
* (did:peer) token's real claim sits inside the WebAuthn challenge, so a later
* stage that re-parsed segment two would find the envelope and no claim.
*/
payload: Record<string, unknown>;
};
type ClientErrorBody = {
clientError?: {
message?: string;
code?: string;
};
};
/** Synthetic userId for unauthenticated local debug registrations (testMode). */
const LOCAL_TEST_USER_ID = "__notification_local_test__";
function isNotificationLocalTestBypass(req: Request): boolean {
if (req.headers.authorization?.startsWith("Bearer ")) {
return false;
}
const body = req.body;
return (
body !== null &&
typeof body === "object" &&
(body as { testMode?: unknown }).testMode === true
);
}
function clientErrorMessage(err: unknown): string | undefined {
if (err && typeof err === "object" && "clientError" in err) {
const message = (err as ClientErrorBody).clientError?.message;
if (typeof message === "string" && message.length > 0) {
return message;
}
}
return undefined;
}
/**
* Express middleware mirroring image-api decodeJwt: Bearer JWT, DID verification,
* attaches req.did / req.jwt / req.auth on success.
*/
export async function requireAuth(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
log.info("[Auth] Authentication failed");
res.status(401).json({
success: false,
message: 'Missing "Bearer JWT" in Authorization header.',
});
return;
}
const token = authHeader.substring("Bearer ".length);
try {
const verified = await decodeAndVerifyJwt(token);
if (!verified.verified) {
const errorTime = new Date().toISOString();
log.info("[Auth] Authentication failed");
log.error(
"[Auth] Invalid JWT at",
errorTime + ":",
verified
);
res.status(401).json({
success: false,
message:
"Got invalid JWT in Authorization header. See server logs at " +
errorTime,
});
return;
}
const did = verified.issuer;
req.did = did;
req.jwt = token;
req.auth = { did, jwt: token, payload: verified.payload };
log.info("[Auth] Authenticated user:", did);
next();
} catch (err) {
const errorTime = new Date().toISOString();
log.info("[Auth] Authentication failed");
log.error(
"[Auth] Invalid JWT at",
errorTime + ":",
err
);
res.status(401).json({
success: false,
message:
clientErrorMessage(err) ??
"Got invalid JWT in Authorization header. See server logs at " +
errorTime,
});
}
}
export async function requireAuthOrNotificationLocalTest(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
if (isNotificationLocalTestBypass(req)) {
req.did = LOCAL_TEST_USER_ID;
log.info("[Auth] Local notification test bypass");
next();
return;
}
return requireAuth(req, res, next);
}
/**
* When req.jwt is set (normal auth), require Endorser acceptance.
* Local test bypass leaves req.jwt unset and is allowed through unchanged.
*/
export async function requireEndorserAuth(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
const jwt = req.jwt;
if (jwt === undefined) {
next();
return;
}
const result = await checkAuth(jwt);
if (result.ok) {
next();
return;
}
const errorTime = new Date().toISOString();
const did = req.did ?? "(unknown)";
if (result.reason !== "unauthorized") {
log.info("[Auth] Endorser unavailable");
log.error(
"[Auth] Endorser auth check unavailable at",
errorTime + ", did:",
did + ", reason:",
result.reason
);
res.status(503).json({
success: false,
message:
"Authentication service unavailable. See server logs at " + errorTime,
});
return;
}
log.info("[Auth] Endorser verification failed");
log.error(
"[Auth] Endorser rejected JWT at",
errorTime + ", did:",
did
);
res.status(401).json({
success: false,
message: "Unauthorized. See server logs at " + errorTime,
});
}
+189
View File
@@ -0,0 +1,189 @@
import { createHash } from "node:crypto";
import type { NextFunction, Request, Response } from "express";
import { smsActionJwtUseDb } from "../db/smsActionJwtUseSqlite.js";
import { smsConfig } from "../env.js";
import { normalizePhoneNumber } from "../util/smsPhoneNumber.js";
import { log } from "../util/log.js";
/** The actions this service defines; one per route. */
export const SMS_ACTIONS = [
"list-phones",
"register-phone",
"verify-phone",
"delete-phone",
"authorize-alert-search",
"revoke-alert-search",
] as const;
export type SmsAction = (typeof SMS_ACTIONS)[number];
/**
* Actions on the DID's whole alert authorization rather than on one handset.
* They bind to no number, so a claim carrying one is neither required nor read.
*/
const PHONELESS_ACTIONS = new Set<SmsAction>([
"authorize-alert-search",
"revoke-alert-search",
]);
export const SMS_ACTION_CLAIM_CONTEXT = "https://giftopia.tech";
export const SMS_ACTION_CLAIM_TYPE = "SmsNotificationAction";
export function sha256Hex(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
/**
* The number this request acts on. DELETE also accepts the query parameter
* because a fair number of proxies drop bodies on DELETE.
*/
export function requestPhoneNumber(req: Request): unknown {
const body = req.body;
if (body !== null && typeof body === "object" && "phoneNumber" in body) {
return (body as { phoneNumber?: unknown }).phoneNumber;
}
return req.query?.phoneNumber;
}
function reject(
res: Response,
status: number,
error: string,
message: string
): void {
log.info("[SmsActionJwt] Rejected:", error);
res.status(status).json({ success: false, error, message });
}
/**
* Confirms the verified Bearer token authorizes this specific action on this
* specific phone. Reads the payload requireAuth already produced; re-decoding
* would find a passkey token's WebAuthn envelope instead of the claim.
*/
export function requireSmsActionJwt(action: SmsAction) {
return async function smsActionJwtStage(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
const config = smsConfig();
if (!config.requireActionClaim) {
next();
return;
}
const auth = req.auth;
if (auth === undefined) {
// Absent means the route was mounted without requireAuth, or with the
// local-test bypass. Either is wiring that must not reach a sendSms call.
log.error(
"[SmsActionJwt] req.auth is missing; route is mounted without requireAuth"
);
res.status(500).json({
success: false,
error: "SMS_ACTION_JWT_NOT_AUTHENTICATED",
message: "Server misconfiguration: this route is not authenticated.",
});
return;
}
const claim = auth.payload.claim;
if (claim === null || typeof claim !== "object" || Array.isArray(claim)) {
reject(
res,
403,
"SMS_ACTION_JWT_MISSING_CLAIM",
"The Bearer JWT carries no SmsNotificationAction claim."
);
return;
}
const typed = claim as { action?: unknown; phoneNumber?: unknown };
if (typed.action !== action) {
reject(
res,
403,
"SMS_ACTION_JWT_WRONG_ACTION",
`The claim authorizes "${String(typed.action)}", not "${action}".`
);
return;
}
if (!PHONELESS_ACTIONS.has(action)) {
const requested = requestPhoneNumber(req);
// list-phones without the query parameter binds to no number at all.
const bindsPhone = action !== "list-phones" || requested !== undefined;
if (bindsPhone) {
const target = normalizePhoneNumber(requested);
if (target === undefined) {
// No claim can authorize a number that is not one. Say what is wrong
// with the request rather than blaming the authorization.
reject(
res,
400,
"SMS_PHONE_INVALID",
"phoneNumber is not a valid phone number."
);
return;
}
const claimed = normalizePhoneNumber(typed.phoneNumber);
if (claimed === undefined || claimed !== target) {
reject(
res,
403,
"SMS_ACTION_JWT_PHONE_MISMATCH",
"The claim does not authorize this phone number."
);
return;
}
}
}
const nowSec = Math.floor(Date.now() / 1000);
const iat = auth.payload.iat;
if (
typeof iat !== "number" ||
Math.abs(nowSec - iat) > config.actionJwtMaxAgeSec
) {
reject(
res,
401,
"SMS_ACTION_JWT_STALE",
`The Bearer JWT must be issued within ${config.actionJwtMaxAgeSec} seconds of use.`
);
return;
}
const exp = auth.payload.exp;
if (typeof exp === "number" && exp <= nowSec) {
reject(
res,
401,
"SMS_ACTION_JWT_EXPIRED",
"The Bearer JWT has expired."
);
return;
}
const jwtHash = sha256Hex(auth.jwt);
const claimed = await smsActionJwtUseDb.claim({
jwtHash,
userId: auth.did,
action,
});
if (!claimed) {
reject(
res,
401,
"SMS_ACTION_JWT_REPLAYED",
"This Bearer JWT has already been used. Mint a fresh one."
);
return;
}
// One token buys one action. A handler that fails afterward does not
// release the hash; minting another JWT is free.
req.smsActionJwtHash = jwtHash;
next();
};
}
+8 -1
View File
@@ -1,7 +1,14 @@
export interface Device {
/** Internal row id used for persistence updates. */
id: string;
pushToken: string;
/** Authenticated user DID (from verified JWT). */
userId: string;
/** Client-provided stable physical device identity. */
deviceId: string;
fcmToken: string;
platform: "ios" | "android" | "web";
createdAt: Date;
updatedAt: Date;
/** Epoch ms; set only after a successful push send. */
lastNotifiedAt?: number;
}
+54
View File
@@ -0,0 +1,54 @@
/** One (DID, phone) pair. Verification is per pair; possession proves nothing about other DIDs. */
export interface SmsRegistration {
/** Internal row id used for persistence updates. */
id: string;
/** Authenticated user DID (from verified JWT). */
userId: string;
/** E.164 normalized number, e.g. +15555550123. */
phoneE164: string;
verified: boolean;
/** HMAC of the pending verification code; undefined once verified or cleared. */
codeHash?: string;
codeExpiresAt?: string;
codeAttempts: number;
lastCodeSentAt?: string;
verifiedAt?: string;
createdAt: string;
updatedAt: string;
}
/** Every phone action this service takes, whether it succeeded or not. */
export type SmsPhoneLogAction =
| "register-requested"
| "code-sent"
| "code-send-failed"
| "verify-succeeded"
| "verify-failed"
| "did-limit-blocked"
| "did-limit-disclosed"
| "deleted"
| "alert-authorization-stored"
| "alert-authorization-deleted"
| "alert-sent"
| "alert-send-failed"
| "recipient-not-allowed"
| "number-blocked"
| "number-unblocked"
| "opt-out";
export type SmsPhoneLogResult = "ok" | "rejected" | "failed";
export interface SmsPhoneLogEntry {
id: string;
userId: string;
/** Nulled by DELETE; the hash and the history survive. */
phoneE164?: string;
phoneHash: string;
action: SmsPhoneLogAction;
result: SmsPhoneLogResult;
/** Short reason. Never a verification code and never a full JWT. */
detail?: string;
jwtHash?: string;
providerMessageId?: string;
createdAt: string;
}
+145
View File
@@ -0,0 +1,145 @@
import express, { Router } from "express";
import { db } from "../db/fcmTokensSqlite.js";
import {
requireAuth,
requireAuthOrNotificationLocalTest,
} from "../middleware/auth.js";
import {
computeNextEligibleAt,
sendPushToDevice,
} from "../services/pushService.js";
import { formatElapsedMs } from "../util/formatElapsed.js";
import { maskToken } from "../util/maskToken.js";
import { log } from "../util/log.js";
export const debugRouter: express.Router = Router();
function deviceDebugPayload(row: {
id: string;
deviceId: string;
platform: string;
testMode?: boolean;
createdAt: string;
updatedAt: string;
lastNotifiedAt?: number;
fcmToken: string;
}) {
return {
id: row.id,
deviceId: row.deviceId,
platform: row.platform,
testMode: row.testMode ?? false,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
lastNotifiedAt: row.lastNotifiedAt,
nextEligibleAt: computeNextEligibleAt(row),
fcmTokenSuffix: maskToken(row.fcmToken),
};
}
function sendWakeupFailureReason(
result: "sent" | "skipped" | "failed"
): string | undefined {
if (result === "sent") return undefined;
if (result === "skipped") {
return "Device was notified within the eligibility threshold";
}
return "FCM send failed";
}
debugRouter.get("/device/:token", requireAuth, async (req, res) => {
const started = Date.now();
const userId = req.did;
if (userId === undefined) {
res.status(401).json({ success: false, message: "Unauthorized" });
return;
}
const tokenParam = req.params.token;
const fcmToken = decodeURIComponent(
Array.isArray(tokenParam) ? tokenParam[0] : tokenParam
);
const suffix = maskToken(fcmToken);
log.info("[DebugEndpoint] Device lookup request, token suffix:", suffix);
const row = await db.resolveOwnedDevice(userId, { fcmToken });
if (row === undefined) {
log.info(
"[DebugEndpoint] Device lookup not found in",
formatElapsedMs(Date.now() - started) + ",",
"token suffix:",
suffix
);
res.status(404).json({ error: "Device not found" });
return;
}
res.json(deviceDebugPayload(row));
log.info(
"[DebugEndpoint] Device lookup completed in",
formatElapsedMs(Date.now() - started) + ",",
"token suffix:",
suffix
);
});
debugRouter.post("/send-wakeup", requireAuthOrNotificationLocalTest, async (req, res) => {
const started = Date.now();
const userId = req.did;
if (userId === undefined) {
res.status(401).json({ success: false, message: "Unauthorized" });
return;
}
const { fcmToken } = req.body as { fcmToken?: unknown };
if (typeof fcmToken !== "string" || fcmToken.length === 0) {
log.info(
"[DebugEndpoint] Send-wakeup rejected in",
formatElapsedMs(Date.now() - started) + ":",
"fcmToken is required"
);
res.status(400).json({
success: false,
failureReason: "fcmToken is required",
});
return;
}
const suffix = maskToken(fcmToken);
log.info("[DebugEndpoint] Send-wakeup request, token suffix:", suffix);
const row = await db.resolveOwnedDevice(userId, { fcmToken });
if (row === undefined) {
log.info(
"[DebugEndpoint] Send-wakeup rejected in",
formatElapsedMs(Date.now() - started) + ",",
"token suffix:",
suffix + ",",
"reason: Device not found"
);
res.status(404).json({
success: false,
failureReason: "Device not found",
fcmTokenSuffix: suffix,
});
return;
}
const result = await sendPushToDevice(fcmToken);
const success = result === "sent";
const failureReason = sendWakeupFailureReason(result);
res.json({
success,
...(failureReason !== undefined ? { failureReason } : {}),
fcmTokenSuffix: suffix,
});
log.info(
"[DebugEndpoint] Send-wakeup completed in",
formatElapsedMs(Date.now() - started) + ",",
success ? "success" : result + ",",
"token suffix:",
suffix
);
});
+332 -2
View File
@@ -1,7 +1,337 @@
import { Router } from "express";
import express, { Router } from "express";
import { alertAuthorizationDb } from "../db/alertAuthorizationSqlite.js";
import { db } from "../db/fcmTokensSqlite.js";
import {
requireAuth,
requireAuthOrNotificationLocalTest,
requireEndorserAuth,
} from "../middleware/auth.js";
import {
type AlertAuthorizationRequestBody,
storedNotifyLabel,
unsupportedIdentityFailure,
validateAlertAuthorizationBatch,
} from "../services/alertAuthorization.js";
import { identitySupportsDelegatedJwtBatch } from "../vc/index.js";
import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js";
import { maskToken } from "../util/maskToken.js";
import { log } from "../util/log.js";
export const notificationsRouter = Router();
export const notificationsRouter: express.Router = Router();
notificationsRouter.get("/", (_req, res) => {
res.json({ ok: true, resource: "notifications" });
});
notificationsRouter.post(
"/refresh",
requireAuthOrNotificationLocalTest,
requireEndorserAuth,
async (req, res) => {
const started = Date.now();
const userId = req.did;
if (userId === undefined) {
res.status(401).json({ success: false, message: "Unauthorized" });
return;
}
const { deviceId, fcmToken } = req.body as {
deviceId?: unknown;
fcmToken?: unknown;
};
const canonicalDeviceId =
typeof deviceId === "string" ? deviceId.trim() : undefined;
const token =
typeof fcmToken === "string" && fcmToken.length > 0
? fcmToken
: undefined;
log.info(
"[Refresh] Request received",
canonicalDeviceId !== undefined ? `deviceId=${canonicalDeviceId}` : "",
token !== undefined ? `token suffix=${maskToken(token)}` : ""
);
if (
(canonicalDeviceId === undefined || canonicalDeviceId.length === 0) &&
token === undefined
) {
log.info(
"[Refresh] Rejected in",
formatElapsedMs(Date.now() - started) + ":",
"deviceId or fcmToken is required"
);
res.status(400).json({ error: "deviceId or fcmToken is required" });
return;
}
const device = await db.resolveOwnedDevice(userId, {
deviceId: canonicalDeviceId,
fcmToken: token,
});
if (device === undefined) {
log.info(
"[Refresh] Device not found in",
formatElapsedMs(Date.now() - started),
canonicalDeviceId !== undefined
? `deviceId=${canonicalDeviceId}`
: "",
token !== undefined ? `token suffix=${maskToken(token)}` : ""
);
res.status(404).json({ error: "Device not found" });
return;
}
const now = Date.now();
res.json({
shouldNotify: true,
nextNotifications: [{ timestamp: now + 600000 }],
});
log.info(
"[Refresh] Completed in",
formatElapsedMs(Date.now() - started) + ",",
"deviceId=" + device.deviceId + ",",
"token suffix=" + maskToken(device.fcmToken)
);
}
);
notificationsRouter.post(
"/alert-authorization",
requireAuth,
requireEndorserAuth,
async (req, res) => {
const started = Date.now();
const userId = req.did;
if (userId === undefined) {
res.status(401).json({ success: false, message: "Unauthorized" });
return;
}
log.info("[AlertAuthorization] Request received, user=" + userId);
if (!identitySupportsDelegatedJwtBatch(userId)) {
const failure = unsupportedIdentityFailure(userId);
log.info(
"[AlertAuthorization] Rejected in",
formatElapsedMs(Date.now() - started) + ":",
failure.error
);
res.status(400).json({
success: false,
error: failure.error,
message: failure.message,
details: failure.details,
});
return;
}
const body = (req.body ?? {}) as AlertAuthorizationRequestBody;
const validated = await validateAlertAuthorizationBatch(userId, body);
if (!validated.ok) {
log.info(
"[AlertAuthorization] Rejected in",
formatElapsedMs(Date.now() - started) + ":",
validated.error + ",",
validated.details[0] ?? validated.message
);
res.status(400).json({
success: false,
error: validated.error,
message: validated.message,
details: validated.details,
});
return;
}
try {
const stored = await alertAuthorizationDb.replaceUnusedBatch({
userId,
batchId: validated.batchId,
notifyHourUtc: validated.notifyHourUtc,
notifyMinuteUtc: validated.notifyMinuteUtc,
timezone: validated.timezone,
jwts: validated.jwts,
});
res.status(200).json({
success: true,
batchId: stored.batch.batchId,
notifyHourUtc: stored.batch.notifyHourUtc ?? null,
notifyMinuteUtc: stored.batch.notifyMinuteUtc ?? null,
timezone: stored.batch.timezone ?? null,
storedCount: stored.storedCount,
unusedCount: stored.unusedCount,
});
log.info(
"[AlertAuthorization] Completed in",
formatElapsedMs(Date.now() - started) + ",",
"batchId=" + stored.batch.batchId + ",",
"stored=" + stored.storedCount + ",",
"notifyUtc=" + storedNotifyLabel(stored.batch)
);
} catch (err) {
log.error(
"[AlertAuthorization] Failed in",
formatElapsedMs(Date.now() - started) + ":",
errorMessage(err)
);
res.status(500).json({
success: false,
message: "Failed to store delegated notification-JWT batch.",
});
}
}
);
/**
* Turn the push channel off: every batch and every JWT for this DID, used or
* not, so the alertSearch scheduler stops listing the identity. Device
* registrations are untouched; WAKEUP_PING is a separate mechanism.
*/
notificationsRouter.delete(
"/alert-authorization",
requireAuth,
requireEndorserAuth,
async (req, res) => {
const started = Date.now();
const userId = req.did;
if (userId === undefined) {
res.status(401).json({ success: false, message: "Unauthorized" });
return;
}
try {
const removed = await alertAuthorizationDb.deleteAllForUser(userId);
res.status(200).json({
success: true,
deletedBatches: removed.deletedBatches,
deletedJwts: removed.deletedJwts,
});
log.info(
"[AlertAuthorization] Deleted in",
formatElapsedMs(Date.now() - started) + ",",
"batches=" + removed.deletedBatches + ",",
"jwts=" + removed.deletedJwts
);
} catch (err) {
log.error(
"[AlertAuthorization] Delete failed in",
formatElapsedMs(Date.now() - started) + ":",
errorMessage(err)
);
res.status(500).json({
success: false,
message: "Failed to remove the delegated notification-JWT inventory.",
});
}
}
);
notificationsRouter.post(
"/register",
requireAuthOrNotificationLocalTest,
requireEndorserAuth,
async (req, res) => {
const started = Date.now();
const userId = req.did;
if (userId === undefined) {
res.status(401).json({ success: false, message: "Unauthorized" });
return;
}
if (
req.body !== null &&
typeof req.body === "object" &&
"userId" in req.body
) {
log.info(
"[Register] Rejected in",
formatElapsedMs(Date.now() - started) + ":",
"userId must not be sent in the request body"
);
res.status(400).json({
error: "userId must not be sent in the request body",
});
return;
}
const { deviceId, fcmToken, platform, testMode } = req.body as {
deviceId?: unknown;
fcmToken?: unknown;
platform?: unknown;
testMode?: unknown;
};
if (typeof deviceId !== "string" || deviceId.trim().length === 0) {
log.info(
"[Register] Rejected in",
formatElapsedMs(Date.now() - started) + ":",
"deviceId is required"
);
res.status(400).json({ error: "deviceId is required" });
return;
}
if (typeof fcmToken !== "string" || fcmToken.length === 0) {
log.info(
"[Register] Rejected in",
formatElapsedMs(Date.now() - started) + ":",
"fcmToken is required"
);
res.status(400).json({ error: "fcmToken is required" });
return;
}
if (typeof platform !== "string" || platform.length === 0) {
log.info(
"[Register] Rejected in",
formatElapsedMs(Date.now() - started) + ":",
"platform is required"
);
res.status(400).json({ error: "platform is required" });
return;
}
const canonicalDeviceId = deviceId.trim();
log.info(
"[Register] Request received,",
"deviceId=" + canonicalDeviceId + ",",
"platform=" + platform + ",",
"token suffix=" + maskToken(fcmToken)
);
try {
const existing = await db.getByDeviceId(userId, canonicalDeviceId);
const action =
existing === undefined
? "create"
: existing.fcmToken !== fcmToken
? "update-token"
: "update";
await db.upsert({
userId,
deviceId: canonicalDeviceId,
fcmToken,
platform,
testMode: typeof testMode === "boolean" ? testMode : undefined,
updatedAt: new Date(),
});
res.sendStatus(200);
log.info(
"[Register] Completed in",
formatElapsedMs(Date.now() - started) + ",",
"deviceId=" + canonicalDeviceId + ",",
"action=" + action
);
} catch (err) {
log.error(
"[Register] Failed in",
formatElapsedMs(Date.now() - started) + ",",
"deviceId=" + canonicalDeviceId + ":",
errorMessage(err)
);
res.sendStatus(500);
}
}
);
+957
View File
@@ -0,0 +1,957 @@
import express, { Router, type RequestHandler } from "express";
import { smsAlertAuthorizationDb } from "../db/smsAlertAuthorizationSqlite.js";
import { smsPhoneLogDb } from "../db/smsPhoneLogSqlite.js";
import { smsRegistrationsDb } from "../db/smsRegistrationsSqlite.js";
import { smsConfig } from "../env.js";
import {
requireAuth,
requireEndorserAuth,
} from "../middleware/auth.js";
import {
requestPhoneNumber,
requireSmsActionJwt,
} from "../middleware/smsActionJwt.js";
import type { SmsPhoneLogAction } from "../models/smsRegistration.js";
import {
type AlertAuthorizationRequestBody,
storedNotifyLabel,
unsupportedIdentityFailure,
validateAlertAuthorizationBatch,
} from "../services/alertAuthorization.js";
import {
TWILIO_UNSUBSCRIBED_CODE,
isPhoneNumberBlocked,
isSmsRecipientAllowed,
sendSms,
type SmsSender,
} from "../services/smsService.js";
import { smsBlockedNumbersDb } from "../db/smsBlockedNumbersSqlite.js";
import { twilioSignatureMatches } from "../services/twilioSignature.js";
import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js";
import {
maskPhoneNumber,
normalizePhoneNumber,
} from "../util/smsPhoneNumber.js";
import {
hashPhoneNumber,
hashVerificationCode,
mintVerificationCode,
verificationCodeMatches,
} from "../util/smsVerificationCode.js";
import { identitySupportsDelegatedJwtBatch } from "../vc/index.js";
import { log } from "../util/log.js";
/** Code sends allowed per handset per hour, counted across every DID. */
export const CODE_SENDS_PER_PHONE_PER_HOUR = 3;
/** Code sends allowed per DID per day. */
export const CODE_SENDS_PER_DID_PER_DAY = 10;
/** Carrier-mandated keywords. Twilio blocks the number at its end; this keeps
* the service's own state in agreement so it stops burning sends. */
export const SMS_OPT_OUT_KEYWORDS = new Set([
"STOP",
"STOPALL",
"UNSUBSCRIBE",
"CANCEL",
"END",
"QUIT",
]);
export const SMS_OPT_IN_KEYWORDS = new Set(["START", "YES", "UNSTOP"]);
export const SMS_HELP_KEYWORDS = new Set(["HELP", "INFO"]);
export const SMS_HELP_REPLY =
"Gift Economies alerts. Reply STOP to end. Support: https://giftopia.me";
export const SMS_OPT_IN_REPLY =
"Register your number again in the app to resume Gift Economies alerts.";
const HOUR_MS = 60 * 60 * 1000;
const DAY_MS = 24 * HOUR_MS;
/** Attempts, not successes: a failing provider must not become a bypass. */
const CODE_SEND_ACTIONS: SmsPhoneLogAction[] = ["code-sent", "code-send-failed"];
const EMPTY_TWIML = '<?xml version="1.0" encoding="UTF-8"?><Response></Response>';
/** Prefix that keeps a stand-in log hash out of the phone-number namespace. */
const NO_PHONE_HASH_SUBJECT = "no-phone:";
function escapeXml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function twiml(message: string): string {
return (
'<?xml version="1.0" encoding="UTF-8"?><Response><Message>' +
escapeXml(message) +
"</Message></Response>"
);
}
export type NotifySmsDeps = {
sender?: SmsSender;
/** Defaults to the real chain; tests substitute a stage that sets req.auth. */
authStages?: RequestHandler[];
};
function sendError(
res: express.Response,
status: number,
error: string,
message: string,
extra: Record<string, unknown> = {}
): void {
res.status(status).json({ success: false, error, message, ...extra });
}
/**
* The whole surface stays off the public internet until it is deliberately
* turned on, credentials and 10DLC registration included.
*/
export const requireSmsEnabled: RequestHandler = (_req, res, next) => {
if (!smsConfig().enabled) {
sendError(
res,
503,
"SMS_DISABLED",
"SMS notifications are not enabled on this server."
);
return;
}
next();
};
function codeSecretOrFail(res: express.Response): string | undefined {
const secret = smsConfig().codeSecret;
if (secret === undefined) {
log.error("[NotifySms] SMS_ENABLED is set but SMS_CODE_SECRET is not");
sendError(
res,
500,
"SMS_NOT_CONFIGURED",
"SMS is enabled but not fully configured."
);
return undefined;
}
return secret;
}
/**
* Every phone action is recorded. Failures here never fail the request: an
* unwritten log line is worse than nothing, but a lost delete is worse still.
*/
async function recordPhoneAction(input: {
userId: string;
phoneE164?: string;
phoneHash: string;
action: SmsPhoneLogAction;
result: "ok" | "rejected" | "failed";
detail?: string;
jwtHash?: string;
providerMessageId?: string;
}): Promise<void> {
try {
await smsPhoneLogDb.append(input);
} catch (err) {
log.error("[NotifySms] Log write failed:", errorMessage(err));
}
}
/**
* A blocked number is barred outright: no code, no verification, no digest.
* Returns true when the request has been answered and the handler must stop.
*/
async function rejectIfBlocked(
res: express.Response,
input: { userId: string; phoneE164: string; phoneHash: string; jwtHash?: string }
): Promise<boolean> {
const blocked = await isPhoneNumberBlocked({
phoneE164: input.phoneE164,
phoneHash: input.phoneHash,
});
if (!blocked) return false;
await recordPhoneAction({
userId: input.userId,
phoneHash: input.phoneHash,
action: "number-blocked",
result: "rejected",
detail: "number is on the block list",
jwtHash: input.jwtHash,
});
sendError(
res,
403,
"SMS_PHONE_BLOCKED",
"This number has opted out of messages from this service."
);
return true;
}
export function createNotifySmsRouter(deps: NotifySmsDeps = {}): express.Router {
const router = Router();
const send = deps.sender ?? sendSms;
const authStages = deps.authStages ?? [requireAuth, requireEndorserAuth];
router.use(requireSmsEnabled);
router.get("/", (_req, res) => {
res.json({ ok: true, resource: "notify-sms" });
});
router.get(
"/phone",
...authStages,
requireSmsActionJwt("list-phones"),
async (req, res) => {
const userId = req.did as string;
const rows = await smsRegistrationsDb.listByUserId(userId);
const phones = rows.map((row) => ({
// The caller's own numbers, returned in full.
phoneNumber: row.phoneE164,
verified: row.verified,
verifiedAt: row.verifiedAt ?? null,
createdAt: row.createdAt,
}));
const requested = req.query.phoneNumber;
if (requested === undefined) {
res.status(200).json({ success: true, phones });
return;
}
const phoneE164 = normalizePhoneNumber(requested);
if (phoneE164 === undefined) {
sendError(
res,
400,
"SMS_PHONE_INVALID",
"phoneNumber is not a valid phone number."
);
return;
}
// Possession of the handset is the gate. A DID that has not verified this
// number learns nothing, or the endpoint becomes a phone-to-identity oracle.
const own = await smsRegistrationsDb.get(userId, phoneE164);
if (own === undefined || !own.verified) {
sendError(
res,
403,
"SMS_PHONE_NOT_VERIFIED_BY_CALLER",
"Verify this number for this identity before asking who else holds it."
);
return;
}
const dids = await smsRegistrationsDb.listVerifiedDidsForPhone(phoneE164);
res.status(200).json({ success: true, phones, phoneNumber: phoneE164, dids });
}
);
router.post(
"/phone",
...authStages,
requireSmsActionJwt("register-phone"),
async (req, res) => {
const started = Date.now();
const userId = req.did as string;
const jwtHash = req.smsActionJwtHash;
const config = smsConfig();
const secret = codeSecretOrFail(res);
if (secret === undefined) return;
const phoneE164 = normalizePhoneNumber(
(req.body as { phoneNumber?: unknown } | undefined)?.phoneNumber
);
if (phoneE164 === undefined) {
sendError(
res,
400,
"SMS_PHONE_INVALID",
"phoneNumber is not a valid phone number."
);
return;
}
const phoneHash = hashPhoneNumber(phoneE164, secret);
log.info(
"[NotifySms] Register requested,",
"phone=" + maskPhoneNumber(phoneE164)
);
if (await rejectIfBlocked(res, { userId, phoneE164, phoneHash, jwtHash })) {
return;
}
// A restricted instance must not onboard identities it may never text.
if (!isSmsRecipientAllowed(userId)) {
await recordPhoneAction({
userId,
phoneE164,
phoneHash,
action: "recipient-not-allowed",
result: "rejected",
detail: "SMS_ALLOWED_RECIPIENT_DIDS",
jwtHash,
});
sendError(
res,
403,
"SMS_RECIPIENT_NOT_ALLOWED",
"This server is restricted to a fixed set of recipient DIDs."
);
return;
}
// Idempotent, costs no money, and removes the obvious SMS-bombing lever.
const existing = await smsRegistrationsDb.get(userId, phoneE164);
if (existing?.verified === true) {
res.status(200).json({
success: true,
phoneNumber: maskPhoneNumber(phoneE164),
verified: true,
});
log.info(
"[NotifySms] Register no-op (already verified) in",
formatElapsedMs(Date.now() - started)
);
return;
}
const verifiedCount =
await smsRegistrationsDb.countVerifiedForPhone(phoneE164);
if (verifiedCount >= config.maxDidsPerPhone) {
// No identities here: a POST names any number on earth and proves
// nothing about it.
await recordPhoneAction({
userId,
phoneE164,
phoneHash,
action: "did-limit-blocked",
result: "rejected",
detail: `verifiedCount=${verifiedCount}`,
jwtHash,
});
sendError(
res,
409,
"SMS_PHONE_DID_LIMIT",
"This number already carries the maximum number of identities.",
{ limit: config.maxDidsPerPhone, verifiedCount }
);
return;
}
const perPhone = await smsPhoneLogDb.countByPhoneHashSince(
phoneHash,
CODE_SEND_ACTIONS,
new Date(Date.now() - HOUR_MS).toISOString()
);
const perDid = await smsPhoneLogDb.countByUserSince(
userId,
CODE_SEND_ACTIONS,
new Date(Date.now() - DAY_MS).toISOString()
);
if (
perPhone >= CODE_SENDS_PER_PHONE_PER_HOUR ||
perDid >= CODE_SENDS_PER_DID_PER_DAY
) {
sendError(
res,
429,
"SMS_CODE_RATE_LIMITED",
"Too many verification codes requested. Try again later."
);
return;
}
await recordPhoneAction({
userId,
phoneE164,
phoneHash,
action: "register-requested",
result: "ok",
jwtHash,
});
const code = mintVerificationCode();
const expiresAt = new Date(
Date.now() + config.codeTtlSec * 1000
).toISOString();
await smsRegistrationsDb.upsertPendingCode({
userId,
phoneE164,
codeHash: hashVerificationCode(code, secret),
codeExpiresAt: expiresAt,
sentAt: new Date().toISOString(),
});
const result = await send(
phoneE164,
`Gift Economies verification code: ${code}`
);
if (result.status === "failed") {
await recordPhoneAction({
userId,
phoneE164,
phoneHash,
action: "code-send-failed",
result: "failed",
detail: result.error,
jwtHash,
});
// Twilio already holds this number on its opt-out list; agree with it
// rather than letting the caller retry into the same refusal.
if (result.code === TWILIO_UNSUBSCRIBED_CODE) {
await smsBlockedNumbersDb.block({
phoneHash,
phoneE164,
reason: "provider-opt-out",
detail: `Twilio ${TWILIO_UNSUBSCRIBED_CODE}`,
});
await recordPhoneAction({
userId,
phoneHash,
action: "number-blocked",
result: "rejected",
detail: `Twilio ${TWILIO_UNSUBSCRIBED_CODE}`,
jwtHash,
});
}
sendError(
res,
502,
"SMS_CODE_SEND_FAILED",
"Could not send the verification code."
);
log.info(
"[NotifySms] Register failed in",
formatElapsedMs(Date.now() - started) + ":",
result.error
);
return;
}
await recordPhoneAction({
userId,
phoneE164,
phoneHash,
action: "code-sent",
result: "ok",
jwtHash,
providerMessageId: result.messageId,
});
res.status(200).json({
success: true,
phoneNumber: maskPhoneNumber(phoneE164),
verified: false,
expiresAt,
// Both conditions checked in smsConfig(); NODE_ENV comes first.
...(config.devEchoCode ? { devCode: code } : {}),
});
log.info(
"[NotifySms] Register completed in",
formatElapsedMs(Date.now() - started)
);
}
);
router.put(
"/phone",
...authStages,
requireSmsActionJwt("verify-phone"),
async (req, res) => {
const started = Date.now();
const userId = req.did as string;
const jwtHash = req.smsActionJwtHash;
const config = smsConfig();
const secret = codeSecretOrFail(res);
if (secret === undefined) return;
const body = (req.body ?? {}) as { phoneNumber?: unknown; code?: unknown };
const phoneE164 = normalizePhoneNumber(body.phoneNumber);
if (phoneE164 === undefined) {
sendError(
res,
400,
"SMS_PHONE_INVALID",
"phoneNumber is not a valid phone number."
);
return;
}
const phoneHash = hashPhoneNumber(phoneE164, secret);
if (await rejectIfBlocked(res, { userId, phoneE164, phoneHash, jwtHash })) {
return;
}
const registration = await smsRegistrationsDb.get(userId, phoneE164);
if (registration?.verified === true) {
res.status(200).json({
success: true,
phoneNumber: maskPhoneNumber(phoneE164),
verified: true,
});
return;
}
const expired =
registration === undefined ||
registration.codeHash === undefined ||
registration.codeExpiresAt === undefined ||
Date.parse(registration.codeExpiresAt) <= Date.now();
if (expired) {
sendError(
res,
400,
"SMS_CODE_EXPIRED",
"There is no pending verification code for this number."
);
return;
}
if (registration.codeAttempts >= config.codeMaxAttempts) {
// Clearing the code makes another POST the only way forward.
await smsRegistrationsDb.clearPendingCode(userId, phoneE164);
await recordPhoneAction({
userId,
phoneE164,
phoneHash,
action: "verify-failed",
result: "rejected",
detail: "attempts exhausted",
jwtHash,
});
sendError(
res,
429,
"SMS_CODE_ATTEMPTS_EXHAUSTED",
"Too many wrong codes. Request a new one."
);
return;
}
const candidate = typeof body.code === "string" ? body.code : "";
const matched = verificationCodeMatches(
candidate,
registration.codeHash as string,
secret
);
if (!matched) {
const attempts = await smsRegistrationsDb.incrementCodeAttempts(
userId,
phoneE164
);
await recordPhoneAction({
userId,
phoneE164,
phoneHash,
action: "verify-failed",
result: "rejected",
detail: `attempt ${attempts}`,
jwtHash,
});
sendError(
res,
400,
"SMS_CODE_MISMATCH",
"That code does not match.",
{
attemptsRemaining: Math.max(config.codeMaxAttempts - attempts, 0),
}
);
return;
}
// The cap is checked here because this is the moment a row starts
// consuming a slot; several rows can clear the POST check under the limit.
const otherVerified = await smsRegistrationsDb.countVerifiedForPhone(
phoneE164,
userId
);
if (otherVerified >= config.maxDidsPerPhone) {
// The code is consumed either way. One code buys one answer, and this
// caller has proved possession, so the identities are already theirs
// to read off the handset.
await smsRegistrationsDb.clearPendingCode(userId, phoneE164);
const dids =
await smsRegistrationsDb.listVerifiedDidsForPhone(phoneE164);
await recordPhoneAction({
userId,
phoneE164,
phoneHash,
action: "did-limit-disclosed",
result: "rejected",
detail: `verifiedCount=${otherVerified}`,
jwtHash,
});
sendError(
res,
409,
"SMS_PHONE_DID_LIMIT",
"This number already carries the maximum number of identities.",
{
limit: config.maxDidsPerPhone,
verifiedCount: otherVerified,
dids,
}
);
return;
}
await smsRegistrationsDb.markVerified(userId, phoneE164);
await recordPhoneAction({
userId,
phoneE164,
phoneHash,
action: "verify-succeeded",
result: "ok",
jwtHash,
});
res.status(200).json({
success: true,
phoneNumber: maskPhoneNumber(phoneE164),
verified: true,
});
log.info(
"[NotifySms] Verify completed in",
formatElapsedMs(Date.now() - started)
);
}
);
router.delete(
"/phone",
...authStages,
requireSmsActionJwt("delete-phone"),
async (req, res) => {
const userId = req.did as string;
const jwtHash = req.smsActionJwtHash;
const secret = codeSecretOrFail(res);
if (secret === undefined) return;
const phoneE164 = normalizePhoneNumber(requestPhoneNumber(req));
if (phoneE164 === undefined) {
sendError(
res,
400,
"SMS_PHONE_INVALID",
"phoneNumber is not a valid phone number."
);
return;
}
const phoneHash = hashPhoneNumber(phoneE164, secret);
const deleted = await smsRegistrationsDb.delete(userId, phoneE164);
// The log says what happened and when, without naming whose number it was.
await smsPhoneLogDb.scrubPhoneNumber(userId, phoneE164);
await recordPhoneAction({
userId,
phoneHash,
action: "deleted",
result: "ok",
detail: deleted ? "row removed" : "no registration",
jwtHash,
});
res.status(200).json({ success: true, deleted });
log.info(
"[NotifySms] Delete completed, deleted=" + String(deleted)
);
}
);
const storeAlertAuthorization: RequestHandler = async (req, res) => {
const started = Date.now();
const userId = req.did as string;
const jwtHash = req.smsActionJwtHash;
const secret = codeSecretOrFail(res);
if (secret === undefined) return;
if (!identitySupportsDelegatedJwtBatch(userId)) {
const failure = unsupportedIdentityFailure(userId);
sendError(res, 400, failure.error, failure.message, {
details: failure.details,
});
return;
}
// Storing 100 credentials for a channel with no reachable address is
// inventory nobody asked for.
const verified = await smsRegistrationsDb.listVerifiedByUserId(userId);
if (verified.length === 0) {
sendError(
res,
409,
"SMS_NO_VERIFIED_PHONE",
"Verify a phone number before authorizing SMS alert searches."
);
return;
}
const body = (req.body ?? {}) as AlertAuthorizationRequestBody;
const validated = await validateAlertAuthorizationBatch(userId, body);
if (!validated.ok) {
log.info(
"[NotifySmsAlertAuthorization] Rejected in",
formatElapsedMs(Date.now() - started) + ":",
validated.error
);
sendError(res, 400, validated.error, validated.message, {
details: validated.details,
});
return;
}
try {
const stored = await smsAlertAuthorizationDb.replaceUnusedBatch({
userId,
batchId: validated.batchId,
notifyHourUtc: validated.notifyHourUtc,
notifyMinuteUtc: validated.notifyMinuteUtc,
timezone: validated.timezone,
jwts: validated.jwts,
});
await recordPhoneAction({
userId,
phoneE164: verified[0].phoneE164,
phoneHash: hashPhoneNumber(verified[0].phoneE164, secret),
action: "alert-authorization-stored",
result: "ok",
detail:
"batchId=" +
stored.batch.batchId +
" notifyUtc=" +
storedNotifyLabel(stored.batch),
jwtHash,
});
res.status(200).json({
success: true,
batchId: stored.batch.batchId,
notifyHourUtc: stored.batch.notifyHourUtc ?? null,
notifyMinuteUtc: stored.batch.notifyMinuteUtc ?? null,
timezone: stored.batch.timezone ?? null,
storedCount: stored.storedCount,
unusedCount: stored.unusedCount,
});
log.info(
"[NotifySmsAlertAuthorization] Completed in",
formatElapsedMs(Date.now() - started) + ",",
"stored=" + stored.storedCount + ",",
"notifyUtc=" + storedNotifyLabel(stored.batch)
);
} catch (err) {
log.error(
"[NotifySmsAlertAuthorization] Failed in",
formatElapsedMs(Date.now() - started) + ":",
errorMessage(err)
);
sendError(
res,
500,
"SMS_ALERT_AUTHORIZATION_FAILED",
"Failed to store delegated notification-JWT batch."
);
}
};
/**
* Turn the channel off: every SMS batch and every SMS JWT for this DID, used
* or not, so the scheduler stops listing the identity entirely. Registered
* phone numbers survive — a user silencing alerts has not asked to redo the
* possession check when they come back. `DELETE /notify-sms/phone` is the
* route that forgets a number.
*/
const deleteAlertAuthorization: RequestHandler = async (req, res) => {
const started = Date.now();
const userId = req.did as string;
const jwtHash = req.smsActionJwtHash;
const secret = codeSecretOrFail(res);
if (secret === undefined) return;
try {
const removed = await smsAlertAuthorizationDb.deleteAllForUser(userId);
const verified = await smsRegistrationsDb.listVerifiedByUserId(userId);
// Logged against a number when there is one. A DID whose handset is
// already gone still gets the revocation recorded, under a hash of the
// identity that stands in for the number the log column expects.
await recordPhoneAction({
userId,
phoneE164: verified[0]?.phoneE164,
phoneHash: hashPhoneNumber(
verified[0]?.phoneE164 ?? NO_PHONE_HASH_SUBJECT + userId,
secret
),
action: "alert-authorization-deleted",
result: "ok",
detail:
"batches=" +
removed.deletedBatches +
" jwts=" +
removed.deletedJwts,
jwtHash,
});
res.status(200).json({
success: true,
deletedBatches: removed.deletedBatches,
deletedJwts: removed.deletedJwts,
});
log.info(
"[NotifySmsAlertAuthorization] Deleted in",
formatElapsedMs(Date.now() - started) + ",",
"batches=" + removed.deletedBatches + ",",
"jwts=" + removed.deletedJwts
);
} catch (err) {
log.error(
"[NotifySmsAlertAuthorization] Delete failed in",
formatElapsedMs(Date.now() - started) + ":",
errorMessage(err)
);
sendError(
res,
500,
"SMS_ALERT_AUTHORIZATION_DELETE_FAILED",
"Failed to remove the delegated notification-JWT inventory."
);
}
};
/**
* Twilio's inbound webhook. It authenticates by X-Twilio-Signature, not by
* JWT: this is Twilio calling, not a user, and the request carries a phone
* number with no identity attached.
*/
router.post(
"/inbound",
express.urlencoded({ extended: false }),
async (req, res) => {
const config = smsConfig();
const params = (req.body ?? {}) as Record<string, string>;
if (config.twilioAuthToken === undefined) {
log.error("[NotifySmsInbound] No TWILIO_AUTH_TOKEN; refusing");
sendError(
res,
503,
"SMS_NOT_CONFIGURED",
"Inbound SMS is not configured."
);
return;
}
const url =
config.twilioWebhookUrl ??
`${req.protocol}://${req.get("host") ?? ""}${req.originalUrl}`;
if (
!twilioSignatureMatches(
config.twilioAuthToken,
url,
params,
req.get("X-Twilio-Signature")
)
) {
log.error("[NotifySmsInbound] Signature mismatch for", url);
sendError(
res,
403,
"SMS_INBOUND_SIGNATURE_INVALID",
"Invalid Twilio signature."
);
return;
}
const secret = codeSecretOrFail(res);
if (secret === undefined) return;
const from = normalizePhoneNumber(params.From);
const keyword = (params.Body ?? "").trim().toUpperCase();
if (from === undefined) {
res.status(200).type("text/xml").send(EMPTY_TWIML);
return;
}
if (SMS_OPT_OUT_KEYWORDS.has(keyword)) {
// Stopping traffic to a handset is not optional, and the request names
// no identity, so this is the one place the DID boundary is crossed.
const switchedOff =
await smsRegistrationsDb.unverifyAllForPhone(from);
const dids = await smsRegistrationsDb.listVerifiedDidsForPhone(from);
// Unverifying alone would let the number re-register minutes later and
// resume queueing messages Twilio then refuses with 21610.
await smsBlockedNumbersDb.block({
phoneHash: hashPhoneNumber(from, secret),
phoneE164: from,
reason: "opt-out",
detail: `keyword ${keyword}`,
});
log.info(
"[NotifySmsInbound] Opt-out for",
maskPhoneNumber(from) + ",",
"registrations switched off=" + String(switchedOff)
);
await recordPhoneAction({
userId: dids[0] ?? "(unknown)",
phoneHash: hashPhoneNumber(from, secret),
action: "opt-out",
result: "ok",
detail: `switchedOff=${switchedOff}, blocked`,
});
// Twilio sends its own STOP confirmation; a second reply is noise.
res.status(200).type("text/xml").send(EMPTY_TWIML);
return;
}
if (SMS_HELP_KEYWORDS.has(keyword)) {
res.status(200).type("text/xml").send(twiml(SMS_HELP_REPLY));
return;
}
if (SMS_OPT_IN_KEYWORDS.has(keyword)) {
// Lifting the block mirrors Twilio, which clears its own opt-out entry
// on START. It does not re-verify: possession was proved by a code, and
// that code is gone, so a fresh POST plus a fresh code is the way back.
const phoneHash = hashPhoneNumber(from, secret);
const unblocked = await smsBlockedNumbersDb.unblock(phoneHash);
if (unblocked) {
await recordPhoneAction({
userId: "(inbound)",
phoneHash,
action: "number-unblocked",
result: "ok",
detail: `keyword ${keyword}`,
});
}
res.status(200).type("text/xml").send(twiml(SMS_OPT_IN_REPLY));
return;
}
res.status(200).type("text/xml").send(EMPTY_TWIML);
}
);
// POST is the documented verb; PUT is accepted as an alias because the
// semantics are replace-not-append either way.
const alertAuthorizationChain: RequestHandler[] = [
...authStages,
requireSmsActionJwt("authorize-alert-search"),
storeAlertAuthorization,
];
router.post("/alert-authorization", alertAuthorizationChain);
router.put("/alert-authorization", alertAuthorizationChain);
router.delete(
"/alert-authorization",
...authStages,
requireSmsActionJwt("revoke-alert-search"),
deleteAlertAuthorization
);
return router;
}
export const notifySmsRouter: express.Router = createNotifySmsRouter();
+55 -4
View File
@@ -1,11 +1,62 @@
import { db } from "./db/fcmTokensSqlite.js";
import { sendPushToDevice } from "./services/pushService.js";
import { errorMessage, formatElapsedMs } from "./util/formatElapsed.js";
import { log } from "./util/log.js";
let intervalId: ReturnType<typeof setInterval> | undefined;
export function startScheduler(): void {
if (intervalId !== undefined) return;
// TODO: replace with job queue or cron for wake-up checks
intervalId = setInterval(() => {
// placeholder tick
}, 60_000);
intervalId = setInterval(async () => {
const passStarted = Date.now();
log.info("[Scheduler] Pass started");
try {
const devices = await db.getAllForScheduler();
const seenTokens = new Set<string>();
let checked = 0;
let sent = 0;
let skipped = 0;
let failed = 0;
let duplicates = 0;
for (const d of devices) {
if (seenTokens.has(d.fcmToken)) {
duplicates++;
continue;
}
seenTokens.add(d.fcmToken);
checked++;
const result = await sendPushToDevice(d.fcmToken);
if (result === "sent") sent++;
else if (result === "skipped") skipped++;
else failed++;
}
const summaryParts = [
`Checked ${checked} devices`,
`sent ${sent} pushes`,
`skipped ${skipped}`,
];
if (failed > 0) summaryParts.push(`failed ${failed}`);
if (duplicates > 0) {
summaryParts.push(`${duplicates} duplicates ignored`);
}
log.info("[Scheduler]", summaryParts.join(", "));
log.info(
"[Scheduler] Pass completed in",
formatElapsedMs(Date.now() - passStarted)
);
} catch (err) {
log.error(
"[Scheduler] Pass failed in",
formatElapsedMs(Date.now() - passStarted) + ":",
errorMessage(err)
);
}
}, 5 * 60 * 1000);
}
export function stopScheduler(): void {
+402
View File
@@ -0,0 +1,402 @@
import { EXPECTED_ALERT_JWT_BATCH_SIZE } from "../db/alertAuthorizationSqlite.js";
import {
DELEGATED_JWT_UNSUPPORTED_IDENTITY_CODE,
decodeAndVerifyDelegatedJwt,
identitySupportsDelegatedJwtBatch,
} from "../vc/index.js";
const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
const SECONDS_PER_DAY = 86400;
const MAX_REPORTED_ERRORS = 20;
export type AlertAuthorizationRequestBody = {
batchId?: unknown;
notifyHourUtc?: unknown;
notifyMinuteUtc?: unknown;
timezone?: unknown;
jwts?: unknown;
};
export type ValidatedAlertJwt = {
sequence: number;
day: string;
jwt: string;
nbf: number;
exp: number;
};
export type BatchValidationSuccess = {
ok: true;
batchId: string;
/** UTC hour, 0-23. Always present; the field is required. */
notifyHourUtc: number;
/** UTC minute, 0-59. Always present; the field is required. */
notifyMinuteUtc: number;
/**
* The IANA zone the caller named, when they named one. Stored and otherwise
* unused: no scheduling decision consults it.
*/
timezone: string | undefined;
jwts: ValidatedAlertJwt[];
};
export type BatchValidationFailure = {
ok: false;
error: string;
message: string;
details: string[];
};
/** `YYYY-MM-DD` in UTC — the frame every stored `day` is labelled in. */
export function utcCalendarDay(epochSec: number): string {
return new Date(epochSec * 1000).toISOString().slice(0, 10);
}
/** Midnight UTC that opens `day`, or undefined when `day` is not a real date. */
export function utcDayStartSeconds(day: string): number | undefined {
if (!DAY_RE.test(day)) return undefined;
const parsed = Date.parse(day + "T00:00:00Z");
if (Number.isNaN(parsed)) return undefined;
const seconds = Math.floor(parsed / 1000);
// Date.parse rolls 2026-02-30 forward to March rather than refusing it.
return utcCalendarDay(seconds) === day ? seconds : undefined;
}
/**
* Zero-pads an hour and minute into the `HH:MM` the column stores. The padding
* is what makes SQLite's text comparison against that column chronological.
*/
export function formatHourMinuteUtc(hour: number, minute: number): string {
return (
String(hour).padStart(2, "0") + ":" + String(minute).padStart(2, "0")
);
}
/** The stored hour as one loggable token, or "none" when a batch names none. */
export function storedNotifyLabel(batch: {
notifyHourUtc?: number;
notifyMinuteUtc?: number;
}): string {
if (batch.notifyHourUtc === undefined || batch.notifyMinuteUtc === undefined) {
return "none";
}
return formatHourMinuteUtc(batch.notifyHourUtc, batch.notifyMinuteUtc);
}
/** Splits a stored `HH:MM` back into its parts, or undefined if malformed. */
export function parseHourMinuteUtc(
value: string
): { hour: number; minute: number } | undefined {
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value);
if (match === null) return undefined;
return { hour: Number(match[1]), minute: Number(match[2]) };
}
/** `HH:MM` UTC, the form `notify_hour_min_utc` is stored in and compared against. */
export function utcHourMinute(epochSec: number): string {
return new Date(epochSec * 1000).toISOString().slice(11, 16);
}
/**
* Whether `Intl` can resolve the name. Checked at the door because a zone this
* service cannot resolve is worth nothing to whatever reads the column later,
* and the caller is the only party able to correct it.
*/
export function isValidIanaTimeZone(timezone: string): boolean {
if (timezone.length === 0) return false;
try {
Intl.DateTimeFormat(undefined, { timeZone: timezone });
return true;
} catch {
return false;
}
}
type JwtItemBody = {
sequence?: unknown;
day?: unknown;
jwt?: unknown;
nbf?: unknown;
exp?: unknown;
};
function clientErrorInfo(err: unknown): { message: string; code?: string } {
if (err && typeof err === "object" && "clientError" in err) {
const clientError = (err as { clientError?: { message?: string; code?: string } })
.clientError;
if (typeof clientError?.message === "string" && clientError.message.length > 0) {
return { message: clientError.message, code: clientError.code };
}
}
if (err instanceof Error && err.message.length > 0) {
return { message: err.message };
}
return { message: "Delegated JWT verification failed." };
}
function isFiniteInteger(value: unknown): value is number {
return typeof value === "number" && Number.isInteger(value);
}
/**
* One half of the notify time: required, an integer, and inside its own range.
* Reported per field so a caller sending only the hour learns which is missing.
*/
function validUtcPart(
value: unknown,
field: string,
max: number,
details: string[]
): number | undefined {
if (value === undefined || value === null) {
details.push(`${field} is required: an integer 0-${max}, in UTC`);
return undefined;
}
if (!isFiniteInteger(value) || value < 0 || value > max) {
details.push(
`${field} must be an integer 0-${max}, in UTC, got ` +
JSON.stringify(value)
);
return undefined;
}
return value;
}
export function unsupportedIdentityFailure(did: string): BatchValidationFailure {
return {
ok: false,
error: DELEGATED_JWT_UNSUPPORTED_IDENTITY_CODE,
message:
"This identity type cannot mint a delegated notification-JWT batch. Passkey (did:peer) sessions require a live assertion and cannot pre-issue 100 future JWTs.",
details: [`Authenticated DID: ${did}`],
};
}
export async function validateAlertAuthorizationBatch(
userDid: string,
body: AlertAuthorizationRequestBody
): Promise<BatchValidationSuccess | BatchValidationFailure> {
if (!identitySupportsDelegatedJwtBatch(userDid)) {
return unsupportedIdentityFailure(userDid);
}
const details: string[] = [];
const batchId =
typeof body.batchId === "string" ? body.batchId.trim() : undefined;
if (batchId === undefined || batchId.length === 0) {
details.push("batchId is required");
}
// Both required, and both UTC — the field names say so, which is why no
// offset or zone travels with them. Required rather than optional because the
// alternative default is not "no gate" but "the first tick after midnight
// UTC", the setting that puts every user on one tick.
const notifyHourUtc = validUtcPart(
body.notifyHourUtc,
"notifyHourUtc",
23,
details
);
const notifyMinuteUtc = validUtcPart(
body.notifyMinuteUtc,
"notifyMinuteUtc",
59,
details
);
// Recorded against the day a later DST mechanism might need it. Optional and
// independent of notifyTime: the offset in that value is what schedules a
// send today, and nothing here reads this one.
let timezone: string | undefined;
if (body.timezone !== undefined && body.timezone !== null) {
const raw =
typeof body.timezone === "string" ? body.timezone.trim() : undefined;
if (raw === undefined || !isValidIanaTimeZone(raw)) {
details.push(
`timezone must be an IANA zone name (e.g. "America/Denver"), got ` +
JSON.stringify(body.timezone)
);
} else {
timezone = raw;
}
}
if (!Array.isArray(body.jwts)) {
details.push("jwts must be an array of 100 delegated JWT entries");
return fail(details);
}
if (body.jwts.length !== EXPECTED_ALERT_JWT_BATCH_SIZE) {
details.push(
`jwts must contain exactly ${EXPECTED_ALERT_JWT_BATCH_SIZE} entries, got ${body.jwts.length}`
);
return fail(details);
}
const sequences = new Set<number>();
const days = new Set<string>();
const validated: ValidatedAlertJwt[] = [];
for (let i = 0; i < body.jwts.length; i++) {
const raw = body.jwts[i];
const prefix = `jwts[${i}]`;
if (raw === null || typeof raw !== "object") {
details.push(`${prefix} must be an object`);
continue;
}
const item = raw as JwtItemBody;
if (!isFiniteInteger(item.sequence)) {
details.push(`${prefix}.sequence must be an integer`);
continue;
}
if (sequences.has(item.sequence)) {
details.push(`${prefix}.sequence ${item.sequence} is duplicated`);
} else {
sequences.add(item.sequence);
}
if (typeof item.day !== "string") {
details.push(`${prefix}.day must be YYYY-MM-DD`);
continue;
}
const dayStart = utcDayStartSeconds(item.day);
if (dayStart === undefined) {
details.push(`${prefix}.day must be a real UTC date as YYYY-MM-DD`);
continue;
}
if (days.has(item.day)) {
details.push(`${prefix}.day ${item.day} is duplicated`);
} else {
days.add(item.day);
}
if (typeof item.jwt !== "string" || item.jwt.length === 0) {
details.push(`${prefix}.jwt is required`);
continue;
}
if (!isFiniteInteger(item.nbf) || !isFiniteInteger(item.exp)) {
details.push(`${prefix}.nbf and ${prefix}.exp must be integer unix seconds`);
continue;
}
if (item.nbf >= item.exp) {
details.push(`${prefix}.nbf must be less than ${prefix}.exp`);
continue;
}
// The daily run picks a JWT by UTC day and may fire at any moment inside
// it, catch-up runs included. A window that only covers part of that day
// would hand Endorser a credential outside its own validity period.
const dayEnd = dayStart + SECONDS_PER_DAY;
if (item.nbf > dayStart || item.exp < dayEnd) {
details.push(
`${prefix} must be valid for all of UTC day ${item.day} ` +
`(nbf <= ${dayStart} and exp >= ${dayEnd}), got nbf ${item.nbf} exp ${item.exp}`
);
}
try {
const verified = await decodeAndVerifyDelegatedJwt(item.jwt);
if (!verified.verified) {
details.push(`${prefix}.jwt failed verification`);
continue;
}
if (verified.issuer !== userDid) {
details.push(
`${prefix}.jwt iss ${verified.issuer} does not match the authenticated user`
);
continue;
}
const payloadNbf = verified.payload.nbf;
const payloadExp = verified.payload.exp;
if (payloadNbf !== item.nbf) {
details.push(
`${prefix}.nbf ${item.nbf} does not match JWT claim nbf ${String(payloadNbf)}`
);
}
if (payloadExp !== item.exp) {
details.push(
`${prefix}.exp ${item.exp} does not match JWT claim exp ${String(payloadExp)}`
);
}
if (
typeof payloadNbf === "number" &&
typeof payloadExp === "number" &&
payloadNbf >= payloadExp
) {
details.push(`${prefix} JWT nbf must be less than exp`);
}
} catch (err) {
const info = clientErrorInfo(err);
if (info.code === DELEGATED_JWT_UNSUPPORTED_IDENTITY_CODE) {
return {
ok: false,
error: DELEGATED_JWT_UNSUPPORTED_IDENTITY_CODE,
message: info.message,
details: [`${prefix}: ${info.message}`],
};
}
details.push(`${prefix}.jwt: ${info.message}`);
continue;
}
validated.push({
sequence: item.sequence,
day: item.day,
jwt: item.jwt,
nbf: item.nbf,
exp: item.exp,
});
}
if (
details.length === 0 &&
body.jwts.length === EXPECTED_ALERT_JWT_BATCH_SIZE &&
sequences.size === EXPECTED_ALERT_JWT_BATCH_SIZE
) {
const seqList = [...sequences].sort((a, b) => a - b);
const spanOk =
seqList[seqList.length - 1] - seqList[0] ===
EXPECTED_ALERT_JWT_BATCH_SIZE - 1;
if (!spanOk) {
details.push(
`sequence values must be 100 consecutive integers (got ${seqList[0]}..${seqList[seqList.length - 1]})`
);
}
}
if (
details.length > 0 ||
batchId === undefined ||
notifyHourUtc === undefined ||
notifyMinuteUtc === undefined ||
validated.length !== EXPECTED_ALERT_JWT_BATCH_SIZE
) {
return fail(details);
}
return {
ok: true,
batchId,
notifyHourUtc,
notifyMinuteUtc,
timezone,
jwts: validated,
};
}
function fail(details: string[]): BatchValidationFailure {
const reported = details.slice(0, MAX_REPORTED_ERRORS);
if (details.length > MAX_REPORTED_ERRORS) {
reported.push(`and ${details.length - MAX_REPORTED_ERRORS} more`);
}
return {
ok: false,
error: "ALERT_AUTHORIZATION_INVALID_BATCH",
message:
"Delegated notification-JWT batch was rejected. Nothing was stored.",
details: reported.length > 0 ? reported : ["Invalid batch"],
};
}
+64
View File
@@ -0,0 +1,64 @@
import { ENDORSER_URL } from "../env.js";
import { errorMessage } from "../util/formatElapsed.js";
import { log } from "../util/log.js";
const RATE_LIMITS_PATH = "/api/report/rateLimits";
export type EndorserAuthResult =
| { ok: true }
| { ok: false; reason: "unauthorized" | "unavailable error" | "unavailable 500" };
function rateLimitsUrl(): string {
const base = ENDORSER_URL.replace(/\/+$/, "");
return `${base}${RATE_LIMITS_PATH}`;
}
/**
* Confirms a JWT is accepted by the Endorser server.
*
* Calls GET /api/report/rateLimits with the JWT as a Bearer token.
* Distinguishes auth rejection from Endorser unavailability.
* Does not expose HTTP status or response bodies to callers.
*/
export async function checkAuth(jwt: string): Promise<EndorserAuthResult> {
const url = rateLimitsUrl();
let response: Response;
try {
response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${jwt}`,
},
});
} catch (err) {
log.error(
"[Endorser] Auth check request failed for",
url + ":",
errorMessage(err)
);
return { ok: false, reason: "unavailable error" };
}
if (response.ok) {
return { ok: true };
}
// 5xx: Endorser is up but unhealthy; treat as unavailable.
if (response.status >= 500) {
log.error(
"[Endorser] Auth check unavailable for",
url + ", status",
response.status
);
return { ok: false, reason: "unavailable 500" };
}
// 4xx: JWT rejected or user not registered on Endorser.
log.error(
"[Endorser] Auth check rejected for",
url + ", status",
response.status
);
return { ok: false, reason: "unauthorized" };
}
+54
View File
@@ -0,0 +1,54 @@
import admin from "firebase-admin";
import type { ServiceAccount } from "firebase-admin/app";
import type { Messaging } from "firebase-admin/messaging";
import { log } from "../util/log.js";
type ServiceAccountJson = ServiceAccount & { project_id?: string };
function serviceAccountProjectId(account: ServiceAccountJson): string | undefined {
if (typeof account.projectId === "string" && account.projectId.length > 0) {
return account.projectId;
}
if (typeof account.project_id === "string" && account.project_id.length > 0) {
return account.project_id;
}
return undefined;
}
function resolveCredential(): admin.credential.Credential {
const json = process.env.FIREBASE_SERVICE_ACCOUNT_JSON;
if (json !== undefined && json.trim() !== "") {
let account: ServiceAccountJson;
try {
account = JSON.parse(json) as ServiceAccountJson;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log.error(
"[Firebase] FIREBASE_SERVICE_ACCOUNT_JSON parse failed:",
message
);
throw err;
}
const projectId = serviceAccountProjectId(account);
log.info(
"[Firebase] Credential: FIREBASE_SERVICE_ACCOUNT_JSON (parsed successfully)"
);
if (projectId !== undefined) {
log.info("[Firebase] project_id:", projectId);
} else {
log.info("[Firebase] project_id: (not found in service account JSON)");
}
return admin.credential.cert(account);
}
log.info("[Firebase] Credential: Application Default Credentials");
return admin.credential.applicationDefault();
}
if (!admin.apps.length) {
admin.initializeApp({
credential: resolveCredential(),
});
}
export const messaging: Messaging = admin.messaging();
+150 -5
View File
@@ -1,8 +1,153 @@
import type { Device } from "../models/device.js";
import { db, type StoredRow } from "../db/fcmTokensSqlite.js";
import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js";
import { maskToken } from "../util/maskToken.js";
import { messaging } from "./firebase.js";
import { log } from "../util/log.js";
const MS_PRODUCTION = 23 * 60 * 60 * 1000;
const MS_TEST = 10 * 60 * 1000;
export function notifyThresholdMs(testMode?: boolean): number {
return testMode === true ? MS_TEST : MS_PRODUCTION;
}
/** Epoch ms when the device may receive another push (diagnostics only). */
export function computeNextEligibleAt(row: {
lastNotifiedAt?: number;
testMode?: boolean;
}): number {
const threshold = notifyThresholdMs(row.testMode);
if (row.lastNotifiedAt === undefined) {
return Date.now();
}
return row.lastNotifiedAt + threshold;
}
function lastNotifiedMs(row: StoredRow | undefined): number | undefined {
const v = row?.lastNotifiedAt;
if (v === undefined) return undefined;
if (typeof v === "number") return Number.isNaN(v) ? undefined : v;
return undefined;
}
function stringifyData(
payload: Record<string, unknown>
): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(payload)) {
out[k] = v === undefined || v === null ? "" : String(v);
}
return out;
}
/**
* Sends an FCM data message if the token is outside the dedupe window
* (23h production, 10m test).
*/
export async function sendPushToDevice(
_device: Device,
_payload: Record<string, unknown>
): Promise<void> {
// TODO: integrate with push provider (FCM, APNs, etc.)
fcmToken: string,
payload: Record<string, unknown> = {}
): Promise<"sent" | "skipped" | "failed"> {
const suffix = maskToken(fcmToken);
const row = await db.getByFcmToken(fcmToken);
const now = Date.now();
const last = lastNotifiedMs(row);
if (
last !== undefined &&
now - last < notifyThresholdMs(row?.testMode)
) {
return "skipped";
}
const sendStarted = Date.now();
log.info("[Push] Send attempt, token suffix:", suffix);
try {
const data: Record<string, string> = {
...stringifyData(payload),
type: "WAKEUP_PING",
};
await messaging.send({
token: fcmToken,
apns: {
headers: {
"apns-push-type": "background",
"apns-priority": "5",
},
payload: {
aps: {
contentAvailable: true,
},
},
},
data,
});
const persisted = await db.getByFcmToken(fcmToken);
if (persisted !== undefined) {
await db.update(persisted.id, { lastNotifiedAt: Date.now() });
}
log.info(
"[Push] Send completed in",
formatElapsedMs(Date.now() - sendStarted) + ",",
"token suffix:",
suffix
);
return "sent";
} catch (err) {
log.error(
"[Push] Send failed in",
formatElapsedMs(Date.now() - sendStarted) + ",",
"token suffix:",
suffix + ":",
errorMessage(err)
);
return "failed";
}
}
export const ALERT_SEARCH_FCM_TYPE = "alert_search";
/**
* User-visible AlertSearch digest push. Does not apply the WAKEUP_PING
* last_notified_at window and does not write last_notified_at.
*/
export async function sendAlertSearchPushToDevice(
fcmToken: string,
content: { title: string; body: string }
): Promise<"sent" | "failed"> {
const suffix = maskToken(fcmToken);
const sendStarted = Date.now();
log.info("[AlertSearchPush] Send attempt, token suffix:", suffix);
try {
await messaging.send({
token: fcmToken,
notification: {
title: content.title,
body: content.body,
},
data: {
type: ALERT_SEARCH_FCM_TYPE,
},
});
log.info(
"[AlertSearchPush] Send completed in",
formatElapsedMs(Date.now() - sendStarted) + ",",
"token suffix:",
suffix
);
return "sent";
} catch (err) {
log.error(
"[AlertSearchPush] Send failed in",
formatElapsedMs(Date.now() - sendStarted) + ",",
"token suffix:",
suffix + ":",
errorMessage(err)
);
return "failed";
}
}
+203
View File
@@ -0,0 +1,203 @@
import { smsConfig, type SmsConfig } from "../env.js";
import { errorMessage } from "../util/formatElapsed.js";
import { maskPhoneNumber } from "../util/smsPhoneNumber.js";
import { log } from "../util/log.js";
export type SmsSendResult =
| { status: "sent"; messageId: string }
| { status: "failed"; error: string; code?: number };
export type SmsSender = (to: string, body: string) => Promise<SmsSendResult>;
export const SMS_NOT_CONFIGURED = "SMS_NOT_CONFIGURED";
/**
* Twilio's "attempt to send to unsubscribed recipient". It is the provider
* telling us a number is on its own opt-out list, which is the authority for
* compliance and is invisible to this service any other way.
*/
export const TWILIO_UNSUBSCRIBED_CODE = 21610;
const TWILIO_API_BASE = "https://api.twilio.com/2010-04-01";
type TwilioCredentials = {
accountSid: string;
authToken: string;
from: { From: string } | { MessagingServiceSid: string };
};
/**
* Names the variables a send would need and does not have. An account and a
* token alone cannot produce a message: there has to be something to send from.
*/
export function missingTwilioConfig(config: SmsConfig = smsConfig()): string[] {
const missing: string[] = [];
if (config.twilioAccountSid === undefined) missing.push("TWILIO_ACCOUNT_SID");
if (config.twilioAuthToken === undefined) missing.push("TWILIO_AUTH_TOKEN");
if (
config.twilioMessagingServiceSid === undefined &&
config.twilioFromNumber === undefined
) {
missing.push("TWILIO_MESSAGING_SERVICE_SID or TWILIO_FROM_NUMBER");
}
return missing;
}
/**
* Whether this instance may text the given identity at all.
*
* An environment guard rather than a transport concern: a test server pointed
* at live credentials and restored from a production database would otherwise
* text every verified user it inherited. Comparison is case-insensitive, since
* a checksummed `did:ethr` address and its lowercase form name one identity.
*/
export function isSmsRecipientAllowed(
userId: string,
config: SmsConfig = smsConfig()
): boolean {
const allowed = config.allowedRecipientDids;
if (allowed === undefined) return true;
return allowed.includes(userId.trim().toLowerCase());
}
/**
* A sender needs an account, a token, and something to send from. Anything less
* cannot produce a message, so it is not a partial configuration but no sender.
*/
function twilioCredentials(
config: SmsConfig
): TwilioCredentials | undefined {
const { twilioAccountSid, twilioAuthToken } = config;
if (twilioAccountSid === undefined || twilioAuthToken === undefined) {
return undefined;
}
const from =
config.twilioMessagingServiceSid !== undefined
? { MessagingServiceSid: config.twilioMessagingServiceSid }
: config.twilioFromNumber !== undefined
? { From: config.twilioFromNumber }
: undefined;
if (from === undefined) return undefined;
return { accountSid: twilioAccountSid, authToken: twilioAuthToken, from };
}
/** One form POST. The repo already talks to Endorser and Partner with fetch. */
export async function sendViaTwilio(
credentials: TwilioCredentials,
to: string,
body: string
): Promise<SmsSendResult> {
const url = `${TWILIO_API_BASE}/Accounts/${encodeURIComponent(
credentials.accountSid
)}/Messages.json`;
const form = new URLSearchParams({ To: to, Body: body, ...credentials.from });
const basic = Buffer.from(
`${credentials.accountSid}:${credentials.authToken}`
).toString("base64");
try {
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Basic ${basic}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: form.toString(),
});
const payload = (await response.json().catch(() => ({}))) as {
sid?: unknown;
message?: unknown;
code?: unknown;
};
if (!response.ok) {
const detail =
typeof payload.message === "string"
? payload.message
: `HTTP ${response.status}`;
return {
status: "failed",
error: detail,
...(typeof payload.code === "number" ? { code: payload.code } : {}),
};
}
if (typeof payload.sid !== "string" || payload.sid.length === 0) {
return { status: "failed", error: "Twilio response carried no sid" };
}
return { status: "sent", messageId: payload.sid };
} catch (err) {
return { status: "failed", error: errorMessage(err) };
}
}
/** Prints instead of sending, so a desk with no carrier coverage still works. */
export const consoleSmsSender: SmsSender = async (to, body) => {
log.info(
"[SmsService] Console adapter would send to",
maskPhoneNumber(to) + ":",
body
);
return { status: "sent", messageId: `console-${Date.now()}` };
};
let warnedNotConfigured = false;
/**
* The configured sender, resolved per call. Missing credentials fail the send
* rather than the process: a texting outage should not take push down with it.
*/
export const sendSms: SmsSender = async (to, body) => {
const config = smsConfig();
const credentials = twilioCredentials(config);
if (credentials === undefined) {
if (process.env.NODE_ENV === "test-local") {
return consoleSmsSender(to, body);
}
if (!warnedNotConfigured) {
warnedNotConfigured = true;
log.error(
"[SmsService] Twilio is not configured; SMS sends will fail. Missing:",
missingTwilioConfig(config).join(", ")
);
}
return { status: "failed", error: SMS_NOT_CONFIGURED };
}
return sendViaTwilio(credentials, to, body);
};
/** Test helper: let the once-per-process warning fire again. */
export function resetSmsNotConfiguredWarning(): void {
warnedNotConfigured = false;
}
/**
* Whether this service must not text a number, for any reason.
*
* Two sources, both consulted: the `sms_blocked_numbers` table, which the STOP
* webhook writes to, and `SMS_BLOCKED_NUMBERS`, which blocks a number without
* waiting for the handset to ask. Twilio keeps its own opt-out list and refuses
* such messages with 21610 regardless; this is how the service comes to agree
* with it rather than retrying forever.
*/
export async function isPhoneNumberBlocked(input: {
phoneE164: string;
phoneHash: string;
config?: SmsConfig;
}): Promise<boolean> {
const config = input.config ?? smsConfig();
if (config.blockedNumbers.length > 0) {
const { normalizePhoneNumber } = await import(
"../util/smsPhoneNumber.js"
);
for (const entry of config.blockedNumbers) {
if (normalizePhoneNumber(entry) === input.phoneE164) return true;
}
}
const { smsBlockedNumbersDb } = await import(
"../db/smsBlockedNumbersSqlite.js"
);
return smsBlockedNumbersDb.isBlocked(input.phoneHash);
}
+31
View File
@@ -0,0 +1,31 @@
import { createHmac, timingSafeEqual } from "node:crypto";
/**
* Twilio signs the exact URL it posted to, concatenated with every POST
* parameter in key order. Behind a proxy or tunnel the request's own headers do
* not reliably reproduce that URL, which is why the public URL is configured.
*/
export function twilioSignatureFor(
authToken: string,
url: string,
params: Record<string, string>
): string {
let payload = url;
for (const key of Object.keys(params).sort()) {
payload += key + params[key];
}
return createHmac("sha1", authToken).update(payload, "utf8").digest("base64");
}
export function twilioSignatureMatches(
authToken: string,
url: string,
params: Record<string, string>,
provided: unknown
): boolean {
if (typeof provided !== "string" || provided.length === 0) return false;
const expected = Buffer.from(twilioSignatureFor(authToken, url, params));
const actual = Buffer.from(provided);
if (expected.length !== actual.length) return false;
return timingSafeEqual(expected, actual);
}
+16
View File
@@ -0,0 +1,16 @@
declare global {
namespace Express {
interface Request {
/** Authenticated user DID (issuer from verified JWT). */
did?: string;
/** Raw Bearer JWT from the Authorization header. */
jwt?: string;
/** Verified auth context. Present only after requireAuth succeeded. */
auth?: import("../middleware/auth.js").AuthContext;
/** sha256 of the consumed action JWT, set by requireSmsActionJwt. */
smsActionJwtHash?: string;
}
}
}
export {};
+37
View File
@@ -0,0 +1,37 @@
/**
* How many users a scheduler pass works on at once. The per-user work is two
* external round trips against independent cursors, so a serial loop spends the
* whole pass waiting: at 200ms per user it stops fitting inside a five-minute
* tick somewhere around 1,500 users, and the in-flight guard then starts
* skipping passes.
*
* Held low deliberately. The ceiling this relieves is latency, not throughput,
* and the two APIs on the other end are shared infrastructure that a wide fan-out
* would simply move the queue into.
*/
export const ALERT_SEARCH_USER_CONCURRENCY = 8;
/**
* Runs `work` over `items` with at most `limit` in flight, preserving neither
* start nor finish order. Callers own their errors: a throw from `work` aborts
* the remaining items, so anything that should merely be counted and skipped has
* to be caught inside `work`.
*/
export async function forEachWithConcurrency<T>(
items: readonly T[],
limit: number,
work: (item: T) => Promise<void>
): Promise<void> {
if (items.length === 0) return;
const width = Math.max(1, Math.min(Math.floor(limit), items.length));
let next = 0;
const workers = Array.from({ length: width }, async () => {
for (let index = next++; index < items.length; index = next++) {
await work(items[index]);
}
});
await Promise.all(workers);
}
+17
View File
@@ -0,0 +1,17 @@
/** Human-readable duration for console logs (e.g. 842ms, 2.1s). */
export function formatElapsedMs(elapsedMs: number): string {
if (elapsedMs < 1000) {
return `${Math.round(elapsedMs)}ms`;
}
return `${(elapsedMs / 1000).toFixed(1)}s`;
}
export function errorMessage(err: unknown): string {
if (err instanceof Error && err.message.length > 0) {
return err.message;
}
if (typeof err === "string" && err.length > 0) {
return err;
}
return "Unknown error";
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Every line this service prints carries the time it was printed. A log line
* without a timestamp cannot answer "when", which is the only question anyone
* asks a log at 3am.
*/
function stamp(): string {
return new Date().toISOString();
}
export const log = {
info(...args: unknown[]): void {
console.log(stamp(), ...args);
},
error(...args: unknown[]): void {
console.error(stamp(), ...args);
},
warn(...args: unknown[]): void {
console.warn(stamp(), ...args);
},
};
+7
View File
@@ -0,0 +1,7 @@
/** Last 6 characters only — safe for logs and debug responses. */
export function maskToken(token: string): string {
if (token.length <= 6) {
return "******";
}
return token.slice(-6);
}
+50
View File
@@ -0,0 +1,50 @@
/** Digits that survive normalization: everything a human might type as separators is dropped. */
const SEPARATORS = /[\s()\-.-― ]/g;
/** E.164 allows 1-15 digits after the "+", and the country code never starts with 0. */
const E164 = /^\+[1-9]\d{7,14}$/;
/**
* Normalize to E.164, assuming US for bare 10-digit input. Returns undefined for
* anything that does not resolve to one unambiguous number.
*/
export function normalizePhoneNumber(input: unknown): string | undefined {
if (typeof input !== "string") return undefined;
let candidate = input.trim().replace(SEPARATORS, "");
if (candidate.length === 0) return undefined;
// "00" is the international access prefix in most of the world.
if (candidate.startsWith("00")) {
candidate = "+" + candidate.slice(2);
}
if (!candidate.startsWith("+")) {
if (!/^\d+$/.test(candidate)) return undefined;
if (candidate.length === 10) {
candidate = "+1" + candidate;
} else if (candidate.length === 11 && candidate.startsWith("1")) {
candidate = "+" + candidate;
} else {
// Any other bare digit string could be several countries. Refuse to guess.
return undefined;
}
}
return E164.test(candidate) ? candidate : undefined;
}
/**
* Keep the country code and the last two digits enough for the caller to
* recognize their own number, not enough to reconstruct someone else's.
*/
export function maskPhoneNumber(phoneE164: string): string {
if (phoneE164.length <= 7) {
return "*".repeat(Math.max(phoneE164.length - 2, 0)) + phoneE164.slice(-2);
}
return (
phoneE164.slice(0, 5) +
"*".repeat(phoneE164.length - 7) +
phoneE164.slice(-2)
);
}
+40
View File
@@ -0,0 +1,40 @@
import { createHmac, randomInt, timingSafeEqual } from "node:crypto";
export const VERIFICATION_CODE_LENGTH = 6;
/**
* Six digits from the CSPRNG. Math.random() is seeded predictably enough that a
* pending code becomes guessable from a couple of observed ones.
*/
export function mintVerificationCode(): string {
return String(randomInt(0, 10 ** VERIFICATION_CODE_LENGTH)).padStart(
VERIFICATION_CODE_LENGTH,
"0"
);
}
function hmacHex(value: string, secret: string): string {
return createHmac("sha256", secret).update(value).digest("hex");
}
/** The plaintext code lives in memory and in the outbound message, nowhere else. */
export function hashVerificationCode(code: string, secret: string): string {
return hmacHex(code, secret);
}
/** Survives a DELETE that nulls the number, so the log still groups by handset. */
export function hashPhoneNumber(phoneE164: string, secret: string): string {
return hmacHex(phoneE164, secret);
}
/** Constant-time over the HMACs, so a wrong code leaks no prefix information. */
export function verificationCodeMatches(
candidate: string,
storedHash: string,
secret: string
): boolean {
const candidateHash = Buffer.from(hashVerificationCode(candidate, secret));
const stored = Buffer.from(storedHash);
if (candidateHash.length !== stored.length) return false;
return timingSafeEqual(candidateHash, stored);
}
+46
View File
@@ -0,0 +1,46 @@
import type { DIDResolutionResult } from "did-resolver";
/**
* This did:ethr resolver instructs the did-jwt machinery to use the
* EcdsaSecp256k1RecoveryMethod2020Uses verification method which adds the recovery bit to the
* signature to recover the DID's public key from a signature.
*
* Similar code resides in image-api, crowd-funder-for-time-pwa, and endorser-ch.
*/
export const didEthLocalResolver = async (
did: string
): Promise<DIDResolutionResult> => {
const didRegex = /^did:ethr:(0x[0-9a-fA-F]{40})$/;
const match = did.match(didRegex);
if (match) {
const address = match[1];
const publicKeyHex = address;
return {
didDocumentMetadata: {},
didResolutionMetadata: {
contentType: "application/did+ld+json",
},
didDocument: {
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/suites/secp256k1recovery-2020/v2",
],
id: did,
verificationMethod: [
{
id: `${did}#controller`,
type: "EcdsaSecp256k1RecoveryMethod2020",
controller: did,
blockchainAccountId: "eip155:1:" + publicKeyHex,
},
],
authentication: [`${did}#controller`],
assertionMethod: [`${did}#controller`],
},
};
}
throw new Error(`Unsupported DID format: ${did}`);
};
+104
View File
@@ -0,0 +1,104 @@
import { AsnParser } from "@peculiar/asn1-schema";
import { ECDSASigValue } from "@peculiar/asn1-ecc";
import crypto from "crypto";
import { decode as cborDecode } from "cbor-x";
/**
* similar code is in image-api and crowd-funder-for-time-pwa
*/
export async function verifyPeerSignature(
payloadBytes: Uint8Array,
publicKeyBytes: Uint8Array,
signatureBytes: Uint8Array
) {
const finalSignatureBuffer = unwrapEC2Signature(signatureBytes);
const verifyAlgorithm = {
name: "ECDSA",
hash: { name: "SHA-256" },
};
const publicKeyJwk = cborToKeys(publicKeyBytes).publicKeyJwk;
const keyAlgorithm = {
name: "ECDSA",
namedCurve: publicKeyJwk.crv,
};
const publicKeyCryptoKey = await crypto.subtle.importKey(
"jwk",
publicKeyJwk,
keyAlgorithm,
false,
["verify"]
);
const verified = await crypto.subtle.verify(
verifyAlgorithm,
publicKeyCryptoKey,
finalSignatureBuffer,
payloadBytes
);
return verified;
}
function cborToKeys(publicKeyBytes: Uint8Array) {
const jwkObj = cborDecode(publicKeyBytes) as Record<number, unknown>;
if (
jwkObj[1] != 2 ||
jwkObj[3] != -7 ||
jwkObj[-1] != 1 ||
!Array.isArray(jwkObj[-2]) ||
(jwkObj[-2] as Uint8Array).length != 32 ||
!Array.isArray(jwkObj[-3]) ||
(jwkObj[-3] as Uint8Array).length != 32
) {
throw new Error("Unable to extract key.");
}
const publicKeyJwk = {
alg: "ES256",
crv: "P-256",
kty: "EC",
x: arrayToBase64Url(jwkObj[-2] as Uint8Array),
y: arrayToBase64Url(jwkObj[-3] as Uint8Array),
};
const publicKeyBuffer = Buffer.concat([
Buffer.from(jwkObj[-2] as Uint8Array),
Buffer.from(jwkObj[-3] as Uint8Array),
]);
return { publicKeyJwk, publicKeyBuffer };
}
function toBase64Url(anythingB64: string) {
return anythingB64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function arrayToBase64Url(anything: Uint8Array) {
return toBase64Url(Buffer.from(anything).toString("base64"));
}
function unwrapEC2Signature(signature: Uint8Array) {
const parsedSignature = AsnParser.parse(signature, ECDSASigValue);
let rBytes = new Uint8Array(parsedSignature.r);
let sBytes = new Uint8Array(parsedSignature.s);
if (shouldRemoveLeadingZero(rBytes)) {
rBytes = rBytes.slice(1);
}
if (shouldRemoveLeadingZero(sBytes)) {
sBytes = sBytes.slice(1);
}
return isoUint8ArrayConcat([rBytes, sBytes]);
}
function shouldRemoveLeadingZero(bytes: Uint8Array) {
return bytes[0] === 0x0 && (bytes[1] & (1 << 7)) !== 0;
}
function isoUint8ArrayConcat(arrays: Uint8Array[]) {
let pointer = 0;
const totalLength = arrays.reduce((prev, curr) => prev + curr.length, 0);
const toReturn = new Uint8Array(totalLength);
arrays.forEach((arr) => {
toReturn.set(arr, pointer);
pointer += arr.length;
});
return toReturn;
}
+189
View File
@@ -0,0 +1,189 @@
/**
* Verifiable Credential & DID functions (shared pattern with image-api, endorser-ch).
*/
import { verifyJWT } from "did-jwt";
import { Resolver } from "did-resolver";
import { didEthLocalResolver } from "./did-eth-local-resolver.js";
import { verifyJwt as peerVerifyJwt } from "./passkeyDidPeer.js";
import { log } from "../util/log.js";
export const TEST_BYPASS_ENV_VALUE = "test-local";
export const ETHR_DID_PREFIX = "did:ethr:";
export const PEER_DID_PREFIX = "did:peer:";
export const JWT_VERIFY_FAILED_CODE = "JWT_VERIFY_FAILED_CODE";
export const UNSUPPORTED_DID_METHOD_CODE = "UNSUPPORTED_DID_METHOD";
export const DELEGATED_JWT_UNSUPPORTED_IDENTITY_CODE =
"DELEGATED_JWT_UNSUPPORTED_IDENTITY";
const resolver = new Resolver({
ethr: didEthLocalResolver,
});
export type VerifiedJwt = {
issuer: string;
payload: Record<string, unknown>;
verified: boolean;
};
function parseJwtHeaderAndPayload(jwt: string): {
header: Record<string, unknown>;
payload: Record<string, unknown>;
pieces: string[];
} {
const pieces = jwt.split(".");
if (pieces.length < 3 || pieces[0].length === 0 || pieces[1].length === 0) {
throw new Error("JWT must have a header, payload, and signature.");
}
const header = JSON.parse(
Buffer.from(pieces[0], "base64url").toString("utf8")
) as Record<string, unknown>;
const payload = JSON.parse(
Buffer.from(pieces[1], "base64url").toString("utf8")
) as Record<string, unknown>;
return { header, payload, pieces };
}
export async function decodeAndVerifyJwt(jwt: string): Promise<VerifiedJwt> {
const { header, payload, pieces } = parseJwtHeaderAndPayload(jwt);
const issuerDid = payload.iss;
if (!issuerDid || typeof issuerDid !== "string") {
return Promise.reject({
clientError: {
message: `Missing "iss" field in JWT.`,
},
});
}
if (
issuerDid.startsWith(ETHR_DID_PREFIX) &&
process.env.NODE_ENV === TEST_BYPASS_ENV_VALUE
) {
const nowEpoch = Math.floor(new Date().getTime() / 1000);
if (typeof payload.exp === "number" && payload.exp < nowEpoch) {
log.info(
"JWT with exp " +
payload.exp +
" has expired but we're in test mode so we'll use a new time."
);
payload.exp = nowEpoch + 100;
}
return { issuer: issuerDid, payload, verified: true };
}
if (issuerDid.startsWith(ETHR_DID_PREFIX)) {
try {
const verified = await verifyJWT(jwt, { resolver });
return verified as VerifiedJwt;
} catch (e) {
return Promise.reject({
clientError: {
message: `JWT failed verification: ` + e,
code: JWT_VERIFY_FAILED_CODE,
},
});
}
}
if (issuerDid.startsWith(PEER_DID_PREFIX) && header.typ === "JWANT") {
const { claimPayload, verified } = await peerVerifyJwt(
payload,
issuerDid,
pieces[2]
);
return {
issuer: issuerDid,
payload: claimPayload as Record<string, unknown>,
verified,
};
}
if (issuerDid.startsWith(PEER_DID_PREFIX)) {
return Promise.reject({
clientError: {
message: `JWT with a PEER DID currently only supported with typ == JWANT. Contact us us for JWT suport since it should be straightforward.`,
},
});
}
return Promise.reject({
clientError: {
message: `Unsupported DID method ${issuerDid}`,
code: UNSUPPORTED_DID_METHOD_CODE,
},
});
}
/**
* Verify a pre-issued delegated notification JWT (future nbf is expected).
* Passkey / did:peer JWANT tokens cannot be stored as standing credentials.
*/
export async function decodeAndVerifyDelegatedJwt(
jwt: string
): Promise<VerifiedJwt> {
let header: Record<string, unknown>;
let payload: Record<string, unknown>;
try {
({ header, payload } = parseJwtHeaderAndPayload(jwt));
} catch {
return Promise.reject({
clientError: {
message: "Delegated credential is not a valid JWT.",
},
});
}
const issuerDid = payload.iss;
if (!issuerDid || typeof issuerDid !== "string") {
return Promise.reject({
clientError: {
message: `Missing "iss" field in JWT.`,
},
});
}
if (issuerDid.startsWith(PEER_DID_PREFIX) || header.typ === "JWANT") {
return Promise.reject({
clientError: {
message:
"This identity type cannot mint a delegated notification-JWT batch. Passkey (did:peer / JWANT) credentials require a live assertion and cannot be pre-issued for future days.",
code: DELEGATED_JWT_UNSUPPORTED_IDENTITY_CODE,
},
});
}
if (
issuerDid.startsWith(ETHR_DID_PREFIX) &&
process.env.NODE_ENV === TEST_BYPASS_ENV_VALUE
) {
return { issuer: issuerDid, payload, verified: true };
}
if (issuerDid.startsWith(ETHR_DID_PREFIX)) {
try {
const verified = await verifyJWT(jwt, {
resolver,
policies: { nbf: false },
});
return verified as VerifiedJwt;
} catch (e) {
return Promise.reject({
clientError: {
message: `JWT failed verification: ` + e,
code: JWT_VERIFY_FAILED_CODE,
},
});
}
}
return Promise.reject({
clientError: {
message: `Unsupported DID method ${issuerDid}`,
code: UNSUPPORTED_DID_METHOD_CODE,
},
});
}
export function identitySupportsDelegatedJwtBatch(did: string): boolean {
return did.startsWith(ETHR_DID_PREFIX);
}
+119
View File
@@ -0,0 +1,119 @@
import crypto from "crypto";
import { multibaseToBytes } from "did-jwt";
import { PEER_DID_PREFIX, TEST_BYPASS_ENV_VALUE } from "./index.js";
import { verifyPeerSignature } from "./didPeer.js";
export async function verifyJwt(
payload: Record<string, unknown>,
issuerDid: string,
signatureString: string
) {
if (!payload.iss) {
return Promise.reject({
clientError: {
message: `JWT is missing an "iss" field.`,
},
});
}
const nowEpoch = Math.floor(new Date().getTime() / 1000);
if (!payload.exp) {
return Promise.reject({
clientError: {
message: `JWT with is missing an "exp" field.`,
},
});
}
if (
typeof payload.exp === "number" &&
payload.exp < nowEpoch &&
process.env.NODE_ENV !== TEST_BYPASS_ENV_VALUE
) {
return Promise.reject({
clientError: {
message: `JWT with exp ${payload.exp} has expired.`,
},
});
}
const authData = payload.AuthenticationDataB64URL;
const clientData = payload.ClientDataJSONB64URL;
if (typeof authData !== "string" || typeof clientData !== "string") {
return Promise.reject({
clientError: {
message: `JWT with typ == JWANT requires AuthenticationData and ClientDataJSON.`,
},
});
}
const decodedAuthDataBuff = Buffer.from(authData, "base64url");
const decodedClientData = Buffer.from(clientData, "base64url");
let claimPayload = JSON.parse(decodedClientData.toString()) as Record<
string,
unknown
>;
if (claimPayload.challenge) {
claimPayload = JSON.parse(
Buffer.from(claimPayload.challenge as string, "base64url").toString()
) as Record<string, unknown>;
if (!claimPayload.exp) {
claimPayload.exp = payload.exp;
}
if (!claimPayload.iat) {
claimPayload.iat = payload.iat;
}
if (!claimPayload.iss) {
claimPayload.iss = payload.iss;
}
}
if (!claimPayload.exp) {
return Promise.reject({
clientError: {
message: `JWT client data challenge is missing an "exp" field.`,
},
});
}
if (
typeof claimPayload.exp === "number" &&
claimPayload.exp < nowEpoch &&
process.env.NODE_ENV !== TEST_BYPASS_ENV_VALUE
) {
return Promise.reject({
clientError: {
message: `JWT client data challenge exp time is past.`,
},
});
}
if (claimPayload.exp !== payload.exp) {
return Promise.reject({
clientError: {
message: `JWT client data challenge "exp" field doesn't match the outside payload "exp".`,
},
});
}
if (claimPayload.iss !== payload.iss) {
return Promise.reject({
clientError: {
message: `JWT client data challenge "iss" field doesn't match the outside payload "iss".`,
},
});
}
const hashedClientDataBuff = crypto
.createHash("sha256")
.update(decodedClientData)
.digest();
const preimage = new Uint8Array(
Buffer.concat([decodedAuthDataBuff, hashedClientDataBuff])
);
const PEER_DID_MULTIBASE_PREFIX = PEER_DID_PREFIX + "0";
const publicKey = multibaseToBytes(
issuerDid.substring(PEER_DID_MULTIBASE_PREFIX.length)
);
const signature = new Uint8Array(
Buffer.from(signatureString, "base64url")
);
const verified = await verifyPeerSignature(preimage, publicKey, signature);
return { claimPayload, verified };
}
+385
View File
@@ -0,0 +1,385 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, it } from "node:test";
import { alertSearchCursorsDb } from "../../src/db/alertSearchCursorsSqlite.js";
import { closeDatabase } from "../../src/db/sqlite.js";
import {
advanceAlertSearchCursors,
loadAlertSearchCursors,
maxEndorserAlertSearchUlid,
} from "../../src/alertSearch/cursors.js";
import { retrieveAlertSearch } from "../../src/alertSearch/retrieve.js";
import type { AlertSearchSourceResult, RetrieveAlertSearchResult } from "../../src/alertSearch/retrieve.js";
import { ALERT_SEARCH_PAGE_SIZE } from "../../src/alertSearch/types.js";
import type { EndorserAlertSearchData, PartnerAlertSearchData } from "../../src/alertSearch/types.js";
const USER = "did:ethr:0xcursoruser";
const OTHER_USER = "did:ethr:0xother";
const JWT = "delegated.jwt.token";
function ulid(n: number): string {
return `01H${String(n).padStart(23, "0")}`;
}
function emptyEndorser(): EndorserAlertSearchData {
return {
claims: [],
personalPlanContributions: [],
trackedPlanUpdates: [],
trackedPlanClaims: [],
plansNearby: [],
};
}
function emptyPartner(): PartnerAlertSearchData {
return { profilesNearby: [] };
}
function endorserResult(
outcome: AlertSearchSourceResult<EndorserAlertSearchData>["outcome"],
data: EndorserAlertSearchData = emptyEndorser()
): AlertSearchSourceResult<EndorserAlertSearchData> {
return { outcome, pageCount: 1, data };
}
function partnerResult(
outcome: AlertSearchSourceResult<PartnerAlertSearchData>["outcome"],
data: PartnerAlertSearchData = emptyPartner()
): AlertSearchSourceResult<PartnerAlertSearchData> {
return { outcome, pageCount: 1, data };
}
function combined(
endorser: AlertSearchSourceResult<EndorserAlertSearchData>,
partner: AlertSearchSourceResult<PartnerAlertSearchData>
): RetrieveAlertSearchResult {
return {
data: { ...endorser.data, ...partner.data },
empty: false,
endorser,
partner,
};
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
function emptyEndorserBody() {
return {
data: {
claims: [],
personalPlanContributions: [],
trackedPlanUpdates: [],
trackedPlanClaims: [],
plansNearby: [],
},
};
}
function emptyPartnerBody(overrides?: Record<string, unknown>) {
return { data: { profilesNearby: [], ...overrides } };
}
describe("alertSearch cursor persistence", () => {
let dir: string;
let previousDataDir: string | undefined;
beforeEach(async () => {
previousDataDir = process.env.NOTIFY_DATA_DIR;
dir = await mkdtemp(path.join(tmpdir(), "alert-search-cursors-"));
process.env.NOTIFY_DATA_DIR = dir;
closeDatabase();
});
afterEach(async () => {
closeDatabase();
if (previousDataDir === undefined) {
delete process.env.NOTIFY_DATA_DIR;
} else {
process.env.NOTIFY_DATA_DIR = previousDataDir;
}
await rm(dir, { recursive: true, force: true });
});
it("no existing cursor → retrieval receives no afterId or afterDate", async () => {
const loaded = await loadAlertSearchCursors(USER);
assert.equal(loaded.endorserAfterId, undefined);
assert.equal(loaded.partnerAfterDate, undefined);
const urls: string[] = [];
const result = await retrieveAlertSearch({
jwt: JWT,
...loaded,
endorserBaseUrl: "https://api.endorser.ch",
partnerBaseUrl: "https://partner-api.endorser.ch",
config: {
fetch: async (url) => {
urls.push(url);
if (url.includes("/api/partner/")) {
return jsonResponse(emptyPartnerBody());
}
return jsonResponse(emptyEndorserBody());
},
},
});
assert.equal(result.endorser.outcome, "empty");
for (const url of urls) {
assert.equal(url.includes("afterId="), false);
assert.equal(url.includes("afterDate="), false);
}
});
it("complete Endorser retrieval → cursor becomes maximum returned ULID", async () => {
const data = emptyEndorser();
data.claims = [
{ id: ulid(10), issuedAt: "t", issuer: "did:ethr:0x1" },
{ id: ulid(30), issuedAt: "t", issuer: "did:ethr:0x1" },
];
data.plansNearby = [{ handleId: "p", jwtId: ulid(20) }];
assert.equal(maxEndorserAlertSearchUlid(data), ulid(30));
const advanced = await advanceAlertSearchCursors(
USER,
combined(endorserResult("success", data), partnerResult("empty"))
);
assert.equal(advanced.endorserAdvanced, true);
assert.equal(advanced.endorserAfterId, ulid(30));
assert.equal(advanced.partnerAdvanced, false);
});
it("Endorser empty retrieval → existing cursor unchanged", async () => {
await alertSearchCursorsDb.setEndorserAfterId(USER, ulid(99));
const advanced = await advanceAlertSearchCursors(
USER,
combined(endorserResult("empty"), partnerResult("empty"))
);
assert.equal(advanced.endorserAdvanced, false);
assert.equal(advanced.endorserAfterId, ulid(99));
});
it("Endorser pagination → cursor unchanged", async () => {
await alertSearchCursorsDb.setEndorserAfterId(USER, ulid(5));
const data = emptyEndorser();
data.claims = Array.from({ length: ALERT_SEARCH_PAGE_SIZE }, (_, i) => ({
id: ulid(100 + i),
issuedAt: "t",
issuer: "did:ethr:0x1",
}));
const advanced = await advanceAlertSearchCursors(
USER,
combined(endorserResult("pagination", data), partnerResult("empty"))
);
assert.equal(advanced.endorserAdvanced, false);
assert.equal(advanced.endorserAfterId, ulid(5));
});
it("Endorser error → cursor unchanged", async () => {
await alertSearchCursorsDb.setEndorserAfterId(USER, ulid(5));
for (const outcome of ["auth", "timeout", "network", "malformed", "http"] as const) {
const advanced = await advanceAlertSearchCursors(
USER,
combined(endorserResult(outcome), partnerResult("empty"))
);
assert.equal(advanced.endorserAdvanced, false, outcome);
assert.equal(advanced.endorserAfterId, ulid(5), outcome);
}
});
it("complete Partner retrieval → cursor becomes maximum returned updatedAt", async () => {
const data = emptyPartner();
data.profilesNearby = [
{
issuerDid: "did:ethr:0x1",
description: "a",
updatedAt: "2026-01-01T00:00:00.000Z",
},
{
issuerDid: "did:ethr:0x2",
description: "b",
updatedAt: "2026-03-01T12:00:00.000Z",
},
];
const advanced = await advanceAlertSearchCursors(
USER,
combined(endorserResult("empty"), partnerResult("success", data))
);
assert.equal(advanced.partnerAdvanced, true);
assert.equal(advanced.partnerAfterAt, "2026-03-01T12:00:00.000Z");
});
it("Partner empty retrieval → existing cursor unchanged", async () => {
await alertSearchCursorsDb.setPartnerAfterAt(USER, "2026-02-01T00:00:00.000Z");
const advanced = await advanceAlertSearchCursors(
USER,
combined(endorserResult("empty"), partnerResult("empty"))
);
assert.equal(advanced.partnerAdvanced, false);
assert.equal(advanced.partnerAfterAt, "2026-02-01T00:00:00.000Z");
});
it("Partner pagination → cursor unchanged", async () => {
await alertSearchCursorsDb.setPartnerAfterAt(USER, "2026-02-01T00:00:00.000Z");
const data = emptyPartner();
data.profilesNearby = Array.from({ length: ALERT_SEARCH_PAGE_SIZE }, (_, i) => ({
issuerDid: `did:ethr:0x${i}`,
description: "p",
updatedAt: "2026-04-01T00:00:00.000Z",
}));
const advanced = await advanceAlertSearchCursors(
USER,
combined(endorserResult("empty"), partnerResult("pagination", data))
);
assert.equal(advanced.partnerAdvanced, false);
assert.equal(advanced.partnerAfterAt, "2026-02-01T00:00:00.000Z");
});
it("Partner error → cursor unchanged", async () => {
await alertSearchCursorsDb.setPartnerAfterAt(USER, "2026-02-01T00:00:00.000Z");
const advanced = await advanceAlertSearchCursors(
USER,
combined(endorserResult("empty"), partnerResult("network"))
);
assert.equal(advanced.partnerAdvanced, false);
assert.equal(advanced.partnerAfterAt, "2026-02-01T00:00:00.000Z");
});
it("Endorser success + Partner failure → only Endorser advances", async () => {
const endorserData = emptyEndorser();
endorserData.claims = [
{ id: ulid(40), issuedAt: "t", issuer: "did:ethr:0x1" },
];
const advanced = await advanceAlertSearchCursors(
USER,
combined(endorserResult("success", endorserData), partnerResult("auth"))
);
assert.equal(advanced.endorserAdvanced, true);
assert.equal(advanced.endorserAfterId, ulid(40));
assert.equal(advanced.partnerAdvanced, false);
assert.equal(advanced.partnerAfterAt, null);
});
it("Endorser failure + Partner success → only Partner advances", async () => {
const partnerData = emptyPartner();
partnerData.profilesNearby = [
{
issuerDid: "did:ethr:0x1",
description: "p",
updatedAt: "2026-05-01T00:00:00.000Z",
},
];
const advanced = await advanceAlertSearchCursors(
USER,
combined(endorserResult("timeout"), partnerResult("success", partnerData))
);
assert.equal(advanced.endorserAdvanced, false);
assert.equal(advanced.endorserAfterId, null);
assert.equal(advanced.partnerAdvanced, true);
assert.equal(advanced.partnerAfterAt, "2026-05-01T00:00:00.000Z");
});
it("both successful → both advance", async () => {
const endorserData = emptyEndorser();
endorserData.trackedPlanClaims = [
{ id: ulid(7), issuedAt: "t", issuer: "did:ethr:0x1" },
];
const partnerData = emptyPartner();
partnerData.profilesNearby = [
{
issuerDid: "did:ethr:0x1",
description: "p",
updatedAt: "2026-06-01T00:00:00.000Z",
},
];
const advanced = await advanceAlertSearchCursors(
USER,
combined(
endorserResult("success", endorserData),
partnerResult("success", partnerData)
)
);
assert.equal(advanced.endorserAdvanced, true);
assert.equal(advanced.partnerAdvanced, true);
assert.equal(advanced.endorserAfterId, ulid(7));
assert.equal(advanced.partnerAfterAt, "2026-06-01T00:00:00.000Z");
});
it("no usable returned cursor → existing cursor unchanged", async () => {
await alertSearchCursorsDb.setEndorserAfterId(USER, ulid(8));
await alertSearchCursorsDb.setPartnerAfterAt(USER, "2026-01-01T00:00:00.000Z");
const endorserData = emptyEndorser();
endorserData.claims = [
{ id: "not-a-ulid", issuedAt: "t", issuer: "did:ethr:0x1" },
];
const partnerData = emptyPartner();
partnerData.profilesNearby = [
{ issuerDid: "did:ethr:0x1", description: "p" },
];
const advanced = await advanceAlertSearchCursors(
USER,
combined(
endorserResult("success", endorserData),
partnerResult("success", partnerData)
)
);
assert.equal(advanced.endorserAdvanced, false);
assert.equal(advanced.partnerAdvanced, false);
assert.equal(advanced.endorserAfterId, ulid(8));
assert.equal(advanced.partnerAfterAt, "2026-01-01T00:00:00.000Z");
});
it("survives close and reopen of the SQLite connection", async () => {
await alertSearchCursorsDb.setEndorserAfterId(USER, ulid(11));
await alertSearchCursorsDb.setPartnerAfterAt(
USER,
"2026-07-01T00:00:00.000Z"
);
closeDatabase();
const loaded = await loadAlertSearchCursors(USER);
assert.equal(loaded.endorserAfterId, ulid(11));
assert.equal(loaded.partnerAfterDate, "2026-07-01T00:00:00.000Z");
const other = await loadAlertSearchCursors(OTHER_USER);
assert.equal(other.endorserAfterId, undefined);
assert.equal(other.partnerAfterDate, undefined);
});
it("loaded Partner cursor is sent as afterDate, not afterId 0", async () => {
await alertSearchCursorsDb.setPartnerAfterAt(
USER,
"2026-08-01T00:00:00.000Z"
);
await alertSearchCursorsDb.setEndorserAfterId(USER, ulid(12));
const loaded = await loadAlertSearchCursors(USER);
const urls: string[] = [];
await retrieveAlertSearch({
jwt: JWT,
...loaded,
endorserBaseUrl: "https://api.endorser.ch",
partnerBaseUrl: "https://partner-api.endorser.ch",
config: {
fetch: async (url) => {
urls.push(url);
if (url.includes("/api/partner/")) {
return jsonResponse(emptyPartnerBody());
}
return jsonResponse(emptyEndorserBody());
},
},
});
const endorserUrl = urls.find((u) => u.includes("/api/v2/report/")) ?? "";
const partnerUrl = urls.find((u) => u.includes("/api/partner/")) ?? "";
assert.equal(endorserUrl.includes(ulid(12)), true);
assert.equal(partnerUrl.includes("afterId=0"), false);
assert.equal(
decodeURIComponent(partnerUrl).includes("afterDate=2026-08-01T00:00:00.000Z"),
true
);
});
});
+530
View File
@@ -0,0 +1,530 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, it } from "node:test";
import { alertSearchCursorsDb } from "../../src/db/alertSearchCursorsSqlite.js";
import { closeDatabase } from "../../src/db/sqlite.js";
import {
partnerPageHasTiedBeforeDate,
type FetchLike,
} from "../../src/alertSearch/client.js";
import { runAlertSearchCycle } from "../../src/alertSearch/cycle.js";
import { ALERT_SEARCH_PAGE_SIZE } from "../../src/alertSearch/types.js";
const USER = "did:ethr:0xcycleuser";
const JWT = "delegated.jwt.token";
const ENDORSER_BASE = "https://api.endorser.ch";
const PARTNER_BASE = "https://partner-api.endorser.ch";
function ulid(n: number): string {
return `01H${String(n).padStart(23, "0")}`;
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
function emptyEndorserBody(overrides?: Record<string, unknown>) {
return {
data: {
claims: [],
personalPlanContributions: [],
trackedPlanUpdates: [],
trackedPlanClaims: [],
plansNearby: [],
...overrides,
},
};
}
function emptyPartnerBody(overrides?: Record<string, unknown>) {
return { data: { profilesNearby: [], ...overrides } };
}
function claimRows(from: number, count: number) {
return Array.from({ length: count }, (_, i) => ({
id: ulid(from + i),
issuedAt: "2026-01-01T00:00:00Z",
issuer: "did:ethr:0x1",
}));
}
function planRows(from: number, count: number) {
return Array.from({ length: count }, (_, i) => ({
handleId: `plan-${from + i}`,
jwtId: ulid(from + i),
}));
}
function profileRows(
count: number,
updatedAt: (index: number) => string
) {
return Array.from({ length: count }, (_, i) => ({
issuerDid: `did:ethr:0x${i}`,
description: "p",
updatedAt: updatedAt(i),
rowId: i + 1,
}));
}
function captureFetch(
handler: (url: string) => Response | Promise<Response>
): { fetch: FetchLike; urls: string[] } {
const urls: string[] = [];
return {
urls,
fetch: async (url) => {
urls.push(url);
return handler(url);
},
};
}
const cycleOpts = {
jwt: JWT,
endorserBaseUrl: ENDORSER_BASE,
partnerBaseUrl: PARTNER_BASE,
};
describe("partnerPageHasTiedBeforeDate", () => {
it("is false when the page is under the server limit", () => {
assert.equal(
partnerPageHasTiedBeforeDate({
profilesNearby: profileRows(10, () => "2026-01-01T00:00:00.000Z"),
}),
false
);
});
it("is true when a full page shares the oldest updatedAt", () => {
const tied = "2026-01-01T12:00:00.000Z";
assert.equal(
partnerPageHasTiedBeforeDate({
profilesNearby: profileRows(ALERT_SEARCH_PAGE_SIZE, () => tied),
}),
true
);
});
it("is true when two or more rows share the page minimum", () => {
const rows = profileRows(ALERT_SEARCH_PAGE_SIZE, (i) =>
i < 2 ? "2026-01-01T00:00:00.000Z" : `2026-02-01T00:${String(i).padStart(2, "0")}:00.000Z`
);
assert.equal(partnerPageHasTiedBeforeDate({ profilesNearby: rows }), true);
});
it("is false when the full page has a unique oldest timestamp", () => {
const rows = profileRows(
ALERT_SEARCH_PAGE_SIZE,
(i) => `2026-01-01T00:${String(i).padStart(2, "0")}:00.000Z`
);
assert.equal(partnerPageHasTiedBeforeDate({ profilesNearby: rows }), false);
});
});
describe("alertSearch cycle integration", () => {
let dir: string;
let previousDataDir: string | undefined;
beforeEach(async () => {
previousDataDir = process.env.NOTIFY_DATA_DIR;
dir = await mkdtemp(path.join(tmpdir(), "alert-search-cycle-"));
process.env.NOTIFY_DATA_DIR = dir;
closeDatabase();
});
afterEach(async () => {
closeDatabase();
if (previousDataDir === undefined) {
delete process.env.NOTIFY_DATA_DIR;
} else {
process.env.NOTIFY_DATA_DIR = previousDataDir;
}
await rm(dir, { recursive: true, force: true });
});
it("first run omits cursors and persists after a complete non-empty retrieve", async () => {
const cap = captureFetch((url) => {
if (url.includes("/api/partner/")) {
return jsonResponse(
emptyPartnerBody({
profilesNearby: [
{
issuerDid: "did:ethr:0x1",
description: "p",
updatedAt: "2026-03-01T00:00:00.000Z",
rowId: 1,
},
],
})
);
}
return jsonResponse(
emptyEndorserBody({
claims: claimRows(10, 1),
trackedPlanUpdates: planRows(15, 1),
})
);
});
const cycle = await runAlertSearchCycle(USER, {
...cycleOpts,
config: { fetch: cap.fetch },
});
assert.equal(cycle.loaded.endorserAfterId, undefined);
assert.equal(cycle.loaded.partnerAfterDate, undefined);
for (const url of cap.urls) {
assert.equal(url.includes("afterId="), false);
assert.equal(url.includes("afterDate="), false);
}
assert.equal(cycle.retrieved.endorser.outcome, "success");
assert.equal(cycle.retrieved.partner.outcome, "success");
assert.equal(cycle.advanced.endorserAdvanced, true);
assert.equal(cycle.advanced.partnerAdvanced, true);
assert.equal(cycle.advanced.endorserAfterId, ulid(15));
assert.equal(cycle.advanced.partnerAfterAt, "2026-03-01T00:00:00.000Z");
});
it("loads Endorser afterId, paginates with MAX per-bucket minima, then advances to max ULID", async () => {
await alertSearchCursorsDb.setEndorserAfterId(USER, ulid(1));
let endorserPages = 0;
const cap = captureFetch((url) => {
if (url.includes("/api/partner/")) {
return jsonResponse(emptyPartnerBody());
}
endorserPages += 1;
if (endorserPages === 1) {
assert.equal(url.includes(ulid(1)), true);
assert.equal(url.includes("beforeId="), false);
return jsonResponse(
emptyEndorserBody({
claims: claimRows(200, ALERT_SEARCH_PAGE_SIZE),
personalPlanContributions: claimRows(50, ALERT_SEARCH_PAGE_SIZE),
})
);
}
assert.equal(url.includes(`beforeId=${ulid(200)}`), true);
assert.equal(url.includes(`beforeId=${ulid(50)}`), false);
return jsonResponse(
emptyEndorserBody({
claims: claimRows(180, 3),
})
);
});
const cycle = await runAlertSearchCycle(USER, {
...cycleOpts,
config: { fetch: cap.fetch },
});
assert.equal(cycle.retrieved.endorser.outcome, "success");
assert.equal(cycle.retrieved.endorser.pageCount, 2);
assert.equal(cycle.advanced.endorserAdvanced, true);
assert.equal(cycle.advanced.endorserAfterId, ulid(249));
assert.equal(cycle.advanced.partnerAdvanced, false);
});
it("does not advance Endorser on empty retrieve", async () => {
await alertSearchCursorsDb.setEndorserAfterId(USER, ulid(9));
const cap = captureFetch((url) => {
if (url.includes("/api/partner/")) return jsonResponse(emptyPartnerBody());
return jsonResponse(emptyEndorserBody());
});
const cycle = await runAlertSearchCycle(USER, {
...cycleOpts,
config: { fetch: cap.fetch },
});
assert.equal(cycle.retrieved.endorser.outcome, "empty");
assert.equal(cycle.advanced.endorserAdvanced, false);
assert.equal(cycle.advanced.endorserAfterId, ulid(9));
});
it("does not advance Endorser when plansNearby hits 50", async () => {
await alertSearchCursorsDb.setEndorserAfterId(USER, ulid(4));
let endorserPages = 0;
const cap = captureFetch((url) => {
if (url.includes("/api/partner/")) return jsonResponse(emptyPartnerBody());
endorserPages += 1;
if (endorserPages === 1) {
return jsonResponse(
emptyEndorserBody({
plansNearby: planRows(300, ALERT_SEARCH_PAGE_SIZE),
})
);
}
return jsonResponse(
emptyEndorserBody({ plansNearby: planRows(250, 2) })
);
});
const cycle = await runAlertSearchCycle(USER, {
...cycleOpts,
config: { fetch: cap.fetch },
});
assert.equal(cycle.retrieved.endorser.outcome, "pagination");
assert.equal(cycle.retrieved.data.plansNearby.length, ALERT_SEARCH_PAGE_SIZE + 2);
assert.equal(cycle.advanced.endorserAdvanced, false);
assert.equal(cycle.advanced.endorserAfterId, ulid(4));
});
it("does not advance Endorser on HTTP auth failure", async () => {
await alertSearchCursorsDb.setEndorserAfterId(USER, ulid(4));
const cap = captureFetch((url) => {
if (url.includes("/api/partner/")) return jsonResponse(emptyPartnerBody());
return jsonResponse({ error: "Unauthorized" }, 401);
});
const cycle = await runAlertSearchCycle(USER, {
...cycleOpts,
config: { fetch: cap.fetch },
});
assert.equal(cycle.retrieved.endorser.outcome, "auth");
assert.equal(cycle.advanced.endorserAdvanced, false);
assert.equal(cycle.advanced.endorserAfterId, ulid(4));
});
it("loads Partner afterDate, paginates with beforeDate, advances max updatedAt", async () => {
await alertSearchCursorsDb.setPartnerAfterAt(
USER,
"2026-01-01T00:00:00.000Z"
);
const page1 = profileRows(
ALERT_SEARCH_PAGE_SIZE,
(i) => `2026-02-01T00:${String(i).padStart(2, "0")}:00.000Z`
);
const minTime = [...page1.map((p) => p.updatedAt)].sort()[0];
let partnerPages = 0;
const cap = captureFetch((url) => {
if (url.includes("/api/v2/report/")) {
return jsonResponse(emptyEndorserBody());
}
partnerPages += 1;
if (partnerPages === 1) {
assert.equal(
decodeURIComponent(url).includes("afterDate=2026-01-01T00:00:00.000Z"),
true
);
return jsonResponse(emptyPartnerBody({ profilesNearby: page1 }));
}
assert.equal(decodeURIComponent(url).includes(`beforeDate=${minTime}`), true);
return jsonResponse(
emptyPartnerBody({
profilesNearby: [
{
issuerDid: "did:ethr:0x99",
description: "p",
updatedAt: "2026-01-15T00:00:00.000Z",
rowId: 99,
},
],
})
);
});
const cycle = await runAlertSearchCycle(USER, {
...cycleOpts,
config: { fetch: cap.fetch },
});
assert.equal(cycle.retrieved.partner.outcome, "success");
assert.equal(cycle.retrieved.partner.pageCount, 2);
assert.equal(cycle.advanced.partnerAdvanced, true);
assert.equal(cycle.advanced.partnerAfterAt, page1.map((p) => p.updatedAt).sort()[page1.length - 1]);
});
it("does not advance Partner on empty retrieve", async () => {
await alertSearchCursorsDb.setPartnerAfterAt(USER, "2026-01-01T00:00:00.000Z");
const cap = captureFetch((url) => {
if (url.includes("/api/partner/")) return jsonResponse(emptyPartnerBody());
return jsonResponse(emptyEndorserBody());
});
const cycle = await runAlertSearchCycle(USER, {
...cycleOpts,
config: { fetch: cap.fetch },
});
assert.equal(cycle.retrieved.partner.outcome, "empty");
assert.equal(cycle.advanced.partnerAdvanced, false);
assert.equal(cycle.advanced.partnerAfterAt, "2026-01-01T00:00:00.000Z");
});
it("does not advance Partner when a full page has tied oldest updatedAt", async () => {
await alertSearchCursorsDb.setPartnerAfterAt(USER, "2026-01-01T00:00:00.000Z");
const tied = "2026-04-01T00:00:00.000Z";
const cap = captureFetch((url) => {
if (url.includes("/api/v2/report/")) {
return jsonResponse(emptyEndorserBody());
}
return jsonResponse(
emptyPartnerBody({
profilesNearby: profileRows(ALERT_SEARCH_PAGE_SIZE, () => tied),
})
);
});
const cycle = await runAlertSearchCycle(USER, {
...cycleOpts,
config: { fetch: cap.fetch },
});
assert.equal(cycle.retrieved.partner.outcome, "pagination");
assert.match(cycle.retrieved.partner.message ?? "", /updatedAt/);
assert.equal(
cycle.retrieved.data.profilesNearby.length,
ALERT_SEARCH_PAGE_SIZE
);
assert.equal(cycle.advanced.partnerAdvanced, false);
assert.equal(cycle.advanced.partnerAfterAt, "2026-01-01T00:00:00.000Z");
});
it("A: both success → both advance", async () => {
const cap = captureFetch((url) => {
if (url.includes("/api/partner/")) {
return jsonResponse(
emptyPartnerBody({
profilesNearby: [
{
issuerDid: "did:ethr:0x1",
description: "p",
updatedAt: "2026-05-01T00:00:00.000Z",
},
],
})
);
}
return jsonResponse(emptyEndorserBody({ claims: claimRows(20, 1) }));
});
const cycle = await runAlertSearchCycle(USER, {
...cycleOpts,
config: { fetch: cap.fetch },
});
assert.equal(cycle.advanced.endorserAdvanced, true);
assert.equal(cycle.advanced.partnerAdvanced, true);
assert.equal(cycle.advanced.endorserAfterId, ulid(20));
assert.equal(cycle.advanced.partnerAfterAt, "2026-05-01T00:00:00.000Z");
});
it("B: Endorser success + Partner failure → Endorser only", async () => {
const cap = captureFetch((url) => {
if (url.includes("/api/partner/")) {
return jsonResponse({ error: "Unauthorized" }, 401);
}
return jsonResponse(emptyEndorserBody({ claims: claimRows(21, 1) }));
});
const cycle = await runAlertSearchCycle(USER, {
...cycleOpts,
config: { fetch: cap.fetch },
});
assert.equal(cycle.retrieved.endorser.outcome, "success");
assert.equal(cycle.retrieved.partner.outcome, "auth");
assert.equal(cycle.advanced.endorserAdvanced, true);
assert.equal(cycle.advanced.partnerAdvanced, false);
assert.equal(cycle.advanced.endorserAfterId, ulid(21));
assert.equal(cycle.advanced.partnerAfterAt, null);
});
it("C: Endorser failure + Partner success → Partner only", async () => {
const cap = captureFetch((url) => {
if (url.includes("/api/partner/")) {
return jsonResponse(
emptyPartnerBody({
profilesNearby: [
{
issuerDid: "did:ethr:0x1",
description: "p",
updatedAt: "2026-06-01T00:00:00.000Z",
},
],
})
);
}
return jsonResponse({ error: "fail" }, 500);
});
const cycle = await runAlertSearchCycle(USER, {
...cycleOpts,
config: { fetch: cap.fetch },
});
assert.equal(cycle.retrieved.endorser.outcome, "http");
assert.equal(cycle.retrieved.partner.outcome, "success");
assert.equal(cycle.advanced.endorserAdvanced, false);
assert.equal(cycle.advanced.partnerAdvanced, true);
assert.equal(cycle.advanced.endorserAfterId, null);
assert.equal(cycle.advanced.partnerAfterAt, "2026-06-01T00:00:00.000Z");
});
it("D: both fail → neither advances", async () => {
await alertSearchCursorsDb.setEndorserAfterId(USER, ulid(3));
await alertSearchCursorsDb.setPartnerAfterAt(USER, "2026-01-01T00:00:00.000Z");
const cap = captureFetch(() => jsonResponse({ error: "down" }, 503));
const cycle = await runAlertSearchCycle(USER, {
...cycleOpts,
config: { fetch: cap.fetch },
});
assert.equal(cycle.advanced.endorserAdvanced, false);
assert.equal(cycle.advanced.partnerAdvanced, false);
assert.equal(cycle.advanced.endorserAfterId, ulid(3));
assert.equal(cycle.advanced.partnerAfterAt, "2026-01-01T00:00:00.000Z");
});
it("E: Partner incomplete + Endorser success → Endorser only", async () => {
const tied = "2026-07-01T00:00:00.000Z";
const cap = captureFetch((url) => {
if (url.includes("/api/partner/")) {
return jsonResponse(
emptyPartnerBody({
profilesNearby: profileRows(ALERT_SEARCH_PAGE_SIZE, () => tied),
})
);
}
return jsonResponse(emptyEndorserBody({ claims: claimRows(22, 1) }));
});
const cycle = await runAlertSearchCycle(USER, {
...cycleOpts,
config: { fetch: cap.fetch },
});
assert.equal(cycle.retrieved.endorser.outcome, "success");
assert.equal(cycle.retrieved.partner.outcome, "pagination");
assert.equal(cycle.advanced.endorserAdvanced, true);
assert.equal(cycle.advanced.partnerAdvanced, false);
assert.equal(cycle.advanced.endorserAfterId, ulid(22));
});
it("persists cursors across close/reopen and sends them on the next retrieve", async () => {
const first = captureFetch((url) => {
if (url.includes("/api/partner/")) {
return jsonResponse(
emptyPartnerBody({
profilesNearby: [
{
issuerDid: "did:ethr:0x1",
description: "p",
updatedAt: "2026-08-01T00:00:00.000Z",
},
],
})
);
}
return jsonResponse(emptyEndorserBody({ claims: claimRows(30, 1) }));
});
await runAlertSearchCycle(USER, {
...cycleOpts,
config: { fetch: first.fetch },
});
closeDatabase();
const second = captureFetch((url) => {
if (url.includes("/api/partner/")) return jsonResponse(emptyPartnerBody());
return jsonResponse(emptyEndorserBody());
});
const cycle = await runAlertSearchCycle(USER, {
...cycleOpts,
config: { fetch: second.fetch },
});
assert.equal(cycle.loaded.endorserAfterId, ulid(30));
assert.equal(cycle.loaded.partnerAfterDate, "2026-08-01T00:00:00.000Z");
const endorserUrl =
second.urls.find((u) => u.includes("/api/v2/report/")) ?? "";
const partnerUrl = second.urls.find((u) => u.includes("/api/partner/")) ?? "";
assert.equal(endorserUrl.includes(ulid(30)), true);
assert.equal(
decodeURIComponent(partnerUrl).includes("afterDate=2026-08-01T00:00:00.000Z"),
true
);
assert.equal(partnerUrl.includes("afterId=0"), false);
});
});
+531
View File
@@ -0,0 +1,531 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, it } from "node:test";
import {
ALERT_JWT_STATUS_CONSUMED,
ALERT_JWT_STATUS_UNUSED,
alertAuthorizationDb,
type AlertAuthorizationJwtInput,
} from "../../src/db/alertAuthorizationSqlite.js";
import { alertSearchCursorsDb } from "../../src/db/alertSearchCursorsSqlite.js";
import { closeDatabase } from "../../src/db/sqlite.js";
import type { FetchLike } from "../../src/alertSearch/client.js";
import { runDailyAlertSearch } from "../../src/alertSearch/daily.js";
import { ALERT_SEARCH_PAGE_SIZE } from "../../src/alertSearch/types.js";
const USER = "did:ethr:0xdailyuser";
const ENDORSER_BASE = "https://api.endorser.ch";
const PARTNER_BASE = "https://partner-api.endorser.ch";
/** 06:00Z: past midnight UTC, but still the previous day in the Americas. */
const NOW_SPLIT = new Date("2026-08-15T06:00:00.000Z");
const DAY_BEFORE = "2026-08-14";
const DAY_UTC = "2026-08-15";
const JWT_DAY_BEFORE = "delegated.jwt.day-before";
const JWT_DAY_UTC = "delegated.jwt.utc-day";
function ulid(n: number): string {
return `01H${String(n).padStart(23, "0")}`;
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
function emptyEndorserBody(overrides?: Record<string, unknown>) {
return {
data: {
claims: [],
personalPlanContributions: [],
trackedPlanUpdates: [],
trackedPlanClaims: [],
plansNearby: [],
...overrides,
},
};
}
function emptyPartnerBody(overrides?: Record<string, unknown>) {
return { data: { profilesNearby: [], ...overrides } };
}
function claimRows(from: number, count: number) {
return Array.from({ length: count }, (_, i) => ({
id: ulid(from + i),
issuedAt: "2026-01-01T00:00:00Z",
issuer: "did:ethr:0x1",
}));
}
function profileRows(
count: number,
updatedAt: (index: number) => string
) {
return Array.from({ length: count }, (_, i) => ({
issuerDid: `did:ethr:0x${i}`,
description: "p",
updatedAt: updatedAt(i),
rowId: i + 1,
}));
}
function captureFetch(
handler: (url: string, init?: RequestInit) => Response | Promise<Response>
): { fetch: FetchLike; urls: string[]; auths: string[] } {
const urls: string[] = [];
const auths: string[] = [];
const fetchImpl: FetchLike = async (url, init) => {
urls.push(url);
auths.push(new Headers(init?.headers).get("Authorization") ?? "");
return handler(url, init);
};
return { fetch: fetchImpl, urls, auths };
}
function jwtInput(
sequence: number,
day: string,
jwt: string
): AlertAuthorizationJwtInput {
return { sequence, day, jwt, nbf: 1, exp: 2 };
}
async function seedBatch(
jwts: AlertAuthorizationJwtInput[],
userId = USER,
batchId = "batch-1"
) {
await alertAuthorizationDb.replaceUnusedBatch({ userId, batchId, jwts });
}
function cycleOpts(fetch: FetchLike) {
return {
endorserBaseUrl: ENDORSER_BASE,
partnerBaseUrl: PARTNER_BASE,
config: { fetch },
};
}
function bothEmptyFetch() {
return captureFetch((url) => {
if (url.includes("/api/partner/")) return jsonResponse(emptyPartnerBody());
return jsonResponse(emptyEndorserBody());
});
}
function bothSuccessFetch() {
return captureFetch((url) => {
if (url.includes("/api/partner/")) {
return jsonResponse(
emptyPartnerBody({
profilesNearby: [
{
issuerDid: "did:ethr:0x1",
description: "p",
updatedAt: "2026-03-01T00:00:00.000Z",
rowId: 1,
},
],
})
);
}
return jsonResponse(
emptyEndorserBody({
claims: claimRows(10, 1),
})
);
});
}
describe("runDailyAlertSearch", () => {
let dir: string;
let previousDataDir: string | undefined;
beforeEach(async () => {
previousDataDir = process.env.NOTIFY_DATA_DIR;
dir = await mkdtemp(path.join(tmpdir(), "alert-search-daily-"));
process.env.NOTIFY_DATA_DIR = dir;
closeDatabase();
});
afterEach(async () => {
closeDatabase();
if (previousDataDir === undefined) {
delete process.env.NOTIFY_DATA_DIR;
} else {
process.env.NOTIFY_DATA_DIR = previousDataDir;
}
await rm(dir, { recursive: true, force: true });
});
it("selects the JWT for the current UTC day", async () => {
await seedBatch([
jwtInput(1, DAY_BEFORE, JWT_DAY_BEFORE),
jwtInput(2, DAY_UTC, JWT_DAY_UTC),
]);
const cap = bothEmptyFetch();
const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch));
assert.equal(result.utcDay, DAY_UTC);
assert.equal(result.jwtSequence, 2);
assert.equal(result.completed, true);
assert.equal(result.consumed, true);
for (const auth of cap.auths) {
assert.equal(auth, `Bearer ${JWT_DAY_UTC}`);
}
});
it("leaves another day's JWT untouched", async () => {
await seedBatch([
jwtInput(1, DAY_BEFORE, JWT_DAY_BEFORE),
jwtInput(2, DAY_UTC, JWT_DAY_UTC),
]);
const cap = bothEmptyFetch();
await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch));
for (const auth of cap.auths) {
assert.equal(auth.includes(JWT_DAY_BEFORE), false);
}
const other = await alertAuthorizationDb.getUnusedForDay(USER, DAY_BEFORE);
assert.equal(other?.jwt, JWT_DAY_BEFORE);
assert.equal(other?.status, ALERT_JWT_STATUS_UNUSED);
});
it("rolls to the next day's JWT the instant UTC midnight passes", async () => {
await seedBatch([
jwtInput(1, DAY_BEFORE, JWT_DAY_BEFORE),
jwtInput(2, DAY_UTC, JWT_DAY_UTC),
]);
const before = await runDailyAlertSearch(
USER,
new Date("2026-08-14T23:59:59.000Z"),
cycleOpts(bothEmptyFetch().fetch)
);
assert.equal(before.utcDay, DAY_BEFORE);
assert.equal(before.jwtSequence, 1);
const after = await runDailyAlertSearch(
USER,
new Date("2026-08-15T00:00:00.000Z"),
cycleOpts(bothEmptyFetch().fetch)
);
assert.equal(after.utcDay, DAY_UTC);
assert.equal(after.jwtSequence, 2);
});
it("returns a structured no-JWT result when today has no unused JWT", async () => {
await seedBatch([jwtInput(2, DAY_BEFORE, JWT_DAY_BEFORE)]);
const result = await runDailyAlertSearch(
USER,
NOW_SPLIT,
cycleOpts(bothEmptyFetch().fetch)
);
assert.equal(result.userId, USER);
assert.equal(result.utcDay, DAY_UTC);
assert.equal(result.batchId, "batch-1");
assert.equal(result.jwtSequence, null);
assert.equal(result.endorserOutcome, null);
assert.equal(result.partnerOutcome, null);
assert.equal(result.completed, false);
assert.equal(result.consumed, false);
assert.equal(result.digest, null);
});
it("returns a structured no-JWT result when the user has no batch", async () => {
const result = await runDailyAlertSearch(USER, NOW_SPLIT);
assert.equal(result.utcDay, null);
assert.equal(result.batchId, null);
assert.equal(result.jwtSequence, null);
assert.equal(result.completed, false);
assert.equal(result.consumed, false);
assert.equal(result.digest, null);
});
it("passes today's JWT to runAlertSearchCycle", async () => {
await seedBatch([
jwtInput(1, DAY_UTC, JWT_DAY_UTC),
jwtInput(2, DAY_BEFORE, JWT_DAY_BEFORE),
]);
const cap = bothSuccessFetch();
const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch));
assert.ok(cap.urls.some((u) => u.includes("/api/v2/report/alertSearch")));
assert.ok(cap.urls.some((u) => u.includes("/api/partner/alertSearch")));
assert.ok(cap.auths.length >= 2);
for (const auth of cap.auths) {
assert.equal(auth, `Bearer ${JWT_DAY_UTC}`);
}
assert.ok(result.digest);
assert.equal(result.digest.hasUpdates, true);
assert.equal(result.jwtSequence, 1);
});
it("consumes today's JWT when both sources succeed", async () => {
await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]);
const result = await runDailyAlertSearch(
USER,
NOW_SPLIT,
cycleOpts(bothSuccessFetch().fetch)
);
assert.equal(result.endorserOutcome, "success");
assert.equal(result.partnerOutcome, "success");
assert.equal(result.completed, true);
assert.equal(result.consumed, true);
assert.ok(result.digest);
assert.equal(result.digest.completed, true);
assert.equal(result.digest.hasUpdates, true);
assert.equal(result.digest.totalCount, 2);
assert.equal(result.digest.counts.claims, 1);
assert.equal(result.digest.counts.profilesNearby, 1);
assert.equal(result.digest.records.claims.length, 1);
assert.equal(result.digest.records.claims[0].id, ulid(10));
assert.equal(result.digest.records.profilesNearby[0].updatedAt, "2026-03-01T00:00:00.000Z");
const leftover = await alertAuthorizationDb.getUnusedForDay(USER, DAY_UTC);
assert.equal(leftover, undefined);
});
it("consumes today's JWT when both sources are empty", async () => {
await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]);
const result = await runDailyAlertSearch(
USER,
NOW_SPLIT,
cycleOpts(bothEmptyFetch().fetch)
);
assert.equal(result.endorserOutcome, "empty");
assert.equal(result.partnerOutcome, "empty");
assert.equal(result.completed, true);
assert.equal(result.consumed, true);
assert.ok(result.digest);
assert.equal(result.digest.completed, true);
assert.equal(result.digest.hasUpdates, false);
assert.equal(result.digest.totalCount, 0);
});
it("does not consume when Endorser succeeds and Partner fails", async () => {
await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]);
const cap = captureFetch((url) => {
if (url.includes("/api/partner/")) {
return jsonResponse({ error: "unauthorized" }, 401);
}
return jsonResponse(emptyEndorserBody({ claims: claimRows(10, 1) }));
});
const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch));
assert.equal(result.endorserOutcome, "success");
assert.equal(result.partnerOutcome, "auth");
assert.equal(result.completed, false);
assert.equal(result.consumed, false);
assert.ok(result.digest);
assert.equal(result.digest.completed, false);
assert.equal(result.digest.hasUpdates, false);
assert.equal(result.digest.records.claims.length, 1);
assert.equal(result.digest.records.claims[0].id, ulid(10));
const unused = await alertAuthorizationDb.getUnusedForDay(USER, DAY_UTC);
assert.equal(unused?.jwt, JWT_DAY_UTC);
const stored = await alertSearchCursorsDb.get(USER);
assert.equal(stored?.endorserAfterId, ulid(10));
assert.equal(stored?.partnerAfterAt, null);
});
it("does not consume when Partner succeeds and Endorser fails", async () => {
await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]);
const cap = captureFetch((url) => {
if (url.includes("/api/partner/")) {
return jsonResponse(
emptyPartnerBody({
profilesNearby: [
{
issuerDid: "did:ethr:0x1",
description: "p",
updatedAt: "2026-03-01T00:00:00.000Z",
rowId: 1,
},
],
})
);
}
return jsonResponse({ error: "unauthorized" }, 401);
});
const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch));
assert.equal(result.endorserOutcome, "auth");
assert.equal(result.partnerOutcome, "success");
assert.equal(result.completed, false);
assert.equal(result.consumed, false);
assert.ok(result.digest);
assert.equal(result.digest.completed, false);
assert.equal(result.digest.hasUpdates, false);
assert.equal(result.digest.records.profilesNearby.length, 1);
const unused = await alertAuthorizationDb.getUnusedForDay(USER, DAY_UTC);
assert.equal(unused?.status, ALERT_JWT_STATUS_UNUSED);
const stored = await alertSearchCursorsDb.get(USER);
assert.equal(stored?.endorserAfterId, null);
assert.equal(stored?.partnerAfterAt, "2026-03-01T00:00:00.000Z");
});
it("does not consume on Endorser pagination", async () => {
await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]);
const cap = captureFetch((url) => {
if (url.includes("/api/partner/")) return jsonResponse(emptyPartnerBody());
return jsonResponse({
...emptyEndorserBody(),
userMessage:
"Some data was not available in this search. Check the detail screens for the full set of data.",
});
});
const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch));
assert.equal(result.endorserOutcome, "pagination");
assert.equal(result.partnerOutcome, "empty");
assert.equal(result.completed, false);
assert.equal(result.consumed, false);
assert.ok(result.digest);
assert.equal(result.digest.completed, false);
assert.equal(result.digest.hasUpdates, false);
assert.equal(result.digest.endorser.outcome, "pagination");
});
it("does not consume on Partner pagination", async () => {
await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]);
const cap = captureFetch((url) => {
if (url.includes("/api/partner/")) {
const tied = "2026-01-01T12:00:00.000Z";
return jsonResponse(
emptyPartnerBody({
profilesNearby: profileRows(ALERT_SEARCH_PAGE_SIZE, () => tied),
})
);
}
return jsonResponse(emptyEndorserBody());
});
const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch));
assert.equal(result.endorserOutcome, "empty");
assert.equal(result.partnerOutcome, "pagination");
assert.equal(result.completed, false);
assert.equal(result.consumed, false);
assert.ok(result.digest);
assert.equal(result.digest.completed, false);
assert.equal(result.digest.partner.outcome, "pagination");
});
it("does not consume on network, auth, timeout, malformed, or http failure", async () => {
const cases: Array<{
name: string;
fetch: FetchLike;
expected: string;
}> = [
{
name: "network",
expected: "network",
fetch: async () => {
throw new TypeError("fetch failed");
},
},
{
name: "auth",
expected: "auth",
fetch: async () => jsonResponse({ error: "no jwt" }, 403),
},
{
name: "timeout",
expected: "timeout",
fetch: async () => {
const err = new Error("The operation was aborted");
err.name = "TimeoutError";
throw err;
},
},
{
name: "malformed",
expected: "malformed",
fetch: async () => jsonResponse({ data: { hitLimit: true } }),
},
{
name: "http",
expected: "http",
fetch: async () => jsonResponse({ error: "boom" }, 500),
},
];
for (const c of cases) {
closeDatabase();
await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]);
const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(c.fetch));
assert.equal(result.endorserOutcome, c.expected, c.name);
assert.equal(result.partnerOutcome, c.expected, c.name);
assert.equal(result.completed, false, c.name);
assert.equal(result.consumed, false, c.name);
assert.ok(result.digest, c.name);
assert.equal(result.digest.completed, false, c.name);
assert.equal(result.digest.hasUpdates, false, c.name);
const unused = await alertAuthorizationDb.getUnusedForDay(USER, DAY_UTC);
assert.equal(unused?.jwt, JWT_DAY_UTC, c.name);
}
});
it("consumes the exact selected JWT row, not another day's unused JWT", async () => {
await seedBatch([
jwtInput(1, DAY_UTC, JWT_DAY_UTC),
jwtInput(2, DAY_BEFORE, JWT_DAY_BEFORE),
]);
const today = await alertAuthorizationDb.getUnusedForDay(USER, DAY_UTC);
const other = await alertAuthorizationDb.getUnusedForDay(USER, DAY_BEFORE);
assert.ok(today);
assert.ok(other);
const result = await runDailyAlertSearch(
USER,
NOW_SPLIT,
cycleOpts(bothEmptyFetch().fetch)
);
assert.equal(result.consumed, true);
assert.equal(result.jwtSequence, 1);
const consumedRow = await alertAuthorizationDb.getJwtById(today.id);
assert.equal(consumedRow?.status, ALERT_JWT_STATUS_CONSUMED);
assert.ok(consumedRow?.consumedAt);
const stillUnused = await alertAuthorizationDb.getJwtById(other.id);
assert.equal(stillUnused?.status, ALERT_JWT_STATUS_UNUSED);
assert.equal(stillUnused?.consumedAt, undefined);
});
it("does not select or consume the same JWT after it has been consumed", async () => {
await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]);
const first = await runDailyAlertSearch(
USER,
NOW_SPLIT,
cycleOpts(bothEmptyFetch().fetch)
);
assert.equal(first.consumed, true);
const second = await runDailyAlertSearch(
USER,
NOW_SPLIT,
cycleOpts(bothEmptyFetch().fetch)
);
assert.equal(second.jwtSequence, null);
assert.equal(second.endorserOutcome, null);
assert.equal(second.completed, false);
assert.equal(second.consumed, false);
assert.equal(second.digest, null);
assert.equal(await alertAuthorizationDb.countUnused(USER), 0);
});
it("does not alter Phase 4B cursor rules: empty does not advance; success does", async () => {
await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]);
await runDailyAlertSearch(
USER,
NOW_SPLIT,
cycleOpts(bothEmptyFetch().fetch)
);
const afterEmpty = await alertSearchCursorsDb.get(USER);
assert.equal(afterEmpty, undefined);
closeDatabase();
await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]);
await runDailyAlertSearch(
USER,
NOW_SPLIT,
cycleOpts(bothSuccessFetch().fetch)
);
const afterSuccess = await alertSearchCursorsDb.get(USER);
assert.equal(afterSuccess?.endorserAfterId, ulid(10));
assert.equal(afterSuccess?.partnerAfterAt, "2026-03-01T00:00:00.000Z");
});
});
+279
View File
@@ -0,0 +1,279 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
ALERT_SEARCH_DIGEST_BUCKETS,
alertSearchDigestDebugSummary,
buildAlertSearchDigest,
} from "../../src/alertSearch/digest.js";
import { emptyEndorserData, emptyPartnerData } from "../../src/alertSearch/client.js";
import type { AlertSearchSourceResult, RetrieveAlertSearchResult } from "../../src/alertSearch/retrieve.js";
import type {
AlertSearchClaimRecord,
AlertSearchFailureReason,
AlertSearchJwtWithClaimRecord,
AlertSearchPlanRecord,
AlertSearchProfileRecord,
AlertSearchQueryOutcome,
CombinedAlertSearchData,
EndorserAlertSearchData,
PartnerAlertSearchData,
} from "../../src/alertSearch/types.js";
function ulid(n: number): string {
return `01H${String(n).padStart(23, "0")}`;
}
function claim(n: number): AlertSearchClaimRecord {
return {
id: ulid(n),
issuedAt: "2026-01-01T00:00:00Z",
issuer: `did:ethr:0x${n}`,
};
}
function jwtClaim(n: number): AlertSearchJwtWithClaimRecord {
return { ...claim(n), claim: `claim-${n}` };
}
function plan(n: number): AlertSearchPlanRecord {
return { handleId: `plan-${n}`, jwtId: ulid(n), name: `Plan ${n}` };
}
function profile(n: number): AlertSearchProfileRecord {
return {
issuerDid: `did:ethr:0xp${n}`,
description: `profile-${n}`,
updatedAt: "2026-03-01T00:00:00.000Z",
rowId: n,
};
}
function endorserResult(
outcome: AlertSearchQueryOutcome,
data: EndorserAlertSearchData = emptyEndorserData(),
extra?: Partial<AlertSearchSourceResult<EndorserAlertSearchData>>
): AlertSearchSourceResult<EndorserAlertSearchData> {
return { outcome, pageCount: 1, data, ...extra };
}
function partnerResult(
outcome: AlertSearchQueryOutcome,
data: PartnerAlertSearchData = emptyPartnerData(),
extra?: Partial<AlertSearchSourceResult<PartnerAlertSearchData>>
): AlertSearchSourceResult<PartnerAlertSearchData> {
return { outcome, pageCount: 1, data, ...extra };
}
function retrieved(
endorser: AlertSearchSourceResult<EndorserAlertSearchData>,
partner: AlertSearchSourceResult<PartnerAlertSearchData>
): RetrieveAlertSearchResult {
const data: CombinedAlertSearchData = {
...endorser.data,
...partner.data,
};
return { data, empty: false, endorser, partner };
}
function populatedEndorser(): EndorserAlertSearchData {
return {
claims: [claim(1), claim(2), claim(3)],
personalPlanContributions: [jwtClaim(10)],
trackedPlanUpdates: [plan(20)],
trackedPlanClaims: [jwtClaim(30), jwtClaim(31)],
plansNearby: [plan(40)],
};
}
describe("buildAlertSearchDigest", () => {
it("preserves all six buckets and their records when populated", () => {
const endorserData = populatedEndorser();
const partnerData = {
profilesNearby: [profile(1), profile(2)],
};
const digest = buildAlertSearchDigest(
retrieved(
endorserResult("success", endorserData),
partnerResult("success", partnerData)
)
);
assert.equal(digest.completed, true);
assert.equal(digest.hasUpdates, true);
assert.equal(digest.counts.claims, 3);
assert.equal(digest.counts.personalPlanContributions, 1);
assert.equal(digest.counts.trackedPlanUpdates, 1);
assert.equal(digest.counts.trackedPlanClaims, 2);
assert.equal(digest.counts.plansNearby, 1);
assert.equal(digest.counts.profilesNearby, 2);
assert.equal(digest.totalCount, 10);
assert.deepEqual(digest.records.claims, endorserData.claims);
assert.deepEqual(
digest.records.personalPlanContributions,
endorserData.personalPlanContributions
);
assert.deepEqual(digest.records.trackedPlanUpdates, endorserData.trackedPlanUpdates);
assert.deepEqual(digest.records.trackedPlanClaims, endorserData.trackedPlanClaims);
assert.deepEqual(digest.records.plansNearby, endorserData.plansNearby);
assert.deepEqual(digest.records.profilesNearby, partnerData.profilesNearby);
assert.equal(digest.records.claims[0], endorserData.claims[0]);
assert.notEqual(digest.records.claims, endorserData.claims);
});
it("supports a single populated bucket", () => {
const claims = [claim(1), claim(2)];
const digest = buildAlertSearchDigest(
retrieved(
endorserResult("success", { ...emptyEndorserData(), claims }),
partnerResult("empty")
)
);
assert.equal(digest.completed, true);
assert.equal(digest.hasUpdates, true);
assert.equal(digest.totalCount, 2);
assert.equal(digest.counts.claims, 2);
for (const bucket of ALERT_SEARCH_DIGEST_BUCKETS) {
if (bucket !== "claims") assert.equal(digest.counts[bucket], 0);
}
assert.equal(digest.records.claims.length, 2);
assert.equal(digest.records.claims[1].id, ulid(2));
});
it("supports multiple populated buckets without dropping others", () => {
const digest = buildAlertSearchDigest(
retrieved(
endorserResult("success", {
...emptyEndorserData(),
claims: [claim(1), claim(2), claim(3)],
trackedPlanUpdates: [plan(20)],
}),
partnerResult("success", { profilesNearby: [profile(1), profile(2)] })
)
);
assert.equal(digest.completed, true);
assert.equal(digest.hasUpdates, true);
assert.equal(digest.totalCount, 6);
assert.equal(digest.counts.claims, 3);
assert.equal(digest.counts.trackedPlanUpdates, 1);
assert.equal(digest.counts.profilesNearby, 2);
assert.equal(digest.records.claims.length, 3);
assert.equal(digest.records.trackedPlanUpdates[0].handleId, "plan-20");
assert.equal(digest.records.profilesNearby[1].description, "profile-2");
});
it("treats a successful empty retrieve as complete with no updates", () => {
const digest = buildAlertSearchDigest(
retrieved(endorserResult("empty"), partnerResult("empty"))
);
assert.equal(digest.completed, true);
assert.equal(digest.hasUpdates, false);
assert.equal(digest.totalCount, 0);
for (const bucket of ALERT_SEARCH_DIGEST_BUCKETS) {
assert.equal(digest.counts[bucket], 0);
assert.equal(digest.records[bucket].length, 0);
}
assert.equal(
alertSearchDigestDebugSummary(digest).includes("You have"),
false
);
});
it("does not mark a digest complete when Endorser succeeds and Partner fails", () => {
const digest = buildAlertSearchDigest(
retrieved(
endorserResult("success", { ...emptyEndorserData(), claims: [claim(1)] }),
partnerResult("auth", emptyPartnerData(), {
reason: "auth",
message: "HTTP 401",
})
)
);
assert.equal(digest.completed, false);
assert.equal(digest.hasUpdates, false);
assert.equal(digest.endorser.completed, true);
assert.equal(digest.endorser.outcome, "success");
assert.equal(digest.partner.completed, false);
assert.equal(digest.partner.outcome, "auth");
assert.equal(digest.partner.reason, "auth");
assert.equal(digest.records.claims.length, 1);
assert.equal(digest.totalCount, 1);
});
it("does not mark a digest complete when Partner succeeds and Endorser fails", () => {
const digest = buildAlertSearchDigest(
retrieved(
endorserResult("network", emptyEndorserData(), {
reason: "network",
message: "fetch failed",
}),
partnerResult("success", { profilesNearby: [profile(1)] })
)
);
assert.equal(digest.completed, false);
assert.equal(digest.hasUpdates, false);
assert.equal(digest.endorser.outcome, "network");
assert.equal(digest.partner.outcome, "success");
assert.equal(digest.records.profilesNearby.length, 1);
});
it("does not treat pagination or other incomplete outcomes as a completed digest", () => {
const incomplete: AlertSearchFailureReason[] = [
"pagination",
"auth",
"timeout",
"network",
"malformed",
"http",
];
for (const outcome of incomplete) {
const digest = buildAlertSearchDigest(
retrieved(
endorserResult(outcome, emptyEndorserData(), { reason: outcome }),
partnerResult("empty")
)
);
assert.equal(digest.completed, false, outcome);
assert.equal(digest.hasUpdates, false, outcome);
assert.equal(digest.endorser.outcome, outcome);
}
});
it("sums counts across all six buckets", () => {
const digest = buildAlertSearchDigest(
retrieved(
endorserResult("success", populatedEndorser()),
partnerResult("success", { profilesNearby: [profile(1), profile(2)] })
)
);
const sum = ALERT_SEARCH_DIGEST_BUCKETS.reduce(
(n, bucket) => n + digest.counts[bucket],
0
);
assert.equal(digest.totalCount, sum);
assert.equal(digest.totalCount, 10);
});
it("preserves underlying records rather than reducing them to counts", () => {
const extra = { claimType: "AgreeAction", subject: "did:ethr:0xabc" };
const row = { ...claim(7), ...extra };
const digest = buildAlertSearchDigest(
retrieved(
endorserResult("success", { ...emptyEndorserData(), claims: [row] }),
partnerResult("empty")
)
);
assert.equal(digest.counts.claims, 1);
assert.equal(digest.records.claims[0].id, ulid(7));
assert.equal(digest.records.claims[0].claimType, "AgreeAction");
assert.equal(digest.records.claims[0].subject, "did:ethr:0xabc");
assert.deepEqual(digest.records.claims[0], row);
});
it("is not notification-worthy when total count is zero", () => {
const digest = buildAlertSearchDigest(
retrieved(endorserResult("success"), partnerResult("success"))
);
assert.equal(digest.completed, true);
assert.equal(digest.totalCount, 0);
assert.equal(digest.hasUpdates, false);
});
});
+234
View File
@@ -0,0 +1,234 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, it } from "node:test";
import { db as fcmDb } from "../../src/db/fcmTokensSqlite.js";
import { closeDatabase } from "../../src/db/sqlite.js";
import type { DailyAlertSearchResult } from "../../src/alertSearch/daily.js";
import type { AlertSearchDigest } from "../../src/alertSearch/digest.js";
import {
ALERT_SEARCH_FCM_TYPE,
ALERT_SEARCH_NOTIFICATION_TITLE,
alertSearchNotificationBody,
buildAlertSearchNotificationContent,
deliverAlertSearchNotification,
isAlertSearchNotificationEligible,
} from "../../src/alertSearch/notify.js";
const USER = "did:ethr:0xnotifyuser";
function emptyDigest(
overrides: Partial<AlertSearchDigest> = {}
): AlertSearchDigest {
return {
completed: true,
hasUpdates: false,
totalCount: 0,
counts: {
claims: 0,
personalPlanContributions: 0,
trackedPlanUpdates: 0,
trackedPlanClaims: 0,
plansNearby: 0,
profilesNearby: 0,
},
records: {
claims: [],
personalPlanContributions: [],
trackedPlanUpdates: [],
trackedPlanClaims: [],
plansNearby: [],
profilesNearby: [],
},
endorser: { outcome: "empty", completed: true },
partner: { outcome: "empty", completed: true },
...overrides,
};
}
function updatesDigest(totalCount: number): AlertSearchDigest {
return emptyDigest({
completed: true,
hasUpdates: true,
totalCount,
counts: {
claims: totalCount,
personalPlanContributions: 0,
trackedPlanUpdates: 0,
trackedPlanClaims: 0,
plansNearby: 0,
profilesNearby: 0,
},
records: {
claims: Array.from({ length: totalCount }, (_, i) => ({
id: `01H${String(i).padStart(23, "0")}`,
issuedAt: "2026-01-01T00:00:00Z",
issuer: USER,
})),
personalPlanContributions: [],
trackedPlanUpdates: [],
trackedPlanClaims: [],
plansNearby: [],
profilesNearby: [],
},
endorser: { outcome: "success", completed: true },
partner: { outcome: "empty", completed: true },
});
}
function daily(
overrides: Partial<DailyAlertSearchResult> = {}
): DailyAlertSearchResult {
return {
userId: USER,
utcDay: "2026-08-14",
batchId: "batch-1",
jwtSequence: 1,
endorserOutcome: "success",
partnerOutcome: "empty",
completed: true,
consumed: true,
digest: updatesDigest(7),
...overrides,
};
}
describe("alertSearch notification gate", () => {
it("does not notify when digest is null", () => {
assert.equal(
isAlertSearchNotificationEligible(daily({ digest: null, consumed: false })),
false
);
});
it("does not notify when the digest is incomplete", () => {
assert.equal(
isAlertSearchNotificationEligible(
daily({
completed: false,
consumed: false,
digest: emptyDigest({
completed: false,
hasUpdates: false,
totalCount: 3,
endorser: { outcome: "success", completed: true },
partner: { outcome: "auth", completed: false },
}),
})
),
false
);
});
it("does not notify on a completed empty digest", () => {
assert.equal(
isAlertSearchNotificationEligible(
daily({
digest: emptyDigest(),
consumed: true,
})
),
false
);
});
it("notifies when completed with updates and today's JWT was consumed", () => {
assert.equal(isAlertSearchNotificationEligible(daily()), true);
});
it("does not notify when completed with updates but JWT was not consumed", () => {
assert.equal(
isAlertSearchNotificationEligible(daily({ consumed: false })),
false
);
});
it("puts totalCount in the body without record contents", () => {
const content = buildAlertSearchNotificationContent(7);
assert.equal(content.title, ALERT_SEARCH_NOTIFICATION_TITLE);
assert.equal(content.body, "You have 7 new updates.");
assert.equal(content.type, ALERT_SEARCH_FCM_TYPE);
assert.equal(content.body.includes(USER), false);
assert.equal(content.body.includes("01H"), false);
assert.equal(alertSearchNotificationBody(1), "You have 1 new update.");
});
});
describe("deliverAlertSearchNotification", () => {
let dir: string;
let previousDataDir: string | undefined;
beforeEach(async () => {
previousDataDir = process.env.NOTIFY_DATA_DIR;
dir = await mkdtemp(path.join(tmpdir(), "alert-search-notify-"));
process.env.NOTIFY_DATA_DIR = dir;
closeDatabase();
});
afterEach(async () => {
closeDatabase();
if (previousDataDir === undefined) {
delete process.env.NOTIFY_DATA_DIR;
} else {
process.env.NOTIFY_DATA_DIR = previousDataDir;
}
await rm(dir, { recursive: true, force: true });
});
it("sends once per distinct token for an eligible digest", async () => {
await fcmDb.upsert({
userId: USER,
deviceId: "d1",
fcmToken: "token-a",
platform: "ios",
updatedAt: new Date(),
});
await fcmDb.upsert({
userId: USER,
deviceId: "d2",
fcmToken: "token-b",
platform: "ios",
updatedAt: new Date(),
});
const sent: string[] = [];
const bodies: string[] = [];
const result = await deliverAlertSearchNotification(daily(), {
send: async (token, content) => {
sent.push(token);
bodies.push(content.body);
assert.equal(content.title, "TimeSafari");
assert.equal(content.type, "alert_search");
return "sent";
},
});
assert.equal(result.eligible, true);
assert.equal(result.sent, 2);
assert.deepEqual(sent.sort(), ["token-a", "token-b"]);
assert.equal(bodies[0], "You have 7 new updates.");
});
it("does not send when ineligible", async () => {
let sends = 0;
const result = await deliverAlertSearchNotification(
daily({ digest: null, consumed: false }),
{
send: async () => {
sends += 1;
return "sent";
},
}
);
assert.equal(result.eligible, false);
assert.equal(sends, 0);
});
it("records per-token failure without throwing", async () => {
const result = await deliverAlertSearchNotification(daily(), {
listTokens: async () => ["t1", "t2"],
send: async (token) => (token === "t1" ? "failed" : "sent"),
});
assert.equal(result.sent, 1);
assert.equal(result.failed, 1);
});
});
+91
View File
@@ -0,0 +1,91 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
alertSearchUrl,
buildAlertSearchQuery,
isAlertSearchCursorUlid,
} from "../../src/alertSearch/params.js";
import {
ENDORSER_ALERT_SEARCH_PATH,
PARTNER_ALERT_SEARCH_PATH,
} from "../../src/alertSearch/types.js";
const LOCATION = {
minLocLat: 40.7,
maxLocLat: 40.8,
minLocLon: -74.1,
maxLocLon: -74.0,
};
describe("alertSearch request construction", () => {
it("omits afterId on first run (does not send 0 or empty)", () => {
const query = buildAlertSearchQuery({});
assert.equal(query.has("afterId"), false);
assert.equal(query.has("beforeId"), false);
const endorser = alertSearchUrl(
"https://api.endorser.ch",
ENDORSER_ALERT_SEARCH_PATH,
query
);
const partner = alertSearchUrl(
"https://partner-api.endorser.ch",
PARTNER_ALERT_SEARCH_PATH,
query
);
assert.equal(endorser, "https://api.endorser.ch/api/v2/report/alertSearch");
assert.equal(
partner,
"https://partner-api.endorser.ch/api/partner/alertSearch"
);
});
it("rejects non-ULID afterId including 0", () => {
assert.equal(isAlertSearchCursorUlid("0"), false);
assert.equal(isAlertSearchCursorUlid(""), false);
const query = buildAlertSearchQuery({ afterId: "0" });
assert.equal(query.has("afterId"), false);
});
it("includes a subsequent afterId ULID", () => {
const afterId = "01H00000000000000000000001";
const query = buildAlertSearchQuery({ afterId });
assert.equal(query.get("afterId"), afterId);
});
it("builds independent Endorser vs Partner URLs and cursors", () => {
const endorserAfter = "01H0000000000000000000000A";
const partnerAfter = "01H0000000000000000000000B";
const endorser = alertSearchUrl(
"https://api.endorser.ch",
ENDORSER_ALERT_SEARCH_PATH,
buildAlertSearchQuery({ afterId: endorserAfter })
);
const partner = alertSearchUrl(
"https://partner-api.endorser.ch",
PARTNER_ALERT_SEARCH_PATH,
buildAlertSearchQuery({ afterId: partnerAfter })
);
assert.match(endorser, /afterId=01H0000000000000000000000A/);
assert.match(partner, /afterId=01H0000000000000000000000B/);
assert.equal(endorser.includes("0000000B"), false);
assert.equal(partner.includes("0000000A"), false);
assert.equal(endorser.includes("/api/v2/report/alertSearch"), true);
assert.equal(partner.includes("/api/partner/alertSearch"), true);
});
it("flattens location bbox for GET query params", () => {
const query = buildAlertSearchQuery({ location: LOCATION });
assert.equal(query.get("minLocLat"), "40.7");
assert.equal(query.get("maxLocLat"), "40.8");
assert.equal(query.get("minLocLon"), "-74.1");
assert.equal(query.get("maxLocLon"), "-74");
assert.equal(query.has("location"), false);
});
it("JSON-encodes planHandleIds", () => {
const query = buildAlertSearchQuery({
planHandleIds: ["plan-1", "plan-2"],
});
assert.equal(query.get("planHandleIds"), JSON.stringify(["plan-1", "plan-2"]));
});
});
+510
View File
@@ -0,0 +1,510 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
fetchEndorserAlertSearchPage,
fetchPartnerAlertSearchPage,
nextEndorserBeforeId,
type FetchLike,
} from "../../src/alertSearch/client.js";
import { retrieveAlertSearch } from "../../src/alertSearch/retrieve.js";
import { ALERT_SEARCH_PAGE_SIZE, type EndorserAlertSearchData } from "../../src/alertSearch/types.js";
const JWT = "delegated.jwt.token";
const ENDORSER_BASE = "https://api.endorser.ch";
const PARTNER_BASE = "https://partner-api.endorser.ch";
const AFTER_E = "01H0000000000000000000000A";
const AFTER_P = "01H0000000000000000000000B";
function emptyEndorserBody(overrides?: Record<string, unknown>) {
return {
data: {
claims: [],
personalPlanContributions: [],
trackedPlanUpdates: [],
trackedPlanClaims: [],
plansNearby: [],
...overrides,
},
};
}
function emptyPartnerBody(overrides?: Record<string, unknown>) {
return {
data: {
profilesNearby: [],
...overrides,
},
};
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
function captureFetch(
handler: (url: string, init?: RequestInit) => Response | Promise<Response>
): { fetch: FetchLike; urls: string[]; auths: string[] } {
const urls: string[] = [];
const auths: string[] = [];
const fetchImpl: FetchLike = async (input, init) => {
urls.push(input);
auths.push(new Headers(init?.headers).get("Authorization") ?? "");
assert.equal(init?.method, "GET");
return handler(input, init);
};
return { fetch: fetchImpl, urls, auths };
}
function ulid(n: number): string {
return `01H${String(n).padStart(23, "0")}`;
}
function claimRows(from: number, count: number) {
return Array.from({ length: count }, (_, i) => ({
id: ulid(from + i),
issuedAt: "2026-01-01T00:00:00Z",
issuer: "did:ethr:0x1",
}));
}
function planRows(from: number, count: number) {
return Array.from({ length: count }, (_, i) => ({
handleId: `plan-${from + i}`,
jwtId: ulid(from + i),
}));
}
describe("alertSearch HTTP pages", () => {
it("sends the delegated JWT as Bearer on Endorser GET", async () => {
const cap = captureFetch(() => jsonResponse(emptyEndorserBody()));
await fetchEndorserAlertSearchPage({
baseUrl: ENDORSER_BASE,
jwt: JWT,
query: {},
config: { fetch: cap.fetch },
});
assert.equal(cap.auths[0], "Bearer delegated.jwt.token");
assert.equal(cap.urls[0], `${ENDORSER_BASE}/api/v2/report/alertSearch`);
});
it("sends the delegated JWT as Bearer on Partner GET", async () => {
const cap = captureFetch(() => jsonResponse(emptyPartnerBody()));
await fetchPartnerAlertSearchPage({
baseUrl: PARTNER_BASE,
jwt: JWT,
query: {},
config: { fetch: cap.fetch },
});
assert.equal(cap.auths[0], "Bearer delegated.jwt.token");
assert.equal(cap.urls[0], `${PARTNER_BASE}/api/partner/alertSearch`);
});
it("classifies auth failure", async () => {
const cap = captureFetch(() =>
jsonResponse({ error: "Request must include a valid Authorization JWT" }, 400)
);
const result = await fetchEndorserAlertSearchPage({
baseUrl: ENDORSER_BASE,
jwt: JWT,
query: {},
config: { fetch: cap.fetch },
});
assert.equal(result.ok, false);
if (!result.ok) assert.equal(result.reason, "auth");
});
it("classifies network failure", async () => {
const fetchImpl: FetchLike = async () => {
throw new TypeError("fetch failed");
};
const result = await fetchPartnerAlertSearchPage({
baseUrl: PARTNER_BASE,
jwt: JWT,
query: {},
config: { fetch: fetchImpl },
});
assert.equal(result.ok, false);
if (!result.ok) assert.equal(result.reason, "network");
});
it("classifies malformed Endorser envelope", async () => {
const cap = captureFetch(() => jsonResponse({ data: { hitLimit: true } }));
const result = await fetchEndorserAlertSearchPage({
baseUrl: ENDORSER_BASE,
jwt: JWT,
query: {},
config: { fetch: cap.fetch },
});
assert.equal(result.ok, false);
if (!result.ok) assert.equal(result.reason, "malformed");
});
it("classifies timeout", async () => {
const fetchImpl: FetchLike = async () => {
const err = new Error("The operation was aborted");
err.name = "TimeoutError";
throw err;
};
const result = await fetchEndorserAlertSearchPage({
baseUrl: ENDORSER_BASE,
jwt: JWT,
query: {},
config: { fetch: fetchImpl },
});
assert.equal(result.ok, false);
if (!result.ok) assert.equal(result.reason, "timeout");
});
});
describe("nextEndorserBeforeId", () => {
it("uses MAX of per-bucket minima when multiple buckets hit 50", () => {
const data: EndorserAlertSearchData = {
claims: claimRows(200, ALERT_SEARCH_PAGE_SIZE),
personalPlanContributions: claimRows(50, ALERT_SEARCH_PAGE_SIZE),
trackedPlanUpdates: [],
trackedPlanClaims: [],
plansNearby: [],
};
const claimsMin = ulid(200);
const contributionsMin = ulid(50);
assert.equal(nextEndorserBeforeId(data), claimsMin);
assert.notEqual(nextEndorserBeforeId(data), contributionsMin);
});
it("uses the single truncated bucket min", () => {
const data: EndorserAlertSearchData = {
claims: claimRows(100, ALERT_SEARCH_PAGE_SIZE),
personalPlanContributions: [],
trackedPlanUpdates: [],
trackedPlanClaims: [],
plansNearby: [],
};
assert.equal(nextEndorserBeforeId(data), ulid(100));
});
});
describe("retrieveAlertSearch", () => {
it("first run omits afterId on both hosts", async () => {
const cap = captureFetch((url) => {
if (url.includes("/api/partner/")) {
return jsonResponse(emptyPartnerBody());
}
return jsonResponse(emptyEndorserBody());
});
const result = await retrieveAlertSearch({
jwt: JWT,
endorserBaseUrl: ENDORSER_BASE,
partnerBaseUrl: PARTNER_BASE,
config: { fetch: cap.fetch },
});
assert.equal(result.endorser.outcome, "empty");
assert.equal(result.partner.outcome, "empty");
assert.equal(result.empty, true);
assert.equal(cap.urls.length, 2);
for (const url of cap.urls) {
assert.equal(url.includes("afterId="), false);
}
});
it("passes independent afterId cursors", async () => {
const cap = captureFetch((url) => {
if (url.includes("/api/partner/")) {
return jsonResponse(emptyPartnerBody());
}
return jsonResponse(emptyEndorserBody());
});
await retrieveAlertSearch({
jwt: JWT,
endorserAfterId: AFTER_E,
partnerAfterId: AFTER_P,
endorserBaseUrl: ENDORSER_BASE,
partnerBaseUrl: PARTNER_BASE,
config: { fetch: cap.fetch },
});
const endorserUrl = cap.urls.find((u) => u.includes("/api/v2/report/")) ?? "";
const partnerUrl = cap.urls.find((u) => u.includes("/api/partner/")) ?? "";
assert.equal(endorserUrl.includes(AFTER_E), true);
assert.equal(endorserUrl.includes(AFTER_P), false);
assert.equal(partnerUrl.includes(AFTER_P), true);
assert.equal(partnerUrl.includes(AFTER_E), false);
});
it("paginates Endorser with beforeId from the limited bucket", async () => {
const page1Ids = Array.from({ length: ALERT_SEARCH_PAGE_SIZE }, (_, i) =>
ulid(100 + i)
);
const minId = [...page1Ids].sort()[0];
let endorserPages = 0;
const cap = captureFetch((url) => {
if (url.includes("/api/partner/")) {
return jsonResponse(emptyPartnerBody());
}
endorserPages += 1;
if (endorserPages === 1) {
assert.equal(url.includes("beforeId="), false);
return jsonResponse(
emptyEndorserBody({
claims: page1Ids.map((id) => ({
id,
issuedAt: "2026-01-01T00:00:00Z",
issuer: "did:ethr:0x1",
})),
})
);
}
assert.equal(url.includes(`beforeId=${minId}`), true);
return jsonResponse(
emptyEndorserBody({
claims: [
{
id: ulid(1),
issuedAt: "2026-01-01T00:00:00Z",
issuer: "did:ethr:0x1",
},
],
})
);
});
const result = await retrieveAlertSearch({
jwt: JWT,
endorserBaseUrl: ENDORSER_BASE,
partnerBaseUrl: PARTNER_BASE,
config: { fetch: cap.fetch },
});
assert.equal(result.endorser.outcome, "success");
assert.equal(result.endorser.pageCount, 2);
assert.equal(result.data.claims.length, ALERT_SEARCH_PAGE_SIZE + 1);
});
it("uses MAX of per-bucket minima as shared beforeId when two buckets hit 50", async () => {
const claimsMin = ulid(200);
const contributionsMin = ulid(50);
let endorserPages = 0;
const cap = captureFetch((url) => {
if (url.includes("/api/partner/")) {
return jsonResponse(emptyPartnerBody());
}
endorserPages += 1;
if (endorserPages === 1) {
return jsonResponse(
emptyEndorserBody({
claims: claimRows(200, ALERT_SEARCH_PAGE_SIZE),
personalPlanContributions: claimRows(50, ALERT_SEARCH_PAGE_SIZE),
})
);
}
assert.equal(url.includes(`beforeId=${claimsMin}`), true);
assert.equal(url.includes(`beforeId=${contributionsMin}`), false);
return jsonResponse(
emptyEndorserBody({
claims: claimRows(180, 5),
})
);
});
const result = await retrieveAlertSearch({
jwt: JWT,
endorserBaseUrl: ENDORSER_BASE,
partnerBaseUrl: PARTNER_BASE,
config: { fetch: cap.fetch },
});
assert.equal(result.endorser.outcome, "success");
assert.equal(result.endorser.pageCount, 2);
assert.equal(result.data.claims.length, ALERT_SEARCH_PAGE_SIZE + 5);
assert.equal(result.data.personalPlanContributions.length, ALERT_SEARCH_PAGE_SIZE);
});
it("classifies plansNearby hitting 50 as incomplete pagination and keeps records", async () => {
let endorserPages = 0;
const cap = captureFetch((url) => {
if (url.includes("/api/partner/")) {
return jsonResponse(emptyPartnerBody());
}
endorserPages += 1;
if (endorserPages === 1) {
return jsonResponse(
emptyEndorserBody({
claims: claimRows(1, 3),
plansNearby: planRows(300, ALERT_SEARCH_PAGE_SIZE),
})
);
}
return jsonResponse(
emptyEndorserBody({
plansNearby: planRows(250, 4),
})
);
});
const result = await retrieveAlertSearch({
jwt: JWT,
endorserBaseUrl: ENDORSER_BASE,
partnerBaseUrl: PARTNER_BASE,
config: { fetch: cap.fetch },
});
assert.equal(result.endorser.outcome, "pagination");
assert.equal(result.endorser.reason, "pagination");
assert.match(result.endorser.message ?? "", /plansNearby/);
assert.equal(result.data.plansNearby.length, ALERT_SEARCH_PAGE_SIZE + 4);
assert.equal(result.data.claims.length, 3);
});
it("paginates Partner with beforeDate independently", async () => {
const times = Array.from(
{ length: ALERT_SEARCH_PAGE_SIZE },
(_, i) => `2026-01-01T00:${String(i).padStart(2, "0")}:00.000Z`
);
const minTime = [...times].sort()[0];
let partnerPages = 0;
const cap = captureFetch((url) => {
if (url.includes("/api/v2/report/")) {
return jsonResponse(emptyEndorserBody());
}
partnerPages += 1;
if (partnerPages === 1) {
assert.equal(url.includes("beforeDate="), false);
return jsonResponse(
emptyPartnerBody({
profilesNearby: times.map((updatedAt, i) => ({
issuerDid: `did:ethr:0x${i}`,
description: "p",
updatedAt,
rowId: i,
})),
})
);
}
assert.equal(decodeURIComponent(url).includes(`beforeDate=${minTime}`), true);
return jsonResponse(emptyPartnerBody());
});
const result = await retrieveAlertSearch({
jwt: JWT,
endorserBaseUrl: ENDORSER_BASE,
partnerBaseUrl: PARTNER_BASE,
config: { fetch: cap.fetch },
});
assert.equal(result.partner.pageCount, 2);
assert.equal(result.partner.outcome, "success");
});
it("Partner full page with tied oldest updatedAt is incomplete, not a second exclusive page", async () => {
const tied = "2026-01-01T12:00:00.000Z";
let partnerPages = 0;
const cap = captureFetch((url) => {
if (url.includes("/api/v2/report/")) {
return jsonResponse(emptyEndorserBody());
}
partnerPages += 1;
assert.equal(url.includes("rowId"), false);
assert.equal(url.includes("beforeRowId"), false);
return jsonResponse(
emptyPartnerBody({
profilesNearby: Array.from(
{ length: ALERT_SEARCH_PAGE_SIZE },
(_, i) => ({
issuerDid: `did:ethr:0x${i}`,
description: "p",
updatedAt: tied,
rowId: i,
})
),
})
);
});
const result = await retrieveAlertSearch({
jwt: JWT,
endorserBaseUrl: ENDORSER_BASE,
partnerBaseUrl: PARTNER_BASE,
config: { fetch: cap.fetch },
});
assert.equal(partnerPages, 1);
assert.equal(result.partner.pageCount, 1);
assert.equal(result.partner.outcome, "pagination");
assert.equal(result.data.profilesNearby.length, ALERT_SEARCH_PAGE_SIZE);
});
it("returns success with empty buckets", async () => {
const cap = captureFetch((url) => {
if (url.includes("/api/partner/")) return jsonResponse(emptyPartnerBody());
return jsonResponse(emptyEndorserBody());
});
const result = await retrieveAlertSearch({
jwt: JWT,
endorserBaseUrl: ENDORSER_BASE,
partnerBaseUrl: PARTNER_BASE,
config: { fetch: cap.fetch },
});
assert.equal(result.empty, true);
assert.equal(result.endorser.outcome, "empty");
assert.equal(result.partner.outcome, "empty");
assert.ok("claims" in result.data);
assert.ok("profilesNearby" in result.data);
});
it("preserves all six result categories", async () => {
const cap = captureFetch((url) => {
if (url.includes("/api/partner/")) {
return jsonResponse(
emptyPartnerBody({
profilesNearby: [
{ issuerDid: "did:ethr:0x2", description: "near", extra: true },
],
})
);
}
return jsonResponse({
data: {
claims: [{ id: ulid(1), issuedAt: "t", issuer: "did:ethr:0x1", extra: 1 }],
personalPlanContributions: [
{ id: ulid(2), issuedAt: "t", issuer: "did:ethr:0x1", claim: "{}" },
],
trackedPlanUpdates: [{ handleId: "h", jwtId: ulid(3) }],
trackedPlanClaims: [
{ id: ulid(4), issuedAt: "t", issuer: "did:ethr:0x1" },
],
plansNearby: [{ handleId: "p", jwtId: ulid(5), locLat: 1 }],
},
});
});
const result = await retrieveAlertSearch({
jwt: JWT,
endorserBaseUrl: ENDORSER_BASE,
partnerBaseUrl: PARTNER_BASE,
config: { fetch: cap.fetch },
});
assert.equal(result.endorser.outcome, "success");
assert.equal(result.partner.outcome, "success");
assert.equal(result.data.claims[0]?.extra, 1);
assert.equal(result.data.profilesNearby[0]?.extra, true);
assert.equal(result.data.personalPlanContributions.length, 1);
assert.equal(result.data.trackedPlanUpdates.length, 1);
assert.equal(result.data.trackedPlanClaims.length, 1);
assert.equal(result.data.plansNearby.length, 1);
});
it("classifies truncation userMessage as incomplete pagination", async () => {
const cap = captureFetch((url) => {
if (url.includes("/api/partner/")) return jsonResponse(emptyPartnerBody());
return jsonResponse({
data: {
claims: [],
personalPlanContributions: [],
trackedPlanUpdates: [],
trackedPlanClaims: [],
plansNearby: [],
},
userMessage:
"Some data was not available in this search. Check the detail screens for the full set of data.",
});
});
const result = await retrieveAlertSearch({
jwt: JWT,
endorserBaseUrl: ENDORSER_BASE,
partnerBaseUrl: PARTNER_BASE,
config: { fetch: cap.fetch },
});
assert.equal(result.endorser.outcome, "pagination");
assert.equal(result.endorser.reason, "pagination");
});
});
+425
View File
@@ -0,0 +1,425 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, it } from "node:test";
import { alertAuthorizationDb } from "../../src/db/alertAuthorizationSqlite.js";
import { db as fcmDb } from "../../src/db/fcmTokensSqlite.js";
import { closeDatabase } from "../../src/db/sqlite.js";
import type { DailyAlertSearchResult } from "../../src/alertSearch/daily.js";
import {
isAlertSearchSchedulerPassInFlight,
resetAlertSearchSchedulerPassGuard,
runAlertSearchSchedulerPass,
startAlertSearchScheduler,
stopAlertSearchScheduler,
} from "../../src/alertSearch/scheduler.js";
import type { AlertSearchDigest } from "../../src/alertSearch/digest.js";
const USER_A = "did:ethr:0xusera";
const USER_B = "did:ethr:0xuserb";
function stubDailyResult(userId: string): DailyAlertSearchResult {
return {
userId,
utcDay: null,
batchId: null,
jwtSequence: null,
endorserOutcome: null,
partnerOutcome: null,
completed: false,
consumed: false,
digest: null,
};
}
function eligibleDaily(userId: string): DailyAlertSearchResult {
const digest: AlertSearchDigest = {
completed: true,
hasUpdates: true,
totalCount: 3,
counts: {
claims: 3,
personalPlanContributions: 0,
trackedPlanUpdates: 0,
trackedPlanClaims: 0,
plansNearby: 0,
profilesNearby: 0,
},
records: {
claims: [],
personalPlanContributions: [],
trackedPlanUpdates: [],
trackedPlanClaims: [],
plansNearby: [],
profilesNearby: [],
},
endorser: { outcome: "success", completed: true },
partner: { outcome: "empty", completed: true },
};
return {
userId,
utcDay: "2026-08-14",
batchId: "batch-1",
jwtSequence: 1,
endorserOutcome: "success",
partnerOutcome: "empty",
completed: true,
consumed: true,
digest,
};
}
/** The day every seeded JWT belongs to, and an instant inside it. */
const DAY = "2026-08-28";
const NOW = new Date(`${DAY}T12:00:00.000Z`);
async function seedBatch(
userId: string,
batchId: string,
notify?: { hour: number; minute: number }
) {
await alertAuthorizationDb.replaceUnusedBatch({
userId,
batchId,
notifyHourUtc: notify?.hour,
notifyMinuteUtc: notify?.minute,
jwts: [
{
sequence: 1,
day: DAY,
jwt: `jwt-${userId}`,
nbf: 1,
exp: 2,
},
],
});
}
async function seedDevice(userId: string, deviceId: string, fcmToken: string) {
await fcmDb.upsert({
userId,
deviceId,
fcmToken,
platform: "ios",
updatedAt: new Date(),
});
}
describe("alertSearch scheduler pass", () => {
let dir: string;
let previousDataDir: string | undefined;
beforeEach(async () => {
previousDataDir = process.env.NOTIFY_DATA_DIR;
dir = await mkdtemp(path.join(tmpdir(), "alert-search-sched-"));
process.env.NOTIFY_DATA_DIR = dir;
closeDatabase();
stopAlertSearchScheduler();
resetAlertSearchSchedulerPassGuard();
});
afterEach(async () => {
stopAlertSearchScheduler();
resetAlertSearchSchedulerPassGuard();
closeDatabase();
if (previousDataDir === undefined) {
delete process.env.NOTIFY_DATA_DIR;
} else {
process.env.NOTIFY_DATA_DIR = previousDataDir;
}
await rm(dir, { recursive: true, force: true });
});
it("runs alertSearch once for a user with multiple FCM devices", async () => {
await seedBatch(USER_A, "batch-a");
await seedDevice(USER_A, "device-1", "token-1");
await seedDevice(USER_A, "device-2", "token-2");
const ran: string[] = [];
const result = await runAlertSearchSchedulerPass({
now: NOW,
concurrency: 1,
runDaily: async (userId) => {
ran.push(userId);
return stubDailyResult(userId);
},
});
assert.equal(result.skipped, false);
assert.deepEqual(ran, [USER_A]);
assert.equal(result.attempted, 1);
});
it("runs alertSearch once per eligible user", async () => {
await seedBatch(USER_A, "batch-a");
await seedBatch(USER_B, "batch-b");
const ran: string[] = [];
const result = await runAlertSearchSchedulerPass({
now: NOW,
concurrency: 1,
runDaily: async (userId) => {
ran.push(userId);
return stubDailyResult(userId);
},
});
assert.deepEqual(ran, [USER_A, USER_B]);
assert.equal(result.attempted, 2);
assert.equal(result.failed, 0);
});
it("defers a user whose notify hour has not arrived", async () => {
await seedBatch(USER_A, "batch-a", { hour: 18, minute: 0 });
await seedBatch(USER_B, "batch-b");
const ran: string[] = [];
const result = await runAlertSearchSchedulerPass({
now: NOW,
concurrency: 1,
runDaily: async (userId) => {
ran.push(userId);
return stubDailyResult(userId);
},
});
assert.deepEqual(ran, [USER_B]);
assert.equal(result.deferred, 1);
assert.equal(result.attempted, 1);
});
it("runs that user once the hour passes", async () => {
await seedBatch(USER_A, "batch-a", { hour: 18, minute: 0 });
const ran: string[] = [];
const result = await runAlertSearchSchedulerPass({
now: new Date(`${DAY}T18:05:00.000Z`),
concurrency: 1,
runDaily: async (userId) => {
ran.push(userId);
return stubDailyResult(userId);
},
});
assert.deepEqual(ran, [USER_A]);
assert.equal(result.deferred, 0);
});
it("does not list a user whose day is already spent", async () => {
await seedBatch(USER_A, "batch-a");
const jwt = await alertAuthorizationDb.getUnusedForDay(USER_A, DAY);
assert.ok(jwt);
await alertAuthorizationDb.consumeUnusedJwt({ id: jwt.id, userId: USER_A });
const ran: string[] = [];
const result = await runAlertSearchSchedulerPass({
now: NOW,
concurrency: 1,
runDaily: async (userId) => {
ran.push(userId);
return stubDailyResult(userId);
},
});
assert.deepEqual(ran, []);
assert.equal(result.attempted, 0);
assert.equal(result.deferred, 0);
});
it("skips a tick while an alertSearch pass is in flight", async () => {
await seedBatch(USER_A, "batch-a");
let release!: () => void;
const blocked = new Promise<void>((resolve) => {
release = resolve;
});
const first = runAlertSearchSchedulerPass({
now: NOW,
concurrency: 1,
runDaily: async (userId) => {
await blocked;
return stubDailyResult(userId);
},
});
while (!isAlertSearchSchedulerPassInFlight()) {
await Promise.resolve();
}
const second = await runAlertSearchSchedulerPass({
now: NOW,
concurrency: 1,
runDaily: async () => {
throw new Error("second pass should not run daily");
},
});
assert.equal(second.skipped, true);
assert.equal(second.attempted, 0);
release();
const firstResult = await first;
assert.equal(firstResult.skipped, false);
assert.equal(firstResult.attempted, 1);
});
it("continues other users when one runDailyAlertSearch fails", async () => {
await seedBatch(USER_A, "batch-a");
await seedBatch(USER_B, "batch-b");
const ran: string[] = [];
const result = await runAlertSearchSchedulerPass({
now: NOW,
concurrency: 1,
runDaily: async (userId) => {
ran.push(userId);
if (userId === USER_A) throw new Error("boom");
return stubDailyResult(userId);
},
});
assert.deepEqual(ran, [USER_A, USER_B]);
assert.equal(result.failed, 1);
assert.equal(result.attempted, 2);
});
it("does not run a pass on start, and a second start is a no-op", async () => {
await seedBatch(USER_A, "batch-a");
assert.equal(startAlertSearchScheduler(), true);
assert.equal(startAlertSearchScheduler(), false);
await new Promise((resolve) => setTimeout(resolve, 25));
assert.equal(isAlertSearchSchedulerPassInFlight(), false);
stopAlertSearchScheduler();
});
it("notifies once for an eligible digest and not for a non-eligible result", async () => {
await seedBatch(USER_A, "batch-a");
await seedBatch(USER_B, "batch-b");
const notified: string[] = [];
await runAlertSearchSchedulerPass({
now: NOW,
concurrency: 1,
runDaily: async (userId) =>
userId === USER_A ? eligibleDaily(userId) : stubDailyResult(userId),
notify: async (result) => {
if (result.digest?.hasUpdates) notified.push(result.userId);
},
});
assert.deepEqual(notified, [USER_A]);
});
it("does not run alertSearch twice for two FCM devices on the same user", async () => {
await seedBatch(USER_A, "batch-a");
await seedDevice(USER_A, "device-1", "token-1");
await seedDevice(USER_A, "device-2", "token-2");
const ran: string[] = [];
const notifyCalls: number[] = [];
await runAlertSearchSchedulerPass({
now: NOW,
concurrency: 1,
runDaily: async (userId) => {
ran.push(userId);
return eligibleDaily(userId);
},
notify: async () => {
notifyCalls.push(1);
},
});
assert.deepEqual(ran, [USER_A]);
assert.equal(notifyCalls.length, 1);
});
it("does not notify again after the day's JWT was consumed", async () => {
await seedBatch(USER_A, "batch-a");
const notifies: Array<DailyAlertSearchResult["digest"]> = [];
await runAlertSearchSchedulerPass({
now: NOW,
concurrency: 1,
runDaily: async (userId) => eligibleDaily(userId),
notify: async (result) => {
notifies.push(result.digest);
},
});
await runAlertSearchSchedulerPass({
now: NOW,
concurrency: 1,
runDaily: async (userId) => stubDailyResult(userId),
notify: async (result) => {
notifies.push(result.digest);
},
});
assert.equal(notifies.length, 2);
assert.equal(notifies[0]?.hasUpdates, true);
assert.equal(notifies[1], null);
});
it("does not suppress a later notification after an incomplete run", async () => {
await seedBatch(USER_A, "batch-a");
const eligible: boolean[] = [];
const first = eligibleDaily(USER_A);
const incompleteDigest = first.digest;
assert.ok(incompleteDigest);
await runAlertSearchSchedulerPass({
now: NOW,
concurrency: 1,
runDaily: async () => ({
...first,
completed: false,
consumed: false,
digest: {
...incompleteDigest,
completed: false,
hasUpdates: false,
},
}),
notify: async (result) => {
eligible.push(Boolean(result.consumed && result.digest?.hasUpdates));
},
});
await runAlertSearchSchedulerPass({
now: NOW,
concurrency: 1,
runDaily: async (userId) => eligibleDaily(userId),
notify: async (result) => {
eligible.push(Boolean(result.consumed && result.digest?.hasUpdates));
},
});
assert.deepEqual(eligible, [false, true]);
});
it("keeps the daily result when notification throws", async () => {
await seedBatch(USER_A, "batch-a");
let dailyConsumed = false;
const result = await runAlertSearchSchedulerPass({
now: NOW,
concurrency: 1,
runDaily: async (userId) => {
const daily = eligibleDaily(userId);
dailyConsumed = daily.consumed;
return daily;
},
notify: async () => {
throw new Error("fcm down");
},
});
assert.equal(result.failed, 0);
assert.equal(dailyConsumed, true);
});
});
describe("alertSearch scheduler isolation from FCM", () => {
it("does not call sendPushToDevice or WAKEUP_PING", () => {
const alertSched = readFileSync(
path.join(process.cwd(), "src/alertSearch/scheduler.ts"),
"utf8"
);
assert.equal(alertSched.includes("sendPushToDevice"), false);
assert.equal(alertSched.includes("WAKEUP_PING"), false);
});
it("leaves the FCM scheduler on sendPushToDevice only", () => {
const fcmSched = readFileSync(
path.join(process.cwd(), "src/scheduler.ts"),
"utf8"
);
assert.equal(fcmSched.includes("sendPushToDevice"), true);
assert.equal(fcmSched.includes("runDailyAlertSearch"), false);
assert.equal(fcmSched.includes("runAlertSearchSchedulerPass"), false);
assert.equal(fcmSched.includes("startAlertSearchScheduler"), false);
});
it("leaves WAKEUP_PING last_notified_at logic on sendPushToDevice", () => {
const push = readFileSync(
path.join(process.cwd(), "src/services/pushService.ts"),
"utf8"
);
assert.equal(push.includes('type: "WAKEUP_PING"'), true);
assert.equal(push.includes("sendAlertSearchPushToDevice"), true);
});
});
+119
View File
@@ -0,0 +1,119 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, it } from "node:test";
import {
alertSearchCursorsDb,
smsAlertSearchCursorsDb,
} from "../../src/db/alertSearchCursorsSqlite.js";
import { closeDatabase } from "../../src/db/sqlite.js";
import {
advanceAlertSearchCursors,
loadAlertSearchCursors,
} from "../../src/alertSearch/cursors.js";
import type { AlertSearchSourceResult, RetrieveAlertSearchResult } from "../../src/alertSearch/retrieve.js";
import type { EndorserAlertSearchData, PartnerAlertSearchData } from "../../src/alertSearch/types.js";
const USER = "did:ethr:0xchanneluser";
let dir: string;
let savedDataDir: string | undefined;
beforeEach(async () => {
savedDataDir = process.env.NOTIFY_DATA_DIR;
dir = await mkdtemp(path.join(tmpdir(), "sms-channel-"));
process.env.NOTIFY_DATA_DIR = dir;
closeDatabase();
});
afterEach(async () => {
closeDatabase();
if (savedDataDir === undefined) delete process.env.NOTIFY_DATA_DIR;
else process.env.NOTIFY_DATA_DIR = savedDataDir;
await rm(dir, { recursive: true, force: true });
});
function ulid(n: number): string {
return `01H${String(n).padStart(23, "0")}`;
}
function retrieved(endorserUlid: string, partnerAt: string): RetrieveAlertSearchResult {
const endorser: AlertSearchSourceResult<EndorserAlertSearchData> = {
outcome: "success",
pageCount: 1,
data: {
claims: [{ id: endorserUlid, issuedAt: "2026-09-05T00:00:00Z", issuer: USER }],
personalPlanContributions: [],
trackedPlanUpdates: [],
trackedPlanClaims: [],
plansNearby: [],
},
};
const partner: AlertSearchSourceResult<PartnerAlertSearchData> = {
outcome: "success",
pageCount: 1,
data: { profilesNearby: [{ rowId: "r1", updatedAt: partnerAt } as never] },
};
return {
data: { ...endorser.data, ...partner.data },
empty: false,
endorser,
partner,
} as RetrieveAlertSearchResult;
}
describe("per-channel alertSearch cursors", () => {
it("advances only the SMS table for an SMS run", async () => {
await advanceAlertSearchCursors(
USER,
retrieved(ulid(5), "2026-09-05T00:00:00Z"),
"sms"
);
assert.equal(
(await smsAlertSearchCursorsDb.get(USER))?.endorserAfterId,
ulid(5)
);
assert.equal(await alertSearchCursorsDb.get(USER), undefined);
});
it("defaults to the FCM table when no channel is named", async () => {
await advanceAlertSearchCursors(
USER,
retrieved(ulid(9), "2026-09-06T00:00:00Z")
);
assert.equal(
(await alertSearchCursorsDb.get(USER))?.endorserAfterId,
ulid(9)
);
assert.equal(await smsAlertSearchCursorsDb.get(USER), undefined);
});
it("lets the two channels sit at different positions", async () => {
await advanceAlertSearchCursors(
USER,
retrieved(ulid(1), "2026-09-01T00:00:00Z"),
"fcm"
);
await advanceAlertSearchCursors(
USER,
retrieved(ulid(2), "2026-09-02T00:00:00Z"),
"sms"
);
assert.deepEqual(await loadAlertSearchCursors(USER, "fcm"), {
endorserAfterId: ulid(1),
partnerAfterDate: "2026-09-01T00:00:00Z",
});
assert.deepEqual(await loadAlertSearchCursors(USER, "sms"), {
endorserAfterId: ulid(2),
partnerAfterDate: "2026-09-02T00:00:00Z",
});
});
it("reports an empty position for a channel that has never run", async () => {
assert.deepEqual(await loadAlertSearchCursors(USER, "sms"), {});
});
});
+480
View File
@@ -0,0 +1,480 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, it } from "node:test";
import { smsPhoneLogDb } from "../../src/db/smsPhoneLogSqlite.js";
import { smsBlockedNumbersDb } from "../../src/db/smsBlockedNumbersSqlite.js";
import { smsRegistrationsDb } from "../../src/db/smsRegistrationsSqlite.js";
import { closeDatabase, getDatabase } from "../../src/db/sqlite.js";
import { hashPhoneNumber } from "../../src/util/smsVerificationCode.js";
import type { SmsSendResult } from "../../src/services/smsService.js";
import type { DailyAlertSearchResult } from "../../src/alertSearch/daily.js";
import type { AlertSearchDigest } from "../../src/alertSearch/digest.js";
import {
ALERT_SEARCH_SMS_LINK,
SMS_SINGLE_SEGMENT_LIMIT,
alertSearchSmsBody,
deliverAlertSearchSms,
isAlertSearchSmsEligible,
} from "../../src/alertSearch/smsNotify.js";
const USER = "did:ethr:0xsmsnotify";
const PHONE = "+15555550123";
const OTHER_PHONE = "+15555550124";
const SECRET = "sms-notify-secret";
let dir: string;
let savedDataDir: string | undefined;
let savedSecret: string | undefined;
let savedAllowlist: string | undefined;
let savedBlocked: string | undefined;
beforeEach(async () => {
savedDataDir = process.env.NOTIFY_DATA_DIR;
savedSecret = process.env.SMS_CODE_SECRET;
savedAllowlist = process.env.SMS_ALLOWED_RECIPIENT_DIDS;
delete process.env.SMS_ALLOWED_RECIPIENT_DIDS;
savedBlocked = process.env.SMS_BLOCKED_NUMBERS;
delete process.env.SMS_BLOCKED_NUMBERS;
dir = await mkdtemp(path.join(tmpdir(), "sms-notify-"));
process.env.NOTIFY_DATA_DIR = dir;
process.env.SMS_CODE_SECRET = SECRET;
closeDatabase();
});
afterEach(async () => {
closeDatabase();
if (savedDataDir === undefined) delete process.env.NOTIFY_DATA_DIR;
else process.env.NOTIFY_DATA_DIR = savedDataDir;
if (savedSecret === undefined) delete process.env.SMS_CODE_SECRET;
else process.env.SMS_CODE_SECRET = savedSecret;
if (savedAllowlist === undefined) delete process.env.SMS_ALLOWED_RECIPIENT_DIDS;
else process.env.SMS_ALLOWED_RECIPIENT_DIDS = savedAllowlist;
if (savedBlocked === undefined) delete process.env.SMS_BLOCKED_NUMBERS;
else process.env.SMS_BLOCKED_NUMBERS = savedBlocked;
await rm(dir, { recursive: true, force: true });
});
function digest(overrides: Partial<AlertSearchDigest> = {}): AlertSearchDigest {
const counts = {
claims: 0,
personalPlanContributions: 0,
trackedPlanUpdates: 0,
trackedPlanClaims: 0,
plansNearby: 0,
profilesNearby: 0,
};
return {
completed: true,
hasUpdates: true,
totalCount: 7,
counts,
records: {
claims: [],
personalPlanContributions: [],
trackedPlanUpdates: [],
trackedPlanClaims: [],
plansNearby: [],
profilesNearby: [],
},
endorser: { outcome: "success", completed: true },
partner: { outcome: "empty", completed: true },
...overrides,
};
}
function daily(
overrides: Partial<DailyAlertSearchResult> = {}
): DailyAlertSearchResult {
return {
userId: USER,
utcDay: "2026-09-05",
batchId: "sms-batch-1",
jwtSequence: 1,
endorserOutcome: "success",
partnerOutcome: "empty",
completed: true,
consumed: true,
digest: digest(),
...overrides,
};
}
async function verifyPhone(phone: string, user = USER): Promise<void> {
const now = new Date().toISOString();
await smsRegistrationsDb.upsertPendingCode({
userId: user,
phoneE164: phone,
codeHash: "hash",
codeExpiresAt: now,
sentAt: now,
});
await smsRegistrationsDb.markVerified(user, phone);
}
describe("alertSearchSmsBody", () => {
it("fits one GSM-7 segment and carries the link and the opt-out", () => {
const body = alertSearchSmsBody(7);
assert.equal(
body,
`Gift Economies: you have 7 new updates. ${ALERT_SEARCH_SMS_LINK} Reply STOP to end.`
);
assert.ok(body.length <= SMS_SINGLE_SEGMENT_LIMIT, `length ${body.length}`);
assert.ok(
alertSearchSmsBody(999999).length <= SMS_SINGLE_SEGMENT_LIMIT
);
assert.equal(
alertSearchSmsBody(1),
`Gift Economies: you have 1 new update. ${ALERT_SEARCH_SMS_LINK} Reply STOP to end.`
);
assert.equal(body.includes("giftopia.tech"), false);
});
});
describe("SMS notification gate", () => {
it("requires consumption, completion, and updates", () => {
assert.equal(isAlertSearchSmsEligible(daily()), true);
assert.equal(isAlertSearchSmsEligible(daily({ consumed: false })), false);
assert.equal(isAlertSearchSmsEligible(daily({ digest: null })), false);
assert.equal(
isAlertSearchSmsEligible(
daily({ digest: digest({ completed: false }) })
),
false
);
assert.equal(
isAlertSearchSmsEligible(
daily({ digest: digest({ hasUpdates: false, totalCount: 0 }) })
),
false
);
});
});
describe("deliverAlertSearchSms", () => {
it("texts each verified number once and logs the provider id", async () => {
await verifyPhone(PHONE);
await verifyPhone(OTHER_PHONE);
const sent: string[] = [];
const result = await deliverAlertSearchSms(daily(), {
send: async (to, body) => {
sent.push(to);
assert.match(body, /7 new updates/);
return { status: "sent", messageId: "SM-" + to };
},
});
assert.equal(result.eligible, true);
assert.equal(result.sent, 2);
assert.deepEqual(sent.sort(), [PHONE, OTHER_PHONE].sort());
const log = await smsPhoneLogDb.listByUserId(USER);
const alerts = log.filter((row) => row.action === "alert-sent");
assert.equal(alerts.length, 2);
assert.ok(alerts.every((row) => row.providerMessageId?.startsWith("SM-")));
});
it("skips unverified numbers and other DIDs' numbers", async () => {
await verifyPhone(PHONE);
const now = new Date().toISOString();
await smsRegistrationsDb.upsertPendingCode({
userId: USER,
phoneE164: OTHER_PHONE,
codeHash: "hash",
codeExpiresAt: now,
sentAt: now,
});
await verifyPhone("+15555550125", "did:ethr:0xsomeoneelse");
const sent: string[] = [];
await deliverAlertSearchSms(daily(), {
send: async (to) => {
sent.push(to);
return { status: "sent", messageId: "SM1" };
},
});
assert.deepEqual(sent, [PHONE]);
});
it("sends nothing when ineligible", async () => {
await verifyPhone(PHONE);
let sends = 0;
const result = await deliverAlertSearchSms(daily({ consumed: false }), {
send: async () => {
sends += 1;
return { status: "sent", messageId: "SM1" };
},
});
assert.equal(result.eligible, false);
assert.equal(sends, 0);
});
it("logs a failure without throwing and without a provider id", async () => {
await verifyPhone(PHONE);
const failure: SmsSendResult = { status: "failed", error: "carrier down" };
const result = await deliverAlertSearchSms(daily(), {
send: async () => failure,
});
assert.equal(result.sent, 0);
assert.equal(result.failed, 1);
const log = await smsPhoneLogDb.listByUserId(USER);
assert.equal(log[0].action, "alert-send-failed");
assert.equal(log[0].detail, "carrier down");
assert.equal(log[0].providerMessageId, undefined);
});
it("records a thrown send as a failure and keeps going", async () => {
await verifyPhone(PHONE);
await verifyPhone(OTHER_PHONE);
const result = await deliverAlertSearchSms(daily(), {
send: async (to) => {
if (to === PHONE) throw new Error("socket hang up");
return { status: "sent", messageId: "SM1" };
},
});
assert.equal(result.sent, 1);
assert.equal(result.failed, 1);
});
it("does not text the same number twice on the same UTC day", async () => {
await verifyPhone(PHONE);
const now = new Date("2026-09-08T18:00:00.000Z");
let sends = 0;
const send = async (): Promise<SmsSendResult> => {
sends += 1;
return { status: "sent", messageId: "SM" + sends };
};
await deliverAlertSearchSms(daily(), { send, now });
// A second eligible run the same UTC day: the JWT is what normally stops
// this, and the log-backed cap is the backstop underneath it.
const second = await deliverAlertSearchSms(daily(), { send, now });
assert.equal(sends, 1);
assert.equal(second.sent, 0);
});
it("does not suppress a send on the next UTC day after a late-evening send", async () => {
await verifyPhone(PHONE);
let sends = 0;
const send = async (): Promise<SmsSendResult> => {
sends += 1;
return { status: "sent", messageId: "SM" + sends };
};
await deliverAlertSearchSms(daily(), {
send,
now: new Date("2026-09-08T23:30:00.000Z"),
});
getDatabase()
.prepare(
`UPDATE sms_phone_log SET created_at = ? WHERE action = 'alert-sent'`
)
.run("2026-09-08T23:30:00.000Z");
const nextDay = await deliverAlertSearchSms(daily(), {
send,
now: new Date("2026-09-09T00:01:00.000Z"),
});
assert.equal(sends, 2);
assert.equal(nextDay.sent, 1);
});
it("does not let a failed send count toward the UTC-day cap", async () => {
await verifyPhone(PHONE);
const now = new Date("2026-09-08T18:00:00.000Z");
let sends = 0;
const result = await deliverAlertSearchSms(daily(), {
now,
send: async () => {
sends += 1;
if (sends === 1) {
return { status: "failed", error: "carrier down" };
}
return { status: "sent", messageId: "SM1" };
},
});
assert.equal(result.sent, 0);
assert.equal(result.failed, 1);
const retry = await deliverAlertSearchSms(daily(), {
now,
send: async () => {
sends += 1;
return { status: "sent", messageId: "SM1" };
},
});
assert.equal(sends, 2);
assert.equal(retry.sent, 1);
assert.equal(retry.failed, 0);
});
});
describe("SMS_ALLOWED_RECIPIENT_DIDS", () => {
it("sends normally to a DID on the list", async () => {
process.env.SMS_ALLOWED_RECIPIENT_DIDS = USER;
await verifyPhone(PHONE);
const result = await deliverAlertSearchSms(daily(), {
send: async () => ({ status: "sent", messageId: "SM1" }),
});
assert.equal(result.sent, 1);
assert.equal(result.blocked, 0);
});
it("withholds an eligible digest from a DID that is not listed", async () => {
process.env.SMS_ALLOWED_RECIPIENT_DIDS = "did:ethr:0xsomeoneelse";
await verifyPhone(PHONE);
await verifyPhone(OTHER_PHONE);
let sends = 0;
const result = await deliverAlertSearchSms(daily(), {
send: async () => {
sends += 1;
return { status: "sent", messageId: "SM1" };
},
});
assert.equal(sends, 0);
// The digest was worth sending; this instance was not allowed to send it.
assert.equal(result.eligible, true);
assert.equal(result.sent, 0);
assert.equal(result.blocked, 2);
});
it("records what it withheld, one row per number", async () => {
process.env.SMS_ALLOWED_RECIPIENT_DIDS = "did:ethr:0xsomeoneelse";
await verifyPhone(PHONE);
await deliverAlertSearchSms(daily(), {
send: async () => ({ status: "sent", messageId: "SM1" }),
});
const rows = await smsPhoneLogDb.listByUserId(USER);
assert.equal(rows.length, 1);
assert.equal(rows[0].action, "recipient-not-allowed");
assert.equal(rows[0].result, "rejected");
assert.equal(rows[0].phoneE164, PHONE);
assert.equal(rows[0].detail, "SMS_ALLOWED_RECIPIENT_DIDS");
});
it("blocks every DID when the list is set but empty", async () => {
process.env.SMS_ALLOWED_RECIPIENT_DIDS = "";
await verifyPhone(PHONE);
let sends = 0;
const result = await deliverAlertSearchSms(daily(), {
send: async () => {
sends += 1;
return { status: "sent", messageId: "SM1" };
},
});
assert.equal(sends, 0);
assert.equal(result.blocked, 1);
});
it("does not fire for an ineligible digest", async () => {
process.env.SMS_ALLOWED_RECIPIENT_DIDS = "did:ethr:0xsomeoneelse";
await verifyPhone(PHONE);
const result = await deliverAlertSearchSms(daily({ consumed: false }), {
send: async () => ({ status: "sent", messageId: "SM1" }),
});
assert.equal(result.eligible, false);
assert.equal(result.blocked, 0);
assert.deepEqual(await smsPhoneLogDb.listByUserId(USER), []);
});
});
describe("blocked numbers in the daily digest", () => {
it("skips a blocked number and still texts the others", async () => {
await verifyPhone(PHONE);
await verifyPhone(OTHER_PHONE);
await smsBlockedNumbersDb.block({
phoneHash: hashPhoneNumber(PHONE, SECRET),
reason: "opt-out",
});
const sent: string[] = [];
const result = await deliverAlertSearchSms(daily(), {
send: async (to) => {
sent.push(to);
return { status: "sent", messageId: "SM1" };
},
});
assert.deepEqual(sent, [OTHER_PHONE]);
assert.equal(result.sent, 1);
assert.equal(result.blocked, 1);
const rows = await smsPhoneLogDb.listByUserId(USER);
const skipped = rows.find((row) => row.action === "number-blocked");
assert.equal(skipped?.result, "rejected");
assert.equal(skipped?.phoneE164, undefined);
});
it("sends nothing when every number is blocked", async () => {
await verifyPhone(PHONE);
process.env.SMS_BLOCKED_NUMBERS = PHONE;
let sends = 0;
const result = await deliverAlertSearchSms(daily(), {
send: async () => {
sends += 1;
return { status: "sent", messageId: "SM1" };
},
});
assert.equal(sends, 0);
assert.equal(result.blocked, 1);
assert.equal(result.sent, 0);
});
});
describe("Twilio 21610 syncs the provider's opt-out list into ours", () => {
it("blocks the number after an unsubscribed refusal", async () => {
await verifyPhone(PHONE);
const hash = hashPhoneNumber(PHONE, SECRET);
const result = await deliverAlertSearchSms(daily(), {
send: async () => ({
status: "failed",
error: "The message From/To pair violates a blacklist rule.",
code: 21610,
}),
});
assert.equal(result.failed, 1);
const stored = await smsBlockedNumbersDb.get(hash);
assert.equal(stored?.reason, "provider-opt-out");
assert.equal(stored?.detail, "Twilio 21610");
const rows = await smsPhoneLogDb.listByUserId(USER);
assert.ok(rows.some((row) => row.action === "alert-send-failed"));
assert.ok(rows.some((row) => row.action === "number-blocked"));
});
it("does not retry that number on the next eligible run", async () => {
await verifyPhone(PHONE);
await deliverAlertSearchSms(daily(), {
send: async () => ({ status: "failed", error: "unsubscribed", code: 21610 }),
});
let sends = 0;
const second = await deliverAlertSearchSms(daily(), {
send: async () => {
sends += 1;
return { status: "sent", messageId: "SM1" };
},
});
assert.equal(sends, 0);
assert.equal(second.blocked, 1);
});
it("leaves an ordinary failure unblocked, so a blip is retried", async () => {
await verifyPhone(PHONE);
await deliverAlertSearchSms(daily(), {
send: async () => ({ status: "failed", error: "carrier down", code: 30001 }),
});
assert.equal(
await smsBlockedNumbersDb.isBlocked(hashPhoneNumber(PHONE, SECRET)),
false
);
});
});
+364
View File
@@ -0,0 +1,364 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, it } from "node:test";
import { smsActionJwtUseDb } from "../../src/db/smsActionJwtUseSqlite.js";
import { smsAlertAuthorizationDb } from "../../src/db/smsAlertAuthorizationSqlite.js";
import { closeDatabase } from "../../src/db/sqlite.js";
import type { DailyAlertSearchResult } from "../../src/alertSearch/daily.js";
import {
SMS_ACTION_JWT_RETENTION_MULTIPLE,
isSmsAlertSearchSchedulerPassInFlight,
resetSmsAlertSearchSchedulerPassGuard,
runSmsAlertSearchSchedulerPass,
startSmsAlertSearchScheduler,
stopSmsAlertSearchScheduler,
} from "../../src/alertSearch/smsScheduler.js";
const USER = "did:ethr:0xschedone";
const OTHER = "did:ethr:0xschedtwo";
/** The day every seeded JWT belongs to, and an instant inside it. */
const DAY = "2026-09-05";
const NOW = new Date(`${DAY}T12:00:00.000Z`);
/** A pending list where every named user's hour has already come. */
function due(userIds: string[]) {
return userIds.map((userId) => ({ userId, due: true }));
}
let dir: string;
let savedDataDir: string | undefined;
beforeEach(async () => {
savedDataDir = process.env.NOTIFY_DATA_DIR;
dir = await mkdtemp(path.join(tmpdir(), "sms-scheduler-"));
process.env.NOTIFY_DATA_DIR = dir;
closeDatabase();
resetSmsAlertSearchSchedulerPassGuard();
});
afterEach(async () => {
stopSmsAlertSearchScheduler();
resetSmsAlertSearchSchedulerPassGuard();
closeDatabase();
if (savedDataDir === undefined) delete process.env.NOTIFY_DATA_DIR;
else process.env.NOTIFY_DATA_DIR = savedDataDir;
await rm(dir, { recursive: true, force: true });
});
function daily(userId: string): DailyAlertSearchResult {
return {
userId,
utcDay: "2026-09-05",
batchId: "b",
jwtSequence: 1,
endorserOutcome: "success",
partnerOutcome: "empty",
completed: true,
consumed: true,
digest: null,
};
}
describe("runSmsAlertSearchSchedulerPass", () => {
it("runs and notifies once per SMS-authorized user", async () => {
const ran: string[] = [];
const notified: string[] = [];
const result = await runSmsAlertSearchSchedulerPass({
concurrency: 1,
listPending: async () => due([USER, OTHER]),
runDaily: async (userId) => {
ran.push(userId);
return daily(userId);
},
notify: async (input) => {
notified.push(input.userId);
},
prune: async () => undefined,
});
assert.deepEqual(ran, [USER, OTHER]);
assert.deepEqual(notified, [USER, OTHER]);
assert.equal(result.attempted, 2);
assert.equal(result.failed, 0);
assert.equal(result.skipped, false);
});
it("lists users from the SMS batches, not the FCM ones", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-1",
jwts: [{ sequence: 1, day: DAY, jwt: "j", nbf: 1, exp: 2 }],
});
const seen: string[] = [];
await runSmsAlertSearchSchedulerPass({
concurrency: 1,
now: NOW,
runDaily: async (userId) => {
seen.push(userId);
return daily(userId);
},
notify: async () => undefined,
prune: async () => undefined,
});
assert.deepEqual(seen, [USER]);
});
it("counts a failing user and keeps going", async () => {
const notified: string[] = [];
const result = await runSmsAlertSearchSchedulerPass({
concurrency: 1,
listPending: async () => due([USER, OTHER]),
runDaily: async (userId) => {
if (userId === USER) throw new Error("Endorser down");
return daily(userId);
},
notify: async (input) => {
notified.push(input.userId);
},
prune: async () => undefined,
});
assert.equal(result.failed, 1);
assert.deepEqual(notified, [OTHER]);
});
it("does not let a failing notification fail the user", async () => {
const result = await runSmsAlertSearchSchedulerPass({
concurrency: 1,
listPending: async () => due([USER]),
runDaily: async (userId) => daily(userId),
notify: async () => {
throw new Error("Twilio down");
},
prune: async () => undefined,
});
assert.equal(result.failed, 0);
});
it("skips a pass while another is in flight", async () => {
let release: (() => void) | undefined;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const running = runSmsAlertSearchSchedulerPass({
concurrency: 1,
listPending: async () => due([USER]),
runDaily: async (userId) => {
await gate;
return daily(userId);
},
notify: async () => undefined,
prune: async () => undefined,
});
assert.equal(isSmsAlertSearchSchedulerPassInFlight(), true);
const skipped = await runSmsAlertSearchSchedulerPass({
concurrency: 1,
listPending: async () => due([OTHER]),
prune: async () => undefined,
});
assert.equal(skipped.skipped, true);
assert.equal(skipped.attempted, 0);
release?.();
await running;
assert.equal(isSmsAlertSearchSchedulerPassInFlight(), false);
});
it("defers a user the pending list reports as not yet due", async () => {
const ran: string[] = [];
const result = await runSmsAlertSearchSchedulerPass({
concurrency: 1,
listPending: async () => [
{ userId: USER, due: false },
{ userId: OTHER, due: true },
],
runDaily: async (userId) => {
ran.push(userId);
return daily(userId);
},
notify: async () => undefined,
prune: async () => undefined,
});
assert.deepEqual(ran, [OTHER]);
assert.equal(result.deferred, 1);
assert.equal(result.attempted, 1);
assert.equal(result.failed, 0);
});
it("holds the stored hour back until that instant, then runs", async () => {
// 18:00-06:00 is 00:00 UTC, so this batch asks for the very top of each UTC
// day; a batch stored at 18:00 UTC is the one that has to wait.
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-1",
notifyHourUtc: 18,
notifyMinuteUtc: 0,
jwts: [{ sequence: 1, day: DAY, jwt: "j", nbf: 1, exp: 2 }],
});
const ran: string[] = [];
const track = async (userId: string) => {
ran.push(userId);
return daily(userId);
};
const before = await runSmsAlertSearchSchedulerPass({
concurrency: 1,
now: new Date(`${DAY}T17:55:00.000Z`),
runDaily: track,
notify: async () => undefined,
prune: async () => undefined,
});
assert.deepEqual(ran, []);
assert.equal(before.deferred, 1);
assert.equal(before.attempted, 0);
const after = await runSmsAlertSearchSchedulerPass({
concurrency: 1,
now: new Date(`${DAY}T18:05:00.000Z`),
runDaily: track,
notify: async () => undefined,
prune: async () => undefined,
});
assert.deepEqual(ran, [USER]);
assert.equal(after.deferred, 0);
assert.equal(after.attempted, 1);
});
it("drops the user entirely once the UTC day rolls past their JWT", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-1",
notifyHourUtc: 18,
notifyMinuteUtc: 0,
jwts: [{ sequence: 1, day: DAY, jwt: "j", nbf: 1, exp: 2 }],
});
const result = await runSmsAlertSearchSchedulerPass({
concurrency: 1,
now: new Date("2026-09-06T00:05:00.000Z"),
runDaily: async (userId) => daily(userId),
notify: async () => undefined,
prune: async () => undefined,
});
// No JWT for the new day, so there is nothing pending to defer.
assert.equal(result.attempted, 0);
assert.equal(result.deferred, 0);
});
it("runs a batch that carries no notifyTime", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-1",
jwts: [{ sequence: 1, day: DAY, jwt: "j", nbf: 1, exp: 2 }],
});
const ran: string[] = [];
const result = await runSmsAlertSearchSchedulerPass({
concurrency: 1,
now: NOW,
runDaily: async (userId) => {
ran.push(userId);
return daily(userId);
},
notify: async () => undefined,
prune: async () => undefined,
});
assert.deepEqual(ran, [USER]);
assert.equal(result.deferred, 0);
});
it("fails the whole pass when the pending query itself fails", async () => {
await assert.rejects(
() =>
runSmsAlertSearchSchedulerPass({
concurrency: 1,
listPending: async () => {
throw new Error("database locked");
},
prune: async () => undefined,
}),
/database locked/
);
// The guard must not stay stuck after a thrown pass.
assert.equal(isSmsAlertSearchSchedulerPassInFlight(), false);
});
it("works on several users at once when concurrency allows", async () => {
const userIds = Array.from({ length: 12 }, (_, i) => `did:ethr:0x${i}`);
let inFlight = 0;
let peak = 0;
const result = await runSmsAlertSearchSchedulerPass({
concurrency: 4,
listPending: async () => due(userIds),
runDaily: async (userId) => {
inFlight += 1;
peak = Math.max(peak, inFlight);
await new Promise((resolve) => setTimeout(resolve, 5));
inFlight -= 1;
return daily(userId);
},
notify: async () => undefined,
prune: async () => undefined,
});
assert.equal(result.attempted, 12);
assert.equal(peak, 4, `expected 4 in flight, saw ${peak}`);
});
it("prunes action-JWT rows past the retention window", async () => {
process.env.SMS_ACTION_JWT_MAX_AGE_SEC = "60";
const retentionMs =
60 * SMS_ACTION_JWT_RETENTION_MULTIPLE * 1000;
await smsActionJwtUseDb.claim({
jwtHash: "fresh",
userId: USER,
action: "verify-phone",
});
// An old row, written straight to the table with a past used_at.
const { getDatabase } = await import("../../src/db/sqlite.js");
getDatabase()
.prepare(
`INSERT INTO sms_action_jwt_use (id, jwt_hash, user_id, action, used_at)
VALUES ('old', 'stale', ?, 'verify-phone', ?)`
)
.run(USER, new Date(Date.now() - retentionMs - 60_000).toISOString());
assert.equal(await smsActionJwtUseDb.count(), 2);
await runSmsAlertSearchSchedulerPass({
concurrency: 1,
listPending: async () => due([]),
notify: async () => undefined,
});
assert.equal(await smsActionJwtUseDb.count(), 1);
delete process.env.SMS_ACTION_JWT_MAX_AGE_SEC;
});
it("survives a prune failure", async () => {
const result = await runSmsAlertSearchSchedulerPass({
concurrency: 1,
listPending: async () => due([]),
prune: async () => {
throw new Error("locked");
},
});
assert.equal(result.skipped, false);
});
});
describe("startSmsAlertSearchScheduler", () => {
it("starts once and stops cleanly", () => {
assert.equal(startSmsAlertSearchScheduler(), true);
assert.equal(startSmsAlertSearchScheduler(), false);
stopSmsAlertSearchScheduler();
assert.equal(startSmsAlertSearchScheduler(), true);
});
it("does not run a pass at start time", async () => {
startSmsAlertSearchScheduler();
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(isSmsAlertSearchSchedulerPassInFlight(), false);
});
});
+184
View File
@@ -0,0 +1,184 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, it } from "node:test";
import {
smsBlockedNumbersDb,
strongerBlockReason,
} from "../../src/db/smsBlockedNumbersSqlite.js";
import { closeDatabase } from "../../src/db/sqlite.js";
import { isPhoneNumberBlocked } from "../../src/services/smsService.js";
import { hashPhoneNumber } from "../../src/util/smsVerificationCode.js";
const SECRET = "block-test-secret";
const PHONE = "+15555550123";
const OTHER = "+15555550124";
const HASH = hashPhoneNumber(PHONE, SECRET);
let dir: string;
let savedDataDir: string | undefined;
let savedBlocked: string | undefined;
beforeEach(async () => {
savedDataDir = process.env.NOTIFY_DATA_DIR;
savedBlocked = process.env.SMS_BLOCKED_NUMBERS;
delete process.env.SMS_BLOCKED_NUMBERS;
dir = await mkdtemp(path.join(tmpdir(), "sms-block-"));
process.env.NOTIFY_DATA_DIR = dir;
closeDatabase();
});
afterEach(async () => {
closeDatabase();
if (savedDataDir === undefined) delete process.env.NOTIFY_DATA_DIR;
else process.env.NOTIFY_DATA_DIR = savedDataDir;
if (savedBlocked === undefined) delete process.env.SMS_BLOCKED_NUMBERS;
else process.env.SMS_BLOCKED_NUMBERS = savedBlocked;
await rm(dir, { recursive: true, force: true });
});
describe("smsBlockedNumbersDb", () => {
it("blocks, reports, and unblocks by hash", async () => {
assert.equal(await smsBlockedNumbersDb.isBlocked(HASH), false);
const stored = await smsBlockedNumbersDb.block({
phoneHash: HASH,
phoneE164: PHONE,
reason: "opt-out",
detail: "keyword STOP",
});
assert.equal(stored.phoneE164, PHONE);
assert.equal(stored.reason, "opt-out");
assert.equal(await smsBlockedNumbersDb.isBlocked(HASH), true);
assert.equal(await smsBlockedNumbersDb.unblock(HASH), true);
assert.equal(await smsBlockedNumbersDb.isBlocked(HASH), false);
assert.equal(await smsBlockedNumbersDb.unblock(HASH), false);
});
it("is idempotent, so a second STOP refreshes rather than fails", async () => {
await smsBlockedNumbersDb.block({
phoneHash: HASH,
phoneE164: PHONE,
reason: "manual",
detail: "first",
});
await smsBlockedNumbersDb.block({
phoneHash: HASH,
reason: "manual",
detail: "second",
});
const rows = await smsBlockedNumbersDb.list();
assert.equal(rows.length, 1);
assert.equal(rows[0].detail, "second");
// The number is preserved when a later block omits it.
assert.equal(rows[0].phoneE164, PHONE);
});
it("never downgrades an opt-out to a manual block", async () => {
await smsBlockedNumbersDb.block({
phoneHash: HASH,
phoneE164: PHONE,
reason: "opt-out",
detail: "keyword STOP",
});
// An operator re-blocking must not erase the record saying the handset
// asked, because that record is what forbids simply lifting the block.
await smsBlockedNumbersDb.block({
phoneHash: HASH,
reason: "manual",
detail: "operator",
});
assert.equal((await smsBlockedNumbersDb.get(HASH))?.reason, "opt-out");
});
it("upgrades a manual block when the handset actually opts out", async () => {
await smsBlockedNumbersDb.block({ phoneHash: HASH, reason: "manual" });
await smsBlockedNumbersDb.block({
phoneHash: HASH,
reason: "provider-opt-out",
});
assert.equal(
(await smsBlockedNumbersDb.get(HASH))?.reason,
"provider-opt-out"
);
await smsBlockedNumbersDb.block({ phoneHash: HASH, reason: "opt-out" });
assert.equal((await smsBlockedNumbersDb.get(HASH))?.reason, "opt-out");
// ...and does not fall back down again.
await smsBlockedNumbersDb.block({
phoneHash: HASH,
reason: "provider-opt-out",
});
assert.equal((await smsBlockedNumbersDb.get(HASH))?.reason, "opt-out");
});
it("blocks only the hashed number, not its neighbours", async () => {
await smsBlockedNumbersDb.block({ phoneHash: HASH, reason: "manual" });
assert.equal(
await smsBlockedNumbersDb.isBlocked(hashPhoneNumber(OTHER, SECRET)),
false
);
});
});
describe("isPhoneNumberBlocked", () => {
it("is false for a number on neither source", async () => {
assert.equal(
await isPhoneNumberBlocked({ phoneE164: PHONE, phoneHash: HASH }),
false
);
});
it("consults the table", async () => {
await smsBlockedNumbersDb.block({ phoneHash: HASH, reason: "opt-out" });
assert.equal(
await isPhoneNumberBlocked({ phoneE164: PHONE, phoneHash: HASH }),
true
);
});
it("consults SMS_BLOCKED_NUMBERS without needing a database row", async () => {
process.env.SMS_BLOCKED_NUMBERS = PHONE;
assert.equal(
await isPhoneNumberBlocked({ phoneE164: PHONE, phoneHash: HASH }),
true
);
assert.equal(
await isPhoneNumberBlocked({
phoneE164: OTHER,
phoneHash: hashPhoneNumber(OTHER, SECRET),
}),
false
);
});
it("normalizes configured entries, so formatting does not defeat it", async () => {
process.env.SMS_BLOCKED_NUMBERS = " (555) 555-0123 , 5555550124 ";
assert.equal(
await isPhoneNumberBlocked({ phoneE164: PHONE, phoneHash: HASH }),
true
);
assert.equal(
await isPhoneNumberBlocked({
phoneE164: OTHER,
phoneHash: hashPhoneNumber(OTHER, SECRET),
}),
true
);
});
});
describe("strongerBlockReason", () => {
it("ranks an opt-out above a provider opt-out above a manual block", () => {
assert.equal(strongerBlockReason(undefined, "manual"), "manual");
assert.equal(strongerBlockReason("manual", "provider-opt-out"), "provider-opt-out");
assert.equal(strongerBlockReason("provider-opt-out", "opt-out"), "opt-out");
assert.equal(strongerBlockReason("opt-out", "manual"), "opt-out");
assert.equal(strongerBlockReason("opt-out", "provider-opt-out"), "opt-out");
assert.equal(strongerBlockReason("provider-opt-out", "manual"), "provider-opt-out");
});
});
+631
View File
@@ -0,0 +1,631 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, it } from "node:test";
import { smsAlertAuthorizationDb } from "../../src/db/smsAlertAuthorizationSqlite.js";
import { alertAuthorizationDb } from "../../src/db/alertAuthorizationSqlite.js";
import { smsPhoneLogDb } from "../../src/db/smsPhoneLogSqlite.js";
import { smsRegistrationsDb } from "../../src/db/smsRegistrationsSqlite.js";
import { closeDatabase, getDatabase } from "../../src/db/sqlite.js";
const USER = "did:ethr:0xsmsuser";
const OTHER = "did:ethr:0xothersmsuser";
const PHONE = "+15555550123";
const HASH = "phone-hash-abc";
let dir: string;
let previousDataDir: string | undefined;
beforeEach(async () => {
previousDataDir = process.env.NOTIFY_DATA_DIR;
dir = await mkdtemp(path.join(tmpdir(), "sms-db-"));
process.env.NOTIFY_DATA_DIR = dir;
closeDatabase();
});
afterEach(async () => {
closeDatabase();
if (previousDataDir === undefined) {
delete process.env.NOTIFY_DATA_DIR;
} else {
process.env.NOTIFY_DATA_DIR = previousDataDir;
}
await rm(dir, { recursive: true, force: true });
});
describe("smsRegistrationsDb", () => {
it("round-trips a pending registration", async () => {
const expires = new Date(Date.now() + 600_000).toISOString();
await smsRegistrationsDb.upsertPendingCode({
userId: USER,
phoneE164: PHONE,
codeHash: "hash-1",
codeExpiresAt: expires,
sentAt: new Date().toISOString(),
});
const row = await smsRegistrationsDb.get(USER, PHONE);
assert.equal(row?.userId, USER);
assert.equal(row?.phoneE164, PHONE);
assert.equal(row?.verified, false);
assert.equal(row?.codeHash, "hash-1");
assert.equal(row?.codeExpiresAt, expires);
assert.equal(row?.codeAttempts, 0);
});
it("keeps one row per (user, phone) and resets attempts on a resend", async () => {
const now = new Date().toISOString();
await smsRegistrationsDb.upsertPendingCode({
userId: USER,
phoneE164: PHONE,
codeHash: "hash-1",
codeExpiresAt: now,
sentAt: now,
});
await smsRegistrationsDb.incrementCodeAttempts(USER, PHONE);
await smsRegistrationsDb.upsertPendingCode({
userId: USER,
phoneE164: PHONE,
codeHash: "hash-2",
codeExpiresAt: now,
sentAt: now,
});
const rows = await smsRegistrationsDb.listByUserId(USER);
assert.equal(rows.length, 1);
assert.equal(rows[0].codeHash, "hash-2");
assert.equal(rows[0].codeAttempts, 0);
});
it("rejects a duplicate (user, phone) insert at the unique index", () => {
const connection = getDatabase();
const insert = connection.prepare(
`
INSERT INTO sms_registrations (
id, user_id, phone_e164, verified, code_attempts, created_at, updated_at
) VALUES (?, ?, ?, 0, 0, ?, ?)
`
);
const now = new Date().toISOString();
insert.run("row-1", USER, PHONE, now, now);
assert.throws(
() => insert.run("row-2", USER, PHONE, now, now),
/UNIQUE/
);
});
it("scopes verification, deletion, and counts by DID", async () => {
const now = new Date().toISOString();
for (const user of [USER, OTHER]) {
await smsRegistrationsDb.upsertPendingCode({
userId: user,
phoneE164: PHONE,
codeHash: "hash",
codeExpiresAt: now,
sentAt: now,
});
}
await smsRegistrationsDb.markVerified(USER, PHONE);
assert.equal((await smsRegistrationsDb.get(USER, PHONE))?.verified, true);
assert.equal((await smsRegistrationsDb.get(OTHER, PHONE))?.verified, false);
assert.equal(await smsRegistrationsDb.countVerifiedForPhone(PHONE), 1);
assert.deepEqual(
await smsRegistrationsDb.listVerifiedDidsForPhone(PHONE),
[USER]
);
assert.equal(await smsRegistrationsDb.delete(USER, PHONE), true);
assert.equal(await smsRegistrationsDb.get(OTHER, PHONE) !== undefined, true);
assert.equal(await smsRegistrationsDb.delete(USER, PHONE), false);
});
it("clears the code and verified flag for every DID on an opt-out", async () => {
const now = new Date().toISOString();
for (const user of [USER, OTHER]) {
await smsRegistrationsDb.upsertPendingCode({
userId: user,
phoneE164: PHONE,
codeHash: "hash",
codeExpiresAt: now,
sentAt: now,
});
await smsRegistrationsDb.markVerified(user, PHONE);
}
assert.equal(await smsRegistrationsDb.unverifyAllForPhone(PHONE), 2);
assert.equal(await smsRegistrationsDb.countVerifiedForPhone(PHONE), 0);
});
it("excludes the named DID from the verified count", async () => {
const now = new Date().toISOString();
for (const user of [USER, OTHER]) {
await smsRegistrationsDb.upsertPendingCode({
userId: user,
phoneE164: PHONE,
codeHash: "hash",
codeExpiresAt: now,
sentAt: now,
});
await smsRegistrationsDb.markVerified(user, PHONE);
}
assert.equal(
await smsRegistrationsDb.countVerifiedForPhone(PHONE, USER),
1
);
});
});
describe("smsPhoneLogDb", () => {
it("records an action and reads it back", async () => {
await smsPhoneLogDb.append({
userId: USER,
phoneE164: PHONE,
phoneHash: HASH,
action: "code-sent",
result: "ok",
providerMessageId: "SM123",
});
const rows = await smsPhoneLogDb.listByUserId(USER);
assert.equal(rows.length, 1);
assert.equal(rows[0].action, "code-sent");
assert.equal(rows[0].providerMessageId, "SM123");
assert.equal(rows[0].phoneE164, PHONE);
});
it("counts sends per phone across DIDs", async () => {
for (const user of [USER, OTHER]) {
await smsPhoneLogDb.append({
userId: user,
phoneE164: PHONE,
phoneHash: HASH,
action: "code-sent",
result: "ok",
});
}
const since = new Date(Date.now() - 3_600_000).toISOString();
assert.equal(
await smsPhoneLogDb.countByPhoneHashSince(HASH, ["code-sent"], since),
2
);
assert.equal(
await smsPhoneLogDb.countByUserSince(USER, ["code-sent"], since),
1
);
});
it("ignores rows older than the window", async () => {
await smsPhoneLogDb.append({
userId: USER,
phoneHash: HASH,
action: "code-sent",
result: "ok",
});
const since = new Date(Date.now() + 60_000).toISOString();
assert.equal(
await smsPhoneLogDb.countByPhoneHashSince(HASH, ["code-sent"], since),
0
);
});
it("scrubs the number for one DID and keeps the hash and history", async () => {
for (const user of [USER, OTHER]) {
await smsPhoneLogDb.append({
userId: user,
phoneE164: PHONE,
phoneHash: HASH,
action: "code-sent",
result: "ok",
});
}
assert.equal(await smsPhoneLogDb.scrubPhoneNumber(USER, PHONE), 1);
const mine = await smsPhoneLogDb.listByUserId(USER);
assert.equal(mine.length, 1);
assert.equal(mine[0].phoneE164, undefined);
assert.equal(mine[0].phoneHash, HASH);
assert.equal(mine[0].action, "code-sent");
const theirs = await smsPhoneLogDb.listByUserId(OTHER);
assert.equal(theirs[0].phoneE164, PHONE);
});
});
describe("smsAlertAuthorizationDb", () => {
function batchJwts(day: string) {
return [{ sequence: 1, day, jwt: "jwt-" + day, nbf: 1, exp: 2 }];
}
it("stores into the SMS tables without touching the FCM ones", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-1",
jwts: batchJwts("2026-09-05"),
});
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 1);
assert.equal(await alertAuthorizationDb.countUnused(USER), 0);
assert.deepEqual(await alertAuthorizationDb.listDistinctUserIds(), []);
assert.deepEqual(await smsAlertAuthorizationDb.listDistinctUserIds(), [
USER,
]);
});
it("replaces unused SMS JWTs and leaves consumed ones", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-1",
jwts: [
...batchJwts("2026-09-05"),
{ sequence: 2, day: "2026-09-06", jwt: "jwt-b", nbf: 1, exp: 2 },
],
});
const first = await smsAlertAuthorizationDb.getUnusedForDay(
USER,
"2026-09-05"
);
assert.ok(first);
assert.equal(
await smsAlertAuthorizationDb.consumeUnusedJwt({
id: first.id,
userId: USER,
}),
true
);
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-2",
jwts: batchJwts("2026-09-07"),
});
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 1);
const consumed = await smsAlertAuthorizationDb.getJwtById(first.id);
assert.equal(consumed?.status, "consumed");
assert.equal(
await smsAlertAuthorizationDb.getUnusedForDay(USER, "2026-09-06"),
undefined
);
});
it("round-trips a notifyTime and reports none when absent", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-1",
notifyHourUtc: 18,
notifyMinuteUtc: 30,
jwts: batchJwts("2026-09-05"),
});
const _b = (await smsAlertAuthorizationDb.getLatestBatch(USER));
assert.equal(_b?.notifyHourUtc, 18);
assert.equal(_b?.notifyMinuteUtc, 30);
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-2",
jwts: batchJwts("2026-09-06"),
});
const _u = (await smsAlertAuthorizationDb.getLatestBatch(USER));
assert.equal(_u?.notifyHourUtc, undefined);
assert.equal(_u?.notifyMinuteUtc, undefined);
});
it("round-trips a recorded timezone and reports none when absent", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-1",
notifyHourUtc: 0,
notifyMinuteUtc: 30,
timezone: "America/Denver",
jwts: batchJwts("2026-09-05"),
});
const withZone = await smsAlertAuthorizationDb.getLatestBatch(USER);
assert.equal(withZone?.timezone, "America/Denver");
assert.equal(withZone?.notifyHourUtc, 0);
assert.equal(withZone?.notifyMinuteUtc, 30);
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-2",
jwts: batchJwts("2026-09-06"),
});
const without = await smsAlertAuthorizationDb.getLatestBatch(USER);
assert.equal(without?.timezone, undefined);
assert.equal(without?.notifyHourUtc, undefined);
assert.equal(without?.notifyMinuteUtc, undefined);
});
it("lists only users with an unused JWT for the day asked about", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-1",
jwts: batchJwts("2026-09-05"),
});
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: OTHER,
batchId: "other-batch",
jwts: batchJwts("2026-09-06"),
});
assert.deepEqual(
await smsAlertAuthorizationDb.listPendingForDay({
day: "2026-09-05",
hourMinute: "12:00",
}),
[{ userId: USER, due: true }]
);
assert.deepEqual(
await smsAlertAuthorizationDb.listPendingForDay({
day: "2026-09-07",
hourMinute: "12:00",
}),
[]
);
});
it("drops a user from the list once that day's JWT is consumed", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-1",
jwts: batchJwts("2026-09-05"),
});
const jwt = await smsAlertAuthorizationDb.getUnusedForDay(
USER,
"2026-09-05"
);
assert.ok(jwt);
await smsAlertAuthorizationDb.consumeUnusedJwt({ id: jwt.id, userId: USER });
assert.deepEqual(
await smsAlertAuthorizationDb.listPendingForDay({
day: "2026-09-05",
hourMinute: "23:59",
}),
[]
);
});
it("flags a user not due until their stored hour, by text comparison", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-1",
notifyHourUtc: 9,
notifyMinuteUtc: 30,
jwts: batchJwts("2026-09-05"),
});
const at = async (hourMinute: string) =>
(
await smsAlertAuthorizationDb.listPendingForDay({
day: "2026-09-05",
hourMinute,
})
)[0]?.due;
assert.equal(await at("00:00"), false);
assert.equal(await at("09:29"), false);
assert.equal(await at("09:30"), true);
// Zero-padded HH:MM sorts chronologically, so 10:00 must beat 09:30.
assert.equal(await at("10:00"), true);
assert.equal(await at("23:59"), true);
});
it("reads the hour from the newest batch, not an older surviving one", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "old",
notifyHourUtc: 23,
notifyMinuteUtc: 0,
jwts: batchJwts("2026-09-05"),
});
// Consume it so the old batch row survives the next replace.
const jwt = await smsAlertAuthorizationDb.getUnusedForDay(
USER,
"2026-09-05"
);
assert.ok(jwt);
await smsAlertAuthorizationDb.consumeUnusedJwt({ id: jwt.id, userId: USER });
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "new",
notifyHourUtc: 6,
notifyMinuteUtc: 0,
jwts: batchJwts("2026-09-06"),
});
const pending = await smsAlertAuthorizationDb.listPendingForDay({
day: "2026-09-06",
hourMinute: "07:00",
});
// 07:00 is past the new batch's 06:00 but short of the old batch's 23:00.
assert.deepEqual(pending, [{ userId: USER, due: true }]);
});
it("breaks a created_at tie by insertion order, not by random id", async () => {
const connection = getDatabase();
const sameInstant = "2026-09-05T00:00:00.000Z";
const insert = connection.prepare(
`INSERT INTO sms_alert_authorization_batches
(id, user_id, batch_id, notify_hour_min_utc, timezone, created_at)
VALUES (?, ?, ?, ?, NULL, ?)`
);
// Ids chosen so lexical order disagrees with insertion order both ways.
insert.run("zzz-first", USER, "older", "23:00", sameInstant);
insert.run("aaa-second", USER, "newer", "06:00", sameInstant);
connection
.prepare(
`INSERT INTO sms_alert_authorization_jwts
(id, batch_pk, user_id, batch_id, sequence, day, jwt, nbf, exp,
status, consumed_at, created_at)
VALUES ('j1', 'aaa-second', ?, 'newer', 1, '2026-09-05', 'j', 1, 2,
'unused', NULL, ?)`
)
.run(USER, sameInstant);
assert.equal(
(await smsAlertAuthorizationDb.getLatestBatch(USER))?.batchId,
"newer"
);
// 07:00 is past the newer batch's 06:00 but short of the older one's 23:00.
assert.deepEqual(
await smsAlertAuthorizationDb.listPendingForDay({
day: "2026-09-05",
hourMinute: "07:00",
}),
[{ userId: USER, due: true }]
);
});
it("treats a batch with no stored hour as always due", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-1",
jwts: batchJwts("2026-09-05"),
});
assert.deepEqual(
await smsAlertAuthorizationDb.listPendingForDay({
day: "2026-09-05",
hourMinute: "00:00",
}),
[{ userId: USER, due: true }]
);
});
it("stays due when the stored hour is unreadable, rather than never", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-1",
jwts: batchJwts("2026-09-05"),
});
// Only a hand-edited database reaches this state, but text ordering would
// rank "midnight" above every HH:MM and defer the user permanently.
getDatabase()
.prepare(
`UPDATE sms_alert_authorization_batches SET notify_hour_min_utc = ?
WHERE user_id = ?`
)
.run("midnight", USER);
assert.deepEqual(
await smsAlertAuthorizationDb.listPendingForDay({
day: "2026-09-05",
hourMinute: "00:00",
}),
[{ userId: USER, due: true }]
);
});
it("reads the SMS tables only, never the FCM ones", async () => {
await alertAuthorizationDb.replaceUnusedBatch({
userId: OTHER,
batchId: "fcm-batch",
jwts: batchJwts("2026-09-05"),
});
assert.deepEqual(
await smsAlertAuthorizationDb.listPendingForDay({
day: "2026-09-05",
hourMinute: "12:00",
}),
[]
);
assert.deepEqual(
await alertAuthorizationDb.listPendingForDay({
day: "2026-09-05",
hourMinute: "12:00",
}),
[{ userId: OTHER, due: true }]
);
});
it("deletes every batch and JWT for one user, consumed included", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-1",
jwts: [
...batchJwts("2026-09-05"),
{ sequence: 2, day: "2026-09-06", jwt: "jwt-b", nbf: 1, exp: 2 },
],
});
const first = await smsAlertAuthorizationDb.getUnusedForDay(
USER,
"2026-09-05"
);
assert.ok(first);
await smsAlertAuthorizationDb.consumeUnusedJwt({
id: first.id,
userId: USER,
});
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: OTHER,
batchId: "sms-batch-other",
jwts: batchJwts("2026-09-05"),
});
const removed = await smsAlertAuthorizationDb.deleteAllForUser(USER);
assert.equal(removed.deletedJwts, 2);
assert.equal(removed.deletedBatches, 1);
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 0);
assert.equal(await smsAlertAuthorizationDb.getJwtById(first.id), undefined);
assert.equal(
await smsAlertAuthorizationDb.getLatestBatch(USER),
undefined
);
assert.deepEqual(await smsAlertAuthorizationDb.listDistinctUserIds(), [
OTHER,
]);
});
it("deletes nothing, and does not fail, for an unknown user", async () => {
assert.deepEqual(
await smsAlertAuthorizationDb.deleteAllForUser("did:ethr:0xnobody"),
{ deletedBatches: 0, deletedJwts: 0 }
);
});
it("leaves the FCM inventory alone when the SMS one is deleted", async () => {
await alertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "fcm-batch-1",
jwts: batchJwts("2026-09-05"),
});
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-1",
jwts: batchJwts("2026-09-05"),
});
await smsAlertAuthorizationDb.deleteAllForUser(USER);
assert.equal(await alertAuthorizationDb.countUnused(USER), 1);
assert.deepEqual(await alertAuthorizationDb.listDistinctUserIds(), [USER]);
});
it("allows only one unused SMS JWT per (user, day)", () => {
const connection = getDatabase();
const now = new Date().toISOString();
connection
.prepare(
`
INSERT INTO sms_alert_authorization_jwts (
id, batch_pk, user_id, batch_id, sequence, day, jwt,
nbf, exp, status, consumed_at, created_at
) VALUES (?, 'pk', ?, 'b', ?, '2026-09-05', 'jwt', 1, 2, 'unused', NULL, ?)
`
)
.run("j1", USER, 1, now);
assert.throws(
() =>
connection
.prepare(
`
INSERT INTO sms_alert_authorization_jwts (
id, batch_pk, user_id, batch_id, sequence, day, jwt,
nbf, exp, status, consumed_at, created_at
) VALUES (?, 'pk', ?, 'b', ?, '2026-09-05', 'jwt', 1, 2, 'unused', NULL, ?)
`
)
.run("j2", USER, 2, now),
/UNIQUE/
);
});
});
+60
View File
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import { afterEach, beforeEach, describe, it } from "node:test";
import { smsConfig } from "../src/env.js";
const KEYS = ["SMS_ENABLED", "SMS_REQUIRE_ACTION_CLAIM"] as const;
const saved: Record<string, string | undefined> = {};
beforeEach(() => {
for (const key of KEYS) {
saved[key] = process.env[key];
delete process.env[key];
}
});
afterEach(() => {
for (const key of KEYS) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
});
describe("smsConfig boolean flags", () => {
it("uses the defaults when unset or empty", () => {
assert.equal(smsConfig().enabled, false);
assert.equal(smsConfig().requireActionClaim, true);
process.env.SMS_ENABLED = "";
process.env.SMS_REQUIRE_ACTION_CLAIM = "";
assert.equal(smsConfig().enabled, false);
assert.equal(smsConfig().requireActionClaim, true);
});
it("accepts every truthy spelling", () => {
for (const value of ["true", "TRUE", "1", "yes", "On"]) {
process.env.SMS_ENABLED = value;
assert.equal(smsConfig().enabled, true, value);
}
});
it("accepts every falsy spelling", () => {
for (const value of ["false", "FALSE", "0", "no", "Off"]) {
process.env.SMS_REQUIRE_ACTION_CLAIM = value;
assert.equal(smsConfig().requireActionClaim, false, value);
}
});
it("ignores surrounding whitespace, as docker --env-file leaves it", () => {
process.env.SMS_ENABLED = "true ";
assert.equal(smsConfig().enabled, true);
process.env.SMS_REQUIRE_ACTION_CLAIM = " false\t";
assert.equal(smsConfig().requireActionClaim, false);
});
it("falls back to the default on an unrecognized value", () => {
process.env.SMS_ENABLED = '"true"';
assert.equal(smsConfig().enabled, false);
process.env.SMS_REQUIRE_ACTION_CLAIM = "nope";
assert.equal(smsConfig().requireActionClaim, true);
});
});
+225
View File
@@ -0,0 +1,225 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, it } from "node:test";
import express from "express";
import { closeDatabase } from "../../src/db/sqlite.js";
import { requireSmsActionJwt, sha256Hex } from "../../src/middleware/smsActionJwt.js";
const USER = "did:ethr:0xclaimuser";
const PHONE = "+15555550123";
const ENV_KEYS = [
"SMS_REQUIRE_ACTION_CLAIM",
"SMS_ACTION_JWT_MAX_AGE_SEC",
"NOTIFY_DATA_DIR",
] as const;
let dir: string;
let savedEnv: Record<string, string | undefined>;
let server: { url: string; close: () => Promise<void> };
let handlerRuns: number;
/** Set to false to mimic a route mounted without requireAuth. */
let authenticate: boolean;
beforeEach(async () => {
savedEnv = {};
for (const key of ENV_KEYS) savedEnv[key] = process.env[key];
dir = await mkdtemp(path.join(tmpdir(), "sms-action-jwt-"));
process.env.NOTIFY_DATA_DIR = dir;
closeDatabase();
handlerRuns = 0;
authenticate = true;
const app = express();
app.use(express.json());
app.post(
"/verify",
(req, _res, next) => {
if (!authenticate) {
next();
return;
}
const parsed = JSON.parse(req.get("X-Test-Auth") as string) as {
jwt: string;
payload: Record<string, unknown>;
};
req.did = USER;
req.jwt = parsed.jwt;
req.auth = { did: USER, jwt: parsed.jwt, payload: parsed.payload };
next();
},
requireSmsActionJwt("verify-phone"),
(req, res) => {
handlerRuns += 1;
res.status(200).json({ ok: true, jwtHash: req.smsActionJwtHash });
}
);
const listening = app.listen(0);
await new Promise((resolve) => listening.once("listening", resolve));
const port = (listening.address() as AddressInfo).port;
server = {
url: `http://127.0.0.1:${port}/verify`,
close: () => new Promise<void>((r) => listening.close(() => r())),
};
});
afterEach(async () => {
closeDatabase();
await server.close();
for (const key of ENV_KEYS) {
if (savedEnv[key] === undefined) delete process.env[key];
else process.env[key] = savedEnv[key];
}
await rm(dir, { recursive: true, force: true });
});
let counter = 0;
async function post(input: {
payload?: Record<string, unknown>;
jwt?: string;
body?: unknown;
}): Promise<{ status: number; body: Record<string, unknown> }> {
counter += 1;
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (authenticate) {
headers["X-Test-Auth"] = JSON.stringify({
jwt: input.jwt ?? `token-${counter}`,
payload: input.payload ?? {},
});
}
const response = await fetch(server.url, {
method: "POST",
headers,
body: JSON.stringify(input.body ?? { phoneNumber: PHONE }),
});
return { status: response.status, body: await response.json() };
}
function payload(
overrides: Record<string, unknown> = {},
claim: Record<string, unknown> | null = {
action: "verify-phone",
phoneNumber: PHONE,
}
): Record<string, unknown> {
return {
iss: USER,
iat: Math.floor(Date.now() / 1000),
...(claim === null ? {} : { claim }),
...overrides,
};
}
describe("requireSmsActionJwt", () => {
it("passes a fresh, correctly bound token and records its hash", async () => {
const result = await post({ payload: payload(), jwt: "the-token" });
assert.equal(result.status, 200);
assert.equal(handlerRuns, 1);
assert.equal(result.body.jwtHash, sha256Hex("the-token"));
});
it("fails closed with 500 when the route is not authenticated", async () => {
authenticate = false;
const result = await post({});
assert.equal(result.status, 500);
assert.equal(result.body.error, "SMS_ACTION_JWT_NOT_AUTHENTICATED");
assert.equal(handlerRuns, 0);
});
it("rejects a token with no claim", async () => {
const result = await post({ payload: payload({}, null) });
assert.equal(result.status, 403);
assert.equal(result.body.error, "SMS_ACTION_JWT_MISSING_CLAIM");
assert.equal(handlerRuns, 0);
});
it("rejects a claim that is not an object", async () => {
const result = await post({ payload: { iss: USER, iat: 1, claim: "nope" } });
assert.equal(result.status, 403);
assert.equal(result.body.error, "SMS_ACTION_JWT_MISSING_CLAIM");
});
it("rejects a claim authorizing a different action", async () => {
const result = await post({
payload: payload({}, { action: "delete-phone", phoneNumber: PHONE }),
});
assert.equal(result.status, 403);
assert.equal(result.body.error, "SMS_ACTION_JWT_WRONG_ACTION");
});
it("rejects a claim naming a different phone", async () => {
const result = await post({
payload: payload({}, { action: "verify-phone", phoneNumber: "+15555559999" }),
});
assert.equal(result.status, 403);
assert.equal(result.body.error, "SMS_ACTION_JWT_PHONE_MISMATCH");
});
it("matches the claim's phone after normalization, not by string", async () => {
const result = await post({
payload: payload({}, { action: "verify-phone", phoneNumber: "(555) 555-0123" }),
body: { phoneNumber: "555.555.0123" },
});
assert.equal(result.status, 200);
});
it("rejects a claim carrying no phone at all", async () => {
const result = await post({
payload: payload({}, { action: "verify-phone" }),
});
assert.equal(result.status, 403);
assert.equal(result.body.error, "SMS_ACTION_JWT_PHONE_MISMATCH");
});
it("rejects a token issued too long ago", async () => {
process.env.SMS_ACTION_JWT_MAX_AGE_SEC = "60";
const result = await post({
payload: payload({ iat: Math.floor(Date.now() / 1000) - 120 }),
});
assert.equal(result.status, 401);
assert.equal(result.body.error, "SMS_ACTION_JWT_STALE");
});
it("rejects a token with no iat", async () => {
const result = await post({ payload: payload({ iat: undefined }) });
assert.equal(result.status, 401);
assert.equal(result.body.error, "SMS_ACTION_JWT_STALE");
});
it("rejects an expired token", async () => {
const result = await post({
payload: payload({ exp: Math.floor(Date.now() / 1000) - 1 }),
});
assert.equal(result.status, 401);
assert.equal(result.body.error, "SMS_ACTION_JWT_EXPIRED");
});
it("accepts a token whose exp is still ahead", async () => {
const result = await post({
payload: payload({ exp: Math.floor(Date.now() / 1000) + 300 }),
});
assert.equal(result.status, 200);
});
it("rejects the same token used a second time", async () => {
const first = await post({ payload: payload(), jwt: "one-shot" });
assert.equal(first.status, 200);
const second = await post({ payload: payload(), jwt: "one-shot" });
assert.equal(second.status, 401);
assert.equal(second.body.error, "SMS_ACTION_JWT_REPLAYED");
assert.equal(handlerRuns, 1);
});
it("skips every check when the claim is not required", async () => {
process.env.SMS_REQUIRE_ACTION_CLAIM = "false";
const result = await post({ payload: payload({}, null) });
assert.equal(result.status, 200);
assert.equal(result.body.jwtHash, undefined);
});
});
File diff suppressed because it is too large Load Diff
+116
View File
@@ -0,0 +1,116 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
formatHourMinuteUtc,
parseHourMinuteUtc,
storedNotifyLabel,
utcCalendarDay,
utcDayStartSeconds,
utcHourMinute,
} from "../../src/services/alertAuthorization.js";
describe("formatHourMinuteUtc", () => {
it("zero-pads both halves", () => {
assert.equal(formatHourMinuteUtc(0, 0), "00:00");
assert.equal(formatHourMinuteUtc(9, 5), "09:05");
assert.equal(formatHourMinuteUtc(18, 30), "18:30");
assert.equal(formatHourMinuteUtc(23, 59), "23:59");
});
it("pads so text ordering matches clock ordering", () => {
// The whole reason for the padding: "9:30" would sort above "10:00".
assert.ok(formatHourMinuteUtc(9, 30) < formatHourMinuteUtc(10, 0));
assert.ok(formatHourMinuteUtc(0, 0) < formatHourMinuteUtc(23, 59));
});
});
describe("parseHourMinuteUtc", () => {
it("round-trips whatever format produced", () => {
for (const [hour, minute] of [
[0, 0],
[9, 5],
[18, 30],
[23, 59],
]) {
assert.deepEqual(parseHourMinuteUtc(formatHourMinuteUtc(hour, minute)), {
hour,
minute,
});
}
});
it("refuses anything that is not a padded HH:MM", () => {
for (const bad of [
"24:00",
"07:60",
"7:00",
"07:00:00",
"0700",
"18:30-06:00",
"midnight",
"",
]) {
assert.equal(parseHourMinuteUtc(bad), undefined, bad);
}
});
});
describe("storedNotifyLabel", () => {
it("renders the pair for a log line", () => {
assert.equal(
storedNotifyLabel({ notifyHourUtc: 18, notifyMinuteUtc: 30 }),
"18:30"
);
assert.equal(
storedNotifyLabel({ notifyHourUtc: 0, notifyMinuteUtc: 5 }),
"00:05"
);
});
it("says none when either half is missing", () => {
assert.equal(storedNotifyLabel({}), "none");
assert.equal(storedNotifyLabel({ notifyHourUtc: 18 }), "none");
assert.equal(storedNotifyLabel({ notifyMinuteUtc: 30 }), "none");
});
});
describe("utcCalendarDay and utcHourMinute", () => {
const at = (iso: string) => Math.floor(Date.parse(iso) / 1000);
it("reads the UTC calendar day, not the server's", () => {
assert.equal(utcCalendarDay(at("2026-08-15T06:00:00Z")), "2026-08-15");
assert.equal(utcCalendarDay(at("2026-08-14T23:59:59Z")), "2026-08-14");
assert.equal(utcCalendarDay(at("2026-08-15T00:00:00Z")), "2026-08-15");
});
it("renders the UTC clock in the form the column stores", () => {
assert.equal(utcHourMinute(at("2026-08-15T00:00:00Z")), "00:00");
assert.equal(utcHourMinute(at("2026-08-15T18:30:59Z")), "18:30");
assert.equal(utcHourMinute(at("2026-08-15T23:59:00Z")), "23:59");
});
it("agrees with formatHourMinuteUtc, since both feed one comparison", () => {
const seconds = at("2026-08-15T09:05:00Z");
assert.equal(utcHourMinute(seconds), formatHourMinuteUtc(9, 5));
});
});
describe("utcDayStartSeconds", () => {
it("returns midnight UTC for a real date", () => {
assert.equal(
utcDayStartSeconds("2026-08-15"),
Math.floor(Date.parse("2026-08-15T00:00:00Z") / 1000)
);
});
it("refuses a date that does not exist rather than rolling it forward", () => {
for (const bad of ["2026-02-30", "2026-13-01", "2026-08-32", "2026-8-15", "x"]) {
assert.equal(utcDayStartSeconds(bad), undefined, bad);
}
});
it("keeps a real leap day", () => {
assert.ok(utcDayStartSeconds("2028-02-29") !== undefined);
assert.equal(utcDayStartSeconds("2027-02-29"), undefined);
});
});
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import { afterEach, beforeEach, describe, it } from "node:test";
import { smsConfig } from "../../src/env.js";
import { isSmsRecipientAllowed } from "../../src/services/smsService.js";
const KEY = "SMS_ALLOWED_RECIPIENT_DIDS";
const MINE = "did:ethr:0xabc";
const THEIRS = "did:ethr:0xdef";
let saved: string | undefined;
beforeEach(() => {
saved = process.env[KEY];
delete process.env[KEY];
});
afterEach(() => {
if (saved === undefined) delete process.env[KEY];
else process.env[KEY] = saved;
});
describe("isSmsRecipientAllowed", () => {
it("allows everyone when the variable is absent", () => {
assert.equal(smsConfig().allowedRecipientDids, undefined);
assert.equal(isSmsRecipientAllowed(MINE), true);
assert.equal(isSmsRecipientAllowed(THEIRS), true);
});
it("allows only the listed DIDs when it is set", () => {
process.env[KEY] = MINE;
assert.equal(isSmsRecipientAllowed(MINE), true);
assert.equal(isSmsRecipientAllowed(THEIRS), false);
});
it("accepts a comma-separated list with untidy spacing", () => {
process.env[KEY] = ` ${MINE} , ${THEIRS} ,`;
assert.deepEqual(smsConfig().allowedRecipientDids, [MINE, THEIRS]);
assert.equal(isSmsRecipientAllowed(MINE), true);
assert.equal(isSmsRecipientAllowed(THEIRS), true);
assert.equal(isSmsRecipientAllowed("did:ethr:0x999"), false);
});
it("compares case-insensitively, so a checksummed address still matches", () => {
process.env[KEY] = "did:ethr:0xAbCdEf";
assert.equal(isSmsRecipientAllowed("did:ethr:0xabcdef"), true);
assert.equal(isSmsRecipientAllowed("DID:ETHR:0XABCDEF"), true);
});
it("blocks everyone when it is set but empty, rather than allowing everyone", () => {
process.env[KEY] = "";
assert.deepEqual(smsConfig().allowedRecipientDids, []);
assert.equal(isSmsRecipientAllowed(MINE), false);
});
it("blocks everyone when it holds only separators", () => {
process.env[KEY] = " , , ";
assert.equal(isSmsRecipientAllowed(MINE), false);
});
});
+174
View File
@@ -0,0 +1,174 @@
import assert from "node:assert/strict";
import { afterEach, beforeEach, describe, it } from "node:test";
import {
SMS_NOT_CONFIGURED,
consoleSmsSender,
resetSmsNotConfiguredWarning,
sendSms,
sendViaTwilio,
} from "../../src/services/smsService.js";
const TWILIO_KEYS = [
"TWILIO_ACCOUNT_SID",
"TWILIO_AUTH_TOKEN",
"TWILIO_FROM_NUMBER",
"TWILIO_MESSAGING_SERVICE_SID",
"NODE_ENV",
] as const;
let saved: Record<string, string | undefined>;
beforeEach(() => {
saved = {};
for (const key of TWILIO_KEYS) {
saved[key] = process.env[key];
delete process.env[key];
}
resetSmsNotConfiguredWarning();
});
afterEach(() => {
for (const key of TWILIO_KEYS) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
});
type FetchArgs = { url: string; init: RequestInit };
function stubFetch(
response: { ok: boolean; status: number; body: unknown },
captured: FetchArgs[]
): typeof fetch {
return (async (url: string, init: RequestInit) => {
captured.push({ url, init });
return {
ok: response.ok,
status: response.status,
json: async () => response.body,
};
}) as unknown as typeof fetch;
}
describe("sendViaTwilio", () => {
const credentials = {
accountSid: "AC123",
authToken: "secret-token",
from: { From: "+15550000000" },
};
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("posts a form-encoded message and returns the sid", async () => {
const captured: FetchArgs[] = [];
globalThis.fetch = stubFetch(
{ ok: true, status: 201, body: { sid: "SM999" } },
captured
);
const result = await sendViaTwilio(credentials, "+15555550123", "hi");
assert.deepEqual(result, { status: "sent", messageId: "SM999" });
assert.equal(captured.length, 1);
assert.equal(
captured[0].url,
"https://api.twilio.com/2010-04-01/Accounts/AC123/Messages.json"
);
const headers = captured[0].init.headers as Record<string, string>;
assert.equal(
headers["Content-Type"],
"application/x-www-form-urlencoded"
);
assert.equal(
headers.Authorization,
"Basic " + Buffer.from("AC123:secret-token").toString("base64")
);
const form = new URLSearchParams(captured[0].init.body as string);
assert.equal(form.get("To"), "+15555550123");
assert.equal(form.get("Body"), "hi");
assert.equal(form.get("From"), "+15550000000");
});
it("sends MessagingServiceSid when that is how it is configured", async () => {
const captured: FetchArgs[] = [];
globalThis.fetch = stubFetch(
{ ok: true, status: 201, body: { sid: "SM1" } },
captured
);
await sendViaTwilio(
{
accountSid: "AC123",
authToken: "t",
from: { MessagingServiceSid: "MG9" },
},
"+15555550123",
"hi"
);
const form = new URLSearchParams(captured[0].init.body as string);
assert.equal(form.get("MessagingServiceSid"), "MG9");
assert.equal(form.get("From"), null);
});
it("reports the provider message on an error response", async () => {
globalThis.fetch = stubFetch(
{ ok: false, status: 400, body: { message: "Invalid 'To'" } },
[]
);
assert.deepEqual(await sendViaTwilio(credentials, "+1", "hi"), {
status: "failed",
error: "Invalid 'To'",
});
});
it("fails when a 2xx response carries no sid", async () => {
globalThis.fetch = stubFetch({ ok: true, status: 200, body: {} }, []);
const result = await sendViaTwilio(credentials, "+15555550123", "hi");
assert.equal(result.status, "failed");
});
it("turns a transport throw into a failed result", async () => {
globalThis.fetch = (async () => {
throw new Error("network down");
}) as unknown as typeof fetch;
assert.deepEqual(await sendViaTwilio(credentials, "+15555550123", "hi"), {
status: "failed",
error: "network down",
});
});
});
describe("sendSms configuration paths", () => {
it("fails with SMS_NOT_CONFIGURED when credentials are absent", async () => {
assert.deepEqual(await sendSms("+15555550123", "hi"), {
status: "failed",
error: SMS_NOT_CONFIGURED,
});
});
it("fails when an account is set but there is nothing to send from", async () => {
process.env.TWILIO_ACCOUNT_SID = "AC123";
process.env.TWILIO_AUTH_TOKEN = "t";
const result = await sendSms("+15555550123", "hi");
assert.equal(result.status, "failed");
assert.equal(
result.status === "failed" ? result.error : "",
SMS_NOT_CONFIGURED
);
});
it("uses the console adapter under test-local with no credentials", async () => {
process.env.NODE_ENV = "test-local";
const result = await sendSms("+15555550123", "hi");
assert.equal(result.status, "sent");
});
});
describe("consoleSmsSender", () => {
it("reports sent without touching the network", async () => {
const result = await consoleSmsSender("+15555550123", "hi");
assert.equal(result.status, "sent");
});
});
+101
View File
@@ -0,0 +1,101 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
ALERT_SEARCH_USER_CONCURRENCY,
forEachWithConcurrency,
} from "../../src/util/concurrency.js";
/** Resolves after `ms`, long enough for the pool to fill before any task ends. */
const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
describe("forEachWithConcurrency", () => {
it("visits every item exactly once", async () => {
const items = Array.from({ length: 50 }, (_, i) => i);
const seen: number[] = [];
await forEachWithConcurrency(items, 7, async (item) => {
seen.push(item);
});
assert.deepEqual([...seen].sort((a, b) => a - b), items);
});
it("never exceeds the limit", async () => {
let inFlight = 0;
let peak = 0;
await forEachWithConcurrency(
Array.from({ length: 30 }, (_, i) => i),
4,
async () => {
inFlight += 1;
peak = Math.max(peak, inFlight);
await pause(3);
inFlight -= 1;
}
);
assert.equal(peak, 4);
});
it("reaches the limit rather than trickling one at a time", async () => {
let peak = 0;
let inFlight = 0;
await forEachWithConcurrency([1, 2, 3, 4, 5, 6], 6, async () => {
inFlight += 1;
peak = Math.max(peak, inFlight);
await pause(3);
inFlight -= 1;
});
assert.equal(peak, 6);
});
it("runs serially at a limit of one, in order", async () => {
const order: number[] = [];
await forEachWithConcurrency([1, 2, 3], 1, async (item) => {
await pause(1);
order.push(item);
});
assert.deepEqual(order, [1, 2, 3]);
});
it("never starts more workers than there are items", async () => {
let peak = 0;
let inFlight = 0;
await forEachWithConcurrency([1, 2], 100, async () => {
inFlight += 1;
peak = Math.max(peak, inFlight);
await pause(3);
inFlight -= 1;
});
assert.equal(peak, 2);
});
it("handles an empty list and a nonsense limit", async () => {
let calls = 0;
await forEachWithConcurrency([], 8, async () => {
calls += 1;
});
assert.equal(calls, 0);
const seen: number[] = [];
for (const limit of [0, -5, 0.5]) {
seen.length = 0;
await forEachWithConcurrency([1, 2, 3], limit, async (item) => {
seen.push(item);
});
assert.deepEqual(seen, [1, 2, 3], `limit ${limit}`);
}
});
it("propagates a throw, since callers own their own errors", async () => {
await assert.rejects(
() =>
forEachWithConcurrency([1, 2, 3], 2, async (item) => {
if (item === 2) throw new Error("boom");
}),
/boom/
);
});
it("ships a conservative default", () => {
assert.ok(ALERT_SEARCH_USER_CONCURRENCY >= 1);
assert.ok(ALERT_SEARCH_USER_CONCURRENCY <= 32);
});
});
+61
View File
@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { maskPhoneNumber, normalizePhoneNumber } from "../../src/util/smsPhoneNumber.js";
describe("normalizePhoneNumber", () => {
const accepted: [unknown, string][] = [
["+15555550123", "+15555550123"],
[" +1 555 555 0123 ", "+15555550123"],
["(555) 555-0123", "+15555550123"],
["555.555.0123", "+15555550123"],
["5555550123", "+15555550123"],
["15555550123", "+15555550123"],
["0015555550123", "+15555550123"],
["+442071838750", "+442071838750"],
["+81312345678", "+81312345678"],
];
for (const [input, expected] of accepted) {
it(`normalizes ${JSON.stringify(input)}`, () => {
assert.equal(normalizePhoneNumber(input), expected);
});
}
const rejected: unknown[] = [
undefined,
null,
42,
"",
" ",
"not a phone",
"555-0123",
"+0155555501",
"+1555555012345678",
"+1555",
"25555550123",
"+1555555o123",
"+",
];
for (const input of rejected) {
it(`rejects ${JSON.stringify(input)}`, () => {
assert.equal(normalizePhoneNumber(input), undefined);
});
}
});
describe("maskPhoneNumber", () => {
it("keeps the country code and the last two digits", () => {
assert.equal(maskPhoneNumber("+15555550123"), "+1555*****23");
});
it("masks a shorter international number without leaking the middle", () => {
const masked = maskPhoneNumber("+442071838750");
assert.equal(masked, "+4420******50");
assert.equal(masked.length, "+442071838750".length);
});
it("never returns more than the last two digits of a short number", () => {
assert.equal(maskPhoneNumber("+12345"), "****45");
});
});
+70
View File
@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
VERIFICATION_CODE_LENGTH,
hashPhoneNumber,
hashVerificationCode,
mintVerificationCode,
verificationCodeMatches,
} from "../../src/util/smsVerificationCode.js";
const SECRET = "test-sms-code-secret";
describe("mintVerificationCode", () => {
it("always produces six digits, leading zeros included", () => {
for (let i = 0; i < 2000; i += 1) {
const code = mintVerificationCode();
assert.equal(code.length, VERIFICATION_CODE_LENGTH);
assert.match(code, /^\d{6}$/);
}
});
it("does not return the same code every call", () => {
const seen = new Set<string>();
for (let i = 0; i < 200; i += 1) seen.add(mintVerificationCode());
assert.ok(seen.size > 100, `expected varied codes, got ${seen.size}`);
});
});
describe("hashVerificationCode", () => {
it("is stable for one secret and different across secrets", () => {
assert.equal(
hashVerificationCode("483920", SECRET),
hashVerificationCode("483920", SECRET)
);
assert.notEqual(
hashVerificationCode("483920", SECRET),
hashVerificationCode("483920", "other-secret")
);
});
it("never contains the plaintext code", () => {
assert.equal(hashVerificationCode("483920", SECRET).includes("483920"), false);
});
});
describe("hashPhoneNumber", () => {
it("is stable per number and does not contain the number", () => {
const hash = hashPhoneNumber("+15555550123", SECRET);
assert.equal(hash, hashPhoneNumber("+15555550123", SECRET));
assert.notEqual(hash, hashPhoneNumber("+15555550124", SECRET));
assert.equal(hash.includes("5555550123"), false);
});
});
describe("verificationCodeMatches", () => {
it("accepts the matching code and rejects every near miss", () => {
const stored = hashVerificationCode("483920", SECRET);
assert.equal(verificationCodeMatches("483920", stored, SECRET), true);
assert.equal(verificationCodeMatches("483921", stored, SECRET), false);
assert.equal(verificationCodeMatches("48392", stored, SECRET), false);
assert.equal(verificationCodeMatches("", stored, SECRET), false);
assert.equal(verificationCodeMatches("483920", stored, "wrong"), false);
});
it("compares full hashes, so a shared prefix is not a partial match", () => {
const stored = hashVerificationCode("111111", SECRET);
const truncated = stored.slice(0, 10);
assert.equal(verificationCodeMatches("111111", truncated, SECRET), false);
});
});
+1 -1
View File
@@ -14,5 +14,5 @@
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "."
},
"include": [
"src/**/*.ts",
"test/**/*.ts",
"scripts/**/*.ts"
],
"exclude": [
"node_modules",
"dist"
]
}