add notify hour-minute setting, and a DELETE for an existing alert

This commit is contained in:
2026-09-07 18:55:52 -06:00
parent 9e5b8bbd59
commit 65eb1bee1e
25 changed files with 2362 additions and 291 deletions
+7
View File
@@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.2.0] - 2026.09.05 ## [0.2.0] - 2026.09.05
### Added ### Added
- `notifyHourUtc` (0-23) and `notifyMinuteUtc` (0-59) are **required** on an alert-authorization batch, both integers and both UTC — the field names carry the frame, so no offset or zone travels with them. Stored zero-padded as one `HH:MM` value in `notify_hour_min_utc`. Required rather than optional because the alternative default is not "no gate" but "the first tick after midnight UTC", which puts every user in one five-minute window. The scheduler holds a user's whole daily run, search included, until that instant, and reports the held users as `deferred`. A stored UTC time does not follow the user through a daylight-saving change; a fresh batch carrying the new offset corrects it
- Both alertSearch schedulers pick their users with one query per pass (`listPendingForDay`) instead of one per user: it returns everyone holding an unused JWT for the current UTC day, flagged by whether their `notify_hour_min_utc` has arrived. A user who has already run that day no longer appears, so an idle tick over 2000 users costs 0 queries and ~1ms, down from 3 queries per user and ~141ms
- Both passes work on `ALERT_SEARCH_USER_CONCURRENCY` users at a time (`src/util/concurrency.ts`, default 8) instead of serially; at 50ms of API latency a 2000-user pass drops from ~106s to ~13s, which is what keeps it inside its own tick
- The notify hour and `timezone` are accepted on `PUT /notifications/alert-authorization` as well, and the push scheduler honours the stored hour
- Optional `timezone` (IANA name) on an alert-authorization batch, validated and stored beside `notify_hour_min_utc` and read by nothing. It is recorded for a future mechanism that would re-derive the UTC time across a DST change; the column carries that reason as a SQL comment
- `DELETE /notifications/alert-authorization` and `DELETE /notify-sms/alert-authorization` remove every batch and JWT a DID holds in that channel, so a user can turn alerts off; verified phone numbers and alertSearch cursors survive, and the SMS revocation is logged as `alert-authorization-deleted` under the `revoke-alert-search` action claim
- `/notify-sms` SMS channel: phone registration with a 6-digit possession check, a per-channel delegated-JWT inventory and cursor, a Twilio sender, an `STOP`/`START`/`HELP` webhook, and a daily digest text alongside the FCM one; off unless `SMS_ENABLED` - `/notify-sms` SMS channel: phone registration with a 6-digit possession check, a per-channel delegated-JWT inventory and cursor, a Twilio sender, an `STOP`/`START`/`HELP` webhook, and a daily digest text alongside the FCM one; off unless `SMS_ENABLED`
- Blocks record their origin (`opt-out` / `provider-opt-out` / `manual`) and are never downgraded; a Twilio `21610` refusal auto-blocks as `provider-opt-out`, and `pnpm run sms:blocks` lists and manages the list by origin - Blocks record their origin (`opt-out` / `provider-opt-out` / `manual`) and are never downgraded; a Twilio `21610` refusal auto-blocks as `provider-opt-out`, and `pnpm run sms:blocks` lists and manages the list by origin
- `sms_blocked_numbers` suppression list: `STOP` now blocks durably instead of only unverifying, `START` lifts the block, and a blocked number is refused at registration and verification and skipped by the digest; `SMS_BLOCKED_NUMBERS` blocks by configuration - `sms_blocked_numbers` suppression list: `STOP` now blocks durably instead of only unverifying, `START` lifts the block, and a blocked number is refused at registration and verification and skipped by the digest; `SMS_BLOCKED_NUMBERS` blocks by configuration
@@ -16,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `pnpm run sms:send` and `pnpm run sms:smoke` exercise the Twilio path and the full `/notify-sms` route flow against a stub Endorser and unsigned test-local JWTs - `pnpm run sms:send` and `pnpm run sms:smoke` exercise the Twilio path and the full `/notify-sms` route flow against a stub Endorser and unsigned test-local JWTs
- `pnpm run twilio:whoami` reports which Twilio account a send would bill and distinguishes a mismatched credential pair from a valid test pair - `pnpm run twilio:whoami` reports which Twilio account a send would bill and distinguishes a mismatched credential pair from a valid test pair
### Changed ### Changed
- Alert-authorization batches no longer schedule from a `timezone`. Days are UTC days: each delegated JWT must be valid for the whole UTC day it names (`nbf` at or before its midnight, `exp` at or after the next), the daily run selects by UTC day and reports it as `utcDay`, and `notifyTime` supplies its own offset. Removes `InvalidAlertAuthorizationTimezoneError`
- `loadAlertSearchCursors`, `advanceAlertSearchCursors`, `runAlertSearchCycle`, and `runDailyAlertSearch` take a channel (`"fcm"` default), selecting the JWT inventory and cursor table - `loadAlertSearchCursors`, `advanceAlertSearchCursors`, `runAlertSearchCycle`, and `runDailyAlertSearch` take a channel (`"fcm"` default), selecting the JWT inventory and cursor table
+261 -25
View File
@@ -1,6 +1,6 @@
# PLAN: SMS notifications (`/notify-sms`) # PLAN: SMS notifications (`/notify-sms`)
**Status:** implemented. Phases 1-12 are code-complete except the **Status:** implemented. Phases 1-15 are code-complete except the
four deployment steps noted under phase 12, which need a real Twilio account and four deployment steps noted under phase 12, which need a real Twilio account and
a real handset. a real handset.
@@ -9,20 +9,25 @@ a real handset.
Deliver the daily alertSearch digest by SMS as well as by FCM. A user registers 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 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 of delegated alertSearch JWTs for the SMS channel, and receives at most one text
per local day when that day's retrieval finds updates. per local day when that day's retrieval finds updates — at an hour of their
choosing, and only until they revoke the authorization.
Every phone action is authorized by a JWT that names the action and the phone 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. number it applies to, and every phone action is recorded.
## Scope ## Scope
In scope: five HTTP endpoints, the middleware chain behind them, five SQLite In scope: six HTTP endpoints, the middleware chain behind them, five SQLite
tables, a Twilio sender, a per-channel cursor split, and an SMS delivery pass tables, a Twilio sender, a per-channel cursor split, and an SMS delivery pass
next to the FCM one. next to the FCM one. Plus one FCM-side addition: `DELETE
/notifications/alert-authorization`, the twin of the SMS revoke route, because a
user turning alerts off means both channels and only one of them had a way to
say so.
Out of scope: changing any FCM behavior, changing `WAKEUP_PING`, MMS, inbound Out of scope: changing how the FCM channel searches or delivers, changing
conversational SMS beyond opt-out keywords, and international sender `WAKEUP_PING`, MMS, inbound conversational SMS beyond opt-out keywords, and
registration beyond US A2P 10DLC. international sender registration beyond US A2P 10DLC. The notification hour is
an SMS-channel setting; the FCM scheduler does not read it.
## Endpoints ## Endpoints
@@ -36,6 +41,7 @@ new `src/routes/notifySms.ts`.
| PUT | `/notify-sms/phone` | Match the code and flip `verified` to true | | PUT | `/notify-sms/phone` | Match the code and flip `verified` to true |
| DELETE | `/notify-sms/phone` | Remove the phone entirely | | DELETE | `/notify-sms/phone` | Remove the phone entirely |
| POST | `/notify-sms/alert-authorization` | Store a delegated JWT batch for the SMS channel | | POST | `/notify-sms/alert-authorization` | Store a delegated JWT batch for the SMS channel |
| DELETE | `/notify-sms/alert-authorization` | Remove every SMS batch and JWT for the DID, turning the channel off |
`src/index.ts` CORS lists `["GET", "POST", "PUT", "OPTIONS"]`. **`DELETE` must be `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 added to that list**, or the browser preflight for the delete route fails before
@@ -130,12 +136,26 @@ Body `{ "phoneNumber": "+15555550123" }`, and also accepted as
### POST /notify-sms/alert-authorization ### POST /notify-sms/alert-authorization
Body is the same shape the FCM twin takes: `{ batchId, timezone, jwts: [100] }`. Body is the FCM twin's shape plus a required UTC hour and an optional zone:
`{ batchId, notifyHourUtc, notifyMinuteUtc, timezone?, jwts: [100] }`.
- Validation reuses `validateAlertAuthorizationBatch` unchanged. It already - Validation reuses `validateAlertAuthorizationBatch`. It verifies each
verifies each delegated JWT's signature, matches `iss` to the authenticated delegated JWT's signature, matches `iss` to the authenticated DID, requires
DID, checks `nbf`/`exp` against the claimed day in the batch timezone, and 100 consecutive sequences and distinct days, and requires each JWT to be valid
requires 100 consecutive sequences. for the whole UTC day it names — `nbf` at or before that day's midnight UTC,
`exp` at or after the next. The daily run selects by UTC day and may fire at
any moment inside it, so a partial window would hand Endorser a credential
outside its own validity period.
- `notifyHourUtc` (0-23) and `notifyMinuteUtc` (0-59) are both required, both
integers, and both UTC — the field names carry the frame, so nothing else has
to. Absent, out of range, or the wrong type is rejected with the rest of the
batch, and one arriving without the other names the missing field. Every batch
therefore states its own hour, and no client silently inherits the first tick
after midnight UTC — the setting that puts every user on one tick.
§Notification hour covers what the value does.
- `timezone` is optional: an IANA name, validated against `Intl` and stored.
Nothing reads it. It may be omitted from a batch that states its hour, but not
the reverse. §Notification hour says what it is being kept for.
- Requires at least one verified phone for the DID. Without one: `409` - Requires at least one verified phone for the DID. Without one: `409`
`SMS_NO_VERIFIED_PHONE`. Storing 100 credentials for a channel with no `SMS_NO_VERIFIED_PHONE`. Storing 100 credentials for a channel with no
reachable address is inventory nobody asked for. reachable address is inventory nobody asked for.
@@ -144,11 +164,36 @@ Body is the same shape the FCM twin takes: `{ batchId, timezone, jwts: [100] }`.
`alertAuthorizationDb.replaceUnusedBatch` pointed at the SMS tables: drop this `alertAuthorizationDb.replaceUnusedBatch` pointed at the SMS tables: drop this
user's unused SMS JWTs, drop orphaned SMS batch rows, insert the new batch, in user's unused SMS JWTs, drop orphaned SMS batch rows, insert the new batch, in
one transaction. one transaction.
- Response mirrors the FCM one: `{ success, batchId, timezone, storedCount, unusedCount }`. - Response mirrors the FCM one plus the stored hour and zone:
`{ success, batchId, notifyHourUtc, notifyMinuteUtc, timezone, storedCount,
unusedCount }`.
- The route registers on `POST` and on `PUT` with the same handler. The FCM twin - 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 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. `PUT` out of symmetry should not get a 404 for its trouble.
### DELETE /notify-sms/alert-authorization
No body. The action claim is `revoke-alert-search`, which binds to no phone
number.
- Removes every SMS batch and every SMS JWT for the DID, consumed rows included,
in one transaction, so the SMS scheduler stops listing that identity at all.
- Response `200` `{ success: true, deletedBatches, deletedJwts }`. Nothing
stored is zeros, not a `404`: the caller asked for a state, and that state is
what they get.
- Registered phone numbers survive. Silencing alerts is not a request to redo
the possession check on return; `DELETE /notify-sms/phone` is the route that
forgets a number, and `STOP` is the route that blocks one.
- The alertSearch cursors survive too, so a later re-authorization resumes where
this one stopped rather than replaying months of history.
- Logged as `alert-authorization-deleted`. For a DID whose handset is already
gone there is no number to log against, so `phone_hash` holds a hash of the
identity instead — the column is `NOT NULL` and the revocation is worth a row.
`DELETE /notifications/alert-authorization` is the same operation on the FCM
inventory, authorized the way its `PUT` twin is: Bearer JWT plus the Endorser
check, no action claim. Device registrations and `WAKEUP_PING` are untouched.
## One phone, several DIDs ## One phone, several DIDs
Nothing keys off a phone number alone except carrier opt-out. Every other read Nothing keys off a phone number alone except carrier opt-out. Every other read
@@ -337,14 +382,15 @@ The app mints the Bearer JWT with a claim this service defines and consumes:
``` ```
`action` is one of `list-phones`, `register-phone`, `verify-phone`, `action` is one of `list-phones`, `register-phone`, `verify-phone`,
`delete-phone`, `authorize-alert-search`. `phoneNumber` is required for `delete-phone`, `authorize-alert-search`, `revoke-alert-search`. `phoneNumber`
`register-phone`, `verify-phone` and `delete-phone`, and optional for is required for `register-phone`, `verify-phone` and `delete-phone`, and
`list-phones` — when the request carries the query parameter, the claim must optional for `list-phones` — when the request carries the query parameter, the
carry the matching number claim must carry the matching number
and is the only binding the claim carries. The claim holds no DID — the and is the only binding the claim carries. The claim holds no DID — the
authenticated identity is `payload.iss` — and no `batchId`. authenticated identity is `payload.iss` — and no `batchId`.
A batch upload is bound by `action` alone. Binding it to a batch id would add A batch upload or revocation is bound by `action` alone. Both act on the DID's
whole inventory rather than on one handset, so there is no number to bind to. Binding it to a batch id would add
nothing: the client invents the id, and the 100 delegated JWTs in the body must 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 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 upload a batch it did not already have the user's signing key to produce. The
@@ -418,8 +464,9 @@ keyword lookup arrives with the number, not the DID), and on
Actions: `register-requested`, `code-sent`, `code-send-failed`, Actions: `register-requested`, `code-sent`, `code-send-failed`,
`verify-succeeded`, `verify-failed`, `did-limit-blocked`, `verify-succeeded`, `verify-failed`, `did-limit-blocked`,
`did-limit-disclosed`, `deleted`, `alert-authorization-stored`, `alert-sent`, `did-limit-disclosed`, `deleted`, `alert-authorization-stored`,
`alert-send-failed`, `opt-out`. `alert-authorization-deleted`, `alert-sent`, `alert-send-failed`,
`recipient-not-allowed`, `number-blocked`, `number-unblocked`, `opt-out`.
`did-limit-blocked` records a POST refused at the limit; `did-limit-disclosed` `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. records a PUT refused at the limit, where the DID list left the building.
@@ -438,7 +485,11 @@ protect.
Column-for-column mirrors of `alert_authorization_batches` / Column-for-column mirrors of `alert_authorization_batches` /
`alert_authorization_jwts`, including the partial unique index on `alert_authorization_jwts`, including the partial unique index on
`(user_id, day) WHERE status = 'unused'`. `(user_id, day) WHERE status = 'unused'`. `day` is a UTC calendar day. Both
batch tables carry nullable `notify_hour_min_utc` and `timezone` because one store
implementation writes both; only the SMS side ever fills them in. `timezone`
carries its purpose as a SQL comment, which SQLite preserves verbatim in
`sqlite_master`, so `.schema` shows the reason beside the column.
### `sms_alert_search_cursors` ### `sms_alert_search_cursors`
@@ -491,15 +542,90 @@ everywhere, so no existing call site or test changes.
`startSmsAlertSearchScheduler()`, started from `src/index.ts` alongside the `startSmsAlertSearchScheduler()`, started from `src/index.ts` alongside the
other two, with its own interval and its own process-local in-flight guard. other two, with its own interval and its own process-local in-flight guard.
- Lists distinct `user_id` from `sms_alert_authorization_batches`. - Selects users with one query per pass, `listPendingForDay({ day, hourMinute })`:
- Calls `runDailyAlertSearch(userId, new Date(), {}, "sms")`, then everyone holding an unused JWT for the current UTC day, each flagged by whether
`deliverAlertSearchSms`. their `notify_hour_min_utc` has arrived. A user who has already run that day holds
no unused JWT for it and does not appear (§Scheduler selection).
- Calls `runDailyAlertSearch(userId, now, {}, "sms")` on the due ones, at most
`ALERT_SEARCH_USER_CONCURRENCY` in flight, then `deliverAlertSearchSms`.
- Interval `SMS_ALERT_SEARCH_INTERVAL_MS`, default 5 minutes, with a 150-second - 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 initial offset so the SMS pass and the FCM pass do not hit Endorser in the
same instant. same instant.
- Logs `[SmsAlertSearchScheduler] Pass started` / `Pass completed in …`, matching - Logs `[SmsAlertSearchScheduler] Pass started` / `Pass completed in …`, matching
the existing prefix convention. the existing prefix convention.
### Scheduler selection
Both passes choose their users in SQL rather than by asking about each one.
`listPendingForDay` joins the newest batch per user — picked with a window
function, because a batch holding a consumed JWT survives the next upload, so
"the user's notify time" is not a plain join — against that day's unused JWTs,
and returns a `due` flag per row. Two properties fall out: a user who has
already run today is absent rather than queried and discarded, and `deferred`
stays countable because held users still appear.
The comparison is text, which is chronological only because `HH:MM` is
zero-padded. A stored value that is not `HH:MM` would sort above every real one
and defer that user forever, so the query guards it with `GLOB` and treats an
unreadable value as due — the same fail-open the column's own documentation
promises.
Due users are worked `ALERT_SEARCH_USER_CONCURRENCY` at a time. The per-user
work is two external round trips against independent cursors, so a serial loop
spends the pass waiting rather than working; measured at 50ms of latency, a
2000-user pass falls from ~106s to ~13s, which is the difference between fitting
in a five-minute tick and being skipped by the in-flight guard. The bound stays
low on purpose: the ceiling is latency, and Endorser and Partner are shared
infrastructure that a wide fan-out would only move the queue into.
### Notification hour
A batch's `notify_hour_min_utc` is the hour the user asked to hear from the
service, sent as two UTC integers and stored zero-padded as one `HH:MM` value.
The SMS scheduler holds that user's whole daily pass until the clock reaches it.
A batch cannot omit the hour, so this gate applies to every user rather than to
the subset who expressed a preference. That is deliberate: the alternative
default is "the first tick after midnight UTC", which is one tick for everybody.
The gate sits ahead of the search, not ahead of the text. Running the search
consumes that UTC day's JWT, and `isAlertSearchSmsEligible` fires only for the
run that consumed it, so a run at the top of the day would spend the credential
and reach the chosen hour with nothing left to send.
The hour lives on the batch rather than inside the delegated JWTs because those
are the alertSearch credential: their `nbf`/`exp` bound a whole UTC day, and
narrowing them to an hour would narrow when the search may run against Endorser,
not when the user hears about it.
The stored value is an instant of the UTC day, not a wall clock, so it does not
follow the user through a daylight-saving change: a Denver user wanting 18:00
local sends `00:30` UTC in summer, and when that region returns to `-07:00` the
same UTC instant reads 17:00 locally. The correction available today is a fresh
batch carrying the new UTC hour, which a client uploads roughly every 100 days
anyway.
The batch's optional `timezone` is recorded against a mechanism that would close
that gap sooner by re-deriving `notify_hour_min_utc` from the zone's rules. None
runs, and nothing reads the column. Two candidates are open, and §Rejected
records what is known about each: a job that sweeps zones whose rules changed,
or a stored next-firing instant that each send recomputes, which needs no
scheduled job. Measured cost is the reason neither is urgent — a formatter
cached per zone resolves a user's local wall clock in about 1.4µs, half the cost
of the per-user SQLite lookup the pass already performs.
There is no upper bound within the day. A service that was down at the chosen
hour and comes back six hours later still runs that UTC day; a silent day is the
worse failure. The text lands on the first tick at or after the hour, so within
one `SMS_ALERT_SEARCH_INTERVAL_MS` in the ordinary case. An hour late in the UTC
day leaves a correspondingly short window before the day key rolls.
Every batch that arrives through the route carries an hour, so the ungated path
is a floor rather than a setting: a row whose `notify_hour_min_utc` is NULL or
unreadable is treated as due, because a value nobody can read must not silence a
channel the user asked for. Only a write that bypasses the route can produce
one.
### Cost of two inventories ### Cost of two inventories
A user on both channels produces two Endorser and two Partner queries per day A user on both channels produces two Endorser and two Partner queries per day
@@ -591,7 +717,19 @@ above the services layer, and digest logic never appears below it.
Modified: Modified:
- `src/db/sqlite.ts` — five tables and their indexes - `src/db/sqlite.ts` — five tables, their indexes, and `notify_hour_min_utc` in
place of `timezone` on both alert-authorization batch tables
- `src/db/alertAuthorizationSqlite.ts` — `notify_hour_min_utc` through the shared
store, `timezone` dropped, plus `deleteAllForUser`
- `src/services/alertAuthorization.ts` — notify-hour and UTC-day JWT validation,
and the clock helpers (`formatHourMinuteUtc`, `parseHourMinuteUtc`,
`storedNotifyLabel`, `utcCalendarDay`, `utcDayStartSeconds`, `utcHourMinute`)
- `src/alertSearch/daily.ts` — selects by UTC day and reports `utcDay`;
`InvalidAlertAuthorizationTimezoneError` removed
- `src/alertSearch/scheduler.ts` — set-based selection, bounded concurrency, and
the notify hour on the push channel
- `src/util/concurrency.ts` — the bounded pool both passes run users through
- `src/routes/notifications.ts` — the FCM twin of the revoke route
- `src/middleware/auth.ts` — carry the decoded payload on `req.auth` - `src/middleware/auth.ts` — carry the decoded payload on `req.auth`
- `src/types/express.d.ts` — the widened `auth` type - `src/types/express.d.ts` — the widened `auth` type
- `src/env.ts` — the SMS variables, read the same way `ENDORSER_URL` is - `src/env.ts` — the SMS variables, read the same way `ENDORSER_URL` is
@@ -673,6 +811,41 @@ Modified:
`test-local` in the deployed environment. `test-local` in the deployed environment.
- [ ] Prune-job follow-up filed for `sms_phone_log` retention and - [ ] Prune-job follow-up filed for `sms_phone_log` retention and
`sms_action_jwt_use` rows. `sms_action_jwt_use` rows.
- [x] **13. Notification hour and revocation.** `notify_hour_min_utc` on both batch
tables and through `validateAlertAuthorizationBatch`; `isNotifyTimeReached`
gating the SMS pass ahead of the search; `deleteAllForUser` on the shared
store behind `DELETE /notify-sms/alert-authorization` (action
`revoke-alert-search`) and `DELETE /notifications/alert-authorization`.
Tests: the hour/minute validation and its ranges; a pass
that defers before the hour, runs after it, and closes again when the UTC
day rolls; a batch with no hour running unchanged; a failing due-check
counted as failed rather than deferred; the round trip of a stored hour;
deletion of consumed and unused rows together while the other channel and
the other DIDs are untouched; the verified phone surviving a revocation;
and the wrong action claim refused.
- [x] **14. Drop the batch timezone.** `timezone` out of the request body, both
batch tables, the store record, and the response. `day` becomes a UTC
calendar day, each JWT must cover the whole of the day it names, and
`runDailyAlertSearch` selects by UTC day and reports `utcDay`. Tests: the
UTC-day selector including the rollover at midnight, a real-date check
that refuses `2026-02-30` rather than rolling it into March, and a
partial-window JWT refused at upload.
- [ ] End-to-end pass of the hour against a real handset: authorize with a
UTC hour an hour out, confirm no text before it and one after, then
`DELETE` and confirm the next day is silent.
- [ ] Confirm the app mints its 100 JWTs on UTC-day boundaries. The batch is
refused otherwise, so this is the one client change this phase forces.
- [x] **15. Set-based selection, bounded concurrency, and the hour on FCM.**
`listPendingForDay` on the shared store replaces per-user `getLatestBatch`
polling in both passes; `forEachWithConcurrency` bounds the per-user work;
the notify hour and `timezone` are accepted on the FCM route and both
schedulers gate on the stored hour; the hour is required on both channels,
so every batch states its own. `isNotifyTimeReached` is deleted rather
than left beside the SQL that now decides the same question. Tests: the
query's day filter, spent-user exclusion, hour boundary, newest-batch
selection, channel isolation, and the unreadable-value fail-open; the pool's
limit, ordering at a limit of one, and error propagation; both schedulers
deferring and then running a user as the hour passes.
## Tests ## Tests
@@ -736,3 +909,66 @@ including one minted for an unrelated purpose and captured — deletes a phone
registration. registration.
**The `twilio` SDK.** One form POST does not justify a dependency tree. **The `twilio` SDK.** One form POST does not justify a dependency tree.
**Gating the notification hour at the send instead of the search.** The natural
reading of "send at 18:00" is a check inside `deliverAlertSearchSms`. It would
never fire: the daily run consumes that local day's JWT, and eligibility depends
on the run that consumed it, so the midnight tick would spend the day's
credential and 18:00 would find `digest: null`. Holding the whole pass costs one
indexed batch lookup per user per tick.
**Carrying the hour inside the delegated JWTs.** They are the alertSearch
credential; `nbf`/`exp` bound a whole local day. Narrowing them to an hour would
narrow when the search may run against Endorser, not when the user hears about
it, and would make the hour unchangeable without re-minting all 100.
**A window rather than a floor.** "Send between 18:00 and 19:00" would leave a
user silent for the day whenever the service was down across that hour, which is
the failure the daily digest exists to avoid. The hour is a floor, and a late
text beats none.
**An ISO-8601 `notifyTime` carrying its own `±HH:MM` offset.** It was
self-describing — `"18:00-06:00"` cannot be misread as a wall clock — which is
the property a bare pair of integers lacks. Rejected once the fields were named
`notifyHourUtc` and `notifyMinuteUtc`: a name that states the frame carries it
as surely as an offset does, and unlike an offset it cannot disagree with the
value beside it. Two integers also validate by range rather than by a 16-line
regex that had to accept basic and extended offsets, optional seconds, optional
fractions, and an optional leading date that changed nothing.
**Deleting phone registrations along with the authorization.** Turning alerts
off would then cost a fresh 6-digit round trip to turn them back on. The two are
separate routes because they are separate decisions.
**Scheduling from an IANA `timezone` instead of a stored UTC instant.** The zone
is the only value that tracks a local hour across a daylight-saving transition,
so scheduling from it would make the notification hour survive one. Rejected for
now on the strength of the drift being corrected by the next batch upload, which
the 100-day inventory forces anyway. The zone is accepted and stored regardless,
unread, so that whichever mechanism wins has the value it needs.
Not a reason: cost. Resolving a user's local wall clock with an
`Intl.DateTimeFormat` cached per zone measures ~1.4µs, against ~2.8µs for the
per-user SQLite lookup the pass already performs — the zone math is half the
price of a query the scheduler pays today, and 100k users cost ~137ms once per
tick. The 34µs/user figure that makes it look expensive comes from constructing
the formatter inside the loop; a `Map` keyed by zone removes it, and a
deployment sees a handful of distinct zones rather than the full IANA set.
**A weekend job that shifts stored UTC times for zones whose DST changed.** It
would keep the indexed `WHERE notify_hour_min_utc <= ?` shape while staying correct
across transitions. Weighed against a stored *next-firing instant* that each
send recomputes from the zone: the job has to run forever or users drift
silently for up to six months; it consults `Intl` per zone anyway, so it is the
same computation merely batched and delayed; and it has to be driven by the
rules rather than a calendar, because Lord Howe shifts 30 minutes, the southern
hemisphere runs opposite, and legislatures abolish DST on short notice (Mexico,
2022). The next-firing-instant design does its maintenance at the moment each
row is used, which is when the answer is needed and when it is cheapest to be
sure of. Neither is built; the column that either would need is.
**Keeping local calendar days with the timezone removed.** There would be no
frame left to resolve them in: `day` would be a label the server could not
check, and the daily run would have no way to know which label meant today. UTC
days give both the check and the selector one frame, at the cost of a client
that must mint its windows on UTC midnights.
+159 -20
View File
@@ -32,7 +32,7 @@ On first use, the service creates `NOTIFY_DATA_DIR` (default `./data`) and the S
### Authentication ### Authentication
`POST /notifications/register` and `POST /notifications/refresh` require a Bearer JWT. After local JWT verification, the service checks the token with Endorser (`GET /api/report/rateLimits` on `ENDORSER_URL`). Registration and refresh continue only if Endorser accepts the JWT. `POST /notifications/register` and `POST /notifications/refresh` require a Bearer JWT. After local JWT verification, the service checks the token with Endorser. Registration and refresh continue only if Endorser accepts the JWT.
`PUT /notifications/alert-authorization` uses the same current-user Bearer JWT + Endorser check. It does **not** accept the `testMode` local bypass. The 100 delegated JWTs in the body are stored credentials, not the request authenticator. `PUT /notifications/alert-authorization` uses the same current-user Bearer JWT + Endorser check. It does **not** accept the `testMode` local bypass. The 100 delegated JWTs in the body are stored credentials, not the request authenticator.
@@ -51,20 +51,33 @@ Authorization: Bearer <current-user-JWT>
```json ```json
{ {
"batchId": "client-batch-id", "batchId": "client-batch-id",
"notifyHourUtc": 18,
"notifyMinuteUtc": 30,
"timezone": "America/Denver", "timezone": "America/Denver",
"jwts": [ "jwts": [
{ {
"sequence": 0, "sequence": 0,
"day": "2026-08-27", "day": "2026-08-27",
"nbf": 1756270800, "nbf": 1756252800,
"exp": 1756357200, "exp": 1756339200,
"jwt": "eyJ..." "jwt": "eyJ..."
} }
] ]
} }
``` ```
`timezone` is the IANA zone used when minting the 100 validity windows; it is stored as batch metadata, not live device-timezone tracking. A successful call replaces that user's previous **unused** JWTs atomically. Passkey (`did:peer`) identities cannot mint this batch and receive `DELEGATED_JWT_UNSUPPORTED_IDENTITY`. `day` is a UTC calendar day, and each JWT must be valid for the whole of the day it names: `nbf` at or before midnight UTC that opens it, `exp` at or after midnight UTC that closes it. The daily run selects by UTC day and may fire at any moment inside it, catch-up runs included, so a window covering only part of that day would hand Endorser a credential outside its own validity period. Days must be distinct and the 100 `sequence` values consecutive.
`notifyHourUtc` and `notifyMinuteUtc` are **required** and `timezone` is optional; all three behave exactly as on [the SMS twin](#endpoints). Both channels gate on the stored hour ([Notification hour](#notification-hour)).
A successful call replaces that user's previous **unused** JWTs atomically. Passkey (`did:peer`) identities cannot mint this batch and receive `DELEGATED_JWT_UNSUPPORTED_IDENTITY`.
### Removing an authorization
`DELETE /notifications/alert-authorization`
Removes every batch and every JWT this DID holds for the push channel, consumed rows included, so the alertSearch scheduler stops listing the identity. It answers `{ success: true, deletedBatches, deletedJwts }`, with zeros when there was nothing stored. Device registrations and `WAKEUP_PING` are untouched, and so are the alertSearch cursors: a later re-authorization resumes where this one stopped rather than replaying months of history.
### Alert search retrieval ### Alert search retrieval
@@ -77,9 +90,9 @@ The **delegated** JWT is sent as `Authorization: Bearer`. Pass independent `endo
`loadAlertSearchCursors` / `retrieveAlertSearch` / `advanceAlertSearchCursors` (or `runAlertSearchCycle`) persist those bounds per user DID in SQLite. Cursors advance only after a complete `success` retrieval (not `empty`, `pagination`, or errors). A Partner page of 50 rows that share the oldest `updatedAt` is `pagination` because exclusive `beforeDate` cannot drain timestamp ties. `loadAlertSearchCursors` / `retrieveAlertSearch` / `advanceAlertSearchCursors` (or `runAlertSearchCycle`) persist those bounds per user DID in SQLite. Cursors advance only after a complete `success` retrieval (not `empty`, `pagination`, or errors). A Partner page of 50 rows that share the oldest `updatedAt` is `pagination` because exclusive `beforeDate` cannot drain timestamp ties.
`runDailyAlertSearch(userId, now?)` uses the latest batch's stored IANA timezone to pick today's unused delegated JWT, runs `runAlertSearchCycle` with that JWT, and marks that specific JWT consumed only when both Endorser and Partner complete (`success` or `empty`, including both empty). Pagination or source failures leave the JWT unused so the same day can be retried. An invalid stored timezone is an error; there is no fallback to the server timezone. After a retrieve, the result includes `digest` from `buildAlertSearchDigest` (six bucket records and counts). `digest` is `null` when there is no batch or no unused JWT for today. Consumption does not depend on `digest.hasUpdates`. `runDailyAlertSearch(userId, now?)` picks the unused delegated JWT for the current **UTC** day, runs `runAlertSearchCycle` with that JWT, and marks that specific JWT consumed only when both Endorser and Partner complete (`success` or `empty`, including both empty). Pagination or source failures leave the JWT unused so the same day can be retried. The result reports the day it used as `utcDay`. After a retrieve, the result includes `digest` from `buildAlertSearchDigest` (six bucket records and counts). `digest` is `null` when there is no batch or no unused JWT for today. Consumption does not depend on `digest.hasUpdates`.
`startAlertSearchScheduler()` (started from `src/index.ts` next to the FCM scheduler) is a **separate** user-level job. It lists distinct `userId`s from `alert_authorization_batches` and calls `runDailyAlertSearch` once per user. After each run, if the digest is complete with updates **and** today's JWT was consumed, it sends a user-visible FCM message (`title: TimeSafari`, body `You have N new updates.`, data `type: alert_search`) to that user's registered tokens. It does not call `sendPushToDevice` or change `WAKEUP_PING`. Subsequent ticks the same local day see no unused JWT (`digest: null`) and do not resend. FCM send failures are logged and do not roll back cursors or JWT consumption. A process-local in-flight flag skips a tick if a pass is still running. `startAlertSearchScheduler()` (started from `src/index.ts` next to the FCM scheduler) is a **separate** user-level job. One query per pass asks for the users who hold an unused JWT for the current UTC day, flagged by whether their `notify_hour_min_utc` has arrived ([Scheduler selection](#scheduler-selection)); it calls `runDailyAlertSearch` on the due ones, up to `ALERT_SEARCH_USER_CONCURRENCY` at a time. After each run, if the digest is complete with updates **and** today's JWT was consumed, it sends a user-visible FCM message (`title: TimeSafari`, body `You have N new updates.`, data `type: alert_search`) to that user's registered tokens. It does not call `sendPushToDevice` or change `WAKEUP_PING`. Subsequent ticks the same local day see no unused JWT (`digest: null`) and do not resend. FCM send failures are logged and do not roll back cursors or JWT consumption. A process-local in-flight flag skips a tick if a pass is still running.
`buildAlertSearchDigest` maps a retrieve result into structured payload data: per-bucket record arrays, counts, `totalCount`, `hasUpdates`, and Endorser/Partner completion status. It does not invent a notification string; FCM uses only `totalCount` for the short body. Incomplete outcomes (`pagination`, auth, network, etc.) yield `completed: false` and `hasUpdates: false`. Empty successful retrieves are complete with `hasUpdates: false` and do not send FCM. `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.
@@ -102,6 +115,7 @@ The whole surface is off unless `SMS_ENABLED` is `true`; every route answers
| PUT | `/notify-sms/phone` | Match the code and mark the registration verified | | PUT | `/notify-sms/phone` | Match the code and mark the registration verified |
| DELETE | `/notify-sms/phone` | Remove the phone entirely | | DELETE | `/notify-sms/phone` | Remove the phone entirely |
| POST, PUT | `/notify-sms/alert-authorization` | Store a delegated JWT batch for the SMS channel | | POST, PUT | `/notify-sms/alert-authorization` | Store a delegated JWT batch for the SMS channel |
| DELETE | `/notify-sms/alert-authorization` | Remove every SMS batch and JWT for the DID, turning the channel off |
| POST | `/notify-sms/inbound` | Twilio's webhook for `STOP` / `START` / `HELP` | | POST | `/notify-sms/inbound` | Twilio's webhook for `STOP` / `START` / `HELP` |
`GET /notify-sms/phone` returns the caller's own numbers in full. Adding `GET /notify-sms/phone` returns the caller's own numbers in full. Adding
@@ -129,10 +143,42 @@ matching `(user_id, phone_e164)` and sets `phone_e164` to null on that DID's
history intact. Deleting a number that is not registered returns history intact. Deleting a number that is not registered returns
`{ success: true, deleted: false }`, not an error. `{ success: true, deleted: false }`, not an error.
`POST /notify-sms/alert-authorization` takes the same body as the FCM twin and `POST /notify-sms/alert-authorization` takes the FCM twin's body plus a
requires at least one verified phone for the DID; without one it returns required `notifyHourUtc` (integer 0-23) and `notifyMinuteUtc` (integer 0-59):
`409 SMS_NO_VERIFIED_PHONE`. `PUT` is accepted as an alias, since the FCM twin the hour the user wants to hear from the service. **Both are UTC**, which the
is `PUT` and the semantics are replace-not-append either way. field names carry, so no offset and no zone travel with them and there is
nothing for the server to misread. A client sends `date.getUTCHours()` and
`date.getUTCMinutes()` with no formatting step.
Both are required, and one without the other is refused naming the missing
field, so every batch states its own hour rather than inheriting one
([Notification hour](#notification-hour)). Out of range, fractional, or the
wrong type is rejected with the rest of the batch. The response echoes both
integers; the server composes the zero-padded `HH:MM` it stores.
An optional `timezone` (an IANA name such as `"America/Denver"`) is validated
and stored beside them. **Nothing reads it**: the two UTC integers are what
schedule a send. It is recorded against the day something needs to re-derive
that hour across a DST change — see [Notification
hour](#notification-hour). A batch may carry the hour without it, but not the
reverse. A name `Intl` cannot resolve is rejected with the rest of the
batch, because a zone this service cannot resolve would be worth nothing to
whatever reads the column later.
The route requires at least one verified phone for the DID;
without one it returns `409 SMS_NO_VERIFIED_PHONE`. `PUT` is accepted as an
alias, since the FCM twin is `PUT` and the semantics are replace-not-append
either way.
`DELETE /notify-sms/alert-authorization` is how a user turns texts off. It
removes every SMS batch and JWT for the DID, used or not, so the SMS scheduler
stops listing that identity, and answers `{ success: true, deletedBatches,
deletedJwts }` — zeros when there was nothing stored. Registered phone numbers
survive: silencing alerts is not a request to redo the possession check later.
`DELETE /notify-sms/phone` is the route that forgets a number, and `STOP` is the
route that blocks one. The revocation is recorded in `sms_phone_log` as
`alert-authorization-deleted`; for a DID whose handset is already gone, the
`phone_hash` on that row stands in for the number the column expects.
### Action claim ### Action claim
@@ -154,9 +200,11 @@ action and the phone number it applies to:
``` ```
`action` is one of `list-phones`, `register-phone`, `verify-phone`, `action` is one of `list-phones`, `register-phone`, `verify-phone`,
`delete-phone`, `authorize-alert-search`. `phoneNumber` is required for `delete-phone`, `authorize-alert-search`, `revoke-alert-search`. `phoneNumber`
`register-phone`, `verify-phone` and `delete-phone`, and required for is required for `register-phone`, `verify-phone` and `delete-phone`, and
`list-phones` only when the request carries the query parameter. The claim holds required for `list-phones` only when the request carries the query parameter.
`authorize-alert-search` and `revoke-alert-search` act on the DID's whole
inventory rather than on one handset, so they bind to no number. The claim holds
no DID — the authenticated identity is `iss` — and no `batchId`. no DID — the authenticated identity is `iss` — and no `batchId`.
`@context` is `https://giftopia.tech` with no trailing path, shared with the FCM `@context` is `https://giftopia.tech` with no trailing path, shared with the FCM
@@ -307,17 +355,52 @@ texts all of them daily. The allowlist turns that from an incident into a log
line. `SMS_ENABLED=false` remains the blunter switch — it returns `503` from line. `SMS_ENABLED=false` remains the blunter switch — it returns `503` from
every route and stops the SMS scheduler from starting at all. every route and stops the SMS scheduler from starting at all.
### Scheduler selection
Both alertSearch passes choose their users with one query, not one query per
user. `listPendingForDay({ day, hourMinute })` returns every user holding an
unused JWT for that UTC day, each flagged `due` by whether their batch's
`notify_hour_min_utc` has arrived:
- A user who has already run today holds no unused JWT for it and does not
appear at all, so the ~287 ticks a day that have nothing to do for them cost
nothing.
- A user whose hour has not arrived appears with `due: false` and is counted in
`deferred`, which is what keeps that number in the log line. Every batch has an
hour, so this applies to every user rather than to a subset.
- The batch consulted is the newest one per user, picked with a window function.
A user accumulates batch rows, because a batch with a consumed JWT survives the
next upload, so "the user's notify time" is not a plain join.
- `HH:MM` is zero-padded, so the text comparison SQLite performs is
chronological.
Due users are then worked on `ALERT_SEARCH_USER_CONCURRENCY` at a time
(`src/util/concurrency.ts`, currently 8). The per-user work is two external
round trips against that user's own cursors, so a serial loop spends the pass
waiting: at 200ms per user it stops fitting inside a five-minute tick at roughly
1,500 users, after which the in-flight guard skips passes and users miss days.
The bound is held low deliberately — the ceiling it relieves is latency, and the
two APIs on the other end are shared infrastructure that a wide fan-out would
only move the queue into.
Measured on this codebase at 2000 users, one tick: a pass where every user has
already run costs 0 queries and ~1ms (it was 3 queries per user and ~141ms when
the scheduler asked per user), and a pass where every user is due drops from
~106s to ~13s at 50ms of API latency.
### SMS delivery ### SMS delivery
`startSmsAlertSearchScheduler()` is a third scheduler alongside the FCM wakeup `startSmsAlertSearchScheduler()` is a third scheduler alongside the FCM wakeup
and FCM alertSearch passes, with its own interval and FCM alertSearch passes, with its own interval
(`SMS_ALERT_SEARCH_INTERVAL_MS`, default 5 minutes) and a 150-second initial (`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 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 selects its users with the one query described in [Scheduler
`runDailyAlertSearch(userId, now, {}, "sms")`, and then `deliverAlertSearchSms`. selection](#scheduler-selection), calls `runDailyAlertSearch(userId, now, {},
It logs `[SmsAlertSearchScheduler] Pass started` / `Pass completed in`. The "sms")` on the due ones with a bounded number in flight, and then
in-flight guard is process-local, exactly like the other two, so a second `deliverAlertSearchSms`. It logs `[SmsAlertSearchScheduler] Pass started` /
replica double-texts. `Pass completed in`, the latter with `attempted`, `deferred`, and `failed`
counts. The in-flight guard is process-local, exactly like the other two, so a
second replica double-texts.
Eligibility is the same predicate the FCM path uses: the run consumed today's 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 JWT and the digest is complete with updates. Consumption is what makes later
@@ -335,6 +418,61 @@ 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 daily runs against one row means whichever fires first consumes the delta and
the other reports nothing. the other reports nothing.
### Notification hour
Every batch on either channel carries a UTC hour and minute. The server stores
them zero-padded as one `HH:MM` value in `notify_hour_min_utc`, which is the
form SQLite compares chronologically. Both schedulers hold a
user's whole daily run until that UTC time arrives, and count the held users as
`deferred` in the pass result. The decision is made in SQL, not per user — see
[Scheduler selection](#scheduler-selection).
The field is required rather than optional because the alternative default is
not "no gate" but "the first tick after midnight UTC" — a user's JWT for a new
UTC day becomes selectable at that moment, so every user without an hour runs in
the same five-minute window. Making each batch name its own hour spreads that
load as a side effect of asking the question.
The gate sits ahead of the search rather than ahead of the text. Running the
search consumes that UTC day's JWT, and a digest is sent only for the run that
consumed it, so a search that ran at the top of the day would leave nothing to
send at the chosen hour.
The hour lives on the batch rather than inside the delegated JWTs. Those are the
alertSearch credential, and their `nbf`/`exp` bound a whole UTC day; narrowing
them to an hour would narrow when the search may run against Endorser, not when
the user hears about it.
**A stored UTC time does not follow the user through a daylight-saving change.**
Someone in Denver who wants 18:00 local sends `00:30` UTC in summer, and when
their region returns to `-07:00` that same UTC instant reads 17:00 on their wall
clock. The correction available today is a fresh batch carrying the new UTC
hour — which a
client uploads roughly every 100 days anyway, since that is how long an
inventory lasts.
The optional `timezone` on the batch exists for a mechanism that would close
that gap without waiting for the next upload, by re-deriving `notify_hour_min_utc`
from the zone's current rules. No such mechanism runs: the column is recorded
and unread, and the schema says so in a comment SQLite keeps, so `.schema` shows
the reason next to the column. Which mechanism it should be is open — a job that
sweeps changed zones, or a stored next-firing instant recomputed each time a
user fires, which needs no scheduled job at all.
There is no upper bound within the day. A service that was down at the chosen
hour and comes back six hours later still runs that UTC day; a silent day is the
worse failure. The text lands on the first tick at or after the hour, so within
one `SMS_ALERT_SEARCH_INTERVAL_MS` in the ordinary case. An hour late in the UTC
day leaves a correspondingly short window before the day key rolls and that
day's JWT is skipped.
A row whose `notify_hour_min_utc` is NULL or not `HH:MM` is treated as due: a value
nobody can read must not silence a channel the user asked for. The selection
query enforces that with a `GLOB` guard, because text ordering alone would rank
an unreadable value above every real `HH:MM` and defer such a user permanently
rather than once. The route cannot produce either state — only a write that
bypasses it can.
### Testing SMS locally ### Testing SMS locally
Two scripts cover the two things worth checking separately. Both default to Two scripts cover the two things worth checking separately. Both default to
@@ -501,7 +639,7 @@ Unique on `(user_id, device_id)`. Indexes also exist on `user_id`, `device_id`,
Tables `alert_authorization_batches` and `alert_authorization_jwts` hold a user's delegated notification-JWT inventory (separate from device registration): Tables `alert_authorization_batches` and `alert_authorization_jwts` hold a user's delegated notification-JWT inventory (separate from device registration):
- Batch: `id`, `user_id` (authenticated DID), `batch_id`, `timezone` (IANA name at mint time), `created_at` - Batch: `id`, `user_id` (authenticated DID), `batch_id`, `notify_hour_min_utc` (zero-padded `HH:MM` UTC, nullable), `timezone` (IANA name, nullable, recorded but unread), `created_at`
- JWT: `batch_pk`, `sequence`, `day` (`YYYY-MM-DD`), `jwt`, `nbf`, `exp`, `status` (`unused` / `consumed`), `consumed_at`, timestamps - JWT: `batch_pk`, `sequence`, `day` (`YYYY-MM-DD`), `jwt`, `nbf`, `exp`, `status` (`unused` / `consumed`), `consumed_at`, timestamps
Unique on `(batch_pk, sequence)` and on `(user_id, day)` for unused rows. Indexes also exist on `(user_id, status)`, `(user_id, day)`, and `batch_pk`. Unique on `(batch_pk, sequence)` and on `(user_id, day)` for unused rows. Indexes also exist on `(user_id, status)`, `(user_id, day)`, and `batch_pk`.
@@ -528,7 +666,8 @@ Table `sms_phone_log` records every phone action: `user_id`, `phone_e164`
`provider_message_id`, `created_at`. Actions are `register-requested`, `provider_message_id`, `created_at`. Actions are `register-requested`,
`code-sent`, `code-send-failed`, `verify-succeeded`, `verify-failed`, `code-sent`, `code-send-failed`, `verify-succeeded`, `verify-failed`,
`did-limit-blocked`, `did-limit-disclosed`, `deleted`, `did-limit-blocked`, `did-limit-disclosed`, `deleted`,
`alert-authorization-stored`, `alert-sent`, `alert-send-failed`, `alert-authorization-stored`, `alert-authorization-deleted`, `alert-sent`,
`alert-send-failed`,
`recipient-not-allowed`, `number-blocked`, `number-unblocked`, and `opt-out`. `recipient-not-allowed`, `number-blocked`, `number-unblocked`, and `opt-out`.
Indexes on `(user_id, created_at)`, `(phone_hash, created_at)`, and Indexes on `(user_id, created_at)`, `(phone_hash, created_at)`, and
`(action, created_at)`; the throttle counts read the second. `(action, created_at)`; the throttle counts read the second.
+8
View File
@@ -184,6 +184,14 @@ async function main(): Promise<void> {
"list-phones", "list-phones",
TO_NUMBER TO_NUMBER
); );
// No batch was ever stored here (that needs 100 signed delegated JWTs), so
// this reports zeros. It still proves the route, the claim, and the log line.
await call(
"DELETE /alert-a",
"DELETE",
"/notify-sms/alert-authorization",
"revoke-alert-search"
);
await call( await call(
"DELETE /phone ", "DELETE /phone ",
"DELETE", "DELETE",
+67 -80
View File
@@ -12,22 +12,19 @@ import {
import { alertSearchCursorsDb } from "../db/alertSearchCursorsSqlite.js"; import { alertSearchCursorsDb } from "../db/alertSearchCursorsSqlite.js";
import { closeDatabase } from "../db/sqlite.js"; import { closeDatabase } from "../db/sqlite.js";
import type { FetchLike } from "./client.js"; import type { FetchLike } from "./client.js";
import { import { runDailyAlertSearch } from "./daily.js";
InvalidAlertAuthorizationTimezoneError,
runDailyAlertSearch,
} from "./daily.js";
import { ALERT_SEARCH_PAGE_SIZE } from "./types.js"; import { ALERT_SEARCH_PAGE_SIZE } from "./types.js";
const USER = "did:ethr:0xdailyuser"; const USER = "did:ethr:0xdailyuser";
const ENDORSER_BASE = "https://api.endorser.ch"; const ENDORSER_BASE = "https://api.endorser.ch";
const PARTNER_BASE = "https://partner-api.endorser.ch"; const PARTNER_BASE = "https://partner-api.endorser.ch";
/** 2026-08-15T06:00:00Z is 2026-08-14 in America/Los_Angeles and 2026-08-15 in Pacific/Auckland. */ /** 06:00Z: past midnight UTC, but still the previous day in the Americas. */
const NOW_SPLIT = new Date("2026-08-15T06:00:00.000Z"); const NOW_SPLIT = new Date("2026-08-15T06:00:00.000Z");
const DAY_LA = "2026-08-14"; const DAY_BEFORE = "2026-08-14";
const DAY_AUCKLAND = "2026-08-15"; const DAY_UTC = "2026-08-15";
const JWT_LA = "delegated.jwt.los-angeles-day"; const JWT_DAY_BEFORE = "delegated.jwt.day-before";
const JWT_AUCKLAND = "delegated.jwt.auckland-day"; const JWT_DAY_UTC = "delegated.jwt.utc-day";
function ulid(n: number): string { function ulid(n: number): string {
return `01H${String(n).padStart(23, "0")}`; return `01H${String(n).padStart(23, "0")}`;
@@ -99,17 +96,11 @@ function jwtInput(
} }
async function seedBatch( async function seedBatch(
timezone: string,
jwts: AlertAuthorizationJwtInput[], jwts: AlertAuthorizationJwtInput[],
userId = USER, userId = USER,
batchId = "batch-1" batchId = "batch-1"
) { ) {
await alertAuthorizationDb.replaceUnusedBatch({ await alertAuthorizationDb.replaceUnusedBatch({ userId, batchId, jwts });
userId,
batchId,
timezone,
jwts,
});
} }
function cycleOpts(fetch: FetchLike) { function cycleOpts(fetch: FetchLike) {
@@ -172,72 +163,68 @@ describe("runDailyAlertSearch", () => {
await rm(dir, { recursive: true, force: true }); await rm(dir, { recursive: true, force: true });
}); });
it("selects today's JWT using the batch stored timezone", async () => { it("selects the JWT for the current UTC day", async () => {
await seedBatch("America/Los_Angeles", [ await seedBatch([
jwtInput(1, DAY_LA, JWT_LA), jwtInput(1, DAY_BEFORE, JWT_DAY_BEFORE),
jwtInput(2, DAY_AUCKLAND, JWT_AUCKLAND), jwtInput(2, DAY_UTC, JWT_DAY_UTC),
]); ]);
const cap = bothEmptyFetch(); const cap = bothEmptyFetch();
const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch)); const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch));
assert.equal(result.localDay, DAY_LA); assert.equal(result.utcDay, DAY_UTC);
assert.equal(result.jwtSequence, 1); assert.equal(result.jwtSequence, 2);
assert.equal(result.completed, true); assert.equal(result.completed, true);
assert.equal(result.consumed, true); assert.equal(result.consumed, true);
for (const auth of cap.auths) { for (const auth of cap.auths) {
assert.equal(auth, `Bearer ${JWT_LA}`); assert.equal(auth, `Bearer ${JWT_DAY_UTC}`);
} }
}); });
it("does not select a JWT belonging to another local day", async () => { it("leaves another day's JWT untouched", async () => {
await seedBatch("Pacific/Auckland", [ await seedBatch([
jwtInput(1, DAY_LA, JWT_LA), jwtInput(1, DAY_BEFORE, JWT_DAY_BEFORE),
jwtInput(2, DAY_AUCKLAND, JWT_AUCKLAND), jwtInput(2, DAY_UTC, JWT_DAY_UTC),
]); ]);
const cap = bothEmptyFetch(); const cap = bothEmptyFetch();
const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch)); await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch));
assert.equal(result.localDay, DAY_AUCKLAND);
assert.equal(result.jwtSequence, 2);
for (const auth of cap.auths) { for (const auth of cap.auths) {
assert.equal(auth, `Bearer ${JWT_AUCKLAND}`); assert.equal(auth.includes(JWT_DAY_BEFORE), false);
assert.equal(auth.includes(JWT_LA), false);
} }
const other = await alertAuthorizationDb.getUnusedForDay(USER, DAY_LA); const other = await alertAuthorizationDb.getUnusedForDay(USER, DAY_BEFORE);
assert.equal(other?.jwt, JWT_LA); assert.equal(other?.jwt, JWT_DAY_BEFORE);
assert.equal(other?.status, ALERT_JWT_STATUS_UNUSED); assert.equal(other?.status, ALERT_JWT_STATUS_UNUSED);
}); });
it("throws a clear error for an invalid stored timezone", async () => { it("rolls to the next day's JWT the instant UTC midnight passes", async () => {
await seedBatch("Not/AZone", [jwtInput(1, DAY_LA, JWT_LA)]); await seedBatch([
await assert.rejects( jwtInput(1, DAY_BEFORE, JWT_DAY_BEFORE),
() => runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(bothEmptyFetch().fetch)), jwtInput(2, DAY_UTC, JWT_DAY_UTC),
(err: unknown) => { ]);
assert.ok(err instanceof InvalidAlertAuthorizationTimezoneError); const before = await runDailyAlertSearch(
assert.equal(err.timezone, "Not/AZone"); USER,
assert.match(err.message, /IANA/); new Date("2026-08-14T23:59:59.000Z"),
return true; cycleOpts(bothEmptyFetch().fetch)
}
); );
}); assert.equal(before.utcDay, DAY_BEFORE);
assert.equal(before.jwtSequence, 1);
it("throws a clear error for a missing stored timezone", async () => { const after = await runDailyAlertSearch(
await seedBatch("", [jwtInput(1, DAY_LA, JWT_LA)]); USER,
await assert.rejects( new Date("2026-08-15T00:00:00.000Z"),
() => runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(bothEmptyFetch().fetch)), cycleOpts(bothEmptyFetch().fetch)
InvalidAlertAuthorizationTimezoneError
); );
assert.equal(after.utcDay, DAY_UTC);
assert.equal(after.jwtSequence, 2);
}); });
it("returns a structured no-JWT result when today has no unused JWT", async () => { it("returns a structured no-JWT result when today has no unused JWT", async () => {
await seedBatch("America/Los_Angeles", [ await seedBatch([jwtInput(2, DAY_BEFORE, JWT_DAY_BEFORE)]);
jwtInput(2, DAY_AUCKLAND, JWT_AUCKLAND),
]);
const result = await runDailyAlertSearch( const result = await runDailyAlertSearch(
USER, USER,
NOW_SPLIT, NOW_SPLIT,
cycleOpts(bothEmptyFetch().fetch) cycleOpts(bothEmptyFetch().fetch)
); );
assert.equal(result.userId, USER); assert.equal(result.userId, USER);
assert.equal(result.localDay, DAY_LA); assert.equal(result.utcDay, DAY_UTC);
assert.equal(result.batchId, "batch-1"); assert.equal(result.batchId, "batch-1");
assert.equal(result.jwtSequence, null); assert.equal(result.jwtSequence, null);
assert.equal(result.endorserOutcome, null); assert.equal(result.endorserOutcome, null);
@@ -249,7 +236,7 @@ describe("runDailyAlertSearch", () => {
it("returns a structured no-JWT result when the user has no batch", async () => { it("returns a structured no-JWT result when the user has no batch", async () => {
const result = await runDailyAlertSearch(USER, NOW_SPLIT); const result = await runDailyAlertSearch(USER, NOW_SPLIT);
assert.equal(result.localDay, null); assert.equal(result.utcDay, null);
assert.equal(result.batchId, null); assert.equal(result.batchId, null);
assert.equal(result.jwtSequence, null); assert.equal(result.jwtSequence, null);
assert.equal(result.completed, false); assert.equal(result.completed, false);
@@ -258,9 +245,9 @@ describe("runDailyAlertSearch", () => {
}); });
it("passes today's JWT to runAlertSearchCycle", async () => { it("passes today's JWT to runAlertSearchCycle", async () => {
await seedBatch("America/Los_Angeles", [ await seedBatch([
jwtInput(1, DAY_LA, JWT_LA), jwtInput(1, DAY_UTC, JWT_DAY_UTC),
jwtInput(2, DAY_AUCKLAND, JWT_AUCKLAND), jwtInput(2, DAY_BEFORE, JWT_DAY_BEFORE),
]); ]);
const cap = bothSuccessFetch(); const cap = bothSuccessFetch();
const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch)); const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch));
@@ -268,7 +255,7 @@ describe("runDailyAlertSearch", () => {
assert.ok(cap.urls.some((u) => u.includes("/api/partner/alertSearch"))); assert.ok(cap.urls.some((u) => u.includes("/api/partner/alertSearch")));
assert.ok(cap.auths.length >= 2); assert.ok(cap.auths.length >= 2);
for (const auth of cap.auths) { for (const auth of cap.auths) {
assert.equal(auth, `Bearer ${JWT_LA}`); assert.equal(auth, `Bearer ${JWT_DAY_UTC}`);
} }
assert.ok(result.digest); assert.ok(result.digest);
assert.equal(result.digest.hasUpdates, true); assert.equal(result.digest.hasUpdates, true);
@@ -276,7 +263,7 @@ describe("runDailyAlertSearch", () => {
}); });
it("consumes today's JWT when both sources succeed", async () => { it("consumes today's JWT when both sources succeed", async () => {
await seedBatch("America/Los_Angeles", [jwtInput(1, DAY_LA, JWT_LA)]); await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]);
const result = await runDailyAlertSearch( const result = await runDailyAlertSearch(
USER, USER,
NOW_SPLIT, NOW_SPLIT,
@@ -295,12 +282,12 @@ describe("runDailyAlertSearch", () => {
assert.equal(result.digest.records.claims.length, 1); assert.equal(result.digest.records.claims.length, 1);
assert.equal(result.digest.records.claims[0].id, ulid(10)); assert.equal(result.digest.records.claims[0].id, ulid(10));
assert.equal(result.digest.records.profilesNearby[0].updatedAt, "2026-03-01T00:00:00.000Z"); assert.equal(result.digest.records.profilesNearby[0].updatedAt, "2026-03-01T00:00:00.000Z");
const leftover = await alertAuthorizationDb.getUnusedForDay(USER, DAY_LA); const leftover = await alertAuthorizationDb.getUnusedForDay(USER, DAY_UTC);
assert.equal(leftover, undefined); assert.equal(leftover, undefined);
}); });
it("consumes today's JWT when both sources are empty", async () => { it("consumes today's JWT when both sources are empty", async () => {
await seedBatch("America/Los_Angeles", [jwtInput(1, DAY_LA, JWT_LA)]); await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]);
const result = await runDailyAlertSearch( const result = await runDailyAlertSearch(
USER, USER,
NOW_SPLIT, NOW_SPLIT,
@@ -317,7 +304,7 @@ describe("runDailyAlertSearch", () => {
}); });
it("does not consume when Endorser succeeds and Partner fails", async () => { it("does not consume when Endorser succeeds and Partner fails", async () => {
await seedBatch("America/Los_Angeles", [jwtInput(1, DAY_LA, JWT_LA)]); await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]);
const cap = captureFetch((url) => { const cap = captureFetch((url) => {
if (url.includes("/api/partner/")) { if (url.includes("/api/partner/")) {
return jsonResponse({ error: "unauthorized" }, 401); return jsonResponse({ error: "unauthorized" }, 401);
@@ -334,15 +321,15 @@ describe("runDailyAlertSearch", () => {
assert.equal(result.digest.hasUpdates, false); assert.equal(result.digest.hasUpdates, false);
assert.equal(result.digest.records.claims.length, 1); assert.equal(result.digest.records.claims.length, 1);
assert.equal(result.digest.records.claims[0].id, ulid(10)); assert.equal(result.digest.records.claims[0].id, ulid(10));
const unused = await alertAuthorizationDb.getUnusedForDay(USER, DAY_LA); const unused = await alertAuthorizationDb.getUnusedForDay(USER, DAY_UTC);
assert.equal(unused?.jwt, JWT_LA); assert.equal(unused?.jwt, JWT_DAY_UTC);
const stored = await alertSearchCursorsDb.get(USER); const stored = await alertSearchCursorsDb.get(USER);
assert.equal(stored?.endorserAfterId, ulid(10)); assert.equal(stored?.endorserAfterId, ulid(10));
assert.equal(stored?.partnerAfterAt, null); assert.equal(stored?.partnerAfterAt, null);
}); });
it("does not consume when Partner succeeds and Endorser fails", async () => { it("does not consume when Partner succeeds and Endorser fails", async () => {
await seedBatch("America/Los_Angeles", [jwtInput(1, DAY_LA, JWT_LA)]); await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]);
const cap = captureFetch((url) => { const cap = captureFetch((url) => {
if (url.includes("/api/partner/")) { if (url.includes("/api/partner/")) {
return jsonResponse( return jsonResponse(
@@ -369,7 +356,7 @@ describe("runDailyAlertSearch", () => {
assert.equal(result.digest.completed, false); assert.equal(result.digest.completed, false);
assert.equal(result.digest.hasUpdates, false); assert.equal(result.digest.hasUpdates, false);
assert.equal(result.digest.records.profilesNearby.length, 1); assert.equal(result.digest.records.profilesNearby.length, 1);
const unused = await alertAuthorizationDb.getUnusedForDay(USER, DAY_LA); const unused = await alertAuthorizationDb.getUnusedForDay(USER, DAY_UTC);
assert.equal(unused?.status, ALERT_JWT_STATUS_UNUSED); assert.equal(unused?.status, ALERT_JWT_STATUS_UNUSED);
const stored = await alertSearchCursorsDb.get(USER); const stored = await alertSearchCursorsDb.get(USER);
assert.equal(stored?.endorserAfterId, null); assert.equal(stored?.endorserAfterId, null);
@@ -377,7 +364,7 @@ describe("runDailyAlertSearch", () => {
}); });
it("does not consume on Endorser pagination", async () => { it("does not consume on Endorser pagination", async () => {
await seedBatch("America/Los_Angeles", [jwtInput(1, DAY_LA, JWT_LA)]); await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]);
const cap = captureFetch((url) => { const cap = captureFetch((url) => {
if (url.includes("/api/partner/")) return jsonResponse(emptyPartnerBody()); if (url.includes("/api/partner/")) return jsonResponse(emptyPartnerBody());
return jsonResponse({ return jsonResponse({
@@ -398,7 +385,7 @@ describe("runDailyAlertSearch", () => {
}); });
it("does not consume on Partner pagination", async () => { it("does not consume on Partner pagination", async () => {
await seedBatch("America/Los_Angeles", [jwtInput(1, DAY_LA, JWT_LA)]); await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]);
const cap = captureFetch((url) => { const cap = captureFetch((url) => {
if (url.includes("/api/partner/")) { if (url.includes("/api/partner/")) {
const tied = "2026-01-01T12:00:00.000Z"; const tied = "2026-01-01T12:00:00.000Z";
@@ -461,7 +448,7 @@ describe("runDailyAlertSearch", () => {
for (const c of cases) { for (const c of cases) {
closeDatabase(); closeDatabase();
await seedBatch("America/Los_Angeles", [jwtInput(1, DAY_LA, JWT_LA)]); await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]);
const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(c.fetch)); const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(c.fetch));
assert.equal(result.endorserOutcome, c.expected, c.name); assert.equal(result.endorserOutcome, c.expected, c.name);
assert.equal(result.partnerOutcome, c.expected, c.name); assert.equal(result.partnerOutcome, c.expected, c.name);
@@ -470,18 +457,18 @@ describe("runDailyAlertSearch", () => {
assert.ok(result.digest, c.name); assert.ok(result.digest, c.name);
assert.equal(result.digest.completed, false, c.name); assert.equal(result.digest.completed, false, c.name);
assert.equal(result.digest.hasUpdates, false, c.name); assert.equal(result.digest.hasUpdates, false, c.name);
const unused = await alertAuthorizationDb.getUnusedForDay(USER, DAY_LA); const unused = await alertAuthorizationDb.getUnusedForDay(USER, DAY_UTC);
assert.equal(unused?.jwt, JWT_LA, c.name); assert.equal(unused?.jwt, JWT_DAY_UTC, c.name);
} }
}); });
it("consumes the exact selected JWT row, not another day's unused JWT", async () => { it("consumes the exact selected JWT row, not another day's unused JWT", async () => {
await seedBatch("America/Los_Angeles", [ await seedBatch([
jwtInput(1, DAY_LA, JWT_LA), jwtInput(1, DAY_UTC, JWT_DAY_UTC),
jwtInput(2, DAY_AUCKLAND, JWT_AUCKLAND), jwtInput(2, DAY_BEFORE, JWT_DAY_BEFORE),
]); ]);
const today = await alertAuthorizationDb.getUnusedForDay(USER, DAY_LA); const today = await alertAuthorizationDb.getUnusedForDay(USER, DAY_UTC);
const other = await alertAuthorizationDb.getUnusedForDay(USER, DAY_AUCKLAND); const other = await alertAuthorizationDb.getUnusedForDay(USER, DAY_BEFORE);
assert.ok(today); assert.ok(today);
assert.ok(other); assert.ok(other);
const result = await runDailyAlertSearch( const result = await runDailyAlertSearch(
@@ -500,7 +487,7 @@ describe("runDailyAlertSearch", () => {
}); });
it("does not select or consume the same JWT after it has been consumed", async () => { it("does not select or consume the same JWT after it has been consumed", async () => {
await seedBatch("America/Los_Angeles", [jwtInput(1, DAY_LA, JWT_LA)]); await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]);
const first = await runDailyAlertSearch( const first = await runDailyAlertSearch(
USER, USER,
NOW_SPLIT, NOW_SPLIT,
@@ -521,7 +508,7 @@ describe("runDailyAlertSearch", () => {
}); });
it("does not alter Phase 4B cursor rules: empty does not advance; success does", async () => { it("does not alter Phase 4B cursor rules: empty does not advance; success does", async () => {
await seedBatch("America/Los_Angeles", [jwtInput(1, DAY_LA, JWT_LA)]); await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]);
await runDailyAlertSearch( await runDailyAlertSearch(
USER, USER,
NOW_SPLIT, NOW_SPLIT,
@@ -531,7 +518,7 @@ describe("runDailyAlertSearch", () => {
assert.equal(afterEmpty, undefined); assert.equal(afterEmpty, undefined);
closeDatabase(); closeDatabase();
await seedBatch("America/Los_Angeles", [jwtInput(1, DAY_LA, JWT_LA)]); await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]);
await runDailyAlertSearch( await runDailyAlertSearch(
USER, USER,
NOW_SPLIT, NOW_SPLIT,
+12 -33
View File
@@ -3,10 +3,7 @@ import {
type AlertAuthorizationStore, type AlertAuthorizationStore,
} from "../db/alertAuthorizationSqlite.js"; } from "../db/alertAuthorizationSqlite.js";
import { smsAlertAuthorizationDb } from "../db/smsAlertAuthorizationSqlite.js"; import { smsAlertAuthorizationDb } from "../db/smsAlertAuthorizationSqlite.js";
import { import { utcCalendarDay } from "../services/alertAuthorization.js";
calendarDayInTimeZone,
isValidIanaTimeZone,
} from "../services/alertAuthorization.js";
import { import {
runAlertSearchCycle, runAlertSearchCycle,
type AlertSearchCycleInput, type AlertSearchCycleInput,
@@ -26,25 +23,14 @@ const JWT_INVENTORIES: Record<AlertSearchChannel, AlertAuthorizationStore> = {
sms: smsAlertAuthorizationDb, sms: smsAlertAuthorizationDb,
}; };
export class InvalidAlertAuthorizationTimezoneError extends Error {
readonly timezone: string;
constructor(timezone: string) {
super(
`Alert authorization batch timezone is not a valid IANA time zone: ${timezone}`
);
this.name = "InvalidAlertAuthorizationTimezoneError";
this.timezone = timezone;
}
}
export { sourceCompletedDailyRun }; export { sourceCompletedDailyRun };
export type DailyAlertSearchCycleInput = Omit<AlertSearchCycleInput, "jwt">; export type DailyAlertSearchCycleInput = Omit<AlertSearchCycleInput, "jwt">;
export type DailyAlertSearchResult = { export type DailyAlertSearchResult = {
userId: string; userId: string;
localDay: string | null; /** The UTC day whose JWT this run used, or null when none was selected. */
utcDay: string | null;
batchId: string | null; batchId: string | null;
jwtSequence: number | null; jwtSequence: number | null;
endorserOutcome: AlertSearchQueryOutcome | null; endorserOutcome: AlertSearchQueryOutcome | null;
@@ -57,12 +43,12 @@ export type DailyAlertSearchResult = {
function noJwtResult( function noJwtResult(
userId: string, userId: string,
localDay: string | null, utcDay: string | null,
batchId: string | null batchId: string | null
): DailyAlertSearchResult { ): DailyAlertSearchResult {
return { return {
userId, userId,
localDay, utcDay,
batchId, batchId,
jwtSequence: null, jwtSequence: null,
endorserOutcome: null, endorserOutcome: null,
@@ -74,9 +60,9 @@ function noJwtResult(
} }
/** /**
* Select today's unused delegated JWT (batch IANA timezone + stored day), * Select today's unused delegated JWT by UTC day, run the existing alertSearch
* run the existing alertSearch cycle, and consume that JWT only when both * cycle, and consume that JWT only when both required sources completed
* required sources completed (success or empty). Not invoked by the scheduler. * (success or empty). Not invoked by the scheduler.
*/ */
export async function runDailyAlertSearch( export async function runDailyAlertSearch(
userId: string, userId: string,
@@ -89,17 +75,10 @@ export async function runDailyAlertSearch(
if (batch === undefined) { if (batch === undefined) {
return noJwtResult(userId, null, null); return noJwtResult(userId, null, null);
} }
if (!isValidIanaTimeZone(batch.timezone)) { const utcDay = utcCalendarDay(Math.floor(now.getTime() / 1000));
throw new InvalidAlertAuthorizationTimezoneError(batch.timezone); const selected = await inventory.getUnusedForDay(userId, utcDay);
}
const localDay = calendarDayInTimeZone(
Math.floor(now.getTime() / 1000),
batch.timezone
);
const selected = await inventory.getUnusedForDay(userId, localDay);
if (selected === undefined) { if (selected === undefined) {
return noJwtResult(userId, localDay, batch.batchId); return noJwtResult(userId, utcDay, batch.batchId);
} }
const cycle = await runAlertSearchCycle( const cycle = await runAlertSearchCycle(
@@ -125,7 +104,7 @@ export async function runDailyAlertSearch(
return { return {
userId, userId,
localDay, utcDay,
batchId: selected.batchId, batchId: selected.batchId,
jwtSequence: selected.sequence, jwtSequence: selected.sequence,
endorserOutcome, endorserOutcome,
-1
View File
@@ -40,7 +40,6 @@ export type { CursorAdvanceResult, StoredAlertSearchCursors } from "./cursors.js
export { runAlertSearchCycle } from "./cycle.js"; export { runAlertSearchCycle } from "./cycle.js";
export type { AlertSearchCycleInput, AlertSearchCycleResult } from "./cycle.js"; export type { AlertSearchCycleInput, AlertSearchCycleResult } from "./cycle.js";
export { export {
InvalidAlertAuthorizationTimezoneError,
runDailyAlertSearch, runDailyAlertSearch,
sourceCompletedDailyRun, sourceCompletedDailyRun,
} from "./daily.js"; } from "./daily.js";
+1 -1
View File
@@ -82,7 +82,7 @@ function daily(
): DailyAlertSearchResult { ): DailyAlertSearchResult {
return { return {
userId: USER, userId: USER,
localDay: "2026-08-14", utcDay: "2026-08-14",
batchId: "batch-1", batchId: "batch-1",
jwtSequence: 1, jwtSequence: 1,
endorserOutcome: "success", endorserOutcome: "success",
+90 -5
View File
@@ -23,7 +23,7 @@ const USER_B = "did:ethr:0xuserb";
function stubDailyResult(userId: string): DailyAlertSearchResult { function stubDailyResult(userId: string): DailyAlertSearchResult {
return { return {
userId, userId,
localDay: null, utcDay: null,
batchId: null, batchId: null,
jwtSequence: null, jwtSequence: null,
endorserOutcome: null, endorserOutcome: null,
@@ -60,7 +60,7 @@ function eligibleDaily(userId: string): DailyAlertSearchResult {
}; };
return { return {
userId, userId,
localDay: "2026-08-14", utcDay: "2026-08-14",
batchId: "batch-1", batchId: "batch-1",
jwtSequence: 1, jwtSequence: 1,
endorserOutcome: "success", endorserOutcome: "success",
@@ -71,15 +71,24 @@ function eligibleDaily(userId: string): DailyAlertSearchResult {
}; };
} }
async function seedBatch(userId: string, batchId: string) { /** The day every seeded JWT belongs to, and an instant inside it. */
const DAY = "2026-08-28";
const NOW = new Date(`${DAY}T12:00:00.000Z`);
async function seedBatch(
userId: string,
batchId: string,
notify?: { hour: number; minute: number }
) {
await alertAuthorizationDb.replaceUnusedBatch({ await alertAuthorizationDb.replaceUnusedBatch({
userId, userId,
batchId, batchId,
timezone: "America/Denver", notifyHourUtc: notify?.hour,
notifyMinuteUtc: notify?.minute,
jwts: [ jwts: [
{ {
sequence: 1, sequence: 1,
day: "2026-08-28", day: DAY,
jwt: `jwt-${userId}`, jwt: `jwt-${userId}`,
nbf: 1, nbf: 1,
exp: 2, exp: 2,
@@ -129,6 +138,8 @@ describe("alertSearch scheduler pass", () => {
await seedDevice(USER_A, "device-2", "token-2"); await seedDevice(USER_A, "device-2", "token-2");
const ran: string[] = []; const ran: string[] = [];
const result = await runAlertSearchSchedulerPass({ const result = await runAlertSearchSchedulerPass({
now: NOW,
concurrency: 1,
runDaily: async (userId) => { runDaily: async (userId) => {
ran.push(userId); ran.push(userId);
return stubDailyResult(userId); return stubDailyResult(userId);
@@ -144,6 +155,8 @@ describe("alertSearch scheduler pass", () => {
await seedBatch(USER_B, "batch-b"); await seedBatch(USER_B, "batch-b");
const ran: string[] = []; const ran: string[] = [];
const result = await runAlertSearchSchedulerPass({ const result = await runAlertSearchSchedulerPass({
now: NOW,
concurrency: 1,
runDaily: async (userId) => { runDaily: async (userId) => {
ran.push(userId); ran.push(userId);
return stubDailyResult(userId); return stubDailyResult(userId);
@@ -154,6 +167,58 @@ describe("alertSearch scheduler pass", () => {
assert.equal(result.failed, 0); assert.equal(result.failed, 0);
}); });
it("defers a user whose notify hour has not arrived", async () => {
await seedBatch(USER_A, "batch-a", { hour: 18, minute: 0 });
await seedBatch(USER_B, "batch-b");
const ran: string[] = [];
const result = await runAlertSearchSchedulerPass({
now: NOW,
concurrency: 1,
runDaily: async (userId) => {
ran.push(userId);
return stubDailyResult(userId);
},
});
assert.deepEqual(ran, [USER_B]);
assert.equal(result.deferred, 1);
assert.equal(result.attempted, 1);
});
it("runs that user once the hour passes", async () => {
await seedBatch(USER_A, "batch-a", { hour: 18, minute: 0 });
const ran: string[] = [];
const result = await runAlertSearchSchedulerPass({
now: new Date(`${DAY}T18:05:00.000Z`),
concurrency: 1,
runDaily: async (userId) => {
ran.push(userId);
return stubDailyResult(userId);
},
});
assert.deepEqual(ran, [USER_A]);
assert.equal(result.deferred, 0);
});
it("does not list a user whose day is already spent", async () => {
await seedBatch(USER_A, "batch-a");
const jwt = await alertAuthorizationDb.getUnusedForDay(USER_A, DAY);
assert.ok(jwt);
await alertAuthorizationDb.consumeUnusedJwt({ id: jwt.id, userId: USER_A });
const ran: string[] = [];
const result = await runAlertSearchSchedulerPass({
now: NOW,
concurrency: 1,
runDaily: async (userId) => {
ran.push(userId);
return stubDailyResult(userId);
},
});
assert.deepEqual(ran, []);
assert.equal(result.attempted, 0);
assert.equal(result.deferred, 0);
});
it("skips a tick while an alertSearch pass is in flight", async () => { it("skips a tick while an alertSearch pass is in flight", async () => {
await seedBatch(USER_A, "batch-a"); await seedBatch(USER_A, "batch-a");
let release!: () => void; let release!: () => void;
@@ -161,6 +226,8 @@ describe("alertSearch scheduler pass", () => {
release = resolve; release = resolve;
}); });
const first = runAlertSearchSchedulerPass({ const first = runAlertSearchSchedulerPass({
now: NOW,
concurrency: 1,
runDaily: async (userId) => { runDaily: async (userId) => {
await blocked; await blocked;
return stubDailyResult(userId); return stubDailyResult(userId);
@@ -170,6 +237,8 @@ describe("alertSearch scheduler pass", () => {
await Promise.resolve(); await Promise.resolve();
} }
const second = await runAlertSearchSchedulerPass({ const second = await runAlertSearchSchedulerPass({
now: NOW,
concurrency: 1,
runDaily: async () => { runDaily: async () => {
throw new Error("second pass should not run daily"); throw new Error("second pass should not run daily");
}, },
@@ -187,6 +256,8 @@ describe("alertSearch scheduler pass", () => {
await seedBatch(USER_B, "batch-b"); await seedBatch(USER_B, "batch-b");
const ran: string[] = []; const ran: string[] = [];
const result = await runAlertSearchSchedulerPass({ const result = await runAlertSearchSchedulerPass({
now: NOW,
concurrency: 1,
runDaily: async (userId) => { runDaily: async (userId) => {
ran.push(userId); ran.push(userId);
if (userId === USER_A) throw new Error("boom"); if (userId === USER_A) throw new Error("boom");
@@ -212,6 +283,8 @@ describe("alertSearch scheduler pass", () => {
await seedBatch(USER_B, "batch-b"); await seedBatch(USER_B, "batch-b");
const notified: string[] = []; const notified: string[] = [];
await runAlertSearchSchedulerPass({ await runAlertSearchSchedulerPass({
now: NOW,
concurrency: 1,
runDaily: async (userId) => runDaily: async (userId) =>
userId === USER_A ? eligibleDaily(userId) : stubDailyResult(userId), userId === USER_A ? eligibleDaily(userId) : stubDailyResult(userId),
notify: async (result) => { notify: async (result) => {
@@ -228,6 +301,8 @@ describe("alertSearch scheduler pass", () => {
const ran: string[] = []; const ran: string[] = [];
const notifyCalls: number[] = []; const notifyCalls: number[] = [];
await runAlertSearchSchedulerPass({ await runAlertSearchSchedulerPass({
now: NOW,
concurrency: 1,
runDaily: async (userId) => { runDaily: async (userId) => {
ran.push(userId); ran.push(userId);
return eligibleDaily(userId); return eligibleDaily(userId);
@@ -244,12 +319,16 @@ describe("alertSearch scheduler pass", () => {
await seedBatch(USER_A, "batch-a"); await seedBatch(USER_A, "batch-a");
const notifies: Array<DailyAlertSearchResult["digest"]> = []; const notifies: Array<DailyAlertSearchResult["digest"]> = [];
await runAlertSearchSchedulerPass({ await runAlertSearchSchedulerPass({
now: NOW,
concurrency: 1,
runDaily: async (userId) => eligibleDaily(userId), runDaily: async (userId) => eligibleDaily(userId),
notify: async (result) => { notify: async (result) => {
notifies.push(result.digest); notifies.push(result.digest);
}, },
}); });
await runAlertSearchSchedulerPass({ await runAlertSearchSchedulerPass({
now: NOW,
concurrency: 1,
runDaily: async (userId) => stubDailyResult(userId), runDaily: async (userId) => stubDailyResult(userId),
notify: async (result) => { notify: async (result) => {
notifies.push(result.digest); notifies.push(result.digest);
@@ -267,6 +346,8 @@ describe("alertSearch scheduler pass", () => {
const incompleteDigest = first.digest; const incompleteDigest = first.digest;
assert.ok(incompleteDigest); assert.ok(incompleteDigest);
await runAlertSearchSchedulerPass({ await runAlertSearchSchedulerPass({
now: NOW,
concurrency: 1,
runDaily: async () => ({ runDaily: async () => ({
...first, ...first,
completed: false, completed: false,
@@ -282,6 +363,8 @@ describe("alertSearch scheduler pass", () => {
}, },
}); });
await runAlertSearchSchedulerPass({ await runAlertSearchSchedulerPass({
now: NOW,
concurrency: 1,
runDaily: async (userId) => eligibleDaily(userId), runDaily: async (userId) => eligibleDaily(userId),
notify: async (result) => { notify: async (result) => {
eligible.push(Boolean(result.consumed && result.digest?.hasUpdates)); eligible.push(Boolean(result.consumed && result.digest?.hasUpdates));
@@ -294,6 +377,8 @@ describe("alertSearch scheduler pass", () => {
await seedBatch(USER_A, "batch-a"); await seedBatch(USER_A, "batch-a");
let dailyConsumed = false; let dailyConsumed = false;
const result = await runAlertSearchSchedulerPass({ const result = await runAlertSearchSchedulerPass({
now: NOW,
concurrency: 1,
runDaily: async (userId) => { runDaily: async (userId) => {
const daily = eligibleDaily(userId); const daily = eligibleDaily(userId);
dailyConsumed = daily.consumed; dailyConsumed = daily.consumed;
+62 -25
View File
@@ -1,4 +1,15 @@
import { alertAuthorizationDb } from "../db/alertAuthorizationSqlite.js"; import {
alertAuthorizationDb,
type PendingDayUser,
} from "../db/alertAuthorizationSqlite.js";
import {
utcCalendarDay,
utcHourMinute,
} from "../services/alertAuthorization.js";
import {
ALERT_SEARCH_USER_CONCURRENCY,
forEachWithConcurrency,
} from "../util/concurrency.js";
import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js"; import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js";
import { import {
runDailyAlertSearch, runDailyAlertSearch,
@@ -19,16 +30,26 @@ export type AlertSearchNotifyRunner = (
) => Promise<unknown>; ) => Promise<unknown>;
export type AlertSearchSchedulerPassInput = { export type AlertSearchSchedulerPassInput = {
listUserIds?: () => Promise<string[]>; listPending?: (input: {
day: string;
hourMinute: string;
}) => Promise<PendingDayUser[]>;
runDaily?: AlertSearchUserRunner; runDaily?: AlertSearchUserRunner;
notify?: AlertSearchNotifyRunner; notify?: AlertSearchNotifyRunner;
/** The instant this pass represents. Defaults to now. */
now?: Date;
/** Users worked on at once. Set to 1 for a deterministic order in tests. */
concurrency?: number;
}; };
export type AlertSearchSchedulerPassResult = { export type AlertSearchSchedulerPassResult = {
skipped: boolean; skipped: boolean;
/** The users this pass ran, in no guaranteed order. */
userIds: string[]; userIds: string[];
attempted: number; attempted: number;
failed: number; failed: number;
/** Users holding an unused JWT whose chosen UTC time has not arrived yet. */
deferred: number;
}; };
let intervalId: ReturnType<typeof setInterval> | undefined; let intervalId: ReturnType<typeof setInterval> | undefined;
@@ -39,16 +60,17 @@ export function isAlertSearchSchedulerPassInFlight(): boolean {
} }
/** /**
* One user-oriented alertSearch pass. Skips if a pass is already running. * One user-oriented alertSearch pass over the users who have work today and
* After each daily run, may send an AlertSearch FCM digest. Does not use the * whose chosen hour has arrived. Skips if a pass is already running. After each
* device wakeup ping path. * daily run, may send an AlertSearch FCM digest. Does not use the device wakeup
* ping path.
*/ */
export async function runAlertSearchSchedulerPass( export async function runAlertSearchSchedulerPass(
input: AlertSearchSchedulerPassInput = {} input: AlertSearchSchedulerPassInput = {}
): Promise<AlertSearchSchedulerPassResult> { ): Promise<AlertSearchSchedulerPassResult> {
if (passInFlight) { if (passInFlight) {
log.info("[AlertSearchScheduler] Pass skipped (already in flight)"); log.info("[AlertSearchScheduler] Pass skipped (already in flight)");
return { skipped: true, userIds: [], attempted: 0, failed: 0 }; return { skipped: true, userIds: [], attempted: 0, failed: 0, deferred: 0 };
} }
passInFlight = true; passInFlight = true;
@@ -56,46 +78,61 @@ export async function runAlertSearchSchedulerPass(
log.info("[AlertSearchScheduler] Pass started"); log.info("[AlertSearchScheduler] Pass started");
try { try {
const listUserIds = const now = input.now ?? new Date();
input.listUserIds ?? const runDaily =
(() => alertAuthorizationDb.listDistinctUserIds()); input.runDaily ?? ((userId: string) => runDailyAlertSearch(userId, now));
const runDaily = input.runDaily ?? runDailyAlertSearch;
const notify = input.notify ?? deliverAlertSearchNotification; const notify = input.notify ?? deliverAlertSearchNotification;
const userIds = await listUserIds(); const listPending =
input.listPending ??
((query: { day: string; hourMinute: string }) =>
alertAuthorizationDb.listPendingForDay(query));
const nowSec = Math.floor(now.getTime() / 1000);
const pending = await listPending({
day: utcCalendarDay(nowSec),
hourMinute: utcHourMinute(nowSec),
});
const userIds = pending.filter((row) => row.due).map((row) => row.userId);
const deferred = pending.length - userIds.length;
let failed = 0; let failed = 0;
for (const userId of userIds) { await forEachWithConcurrency(
try { userIds,
const daily = await runDaily(userId); input.concurrency ?? ALERT_SEARCH_USER_CONCURRENCY,
async (userId) => {
try { try {
await notify(daily); const daily = await runDaily(userId);
try {
await notify(daily);
} catch (err) {
log.error(
"[AlertSearchScheduler] Notification failed",
userId + ":",
errorMessage(err)
);
}
} catch (err) { } catch (err) {
failed += 1;
log.error( log.error(
"[AlertSearchScheduler] Notification failed", "[AlertSearchScheduler] User failed",
userId + ":", userId + ":",
errorMessage(err) errorMessage(err)
); );
} }
} catch (err) {
failed += 1;
log.error(
"[AlertSearchScheduler] User failed",
userId + ":",
errorMessage(err)
);
} }
} );
log.info( log.info(
"[AlertSearchScheduler] Pass completed in", "[AlertSearchScheduler] Pass completed in",
formatElapsedMs(Date.now() - passStarted) + ",", formatElapsedMs(Date.now() - passStarted) + ",",
`attempted ${userIds.length}, failed ${failed}` `attempted ${userIds.length}, deferred ${deferred}, failed ${failed}`
); );
return { return {
skipped: false, skipped: false,
userIds, userIds,
attempted: userIds.length, attempted: userIds.length,
failed, failed,
deferred,
}; };
} catch (err) { } catch (err) {
log.error( log.error(
+62 -24
View File
@@ -1,6 +1,15 @@
import { smsActionJwtUseDb } from "../db/smsActionJwtUseSqlite.js"; import { smsActionJwtUseDb } from "../db/smsActionJwtUseSqlite.js";
import type { PendingDayUser } from "../db/alertAuthorizationSqlite.js";
import { smsAlertAuthorizationDb } from "../db/smsAlertAuthorizationSqlite.js"; import { smsAlertAuthorizationDb } from "../db/smsAlertAuthorizationSqlite.js";
import { smsConfig } from "../env.js"; import { smsConfig } from "../env.js";
import {
utcCalendarDay,
utcHourMinute,
} from "../services/alertAuthorization.js";
import {
ALERT_SEARCH_USER_CONCURRENCY,
forEachWithConcurrency,
} from "../util/concurrency.js";
import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js"; import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js";
import { import {
runDailyAlertSearch, runDailyAlertSearch,
@@ -16,17 +25,27 @@ export const SMS_ALERT_SEARCH_INITIAL_OFFSET_MS = 150 * 1000;
export const SMS_ACTION_JWT_RETENTION_MULTIPLE = 10; export const SMS_ACTION_JWT_RETENTION_MULTIPLE = 10;
export type SmsAlertSearchSchedulerPassInput = { export type SmsAlertSearchSchedulerPassInput = {
listUserIds?: () => Promise<string[]>; listPending?: (input: {
day: string;
hourMinute: string;
}) => Promise<PendingDayUser[]>;
runDaily?: (userId: string) => Promise<DailyAlertSearchResult>; runDaily?: (userId: string) => Promise<DailyAlertSearchResult>;
notify?: (result: DailyAlertSearchResult) => Promise<unknown>; notify?: (result: DailyAlertSearchResult) => Promise<unknown>;
prune?: () => Promise<unknown>; prune?: () => Promise<unknown>;
/** The instant this pass represents. Defaults to now. */
now?: Date;
/** Users worked on at once. Set to 1 for a deterministic order in tests. */
concurrency?: number;
}; };
export type SmsAlertSearchSchedulerPassResult = { export type SmsAlertSearchSchedulerPassResult = {
skipped: boolean; skipped: boolean;
/** The users this pass ran, in no guaranteed order. */
userIds: string[]; userIds: string[];
attempted: number; attempted: number;
failed: number; failed: number;
/** Users holding an unused JWT whose chosen UTC time has not arrived yet. */
deferred: number;
}; };
let intervalId: ReturnType<typeof setInterval> | undefined; let intervalId: ReturnType<typeof setInterval> | undefined;
@@ -48,16 +67,16 @@ async function defaultPrune(): Promise<void> {
} }
/** /**
* One SMS-channel alertSearch pass over the users who authorized that channel. * One SMS-channel alertSearch pass over the users who have work today and whose
* Skips if a pass is already running; the guard is process-local, so a second * chosen hour has arrived. Skips if a pass is already running; the guard is
* replica double-texts. * process-local, so a second replica double-texts.
*/ */
export async function runSmsAlertSearchSchedulerPass( export async function runSmsAlertSearchSchedulerPass(
input: SmsAlertSearchSchedulerPassInput = {} input: SmsAlertSearchSchedulerPassInput = {}
): Promise<SmsAlertSearchSchedulerPassResult> { ): Promise<SmsAlertSearchSchedulerPassResult> {
if (passInFlight) { if (passInFlight) {
log.info("[SmsAlertSearchScheduler] Pass skipped (already in flight)"); log.info("[SmsAlertSearchScheduler] Pass skipped (already in flight)");
return { skipped: true, userIds: [], attempted: 0, failed: 0 }; return { skipped: true, userIds: [], attempted: 0, failed: 0, deferred: 0 };
} }
passInFlight = true; passInFlight = true;
@@ -65,13 +84,16 @@ export async function runSmsAlertSearchSchedulerPass(
log.info("[SmsAlertSearchScheduler] Pass started"); log.info("[SmsAlertSearchScheduler] Pass started");
try { try {
const listUserIds = const now = input.now ?? new Date();
input.listUserIds ?? (() => smsAlertAuthorizationDb.listDistinctUserIds());
const runDaily = const runDaily =
input.runDaily ?? input.runDaily ??
((userId: string) => runDailyAlertSearch(userId, new Date(), {}, "sms")); ((userId: string) => runDailyAlertSearch(userId, now, {}, "sms"));
const notify = input.notify ?? deliverAlertSearchSms; const notify = input.notify ?? deliverAlertSearchSms;
const prune = input.prune ?? defaultPrune; const prune = input.prune ?? defaultPrune;
const listPending =
input.listPending ??
((query: { day: string; hourMinute: string }) =>
smsAlertAuthorizationDb.listPendingForDay(query));
try { try {
await prune(); await prune();
@@ -82,37 +104,53 @@ export async function runSmsAlertSearchSchedulerPass(
); );
} }
const userIds = await listUserIds(); const nowSec = Math.floor(now.getTime() / 1000);
const pending = await listPending({
day: utcCalendarDay(nowSec),
hourMinute: utcHourMinute(nowSec),
});
const userIds = pending.filter((row) => row.due).map((row) => row.userId);
const deferred = pending.length - userIds.length;
let failed = 0; let failed = 0;
for (const userId of userIds) { await forEachWithConcurrency(
try { userIds,
const daily = await runDaily(userId); input.concurrency ?? ALERT_SEARCH_USER_CONCURRENCY,
async (userId) => {
try { try {
await notify(daily); const daily = await runDaily(userId);
try {
await notify(daily);
} catch (err) {
log.error(
"[SmsAlertSearchScheduler] Notification failed",
userId + ":",
errorMessage(err)
);
}
} catch (err) { } catch (err) {
failed += 1;
log.error( log.error(
"[SmsAlertSearchScheduler] Notification failed", "[SmsAlertSearchScheduler] User failed",
userId + ":", userId + ":",
errorMessage(err) errorMessage(err)
); );
} }
} catch (err) {
failed += 1;
log.error(
"[SmsAlertSearchScheduler] User failed",
userId + ":",
errorMessage(err)
);
} }
} );
log.info( log.info(
"[SmsAlertSearchScheduler] Pass completed in", "[SmsAlertSearchScheduler] Pass completed in",
formatElapsedMs(Date.now() - passStarted) + ",", formatElapsedMs(Date.now() - passStarted) + ",",
`attempted ${userIds.length}, failed ${failed}` `attempted ${userIds.length}, deferred ${deferred}, failed ${failed}`
); );
return { skipped: false, userIds, attempted: userIds.length, failed }; return {
skipped: false,
userIds,
attempted: userIds.length,
failed,
deferred,
};
} catch (err) { } catch (err) {
log.error( log.error(
"[SmsAlertSearchScheduler] Pass failed in", "[SmsAlertSearchScheduler] Pass failed in",
+151 -9
View File
@@ -1,4 +1,8 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import {
formatHourMinuteUtc,
parseHourMinuteUtc,
} from "../services/alertAuthorization.js";
import { getDatabase } from "./sqlite.js"; import { getDatabase } from "./sqlite.js";
export const ALERT_JWT_STATUS_UNUSED = "unused"; export const ALERT_JWT_STATUS_UNUSED = "unused";
@@ -10,7 +14,15 @@ export type AlertAuthorizationBatchRecord = {
id: string; id: string;
userId: string; userId: string;
batchId: string; batchId: string;
timezone: string; /**
* UTC hour and minute the user asked to be notified at, or undefined for no
* gate. The column holds them as one `HH:MM` string, because that is the form
* SQLite compares chronologically; TypeScript never sees that string.
*/
notifyHourUtc?: number;
notifyMinuteUtc?: number;
/** IANA zone the caller's offset came from. Recorded only; nothing reads it. */
timezone?: string;
createdAt: string; createdAt: string;
}; };
@@ -29,6 +41,12 @@ export type AlertAuthorizationJwtRecord = {
createdAt: string; createdAt: string;
}; };
/** One user with an unused JWT for a given day, and whether their hour has come. */
export type PendingDayUser = {
userId: string;
due: boolean;
};
export type AlertAuthorizationJwtInput = { export type AlertAuthorizationJwtInput = {
sequence: number; sequence: number;
day: string; day: string;
@@ -41,7 +59,8 @@ type BatchDbRow = {
id: string; id: string;
user_id: string; user_id: string;
batch_id: string; batch_id: string;
timezone: string; notify_hour_min_utc: string | null;
timezone: string | null;
created_at: string; created_at: string;
}; };
@@ -80,12 +99,40 @@ function toJwtRecord(row: JwtDbRow): AlertAuthorizationJwtRecord {
}; };
} }
const BATCH_COLUMNS =
"id, user_id, batch_id, notify_hour_min_utc, timezone, created_at";
/** The stored column value for a batch, or null when it names no hour. */
function storedHourMinute(input: {
notifyHourUtc?: number;
notifyMinuteUtc?: number;
}): string | null {
if (input.notifyHourUtc === undefined || input.notifyMinuteUtc === undefined) {
return null;
}
return formatHourMinuteUtc(input.notifyHourUtc, input.notifyMinuteUtc);
}
/**
* The column back into a pair. An unreadable value yields neither half rather
* than a plausible-looking one, matching the selection query, which treats a
* value it cannot read as ungated rather than deferring that user forever.
*/
function splitStoredHourMinute(
value: string | null
): { notifyHourUtc?: number; notifyMinuteUtc?: number } {
const parsed = value === null ? undefined : parseHourMinuteUtc(value);
if (parsed === undefined) return {};
return { notifyHourUtc: parsed.hour, notifyMinuteUtc: parsed.minute };
}
function toBatchRecord(row: BatchDbRow): AlertAuthorizationBatchRecord { function toBatchRecord(row: BatchDbRow): AlertAuthorizationBatchRecord {
return { return {
id: row.id, id: row.id,
userId: row.user_id, userId: row.user_id,
batchId: row.batch_id, batchId: row.batch_id,
timezone: row.timezone, ...splitStoredHourMinute(row.notify_hour_min_utc),
timezone: row.timezone ?? undefined,
createdAt: row.created_at, createdAt: row.created_at,
}; };
} }
@@ -121,7 +168,9 @@ export function createAlertAuthorizationStore(tables: AlertAuthorizationTables)
async replaceUnusedBatch(input: { async replaceUnusedBatch(input: {
userId: string; userId: string;
batchId: string; batchId: string;
timezone: string; notifyHourUtc?: number;
notifyMinuteUtc?: number;
timezone?: string;
jwts: AlertAuthorizationJwtInput[]; jwts: AlertAuthorizationJwtInput[];
}): Promise<{ }): Promise<{
batch: AlertAuthorizationBatchRecord; batch: AlertAuthorizationBatchRecord;
@@ -159,11 +208,18 @@ export function createAlertAuthorizationStore(tables: AlertAuthorizationTables)
.prepare( .prepare(
` `
INSERT INTO ${tables.batches} ( INSERT INTO ${tables.batches} (
id, user_id, batch_id, timezone, created_at id, user_id, batch_id, notify_hour_min_utc, timezone, created_at
) VALUES (?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?)
` `
) )
.run(batchPk, input.userId, input.batchId, input.timezone, now); .run(
batchPk,
input.userId,
input.batchId,
storedHourMinute(input),
input.timezone ?? null,
now
);
const insertJwt = connection.prepare( const insertJwt = connection.prepare(
` `
@@ -203,6 +259,8 @@ export function createAlertAuthorizationStore(tables: AlertAuthorizationTables)
id: batchPk, id: batchPk,
userId: input.userId, userId: input.userId,
batchId: input.batchId, batchId: input.batchId,
notifyHourUtc: input.notifyHourUtc,
notifyMinuteUtc: input.notifyMinuteUtc,
timezone: input.timezone, timezone: input.timezone,
createdAt: now, createdAt: now,
}, },
@@ -239,6 +297,64 @@ export function createAlertAuthorizationStore(tables: AlertAuthorizationTables)
return row.n; return row.n;
}, },
/**
* The users this channel has work for on `day`, each flagged with whether
* their chosen hour has arrived. One query for the whole pass: a scheduler
* that asked per user would spend most of its ticks paying a round trip to
* be told "nothing to do".
*
* A user appears only while holding an unused JWT for that day, so one that
* has already run drops out until the day rolls. `hourMinute` is compared as
* text, which is chronological because `HH:MM` is zero-padded.
*
* `rowid` breaks a `created_at` tie: two batches uploaded inside the same
* millisecond carry the same ISO timestamp, and `id` is a random UUID, so
* ordering by that would pick the newest batch by coin flip.
*/
async listPendingForDay(input: {
day: string;
hourMinute: string;
}): Promise<PendingDayUser[]> {
const rows = getDatabase()
.prepare(
`
WITH latest AS (
SELECT
user_id,
notify_hour_min_utc,
ROW_NUMBER() OVER (
PARTITION BY user_id ORDER BY created_at DESC, rowid DESC
) AS rn
FROM ${tables.batches}
)
SELECT DISTINCT
latest.user_id AS user_id,
CASE
WHEN latest.notify_hour_min_utc IS NULL THEN 1
-- A value this comparison cannot read must not silence the user.
-- Text ordering would rank anything non-numeric above every
-- 'HH:MM', which would defer such a row forever rather than once.
WHEN latest.notify_hour_min_utc NOT GLOB '[0-2][0-9]:[0-5][0-9]' THEN 1
WHEN latest.notify_hour_min_utc <= @hourMinute THEN 1
ELSE 0
END AS due
FROM latest
JOIN ${tables.jwts} AS jwts
ON jwts.user_id = latest.user_id
AND jwts.day = @day
AND jwts.status = @unused
WHERE latest.rn = 1
ORDER BY latest.user_id
`
)
.all({
day: input.day,
hourMinute: input.hourMinute,
unused: ALERT_JWT_STATUS_UNUSED,
}) as { user_id: string; due: number }[];
return rows.map((row) => ({ userId: row.user_id, due: row.due === 1 }));
},
async listDistinctUserIds(): Promise<string[]> { async listDistinctUserIds(): Promise<string[]> {
const rows = getDatabase() const rows = getDatabase()
.prepare( .prepare(
@@ -258,10 +374,10 @@ export function createAlertAuthorizationStore(tables: AlertAuthorizationTables)
const row = getDatabase() const row = getDatabase()
.prepare( .prepare(
` `
SELECT id, user_id, batch_id, timezone, created_at SELECT ${BATCH_COLUMNS}
FROM ${tables.batches} FROM ${tables.batches}
WHERE user_id = ? WHERE user_id = ?
ORDER BY created_at DESC ORDER BY created_at DESC, rowid DESC
LIMIT 1 LIMIT 1
` `
) )
@@ -283,6 +399,32 @@ export function createAlertAuthorizationStore(tables: AlertAuthorizationTables)
return row === undefined ? undefined : toJwtRecord(row); return row === undefined ? undefined : toJwtRecord(row);
}, },
/**
* Drop every batch and every JWT this user holds in this channel, consumed
* rows included, so the scheduler stops listing them. Alert-search cursors
* are left alone: a later re-authorization resumes where this one stopped
* instead of replaying months of history.
*/
async deleteAllForUser(
userId: string
): Promise<{ deletedBatches: number; deletedJwts: number }> {
const connection = getDatabase();
let deletedJwts = 0;
let deletedBatches = 0;
const run = connection.transaction(() => {
deletedJwts = connection
.prepare(`DELETE FROM ${tables.jwts} WHERE user_id = ?`)
.run(userId).changes;
deletedBatches = connection
.prepare(`DELETE FROM ${tables.batches} WHERE user_id = ?`)
.run(userId).changes;
});
run();
return { deletedBatches, deletedJwts };
},
/** /**
* Mark one unused JWT consumed. Matches the specific row, not "any unused for today". * Mark one unused JWT consumed. Matches the specific row, not "any unused for today".
*/ */
+52 -2
View File
@@ -12,7 +12,11 @@ CREATE TABLE IF NOT EXISTS fcm_registrations (
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
device_id TEXT NOT NULL, device_id TEXT NOT NULL,
fcm_token TEXT NOT NULL, fcm_token TEXT NOT NULL,
-- The client's self-declared platform, in practice "ios", "android" or
-- "web", though the route accepts any non-empty string.
platform TEXT NOT NULL, platform TEXT NOT NULL,
test_mode INTEGER, test_mode INTEGER,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
@@ -38,7 +42,16 @@ CREATE TABLE IF NOT EXISTS alert_authorization_batches (
id TEXT PRIMARY KEY NOT NULL, id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
batch_id TEXT NOT NULL, batch_id TEXT NOT NULL,
timezone TEXT NOT NULL,
-- A 24-hour UTC clock time as zero-padded "HH:MM", which is what lets the
-- scheduler's plain text comparison against it come out chronological.
notify_hour_min_utc TEXT,
-- An IANA zone name such as "America/Denver", read by nothing and recorded
-- only so a later mechanism could re-derive notify_hour_min_utc when a zone's
-- DST rules move the offset out from under the stored time.
timezone TEXT,
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
@@ -54,11 +67,18 @@ CREATE TABLE IF NOT EXISTS alert_authorization_jwts (
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
batch_id TEXT NOT NULL, batch_id TEXT NOT NULL,
sequence INTEGER NOT NULL, sequence INTEGER NOT NULL,
-- The UTC calendar day this JWT covers, as "YYYY-MM-DD".
day TEXT NOT NULL, day TEXT NOT NULL,
jwt TEXT NOT NULL, jwt TEXT NOT NULL,
nbf INTEGER NOT NULL, nbf INTEGER NOT NULL,
exp INTEGER NOT NULL, exp INTEGER NOT NULL,
-- Either "unused" or "consumed", the value the partial unique index below
-- keys on.
status TEXT NOT NULL, status TEXT NOT NULL,
consumed_at TEXT, consumed_at TEXT,
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
@@ -117,8 +137,14 @@ CREATE TABLE IF NOT EXISTS sms_phone_log (
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
phone_e164 TEXT, phone_e164 TEXT,
phone_hash TEXT NOT NULL, phone_hash TEXT NOT NULL,
-- One of the SmsPhoneLogAction names in src/models/smsRegistration.ts, such
-- as "code-sent" or "alert-authorization-deleted".
action TEXT NOT NULL, action TEXT NOT NULL,
-- Either "ok", "rejected" or "failed".
result TEXT NOT NULL, result TEXT NOT NULL,
detail TEXT, detail TEXT,
jwt_hash TEXT, jwt_hash TEXT,
provider_message_id TEXT, provider_message_id TEXT,
@@ -138,7 +164,11 @@ CREATE TABLE IF NOT EXISTS sms_action_jwt_use (
id TEXT PRIMARY KEY NOT NULL, id TEXT PRIMARY KEY NOT NULL,
jwt_hash TEXT NOT NULL, jwt_hash TEXT NOT NULL,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
-- One of the SMS_ACTIONS names in src/middleware/smsActionJwt.ts, such as
-- "register-phone" or "revoke-alert-search".
action TEXT NOT NULL, action TEXT NOT NULL,
used_at TEXT NOT NULL used_at TEXT NOT NULL
); );
@@ -152,7 +182,16 @@ CREATE TABLE IF NOT EXISTS sms_alert_authorization_batches (
id TEXT PRIMARY KEY NOT NULL, id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
batch_id TEXT NOT NULL, batch_id TEXT NOT NULL,
timezone TEXT NOT NULL,
-- A 24-hour UTC clock time as zero-padded "HH:MM", which is what lets the
-- scheduler's plain text comparison against it come out chronological.
notify_hour_min_utc TEXT,
-- An IANA zone name such as "America/Denver", read by nothing and recorded
-- only so a later mechanism could re-derive notify_hour_min_utc when a zone's
-- DST rules move the offset out from under the stored time.
timezone TEXT,
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
@@ -168,11 +207,18 @@ CREATE TABLE IF NOT EXISTS sms_alert_authorization_jwts (
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
batch_id TEXT NOT NULL, batch_id TEXT NOT NULL,
sequence INTEGER NOT NULL, sequence INTEGER NOT NULL,
-- The UTC calendar day this JWT covers, as "YYYY-MM-DD".
day TEXT NOT NULL, day TEXT NOT NULL,
jwt TEXT NOT NULL, jwt TEXT NOT NULL,
nbf INTEGER NOT NULL, nbf INTEGER NOT NULL,
exp INTEGER NOT NULL, exp INTEGER NOT NULL,
-- Either "unused" or "consumed", the value the partial unique index below
-- keys on.
status TEXT NOT NULL, status TEXT NOT NULL,
consumed_at TEXT, consumed_at TEXT,
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
@@ -196,7 +242,11 @@ CREATE TABLE IF NOT EXISTS sms_blocked_numbers (
id TEXT PRIMARY KEY NOT NULL, id TEXT PRIMARY KEY NOT NULL,
phone_hash TEXT NOT NULL, phone_hash TEXT NOT NULL,
phone_e164 TEXT, phone_e164 TEXT,
-- Either "opt-out", "provider-opt-out" or "manual", ranked in that order of
-- strength so a block is never quietly downgraded.
reason TEXT NOT NULL, reason TEXT NOT NULL,
detail TEXT, detail TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
+11 -1
View File
@@ -12,10 +12,20 @@ export const SMS_ACTIONS = [
"verify-phone", "verify-phone",
"delete-phone", "delete-phone",
"authorize-alert-search", "authorize-alert-search",
"revoke-alert-search",
] as const; ] as const;
export type SmsAction = (typeof SMS_ACTIONS)[number]; export type SmsAction = (typeof SMS_ACTIONS)[number];
/**
* Actions on the DID's whole alert authorization rather than on one handset.
* They bind to no number, so a claim carrying one is neither required nor read.
*/
const PHONELESS_ACTIONS = new Set<SmsAction>([
"authorize-alert-search",
"revoke-alert-search",
]);
export const SMS_ACTION_CLAIM_CONTEXT = "https://giftopia.tech"; export const SMS_ACTION_CLAIM_CONTEXT = "https://giftopia.tech";
export const SMS_ACTION_CLAIM_TYPE = "SmsNotificationAction"; export const SMS_ACTION_CLAIM_TYPE = "SmsNotificationAction";
@@ -99,7 +109,7 @@ export function requireSmsActionJwt(action: SmsAction) {
return; return;
} }
if (action !== "authorize-alert-search") { if (!PHONELESS_ACTIONS.has(action)) {
const requested = requestPhoneNumber(req); const requested = requestPhoneNumber(req);
// list-phones without the query parameter binds to no number at all. // list-phones without the query parameter binds to no number at all.
const bindsPhone = action !== "list-phones" || requested !== undefined; const bindsPhone = action !== "list-phones" || requested !== undefined;
+1
View File
@@ -28,6 +28,7 @@ export type SmsPhoneLogAction =
| "did-limit-disclosed" | "did-limit-disclosed"
| "deleted" | "deleted"
| "alert-authorization-stored" | "alert-authorization-stored"
| "alert-authorization-deleted"
| "alert-sent" | "alert-sent"
| "alert-send-failed" | "alert-send-failed"
| "recipient-not-allowed" | "recipient-not-allowed"
+52 -2
View File
@@ -8,6 +8,7 @@ import {
} from "../middleware/auth.js"; } from "../middleware/auth.js";
import { import {
type AlertAuthorizationRequestBody, type AlertAuthorizationRequestBody,
storedNotifyLabel,
unsupportedIdentityFailure, unsupportedIdentityFailure,
validateAlertAuthorizationBatch, validateAlertAuthorizationBatch,
} from "../services/alertAuthorization.js"; } from "../services/alertAuthorization.js";
@@ -149,13 +150,17 @@ notificationsRouter.put(
const stored = await alertAuthorizationDb.replaceUnusedBatch({ const stored = await alertAuthorizationDb.replaceUnusedBatch({
userId, userId,
batchId: validated.batchId, batchId: validated.batchId,
notifyHourUtc: validated.notifyHourUtc,
notifyMinuteUtc: validated.notifyMinuteUtc,
timezone: validated.timezone, timezone: validated.timezone,
jwts: validated.jwts, jwts: validated.jwts,
}); });
res.status(200).json({ res.status(200).json({
success: true, success: true,
batchId: stored.batch.batchId, batchId: stored.batch.batchId,
timezone: stored.batch.timezone, notifyHourUtc: stored.batch.notifyHourUtc ?? null,
notifyMinuteUtc: stored.batch.notifyMinuteUtc ?? null,
timezone: stored.batch.timezone ?? null,
storedCount: stored.storedCount, storedCount: stored.storedCount,
unusedCount: stored.unusedCount, unusedCount: stored.unusedCount,
}); });
@@ -163,7 +168,8 @@ notificationsRouter.put(
"[AlertAuthorization] Completed in", "[AlertAuthorization] Completed in",
formatElapsedMs(Date.now() - started) + ",", formatElapsedMs(Date.now() - started) + ",",
"batchId=" + stored.batch.batchId + ",", "batchId=" + stored.batch.batchId + ",",
"stored=" + stored.storedCount "stored=" + stored.storedCount + ",",
"notifyUtc=" + storedNotifyLabel(stored.batch)
); );
} catch (err) { } catch (err) {
log.error( log.error(
@@ -179,6 +185,50 @@ notificationsRouter.put(
} }
); );
/**
* Turn the push channel off: every batch and every JWT for this DID, used or
* not, so the alertSearch scheduler stops listing the identity. Device
* registrations are untouched; WAKEUP_PING is a separate mechanism.
*/
notificationsRouter.delete(
"/alert-authorization",
requireAuth,
requireEndorserAuth,
async (req, res) => {
const started = Date.now();
const userId = req.did;
if (userId === undefined) {
res.status(401).json({ success: false, message: "Unauthorized" });
return;
}
try {
const removed = await alertAuthorizationDb.deleteAllForUser(userId);
res.status(200).json({
success: true,
deletedBatches: removed.deletedBatches,
deletedJwts: removed.deletedJwts,
});
log.info(
"[AlertAuthorization] Deleted in",
formatElapsedMs(Date.now() - started) + ",",
"batches=" + removed.deletedBatches + ",",
"jwts=" + removed.deletedJwts
);
} catch (err) {
log.error(
"[AlertAuthorization] Delete failed in",
formatElapsedMs(Date.now() - started) + ":",
errorMessage(err)
);
res.status(500).json({
success: false,
message: "Failed to remove the delegated notification-JWT inventory.",
});
}
}
);
notificationsRouter.post( notificationsRouter.post(
"/register", "/register",
requireAuthOrNotificationLocalTest, requireAuthOrNotificationLocalTest,
+86 -3
View File
@@ -14,6 +14,7 @@ import {
import type { SmsPhoneLogAction } from "../models/smsRegistration.js"; import type { SmsPhoneLogAction } from "../models/smsRegistration.js";
import { import {
type AlertAuthorizationRequestBody, type AlertAuthorizationRequestBody,
storedNotifyLabel,
unsupportedIdentityFailure, unsupportedIdentityFailure,
validateAlertAuthorizationBatch, validateAlertAuthorizationBatch,
} from "../services/alertAuthorization.js"; } from "../services/alertAuthorization.js";
@@ -71,6 +72,9 @@ const CODE_SEND_ACTIONS: SmsPhoneLogAction[] = ["code-sent", "code-send-failed"]
const EMPTY_TWIML = '<?xml version="1.0" encoding="UTF-8"?><Response></Response>'; const EMPTY_TWIML = '<?xml version="1.0" encoding="UTF-8"?><Response></Response>';
/** Prefix that keeps a stand-in log hash out of the phone-number namespace. */
const NO_PHONE_HASH_SUBJECT = "no-phone:";
function escapeXml(text: string): string { function escapeXml(text: string): string {
return text return text
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
@@ -707,6 +711,8 @@ export function createNotifySmsRouter(deps: NotifySmsDeps = {}): express.Router
const stored = await smsAlertAuthorizationDb.replaceUnusedBatch({ const stored = await smsAlertAuthorizationDb.replaceUnusedBatch({
userId, userId,
batchId: validated.batchId, batchId: validated.batchId,
notifyHourUtc: validated.notifyHourUtc,
notifyMinuteUtc: validated.notifyMinuteUtc,
timezone: validated.timezone, timezone: validated.timezone,
jwts: validated.jwts, jwts: validated.jwts,
}); });
@@ -716,20 +722,27 @@ export function createNotifySmsRouter(deps: NotifySmsDeps = {}): express.Router
phoneHash: hashPhoneNumber(verified[0].phoneE164, secret), phoneHash: hashPhoneNumber(verified[0].phoneE164, secret),
action: "alert-authorization-stored", action: "alert-authorization-stored",
result: "ok", result: "ok",
detail: "batchId=" + stored.batch.batchId, detail:
"batchId=" +
stored.batch.batchId +
" notifyUtc=" +
storedNotifyLabel(stored.batch),
jwtHash, jwtHash,
}); });
res.status(200).json({ res.status(200).json({
success: true, success: true,
batchId: stored.batch.batchId, batchId: stored.batch.batchId,
timezone: stored.batch.timezone, notifyHourUtc: stored.batch.notifyHourUtc ?? null,
notifyMinuteUtc: stored.batch.notifyMinuteUtc ?? null,
timezone: stored.batch.timezone ?? null,
storedCount: stored.storedCount, storedCount: stored.storedCount,
unusedCount: stored.unusedCount, unusedCount: stored.unusedCount,
}); });
log.info( log.info(
"[NotifySmsAlertAuthorization] Completed in", "[NotifySmsAlertAuthorization] Completed in",
formatElapsedMs(Date.now() - started) + ",", formatElapsedMs(Date.now() - started) + ",",
"stored=" + stored.storedCount "stored=" + stored.storedCount + ",",
"notifyUtc=" + storedNotifyLabel(stored.batch)
); );
} catch (err) { } catch (err) {
log.error( log.error(
@@ -746,6 +759,69 @@ export function createNotifySmsRouter(deps: NotifySmsDeps = {}): express.Router
} }
}; };
/**
* Turn the channel off: every SMS batch and every SMS JWT for this DID, used
* or not, so the scheduler stops listing the identity entirely. Registered
* phone numbers survive — a user silencing alerts has not asked to redo the
* possession check when they come back. `DELETE /notify-sms/phone` is the
* route that forgets a number.
*/
const deleteAlertAuthorization: RequestHandler = async (req, res) => {
const started = Date.now();
const userId = req.did as string;
const jwtHash = req.smsActionJwtHash;
const secret = codeSecretOrFail(res);
if (secret === undefined) return;
try {
const removed = await smsAlertAuthorizationDb.deleteAllForUser(userId);
const verified = await smsRegistrationsDb.listVerifiedByUserId(userId);
// Logged against a number when there is one. A DID whose handset is
// already gone still gets the revocation recorded, under a hash of the
// identity that stands in for the number the log column expects.
await recordPhoneAction({
userId,
phoneE164: verified[0]?.phoneE164,
phoneHash: hashPhoneNumber(
verified[0]?.phoneE164 ?? NO_PHONE_HASH_SUBJECT + userId,
secret
),
action: "alert-authorization-deleted",
result: "ok",
detail:
"batches=" +
removed.deletedBatches +
" jwts=" +
removed.deletedJwts,
jwtHash,
});
res.status(200).json({
success: true,
deletedBatches: removed.deletedBatches,
deletedJwts: removed.deletedJwts,
});
log.info(
"[NotifySmsAlertAuthorization] Deleted in",
formatElapsedMs(Date.now() - started) + ",",
"batches=" + removed.deletedBatches + ",",
"jwts=" + removed.deletedJwts
);
} catch (err) {
log.error(
"[NotifySmsAlertAuthorization] Delete failed in",
formatElapsedMs(Date.now() - started) + ":",
errorMessage(err)
);
sendError(
res,
500,
"SMS_ALERT_AUTHORIZATION_DELETE_FAILED",
"Failed to remove the delegated notification-JWT inventory."
);
}
};
/** /**
* Twilio's inbound webhook. It authenticates by X-Twilio-Signature, not by * 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 * JWT: this is Twilio calling, not a user, and the request carries a phone
@@ -868,6 +944,13 @@ export function createNotifySmsRouter(deps: NotifySmsDeps = {}): express.Router
router.post("/alert-authorization", alertAuthorizationChain); router.post("/alert-authorization", alertAuthorizationChain);
router.put("/alert-authorization", alertAuthorizationChain); router.put("/alert-authorization", alertAuthorizationChain);
router.delete(
"/alert-authorization",
...authStages,
requireSmsActionJwt("revoke-alert-search"),
deleteAlertAuthorization
);
return router; return router;
} }
+149 -35
View File
@@ -6,10 +6,14 @@ import {
} from "../vc/index.js"; } from "../vc/index.js";
const DAY_RE = /^\d{4}-\d{2}-\d{2}$/; const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
const SECONDS_PER_DAY = 86400;
const MAX_REPORTED_ERRORS = 20; const MAX_REPORTED_ERRORS = 20;
export type AlertAuthorizationRequestBody = { export type AlertAuthorizationRequestBody = {
batchId?: unknown; batchId?: unknown;
notifyHourUtc?: unknown;
notifyMinuteUtc?: unknown;
timezone?: unknown; timezone?: unknown;
jwts?: unknown; jwts?: unknown;
}; };
@@ -25,7 +29,15 @@ export type ValidatedAlertJwt = {
export type BatchValidationSuccess = { export type BatchValidationSuccess = {
ok: true; ok: true;
batchId: string; batchId: string;
timezone: string; /** UTC hour, 0-23. Always present; the field is required. */
notifyHourUtc: number;
/** UTC minute, 0-59. Always present; the field is required. */
notifyMinuteUtc: number;
/**
* The IANA zone the caller named, when they named one. Stored and otherwise
* unused: no scheduling decision consults it.
*/
timezone: string | undefined;
jwts: ValidatedAlertJwt[]; jwts: ValidatedAlertJwt[];
}; };
@@ -36,14 +48,61 @@ export type BatchValidationFailure = {
details: string[]; details: string[];
}; };
type JwtItemBody = { /** `YYYY-MM-DD` in UTC — the frame every stored `day` is labelled in. */
sequence?: unknown; export function utcCalendarDay(epochSec: number): string {
day?: unknown; return new Date(epochSec * 1000).toISOString().slice(0, 10);
jwt?: unknown; }
nbf?: unknown;
exp?: unknown;
};
/** Midnight UTC that opens `day`, or undefined when `day` is not a real date. */
export function utcDayStartSeconds(day: string): number | undefined {
if (!DAY_RE.test(day)) return undefined;
const parsed = Date.parse(day + "T00:00:00Z");
if (Number.isNaN(parsed)) return undefined;
const seconds = Math.floor(parsed / 1000);
// Date.parse rolls 2026-02-30 forward to March rather than refusing it.
return utcCalendarDay(seconds) === day ? seconds : undefined;
}
/**
* Zero-pads an hour and minute into the `HH:MM` the column stores. The padding
* is what makes SQLite's text comparison against that column chronological.
*/
export function formatHourMinuteUtc(hour: number, minute: number): string {
return (
String(hour).padStart(2, "0") + ":" + String(minute).padStart(2, "0")
);
}
/** The stored hour as one loggable token, or "none" when a batch names none. */
export function storedNotifyLabel(batch: {
notifyHourUtc?: number;
notifyMinuteUtc?: number;
}): string {
if (batch.notifyHourUtc === undefined || batch.notifyMinuteUtc === undefined) {
return "none";
}
return formatHourMinuteUtc(batch.notifyHourUtc, batch.notifyMinuteUtc);
}
/** Splits a stored `HH:MM` back into its parts, or undefined if malformed. */
export function parseHourMinuteUtc(
value: string
): { hour: number; minute: number } | undefined {
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value);
if (match === null) return undefined;
return { hour: Number(match[1]), minute: Number(match[2]) };
}
/** `HH:MM` UTC, the form `notify_hour_min_utc` is stored in and compared against. */
export function utcHourMinute(epochSec: number): string {
return new Date(epochSec * 1000).toISOString().slice(11, 16);
}
/**
* Whether `Intl` can resolve the name. Checked at the door because a zone this
* service cannot resolve is worth nothing to whatever reads the column later,
* and the caller is the only party able to correct it.
*/
export function isValidIanaTimeZone(timezone: string): boolean { export function isValidIanaTimeZone(timezone: string): boolean {
if (timezone.length === 0) return false; if (timezone.length === 0) return false;
try { try {
@@ -54,18 +113,13 @@ export function isValidIanaTimeZone(timezone: string): boolean {
} }
} }
export function calendarDayInTimeZone(epochSec: number, timeZone: string): string { type JwtItemBody = {
const parts = new Intl.DateTimeFormat("en-US", { sequence?: unknown;
timeZone, day?: unknown;
year: "numeric", jwt?: unknown;
month: "2-digit", nbf?: unknown;
day: "2-digit", exp?: unknown;
}).formatToParts(new Date(epochSec * 1000)); };
const year = parts.find((p) => p.type === "year")?.value;
const month = parts.find((p) => p.type === "month")?.value;
const day = parts.find((p) => p.type === "day")?.value;
return `${year}-${month}-${day}`;
}
function clientErrorInfo(err: unknown): { message: string; code?: string } { function clientErrorInfo(err: unknown): { message: string; code?: string } {
if (err && typeof err === "object" && "clientError" in err) { if (err && typeof err === "object" && "clientError" in err) {
@@ -85,6 +139,30 @@ function isFiniteInteger(value: unknown): value is number {
return typeof value === "number" && Number.isInteger(value); return typeof value === "number" && Number.isInteger(value);
} }
/**
* One half of the notify time: required, an integer, and inside its own range.
* Reported per field so a caller sending only the hour learns which is missing.
*/
function validUtcPart(
value: unknown,
field: string,
max: number,
details: string[]
): number | undefined {
if (value === undefined || value === null) {
details.push(`${field} is required: an integer 0-${max}, in UTC`);
return undefined;
}
if (!isFiniteInteger(value) || value < 0 || value > max) {
details.push(
`${field} must be an integer 0-${max}, in UTC, got ` +
JSON.stringify(value)
);
return undefined;
}
return value;
}
export function unsupportedIdentityFailure(did: string): BatchValidationFailure { export function unsupportedIdentityFailure(did: string): BatchValidationFailure {
return { return {
ok: false, ok: false,
@@ -110,12 +188,38 @@ export async function validateAlertAuthorizationBatch(
details.push("batchId is required"); details.push("batchId is required");
} }
const timezone = // Both required, and both UTC — the field names say so, which is why no
typeof body.timezone === "string" ? body.timezone.trim() : undefined; // offset or zone travels with them. Required rather than optional because the
if (timezone === undefined || timezone.length === 0) { // alternative default is not "no gate" but "the first tick after midnight
details.push("timezone is required (IANA name used when minting validity windows)"); // UTC", the setting that puts every user on one tick.
} else if (!isValidIanaTimeZone(timezone)) { const notifyHourUtc = validUtcPart(
details.push(`timezone is not a valid IANA time zone: ${timezone}`); body.notifyHourUtc,
"notifyHourUtc",
23,
details
);
const notifyMinuteUtc = validUtcPart(
body.notifyMinuteUtc,
"notifyMinuteUtc",
59,
details
);
// Recorded against the day a later DST mechanism might need it. Optional and
// independent of notifyTime: the offset in that value is what schedules a
// send today, and nothing here reads this one.
let timezone: string | undefined;
if (body.timezone !== undefined && body.timezone !== null) {
const raw =
typeof body.timezone === "string" ? body.timezone.trim() : undefined;
if (raw === undefined || !isValidIanaTimeZone(raw)) {
details.push(
`timezone must be an IANA zone name (e.g. "America/Denver"), got ` +
JSON.stringify(body.timezone)
);
} else {
timezone = raw;
}
} }
if (!Array.isArray(body.jwts)) { if (!Array.isArray(body.jwts)) {
@@ -153,10 +257,15 @@ export async function validateAlertAuthorizationBatch(
sequences.add(item.sequence); sequences.add(item.sequence);
} }
if (typeof item.day !== "string" || !DAY_RE.test(item.day)) { if (typeof item.day !== "string") {
details.push(`${prefix}.day must be YYYY-MM-DD`); details.push(`${prefix}.day must be YYYY-MM-DD`);
continue; continue;
} }
const dayStart = utcDayStartSeconds(item.day);
if (dayStart === undefined) {
details.push(`${prefix}.day must be a real UTC date as YYYY-MM-DD`);
continue;
}
if (days.has(item.day)) { if (days.has(item.day)) {
details.push(`${prefix}.day ${item.day} is duplicated`); details.push(`${prefix}.day ${item.day} is duplicated`);
} else { } else {
@@ -177,13 +286,15 @@ export async function validateAlertAuthorizationBatch(
continue; continue;
} }
if (timezone !== undefined && isValidIanaTimeZone(timezone)) { // The daily run picks a JWT by UTC day and may fire at any moment inside
const dayFromNbf = calendarDayInTimeZone(item.nbf, timezone); // it, catch-up runs included. A window that only covers part of that day
if (dayFromNbf !== item.day) { // would hand Endorser a credential outside its own validity period.
details.push( const dayEnd = dayStart + SECONDS_PER_DAY;
`${prefix}.day ${item.day} does not match nbf ${item.nbf} in timezone ${timezone} (${dayFromNbf})` if (item.nbf > dayStart || item.exp < dayEnd) {
); details.push(
} `${prefix} must be valid for all of UTC day ${item.day} ` +
`(nbf <= ${dayStart} and exp >= ${dayEnd}), got nbf ${item.nbf} exp ${item.exp}`
);
} }
try { try {
@@ -259,7 +370,8 @@ export async function validateAlertAuthorizationBatch(
if ( if (
details.length > 0 || details.length > 0 ||
batchId === undefined || batchId === undefined ||
timezone === undefined || notifyHourUtc === undefined ||
notifyMinuteUtc === undefined ||
validated.length !== EXPECTED_ALERT_JWT_BATCH_SIZE validated.length !== EXPECTED_ALERT_JWT_BATCH_SIZE
) { ) {
return fail(details); return fail(details);
@@ -268,6 +380,8 @@ export async function validateAlertAuthorizationBatch(
return { return {
ok: true, ok: true,
batchId, batchId,
notifyHourUtc,
notifyMinuteUtc,
timezone, timezone,
jwts: validated, jwts: validated,
}; };
+37
View File
@@ -0,0 +1,37 @@
/**
* How many users a scheduler pass works on at once. The per-user work is two
* external round trips against independent cursors, so a serial loop spends the
* whole pass waiting: at 200ms per user it stops fitting inside a five-minute
* tick somewhere around 1,500 users, and the in-flight guard then starts
* skipping passes.
*
* Held low deliberately. The ceiling this relieves is latency, not throughput,
* and the two APIs on the other end are shared infrastructure that a wide fan-out
* would simply move the queue into.
*/
export const ALERT_SEARCH_USER_CONCURRENCY = 8;
/**
* Runs `work` over `items` with at most `limit` in flight, preserving neither
* start nor finish order. Callers own their errors: a throw from `work` aborts
* the remaining items, so anything that should merely be counted and skipped has
* to be caught inside `work`.
*/
export async function forEachWithConcurrency<T>(
items: readonly T[],
limit: number,
work: (item: T) => Promise<void>
): Promise<void> {
if (items.length === 0) return;
const width = Math.max(1, Math.min(Math.floor(limit), items.length));
let next = 0;
const workers = Array.from({ length: width }, async () => {
for (let index = next++; index < items.length; index = next++) {
await work(items[index]);
}
});
await Promise.all(workers);
}
+1 -1
View File
@@ -89,7 +89,7 @@ function daily(
): DailyAlertSearchResult { ): DailyAlertSearchResult {
return { return {
userId: USER, userId: USER,
localDay: "2026-09-05", utcDay: "2026-09-05",
batchId: "sms-batch-1", batchId: "sms-batch-1",
jwtSequence: 1, jwtSequence: 1,
endorserOutcome: "success", endorserOutcome: "success",
+168 -10
View File
@@ -19,6 +19,15 @@ import {
const USER = "did:ethr:0xschedone"; const USER = "did:ethr:0xschedone";
const OTHER = "did:ethr:0xschedtwo"; const OTHER = "did:ethr:0xschedtwo";
/** The day every seeded JWT belongs to, and an instant inside it. */
const DAY = "2026-09-05";
const NOW = new Date(`${DAY}T12:00:00.000Z`);
/** A pending list where every named user's hour has already come. */
function due(userIds: string[]) {
return userIds.map((userId) => ({ userId, due: true }));
}
let dir: string; let dir: string;
let savedDataDir: string | undefined; let savedDataDir: string | undefined;
@@ -42,7 +51,7 @@ afterEach(async () => {
function daily(userId: string): DailyAlertSearchResult { function daily(userId: string): DailyAlertSearchResult {
return { return {
userId, userId,
localDay: "2026-09-05", utcDay: "2026-09-05",
batchId: "b", batchId: "b",
jwtSequence: 1, jwtSequence: 1,
endorserOutcome: "success", endorserOutcome: "success",
@@ -58,7 +67,8 @@ describe("runSmsAlertSearchSchedulerPass", () => {
const ran: string[] = []; const ran: string[] = [];
const notified: string[] = []; const notified: string[] = [];
const result = await runSmsAlertSearchSchedulerPass({ const result = await runSmsAlertSearchSchedulerPass({
listUserIds: async () => [USER, OTHER], concurrency: 1,
listPending: async () => due([USER, OTHER]),
runDaily: async (userId) => { runDaily: async (userId) => {
ran.push(userId); ran.push(userId);
return daily(userId); return daily(userId);
@@ -80,11 +90,12 @@ describe("runSmsAlertSearchSchedulerPass", () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({ await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER, userId: USER,
batchId: "sms-1", batchId: "sms-1",
timezone: "UTC", jwts: [{ sequence: 1, day: DAY, jwt: "j", nbf: 1, exp: 2 }],
jwts: [{ sequence: 1, day: "2026-09-05", jwt: "j", nbf: 1, exp: 2 }],
}); });
const seen: string[] = []; const seen: string[] = [];
await runSmsAlertSearchSchedulerPass({ await runSmsAlertSearchSchedulerPass({
concurrency: 1,
now: NOW,
runDaily: async (userId) => { runDaily: async (userId) => {
seen.push(userId); seen.push(userId);
return daily(userId); return daily(userId);
@@ -98,7 +109,8 @@ describe("runSmsAlertSearchSchedulerPass", () => {
it("counts a failing user and keeps going", async () => { it("counts a failing user and keeps going", async () => {
const notified: string[] = []; const notified: string[] = [];
const result = await runSmsAlertSearchSchedulerPass({ const result = await runSmsAlertSearchSchedulerPass({
listUserIds: async () => [USER, OTHER], concurrency: 1,
listPending: async () => due([USER, OTHER]),
runDaily: async (userId) => { runDaily: async (userId) => {
if (userId === USER) throw new Error("Endorser down"); if (userId === USER) throw new Error("Endorser down");
return daily(userId); return daily(userId);
@@ -114,7 +126,8 @@ describe("runSmsAlertSearchSchedulerPass", () => {
it("does not let a failing notification fail the user", async () => { it("does not let a failing notification fail the user", async () => {
const result = await runSmsAlertSearchSchedulerPass({ const result = await runSmsAlertSearchSchedulerPass({
listUserIds: async () => [USER], concurrency: 1,
listPending: async () => due([USER]),
runDaily: async (userId) => daily(userId), runDaily: async (userId) => daily(userId),
notify: async () => { notify: async () => {
throw new Error("Twilio down"); throw new Error("Twilio down");
@@ -130,7 +143,8 @@ describe("runSmsAlertSearchSchedulerPass", () => {
release = resolve; release = resolve;
}); });
const running = runSmsAlertSearchSchedulerPass({ const running = runSmsAlertSearchSchedulerPass({
listUserIds: async () => [USER], concurrency: 1,
listPending: async () => due([USER]),
runDaily: async (userId) => { runDaily: async (userId) => {
await gate; await gate;
return daily(userId); return daily(userId);
@@ -141,7 +155,8 @@ describe("runSmsAlertSearchSchedulerPass", () => {
assert.equal(isSmsAlertSearchSchedulerPassInFlight(), true); assert.equal(isSmsAlertSearchSchedulerPassInFlight(), true);
const skipped = await runSmsAlertSearchSchedulerPass({ const skipped = await runSmsAlertSearchSchedulerPass({
listUserIds: async () => [OTHER], concurrency: 1,
listPending: async () => due([OTHER]),
prune: async () => undefined, prune: async () => undefined,
}); });
assert.equal(skipped.skipped, true); assert.equal(skipped.skipped, true);
@@ -152,6 +167,147 @@ describe("runSmsAlertSearchSchedulerPass", () => {
assert.equal(isSmsAlertSearchSchedulerPassInFlight(), false); assert.equal(isSmsAlertSearchSchedulerPassInFlight(), false);
}); });
it("defers a user the pending list reports as not yet due", async () => {
const ran: string[] = [];
const result = await runSmsAlertSearchSchedulerPass({
concurrency: 1,
listPending: async () => [
{ userId: USER, due: false },
{ userId: OTHER, due: true },
],
runDaily: async (userId) => {
ran.push(userId);
return daily(userId);
},
notify: async () => undefined,
prune: async () => undefined,
});
assert.deepEqual(ran, [OTHER]);
assert.equal(result.deferred, 1);
assert.equal(result.attempted, 1);
assert.equal(result.failed, 0);
});
it("holds the stored hour back until that instant, then runs", async () => {
// 18:00-06:00 is 00:00 UTC, so this batch asks for the very top of each UTC
// day; a batch stored at 18:00 UTC is the one that has to wait.
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-1",
notifyHourUtc: 18,
notifyMinuteUtc: 0,
jwts: [{ sequence: 1, day: DAY, jwt: "j", nbf: 1, exp: 2 }],
});
const ran: string[] = [];
const track = async (userId: string) => {
ran.push(userId);
return daily(userId);
};
const before = await runSmsAlertSearchSchedulerPass({
concurrency: 1,
now: new Date(`${DAY}T17:55:00.000Z`),
runDaily: track,
notify: async () => undefined,
prune: async () => undefined,
});
assert.deepEqual(ran, []);
assert.equal(before.deferred, 1);
assert.equal(before.attempted, 0);
const after = await runSmsAlertSearchSchedulerPass({
concurrency: 1,
now: new Date(`${DAY}T18:05:00.000Z`),
runDaily: track,
notify: async () => undefined,
prune: async () => undefined,
});
assert.deepEqual(ran, [USER]);
assert.equal(after.deferred, 0);
assert.equal(after.attempted, 1);
});
it("drops the user entirely once the UTC day rolls past their JWT", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-1",
notifyHourUtc: 18,
notifyMinuteUtc: 0,
jwts: [{ sequence: 1, day: DAY, jwt: "j", nbf: 1, exp: 2 }],
});
const result = await runSmsAlertSearchSchedulerPass({
concurrency: 1,
now: new Date("2026-09-06T00:05:00.000Z"),
runDaily: async (userId) => daily(userId),
notify: async () => undefined,
prune: async () => undefined,
});
// No JWT for the new day, so there is nothing pending to defer.
assert.equal(result.attempted, 0);
assert.equal(result.deferred, 0);
});
it("runs a batch that carries no notifyTime", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-1",
jwts: [{ sequence: 1, day: DAY, jwt: "j", nbf: 1, exp: 2 }],
});
const ran: string[] = [];
const result = await runSmsAlertSearchSchedulerPass({
concurrency: 1,
now: NOW,
runDaily: async (userId) => {
ran.push(userId);
return daily(userId);
},
notify: async () => undefined,
prune: async () => undefined,
});
assert.deepEqual(ran, [USER]);
assert.equal(result.deferred, 0);
});
it("fails the whole pass when the pending query itself fails", async () => {
await assert.rejects(
() =>
runSmsAlertSearchSchedulerPass({
concurrency: 1,
listPending: async () => {
throw new Error("database locked");
},
prune: async () => undefined,
}),
/database locked/
);
// The guard must not stay stuck after a thrown pass.
assert.equal(isSmsAlertSearchSchedulerPassInFlight(), false);
});
it("works on several users at once when concurrency allows", async () => {
const userIds = Array.from({ length: 12 }, (_, i) => `did:ethr:0x${i}`);
let inFlight = 0;
let peak = 0;
const result = await runSmsAlertSearchSchedulerPass({
concurrency: 4,
listPending: async () => due(userIds),
runDaily: async (userId) => {
inFlight += 1;
peak = Math.max(peak, inFlight);
await new Promise((resolve) => setTimeout(resolve, 5));
inFlight -= 1;
return daily(userId);
},
notify: async () => undefined,
prune: async () => undefined,
});
assert.equal(result.attempted, 12);
assert.equal(peak, 4, `expected 4 in flight, saw ${peak}`);
});
it("prunes action-JWT rows past the retention window", async () => { it("prunes action-JWT rows past the retention window", async () => {
process.env.SMS_ACTION_JWT_MAX_AGE_SEC = "60"; process.env.SMS_ACTION_JWT_MAX_AGE_SEC = "60";
const retentionMs = const retentionMs =
@@ -172,7 +328,8 @@ describe("runSmsAlertSearchSchedulerPass", () => {
assert.equal(await smsActionJwtUseDb.count(), 2); assert.equal(await smsActionJwtUseDb.count(), 2);
await runSmsAlertSearchSchedulerPass({ await runSmsAlertSearchSchedulerPass({
listUserIds: async () => [], concurrency: 1,
listPending: async () => due([]),
notify: async () => undefined, notify: async () => undefined,
}); });
assert.equal(await smsActionJwtUseDb.count(), 1); assert.equal(await smsActionJwtUseDb.count(), 1);
@@ -181,7 +338,8 @@ describe("runSmsAlertSearchSchedulerPass", () => {
it("survives a prune failure", async () => { it("survives a prune failure", async () => {
const result = await runSmsAlertSearchSchedulerPass({ const result = await runSmsAlertSearchSchedulerPass({
listUserIds: async () => [], concurrency: 1,
listPending: async () => due([]),
prune: async () => { prune: async () => {
throw new Error("locked"); throw new Error("locked");
}, },
+312 -3
View File
@@ -240,7 +240,6 @@ describe("smsAlertAuthorizationDb", () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({ await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER, userId: USER,
batchId: "sms-batch-1", batchId: "sms-batch-1",
timezone: "America/Denver",
jwts: batchJwts("2026-09-05"), jwts: batchJwts("2026-09-05"),
}); });
@@ -256,7 +255,6 @@ describe("smsAlertAuthorizationDb", () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({ await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER, userId: USER,
batchId: "sms-batch-1", batchId: "sms-batch-1",
timezone: "UTC",
jwts: [ jwts: [
...batchJwts("2026-09-05"), ...batchJwts("2026-09-05"),
{ sequence: 2, day: "2026-09-06", jwt: "jwt-b", nbf: 1, exp: 2 }, { sequence: 2, day: "2026-09-06", jwt: "jwt-b", nbf: 1, exp: 2 },
@@ -278,7 +276,6 @@ describe("smsAlertAuthorizationDb", () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({ await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER, userId: USER,
batchId: "sms-batch-2", batchId: "sms-batch-2",
timezone: "UTC",
jwts: batchJwts("2026-09-07"), jwts: batchJwts("2026-09-07"),
}); });
@@ -291,6 +288,318 @@ describe("smsAlertAuthorizationDb", () => {
); );
}); });
it("round-trips a notifyTime and reports none when absent", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-1",
notifyHourUtc: 18,
notifyMinuteUtc: 30,
jwts: batchJwts("2026-09-05"),
});
const _b = (await smsAlertAuthorizationDb.getLatestBatch(USER));
assert.equal(_b?.notifyHourUtc, 18);
assert.equal(_b?.notifyMinuteUtc, 30);
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-2",
jwts: batchJwts("2026-09-06"),
});
const _u = (await smsAlertAuthorizationDb.getLatestBatch(USER));
assert.equal(_u?.notifyHourUtc, undefined);
assert.equal(_u?.notifyMinuteUtc, undefined);
});
it("round-trips a recorded timezone and reports none when absent", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-1",
notifyHourUtc: 0,
notifyMinuteUtc: 30,
timezone: "America/Denver",
jwts: batchJwts("2026-09-05"),
});
const withZone = await smsAlertAuthorizationDb.getLatestBatch(USER);
assert.equal(withZone?.timezone, "America/Denver");
assert.equal(withZone?.notifyHourUtc, 0);
assert.equal(withZone?.notifyMinuteUtc, 30);
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-2",
jwts: batchJwts("2026-09-06"),
});
const without = await smsAlertAuthorizationDb.getLatestBatch(USER);
assert.equal(without?.timezone, undefined);
assert.equal(without?.notifyHourUtc, undefined);
assert.equal(without?.notifyMinuteUtc, undefined);
});
it("lists only users with an unused JWT for the day asked about", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-1",
jwts: batchJwts("2026-09-05"),
});
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: OTHER,
batchId: "other-batch",
jwts: batchJwts("2026-09-06"),
});
assert.deepEqual(
await smsAlertAuthorizationDb.listPendingForDay({
day: "2026-09-05",
hourMinute: "12:00",
}),
[{ userId: USER, due: true }]
);
assert.deepEqual(
await smsAlertAuthorizationDb.listPendingForDay({
day: "2026-09-07",
hourMinute: "12:00",
}),
[]
);
});
it("drops a user from the list once that day's JWT is consumed", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-1",
jwts: batchJwts("2026-09-05"),
});
const jwt = await smsAlertAuthorizationDb.getUnusedForDay(
USER,
"2026-09-05"
);
assert.ok(jwt);
await smsAlertAuthorizationDb.consumeUnusedJwt({ id: jwt.id, userId: USER });
assert.deepEqual(
await smsAlertAuthorizationDb.listPendingForDay({
day: "2026-09-05",
hourMinute: "23:59",
}),
[]
);
});
it("flags a user not due until their stored hour, by text comparison", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-1",
notifyHourUtc: 9,
notifyMinuteUtc: 30,
jwts: batchJwts("2026-09-05"),
});
const at = async (hourMinute: string) =>
(
await smsAlertAuthorizationDb.listPendingForDay({
day: "2026-09-05",
hourMinute,
})
)[0]?.due;
assert.equal(await at("00:00"), false);
assert.equal(await at("09:29"), false);
assert.equal(await at("09:30"), true);
// Zero-padded HH:MM sorts chronologically, so 10:00 must beat 09:30.
assert.equal(await at("10:00"), true);
assert.equal(await at("23:59"), true);
});
it("reads the hour from the newest batch, not an older surviving one", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "old",
notifyHourUtc: 23,
notifyMinuteUtc: 0,
jwts: batchJwts("2026-09-05"),
});
// Consume it so the old batch row survives the next replace.
const jwt = await smsAlertAuthorizationDb.getUnusedForDay(
USER,
"2026-09-05"
);
assert.ok(jwt);
await smsAlertAuthorizationDb.consumeUnusedJwt({ id: jwt.id, userId: USER });
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "new",
notifyHourUtc: 6,
notifyMinuteUtc: 0,
jwts: batchJwts("2026-09-06"),
});
const pending = await smsAlertAuthorizationDb.listPendingForDay({
day: "2026-09-06",
hourMinute: "07:00",
});
// 07:00 is past the new batch's 06:00 but short of the old batch's 23:00.
assert.deepEqual(pending, [{ userId: USER, due: true }]);
});
it("breaks a created_at tie by insertion order, not by random id", async () => {
const connection = getDatabase();
const sameInstant = "2026-09-05T00:00:00.000Z";
const insert = connection.prepare(
`INSERT INTO sms_alert_authorization_batches
(id, user_id, batch_id, notify_hour_min_utc, timezone, created_at)
VALUES (?, ?, ?, ?, NULL, ?)`
);
// Ids chosen so lexical order disagrees with insertion order both ways.
insert.run("zzz-first", USER, "older", "23:00", sameInstant);
insert.run("aaa-second", USER, "newer", "06:00", sameInstant);
connection
.prepare(
`INSERT INTO sms_alert_authorization_jwts
(id, batch_pk, user_id, batch_id, sequence, day, jwt, nbf, exp,
status, consumed_at, created_at)
VALUES ('j1', 'aaa-second', ?, 'newer', 1, '2026-09-05', 'j', 1, 2,
'unused', NULL, ?)`
)
.run(USER, sameInstant);
assert.equal(
(await smsAlertAuthorizationDb.getLatestBatch(USER))?.batchId,
"newer"
);
// 07:00 is past the newer batch's 06:00 but short of the older one's 23:00.
assert.deepEqual(
await smsAlertAuthorizationDb.listPendingForDay({
day: "2026-09-05",
hourMinute: "07:00",
}),
[{ userId: USER, due: true }]
);
});
it("treats a batch with no stored hour as always due", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-1",
jwts: batchJwts("2026-09-05"),
});
assert.deepEqual(
await smsAlertAuthorizationDb.listPendingForDay({
day: "2026-09-05",
hourMinute: "00:00",
}),
[{ userId: USER, due: true }]
);
});
it("stays due when the stored hour is unreadable, rather than never", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-1",
jwts: batchJwts("2026-09-05"),
});
// Only a hand-edited database reaches this state, but text ordering would
// rank "midnight" above every HH:MM and defer the user permanently.
getDatabase()
.prepare(
`UPDATE sms_alert_authorization_batches SET notify_hour_min_utc = ?
WHERE user_id = ?`
)
.run("midnight", USER);
assert.deepEqual(
await smsAlertAuthorizationDb.listPendingForDay({
day: "2026-09-05",
hourMinute: "00:00",
}),
[{ userId: USER, due: true }]
);
});
it("reads the SMS tables only, never the FCM ones", async () => {
await alertAuthorizationDb.replaceUnusedBatch({
userId: OTHER,
batchId: "fcm-batch",
jwts: batchJwts("2026-09-05"),
});
assert.deepEqual(
await smsAlertAuthorizationDb.listPendingForDay({
day: "2026-09-05",
hourMinute: "12:00",
}),
[]
);
assert.deepEqual(
await alertAuthorizationDb.listPendingForDay({
day: "2026-09-05",
hourMinute: "12:00",
}),
[{ userId: OTHER, due: true }]
);
});
it("deletes every batch and JWT for one user, consumed included", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-1",
jwts: [
...batchJwts("2026-09-05"),
{ sequence: 2, day: "2026-09-06", jwt: "jwt-b", nbf: 1, exp: 2 },
],
});
const first = await smsAlertAuthorizationDb.getUnusedForDay(
USER,
"2026-09-05"
);
assert.ok(first);
await smsAlertAuthorizationDb.consumeUnusedJwt({
id: first.id,
userId: USER,
});
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: OTHER,
batchId: "sms-batch-other",
jwts: batchJwts("2026-09-05"),
});
const removed = await smsAlertAuthorizationDb.deleteAllForUser(USER);
assert.equal(removed.deletedJwts, 2);
assert.equal(removed.deletedBatches, 1);
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 0);
assert.equal(await smsAlertAuthorizationDb.getJwtById(first.id), undefined);
assert.equal(
await smsAlertAuthorizationDb.getLatestBatch(USER),
undefined
);
assert.deepEqual(await smsAlertAuthorizationDb.listDistinctUserIds(), [
OTHER,
]);
});
it("deletes nothing, and does not fail, for an unknown user", async () => {
assert.deepEqual(
await smsAlertAuthorizationDb.deleteAllForUser("did:ethr:0xnobody"),
{ deletedBatches: 0, deletedJwts: 0 }
);
});
it("leaves the FCM inventory alone when the SMS one is deleted", async () => {
await alertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "fcm-batch-1",
jwts: batchJwts("2026-09-05"),
});
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-1",
jwts: batchJwts("2026-09-05"),
});
await smsAlertAuthorizationDb.deleteAllForUser(USER);
assert.equal(await alertAuthorizationDb.countUnused(USER), 1);
assert.deepEqual(await alertAuthorizationDb.listDistinctUserIds(), [USER]);
});
it("allows only one unused SMS JWT per (user, day)", () => { it("allows only one unused SMS JWT per (user, day)", () => {
const connection = getDatabase(); const connection = getDatabase();
const now = new Date().toISOString(); const now = new Date().toISOString();
+396 -11
View File
@@ -10,7 +10,7 @@ import { smsPhoneLogDb } from "../../src/db/smsPhoneLogSqlite.js";
import { smsBlockedNumbersDb } from "../../src/db/smsBlockedNumbersSqlite.js"; import { smsBlockedNumbersDb } from "../../src/db/smsBlockedNumbersSqlite.js";
import { smsRegistrationsDb } from "../../src/db/smsRegistrationsSqlite.js"; import { smsRegistrationsDb } from "../../src/db/smsRegistrationsSqlite.js";
import { closeDatabase } from "../../src/db/sqlite.js"; import { closeDatabase } from "../../src/db/sqlite.js";
import { calendarDayInTimeZone } from "../../src/services/alertAuthorization.js"; import { utcCalendarDay } from "../../src/services/alertAuthorization.js";
import type { SmsSendResult } from "../../src/services/smsService.js"; import type { SmsSendResult } from "../../src/services/smsService.js";
import { twilioSignatureFor } from "../../src/services/twilioSignature.js"; import { twilioSignatureFor } from "../../src/services/twilioSignature.js";
import { hashPhoneNumber } from "../../src/util/smsVerificationCode.js"; import { hashPhoneNumber } from "../../src/util/smsVerificationCode.js";
@@ -19,7 +19,11 @@ import { createNotifySmsRouter } from "../../src/routes/notifySms.js";
const SECRET = "route-test-secret"; const SECRET = "route-test-secret";
const USER = "did:ethr:0xrouteuser"; const USER = "did:ethr:0xrouteuser";
const PHONE = "+15555550123"; const PHONE = "+15555550123";
const TZ = "America/Denver"; const DAY_SECONDS = 86400;
/** The hour every test batch asks for, and the UTC time it reduces to. */
const NOTIFY_HOUR = 12;
const NOTIFY_MINUTE = 0;
const NOTIFY_LABEL = "12:00";
const ENV_KEYS = [ const ENV_KEYS = [
"SMS_ENABLED", "SMS_ENABLED",
@@ -660,21 +664,28 @@ describe("POST /notify-sms/alert-authorization", () => {
return `${encode({ alg: "ES256K", typ: "JWT" })}.${encode(payload)}.sig`; return `${encode({ alg: "ES256K", typ: "JWT" })}.${encode(payload)}.sig`;
} }
/** 100 consecutive UTC days, each JWT valid for the whole of its own day. */
function batch(userId = USER): Record<string, unknown> { function batch(userId = USER): Record<string, unknown> {
const jwts = []; const jwts = [];
const base = Math.floor(Date.now() / 1000); const firstDay =
Math.floor(Date.now() / 1000 / DAY_SECONDS) * DAY_SECONDS;
for (let i = 0; i < 100; i += 1) { for (let i = 0; i < 100; i += 1) {
const nbf = base + i * 86400; const nbf = firstDay + i * DAY_SECONDS;
const exp = nbf + 86400; const exp = nbf + DAY_SECONDS;
jwts.push({ jwts.push({
sequence: i + 1, sequence: i + 1,
day: calendarDayInTimeZone(nbf, TZ), day: utcCalendarDay(nbf),
jwt: delegatedJwt({ iss: userId, nbf, exp }), jwt: delegatedJwt({ iss: userId, nbf, exp }),
nbf, nbf,
exp, exp,
}); });
} }
return { batchId: "sms-batch-1", timezone: TZ, jwts }; return {
batchId: "sms-batch-1",
notifyHourUtc: NOTIFY_HOUR,
notifyMinuteUtc: NOTIFY_MINUTE,
jwts,
};
} }
const authorizeClaim = { action: "authorize-alert-search" }; const authorizeClaim = { action: "authorize-alert-search" };
@@ -699,14 +710,16 @@ describe("POST /notify-sms/alert-authorization", () => {
}); });
assert.equal(result.status, 200, JSON.stringify(result.body)); assert.equal(result.status, 200, JSON.stringify(result.body));
assert.equal(result.body.storedCount, 100); assert.equal(result.body.storedCount, 100);
assert.equal(result.body.timezone, TZ);
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 100); assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 100);
const log = await smsPhoneLogDb.listByUserId(USER); const log = await smsPhoneLogDb.listByUserId(USER);
const stored = log.find( const stored = log.find(
(row) => row.action === "alert-authorization-stored" (row) => row.action === "alert-authorization-stored"
); );
assert.equal(stored?.detail, "batchId=sms-batch-1"); assert.equal(
stored?.detail,
`batchId=sms-batch-1 notifyUtc=${NOTIFY_LABEL}`
);
}); });
it("accepts PUT as an alias for the same handler", async () => { it("accepts PUT as an alias for the same handler", async () => {
@@ -728,7 +741,7 @@ describe("POST /notify-sms/alert-authorization", () => {
body: batch(), body: batch(),
auth: { claim: authorizeClaim }, auth: { claim: authorizeClaim },
}); });
const today = calendarDayInTimeZone(Math.floor(Date.now() / 1000), TZ); const today = utcCalendarDay(Math.floor(Date.now() / 1000));
const first = await smsAlertAuthorizationDb.getUnusedForDay(USER, today); const first = await smsAlertAuthorizationDb.getUnusedForDay(USER, today);
assert.ok(first); assert.ok(first);
await smsAlertAuthorizationDb.consumeUnusedJwt({ await smsAlertAuthorizationDb.consumeUnusedJwt({
@@ -749,11 +762,237 @@ describe("POST /notify-sms/alert-authorization", () => {
); );
}); });
it("stores the hour and minute and echoes both back", async () => {
await registerAndVerify();
const result = await call({
path: "/notify-sms/alert-authorization",
body: { ...batch(), notifyHourUtc: 0, notifyMinuteUtc: 30 },
auth: { claim: authorizeClaim },
});
assert.equal(result.status, 200, JSON.stringify(result.body));
assert.equal(result.body.notifyHourUtc, 0);
assert.equal(result.body.notifyMinuteUtc, 30);
const stored = await smsAlertAuthorizationDb.getLatestBatch(USER);
assert.equal(stored?.notifyHourUtc, 0);
assert.equal(stored?.notifyMinuteUtc, 30);
const log = await smsPhoneLogDb.listByUserId(USER);
const row = log.find((entry) => entry.action === "alert-authorization-stored");
// Zero-padded in the log even though the request carried bare integers.
assert.equal(row?.detail, "batchId=sms-batch-1 notifyUtc=00:30");
});
it("keeps midnight distinct from absent, since 0 is a real hour", async () => {
await registerAndVerify();
const result = await call({
path: "/notify-sms/alert-authorization",
body: { ...batch(), notifyHourUtc: 0, notifyMinuteUtc: 0 },
auth: { claim: authorizeClaim },
});
assert.equal(result.status, 200, JSON.stringify(result.body));
assert.equal(result.body.notifyHourUtc, 0);
assert.equal(result.body.notifyMinuteUtc, 0);
const stored = await smsAlertAuthorizationDb.getLatestBatch(USER);
assert.equal(stored?.notifyHourUtc, 0);
assert.equal(stored?.notifyMinuteUtc, 0);
});
it("stores an optional IANA timezone alongside the hour", async () => {
await registerAndVerify();
const result = await call({
path: "/notify-sms/alert-authorization",
body: { ...batch(), timezone: "America/Denver" },
auth: { claim: authorizeClaim },
});
assert.equal(result.status, 200, JSON.stringify(result.body));
assert.equal(result.body.timezone, "America/Denver");
const stored = await smsAlertAuthorizationDb.getLatestBatch(USER);
assert.equal(stored?.timezone, "America/Denver");
// Recorded only: the stored hour is still what a send would consult.
assert.equal(stored?.notifyHourUtc, NOTIFY_HOUR);
});
it("keeps the timezone optional even though the hour is not", 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.notifyHourUtc, NOTIFY_HOUR);
assert.equal(result.body.timezone, null);
});
it("treats an omitted or null timezone as none", async () => {
await registerAndVerify();
for (const body of [batch(), { ...batch(), timezone: null }]) {
const result = await call({
path: "/notify-sms/alert-authorization",
body,
auth: { claim: authorizeClaim },
});
assert.equal(result.status, 200, JSON.stringify(result.body));
assert.equal(result.body.timezone, null);
assert.equal(
(await smsAlertAuthorizationDb.getLatestBatch(USER))?.timezone,
undefined
);
}
});
it("rejects a timezone Intl cannot resolve", async () => {
await registerAndVerify();
for (const timezone of ["Mars/Olympus", "Denver", "", " ", -7]) {
const result = await call({
path: "/notify-sms/alert-authorization",
body: { ...batch(), timezone },
auth: { claim: authorizeClaim },
});
assert.equal(result.status, 400, JSON.stringify(timezone));
assert.ok(
(result.body.details as string[]).some((line) =>
line.startsWith("timezone must be")
),
JSON.stringify(result.body.details)
);
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 0);
}
});
it("requires both halves, and rejects one arriving without the other", async () => {
await registerAndVerify();
const jwts = (batch() as { jwts: unknown }).jwts;
const cases: [string, Record<string, unknown>, string[]][] = [
["neither", { batchId: "b", jwts }, ["notifyHourUtc", "notifyMinuteUtc"]],
[
"hour only",
{ batchId: "b", notifyHourUtc: 18, jwts },
["notifyMinuteUtc"],
],
[
"minute only",
{ batchId: "b", notifyMinuteUtc: 30, jwts },
["notifyHourUtc"],
],
[
"nulls",
{ batchId: "b", notifyHourUtc: null, notifyMinuteUtc: null, jwts },
["notifyHourUtc", "notifyMinuteUtc"],
],
];
for (const [label, body, missing] of cases) {
const result = await call({
path: "/notify-sms/alert-authorization",
body,
auth: { claim: authorizeClaim },
});
assert.equal(result.status, 400, label);
const details = result.body.details as string[];
for (const field of missing) {
assert.ok(
details.some((line) => line.startsWith(`${field} is required`)),
`${label}: expected ${field}, got ${JSON.stringify(details)}`
);
}
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 0);
}
});
it("rejects an hour or minute outside its range, or not an integer", async () => {
await registerAndVerify();
const cases: [string, unknown, unknown][] = [
["hour 24", 24, 0],
["hour -1", -1, 0],
["minute 60", 12, 60],
["minute -1", 12, -1],
["fractional hour", 12.5, 0],
["string hour", "12", 0],
["string minute", 12, "30"],
["boolean hour", true, 0],
["object minute", 12, {}],
];
// NaN is absent from this list on purpose: JSON.stringify turns it into
// null, so it reaches the route as a missing field, not a malformed one.
for (const [label, notifyHourUtc, notifyMinuteUtc] of cases) {
const result = await call({
path: "/notify-sms/alert-authorization",
body: { ...batch(), notifyHourUtc, notifyMinuteUtc },
auth: { claim: authorizeClaim },
});
assert.equal(result.status, 400, label);
assert.ok(
(result.body.details as string[]).some((line) =>
/^notify(Hour|Minute)Utc must be an integer/.test(line)
),
`${label}: ${JSON.stringify(result.body.details)}`
);
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 0);
}
});
it("accepts the ends of both ranges", async () => {
await registerAndVerify();
const result = await call({
path: "/notify-sms/alert-authorization",
body: { ...batch(), notifyHourUtc: 23, notifyMinuteUtc: 59 },
auth: { claim: authorizeClaim },
});
assert.equal(result.status, 200, JSON.stringify(result.body));
assert.equal(result.body.notifyHourUtc, 23);
assert.equal(result.body.notifyMinuteUtc, 59);
});
it("rejects a JWT whose window does not cover its whole UTC day", async () => {
await registerAndVerify();
const body = batch() as { jwts: { nbf: number; exp: number }[] };
// An hour short at each end: usable at noon, useless at midnight.
body.jwts[0].nbf += 3600;
body.jwts[0].exp -= 3600;
const result = await call({
path: "/notify-sms/alert-authorization",
body,
auth: { claim: authorizeClaim },
});
assert.equal(result.status, 400, JSON.stringify(result.body));
assert.ok(
(result.body.details as string[]).some((line) =>
line.includes("must be valid for all of UTC day")
),
JSON.stringify(result.body.details)
);
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 0);
});
it("rejects a day that is not a real date", async () => {
await registerAndVerify();
const body = batch() as { jwts: { day: string }[] };
body.jwts[0].day = "2026-02-30";
const result = await call({
path: "/notify-sms/alert-authorization",
body,
auth: { claim: authorizeClaim },
});
assert.equal(result.status, 400, JSON.stringify(result.body));
assert.ok(
(result.body.details as string[]).some((line) =>
line.includes("must be a real UTC date")
),
JSON.stringify(result.body.details)
);
});
it("rejects a malformed batch after the phone gate passes", async () => { it("rejects a malformed batch after the phone gate passes", async () => {
await registerAndVerify(); await registerAndVerify();
const result = await call({ const result = await call({
path: "/notify-sms/alert-authorization", path: "/notify-sms/alert-authorization",
body: { batchId: "b", timezone: TZ, jwts: [] }, body: { batchId: "b", jwts: [] },
auth: { claim: authorizeClaim }, auth: { claim: authorizeClaim },
}); });
assert.equal(result.status, 400); assert.equal(result.status, 400);
@@ -761,6 +1000,152 @@ describe("POST /notify-sms/alert-authorization", () => {
}); });
}); });
describe("DELETE /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`;
}
/** 100 consecutive UTC days, each JWT valid for the whole of its own day. */
function batch(userId = USER): Record<string, unknown> {
const jwts = [];
const firstDay =
Math.floor(Date.now() / 1000 / DAY_SECONDS) * DAY_SECONDS;
for (let i = 0; i < 100; i += 1) {
const nbf = firstDay + i * DAY_SECONDS;
const exp = nbf + DAY_SECONDS;
jwts.push({
sequence: i + 1,
day: utcCalendarDay(nbf),
jwt: delegatedJwt({ iss: userId, nbf, exp }),
nbf,
exp,
});
}
return {
batchId: "sms-batch-1",
notifyHourUtc: NOTIFY_HOUR,
notifyMinuteUtc: NOTIFY_MINUTE,
jwts,
};
}
const revokeClaim = { action: "revoke-alert-search" };
async function authorize(did = USER): Promise<void> {
const stored = await call({
path: "/notify-sms/alert-authorization",
body: batch(did),
auth: { did, claim: { action: "authorize-alert-search" } },
});
assert.equal(stored.status, 200, JSON.stringify(stored.body));
}
it("removes every batch and JWT, and stops the scheduler listing the DID", async () => {
await registerAndVerify();
await authorize();
const result = await call({
method: "DELETE",
path: "/notify-sms/alert-authorization",
auth: { claim: revokeClaim },
});
assert.equal(result.status, 200, JSON.stringify(result.body));
assert.equal(result.body.deletedBatches, 1);
assert.equal(result.body.deletedJwts, 100);
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 0);
assert.deepEqual(await smsAlertAuthorizationDb.listDistinctUserIds(), []);
});
it("keeps the verified phone, so re-authorizing needs no new code", async () => {
await registerAndVerify();
await authorize();
await call({
method: "DELETE",
path: "/notify-sms/alert-authorization",
auth: { claim: revokeClaim },
});
assert.equal((await smsRegistrationsDb.get(USER, PHONE))?.verified, true);
await authorize();
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 100);
});
it("records the revocation in the phone log", async () => {
await registerAndVerify();
await authorize();
await call({
method: "DELETE",
path: "/notify-sms/alert-authorization",
auth: { claim: revokeClaim },
});
const log = await smsPhoneLogDb.listByUserId(USER);
const row = log.find((entry) => entry.action === "alert-authorization-deleted");
assert.equal(row?.result, "ok");
assert.equal(row?.detail, "batches=1 jwts=100");
});
it("touches only the calling DID's inventory", async () => {
await registerAndVerify("did:ethr:0xa");
await registerAndVerify("did:ethr:0xb", "+15555550124");
await authorize("did:ethr:0xa");
await authorize("did:ethr:0xb");
await call({
method: "DELETE",
path: "/notify-sms/alert-authorization",
auth: { did: "did:ethr:0xa", claim: revokeClaim },
});
assert.equal(await smsAlertAuthorizationDb.countUnused("did:ethr:0xa"), 0);
assert.equal(await smsAlertAuthorizationDb.countUnused("did:ethr:0xb"), 100);
});
it("succeeds with zero counts when there was nothing to remove", async () => {
await registerAndVerify();
const result = await call({
method: "DELETE",
path: "/notify-sms/alert-authorization",
auth: { claim: revokeClaim },
});
assert.equal(result.status, 200);
assert.equal(result.body.deletedBatches, 0);
assert.equal(result.body.deletedJwts, 0);
});
it("works for a DID that has already deleted its phone", async () => {
await registerAndVerify();
await authorize();
await call({
method: "DELETE",
body: { phoneNumber: PHONE },
auth: { claim: { action: "delete-phone", phoneNumber: PHONE } },
});
const result = await call({
method: "DELETE",
path: "/notify-sms/alert-authorization",
auth: { claim: revokeClaim },
});
assert.equal(result.status, 200, JSON.stringify(result.body));
assert.equal(result.body.deletedJwts, 100);
});
it("refuses a claim that authorizes a different action", async () => {
await registerAndVerify();
await authorize();
const result = await call({
method: "DELETE",
path: "/notify-sms/alert-authorization",
auth: { claim: { action: "authorize-alert-search" } },
});
assert.equal(result.status, 403);
assert.equal(result.body.error, "SMS_ACTION_JWT_WRONG_ACTION");
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 100);
});
});
describe("POST /notify-sms/inbound", () => { describe("POST /notify-sms/inbound", () => {
const TOKEN = "twilio-auth-token"; const TOKEN = "twilio-auth-token";
+116
View File
@@ -0,0 +1,116 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
formatHourMinuteUtc,
parseHourMinuteUtc,
storedNotifyLabel,
utcCalendarDay,
utcDayStartSeconds,
utcHourMinute,
} from "../../src/services/alertAuthorization.js";
describe("formatHourMinuteUtc", () => {
it("zero-pads both halves", () => {
assert.equal(formatHourMinuteUtc(0, 0), "00:00");
assert.equal(formatHourMinuteUtc(9, 5), "09:05");
assert.equal(formatHourMinuteUtc(18, 30), "18:30");
assert.equal(formatHourMinuteUtc(23, 59), "23:59");
});
it("pads so text ordering matches clock ordering", () => {
// The whole reason for the padding: "9:30" would sort above "10:00".
assert.ok(formatHourMinuteUtc(9, 30) < formatHourMinuteUtc(10, 0));
assert.ok(formatHourMinuteUtc(0, 0) < formatHourMinuteUtc(23, 59));
});
});
describe("parseHourMinuteUtc", () => {
it("round-trips whatever format produced", () => {
for (const [hour, minute] of [
[0, 0],
[9, 5],
[18, 30],
[23, 59],
]) {
assert.deepEqual(parseHourMinuteUtc(formatHourMinuteUtc(hour, minute)), {
hour,
minute,
});
}
});
it("refuses anything that is not a padded HH:MM", () => {
for (const bad of [
"24:00",
"07:60",
"7:00",
"07:00:00",
"0700",
"18:30-06:00",
"midnight",
"",
]) {
assert.equal(parseHourMinuteUtc(bad), undefined, bad);
}
});
});
describe("storedNotifyLabel", () => {
it("renders the pair for a log line", () => {
assert.equal(
storedNotifyLabel({ notifyHourUtc: 18, notifyMinuteUtc: 30 }),
"18:30"
);
assert.equal(
storedNotifyLabel({ notifyHourUtc: 0, notifyMinuteUtc: 5 }),
"00:05"
);
});
it("says none when either half is missing", () => {
assert.equal(storedNotifyLabel({}), "none");
assert.equal(storedNotifyLabel({ notifyHourUtc: 18 }), "none");
assert.equal(storedNotifyLabel({ notifyMinuteUtc: 30 }), "none");
});
});
describe("utcCalendarDay and utcHourMinute", () => {
const at = (iso: string) => Math.floor(Date.parse(iso) / 1000);
it("reads the UTC calendar day, not the server's", () => {
assert.equal(utcCalendarDay(at("2026-08-15T06:00:00Z")), "2026-08-15");
assert.equal(utcCalendarDay(at("2026-08-14T23:59:59Z")), "2026-08-14");
assert.equal(utcCalendarDay(at("2026-08-15T00:00:00Z")), "2026-08-15");
});
it("renders the UTC clock in the form the column stores", () => {
assert.equal(utcHourMinute(at("2026-08-15T00:00:00Z")), "00:00");
assert.equal(utcHourMinute(at("2026-08-15T18:30:59Z")), "18:30");
assert.equal(utcHourMinute(at("2026-08-15T23:59:00Z")), "23:59");
});
it("agrees with formatHourMinuteUtc, since both feed one comparison", () => {
const seconds = at("2026-08-15T09:05:00Z");
assert.equal(utcHourMinute(seconds), formatHourMinuteUtc(9, 5));
});
});
describe("utcDayStartSeconds", () => {
it("returns midnight UTC for a real date", () => {
assert.equal(
utcDayStartSeconds("2026-08-15"),
Math.floor(Date.parse("2026-08-15T00:00:00Z") / 1000)
);
});
it("refuses a date that does not exist rather than rolling it forward", () => {
for (const bad of ["2026-02-30", "2026-13-01", "2026-08-32", "2026-8-15", "x"]) {
assert.equal(utcDayStartSeconds(bad), undefined, bad);
}
});
it("keeps a real leap day", () => {
assert.ok(utcDayStartSeconds("2028-02-29") !== undefined);
assert.equal(utcDayStartSeconds("2027-02-29"), undefined);
});
});
+101
View File
@@ -0,0 +1,101 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
ALERT_SEARCH_USER_CONCURRENCY,
forEachWithConcurrency,
} from "../../src/util/concurrency.js";
/** Resolves after `ms`, long enough for the pool to fill before any task ends. */
const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
describe("forEachWithConcurrency", () => {
it("visits every item exactly once", async () => {
const items = Array.from({ length: 50 }, (_, i) => i);
const seen: number[] = [];
await forEachWithConcurrency(items, 7, async (item) => {
seen.push(item);
});
assert.deepEqual([...seen].sort((a, b) => a - b), items);
});
it("never exceeds the limit", async () => {
let inFlight = 0;
let peak = 0;
await forEachWithConcurrency(
Array.from({ length: 30 }, (_, i) => i),
4,
async () => {
inFlight += 1;
peak = Math.max(peak, inFlight);
await pause(3);
inFlight -= 1;
}
);
assert.equal(peak, 4);
});
it("reaches the limit rather than trickling one at a time", async () => {
let peak = 0;
let inFlight = 0;
await forEachWithConcurrency([1, 2, 3, 4, 5, 6], 6, async () => {
inFlight += 1;
peak = Math.max(peak, inFlight);
await pause(3);
inFlight -= 1;
});
assert.equal(peak, 6);
});
it("runs serially at a limit of one, in order", async () => {
const order: number[] = [];
await forEachWithConcurrency([1, 2, 3], 1, async (item) => {
await pause(1);
order.push(item);
});
assert.deepEqual(order, [1, 2, 3]);
});
it("never starts more workers than there are items", async () => {
let peak = 0;
let inFlight = 0;
await forEachWithConcurrency([1, 2], 100, async () => {
inFlight += 1;
peak = Math.max(peak, inFlight);
await pause(3);
inFlight -= 1;
});
assert.equal(peak, 2);
});
it("handles an empty list and a nonsense limit", async () => {
let calls = 0;
await forEachWithConcurrency([], 8, async () => {
calls += 1;
});
assert.equal(calls, 0);
const seen: number[] = [];
for (const limit of [0, -5, 0.5]) {
seen.length = 0;
await forEachWithConcurrency([1, 2, 3], limit, async (item) => {
seen.push(item);
});
assert.deepEqual(seen, [1, 2, 3], `limit ${limit}`);
}
});
it("propagates a throw, since callers own their own errors", async () => {
await assert.rejects(
() =>
forEachWithConcurrency([1, 2, 3], 2, async (item) => {
if (item === 2) throw new Error("boom");
}),
/boom/
);
});
it("ships a conservative default", () => {
assert.ok(ALERT_SEARCH_USER_CONCURRENCY >= 1);
assert.ok(ALERT_SEARCH_USER_CONCURRENCY <= 32);
});
});