diff --git a/CHANGELOG.md b/CHANGELOG.md index 51027e6..2e0e17b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.2.0] - 2026.09.05 ### Added +- `notifyHourUtc` (0-23) and `notifyMinuteUtc` (0-59) are **required** on an alert-authorization batch, both integers and both UTC — the field names carry the frame, so no offset or zone travels with them. Stored zero-padded as one `HH:MM` value in `notify_hour_min_utc`. Required rather than optional because the alternative default is not "no gate" but "the first tick after midnight UTC", which puts every user in one five-minute window. The scheduler holds a user's whole daily run, search included, until that instant, and reports the held users as `deferred`. A stored UTC time does not follow the user through a daylight-saving change; a fresh batch carrying the new offset corrects it +- Both alertSearch schedulers pick their users with one query per pass (`listPendingForDay`) instead of one per user: it returns everyone holding an unused JWT for the current UTC day, flagged by whether their `notify_hour_min_utc` has arrived. A user who has already run that day no longer appears, so an idle tick over 2000 users costs 0 queries and ~1ms, down from 3 queries per user and ~141ms +- Both passes work on `ALERT_SEARCH_USER_CONCURRENCY` users at a time (`src/util/concurrency.ts`, default 8) instead of serially; at 50ms of API latency a 2000-user pass drops from ~106s to ~13s, which is what keeps it inside its own tick +- The notify hour and `timezone` are accepted on `PUT /notifications/alert-authorization` as well, and the push scheduler honours the stored hour +- Optional `timezone` (IANA name) on an alert-authorization batch, validated and stored beside `notify_hour_min_utc` and read by nothing. It is recorded for a future mechanism that would re-derive the UTC time across a DST change; the column carries that reason as a SQL comment +- `DELETE /notifications/alert-authorization` and `DELETE /notify-sms/alert-authorization` remove every batch and JWT a DID holds in that channel, so a user can turn alerts off; verified phone numbers and alertSearch cursors survive, and the SMS revocation is logged as `alert-authorization-deleted` under the `revoke-alert-search` action claim - `/notify-sms` SMS channel: phone registration with a 6-digit possession check, a per-channel delegated-JWT inventory and cursor, a Twilio sender, an `STOP`/`START`/`HELP` webhook, and a daily digest text alongside the FCM one; off unless `SMS_ENABLED` - Blocks record their origin (`opt-out` / `provider-opt-out` / `manual`) and are never downgraded; a Twilio `21610` refusal auto-blocks as `provider-opt-out`, and `pnpm run sms:blocks` lists and manages the list by origin - `sms_blocked_numbers` suppression list: `STOP` now blocks durably instead of only unverifying, `START` lifts the block, and a blocked number is refused at registration and verification and skipped by the digest; `SMS_BLOCKED_NUMBERS` blocks by configuration @@ -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 twilio:whoami` reports which Twilio account a send would bill and distinguishes a mismatched credential pair from a valid test pair ### Changed +- Alert-authorization batches no longer schedule from a `timezone`. Days are UTC days: each delegated JWT must be valid for the whole UTC day it names (`nbf` at or before its midnight, `exp` at or after the next), the daily run selects by UTC day and reports it as `utcDay`, and `notifyTime` supplies its own offset. Removes `InvalidAlertAuthorizationTimezoneError` - `loadAlertSearchCursors`, `advanceAlertSearchCursors`, `runAlertSearchCycle`, and `runDailyAlertSearch` take a channel (`"fcm"` default), selecting the JWT inventory and cursor table diff --git a/CHANGES/PLAN-sms-notifications.md b/CHANGES/PLAN-sms-notifications.md index 8d87e15..41481b0 100644 --- a/CHANGES/PLAN-sms-notifications.md +++ b/CHANGES/PLAN-sms-notifications.md @@ -1,6 +1,6 @@ # 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 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 a phone number, proves possession of it with a 6-digit code, authorizes a batch of delegated alertSearch JWTs for the SMS channel, and receives at most one text -per local day when that day's retrieval finds updates. +per local day when that day's retrieval finds updates — at an hour of their +choosing, and only until they revoke the authorization. Every phone action is authorized by a JWT that names the action and the phone number it applies to, and every phone action is recorded. ## Scope -In scope: 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 -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 -conversational SMS beyond opt-out keywords, and international sender -registration beyond US A2P 10DLC. +Out of scope: changing how the FCM channel searches or delivers, changing +`WAKEUP_PING`, MMS, inbound conversational SMS beyond opt-out keywords, and +international sender registration beyond US A2P 10DLC. The notification hour is +an SMS-channel setting; the FCM scheduler does not read it. ## Endpoints @@ -36,6 +41,7 @@ new `src/routes/notifySms.ts`. | PUT | `/notify-sms/phone` | Match the code and flip `verified` to true | | DELETE | `/notify-sms/phone` | Remove the phone entirely | | POST | `/notify-sms/alert-authorization` | Store a delegated JWT batch for the SMS channel | +| DELETE | `/notify-sms/alert-authorization` | Remove every SMS batch and JWT for the DID, turning the channel off | `src/index.ts` CORS lists `["GET", "POST", "PUT", "OPTIONS"]`. **`DELETE` must be added to that list**, or the browser preflight for the delete route fails before @@ -130,12 +136,26 @@ Body `{ "phoneNumber": "+15555550123" }`, and also accepted as ### 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 - verifies each delegated JWT's signature, matches `iss` to the authenticated - DID, checks `nbf`/`exp` against the claimed day in the batch timezone, and - requires 100 consecutive sequences. +- Validation reuses `validateAlertAuthorizationBatch`. It verifies each + delegated JWT's signature, matches `iss` to the authenticated DID, requires + 100 consecutive sequences and distinct days, and requires each JWT to be valid + for the whole UTC day it names — `nbf` at or before that day's midnight UTC, + `exp` at or after the next. The daily run selects by UTC day and may fire at + any moment inside it, so a partial window would hand Endorser a credential + outside its own validity period. +- `notifyHourUtc` (0-23) and `notifyMinuteUtc` (0-59) are both required, both + integers, and both UTC — the field names carry the frame, so nothing else has + to. Absent, out of range, or the wrong type is rejected with the rest of the + batch, and one arriving without the other names the missing field. Every batch + therefore states its own hour, and no client silently inherits the first tick + after midnight UTC — the setting that puts every user on one tick. + §Notification hour covers what the value does. +- `timezone` is optional: an IANA name, validated against `Intl` and stored. + Nothing reads it. It may be omitted from a batch that states its hour, but not + the reverse. §Notification hour says what it is being kept for. - Requires at least one verified phone for the DID. Without one: `409` `SMS_NO_VERIFIED_PHONE`. Storing 100 credentials for a channel with no reachable address is inventory nobody asked for. @@ -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 user's unused SMS JWTs, drop orphaned SMS batch rows, insert the new batch, in one transaction. -- Response mirrors the FCM one: `{ success, batchId, timezone, storedCount, unusedCount }`. +- Response mirrors the FCM one plus the stored hour and zone: + `{ success, batchId, notifyHourUtc, notifyMinuteUtc, timezone, storedCount, + unusedCount }`. - The route registers on `POST` and on `PUT` with the same handler. The FCM twin is `PUT`, the semantics are replace-not-append, and an app that reaches for `PUT` out of symmetry should not get a 404 for its trouble. +### DELETE /notify-sms/alert-authorization + +No body. The action claim is `revoke-alert-search`, which binds to no phone +number. + +- Removes every SMS batch and every SMS JWT for the DID, consumed rows included, + in one transaction, so the SMS scheduler stops listing that identity at all. +- Response `200` `{ success: true, deletedBatches, deletedJwts }`. Nothing + stored is zeros, not a `404`: the caller asked for a state, and that state is + what they get. +- Registered phone numbers survive. Silencing alerts is not a request to redo + the possession check on return; `DELETE /notify-sms/phone` is the route that + forgets a number, and `STOP` is the route that blocks one. +- The alertSearch cursors survive too, so a later re-authorization resumes where + this one stopped rather than replaying months of history. +- Logged as `alert-authorization-deleted`. For a DID whose handset is already + gone there is no number to log against, so `phone_hash` holds a hash of the + identity instead — the column is `NOT NULL` and the revocation is worth a row. + +`DELETE /notifications/alert-authorization` is the same operation on the FCM +inventory, authorized the way its `PUT` twin is: Bearer JWT plus the Endorser +check, no action claim. Device registrations and `WAKEUP_PING` are untouched. + ## One phone, several DIDs Nothing keys off a phone number alone except carrier opt-out. Every other read @@ -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`, -`delete-phone`, `authorize-alert-search`. `phoneNumber` is required for -`register-phone`, `verify-phone` and `delete-phone`, and optional for -`list-phones` — when the request carries the query parameter, the claim must -carry the matching number +`delete-phone`, `authorize-alert-search`, `revoke-alert-search`. `phoneNumber` +is required for `register-phone`, `verify-phone` and `delete-phone`, and +optional for `list-phones` — when the request carries the query parameter, the +claim must carry the matching number and is the only binding the claim carries. The claim holds no DID — the authenticated identity is `payload.iss` — and no `batchId`. -A batch upload 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 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 @@ -418,8 +464,9 @@ keyword lookup arrives with the number, not the DID), and on Actions: `register-requested`, `code-sent`, `code-send-failed`, `verify-succeeded`, `verify-failed`, `did-limit-blocked`, -`did-limit-disclosed`, `deleted`, `alert-authorization-stored`, `alert-sent`, -`alert-send-failed`, `opt-out`. +`did-limit-disclosed`, `deleted`, `alert-authorization-stored`, +`alert-authorization-deleted`, `alert-sent`, `alert-send-failed`, +`recipient-not-allowed`, `number-blocked`, `number-unblocked`, `opt-out`. `did-limit-blocked` records a POST refused at the limit; `did-limit-disclosed` records a PUT refused at the limit, where the DID list left the building. @@ -438,7 +485,11 @@ protect. Column-for-column mirrors of `alert_authorization_batches` / `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` @@ -491,15 +542,90 @@ everywhere, so no existing call site or test changes. `startSmsAlertSearchScheduler()`, started from `src/index.ts` alongside the other two, with its own interval and its own process-local in-flight guard. -- Lists distinct `user_id` from `sms_alert_authorization_batches`. -- Calls `runDailyAlertSearch(userId, new Date(), {}, "sms")`, then - `deliverAlertSearchSms`. +- Selects users with one query per pass, `listPendingForDay({ day, hourMinute })`: + everyone holding an unused JWT for the current UTC day, each flagged by whether + their `notify_hour_min_utc` has arrived. A user who has already run that day holds + no unused JWT for it and does not appear (§Scheduler selection). +- Calls `runDailyAlertSearch(userId, now, {}, "sms")` on the due ones, at most + `ALERT_SEARCH_USER_CONCURRENCY` in flight, then `deliverAlertSearchSms`. - Interval `SMS_ALERT_SEARCH_INTERVAL_MS`, default 5 minutes, with a 150-second initial offset so the SMS pass and the FCM pass do not hit Endorser in the same instant. - Logs `[SmsAlertSearchScheduler] Pass started` / `Pass completed in …`, matching the existing prefix convention. +### Scheduler selection + +Both passes choose their users in SQL rather than by asking about each one. +`listPendingForDay` joins the newest batch per user — picked with a window +function, because a batch holding a consumed JWT survives the next upload, so +"the user's notify time" is not a plain join — against that day's unused JWTs, +and returns a `due` flag per row. Two properties fall out: a user who has +already run today is absent rather than queried and discarded, and `deferred` +stays countable because held users still appear. + +The comparison is text, which is chronological only because `HH:MM` is +zero-padded. A stored value that is not `HH:MM` would sort above every real one +and defer that user forever, so the query guards it with `GLOB` and treats an +unreadable value as due — the same fail-open the column's own documentation +promises. + +Due users are worked `ALERT_SEARCH_USER_CONCURRENCY` at a time. The per-user +work is two external round trips against independent cursors, so a serial loop +spends the pass waiting rather than working; measured at 50ms of latency, a +2000-user pass falls from ~106s to ~13s, which is the difference between fitting +in a five-minute tick and being skipped by the in-flight guard. The bound stays +low on purpose: the ceiling is latency, and Endorser and Partner are shared +infrastructure that a wide fan-out would only move the queue into. + +### Notification hour + +A batch's `notify_hour_min_utc` is the hour the user asked to hear from the +service, sent as two UTC integers and stored zero-padded as one `HH:MM` value. +The SMS scheduler holds that user's whole daily pass until the clock reaches it. + +A batch cannot omit the hour, so this gate applies to every user rather than to +the subset who expressed a preference. That is deliberate: the alternative +default is "the first tick after midnight UTC", which is one tick for everybody. + +The gate sits ahead of the search, not ahead of the text. Running the search +consumes that UTC day's JWT, and `isAlertSearchSmsEligible` fires only for the +run that consumed it, so a run at the top of the day would spend the credential +and reach the chosen hour with nothing left to send. + +The hour lives on the batch rather than inside the delegated JWTs because those +are the alertSearch credential: their `nbf`/`exp` bound a whole UTC day, and +narrowing them to an hour would narrow when the search may run against Endorser, +not when the user hears about it. + +The stored value is an instant of the UTC day, not a wall clock, so it does not +follow the user through a daylight-saving change: a Denver user wanting 18:00 +local sends `00:30` UTC in summer, and when that region returns to `-07:00` the +same UTC instant reads 17:00 locally. The correction available today is a fresh +batch carrying the new UTC hour, which a client uploads roughly every 100 days +anyway. + +The batch's optional `timezone` is recorded against a mechanism that would close +that gap sooner by re-deriving `notify_hour_min_utc` from the zone's rules. None +runs, and nothing reads the column. Two candidates are open, and §Rejected +records what is known about each: a job that sweeps zones whose rules changed, +or a stored next-firing instant that each send recomputes, which needs no +scheduled job. Measured cost is the reason neither is urgent — a formatter +cached per zone resolves a user's local wall clock in about 1.4µs, half the cost +of the per-user SQLite lookup the pass already performs. + +There is no upper bound within the day. A service that was down at the chosen +hour and comes back six hours later still runs that UTC day; a silent day is the +worse failure. The text lands on the first tick at or after the hour, so within +one `SMS_ALERT_SEARCH_INTERVAL_MS` in the ordinary case. An hour late in the UTC +day leaves a correspondingly short window before the day key rolls. + +Every batch that arrives through the route carries an hour, so the ungated path +is a floor rather than a setting: a row whose `notify_hour_min_utc` is NULL or +unreadable is treated as due, because a value nobody can read must not silence a +channel the user asked for. Only a write that bypasses the route can produce +one. + ### Cost of two inventories A user on both channels produces two Endorser and two Partner queries per day @@ -591,7 +717,19 @@ above the services layer, and digest logic never appears below it. 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/types/express.d.ts` — the widened `auth` type - `src/env.ts` — the SMS variables, read the same way `ENDORSER_URL` is @@ -673,6 +811,41 @@ Modified: `test-local` in the deployed environment. - [ ] Prune-job follow-up filed for `sms_phone_log` retention and `sms_action_jwt_use` rows. +- [x] **13. Notification hour and revocation.** `notify_hour_min_utc` on both batch + tables and through `validateAlertAuthorizationBatch`; `isNotifyTimeReached` + gating the SMS pass ahead of the search; `deleteAllForUser` on the shared + store behind `DELETE /notify-sms/alert-authorization` (action + `revoke-alert-search`) and `DELETE /notifications/alert-authorization`. + Tests: the hour/minute validation and its ranges; a pass + that defers before the hour, runs after it, and closes again when the UTC + day rolls; a batch with no hour running unchanged; a failing due-check + counted as failed rather than deferred; the round trip of a stored hour; + deletion of consumed and unused rows together while the other channel and + the other DIDs are untouched; the verified phone surviving a revocation; + and the wrong action claim refused. +- [x] **14. Drop the batch timezone.** `timezone` out of the request body, both + batch tables, the store record, and the response. `day` becomes a UTC + calendar day, each JWT must cover the whole of the day it names, and + `runDailyAlertSearch` selects by UTC day and reports `utcDay`. Tests: the + UTC-day selector including the rollover at midnight, a real-date check + that refuses `2026-02-30` rather than rolling it into March, and a + partial-window JWT refused at upload. + - [ ] End-to-end pass of the hour against a real handset: authorize with a + UTC hour an hour out, confirm no text before it and one after, then + `DELETE` and confirm the next day is silent. + - [ ] Confirm the app mints its 100 JWTs on UTC-day boundaries. The batch is + refused otherwise, so this is the one client change this phase forces. +- [x] **15. Set-based selection, bounded concurrency, and the hour on FCM.** + `listPendingForDay` on the shared store replaces per-user `getLatestBatch` + polling in both passes; `forEachWithConcurrency` bounds the per-user work; + the notify hour and `timezone` are accepted on the FCM route and both + schedulers gate on the stored hour; the hour is required on both channels, + so every batch states its own. `isNotifyTimeReached` is deleted rather + than left beside the SQL that now decides the same question. Tests: the + query's day filter, spent-user exclusion, hour boundary, newest-batch + selection, channel isolation, and the unreadable-value fail-open; the pool's + limit, ordering at a limit of one, and error propagation; both schedulers + deferring and then running a user as the hour passes. ## Tests @@ -736,3 +909,66 @@ including one minted for an unrelated purpose and captured — deletes a phone registration. **The `twilio` SDK.** One form POST does not justify a dependency tree. + +**Gating the notification hour at the send instead of the search.** The natural +reading of "send at 18:00" is a check inside `deliverAlertSearchSms`. It would +never fire: the daily run consumes that local day's JWT, and eligibility depends +on the run that consumed it, so the midnight tick would spend the day's +credential and 18:00 would find `digest: null`. Holding the whole pass costs one +indexed batch lookup per user per tick. + +**Carrying the hour inside the delegated JWTs.** They are the alertSearch +credential; `nbf`/`exp` bound a whole local day. Narrowing them to an hour would +narrow when the search may run against Endorser, not when the user hears about +it, and would make the hour unchangeable without re-minting all 100. + +**A window rather than a floor.** "Send between 18:00 and 19:00" would leave a +user silent for the day whenever the service was down across that hour, which is +the failure the daily digest exists to avoid. The hour is a floor, and a late +text beats none. + +**An ISO-8601 `notifyTime` carrying its own `±HH:MM` offset.** It was +self-describing — `"18:00-06:00"` cannot be misread as a wall clock — which is +the property a bare pair of integers lacks. Rejected once the fields were named +`notifyHourUtc` and `notifyMinuteUtc`: a name that states the frame carries it +as surely as an offset does, and unlike an offset it cannot disagree with the +value beside it. Two integers also validate by range rather than by a 16-line +regex that had to accept basic and extended offsets, optional seconds, optional +fractions, and an optional leading date that changed nothing. + +**Deleting phone registrations along with the authorization.** Turning alerts +off would then cost a fresh 6-digit round trip to turn them back on. The two are +separate routes because they are separate decisions. + +**Scheduling from an IANA `timezone` instead of a stored UTC instant.** The zone +is the only value that tracks a local hour across a daylight-saving transition, +so scheduling from it would make the notification hour survive one. Rejected for +now on the strength of the drift being corrected by the next batch upload, which +the 100-day inventory forces anyway. The zone is accepted and stored regardless, +unread, so that whichever mechanism wins has the value it needs. + +Not a reason: cost. Resolving a user's local wall clock with an +`Intl.DateTimeFormat` cached per zone measures ~1.4µs, against ~2.8µs for the +per-user SQLite lookup the pass already performs — the zone math is half the +price of a query the scheduler pays today, and 100k users cost ~137ms once per +tick. The 34µs/user figure that makes it look expensive comes from constructing +the formatter inside the loop; a `Map` keyed by zone removes it, and a +deployment sees a handful of distinct zones rather than the full IANA set. + +**A weekend job that shifts stored UTC times for zones whose DST changed.** It +would keep the indexed `WHERE notify_hour_min_utc <= ?` shape while staying correct +across transitions. Weighed against a stored *next-firing instant* that each +send recomputes from the zone: the job has to run forever or users drift +silently for up to six months; it consults `Intl` per zone anyway, so it is the +same computation merely batched and delayed; and it has to be driven by the +rules rather than a calendar, because Lord Howe shifts 30 minutes, the southern +hemisphere runs opposite, and legislatures abolish DST on short notice (Mexico, +2022). The next-firing-instant design does its maintenance at the moment each +row is used, which is when the answer is needed and when it is cheapest to be +sure of. Neither is built; the column that either would need is. + +**Keeping local calendar days with the timezone removed.** There would be no +frame left to resolve them in: `day` would be a label the server could not +check, and the daily run would have no way to know which label meant today. UTC +days give both the check and the selector one frame, at the cost of a client +that must mint its windows on UTC midnights. diff --git a/README.md b/README.md index 8fd7bbd..a95826f 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ On first use, the service creates `NOTIFY_DATA_DIR` (default `./data`) and the S ### 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. @@ -51,20 +51,33 @@ Authorization: Bearer ```json { "batchId": "client-batch-id", + "notifyHourUtc": 18, + "notifyMinuteUtc": 30, "timezone": "America/Denver", "jwts": [ { "sequence": 0, "day": "2026-08-27", - "nbf": 1756270800, - "exp": 1756357200, + "nbf": 1756252800, + "exp": 1756339200, "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 @@ -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. -`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. @@ -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 | | DELETE | `/notify-sms/phone` | Remove the phone entirely | | POST, PUT | `/notify-sms/alert-authorization` | Store a delegated JWT batch for the SMS channel | +| DELETE | `/notify-sms/alert-authorization` | Remove every SMS batch and JWT for the DID, turning the channel off | | POST | `/notify-sms/inbound` | Twilio's webhook for `STOP` / `START` / `HELP` | `GET /notify-sms/phone` returns the caller's own numbers in full. Adding @@ -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 `{ success: true, deleted: false }`, not an error. -`POST /notify-sms/alert-authorization` takes the same body as the FCM twin and -requires at least one verified phone for the DID; without one it returns -`409 SMS_NO_VERIFIED_PHONE`. `PUT` is accepted as an alias, since the FCM twin -is `PUT` and the semantics are replace-not-append either way. +`POST /notify-sms/alert-authorization` takes the FCM twin's body plus a +required `notifyHourUtc` (integer 0-23) and `notifyMinuteUtc` (integer 0-59): +the hour the user wants to hear from the service. **Both are UTC**, which the +field names carry, so no offset and no zone travel with them and there is +nothing for the server to misread. A client sends `date.getUTCHours()` and +`date.getUTCMinutes()` with no formatting step. + +Both are required, and one without the other is refused naming the missing +field, so every batch states its own hour rather than inheriting one +([Notification hour](#notification-hour)). Out of range, fractional, or the +wrong type is rejected with the rest of the batch. The response echoes both +integers; the server composes the zero-padded `HH:MM` it stores. + +An optional `timezone` (an IANA name such as `"America/Denver"`) is validated +and stored beside them. **Nothing reads it**: the two UTC integers are what +schedule a send. It is recorded against the day something needs to re-derive +that hour across a DST change — see [Notification +hour](#notification-hour). A batch may carry the hour without it, but not the +reverse. A name `Intl` cannot resolve is rejected with the rest of the +batch, because a zone this service cannot resolve would be worth nothing to +whatever reads the column later. + +The route requires at least one verified phone for the DID; +without one it returns `409 SMS_NO_VERIFIED_PHONE`. `PUT` is accepted as an +alias, since the FCM twin is `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 @@ -154,9 +200,11 @@ action and the phone number it applies to: ``` `action` is one of `list-phones`, `register-phone`, `verify-phone`, -`delete-phone`, `authorize-alert-search`. `phoneNumber` is required for -`register-phone`, `verify-phone` and `delete-phone`, and required for -`list-phones` only when the request carries the query parameter. The claim holds +`delete-phone`, `authorize-alert-search`, `revoke-alert-search`. `phoneNumber` +is required for `register-phone`, `verify-phone` and `delete-phone`, and +required for `list-phones` only when the request carries the query parameter. +`authorize-alert-search` and `revoke-alert-search` act on the DID's whole +inventory rather than on one handset, so they bind to no number. The claim holds no DID — the authenticated identity is `iss` — and no `batchId`. `@context` is `https://giftopia.tech` with no trailing path, shared with the FCM @@ -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 every route and stops the SMS scheduler from starting at all. +### Scheduler selection + +Both alertSearch passes choose their users with one query, not one query per +user. `listPendingForDay({ day, hourMinute })` returns every user holding an +unused JWT for that UTC day, each flagged `due` by whether their batch's +`notify_hour_min_utc` has arrived: + +- A user who has already run today holds no unused JWT for it and does not + appear at all, so the ~287 ticks a day that have nothing to do for them cost + nothing. +- A user whose hour has not arrived appears with `due: false` and is counted in + `deferred`, which is what keeps that number in the log line. Every batch has an + hour, so this applies to every user rather than to a subset. +- The batch consulted is the newest one per user, picked with a window function. + A user accumulates batch rows, because a batch with a consumed JWT survives the + next upload, so "the user's notify time" is not a plain join. +- `HH:MM` is zero-padded, so the text comparison SQLite performs is + chronological. + +Due users are then worked on `ALERT_SEARCH_USER_CONCURRENCY` at a time +(`src/util/concurrency.ts`, currently 8). The per-user work is two external +round trips against that user's own cursors, so a serial loop spends the pass +waiting: at 200ms per user it stops fitting inside a five-minute tick at roughly +1,500 users, after which the in-flight guard skips passes and users miss days. +The bound is held low deliberately — the ceiling it relieves is latency, and the +two APIs on the other end are shared infrastructure that a wide fan-out would +only move the queue into. + +Measured on this codebase at 2000 users, one tick: a pass where every user has +already run costs 0 queries and ~1ms (it was 3 queries per user and ~141ms when +the scheduler asked per user), and a pass where every user is due drops from +~106s to ~13s at 50ms of API latency. + ### SMS delivery `startSmsAlertSearchScheduler()` is a third scheduler alongside the FCM wakeup and FCM alertSearch passes, with its own interval (`SMS_ALERT_SEARCH_INTERVAL_MS`, default 5 minutes) and a 150-second initial offset so the two alertSearch passes do not hit Endorser in the same instant. It -lists distinct `user_id` from `sms_alert_authorization_batches`, calls -`runDailyAlertSearch(userId, now, {}, "sms")`, and then `deliverAlertSearchSms`. -It logs `[SmsAlertSearchScheduler] Pass started` / `Pass completed in`. The -in-flight guard is process-local, exactly like the other two, so a second -replica double-texts. +selects its users with the one query described in [Scheduler +selection](#scheduler-selection), calls `runDailyAlertSearch(userId, now, {}, +"sms")` on the due ones with a bounded number in flight, and then +`deliverAlertSearchSms`. It logs `[SmsAlertSearchScheduler] Pass started` / +`Pass completed in`, the latter with `attempted`, `deferred`, and `failed` +counts. The in-flight guard is process-local, exactly like the other two, so a +second replica double-texts. Eligibility is the same predicate the FCM path uses: the run consumed today's JWT and the digest is complete with updates. Consumption is what makes later @@ -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 the other reports nothing. +### Notification hour + +Every batch on either channel carries a UTC hour and minute. The server stores +them zero-padded as one `HH:MM` value in `notify_hour_min_utc`, which is the +form SQLite compares chronologically. Both schedulers hold a +user's whole daily run until that UTC time arrives, and count the held users as +`deferred` in the pass result. The decision is made in SQL, not per user — see +[Scheduler selection](#scheduler-selection). + +The field is required rather than optional because the alternative default is +not "no gate" but "the first tick after midnight UTC" — a user's JWT for a new +UTC day becomes selectable at that moment, so every user without an hour runs in +the same five-minute window. Making each batch name its own hour spreads that +load as a side effect of asking the question. + +The gate sits ahead of the search rather than ahead of the text. Running the +search consumes that UTC day's JWT, and a digest is sent only for the run that +consumed it, so a search that ran at the top of the day would leave nothing to +send at the chosen hour. + +The hour lives on the batch rather than inside the delegated JWTs. Those are the +alertSearch credential, and their `nbf`/`exp` bound a whole UTC day; narrowing +them to an hour would narrow when the search may run against Endorser, not when +the user hears about it. + +**A stored UTC time does not follow the user through a daylight-saving change.** +Someone in Denver who wants 18:00 local sends `00:30` UTC in summer, and when +their region returns to `-07:00` that same UTC instant reads 17:00 on their wall +clock. The correction available today is a fresh batch carrying the new UTC +hour — which a +client uploads roughly every 100 days anyway, since that is how long an +inventory lasts. + +The optional `timezone` on the batch exists for a mechanism that would close +that gap without waiting for the next upload, by re-deriving `notify_hour_min_utc` +from the zone's current rules. No such mechanism runs: the column is recorded +and unread, and the schema says so in a comment SQLite keeps, so `.schema` shows +the reason next to the column. Which mechanism it should be is open — a job that +sweeps changed zones, or a stored next-firing instant recomputed each time a +user fires, which needs no scheduled job at all. + +There is no upper bound within the day. A service that was down at the chosen +hour and comes back six hours later still runs that UTC day; a silent day is the +worse failure. The text lands on the first tick at or after the hour, so within +one `SMS_ALERT_SEARCH_INTERVAL_MS` in the ordinary case. An hour late in the UTC +day leaves a correspondingly short window before the day key rolls and that +day's JWT is skipped. + +A row whose `notify_hour_min_utc` is NULL or not `HH:MM` is treated as due: a value +nobody can read must not silence a channel the user asked for. The selection +query enforces that with a `GLOB` guard, because text ordering alone would rank +an unreadable value above every real `HH:MM` and defer such a user permanently +rather than once. The route cannot produce either state — only a write that +bypasses it can. + ### Testing SMS locally Two scripts cover the two things worth checking separately. Both default to @@ -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): -- 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 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`, `code-sent`, `code-send-failed`, `verify-succeeded`, `verify-failed`, `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`. Indexes on `(user_id, created_at)`, `(phone_hash, created_at)`, and `(action, created_at)`; the throttle counts read the second. diff --git a/scripts/sms-smoke.ts b/scripts/sms-smoke.ts index 441d8d3..1b1ab21 100644 --- a/scripts/sms-smoke.ts +++ b/scripts/sms-smoke.ts @@ -184,6 +184,14 @@ async function main(): Promise { "list-phones", TO_NUMBER ); + // No batch was ever stored here (that needs 100 signed delegated JWTs), so + // this reports zeros. It still proves the route, the claim, and the log line. + await call( + "DELETE /alert-a", + "DELETE", + "/notify-sms/alert-authorization", + "revoke-alert-search" + ); await call( "DELETE /phone ", "DELETE", diff --git a/src/alertSearch/daily.test.ts b/src/alertSearch/daily.test.ts index 885111b..112ffbd 100644 --- a/src/alertSearch/daily.test.ts +++ b/src/alertSearch/daily.test.ts @@ -12,22 +12,19 @@ import { import { alertSearchCursorsDb } from "../db/alertSearchCursorsSqlite.js"; import { closeDatabase } from "../db/sqlite.js"; import type { FetchLike } from "./client.js"; -import { - InvalidAlertAuthorizationTimezoneError, - runDailyAlertSearch, -} from "./daily.js"; +import { runDailyAlertSearch } from "./daily.js"; import { ALERT_SEARCH_PAGE_SIZE } from "./types.js"; const USER = "did:ethr:0xdailyuser"; const ENDORSER_BASE = "https://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 DAY_LA = "2026-08-14"; -const DAY_AUCKLAND = "2026-08-15"; -const JWT_LA = "delegated.jwt.los-angeles-day"; -const JWT_AUCKLAND = "delegated.jwt.auckland-day"; +const DAY_BEFORE = "2026-08-14"; +const DAY_UTC = "2026-08-15"; +const JWT_DAY_BEFORE = "delegated.jwt.day-before"; +const JWT_DAY_UTC = "delegated.jwt.utc-day"; function ulid(n: number): string { return `01H${String(n).padStart(23, "0")}`; @@ -99,17 +96,11 @@ function jwtInput( } async function seedBatch( - timezone: string, jwts: AlertAuthorizationJwtInput[], userId = USER, batchId = "batch-1" ) { - await alertAuthorizationDb.replaceUnusedBatch({ - userId, - batchId, - timezone, - jwts, - }); + await alertAuthorizationDb.replaceUnusedBatch({ userId, batchId, jwts }); } function cycleOpts(fetch: FetchLike) { @@ -172,72 +163,68 @@ describe("runDailyAlertSearch", () => { await rm(dir, { recursive: true, force: true }); }); - it("selects today's JWT using the batch stored timezone", async () => { - await seedBatch("America/Los_Angeles", [ - jwtInput(1, DAY_LA, JWT_LA), - jwtInput(2, DAY_AUCKLAND, JWT_AUCKLAND), + it("selects the JWT for the current UTC day", async () => { + await seedBatch([ + jwtInput(1, DAY_BEFORE, JWT_DAY_BEFORE), + jwtInput(2, DAY_UTC, JWT_DAY_UTC), ]); const cap = bothEmptyFetch(); const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch)); - assert.equal(result.localDay, DAY_LA); - assert.equal(result.jwtSequence, 1); + assert.equal(result.utcDay, DAY_UTC); + assert.equal(result.jwtSequence, 2); assert.equal(result.completed, true); assert.equal(result.consumed, true); for (const auth of cap.auths) { - assert.equal(auth, `Bearer ${JWT_LA}`); + assert.equal(auth, `Bearer ${JWT_DAY_UTC}`); } }); - it("does not select a JWT belonging to another local day", async () => { - await seedBatch("Pacific/Auckland", [ - jwtInput(1, DAY_LA, JWT_LA), - jwtInput(2, DAY_AUCKLAND, JWT_AUCKLAND), + it("leaves another day's JWT untouched", async () => { + await seedBatch([ + jwtInput(1, DAY_BEFORE, JWT_DAY_BEFORE), + jwtInput(2, DAY_UTC, JWT_DAY_UTC), ]); const cap = bothEmptyFetch(); - const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch)); - assert.equal(result.localDay, DAY_AUCKLAND); - assert.equal(result.jwtSequence, 2); + await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch)); for (const auth of cap.auths) { - assert.equal(auth, `Bearer ${JWT_AUCKLAND}`); - assert.equal(auth.includes(JWT_LA), false); + assert.equal(auth.includes(JWT_DAY_BEFORE), false); } - const other = await alertAuthorizationDb.getUnusedForDay(USER, DAY_LA); - assert.equal(other?.jwt, JWT_LA); + const other = await alertAuthorizationDb.getUnusedForDay(USER, DAY_BEFORE); + assert.equal(other?.jwt, JWT_DAY_BEFORE); assert.equal(other?.status, ALERT_JWT_STATUS_UNUSED); }); - it("throws a clear error for an invalid stored timezone", async () => { - await seedBatch("Not/AZone", [jwtInput(1, DAY_LA, JWT_LA)]); - await assert.rejects( - () => runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(bothEmptyFetch().fetch)), - (err: unknown) => { - assert.ok(err instanceof InvalidAlertAuthorizationTimezoneError); - assert.equal(err.timezone, "Not/AZone"); - assert.match(err.message, /IANA/); - return true; - } + it("rolls to the next day's JWT the instant UTC midnight passes", async () => { + await seedBatch([ + jwtInput(1, DAY_BEFORE, JWT_DAY_BEFORE), + jwtInput(2, DAY_UTC, JWT_DAY_UTC), + ]); + const before = await runDailyAlertSearch( + USER, + new Date("2026-08-14T23:59:59.000Z"), + cycleOpts(bothEmptyFetch().fetch) ); - }); + assert.equal(before.utcDay, DAY_BEFORE); + assert.equal(before.jwtSequence, 1); - it("throws a clear error for a missing stored timezone", async () => { - await seedBatch("", [jwtInput(1, DAY_LA, JWT_LA)]); - await assert.rejects( - () => runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(bothEmptyFetch().fetch)), - InvalidAlertAuthorizationTimezoneError + const after = await runDailyAlertSearch( + USER, + new Date("2026-08-15T00:00:00.000Z"), + cycleOpts(bothEmptyFetch().fetch) ); + assert.equal(after.utcDay, DAY_UTC); + assert.equal(after.jwtSequence, 2); }); it("returns a structured no-JWT result when today has no unused JWT", async () => { - await seedBatch("America/Los_Angeles", [ - jwtInput(2, DAY_AUCKLAND, JWT_AUCKLAND), - ]); + await seedBatch([jwtInput(2, DAY_BEFORE, JWT_DAY_BEFORE)]); const result = await runDailyAlertSearch( USER, NOW_SPLIT, cycleOpts(bothEmptyFetch().fetch) ); assert.equal(result.userId, USER); - assert.equal(result.localDay, DAY_LA); + assert.equal(result.utcDay, DAY_UTC); assert.equal(result.batchId, "batch-1"); assert.equal(result.jwtSequence, 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 () => { 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.jwtSequence, null); assert.equal(result.completed, false); @@ -258,9 +245,9 @@ describe("runDailyAlertSearch", () => { }); it("passes today's JWT to runAlertSearchCycle", async () => { - await seedBatch("America/Los_Angeles", [ - jwtInput(1, DAY_LA, JWT_LA), - jwtInput(2, DAY_AUCKLAND, JWT_AUCKLAND), + await seedBatch([ + jwtInput(1, DAY_UTC, JWT_DAY_UTC), + jwtInput(2, DAY_BEFORE, JWT_DAY_BEFORE), ]); const cap = bothSuccessFetch(); const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch)); @@ -268,7 +255,7 @@ describe("runDailyAlertSearch", () => { assert.ok(cap.urls.some((u) => u.includes("/api/partner/alertSearch"))); assert.ok(cap.auths.length >= 2); for (const auth of cap.auths) { - assert.equal(auth, `Bearer ${JWT_LA}`); + assert.equal(auth, `Bearer ${JWT_DAY_UTC}`); } assert.ok(result.digest); assert.equal(result.digest.hasUpdates, true); @@ -276,7 +263,7 @@ describe("runDailyAlertSearch", () => { }); 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( USER, NOW_SPLIT, @@ -295,12 +282,12 @@ describe("runDailyAlertSearch", () => { assert.equal(result.digest.records.claims.length, 1); assert.equal(result.digest.records.claims[0].id, ulid(10)); assert.equal(result.digest.records.profilesNearby[0].updatedAt, "2026-03-01T00:00:00.000Z"); - const leftover = await alertAuthorizationDb.getUnusedForDay(USER, DAY_LA); + const leftover = await alertAuthorizationDb.getUnusedForDay(USER, DAY_UTC); assert.equal(leftover, undefined); }); it("consumes today's JWT when both sources are empty", async () => { - await seedBatch("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, @@ -317,7 +304,7 @@ describe("runDailyAlertSearch", () => { }); 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) => { if (url.includes("/api/partner/")) { return jsonResponse({ error: "unauthorized" }, 401); @@ -334,15 +321,15 @@ describe("runDailyAlertSearch", () => { assert.equal(result.digest.hasUpdates, false); assert.equal(result.digest.records.claims.length, 1); assert.equal(result.digest.records.claims[0].id, ulid(10)); - const unused = await alertAuthorizationDb.getUnusedForDay(USER, DAY_LA); - assert.equal(unused?.jwt, JWT_LA); + const unused = await alertAuthorizationDb.getUnusedForDay(USER, DAY_UTC); + assert.equal(unused?.jwt, JWT_DAY_UTC); const stored = await alertSearchCursorsDb.get(USER); assert.equal(stored?.endorserAfterId, ulid(10)); assert.equal(stored?.partnerAfterAt, null); }); it("does not consume when Partner succeeds and Endorser fails", async () => { - await seedBatch("America/Los_Angeles", [jwtInput(1, DAY_LA, JWT_LA)]); + await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]); const cap = captureFetch((url) => { if (url.includes("/api/partner/")) { return jsonResponse( @@ -369,7 +356,7 @@ describe("runDailyAlertSearch", () => { assert.equal(result.digest.completed, false); assert.equal(result.digest.hasUpdates, false); assert.equal(result.digest.records.profilesNearby.length, 1); - const unused = await alertAuthorizationDb.getUnusedForDay(USER, DAY_LA); + const unused = await alertAuthorizationDb.getUnusedForDay(USER, DAY_UTC); assert.equal(unused?.status, ALERT_JWT_STATUS_UNUSED); const stored = await alertSearchCursorsDb.get(USER); assert.equal(stored?.endorserAfterId, null); @@ -377,7 +364,7 @@ describe("runDailyAlertSearch", () => { }); 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) => { if (url.includes("/api/partner/")) return jsonResponse(emptyPartnerBody()); return jsonResponse({ @@ -398,7 +385,7 @@ describe("runDailyAlertSearch", () => { }); 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) => { if (url.includes("/api/partner/")) { const tied = "2026-01-01T12:00:00.000Z"; @@ -461,7 +448,7 @@ describe("runDailyAlertSearch", () => { for (const c of cases) { 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)); assert.equal(result.endorserOutcome, 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.equal(result.digest.completed, false, c.name); assert.equal(result.digest.hasUpdates, false, c.name); - const unused = await alertAuthorizationDb.getUnusedForDay(USER, DAY_LA); - assert.equal(unused?.jwt, JWT_LA, c.name); + const unused = await alertAuthorizationDb.getUnusedForDay(USER, DAY_UTC); + assert.equal(unused?.jwt, JWT_DAY_UTC, c.name); } }); it("consumes the exact selected JWT row, not another day's unused JWT", async () => { - await seedBatch("America/Los_Angeles", [ - jwtInput(1, DAY_LA, JWT_LA), - jwtInput(2, DAY_AUCKLAND, JWT_AUCKLAND), + await seedBatch([ + jwtInput(1, DAY_UTC, JWT_DAY_UTC), + jwtInput(2, DAY_BEFORE, JWT_DAY_BEFORE), ]); - const today = await alertAuthorizationDb.getUnusedForDay(USER, DAY_LA); - const other = await alertAuthorizationDb.getUnusedForDay(USER, DAY_AUCKLAND); + const today = await alertAuthorizationDb.getUnusedForDay(USER, DAY_UTC); + const other = await alertAuthorizationDb.getUnusedForDay(USER, DAY_BEFORE); assert.ok(today); assert.ok(other); const result = await runDailyAlertSearch( @@ -500,7 +487,7 @@ describe("runDailyAlertSearch", () => { }); 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( USER, NOW_SPLIT, @@ -521,7 +508,7 @@ describe("runDailyAlertSearch", () => { }); 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( USER, NOW_SPLIT, @@ -531,7 +518,7 @@ describe("runDailyAlertSearch", () => { assert.equal(afterEmpty, undefined); closeDatabase(); - await seedBatch("America/Los_Angeles", [jwtInput(1, DAY_LA, JWT_LA)]); + await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]); await runDailyAlertSearch( USER, NOW_SPLIT, diff --git a/src/alertSearch/daily.ts b/src/alertSearch/daily.ts index 27882ac..6835bec 100644 --- a/src/alertSearch/daily.ts +++ b/src/alertSearch/daily.ts @@ -3,10 +3,7 @@ import { type AlertAuthorizationStore, } from "../db/alertAuthorizationSqlite.js"; import { smsAlertAuthorizationDb } from "../db/smsAlertAuthorizationSqlite.js"; -import { - calendarDayInTimeZone, - isValidIanaTimeZone, -} from "../services/alertAuthorization.js"; +import { utcCalendarDay } from "../services/alertAuthorization.js"; import { runAlertSearchCycle, type AlertSearchCycleInput, @@ -26,25 +23,14 @@ const JWT_INVENTORIES: Record = { 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 type DailyAlertSearchCycleInput = Omit; export type DailyAlertSearchResult = { 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; jwtSequence: number | null; endorserOutcome: AlertSearchQueryOutcome | null; @@ -57,12 +43,12 @@ export type DailyAlertSearchResult = { function noJwtResult( userId: string, - localDay: string | null, + utcDay: string | null, batchId: string | null ): DailyAlertSearchResult { return { userId, - localDay, + utcDay, batchId, jwtSequence: null, endorserOutcome: null, @@ -74,9 +60,9 @@ function noJwtResult( } /** - * Select today's unused delegated JWT (batch IANA timezone + stored day), - * run the existing alertSearch cycle, and consume that JWT only when both - * required sources completed (success or empty). Not invoked by the scheduler. + * Select today's unused delegated JWT by UTC day, run the existing alertSearch + * cycle, and consume that JWT only when both required sources completed + * (success or empty). Not invoked by the scheduler. */ export async function runDailyAlertSearch( userId: string, @@ -89,17 +75,10 @@ export async function runDailyAlertSearch( if (batch === undefined) { return noJwtResult(userId, null, null); } - if (!isValidIanaTimeZone(batch.timezone)) { - throw new InvalidAlertAuthorizationTimezoneError(batch.timezone); - } - - const localDay = calendarDayInTimeZone( - Math.floor(now.getTime() / 1000), - batch.timezone - ); - const selected = await inventory.getUnusedForDay(userId, localDay); + const utcDay = utcCalendarDay(Math.floor(now.getTime() / 1000)); + const selected = await inventory.getUnusedForDay(userId, utcDay); if (selected === undefined) { - return noJwtResult(userId, localDay, batch.batchId); + return noJwtResult(userId, utcDay, batch.batchId); } const cycle = await runAlertSearchCycle( @@ -125,7 +104,7 @@ export async function runDailyAlertSearch( return { userId, - localDay, + utcDay, batchId: selected.batchId, jwtSequence: selected.sequence, endorserOutcome, diff --git a/src/alertSearch/index.ts b/src/alertSearch/index.ts index c06398e..b705e91 100644 --- a/src/alertSearch/index.ts +++ b/src/alertSearch/index.ts @@ -40,7 +40,6 @@ export type { CursorAdvanceResult, StoredAlertSearchCursors } from "./cursors.js export { runAlertSearchCycle } from "./cycle.js"; export type { AlertSearchCycleInput, AlertSearchCycleResult } from "./cycle.js"; export { - InvalidAlertAuthorizationTimezoneError, runDailyAlertSearch, sourceCompletedDailyRun, } from "./daily.js"; diff --git a/src/alertSearch/notify.test.ts b/src/alertSearch/notify.test.ts index 8ca5ce7..1309ac6 100644 --- a/src/alertSearch/notify.test.ts +++ b/src/alertSearch/notify.test.ts @@ -82,7 +82,7 @@ function daily( ): DailyAlertSearchResult { return { userId: USER, - localDay: "2026-08-14", + utcDay: "2026-08-14", batchId: "batch-1", jwtSequence: 1, endorserOutcome: "success", diff --git a/src/alertSearch/scheduler.test.ts b/src/alertSearch/scheduler.test.ts index b0de99b..24659f8 100644 --- a/src/alertSearch/scheduler.test.ts +++ b/src/alertSearch/scheduler.test.ts @@ -23,7 +23,7 @@ const USER_B = "did:ethr:0xuserb"; function stubDailyResult(userId: string): DailyAlertSearchResult { return { userId, - localDay: null, + utcDay: null, batchId: null, jwtSequence: null, endorserOutcome: null, @@ -60,7 +60,7 @@ function eligibleDaily(userId: string): DailyAlertSearchResult { }; return { userId, - localDay: "2026-08-14", + utcDay: "2026-08-14", batchId: "batch-1", jwtSequence: 1, 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({ userId, batchId, - timezone: "America/Denver", + notifyHourUtc: notify?.hour, + notifyMinuteUtc: notify?.minute, jwts: [ { sequence: 1, - day: "2026-08-28", + day: DAY, jwt: `jwt-${userId}`, nbf: 1, exp: 2, @@ -129,6 +138,8 @@ describe("alertSearch scheduler pass", () => { await seedDevice(USER_A, "device-2", "token-2"); const ran: string[] = []; const result = await runAlertSearchSchedulerPass({ + now: NOW, + concurrency: 1, runDaily: async (userId) => { ran.push(userId); return stubDailyResult(userId); @@ -144,6 +155,8 @@ describe("alertSearch scheduler pass", () => { 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); @@ -154,6 +167,58 @@ describe("alertSearch scheduler pass", () => { assert.equal(result.failed, 0); }); + it("defers a user whose notify hour has not arrived", async () => { + await seedBatch(USER_A, "batch-a", { hour: 18, minute: 0 }); + await seedBatch(USER_B, "batch-b"); + const ran: string[] = []; + const result = await runAlertSearchSchedulerPass({ + now: NOW, + concurrency: 1, + runDaily: async (userId) => { + ran.push(userId); + return stubDailyResult(userId); + }, + }); + assert.deepEqual(ran, [USER_B]); + assert.equal(result.deferred, 1); + assert.equal(result.attempted, 1); + }); + + it("runs that user once the hour passes", async () => { + await seedBatch(USER_A, "batch-a", { hour: 18, minute: 0 }); + const ran: string[] = []; + const result = await runAlertSearchSchedulerPass({ + now: new Date(`${DAY}T18:05:00.000Z`), + concurrency: 1, + runDaily: async (userId) => { + ran.push(userId); + return stubDailyResult(userId); + }, + }); + assert.deepEqual(ran, [USER_A]); + assert.equal(result.deferred, 0); + }); + + it("does not list a user whose day is already spent", async () => { + await seedBatch(USER_A, "batch-a"); + const jwt = await alertAuthorizationDb.getUnusedForDay(USER_A, DAY); + assert.ok(jwt); + await alertAuthorizationDb.consumeUnusedJwt({ id: jwt.id, userId: USER_A }); + + const ran: string[] = []; + const result = await runAlertSearchSchedulerPass({ + now: NOW, + concurrency: 1, + runDaily: async (userId) => { + ran.push(userId); + return stubDailyResult(userId); + }, + }); + assert.deepEqual(ran, []); + assert.equal(result.attempted, 0); + assert.equal(result.deferred, 0); + }); + it("skips a tick while an alertSearch pass is in flight", async () => { await seedBatch(USER_A, "batch-a"); let release!: () => void; @@ -161,6 +226,8 @@ describe("alertSearch scheduler pass", () => { release = resolve; }); const first = runAlertSearchSchedulerPass({ + now: NOW, + concurrency: 1, runDaily: async (userId) => { await blocked; return stubDailyResult(userId); @@ -170,6 +237,8 @@ describe("alertSearch scheduler pass", () => { await Promise.resolve(); } const second = await runAlertSearchSchedulerPass({ + now: NOW, + concurrency: 1, runDaily: async () => { throw new Error("second pass should not run daily"); }, @@ -187,6 +256,8 @@ describe("alertSearch scheduler pass", () => { await seedBatch(USER_B, "batch-b"); const ran: string[] = []; const result = await runAlertSearchSchedulerPass({ + now: NOW, + concurrency: 1, runDaily: async (userId) => { ran.push(userId); if (userId === USER_A) throw new Error("boom"); @@ -212,6 +283,8 @@ describe("alertSearch scheduler pass", () => { await seedBatch(USER_B, "batch-b"); const notified: string[] = []; await runAlertSearchSchedulerPass({ + now: NOW, + concurrency: 1, runDaily: async (userId) => userId === USER_A ? eligibleDaily(userId) : stubDailyResult(userId), notify: async (result) => { @@ -228,6 +301,8 @@ describe("alertSearch scheduler pass", () => { const ran: string[] = []; const notifyCalls: number[] = []; await runAlertSearchSchedulerPass({ + now: NOW, + concurrency: 1, runDaily: async (userId) => { ran.push(userId); return eligibleDaily(userId); @@ -244,12 +319,16 @@ describe("alertSearch scheduler pass", () => { await seedBatch(USER_A, "batch-a"); const notifies: Array = []; await runAlertSearchSchedulerPass({ + now: NOW, + concurrency: 1, runDaily: async (userId) => eligibleDaily(userId), notify: async (result) => { notifies.push(result.digest); }, }); await runAlertSearchSchedulerPass({ + now: NOW, + concurrency: 1, runDaily: async (userId) => stubDailyResult(userId), notify: async (result) => { notifies.push(result.digest); @@ -267,6 +346,8 @@ describe("alertSearch scheduler pass", () => { const incompleteDigest = first.digest; assert.ok(incompleteDigest); await runAlertSearchSchedulerPass({ + now: NOW, + concurrency: 1, runDaily: async () => ({ ...first, completed: false, @@ -282,6 +363,8 @@ describe("alertSearch scheduler pass", () => { }, }); await runAlertSearchSchedulerPass({ + now: NOW, + concurrency: 1, runDaily: async (userId) => eligibleDaily(userId), notify: async (result) => { eligible.push(Boolean(result.consumed && result.digest?.hasUpdates)); @@ -294,6 +377,8 @@ describe("alertSearch scheduler pass", () => { await seedBatch(USER_A, "batch-a"); let dailyConsumed = false; const result = await runAlertSearchSchedulerPass({ + now: NOW, + concurrency: 1, runDaily: async (userId) => { const daily = eligibleDaily(userId); dailyConsumed = daily.consumed; diff --git a/src/alertSearch/scheduler.ts b/src/alertSearch/scheduler.ts index c574df6..a366954 100644 --- a/src/alertSearch/scheduler.ts +++ b/src/alertSearch/scheduler.ts @@ -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 { runDailyAlertSearch, @@ -19,16 +30,26 @@ export type AlertSearchNotifyRunner = ( ) => Promise; export type AlertSearchSchedulerPassInput = { - listUserIds?: () => Promise; + listPending?: (input: { + day: string; + hourMinute: string; + }) => Promise; runDaily?: AlertSearchUserRunner; notify?: AlertSearchNotifyRunner; + /** The instant this pass represents. Defaults to now. */ + now?: Date; + /** Users worked on at once. Set to 1 for a deterministic order in tests. */ + concurrency?: number; }; export type AlertSearchSchedulerPassResult = { skipped: boolean; + /** The users this pass ran, in no guaranteed order. */ userIds: string[]; attempted: number; failed: number; + /** Users holding an unused JWT whose chosen UTC time has not arrived yet. */ + deferred: number; }; let intervalId: ReturnType | undefined; @@ -39,16 +60,17 @@ export function isAlertSearchSchedulerPassInFlight(): boolean { } /** - * One user-oriented alertSearch pass. Skips if a pass is already running. - * After each daily run, may send an AlertSearch FCM digest. Does not use the - * device wakeup ping path. + * One user-oriented alertSearch pass over the users who have work today and + * whose chosen hour has arrived. Skips if a pass is already running. After each + * daily run, may send an AlertSearch FCM digest. Does not use the device wakeup + * ping path. */ export async function runAlertSearchSchedulerPass( input: AlertSearchSchedulerPassInput = {} ): Promise { if (passInFlight) { 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; @@ -56,46 +78,61 @@ export async function runAlertSearchSchedulerPass( log.info("[AlertSearchScheduler] Pass started"); try { - const listUserIds = - input.listUserIds ?? - (() => alertAuthorizationDb.listDistinctUserIds()); - const runDaily = input.runDaily ?? runDailyAlertSearch; + const now = input.now ?? new Date(); + const runDaily = + input.runDaily ?? ((userId: string) => runDailyAlertSearch(userId, now)); 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; - for (const userId of userIds) { - try { - const daily = await runDaily(userId); + await forEachWithConcurrency( + userIds, + input.concurrency ?? ALERT_SEARCH_USER_CONCURRENCY, + async (userId) => { 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) { + failed += 1; log.error( - "[AlertSearchScheduler] Notification failed", + "[AlertSearchScheduler] User failed", userId + ":", errorMessage(err) ); } - } catch (err) { - failed += 1; - log.error( - "[AlertSearchScheduler] User failed", - userId + ":", - errorMessage(err) - ); } - } + ); log.info( "[AlertSearchScheduler] Pass completed in", formatElapsedMs(Date.now() - passStarted) + ",", - `attempted ${userIds.length}, failed ${failed}` + `attempted ${userIds.length}, deferred ${deferred}, failed ${failed}` ); return { skipped: false, userIds, attempted: userIds.length, failed, + deferred, }; } catch (err) { log.error( diff --git a/src/alertSearch/smsScheduler.ts b/src/alertSearch/smsScheduler.ts index c4e4f7e..b0af982 100644 --- a/src/alertSearch/smsScheduler.ts +++ b/src/alertSearch/smsScheduler.ts @@ -1,6 +1,15 @@ import { smsActionJwtUseDb } from "../db/smsActionJwtUseSqlite.js"; +import type { PendingDayUser } from "../db/alertAuthorizationSqlite.js"; import { smsAlertAuthorizationDb } from "../db/smsAlertAuthorizationSqlite.js"; import { smsConfig } from "../env.js"; +import { + utcCalendarDay, + utcHourMinute, +} from "../services/alertAuthorization.js"; +import { + ALERT_SEARCH_USER_CONCURRENCY, + forEachWithConcurrency, +} from "../util/concurrency.js"; import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js"; import { runDailyAlertSearch, @@ -16,17 +25,27 @@ export const SMS_ALERT_SEARCH_INITIAL_OFFSET_MS = 150 * 1000; export const SMS_ACTION_JWT_RETENTION_MULTIPLE = 10; export type SmsAlertSearchSchedulerPassInput = { - listUserIds?: () => Promise; + listPending?: (input: { + day: string; + hourMinute: string; + }) => Promise; runDaily?: (userId: string) => Promise; notify?: (result: DailyAlertSearchResult) => Promise; prune?: () => Promise; + /** The instant this pass represents. Defaults to now. */ + now?: Date; + /** Users worked on at once. Set to 1 for a deterministic order in tests. */ + concurrency?: number; }; export type SmsAlertSearchSchedulerPassResult = { skipped: boolean; + /** The users this pass ran, in no guaranteed order. */ userIds: string[]; attempted: number; failed: number; + /** Users holding an unused JWT whose chosen UTC time has not arrived yet. */ + deferred: number; }; let intervalId: ReturnType | undefined; @@ -48,16 +67,16 @@ async function defaultPrune(): Promise { } /** - * One SMS-channel alertSearch pass over the users who authorized that channel. - * Skips if a pass is already running; the guard is process-local, so a second - * replica double-texts. + * One SMS-channel alertSearch pass over the users who have work today and whose + * chosen hour has arrived. Skips if a pass is already running; the guard is + * process-local, so a second replica double-texts. */ export async function runSmsAlertSearchSchedulerPass( input: SmsAlertSearchSchedulerPassInput = {} ): Promise { if (passInFlight) { 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; @@ -65,13 +84,16 @@ export async function runSmsAlertSearchSchedulerPass( log.info("[SmsAlertSearchScheduler] Pass started"); try { - const listUserIds = - input.listUserIds ?? (() => smsAlertAuthorizationDb.listDistinctUserIds()); + const now = input.now ?? new Date(); const runDaily = input.runDaily ?? - ((userId: string) => runDailyAlertSearch(userId, new Date(), {}, "sms")); + ((userId: string) => runDailyAlertSearch(userId, now, {}, "sms")); const notify = input.notify ?? deliverAlertSearchSms; const prune = input.prune ?? defaultPrune; + const listPending = + input.listPending ?? + ((query: { day: string; hourMinute: string }) => + smsAlertAuthorizationDb.listPendingForDay(query)); try { await prune(); @@ -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; - for (const userId of userIds) { - try { - const daily = await runDaily(userId); + await forEachWithConcurrency( + userIds, + input.concurrency ?? ALERT_SEARCH_USER_CONCURRENCY, + async (userId) => { 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) { + failed += 1; log.error( - "[SmsAlertSearchScheduler] Notification failed", + "[SmsAlertSearchScheduler] User failed", userId + ":", errorMessage(err) ); } - } catch (err) { - failed += 1; - log.error( - "[SmsAlertSearchScheduler] User failed", - userId + ":", - errorMessage(err) - ); } - } + ); log.info( "[SmsAlertSearchScheduler] Pass completed in", formatElapsedMs(Date.now() - passStarted) + ",", - `attempted ${userIds.length}, failed ${failed}` + `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) { log.error( "[SmsAlertSearchScheduler] Pass failed in", diff --git a/src/db/alertAuthorizationSqlite.ts b/src/db/alertAuthorizationSqlite.ts index 9b6b86d..13cf14f 100644 --- a/src/db/alertAuthorizationSqlite.ts +++ b/src/db/alertAuthorizationSqlite.ts @@ -1,4 +1,8 @@ import { randomUUID } from "node:crypto"; +import { + formatHourMinuteUtc, + parseHourMinuteUtc, +} from "../services/alertAuthorization.js"; import { getDatabase } from "./sqlite.js"; export const ALERT_JWT_STATUS_UNUSED = "unused"; @@ -10,7 +14,15 @@ export type AlertAuthorizationBatchRecord = { id: string; userId: 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; }; @@ -29,6 +41,12 @@ export type AlertAuthorizationJwtRecord = { createdAt: string; }; +/** One user with an unused JWT for a given day, and whether their hour has come. */ +export type PendingDayUser = { + userId: string; + due: boolean; +}; + export type AlertAuthorizationJwtInput = { sequence: number; day: string; @@ -41,7 +59,8 @@ type BatchDbRow = { id: string; user_id: string; batch_id: string; - timezone: string; + notify_hour_min_utc: string | null; + timezone: string | null; 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 { return { id: row.id, userId: row.user_id, batchId: row.batch_id, - timezone: row.timezone, + ...splitStoredHourMinute(row.notify_hour_min_utc), + timezone: row.timezone ?? undefined, createdAt: row.created_at, }; } @@ -121,7 +168,9 @@ export function createAlertAuthorizationStore(tables: AlertAuthorizationTables) async replaceUnusedBatch(input: { userId: string; batchId: string; - timezone: string; + notifyHourUtc?: number; + notifyMinuteUtc?: number; + timezone?: string; jwts: AlertAuthorizationJwtInput[]; }): Promise<{ batch: AlertAuthorizationBatchRecord; @@ -159,11 +208,18 @@ export function createAlertAuthorizationStore(tables: AlertAuthorizationTables) .prepare( ` INSERT INTO ${tables.batches} ( - id, user_id, batch_id, timezone, created_at - ) VALUES (?, ?, ?, ?, ?) + id, user_id, batch_id, notify_hour_min_utc, timezone, created_at + ) 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( ` @@ -203,6 +259,8 @@ export function createAlertAuthorizationStore(tables: AlertAuthorizationTables) id: batchPk, userId: input.userId, batchId: input.batchId, + notifyHourUtc: input.notifyHourUtc, + notifyMinuteUtc: input.notifyMinuteUtc, timezone: input.timezone, createdAt: now, }, @@ -239,6 +297,64 @@ export function createAlertAuthorizationStore(tables: AlertAuthorizationTables) 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 { + 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 { const rows = getDatabase() .prepare( @@ -258,10 +374,10 @@ export function createAlertAuthorizationStore(tables: AlertAuthorizationTables) const row = getDatabase() .prepare( ` - SELECT id, user_id, batch_id, timezone, created_at + SELECT ${BATCH_COLUMNS} FROM ${tables.batches} WHERE user_id = ? - ORDER BY created_at DESC + ORDER BY created_at DESC, rowid DESC LIMIT 1 ` ) @@ -283,6 +399,32 @@ export function createAlertAuthorizationStore(tables: AlertAuthorizationTables) 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". */ diff --git a/src/db/sqlite.ts b/src/db/sqlite.ts index 6af70a2..a332827 100644 --- a/src/db/sqlite.ts +++ b/src/db/sqlite.ts @@ -12,7 +12,11 @@ CREATE TABLE IF NOT EXISTS fcm_registrations ( user_id TEXT NOT NULL, device_id TEXT NOT NULL, fcm_token TEXT NOT NULL, + + -- The client's self-declared platform, in practice "ios", "android" or + -- "web", though the route accepts any non-empty string. platform TEXT NOT NULL, + test_mode INTEGER, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, @@ -38,7 +42,16 @@ CREATE TABLE IF NOT EXISTS alert_authorization_batches ( id TEXT PRIMARY KEY NOT NULL, user_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 ); @@ -54,11 +67,18 @@ CREATE TABLE IF NOT EXISTS alert_authorization_jwts ( user_id TEXT NOT NULL, batch_id TEXT NOT NULL, sequence INTEGER NOT NULL, + + -- The UTC calendar day this JWT covers, as "YYYY-MM-DD". day TEXT NOT NULL, + jwt TEXT NOT NULL, nbf INTEGER NOT NULL, exp INTEGER NOT NULL, + + -- Either "unused" or "consumed", the value the partial unique index below + -- keys on. status TEXT NOT NULL, + consumed_at TEXT, created_at TEXT NOT NULL ); @@ -117,8 +137,14 @@ CREATE TABLE IF NOT EXISTS sms_phone_log ( user_id TEXT NOT NULL, phone_e164 TEXT, phone_hash TEXT NOT NULL, + + -- One of the SmsPhoneLogAction names in src/models/smsRegistration.ts, such + -- as "code-sent" or "alert-authorization-deleted". action TEXT NOT NULL, + + -- Either "ok", "rejected" or "failed". result TEXT NOT NULL, + detail TEXT, jwt_hash TEXT, provider_message_id TEXT, @@ -138,7 +164,11 @@ CREATE TABLE IF NOT EXISTS sms_action_jwt_use ( id TEXT PRIMARY KEY NOT NULL, jwt_hash TEXT NOT NULL, user_id TEXT NOT NULL, + + -- One of the SMS_ACTIONS names in src/middleware/smsActionJwt.ts, such as + -- "register-phone" or "revoke-alert-search". action TEXT NOT NULL, + used_at TEXT NOT NULL ); @@ -152,7 +182,16 @@ CREATE TABLE IF NOT EXISTS sms_alert_authorization_batches ( id TEXT PRIMARY KEY NOT NULL, user_id TEXT NOT NULL, batch_id TEXT NOT NULL, - timezone TEXT NOT NULL, + + -- 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 ); @@ -168,11 +207,18 @@ CREATE TABLE IF NOT EXISTS sms_alert_authorization_jwts ( user_id TEXT NOT NULL, batch_id TEXT NOT NULL, sequence INTEGER NOT NULL, + + -- The UTC calendar day this JWT covers, as "YYYY-MM-DD". day TEXT NOT NULL, + jwt TEXT NOT NULL, nbf INTEGER NOT NULL, exp INTEGER NOT NULL, + + -- Either "unused" or "consumed", the value the partial unique index below + -- keys on. status TEXT NOT NULL, + consumed_at TEXT, created_at TEXT NOT NULL ); @@ -196,7 +242,11 @@ CREATE TABLE IF NOT EXISTS sms_blocked_numbers ( id TEXT PRIMARY KEY NOT NULL, phone_hash TEXT NOT NULL, phone_e164 TEXT, + + -- Either "opt-out", "provider-opt-out" or "manual", ranked in that order of + -- strength so a block is never quietly downgraded. reason TEXT NOT NULL, + detail TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL diff --git a/src/middleware/smsActionJwt.ts b/src/middleware/smsActionJwt.ts index 973b59e..c636b23 100644 --- a/src/middleware/smsActionJwt.ts +++ b/src/middleware/smsActionJwt.ts @@ -12,10 +12,20 @@ export const SMS_ACTIONS = [ "verify-phone", "delete-phone", "authorize-alert-search", + "revoke-alert-search", ] as const; export type SmsAction = (typeof SMS_ACTIONS)[number]; +/** + * Actions on the DID's whole alert authorization rather than on one handset. + * They bind to no number, so a claim carrying one is neither required nor read. + */ +const PHONELESS_ACTIONS = new Set([ + "authorize-alert-search", + "revoke-alert-search", +]); + export const SMS_ACTION_CLAIM_CONTEXT = "https://giftopia.tech"; export const SMS_ACTION_CLAIM_TYPE = "SmsNotificationAction"; @@ -99,7 +109,7 @@ export function requireSmsActionJwt(action: SmsAction) { return; } - if (action !== "authorize-alert-search") { + if (!PHONELESS_ACTIONS.has(action)) { const requested = requestPhoneNumber(req); // list-phones without the query parameter binds to no number at all. const bindsPhone = action !== "list-phones" || requested !== undefined; diff --git a/src/models/smsRegistration.ts b/src/models/smsRegistration.ts index c7e1f00..2904d44 100644 --- a/src/models/smsRegistration.ts +++ b/src/models/smsRegistration.ts @@ -28,6 +28,7 @@ export type SmsPhoneLogAction = | "did-limit-disclosed" | "deleted" | "alert-authorization-stored" + | "alert-authorization-deleted" | "alert-sent" | "alert-send-failed" | "recipient-not-allowed" diff --git a/src/routes/notifications.ts b/src/routes/notifications.ts index ab66b3f..3d7e8e3 100644 --- a/src/routes/notifications.ts +++ b/src/routes/notifications.ts @@ -8,6 +8,7 @@ import { } from "../middleware/auth.js"; import { type AlertAuthorizationRequestBody, + storedNotifyLabel, unsupportedIdentityFailure, validateAlertAuthorizationBatch, } from "../services/alertAuthorization.js"; @@ -149,13 +150,17 @@ notificationsRouter.put( const stored = await alertAuthorizationDb.replaceUnusedBatch({ userId, batchId: validated.batchId, + notifyHourUtc: validated.notifyHourUtc, + notifyMinuteUtc: validated.notifyMinuteUtc, timezone: validated.timezone, jwts: validated.jwts, }); res.status(200).json({ success: true, batchId: stored.batch.batchId, - timezone: stored.batch.timezone, + notifyHourUtc: stored.batch.notifyHourUtc ?? null, + notifyMinuteUtc: stored.batch.notifyMinuteUtc ?? null, + timezone: stored.batch.timezone ?? null, storedCount: stored.storedCount, unusedCount: stored.unusedCount, }); @@ -163,7 +168,8 @@ notificationsRouter.put( "[AlertAuthorization] Completed in", formatElapsedMs(Date.now() - started) + ",", "batchId=" + stored.batch.batchId + ",", - "stored=" + stored.storedCount + "stored=" + stored.storedCount + ",", + "notifyUtc=" + storedNotifyLabel(stored.batch) ); } catch (err) { 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( "/register", requireAuthOrNotificationLocalTest, diff --git a/src/routes/notifySms.ts b/src/routes/notifySms.ts index 41295ec..163bfb9 100644 --- a/src/routes/notifySms.ts +++ b/src/routes/notifySms.ts @@ -14,6 +14,7 @@ import { import type { SmsPhoneLogAction } from "../models/smsRegistration.js"; import { type AlertAuthorizationRequestBody, + storedNotifyLabel, unsupportedIdentityFailure, validateAlertAuthorizationBatch, } from "../services/alertAuthorization.js"; @@ -71,6 +72,9 @@ const CODE_SEND_ACTIONS: SmsPhoneLogAction[] = ["code-sent", "code-send-failed"] const EMPTY_TWIML = ''; +/** Prefix that keeps a stand-in log hash out of the phone-number namespace. */ +const NO_PHONE_HASH_SUBJECT = "no-phone:"; + function escapeXml(text: string): string { return text .replace(/&/g, "&") @@ -707,6 +711,8 @@ export function createNotifySmsRouter(deps: NotifySmsDeps = {}): express.Router const stored = await smsAlertAuthorizationDb.replaceUnusedBatch({ userId, batchId: validated.batchId, + notifyHourUtc: validated.notifyHourUtc, + notifyMinuteUtc: validated.notifyMinuteUtc, timezone: validated.timezone, jwts: validated.jwts, }); @@ -716,20 +722,27 @@ export function createNotifySmsRouter(deps: NotifySmsDeps = {}): express.Router phoneHash: hashPhoneNumber(verified[0].phoneE164, secret), action: "alert-authorization-stored", result: "ok", - detail: "batchId=" + stored.batch.batchId, + detail: + "batchId=" + + stored.batch.batchId + + " notifyUtc=" + + storedNotifyLabel(stored.batch), jwtHash, }); res.status(200).json({ success: true, 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, unusedCount: stored.unusedCount, }); log.info( "[NotifySmsAlertAuthorization] Completed in", formatElapsedMs(Date.now() - started) + ",", - "stored=" + stored.storedCount + "stored=" + stored.storedCount + ",", + "notifyUtc=" + storedNotifyLabel(stored.batch) ); } catch (err) { 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 * 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.put("/alert-authorization", alertAuthorizationChain); + router.delete( + "/alert-authorization", + ...authStages, + requireSmsActionJwt("revoke-alert-search"), + deleteAlertAuthorization + ); + return router; } diff --git a/src/services/alertAuthorization.ts b/src/services/alertAuthorization.ts index 912471d..7f2e1e7 100644 --- a/src/services/alertAuthorization.ts +++ b/src/services/alertAuthorization.ts @@ -6,10 +6,14 @@ import { } from "../vc/index.js"; const DAY_RE = /^\d{4}-\d{2}-\d{2}$/; + +const SECONDS_PER_DAY = 86400; const MAX_REPORTED_ERRORS = 20; export type AlertAuthorizationRequestBody = { batchId?: unknown; + notifyHourUtc?: unknown; + notifyMinuteUtc?: unknown; timezone?: unknown; jwts?: unknown; }; @@ -25,7 +29,15 @@ export type ValidatedAlertJwt = { export type BatchValidationSuccess = { ok: true; 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[]; }; @@ -36,14 +48,61 @@ export type BatchValidationFailure = { details: string[]; }; -type JwtItemBody = { - sequence?: unknown; - day?: unknown; - jwt?: unknown; - nbf?: unknown; - exp?: unknown; -}; +/** `YYYY-MM-DD` in UTC — the frame every stored `day` is labelled in. */ +export function utcCalendarDay(epochSec: number): string { + return new Date(epochSec * 1000).toISOString().slice(0, 10); +} +/** Midnight UTC that opens `day`, or undefined when `day` is not a real date. */ +export function utcDayStartSeconds(day: string): number | undefined { + if (!DAY_RE.test(day)) return undefined; + const parsed = Date.parse(day + "T00:00:00Z"); + if (Number.isNaN(parsed)) return undefined; + const seconds = Math.floor(parsed / 1000); + // Date.parse rolls 2026-02-30 forward to March rather than refusing it. + return utcCalendarDay(seconds) === day ? seconds : undefined; +} + +/** + * Zero-pads an hour and minute into the `HH:MM` the column stores. The padding + * is what makes SQLite's text comparison against that column chronological. + */ +export function formatHourMinuteUtc(hour: number, minute: number): string { + return ( + String(hour).padStart(2, "0") + ":" + String(minute).padStart(2, "0") + ); +} + +/** The stored hour as one loggable token, or "none" when a batch names none. */ +export function storedNotifyLabel(batch: { + notifyHourUtc?: number; + notifyMinuteUtc?: number; +}): string { + if (batch.notifyHourUtc === undefined || batch.notifyMinuteUtc === undefined) { + return "none"; + } + return formatHourMinuteUtc(batch.notifyHourUtc, batch.notifyMinuteUtc); +} + +/** Splits a stored `HH:MM` back into its parts, or undefined if malformed. */ +export function parseHourMinuteUtc( + value: string +): { hour: number; minute: number } | undefined { + const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value); + if (match === null) return undefined; + return { hour: Number(match[1]), minute: Number(match[2]) }; +} + +/** `HH:MM` UTC, the form `notify_hour_min_utc` is stored in and compared against. */ +export function utcHourMinute(epochSec: number): string { + return new Date(epochSec * 1000).toISOString().slice(11, 16); +} + +/** + * Whether `Intl` can resolve the name. Checked at the door because a zone this + * service cannot resolve is worth nothing to whatever reads the column later, + * and the caller is the only party able to correct it. + */ export function isValidIanaTimeZone(timezone: string): boolean { if (timezone.length === 0) return false; try { @@ -54,18 +113,13 @@ export function isValidIanaTimeZone(timezone: string): boolean { } } -export function calendarDayInTimeZone(epochSec: number, timeZone: string): string { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone, - year: "numeric", - month: "2-digit", - day: "2-digit", - }).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}`; -} +type JwtItemBody = { + sequence?: unknown; + day?: unknown; + jwt?: unknown; + nbf?: unknown; + exp?: unknown; +}; function clientErrorInfo(err: unknown): { message: string; code?: string } { if (err && typeof err === "object" && "clientError" in err) { @@ -85,6 +139,30 @@ function isFiniteInteger(value: unknown): value is number { return typeof value === "number" && Number.isInteger(value); } +/** + * One half of the notify time: required, an integer, and inside its own range. + * Reported per field so a caller sending only the hour learns which is missing. + */ +function validUtcPart( + value: unknown, + field: string, + max: number, + details: string[] +): number | undefined { + if (value === undefined || value === null) { + details.push(`${field} is required: an integer 0-${max}, in UTC`); + return undefined; + } + if (!isFiniteInteger(value) || value < 0 || value > max) { + details.push( + `${field} must be an integer 0-${max}, in UTC, got ` + + JSON.stringify(value) + ); + return undefined; + } + return value; +} + export function unsupportedIdentityFailure(did: string): BatchValidationFailure { return { ok: false, @@ -110,12 +188,38 @@ export async function validateAlertAuthorizationBatch( details.push("batchId is required"); } - const timezone = - typeof body.timezone === "string" ? body.timezone.trim() : undefined; - if (timezone === undefined || timezone.length === 0) { - details.push("timezone is required (IANA name used when minting validity windows)"); - } else if (!isValidIanaTimeZone(timezone)) { - details.push(`timezone is not a valid IANA time zone: ${timezone}`); + // Both required, and both UTC — the field names say so, which is why no + // offset or zone travels with them. Required rather than optional because the + // alternative default is not "no gate" but "the first tick after midnight + // UTC", the setting that puts every user on one tick. + const notifyHourUtc = validUtcPart( + body.notifyHourUtc, + "notifyHourUtc", + 23, + details + ); + const notifyMinuteUtc = validUtcPart( + body.notifyMinuteUtc, + "notifyMinuteUtc", + 59, + details + ); + + // Recorded against the day a later DST mechanism might need it. Optional and + // independent of notifyTime: the offset in that value is what schedules a + // send today, and nothing here reads this one. + let timezone: string | undefined; + if (body.timezone !== undefined && body.timezone !== null) { + const raw = + typeof body.timezone === "string" ? body.timezone.trim() : undefined; + if (raw === undefined || !isValidIanaTimeZone(raw)) { + details.push( + `timezone must be an IANA zone name (e.g. "America/Denver"), got ` + + JSON.stringify(body.timezone) + ); + } else { + timezone = raw; + } } if (!Array.isArray(body.jwts)) { @@ -153,10 +257,15 @@ export async function validateAlertAuthorizationBatch( 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`); continue; } + const dayStart = utcDayStartSeconds(item.day); + if (dayStart === undefined) { + details.push(`${prefix}.day must be a real UTC date as YYYY-MM-DD`); + continue; + } if (days.has(item.day)) { details.push(`${prefix}.day ${item.day} is duplicated`); } else { @@ -177,13 +286,15 @@ export async function validateAlertAuthorizationBatch( continue; } - if (timezone !== undefined && isValidIanaTimeZone(timezone)) { - const dayFromNbf = calendarDayInTimeZone(item.nbf, timezone); - if (dayFromNbf !== item.day) { - details.push( - `${prefix}.day ${item.day} does not match nbf ${item.nbf} in timezone ${timezone} (${dayFromNbf})` - ); - } + // The daily run picks a JWT by UTC day and may fire at any moment inside + // it, catch-up runs included. A window that only covers part of that day + // would hand Endorser a credential outside its own validity period. + const dayEnd = dayStart + SECONDS_PER_DAY; + if (item.nbf > dayStart || item.exp < dayEnd) { + details.push( + `${prefix} must be valid for all of UTC day ${item.day} ` + + `(nbf <= ${dayStart} and exp >= ${dayEnd}), got nbf ${item.nbf} exp ${item.exp}` + ); } try { @@ -259,7 +370,8 @@ export async function validateAlertAuthorizationBatch( if ( details.length > 0 || batchId === undefined || - timezone === undefined || + notifyHourUtc === undefined || + notifyMinuteUtc === undefined || validated.length !== EXPECTED_ALERT_JWT_BATCH_SIZE ) { return fail(details); @@ -268,6 +380,8 @@ export async function validateAlertAuthorizationBatch( return { ok: true, batchId, + notifyHourUtc, + notifyMinuteUtc, timezone, jwts: validated, }; diff --git a/src/util/concurrency.ts b/src/util/concurrency.ts new file mode 100644 index 0000000..15a17c5 --- /dev/null +++ b/src/util/concurrency.ts @@ -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( + items: readonly T[], + limit: number, + work: (item: T) => Promise +): Promise { + 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); +} diff --git a/test/alertSearch/smsNotify.test.ts b/test/alertSearch/smsNotify.test.ts index 7b6c695..34e71aa 100644 --- a/test/alertSearch/smsNotify.test.ts +++ b/test/alertSearch/smsNotify.test.ts @@ -89,7 +89,7 @@ function daily( ): DailyAlertSearchResult { return { userId: USER, - localDay: "2026-09-05", + utcDay: "2026-09-05", batchId: "sms-batch-1", jwtSequence: 1, endorserOutcome: "success", diff --git a/test/alertSearch/smsScheduler.test.ts b/test/alertSearch/smsScheduler.test.ts index f17e380..62eeaa2 100644 --- a/test/alertSearch/smsScheduler.test.ts +++ b/test/alertSearch/smsScheduler.test.ts @@ -19,6 +19,15 @@ import { const USER = "did:ethr:0xschedone"; const OTHER = "did:ethr:0xschedtwo"; +/** The day every seeded JWT belongs to, and an instant inside it. */ +const DAY = "2026-09-05"; +const NOW = new Date(`${DAY}T12:00:00.000Z`); + +/** A pending list where every named user's hour has already come. */ +function due(userIds: string[]) { + return userIds.map((userId) => ({ userId, due: true })); +} + let dir: string; let savedDataDir: string | undefined; @@ -42,7 +51,7 @@ afterEach(async () => { function daily(userId: string): DailyAlertSearchResult { return { userId, - localDay: "2026-09-05", + utcDay: "2026-09-05", batchId: "b", jwtSequence: 1, endorserOutcome: "success", @@ -58,7 +67,8 @@ describe("runSmsAlertSearchSchedulerPass", () => { const ran: string[] = []; const notified: string[] = []; const result = await runSmsAlertSearchSchedulerPass({ - listUserIds: async () => [USER, OTHER], + concurrency: 1, + listPending: async () => due([USER, OTHER]), runDaily: async (userId) => { ran.push(userId); return daily(userId); @@ -80,11 +90,12 @@ describe("runSmsAlertSearchSchedulerPass", () => { await smsAlertAuthorizationDb.replaceUnusedBatch({ userId: USER, batchId: "sms-1", - timezone: "UTC", - jwts: [{ sequence: 1, day: "2026-09-05", jwt: "j", nbf: 1, exp: 2 }], + jwts: [{ sequence: 1, day: DAY, jwt: "j", nbf: 1, exp: 2 }], }); const seen: string[] = []; await runSmsAlertSearchSchedulerPass({ + concurrency: 1, + now: NOW, runDaily: async (userId) => { seen.push(userId); return daily(userId); @@ -98,7 +109,8 @@ describe("runSmsAlertSearchSchedulerPass", () => { it("counts a failing user and keeps going", async () => { const notified: string[] = []; const result = await runSmsAlertSearchSchedulerPass({ - listUserIds: async () => [USER, OTHER], + concurrency: 1, + listPending: async () => due([USER, OTHER]), runDaily: async (userId) => { if (userId === USER) throw new Error("Endorser down"); return daily(userId); @@ -114,7 +126,8 @@ describe("runSmsAlertSearchSchedulerPass", () => { it("does not let a failing notification fail the user", async () => { const result = await runSmsAlertSearchSchedulerPass({ - listUserIds: async () => [USER], + concurrency: 1, + listPending: async () => due([USER]), runDaily: async (userId) => daily(userId), notify: async () => { throw new Error("Twilio down"); @@ -130,7 +143,8 @@ describe("runSmsAlertSearchSchedulerPass", () => { release = resolve; }); const running = runSmsAlertSearchSchedulerPass({ - listUserIds: async () => [USER], + concurrency: 1, + listPending: async () => due([USER]), runDaily: async (userId) => { await gate; return daily(userId); @@ -141,7 +155,8 @@ describe("runSmsAlertSearchSchedulerPass", () => { assert.equal(isSmsAlertSearchSchedulerPassInFlight(), true); const skipped = await runSmsAlertSearchSchedulerPass({ - listUserIds: async () => [OTHER], + concurrency: 1, + listPending: async () => due([OTHER]), prune: async () => undefined, }); assert.equal(skipped.skipped, true); @@ -152,6 +167,147 @@ describe("runSmsAlertSearchSchedulerPass", () => { assert.equal(isSmsAlertSearchSchedulerPassInFlight(), false); }); + it("defers a user the pending list reports as not yet due", async () => { + const ran: string[] = []; + const result = await runSmsAlertSearchSchedulerPass({ + concurrency: 1, + listPending: async () => [ + { userId: USER, due: false }, + { userId: OTHER, due: true }, + ], + runDaily: async (userId) => { + ran.push(userId); + return daily(userId); + }, + notify: async () => undefined, + prune: async () => undefined, + }); + + assert.deepEqual(ran, [OTHER]); + assert.equal(result.deferred, 1); + assert.equal(result.attempted, 1); + assert.equal(result.failed, 0); + }); + + it("holds the stored hour back until that instant, then runs", async () => { + // 18:00-06:00 is 00:00 UTC, so this batch asks for the very top of each UTC + // day; a batch stored at 18:00 UTC is the one that has to wait. + await smsAlertAuthorizationDb.replaceUnusedBatch({ + userId: USER, + batchId: "sms-1", + notifyHourUtc: 18, + notifyMinuteUtc: 0, + jwts: [{ sequence: 1, day: DAY, jwt: "j", nbf: 1, exp: 2 }], + }); + + const ran: string[] = []; + const track = async (userId: string) => { + ran.push(userId); + return daily(userId); + }; + + const before = await runSmsAlertSearchSchedulerPass({ + concurrency: 1, + now: new Date(`${DAY}T17:55:00.000Z`), + runDaily: track, + notify: async () => undefined, + prune: async () => undefined, + }); + assert.deepEqual(ran, []); + assert.equal(before.deferred, 1); + assert.equal(before.attempted, 0); + + const after = await runSmsAlertSearchSchedulerPass({ + concurrency: 1, + now: new Date(`${DAY}T18:05:00.000Z`), + runDaily: track, + notify: async () => undefined, + prune: async () => undefined, + }); + assert.deepEqual(ran, [USER]); + assert.equal(after.deferred, 0); + assert.equal(after.attempted, 1); + }); + + it("drops the user entirely once the UTC day rolls past their JWT", async () => { + await smsAlertAuthorizationDb.replaceUnusedBatch({ + userId: USER, + batchId: "sms-1", + notifyHourUtc: 18, + notifyMinuteUtc: 0, + jwts: [{ sequence: 1, day: DAY, jwt: "j", nbf: 1, exp: 2 }], + }); + + const result = await runSmsAlertSearchSchedulerPass({ + concurrency: 1, + now: new Date("2026-09-06T00:05:00.000Z"), + runDaily: async (userId) => daily(userId), + notify: async () => undefined, + prune: async () => undefined, + }); + // No JWT for the new day, so there is nothing pending to defer. + assert.equal(result.attempted, 0); + assert.equal(result.deferred, 0); + }); + + it("runs a batch that carries no notifyTime", async () => { + await smsAlertAuthorizationDb.replaceUnusedBatch({ + userId: USER, + batchId: "sms-1", + jwts: [{ sequence: 1, day: DAY, jwt: "j", nbf: 1, exp: 2 }], + }); + const ran: string[] = []; + const result = await runSmsAlertSearchSchedulerPass({ + concurrency: 1, + now: NOW, + runDaily: async (userId) => { + ran.push(userId); + return daily(userId); + }, + notify: async () => undefined, + prune: async () => undefined, + }); + assert.deepEqual(ran, [USER]); + assert.equal(result.deferred, 0); + }); + + it("fails the whole pass when the pending query itself fails", async () => { + await assert.rejects( + () => + runSmsAlertSearchSchedulerPass({ + concurrency: 1, + listPending: async () => { + throw new Error("database locked"); + }, + prune: async () => undefined, + }), + /database locked/ + ); + // The guard must not stay stuck after a thrown pass. + assert.equal(isSmsAlertSearchSchedulerPassInFlight(), false); + }); + + it("works on several users at once when concurrency allows", async () => { + const userIds = Array.from({ length: 12 }, (_, i) => `did:ethr:0x${i}`); + let inFlight = 0; + let peak = 0; + const result = await runSmsAlertSearchSchedulerPass({ + concurrency: 4, + listPending: async () => due(userIds), + runDaily: async (userId) => { + inFlight += 1; + peak = Math.max(peak, inFlight); + await new Promise((resolve) => setTimeout(resolve, 5)); + inFlight -= 1; + return daily(userId); + }, + notify: async () => undefined, + prune: async () => undefined, + }); + assert.equal(result.attempted, 12); + assert.equal(peak, 4, `expected 4 in flight, saw ${peak}`); + }); + it("prunes action-JWT rows past the retention window", async () => { process.env.SMS_ACTION_JWT_MAX_AGE_SEC = "60"; const retentionMs = @@ -172,7 +328,8 @@ describe("runSmsAlertSearchSchedulerPass", () => { assert.equal(await smsActionJwtUseDb.count(), 2); await runSmsAlertSearchSchedulerPass({ - listUserIds: async () => [], + concurrency: 1, + listPending: async () => due([]), notify: async () => undefined, }); assert.equal(await smsActionJwtUseDb.count(), 1); @@ -181,7 +338,8 @@ describe("runSmsAlertSearchSchedulerPass", () => { it("survives a prune failure", async () => { const result = await runSmsAlertSearchSchedulerPass({ - listUserIds: async () => [], + concurrency: 1, + listPending: async () => due([]), prune: async () => { throw new Error("locked"); }, diff --git a/test/db/smsSqlite.test.ts b/test/db/smsSqlite.test.ts index 8cbdcfe..2c85d7a 100644 --- a/test/db/smsSqlite.test.ts +++ b/test/db/smsSqlite.test.ts @@ -240,7 +240,6 @@ describe("smsAlertAuthorizationDb", () => { await smsAlertAuthorizationDb.replaceUnusedBatch({ userId: USER, batchId: "sms-batch-1", - timezone: "America/Denver", jwts: batchJwts("2026-09-05"), }); @@ -256,7 +255,6 @@ describe("smsAlertAuthorizationDb", () => { await smsAlertAuthorizationDb.replaceUnusedBatch({ userId: USER, batchId: "sms-batch-1", - timezone: "UTC", jwts: [ ...batchJwts("2026-09-05"), { sequence: 2, day: "2026-09-06", jwt: "jwt-b", nbf: 1, exp: 2 }, @@ -278,7 +276,6 @@ describe("smsAlertAuthorizationDb", () => { await smsAlertAuthorizationDb.replaceUnusedBatch({ userId: USER, batchId: "sms-batch-2", - timezone: "UTC", 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)", () => { const connection = getDatabase(); const now = new Date().toISOString(); diff --git a/test/routes/notifySms.test.ts b/test/routes/notifySms.test.ts index 3b55a45..26c33f8 100644 --- a/test/routes/notifySms.test.ts +++ b/test/routes/notifySms.test.ts @@ -10,7 +10,7 @@ import { smsPhoneLogDb } from "../../src/db/smsPhoneLogSqlite.js"; import { smsBlockedNumbersDb } from "../../src/db/smsBlockedNumbersSqlite.js"; import { smsRegistrationsDb } from "../../src/db/smsRegistrationsSqlite.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 { twilioSignatureFor } from "../../src/services/twilioSignature.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 USER = "did:ethr:0xrouteuser"; 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 = [ "SMS_ENABLED", @@ -660,21 +664,28 @@ describe("POST /notify-sms/alert-authorization", () => { 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 { 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) { - const nbf = base + i * 86400; - const exp = nbf + 86400; + const nbf = firstDay + i * DAY_SECONDS; + const exp = nbf + DAY_SECONDS; jwts.push({ sequence: i + 1, - day: calendarDayInTimeZone(nbf, TZ), + day: utcCalendarDay(nbf), jwt: delegatedJwt({ iss: userId, nbf, exp }), nbf, 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" }; @@ -699,14 +710,16 @@ describe("POST /notify-sms/alert-authorization", () => { }); assert.equal(result.status, 200, JSON.stringify(result.body)); assert.equal(result.body.storedCount, 100); - assert.equal(result.body.timezone, TZ); assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 100); const log = await smsPhoneLogDb.listByUserId(USER); const stored = log.find( (row) => row.action === "alert-authorization-stored" ); - assert.equal(stored?.detail, "batchId=sms-batch-1"); + assert.equal( + stored?.detail, + `batchId=sms-batch-1 notifyUtc=${NOTIFY_LABEL}` + ); }); it("accepts PUT as an alias for the same handler", async () => { @@ -728,7 +741,7 @@ describe("POST /notify-sms/alert-authorization", () => { body: batch(), 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); assert.ok(first); 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[]][] = [ + ["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 () => { await registerAndVerify(); const result = await call({ path: "/notify-sms/alert-authorization", - body: { batchId: "b", timezone: TZ, jwts: [] }, + body: { batchId: "b", jwts: [] }, auth: { claim: authorizeClaim }, }); 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 { + 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 { + 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 { + 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", () => { const TOKEN = "twilio-auth-token"; diff --git a/test/services/alertNotifyTime.test.ts b/test/services/alertNotifyTime.test.ts new file mode 100644 index 0000000..ff97ddd --- /dev/null +++ b/test/services/alertNotifyTime.test.ts @@ -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); + }); +}); diff --git a/test/util/concurrency.test.ts b/test/util/concurrency.test.ts new file mode 100644 index 0000000..39e0781 --- /dev/null +++ b/test/util/concurrency.test.ts @@ -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); + }); +});