Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c75060a7f1 | ||
|
|
77df676d6e | ||
|
|
226925278f | ||
|
|
93168ae360 | ||
|
|
885c6ced42 | ||
|
|
4c2e6491db | ||
|
|
657fd9c530 |
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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(",")
|
||||
|
||||
@@ -98,7 +98,7 @@ notificationsRouter.post(
|
||||
}
|
||||
);
|
||||
|
||||
notificationsRouter.put(
|
||||
notificationsRouter.post(
|
||||
"/alert-authorization",
|
||||
requireAuth,
|
||||
requireEndorserAuth,
|
||||
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user