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