Add SMS notifications. The PLAN is executed but this isn't tested yet.

This commit is contained in:
2026-09-05 20:52:34 -06:00
parent 776558b230
commit f967db7373
51 changed files with 5983 additions and 323 deletions
+36
View File
@@ -21,3 +21,39 @@ PORT=3003
# Do not set NODE_ENV=test-local in production (bypasses ethr JWT expiry).
# NODE_ENV=test-local
# --- 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=
# TWILIO_FROM_NUMBER=+15550000000
# TWILIO_MESSAGING_SERVICE_SID=
# 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
+8
View File
@@ -6,6 +6,14 @@ 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).
## [0.2.0] - 2026.09.05
### Added
- `/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`
- Every log line is prefixed with an ISO-8601 UTC timestamp (`src/util/log.ts`)
### Changed
- `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
+738
View File
@@ -0,0 +1,738 @@
# PLAN: SMS notifications (`/notify-sms`)
**Status:** implemented. Phases 1-12 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.
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: five 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.
Out of scope: changing any FCM behavior, changing `WAKEUP_PING`, MMS, inbound
conversational SMS beyond opt-out keywords, and international sender
registration beyond US A2P 10DLC.
## 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 |
`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 same shape the FCM twin takes: `{ batchId, timezone, jwts: [100] }`.
- Validation reuses `validateAlertAuthorizationBatch` unchanged. It already
verifies each delegated JWT's signature, matches `iss` to the authenticated
DID, checks `nbf`/`exp` against the claimed day in the batch timezone, and
requires 100 consecutive sequences.
- 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: `{ success, batchId, 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.
## One phone, several DIDs
Nothing keys off a phone number alone except carrier opt-out. Every other read
and write is scoped by `(user_id, phone_e164)`, so two identities sharing one
handset stay independent:
| Action by DID B | Effect on DID A's registration of the same number |
|---|---|
| POST (register) | None. DID B gets its own row, `verified = 0`, its own code. |
| PUT (verify) | None. The code is matched against DID B's row only. |
| DELETE | None. The `WHERE` clause carries both columns. |
| DELETE's log scrub | None. Only DID B's `sms_phone_log` rows are nulled. |
| Daily alert send | None. Recipients are that DID's own verified rows. |
Verification is per `(DID, phone)` and has to be. Possession of the handset is
what the code proves, and DID B has not proved it by watching DID A do so.
Three consequences worth stating rather than discovering:
- **Two verified DIDs on one handset receive two texts a day**, one per
identity, because each identity runs its own alertSearch against its own
cursor and sees its own results. The per-DID daily cap does not merge them.
- **The per-phone send throttle is deliberately cross-DID.** Three code sends
per hour is counted from `phone_hash`, not from `user_id`, because a
per-identity counter is trivially defeated by minting more identities. Two
legitimate registrations minutes apart both fit; a bombing run does not.
- **DELETE forgets a number per DID; `STOP` forgets it everywhere.** A carrier
opt-out arrives with a phone number and no identity attached, and stopping
traffic to that handset is not optional, so phase 9 sets `verified = 0` on
every registration of that number regardless of DID. That is the single place
in the design where the DID boundary is crossed on purpose.
### The cap
`SMS_MAX_DIDS_PER_PHONE` bounds how many DIDs one handset can carry. Default 5,
configurable up or down without a code change.
**The cap counts verified rows only.** Counting every row would hand an attacker
a registration lock: five POSTs from five throwaway DIDs, never verified, and
the handset's actual owner can no longer register it. Unverified rows cost
nothing to hold and cannot receive an alert, so they are not what the cap is
protecting against.
Checked in two places:
- **POST**, as an early rejection when the number already has
`SMS_MAX_DIDS_PER_PHONE` verified registrations: `409 SMS_PHONE_DID_LIMIT`.
Cheaper and clearer than letting someone verify a code and then be told no.
- **PUT**, immediately before flipping `verified` to 1, counting rows other than
this one. This is the check that actually holds: several registrations can
clear the POST check while the count sits under the limit, and PUT is the
moment a row starts consuming a slot. Same `409 SMS_PHONE_DID_LIMIT`.
The count reads the `phone_e164` index, filtered on `verified = 1`.
What stops unverified rows from accumulating is not this cap but the per-phone
send throttle: every POST that creates one also sends a text, and three texts
per phone per hour is the actual brake. The cap governs how many identities can
be *reached* at one number; the throttle governs how fast anyone can try.
### Telling the caller who holds the slots
A blocked caller needs to know which DIDs occupy the number, and in the ordinary
case the answer is "your own other identities, which you have lost track of."
Disclosing that list is gated on **proof of possession of the handset**, never
on the request alone.
| Rejection point | Body |
|---|---|
| POST at the limit | `409 SMS_PHONE_DID_LIMIT`, `limit` and `verifiedCount`. **No identities.** |
| PUT at the limit | `409 SMS_PHONE_DID_LIMIT`, `limit`, `verifiedCount`, and `dids`: the full DID of every verified registration of that number. |
The asymmetry is the whole design. A POST names any phone number on earth and
proves nothing about it. If it answered with identities, the endpoint would be a
phone-number-to-identity lookup oracle: feed it numbers, harvest the DIDs of
whoever holds them, authenticated by nothing more than possessing some DID of
one's own. A PUT that reaches the limit check has already matched a correct
six-digit code, which means the caller is holding the handset and can read every
message sent to it. Nothing is disclosed that possession did not already grant.
The code is consumed on a limit rejection exactly as it is on success. One code
buys one answer; a fresh answer costs a fresh POST, which the per-phone throttle
meters at three per hour. The rejection is logged as `did-limit-disclosed` in
`sms_phone_log`.
`sms_action_jwt_use` is keyed on the token hash, so two identities acting on one
number never collide there.
## Middleware chain
Four stages, in this order, on all five routes:
1. **`requireAuth`** — existing. Bearer JWT, signature verified against the
issuer DID, `req.did` / `req.jwt` set.
2. **`requireEndorserAuth`** — existing. `GET {ENDORSER_URL}/api/report/rateLimits`
with the same token. This is the "rateLimits" stage: it proves Endorser knows
and accepts this DID.
3. **`requireSmsActionJwt(action)`** — added by this plan. Confirms the verified token
authorizes *this specific action on this specific phone*.
4. **The route handler itself** — the Express callback that does the work
described under §Endpoints: normalize, read and write SQLite, call the
`SmsSender`, write `sms_phone_log`, send the response. It is listed as a
stage because Express treats it as one more function in the same chain, and
because of what that ordering guarantees: by the time the handler's first
line runs, the caller is authenticated, accepted by Endorser, and proven to
have authorized this exact action on this exact number. The handler performs
no authorization checks of its own and never re-reads `req.headers`; it
trusts `req.did` and the validated body, and nothing else.
Stages 13 either call `next()` or send a response and return. A stage that
sends a response ends the chain, so no later stage — the handler included —
runs. That is the whole mechanism preventing an unauthorized request from
reaching a `sendSms` call.
`testMode` has no bypass here. There is no local-test path that sends real texts
to real handsets on someone else's dime.
### Stage 3, in detail
There is one JWT. The client mints it with the claim inside, sends it as the
Bearer token, and the signature over that token is the authorization — the same
arrangement Endorser uses. Nothing is sent alongside it.
`decodeAndVerifyJwt` already returns `{ issuer, payload, verified }`, and
`requireAuth` keeps only `issuer` (as `req.did`) and the raw token string (as
`req.jwt`), discarding the decoded payload. Stage 3 needs the claim out of that
payload, so `requireAuth` stops discarding it: `req.auth = { did, jwt, payload }`.
`payload` is the verifier's own output for that one token, not a second
document.
Stage 3 reads that object and does not decode the token again. For `did:peer`
JWANT identities, re-decoding would be wrong, not merely wasteful: the outer
payload of a passkey token is a WebAuthn envelope
(`AuthenticationDataB64URL`, `ClientDataJSONB64URL`), and the actual claim sits
base64url-encoded inside `clientData.challenge`. `peerVerifyJwt` unwraps it and
that inner object is what `decodeAndVerifyJwt` returns. A stage that re-parsed
segment two would find the envelope and no claim at all, failing every passkey
user.
Stage 3 does not compare an issuer against `req.did`. `requireAuth` assigns
`req.did` from the verified issuer, so the two are the same value read twice —
a check that cannot fail proves nothing. Identity comes from `payload.iss`, and
the claim carries no DID of its own to disagree with it.
The one structural guard stage 3 does keep is that `req.auth` is present.
`requireAuth` sets it only after `verified.verified` is true, so its presence
means verification happened. Its absence means the route was mounted without
stage 1, or mounted with `requireAuthOrNotificationLocalTest`, whose `testMode`
path sets a synthetic `req.did` and no `req.auth`. Either is a wiring mistake
that must not fall through to a handler that sends texts. It is a presence
check, not a second verification, and it carries no timestamp — the signature
already did the verifying.
Checks, each with its own error code:
| Check | Failure |
|---|---|
| `req.auth` present | `500` — route wiring bug, not a client error |
| `payload.claim` is an object | `403 SMS_ACTION_JWT_MISSING_CLAIM` |
| `claim.action` equals the route's action | `403 SMS_ACTION_JWT_WRONG_ACTION` |
| `claim.phoneNumber` normalizes equal to the body's phone (phone routes) | `403 SMS_ACTION_JWT_PHONE_MISMATCH` |
| `payload.iat` within `SMS_ACTION_JWT_MAX_AGE_SEC` (default 300) | `401 SMS_ACTION_JWT_STALE` |
| `payload.exp`, when present, not passed | `401 SMS_ACTION_JWT_EXPIRED` |
| `sha256(jwt)` absent from `sms_action_jwt_use` | `401 SMS_ACTION_JWT_REPLAYED` |
On success the hash is inserted into `sms_action_jwt_use` before the handler
runs, so one token buys one action. A handler that fails afterward does not
release the hash; the client mints a fresh JWT, which it can do freely.
### Action claim contract
The app mints the Bearer JWT with a claim this service defines and consumes:
```json
{
"iss": "did:ethr:0x…",
"iat": 1756270800,
"exp": 1756271100,
"claim": {
"@context": "https://giftopia.tech",
"@type": "SmsNotificationAction",
"action": "register-phone",
"phoneNumber": "+15555550123"
}
}
```
`action` is one of `list-phones`, `register-phone`, `verify-phone`,
`delete-phone`, `authorize-alert-search`. `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 is bound by `action` alone. 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-sent`,
`alert-send-failed`, `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'`.
### `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.
- Lists distinct `user_id` from `sms_alert_authorization_batches`.
- Calls `runDailyAlertSearch(userId, new Date(), {}, "sms")`, 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.
### 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 and their indexes
- `src/middleware/auth.ts` — carry the decoded payload on `req.auth`
- `src/types/express.d.ts` — the widened `auth` type
- `src/env.ts` — the SMS variables, read the same way `ENDORSER_URL` is
- `src/index.ts` — mount `/notify-sms`, add `DELETE` to CORS, start the SMS scheduler
- `src/alertSearch/cursors.ts`, `cycle.ts`, `daily.ts` — channel parameter
- `package.json` — test glob
- `README.md`, `.env.example`, `CHANGELOG.md`
## Phases
- [x] **1. Schema and db modules.** Five tables in `SCHEMA_SQL`;
`smsRegistrationsSqlite`, `smsPhoneLogSqlite`, `smsAlertAuthorizationSqlite`.
Tests: insert/read round trips, the `(user_id, phone_e164)` uniqueness, the
partial unique index on unused SMS JWTs.
- [x] **2. Phone and code utilities.** `util/smsPhoneNumber.ts` and
`util/smsVerificationCode.ts`: E.164 normalization, masking, code mint, HMAC,
timing-safe compare. Tests: normalization table including the
rejections, code is always six digits, compare does not short-circuit.
- [x] **3. Stop discarding the decoded payload.** `requireAuth` sets
`req.auth.payload` from the value `decodeAndVerifyJwt` already returns;
`src/types/express.d.ts` updated. No behavior change to existing routes.
- [x] **4. Twilio sender.** `SmsSender` port, Twilio adapter, console adapter,
unconfigured path. Tests use an injected sender; no test touches the
network.
- [x] **5. Phone routes.** GET / POST / PUT / DELETE wired to stages 12,
mounted in `index.ts`, `DELETE` added to CORS. Enforce
`SMS_MAX_DIDS_PER_PHONE` on both POST and PUT, counting verified rows.
Tests: happy path, wrong code, expired code, attempt exhaustion, re-POST
on a verified number sends nothing, DELETE nulls `phone_e164` in the log
but keeps `phone_hash`, the sixth DID is refused at PUT even when its POST
was accepted under the limit, and unverified rows from other DIDs do not
count toward it. Disclosure tests carry their own weight: a POST rejected
at the limit returns **no** `dids`, a PUT rejected at the limit returns
them, a GET with `?phoneNumber=` returns them only to a DID verified on
that number, and the code is consumed either way so a second PUT with the
same code cannot re-ask.
- [x] **6. SMS alert-authorization route.** POST plus the PUT alias, reusing
`validateAlertAuthorizationBatch`, requiring a verified phone, storing to
the SMS tables. Tests: batch of 100 stored, second batch replaces unused
rows and leaves consumed ones, `409` with no verified phone.
- [x] **7. Per-channel cursors and daily run.** Channel parameter through
`cursors.ts`, `cycle.ts`, `daily.ts`, defaulting to `"fcm"`. Tests: an SMS
run advances only `sms_alert_search_cursors`; the FCM suite passes
unchanged.
- [x] **8. Delivery and scheduler.** `alertSearch/smsNotify.ts` and
`alertSearch/smsScheduler.ts`, started in `index.ts`. Tests: eligibility
predicate, verified-only recipients, one send per number, failures logged
without rolling back consumption, second tick the same day sends nothing.
- [x] **9. Opt-out and caps.** `POST /notify-sms/inbound` for Twilio's webhook:
`STOP` / `UNSUBSCRIBE` marks every registration for that number
`verified = 0` and logs `opt-out`; `START` requires a fresh POST + code;
`HELP` returns a fixed reply. Enforce the per-DID daily send cap. The
webhook authenticates by Twilio's `X-Twilio-Signature`, not by JWT — it is
Twilio calling, not a user.
- [x] **10. Docs.** README sections for the endpoints, the action claim, the SMS
tables, and the environment table; `.env.example`; `CHANGELOG.md`.
- [x] **11. `requireSmsActionJwt`.** The action-authorization stage, built last.
Every check in the stage-3 table, each with its own code, plus the replay
insert and the `sms_action_jwt_use` table. Add the middleware to all five
routes. Tests: one per failure mode, plus the same token rejected on second
use.
- [x] **12. Wrap-up.**
- [x] Prefix **every** line this service prints with an ISO-8601 UTC timestamp.
A `src/util/log.ts` wrapper (`log.info` / `log.error`) that prepends
`new Date().toISOString()` and forwards to `console`, applied to every
existing `console.log` / `console.error` call site in `src/` — routes,
middleware, services, db modules, and all three schedulers. A log line without a timestamp cannot answer "when", which is
the only question anyone asks a log at 3am.
- [x] Flip `SMS_REQUIRE_ACTION_CLAIM` to default `true` and record the flip in
`CHANGELOG.md`. Phase 11 is what makes that default safe.
- [ ] Turn `SMS_ENABLED` on in the deployment environment, with Twilio
credentials, `SMS_CODE_SECRET`, and completed A2P 10DLC registration all
in place.
- [ ] End-to-end pass against a real handset: register, verify, authorize a
batch, receive one daily digest text, `STOP`, confirm no further sends.
- [ ] Confirm `sms_phone_log` holds one row per action taken during that pass,
and that DELETE nulled `phone_e164` while leaving `phone_hash`.
- [ ] Confirm `SMS_DEV_ECHO_CODE` is unset (or `false`) and `NODE_ENV` is not
`test-local` in the deployed environment.
- [ ] Prune-job follow-up filed for `sms_phone_log` retention and
`sms_action_jwt_use` rows.
## 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.
+244 -9
View File
@@ -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
cp .env.example .env
@@ -28,6 +28,8 @@ The server starts on `http://localhost:3003` (or the port in `PORT`). Hot-reload
On first use, the service creates `NOTIFY_DATA_DIR` (default `./data`) and the SQLite file `notify.sqlite` with the required schema.
## FCM
### Authentication
`POST /notifications/register` and `POST /notifications/refresh` require a Bearer JWT. After local JWT verification, the service checks the token with Endorser (`GET /api/report/rateLimits` on `ENDORSER_URL`). Registration and refresh continue only if Endorser accepts the JWT.
@@ -81,6 +83,191 @@ The **delegated** JWT is sent as `Authorization: Bearer`. Pass independent `endo
`buildAlertSearchDigest` maps a retrieve result into structured payload data: per-bucket record arrays, counts, `totalCount`, `hasUpdates`, and Endorser/Partner completion status. It does not invent a notification string; FCM uses only `totalCount` for the short body. Incomplete outcomes (`pagination`, auth, network, etc.) yield `completed: false` and `hasUpdates: false`. Empty successful retrieves are complete with `hasUpdates: false` and do not send FCM.
## SMS
`/notify-sms` delivers the daily alertSearch digest by text as well as by push.
A user registers a phone number, proves possession of it with a 6-digit code,
authorizes a batch of delegated alertSearch JWTs for the SMS channel, and
receives at most one text per local day when that day's retrieval finds updates.
The whole surface is off unless `SMS_ENABLED` is `true`; every route answers
`503 SMS_DISABLED` otherwise, and the SMS scheduler does not start.
### Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | `/notify-sms/phone` | List this DID's registrations; with `?phoneNumber=`, the other DIDs on a number this DID has verified |
| POST | `/notify-sms/phone` | Record a phone for the DID, unverified, and text it a 6-digit code |
| PUT | `/notify-sms/phone` | Match the code and mark the registration verified |
| DELETE | `/notify-sms/phone` | Remove the phone entirely |
| POST, PUT | `/notify-sms/alert-authorization` | Store a delegated JWT batch for the SMS channel |
| POST | `/notify-sms/inbound` | Twilio's webhook for `STOP` / `START` / `HELP` |
`GET /notify-sms/phone` returns the caller's own numbers in full. Adding
`?phoneNumber=` also returns `dids`, every DID holding a verified registration
of that number — but only to a caller who has itself verified that number.
Otherwise `403 SMS_PHONE_NOT_VERIFIED_BY_CALLER`, with no count and no
identities.
`POST /notify-sms/phone` takes `{ "phoneNumber": "+15555550123" }`. A number the
DID has already verified returns `200` with `verified: true` and sends nothing.
Otherwise it mints a code, stores the code's HMAC, and texts the plaintext. The
response carries `{ success, phoneNumber: "+1555*****23", verified: false,
expiresAt }` and never the code. Sends are throttled to three per phone per hour
(counted across every DID) and ten per DID per day.
`PUT /notify-sms/phone` takes `{ "phoneNumber", "code" }`. A miss returns
`400 SMS_CODE_MISMATCH` with `attemptsRemaining`; `SMS_CODE_MAX_ATTEMPTS` misses
return `429 SMS_CODE_ATTEMPTS_EXHAUSTED` and clear the code, so recovery is
another POST.
`DELETE /notify-sms/phone` accepts the number in the body or as `?phoneNumber=`,
because a fair number of proxies drop bodies on DELETE. It removes the row
matching `(user_id, phone_e164)` and sets `phone_e164` to null on that DID's
`sms_phone_log` rows for that number, leaving `phone_hash` and the action
history intact. Deleting a number that is not registered returns
`{ success: true, deleted: false }`, not an error.
`POST /notify-sms/alert-authorization` takes the same body as the FCM twin and
requires at least one verified phone for the DID; without one it returns
`409 SMS_NO_VERIFIED_PHONE`. `PUT` is accepted as an alias, since the FCM twin
is `PUT` and the semantics are replace-not-append either way.
### Action claim
Every `/notify-sms` call is authorized by a Bearer JWT whose claim names the
action and the phone number it applies to:
```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`. `phoneNumber` is required for
`register-phone`, `verify-phone` and `delete-phone`, and required for
`list-phones` only when the request carries the query parameter. The claim holds
no DID — the authenticated identity is `iss` — and no `batchId`.
`@context` is `https://giftopia.tech` with no trailing path, shared with the FCM
setup claim; `@type` is what separates them. `https://giftopia.me` is the app
link that appears in messages and is never the claim namespace.
The middleware chain is `requireAuth` (Bearer JWT, signature verified against
the issuer DID), `requireEndorserAuth` (Endorser accepts the same token), then
`requireSmsActionJwt(action)`, then the handler. The handler performs no
authorization checks of its own. There is no `testMode` bypass: no local-test
path sends real texts to real handsets.
| Failure | Response |
|---|---|
| No claim in the token | `403 SMS_ACTION_JWT_MISSING_CLAIM` |
| Claim names a different action | `403 SMS_ACTION_JWT_WRONG_ACTION` |
| Claim names a different number | `403 SMS_ACTION_JWT_PHONE_MISMATCH` |
| `iat` outside `SMS_ACTION_JWT_MAX_AGE_SEC` | `401 SMS_ACTION_JWT_STALE` |
| `exp` has passed | `401 SMS_ACTION_JWT_EXPIRED` |
| Token already used | `401 SMS_ACTION_JWT_REPLAYED` |
The token's sha256 is recorded before the handler runs, so one token buys one
action. A handler that fails afterward does not release the hash; the client
mints a fresh JWT, which it can do freely. `SMS_REQUIRE_ACTION_CLAIM=false`
turns the stage off entirely.
**This is a client change.** The TimeSafari app sends a plain identity JWT to
`/notifications/*`; a `/notify-sms` call carrying no claim returns
`SMS_ACTION_JWT_MISSING_CLAIM`.
### One phone, several DIDs
Every read and write is scoped by `(user_id, phone_e164)`, so two identities
sharing one handset stay independent: registering, verifying, or deleting under
one DID does not touch the other's row, and each identity runs its own
alertSearch against its own cursor. Two verified DIDs on one handset therefore
receive two texts a day.
`SMS_MAX_DIDS_PER_PHONE` bounds how many DIDs one handset can carry, counting
**verified rows only** — counting every row would let five throwaway DIDs lock
the handset's owner out by registering and never verifying. The count is checked
at POST as an early rejection and again at PUT, which is the check that holds,
since PUT is the moment a row starts consuming a slot.
A blocked POST returns `409 SMS_PHONE_DID_LIMIT` with `limit` and
`verifiedCount` and **no identities**: a POST names any phone number on earth
and proves nothing about it, so answering with DIDs would make the endpoint a
phone-number-to-identity lookup oracle. A blocked PUT returns the same code plus
`dids`, because a PUT that reaches the limit check has already matched a correct
code and the caller is holding the handset. The code is consumed on a limit
rejection exactly as on success, so a fresh answer costs a fresh POST.
Carrier opt-out is the one place the DID boundary is crossed on purpose: `STOP`
arrives with a phone number and no identity, so it marks every registration of
that number unverified. `START` does not restore anything; possession has to be
proved again with a fresh POST and code.
### SMS delivery
`startSmsAlertSearchScheduler()` is a third scheduler alongside the FCM wakeup
and FCM alertSearch passes, with its own interval
(`SMS_ALERT_SEARCH_INTERVAL_MS`, default 5 minutes) and a 150-second initial
offset so the two alertSearch passes do not hit Endorser in the same instant. It
lists distinct `user_id` from `sms_alert_authorization_batches`, calls
`runDailyAlertSearch(userId, now, {}, "sms")`, and then `deliverAlertSearchSms`.
It logs `[SmsAlertSearchScheduler] Pass started` / `Pass completed in`. The
in-flight guard is process-local, exactly like the other two, so a second
replica double-texts.
Eligibility is the same predicate the FCM path uses: the run consumed today's
JWT and the digest is complete with updates. Consumption is what makes later
ticks on the same local day no-ops. The message is
`Gift Economies: you have N new updates. https://giftopia.me Reply STOP to end.`,
kept inside one 160-character GSM-7 segment, since a second segment is a second
charge. Underneath the JWT rule, an `sms_phone_log` count caps sends at one per
handset per identity per day. Send failures are logged and do not roll back
cursor advancement or JWT consumption.
The SMS channel keeps its own JWT inventory and its own cursor table. A user on
both channels produces two Endorser and two Partner queries per day and needs
200 minted JWTs; the counts can differ transiently when one channel's retrieval
fails and the other's succeeds. A shared cursor was rejected: two independent
daily runs against one row means whichever fires first consumes the delta and
the other reports nothing.
### Provider
Sends go to Twilio over plain `fetch` against
`https://api.twilio.com/2010-04-01/Accounts/{SID}/Messages.json` with HTTP Basic
auth and a form-encoded `To` / `From` (or `MessagingServiceSid`) / `Body`. There
is no `twilio` SDK dependency. With configuration absent or incomplete, sends
return `SMS_NOT_CONFIGURED` and the process still boots: a texting outage must
not take push down with it. Under `NODE_ENV=test-local` with no Twilio
credentials, a console adapter prints the message instead of sending it.
`SMS_DEV_ECHO_CODE` adds a `devCode` field to the POST response holding the
plaintext six digits, so a developer with no Twilio account or no carrier
coverage can still exercise POST-then-PUT. It is honored **only** when
`NODE_ENV` is also `test-local`, checked first, so a production process with the
flag set by accident echoes nothing.
**US A2P 10DLC registration is required** before Twilio will carry
application-to-person traffic on a long code. Brand and campaign registration
take days and carry per-campaign fees. Unregistered traffic gets filtered by
carriers silently, with a `sent` status from the API.
The inbound webhook authenticates by Twilio's `X-Twilio-Signature` over the
exact URL Twilio posted to, not by JWT — it is Twilio calling, not a user. Set
`TWILIO_WEBHOOK_URL` to that public URL; behind a proxy or tunnel the request's
own headers do not reliably reproduce it.
## Storage
### Database location
@@ -118,12 +305,41 @@ Table `alert_search_cursors` holds one row per user DID:
- `partner_after_at` — last complete Partner `updatedAt` bound (`afterDate`), or null
- `created_at`, `updated_at`
Table `sms_registrations` holds one row per `(DID, phone)` pair:
- Identity: `id`, `user_id`, `phone_e164` (E.164 normalized)
- Verification: `verified`, `code_hash` (HMAC of the pending code), `code_expires_at`, `code_attempts`, `last_code_sent_at`, `verified_at`
- Timestamps: `created_at`, `updated_at`
Unique on `(user_id, phone_e164)`. Indexes also exist on `user_id`, on
`phone_e164` (opt-out arrives with the number, not the DID), and on
`(user_id, verified)`.
Table `sms_phone_log` records every phone action: `user_id`, `phone_e164`
(nullable, nulled by DELETE), `phone_hash` (HMAC, never nulled), `action`,
`result` (`ok` / `rejected` / `failed`), `detail`, `jwt_hash`,
`provider_message_id`, `created_at`. Actions are `register-requested`,
`code-sent`, `code-send-failed`, `verify-succeeded`, `verify-failed`,
`did-limit-blocked`, `did-limit-disclosed`, `deleted`,
`alert-authorization-stored`, `alert-sent`, `alert-send-failed`, and `opt-out`.
Indexes on `(user_id, created_at)`, `(phone_hash, created_at)`, and
`(action, created_at)`; the throttle counts read the second.
**This is the first table in this database with directly identifying personal
data.** `phone_e164` is nulled on delete; a prune job for rows older than 400
days is a follow-up.
Table `sms_action_jwt_use` holds the replay guard: `jwt_hash` (unique),
`user_id`, `action`, `used_at`. Rows older than
`SMS_ACTION_JWT_MAX_AGE_SEC × 10` are pruned on each SMS scheduler pass; a token
that stale fails the freshness check anyway.
Tables `sms_alert_authorization_batches` / `sms_alert_authorization_jwts` and
`sms_alert_search_cursors` are column-for-column mirrors of their FCM
counterparts, holding the SMS channel's independent JWT inventory and cursor.
The schema is created automatically on startup if the database or tables do not already exist. New tables are added with `CREATE TABLE IF NOT EXISTS`; existing `fcm_registrations` rows are not migrated or altered.
### JSON → SQLite
There is **no automatic migration** from the old JSON file (`fcm-tokens.json`). That format is no longer used. If you still have a local `fcm-tokens.json` from earlier development, it is ignored; re-register devices or import data manually if you need it.
### Backup
Persist or back up the SQLite files under `NOTIFY_DATA_DIR`:
@@ -169,9 +385,24 @@ Set `NOTIFY_DATA_DIR` to a durable directory, or keep the Docker default `/app/d
| `FIREBASE_SERVICE_ACCOUNT_JSON` **or** working ADC | **Required** for FCM (wakeup and AlertSearch) | ADC if JSON unset |
| `NOTIFY_DATA_DIR` | **Durable** path (or volume on `/app/data`) | `./data` (cwd-relative; in Docker that is `/app/data`) |
| `NODE_ENV` | Must **not** be `test-local` (that bypasses ethr JWT expiry) | Docker image sets `production` |
| `SMS_ENABLED` | Optional; `/notify-sms` returns `503 SMS_DISABLED` while off | `false` |
| `SMS_CODE_SECRET` | **Required** when `SMS_ENABLED` (startup fails without it) | None |
| `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN` | **Required** to send; absent means `SMS_NOT_CONFIGURED` per send | None |
| `TWILIO_FROM_NUMBER` **or** `TWILIO_MESSAGING_SERVICE_SID` | One of the two required to send | None |
| `TWILIO_WEBHOOK_URL` | The public URL Twilio posts `/notify-sms/inbound` to; it signs that exact string | Derived from request headers |
| `SMS_CODE_TTL_SEC` | Optional | `600` |
| `SMS_CODE_MAX_ATTEMPTS` | Optional | `5` |
| `SMS_ACTION_JWT_MAX_AGE_SEC` | Optional | `300` |
| `SMS_MAX_DIDS_PER_PHONE` | Optional | `5` |
| `SMS_ALERT_SEARCH_INTERVAL_MS` | Optional | `300000` |
| `SMS_REQUIRE_ACTION_CLAIM` | Must **not** be `false` in production | `true` |
| `SMS_DEV_ECHO_CODE` | Must be unset or `false`; honored only under `NODE_ENV=test-local` | `false` |
| Replicas | **One** process | Not enforced in code |
| Persistent volume | **Required** for Docker so SQLite survives replace | None unless you pass `-v` |
Rotating `SMS_CODE_SECRET` invalidates every pending verification code and
orphans every stored `phone_hash`. Rotate between deploys, not casually.
Optional: `DEFAULT_ENDORSER_API_SERVER` / `DEFAULT_PARTNER_API_SERVER` are honored only if the corresponding `ENDORSER_URL` / `PARTNER_URL` is unset (`src/env.ts`).
### Docker (canonical)
@@ -187,11 +418,15 @@ The image runs `node dist/index.js`. Mount a volume at `/app/data` (or set `NOTI
### 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. **Both schedulers are started** from `src/index.ts` (`startScheduler()` then `startAlertSearchScheduler()`) when the process reaches `* Running backend`. Neither scheduler runs a pass on startup; the first pass is on the 5-minute timer.
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. **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.
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.
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "notification-wakeup-service",
"version": "0.1.14",
"version": "0.2.0",
"private": true,
"type": "module",
"packageManager": "pnpm@11.4.0",
@@ -8,7 +8,8 @@
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"build": "tsc",
"test": "tsx --test src/alertSearch/*.test.ts"
"test": "tsx --test \"test/**/*.test.ts\" \"src/**/*.test.ts\"",
"typecheck": "tsc --noEmit -p tsconfig.test.json"
},
"dependencies": {
"@peculiar/asn1-ecc": "^2.7.0",
+2 -1
View File
@@ -14,6 +14,7 @@ import {
type PartnerAlertSearchData,
type PartnerAlertSearchResponse,
} from "./types.js";
import { log } from "../util/log.js";
export type FetchLike = (
input: string,
@@ -95,7 +96,7 @@ async function getJson(
const reason: AlertSearchFailureReason = isAbortError(err)
? "timeout"
: "network";
console.error(
log.error(
"[AlertSearch]",
source,
reason,
+20 -7
View File
@@ -1,10 +1,15 @@
import { alertSearchCursorsDb } from "../db/alertSearchCursorsSqlite.js";
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";
@@ -21,6 +26,11 @@ export type CursorAdvanceResult = {
partnerAfterAt: string | null;
};
const CURSOR_STORES: Record<AlertSearchChannel, AlertSearchCursorsStore> = {
fcm: alertSearchCursorsDb,
sms: smsAlertSearchCursorsDb,
};
const COMPLETE_OUTCOMES = new Set(["success"]);
function isCompleteOutcome(outcome: string): boolean {
@@ -91,9 +101,10 @@ export function nextPartnerCursorFromResult(
* Omits missing values so first run sends no afterId / afterDate / "0".
*/
export async function loadAlertSearchCursors(
userId: string
userId: string,
channel: AlertSearchChannel = "fcm"
): Promise<StoredAlertSearchCursors> {
const row = await alertSearchCursorsDb.get(userId);
const row = await CURSOR_STORES[channel].get(userId);
const out: StoredAlertSearchCursors = {};
if (row?.endorserAfterId && isAlertSearchCursorUlid(row.endorserAfterId)) {
out.endorserAfterId = row.endorserAfterId;
@@ -110,24 +121,26 @@ export async function loadAlertSearchCursors(
*/
export async function advanceAlertSearchCursors(
userId: string,
result: RetrieveAlertSearchResult
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 alertSearchCursorsDb.setEndorserAfterId(userId, nextEndorser);
await store.setEndorserAfterId(userId, nextEndorser);
endorserAdvanced = true;
}
const nextPartner = nextPartnerCursorFromResult(result.partner);
if (nextPartner !== undefined) {
await alertSearchCursorsDb.setPartnerAfterAt(userId, nextPartner);
await store.setPartnerAfterAt(userId, nextPartner);
partnerAdvanced = true;
}
const stored = await alertSearchCursorsDb.get(userId);
const stored = await store.get(userId);
return {
endorserAdvanced,
partnerAdvanced,
+5 -3
View File
@@ -3,6 +3,7 @@ import {
loadAlertSearchCursors,
type CursorAdvanceResult,
} from "./cursors.js";
import type { AlertSearchChannel } from "./types.js";
import {
retrieveAlertSearch,
type RetrieveAlertSearchInput,
@@ -26,14 +27,15 @@ export type AlertSearchCycleResult = {
*/
export async function runAlertSearchCycle(
userId: string,
input: AlertSearchCycleInput
input: AlertSearchCycleInput,
channel: AlertSearchChannel = "fcm"
): Promise<AlertSearchCycleResult> {
const loaded = await loadAlertSearchCursors(userId);
const loaded = await loadAlertSearchCursors(userId, channel);
const retrieved = await retrieveAlertSearch({
...input,
endorserAfterId: loaded.endorserAfterId,
partnerAfterDate: loaded.partnerAfterDate,
});
const advanced = await advanceAlertSearchCursors(userId, retrieved);
const advanced = await advanceAlertSearchCursors(userId, retrieved, channel);
return { loaded, retrieved, advanced };
}
+25 -10
View File
@@ -1,4 +1,8 @@
import { alertAuthorizationDb } from "../db/alertAuthorizationSqlite.js";
import {
alertAuthorizationDb,
type AlertAuthorizationStore,
} from "../db/alertAuthorizationSqlite.js";
import { smsAlertAuthorizationDb } from "../db/smsAlertAuthorizationSqlite.js";
import {
calendarDayInTimeZone,
isValidIanaTimeZone,
@@ -12,7 +16,15 @@ import {
sourceCompletedDailyRun,
type AlertSearchDigest,
} from "./digest.js";
import type { AlertSearchQueryOutcome } from "./types.js";
import type {
AlertSearchChannel,
AlertSearchQueryOutcome,
} from "./types.js";
const JWT_INVENTORIES: Record<AlertSearchChannel, AlertAuthorizationStore> = {
fcm: alertAuthorizationDb,
sms: smsAlertAuthorizationDb,
};
export class InvalidAlertAuthorizationTimezoneError extends Error {
readonly timezone: string;
@@ -69,9 +81,11 @@ function noJwtResult(
export async function runDailyAlertSearch(
userId: string,
now: Date = new Date(),
cycleInput: DailyAlertSearchCycleInput = {}
cycleInput: DailyAlertSearchCycleInput = {},
channel: AlertSearchChannel = "fcm"
): Promise<DailyAlertSearchResult> {
const batch = await alertAuthorizationDb.getLatestBatch(userId);
const inventory = JWT_INVENTORIES[channel];
const batch = await inventory.getLatestBatch(userId);
if (batch === undefined) {
return noJwtResult(userId, null, null);
}
@@ -83,15 +97,16 @@ export async function runDailyAlertSearch(
Math.floor(now.getTime() / 1000),
batch.timezone
);
const selected = await alertAuthorizationDb.getUnusedForDay(userId, localDay);
const selected = await inventory.getUnusedForDay(userId, localDay);
if (selected === undefined) {
return noJwtResult(userId, localDay, batch.batchId);
}
const cycle = await runAlertSearchCycle(userId, {
...cycleInput,
jwt: selected.jwt,
});
const cycle = await runAlertSearchCycle(
userId,
{ ...cycleInput, jwt: selected.jwt },
channel
);
const digest = buildAlertSearchDigest(cycle.retrieved);
const endorserOutcome = cycle.retrieved.endorser.outcome;
@@ -102,7 +117,7 @@ export async function runDailyAlertSearch(
let consumed = false;
if (completed) {
consumed = await alertAuthorizationDb.consumeUnusedJwt({
consumed = await inventory.consumeUnusedJwt({
id: selected.id,
userId,
});
+2 -1
View File
@@ -9,6 +9,7 @@ import { emptyEndorserData, emptyPartnerData } from "./client.js";
import type { AlertSearchSourceResult, RetrieveAlertSearchResult } from "./retrieve.js";
import type {
AlertSearchClaimRecord,
AlertSearchFailureReason,
AlertSearchJwtWithClaimRecord,
AlertSearchPlanRecord,
AlertSearchProfileRecord,
@@ -215,7 +216,7 @@ describe("buildAlertSearchDigest", () => {
});
it("does not treat pagination or other incomplete outcomes as a completed digest", () => {
const incomplete: AlertSearchQueryOutcome[] = [
const incomplete: AlertSearchFailureReason[] = [
"pagination",
"auth",
"timeout",
+2 -1
View File
@@ -1,6 +1,7 @@
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";
@@ -110,7 +111,7 @@ export async function deliverAlertSearchNotification(
else failed += 1;
} catch (err) {
failed += 1;
console.error("[AlertSearchNotify] Send threw:", errorMessage(err));
log.error("[AlertSearchNotify] Send threw:", errorMessage(err));
}
}
+7 -6
View File
@@ -5,6 +5,7 @@ import {
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;
@@ -46,13 +47,13 @@ export async function runAlertSearchSchedulerPass(
input: AlertSearchSchedulerPassInput = {}
): Promise<AlertSearchSchedulerPassResult> {
if (passInFlight) {
console.log("[AlertSearchScheduler] Pass skipped (already in flight)");
log.info("[AlertSearchScheduler] Pass skipped (already in flight)");
return { skipped: true, userIds: [], attempted: 0, failed: 0 };
}
passInFlight = true;
const passStarted = Date.now();
console.log("[AlertSearchScheduler] Pass started");
log.info("[AlertSearchScheduler] Pass started");
try {
const listUserIds =
@@ -69,7 +70,7 @@ export async function runAlertSearchSchedulerPass(
try {
await notify(daily);
} catch (err) {
console.error(
log.error(
"[AlertSearchScheduler] Notification failed",
userId + ":",
errorMessage(err)
@@ -77,7 +78,7 @@ export async function runAlertSearchSchedulerPass(
}
} catch (err) {
failed += 1;
console.error(
log.error(
"[AlertSearchScheduler] User failed",
userId + ":",
errorMessage(err)
@@ -85,7 +86,7 @@ export async function runAlertSearchSchedulerPass(
}
}
console.log(
log.info(
"[AlertSearchScheduler] Pass completed in",
formatElapsedMs(Date.now() - passStarted) + ",",
`attempted ${userIds.length}, failed ${failed}`
@@ -97,7 +98,7 @@ export async function runAlertSearchSchedulerPass(
failed,
};
} catch (err) {
console.error(
log.error(
"[AlertSearchScheduler] Pass failed in",
formatElapsedMs(Date.now() - passStarted) + ":",
errorMessage(err)
+162
View File
@@ -0,0 +1,162 @@
import { smsPhoneLogDb } from "../db/smsPhoneLogSqlite.js";
import { smsRegistrationsDb } from "../db/smsRegistrationsSqlite.js";
import { smsConfig } from "../env.js";
import { sendSms, type SmsSender } from "../services/smsService.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 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;
const DAY_MS = 24 * 60 * 60 * 1000;
export type AlertSearchSmsNotifyDeps = {
listPhones?: (userId: string) => Promise<string[]>;
send?: SmsSender;
};
export type AlertSearchSmsNotifyResult = {
eligible: boolean;
sent: number;
failed: number;
};
export function alertSearchSmsBody(totalCount: number): string {
return (
`Gift Economies: you have ${totalCount} new updates. ` +
`${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 local 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
);
}
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 };
}
const secret = smsConfig().codeSecret;
if (secret === undefined) {
log.error("[SmsAlertSearchNotify] SMS_CODE_SECRET is not set; skipping");
return { eligible: false, sent: 0, failed: 0 };
}
const body = alertSearchSmsBody(digest.totalCount);
const listPhones = deps.listPhones ?? defaultListPhones;
const send = deps.send ?? sendSms;
const phones = await listPhones(result.userId);
let sent = 0;
let failed = 0;
for (const phoneE164 of phones) {
const phoneHash = hashPhoneNumber(phoneE164, secret);
const alreadySent = await smsPhoneLogDb.countByUserAndPhoneHashSince(
result.userId,
phoneHash,
["alert-sent"],
new Date(Date.now() - DAY_MS).toISOString()
);
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,
});
}
} 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 };
}
+160
View File
@@ -0,0 +1,160 @@
import { smsActionJwtUseDb } from "../db/smsActionJwtUseSqlite.js";
import { smsAlertAuthorizationDb } from "../db/smsAlertAuthorizationSqlite.js";
import { smsConfig } from "../env.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 = {
listUserIds?: () => Promise<string[]>;
runDaily?: (userId: string) => Promise<DailyAlertSearchResult>;
notify?: (result: DailyAlertSearchResult) => Promise<unknown>;
prune?: () => Promise<unknown>;
};
export type SmsAlertSearchSchedulerPassResult = {
skipped: boolean;
userIds: string[];
attempted: number;
failed: 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 authorized that channel.
* 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 };
}
passInFlight = true;
const passStarted = Date.now();
log.info("[SmsAlertSearchScheduler] Pass started");
try {
const listUserIds =
input.listUserIds ?? (() => smsAlertAuthorizationDb.listDistinctUserIds());
const runDaily =
input.runDaily ??
((userId: string) => runDailyAlertSearch(userId, new Date(), {}, "sms"));
const notify = input.notify ?? deliverAlertSearchSms;
const prune = input.prune ?? defaultPrune;
try {
await prune();
} catch (err) {
log.error(
"[SmsAlertSearchScheduler] Prune failed:",
errorMessage(err)
);
}
const userIds = await listUserIds();
let failed = 0;
for (const userId of userIds) {
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}, failed ${failed}`
);
return { skipped: false, userIds, attempted: userIds.length, failed };
} 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;
}
+6
View File
@@ -1,3 +1,9 @@
/**
* 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.
+203 -176
View File
@@ -90,199 +90,226 @@ function toBatchRecord(row: BatchDbRow): AlertAuthorizationBatchRecord {
};
}
export const alertAuthorizationDb = {
/**
* 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;
timezone: string;
jwts: AlertAuthorizationJwtInput[];
}): Promise<{
batch: AlertAuthorizationBatchRecord;
storedCount: number;
unusedCount: number;
}> {
const connection = getDatabase();
const now = new Date().toISOString();
const batchPk = randomUUID();
/**
* 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;
};
const run = connection.transaction(() => {
connection
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;
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, timezone, created_at
) VALUES (?, ?, ?, ?, ?)
`
)
.run(batchPk, input.userId, input.batchId, input.timezone, 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,
timezone: input.timezone,
createdAt: now,
},
storedCount: input.jwts.length,
unusedCount,
};
},
async getUnusedForDay(
userId: string,
day: string
): Promise<AlertAuthorizationJwtRecord | undefined> {
const row = getDatabase()
.prepare(
`
DELETE FROM alert_authorization_jwts
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 = ?
`
)
.run(input.userId, ALERT_JWT_STATUS_UNUSED);
.get(userId, ALERT_JWT_STATUS_UNUSED) as { n: number };
return row.n;
},
connection
async listDistinctUserIds(): Promise<string[]> {
const rows = getDatabase()
.prepare(
`
DELETE FROM alert_authorization_batches
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 id, user_id, batch_id, timezone, created_at
FROM ${tables.batches}
WHERE user_id = ?
AND id NOT IN (
SELECT DISTINCT batch_pk FROM alert_authorization_jwts
WHERE user_id = ?
)
ORDER BY created_at DESC
LIMIT 1
`
)
.run(input.userId, input.userId);
.get(userId) as BatchDbRow | undefined;
return row === undefined ? undefined : toBatchRecord(row);
},
connection
async getJwtById(
id: string
): Promise<AlertAuthorizationJwtRecord | undefined> {
const row = getDatabase()
.prepare(
`
INSERT INTO alert_authorization_batches (
id, user_id, batch_id, timezone, created_at
) VALUES (?, ?, ?, ?, ?)
SELECT ${JWT_COLUMNS} FROM ${tables.jwts}
WHERE id = ?
`
)
.run(batchPk, input.userId, input.batchId, input.timezone, now);
.get(id) as JwtDbRow | undefined;
return row === undefined ? undefined : toJwtRecord(row);
},
const insertJwt = connection.prepare(
`
INSERT INTO alert_authorization_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
/**
* 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;
},
};
}
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,
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 alert_authorization_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 alert_authorization_jwts
WHERE user_id = ? AND status = ?
`
)
.get(userId, ALERT_JWT_STATUS_UNUSED) as { n: number };
return row.n;
},
async listDistinctUserIds(): Promise<string[]> {
const rows = getDatabase()
.prepare(
`
SELECT DISTINCT user_id
FROM alert_authorization_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 id, user_id, batch_id, timezone, created_at
FROM alert_authorization_batches
WHERE user_id = ?
ORDER BY created_at 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 alert_authorization_jwts
WHERE id = ?
`
)
.get(id) as JwtDbRow | undefined;
return row === undefined ? undefined : toJwtRecord(row);
},
/**
* 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 alert_authorization_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);
+69 -48
View File
@@ -26,11 +26,18 @@ function toRecord(row: CursorDbRow): AlertSearchCursorRecord {
};
}
function ensureRow(userId: string, now: string): void {
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 alert_search_cursors (
INSERT INTO ${table} (
user_id, endorser_after_id, partner_after_at, created_at, updated_at
) VALUES (?, NULL, NULL, ?, ?)
ON CONFLICT(user_id) DO NOTHING
@@ -39,55 +46,69 @@ function ensureRow(userId: string, now: string): void {
.run(userId, now, now);
}
export const alertSearchCursorsDb = {
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 alert_search_cursors
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(userId, now);
connection
/**
* 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(
`
UPDATE alert_search_cursors
SET endorser_after_id = ?, updated_at = ?
SELECT user_id, endorser_after_id, partner_after_at, created_at, updated_at
FROM ${table}
WHERE user_id = ?
`
)
.run(afterId, now, userId);
});
run();
},
.get(userId) as CursorDbRow | undefined;
return row === undefined ? undefined : toRecord(row);
},
async setPartnerAfterAt(userId: string, afterAt: string): Promise<void> {
const now = new Date().toISOString();
const connection = getDatabase();
const run = connection.transaction(() => {
ensureRow(userId, now);
connection
.prepare(
`
UPDATE alert_search_cursors
SET partner_after_at = ?, updated_at = ?
WHERE user_id = ?
`
)
.run(afterAt, now, userId);
});
run();
},
};
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);
+57
View File
@@ -0,0 +1,57 @@
import { randomUUID } from "node:crypto";
import { getDatabase } from "./sqlite.js";
/** Keyed on the token hash, so two identities acting on one number never collide. */
export const smsActionJwtUseDb = {
/**
* Claim a token for one action. Returns false when the hash is already
* present. The insert itself is the check, so two concurrent requests
* carrying one token cannot both win.
*/
async claim(input: {
jwtHash: string;
userId: string;
action: string;
}): Promise<boolean> {
try {
getDatabase()
.prepare(
`
INSERT INTO sms_action_jwt_use (id, jwt_hash, user_id, action, used_at)
VALUES (?, ?, ?, ?, ?)
`
)
.run(
randomUUID(),
input.jwtHash,
input.userId,
input.action,
new Date().toISOString()
);
return true;
} catch (err) {
if (
err instanceof Error &&
err.message.includes("UNIQUE constraint failed")
) {
return false;
}
throw err;
}
},
/** A token this stale fails the freshness check anyway, so the row protects nothing. */
async pruneOlderThan(cutoffIso: string): Promise<number> {
const result = getDatabase()
.prepare(`DELETE FROM sms_action_jwt_use WHERE used_at < ?`)
.run(cutoffIso);
return result.changes;
},
async count(): Promise<number> {
const row = getDatabase()
.prepare(`SELECT COUNT(*) AS n FROM sms_action_jwt_use`)
.get() as { n: number };
return row.n;
},
};
+17
View File
@@ -0,0 +1,17 @@
import {
createAlertAuthorizationStore,
type AlertAuthorizationStore,
type AlertAuthorizationTables,
} from "./alertAuthorizationSqlite.js";
export const SMS_ALERT_AUTHORIZATION_TABLES: AlertAuthorizationTables = {
batches: "sms_alert_authorization_batches",
jwts: "sms_alert_authorization_jwts",
};
/**
* The SMS channel's own JWT inventory. Same behavior as the FCM store over its
* own tables, so an SMS batch upload never disturbs push credentials.
*/
export const smsAlertAuthorizationDb: AlertAuthorizationStore =
createAlertAuthorizationStore(SMS_ALERT_AUTHORIZATION_TABLES);
+191
View File
@@ -0,0 +1,191 @@
import { randomUUID } from "node:crypto";
import type {
SmsPhoneLogAction,
SmsPhoneLogEntry,
SmsPhoneLogResult,
} from "../models/smsRegistration.js";
import { getDatabase } from "./sqlite.js";
type DbRow = {
id: string;
user_id: string;
phone_e164: string | null;
phone_hash: string;
action: string;
result: string;
detail: string | null;
jwt_hash: string | null;
provider_message_id: string | null;
created_at: string;
};
const ROW_COLUMNS =
"id, user_id, phone_e164, phone_hash, action, result, detail, " +
"jwt_hash, provider_message_id, created_at";
function toRecord(row: DbRow): SmsPhoneLogEntry {
return {
id: row.id,
userId: row.user_id,
phoneE164: row.phone_e164 ?? undefined,
phoneHash: row.phone_hash,
action: row.action as SmsPhoneLogAction,
result: row.result as SmsPhoneLogResult,
detail: row.detail ?? undefined,
jwtHash: row.jwt_hash ?? undefined,
providerMessageId: row.provider_message_id ?? undefined,
createdAt: row.created_at,
};
}
export type SmsPhoneLogInput = {
userId: string;
phoneE164?: string;
phoneHash: string;
action: SmsPhoneLogAction;
result: SmsPhoneLogResult;
detail?: string;
jwtHash?: string;
providerMessageId?: string;
};
/** Append-only record of every phone action, plus the counts the throttles read. */
export const smsPhoneLogDb = {
async append(input: SmsPhoneLogInput): Promise<SmsPhoneLogEntry> {
const now = new Date().toISOString();
const id = randomUUID();
getDatabase()
.prepare(
`
INSERT INTO sms_phone_log (
id, user_id, phone_e164, phone_hash, action, result,
detail, jwt_hash, provider_message_id, created_at
) VALUES (
@id, @user_id, @phone_e164, @phone_hash, @action, @result,
@detail, @jwt_hash, @provider_message_id, @created_at
)
`
)
.run({
id,
user_id: input.userId,
phone_e164: input.phoneE164 ?? null,
phone_hash: input.phoneHash,
action: input.action,
result: input.result,
detail: input.detail ?? null,
jwt_hash: input.jwtHash ?? null,
provider_message_id: input.providerMessageId ?? null,
created_at: now,
});
return {
id,
userId: input.userId,
phoneE164: input.phoneE164,
phoneHash: input.phoneHash,
action: input.action,
result: input.result,
detail: input.detail,
jwtHash: input.jwtHash,
providerMessageId: input.providerMessageId,
createdAt: now,
};
},
async listByUserId(
userId: string,
limit = 100
): Promise<SmsPhoneLogEntry[]> {
const rows = getDatabase()
.prepare(
`
SELECT ${ROW_COLUMNS} FROM sms_phone_log
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT ?
`
)
.all(userId, limit) as DbRow[];
return rows.map(toRecord);
},
/**
* Counted from phone_hash, not user_id: a per-identity counter is defeated by
* minting more identities, so this throttle is deliberately cross-DID.
*/
async countByPhoneHashSince(
phoneHash: string,
actions: SmsPhoneLogAction[],
sinceIso: string
): Promise<number> {
if (actions.length === 0) return 0;
const placeholders = actions.map(() => "?").join(", ");
const row = getDatabase()
.prepare(
`
SELECT COUNT(*) AS n FROM sms_phone_log
WHERE phone_hash = ? AND created_at >= ?
AND action IN (${placeholders})
`
)
.get(phoneHash, sinceIso, ...actions) as { n: number };
return row.n;
},
/** The backstop on daily alert sends: one handset, one identity, one window. */
async countByUserAndPhoneHashSince(
userId: string,
phoneHash: string,
actions: SmsPhoneLogAction[],
sinceIso: string
): Promise<number> {
if (actions.length === 0) return 0;
const placeholders = actions.map(() => "?").join(", ");
const row = getDatabase()
.prepare(
`
SELECT COUNT(*) AS n FROM sms_phone_log
WHERE user_id = ? AND phone_hash = ? AND created_at >= ?
AND action IN (${placeholders})
`
)
.get(userId, phoneHash, sinceIso, ...actions) as { n: number };
return row.n;
},
async countByUserSince(
userId: string,
actions: SmsPhoneLogAction[],
sinceIso: string
): Promise<number> {
if (actions.length === 0) return 0;
const placeholders = actions.map(() => "?").join(", ");
const row = getDatabase()
.prepare(
`
SELECT COUNT(*) AS n FROM sms_phone_log
WHERE user_id = ? AND created_at >= ?
AND action IN (${placeholders})
`
)
.get(userId, sinceIso, ...actions) as { n: number };
return row.n;
},
/**
* Forget whose number it was, keep what happened. DELETE nulls phone_e164 for
* one DID's rows; phone_hash and the action history are left intact.
*/
async scrubPhoneNumber(userId: string, phoneE164: string): Promise<number> {
const result = getDatabase()
.prepare(
`
UPDATE sms_phone_log
SET phone_e164 = NULL
WHERE user_id = ? AND phone_e164 = ?
`
)
.run(userId, phoneE164);
return result.changes;
},
};
+261
View File
@@ -0,0 +1,261 @@
import { randomUUID } from "node:crypto";
import type { SmsRegistration } from "../models/smsRegistration.js";
import { getDatabase } from "./sqlite.js";
type DbRow = {
id: string;
user_id: string;
phone_e164: string;
verified: number;
code_hash: string | null;
code_expires_at: string | null;
code_attempts: number;
last_code_sent_at: string | null;
verified_at: string | null;
created_at: string;
updated_at: string;
};
const ROW_COLUMNS =
"id, user_id, phone_e164, verified, code_hash, code_expires_at, " +
"code_attempts, last_code_sent_at, verified_at, created_at, updated_at";
function toRecord(row: DbRow): SmsRegistration {
return {
id: row.id,
userId: row.user_id,
phoneE164: row.phone_e164,
verified: row.verified !== 0,
codeHash: row.code_hash ?? undefined,
codeExpiresAt: row.code_expires_at ?? undefined,
codeAttempts: row.code_attempts,
lastCodeSentAt: row.last_code_sent_at ?? undefined,
verifiedAt: row.verified_at ?? undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
/**
* Every read and write is scoped by (user_id, phone_e164). Two DIDs sharing one
* handset stay independent; only carrier opt-out crosses the DID boundary.
*/
export const smsRegistrationsDb = {
async get(
userId: string,
phoneE164: string
): Promise<SmsRegistration | undefined> {
const row = getDatabase()
.prepare(
`
SELECT ${ROW_COLUMNS} FROM sms_registrations
WHERE user_id = ? AND phone_e164 = ?
`
)
.get(userId, phoneE164) as DbRow | undefined;
return row === undefined ? undefined : toRecord(row);
},
async listByUserId(userId: string): Promise<SmsRegistration[]> {
const rows = getDatabase()
.prepare(
`
SELECT ${ROW_COLUMNS} FROM sms_registrations
WHERE user_id = ?
ORDER BY created_at
`
)
.all(userId) as DbRow[];
return rows.map(toRecord);
},
async listVerifiedByUserId(userId: string): Promise<SmsRegistration[]> {
const rows = getDatabase()
.prepare(
`
SELECT ${ROW_COLUMNS} FROM sms_registrations
WHERE user_id = ? AND verified = 1
ORDER BY created_at
`
)
.all(userId) as DbRow[];
return rows.map(toRecord);
},
/** DIDs holding a verified registration of this number, ordered for stable output. */
async listVerifiedDidsForPhone(phoneE164: string): Promise<string[]> {
const rows = getDatabase()
.prepare(
`
SELECT DISTINCT user_id FROM sms_registrations
WHERE phone_e164 = ? AND verified = 1
ORDER BY user_id
`
)
.all(phoneE164) as { user_id: string }[];
return rows.map((row) => row.user_id);
},
/**
* Verified rows only. Counting unverified rows would let five throwaway DIDs
* lock the handset's actual owner out of registering it.
*/
async countVerifiedForPhone(
phoneE164: string,
excludeUserId?: string
): Promise<number> {
const row =
excludeUserId === undefined
? (getDatabase()
.prepare(
`
SELECT COUNT(*) AS n FROM sms_registrations
WHERE phone_e164 = ? AND verified = 1
`
)
.get(phoneE164) as { n: number })
: (getDatabase()
.prepare(
`
SELECT COUNT(*) AS n FROM sms_registrations
WHERE phone_e164 = ? AND verified = 1 AND user_id != ?
`
)
.get(phoneE164, excludeUserId) as { n: number });
return row.n;
},
/** Insert or refresh the pending code on an existing unverified row. */
async upsertPendingCode(input: {
userId: string;
phoneE164: string;
codeHash: string;
codeExpiresAt: string;
sentAt: string;
}): Promise<SmsRegistration> {
const connection = getDatabase();
const now = input.sentAt;
connection
.prepare(
`
INSERT INTO sms_registrations (
id, user_id, phone_e164, verified, code_hash, code_expires_at,
code_attempts, last_code_sent_at, verified_at, created_at, updated_at
) VALUES (
@id, @user_id, @phone_e164, 0, @code_hash, @code_expires_at,
0, @last_code_sent_at, NULL, @created_at, @updated_at
)
ON CONFLICT(user_id, phone_e164) DO UPDATE SET
code_hash = excluded.code_hash,
code_expires_at = excluded.code_expires_at,
code_attempts = 0,
last_code_sent_at = excluded.last_code_sent_at,
updated_at = excluded.updated_at
`
)
.run({
id: randomUUID(),
user_id: input.userId,
phone_e164: input.phoneE164,
code_hash: input.codeHash,
code_expires_at: input.codeExpiresAt,
last_code_sent_at: now,
created_at: now,
updated_at: now,
});
const stored = await this.get(input.userId, input.phoneE164);
if (stored === undefined) {
throw new Error("sms_registrations upsert did not produce a row");
}
return stored;
},
/** Flip to verified and clear the code. Only the matching pending row moves. */
async markVerified(userId: string, phoneE164: string): Promise<boolean> {
const now = new Date().toISOString();
const result = getDatabase()
.prepare(
`
UPDATE sms_registrations
SET verified = 1, verified_at = ?, code_hash = NULL,
code_expires_at = NULL, code_attempts = 0, updated_at = ?
WHERE user_id = ? AND phone_e164 = ?
`
)
.run(now, now, userId, phoneE164);
return result.changes === 1;
},
async incrementCodeAttempts(
userId: string,
phoneE164: string
): Promise<number> {
const now = new Date().toISOString();
const connection = getDatabase();
const run = connection.transaction(() => {
connection
.prepare(
`
UPDATE sms_registrations
SET code_attempts = code_attempts + 1, updated_at = ?
WHERE user_id = ? AND phone_e164 = ?
`
)
.run(now, userId, phoneE164);
return connection
.prepare(
`
SELECT code_attempts AS n FROM sms_registrations
WHERE user_id = ? AND phone_e164 = ?
`
)
.get(userId, phoneE164) as { n: number } | undefined;
});
return run()?.n ?? 0;
},
/** Drop the pending code without verifying. Recovery is another POST. */
async clearPendingCode(userId: string, phoneE164: string): Promise<void> {
const now = new Date().toISOString();
getDatabase()
.prepare(
`
UPDATE sms_registrations
SET code_hash = NULL, code_expires_at = NULL, updated_at = ?
WHERE user_id = ? AND phone_e164 = ?
`
)
.run(now, userId, phoneE164);
},
async delete(userId: string, phoneE164: string): Promise<boolean> {
const result = getDatabase()
.prepare(
`
DELETE FROM sms_registrations
WHERE user_id = ? AND phone_e164 = ?
`
)
.run(userId, phoneE164);
return result.changes > 0;
},
/**
* Carrier opt-out arrives with a number and no identity, so it crosses the DID
* boundary on purpose. Returns how many registrations were switched off.
*/
async unverifyAllForPhone(phoneE164: string): Promise<number> {
const now = new Date().toISOString();
const result = getDatabase()
.prepare(
`
UPDATE sms_registrations
SET verified = 0, verified_at = NULL, code_hash = NULL,
code_expires_at = NULL, updated_at = ?
WHERE phone_e164 = ? AND verified = 1
`
)
.run(now, phoneE164);
return result.changes;
},
};
+114
View File
@@ -85,6 +85,120 @@ CREATE TABLE IF NOT EXISTS alert_search_cursors (
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,
action TEXT NOT NULL,
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,
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,
timezone TEXT NOT NULL,
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,
day TEXT NOT NULL,
jwt TEXT NOT NULL,
nbf INTEGER NOT NULL,
exp INTEGER NOT NULL,
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_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;
+77
View File
@@ -19,3 +19,80 @@ 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";
function booleanEnv(name: string, fallback: boolean): boolean {
const raw = process.env[name];
if (raw === undefined || raw.length === 0) return fallback;
return raw.toLowerCase() === "true" || raw === "1";
}
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;
}
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;
};
/**
* 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: booleanEnv("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: booleanEnv("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 && booleanEnv("SMS_DEV_ECHO_CODE", false),
};
}
/**
* 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.");
}
}
+12 -3
View File
@@ -1,11 +1,14 @@
import "./env.js";
import { assertSmsConfigured, smsConfig } from "./env.js";
import cors from "cors";
import express from "express";
import "./services/firebase.js";
import { debugRouter } from "./routes/debug.js";
import { notificationsRouter } from "./routes/notifications.js";
import { notifySmsRouter } from "./routes/notifySms.js";
import { startAlertSearchScheduler } from "./alertSearch/scheduler.js";
import { startSmsAlertSearchScheduler } from "./alertSearch/smsScheduler.js";
import { startScheduler } from "./scheduler.js";
import { log } from "./util/log.js";
const app = express();
const port = Number(process.env.PORT) || 3003;
@@ -13,7 +16,7 @@ const port = Number(process.env.PORT) || 3003;
app.use(
cors({
origin: true,
methods: ["GET", "POST", "PUT", "OPTIONS"],
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "ngrok-skip-browser-warning"],
}),
);
@@ -26,13 +29,19 @@ app.get("/health", (_req, res) => {
});
app.use("/notifications", notificationsRouter);
app.use("/notify-sms", notifySmsRouter);
// Only include on test environments
// app.use("/debug", debugRouter);
assertSmsConfigured();
startScheduler();
startAlertSearchScheduler();
if (smsConfig().enabled) {
startSmsAlertSearchScheduler();
}
app.listen(port, () => {
console.log("* Running backend");
log.info("* Running backend");
});
+19 -12
View File
@@ -1,10 +1,17 @@
import type { NextFunction, Request, Response } from "express";
import { checkAuth } from "../services/endorserClient.js";
import { decodeAndVerifyJwt } from "../vc/index.js";
import { log } from "../util/log.js";
export type AuthContext = {
did: string;
jwt: string;
/**
* The verifier's own output for this one token. Carried because a passkey
* (did:peer) token's real claim sits inside the WebAuthn challenge, so a later
* stage that re-parsed segment two would find the envelope and no claim.
*/
payload: Record<string, unknown>;
};
type ClientErrorBody = {
@@ -50,7 +57,7 @@ export async function requireAuth(
): Promise<void> {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
console.log("[Auth] Authentication failed");
log.info("[Auth] Authentication failed");
res.status(401).json({
success: false,
message: 'Missing "Bearer JWT" in Authorization header.',
@@ -64,8 +71,8 @@ export async function requireAuth(
const verified = await decodeAndVerifyJwt(token);
if (!verified.verified) {
const errorTime = new Date().toISOString();
console.log("[Auth] Authentication failed");
console.error(
log.info("[Auth] Authentication failed");
log.error(
"[Auth] Invalid JWT at",
errorTime + ":",
verified
@@ -82,13 +89,13 @@ export async function requireAuth(
const did = verified.issuer;
req.did = did;
req.jwt = token;
req.auth = { did, jwt: token };
console.log("[Auth] Authenticated user:", did);
req.auth = { did, jwt: token, payload: verified.payload };
log.info("[Auth] Authenticated user:", did);
next();
} catch (err) {
const errorTime = new Date().toISOString();
console.log("[Auth] Authentication failed");
console.error(
log.info("[Auth] Authentication failed");
log.error(
"[Auth] Invalid JWT at",
errorTime + ":",
err
@@ -110,7 +117,7 @@ export async function requireAuthOrNotificationLocalTest(
): Promise<void> {
if (isNotificationLocalTestBypass(req)) {
req.did = LOCAL_TEST_USER_ID;
console.log("[Auth] Local notification test bypass");
log.info("[Auth] Local notification test bypass");
next();
return;
}
@@ -142,8 +149,8 @@ export async function requireEndorserAuth(
const did = req.did ?? "(unknown)";
if (result.reason === "unavailable") {
console.log("[Auth] Endorser unavailable");
console.error(
log.info("[Auth] Endorser unavailable");
log.error(
"[Auth] Endorser auth check unavailable at",
errorTime + ", did:",
did
@@ -156,8 +163,8 @@ export async function requireEndorserAuth(
return;
}
console.log("[Auth] Endorser verification failed");
console.error(
log.info("[Auth] Endorser verification failed");
log.error(
"[Auth] Endorser rejected JWT at",
errorTime + ", did:",
did
+179
View File
@@ -0,0 +1,179 @@
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",
] as const;
export type SmsAction = (typeof SMS_ACTIONS)[number];
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 (action !== "authorize-alert-search") {
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();
};
}
+50
View File
@@ -0,0 +1,50 @@
/** 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-sent"
| "alert-send-failed"
| "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 -7
View File
@@ -10,6 +10,7 @@ import {
} from "../services/pushService.js";
import { formatElapsedMs } from "../util/formatElapsed.js";
import { maskToken } from "../util/maskToken.js";
import { log } from "../util/log.js";
export const debugRouter: express.Router = Router();
@@ -59,11 +60,11 @@ debugRouter.get("/device/:token", requireAuth, async (req, res) => {
Array.isArray(tokenParam) ? tokenParam[0] : tokenParam
);
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 });
if (row === undefined) {
console.log(
log.info(
"[DebugEndpoint] Device lookup not found in",
formatElapsedMs(Date.now() - started) + ",",
"token suffix:",
@@ -74,7 +75,7 @@ debugRouter.get("/device/:token", requireAuth, async (req, res) => {
}
res.json(deviceDebugPayload(row));
console.log(
log.info(
"[DebugEndpoint] Device lookup completed in",
formatElapsedMs(Date.now() - started) + ",",
"token suffix:",
@@ -92,7 +93,7 @@ debugRouter.post("/send-wakeup", requireAuthOrNotificationLocalTest, async (req,
const { fcmToken } = req.body as { fcmToken?: unknown };
if (typeof fcmToken !== "string" || fcmToken.length === 0) {
console.log(
log.info(
"[DebugEndpoint] Send-wakeup rejected in",
formatElapsedMs(Date.now() - started) + ":",
"fcmToken is required"
@@ -105,11 +106,11 @@ debugRouter.post("/send-wakeup", requireAuthOrNotificationLocalTest, async (req,
}
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 });
if (row === undefined) {
console.log(
log.info(
"[DebugEndpoint] Send-wakeup rejected in",
formatElapsedMs(Date.now() - started) + ",",
"token suffix:",
@@ -134,7 +135,7 @@ debugRouter.post("/send-wakeup", requireAuthOrNotificationLocalTest, async (req,
fcmTokenSuffix: suffix,
});
console.log(
log.info(
"[DebugEndpoint] Send-wakeup completed in",
formatElapsedMs(Date.now() - started) + ",",
success ? "success" : result + ",",
+17 -16
View File
@@ -14,6 +14,7 @@ import {
import { identitySupportsDelegatedJwtBatch } from "../vc/index.js";
import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js";
import { maskToken } from "../util/maskToken.js";
import { log } from "../util/log.js";
export const notificationsRouter: express.Router = Router();
@@ -45,7 +46,7 @@ notificationsRouter.post(
? fcmToken
: undefined;
console.log(
log.info(
"[Refresh] Request received",
canonicalDeviceId !== undefined ? `deviceId=${canonicalDeviceId}` : "",
token !== undefined ? `token suffix=${maskToken(token)}` : ""
@@ -55,7 +56,7 @@ notificationsRouter.post(
(canonicalDeviceId === undefined || canonicalDeviceId.length === 0) &&
token === undefined
) {
console.log(
log.info(
"[Refresh] Rejected in",
formatElapsedMs(Date.now() - started) + ":",
"deviceId or fcmToken is required"
@@ -70,7 +71,7 @@ notificationsRouter.post(
});
if (device === undefined) {
console.log(
log.info(
"[Refresh] Device not found in",
formatElapsedMs(Date.now() - started),
canonicalDeviceId !== undefined
@@ -87,7 +88,7 @@ notificationsRouter.post(
shouldNotify: true,
nextNotifications: [{ timestamp: now + 600000 }],
});
console.log(
log.info(
"[Refresh] Completed in",
formatElapsedMs(Date.now() - started) + ",",
"deviceId=" + device.deviceId + ",",
@@ -108,11 +109,11 @@ notificationsRouter.put(
return;
}
console.log("[AlertAuthorization] Request received, user=" + userId);
log.info("[AlertAuthorization] Request received, user=" + userId);
if (!identitySupportsDelegatedJwtBatch(userId)) {
const failure = unsupportedIdentityFailure(userId);
console.log(
log.info(
"[AlertAuthorization] Rejected in",
formatElapsedMs(Date.now() - started) + ":",
failure.error
@@ -129,7 +130,7 @@ notificationsRouter.put(
const body = (req.body ?? {}) as AlertAuthorizationRequestBody;
const validated = await validateAlertAuthorizationBatch(userId, body);
if (!validated.ok) {
console.log(
log.info(
"[AlertAuthorization] Rejected in",
formatElapsedMs(Date.now() - started) + ":",
validated.error + ",",
@@ -158,14 +159,14 @@ notificationsRouter.put(
storedCount: stored.storedCount,
unusedCount: stored.unusedCount,
});
console.log(
log.info(
"[AlertAuthorization] Completed in",
formatElapsedMs(Date.now() - started) + ",",
"batchId=" + stored.batch.batchId + ",",
"stored=" + stored.storedCount
);
} catch (err) {
console.error(
log.error(
"[AlertAuthorization] Failed in",
formatElapsedMs(Date.now() - started) + ":",
errorMessage(err)
@@ -195,7 +196,7 @@ notificationsRouter.post(
typeof req.body === "object" &&
"userId" in req.body
) {
console.log(
log.info(
"[Register] Rejected in",
formatElapsedMs(Date.now() - started) + ":",
"userId must not be sent in the request body"
@@ -214,7 +215,7 @@ notificationsRouter.post(
};
if (typeof deviceId !== "string" || deviceId.trim().length === 0) {
console.log(
log.info(
"[Register] Rejected in",
formatElapsedMs(Date.now() - started) + ":",
"deviceId is required"
@@ -223,7 +224,7 @@ notificationsRouter.post(
return;
}
if (typeof fcmToken !== "string" || fcmToken.length === 0) {
console.log(
log.info(
"[Register] Rejected in",
formatElapsedMs(Date.now() - started) + ":",
"fcmToken is required"
@@ -232,7 +233,7 @@ notificationsRouter.post(
return;
}
if (typeof platform !== "string" || platform.length === 0) {
console.log(
log.info(
"[Register] Rejected in",
formatElapsedMs(Date.now() - started) + ":",
"platform is required"
@@ -242,7 +243,7 @@ notificationsRouter.post(
}
const canonicalDeviceId = deviceId.trim();
console.log(
log.info(
"[Register] Request received,",
"deviceId=" + canonicalDeviceId + ",",
"platform=" + platform + ",",
@@ -267,14 +268,14 @@ notificationsRouter.post(
updatedAt: new Date(),
});
res.sendStatus(200);
console.log(
log.info(
"[Register] Completed in",
formatElapsedMs(Date.now() - started) + ",",
"deviceId=" + canonicalDeviceId + ",",
"action=" + action
);
} catch (err) {
console.error(
log.error(
"[Register] Failed in",
formatElapsedMs(Date.now() - started) + ",",
"deviceId=" + canonicalDeviceId + ":",
+770
View File
@@ -0,0 +1,770 @@
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,
unsupportedIdentityFailure,
validateAlertAuthorizationBatch,
} from "../services/alertAuthorization.js";
import { sendSms, type SmsSender } from "../services/smsService.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>';
function escapeXml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function twiml(message: string): string {
return (
'<?xml version="1.0" encoding="UTF-8"?><Response><Message>' +
escapeXml(message) +
"</Message></Response>"
);
}
export type NotifySmsDeps = {
sender?: SmsSender;
/** Defaults to the real chain; tests substitute a stage that sets req.auth. */
authStages?: RequestHandler[];
};
function sendError(
res: express.Response,
status: number,
error: string,
message: string,
extra: Record<string, unknown> = {}
): void {
res.status(status).json({ success: false, error, message, ...extra });
}
/**
* The whole surface stays off the public internet until it is deliberately
* turned on, credentials and 10DLC registration included.
*/
export const requireSmsEnabled: RequestHandler = (_req, res, next) => {
if (!smsConfig().enabled) {
sendError(
res,
503,
"SMS_DISABLED",
"SMS notifications are not enabled on this server."
);
return;
}
next();
};
function codeSecretOrFail(res: express.Response): string | undefined {
const secret = smsConfig().codeSecret;
if (secret === undefined) {
log.error("[NotifySms] SMS_ENABLED is set but SMS_CODE_SECRET is not");
sendError(
res,
500,
"SMS_NOT_CONFIGURED",
"SMS is enabled but not fully configured."
);
return undefined;
}
return secret;
}
/**
* Every phone action is recorded. Failures here never fail the request: an
* unwritten log line is worse than nothing, but a lost delete is worse still.
*/
async function recordPhoneAction(input: {
userId: string;
phoneE164?: string;
phoneHash: string;
action: SmsPhoneLogAction;
result: "ok" | "rejected" | "failed";
detail?: string;
jwtHash?: string;
providerMessageId?: string;
}): Promise<void> {
try {
await smsPhoneLogDb.append(input);
} catch (err) {
log.error("[NotifySms] Log write failed:", errorMessage(err));
}
}
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)
);
// 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,
});
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);
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,
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,
jwtHash,
});
res.status(200).json({
success: true,
batchId: stored.batch.batchId,
timezone: stored.batch.timezone,
storedCount: stored.storedCount,
unusedCount: stored.unusedCount,
});
log.info(
"[NotifySmsAlertAuthorization] Completed in",
formatElapsedMs(Date.now() - started) + ",",
"stored=" + stored.storedCount
);
} 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."
);
}
};
/**
* 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);
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}`,
});
// 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)) {
// START cannot re-verify: possession was proved by a code, and that
// code is gone. A fresh POST plus a fresh code is the only way back.
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 because the FCM twin is PUT and
// 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);
return router;
}
export const notifySmsRouter: express.Router = createNotifySmsRouter();
+5 -4
View File
@@ -1,6 +1,7 @@
import { db } from "./db/fcmTokensSqlite.js";
import { sendPushToDevice } from "./services/pushService.js";
import { errorMessage, formatElapsedMs } from "./util/formatElapsed.js";
import { log } from "./util/log.js";
let intervalId: ReturnType<typeof setInterval> | undefined;
@@ -9,7 +10,7 @@ export function startScheduler(): void {
intervalId = setInterval(async () => {
const passStarted = Date.now();
console.log("[Scheduler] Pass started");
log.info("[Scheduler] Pass started");
try {
const devices = await db.getAllForScheduler();
@@ -43,13 +44,13 @@ export function startScheduler(): void {
if (duplicates > 0) {
summaryParts.push(`${duplicates} duplicates ignored`);
}
console.log("[Scheduler]", summaryParts.join(", "));
console.log(
log.info("[Scheduler]", summaryParts.join(", "));
log.info(
"[Scheduler] Pass completed in",
formatElapsedMs(Date.now() - passStarted)
);
} catch (err) {
console.error(
log.error(
"[Scheduler] Pass failed in",
formatElapsedMs(Date.now() - passStarted) + ":",
errorMessage(err)
+4 -3
View File
@@ -1,5 +1,6 @@
import { ENDORSER_URL } from "../env.js";
import { errorMessage } from "../util/formatElapsed.js";
import { log } from "../util/log.js";
const RATE_LIMITS_PATH = "/api/report/rateLimits";
@@ -31,7 +32,7 @@ export async function checkAuth(jwt: string): Promise<EndorserAuthResult> {
},
});
} catch (err) {
console.error(
log.error(
"[Endorser] Auth check request failed for",
url + ":",
errorMessage(err)
@@ -45,7 +46,7 @@ export async function checkAuth(jwt: string): Promise<EndorserAuthResult> {
// 5xx: Endorser is up but unhealthy; treat as unavailable.
if (response.status >= 500) {
console.error(
log.error(
"[Endorser] Auth check unavailable for",
url + ", status",
response.status
@@ -54,7 +55,7 @@ export async function checkAuth(jwt: string): Promise<EndorserAuthResult> {
}
// 4xx: JWT rejected or user not registered on Endorser.
console.error(
log.error(
"[Endorser] Auth check rejected for",
url + ", status",
response.status
+6 -5
View File
@@ -1,6 +1,7 @@
import admin from "firebase-admin";
import type { ServiceAccount } from "firebase-admin/app";
import type { Messaging } from "firebase-admin/messaging";
import { log } from "../util/log.js";
type ServiceAccountJson = ServiceAccount & { project_id?: string };
@@ -22,25 +23,25 @@ function resolveCredential(): admin.credential.Credential {
account = JSON.parse(json) as ServiceAccountJson;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(
log.error(
"[Firebase] FIREBASE_SERVICE_ACCOUNT_JSON parse failed:",
message
);
throw err;
}
const projectId = serviceAccountProjectId(account);
console.log(
log.info(
"[Firebase] Credential: FIREBASE_SERVICE_ACCOUNT_JSON (parsed successfully)"
);
if (projectId !== undefined) {
console.log("[Firebase] project_id:", projectId);
log.info("[Firebase] project_id:", projectId);
} 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);
}
console.log("[Firebase] Credential: Application Default Credentials");
log.info("[Firebase] Credential: Application Default Credentials");
return admin.credential.applicationDefault();
}
+7 -6
View File
@@ -2,6 +2,7 @@ import { db, type StoredRow } from "../db/fcmTokensSqlite.js";
import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js";
import { maskToken } from "../util/maskToken.js";
import { messaging } from "./firebase.js";
import { log } from "../util/log.js";
const MS_PRODUCTION = 23 * 60 * 60 * 1000;
const MS_TEST = 10 * 60 * 1000;
@@ -60,7 +61,7 @@ export async function sendPushToDevice(
}
const sendStarted = Date.now();
console.log("[Push] Send attempt, token suffix:", suffix);
log.info("[Push] Send attempt, token suffix:", suffix);
try {
const data: Record<string, string> = {
@@ -88,7 +89,7 @@ export async function sendPushToDevice(
if (persisted !== undefined) {
await db.update(persisted.id, { lastNotifiedAt: Date.now() });
}
console.log(
log.info(
"[Push] Send completed in",
formatElapsedMs(Date.now() - sendStarted) + ",",
"token suffix:",
@@ -96,7 +97,7 @@ export async function sendPushToDevice(
);
return "sent";
} catch (err) {
console.error(
log.error(
"[Push] Send failed in",
formatElapsedMs(Date.now() - sendStarted) + ",",
"token suffix:",
@@ -119,7 +120,7 @@ export async function sendAlertSearchPushToDevice(
): Promise<"sent" | "failed"> {
const suffix = maskToken(fcmToken);
const sendStarted = Date.now();
console.log("[AlertSearchPush] Send attempt, token suffix:", suffix);
log.info("[AlertSearchPush] Send attempt, token suffix:", suffix);
try {
await messaging.send({
@@ -132,7 +133,7 @@ export async function sendAlertSearchPushToDevice(
type: ALERT_SEARCH_FCM_TYPE,
},
});
console.log(
log.info(
"[AlertSearchPush] Send completed in",
formatElapsedMs(Date.now() - sendStarted) + ",",
"token suffix:",
@@ -140,7 +141,7 @@ export async function sendAlertSearchPushToDevice(
);
return "sent";
} catch (err) {
console.error(
log.error(
"[AlertSearchPush] Send failed in",
formatElapsedMs(Date.now() - sendStarted) + ",",
"token suffix:",
+128
View File
@@ -0,0 +1,128 @@
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 };
export type SmsSender = (to: string, body: string) => Promise<SmsSendResult>;
export const SMS_NOT_CONFIGURED = "SMS_NOT_CONFIGURED";
const TWILIO_API_BASE = "https://api.twilio.com/2010-04-01";
type TwilioCredentials = {
accountSid: string;
authToken: string;
from: { From: string } | { MessagingServiceSid: string };
};
/**
* 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 };
}
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."
);
}
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;
}
+31
View File
@@ -0,0 +1,31 @@
import { createHmac, timingSafeEqual } from "node:crypto";
/**
* Twilio signs the exact URL it posted to, concatenated with every POST
* parameter in key order. Behind a proxy or tunnel the request's own headers do
* not reliably reproduce that URL, which is why the public URL is configured.
*/
export function twilioSignatureFor(
authToken: string,
url: string,
params: Record<string, string>
): string {
let payload = url;
for (const key of Object.keys(params).sort()) {
payload += key + params[key];
}
return createHmac("sha1", authToken).update(payload, "utf8").digest("base64");
}
export function twilioSignatureMatches(
authToken: string,
url: string,
params: Record<string, string>,
provided: unknown
): boolean {
if (typeof provided !== "string" || provided.length === 0) return false;
const expected = Buffer.from(twilioSignatureFor(authToken, url, params));
const actual = Buffer.from(provided);
if (expected.length !== actual.length) return false;
return timingSafeEqual(expected, actual);
}
+4 -2
View File
@@ -5,8 +5,10 @@ declare global {
did?: string;
/** Raw Bearer JWT from the Authorization header. */
jwt?: string;
/** Verified auth context (did + jwt). */
auth?: { did: string; jwt: string };
/** Verified auth context. Present only after requireAuth succeeded. */
auth?: import("../middleware/auth.js").AuthContext;
/** sha256 of the consumed action JWT, set by requireSmsActionJwt. */
smsActionJwtHash?: string;
}
}
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Every line this service prints carries the time it was printed. A log line
* without a timestamp cannot answer "when", which is the only question anyone
* asks a log at 3am.
*/
function stamp(): string {
return new Date().toISOString();
}
export const log = {
info(...args: unknown[]): void {
console.log(stamp(), ...args);
},
error(...args: unknown[]): void {
console.error(stamp(), ...args);
},
warn(...args: unknown[]): void {
console.warn(stamp(), ...args);
},
};
+50
View File
@@ -0,0 +1,50 @@
/** Digits that survive normalization: everything a human might type as separators is dropped. */
const SEPARATORS = /[\s()\-.-― ]/g;
/** E.164 allows 1-15 digits after the "+", and the country code never starts with 0. */
const E164 = /^\+[1-9]\d{7,14}$/;
/**
* Normalize to E.164, assuming US for bare 10-digit input. Returns undefined for
* anything that does not resolve to one unambiguous number.
*/
export function normalizePhoneNumber(input: unknown): string | undefined {
if (typeof input !== "string") return undefined;
let candidate = input.trim().replace(SEPARATORS, "");
if (candidate.length === 0) return undefined;
// "00" is the international access prefix in most of the world.
if (candidate.startsWith("00")) {
candidate = "+" + candidate.slice(2);
}
if (!candidate.startsWith("+")) {
if (!/^\d+$/.test(candidate)) return undefined;
if (candidate.length === 10) {
candidate = "+1" + candidate;
} else if (candidate.length === 11 && candidate.startsWith("1")) {
candidate = "+" + candidate;
} else {
// Any other bare digit string could be several countries. Refuse to guess.
return undefined;
}
}
return E164.test(candidate) ? candidate : undefined;
}
/**
* Keep the country code and the last two digits — enough for the caller to
* recognize their own number, not enough to reconstruct someone else's.
*/
export function maskPhoneNumber(phoneE164: string): string {
if (phoneE164.length <= 7) {
return "*".repeat(Math.max(phoneE164.length - 2, 0)) + phoneE164.slice(-2);
}
return (
phoneE164.slice(0, 5) +
"*".repeat(phoneE164.length - 7) +
phoneE164.slice(-2)
);
}
+40
View File
@@ -0,0 +1,40 @@
import { createHmac, randomInt, timingSafeEqual } from "node:crypto";
export const VERIFICATION_CODE_LENGTH = 6;
/**
* Six digits from the CSPRNG. Math.random() is seeded predictably enough that a
* pending code becomes guessable from a couple of observed ones.
*/
export function mintVerificationCode(): string {
return String(randomInt(0, 10 ** VERIFICATION_CODE_LENGTH)).padStart(
VERIFICATION_CODE_LENGTH,
"0"
);
}
function hmacHex(value: string, secret: string): string {
return createHmac("sha256", secret).update(value).digest("hex");
}
/** The plaintext code lives in memory and in the outbound message, nowhere else. */
export function hashVerificationCode(code: string, secret: string): string {
return hmacHex(code, secret);
}
/** Survives a DELETE that nulls the number, so the log still groups by handset. */
export function hashPhoneNumber(phoneE164: string, secret: string): string {
return hmacHex(phoneE164, secret);
}
/** Constant-time over the HMACs, so a wrong code leaks no prefix information. */
export function verificationCodeMatches(
candidate: string,
storedHash: string,
secret: string
): boolean {
const candidateHash = Buffer.from(hashVerificationCode(candidate, secret));
const stored = Buffer.from(storedHash);
if (candidateHash.length !== stored.length) return false;
return timingSafeEqual(candidateHash, stored);
}
+2 -1
View File
@@ -7,6 +7,7 @@ import { Resolver } from "did-resolver";
import { didEthLocalResolver } from "./did-eth-local-resolver.js";
import { verifyJwt as peerVerifyJwt } from "./passkeyDidPeer.js";
import { log } from "../util/log.js";
export const TEST_BYPASS_ENV_VALUE = "test-local";
export const ETHR_DID_PREFIX = "did:ethr:";
@@ -60,7 +61,7 @@ export async function decodeAndVerifyJwt(jwt: string): Promise<VerifiedJwt> {
) {
const nowEpoch = Math.floor(new Date().getTime() / 1000);
if (typeof payload.exp === "number" && payload.exp < nowEpoch) {
console.log(
log.info(
"JWT with exp " +
payload.exp +
" has expired but we're in test mode so we'll use a new time."
+119
View File
@@ -0,0 +1,119 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, it } from "node:test";
import {
alertSearchCursorsDb,
smsAlertSearchCursorsDb,
} from "../../src/db/alertSearchCursorsSqlite.js";
import { closeDatabase } from "../../src/db/sqlite.js";
import {
advanceAlertSearchCursors,
loadAlertSearchCursors,
} from "../../src/alertSearch/cursors.js";
import type { AlertSearchSourceResult, RetrieveAlertSearchResult } from "../../src/alertSearch/retrieve.js";
import type { EndorserAlertSearchData, PartnerAlertSearchData } from "../../src/alertSearch/types.js";
const USER = "did:ethr:0xchanneluser";
let dir: string;
let savedDataDir: string | undefined;
beforeEach(async () => {
savedDataDir = process.env.NOTIFY_DATA_DIR;
dir = await mkdtemp(path.join(tmpdir(), "sms-channel-"));
process.env.NOTIFY_DATA_DIR = dir;
closeDatabase();
});
afterEach(async () => {
closeDatabase();
if (savedDataDir === undefined) delete process.env.NOTIFY_DATA_DIR;
else process.env.NOTIFY_DATA_DIR = savedDataDir;
await rm(dir, { recursive: true, force: true });
});
function ulid(n: number): string {
return `01H${String(n).padStart(23, "0")}`;
}
function retrieved(endorserUlid: string, partnerAt: string): RetrieveAlertSearchResult {
const endorser: AlertSearchSourceResult<EndorserAlertSearchData> = {
outcome: "success",
pageCount: 1,
data: {
claims: [{ id: endorserUlid, issuedAt: "2026-09-05T00:00:00Z", issuer: USER }],
personalPlanContributions: [],
trackedPlanUpdates: [],
trackedPlanClaims: [],
plansNearby: [],
},
};
const partner: AlertSearchSourceResult<PartnerAlertSearchData> = {
outcome: "success",
pageCount: 1,
data: { profilesNearby: [{ rowId: "r1", updatedAt: partnerAt } as never] },
};
return {
data: { ...endorser.data, ...partner.data },
empty: false,
endorser,
partner,
} as RetrieveAlertSearchResult;
}
describe("per-channel alertSearch cursors", () => {
it("advances only the SMS table for an SMS run", async () => {
await advanceAlertSearchCursors(
USER,
retrieved(ulid(5), "2026-09-05T00:00:00Z"),
"sms"
);
assert.equal(
(await smsAlertSearchCursorsDb.get(USER))?.endorserAfterId,
ulid(5)
);
assert.equal(await alertSearchCursorsDb.get(USER), undefined);
});
it("defaults to the FCM table when no channel is named", async () => {
await advanceAlertSearchCursors(
USER,
retrieved(ulid(9), "2026-09-06T00:00:00Z")
);
assert.equal(
(await alertSearchCursorsDb.get(USER))?.endorserAfterId,
ulid(9)
);
assert.equal(await smsAlertSearchCursorsDb.get(USER), undefined);
});
it("lets the two channels sit at different positions", async () => {
await advanceAlertSearchCursors(
USER,
retrieved(ulid(1), "2026-09-01T00:00:00Z"),
"fcm"
);
await advanceAlertSearchCursors(
USER,
retrieved(ulid(2), "2026-09-02T00:00:00Z"),
"sms"
);
assert.deepEqual(await loadAlertSearchCursors(USER, "fcm"), {
endorserAfterId: ulid(1),
partnerAfterDate: "2026-09-01T00:00:00Z",
});
assert.deepEqual(await loadAlertSearchCursors(USER, "sms"), {
endorserAfterId: ulid(2),
partnerAfterDate: "2026-09-02T00:00:00Z",
});
});
it("reports an empty position for a channel that has never run", async () => {
assert.deepEqual(await loadAlertSearchCursors(USER, "sms"), {});
});
});
+241
View File
@@ -0,0 +1,241 @@
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 { smsRegistrationsDb } from "../../src/db/smsRegistrationsSqlite.js";
import { closeDatabase } from "../../src/db/sqlite.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;
beforeEach(async () => {
savedDataDir = process.env.NOTIFY_DATA_DIR;
savedSecret = process.env.SMS_CODE_SECRET;
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;
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,
localDay: "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(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 in one day", async () => {
await verifyPhone(PHONE);
let sends = 0;
const send = async (): Promise<SmsSendResult> => {
sends += 1;
return { status: "sent", messageId: "SM" + sends };
};
await deliverAlertSearchSms(daily(), { send });
// A second eligible run the same day: the JWT is what normally stops this,
// and the log-backed cap is the backstop underneath it.
const second = await deliverAlertSearchSms(daily(), { send });
assert.equal(sends, 1);
assert.equal(second.sent, 0);
});
});
+206
View File
@@ -0,0 +1,206 @@
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";
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,
localDay: "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({
listUserIds: async () => [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",
timezone: "UTC",
jwts: [{ sequence: 1, day: "2026-09-05", jwt: "j", nbf: 1, exp: 2 }],
});
const seen: string[] = [];
await runSmsAlertSearchSchedulerPass({
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({
listUserIds: async () => [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({
listUserIds: async () => [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({
listUserIds: async () => [USER],
runDaily: async (userId) => {
await gate;
return daily(userId);
},
notify: async () => undefined,
prune: async () => undefined,
});
assert.equal(isSmsAlertSearchSchedulerPassInFlight(), true);
const skipped = await runSmsAlertSearchSchedulerPass({
listUserIds: async () => [OTHER],
prune: async () => undefined,
});
assert.equal(skipped.skipped, true);
assert.equal(skipped.attempted, 0);
release?.();
await running;
assert.equal(isSmsAlertSearchSchedulerPassInFlight(), false);
});
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({
listUserIds: async () => [],
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({
listUserIds: async () => [],
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);
});
});
+322
View File
@@ -0,0 +1,322 @@
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",
timezone: "America/Denver",
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",
timezone: "UTC",
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",
timezone: "UTC",
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("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/
);
});
});
+225
View File
@@ -0,0 +1,225 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, it } from "node:test";
import express from "express";
import { closeDatabase } from "../../src/db/sqlite.js";
import { requireSmsActionJwt, sha256Hex } from "../../src/middleware/smsActionJwt.js";
const USER = "did:ethr:0xclaimuser";
const PHONE = "+15555550123";
const ENV_KEYS = [
"SMS_REQUIRE_ACTION_CLAIM",
"SMS_ACTION_JWT_MAX_AGE_SEC",
"NOTIFY_DATA_DIR",
] as const;
let dir: string;
let savedEnv: Record<string, string | undefined>;
let server: { url: string; close: () => Promise<void> };
let handlerRuns: number;
/** Set to false to mimic a route mounted without requireAuth. */
let authenticate: boolean;
beforeEach(async () => {
savedEnv = {};
for (const key of ENV_KEYS) savedEnv[key] = process.env[key];
dir = await mkdtemp(path.join(tmpdir(), "sms-action-jwt-"));
process.env.NOTIFY_DATA_DIR = dir;
closeDatabase();
handlerRuns = 0;
authenticate = true;
const app = express();
app.use(express.json());
app.post(
"/verify",
(req, _res, next) => {
if (!authenticate) {
next();
return;
}
const parsed = JSON.parse(req.get("X-Test-Auth") as string) as {
jwt: string;
payload: Record<string, unknown>;
};
req.did = USER;
req.jwt = parsed.jwt;
req.auth = { did: USER, jwt: parsed.jwt, payload: parsed.payload };
next();
},
requireSmsActionJwt("verify-phone"),
(req, res) => {
handlerRuns += 1;
res.status(200).json({ ok: true, jwtHash: req.smsActionJwtHash });
}
);
const listening = app.listen(0);
await new Promise((resolve) => listening.once("listening", resolve));
const port = (listening.address() as AddressInfo).port;
server = {
url: `http://127.0.0.1:${port}/verify`,
close: () => new Promise<void>((r) => listening.close(() => r())),
};
});
afterEach(async () => {
closeDatabase();
await server.close();
for (const key of ENV_KEYS) {
if (savedEnv[key] === undefined) delete process.env[key];
else process.env[key] = savedEnv[key];
}
await rm(dir, { recursive: true, force: true });
});
let counter = 0;
async function post(input: {
payload?: Record<string, unknown>;
jwt?: string;
body?: unknown;
}): Promise<{ status: number; body: Record<string, unknown> }> {
counter += 1;
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (authenticate) {
headers["X-Test-Auth"] = JSON.stringify({
jwt: input.jwt ?? `token-${counter}`,
payload: input.payload ?? {},
});
}
const response = await fetch(server.url, {
method: "POST",
headers,
body: JSON.stringify(input.body ?? { phoneNumber: PHONE }),
});
return { status: response.status, body: await response.json() };
}
function payload(
overrides: Record<string, unknown> = {},
claim: Record<string, unknown> | null = {
action: "verify-phone",
phoneNumber: PHONE,
}
): Record<string, unknown> {
return {
iss: USER,
iat: Math.floor(Date.now() / 1000),
...(claim === null ? {} : { claim }),
...overrides,
};
}
describe("requireSmsActionJwt", () => {
it("passes a fresh, correctly bound token and records its hash", async () => {
const result = await post({ payload: payload(), jwt: "the-token" });
assert.equal(result.status, 200);
assert.equal(handlerRuns, 1);
assert.equal(result.body.jwtHash, sha256Hex("the-token"));
});
it("fails closed with 500 when the route is not authenticated", async () => {
authenticate = false;
const result = await post({});
assert.equal(result.status, 500);
assert.equal(result.body.error, "SMS_ACTION_JWT_NOT_AUTHENTICATED");
assert.equal(handlerRuns, 0);
});
it("rejects a token with no claim", async () => {
const result = await post({ payload: payload({}, null) });
assert.equal(result.status, 403);
assert.equal(result.body.error, "SMS_ACTION_JWT_MISSING_CLAIM");
assert.equal(handlerRuns, 0);
});
it("rejects a claim that is not an object", async () => {
const result = await post({ payload: { iss: USER, iat: 1, claim: "nope" } });
assert.equal(result.status, 403);
assert.equal(result.body.error, "SMS_ACTION_JWT_MISSING_CLAIM");
});
it("rejects a claim authorizing a different action", async () => {
const result = await post({
payload: payload({}, { action: "delete-phone", phoneNumber: PHONE }),
});
assert.equal(result.status, 403);
assert.equal(result.body.error, "SMS_ACTION_JWT_WRONG_ACTION");
});
it("rejects a claim naming a different phone", async () => {
const result = await post({
payload: payload({}, { action: "verify-phone", phoneNumber: "+15555559999" }),
});
assert.equal(result.status, 403);
assert.equal(result.body.error, "SMS_ACTION_JWT_PHONE_MISMATCH");
});
it("matches the claim's phone after normalization, not by string", async () => {
const result = await post({
payload: payload({}, { action: "verify-phone", phoneNumber: "(555) 555-0123" }),
body: { phoneNumber: "555.555.0123" },
});
assert.equal(result.status, 200);
});
it("rejects a claim carrying no phone at all", async () => {
const result = await post({
payload: payload({}, { action: "verify-phone" }),
});
assert.equal(result.status, 403);
assert.equal(result.body.error, "SMS_ACTION_JWT_PHONE_MISMATCH");
});
it("rejects a token issued too long ago", async () => {
process.env.SMS_ACTION_JWT_MAX_AGE_SEC = "60";
const result = await post({
payload: payload({ iat: Math.floor(Date.now() / 1000) - 120 }),
});
assert.equal(result.status, 401);
assert.equal(result.body.error, "SMS_ACTION_JWT_STALE");
});
it("rejects a token with no iat", async () => {
const result = await post({ payload: payload({ iat: undefined }) });
assert.equal(result.status, 401);
assert.equal(result.body.error, "SMS_ACTION_JWT_STALE");
});
it("rejects an expired token", async () => {
const result = await post({
payload: payload({ exp: Math.floor(Date.now() / 1000) - 1 }),
});
assert.equal(result.status, 401);
assert.equal(result.body.error, "SMS_ACTION_JWT_EXPIRED");
});
it("accepts a token whose exp is still ahead", async () => {
const result = await post({
payload: payload({ exp: Math.floor(Date.now() / 1000) + 300 }),
});
assert.equal(result.status, 200);
});
it("rejects the same token used a second time", async () => {
const first = await post({ payload: payload(), jwt: "one-shot" });
assert.equal(first.status, 200);
const second = await post({ payload: payload(), jwt: "one-shot" });
assert.equal(second.status, 401);
assert.equal(second.body.error, "SMS_ACTION_JWT_REPLAYED");
assert.equal(handlerRuns, 1);
});
it("skips every check when the claim is not required", async () => {
process.env.SMS_REQUIRE_ACTION_CLAIM = "false";
const result = await post({ payload: payload({}, null) });
assert.equal(result.status, 200);
assert.equal(result.body.jwtHash, undefined);
});
});
+789
View File
@@ -0,0 +1,789 @@
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, { type RequestHandler } from "express";
import { smsAlertAuthorizationDb } from "../../src/db/smsAlertAuthorizationSqlite.js";
import { smsPhoneLogDb } from "../../src/db/smsPhoneLogSqlite.js";
import { smsRegistrationsDb } from "../../src/db/smsRegistrationsSqlite.js";
import { closeDatabase } from "../../src/db/sqlite.js";
import { calendarDayInTimeZone } from "../../src/services/alertAuthorization.js";
import type { SmsSendResult } from "../../src/services/smsService.js";
import { twilioSignatureFor } from "../../src/services/twilioSignature.js";
import { hashPhoneNumber } from "../../src/util/smsVerificationCode.js";
import { createNotifySmsRouter } from "../../src/routes/notifySms.js";
const SECRET = "route-test-secret";
const USER = "did:ethr:0xrouteuser";
const PHONE = "+15555550123";
const TZ = "America/Denver";
const ENV_KEYS = [
"SMS_ENABLED",
"SMS_CODE_SECRET",
"SMS_MAX_DIDS_PER_PHONE",
"SMS_CODE_MAX_ATTEMPTS",
"SMS_CODE_TTL_SEC",
"SMS_DEV_ECHO_CODE",
"SMS_REQUIRE_ACTION_CLAIM",
"SMS_ACTION_JWT_MAX_AGE_SEC",
"TWILIO_AUTH_TOKEN",
"TWILIO_WEBHOOK_URL",
"NODE_ENV",
"NOTIFY_DATA_DIR",
] as const;
let dir: string;
let savedEnv: Record<string, string | undefined>;
let sent: { to: string; body: string }[];
let sendResult: SmsSendResult;
let jwtCounter: number;
beforeEach(async () => {
savedEnv = {};
for (const key of ENV_KEYS) savedEnv[key] = process.env[key];
dir = await mkdtemp(path.join(tmpdir(), "notify-sms-routes-"));
process.env.NOTIFY_DATA_DIR = dir;
process.env.SMS_ENABLED = "true";
process.env.SMS_CODE_SECRET = SECRET;
process.env.NODE_ENV = "test-local";
closeDatabase();
sent = [];
sendResult = { status: "sent", messageId: "SM1" };
jwtCounter = 0;
});
afterEach(async () => {
closeDatabase();
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 });
});
type Claim = { action: string; phoneNumber?: string };
/**
* Stands in for requireAuth + requireEndorserAuth: sets exactly what those
* stages set, so requireSmsActionJwt is exercised for real.
*/
function authStub(): RequestHandler {
return (req, _res, next) => {
const header = req.get("X-Test-Auth");
if (header === undefined) {
next();
return;
}
const parsed = JSON.parse(header) as {
did?: string;
jwt?: string;
payload?: Record<string, unknown>;
};
const did = parsed.did ?? USER;
req.did = did;
req.jwt = parsed.jwt ?? "token";
req.auth = {
did,
jwt: parsed.jwt ?? "token",
payload: parsed.payload ?? {},
};
next();
};
}
function testAuthHeader(input: {
did?: string;
claim?: Claim | null;
jwt?: string;
iat?: number;
exp?: number;
}): string {
jwtCounter += 1;
const payload: Record<string, unknown> = {
iss: input.did ?? USER,
iat: input.iat ?? Math.floor(Date.now() / 1000),
};
if (input.exp !== undefined) payload.exp = input.exp;
if (input.claim !== null) {
payload.claim = {
"@context": "https://giftopia.tech",
"@type": "SmsNotificationAction",
...(input.claim ?? { action: "register-phone", phoneNumber: PHONE }),
};
}
return JSON.stringify({
did: input.did ?? USER,
jwt: input.jwt ?? `bearer-${jwtCounter}`,
payload,
});
}
type Server = { url: string; close: () => Promise<void> };
async function startServer(): Promise<Server> {
const app = express();
app.use(express.json());
app.use(
"/notify-sms",
createNotifySmsRouter({
authStages: [authStub()],
sender: async (to, body) => {
sent.push({ to, body });
return sendResult;
},
})
);
const server = app.listen(0);
await new Promise((resolve) => server.once("listening", resolve));
const port = (server.address() as AddressInfo).port;
return {
url: `http://127.0.0.1:${port}`,
close: () =>
new Promise<void>((resolve) => server.close(() => resolve())),
};
}
let server: Server;
beforeEach(async () => {
server = await startServer();
});
afterEach(async () => {
await server.close();
});
type CallInput = {
method?: string;
path?: string;
body?: unknown;
auth?: Parameters<typeof testAuthHeader>[0] | "none";
};
async function call(
input: CallInput
): Promise<{ status: number; body: Record<string, unknown> }> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (input.auth !== "none") {
headers["X-Test-Auth"] = testAuthHeader(input.auth ?? {});
}
const response = await fetch(server.url + (input.path ?? "/notify-sms/phone"), {
method: input.method ?? "POST",
headers,
body: input.body === undefined ? undefined : JSON.stringify(input.body),
});
const text = await response.text();
return {
status: response.status,
body: text.length > 0 ? JSON.parse(text) : {},
};
}
/** POST then PUT with the echoed code, leaving the DID verified. */
async function registerAndVerify(did = USER, phone = PHONE): Promise<void> {
process.env.SMS_DEV_ECHO_CODE = "true";
const posted = await call({
body: { phoneNumber: phone },
auth: { did, claim: { action: "register-phone", phoneNumber: phone } },
});
assert.equal(posted.status, 200, JSON.stringify(posted.body));
const code = posted.body.devCode as string;
const put = await call({
method: "PUT",
body: { phoneNumber: phone, code },
auth: { did, claim: { action: "verify-phone", phoneNumber: phone } },
});
assert.equal(put.status, 200, JSON.stringify(put.body));
delete process.env.SMS_DEV_ECHO_CODE;
}
describe("notify-sms enable flag", () => {
it("returns 503 for every route when SMS is off", async () => {
process.env.SMS_ENABLED = "false";
const result = await call({ body: { phoneNumber: PHONE } });
assert.equal(result.status, 503);
assert.equal(result.body.error, "SMS_DISABLED");
assert.equal(sent.length, 0);
});
});
describe("POST /notify-sms/phone", () => {
it("sends a code and stores only its hash", async () => {
const result = await call({ body: { phoneNumber: "(555) 555-0123" } });
assert.equal(result.status, 200);
assert.equal(result.body.verified, false);
assert.equal(result.body.phoneNumber, "+1555*****23");
assert.equal(typeof result.body.expiresAt, "string");
assert.equal(result.body.devCode, undefined);
assert.equal(sent.length, 1);
assert.equal(sent[0].to, PHONE);
const code = /(\d{6})/.exec(sent[0].body)?.[1];
assert.ok(code);
const row = await smsRegistrationsDb.get(USER, PHONE);
assert.equal(row?.verified, false);
assert.notEqual(row?.codeHash, code);
assert.equal(row?.codeHash?.includes(code as string), false);
});
it("rejects a number that does not normalize", async () => {
const result = await call({ body: { phoneNumber: "not a phone" } });
assert.equal(result.status, 400);
assert.equal(result.body.error, "SMS_PHONE_INVALID");
assert.equal(sent.length, 0);
});
it("is a no-op on a number this DID already verified", async () => {
await registerAndVerify();
sent = [];
const result = await call({
body: { phoneNumber: PHONE },
auth: { claim: { action: "register-phone", phoneNumber: PHONE } },
});
assert.equal(result.status, 200);
assert.equal(result.body.verified, true);
assert.equal(sent.length, 0);
});
it("echoes the code only under test-local with the flag set", async () => {
process.env.SMS_DEV_ECHO_CODE = "true";
const withFlag = await call({ body: { phoneNumber: PHONE } });
assert.match(withFlag.body.devCode as string, /^\d{6}$/);
process.env.NODE_ENV = "production";
const inProduction = await call({
body: { phoneNumber: "+15555550124" },
auth: { claim: { action: "register-phone", phoneNumber: "+15555550124" } },
});
assert.equal(inProduction.body.devCode, undefined);
});
it("throttles code sends per phone across DIDs", async () => {
for (let i = 0; i < 3; i += 1) {
const ok = await call({
body: { phoneNumber: PHONE },
auth: { did: `did:ethr:0xu${i}` },
});
assert.equal(ok.status, 200, JSON.stringify(ok.body));
}
const blocked = await call({
body: { phoneNumber: PHONE },
auth: { did: "did:ethr:0xu4" },
});
assert.equal(blocked.status, 429);
assert.equal(blocked.body.error, "SMS_CODE_RATE_LIMITED");
assert.equal(sent.length, 3);
});
it("counts a failed send against the throttle", async () => {
sendResult = { status: "failed", error: "carrier down" };
const failed = await call({ body: { phoneNumber: PHONE } });
assert.equal(failed.status, 502);
assert.equal(failed.body.error, "SMS_CODE_SEND_FAILED");
const log = await smsPhoneLogDb.listByUserId(USER);
assert.ok(log.some((row) => row.action === "code-send-failed"));
});
it("refuses at the DID limit and discloses no identities", async () => {
process.env.SMS_MAX_DIDS_PER_PHONE = "2";
await registerAndVerify("did:ethr:0xa");
await registerAndVerify("did:ethr:0xb");
const blocked = await call({
body: { phoneNumber: PHONE },
auth: { did: "did:ethr:0xc" },
});
assert.equal(blocked.status, 409);
assert.equal(blocked.body.error, "SMS_PHONE_DID_LIMIT");
assert.equal(blocked.body.limit, 2);
assert.equal(blocked.body.verifiedCount, 2);
assert.equal(blocked.body.dids, undefined);
const log = await smsPhoneLogDb.listByUserId("did:ethr:0xc");
assert.equal(log[0].action, "did-limit-blocked");
});
it("does not count unverified rows from other DIDs toward the limit", async () => {
process.env.SMS_MAX_DIDS_PER_PHONE = "1";
await call({ body: { phoneNumber: PHONE }, auth: { did: "did:ethr:0xa" } });
await call({ body: { phoneNumber: PHONE }, auth: { did: "did:ethr:0xb" } });
const third = await call({
body: { phoneNumber: PHONE },
auth: { did: "did:ethr:0xc" },
});
assert.equal(third.status, 200, JSON.stringify(third.body));
});
});
describe("PUT /notify-sms/phone", () => {
async function post(did = USER): Promise<string> {
process.env.SMS_DEV_ECHO_CODE = "true";
const result = await call({
body: { phoneNumber: PHONE },
auth: { did },
});
delete process.env.SMS_DEV_ECHO_CODE;
return result.body.devCode as string;
}
it("verifies with the right code and clears the stored hash", async () => {
const code = await post();
const result = await call({
method: "PUT",
body: { phoneNumber: PHONE, code },
auth: { claim: { action: "verify-phone", phoneNumber: PHONE } },
});
assert.equal(result.status, 200);
assert.equal(result.body.verified, true);
const row = await smsRegistrationsDb.get(USER, PHONE);
assert.equal(row?.verified, true);
assert.equal(row?.codeHash, undefined);
assert.equal(row?.codeAttempts, 0);
});
it("counts a wrong code and reports what is left", async () => {
await post();
const result = await call({
method: "PUT",
body: { phoneNumber: PHONE, code: "000000" },
auth: { claim: { action: "verify-phone", phoneNumber: PHONE } },
});
assert.equal(result.status, 400);
assert.equal(result.body.error, "SMS_CODE_MISMATCH");
assert.equal(result.body.attemptsRemaining, 4);
});
it("reports an expired code as expired", async () => {
process.env.SMS_CODE_TTL_SEC = "1";
const code = await post();
await new Promise((resolve) => setTimeout(resolve, 1100));
const result = await call({
method: "PUT",
body: { phoneNumber: PHONE, code },
auth: { claim: { action: "verify-phone", phoneNumber: PHONE } },
});
assert.equal(result.status, 400);
assert.equal(result.body.error, "SMS_CODE_EXPIRED");
});
it("reports no pending code for a number never registered", async () => {
const result = await call({
method: "PUT",
body: { phoneNumber: PHONE, code: "123456" },
auth: { claim: { action: "verify-phone", phoneNumber: PHONE } },
});
assert.equal(result.status, 400);
assert.equal(result.body.error, "SMS_CODE_EXPIRED");
});
it("exhausts attempts and clears the code", async () => {
process.env.SMS_CODE_MAX_ATTEMPTS = "2";
await post();
for (let i = 0; i < 2; i += 1) {
await call({
method: "PUT",
body: { phoneNumber: PHONE, code: "000000" },
auth: { claim: { action: "verify-phone", phoneNumber: PHONE } },
});
}
const result = await call({
method: "PUT",
body: { phoneNumber: PHONE, code: "000000" },
auth: { claim: { action: "verify-phone", phoneNumber: PHONE } },
});
assert.equal(result.status, 429);
assert.equal(result.body.error, "SMS_CODE_ATTEMPTS_EXHAUSTED");
assert.equal(
(await smsRegistrationsDb.get(USER, PHONE))?.codeHash,
undefined
);
});
it("returns verified without counting an attempt when already verified", async () => {
await registerAndVerify();
const result = await call({
method: "PUT",
body: { phoneNumber: PHONE, code: "000000" },
auth: { claim: { action: "verify-phone", phoneNumber: PHONE } },
});
assert.equal(result.status, 200);
assert.equal(result.body.verified, true);
assert.equal((await smsRegistrationsDb.get(USER, PHONE))?.codeAttempts, 0);
});
it("refuses the sixth DID at PUT even though its POST was accepted", async () => {
process.env.SMS_MAX_DIDS_PER_PHONE = "2";
await registerAndVerify("did:ethr:0xa");
await registerAndVerify("did:ethr:0xb");
// This POST is only accepted because the third DID registered before the
// other two verified; the cap is what holds at PUT.
process.env.SMS_MAX_DIDS_PER_PHONE = "9";
const code = await post("did:ethr:0xc");
process.env.SMS_MAX_DIDS_PER_PHONE = "2";
const result = await call({
method: "PUT",
body: { phoneNumber: PHONE, code },
auth: {
did: "did:ethr:0xc",
claim: { action: "verify-phone", phoneNumber: PHONE },
},
});
assert.equal(result.status, 409);
assert.equal(result.body.error, "SMS_PHONE_DID_LIMIT");
assert.deepEqual(result.body.dids, ["did:ethr:0xa", "did:ethr:0xb"]);
assert.equal(
(await smsRegistrationsDb.get("did:ethr:0xc", PHONE))?.verified,
false
);
});
it("consumes the code on a limit rejection so a second PUT cannot re-ask", async () => {
process.env.SMS_MAX_DIDS_PER_PHONE = "1";
await registerAndVerify("did:ethr:0xa");
process.env.SMS_MAX_DIDS_PER_PHONE = "9";
const code = await post("did:ethr:0xc");
process.env.SMS_MAX_DIDS_PER_PHONE = "1";
const first = await call({
method: "PUT",
body: { phoneNumber: PHONE, code },
auth: {
did: "did:ethr:0xc",
claim: { action: "verify-phone", phoneNumber: PHONE },
},
});
assert.equal(first.status, 409);
assert.ok(Array.isArray(first.body.dids));
const second = await call({
method: "PUT",
body: { phoneNumber: PHONE, code },
auth: {
did: "did:ethr:0xc",
claim: { action: "verify-phone", phoneNumber: PHONE },
},
});
assert.equal(second.status, 400);
assert.equal(second.body.error, "SMS_CODE_EXPIRED");
assert.equal(second.body.dids, undefined);
});
});
describe("GET /notify-sms/phone", () => {
it("lists this DID's own registrations in full", async () => {
await registerAndVerify();
const result = await call({
method: "GET",
body: undefined,
auth: { claim: { action: "list-phones" } },
});
assert.equal(result.status, 200);
const phones = result.body.phones as Record<string, unknown>[];
assert.equal(phones.length, 1);
assert.equal(phones[0].phoneNumber, PHONE);
assert.equal(phones[0].verified, true);
assert.equal(result.body.dids, undefined);
});
it("returns an empty list rather than a 404", async () => {
const result = await call({
method: "GET",
auth: { claim: { action: "list-phones" } },
});
assert.equal(result.status, 200);
assert.deepEqual(result.body.phones, []);
});
it("discloses the DIDs on a number only to a DID verified on it", async () => {
await registerAndVerify("did:ethr:0xa");
await registerAndVerify("did:ethr:0xb");
const query = `/notify-sms/phone?phoneNumber=${encodeURIComponent(PHONE)}`;
const allowed = await call({
method: "GET",
path: query,
auth: {
did: "did:ethr:0xa",
claim: { action: "list-phones", phoneNumber: PHONE },
},
});
assert.equal(allowed.status, 200);
assert.deepEqual(allowed.body.dids, ["did:ethr:0xa", "did:ethr:0xb"]);
const refused = await call({
method: "GET",
path: query,
auth: {
did: "did:ethr:0xstranger",
claim: { action: "list-phones", phoneNumber: PHONE },
},
});
assert.equal(refused.status, 403);
assert.equal(refused.body.error, "SMS_PHONE_NOT_VERIFIED_BY_CALLER");
assert.equal(refused.body.dids, undefined);
assert.equal(refused.body.verifiedCount, undefined);
});
it("refuses an unverified registration of the number just the same", async () => {
await registerAndVerify("did:ethr:0xa");
await call({ body: { phoneNumber: PHONE }, auth: { did: "did:ethr:0xb" } });
const result = await call({
method: "GET",
path: `/notify-sms/phone?phoneNumber=${encodeURIComponent(PHONE)}`,
auth: {
did: "did:ethr:0xb",
claim: { action: "list-phones", phoneNumber: PHONE },
},
});
assert.equal(result.status, 403);
});
});
describe("DELETE /notify-sms/phone", () => {
it("removes the row, scrubs the number from the log, and keeps the hash", async () => {
await registerAndVerify();
const result = await call({
method: "DELETE",
body: { phoneNumber: PHONE },
auth: { claim: { action: "delete-phone", phoneNumber: PHONE } },
});
assert.equal(result.status, 200);
assert.equal(result.body.deleted, true);
assert.equal(await smsRegistrationsDb.get(USER, PHONE), undefined);
const log = await smsPhoneLogDb.listByUserId(USER);
assert.ok(log.length > 1);
const expectedHash = hashPhoneNumber(PHONE, SECRET);
for (const row of log) {
assert.equal(row.phoneE164, undefined);
assert.equal(row.phoneHash, expectedHash);
}
assert.ok(log.some((row) => row.action === "code-sent"));
assert.ok(log.some((row) => row.action === "deleted"));
});
it("accepts the query parameter, for proxies that drop DELETE bodies", async () => {
await registerAndVerify();
const result = await call({
method: "DELETE",
path: `/notify-sms/phone?phoneNumber=${encodeURIComponent(PHONE)}`,
auth: { claim: { action: "delete-phone", phoneNumber: PHONE } },
});
assert.equal(result.status, 200);
assert.equal(result.body.deleted, true);
});
it("reports deleted:false rather than an error for an unknown number", async () => {
const result = await call({
method: "DELETE",
body: { phoneNumber: PHONE },
auth: { claim: { action: "delete-phone", phoneNumber: PHONE } },
});
assert.equal(result.status, 200);
assert.equal(result.body.deleted, false);
});
it("leaves another DID's registration of the same number alone", async () => {
await registerAndVerify("did:ethr:0xa");
await registerAndVerify("did:ethr:0xb");
await call({
method: "DELETE",
body: { phoneNumber: PHONE },
auth: {
did: "did:ethr:0xa",
claim: { action: "delete-phone", phoneNumber: PHONE },
},
});
assert.equal(
(await smsRegistrationsDb.get("did:ethr:0xb", PHONE))?.verified,
true
);
const theirLog = await smsPhoneLogDb.listByUserId("did:ethr:0xb");
assert.ok(theirLog.every((row) => row.phoneE164 === PHONE));
});
});
describe("POST /notify-sms/alert-authorization", () => {
function delegatedJwt(payload: Record<string, unknown>): string {
const encode = (value: unknown) =>
Buffer.from(JSON.stringify(value)).toString("base64url");
return `${encode({ alg: "ES256K", typ: "JWT" })}.${encode(payload)}.sig`;
}
function batch(userId = USER): Record<string, unknown> {
const jwts = [];
const base = Math.floor(Date.now() / 1000);
for (let i = 0; i < 100; i += 1) {
const nbf = base + i * 86400;
const exp = nbf + 86400;
jwts.push({
sequence: i + 1,
day: calendarDayInTimeZone(nbf, TZ),
jwt: delegatedJwt({ iss: userId, nbf, exp }),
nbf,
exp,
});
}
return { batchId: "sms-batch-1", timezone: TZ, jwts };
}
const authorizeClaim = { action: "authorize-alert-search" };
it("refuses without a verified phone", async () => {
const result = await call({
path: "/notify-sms/alert-authorization",
body: batch(),
auth: { claim: authorizeClaim },
});
assert.equal(result.status, 409);
assert.equal(result.body.error, "SMS_NO_VERIFIED_PHONE");
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 0);
});
it("stores a batch of 100 into the SMS tables", async () => {
await registerAndVerify();
const result = await call({
path: "/notify-sms/alert-authorization",
body: batch(),
auth: { claim: authorizeClaim },
});
assert.equal(result.status, 200, JSON.stringify(result.body));
assert.equal(result.body.storedCount, 100);
assert.equal(result.body.timezone, TZ);
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 100);
const log = await smsPhoneLogDb.listByUserId(USER);
const stored = log.find(
(row) => row.action === "alert-authorization-stored"
);
assert.equal(stored?.detail, "batchId=sms-batch-1");
});
it("accepts PUT as an alias for the same handler", async () => {
await registerAndVerify();
const result = await call({
method: "PUT",
path: "/notify-sms/alert-authorization",
body: batch(),
auth: { claim: authorizeClaim },
});
assert.equal(result.status, 200, JSON.stringify(result.body));
assert.equal(result.body.storedCount, 100);
});
it("replaces unused rows and leaves consumed ones", async () => {
await registerAndVerify();
await call({
path: "/notify-sms/alert-authorization",
body: batch(),
auth: { claim: authorizeClaim },
});
const today = calendarDayInTimeZone(Math.floor(Date.now() / 1000), TZ);
const first = await smsAlertAuthorizationDb.getUnusedForDay(USER, today);
assert.ok(first);
await smsAlertAuthorizationDb.consumeUnusedJwt({
id: first.id,
userId: USER,
});
const again = await call({
path: "/notify-sms/alert-authorization",
body: batch(),
auth: { claim: authorizeClaim },
});
assert.equal(again.status, 200);
assert.equal(again.body.storedCount, 100);
assert.equal(
(await smsAlertAuthorizationDb.getJwtById(first.id))?.status,
"consumed"
);
});
it("rejects a malformed batch after the phone gate passes", async () => {
await registerAndVerify();
const result = await call({
path: "/notify-sms/alert-authorization",
body: { batchId: "b", timezone: TZ, jwts: [] },
auth: { claim: authorizeClaim },
});
assert.equal(result.status, 400);
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 0);
});
});
describe("POST /notify-sms/inbound", () => {
const TOKEN = "twilio-auth-token";
async function inbound(
params: Record<string, string>,
signature?: string
): Promise<{ status: number; text: string }> {
const url = "https://example.test/notify-sms/inbound";
process.env.TWILIO_AUTH_TOKEN = TOKEN;
process.env.TWILIO_WEBHOOK_URL = url;
const response = await fetch(server.url + "/notify-sms/inbound", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-Twilio-Signature":
signature ?? twilioSignatureFor(TOKEN, url, params),
},
body: new URLSearchParams(params).toString(),
});
return { status: response.status, text: await response.text() };
}
it("refuses a request whose signature does not match", async () => {
await registerAndVerify();
const result = await inbound({ From: PHONE, Body: "STOP" }, "wrong");
assert.equal(result.status, 403);
assert.equal(
(await smsRegistrationsDb.get(USER, PHONE))?.verified,
true
);
});
it("switches off every registration of the number on STOP", async () => {
await registerAndVerify("did:ethr:0xa");
await registerAndVerify("did:ethr:0xb");
const result = await inbound({ From: PHONE, Body: " stop " });
assert.equal(result.status, 200);
assert.equal(
(await smsRegistrationsDb.get("did:ethr:0xa", PHONE))?.verified,
false
);
assert.equal(
(await smsRegistrationsDb.get("did:ethr:0xb", PHONE))?.verified,
false
);
assert.equal(await smsRegistrationsDb.countVerifiedForPhone(PHONE), 0);
});
it("answers HELP with a fixed reply and changes nothing", async () => {
await registerAndVerify();
const result = await inbound({ From: PHONE, Body: "HELP" });
assert.equal(result.status, 200);
assert.match(result.text, /Reply STOP to end/);
assert.equal((await smsRegistrationsDb.get(USER, PHONE))?.verified, true);
});
it("tells START to register again rather than re-verifying", async () => {
await registerAndVerify();
await inbound({ From: PHONE, Body: "STOP" });
const result = await inbound({ From: PHONE, Body: "START" });
assert.match(result.text, /Register your number again/);
assert.equal((await smsRegistrationsDb.get(USER, PHONE))?.verified, false);
});
});
+174
View File
@@ -0,0 +1,174 @@
import assert from "node:assert/strict";
import { afterEach, beforeEach, describe, it } from "node:test";
import {
SMS_NOT_CONFIGURED,
consoleSmsSender,
resetSmsNotConfiguredWarning,
sendSms,
sendViaTwilio,
} from "../../src/services/smsService.js";
const TWILIO_KEYS = [
"TWILIO_ACCOUNT_SID",
"TWILIO_AUTH_TOKEN",
"TWILIO_FROM_NUMBER",
"TWILIO_MESSAGING_SERVICE_SID",
"NODE_ENV",
] as const;
let saved: Record<string, string | undefined>;
beforeEach(() => {
saved = {};
for (const key of TWILIO_KEYS) {
saved[key] = process.env[key];
delete process.env[key];
}
resetSmsNotConfiguredWarning();
});
afterEach(() => {
for (const key of TWILIO_KEYS) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
});
type FetchArgs = { url: string; init: RequestInit };
function stubFetch(
response: { ok: boolean; status: number; body: unknown },
captured: FetchArgs[]
): typeof fetch {
return (async (url: string, init: RequestInit) => {
captured.push({ url, init });
return {
ok: response.ok,
status: response.status,
json: async () => response.body,
};
}) as unknown as typeof fetch;
}
describe("sendViaTwilio", () => {
const credentials = {
accountSid: "AC123",
authToken: "secret-token",
from: { From: "+15550000000" },
};
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("posts a form-encoded message and returns the sid", async () => {
const captured: FetchArgs[] = [];
globalThis.fetch = stubFetch(
{ ok: true, status: 201, body: { sid: "SM999" } },
captured
);
const result = await sendViaTwilio(credentials, "+15555550123", "hi");
assert.deepEqual(result, { status: "sent", messageId: "SM999" });
assert.equal(captured.length, 1);
assert.equal(
captured[0].url,
"https://api.twilio.com/2010-04-01/Accounts/AC123/Messages.json"
);
const headers = captured[0].init.headers as Record<string, string>;
assert.equal(
headers["Content-Type"],
"application/x-www-form-urlencoded"
);
assert.equal(
headers.Authorization,
"Basic " + Buffer.from("AC123:secret-token").toString("base64")
);
const form = new URLSearchParams(captured[0].init.body as string);
assert.equal(form.get("To"), "+15555550123");
assert.equal(form.get("Body"), "hi");
assert.equal(form.get("From"), "+15550000000");
});
it("sends MessagingServiceSid when that is how it is configured", async () => {
const captured: FetchArgs[] = [];
globalThis.fetch = stubFetch(
{ ok: true, status: 201, body: { sid: "SM1" } },
captured
);
await sendViaTwilio(
{
accountSid: "AC123",
authToken: "t",
from: { MessagingServiceSid: "MG9" },
},
"+15555550123",
"hi"
);
const form = new URLSearchParams(captured[0].init.body as string);
assert.equal(form.get("MessagingServiceSid"), "MG9");
assert.equal(form.get("From"), null);
});
it("reports the provider message on an error response", async () => {
globalThis.fetch = stubFetch(
{ ok: false, status: 400, body: { message: "Invalid 'To'" } },
[]
);
assert.deepEqual(await sendViaTwilio(credentials, "+1", "hi"), {
status: "failed",
error: "Invalid 'To'",
});
});
it("fails when a 2xx response carries no sid", async () => {
globalThis.fetch = stubFetch({ ok: true, status: 200, body: {} }, []);
const result = await sendViaTwilio(credentials, "+15555550123", "hi");
assert.equal(result.status, "failed");
});
it("turns a transport throw into a failed result", async () => {
globalThis.fetch = (async () => {
throw new Error("network down");
}) as unknown as typeof fetch;
assert.deepEqual(await sendViaTwilio(credentials, "+15555550123", "hi"), {
status: "failed",
error: "network down",
});
});
});
describe("sendSms configuration paths", () => {
it("fails with SMS_NOT_CONFIGURED when credentials are absent", async () => {
assert.deepEqual(await sendSms("+15555550123", "hi"), {
status: "failed",
error: SMS_NOT_CONFIGURED,
});
});
it("fails when an account is set but there is nothing to send from", async () => {
process.env.TWILIO_ACCOUNT_SID = "AC123";
process.env.TWILIO_AUTH_TOKEN = "t";
const result = await sendSms("+15555550123", "hi");
assert.equal(result.status, "failed");
assert.equal(
result.status === "failed" ? result.error : "",
SMS_NOT_CONFIGURED
);
});
it("uses the console adapter under test-local with no credentials", async () => {
process.env.NODE_ENV = "test-local";
const result = await sendSms("+15555550123", "hi");
assert.equal(result.status, "sent");
});
});
describe("consoleSmsSender", () => {
it("reports sent without touching the network", async () => {
const result = await consoleSmsSender("+15555550123", "hi");
assert.equal(result.status, "sent");
});
});
+61
View File
@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { maskPhoneNumber, normalizePhoneNumber } from "../../src/util/smsPhoneNumber.js";
describe("normalizePhoneNumber", () => {
const accepted: [unknown, string][] = [
["+15555550123", "+15555550123"],
[" +1 555 555 0123 ", "+15555550123"],
["(555) 555-0123", "+15555550123"],
["555.555.0123", "+15555550123"],
["5555550123", "+15555550123"],
["15555550123", "+15555550123"],
["0015555550123", "+15555550123"],
["+442071838750", "+442071838750"],
["+81312345678", "+81312345678"],
];
for (const [input, expected] of accepted) {
it(`normalizes ${JSON.stringify(input)}`, () => {
assert.equal(normalizePhoneNumber(input), expected);
});
}
const rejected: unknown[] = [
undefined,
null,
42,
"",
" ",
"not a phone",
"555-0123",
"+0155555501",
"+1555555012345678",
"+1555",
"25555550123",
"+1555555o123",
"+",
];
for (const input of rejected) {
it(`rejects ${JSON.stringify(input)}`, () => {
assert.equal(normalizePhoneNumber(input), undefined);
});
}
});
describe("maskPhoneNumber", () => {
it("keeps the country code and the last two digits", () => {
assert.equal(maskPhoneNumber("+15555550123"), "+1555*****23");
});
it("masks a shorter international number without leaking the middle", () => {
const masked = maskPhoneNumber("+442071838750");
assert.equal(masked, "+4420******50");
assert.equal(masked.length, "+442071838750".length);
});
it("never returns more than the last two digits of a short number", () => {
assert.equal(maskPhoneNumber("+12345"), "****45");
});
});
+70
View File
@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
VERIFICATION_CODE_LENGTH,
hashPhoneNumber,
hashVerificationCode,
mintVerificationCode,
verificationCodeMatches,
} from "../../src/util/smsVerificationCode.js";
const SECRET = "test-sms-code-secret";
describe("mintVerificationCode", () => {
it("always produces six digits, leading zeros included", () => {
for (let i = 0; i < 2000; i += 1) {
const code = mintVerificationCode();
assert.equal(code.length, VERIFICATION_CODE_LENGTH);
assert.match(code, /^\d{6}$/);
}
});
it("does not return the same code every call", () => {
const seen = new Set<string>();
for (let i = 0; i < 200; i += 1) seen.add(mintVerificationCode());
assert.ok(seen.size > 100, `expected varied codes, got ${seen.size}`);
});
});
describe("hashVerificationCode", () => {
it("is stable for one secret and different across secrets", () => {
assert.equal(
hashVerificationCode("483920", SECRET),
hashVerificationCode("483920", SECRET)
);
assert.notEqual(
hashVerificationCode("483920", SECRET),
hashVerificationCode("483920", "other-secret")
);
});
it("never contains the plaintext code", () => {
assert.equal(hashVerificationCode("483920", SECRET).includes("483920"), false);
});
});
describe("hashPhoneNumber", () => {
it("is stable per number and does not contain the number", () => {
const hash = hashPhoneNumber("+15555550123", SECRET);
assert.equal(hash, hashPhoneNumber("+15555550123", SECRET));
assert.notEqual(hash, hashPhoneNumber("+15555550124", SECRET));
assert.equal(hash.includes("5555550123"), false);
});
});
describe("verificationCodeMatches", () => {
it("accepts the matching code and rejects every near miss", () => {
const stored = hashVerificationCode("483920", SECRET);
assert.equal(verificationCodeMatches("483920", stored, SECRET), true);
assert.equal(verificationCodeMatches("483921", stored, SECRET), false);
assert.equal(verificationCodeMatches("48392", stored, SECRET), false);
assert.equal(verificationCodeMatches("", stored, SECRET), false);
assert.equal(verificationCodeMatches("483920", stored, "wrong"), false);
});
it("compares full hashes, so a shared prefix is not a partial match", () => {
const stored = hashVerificationCode("111111", SECRET);
const truncated = stored.slice(0, 10);
assert.equal(verificationCodeMatches("111111", truncated, SECRET), false);
});
});
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "."
},
"include": [
"src/**/*.ts",
"test/**/*.ts"
],
"exclude": [
"node_modules",
"dist"
]
}