sms: create explicit allow & block management
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user