sms: create explicit allow & block management

This commit is contained in:
2026-09-06 12:20:04 -06:00
parent 2e34bed700
commit 6cea0e9b67
16 changed files with 1306 additions and 12 deletions
+11
View File
@@ -59,3 +59,14 @@
# Returns the plaintext verification code in the POST /notify-sms/phone response.
# Honored only when NODE_ENV=test-local, which is checked first. Never in production.
# SMS_DEV_ECHO_CODE=false
# The only DIDs this instance may text, comma-separated. Unset means no
# restriction, which is the production setting. Set but empty blocks everyone.
# Intended for a test server that holds live Twilio credentials: without it, a
# production database restored into staging texts every verified user daily.
# SMS_ALLOWED_RECIPIENT_DIDS=did:ethr:0xabc,did:ethr:0xdef
# Numbers this service must never text, comma-separated, on top of the
# sms_blocked_numbers table that the STOP webhook writes to. Normalized before
# comparison, so formatting does not defeat them.
# SMS_BLOCKED_NUMBERS=+15555550123,+15555550124
+3
View File
@@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.2.0] - 2026.09.05
### Added
- `/notify-sms` SMS channel: phone registration with a 6-digit possession check, a per-channel delegated-JWT inventory and cursor, a Twilio sender, an `STOP`/`START`/`HELP` webhook, and a daily digest text alongside the FCM one; off unless `SMS_ENABLED`
- Blocks record their origin (`opt-out` / `provider-opt-out` / `manual`) and are never downgraded; a Twilio `21610` refusal auto-blocks as `provider-opt-out`, and `pnpm run sms:blocks` lists and manages the list by origin
- `sms_blocked_numbers` suppression list: `STOP` now blocks durably instead of only unverifying, `START` lifts the block, and a blocked number is refused at registration and verification and skipped by the digest; `SMS_BLOCKED_NUMBERS` blocks by configuration
- `SMS_ALLOWED_RECIPIENT_DIDS` restricts which DIDs an instance may text, guarding both the verification code and the daily digest, so a test server with live Twilio credentials cannot message a restored production database
- Every log line is prefixed with an ISO-8601 UTC timestamp (`src/util/log.ts`)
- `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
+103 -1
View File
@@ -214,6 +214,99 @@ arrives with a phone number and no identity, so it marks every registration of
that number unverified. `START` does not restore anything; possession has to be
proved again with a fresh POST and code.
### Blocking a number
`sms_blocked_numbers` is the service's suppression list. A number on it is
refused at registration (`403 SMS_PHONE_BLOCKED`), refused at verification even
with a correct code, and skipped by the daily digest. Deleting a registration
still works — removing yourself is always allowed.
#### Who blocked a number
The `reason` column records the origin, and nothing ever downgrades it:
| `reason` | Origin |
|---|---|
| `opt-out` | The handset texted `STOP` to this service |
| `provider-opt-out` | Twilio refused a send with `21610`, so the number is on its own opt-out list — it opted out somewhere this service did not observe |
| `manual` | An operator added it |
A re-block keeps whichever reason carries the stronger claim
(`opt-out` > `provider-opt-out` > `manual`). An operator re-blocking a number
that had already opted out must not erase the opt-out, because that record is
what says the block may not simply be lifted again.
`pnpm run sms:blocks` lists the table grouped by origin, `sms:blocks list manual`
filters to one, and `sms:blocks block <number> "<why>"` / `sms:blocks unblock
<number>` manage entries without hand-written SQL — which also keeps `reason`
honest, since a manual `INSERT` can claim any origin it likes. Unblocking
anything other than a `manual` entry requires `--force`.
```bash
SMS_CODE_SECRET=... pnpm run sms:blocks list
```
Two sources are consulted. The table is written by the `STOP` webhook, and
`SMS_BLOCKED_NUMBERS` in the environment blocks a number by configuration without waiting for the
handset to ask:
```bash
SMS_BLOCKED_NUMBERS=+15555550123,+15555550124
```
Entries are normalized before comparison, so formatting cannot defeat them.
Rows are keyed on `phone_hash`, so a block outlives the `DELETE` that nulls
`phone_e164` elsewhere. This table does retain the number itself: a suppression
list nobody can read is one nobody can audit or correct, and honoring an
opt-out means remembering who asked.
`STOP` blocks as well as unverifying. Unverifying alone left the number free to
register again minutes later and resume queueing messages, which Twilio then
refuses with `21610` — the service would retry forever on behalf of someone who
asked to be left alone. `START` lifts the block, matching Twilio, which clears
its own opt-out entry on the same keyword. It does not restore verification:
possession was proved by a code and that code is gone, so a fresh POST and a
fresh code are the way back.
Twilio keeps its own opt-out list regardless, and it is the authority for
compliance. A send refused with `21610` is Twilio saying the number is on it, so
both send paths record that as a `provider-opt-out` block and stop retrying.
Twilio can also report `21610` asynchronously; catching those needs the
`StatusCallback` handling this service does not yet do, so synchronous refusals
are what get captured today.
An ordinary send failure never blocks — only `21610` does, so a carrier blip
stays retryable.
### Restricting who a server may text
`SMS_ALLOWED_RECIPIENT_DIDS` is a comma-separated list of the only DIDs an
instance may send to. Unset — the production case — there is no restriction.
```bash
SMS_ALLOWED_RECIPIENT_DIDS=did:ethr:0xabc,did:ethr:0xdef
```
It guards both paths that spend money: the verification code on
`POST /notify-sms/phone`, which returns `403 SMS_RECIPIENT_NOT_ALLOWED` and
registers nothing, and the daily digest, which is withheld before any send.
Either way an `recipient-not-allowed` row lands in `sms_phone_log`, one per
number, so the log records exactly what was held back. Matching is
case-insensitive, since a checksummed `did:ethr` address and its lowercase form
name one identity.
Set but **empty** blocks every recipient rather than allowing all of them. An
operator who sets the variable meant to restrict something, so the blank case
fails closed.
This exists because the dangerous configuration is a test server holding live
Twilio credentials and a copy of the production database: every
`sms_registrations` row with `verified = 1` is a real handset, and the scheduler
texts all of them daily. The allowlist turns that from an incident into a log
line. `SMS_ENABLED=false` remains the blunter switch — it returns `503` from
every route and stops the SMS scheduler from starting at all.
### SMS delivery
`startSmsAlertSearchScheduler()` is a third scheduler alongside the FCM wakeup
@@ -435,7 +528,8 @@ Table `sms_phone_log` records every phone action: `user_id`, `phone_e164`
`provider_message_id`, `created_at`. Actions are `register-requested`,
`code-sent`, `code-send-failed`, `verify-succeeded`, `verify-failed`,
`did-limit-blocked`, `did-limit-disclosed`, `deleted`,
`alert-authorization-stored`, `alert-sent`, `alert-send-failed`, and `opt-out`.
`alert-authorization-stored`, `alert-sent`, `alert-send-failed`,
`recipient-not-allowed`, `number-blocked`, `number-unblocked`, and `opt-out`.
Indexes on `(user_id, created_at)`, `(phone_hash, created_at)`, and
`(action, created_at)`; the throttle counts read the second.
@@ -443,6 +537,12 @@ Indexes on `(user_id, created_at)`, `(phone_hash, created_at)`, and
data.** `phone_e164` is nulled on delete; a prune job for rows older than 400
days is a follow-up.
Table `sms_blocked_numbers` is the suppression list: `phone_hash` (unique, the
matching key), `phone_e164`, `reason` (`opt-out` / `provider-opt-out` / `manual`), `detail`, and
timestamps. It is the one table that deliberately retains a number after the
owner has asked to be left alone, because honoring that request means
remembering it.
Table `sms_action_jwt_use` holds the replay guard: `jwt_hash` (unique),
`user_id`, `action`, `used_at`. Rows older than
`SMS_ACTION_JWT_MAX_AGE_SEC × 10` are pruned on each SMS scheduler pass; a token
@@ -510,6 +610,8 @@ Set `NOTIFY_DATA_DIR` to a durable directory, or keep the Docker default `/app/d
| `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_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` |
+2 -1
View File
@@ -12,7 +12,8 @@
"typecheck": "tsc --noEmit -p tsconfig.test.json",
"sms:send": "tsx scripts/sms-send.ts",
"sms:smoke": "tsx scripts/sms-smoke.ts",
"twilio:whoami": "tsx scripts/twilio-whoami.ts"
"twilio:whoami": "tsx scripts/twilio-whoami.ts",
"sms:blocks": "tsx scripts/sms-blocks.ts"
},
"dependencies": {
"@peculiar/asn1-ecc": "^2.7.0",
+119
View File
@@ -0,0 +1,119 @@
/**
* Inspect and manage the suppression list without hand-writing SQL.
*
* pkgx pnpm run sms:blocks list every block
* pkgx pnpm run sms:blocks list manual list one origin only
* pkgx pnpm run sms:blocks block +15551234567 "spam complaint"
* pkgx pnpm run sms:blocks unblock +15551234567
*
* Blocking here is always recorded as `manual`, and never downgrades an
* existing opt-out. Needs SMS_CODE_SECRET, since rows are keyed on the HMAC of
* the number.
*/
import {
smsBlockedNumbersDb,
type SmsBlockReason,
} from "../src/db/smsBlockedNumbersSqlite.js";
import { smsConfig } from "../src/env.js";
import { normalizePhoneNumber } from "../src/util/smsPhoneNumber.js";
import { hashPhoneNumber } from "../src/util/smsVerificationCode.js";
const REASONS: SmsBlockReason[] = ["opt-out", "provider-opt-out", "manual"];
const ORIGIN_NOTE: Record<SmsBlockReason, string> = {
"opt-out": "the handset texted STOP to this service",
"provider-opt-out": "Twilio refused a send with 21610",
manual: "an operator added it here",
};
const secret = smsConfig().codeSecret;
if (secret === undefined) {
console.error("Set SMS_CODE_SECRET; blocks are keyed on the HMAC of the number.");
process.exit(1);
}
function requireNumber(raw: string | undefined): string {
const normalized = normalizePhoneNumber(raw);
if (normalized === undefined) {
console.error(`Not a phone number: ${raw ?? "(missing)"}`);
process.exit(1);
}
return normalized;
}
const [command = "list", ...rest] = process.argv.slice(2);
if (command === "list") {
const filter = rest[0] as SmsBlockReason | undefined;
if (filter !== undefined && !REASONS.includes(filter)) {
console.error(`Unknown origin: ${filter}. One of: ${REASONS.join(", ")}`);
process.exit(1);
}
const rows = (await smsBlockedNumbersDb.list(1000)).filter(
(row) => filter === undefined || row.reason === filter
);
if (rows.length === 0) {
console.log(filter === undefined ? "No blocked numbers." : `No ${filter} blocks.`);
process.exit(0);
}
for (const reason of REASONS) {
const group = rows.filter((row) => row.reason === reason);
if (group.length === 0) continue;
console.log(`\n${reason} (${group.length}) — ${ORIGIN_NOTE[reason]}`);
for (const row of group) {
console.log(
` ${(row.phoneE164 ?? "(hash only)").padEnd(16)} ${row.createdAt}` +
(row.detail === undefined ? "" : ` ${row.detail}`)
);
}
}
console.log(`\n${rows.length} total.`);
process.exit(0);
}
if (command === "block") {
const phoneE164 = requireNumber(rest[0]);
const stored = await smsBlockedNumbersDb.block({
phoneHash: hashPhoneNumber(phoneE164, secret),
phoneE164,
reason: "manual",
detail: rest[1],
});
console.log(`Blocked ${phoneE164} — recorded as ${stored.reason}.`);
if (stored.reason !== "manual") {
console.log(
"It already carried a stronger origin, which is kept: an opt-out record\n" +
"is what says the block may not simply be lifted again."
);
}
process.exit(0);
}
if (command === "unblock") {
const phoneE164 = requireNumber(rest[0]);
const phoneHash = hashPhoneNumber(phoneE164, secret);
const existing = await smsBlockedNumbersDb.get(phoneHash);
if (existing === undefined) {
console.log(`${phoneE164} is not blocked.`);
process.exit(0);
}
if (existing.reason !== "manual" && rest[1] !== "--force") {
console.error(
`${phoneE164} is blocked as "${existing.reason}" — ${ORIGIN_NOTE[existing.reason]}.\n` +
"Lifting it would resume messaging someone who asked to stop, and Twilio\n" +
"will refuse the send anyway until they text START. Pass --force if you\n" +
"are certain this is a mistaken record."
);
process.exit(1);
}
await smsBlockedNumbersDb.unblock(phoneHash);
console.log(`Unblocked ${phoneE164} (was ${existing.reason}).`);
process.exit(0);
}
console.error(`Unknown command: ${command}. Use list, block, or unblock.`);
process.exit(1);
+89 -4
View File
@@ -1,7 +1,14 @@
import { smsPhoneLogDb } from "../db/smsPhoneLogSqlite.js";
import { smsRegistrationsDb } from "../db/smsRegistrationsSqlite.js";
import { smsConfig } from "../env.js";
import { sendSms, type SmsSender } from "../services/smsService.js";
import {
TWILIO_UNSUBSCRIBED_CODE,
isPhoneNumberBlocked,
isSmsRecipientAllowed,
sendSms,
type SmsSender,
} from "../services/smsService.js";
import { smsBlockedNumbersDb } from "../db/smsBlockedNumbersSqlite.js";
import { errorMessage } from "../util/formatElapsed.js";
import { maskPhoneNumber } from "../util/smsPhoneNumber.js";
import { hashPhoneNumber } from "../util/smsVerificationCode.js";
@@ -32,6 +39,8 @@ export type AlertSearchSmsNotifyResult = {
eligible: boolean;
sent: number;
failed: number;
/** Recipients suppressed by SMS_ALLOWED_RECIPIENT_DIDS. */
blocked: number;
};
export function alertSearchSmsBody(totalCount: number): string {
@@ -58,6 +67,34 @@ export function isAlertSearchSmsEligible(
);
}
/** Syncs Twilio's opt-out list into ours, one refusal at a time. */
async function recordProviderOptOut(
phoneHash: string,
phoneE164: string,
userId: string
): Promise<void> {
try {
await smsBlockedNumbersDb.block({
phoneHash,
phoneE164,
reason: "provider-opt-out",
detail: `Twilio ${TWILIO_UNSUBSCRIBED_CODE}`,
});
await smsPhoneLogDb.append({
userId,
phoneHash,
action: "number-blocked",
result: "rejected",
detail: `Twilio ${TWILIO_UNSUBSCRIBED_CODE}`,
});
} catch (err) {
log.error(
"[SmsAlertSearchNotify] Could not record provider opt-out:",
errorMessage(err)
);
}
}
async function defaultListPhones(userId: string): Promise<string[]> {
const rows = await smsRegistrationsDb.listVerifiedByUserId(userId);
const seen = new Set<string>();
@@ -81,25 +118,68 @@ export async function deliverAlertSearchSms(
): Promise<AlertSearchSmsNotifyResult> {
const digest = result.digest;
if (!isAlertSearchSmsEligible(result) || digest === null) {
return { eligible: false, sent: 0, failed: 0 };
return { eligible: false, sent: 0, failed: 0, blocked: 0 };
}
const secret = smsConfig().codeSecret;
if (secret === undefined) {
log.error("[SmsAlertSearchNotify] SMS_CODE_SECRET is not set; skipping");
return { eligible: false, sent: 0, failed: 0 };
return { eligible: false, sent: 0, failed: 0, blocked: 0 };
}
const body = alertSearchSmsBody(digest.totalCount);
const listPhones = deps.listPhones ?? defaultListPhones;
const send = deps.send ?? sendSms;
const phones = await listPhones(result.userId);
// The digest was worth sending; this instance is simply not allowed to send
// it. Recorded per number, so the log shows exactly what was withheld.
if (!isSmsRecipientAllowed(result.userId)) {
log.info(
"[SmsAlertSearchNotify] Recipient not on SMS_ALLOWED_RECIPIENT_DIDS:",
result.userId + ",",
"withheld=" + String(phones.length)
);
for (const phoneE164 of phones) {
await smsPhoneLogDb
.append({
userId: result.userId,
phoneE164,
phoneHash: hashPhoneNumber(phoneE164, secret),
action: "recipient-not-allowed",
result: "rejected",
detail: "SMS_ALLOWED_RECIPIENT_DIDS",
})
.catch(() => undefined);
}
return { eligible: true, sent: 0, failed: 0, blocked: phones.length };
}
let sent = 0;
let failed = 0;
let blocked = 0;
for (const phoneE164 of phones) {
const phoneHash = hashPhoneNumber(phoneE164, secret);
if (await isPhoneNumberBlocked({ phoneE164, phoneHash })) {
blocked += 1;
log.info(
"[SmsAlertSearchNotify] Number is blocked, withholding:",
maskPhoneNumber(phoneE164)
);
await smsPhoneLogDb
.append({
userId: result.userId,
phoneHash,
action: "number-blocked",
result: "rejected",
detail: "number is on the block list",
})
.catch(() => undefined);
continue;
}
const alreadySent = await smsPhoneLogDb.countByUserAndPhoneHashSince(
result.userId,
phoneHash,
@@ -136,6 +216,11 @@ export async function deliverAlertSearchSms(
result: "failed",
detail: outcome.error,
});
// Twilio is the authority on its own opt-out list. Recording its
// refusal stops this number being retried every day thereafter.
if (outcome.code === TWILIO_UNSUBSCRIBED_CODE) {
await recordProviderOptOut(phoneHash, phoneE164, result.userId);
}
}
} catch (err) {
failed += 1;
@@ -158,5 +243,5 @@ export async function deliverAlertSearchSms(
}
}
return { eligible: true, sent, failed };
return { eligible: true, sent, failed, blocked };
}
+158
View File
@@ -0,0 +1,158 @@
import { randomUUID } from "node:crypto";
import { getDatabase } from "./sqlite.js";
/**
* Who put a number on the list.
*
* `opt-out` the handset texted STOP to this service
* `provider-opt-out` Twilio refused a send with 21610, so the number is on its
* opt-out list — it opted out somewhere we did not observe
* `manual` an operator decided
*/
export type SmsBlockReason = "opt-out" | "provider-opt-out" | "manual";
/**
* A re-block never weakens an existing claim. An operator re-blocking a number
* that had already opted out must not erase the opt-out, because that is the
* record that says the block may not simply be lifted again.
*/
const REASON_RANK: Record<SmsBlockReason, number> = {
"opt-out": 2,
"provider-opt-out": 1,
manual: 0,
};
export function strongerBlockReason(
existing: SmsBlockReason | undefined,
incoming: SmsBlockReason
): SmsBlockReason {
if (existing === undefined) return incoming;
return REASON_RANK[incoming] > REASON_RANK[existing] ? incoming : existing;
}
export type SmsBlockedNumber = {
id: string;
/** The matching key, so a block survives the DELETE scrub of phone_e164. */
phoneHash: string;
/**
* Retained deliberately. A suppression list nobody can read is one nobody can
* audit or correct, and honoring an opt-out means remembering who asked.
*/
phoneE164?: string;
reason: SmsBlockReason;
detail?: string;
createdAt: string;
updatedAt: string;
};
type DbRow = {
id: string;
phone_hash: string;
phone_e164: string | null;
reason: string;
detail: string | null;
created_at: string;
updated_at: string;
};
const ROW_COLUMNS =
"id, phone_hash, phone_e164, reason, detail, created_at, updated_at";
function toRecord(row: DbRow): SmsBlockedNumber {
return {
id: row.id,
phoneHash: row.phone_hash,
phoneE164: row.phone_e164 ?? undefined,
reason: row.reason as SmsBlockReason,
detail: row.detail ?? undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
/**
* Numbers this service must not text, whatever any registration says. Keyed on
* the HMAC of the number so the entry outlives a DELETE, and so a lookup needs
* only the same secret every other phone hash uses.
*/
export const smsBlockedNumbersDb = {
async isBlocked(phoneHash: string): Promise<boolean> {
const row = getDatabase()
.prepare(`SELECT 1 AS n FROM sms_blocked_numbers WHERE phone_hash = ?`)
.get(phoneHash) as { n: number } | undefined;
return row !== undefined;
},
async get(phoneHash: string): Promise<SmsBlockedNumber | undefined> {
const row = getDatabase()
.prepare(
`SELECT ${ROW_COLUMNS} FROM sms_blocked_numbers WHERE phone_hash = ?`
)
.get(phoneHash) as DbRow | undefined;
return row === undefined ? undefined : toRecord(row);
},
/**
* Idempotent. A second block refreshes the row, keeping whichever reason
* carries the stronger claim, so origin is never downgraded.
*/
async block(input: {
phoneHash: string;
phoneE164?: string;
reason: SmsBlockReason;
detail?: string;
}): Promise<SmsBlockedNumber> {
const now = new Date().toISOString();
const existing = await this.get(input.phoneHash);
const reason = strongerBlockReason(existing?.reason, input.reason);
getDatabase()
.prepare(
`
INSERT INTO sms_blocked_numbers (
id, phone_hash, phone_e164, reason, detail, created_at, updated_at
) VALUES (@id, @phone_hash, @phone_e164, @reason, @detail, @created_at, @updated_at)
ON CONFLICT(phone_hash) DO UPDATE SET
phone_e164 = COALESCE(excluded.phone_e164, phone_e164),
reason = excluded.reason,
-- excluded.reason is already the stronger of the two, resolved above.
detail = excluded.detail,
updated_at = excluded.updated_at
`
)
.run({
id: randomUUID(),
phone_hash: input.phoneHash,
phone_e164: input.phoneE164 ?? null,
reason,
detail: input.detail ?? null,
created_at: now,
updated_at: now,
});
const stored = await this.get(input.phoneHash);
if (stored === undefined) {
throw new Error("sms_blocked_numbers insert did not produce a row");
}
return stored;
},
/** Returns whether a row was actually removed. */
async unblock(phoneHash: string): Promise<boolean> {
const result = getDatabase()
.prepare(`DELETE FROM sms_blocked_numbers WHERE phone_hash = ?`)
.run(phoneHash);
return result.changes > 0;
},
async list(limit = 200): Promise<SmsBlockedNumber[]> {
const rows = getDatabase()
.prepare(
`
SELECT ${ROW_COLUMNS} FROM sms_blocked_numbers
ORDER BY created_at DESC
LIMIT ?
`
)
.all(limit) as DbRow[];
return rows.map(toRecord);
},
};
+13
View File
@@ -192,6 +192,19 @@ CREATE INDEX IF NOT EXISTS idx_sms_alert_auth_jwts_user_day
CREATE INDEX IF NOT EXISTS idx_sms_alert_auth_jwts_batch_pk
ON sms_alert_authorization_jwts (batch_pk);
CREATE TABLE IF NOT EXISTS sms_blocked_numbers (
id TEXT PRIMARY KEY NOT NULL,
phone_hash TEXT NOT NULL,
phone_e164 TEXT,
reason TEXT NOT NULL,
detail TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_sms_blocked_numbers_hash
ON sms_blocked_numbers (phone_hash);
CREATE TABLE IF NOT EXISTS sms_alert_search_cursors (
user_id TEXT PRIMARY KEY NOT NULL,
endorser_after_id TEXT,
+29
View File
@@ -36,6 +36,20 @@ function intEnv(name: string, fallback: number): number {
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
/**
* A DID allowlist, or undefined when the variable is absent entirely. Present
* but empty yields an empty list, which blocks every recipient: an operator who
* sets the variable meant to restrict something, so the blank case fails closed.
*/
function didListEnv(name: string): string[] | undefined {
const raw = process.env[name];
if (raw === undefined) return undefined;
return raw
.split(",")
.map((did) => did.trim().toLowerCase())
.filter((did) => did.length > 0);
}
function stringEnv(name: string): string | undefined {
const raw = process.env[name];
return raw !== undefined && raw.length > 0 ? raw : undefined;
@@ -58,6 +72,16 @@ export type SmsConfig = {
requireActionClaim: boolean;
/** Echo the verification code in the POST response. Both conditions required. */
devEchoCode: boolean;
/**
* When present, the only DIDs this instance may text. Undefined means no
* restriction, which is what production runs with.
*/
allowedRecipientDids?: string[];
/**
* Numbers blocked by configuration, on top of whatever the database holds.
* Normalized at read time so the comparison matches stored E.164.
*/
blockedNumbers: string[];
};
/**
@@ -83,6 +107,11 @@ export function smsConfig(): SmsConfig {
// 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),
allowedRecipientDids: didListEnv("SMS_ALLOWED_RECIPIENT_DIDS"),
blockedNumbers: (process.env.SMS_BLOCKED_NUMBERS ?? "")
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0),
};
}
+3
View File
@@ -30,6 +30,9 @@ export type SmsPhoneLogAction =
| "alert-authorization-stored"
| "alert-sent"
| "alert-send-failed"
| "recipient-not-allowed"
| "number-blocked"
| "number-unblocked"
| "opt-out";
export type SmsPhoneLogResult = "ok" | "rejected" | "failed";
+108 -4
View File
@@ -17,7 +17,14 @@ import {
unsupportedIdentityFailure,
validateAlertAuthorizationBatch,
} from "../services/alertAuthorization.js";
import { sendSms, type SmsSender } from "../services/smsService.js";
import {
TWILIO_UNSUBSCRIBED_CODE,
isPhoneNumberBlocked,
isSmsRecipientAllowed,
sendSms,
type SmsSender,
} from "../services/smsService.js";
import { smsBlockedNumbersDb } from "../db/smsBlockedNumbersSqlite.js";
import { twilioSignatureMatches } from "../services/twilioSignature.js";
import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js";
import {
@@ -148,6 +155,37 @@ async function recordPhoneAction(input: {
}
}
/**
* A blocked number is barred outright: no code, no verification, no digest.
* Returns true when the request has been answered and the handler must stop.
*/
async function rejectIfBlocked(
res: express.Response,
input: { userId: string; phoneE164: string; phoneHash: string; jwtHash?: string }
): Promise<boolean> {
const blocked = await isPhoneNumberBlocked({
phoneE164: input.phoneE164,
phoneHash: input.phoneHash,
});
if (!blocked) return false;
await recordPhoneAction({
userId: input.userId,
phoneHash: input.phoneHash,
action: "number-blocked",
result: "rejected",
detail: "number is on the block list",
jwtHash: input.jwtHash,
});
sendError(
res,
403,
"SMS_PHONE_BLOCKED",
"This number has opted out of messages from this service."
);
return true;
}
export function createNotifySmsRouter(deps: NotifySmsDeps = {}): express.Router {
const router = Router();
const send = deps.sender ?? sendSms;
@@ -239,6 +277,30 @@ export function createNotifySmsRouter(deps: NotifySmsDeps = {}): express.Router
"phone=" + maskPhoneNumber(phoneE164)
);
if (await rejectIfBlocked(res, { userId, phoneE164, phoneHash, jwtHash })) {
return;
}
// A restricted instance must not onboard identities it may never text.
if (!isSmsRecipientAllowed(userId)) {
await recordPhoneAction({
userId,
phoneE164,
phoneHash,
action: "recipient-not-allowed",
result: "rejected",
detail: "SMS_ALLOWED_RECIPIENT_DIDS",
jwtHash,
});
sendError(
res,
403,
"SMS_RECIPIENT_NOT_ALLOWED",
"This server is restricted to a fixed set of recipient DIDs."
);
return;
}
// Idempotent, costs no money, and removes the obvious SMS-bombing lever.
const existing = await smsRegistrationsDb.get(userId, phoneE164);
if (existing?.verified === true) {
@@ -336,6 +398,24 @@ export function createNotifySmsRouter(deps: NotifySmsDeps = {}): express.Router
detail: result.error,
jwtHash,
});
// Twilio already holds this number on its opt-out list; agree with it
// rather than letting the caller retry into the same refusal.
if (result.code === TWILIO_UNSUBSCRIBED_CODE) {
await smsBlockedNumbersDb.block({
phoneHash,
phoneE164,
reason: "provider-opt-out",
detail: `Twilio ${TWILIO_UNSUBSCRIBED_CODE}`,
});
await recordPhoneAction({
userId,
phoneHash,
action: "number-blocked",
result: "rejected",
detail: `Twilio ${TWILIO_UNSUBSCRIBED_CODE}`,
jwtHash,
});
}
sendError(
res,
502,
@@ -400,6 +480,10 @@ export function createNotifySmsRouter(deps: NotifySmsDeps = {}): express.Router
}
const phoneHash = hashPhoneNumber(phoneE164, secret);
if (await rejectIfBlocked(res, { userId, phoneE164, phoneHash, jwtHash })) {
return;
}
const registration = await smsRegistrationsDb.get(userId, phoneE164);
if (registration?.verified === true) {
res.status(200).json({
@@ -721,6 +805,14 @@ export function createNotifySmsRouter(deps: NotifySmsDeps = {}): express.Router
const switchedOff =
await smsRegistrationsDb.unverifyAllForPhone(from);
const dids = await smsRegistrationsDb.listVerifiedDidsForPhone(from);
// Unverifying alone would let the number re-register minutes later and
// resume queueing messages Twilio then refuses with 21610.
await smsBlockedNumbersDb.block({
phoneHash: hashPhoneNumber(from, secret),
phoneE164: from,
reason: "opt-out",
detail: `keyword ${keyword}`,
});
log.info(
"[NotifySmsInbound] Opt-out for",
maskPhoneNumber(from) + ",",
@@ -731,7 +823,7 @@ export function createNotifySmsRouter(deps: NotifySmsDeps = {}): express.Router
phoneHash: hashPhoneNumber(from, secret),
action: "opt-out",
result: "ok",
detail: `switchedOff=${switchedOff}`,
detail: `switchedOff=${switchedOff}, blocked`,
});
// Twilio sends its own STOP confirmation; a second reply is noise.
res.status(200).type("text/xml").send(EMPTY_TWIML);
@@ -744,8 +836,20 @@ export function createNotifySmsRouter(deps: NotifySmsDeps = {}): express.Router
}
if (SMS_OPT_IN_KEYWORDS.has(keyword)) {
// START cannot re-verify: possession was proved by a code, and that
// code is gone. A fresh POST plus a fresh code is the only way back.
// Lifting the block mirrors Twilio, which clears its own opt-out entry
// on START. It does not re-verify: possession was proved by a code, and
// that code is gone, so a fresh POST plus a fresh code is the way back.
const phoneHash = hashPhoneNumber(from, secret);
const unblocked = await smsBlockedNumbersDb.unblock(phoneHash);
if (unblocked) {
await recordPhoneAction({
userId: "(inbound)",
phoneHash,
action: "number-unblocked",
result: "ok",
detail: `keyword ${keyword}`,
});
}
res.status(200).type("text/xml").send(twiml(SMS_OPT_IN_REPLY));
return;
}
+59 -2
View File
@@ -5,12 +5,19 @@ import { log } from "../util/log.js";
export type SmsSendResult =
| { status: "sent"; messageId: string }
| { status: "failed"; error: string };
| { status: "failed"; error: string; code?: number };
export type SmsSender = (to: string, body: string) => Promise<SmsSendResult>;
export const SMS_NOT_CONFIGURED = "SMS_NOT_CONFIGURED";
/**
* Twilio's "attempt to send to unsubscribed recipient". It is the provider
* telling us a number is on its own opt-out list, which is the authority for
* compliance and is invisible to this service any other way.
*/
export const TWILIO_UNSUBSCRIBED_CODE = 21610;
const TWILIO_API_BASE = "https://api.twilio.com/2010-04-01";
type TwilioCredentials = {
@@ -36,6 +43,23 @@ export function missingTwilioConfig(config: SmsConfig = smsConfig()): string[] {
return missing;
}
/**
* Whether this instance may text the given identity at all.
*
* An environment guard rather than a transport concern: a test server pointed
* at live credentials and restored from a production database would otherwise
* text every verified user it inherited. Comparison is case-insensitive, since
* a checksummed `did:ethr` address and its lowercase form name one identity.
*/
export function isSmsRecipientAllowed(
userId: string,
config: SmsConfig = smsConfig()
): boolean {
const allowed = config.allowedRecipientDids;
if (allowed === undefined) return true;
return allowed.includes(userId.trim().toLowerCase());
}
/**
* A sender needs an account, a token, and something to send from. Anything less
* cannot produce a message, so it is not a partial configuration but no sender.
@@ -92,7 +116,11 @@ export async function sendViaTwilio(
typeof payload.message === "string"
? payload.message
: `HTTP ${response.status}`;
return { status: "failed", error: detail };
return {
status: "failed",
error: detail,
...(typeof payload.code === "number" ? { code: payload.code } : {}),
};
}
if (typeof payload.sid !== "string" || payload.sid.length === 0) {
return { status: "failed", error: "Twilio response carried no sid" };
@@ -144,3 +172,32 @@ export const sendSms: SmsSender = async (to, body) => {
export function resetSmsNotConfiguredWarning(): void {
warnedNotConfigured = false;
}
/**
* Whether this service must not text a number, for any reason.
*
* Two sources, both consulted: the `sms_blocked_numbers` table, which the STOP
* webhook writes to, and `SMS_BLOCKED_NUMBERS`, which blocks a number without
* waiting for the handset to ask. Twilio keeps its own opt-out list and refuses
* such messages with 21610 regardless; this is how the service comes to agree
* with it rather than retrying forever.
*/
export async function isPhoneNumberBlocked(input: {
phoneE164: string;
phoneHash: string;
config?: SmsConfig;
}): Promise<boolean> {
const config = input.config ?? smsConfig();
if (config.blockedNumbers.length > 0) {
const { normalizePhoneNumber } = await import(
"../util/smsPhoneNumber.js"
);
for (const entry of config.blockedNumbers) {
if (normalizePhoneNumber(entry) === input.phoneE164) return true;
}
}
const { smsBlockedNumbersDb } = await import(
"../db/smsBlockedNumbersSqlite.js"
);
return smsBlockedNumbersDb.isBlocked(input.phoneHash);
}
+179
View File
@@ -4,8 +4,10 @@ import { tmpdir } from "node:os";
import path from "node:path";
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 { hashPhoneNumber } from "../../src/util/smsVerificationCode.js";
import type { SmsSendResult } from "../../src/services/smsService.js";
import type { DailyAlertSearchResult } from "../../src/alertSearch/daily.js";
import type { AlertSearchDigest } from "../../src/alertSearch/digest.js";
@@ -25,10 +27,16 @@ const SECRET = "sms-notify-secret";
let dir: string;
let savedDataDir: string | undefined;
let savedSecret: string | undefined;
let savedAllowlist: string | undefined;
let savedBlocked: string | undefined;
beforeEach(async () => {
savedDataDir = process.env.NOTIFY_DATA_DIR;
savedSecret = process.env.SMS_CODE_SECRET;
savedAllowlist = process.env.SMS_ALLOWED_RECIPIENT_DIDS;
delete process.env.SMS_ALLOWED_RECIPIENT_DIDS;
savedBlocked = process.env.SMS_BLOCKED_NUMBERS;
delete process.env.SMS_BLOCKED_NUMBERS;
dir = await mkdtemp(path.join(tmpdir(), "sms-notify-"));
process.env.NOTIFY_DATA_DIR = dir;
process.env.SMS_CODE_SECRET = SECRET;
@@ -41,6 +49,10 @@ afterEach(async () => {
else process.env.NOTIFY_DATA_DIR = savedDataDir;
if (savedSecret === undefined) delete process.env.SMS_CODE_SECRET;
else process.env.SMS_CODE_SECRET = savedSecret;
if (savedAllowlist === undefined) delete process.env.SMS_ALLOWED_RECIPIENT_DIDS;
else process.env.SMS_ALLOWED_RECIPIENT_DIDS = savedAllowlist;
if (savedBlocked === undefined) delete process.env.SMS_BLOCKED_NUMBERS;
else process.env.SMS_BLOCKED_NUMBERS = savedBlocked;
await rm(dir, { recursive: true, force: true });
});
@@ -239,3 +251,170 @@ describe("deliverAlertSearchSms", () => {
assert.equal(second.sent, 0);
});
});
describe("SMS_ALLOWED_RECIPIENT_DIDS", () => {
it("sends normally to a DID on the list", async () => {
process.env.SMS_ALLOWED_RECIPIENT_DIDS = USER;
await verifyPhone(PHONE);
const result = await deliverAlertSearchSms(daily(), {
send: async () => ({ status: "sent", messageId: "SM1" }),
});
assert.equal(result.sent, 1);
assert.equal(result.blocked, 0);
});
it("withholds an eligible digest from a DID that is not listed", async () => {
process.env.SMS_ALLOWED_RECIPIENT_DIDS = "did:ethr:0xsomeoneelse";
await verifyPhone(PHONE);
await verifyPhone(OTHER_PHONE);
let sends = 0;
const result = await deliverAlertSearchSms(daily(), {
send: async () => {
sends += 1;
return { status: "sent", messageId: "SM1" };
},
});
assert.equal(sends, 0);
// The digest was worth sending; this instance was not allowed to send it.
assert.equal(result.eligible, true);
assert.equal(result.sent, 0);
assert.equal(result.blocked, 2);
});
it("records what it withheld, one row per number", async () => {
process.env.SMS_ALLOWED_RECIPIENT_DIDS = "did:ethr:0xsomeoneelse";
await verifyPhone(PHONE);
await deliverAlertSearchSms(daily(), {
send: async () => ({ status: "sent", messageId: "SM1" }),
});
const rows = await smsPhoneLogDb.listByUserId(USER);
assert.equal(rows.length, 1);
assert.equal(rows[0].action, "recipient-not-allowed");
assert.equal(rows[0].result, "rejected");
assert.equal(rows[0].phoneE164, PHONE);
assert.equal(rows[0].detail, "SMS_ALLOWED_RECIPIENT_DIDS");
});
it("blocks every DID when the list is set but empty", async () => {
process.env.SMS_ALLOWED_RECIPIENT_DIDS = "";
await verifyPhone(PHONE);
let sends = 0;
const result = await deliverAlertSearchSms(daily(), {
send: async () => {
sends += 1;
return { status: "sent", messageId: "SM1" };
},
});
assert.equal(sends, 0);
assert.equal(result.blocked, 1);
});
it("does not fire for an ineligible digest", async () => {
process.env.SMS_ALLOWED_RECIPIENT_DIDS = "did:ethr:0xsomeoneelse";
await verifyPhone(PHONE);
const result = await deliverAlertSearchSms(daily({ consumed: false }), {
send: async () => ({ status: "sent", messageId: "SM1" }),
});
assert.equal(result.eligible, false);
assert.equal(result.blocked, 0);
assert.deepEqual(await smsPhoneLogDb.listByUserId(USER), []);
});
});
describe("blocked numbers in the daily digest", () => {
it("skips a blocked number and still texts the others", async () => {
await verifyPhone(PHONE);
await verifyPhone(OTHER_PHONE);
await smsBlockedNumbersDb.block({
phoneHash: hashPhoneNumber(PHONE, SECRET),
reason: "opt-out",
});
const sent: string[] = [];
const result = await deliverAlertSearchSms(daily(), {
send: async (to) => {
sent.push(to);
return { status: "sent", messageId: "SM1" };
},
});
assert.deepEqual(sent, [OTHER_PHONE]);
assert.equal(result.sent, 1);
assert.equal(result.blocked, 1);
const rows = await smsPhoneLogDb.listByUserId(USER);
const skipped = rows.find((row) => row.action === "number-blocked");
assert.equal(skipped?.result, "rejected");
assert.equal(skipped?.phoneE164, undefined);
});
it("sends nothing when every number is blocked", async () => {
await verifyPhone(PHONE);
process.env.SMS_BLOCKED_NUMBERS = PHONE;
let sends = 0;
const result = await deliverAlertSearchSms(daily(), {
send: async () => {
sends += 1;
return { status: "sent", messageId: "SM1" };
},
});
assert.equal(sends, 0);
assert.equal(result.blocked, 1);
assert.equal(result.sent, 0);
});
});
describe("Twilio 21610 syncs the provider's opt-out list into ours", () => {
it("blocks the number after an unsubscribed refusal", async () => {
await verifyPhone(PHONE);
const hash = hashPhoneNumber(PHONE, SECRET);
const result = await deliverAlertSearchSms(daily(), {
send: async () => ({
status: "failed",
error: "The message From/To pair violates a blacklist rule.",
code: 21610,
}),
});
assert.equal(result.failed, 1);
const stored = await smsBlockedNumbersDb.get(hash);
assert.equal(stored?.reason, "provider-opt-out");
assert.equal(stored?.detail, "Twilio 21610");
const rows = await smsPhoneLogDb.listByUserId(USER);
assert.ok(rows.some((row) => row.action === "alert-send-failed"));
assert.ok(rows.some((row) => row.action === "number-blocked"));
});
it("does not retry that number on the next eligible run", async () => {
await verifyPhone(PHONE);
await deliverAlertSearchSms(daily(), {
send: async () => ({ status: "failed", error: "unsubscribed", code: 21610 }),
});
let sends = 0;
const second = await deliverAlertSearchSms(daily(), {
send: async () => {
sends += 1;
return { status: "sent", messageId: "SM1" };
},
});
assert.equal(sends, 0);
assert.equal(second.blocked, 1);
});
it("leaves an ordinary failure unblocked, so a blip is retried", async () => {
await verifyPhone(PHONE);
await deliverAlertSearchSms(daily(), {
send: async () => ({ status: "failed", error: "carrier down", code: 30001 }),
});
assert.equal(
await smsBlockedNumbersDb.isBlocked(hashPhoneNumber(PHONE, SECRET)),
false
);
});
});
+184
View File
@@ -0,0 +1,184 @@
import assert from "node:assert/strict";
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 {
smsBlockedNumbersDb,
strongerBlockReason,
} from "../../src/db/smsBlockedNumbersSqlite.js";
import { closeDatabase } from "../../src/db/sqlite.js";
import { isPhoneNumberBlocked } from "../../src/services/smsService.js";
import { hashPhoneNumber } from "../../src/util/smsVerificationCode.js";
const SECRET = "block-test-secret";
const PHONE = "+15555550123";
const OTHER = "+15555550124";
const HASH = hashPhoneNumber(PHONE, SECRET);
let dir: string;
let savedDataDir: string | undefined;
let savedBlocked: string | undefined;
beforeEach(async () => {
savedDataDir = process.env.NOTIFY_DATA_DIR;
savedBlocked = process.env.SMS_BLOCKED_NUMBERS;
delete process.env.SMS_BLOCKED_NUMBERS;
dir = await mkdtemp(path.join(tmpdir(), "sms-block-"));
process.env.NOTIFY_DATA_DIR = dir;
closeDatabase();
});
afterEach(async () => {
closeDatabase();
if (savedDataDir === undefined) delete process.env.NOTIFY_DATA_DIR;
else process.env.NOTIFY_DATA_DIR = savedDataDir;
if (savedBlocked === undefined) delete process.env.SMS_BLOCKED_NUMBERS;
else process.env.SMS_BLOCKED_NUMBERS = savedBlocked;
await rm(dir, { recursive: true, force: true });
});
describe("smsBlockedNumbersDb", () => {
it("blocks, reports, and unblocks by hash", async () => {
assert.equal(await smsBlockedNumbersDb.isBlocked(HASH), false);
const stored = await smsBlockedNumbersDb.block({
phoneHash: HASH,
phoneE164: PHONE,
reason: "opt-out",
detail: "keyword STOP",
});
assert.equal(stored.phoneE164, PHONE);
assert.equal(stored.reason, "opt-out");
assert.equal(await smsBlockedNumbersDb.isBlocked(HASH), true);
assert.equal(await smsBlockedNumbersDb.unblock(HASH), true);
assert.equal(await smsBlockedNumbersDb.isBlocked(HASH), false);
assert.equal(await smsBlockedNumbersDb.unblock(HASH), false);
});
it("is idempotent, so a second STOP refreshes rather than fails", async () => {
await smsBlockedNumbersDb.block({
phoneHash: HASH,
phoneE164: PHONE,
reason: "manual",
detail: "first",
});
await smsBlockedNumbersDb.block({
phoneHash: HASH,
reason: "manual",
detail: "second",
});
const rows = await smsBlockedNumbersDb.list();
assert.equal(rows.length, 1);
assert.equal(rows[0].detail, "second");
// The number is preserved when a later block omits it.
assert.equal(rows[0].phoneE164, PHONE);
});
it("never downgrades an opt-out to a manual block", async () => {
await smsBlockedNumbersDb.block({
phoneHash: HASH,
phoneE164: PHONE,
reason: "opt-out",
detail: "keyword STOP",
});
// An operator re-blocking must not erase the record saying the handset
// asked, because that record is what forbids simply lifting the block.
await smsBlockedNumbersDb.block({
phoneHash: HASH,
reason: "manual",
detail: "operator",
});
assert.equal((await smsBlockedNumbersDb.get(HASH))?.reason, "opt-out");
});
it("upgrades a manual block when the handset actually opts out", async () => {
await smsBlockedNumbersDb.block({ phoneHash: HASH, reason: "manual" });
await smsBlockedNumbersDb.block({
phoneHash: HASH,
reason: "provider-opt-out",
});
assert.equal(
(await smsBlockedNumbersDb.get(HASH))?.reason,
"provider-opt-out"
);
await smsBlockedNumbersDb.block({ phoneHash: HASH, reason: "opt-out" });
assert.equal((await smsBlockedNumbersDb.get(HASH))?.reason, "opt-out");
// ...and does not fall back down again.
await smsBlockedNumbersDb.block({
phoneHash: HASH,
reason: "provider-opt-out",
});
assert.equal((await smsBlockedNumbersDb.get(HASH))?.reason, "opt-out");
});
it("blocks only the hashed number, not its neighbours", async () => {
await smsBlockedNumbersDb.block({ phoneHash: HASH, reason: "manual" });
assert.equal(
await smsBlockedNumbersDb.isBlocked(hashPhoneNumber(OTHER, SECRET)),
false
);
});
});
describe("isPhoneNumberBlocked", () => {
it("is false for a number on neither source", async () => {
assert.equal(
await isPhoneNumberBlocked({ phoneE164: PHONE, phoneHash: HASH }),
false
);
});
it("consults the table", async () => {
await smsBlockedNumbersDb.block({ phoneHash: HASH, reason: "opt-out" });
assert.equal(
await isPhoneNumberBlocked({ phoneE164: PHONE, phoneHash: HASH }),
true
);
});
it("consults SMS_BLOCKED_NUMBERS without needing a database row", async () => {
process.env.SMS_BLOCKED_NUMBERS = PHONE;
assert.equal(
await isPhoneNumberBlocked({ phoneE164: PHONE, phoneHash: HASH }),
true
);
assert.equal(
await isPhoneNumberBlocked({
phoneE164: OTHER,
phoneHash: hashPhoneNumber(OTHER, SECRET),
}),
false
);
});
it("normalizes configured entries, so formatting does not defeat it", async () => {
process.env.SMS_BLOCKED_NUMBERS = " (555) 555-0123 , 5555550124 ";
assert.equal(
await isPhoneNumberBlocked({ phoneE164: PHONE, phoneHash: HASH }),
true
);
assert.equal(
await isPhoneNumberBlocked({
phoneE164: OTHER,
phoneHash: hashPhoneNumber(OTHER, SECRET),
}),
true
);
});
});
describe("strongerBlockReason", () => {
it("ranks an opt-out above a provider opt-out above a manual block", () => {
assert.equal(strongerBlockReason(undefined, "manual"), "manual");
assert.equal(strongerBlockReason("manual", "provider-opt-out"), "provider-opt-out");
assert.equal(strongerBlockReason("provider-opt-out", "opt-out"), "opt-out");
assert.equal(strongerBlockReason("opt-out", "manual"), "opt-out");
assert.equal(strongerBlockReason("opt-out", "provider-opt-out"), "opt-out");
assert.equal(strongerBlockReason("provider-opt-out", "manual"), "provider-opt-out");
});
});
+187
View File
@@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, it } from "node:test";
import express, { type RequestHandler } from "express";
import { smsAlertAuthorizationDb } from "../../src/db/smsAlertAuthorizationSqlite.js";
import { smsPhoneLogDb } from "../../src/db/smsPhoneLogSqlite.js";
import { smsBlockedNumbersDb } from "../../src/db/smsBlockedNumbersSqlite.js";
import { smsRegistrationsDb } from "../../src/db/smsRegistrationsSqlite.js";
import { closeDatabase } from "../../src/db/sqlite.js";
import { calendarDayInTimeZone } from "../../src/services/alertAuthorization.js";
@@ -29,6 +30,8 @@ const ENV_KEYS = [
"SMS_DEV_ECHO_CODE",
"SMS_REQUIRE_ACTION_CLAIM",
"SMS_ACTION_JWT_MAX_AGE_SEC",
"SMS_ALLOWED_RECIPIENT_DIDS",
"SMS_BLOCKED_NUMBERS",
"TWILIO_AUTH_TOKEN",
"TWILIO_WEBHOOK_URL",
"NODE_ENV",
@@ -212,6 +215,41 @@ describe("notify-sms enable flag", () => {
});
});
describe("SMS_ALLOWED_RECIPIENT_DIDS on POST /notify-sms/phone", () => {
it("registers normally for a DID on the list", async () => {
process.env.SMS_ALLOWED_RECIPIENT_DIDS = USER;
const result = await call({ body: { phoneNumber: PHONE } });
assert.equal(result.status, 200);
assert.equal(sent.length, 1);
});
it("refuses a DID that is not listed, before any send", async () => {
process.env.SMS_ALLOWED_RECIPIENT_DIDS = "did:ethr:0xsomeoneelse";
const result = await call({ body: { phoneNumber: PHONE } });
assert.equal(result.status, 403);
assert.equal(result.body.error, "SMS_RECIPIENT_NOT_ALLOWED");
assert.equal(sent.length, 0);
assert.equal(await smsRegistrationsDb.get(USER, PHONE), undefined);
const log = await smsPhoneLogDb.listByUserId(USER);
assert.equal(log[0].action, "recipient-not-allowed");
assert.equal(log[0].result, "rejected");
});
it("matches the list case-insensitively", async () => {
process.env.SMS_ALLOWED_RECIPIENT_DIDS = USER.toUpperCase();
const result = await call({ body: { phoneNumber: PHONE } });
assert.equal(result.status, 200);
});
it("refuses everyone when the list is set but empty", async () => {
process.env.SMS_ALLOWED_RECIPIENT_DIDS = "";
const result = await call({ body: { phoneNumber: PHONE } });
assert.equal(result.status, 403);
assert.equal(sent.length, 0);
});
});
describe("POST /notify-sms/phone", () => {
it("sends a code and stores only its hash", async () => {
const result = await call({ body: { phoneNumber: "(555) 555-0123" } });
@@ -779,6 +817,41 @@ describe("POST /notify-sms/inbound", () => {
assert.equal((await smsRegistrationsDb.get(USER, PHONE))?.verified, true);
});
it("makes the opt-out durable, so the number cannot simply re-register", async () => {
await registerAndVerify();
await inbound({ From: PHONE, Body: "STOP" });
assert.equal(
await smsBlockedNumbersDb.isBlocked(hashPhoneNumber(PHONE, SECRET)),
true
);
// Before the block existed this POST succeeded and texted a fresh code.
sent = [];
const retry = await call({ body: { phoneNumber: PHONE } });
assert.equal(retry.status, 403);
assert.equal(retry.body.error, "SMS_PHONE_BLOCKED");
assert.equal(sent.length, 0);
});
it("lifts the block on START, and the number can register again", async () => {
await registerAndVerify();
await inbound({ From: PHONE, Body: "STOP" });
await inbound({ From: PHONE, Body: "START" });
assert.equal(
await smsBlockedNumbersDb.isBlocked(hashPhoneNumber(PHONE, SECRET)),
false
);
sent = [];
const retry = await call({ body: { phoneNumber: PHONE } });
assert.equal(retry.status, 200);
assert.equal(sent.length, 1);
// START lifts the block; it does not restore verification.
assert.equal((await smsRegistrationsDb.get(USER, PHONE))?.verified, false);
});
it("tells START to register again rather than re-verifying", async () => {
await registerAndVerify();
await inbound({ From: PHONE, Body: "STOP" });
@@ -787,3 +860,117 @@ describe("POST /notify-sms/inbound", () => {
assert.equal((await smsRegistrationsDb.get(USER, PHONE))?.verified, false);
});
});
describe("blocked numbers", () => {
const blockedHash = () => hashPhoneNumber(PHONE, SECRET);
it("refuses registration of a blocked number, before any send", async () => {
await smsBlockedNumbersDb.block({
phoneHash: blockedHash(),
phoneE164: PHONE,
reason: "opt-out",
});
const result = await call({ body: { phoneNumber: PHONE } });
assert.equal(result.status, 403);
assert.equal(result.body.error, "SMS_PHONE_BLOCKED");
assert.equal(sent.length, 0);
assert.equal(await smsRegistrationsDb.get(USER, PHONE), undefined);
const log = await smsPhoneLogDb.listByUserId(USER);
assert.equal(log[0].action, "number-blocked");
// The rejection log names no number, only its hash.
assert.equal(log[0].phoneE164, undefined);
});
it("refuses verification even with a valid code", async () => {
process.env.SMS_DEV_ECHO_CODE = "true";
const posted = await call({ body: { phoneNumber: PHONE } });
const code = posted.body.devCode as string;
delete process.env.SMS_DEV_ECHO_CODE;
await smsBlockedNumbersDb.block({
phoneHash: blockedHash(),
reason: "opt-out",
});
const result = await call({
method: "PUT",
body: { phoneNumber: PHONE, code },
auth: { claim: { action: "verify-phone", phoneNumber: PHONE } },
});
assert.equal(result.status, 403);
assert.equal(result.body.error, "SMS_PHONE_BLOCKED");
assert.equal((await smsRegistrationsDb.get(USER, PHONE))?.verified, false);
});
it("beats the already-verified short circuit", async () => {
await registerAndVerify();
await smsBlockedNumbersDb.block({
phoneHash: blockedHash(),
reason: "manual",
});
const result = await call({
method: "PUT",
body: { phoneNumber: PHONE, code: "000000" },
auth: { claim: { action: "verify-phone", phoneNumber: PHONE } },
});
assert.equal(result.status, 403);
assert.equal(result.body.error, "SMS_PHONE_BLOCKED");
});
it("still lets the owner delete their registration", async () => {
await registerAndVerify();
await smsBlockedNumbersDb.block({
phoneHash: blockedHash(),
reason: "opt-out",
});
const result = await call({
method: "DELETE",
body: { phoneNumber: PHONE },
auth: { claim: { action: "delete-phone", phoneNumber: PHONE } },
});
assert.equal(result.status, 200);
assert.equal(result.body.deleted, true);
});
it("honours SMS_BLOCKED_NUMBERS with no database row", async () => {
process.env.SMS_BLOCKED_NUMBERS = "555-555-0123";
const result = await call({ body: { phoneNumber: PHONE } });
assert.equal(result.status, 403);
assert.equal(result.body.error, "SMS_PHONE_BLOCKED");
assert.equal(sent.length, 0);
});
});
describe("Twilio 21610 on the verification code send", () => {
it("blocks the number so the caller cannot retry into the same refusal", async () => {
sendResult = {
status: "failed",
error: "The message From/To pair violates a blacklist rule.",
code: 21610,
};
const first = await call({ body: { phoneNumber: PHONE } });
assert.equal(first.status, 502);
const stored = await smsBlockedNumbersDb.get(hashPhoneNumber(PHONE, SECRET));
assert.equal(stored?.reason, "provider-opt-out");
sendResult = { status: "sent", messageId: "SM1" };
sent = [];
const second = await call({ body: { phoneNumber: PHONE } });
assert.equal(second.status, 403);
assert.equal(second.body.error, "SMS_PHONE_BLOCKED");
assert.equal(sent.length, 0);
});
it("leaves an ordinary send failure retryable", async () => {
sendResult = { status: "failed", error: "carrier down" };
await call({ body: { phoneNumber: PHONE } });
assert.equal(
await smsBlockedNumbersDb.isBlocked(hashPhoneNumber(PHONE, SECRET)),
false
);
});
});
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import { afterEach, beforeEach, describe, it } from "node:test";
import { smsConfig } from "../../src/env.js";
import { isSmsRecipientAllowed } from "../../src/services/smsService.js";
const KEY = "SMS_ALLOWED_RECIPIENT_DIDS";
const MINE = "did:ethr:0xabc";
const THEIRS = "did:ethr:0xdef";
let saved: string | undefined;
beforeEach(() => {
saved = process.env[KEY];
delete process.env[KEY];
});
afterEach(() => {
if (saved === undefined) delete process.env[KEY];
else process.env[KEY] = saved;
});
describe("isSmsRecipientAllowed", () => {
it("allows everyone when the variable is absent", () => {
assert.equal(smsConfig().allowedRecipientDids, undefined);
assert.equal(isSmsRecipientAllowed(MINE), true);
assert.equal(isSmsRecipientAllowed(THEIRS), true);
});
it("allows only the listed DIDs when it is set", () => {
process.env[KEY] = MINE;
assert.equal(isSmsRecipientAllowed(MINE), true);
assert.equal(isSmsRecipientAllowed(THEIRS), false);
});
it("accepts a comma-separated list with untidy spacing", () => {
process.env[KEY] = ` ${MINE} , ${THEIRS} ,`;
assert.deepEqual(smsConfig().allowedRecipientDids, [MINE, THEIRS]);
assert.equal(isSmsRecipientAllowed(MINE), true);
assert.equal(isSmsRecipientAllowed(THEIRS), true);
assert.equal(isSmsRecipientAllowed("did:ethr:0x999"), false);
});
it("compares case-insensitively, so a checksummed address still matches", () => {
process.env[KEY] = "did:ethr:0xAbCdEf";
assert.equal(isSmsRecipientAllowed("did:ethr:0xabcdef"), true);
assert.equal(isSmsRecipientAllowed("DID:ETHR:0XABCDEF"), true);
});
it("blocks everyone when it is set but empty, rather than allowing everyone", () => {
process.env[KEY] = "";
assert.deepEqual(smsConfig().allowedRecipientDids, []);
assert.equal(isSmsRecipientAllowed(MINE), false);
});
it("blocks everyone when it holds only separators", () => {
process.env[KEY] = " , , ";
assert.equal(isSmsRecipientAllowed(MINE), false);
});
});