Compare commits
27
Commits
master
...
endpoint-query
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c75060a7f1 | ||
|
|
77df676d6e | ||
|
|
226925278f | ||
|
|
93168ae360 | ||
|
|
885c6ced42 | ||
|
|
4c2e6491db | ||
|
|
657fd9c530 | ||
|
|
65eb1bee1e | ||
|
|
9e5b8bbd59 | ||
|
|
6cea0e9b67 | ||
|
|
2e34bed700 | ||
|
|
bfac283690 | ||
|
|
f967db7373 | ||
|
|
adf5e6166d | ||
|
|
776558b230 | ||
|
|
d0a3951689 | ||
|
|
c9df679ac2 | ||
|
|
cfc2cdefa3 | ||
|
|
10e80c6f41 | ||
|
|
49f50e36b7 | ||
|
|
209b346d16 | ||
|
|
d45ea26bca | ||
|
|
131d2fc9d9 | ||
|
|
eb5b286476 | ||
|
|
caeca10910 | ||
|
|
74e9c02669 | ||
|
|
00cd0cf8f4 |
+67
-6
@@ -1,16 +1,77 @@
|
|||||||
# HTTP port (default: 3003)
|
# HTTP port (default: 3003)
|
||||||
PORT=3003
|
# PORT=3003
|
||||||
|
|
||||||
# Endorser API base URL (default: https://api.endorser.ch)
|
# Endorser API base URL (default: https://api.endorser.ch)
|
||||||
# ENDORSER_URL=https://api.endorser.ch
|
# ENDORSER_URL=https://api.endorser.ch
|
||||||
|
# DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
|
||||||
|
|
||||||
# Firebase Admin: inline service account JSON (one line).
|
# Partner API base URL (default: https://partner-api.endorser.ch)
|
||||||
# If unset, uses Application Default Credentials (e.g. GOOGLE_APPLICATION_CREDENTIALS).
|
# 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",...}
|
# 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.
|
||||||
|
|
||||||
# Directory for the SQLite FCM registration database (default: ./data).
|
# Durable directory for SQLite: notify.sqlite plus -wal/-shm while running (default: ./data).
|
||||||
# Creates notify.sqlite (plus -wal/-shm while the process is running).
|
# In production this must be persistent storage (Docker: volume on /app/data).
|
||||||
# NOTIFY_DATA_DIR=./data
|
# NOTIFY_DATA_DIR=./data
|
||||||
|
|
||||||
# Set to "test-local" to bypass ethr JWT expiry verification in local dev only.
|
# Do not set NODE_ENV=test-local in production (bypasses ethr JWT expiry).
|
||||||
# NODE_ENV=test-local
|
# 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
|
||||||
|
|||||||
Binary file not shown.
@@ -6,6 +6,85 @@ 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).
|
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
|
## [0.1.4] - 2026.08.07
|
||||||
### Changed
|
### Changed
|
||||||
- Renamed `FCM_TOKEN_DATA_DIR` env var to `NOTIFY_DATA_DIR`
|
- Renamed `FCM_TOKEN_DATA_DIR` env var to `NOTIFY_DATA_DIR`
|
||||||
|
|||||||
@@ -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 1–3 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 1–2,
|
||||||
|
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.
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
A lightweight Express service that schedules and sends Firebase Cloud Messaging (FCM) push notifications to wake up registered devices.
|
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 (not JSON).
|
Device registrations are stored in a local **SQLite** database.
|
||||||
|
|
||||||
## Dev
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
@@ -11,7 +11,7 @@ cp .env.example .env
|
|||||||
Edit .env — set `FIREBASE_SERVICE_ACCOUNT_JSON`.
|
Edit .env — set `FIREBASE_SERVICE_ACCOUNT_JSON`.
|
||||||
Here is one way to generate the contents: `cat your-downloaded-key.json | jq -c .`
|
Here is one way to generate the contents: `cat your-downloaded-key.json | jq -c .`
|
||||||
|
|
||||||
Optionally set `ENDORSER_URL` if you are not using the default production Endorser API (`https://api.endorser.ch`).
|
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`.
|
Optionally set `NOTIFY_DATA_DIR` if you want the SQLite database somewhere other than `./data`.
|
||||||
|
|
||||||
@@ -20,18 +20,599 @@ pnpm install
|
|||||||
pnpm run dev
|
pnpm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
The server starts on `http://localhost:3003` (or the port in `PORT`). Hot-reloads on file changes.
|
```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.
|
On first use, the service creates `NOTIFY_DATA_DIR` (default `./data`) and the SQLite file `notify.sqlite` with the required schema.
|
||||||
|
|
||||||
|
## FCM
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
`POST /notifications/register` and `POST /notifications/refresh` require a Bearer JWT. After local JWT verification, the service checks the token with Endorser (`GET /api/report/rateLimits` on `ENDORSER_URL`). Registration and refresh continue only if Endorser accepts the JWT.
|
`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.
|
||||||
|
|
||||||
**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.
|
`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).
|
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
|
## Storage
|
||||||
|
|
||||||
### Database location
|
### Database location
|
||||||
@@ -44,6 +625,8 @@ Set `NODE_ENV=test-local` in `.env` to bypass ethr JWT *expiry* verification dur
|
|||||||
|
|
||||||
`NOTIFY_DATA_DIR` defaults to `./data` (relative to the process working directory). The `data/` directory is gitignored.
|
`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)
|
### Schema (high level)
|
||||||
|
|
||||||
Table `fcm_registrations` holds one row per registered device:
|
Table `fcm_registrations` holds one row per registered device:
|
||||||
@@ -54,11 +637,61 @@ Table `fcm_registrations` holds one row per registered device:
|
|||||||
|
|
||||||
Unique on `(user_id, device_id)`. Indexes also exist on `user_id`, `device_id`, `fcm_token`, and `(user_id, fcm_token)`.
|
Unique on `(user_id, device_id)`. Indexes also exist on `user_id`, `device_id`, `fcm_token`, and `(user_id, fcm_token)`.
|
||||||
|
|
||||||
The schema is created automatically on startup if the database or tables do not already exist.
|
Tables `alert_authorization_batches` and `alert_authorization_jwts` hold a user's delegated notification-JWT inventory (separate from device registration):
|
||||||
|
|
||||||
### JSON → SQLite
|
- 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
|
||||||
|
|
||||||
There is **no automatic migration** from the old JSON file (`fcm-tokens.json`). That format is no longer used. If you still have a local `fcm-tokens.json` from earlier development, it is ignored; re-register devices or import data manually if you need it.
|
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
|
### Backup
|
||||||
|
|
||||||
@@ -70,16 +703,66 @@ Persist or back up the SQLite files under `NOTIFY_DATA_DIR`:
|
|||||||
|
|
||||||
## Production
|
## Production
|
||||||
|
|
||||||
Runs TypeScript directly via `tsx` (no compile step).
|
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.
|
||||||
|
|
||||||
```bash
|
### Single replica
|
||||||
pnpm install --prod
|
|
||||||
pnpm start
|
|
||||||
```
|
|
||||||
|
|
||||||
Ensure `NOTIFY_DATA_DIR` points at a durable location (or accept the default `./data` next to the process cwd).
|
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.
|
||||||
|
|
||||||
Or with Docker:
|
### 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
|
```bash
|
||||||
docker build --no-cache -t notify-wakeup-api:amd-$NOTIFY_WAKEUP_API_VERSION --platform linux/amd64 .
|
docker build --no-cache -t notify-wakeup-api:amd-$NOTIFY_WAKEUP_API_VERSION --platform linux/amd64 .
|
||||||
@@ -88,13 +771,19 @@ docker run --env-file notify-wakeup-api.env -p 3003:3003 \
|
|||||||
notify-wakeup-api
|
notify-wakeup-api
|
||||||
```
|
```
|
||||||
|
|
||||||
Mount a volume over `/app/data` (or whatever path you set with `NOTIFY_DATA_DIR`) so the SQLite database is not lost when the container is replaced.
|
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.
|
||||||
|
|
||||||
Required environment variables:
|
### 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.
|
||||||
|
|
||||||
| Variable | Description |
|
|
||||||
|---|---|
|
|
||||||
| `FIREBASE_SERVICE_ACCOUNT_JSON` | Inline service account JSON (one line). If unset, falls back to Application Default Credentials. |
|
|
||||||
| `PORT` | HTTP port (default: `3003`). |
|
|
||||||
| `ENDORSER_URL` | Endorser API base URL used for auth checks on register/refresh (default: `https://api.endorser.ch`). |
|
|
||||||
| `NOTIFY_DATA_DIR` | Directory for the SQLite database file `notify.sqlite` (default: `./data`). |
|
|
||||||
|
|||||||
+8
-2
@@ -1,13 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "notification-wakeup-service",
|
"name": "notification-wakeup-service",
|
||||||
"version": "0.1.4",
|
"version": "0.2.2-beta",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@11.4.0",
|
"packageManager": "pnpm@11.4.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"start": "tsx 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": {
|
"dependencies": {
|
||||||
"@peculiar/asn1-ecc": "^2.7.0",
|
"@peculiar/asn1-ecc": "^2.7.0",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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(" ");
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
+234
-4
@@ -2,9 +2,9 @@ import { mkdirSync } from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
|
|
||||||
const dataDir =
|
function dataDirPath(): string {
|
||||||
process.env.NOTIFY_DATA_DIR ?? path.join(process.cwd(), "data");
|
return process.env.NOTIFY_DATA_DIR ?? path.join(process.cwd(), "data");
|
||||||
const dbFile = path.join(dataDir, "notify.sqlite");
|
}
|
||||||
|
|
||||||
const SCHEMA_SQL = `
|
const SCHEMA_SQL = `
|
||||||
CREATE TABLE IF NOT EXISTS fcm_registrations (
|
CREATE TABLE IF NOT EXISTS fcm_registrations (
|
||||||
@@ -12,7 +12,11 @@ CREATE TABLE IF NOT EXISTS fcm_registrations (
|
|||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
device_id TEXT NOT NULL,
|
device_id TEXT NOT NULL,
|
||||||
fcm_token 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,
|
platform TEXT NOT NULL,
|
||||||
|
|
||||||
test_mode INTEGER,
|
test_mode INTEGER,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
@@ -33,6 +37,231 @@ CREATE INDEX IF NOT EXISTS idx_fcm_registrations_fcm_token
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_fcm_registrations_user_fcm_token
|
CREATE INDEX IF NOT EXISTS idx_fcm_registrations_user_fcm_token
|
||||||
ON fcm_registrations (user_id, 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;
|
let database: Database.Database | null = null;
|
||||||
@@ -47,8 +276,9 @@ function ensureSchema(connection: Database.Database): void {
|
|||||||
*/
|
*/
|
||||||
export function getDatabase(): Database.Database {
|
export function getDatabase(): Database.Database {
|
||||||
if (database === null) {
|
if (database === null) {
|
||||||
|
const dataDir = dataDirPath();
|
||||||
mkdirSync(dataDir, { recursive: true });
|
mkdirSync(dataDir, { recursive: true });
|
||||||
database = new Database(dbFile);
|
database = new Database(path.join(dataDir, "notify.sqlite"));
|
||||||
database.pragma("journal_mode = WAL");
|
database.pragma("journal_mode = WAL");
|
||||||
ensureSchema(database);
|
ensureSchema(database);
|
||||||
}
|
}
|
||||||
|
|||||||
+132
-1
@@ -2,6 +2,137 @@ import { config } from "dotenv";
|
|||||||
|
|
||||||
config();
|
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. */
|
/** Base URL for the Endorser API server. */
|
||||||
export const ENDORSER_URL =
|
export const ENDORSER_URL =
|
||||||
process.env.ENDORSER_URL ?? "https://api.endorser.ch";
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+23
-6
@@ -1,10 +1,15 @@
|
|||||||
import "./env.js";
|
import { assertSmsConfigured, smsConfig } from "./env.js";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import "./services/firebase.js";
|
import "./services/firebase.js";
|
||||||
|
import { DEBUG_ENDPOINT } from "./env.js";
|
||||||
import { debugRouter } from "./routes/debug.js";
|
import { debugRouter } from "./routes/debug.js";
|
||||||
import { notificationsRouter } from "./routes/notifications.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 { startScheduler } from "./scheduler.js";
|
||||||
|
import { log } from "./util/log.js";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = Number(process.env.PORT) || 3003;
|
const port = Number(process.env.PORT) || 3003;
|
||||||
@@ -12,12 +17,12 @@ const port = Number(process.env.PORT) || 3003;
|
|||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
origin: true,
|
origin: true,
|
||||||
methods: ["GET", "POST", "OPTIONS"],
|
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||||
allowedHeaders: ["Content-Type", "Authorization"],
|
allowedHeaders: ["Content-Type", "Authorization", "ngrok-skip-browser-warning"],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json({ limit: "1mb" }));
|
||||||
|
|
||||||
// Keep stable for diagnostics tooling compatibility
|
// Keep stable for diagnostics tooling compatibility
|
||||||
app.get("/health", (_req, res) => {
|
app.get("/health", (_req, res) => {
|
||||||
@@ -25,10 +30,22 @@ app.get("/health", (_req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.use("/notifications", notificationsRouter);
|
app.use("/notifications", notificationsRouter);
|
||||||
app.use("/debug", debugRouter);
|
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();
|
startScheduler();
|
||||||
|
startAlertSearchScheduler();
|
||||||
|
if (smsConfig().enabled) {
|
||||||
|
startSmsAlertSearchScheduler();
|
||||||
|
}
|
||||||
|
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log("* Running backend");
|
log.info("* Running backend");
|
||||||
});
|
});
|
||||||
|
|||||||
+22
-14
@@ -1,10 +1,17 @@
|
|||||||
import type { NextFunction, Request, Response } from "express";
|
import type { NextFunction, Request, Response } from "express";
|
||||||
import { checkAuth } from "../services/endorserClient.js";
|
import { checkAuth } from "../services/endorserClient.js";
|
||||||
import { decodeAndVerifyJwt } from "../vc/index.js";
|
import { decodeAndVerifyJwt } from "../vc/index.js";
|
||||||
|
import { log } from "../util/log.js";
|
||||||
|
|
||||||
export type AuthContext = {
|
export type AuthContext = {
|
||||||
did: string;
|
did: string;
|
||||||
jwt: 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 = {
|
type ClientErrorBody = {
|
||||||
@@ -50,7 +57,7 @@ export async function requireAuth(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||||
console.log("[Auth] Authentication failed");
|
log.info("[Auth] Authentication failed");
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Missing "Bearer JWT" in Authorization header.',
|
message: 'Missing "Bearer JWT" in Authorization header.',
|
||||||
@@ -64,8 +71,8 @@ export async function requireAuth(
|
|||||||
const verified = await decodeAndVerifyJwt(token);
|
const verified = await decodeAndVerifyJwt(token);
|
||||||
if (!verified.verified) {
|
if (!verified.verified) {
|
||||||
const errorTime = new Date().toISOString();
|
const errorTime = new Date().toISOString();
|
||||||
console.log("[Auth] Authentication failed");
|
log.info("[Auth] Authentication failed");
|
||||||
console.error(
|
log.error(
|
||||||
"[Auth] Invalid JWT at",
|
"[Auth] Invalid JWT at",
|
||||||
errorTime + ":",
|
errorTime + ":",
|
||||||
verified
|
verified
|
||||||
@@ -82,13 +89,13 @@ export async function requireAuth(
|
|||||||
const did = verified.issuer;
|
const did = verified.issuer;
|
||||||
req.did = did;
|
req.did = did;
|
||||||
req.jwt = token;
|
req.jwt = token;
|
||||||
req.auth = { did, jwt: token };
|
req.auth = { did, jwt: token, payload: verified.payload };
|
||||||
console.log("[Auth] Authenticated user:", did);
|
log.info("[Auth] Authenticated user:", did);
|
||||||
next();
|
next();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorTime = new Date().toISOString();
|
const errorTime = new Date().toISOString();
|
||||||
console.log("[Auth] Authentication failed");
|
log.info("[Auth] Authentication failed");
|
||||||
console.error(
|
log.error(
|
||||||
"[Auth] Invalid JWT at",
|
"[Auth] Invalid JWT at",
|
||||||
errorTime + ":",
|
errorTime + ":",
|
||||||
err
|
err
|
||||||
@@ -110,7 +117,7 @@ export async function requireAuthOrNotificationLocalTest(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (isNotificationLocalTestBypass(req)) {
|
if (isNotificationLocalTestBypass(req)) {
|
||||||
req.did = LOCAL_TEST_USER_ID;
|
req.did = LOCAL_TEST_USER_ID;
|
||||||
console.log("[Auth] Local notification test bypass");
|
log.info("[Auth] Local notification test bypass");
|
||||||
next();
|
next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -141,12 +148,13 @@ export async function requireEndorserAuth(
|
|||||||
const errorTime = new Date().toISOString();
|
const errorTime = new Date().toISOString();
|
||||||
const did = req.did ?? "(unknown)";
|
const did = req.did ?? "(unknown)";
|
||||||
|
|
||||||
if (result.reason === "unavailable") {
|
if (result.reason !== "unauthorized") {
|
||||||
console.log("[Auth] Endorser unavailable");
|
log.info("[Auth] Endorser unavailable");
|
||||||
console.error(
|
log.error(
|
||||||
"[Auth] Endorser auth check unavailable at",
|
"[Auth] Endorser auth check unavailable at",
|
||||||
errorTime + ", did:",
|
errorTime + ", did:",
|
||||||
did
|
did + ", reason:",
|
||||||
|
result.reason
|
||||||
);
|
);
|
||||||
res.status(503).json({
|
res.status(503).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -156,8 +164,8 @@ export async function requireEndorserAuth(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[Auth] Endorser verification failed");
|
log.info("[Auth] Endorser verification failed");
|
||||||
console.error(
|
log.error(
|
||||||
"[Auth] Endorser rejected JWT at",
|
"[Auth] Endorser rejected JWT at",
|
||||||
errorTime + ", did:",
|
errorTime + ", did:",
|
||||||
did
|
did
|
||||||
|
|||||||
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+8
-10
@@ -10,8 +10,8 @@ import {
|
|||||||
} from "../services/pushService.js";
|
} from "../services/pushService.js";
|
||||||
import { formatElapsedMs } from "../util/formatElapsed.js";
|
import { formatElapsedMs } from "../util/formatElapsed.js";
|
||||||
import { maskToken } from "../util/maskToken.js";
|
import { maskToken } from "../util/maskToken.js";
|
||||||
|
import { log } from "../util/log.js";
|
||||||
|
|
||||||
// TODO: Protect this endpoint before production deployment
|
|
||||||
export const debugRouter: express.Router = Router();
|
export const debugRouter: express.Router = Router();
|
||||||
|
|
||||||
function deviceDebugPayload(row: {
|
function deviceDebugPayload(row: {
|
||||||
@@ -47,7 +47,6 @@ function sendWakeupFailureReason(
|
|||||||
return "FCM send failed";
|
return "FCM send failed";
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Protect this endpoint before production deployment
|
|
||||||
debugRouter.get("/device/:token", requireAuth, async (req, res) => {
|
debugRouter.get("/device/:token", requireAuth, async (req, res) => {
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
const userId = req.did;
|
const userId = req.did;
|
||||||
@@ -61,11 +60,11 @@ debugRouter.get("/device/:token", requireAuth, async (req, res) => {
|
|||||||
Array.isArray(tokenParam) ? tokenParam[0] : tokenParam
|
Array.isArray(tokenParam) ? tokenParam[0] : tokenParam
|
||||||
);
|
);
|
||||||
const suffix = maskToken(fcmToken);
|
const suffix = maskToken(fcmToken);
|
||||||
console.log("[DebugEndpoint] Device lookup request, token suffix:", suffix);
|
log.info("[DebugEndpoint] Device lookup request, token suffix:", suffix);
|
||||||
|
|
||||||
const row = await db.resolveOwnedDevice(userId, { fcmToken });
|
const row = await db.resolveOwnedDevice(userId, { fcmToken });
|
||||||
if (row === undefined) {
|
if (row === undefined) {
|
||||||
console.log(
|
log.info(
|
||||||
"[DebugEndpoint] Device lookup not found in",
|
"[DebugEndpoint] Device lookup not found in",
|
||||||
formatElapsedMs(Date.now() - started) + ",",
|
formatElapsedMs(Date.now() - started) + ",",
|
||||||
"token suffix:",
|
"token suffix:",
|
||||||
@@ -76,7 +75,7 @@ debugRouter.get("/device/:token", requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res.json(deviceDebugPayload(row));
|
res.json(deviceDebugPayload(row));
|
||||||
console.log(
|
log.info(
|
||||||
"[DebugEndpoint] Device lookup completed in",
|
"[DebugEndpoint] Device lookup completed in",
|
||||||
formatElapsedMs(Date.now() - started) + ",",
|
formatElapsedMs(Date.now() - started) + ",",
|
||||||
"token suffix:",
|
"token suffix:",
|
||||||
@@ -84,7 +83,6 @@ debugRouter.get("/device/:token", requireAuth, async (req, res) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: Protect this endpoint before production deployment
|
|
||||||
debugRouter.post("/send-wakeup", requireAuthOrNotificationLocalTest, async (req, res) => {
|
debugRouter.post("/send-wakeup", requireAuthOrNotificationLocalTest, async (req, res) => {
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
const userId = req.did;
|
const userId = req.did;
|
||||||
@@ -95,7 +93,7 @@ debugRouter.post("/send-wakeup", requireAuthOrNotificationLocalTest, async (req,
|
|||||||
|
|
||||||
const { fcmToken } = req.body as { fcmToken?: unknown };
|
const { fcmToken } = req.body as { fcmToken?: unknown };
|
||||||
if (typeof fcmToken !== "string" || fcmToken.length === 0) {
|
if (typeof fcmToken !== "string" || fcmToken.length === 0) {
|
||||||
console.log(
|
log.info(
|
||||||
"[DebugEndpoint] Send-wakeup rejected in",
|
"[DebugEndpoint] Send-wakeup rejected in",
|
||||||
formatElapsedMs(Date.now() - started) + ":",
|
formatElapsedMs(Date.now() - started) + ":",
|
||||||
"fcmToken is required"
|
"fcmToken is required"
|
||||||
@@ -108,11 +106,11 @@ debugRouter.post("/send-wakeup", requireAuthOrNotificationLocalTest, async (req,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const suffix = maskToken(fcmToken);
|
const suffix = maskToken(fcmToken);
|
||||||
console.log("[DebugEndpoint] Send-wakeup request, token suffix:", suffix);
|
log.info("[DebugEndpoint] Send-wakeup request, token suffix:", suffix);
|
||||||
|
|
||||||
const row = await db.resolveOwnedDevice(userId, { fcmToken });
|
const row = await db.resolveOwnedDevice(userId, { fcmToken });
|
||||||
if (row === undefined) {
|
if (row === undefined) {
|
||||||
console.log(
|
log.info(
|
||||||
"[DebugEndpoint] Send-wakeup rejected in",
|
"[DebugEndpoint] Send-wakeup rejected in",
|
||||||
formatElapsedMs(Date.now() - started) + ",",
|
formatElapsedMs(Date.now() - started) + ",",
|
||||||
"token suffix:",
|
"token suffix:",
|
||||||
@@ -137,7 +135,7 @@ debugRouter.post("/send-wakeup", requireAuthOrNotificationLocalTest, async (req,
|
|||||||
fcmTokenSuffix: suffix,
|
fcmTokenSuffix: suffix,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(
|
log.info(
|
||||||
"[DebugEndpoint] Send-wakeup completed in",
|
"[DebugEndpoint] Send-wakeup completed in",
|
||||||
formatElapsedMs(Date.now() - started) + ",",
|
formatElapsedMs(Date.now() - started) + ",",
|
||||||
success ? "success" : result + ",",
|
success ? "success" : result + ",",
|
||||||
|
|||||||
+152
-11
@@ -1,11 +1,21 @@
|
|||||||
import express, { Router } from "express";
|
import express, { Router } from "express";
|
||||||
|
import { alertAuthorizationDb } from "../db/alertAuthorizationSqlite.js";
|
||||||
import { db } from "../db/fcmTokensSqlite.js";
|
import { db } from "../db/fcmTokensSqlite.js";
|
||||||
import {
|
import {
|
||||||
|
requireAuth,
|
||||||
requireAuthOrNotificationLocalTest,
|
requireAuthOrNotificationLocalTest,
|
||||||
requireEndorserAuth,
|
requireEndorserAuth,
|
||||||
} from "../middleware/auth.js";
|
} 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 { errorMessage, formatElapsedMs } from "../util/formatElapsed.js";
|
||||||
import { maskToken } from "../util/maskToken.js";
|
import { maskToken } from "../util/maskToken.js";
|
||||||
|
import { log } from "../util/log.js";
|
||||||
|
|
||||||
export const notificationsRouter: express.Router = Router();
|
export const notificationsRouter: express.Router = Router();
|
||||||
|
|
||||||
@@ -37,7 +47,7 @@ notificationsRouter.post(
|
|||||||
? fcmToken
|
? fcmToken
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
console.log(
|
log.info(
|
||||||
"[Refresh] Request received",
|
"[Refresh] Request received",
|
||||||
canonicalDeviceId !== undefined ? `deviceId=${canonicalDeviceId}` : "",
|
canonicalDeviceId !== undefined ? `deviceId=${canonicalDeviceId}` : "",
|
||||||
token !== undefined ? `token suffix=${maskToken(token)}` : ""
|
token !== undefined ? `token suffix=${maskToken(token)}` : ""
|
||||||
@@ -47,7 +57,7 @@ notificationsRouter.post(
|
|||||||
(canonicalDeviceId === undefined || canonicalDeviceId.length === 0) &&
|
(canonicalDeviceId === undefined || canonicalDeviceId.length === 0) &&
|
||||||
token === undefined
|
token === undefined
|
||||||
) {
|
) {
|
||||||
console.log(
|
log.info(
|
||||||
"[Refresh] Rejected in",
|
"[Refresh] Rejected in",
|
||||||
formatElapsedMs(Date.now() - started) + ":",
|
formatElapsedMs(Date.now() - started) + ":",
|
||||||
"deviceId or fcmToken is required"
|
"deviceId or fcmToken is required"
|
||||||
@@ -62,7 +72,7 @@ notificationsRouter.post(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (device === undefined) {
|
if (device === undefined) {
|
||||||
console.log(
|
log.info(
|
||||||
"[Refresh] Device not found in",
|
"[Refresh] Device not found in",
|
||||||
formatElapsedMs(Date.now() - started),
|
formatElapsedMs(Date.now() - started),
|
||||||
canonicalDeviceId !== undefined
|
canonicalDeviceId !== undefined
|
||||||
@@ -79,7 +89,7 @@ notificationsRouter.post(
|
|||||||
shouldNotify: true,
|
shouldNotify: true,
|
||||||
nextNotifications: [{ timestamp: now + 600000 }],
|
nextNotifications: [{ timestamp: now + 600000 }],
|
||||||
});
|
});
|
||||||
console.log(
|
log.info(
|
||||||
"[Refresh] Completed in",
|
"[Refresh] Completed in",
|
||||||
formatElapsedMs(Date.now() - started) + ",",
|
formatElapsedMs(Date.now() - started) + ",",
|
||||||
"deviceId=" + device.deviceId + ",",
|
"deviceId=" + device.deviceId + ",",
|
||||||
@@ -88,6 +98,137 @@ notificationsRouter.post(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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(
|
notificationsRouter.post(
|
||||||
"/register",
|
"/register",
|
||||||
requireAuthOrNotificationLocalTest,
|
requireAuthOrNotificationLocalTest,
|
||||||
@@ -105,7 +246,7 @@ notificationsRouter.post(
|
|||||||
typeof req.body === "object" &&
|
typeof req.body === "object" &&
|
||||||
"userId" in req.body
|
"userId" in req.body
|
||||||
) {
|
) {
|
||||||
console.log(
|
log.info(
|
||||||
"[Register] Rejected in",
|
"[Register] Rejected in",
|
||||||
formatElapsedMs(Date.now() - started) + ":",
|
formatElapsedMs(Date.now() - started) + ":",
|
||||||
"userId must not be sent in the request body"
|
"userId must not be sent in the request body"
|
||||||
@@ -124,7 +265,7 @@ notificationsRouter.post(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (typeof deviceId !== "string" || deviceId.trim().length === 0) {
|
if (typeof deviceId !== "string" || deviceId.trim().length === 0) {
|
||||||
console.log(
|
log.info(
|
||||||
"[Register] Rejected in",
|
"[Register] Rejected in",
|
||||||
formatElapsedMs(Date.now() - started) + ":",
|
formatElapsedMs(Date.now() - started) + ":",
|
||||||
"deviceId is required"
|
"deviceId is required"
|
||||||
@@ -133,7 +274,7 @@ notificationsRouter.post(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof fcmToken !== "string" || fcmToken.length === 0) {
|
if (typeof fcmToken !== "string" || fcmToken.length === 0) {
|
||||||
console.log(
|
log.info(
|
||||||
"[Register] Rejected in",
|
"[Register] Rejected in",
|
||||||
formatElapsedMs(Date.now() - started) + ":",
|
formatElapsedMs(Date.now() - started) + ":",
|
||||||
"fcmToken is required"
|
"fcmToken is required"
|
||||||
@@ -142,7 +283,7 @@ notificationsRouter.post(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof platform !== "string" || platform.length === 0) {
|
if (typeof platform !== "string" || platform.length === 0) {
|
||||||
console.log(
|
log.info(
|
||||||
"[Register] Rejected in",
|
"[Register] Rejected in",
|
||||||
formatElapsedMs(Date.now() - started) + ":",
|
formatElapsedMs(Date.now() - started) + ":",
|
||||||
"platform is required"
|
"platform is required"
|
||||||
@@ -152,7 +293,7 @@ notificationsRouter.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const canonicalDeviceId = deviceId.trim();
|
const canonicalDeviceId = deviceId.trim();
|
||||||
console.log(
|
log.info(
|
||||||
"[Register] Request received,",
|
"[Register] Request received,",
|
||||||
"deviceId=" + canonicalDeviceId + ",",
|
"deviceId=" + canonicalDeviceId + ",",
|
||||||
"platform=" + platform + ",",
|
"platform=" + platform + ",",
|
||||||
@@ -177,14 +318,14 @@ notificationsRouter.post(
|
|||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
});
|
});
|
||||||
res.sendStatus(200);
|
res.sendStatus(200);
|
||||||
console.log(
|
log.info(
|
||||||
"[Register] Completed in",
|
"[Register] Completed in",
|
||||||
formatElapsedMs(Date.now() - started) + ",",
|
formatElapsedMs(Date.now() - started) + ",",
|
||||||
"deviceId=" + canonicalDeviceId + ",",
|
"deviceId=" + canonicalDeviceId + ",",
|
||||||
"action=" + action
|
"action=" + action
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
log.error(
|
||||||
"[Register] Failed in",
|
"[Register] Failed in",
|
||||||
formatElapsedMs(Date.now() - started) + ",",
|
formatElapsedMs(Date.now() - started) + ",",
|
||||||
"deviceId=" + canonicalDeviceId + ":",
|
"deviceId=" + canonicalDeviceId + ":",
|
||||||
|
|||||||
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
+5
-4
@@ -1,6 +1,7 @@
|
|||||||
import { db } from "./db/fcmTokensSqlite.js";
|
import { db } from "./db/fcmTokensSqlite.js";
|
||||||
import { sendPushToDevice } from "./services/pushService.js";
|
import { sendPushToDevice } from "./services/pushService.js";
|
||||||
import { errorMessage, formatElapsedMs } from "./util/formatElapsed.js";
|
import { errorMessage, formatElapsedMs } from "./util/formatElapsed.js";
|
||||||
|
import { log } from "./util/log.js";
|
||||||
|
|
||||||
let intervalId: ReturnType<typeof setInterval> | undefined;
|
let intervalId: ReturnType<typeof setInterval> | undefined;
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@ export function startScheduler(): void {
|
|||||||
|
|
||||||
intervalId = setInterval(async () => {
|
intervalId = setInterval(async () => {
|
||||||
const passStarted = Date.now();
|
const passStarted = Date.now();
|
||||||
console.log("[Scheduler] Pass started");
|
log.info("[Scheduler] Pass started");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const devices = await db.getAllForScheduler();
|
const devices = await db.getAllForScheduler();
|
||||||
@@ -43,13 +44,13 @@ export function startScheduler(): void {
|
|||||||
if (duplicates > 0) {
|
if (duplicates > 0) {
|
||||||
summaryParts.push(`${duplicates} duplicates ignored`);
|
summaryParts.push(`${duplicates} duplicates ignored`);
|
||||||
}
|
}
|
||||||
console.log("[Scheduler]", summaryParts.join(", "));
|
log.info("[Scheduler]", summaryParts.join(", "));
|
||||||
console.log(
|
log.info(
|
||||||
"[Scheduler] Pass completed in",
|
"[Scheduler] Pass completed in",
|
||||||
formatElapsedMs(Date.now() - passStarted)
|
formatElapsedMs(Date.now() - passStarted)
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
log.error(
|
||||||
"[Scheduler] Pass failed in",
|
"[Scheduler] Pass failed in",
|
||||||
formatElapsedMs(Date.now() - passStarted) + ":",
|
formatElapsedMs(Date.now() - passStarted) + ":",
|
||||||
errorMessage(err)
|
errorMessage(err)
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { ENDORSER_URL } from "../env.js";
|
import { ENDORSER_URL } from "../env.js";
|
||||||
import { errorMessage } from "../util/formatElapsed.js";
|
import { errorMessage } from "../util/formatElapsed.js";
|
||||||
|
import { log } from "../util/log.js";
|
||||||
|
|
||||||
const RATE_LIMITS_PATH = "/api/report/rateLimits";
|
const RATE_LIMITS_PATH = "/api/report/rateLimits";
|
||||||
|
|
||||||
export type EndorserAuthResult =
|
export type EndorserAuthResult =
|
||||||
| { ok: true }
|
| { ok: true }
|
||||||
| { ok: false; reason: "unauthorized" | "unavailable" };
|
| { ok: false; reason: "unauthorized" | "unavailable error" | "unavailable 500" };
|
||||||
|
|
||||||
function rateLimitsUrl(): string {
|
function rateLimitsUrl(): string {
|
||||||
const base = ENDORSER_URL.replace(/\/+$/, "");
|
const base = ENDORSER_URL.replace(/\/+$/, "");
|
||||||
@@ -31,12 +32,12 @@ export async function checkAuth(jwt: string): Promise<EndorserAuthResult> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
log.error(
|
||||||
"[Endorser] Auth check request failed for",
|
"[Endorser] Auth check request failed for",
|
||||||
url + ":",
|
url + ":",
|
||||||
errorMessage(err)
|
errorMessage(err)
|
||||||
);
|
);
|
||||||
return { ok: false, reason: "unavailable" };
|
return { ok: false, reason: "unavailable error" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -45,16 +46,16 @@ export async function checkAuth(jwt: string): Promise<EndorserAuthResult> {
|
|||||||
|
|
||||||
// 5xx: Endorser is up but unhealthy; treat as unavailable.
|
// 5xx: Endorser is up but unhealthy; treat as unavailable.
|
||||||
if (response.status >= 500) {
|
if (response.status >= 500) {
|
||||||
console.error(
|
log.error(
|
||||||
"[Endorser] Auth check unavailable for",
|
"[Endorser] Auth check unavailable for",
|
||||||
url + ", status",
|
url + ", status",
|
||||||
response.status
|
response.status
|
||||||
);
|
);
|
||||||
return { ok: false, reason: "unavailable" };
|
return { ok: false, reason: "unavailable 500" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4xx: JWT rejected or user not registered on Endorser.
|
// 4xx: JWT rejected or user not registered on Endorser.
|
||||||
console.error(
|
log.error(
|
||||||
"[Endorser] Auth check rejected for",
|
"[Endorser] Auth check rejected for",
|
||||||
url + ", status",
|
url + ", status",
|
||||||
response.status
|
response.status
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import admin from "firebase-admin";
|
import admin from "firebase-admin";
|
||||||
import type { ServiceAccount } from "firebase-admin/app";
|
import type { ServiceAccount } from "firebase-admin/app";
|
||||||
import type { Messaging } from "firebase-admin/messaging";
|
import type { Messaging } from "firebase-admin/messaging";
|
||||||
|
import { log } from "../util/log.js";
|
||||||
|
|
||||||
type ServiceAccountJson = ServiceAccount & { project_id?: string };
|
type ServiceAccountJson = ServiceAccount & { project_id?: string };
|
||||||
|
|
||||||
@@ -22,25 +23,25 @@ function resolveCredential(): admin.credential.Credential {
|
|||||||
account = JSON.parse(json) as ServiceAccountJson;
|
account = JSON.parse(json) as ServiceAccountJson;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
console.error(
|
log.error(
|
||||||
"[Firebase] FIREBASE_SERVICE_ACCOUNT_JSON parse failed:",
|
"[Firebase] FIREBASE_SERVICE_ACCOUNT_JSON parse failed:",
|
||||||
message
|
message
|
||||||
);
|
);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
const projectId = serviceAccountProjectId(account);
|
const projectId = serviceAccountProjectId(account);
|
||||||
console.log(
|
log.info(
|
||||||
"[Firebase] Credential: FIREBASE_SERVICE_ACCOUNT_JSON (parsed successfully)"
|
"[Firebase] Credential: FIREBASE_SERVICE_ACCOUNT_JSON (parsed successfully)"
|
||||||
);
|
);
|
||||||
if (projectId !== undefined) {
|
if (projectId !== undefined) {
|
||||||
console.log("[Firebase] project_id:", projectId);
|
log.info("[Firebase] project_id:", projectId);
|
||||||
} else {
|
} else {
|
||||||
console.log("[Firebase] project_id: (not found in service account JSON)");
|
log.info("[Firebase] project_id: (not found in service account JSON)");
|
||||||
}
|
}
|
||||||
return admin.credential.cert(account);
|
return admin.credential.cert(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[Firebase] Credential: Application Default Credentials");
|
log.info("[Firebase] Credential: Application Default Credentials");
|
||||||
return admin.credential.applicationDefault();
|
return admin.credential.applicationDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { db, type StoredRow } from "../db/fcmTokensSqlite.js";
|
|||||||
import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js";
|
import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js";
|
||||||
import { maskToken } from "../util/maskToken.js";
|
import { maskToken } from "../util/maskToken.js";
|
||||||
import { messaging } from "./firebase.js";
|
import { messaging } from "./firebase.js";
|
||||||
|
import { log } from "../util/log.js";
|
||||||
|
|
||||||
const MS_PRODUCTION = 23 * 60 * 60 * 1000;
|
const MS_PRODUCTION = 23 * 60 * 60 * 1000;
|
||||||
const MS_TEST = 10 * 60 * 1000;
|
const MS_TEST = 10 * 60 * 1000;
|
||||||
@@ -60,7 +61,7 @@ export async function sendPushToDevice(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sendStarted = Date.now();
|
const sendStarted = Date.now();
|
||||||
console.log("[Push] Send attempt, token suffix:", suffix);
|
log.info("[Push] Send attempt, token suffix:", suffix);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data: Record<string, string> = {
|
const data: Record<string, string> = {
|
||||||
@@ -88,7 +89,7 @@ export async function sendPushToDevice(
|
|||||||
if (persisted !== undefined) {
|
if (persisted !== undefined) {
|
||||||
await db.update(persisted.id, { lastNotifiedAt: Date.now() });
|
await db.update(persisted.id, { lastNotifiedAt: Date.now() });
|
||||||
}
|
}
|
||||||
console.log(
|
log.info(
|
||||||
"[Push] Send completed in",
|
"[Push] Send completed in",
|
||||||
formatElapsedMs(Date.now() - sendStarted) + ",",
|
formatElapsedMs(Date.now() - sendStarted) + ",",
|
||||||
"token suffix:",
|
"token suffix:",
|
||||||
@@ -96,7 +97,7 @@ export async function sendPushToDevice(
|
|||||||
);
|
);
|
||||||
return "sent";
|
return "sent";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
log.error(
|
||||||
"[Push] Send failed in",
|
"[Push] Send failed in",
|
||||||
formatElapsedMs(Date.now() - sendStarted) + ",",
|
formatElapsedMs(Date.now() - sendStarted) + ",",
|
||||||
"token suffix:",
|
"token suffix:",
|
||||||
@@ -106,3 +107,47 @@ export async function sendPushToDevice(
|
|||||||
return "failed";
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Vendored
+4
-2
@@ -5,8 +5,10 @@ declare global {
|
|||||||
did?: string;
|
did?: string;
|
||||||
/** Raw Bearer JWT from the Authorization header. */
|
/** Raw Bearer JWT from the Authorization header. */
|
||||||
jwt?: string;
|
jwt?: string;
|
||||||
/** Verified auth context (did + jwt). */
|
/** Verified auth context. Present only after requireAuth succeeded. */
|
||||||
auth?: { did: string; jwt: string };
|
auth?: import("../middleware/auth.js").AuthContext;
|
||||||
|
/** sha256 of the consumed action JWT, set by requireSmsActionJwt. */
|
||||||
|
smsActionJwtHash?: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
+91
-2
@@ -7,12 +7,15 @@ import { Resolver } from "did-resolver";
|
|||||||
|
|
||||||
import { didEthLocalResolver } from "./did-eth-local-resolver.js";
|
import { didEthLocalResolver } from "./did-eth-local-resolver.js";
|
||||||
import { verifyJwt as peerVerifyJwt } from "./passkeyDidPeer.js";
|
import { verifyJwt as peerVerifyJwt } from "./passkeyDidPeer.js";
|
||||||
|
import { log } from "../util/log.js";
|
||||||
|
|
||||||
export const TEST_BYPASS_ENV_VALUE = "test-local";
|
export const TEST_BYPASS_ENV_VALUE = "test-local";
|
||||||
export const ETHR_DID_PREFIX = "did:ethr:";
|
export const ETHR_DID_PREFIX = "did:ethr:";
|
||||||
export const PEER_DID_PREFIX = "did:peer:";
|
export const PEER_DID_PREFIX = "did:peer:";
|
||||||
export const JWT_VERIFY_FAILED_CODE = "JWT_VERIFY_FAILED_CODE";
|
export const JWT_VERIFY_FAILED_CODE = "JWT_VERIFY_FAILED_CODE";
|
||||||
export const UNSUPPORTED_DID_METHOD_CODE = "UNSUPPORTED_DID_METHOD";
|
export const UNSUPPORTED_DID_METHOD_CODE = "UNSUPPORTED_DID_METHOD";
|
||||||
|
export const DELEGATED_JWT_UNSUPPORTED_IDENTITY_CODE =
|
||||||
|
"DELEGATED_JWT_UNSUPPORTED_IDENTITY";
|
||||||
|
|
||||||
const resolver = new Resolver({
|
const resolver = new Resolver({
|
||||||
ethr: didEthLocalResolver,
|
ethr: didEthLocalResolver,
|
||||||
@@ -24,14 +27,26 @@ export type VerifiedJwt = {
|
|||||||
verified: boolean;
|
verified: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function decodeAndVerifyJwt(jwt: string): Promise<VerifiedJwt> {
|
function parseJwtHeaderAndPayload(jwt: string): {
|
||||||
|
header: Record<string, unknown>;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
pieces: string[];
|
||||||
|
} {
|
||||||
const pieces = jwt.split(".");
|
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(
|
const header = JSON.parse(
|
||||||
Buffer.from(pieces[0], "base64url").toString("utf8")
|
Buffer.from(pieces[0], "base64url").toString("utf8")
|
||||||
) as Record<string, unknown>;
|
) as Record<string, unknown>;
|
||||||
const payload = JSON.parse(
|
const payload = JSON.parse(
|
||||||
Buffer.from(pieces[1], "base64url").toString("utf8")
|
Buffer.from(pieces[1], "base64url").toString("utf8")
|
||||||
) as Record<string, unknown>;
|
) 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;
|
const issuerDid = payload.iss;
|
||||||
if (!issuerDid || typeof issuerDid !== "string") {
|
if (!issuerDid || typeof issuerDid !== "string") {
|
||||||
return Promise.reject({
|
return Promise.reject({
|
||||||
@@ -46,7 +61,7 @@ export async function decodeAndVerifyJwt(jwt: string): Promise<VerifiedJwt> {
|
|||||||
) {
|
) {
|
||||||
const nowEpoch = Math.floor(new Date().getTime() / 1000);
|
const nowEpoch = Math.floor(new Date().getTime() / 1000);
|
||||||
if (typeof payload.exp === "number" && payload.exp < nowEpoch) {
|
if (typeof payload.exp === "number" && payload.exp < nowEpoch) {
|
||||||
console.log(
|
log.info(
|
||||||
"JWT with exp " +
|
"JWT with exp " +
|
||||||
payload.exp +
|
payload.exp +
|
||||||
" has expired but we're in test mode so we'll use a new time."
|
" has expired but we're in test mode so we'll use a new time."
|
||||||
@@ -98,3 +113,77 @@ export async function decodeAndVerifyJwt(jwt: string): Promise<VerifiedJwt> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"]));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"), {});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -14,5 +14,5 @@
|
|||||||
"sourceMap": true
|
"sourceMap": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts",
|
||||||
|
"test/**/*.ts",
|
||||||
|
"scripts/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user