Add SMS notifications. The PLAN is executed but this isn't tested yet.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 1–3 either call `next()` or send a response and return. A stage that
|
||||
sends a response ends the chain, so no later stage — the handler included —
|
||||
runs. That is the whole mechanism preventing an unauthorized request from
|
||||
reaching a `sendSms` call.
|
||||
|
||||
`testMode` has no bypass here. There is no local-test path that sends real texts
|
||||
to real handsets on someone else's dime.
|
||||
|
||||
### Stage 3, in detail
|
||||
|
||||
There is one JWT. The client mints it with the claim inside, sends it as the
|
||||
Bearer token, and the signature over that token is the authorization — the same
|
||||
arrangement Endorser uses. Nothing is sent alongside it.
|
||||
|
||||
`decodeAndVerifyJwt` already returns `{ issuer, payload, verified }`, and
|
||||
`requireAuth` keeps only `issuer` (as `req.did`) and the raw token string (as
|
||||
`req.jwt`), discarding the decoded payload. Stage 3 needs the claim out of that
|
||||
payload, so `requireAuth` stops discarding it: `req.auth = { did, jwt, payload }`.
|
||||
`payload` is the verifier's own output for that one token, not a second
|
||||
document.
|
||||
|
||||
Stage 3 reads that object and does not decode the token again. For `did:peer`
|
||||
JWANT identities, re-decoding would be wrong, not merely wasteful: the outer
|
||||
payload of a passkey token is a WebAuthn envelope
|
||||
(`AuthenticationDataB64URL`, `ClientDataJSONB64URL`), and the actual claim sits
|
||||
base64url-encoded inside `clientData.challenge`. `peerVerifyJwt` unwraps it and
|
||||
that inner object is what `decodeAndVerifyJwt` returns. A stage that re-parsed
|
||||
segment two would find the envelope and no claim at all, failing every passkey
|
||||
user.
|
||||
|
||||
Stage 3 does not compare an issuer against `req.did`. `requireAuth` assigns
|
||||
`req.did` from the verified issuer, so the two are the same value read twice —
|
||||
a check that cannot fail proves nothing. Identity comes from `payload.iss`, and
|
||||
the claim carries no DID of its own to disagree with it.
|
||||
|
||||
The one structural guard stage 3 does keep is that `req.auth` is present.
|
||||
`requireAuth` sets it only after `verified.verified` is true, so its presence
|
||||
means verification happened. Its absence means the route was mounted without
|
||||
stage 1, or mounted with `requireAuthOrNotificationLocalTest`, whose `testMode`
|
||||
path sets a synthetic `req.did` and no `req.auth`. Either is a wiring mistake
|
||||
that must not fall through to a handler that sends texts. It is a presence
|
||||
check, not a second verification, and it carries no timestamp — the signature
|
||||
already did the verifying.
|
||||
|
||||
Checks, each with its own error code:
|
||||
|
||||
| Check | Failure |
|
||||
|---|---|
|
||||
| `req.auth` present | `500` — route wiring bug, not a client error |
|
||||
| `payload.claim` is an object | `403 SMS_ACTION_JWT_MISSING_CLAIM` |
|
||||
| `claim.action` equals the route's action | `403 SMS_ACTION_JWT_WRONG_ACTION` |
|
||||
| `claim.phoneNumber` normalizes equal to the body's phone (phone routes) | `403 SMS_ACTION_JWT_PHONE_MISMATCH` |
|
||||
| `payload.iat` within `SMS_ACTION_JWT_MAX_AGE_SEC` (default 300) | `401 SMS_ACTION_JWT_STALE` |
|
||||
| `payload.exp`, when present, not passed | `401 SMS_ACTION_JWT_EXPIRED` |
|
||||
| `sha256(jwt)` absent from `sms_action_jwt_use` | `401 SMS_ACTION_JWT_REPLAYED` |
|
||||
|
||||
On success the hash is inserted into `sms_action_jwt_use` before the handler
|
||||
runs, so one token buys one action. A handler that fails afterward does not
|
||||
release the hash; the client mints a fresh JWT, which it can do freely.
|
||||
|
||||
### Action claim contract
|
||||
|
||||
The app mints the Bearer JWT with a claim this service defines and consumes:
|
||||
|
||||
```json
|
||||
{
|
||||
"iss": "did:ethr:0x…",
|
||||
"iat": 1756270800,
|
||||
"exp": 1756271100,
|
||||
"claim": {
|
||||
"@context": "https://giftopia.tech",
|
||||
"@type": "SmsNotificationAction",
|
||||
"action": "register-phone",
|
||||
"phoneNumber": "+15555550123"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`action` is one of `list-phones`, `register-phone`, `verify-phone`,
|
||||
`delete-phone`, `authorize-alert-search`. `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 1–2,
|
||||
mounted in `index.ts`, `DELETE` added to CORS. Enforce
|
||||
`SMS_MAX_DIDS_PER_PHONE` on both POST and PUT, counting verified rows.
|
||||
Tests: happy path, wrong code, expired code, attempt exhaustion, re-POST
|
||||
on a verified number sends nothing, DELETE nulls `phone_e164` in the log
|
||||
but keeps `phone_hash`, the sixth DID is refused at PUT even when its POST
|
||||
was accepted under the limit, and unverified rows from other DIDs do not
|
||||
count toward it. Disclosure tests carry their own weight: a POST rejected
|
||||
at the limit returns **no** `dids`, a PUT rejected at the limit returns
|
||||
them, a GET with `?phoneNumber=` returns them only to a DID verified on
|
||||
that number, and the code is consumed either way so a second PUT with the
|
||||
same code cannot re-ask.
|
||||
- [x] **6. SMS alert-authorization route.** POST plus the PUT alias, reusing
|
||||
`validateAlertAuthorizationBatch`, requiring a verified phone, storing to
|
||||
the SMS tables. Tests: batch of 100 stored, second batch replaces unused
|
||||
rows and leaves consumed ones, `409` with no verified phone.
|
||||
- [x] **7. Per-channel cursors and daily run.** Channel parameter through
|
||||
`cursors.ts`, `cycle.ts`, `daily.ts`, defaulting to `"fcm"`. Tests: an SMS
|
||||
run advances only `sms_alert_search_cursors`; the FCM suite passes
|
||||
unchanged.
|
||||
- [x] **8. Delivery and scheduler.** `alertSearch/smsNotify.ts` and
|
||||
`alertSearch/smsScheduler.ts`, started in `index.ts`. Tests: eligibility
|
||||
predicate, verified-only recipients, one send per number, failures logged
|
||||
without rolling back consumption, second tick the same day sends nothing.
|
||||
- [x] **9. Opt-out and caps.** `POST /notify-sms/inbound` for Twilio's webhook:
|
||||
`STOP` / `UNSUBSCRIBE` marks every registration for that number
|
||||
`verified = 0` and logs `opt-out`; `START` requires a fresh POST + code;
|
||||
`HELP` returns a fixed reply. Enforce the per-DID daily send cap. The
|
||||
webhook authenticates by Twilio's `X-Twilio-Signature`, not by JWT — it is
|
||||
Twilio calling, not a user.
|
||||
- [x] **10. Docs.** README sections for the endpoints, the action claim, the SMS
|
||||
tables, and the environment table; `.env.example`; `CHANGELOG.md`.
|
||||
- [x] **11. `requireSmsActionJwt`.** The action-authorization stage, built last.
|
||||
Every check in the stage-3 table, each with its own code, plus the replay
|
||||
insert and the `sms_action_jwt_use` table. Add the middleware to all five
|
||||
routes. Tests: one per failure mode, plus the same token rejected on second
|
||||
use.
|
||||
- [x] **12. Wrap-up.**
|
||||
- [x] Prefix **every** line this service prints with an ISO-8601 UTC timestamp.
|
||||
A `src/util/log.ts` wrapper (`log.info` / `log.error`) that prepends
|
||||
`new Date().toISOString()` and forwards to `console`, applied to every
|
||||
existing `console.log` / `console.error` call site in `src/` — routes,
|
||||
middleware, services, db modules, and all three schedulers. A log line without a timestamp cannot answer "when", which is
|
||||
the only question anyone asks a log at 3am.
|
||||
- [x] Flip `SMS_REQUIRE_ACTION_CLAIM` to default `true` and record the flip in
|
||||
`CHANGELOG.md`. Phase 11 is what makes that default safe.
|
||||
- [ ] Turn `SMS_ENABLED` on in the deployment environment, with Twilio
|
||||
credentials, `SMS_CODE_SECRET`, and completed A2P 10DLC registration all
|
||||
in place.
|
||||
- [ ] End-to-end pass against a real handset: register, verify, authorize a
|
||||
batch, receive one daily digest text, `STOP`, confirm no further sends.
|
||||
- [ ] Confirm `sms_phone_log` holds one row per action taken during that pass,
|
||||
and that DELETE nulled `phone_e164` while leaving `phone_hash`.
|
||||
- [ ] Confirm `SMS_DEV_ECHO_CODE` is unset (or `false`) and `NODE_ENV` is not
|
||||
`test-local` in the deployed environment.
|
||||
- [ ] Prune-job follow-up filed for `sms_phone_log` retention and
|
||||
`sms_action_jwt_use` rows.
|
||||
|
||||
## 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.
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getDatabase } from "./sqlite.js";
|
||||
|
||||
/** Keyed on the token hash, so two identities acting on one number never collide. */
|
||||
export const smsActionJwtUseDb = {
|
||||
/**
|
||||
* Claim a token for one action. Returns false when the hash is already
|
||||
* present. The insert itself is the check, so two concurrent requests
|
||||
* carrying one token cannot both win.
|
||||
*/
|
||||
async claim(input: {
|
||||
jwtHash: string;
|
||||
userId: string;
|
||||
action: string;
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
getDatabase()
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO sms_action_jwt_use (id, jwt_hash, user_id, action, used_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`
|
||||
)
|
||||
.run(
|
||||
randomUUID(),
|
||||
input.jwtHash,
|
||||
input.userId,
|
||||
input.action,
|
||||
new Date().toISOString()
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
err.message.includes("UNIQUE constraint failed")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
/** A token this stale fails the freshness check anyway, so the row protects nothing. */
|
||||
async pruneOlderThan(cutoffIso: string): Promise<number> {
|
||||
const result = getDatabase()
|
||||
.prepare(`DELETE FROM sms_action_jwt_use WHERE used_at < ?`)
|
||||
.run(cutoffIso);
|
||||
return result.changes;
|
||||
},
|
||||
|
||||
async count(): Promise<number> {
|
||||
const row = getDatabase()
|
||||
.prepare(`SELECT COUNT(*) AS n FROM sms_action_jwt_use`)
|
||||
.get() as { n: number };
|
||||
return row.n;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import {
|
||||
createAlertAuthorizationStore,
|
||||
type AlertAuthorizationStore,
|
||||
type AlertAuthorizationTables,
|
||||
} from "./alertAuthorizationSqlite.js";
|
||||
|
||||
export const SMS_ALERT_AUTHORIZATION_TABLES: AlertAuthorizationTables = {
|
||||
batches: "sms_alert_authorization_batches",
|
||||
jwts: "sms_alert_authorization_jwts",
|
||||
};
|
||||
|
||||
/**
|
||||
* The SMS channel's own JWT inventory. Same behavior as the FCM store over its
|
||||
* own tables, so an SMS batch upload never disturbs push credentials.
|
||||
*/
|
||||
export const smsAlertAuthorizationDb: AlertAuthorizationStore =
|
||||
createAlertAuthorizationStore(SMS_ALERT_AUTHORIZATION_TABLES);
|
||||
@@ -0,0 +1,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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 + ":",
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function twiml(message: string): string {
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8"?><Response><Message>' +
|
||||
escapeXml(message) +
|
||||
"</Message></Response>"
|
||||
);
|
||||
}
|
||||
|
||||
export type NotifySmsDeps = {
|
||||
sender?: SmsSender;
|
||||
/** Defaults to the real chain; tests substitute a stage that sets req.auth. */
|
||||
authStages?: RequestHandler[];
|
||||
};
|
||||
|
||||
function sendError(
|
||||
res: express.Response,
|
||||
status: number,
|
||||
error: string,
|
||||
message: string,
|
||||
extra: Record<string, unknown> = {}
|
||||
): void {
|
||||
res.status(status).json({ success: false, error, message, ...extra });
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole surface stays off the public internet until it is deliberately
|
||||
* turned on, credentials and 10DLC registration included.
|
||||
*/
|
||||
export const requireSmsEnabled: RequestHandler = (_req, res, next) => {
|
||||
if (!smsConfig().enabled) {
|
||||
sendError(
|
||||
res,
|
||||
503,
|
||||
"SMS_DISABLED",
|
||||
"SMS notifications are not enabled on this server."
|
||||
);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
function codeSecretOrFail(res: express.Response): string | undefined {
|
||||
const secret = smsConfig().codeSecret;
|
||||
if (secret === undefined) {
|
||||
log.error("[NotifySms] SMS_ENABLED is set but SMS_CODE_SECRET is not");
|
||||
sendError(
|
||||
res,
|
||||
500,
|
||||
"SMS_NOT_CONFIGURED",
|
||||
"SMS is enabled but not fully configured."
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every phone action is recorded. Failures here never fail the request: an
|
||||
* unwritten log line is worse than nothing, but a lost delete is worse still.
|
||||
*/
|
||||
async function recordPhoneAction(input: {
|
||||
userId: string;
|
||||
phoneE164?: string;
|
||||
phoneHash: string;
|
||||
action: SmsPhoneLogAction;
|
||||
result: "ok" | "rejected" | "failed";
|
||||
detail?: string;
|
||||
jwtHash?: string;
|
||||
providerMessageId?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await smsPhoneLogDb.append(input);
|
||||
} catch (err) {
|
||||
log.error("[NotifySms] Log write failed:", errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Twilio signs the exact URL it posted to, concatenated with every POST
|
||||
* parameter in key order. Behind a proxy or tunnel the request's own headers do
|
||||
* not reliably reproduce that URL, which is why the public URL is configured.
|
||||
*/
|
||||
export function twilioSignatureFor(
|
||||
authToken: string,
|
||||
url: string,
|
||||
params: Record<string, string>
|
||||
): string {
|
||||
let payload = url;
|
||||
for (const key of Object.keys(params).sort()) {
|
||||
payload += key + params[key];
|
||||
}
|
||||
return createHmac("sha1", authToken).update(payload, "utf8").digest("base64");
|
||||
}
|
||||
|
||||
export function twilioSignatureMatches(
|
||||
authToken: string,
|
||||
url: string,
|
||||
params: Record<string, string>,
|
||||
provided: unknown
|
||||
): boolean {
|
||||
if (typeof provided !== "string" || provided.length === 0) return false;
|
||||
const expected = Buffer.from(twilioSignatureFor(authToken, url, params));
|
||||
const actual = Buffer.from(provided);
|
||||
if (expected.length !== actual.length) return false;
|
||||
return timingSafeEqual(expected, actual);
|
||||
}
|
||||
Vendored
+4
-2
@@ -5,8 +5,10 @@ declare global {
|
||||
did?: string;
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Every line this service prints carries the time it was printed. A log line
|
||||
* without a timestamp cannot answer "when", which is the only question anyone
|
||||
* asks a log at 3am.
|
||||
*/
|
||||
function stamp(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export const log = {
|
||||
info(...args: unknown[]): void {
|
||||
console.log(stamp(), ...args);
|
||||
},
|
||||
error(...args: unknown[]): void {
|
||||
console.error(stamp(), ...args);
|
||||
},
|
||||
warn(...args: unknown[]): void {
|
||||
console.warn(stamp(), ...args);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
/** Digits that survive normalization: everything a human might type as separators is dropped. */
|
||||
const SEPARATORS = /[\s()\-.‐-― ]/g;
|
||||
|
||||
/** E.164 allows 1-15 digits after the "+", and the country code never starts with 0. */
|
||||
const E164 = /^\+[1-9]\d{7,14}$/;
|
||||
|
||||
/**
|
||||
* Normalize to E.164, assuming US for bare 10-digit input. Returns undefined for
|
||||
* anything that does not resolve to one unambiguous number.
|
||||
*/
|
||||
export function normalizePhoneNumber(input: unknown): string | undefined {
|
||||
if (typeof input !== "string") return undefined;
|
||||
|
||||
let candidate = input.trim().replace(SEPARATORS, "");
|
||||
if (candidate.length === 0) return undefined;
|
||||
|
||||
// "00" is the international access prefix in most of the world.
|
||||
if (candidate.startsWith("00")) {
|
||||
candidate = "+" + candidate.slice(2);
|
||||
}
|
||||
|
||||
if (!candidate.startsWith("+")) {
|
||||
if (!/^\d+$/.test(candidate)) return undefined;
|
||||
if (candidate.length === 10) {
|
||||
candidate = "+1" + candidate;
|
||||
} else if (candidate.length === 11 && candidate.startsWith("1")) {
|
||||
candidate = "+" + candidate;
|
||||
} else {
|
||||
// Any other bare digit string could be several countries. Refuse to guess.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return E164.test(candidate) ? candidate : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the country code and the last two digits — enough for the caller to
|
||||
* recognize their own number, not enough to reconstruct someone else's.
|
||||
*/
|
||||
export function maskPhoneNumber(phoneE164: string): string {
|
||||
if (phoneE164.length <= 7) {
|
||||
return "*".repeat(Math.max(phoneE164.length - 2, 0)) + phoneE164.slice(-2);
|
||||
}
|
||||
return (
|
||||
phoneE164.slice(0, 5) +
|
||||
"*".repeat(phoneE164.length - 7) +
|
||||
phoneE164.slice(-2)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createHmac, randomInt, timingSafeEqual } from "node:crypto";
|
||||
|
||||
export const VERIFICATION_CODE_LENGTH = 6;
|
||||
|
||||
/**
|
||||
* Six digits from the CSPRNG. Math.random() is seeded predictably enough that a
|
||||
* pending code becomes guessable from a couple of observed ones.
|
||||
*/
|
||||
export function mintVerificationCode(): string {
|
||||
return String(randomInt(0, 10 ** VERIFICATION_CODE_LENGTH)).padStart(
|
||||
VERIFICATION_CODE_LENGTH,
|
||||
"0"
|
||||
);
|
||||
}
|
||||
|
||||
function hmacHex(value: string, secret: string): string {
|
||||
return createHmac("sha256", secret).update(value).digest("hex");
|
||||
}
|
||||
|
||||
/** The plaintext code lives in memory and in the outbound message, nowhere else. */
|
||||
export function hashVerificationCode(code: string, secret: string): string {
|
||||
return hmacHex(code, secret);
|
||||
}
|
||||
|
||||
/** Survives a DELETE that nulls the number, so the log still groups by handset. */
|
||||
export function hashPhoneNumber(phoneE164: string, secret: string): string {
|
||||
return hmacHex(phoneE164, secret);
|
||||
}
|
||||
|
||||
/** Constant-time over the HMACs, so a wrong code leaks no prefix information. */
|
||||
export function verificationCodeMatches(
|
||||
candidate: string,
|
||||
storedHash: string,
|
||||
secret: string
|
||||
): boolean {
|
||||
const candidateHash = Buffer.from(hashVerificationCode(candidate, secret));
|
||||
const stored = Buffer.from(storedHash);
|
||||
if (candidateHash.length !== stored.length) return false;
|
||||
return timingSafeEqual(candidateHash, stored);
|
||||
}
|
||||
+2
-1
@@ -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."
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, it } from "node:test";
|
||||
import {
|
||||
alertSearchCursorsDb,
|
||||
smsAlertSearchCursorsDb,
|
||||
} from "../../src/db/alertSearchCursorsSqlite.js";
|
||||
import { closeDatabase } from "../../src/db/sqlite.js";
|
||||
import {
|
||||
advanceAlertSearchCursors,
|
||||
loadAlertSearchCursors,
|
||||
} from "../../src/alertSearch/cursors.js";
|
||||
import type { AlertSearchSourceResult, RetrieveAlertSearchResult } from "../../src/alertSearch/retrieve.js";
|
||||
import type { EndorserAlertSearchData, PartnerAlertSearchData } from "../../src/alertSearch/types.js";
|
||||
|
||||
const USER = "did:ethr:0xchanneluser";
|
||||
|
||||
let dir: string;
|
||||
let savedDataDir: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
savedDataDir = process.env.NOTIFY_DATA_DIR;
|
||||
dir = await mkdtemp(path.join(tmpdir(), "sms-channel-"));
|
||||
process.env.NOTIFY_DATA_DIR = dir;
|
||||
closeDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
closeDatabase();
|
||||
if (savedDataDir === undefined) delete process.env.NOTIFY_DATA_DIR;
|
||||
else process.env.NOTIFY_DATA_DIR = savedDataDir;
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function ulid(n: number): string {
|
||||
return `01H${String(n).padStart(23, "0")}`;
|
||||
}
|
||||
|
||||
function retrieved(endorserUlid: string, partnerAt: string): RetrieveAlertSearchResult {
|
||||
const endorser: AlertSearchSourceResult<EndorserAlertSearchData> = {
|
||||
outcome: "success",
|
||||
pageCount: 1,
|
||||
data: {
|
||||
claims: [{ id: endorserUlid, issuedAt: "2026-09-05T00:00:00Z", issuer: USER }],
|
||||
personalPlanContributions: [],
|
||||
trackedPlanUpdates: [],
|
||||
trackedPlanClaims: [],
|
||||
plansNearby: [],
|
||||
},
|
||||
};
|
||||
const partner: AlertSearchSourceResult<PartnerAlertSearchData> = {
|
||||
outcome: "success",
|
||||
pageCount: 1,
|
||||
data: { profilesNearby: [{ rowId: "r1", updatedAt: partnerAt } as never] },
|
||||
};
|
||||
return {
|
||||
data: { ...endorser.data, ...partner.data },
|
||||
empty: false,
|
||||
endorser,
|
||||
partner,
|
||||
} as RetrieveAlertSearchResult;
|
||||
}
|
||||
|
||||
describe("per-channel alertSearch cursors", () => {
|
||||
it("advances only the SMS table for an SMS run", async () => {
|
||||
await advanceAlertSearchCursors(
|
||||
USER,
|
||||
retrieved(ulid(5), "2026-09-05T00:00:00Z"),
|
||||
"sms"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
(await smsAlertSearchCursorsDb.get(USER))?.endorserAfterId,
|
||||
ulid(5)
|
||||
);
|
||||
assert.equal(await alertSearchCursorsDb.get(USER), undefined);
|
||||
});
|
||||
|
||||
it("defaults to the FCM table when no channel is named", async () => {
|
||||
await advanceAlertSearchCursors(
|
||||
USER,
|
||||
retrieved(ulid(9), "2026-09-06T00:00:00Z")
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
(await alertSearchCursorsDb.get(USER))?.endorserAfterId,
|
||||
ulid(9)
|
||||
);
|
||||
assert.equal(await smsAlertSearchCursorsDb.get(USER), undefined);
|
||||
});
|
||||
|
||||
it("lets the two channels sit at different positions", async () => {
|
||||
await advanceAlertSearchCursors(
|
||||
USER,
|
||||
retrieved(ulid(1), "2026-09-01T00:00:00Z"),
|
||||
"fcm"
|
||||
);
|
||||
await advanceAlertSearchCursors(
|
||||
USER,
|
||||
retrieved(ulid(2), "2026-09-02T00:00:00Z"),
|
||||
"sms"
|
||||
);
|
||||
|
||||
assert.deepEqual(await loadAlertSearchCursors(USER, "fcm"), {
|
||||
endorserAfterId: ulid(1),
|
||||
partnerAfterDate: "2026-09-01T00:00:00Z",
|
||||
});
|
||||
assert.deepEqual(await loadAlertSearchCursors(USER, "sms"), {
|
||||
endorserAfterId: ulid(2),
|
||||
partnerAfterDate: "2026-09-02T00:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an empty position for a channel that has never run", async () => {
|
||||
assert.deepEqual(await loadAlertSearchCursors(USER, "sms"), {});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, beforeEach, describe, it } from "node:test";
|
||||
import {
|
||||
SMS_NOT_CONFIGURED,
|
||||
consoleSmsSender,
|
||||
resetSmsNotConfiguredWarning,
|
||||
sendSms,
|
||||
sendViaTwilio,
|
||||
} from "../../src/services/smsService.js";
|
||||
|
||||
const TWILIO_KEYS = [
|
||||
"TWILIO_ACCOUNT_SID",
|
||||
"TWILIO_AUTH_TOKEN",
|
||||
"TWILIO_FROM_NUMBER",
|
||||
"TWILIO_MESSAGING_SERVICE_SID",
|
||||
"NODE_ENV",
|
||||
] as const;
|
||||
|
||||
let saved: Record<string, string | undefined>;
|
||||
|
||||
beforeEach(() => {
|
||||
saved = {};
|
||||
for (const key of TWILIO_KEYS) {
|
||||
saved[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
resetSmsNotConfiguredWarning();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of TWILIO_KEYS) {
|
||||
if (saved[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = saved[key];
|
||||
}
|
||||
});
|
||||
|
||||
type FetchArgs = { url: string; init: RequestInit };
|
||||
|
||||
function stubFetch(
|
||||
response: { ok: boolean; status: number; body: unknown },
|
||||
captured: FetchArgs[]
|
||||
): typeof fetch {
|
||||
return (async (url: string, init: RequestInit) => {
|
||||
captured.push({ url, init });
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
json: async () => response.body,
|
||||
};
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
describe("sendViaTwilio", () => {
|
||||
const credentials = {
|
||||
accountSid: "AC123",
|
||||
authToken: "secret-token",
|
||||
from: { From: "+15550000000" },
|
||||
};
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("posts a form-encoded message and returns the sid", async () => {
|
||||
const captured: FetchArgs[] = [];
|
||||
globalThis.fetch = stubFetch(
|
||||
{ ok: true, status: 201, body: { sid: "SM999" } },
|
||||
captured
|
||||
);
|
||||
|
||||
const result = await sendViaTwilio(credentials, "+15555550123", "hi");
|
||||
assert.deepEqual(result, { status: "sent", messageId: "SM999" });
|
||||
|
||||
assert.equal(captured.length, 1);
|
||||
assert.equal(
|
||||
captured[0].url,
|
||||
"https://api.twilio.com/2010-04-01/Accounts/AC123/Messages.json"
|
||||
);
|
||||
const headers = captured[0].init.headers as Record<string, string>;
|
||||
assert.equal(
|
||||
headers["Content-Type"],
|
||||
"application/x-www-form-urlencoded"
|
||||
);
|
||||
assert.equal(
|
||||
headers.Authorization,
|
||||
"Basic " + Buffer.from("AC123:secret-token").toString("base64")
|
||||
);
|
||||
const form = new URLSearchParams(captured[0].init.body as string);
|
||||
assert.equal(form.get("To"), "+15555550123");
|
||||
assert.equal(form.get("Body"), "hi");
|
||||
assert.equal(form.get("From"), "+15550000000");
|
||||
});
|
||||
|
||||
it("sends MessagingServiceSid when that is how it is configured", async () => {
|
||||
const captured: FetchArgs[] = [];
|
||||
globalThis.fetch = stubFetch(
|
||||
{ ok: true, status: 201, body: { sid: "SM1" } },
|
||||
captured
|
||||
);
|
||||
await sendViaTwilio(
|
||||
{
|
||||
accountSid: "AC123",
|
||||
authToken: "t",
|
||||
from: { MessagingServiceSid: "MG9" },
|
||||
},
|
||||
"+15555550123",
|
||||
"hi"
|
||||
);
|
||||
const form = new URLSearchParams(captured[0].init.body as string);
|
||||
assert.equal(form.get("MessagingServiceSid"), "MG9");
|
||||
assert.equal(form.get("From"), null);
|
||||
});
|
||||
|
||||
it("reports the provider message on an error response", async () => {
|
||||
globalThis.fetch = stubFetch(
|
||||
{ ok: false, status: 400, body: { message: "Invalid 'To'" } },
|
||||
[]
|
||||
);
|
||||
assert.deepEqual(await sendViaTwilio(credentials, "+1", "hi"), {
|
||||
status: "failed",
|
||||
error: "Invalid 'To'",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails when a 2xx response carries no sid", async () => {
|
||||
globalThis.fetch = stubFetch({ ok: true, status: 200, body: {} }, []);
|
||||
const result = await sendViaTwilio(credentials, "+15555550123", "hi");
|
||||
assert.equal(result.status, "failed");
|
||||
});
|
||||
|
||||
it("turns a transport throw into a failed result", async () => {
|
||||
globalThis.fetch = (async () => {
|
||||
throw new Error("network down");
|
||||
}) as unknown as typeof fetch;
|
||||
assert.deepEqual(await sendViaTwilio(credentials, "+15555550123", "hi"), {
|
||||
status: "failed",
|
||||
error: "network down",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendSms configuration paths", () => {
|
||||
it("fails with SMS_NOT_CONFIGURED when credentials are absent", async () => {
|
||||
assert.deepEqual(await sendSms("+15555550123", "hi"), {
|
||||
status: "failed",
|
||||
error: SMS_NOT_CONFIGURED,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails when an account is set but there is nothing to send from", async () => {
|
||||
process.env.TWILIO_ACCOUNT_SID = "AC123";
|
||||
process.env.TWILIO_AUTH_TOKEN = "t";
|
||||
const result = await sendSms("+15555550123", "hi");
|
||||
assert.equal(result.status, "failed");
|
||||
assert.equal(
|
||||
result.status === "failed" ? result.error : "",
|
||||
SMS_NOT_CONFIGURED
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the console adapter under test-local with no credentials", async () => {
|
||||
process.env.NODE_ENV = "test-local";
|
||||
const result = await sendSms("+15555550123", "hi");
|
||||
assert.equal(result.status, "sent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("consoleSmsSender", () => {
|
||||
it("reports sent without touching the network", async () => {
|
||||
const result = await consoleSmsSender("+15555550123", "hi");
|
||||
assert.equal(result.status, "sent");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { maskPhoneNumber, normalizePhoneNumber } from "../../src/util/smsPhoneNumber.js";
|
||||
|
||||
describe("normalizePhoneNumber", () => {
|
||||
const accepted: [unknown, string][] = [
|
||||
["+15555550123", "+15555550123"],
|
||||
[" +1 555 555 0123 ", "+15555550123"],
|
||||
["(555) 555-0123", "+15555550123"],
|
||||
["555.555.0123", "+15555550123"],
|
||||
["5555550123", "+15555550123"],
|
||||
["15555550123", "+15555550123"],
|
||||
["0015555550123", "+15555550123"],
|
||||
["+442071838750", "+442071838750"],
|
||||
["+81312345678", "+81312345678"],
|
||||
];
|
||||
|
||||
for (const [input, expected] of accepted) {
|
||||
it(`normalizes ${JSON.stringify(input)}`, () => {
|
||||
assert.equal(normalizePhoneNumber(input), expected);
|
||||
});
|
||||
}
|
||||
|
||||
const rejected: unknown[] = [
|
||||
undefined,
|
||||
null,
|
||||
42,
|
||||
"",
|
||||
" ",
|
||||
"not a phone",
|
||||
"555-0123",
|
||||
"+0155555501",
|
||||
"+1555555012345678",
|
||||
"+1555",
|
||||
"25555550123",
|
||||
"+1555555o123",
|
||||
"+",
|
||||
];
|
||||
|
||||
for (const input of rejected) {
|
||||
it(`rejects ${JSON.stringify(input)}`, () => {
|
||||
assert.equal(normalizePhoneNumber(input), undefined);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("maskPhoneNumber", () => {
|
||||
it("keeps the country code and the last two digits", () => {
|
||||
assert.equal(maskPhoneNumber("+15555550123"), "+1555*****23");
|
||||
});
|
||||
|
||||
it("masks a shorter international number without leaking the middle", () => {
|
||||
const masked = maskPhoneNumber("+442071838750");
|
||||
assert.equal(masked, "+4420******50");
|
||||
assert.equal(masked.length, "+442071838750".length);
|
||||
});
|
||||
|
||||
it("never returns more than the last two digits of a short number", () => {
|
||||
assert.equal(maskPhoneNumber("+12345"), "****45");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
VERIFICATION_CODE_LENGTH,
|
||||
hashPhoneNumber,
|
||||
hashVerificationCode,
|
||||
mintVerificationCode,
|
||||
verificationCodeMatches,
|
||||
} from "../../src/util/smsVerificationCode.js";
|
||||
|
||||
const SECRET = "test-sms-code-secret";
|
||||
|
||||
describe("mintVerificationCode", () => {
|
||||
it("always produces six digits, leading zeros included", () => {
|
||||
for (let i = 0; i < 2000; i += 1) {
|
||||
const code = mintVerificationCode();
|
||||
assert.equal(code.length, VERIFICATION_CODE_LENGTH);
|
||||
assert.match(code, /^\d{6}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not return the same code every call", () => {
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < 200; i += 1) seen.add(mintVerificationCode());
|
||||
assert.ok(seen.size > 100, `expected varied codes, got ${seen.size}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashVerificationCode", () => {
|
||||
it("is stable for one secret and different across secrets", () => {
|
||||
assert.equal(
|
||||
hashVerificationCode("483920", SECRET),
|
||||
hashVerificationCode("483920", SECRET)
|
||||
);
|
||||
assert.notEqual(
|
||||
hashVerificationCode("483920", SECRET),
|
||||
hashVerificationCode("483920", "other-secret")
|
||||
);
|
||||
});
|
||||
|
||||
it("never contains the plaintext code", () => {
|
||||
assert.equal(hashVerificationCode("483920", SECRET).includes("483920"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashPhoneNumber", () => {
|
||||
it("is stable per number and does not contain the number", () => {
|
||||
const hash = hashPhoneNumber("+15555550123", SECRET);
|
||||
assert.equal(hash, hashPhoneNumber("+15555550123", SECRET));
|
||||
assert.notEqual(hash, hashPhoneNumber("+15555550124", SECRET));
|
||||
assert.equal(hash.includes("5555550123"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verificationCodeMatches", () => {
|
||||
it("accepts the matching code and rejects every near miss", () => {
|
||||
const stored = hashVerificationCode("483920", SECRET);
|
||||
assert.equal(verificationCodeMatches("483920", stored, SECRET), true);
|
||||
assert.equal(verificationCodeMatches("483921", stored, SECRET), false);
|
||||
assert.equal(verificationCodeMatches("48392", stored, SECRET), false);
|
||||
assert.equal(verificationCodeMatches("", stored, SECRET), false);
|
||||
assert.equal(verificationCodeMatches("483920", stored, "wrong"), false);
|
||||
});
|
||||
|
||||
it("compares full hashes, so a shared prefix is not a partial match", () => {
|
||||
const stored = hashVerificationCode("111111", SECRET);
|
||||
const truncated = stored.slice(0, 10);
|
||||
assert.equal(verificationCodeMatches("111111", truncated, SECRET), false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user