add notify hour-minute setting, and a DELETE for an existing alert
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <current-user-JWT>
|
||||
```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.
|
||||
|
||||
@@ -184,6 +184,14 @@ async function main(): Promise<void> {
|
||||
"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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
+12
-33
@@ -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<AlertSearchChannel, AlertAuthorizationStore> = {
|
||||
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<AlertSearchCycleInput, "jwt">;
|
||||
|
||||
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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<DailyAlertSearchResult["digest"]> = [];
|
||||
await runAlertSearchSchedulerPass({
|
||||
now: NOW,
|
||||
concurrency: 1,
|
||||
runDaily: async (userId) => eligibleDaily(userId),
|
||||
notify: async (result) => {
|
||||
notifies.push(result.digest);
|
||||
},
|
||||
});
|
||||
await runAlertSearchSchedulerPass({
|
||||
now: NOW,
|
||||
concurrency: 1,
|
||||
runDaily: async (userId) => stubDailyResult(userId),
|
||||
notify: async (result) => {
|
||||
notifies.push(result.digest);
|
||||
@@ -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;
|
||||
|
||||
@@ -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<unknown>;
|
||||
|
||||
export type AlertSearchSchedulerPassInput = {
|
||||
listUserIds?: () => Promise<string[]>;
|
||||
listPending?: (input: {
|
||||
day: string;
|
||||
hourMinute: string;
|
||||
}) => Promise<PendingDayUser[]>;
|
||||
runDaily?: AlertSearchUserRunner;
|
||||
notify?: AlertSearchNotifyRunner;
|
||||
/** The instant this pass represents. Defaults to now. */
|
||||
now?: Date;
|
||||
/** Users worked on at once. Set to 1 for a deterministic order in tests. */
|
||||
concurrency?: number;
|
||||
};
|
||||
|
||||
export type AlertSearchSchedulerPassResult = {
|
||||
skipped: boolean;
|
||||
/** The users this pass ran, in no guaranteed order. */
|
||||
userIds: string[];
|
||||
attempted: number;
|
||||
failed: number;
|
||||
/** Users holding an unused JWT whose chosen UTC time has not arrived yet. */
|
||||
deferred: number;
|
||||
};
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | undefined;
|
||||
@@ -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<AlertSearchSchedulerPassResult> {
|
||||
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,15 +78,28 @@ 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) {
|
||||
await forEachWithConcurrency(
|
||||
userIds,
|
||||
input.concurrency ?? ALERT_SEARCH_USER_CONCURRENCY,
|
||||
async (userId) => {
|
||||
try {
|
||||
const daily = await runDaily(userId);
|
||||
try {
|
||||
@@ -85,17 +120,19 @@ export async function runAlertSearchSchedulerPass(
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
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(
|
||||
|
||||
@@ -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<string[]>;
|
||||
listPending?: (input: {
|
||||
day: string;
|
||||
hourMinute: string;
|
||||
}) => Promise<PendingDayUser[]>;
|
||||
runDaily?: (userId: string) => Promise<DailyAlertSearchResult>;
|
||||
notify?: (result: DailyAlertSearchResult) => Promise<unknown>;
|
||||
prune?: () => Promise<unknown>;
|
||||
/** The instant this pass represents. Defaults to now. */
|
||||
now?: Date;
|
||||
/** Users worked on at once. Set to 1 for a deterministic order in tests. */
|
||||
concurrency?: number;
|
||||
};
|
||||
|
||||
export type SmsAlertSearchSchedulerPassResult = {
|
||||
skipped: boolean;
|
||||
/** The users this pass ran, in no guaranteed order. */
|
||||
userIds: string[];
|
||||
attempted: number;
|
||||
failed: number;
|
||||
/** Users holding an unused JWT whose chosen UTC time has not arrived yet. */
|
||||
deferred: number;
|
||||
};
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | undefined;
|
||||
@@ -48,16 +67,16 @@ async function defaultPrune(): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<SmsAlertSearchSchedulerPassResult> {
|
||||
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,10 +104,19 @@ 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) {
|
||||
await forEachWithConcurrency(
|
||||
userIds,
|
||||
input.concurrency ?? ALERT_SEARCH_USER_CONCURRENCY,
|
||||
async (userId) => {
|
||||
try {
|
||||
const daily = await runDaily(userId);
|
||||
try {
|
||||
@@ -106,13 +137,20 @@ export async function runSmsAlertSearchSchedulerPass(
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
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",
|
||||
|
||||
@@ -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<PendingDayUser[]> {
|
||||
const rows = getDatabase()
|
||||
.prepare(
|
||||
`
|
||||
WITH latest AS (
|
||||
SELECT
|
||||
user_id,
|
||||
notify_hour_min_utc,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY user_id ORDER BY created_at DESC, rowid DESC
|
||||
) AS rn
|
||||
FROM ${tables.batches}
|
||||
)
|
||||
SELECT DISTINCT
|
||||
latest.user_id AS user_id,
|
||||
CASE
|
||||
WHEN latest.notify_hour_min_utc IS NULL THEN 1
|
||||
-- A value this comparison cannot read must not silence the user.
|
||||
-- Text ordering would rank anything non-numeric above every
|
||||
-- 'HH:MM', which would defer such a row forever rather than once.
|
||||
WHEN latest.notify_hour_min_utc NOT GLOB '[0-2][0-9]:[0-5][0-9]' THEN 1
|
||||
WHEN latest.notify_hour_min_utc <= @hourMinute THEN 1
|
||||
ELSE 0
|
||||
END AS due
|
||||
FROM latest
|
||||
JOIN ${tables.jwts} AS jwts
|
||||
ON jwts.user_id = latest.user_id
|
||||
AND jwts.day = @day
|
||||
AND jwts.status = @unused
|
||||
WHERE latest.rn = 1
|
||||
ORDER BY latest.user_id
|
||||
`
|
||||
)
|
||||
.all({
|
||||
day: input.day,
|
||||
hourMinute: input.hourMinute,
|
||||
unused: ALERT_JWT_STATUS_UNUSED,
|
||||
}) as { user_id: string; due: number }[];
|
||||
return rows.map((row) => ({ userId: row.user_id, due: row.due === 1 }));
|
||||
},
|
||||
|
||||
async listDistinctUserIds(): Promise<string[]> {
|
||||
const rows = getDatabase()
|
||||
.prepare(
|
||||
@@ -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".
|
||||
*/
|
||||
|
||||
+52
-2
@@ -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
|
||||
|
||||
@@ -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<SmsAction>([
|
||||
"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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
+86
-3
@@ -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 = '<?xml version="1.0" encoding="UTF-8"?><Response></Response>';
|
||||
|
||||
/** Prefix that keeps a stand-in log hash out of the phone-number namespace. */
|
||||
const NO_PHONE_HASH_SUBJECT = "no-phone:";
|
||||
|
||||
function escapeXml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 =
|
||||
// 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 (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}`);
|
||||
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,14 +286,16 @@ export async function validateAlertAuthorizationBatch(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (timezone !== undefined && isValidIanaTimeZone(timezone)) {
|
||||
const dayFromNbf = calendarDayInTimeZone(item.nbf, timezone);
|
||||
if (dayFromNbf !== item.day) {
|
||||
// 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}.day ${item.day} does not match nbf ${item.nbf} in timezone ${timezone} (${dayFromNbf})`
|
||||
`${prefix} must be valid for all of UTC day ${item.day} ` +
|
||||
`(nbf <= ${dayStart} and exp >= ${dayEnd}), got nbf ${item.nbf} exp ${item.exp}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const verified = await decodeAndVerifyDelegatedJwt(item.jwt);
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* How many users a scheduler pass works on at once. The per-user work is two
|
||||
* external round trips against independent cursors, so a serial loop spends the
|
||||
* whole pass waiting: at 200ms per user it stops fitting inside a five-minute
|
||||
* tick somewhere around 1,500 users, and the in-flight guard then starts
|
||||
* skipping passes.
|
||||
*
|
||||
* Held low deliberately. The ceiling this relieves is latency, not throughput,
|
||||
* and the two APIs on the other end are shared infrastructure that a wide fan-out
|
||||
* would simply move the queue into.
|
||||
*/
|
||||
export const ALERT_SEARCH_USER_CONCURRENCY = 8;
|
||||
|
||||
/**
|
||||
* Runs `work` over `items` with at most `limit` in flight, preserving neither
|
||||
* start nor finish order. Callers own their errors: a throw from `work` aborts
|
||||
* the remaining items, so anything that should merely be counted and skipped has
|
||||
* to be caught inside `work`.
|
||||
*/
|
||||
export async function forEachWithConcurrency<T>(
|
||||
items: readonly T[],
|
||||
limit: number,
|
||||
work: (item: T) => Promise<void>
|
||||
): Promise<void> {
|
||||
if (items.length === 0) return;
|
||||
|
||||
const width = Math.max(1, Math.min(Math.floor(limit), items.length));
|
||||
let next = 0;
|
||||
|
||||
const workers = Array.from({ length: width }, async () => {
|
||||
for (let index = next++; index < items.length; index = next++) {
|
||||
await work(items[index]);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(workers);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
|
||||
+312
-3
@@ -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();
|
||||
|
||||
+396
-11
@@ -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<string, unknown> {
|
||||
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, unknown>, string[]][] = [
|
||||
["neither", { batchId: "b", jwts }, ["notifyHourUtc", "notifyMinuteUtc"]],
|
||||
[
|
||||
"hour only",
|
||||
{ batchId: "b", notifyHourUtc: 18, jwts },
|
||||
["notifyMinuteUtc"],
|
||||
],
|
||||
[
|
||||
"minute only",
|
||||
{ batchId: "b", notifyMinuteUtc: 30, jwts },
|
||||
["notifyHourUtc"],
|
||||
],
|
||||
[
|
||||
"nulls",
|
||||
{ batchId: "b", notifyHourUtc: null, notifyMinuteUtc: null, jwts },
|
||||
["notifyHourUtc", "notifyMinuteUtc"],
|
||||
],
|
||||
];
|
||||
|
||||
for (const [label, body, missing] of cases) {
|
||||
const result = await call({
|
||||
path: "/notify-sms/alert-authorization",
|
||||
body,
|
||||
auth: { claim: authorizeClaim },
|
||||
});
|
||||
assert.equal(result.status, 400, label);
|
||||
const details = result.body.details as string[];
|
||||
for (const field of missing) {
|
||||
assert.ok(
|
||||
details.some((line) => line.startsWith(`${field} is required`)),
|
||||
`${label}: expected ${field}, got ${JSON.stringify(details)}`
|
||||
);
|
||||
}
|
||||
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 0);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an hour or minute outside its range, or not an integer", async () => {
|
||||
await registerAndVerify();
|
||||
const cases: [string, unknown, unknown][] = [
|
||||
["hour 24", 24, 0],
|
||||
["hour -1", -1, 0],
|
||||
["minute 60", 12, 60],
|
||||
["minute -1", 12, -1],
|
||||
["fractional hour", 12.5, 0],
|
||||
["string hour", "12", 0],
|
||||
["string minute", 12, "30"],
|
||||
["boolean hour", true, 0],
|
||||
["object minute", 12, {}],
|
||||
];
|
||||
// NaN is absent from this list on purpose: JSON.stringify turns it into
|
||||
// null, so it reaches the route as a missing field, not a malformed one.
|
||||
|
||||
for (const [label, notifyHourUtc, notifyMinuteUtc] of cases) {
|
||||
const result = await call({
|
||||
path: "/notify-sms/alert-authorization",
|
||||
body: { ...batch(), notifyHourUtc, notifyMinuteUtc },
|
||||
auth: { claim: authorizeClaim },
|
||||
});
|
||||
assert.equal(result.status, 400, label);
|
||||
assert.ok(
|
||||
(result.body.details as string[]).some((line) =>
|
||||
/^notify(Hour|Minute)Utc must be an integer/.test(line)
|
||||
),
|
||||
`${label}: ${JSON.stringify(result.body.details)}`
|
||||
);
|
||||
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 0);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts the ends of both ranges", async () => {
|
||||
await registerAndVerify();
|
||||
const result = await call({
|
||||
path: "/notify-sms/alert-authorization",
|
||||
body: { ...batch(), notifyHourUtc: 23, notifyMinuteUtc: 59 },
|
||||
auth: { claim: authorizeClaim },
|
||||
});
|
||||
assert.equal(result.status, 200, JSON.stringify(result.body));
|
||||
assert.equal(result.body.notifyHourUtc, 23);
|
||||
assert.equal(result.body.notifyMinuteUtc, 59);
|
||||
});
|
||||
|
||||
it("rejects a JWT whose window does not cover its whole UTC day", async () => {
|
||||
await registerAndVerify();
|
||||
const body = batch() as { jwts: { nbf: number; exp: number }[] };
|
||||
// An hour short at each end: usable at noon, useless at midnight.
|
||||
body.jwts[0].nbf += 3600;
|
||||
body.jwts[0].exp -= 3600;
|
||||
|
||||
const result = await call({
|
||||
path: "/notify-sms/alert-authorization",
|
||||
body,
|
||||
auth: { claim: authorizeClaim },
|
||||
});
|
||||
assert.equal(result.status, 400, JSON.stringify(result.body));
|
||||
assert.ok(
|
||||
(result.body.details as string[]).some((line) =>
|
||||
line.includes("must be valid for all of UTC day")
|
||||
),
|
||||
JSON.stringify(result.body.details)
|
||||
);
|
||||
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 0);
|
||||
});
|
||||
|
||||
it("rejects a day that is not a real date", async () => {
|
||||
await registerAndVerify();
|
||||
const body = batch() as { jwts: { day: string }[] };
|
||||
body.jwts[0].day = "2026-02-30";
|
||||
|
||||
const result = await call({
|
||||
path: "/notify-sms/alert-authorization",
|
||||
body,
|
||||
auth: { claim: authorizeClaim },
|
||||
});
|
||||
assert.equal(result.status, 400, JSON.stringify(result.body));
|
||||
assert.ok(
|
||||
(result.body.details as string[]).some((line) =>
|
||||
line.includes("must be a real UTC date")
|
||||
),
|
||||
JSON.stringify(result.body.details)
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a malformed batch after the phone gate passes", async () => {
|
||||
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, unknown>): string {
|
||||
const encode = (value: unknown) =>
|
||||
Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
return `${encode({ alg: "ES256K", typ: "JWT" })}.${encode(payload)}.sig`;
|
||||
}
|
||||
|
||||
/** 100 consecutive UTC days, each JWT valid for the whole of its own day. */
|
||||
function batch(userId = USER): Record<string, unknown> {
|
||||
const jwts = [];
|
||||
const firstDay =
|
||||
Math.floor(Date.now() / 1000 / DAY_SECONDS) * DAY_SECONDS;
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
const nbf = firstDay + i * DAY_SECONDS;
|
||||
const exp = nbf + DAY_SECONDS;
|
||||
jwts.push({
|
||||
sequence: i + 1,
|
||||
day: utcCalendarDay(nbf),
|
||||
jwt: delegatedJwt({ iss: userId, nbf, exp }),
|
||||
nbf,
|
||||
exp,
|
||||
});
|
||||
}
|
||||
return {
|
||||
batchId: "sms-batch-1",
|
||||
notifyHourUtc: NOTIFY_HOUR,
|
||||
notifyMinuteUtc: NOTIFY_MINUTE,
|
||||
jwts,
|
||||
};
|
||||
}
|
||||
|
||||
const revokeClaim = { action: "revoke-alert-search" };
|
||||
|
||||
async function authorize(did = USER): Promise<void> {
|
||||
const stored = await call({
|
||||
path: "/notify-sms/alert-authorization",
|
||||
body: batch(did),
|
||||
auth: { did, claim: { action: "authorize-alert-search" } },
|
||||
});
|
||||
assert.equal(stored.status, 200, JSON.stringify(stored.body));
|
||||
}
|
||||
|
||||
it("removes every batch and JWT, and stops the scheduler listing the DID", async () => {
|
||||
await registerAndVerify();
|
||||
await authorize();
|
||||
|
||||
const result = await call({
|
||||
method: "DELETE",
|
||||
path: "/notify-sms/alert-authorization",
|
||||
auth: { claim: revokeClaim },
|
||||
});
|
||||
assert.equal(result.status, 200, JSON.stringify(result.body));
|
||||
assert.equal(result.body.deletedBatches, 1);
|
||||
assert.equal(result.body.deletedJwts, 100);
|
||||
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 0);
|
||||
assert.deepEqual(await smsAlertAuthorizationDb.listDistinctUserIds(), []);
|
||||
});
|
||||
|
||||
it("keeps the verified phone, so re-authorizing needs no new code", async () => {
|
||||
await registerAndVerify();
|
||||
await authorize();
|
||||
await call({
|
||||
method: "DELETE",
|
||||
path: "/notify-sms/alert-authorization",
|
||||
auth: { claim: revokeClaim },
|
||||
});
|
||||
|
||||
assert.equal((await smsRegistrationsDb.get(USER, PHONE))?.verified, true);
|
||||
await authorize();
|
||||
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 100);
|
||||
});
|
||||
|
||||
it("records the revocation in the phone log", async () => {
|
||||
await registerAndVerify();
|
||||
await authorize();
|
||||
await call({
|
||||
method: "DELETE",
|
||||
path: "/notify-sms/alert-authorization",
|
||||
auth: { claim: revokeClaim },
|
||||
});
|
||||
|
||||
const log = await smsPhoneLogDb.listByUserId(USER);
|
||||
const row = log.find((entry) => entry.action === "alert-authorization-deleted");
|
||||
assert.equal(row?.result, "ok");
|
||||
assert.equal(row?.detail, "batches=1 jwts=100");
|
||||
});
|
||||
|
||||
it("touches only the calling DID's inventory", async () => {
|
||||
await registerAndVerify("did:ethr:0xa");
|
||||
await registerAndVerify("did:ethr:0xb", "+15555550124");
|
||||
await authorize("did:ethr:0xa");
|
||||
await authorize("did:ethr:0xb");
|
||||
|
||||
await call({
|
||||
method: "DELETE",
|
||||
path: "/notify-sms/alert-authorization",
|
||||
auth: { did: "did:ethr:0xa", claim: revokeClaim },
|
||||
});
|
||||
assert.equal(await smsAlertAuthorizationDb.countUnused("did:ethr:0xa"), 0);
|
||||
assert.equal(await smsAlertAuthorizationDb.countUnused("did:ethr:0xb"), 100);
|
||||
});
|
||||
|
||||
it("succeeds with zero counts when there was nothing to remove", async () => {
|
||||
await registerAndVerify();
|
||||
const result = await call({
|
||||
method: "DELETE",
|
||||
path: "/notify-sms/alert-authorization",
|
||||
auth: { claim: revokeClaim },
|
||||
});
|
||||
assert.equal(result.status, 200);
|
||||
assert.equal(result.body.deletedBatches, 0);
|
||||
assert.equal(result.body.deletedJwts, 0);
|
||||
});
|
||||
|
||||
it("works for a DID that has already deleted its phone", async () => {
|
||||
await registerAndVerify();
|
||||
await authorize();
|
||||
await call({
|
||||
method: "DELETE",
|
||||
body: { phoneNumber: PHONE },
|
||||
auth: { claim: { action: "delete-phone", phoneNumber: PHONE } },
|
||||
});
|
||||
|
||||
const result = await call({
|
||||
method: "DELETE",
|
||||
path: "/notify-sms/alert-authorization",
|
||||
auth: { claim: revokeClaim },
|
||||
});
|
||||
assert.equal(result.status, 200, JSON.stringify(result.body));
|
||||
assert.equal(result.body.deletedJwts, 100);
|
||||
});
|
||||
|
||||
it("refuses a claim that authorizes a different action", async () => {
|
||||
await registerAndVerify();
|
||||
await authorize();
|
||||
const result = await call({
|
||||
method: "DELETE",
|
||||
path: "/notify-sms/alert-authorization",
|
||||
auth: { claim: { action: "authorize-alert-search" } },
|
||||
});
|
||||
assert.equal(result.status, 403);
|
||||
assert.equal(result.body.error, "SMS_ACTION_JWT_WRONG_ACTION");
|
||||
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /notify-sms/inbound", () => {
|
||||
const TOKEN = "twilio-auth-token";
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
formatHourMinuteUtc,
|
||||
parseHourMinuteUtc,
|
||||
storedNotifyLabel,
|
||||
utcCalendarDay,
|
||||
utcDayStartSeconds,
|
||||
utcHourMinute,
|
||||
} from "../../src/services/alertAuthorization.js";
|
||||
|
||||
describe("formatHourMinuteUtc", () => {
|
||||
it("zero-pads both halves", () => {
|
||||
assert.equal(formatHourMinuteUtc(0, 0), "00:00");
|
||||
assert.equal(formatHourMinuteUtc(9, 5), "09:05");
|
||||
assert.equal(formatHourMinuteUtc(18, 30), "18:30");
|
||||
assert.equal(formatHourMinuteUtc(23, 59), "23:59");
|
||||
});
|
||||
|
||||
it("pads so text ordering matches clock ordering", () => {
|
||||
// The whole reason for the padding: "9:30" would sort above "10:00".
|
||||
assert.ok(formatHourMinuteUtc(9, 30) < formatHourMinuteUtc(10, 0));
|
||||
assert.ok(formatHourMinuteUtc(0, 0) < formatHourMinuteUtc(23, 59));
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseHourMinuteUtc", () => {
|
||||
it("round-trips whatever format produced", () => {
|
||||
for (const [hour, minute] of [
|
||||
[0, 0],
|
||||
[9, 5],
|
||||
[18, 30],
|
||||
[23, 59],
|
||||
]) {
|
||||
assert.deepEqual(parseHourMinuteUtc(formatHourMinuteUtc(hour, minute)), {
|
||||
hour,
|
||||
minute,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses anything that is not a padded HH:MM", () => {
|
||||
for (const bad of [
|
||||
"24:00",
|
||||
"07:60",
|
||||
"7:00",
|
||||
"07:00:00",
|
||||
"0700",
|
||||
"18:30-06:00",
|
||||
"midnight",
|
||||
"",
|
||||
]) {
|
||||
assert.equal(parseHourMinuteUtc(bad), undefined, bad);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("storedNotifyLabel", () => {
|
||||
it("renders the pair for a log line", () => {
|
||||
assert.equal(
|
||||
storedNotifyLabel({ notifyHourUtc: 18, notifyMinuteUtc: 30 }),
|
||||
"18:30"
|
||||
);
|
||||
assert.equal(
|
||||
storedNotifyLabel({ notifyHourUtc: 0, notifyMinuteUtc: 5 }),
|
||||
"00:05"
|
||||
);
|
||||
});
|
||||
|
||||
it("says none when either half is missing", () => {
|
||||
assert.equal(storedNotifyLabel({}), "none");
|
||||
assert.equal(storedNotifyLabel({ notifyHourUtc: 18 }), "none");
|
||||
assert.equal(storedNotifyLabel({ notifyMinuteUtc: 30 }), "none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("utcCalendarDay and utcHourMinute", () => {
|
||||
const at = (iso: string) => Math.floor(Date.parse(iso) / 1000);
|
||||
|
||||
it("reads the UTC calendar day, not the server's", () => {
|
||||
assert.equal(utcCalendarDay(at("2026-08-15T06:00:00Z")), "2026-08-15");
|
||||
assert.equal(utcCalendarDay(at("2026-08-14T23:59:59Z")), "2026-08-14");
|
||||
assert.equal(utcCalendarDay(at("2026-08-15T00:00:00Z")), "2026-08-15");
|
||||
});
|
||||
|
||||
it("renders the UTC clock in the form the column stores", () => {
|
||||
assert.equal(utcHourMinute(at("2026-08-15T00:00:00Z")), "00:00");
|
||||
assert.equal(utcHourMinute(at("2026-08-15T18:30:59Z")), "18:30");
|
||||
assert.equal(utcHourMinute(at("2026-08-15T23:59:00Z")), "23:59");
|
||||
});
|
||||
|
||||
it("agrees with formatHourMinuteUtc, since both feed one comparison", () => {
|
||||
const seconds = at("2026-08-15T09:05:00Z");
|
||||
assert.equal(utcHourMinute(seconds), formatHourMinuteUtc(9, 5));
|
||||
});
|
||||
});
|
||||
|
||||
describe("utcDayStartSeconds", () => {
|
||||
it("returns midnight UTC for a real date", () => {
|
||||
assert.equal(
|
||||
utcDayStartSeconds("2026-08-15"),
|
||||
Math.floor(Date.parse("2026-08-15T00:00:00Z") / 1000)
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a date that does not exist rather than rolling it forward", () => {
|
||||
for (const bad of ["2026-02-30", "2026-13-01", "2026-08-32", "2026-8-15", "x"]) {
|
||||
assert.equal(utcDayStartSeconds(bad), undefined, bad);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a real leap day", () => {
|
||||
assert.ok(utcDayStartSeconds("2028-02-29") !== undefined);
|
||||
assert.equal(utcDayStartSeconds("2027-02-29"), undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user