Author SHA1 Message Date
Jose Olarte III c75060a7f1 Use singular “update” when the digest has one item so FCM and SMS bodies stay grammatical. 2026-09-18 16:54:09 +08:00
Jose Olarte III 77df676d6e Accept POST on /notifications/alert-authorization instead of PUT.
The path and payload stay the same; this matches how the client submits a batch.
2026-09-18 15:05:30 +08:00
Jose Olarte III 226925278f Move remaining alertSearch tests under test/.
Keep them beside the existing SMS alertSearch tests and update imports so they still load src/.
2026-09-18 14:53:41 +08:00
trentlarson 93168ae360 make the boolean checks consistent, and guard against mistakes 2026-09-14 11:43:22 -06:00
trentlarson 885c6ced42 bump version and add '-beta' 2026-09-09 21:09:44 -06:00
trentlarson 4c2e6491db bump to version 0.2.1, for relase on test server 2026-09-09 21:09:09 -06:00
Jose Olarte III 657fd9c530 Count the SMS digest cap by UTC calendar day so a send just before midnight does not block the next day’s text. 2026-09-09 21:19:49 +08:00
20 changed files with 213 additions and 79 deletions
+1
View File
@@ -41,6 +41,7 @@
# one of the two "from" values are all present.
# TWILIO_ACCOUNT_SID=
# TWILIO_AUTH_TOKEN=
# Identifier for sender whom this is from.
# Prefer the Messaging Service once an A2P 10DLC campaign is approved: the
# campaign lives on it, and it wins when both are set.
# TWILIO_MESSAGING_SERVICE_SID=
+6 -1
View File
@@ -6,7 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.2.0] - 2026.09.05
## [Unreleased]
### Changed
- `SMS_ENABLED`, `SMS_REQUIRE_ACTION_CLAIM`, and `SMS_DEV_ECHO_CODE` parse like `DEBUG_ENDPOINT`: trimmed, case-insensitive, accepting `true`/`1`/`yes`/`on` and `false`/`0`/`no`/`off`. (A trailing space from `docker run --env-file` no longer silently turns SMS off, and an unrecognized `SMS_REQUIRE_ACTION_CLAIM` keeps the claim required instead of disabling it.)
## [0.2.1] - 2026.09.09
### 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
+10 -8
View File
@@ -34,7 +34,7 @@ On first use, the service creates `NOTIFY_DATA_DIR` (default `./data`) and the S
`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.
`POST /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.
**Local notification test bypass:** send `testMode: true` in the JSON body and omit the `Authorization` header. The request skips JWT and Endorser checks and uses a synthetic local test user, same as before. This applies to register/refresh only.
@@ -42,7 +42,7 @@ Set `NODE_ENV=test-local` in `.env` to bypass ethr JWT *expiry* verification dur
### Alert authorization
`PUT /notifications/alert-authorization`
`POST /notifications/alert-authorization`
```
Authorization: Bearer <current-user-JWT>
@@ -92,7 +92,7 @@ The **delegated** JWT is sent as `Authorization: Bearer`. Pass independent `endo
`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. 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.
`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 UTC 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.
@@ -101,7 +101,7 @@ The **delegated** JWT is sent as `Authorization: Bearer`. Pass independent `endo
`/notify-sms` delivers the daily alertSearch digest by text as well as by push.
A user registers a phone number, proves possession of it with a 6-digit code,
authorizes a batch of delegated alertSearch JWTs for the SMS channel, and
receives at most one text per local day when that day's retrieval finds updates.
receives at most one text per UTC day when that day's retrieval finds updates.
The whole surface is off unless `SMS_ENABLED` is `true`; every route answers
`503 SMS_DISABLED` otherwise, and the SMS scheduler does not start.
@@ -167,7 +167,7 @@ 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
alias, since the FCM twin is `POST` and the semantics are replace-not-append
either way.
`DELETE /notify-sms/alert-authorization` is how a user turns texts off. It
@@ -404,11 +404,11 @@ second replica double-texts.
Eligibility is the same predicate the FCM path uses: the run consumed today's
JWT and the digest is complete with updates. Consumption is what makes later
ticks on the same local day no-ops. The message is
ticks on the same UTC day no-ops. The message is
`Gift Economies: you have N new updates. https://giftopia.me Reply STOP to end.`,
kept inside one 160-character GSM-7 segment, since a second segment is a second
charge. Underneath the JWT rule, an `sms_phone_log` count caps sends at one per
handset per identity per day. Send failures are logged and do not roll back
handset per identity per UTC calendar day. Send failures are logged and do not roll back
cursor advancement or JWT consumption.
The SMS channel keeps its own JWT inventory and its own cursor table. A user on
@@ -748,13 +748,15 @@ Set `NOTIFY_DATA_DIR` to a durable directory, or keep the Docker default `/app/d
| `SMS_ACTION_JWT_MAX_AGE_SEC` | Optional | `300` |
| `SMS_MAX_DIDS_PER_PHONE` | Optional | `5` |
| `SMS_ALERT_SEARCH_INTERVAL_MS` | Optional | `300000` |
| `SMS_REQUIRE_ACTION_CLAIM` | Must **not** be `false` in production | `true` |
| `SMS_REQUIRE_ACTION_CLAIM` | Must **not** be a false value in production | `true` |
| `SMS_ALLOWED_RECIPIENT_DIDS` | Leave **unset** in production; on a test server, set it to the DIDs that server may text | Unset (no restriction) |
| `SMS_BLOCKED_NUMBERS` | Optional; numbers blocked by configuration, on top of the `sms_blocked_numbers` table | Empty |
| `SMS_DEV_ECHO_CODE` | Must be unset or `false`; honored only under `NODE_ENV=test-local` | `false` |
| Replicas | **One** process | Not enforced in code |
| Persistent volume | **Required** for Docker so SQLite survives replace | None unless you pass `-v` |
Boolean flags (`SMS_ENABLED`, `SMS_REQUIRE_ACTION_CLAIM`, `SMS_DEV_ECHO_CODE`, `DEBUG_ENDPOINT`) accept `true`/`1`/`yes`/`on` and `false`/`0`/`no`/`off`, case-insensitive, with surrounding whitespace ignored. Any other value, including a quoted one like `"true"` (which `docker run --env-file` passes through with its quotes), leaves the flag at its default.
Rotating `SMS_CODE_SECRET` invalidates every pending verification code and
orphans every stored `phone_hash`. Rotate between deploys, not casually.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "notification-wakeup-service",
"version": "0.2.0",
"version": "0.2.2-beta",
"private": true,
"type": "module",
"packageManager": "pnpm@11.4.0",
+2 -1
View File
@@ -29,7 +29,8 @@ export type AlertSearchNotifyResult = {
};
export function alertSearchNotificationBody(totalCount: number): string {
return `You have ${totalCount} new updates.`;
const noun = totalCount === 1 ? "update" : "updates";
return `You have ${totalCount} new ${noun}.`;
}
export function buildAlertSearchNotificationContent(
+13 -7
View File
@@ -1,6 +1,7 @@
import { smsPhoneLogDb } from "../db/smsPhoneLogSqlite.js";
import { smsRegistrationsDb } from "../db/smsRegistrationsSqlite.js";
import { smsConfig } from "../env.js";
import { utcCalendarDay } from "../services/alertAuthorization.js";
import {
TWILIO_UNSUBSCRIBED_CODE,
isPhoneNumberBlocked,
@@ -23,16 +24,17 @@ export const SMS_SINGLE_SEGMENT_LIMIT = 160;
/**
* Backstop on top of JWT consumption: at most one digest per handset per
* identity per day. Scoped to the pair rather than the identity alone, because
* a DID with two verified handsets legitimately receives two texts.
* identity per UTC calendar day. Scoped to the pair rather than the identity
* alone, because a DID with two verified handsets legitimately receives two
* texts.
*/
export const ALERT_SENDS_PER_PHONE_PER_DAY = 1;
const DAY_MS = 24 * 60 * 60 * 1000;
export type AlertSearchSmsNotifyDeps = {
listPhones?: (userId: string) => Promise<string[]>;
send?: SmsSender;
/** Cap window uses this instant's UTC day. Tests pass a fixed clock. */
now?: Date;
};
export type AlertSearchSmsNotifyResult = {
@@ -44,15 +46,16 @@ export type AlertSearchSmsNotifyResult = {
};
export function alertSearchSmsBody(totalCount: number): string {
const noun = totalCount === 1 ? "update" : "updates";
return (
`Gift Economies: you have ${totalCount} new updates. ` +
`Gift Economies: you have ${totalCount} new ${noun}. ` +
`${ALERT_SEARCH_SMS_LINK} Reply STOP to end.`
);
}
/**
* The same predicate the FCM path uses. Consumption of the day's SMS JWT is what
* makes later ticks on the same local day no-ops, so no separate flag is needed.
* makes later ticks on the same UTC day no-ops, so no separate flag is needed.
*/
export function isAlertSearchSmsEligible(
result: DailyAlertSearchResult
@@ -130,6 +133,9 @@ export async function deliverAlertSearchSms(
const body = alertSearchSmsBody(digest.totalCount);
const listPhones = deps.listPhones ?? defaultListPhones;
const send = deps.send ?? sendSms;
const now = deps.now ?? new Date();
const utcDayStartIso =
utcCalendarDay(Math.floor(now.getTime() / 1000)) + "T00:00:00.000Z";
const phones = await listPhones(result.userId);
// The digest was worth sending; this instance is simply not allowed to send
@@ -184,7 +190,7 @@ export async function deliverAlertSearchSms(
result.userId,
phoneHash,
["alert-sent"],
new Date(Date.now() - DAY_MS).toISOString()
utcDayStartIso
);
if (alreadySent >= ALERT_SENDS_PER_PHONE_PER_DAY) {
log.info(
+1 -1
View File
@@ -132,7 +132,7 @@ export const smsPhoneLogDb = {
return row.n;
},
/** The backstop on daily alert sends: one handset, one identity, one window. */
/** The backstop on daily alert sends: one handset, one identity, one UTC day. */
async countByUserAndPhoneHashSince(
userId: string,
phoneHash: string,
+9 -10
View File
@@ -23,7 +23,12 @@ export const PARTNER_URL =
/** NODE_ENV value that unlocks developer conveniences. Never set in production. */
export const TEST_LOCAL_ENV = "test-local";
/** Truthy env values for boolean flags: "true"/"1"/"yes"/"on" (case-insensitive). */
/**
* Every boolean env flag goes through here. Accepts "true"/"1"/"yes"/"on" and
* "false"/"0"/"no"/"off", case-insensitive and trimmed, because `docker run
* --env-file` keeps trailing whitespace. Anything else yields the fallback, so
* a typo leaves a flag at its documented default rather than flipping it.
*/
function envFlag(value: string | undefined, fallback: boolean): boolean {
if (value === undefined || value.trim().length === 0) return fallback;
const normalized = value.trim().toLowerCase();
@@ -35,12 +40,6 @@ function envFlag(value: string | undefined, fallback: boolean): boolean {
/** Mounts /debug when true. Off unless explicitly enabled. */
export const DEBUG_ENDPOINT = envFlag(process.env.DEBUG_ENDPOINT, false);
function booleanEnv(name: string, fallback: boolean): boolean {
const raw = process.env[name];
if (raw === undefined || raw.length === 0) return fallback;
return raw.toLowerCase() === "true" || raw === "1";
}
function intEnv(name: string, fallback: number): number {
const raw = process.env[name];
if (raw === undefined || raw.length === 0) return fallback;
@@ -103,7 +102,7 @@ export type SmsConfig = {
export function smsConfig(): SmsConfig {
const isTestLocal = process.env.NODE_ENV === TEST_LOCAL_ENV;
return {
enabled: booleanEnv("SMS_ENABLED", false),
enabled: envFlag(process.env.SMS_ENABLED, false),
codeSecret: stringEnv("SMS_CODE_SECRET"),
twilioAccountSid: stringEnv("TWILIO_ACCOUNT_SID"),
twilioAuthToken: stringEnv("TWILIO_AUTH_TOKEN"),
@@ -115,10 +114,10 @@ export function smsConfig(): SmsConfig {
actionJwtMaxAgeSec: intEnv("SMS_ACTION_JWT_MAX_AGE_SEC", 300),
maxDidsPerPhone: intEnv("SMS_MAX_DIDS_PER_PHONE", 5),
alertSearchIntervalMs: intEnv("SMS_ALERT_SEARCH_INTERVAL_MS", 300000),
requireActionClaim: booleanEnv("SMS_REQUIRE_ACTION_CLAIM", true),
requireActionClaim: envFlag(process.env.SMS_REQUIRE_ACTION_CLAIM, true),
// NODE_ENV is checked first: a production process with the flag set by
// accident echoes nothing, because its NODE_ENV is not test-local.
devEchoCode: isTestLocal && booleanEnv("SMS_DEV_ECHO_CODE", false),
devEchoCode: isTestLocal && envFlag(process.env.SMS_DEV_ECHO_CODE, false),
allowedRecipientDids: didListEnv("SMS_ALLOWED_RECIPIENT_DIDS"),
blockedNumbers: (process.env.SMS_BLOCKED_NUMBERS ?? "")
.split(",")
+1 -1
View File
@@ -98,7 +98,7 @@ notificationsRouter.post(
}
);
notificationsRouter.put(
notificationsRouter.post(
"/alert-authorization",
requireAuth,
requireEndorserAuth,
+2 -2
View File
@@ -934,8 +934,8 @@ export function createNotifySmsRouter(deps: NotifySmsDeps = {}): express.Router
}
);
// POST is the documented verb; PUT is accepted because the FCM twin is PUT and
// the semantics are replace-not-append either way.
// POST is the documented verb; PUT is accepted as an alias because the
// semantics are replace-not-append either way.
const alertAuthorizationChain: RequestHandler[] = [
...authStages,
requireSmsActionJwt("authorize-alert-search"),
@@ -3,17 +3,17 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, it } from "node:test";
import { alertSearchCursorsDb } from "../db/alertSearchCursorsSqlite.js";
import { closeDatabase } from "../db/sqlite.js";
import { alertSearchCursorsDb } from "../../src/db/alertSearchCursorsSqlite.js";
import { closeDatabase } from "../../src/db/sqlite.js";
import {
advanceAlertSearchCursors,
loadAlertSearchCursors,
maxEndorserAlertSearchUlid,
} from "./cursors.js";
import { retrieveAlertSearch } from "./retrieve.js";
import type { AlertSearchSourceResult, RetrieveAlertSearchResult } from "./retrieve.js";
import { ALERT_SEARCH_PAGE_SIZE } from "./types.js";
import type { EndorserAlertSearchData, PartnerAlertSearchData } from "./types.js";
} from "../../src/alertSearch/cursors.js";
import { retrieveAlertSearch } from "../../src/alertSearch/retrieve.js";
import type { AlertSearchSourceResult, RetrieveAlertSearchResult } from "../../src/alertSearch/retrieve.js";
import { ALERT_SEARCH_PAGE_SIZE } from "../../src/alertSearch/types.js";
import type { EndorserAlertSearchData, PartnerAlertSearchData } from "../../src/alertSearch/types.js";
const USER = "did:ethr:0xcursoruser";
const OTHER_USER = "did:ethr:0xother";
@@ -3,14 +3,14 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, it } from "node:test";
import { alertSearchCursorsDb } from "../db/alertSearchCursorsSqlite.js";
import { closeDatabase } from "../db/sqlite.js";
import { alertSearchCursorsDb } from "../../src/db/alertSearchCursorsSqlite.js";
import { closeDatabase } from "../../src/db/sqlite.js";
import {
partnerPageHasTiedBeforeDate,
type FetchLike,
} from "./client.js";
import { runAlertSearchCycle } from "./cycle.js";
import { ALERT_SEARCH_PAGE_SIZE } from "./types.js";
} from "../../src/alertSearch/client.js";
import { runAlertSearchCycle } from "../../src/alertSearch/cycle.js";
import { ALERT_SEARCH_PAGE_SIZE } from "../../src/alertSearch/types.js";
const USER = "did:ethr:0xcycleuser";
const JWT = "delegated.jwt.token";
@@ -8,12 +8,12 @@ import {
ALERT_JWT_STATUS_UNUSED,
alertAuthorizationDb,
type AlertAuthorizationJwtInput,
} from "../db/alertAuthorizationSqlite.js";
import { alertSearchCursorsDb } from "../db/alertSearchCursorsSqlite.js";
import { closeDatabase } from "../db/sqlite.js";
import type { FetchLike } from "./client.js";
import { runDailyAlertSearch } from "./daily.js";
import { ALERT_SEARCH_PAGE_SIZE } from "./types.js";
} from "../../src/db/alertAuthorizationSqlite.js";
import { alertSearchCursorsDb } from "../../src/db/alertSearchCursorsSqlite.js";
import { closeDatabase } from "../../src/db/sqlite.js";
import type { FetchLike } from "../../src/alertSearch/client.js";
import { runDailyAlertSearch } from "../../src/alertSearch/daily.js";
import { ALERT_SEARCH_PAGE_SIZE } from "../../src/alertSearch/types.js";
const USER = "did:ethr:0xdailyuser";
const ENDORSER_BASE = "https://api.endorser.ch";
@@ -4,9 +4,9 @@ import {
ALERT_SEARCH_DIGEST_BUCKETS,
alertSearchDigestDebugSummary,
buildAlertSearchDigest,
} from "./digest.js";
import { emptyEndorserData, emptyPartnerData } from "./client.js";
import type { AlertSearchSourceResult, RetrieveAlertSearchResult } from "./retrieve.js";
} from "../../src/alertSearch/digest.js";
import { emptyEndorserData, emptyPartnerData } from "../../src/alertSearch/client.js";
import type { AlertSearchSourceResult, RetrieveAlertSearchResult } from "../../src/alertSearch/retrieve.js";
import type {
AlertSearchClaimRecord,
AlertSearchFailureReason,
@@ -17,7 +17,7 @@ import type {
CombinedAlertSearchData,
EndorserAlertSearchData,
PartnerAlertSearchData,
} from "./types.js";
} from "../../src/alertSearch/types.js";
function ulid(n: number): string {
return `01H${String(n).padStart(23, "0")}`;
@@ -3,10 +3,10 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, it } from "node:test";
import { db as fcmDb } from "../db/fcmTokensSqlite.js";
import { closeDatabase } from "../db/sqlite.js";
import type { DailyAlertSearchResult } from "./daily.js";
import type { AlertSearchDigest } from "./digest.js";
import { db as fcmDb } from "../../src/db/fcmTokensSqlite.js";
import { closeDatabase } from "../../src/db/sqlite.js";
import type { DailyAlertSearchResult } from "../../src/alertSearch/daily.js";
import type { AlertSearchDigest } from "../../src/alertSearch/digest.js";
import {
ALERT_SEARCH_FCM_TYPE,
ALERT_SEARCH_NOTIFICATION_TITLE,
@@ -14,7 +14,7 @@ import {
buildAlertSearchNotificationContent,
deliverAlertSearchNotification,
isAlertSearchNotificationEligible,
} from "./notify.js";
} from "../../src/alertSearch/notify.js";
const USER = "did:ethr:0xnotifyuser";
@@ -151,7 +151,7 @@ describe("alertSearch notification gate", () => {
assert.equal(content.type, ALERT_SEARCH_FCM_TYPE);
assert.equal(content.body.includes(USER), false);
assert.equal(content.body.includes("01H"), false);
assert.equal(alertSearchNotificationBody(1), "You have 1 new updates.");
assert.equal(alertSearchNotificationBody(1), "You have 1 new update.");
});
});
@@ -4,11 +4,11 @@ import {
alertSearchUrl,
buildAlertSearchQuery,
isAlertSearchCursorUlid,
} from "./params.js";
} from "../../src/alertSearch/params.js";
import {
ENDORSER_ALERT_SEARCH_PATH,
PARTNER_ALERT_SEARCH_PATH,
} from "./types.js";
} from "../../src/alertSearch/types.js";
const LOCATION = {
minLocLat: 40.7,
@@ -5,9 +5,9 @@ import {
fetchPartnerAlertSearchPage,
nextEndorserBeforeId,
type FetchLike,
} from "./client.js";
import { retrieveAlertSearch } from "./retrieve.js";
import { ALERT_SEARCH_PAGE_SIZE, type EndorserAlertSearchData } from "./types.js";
} from "../../src/alertSearch/client.js";
import { retrieveAlertSearch } from "../../src/alertSearch/retrieve.js";
import { ALERT_SEARCH_PAGE_SIZE, type EndorserAlertSearchData } from "../../src/alertSearch/types.js";
const JWT = "delegated.jwt.token";
const ENDORSER_BASE = "https://api.endorser.ch";
@@ -4,18 +4,18 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, it } from "node:test";
import { alertAuthorizationDb } from "../db/alertAuthorizationSqlite.js";
import { db as fcmDb } from "../db/fcmTokensSqlite.js";
import { closeDatabase } from "../db/sqlite.js";
import type { DailyAlertSearchResult } from "./daily.js";
import { alertAuthorizationDb } from "../../src/db/alertAuthorizationSqlite.js";
import { db as fcmDb } from "../../src/db/fcmTokensSqlite.js";
import { closeDatabase } from "../../src/db/sqlite.js";
import type { DailyAlertSearchResult } from "../../src/alertSearch/daily.js";
import {
isAlertSearchSchedulerPassInFlight,
resetAlertSearchSchedulerPassGuard,
runAlertSearchSchedulerPass,
startAlertSearchScheduler,
stopAlertSearchScheduler,
} from "./scheduler.js";
import type { AlertSearchDigest } from "./digest.js";
} from "../../src/alertSearch/scheduler.js";
import type { AlertSearchDigest } from "../../src/alertSearch/digest.js";
const USER_A = "did:ethr:0xusera";
const USER_B = "did:ethr:0xuserb";
+68 -8
View File
@@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, it } from "node:test";
import { smsPhoneLogDb } from "../../src/db/smsPhoneLogSqlite.js";
import { smsBlockedNumbersDb } from "../../src/db/smsBlockedNumbersSqlite.js";
import { smsRegistrationsDb } from "../../src/db/smsRegistrationsSqlite.js";
import { closeDatabase } from "../../src/db/sqlite.js";
import { closeDatabase, getDatabase } from "../../src/db/sqlite.js";
import { hashPhoneNumber } from "../../src/util/smsVerificationCode.js";
import type { SmsSendResult } from "../../src/services/smsService.js";
import type { DailyAlertSearchResult } from "../../src/alertSearch/daily.js";
@@ -124,6 +124,10 @@ describe("alertSearchSmsBody", () => {
assert.ok(
alertSearchSmsBody(999999).length <= SMS_SINGLE_SEGMENT_LIMIT
);
assert.equal(
alertSearchSmsBody(1),
`Gift Economies: you have 1 new update. ${ALERT_SEARCH_SMS_LINK} Reply STOP to end.`
);
assert.equal(body.includes("giftopia.tech"), false);
});
});
@@ -235,7 +239,24 @@ describe("deliverAlertSearchSms", () => {
assert.equal(result.failed, 1);
});
it("does not text the same number twice in one day", async () => {
it("does not text the same number twice on the same UTC day", async () => {
await verifyPhone(PHONE);
const now = new Date("2026-09-08T18:00:00.000Z");
let sends = 0;
const send = async (): Promise<SmsSendResult> => {
sends += 1;
return { status: "sent", messageId: "SM" + sends };
};
await deliverAlertSearchSms(daily(), { send, now });
// A second eligible run the same UTC day: the JWT is what normally stops
// this, and the log-backed cap is the backstop underneath it.
const second = await deliverAlertSearchSms(daily(), { send, now });
assert.equal(sends, 1);
assert.equal(second.sent, 0);
});
it("does not suppress a send on the next UTC day after a late-evening send", async () => {
await verifyPhone(PHONE);
let sends = 0;
const send = async (): Promise<SmsSendResult> => {
@@ -243,12 +264,51 @@ describe("deliverAlertSearchSms", () => {
return { status: "sent", messageId: "SM" + sends };
};
await deliverAlertSearchSms(daily(), { send });
// A second eligible run the same day: the JWT is what normally stops this,
// and the log-backed cap is the backstop underneath it.
const second = await deliverAlertSearchSms(daily(), { send });
assert.equal(sends, 1);
assert.equal(second.sent, 0);
await deliverAlertSearchSms(daily(), {
send,
now: new Date("2026-09-08T23:30:00.000Z"),
});
getDatabase()
.prepare(
`UPDATE sms_phone_log SET created_at = ? WHERE action = 'alert-sent'`
)
.run("2026-09-08T23:30:00.000Z");
const nextDay = await deliverAlertSearchSms(daily(), {
send,
now: new Date("2026-09-09T00:01:00.000Z"),
});
assert.equal(sends, 2);
assert.equal(nextDay.sent, 1);
});
it("does not let a failed send count toward the UTC-day cap", async () => {
await verifyPhone(PHONE);
const now = new Date("2026-09-08T18:00:00.000Z");
let sends = 0;
const result = await deliverAlertSearchSms(daily(), {
now,
send: async () => {
sends += 1;
if (sends === 1) {
return { status: "failed", error: "carrier down" };
}
return { status: "sent", messageId: "SM1" };
},
});
assert.equal(result.sent, 0);
assert.equal(result.failed, 1);
const retry = await deliverAlertSearchSms(daily(), {
now,
send: async () => {
sends += 1;
return { status: "sent", messageId: "SM1" };
},
});
assert.equal(sends, 2);
assert.equal(retry.sent, 1);
assert.equal(retry.failed, 0);
});
});
+60
View File
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import { afterEach, beforeEach, describe, it } from "node:test";
import { smsConfig } from "../src/env.js";
const KEYS = ["SMS_ENABLED", "SMS_REQUIRE_ACTION_CLAIM"] as const;
const saved: Record<string, string | undefined> = {};
beforeEach(() => {
for (const key of KEYS) {
saved[key] = process.env[key];
delete process.env[key];
}
});
afterEach(() => {
for (const key of KEYS) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
});
describe("smsConfig boolean flags", () => {
it("uses the defaults when unset or empty", () => {
assert.equal(smsConfig().enabled, false);
assert.equal(smsConfig().requireActionClaim, true);
process.env.SMS_ENABLED = "";
process.env.SMS_REQUIRE_ACTION_CLAIM = "";
assert.equal(smsConfig().enabled, false);
assert.equal(smsConfig().requireActionClaim, true);
});
it("accepts every truthy spelling", () => {
for (const value of ["true", "TRUE", "1", "yes", "On"]) {
process.env.SMS_ENABLED = value;
assert.equal(smsConfig().enabled, true, value);
}
});
it("accepts every falsy spelling", () => {
for (const value of ["false", "FALSE", "0", "no", "Off"]) {
process.env.SMS_REQUIRE_ACTION_CLAIM = value;
assert.equal(smsConfig().requireActionClaim, false, value);
}
});
it("ignores surrounding whitespace, as docker --env-file leaves it", () => {
process.env.SMS_ENABLED = "true ";
assert.equal(smsConfig().enabled, true);
process.env.SMS_REQUIRE_ACTION_CLAIM = " false\t";
assert.equal(smsConfig().requireActionClaim, false);
});
it("falls back to the default on an unrecognized value", () => {
process.env.SMS_ENABLED = '"true"';
assert.equal(smsConfig().enabled, false);
process.env.SMS_REQUIRE_ACTION_CLAIM = "nope";
assert.equal(smsConfig().requireActionClaim, true);
});
});