+ );
+}
+
+const when = (iso: string | null) => (iso ? new Date(iso).toLocaleString() : null);
+
+/** Spam check (spec 14 §7.1): IPQualityScore first, then the FTC list. Information only. */
+function SpamTab({ s }: { s: SettingsDto }) {
+ const save = useSave();
+ const sp = s.spam;
+ const st = s['spam.state'];
+ const [v, setV] = useState({
+ enabled: sp.enabled,
+ useIpqs: sp.ipqs !== null,
+ ipqsKey: '',
+ useFtc: sp.ftc !== null,
+ ftcKey: '',
+ syncHours: String(sp.ftc?.syncHours ?? 24),
+ windowDays: String(sp.ftc?.windowDays ?? 30),
+ flaggedDays: String(sp.cache.flaggedDays),
+ cleanDays: String(sp.cache.cleanDays),
+ high: sp.deskPhoneLabels.high,
+ medium: sp.deskPhoneLabels.medium,
+ });
+ const [confirming, setConfirming] = useState(false);
+ const text = (k: keyof typeof v) => (e: { target: { value: string } }) =>
+ setV({ ...v, [k]: e.target.value });
+ const flag = (k: 'enabled' | 'useIpqs' | 'useFtc') => (e: { target: { checked: boolean } }) =>
+ setV({ ...v, [k]: e.target.checked });
+ const submit = () => {
+ setConfirming(false);
+ save.mutate(
+ {
+ spam: {
+ enabled: v.enabled,
+ ipqs: v.useIpqs ? { key: v.ipqsKey || undefined } : null,
+ ftc: v.useFtc
+ ? {
+ key: v.ftcKey || undefined,
+ syncHours: Number(v.syncHours),
+ windowDays: Number(v.windowDays),
+ }
+ : null,
+ cache: { flaggedDays: Number(v.flaggedDays), cleanDays: Number(v.cleanDays) },
+ deskPhoneLabels: { high: v.high, medium: v.medium },
+ },
+ },
+ { onSuccess: () => setV((x) => ({ ...x, ipqsKey: '', ftcKey: '' })) },
+ );
+ };
+ // 14 §8: sending callers' numbers out is confirmed once, when IPQualityScore starts being used.
+ const sendsOut = v.enabled && v.useIpqs && !(sp.enabled && sp.ipqs);
+ const num = (k: 'syncHours' | 'windowDays' | 'flaggedDays' | 'cleanDays', label: string) => (
+
+ {(a) => (
+
+ )}
+
+ );
+ const keyField = (k: 'ipqsKey' | 'ftcKey', label: string, saved: boolean, hint: string) => (
+
+ {(a) => (
+
+ )}
+
+ );
+ return (
+
+ );
+}
+
const lines = (s: string) =>
s
.split(/[\n,]/)
diff --git a/docs/spec/02-data-model.md b/docs/spec/02-data-model.md
index a477f11..58d9778 100644
--- a/docs/spec/02-data-model.md
+++ b/docs/spec/02-data-model.md
@@ -8,7 +8,7 @@ Conventions:
- Primary keys: `uuid` with `default gen_random_uuid()` unless stated (built into PostgreSQL 18, so no pgcrypto extension is needed).
- Every table has `created_at timestamptz not null default now()` and
`updated_at timestamptz not null default now()` (left out below to save space,
- **but add them**). Exceptions: `cdr`, `audit_log`, `system_events`, `webhook_deliveries`, `generator_runs` (append-only, `created_at` only).
+ **but add them**). Exceptions: `cdr`, `audit_log`, `system_events`, `webhook_deliveries`, `generator_runs`, `ftc_complaints` (append-only, `created_at` only).
- Columns ending in `_enc` hold secrets encrypted with AES-256-GCM using
`APP_SECRET` (format: `base64(iv[12] | tag[16] | ciphertext)`). Only
`crypto/secrets.ts` encrypts and decrypts. API responses never include `_enc`
@@ -451,10 +451,35 @@ create table calls ( -- one row per logical cal
did_id uuid references dids(id) on delete set null,
answered_by_extension_id uuid references extensions(id) on delete set null,
talk_seconds int not null default 0,
- recording_id uuid references recordings(id) on delete set null
+ recording_id uuid references recordings(id) on delete set null,
+ spam_risk text, -- 14 §6: 'low' | 'medium' | 'high'; null = not checked
+ spam_info jsonb -- 14 §6: what the check said
);
create index on calls (started_at desc);
+-- Spam check (14 §7): IPQualityScore answers, and the local copy of the FTC complaints.
+create table spam_lookups ( -- IPQualityScore answers (14 §4)
+ number text primary key, -- 1NXXNXXXXXX
+ provider text not null, -- 'ipqs'
+ risk text not null check (risk in ('low', 'medium', 'high')),
+ info jsonb not null, -- the 14 §2.1 fields only
+ expires_at timestamptz not null, -- updated_at is when it was checked
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+create index on spam_lookups (expires_at);
+
+create table ftc_complaints ( -- the FTC copy (14 §2.2), kept windowDays; append-only
+ id text primary key, -- the FTC's id
+ number text not null, -- 1NXXNXXXXXX
+ reported_at timestamptz not null, -- the complaint's created-date
+ subject text,
+ robocall boolean,
+ created_at timestamptz not null default now()
+);
+create index on ftc_complaints (number);
+create index on ftc_complaints (reported_at);
+
create table call_extensions ( -- which extensions were involved (for per-user history)
call_id uuid not null references calls(id) on delete cascade,
extension_id uuid not null references extensions(id) on delete cascade,
@@ -654,3 +679,5 @@ with a zod schema per key and a default. Missing rows return the default.
| `provisioning.lanOnly` | boolean | false | only serve `/prov` to clients from local nets |
| `provisioning.enabled` | boolean | true | |
| `system.restartRequired` | boolean | false | set by generator |
+| `spam` | object | off, no providers | spam check (14 §7): `enabled`, `ipqs {keyEnc}`, `ftc {keyEnc, syncHours, windowDays}`, `cache {flaggedDays, cleanDays}`, `deskPhoneLabels {high, medium}` |
+| `spam.state` | object | `{}` | set by the system (14 §3, §2.2): IPQualityScore paused, the warning dismissed, the FTC sync's progress |
diff --git a/docs/spec/04-backend-api.md b/docs/spec/04-backend-api.md
index 4b7ef1e..0f52aa4 100644
--- a/docs/spec/04-backend-api.md
+++ b/docs/spec/04-backend-api.md
@@ -433,6 +433,7 @@ As built (M11-T6): `routes/reports.ts`. `from`/`to` are company-time-zone days,
| GET | `/api/audit-log` | |
| GET/PATCH | `/api/settings` | whole settings object; PATCH merges known keys only. `smtp` comes back as `{host, port, secure, username, from, hasPassword}` and is written with an optional `password` (empty keeps the stored one). `system.*`, `sms.webhookToken` and `setup.completed` are read-only. References (DIDs, extensions, destinations) are checked, and a transport a trunk uses can't be turned off |
| POST | `/api/settings/smtp/test` | `{to}` sends a test email |
+| POST | `/api/settings/spam/ipqs/test`, `/api/settings/spam/ftc/test` | `{key?}`: the typed key, else the saved one; `{ok, detail}` or `{ok: false, error}` (14 §7.1) |
| POST | `/api/settings/emergency/test` | runs 04 §5.3 steps 2–4 with `TEST` in front (email, ring-out, banner); records nothing and dials nothing (06 §6.5). Returns `{notified}` |
| GET | `/api/system/warnings` | `{items: [{code, message, link}]}` for the dashboard banner: `restart_required`, `e911_cid_missing` (a trunk exists, no default E911 caller ID), `e911_address_unconfirmed` (that DID has no `e911_address`), `smtp_missing`, `tls_cert` (TLS on and the cert missing or expiring within 14 days, M11-T2), `backup_old` (backups on and today's not made yet though the job had its chance, M11-T3), `directory_check` (12 §4.2, §4.5: the directory unreachable at a sign-in or by `directoryCheck`, or that job refusing to disable most people). "Trunk down" comes from live state in the browser |
| POST | `/api/settings/network/detect-public-ip` | |
@@ -465,7 +466,7 @@ Provisioning and inbound hooks (outside `/api`, no session):
| Job | Schedule | Does |
|---|---|---|
| `nightlyRegenerate` | 00:05 daily | `generator.apply({trigger:'nightly', force:true})` (holidays roll over), aggregate orphan CDRs |
-| `retentionCleanup` | 02:00 daily | delete recordings older than `recordings.retentionDays` (file + row), voicemail older than `voicemail.retentionDays` if > 0, sessions past expiry, webhook_deliveries older than 30 days |
+| `retentionCleanup` | 02:00 daily | delete recordings older than `recordings.retentionDays` (file + row), voicemail older than `voicemail.retentionDays` if > 0, sessions past expiry, webhook_deliveries older than 30 days, expired `spam_lookups` (14 §4) |
| `backup` | `backup.time` daily if enabled | §8.1 |
| `publicIpWatch` | every 5 min if enabled | detect IP; if changed: update settings, apply, set restartRequired, email admins |
| `webhookDelivery` | every 10 s | send pending deliveries (§9) |
diff --git a/docs/spec/05-web-ui.md b/docs/spec/05-web-ui.md
index 9acbadc..23a34f8 100644
--- a/docs/spec/05-web-ui.md
+++ b/docs/spec/05-web-ui.md
@@ -47,7 +47,7 @@ Left sidebar navigation, in this order:
| Reports | `/admin/reports` | Summary charts (calls per day stacked by direction, missed per day, busiest hours heatmap), queue report table. As built (M11-T6): From/To dates (the last 30 days by default, a year at most); totals (calls, missed, average talk time); the charts are plain bars, no chart library, each bar labeled for screen readers; the heatmap is a weekday × hour table in the company time zone; the queue table has the same numbers as the queue page. Owner follow-up (2026-10-21, "total minutes, calltime, missed calls"): totals also have answered, talk time and outside minutes (in and out: inbound from arrival, since a receptionist or voicemail answers too; outbound from the answer; roughly what the provider bills), an outside-minutes-per-day chart, and one tile per voip.ms trunk with its account balance (`GET /api/reports/balance`, `getBalance`; a trunk without the API login says what to add) |
| SMS | `/admin/sms` | Same UI as the user one, with all DIDs |
| Blocked numbers | `/admin/blocked` | |
-| Settings | `/admin/settings` | Tabs: General, Network (public IP detect, LAN IP, local nets, STUN/TURN), SIP (ports, TCP/TLS), Numbering ranges, Email (SMTP + test), Voicemail, Recording (retention, announcement sound + **legal notice text, 08 §5**), Emergency (default E911 caller ID, notify emails/extensions + a **test notification** button that does NOT dial 911), Backups (schedule, keep, include recordings, list + download + "Back up now"), Webhooks, API tokens. Security: desk phone networks, **outside calls at once** (per extension, whole system; 0 = no limit), phone setup files, SIP TLS |
+| Settings | `/admin/settings` | Tabs: General, Network (public IP detect, LAN IP, local nets, STUN/TURN), SIP (ports, TCP/TLS), Numbering ranges, Email (SMTP + test), Voicemail, Recording (retention, announcement sound + **legal notice text, 08 §5**), Emergency (default E911 caller ID, notify emails/extensions + a **test notification** button that does NOT dial 911), Backups (schedule, keep, include recordings, list + download + "Back up now"), Webhooks, API tokens. Security: desk phone networks, **outside calls at once** (per extension, whole system; 0 = no limit), phone setup files, SIP TLS. Spam check (14 §7.1) |
| System | `/admin/system` | Status JSON pretty-printed, Asterisk version, generator runs list, "Apply config now", "Rollback", "Restart telephony", generated-files viewer (read-only, masked), allowlisted CLI dropdown (04 §7.3) with output panel, audit log. As built (owner follow-ups to M11-T7, 2026-10-20), laid out after 3CX's admin dashboard (not copied): a **System information** panel (version, host name, company, public IP static/dynamic, LAN IPs, ports SIP/SIPS/media/HTTPS, telephony running, restart needed; copy buttons) with how to restart (`docker compose restart asterisk` on the server; no restart button),, a **Diagnostics** panel (08 §2.6: the server's own checks, then a Quick or Complete network test through the probe, each line with how to fix it), and an **Event log** panel (severity, event code, time, source, details; Load more; Download = `/api/system/events.csv`). Events (`system_events`, kept 90 days): `trunk.down`/`trunk.up` (registration changes), `telephony.down`/`telephony.up` (AMI), `telephony.restartNeeded`, `config.failed`, `emergency.call`/`emergency.test`, `network.publicIpChanged`, `backup.done`/`backup.failed`, `disk.low`. Admin edits stay in the audit log (not shown yet). The dashboard's `restart_required` warning links here. Generator runs, generated files and CLI views aren't built; the admin guide's troubleshooting page covers them from the shell |
### 2.1 First-run setup wizard (`/setup`)
diff --git a/docs/spec/09-roadmap.md b/docs/spec/09-roadmap.md
index 56c7a19..22cb4ed 100644
--- a/docs/spec/09-roadmap.md
+++ b/docs/spec/09-roadmap.md
@@ -9,7 +9,7 @@ Legend: 📖 read · 🔨 build · ✅ done when
---
## Backlog (owner's order, 2026-10-21)
-0. **Spam check** (owner, 2026-10-27: spec reviewed by the owner; SD1 next), tasks SD1–SD5
+0. **Spam check** (owner, 2026-10-27: SD1 done; SD2 next), tasks SD1–SD5
in [14 §9](14-spam.md#9-order-of-work): IPQualityScore and the FTC complaint list for outside
callers who aren't in anyone's contacts; information only, off by default.
0. **Mobile app** (owner, 2026-10-26: next), tasks MB1–MB8 in [13 §5](13-mobile.md#5-order-of-work):
diff --git a/docs/spec/14-spam.md b/docs/spec/14-spam.md
index 7bd44df..32f4b77 100644
--- a/docs/spec/14-spam.md
+++ b/docs/spec/14-spam.md
@@ -157,25 +157,27 @@ contacts test: each caller of it does that for its own user.
Migration (SD1):
```sql
-create table spam_lookups ( -- IPQualityScore answers (§4)
+create table spam_lookups ( -- IPQualityScore answers (14 §4)
number text primary key, -- 1NXXNXXXXXX
provider text not null, -- 'ipqs'
risk text not null check (risk in ('low', 'medium', 'high')),
- info jsonb not null, -- the §2.1 fields only
- checked_at timestamptz not null,
- expires_at timestamptz not null
+ info jsonb not null, -- the 14 §2.1 fields only
+ expires_at timestamptz not null, -- updated_at is when it was checked
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
);
create index on spam_lookups (expires_at);
-create table ftc_complaints ( -- the FTC copy (§2.2), kept windowDays
- id text primary key, -- the FTC's id
- number text not null, -- 1NXXNXXXXXX
- created_at timestamptz not null, -- the complaint's created-date
- subject text,
- robocall boolean
+create table ftc_complaints ( -- the FTC copy (14 §2.2), kept windowDays; append-only
+ id text primary key, -- the FTC's id
+ number text not null, -- 1NXXNXXXXXX
+ reported_at timestamptz not null, -- the complaint's created-date
+ subject text,
+ robocall boolean,
+ created_at timestamptz not null default now()
);
create index on ftc_complaints (number);
-create index on ftc_complaints (created_at);
+create index on ftc_complaints (reported_at);
alter table calls add column spam_risk text, add column spam_info jsonb;
```
@@ -231,12 +233,40 @@ log masks it. Validated with zod (`packages/shared`).
| Task | What | Done when |
|---|---|---|
-| SD1 | Decision 0006; this spec; the §7 migration and settings; `spam/check.ts` with both providers' lookups, the cache and the shared requests; Settings → Spam check with keys, Test, privacy confirmation | `pnpm lint && typecheck && test` pass; checks, cache times, the order (IPQualityScore, then the FTC list) and failures are tested against fake HTTP |
+| SD1 | Decision 0006; this spec; the §7 migration and settings; `spam/check.ts` with both providers' lookups, the cache and the shared requests; Settings → Spam check with keys, Test, privacy confirmation | `pnpm lint && typecheck && test` pass; checks, cache times, the order (IPQualityScore, then the FTC list) and failures are tested against fake HTTP. Done, see §9.1 |
| SD2 | §3: pause, events, the `spam_quota` warning and Dismiss, `spamQuotaCheck` | an exhausted fake pauses, falls back to the FTC list, warns once and resumes; the owner's real key shows the credit call is free |
| SD3 | §2.2 sync: paging, pacing, the saved cursor, the window, Sync now | a fake FTC API with 3 days of data syncs, resumes after a 429 and drops old complaints; one real sync with the owner's key |
| SD4 | §5: `/api/me/caller-check`, the softphone badge and line, the desk phone labels | an unknown number rings the softphone and shows its answer; a contact or a low risk shows nothing; a desk phone shows "SPAM LIKELY" when the answer is in time |
| SD5 | §6 call history; the admin guide page | a checked call keeps its badge in call history after its cache expires |
+### 9.1 As built: SD1
+
+- `spam/check.ts` `checkNumber(deps, number, { cachedOnly })`. There's no `wait`: a caller that
+ can't wait (14 §5.1, §5.2) races it against its own timer, and the check carries on and fills
+ the cache. The cache is only read while IPQualityScore is set up; turning it off leaves the
+ rows to expire.
+- `spam/ipqs.ts`, `spam/ftc.ts` (the local lookup and Test; the sync is SD3), `spam/http.ts`
+ (`getJson`, 4 s timeout, and `SpamError`, the only error text that is logged or shown: never
+ the URL or fetch's own error). A provider message is cut to 200 characters with the key taken
+ out, in case it quotes it.
+- "Insufficient credits" is a failure until SD2: logged, the FTC list asked, and the next check
+ tries IPQualityScore again.
+- Settings: the `spam` key holds the admin's choices, `spam.state` is read-only to PATCH (like
+ `system.*`). PATCH refuses a provider with no key typed or saved ("Enter the IPQualityScore
+ key", "Enter the api.data.gov key") and spam check on with neither provider. Labels are 1–20 of
+ `phoneSafeName`'s characters, runs of spaces made one. The audit log masks `keyEnc` (its
+ `…Enc` rule).
+- `POST /api/settings/spam/ipqs/test` and `/ftc/test` `{ key? }` → `{ ok, detail | error }`: the
+ typed key, else the saved one. IPQualityScore's is the Credit Usage API ("Works: 912 credits
+ left this billing period."); the FTC's is one request for one complaint.
+- Settings → Spam check (`SpamTab`): the switch, a section per provider with its key and Test,
+ the cache days and the desk phone labels. Saving with IPQualityScore newly in use opens the
+ 14 §8 confirmation first.
+- `retentionCleanup` deletes expired `spam_lookups` rows.
+- Tests: `test/spam.test.ts` with a stubbed `fetch` (risk, kept fields, cache days and expiry,
+ the shared request, IPQualityScore down / timing out / out of credits → the FTC list, warnings
+ without the key, `cachedOnly`, the settings and Test routes).
+
Later, not now:
- **Report as spam** (owner, 2026-10-27: not now): a button in call history that reports the
number to a provider that takes reports (OpenSpam has `POST /api/report`).
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 23e999e..91ffba3 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -27,3 +27,4 @@ export * from './blocked.ts';
export * from './calls.ts';
export * from './contacts.ts';
export * from './integrations.ts';
+export * from './spam.ts';
diff --git a/packages/shared/src/settings.ts b/packages/shared/src/settings.ts
index 4370244..a077baa 100644
--- a/packages/shared/src/settings.ts
+++ b/packages/shared/src/settings.ts
@@ -2,6 +2,12 @@
// One zod schema (with its default) per settings key (spec 02 §3).
import { z } from 'zod';
import { DestinationSchema } from './destination.ts';
+import {
+ SPAM_DEFAULTS,
+ SpamSettingsSchema,
+ SpamStateSchema,
+ type SpamSettingsDto,
+} from './spam.ts';
const range = (from: number, to: number) =>
z
@@ -151,6 +157,10 @@ export const SettingsSchemas = {
})
.nullable()
.default(null),
+ /** Spam check (spec 14 §7): off until the admin turns it on. */
+ spam: SpamSettingsSchema.default(SPAM_DEFAULTS),
+ /** What the system keeps about the spam check's providers (14 §3, §2.2). */
+ 'spam.state': SpamStateSchema.default(SpamStateSchema.parse({})),
/** Why the directory couldn't be used (12 §4.2, §4.5), for the dashboard; null = fine. */
'system.directoryWarning': z.string().nullable().default(null),
// Not in 02 §3's table but used by the generator (03 §3).
@@ -317,12 +327,13 @@ export type SettingsDto = {
[
K in Exclude<
SettingKey,
- 'smtp' | 'sms.webhookToken' | 'contacts.contrack' | 'auth.oidc' | 'auth.ldap'
+ 'smtp' | 'sms.webhookToken' | 'contacts.contrack' | 'auth.oidc' | 'auth.ldap' | 'spam'
>
]: SettingValue;
} & {
smtp: (Omit & { hasPassword: boolean }) | null;
'contacts.contrack': { url: string; publicUrl: string | null; hasCompanyToken: boolean } | null;
+ spam: SpamSettingsDto;
'auth.oidc':
(Omit & { hasClientSecret: boolean }) | null;
'auth.ldap':
diff --git a/packages/shared/src/spam.ts b/packages/shared/src/spam.ts
new file mode 100644
index 0000000..1e8f4c9
--- /dev/null
+++ b/packages/shared/src/spam.ts
@@ -0,0 +1,106 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Spam check (spec 14): what a check says about an outside caller, and its settings.
+import { z } from 'zod';
+
+export type SpamRisk = 'low' | 'medium' | 'high';
+export type SpamProvider = 'ipqs' | 'ftc';
+
+/** IPQualityScore's answer, only the fields 14 §2.1 keeps. */
+export type IpqsInfo = {
+ fraudScore: number;
+ spammer: boolean;
+ recentAbuse: boolean;
+ lineType: string | null;
+ carrier: string | null;
+ name: string | null;
+ city: string | null;
+ region: string | null;
+};
+
+/** The FTC complaints in the window (14 §2.2). */
+export type FtcInfo = {
+ complaints: number;
+ robocalls: number;
+ lastReportedAt: string | null;
+ subject: string | null;
+};
+
+export type SpamCheck =
+ | { risk: SpamRisk; provider: 'ipqs'; info: IpqsInfo }
+ | { risk: SpamRisk; provider: 'ftc'; info: FtcInfo };
+
+/** The desk phone labels go through phoneSafeName (api), so only what it keeps. */
+const label = z
+ .string()
+ .trim()
+ .min(1)
+ .max(20)
+ .regex(/^[\p{L}\p{M}\p{N} .'&@-]+$/u, "Letters, digits, spaces and . ' & @ - only")
+ .transform((s) => s.replace(/ +/g, ' '));
+
+export const SpamLabelsSchema = z.object({ high: label, medium: label });
+
+export const SpamCacheSchema = z.object({
+ flaggedDays: z.number().int().min(1).max(365),
+ cleanDays: z.number().int().min(1).max(365),
+});
+
+/** The stored `spam` setting (14 §7). Keys encrypted; a null provider isn't used. */
+export const SpamSettingsSchema = z.object({
+ enabled: z.boolean(),
+ ipqs: z.object({ keyEnc: z.string() }).nullable(),
+ ftc: z
+ .object({
+ keyEnc: z.string(),
+ syncHours: z.number().int().min(1).max(168),
+ windowDays: z.number().int().min(7).max(365),
+ })
+ .nullable(),
+ cache: SpamCacheSchema,
+ deskPhoneLabels: SpamLabelsSchema,
+});
+export type SpamSettings = z.infer;
+
+export const SPAM_DEFAULTS: SpamSettings = {
+ enabled: false,
+ ipqs: null,
+ ftc: null,
+ cache: { flaggedDays: 14, cleanDays: 3 },
+ deskPhoneLabels: { high: 'SPAM LIKELY', medium: 'Spam risk' },
+};
+
+/** What the system keeps about the providers (`spam.state`, read-only to PATCH). */
+export const SpamStateSchema = z.object({
+ ipqsPausedAt: z.string().nullable().default(null),
+ warningDismissedAt: z.string().nullable().default(null),
+ ftcSyncedTo: z.string().nullable().default(null),
+ ftcCursor: z.number().int().nullable().default(null),
+ ftcLastSyncAt: z.string().nullable().default(null),
+ ftcLastError: z.string().nullable().default(null),
+});
+export type SpamState = z.infer;
+
+/** PATCH /api/settings `spam`: keys are write-only (empty keeps the stored one). */
+export const SpamInputSchema = z.object({
+ enabled: z.boolean(),
+ ipqs: z.object({ key: z.string().trim().max(200).optional() }).nullable(),
+ ftc: z
+ .object({
+ key: z.string().trim().max(200).optional(),
+ syncHours: z.number().int().min(1).max(168),
+ windowDays: z.number().int().min(7).max(365),
+ })
+ .nullable(),
+ cache: SpamCacheSchema,
+ deskPhoneLabels: SpamLabelsSchema,
+});
+export type SpamInput = z.input;
+
+/** GET /api/settings `spam`: keys replaced by hasKey. */
+export type SpamSettingsDto = Omit & {
+ ipqs: { hasKey: boolean } | null;
+ ftc: { hasKey: boolean; syncHours: number; windowDays: number } | null;
+};
+
+/** POST /api/settings/spam/{ipqs,ftc}/test. */
+export type SpamTestResult = { ok: true; detail: string } | { ok: false; error: string };
--
2.34.1
From 70f14288caec22bc067ebe1d0c9d9f5475678f49 Mon Sep 17 00:00:00 2001
From: iamdoubz
Date: Tue, 27 Oct 2026 16:17:42 -0500
Subject: [PATCH 3/7] api: pause spam check when IPQualityScore credits run out
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
SD2 (14 §3): out of credits pauses IPQualityScore (one warning, one
event), the FTC list answers meanwhile, the dashboard shows a dismissable
spam_quota warning, and spamQuotaCheck resumes it every 6 hours once
credits are back. A newly saved key resumes it at once.
Follow-up: confirm with the owner's real key that the Credit Usage API
costs no credit (TODO in 14 §2.1).
---
apps/api/src/jobs.ts | 3 +
apps/api/src/routes/settings.ts | 17 ++++
apps/api/src/spam/check.ts | 86 +++++++++++++++--
apps/api/test/spam.test.ts | 107 +++++++++++++++++++--
apps/web/src/api/client.ts | 2 +
apps/web/src/pages/admin/DashboardPage.tsx | 24 ++++-
docs/spec/04-backend-api.md | 4 +-
docs/spec/05-web-ui.md | 4 +-
docs/spec/09-roadmap.md | 2 +-
docs/spec/14-spam.md | 31 ++++--
packages/shared/src/settings.ts | 3 +-
11 files changed, 254 insertions(+), 29 deletions(-)
diff --git a/apps/api/src/jobs.ts b/apps/api/src/jobs.ts
index 2674b96..d52a438 100644
--- a/apps/api/src/jobs.ts
+++ b/apps/api/src/jobs.ts
@@ -31,6 +31,7 @@ import { logCallsToContrack } from './contacts/timeline.ts';
import { checkDirectory } from './sso/directory.ts';
import { checkOidcSessions } from './sso/oidcCheck.ts';
import { logEvent } from './services/events.ts';
+import { spamQuotaCheck } from './spam/check.ts';
import { deliverDue } from './services/webhooks.ts';
import type { SendMail } from './services/mail.ts';
import { getSetting, setSetting } from './services/settings.ts';
@@ -277,6 +278,7 @@ export function createJobs(d: JobDeps) {
};
return {
+ spamQuotaCheck: () => spamQuotaCheck(d.db),
oidcCheck,
directoryCheck,
daily,
@@ -314,6 +316,7 @@ export function startJobs(d: JobDeps): () => void {
run('contrackLog', j.contrackLog);
if (minute % 5 === 0) run('oidcCheck', j.oidcCheck);
if (minute % 60 === 0) run('directoryCheck', j.directoryCheck);
+ if (minute % 360 === 0) run('spamQuotaCheck', j.spamQuotaCheck);
run('callTranscripts', async () => {
await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log });
});
diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts
index 57cfd56..13a25b7 100644
--- a/apps/api/src/routes/settings.ts
+++ b/apps/api/src/routes/settings.ts
@@ -46,6 +46,7 @@ import { assertIssuer, oidcConfig, oidcError, testOidc, type OidcSettings } from
import { ftcTest } from '../spam/ftc.ts';
import { SpamError } from '../spam/http.ts';
import { ipqsCredits } from '../spam/ipqs.ts';
+import { patchSpamState, resumeIpqs } from '../spam/check.ts';
declare module 'fastify' {
interface FastifyInstance {
@@ -350,6 +351,8 @@ export async function settingsRoutes(app: FastifyInstance) {
}
for (const [key, value] of Object.entries(values))
await setSetting(db, key as SettingKey, value as never);
+ // A new IPQualityScore key may have credits: don't wait for spamQuotaCheck (14 §3).
+ if ((body.spam as SpamInput | undefined)?.ipqs?.key) await resumeIpqs(db, 'a new key');
const changed = Object.keys(values).filter(
(k) => JSON.stringify(before[k as keyof typeof before]) !== JSON.stringify(values[k]),
);
@@ -556,6 +559,14 @@ export async function settingsRoutes(app: FastifyInstance) {
message: s['system.directoryWarning'],
link: '/admin/settings/sign-in',
});
+ const st = s['spam.state'];
+ if (s.spam.enabled && s.spam.ipqs && st.ipqsPausedAt && !st.warningDismissedAt)
+ items.push({
+ code: 'spam_quota',
+ message: `IPQualityScore's lookups are used up. Spam checks ${s.spam.ftc ? 'use the FTC list' : 'are off'} until they come back.`,
+ link: '/admin/settings/spam',
+ dismissable: true,
+ });
if (!s.smtp)
items.push({
code: 'smtp_missing',
@@ -564,4 +575,10 @@ export async function settingsRoutes(app: FastifyInstance) {
});
return { items };
});
+
+ /** Hidden until the next time the credits run out (14 §3). */
+ app.post('/api/system/warnings/spam_quota/dismiss', async () => {
+ await patchSpamState(db, { warningDismissedAt: new Date().toISOString() });
+ return { ok: true };
+ });
}
diff --git a/apps/api/src/spam/check.ts b/apps/api/src/spam/check.ts
index 7226ef0..9762a5a 100644
--- a/apps/api/src/spam/check.ts
+++ b/apps/api/src/spam/check.ts
@@ -2,16 +2,24 @@
// The spam check (14 §5): the one way to ask what's known about a caller's number. Information
// only: nothing here changes a call, and a failure only means there's no answer (and one warning
// in the log). The contacts test (14 §1) is the caller's: it knows whose books to look in.
-import { and, eq, gt, sql } from 'drizzle-orm';
+import { and, eq, gt, isNull, sql, type SQL } from 'drizzle-orm';
import type { FastifyBaseLogger } from 'fastify';
-import type { IpqsInfo, SpamCheck, SpamRisk, SpamSettings } from '@selfphoneng/shared';
+import {
+ SpamStateSchema,
+ type IpqsInfo,
+ type SpamCheck,
+ type SpamRisk,
+ type SpamSettings,
+ type SpamState,
+} from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
-import { spamLookups } from '../db/schema.ts';
+import { settings, spamLookups } from '../db/schema.ts';
import { decrypt } from '../crypto/secrets.ts';
+import { logEvent } from '../services/events.ts';
import { getSetting } from '../services/settings.ts';
import { ftcLookup } from './ftc.ts';
import { SpamError } from './http.ts';
-import { ipqsLookup } from './ipqs.ts';
+import { ipqsCredits, ipqsLookup } from './ipqs.ts';
export type SpamDeps = { db: Db; log: Pick };
@@ -44,7 +52,8 @@ export async function checkNumber(
.from(spamLookups)
.where(and(eq(spamLookups.number, number), gt(spamLookups.expiresAt, sql`now()`)));
if (hit) return { risk: hit.risk as SpamRisk, provider: 'ipqs', info: hit.info as IpqsInfo };
- if (!opts.cachedOnly) {
+ // Paused (14 §3): the cache still answers, IPQualityScore isn't asked.
+ if (!opts.cachedOnly && !(await getSetting(d.db, 'spam.state')).ipqsPausedAt) {
let p = pending.get(number);
if (!p) {
p = askIpqs(d, s, s.ipqs.keyEnc, number).finally(() => pending.delete(number));
@@ -72,8 +81,7 @@ async function askIpqs(
try {
const a = await ipqsLookup(decrypt(keyEnc), number);
if (a.kind === 'exhausted') {
- // TODO(spec): SD2 pauses IPQualityScore here (14 §3); until then each check tries again.
- d.log.warn({ provider: 'ipqs' }, 'spam check: IPQualityScore credits are used up');
+ await pauseIpqs(d, s);
return null;
}
const days = a.risk === 'low' ? s.cache.cleanDays : s.cache.flaggedDays;
@@ -91,3 +99,67 @@ async function askIpqs(
return null;
}
}
+
+/**
+ * Merges `patch` into spam.state in one statement, so the jobs and checks don't overwrite each
+ * other's fields. `onlyIf`: the condition the stored state must meet; false when it didn't.
+ */
+export async function patchSpamState(
+ db: Db,
+ patch: Partial,
+ onlyIf?: SQL,
+): Promise {
+ await db
+ .insert(settings)
+ .values({ key: 'spam.state', value: SpamStateSchema.parse({}) })
+ .onConflictDoNothing();
+ const rows = await db
+ .update(settings)
+ .set({
+ value: sql`${settings.value} || ${JSON.stringify(patch)}::jsonb`,
+ updatedAt: new Date(),
+ })
+ .where(and(eq(settings.key, 'spam.state'), onlyIf))
+ .returning({ key: settings.key });
+ return rows.length > 0;
+}
+
+const paused = sql`${settings.value}->>'ipqsPausedAt'`;
+
+/** Out of credits (14 §3): paused, and the event, warning and log line once per time. */
+async function pauseIpqs(d: SpamDeps, s: SpamSettings) {
+ const first = await patchSpamState(
+ d.db,
+ { ipqsPausedAt: new Date().toISOString(), warningDismissedAt: null },
+ isNull(paused),
+ );
+ if (!first) return;
+ d.log.warn({ provider: 'ipqs' }, 'spam check: IPQualityScore credits are used up, paused');
+ await logEvent(d.db, {
+ severity: 'warning',
+ code: 'spam.quotaExhausted',
+ source: 'Spam check',
+ message: `IPQualityScore's lookups are used up. ${s.ftc ? 'Spam checks use the FTC list' : 'Spam checks are off'} until they come back.`,
+ });
+}
+
+/** Unpaused, by the job or a new key (14 §3). */
+export async function resumeIpqs(db: Db, why: string) {
+ const was = await patchSpamState(db, { ipqsPausedAt: null }, sql`${paused} is not null`);
+ if (was)
+ await logEvent(db, {
+ severity: 'info',
+ code: 'spam.quotaRestored',
+ source: 'Spam check',
+ message: `IPQualityScore lookups are back (${why}).`,
+ });
+}
+
+/** Job spamQuotaCheck (14 §3), every 6 hours: while paused, are credits back? A failure throws
+ * (the job's log line) and the next run tries again. */
+export async function spamQuotaCheck(db: Db) {
+ const s = await getSetting(db, 'spam');
+ if (!s.ipqs || !(await getSetting(db, 'spam.state')).ipqsPausedAt) return;
+ const credits = await ipqsCredits(decrypt(s.ipqs.keyEnc));
+ if (credits > 0) await resumeIpqs(db, `${credits} credits left`);
+}
diff --git a/apps/api/test/spam.test.ts b/apps/api/test/spam.test.ts
index fea7c8d..5aad69a 100644
--- a/apps/api/test/spam.test.ts
+++ b/apps/api/test/spam.test.ts
@@ -1,12 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
-// SD1: the spam check (14): IPQualityScore first, then the local FTC copy, against a fake fetch.
-import { eq, sql } from 'drizzle-orm';
+// SD1, SD2: the spam check (14): IPQualityScore first, then the local FTC copy, against a fake fetch.
+import { eq, like, sql } from 'drizzle-orm';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { SPAM_DEFAULTS, type SpamSettings } from '@selfphoneng/shared';
import { encrypt } from '../src/crypto/secrets.ts';
-import { auditLog, ftcComplaints, spamLookups } from '../src/db/schema.ts';
-import { checkNumber } from '../src/spam/check.ts';
+import {
+ auditLog,
+ ftcComplaints,
+ settings as settingsTable,
+ spamLookups,
+ systemEvents,
+} from '../src/db/schema.ts';
+import { checkNumber, spamQuotaCheck } from '../src/spam/check.ts';
import { getSetting, setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
@@ -32,6 +38,7 @@ describe.skipIf(!hasTestDb)('spam check', () => {
const urls: string[] = [];
/** What the fake IPQualityScore does: answer, fail, or say the credits are used up. */
let ipqs: 'ok' | 'down' | 'exhausted' | 'slow' = 'ok';
+ let credits = 912;
const log = { warn: vi.fn() };
const d = () => ({ db: t.db, log });
@@ -73,6 +80,7 @@ describe.skipIf(!hasTestDb)('spam check', () => {
urls.length = 0;
log.warn.mockClear();
ipqs = 'ok';
+ credits = 912;
vi.stubGlobal('fetch', async (url: string, init?: RequestInit) => {
urls.push(url);
const json = (j: unknown, status = 200) => new Response(JSON.stringify(j), { status });
@@ -83,7 +91,7 @@ describe.skipIf(!hasTestDb)('spam check', () => {
const account = /\/api\/json\/account\/([^/]+)$/.exec(url);
if (account)
return account[1] === IPQS_KEY
- ? json({ success: true, credits: 912, usage: 88 })
+ ? json({ success: true, credits, usage: 88 })
: json({ success: false, message: `Invalid or unauthorized key: ${account[1]}` });
const phone = /\/api\/json\/phone\/[^/]+\/(\d+)\?country\[\]=US$/.exec(url);
if (!phone) return json({}, 404);
@@ -101,6 +109,8 @@ describe.skipIf(!hasTestDb)('spam check', () => {
afterEach(async () => {
vi.unstubAllGlobals();
await t.db.delete(spamLookups);
+ await t.db.delete(settingsTable).where(eq(settingsTable.key, 'spam.state'));
+ await t.db.delete(systemEvents).where(like(systemEvents.code, 'spam.%'));
});
it('checks nothing while off, and only US numbers', async () => {
@@ -165,7 +175,7 @@ describe.skipIf(!hasTestDb)('spam check', () => {
it('IPQualityScore failing: one warning without the key, nothing cached, the FTC answers', async () => {
await settings({ ...keys.withIpqs, ...keys.withFtc });
- for (const mode of ['down', 'slow', 'exhausted'] as const) {
+ for (const mode of ['down', 'slow'] as const) {
ipqs = mode;
expect(await checkNumber(d(), REPORTED)).toEqual({
risk: 'high',
@@ -178,11 +188,9 @@ describe.skipIf(!hasTestDb)('spam check', () => {
},
});
}
- expect(log.warn).toHaveBeenCalledTimes(3);
expect(log.warn.mock.calls.map((c) => c[0])).toEqual([
{ provider: 'ipqs', reason: 'HTTP 503' },
{ provider: 'ipqs', reason: 'no answer in 4 s' },
- { provider: 'ipqs' },
]);
expect(JSON.stringify(log.warn.mock.calls)).not.toContain(IPQS_KEY);
expect(await t.db.select().from(spamLookups)).toEqual([]);
@@ -204,6 +212,89 @@ describe.skipIf(!hasTestDb)('spam check', () => {
expect(await checkNumber(d(), REPORTED)).toBeNull();
});
+ it('out of credits: paused once, the FTC answers, the warning, Dismiss, and back', async () => {
+ await settings({ ...keys.withIpqs, ...keys.withFtc });
+ const warning = async () =>
+ (await admin('GET', '/api/system/warnings'))
+ .json()
+ .items.find((w: { code: string }) => w.code === 'spam_quota');
+ const events = async () =>
+ (
+ await t.db
+ .select({ code: systemEvents.code })
+ .from(systemEvents)
+ .where(like(systemEvents.code, 'spam.%'))
+ .orderBy(systemEvents.createdAt)
+ ).map((e) => e.code);
+ ipqs = 'exhausted';
+ const both = await Promise.all([checkNumber(d(), REPORTED), checkNumber(d(), SHADY)]);
+ expect(both.map((a) => a?.provider)).toEqual(['ftc', 'ftc']);
+ expect((await getSetting(t.db, 'spam.state')).ipqsPausedAt).not.toBeNull();
+ expect(log.warn).toHaveBeenCalledTimes(1);
+ expect(await events()).toEqual(['spam.quotaExhausted']);
+ expect(await warning()).toEqual({
+ code: 'spam_quota',
+ message:
+ "IPQualityScore's lookups are used up. Spam checks use the FTC list until they come back.",
+ link: '/admin/settings/spam',
+ dismissable: true,
+ });
+
+ // Paused: IPQualityScore isn't asked and nothing is logged.
+ urls.length = 0;
+ expect((await checkNumber(d(), CLEAN))?.provider).toBe('ftc');
+ expect(urls).toEqual([]);
+ expect(log.warn).toHaveBeenCalledTimes(1);
+
+ expect((await admin('POST', '/api/system/warnings/spam_quota/dismiss')).statusCode).toBe(200);
+ expect(await warning()).toBeUndefined();
+
+ credits = 0;
+ await spamQuotaCheck(t.db);
+ expect((await getSetting(t.db, 'spam.state')).ipqsPausedAt).not.toBeNull();
+ credits = 5;
+ await spamQuotaCheck(t.db);
+ expect((await getSetting(t.db, 'spam.state')).ipqsPausedAt).toBeNull();
+ expect(await events()).toEqual(['spam.quotaExhausted', 'spam.quotaRestored']);
+ ipqs = 'ok';
+ expect((await checkNumber(d(), SPAMMER))?.provider).toBe('ipqs');
+
+ // Out again: the warning comes back though it was dismissed; without the FTC list, "off".
+ await settings(keys.withIpqs);
+ ipqs = 'exhausted';
+ expect(await checkNumber(d(), SHADY)).toBeNull();
+ expect((await warning())?.message).toBe(
+ "IPQualityScore's lookups are used up. Spam checks are off until they come back.",
+ );
+
+ // A new key doesn't wait for the job.
+ const r = await admin('PATCH', '/api/settings', {
+ spam: {
+ ...SPAM_DEFAULTS,
+ enabled: true,
+ ipqs: { key: IPQS_KEY },
+ },
+ });
+ expect(r.statusCode).toBe(200);
+ expect((await getSetting(t.db, 'spam.state')).ipqsPausedAt).toBeNull();
+ expect(await warning()).toBeUndefined();
+ });
+
+ it('spamQuotaCheck asks nothing unless paused, and a failure throws without the key', async () => {
+ await settings(keys.withIpqs);
+ await spamQuotaCheck(t.db);
+ expect(urls).toEqual([]);
+ await setSetting(t.db, 'spam.state', {
+ ...(await getSetting(t.db, 'spam.state')),
+ ipqsPausedAt: new Date().toISOString(),
+ });
+ await settings({ ipqs: { keyEnc: encrypt('wrong-key') } });
+ const err = await spamQuotaCheck(t.db).catch((e: unknown) => e as Error);
+ expect(err).toBeInstanceOf(Error);
+ expect(err!.message).toBe('Invalid or unauthorized key: ***');
+ expect((await getSetting(t.db, 'spam.state')).ipqsPausedAt).not.toBeNull();
+ });
+
it('cachedOnly never asks IPQualityScore', async () => {
await settings({ ...keys.withIpqs, ...keys.withFtc });
expect((await checkNumber(d(), REPORTED, { cachedOnly: true }))?.provider).toBe('ftc');
diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
index 6dfe9ed..bbf04a1 100644
--- a/apps/web/src/api/client.ts
+++ b/apps/web/src/api/client.ts
@@ -256,6 +256,8 @@ export const settingsApi = {
'/api/settings/emergency/test',
),
warnings: () => api<{ items: Warning[] }>('GET', '/api/system/warnings').then((r) => r.items),
+ dismissWarning: (code: string) =>
+ api<{ ok: true }>('POST', `/api/system/warnings/${code}/dismiss`),
testOidc: (v: OidcInput) => api('POST', '/api/settings/auth/oidc/test', v),
/** 12 §4.1 */
testLdap: (v: LdapInput & { testLogin?: string; testPassword?: string }) =>
diff --git a/apps/web/src/pages/admin/DashboardPage.tsx b/apps/web/src/pages/admin/DashboardPage.tsx
index 786789d..6566ea9 100644
--- a/apps/web/src/pages/admin/DashboardPage.tsx
+++ b/apps/web/src/pages/admin/DashboardPage.tsx
@@ -1,9 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Dashboard (05 §2). Warnings, telephony link, trunks, calls in progress (hang up / transfer)
// queue tiles, parked calls and extensions online.
-import { useMutation, useQuery } from '@tanstack/react-query';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState, type ReactNode } from 'react';
-import type { ActiveCall, QueueLive } from '@selfphoneng/shared';
+import type { ActiveCall, QueueLive, Warning } from '@selfphoneng/shared';
import { Link } from 'react-router';
import { liveApi, settingsApi, type SpyMode } from '../../api/client.ts';
import { ConfirmDialog } from '../../components/Dialog.tsx';
@@ -144,7 +144,12 @@ function Warnings({ trunkDown }: { trunkDown: boolean }) {
queryFn: settingsApi.warnings,
refetchInterval: 30_000,
});
- const items = [
+ const qc = useQueryClient();
+ const dismiss = useMutation({
+ mutationFn: settingsApi.dismissWarning,
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['warnings'] }),
+ });
+ const items: Warning[] = [
...(trunkDown
? [
{
@@ -168,6 +173,19 @@ function Warnings({ trunkDown }: { trunkDown: boolean }) {
Fix
+ {w.dismissable && (
+ <>
+ {' '}
+
+ >
+ )}
))}
diff --git a/docs/spec/04-backend-api.md b/docs/spec/04-backend-api.md
index 0f52aa4..ce09217 100644
--- a/docs/spec/04-backend-api.md
+++ b/docs/spec/04-backend-api.md
@@ -435,7 +435,8 @@ As built (M11-T6): `routes/reports.ts`. `from`/`to` are company-time-zone days,
| POST | `/api/settings/smtp/test` | `{to}` sends a test email |
| POST | `/api/settings/spam/ipqs/test`, `/api/settings/spam/ftc/test` | `{key?}`: the typed key, else the saved one; `{ok, detail}` or `{ok: false, error}` (14 §7.1) |
| POST | `/api/settings/emergency/test` | runs 04 §5.3 steps 2–4 with `TEST` in front (email, ring-out, banner); records nothing and dials nothing (06 §6.5). Returns `{notified}` |
-| GET | `/api/system/warnings` | `{items: [{code, message, link}]}` for the dashboard banner: `restart_required`, `e911_cid_missing` (a trunk exists, no default E911 caller ID), `e911_address_unconfirmed` (that DID has no `e911_address`), `smtp_missing`, `tls_cert` (TLS on and the cert missing or expiring within 14 days, M11-T2), `backup_old` (backups on and today's not made yet though the job had its chance, M11-T3), `directory_check` (12 §4.2, §4.5: the directory unreachable at a sign-in or by `directoryCheck`, or that job refusing to disable most people). "Trunk down" comes from live state in the browser |
+| GET | `/api/system/warnings` | `{items: [{code, message, link, dismissable?}]}` for the dashboard banner: `restart_required`, `e911_cid_missing` (a trunk exists, no default E911 caller ID), `e911_address_unconfirmed` (that DID has no `e911_address`), `smtp_missing`, `tls_cert` (TLS on and the cert missing or expiring within 14 days, M11-T2), `backup_old` (backups on and today's not made yet though the job had its chance, M11-T3), `directory_check` (12 §4.2, §4.5: the directory unreachable at a sign-in or by `directoryCheck`, or that job refusing to disable most people), `spam_quota` (14 §3, dismissable). "Trunk down" comes from live state in the browser |
+| POST | `/api/system/warnings/spam_quota/dismiss` | hides `spam_quota` until IPQualityScore's credits run out again (14 §3) |
| POST | `/api/settings/network/detect-public-ip` | |
| GET | `/api/system/status` | `{ db: ok, ami: connected, asteriskVersion, uptime, trunks: [...], disk: {free, total}, restartRequired, lastGeneratorRun }` |
| GET | `/api/system/tls` | the SIP TLS certificate in `TLS_DIR`: `{ok: true, subject, names, notAfter}` or `{ok: false, error}` (M11-T2) |
@@ -475,6 +476,7 @@ Provisioning and inbound hooks (outside `/api`, no session):
| `contactsWarm` | every 5 min, Contrack mode | refresh the company's and each connected person's Contrack book, so a ringing phone's caller-name lookup (11 §6.1) finds them |
| `contrackLog` | every 60 s, Contrack mode | calls ended over 1 min ago (and within 2 days) of people logging calls: one `call` interaction on the matching contact's Contrack timeline (11 §4.4) |
| `callReports` | every 60 s | each user whose call report period (05 §4) ended over 5 min ago and isn't reported yet: email their calls of that period (by hang-up time) if there were any; `extensions.call_report_sent_at` = the period's end. Changing the schedule sets it to now, so the first report is the next period's |
+| `spamQuotaCheck` | every 6 hours, while IPQualityScore is paused | ask its Credit Usage API; credits back → resume (14 §3) |
Jobs must never overlap with themselves (use a running flag).
diff --git a/docs/spec/05-web-ui.md b/docs/spec/05-web-ui.md
index 23a34f8..76a0a99 100644
--- a/docs/spec/05-web-ui.md
+++ b/docs/spec/05-web-ui.md
@@ -27,7 +27,7 @@ Left sidebar navigation, in this order:
| Page | Path | Contents |
|---|---|---|
-| Dashboard | `/admin` | Cards: trunks (registered?), extensions online/total, active calls, calls today, missed today, voicemails waiting, disk free. Live call list (from, to, state, duration ticking, recording dot; admin buttons Hang up / Transfer / Listen). Queue tiles (waiting, longest wait, agents available). Warnings banner (restart required, SMTP not set, E911 caller ID not set, trunk down) |
+| Dashboard | `/admin` | Cards: trunks (registered?), extensions online/total, active calls, calls today, missed today, voicemails waiting, disk free. Live call list (from, to, state, duration ticking, recording dot; admin buttons Hang up / Transfer / Listen). Queue tiles (waiting, longest wait, agents available). Warnings banner (restart required, SMTP not set, E911 caller ID not set, trunk down; IPQualityScore out of credits, with Dismiss, 14 §3) |
| Users & Extensions | `/admin/users` | Table: number, name, email, role, online (contacts count + state dot), DND, devices. Create/edit drawer with tabs: **General** (name, email, role, number with "next free" button), **Phone** (ring seconds, webrtc on/off, pickup group, BLF numbers multiselect, language, MOH class), **Voicemail** (enabled, PIN, email options), **Calling** (outbound class, outbound caller ID DID, recording mode), **Emergency** (E911 caller ID DID, location text), **Credentials** (SIP username/server/password reveal, regenerate, softphone QR), **Forwarding** (current DND/CFU/CFB/CFNA, editable). As built (owner follow-up to M11-T7, 2026-10-20): the Calling tab's recording mode was missing until then; it is a **Record calls** select (Never, Calls from outside, Calls to outside, All outside calls = `recordMode` never/inbound/outbound/all). Calls inside the company are never recorded, and no recording notice plays for it (only numbers set to record play one) |
| Trunks | `/admin/trunks` | List with status pill. Form: provider (voip.ms / generic). Choosing voip.ms pre-fills port, transport, codecs and shows a POP server dropdown (06 §2) plus the voip.ms-side checklist. Test button. API credentials (optional) + "Import DIDs" |
| Phone numbers (DIDs) | `/admin/numbers` | Table: number, label, trunk, destination summary ("→ IVR Main menu"), SMS on/off. Form: number, label, trunk, **DestinationPicker**, caller-name prefix, record inbound, SMS enabled + users allowed |
@@ -48,7 +48,7 @@ Left sidebar navigation, in this order:
| SMS | `/admin/sms` | Same UI as the user one, with all DIDs |
| Blocked numbers | `/admin/blocked` | |
| Settings | `/admin/settings` | Tabs: General, Network (public IP detect, LAN IP, local nets, STUN/TURN), SIP (ports, TCP/TLS), Numbering ranges, Email (SMTP + test), Voicemail, Recording (retention, announcement sound + **legal notice text, 08 §5**), Emergency (default E911 caller ID, notify emails/extensions + a **test notification** button that does NOT dial 911), Backups (schedule, keep, include recordings, list + download + "Back up now"), Webhooks, API tokens. Security: desk phone networks, **outside calls at once** (per extension, whole system; 0 = no limit), phone setup files, SIP TLS. Spam check (14 §7.1) |
-| System | `/admin/system` | Status JSON pretty-printed, Asterisk version, generator runs list, "Apply config now", "Rollback", "Restart telephony", generated-files viewer (read-only, masked), allowlisted CLI dropdown (04 §7.3) with output panel, audit log. As built (owner follow-ups to M11-T7, 2026-10-20), laid out after 3CX's admin dashboard (not copied): a **System information** panel (version, host name, company, public IP static/dynamic, LAN IPs, ports SIP/SIPS/media/HTTPS, telephony running, restart needed; copy buttons) with how to restart (`docker compose restart asterisk` on the server; no restart button),, a **Diagnostics** panel (08 §2.6: the server's own checks, then a Quick or Complete network test through the probe, each line with how to fix it), and an **Event log** panel (severity, event code, time, source, details; Load more; Download = `/api/system/events.csv`). Events (`system_events`, kept 90 days): `trunk.down`/`trunk.up` (registration changes), `telephony.down`/`telephony.up` (AMI), `telephony.restartNeeded`, `config.failed`, `emergency.call`/`emergency.test`, `network.publicIpChanged`, `backup.done`/`backup.failed`, `disk.low`. Admin edits stay in the audit log (not shown yet). The dashboard's `restart_required` warning links here. Generator runs, generated files and CLI views aren't built; the admin guide's troubleshooting page covers them from the shell |
+| System | `/admin/system` | Status JSON pretty-printed, Asterisk version, generator runs list, "Apply config now", "Rollback", "Restart telephony", generated-files viewer (read-only, masked), allowlisted CLI dropdown (04 §7.3) with output panel, audit log. As built (owner follow-ups to M11-T7, 2026-10-20), laid out after 3CX's admin dashboard (not copied): a **System information** panel (version, host name, company, public IP static/dynamic, LAN IPs, ports SIP/SIPS/media/HTTPS, telephony running, restart needed; copy buttons) with how to restart (`docker compose restart asterisk` on the server; no restart button),, a **Diagnostics** panel (08 §2.6: the server's own checks, then a Quick or Complete network test through the probe, each line with how to fix it), and an **Event log** panel (severity, event code, time, source, details; Load more; Download = `/api/system/events.csv`). Events (`system_events`, kept 90 days): `trunk.down`/`trunk.up` (registration changes), `telephony.down`/`telephony.up` (AMI), `telephony.restartNeeded`, `config.failed`, `emergency.call`/`emergency.test`, `network.publicIpChanged`, `backup.done`/`backup.failed`, `disk.low`, `spam.quotaExhausted`/`spam.quotaRestored` (14 §3). Admin edits stay in the audit log (not shown yet). The dashboard's `restart_required` warning links here. Generator runs, generated files and CLI views aren't built; the admin guide's troubleshooting page covers them from the shell |
### 2.1 First-run setup wizard (`/setup`)
diff --git a/docs/spec/09-roadmap.md b/docs/spec/09-roadmap.md
index 22cb4ed..946a6e5 100644
--- a/docs/spec/09-roadmap.md
+++ b/docs/spec/09-roadmap.md
@@ -9,7 +9,7 @@ Legend: 📖 read · 🔨 build · ✅ done when
---
## Backlog (owner's order, 2026-10-21)
-0. **Spam check** (owner, 2026-10-27: SD1 done; SD2 next), tasks SD1–SD5
+0. **Spam check** (owner, 2026-10-27: SD1, SD2 done; SD3 next), tasks SD1–SD5
in [14 §9](14-spam.md#9-order-of-work): IPQualityScore and the FTC complaint list for outside
callers who aren't in anyone's contacts; information only, off by default.
0. **Mobile app** (owner, 2026-10-26: next), tasks MB1–MB8 in [13 §5](13-mobile.md#5-order-of-work):
diff --git a/docs/spec/14-spam.md b/docs/spec/14-spam.md
index 32f4b77..2cb7391 100644
--- a/docs/spec/14-spam.md
+++ b/docs/spec/14-spam.md
@@ -51,7 +51,7 @@ for that check only; nothing is remembered about the failure. Neither set up →
`line_type`, `carrier`, `name`, `city`, `region`.
- Credit Usage API: `GET https://www.ipqualityscore.com/api/json/account/{key}` → `credits` (left
in this billing period), `usage`, `phone_usage`. Used by Test, the Settings tab and §3.
- `// TODO(spec): confirm in SD2 that this call costs no credit.`
+ `// TODO(spec): confirm with the owner's real key that this call costs no credit (SD2).`
### 2.2 FTC Do Not Call complaints (`spam/ftc.ts`)
@@ -234,7 +234,7 @@ log masks it. Validated with zod (`packages/shared`).
| Task | What | Done when |
|---|---|---|
| SD1 | Decision 0006; this spec; the §7 migration and settings; `spam/check.ts` with both providers' lookups, the cache and the shared requests; Settings → Spam check with keys, Test, privacy confirmation | `pnpm lint && typecheck && test` pass; checks, cache times, the order (IPQualityScore, then the FTC list) and failures are tested against fake HTTP. Done, see §9.1 |
-| SD2 | §3: pause, events, the `spam_quota` warning and Dismiss, `spamQuotaCheck` | an exhausted fake pauses, falls back to the FTC list, warns once and resumes; the owner's real key shows the credit call is free |
+| SD2 | §3: pause, events, the `spam_quota` warning and Dismiss, `spamQuotaCheck` | an exhausted fake pauses, falls back to the FTC list, warns once and resumes; the owner's real key shows the credit call is free. Done but the real-key check, see §9.2 |
| SD3 | §2.2 sync: paging, pacing, the saved cursor, the window, Sync now | a fake FTC API with 3 days of data syncs, resumes after a 429 and drops old complaints; one real sync with the owner's key |
| SD4 | §5: `/api/me/caller-check`, the softphone badge and line, the desk phone labels | an unknown number rings the softphone and shows its answer; a contact or a low risk shows nothing; a desk phone shows "SPAM LIKELY" when the answer is in time |
| SD5 | §6 call history; the admin guide page | a checked call keeps its badge in call history after its cache expires |
@@ -249,8 +249,7 @@ log masks it. Validated with zod (`packages/shared`).
(`getJson`, 4 s timeout, and `SpamError`, the only error text that is logged or shown: never
the URL or fetch's own error). A provider message is cut to 200 characters with the key taken
out, in case it quotes it.
-- "Insufficient credits" is a failure until SD2: logged, the FTC list asked, and the next check
- tries IPQualityScore again.
+- "Insufficient credits": see §9.2.
- Settings: the `spam` key holds the admin's choices, `spam.state` is read-only to PATCH (like
`system.*`). PATCH refuses a provider with no key typed or saved ("Enter the IPQualityScore
key", "Enter the api.data.gov key") and spam check on with neither provider. Labels are 1–20 of
@@ -264,8 +263,28 @@ log masks it. Validated with zod (`packages/shared`).
14 §8 confirmation first.
- `retentionCleanup` deletes expired `spam_lookups` rows.
- Tests: `test/spam.test.ts` with a stubbed `fetch` (risk, kept fields, cache days and expiry,
- the shared request, IPQualityScore down / timing out / out of credits → the FTC list, warnings
- without the key, `cachedOnly`, the settings and Test routes).
+ the shared request, IPQualityScore down / timing out → the FTC list, warnings without the key,
+ `cachedOnly`, the settings and Test routes).
+
+### 9.2 As built: SD2
+
+- `spam/check.ts` `patchSpamState(db, patch, onlyIf?)` merges into `spam.state` in one
+ statement (`value || patch`), so a check and a job never overwrite each other's fields; SD3's
+ cursor uses it too. Pausing is `onlyIf` not paused yet, so of several checks running out at
+ once, one logs the warning and the `spam.quotaExhausted` event.
+- Paused: the cache still answers (14 §4), IPQualityScore isn't asked, nothing is logged.
+- `spamQuotaCheck` runs at start and every 6 hours; it does nothing unless paused. A failing
+ account call throws `SpamError` (no key in it), which the job's log line shows.
+- Also resumed (owner's intent, not in §3): saving a newly typed IPQualityScore key, so a new
+ account isn't paused for up to 6 hours.
+- `Warning` has `dismissable`; the dashboard shows **Dismiss** next to **Fix** for it. Pausing
+ clears `warningDismissedAt`, so the warning comes back the next time.
+- Not done: the owner's real key showing that the Credit Usage API costs no credit (the TODO in
+ §2.1). If it does cost one, 4 calls a day while paused is the price.
+- Tests: `test/spam.test.ts` (two checks running out at once: one warning, one event; the FTC
+ list answering; no requests while paused; the warning and Dismiss; the job leaving it paused at
+ 0 credits and resuming above; the "are off" text without the FTC list; a new key; the job
+ asking nothing unless paused and failing without the key).
Later, not now:
- **Report as spam** (owner, 2026-10-27: not now): a button in call history that reports the
diff --git a/packages/shared/src/settings.ts b/packages/shared/src/settings.ts
index a077baa..e0bb12d 100644
--- a/packages/shared/src/settings.ts
+++ b/packages/shared/src/settings.ts
@@ -348,7 +348,8 @@ export type AiTestResult =
{ ok: true; ms: number; detail: string; audio?: string } | { ok: false; error: string };
/** GET /api/system/warnings: shown as the dashboard banner (05 §2). */
-export type Warning = { code: string; message: string; link: string };
+/** `dismissable`: POST /api/system/warnings/{code}/dismiss hides it (14 §3). */
+export type Warning = { code: string; message: string; link: string; dismissable?: boolean };
/** GET /api/system/backups (04 §8.1). */
export type BackupDto = { name: string; size: number; createdAt: string };
--
2.34.1
From 49d529e185ea26e5aa7b44bece4d8cd965cac112 Mon Sep 17 00:00:00 2001
From: iamdoubz
Date: Tue, 27 Oct 2026 16:40:14 -0500
Subject: [PATCH 4/7] api: download the FTC complaint list
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
SD3 (14 §2.2): spamFtcSync reads the FTC's Do Not Call complaints a day
at a time, 15 pages a minute, saving its place; failures are kept for
Settings → Spam check and retried the next minute. Download now starts
one. Old complaints are dropped after windowDays.
Follow-up: one real download with the owner's api.data.gov key.
---
apps/api/src/jobs.ts | 13 +-
apps/api/src/routes/settings.ts | 7 +
apps/api/src/spam/ftc.ts | 128 +++++++++++++++-
apps/api/test/ftc-sync.test.ts | 177 ++++++++++++++++++++++
apps/api/test/spam.test.ts | 14 ++
apps/web/src/api/client.ts | 1 +
apps/web/src/pages/admin/SettingsPage.tsx | 40 ++++-
docs/spec/04-backend-api.md | 2 +
docs/spec/09-roadmap.md | 2 +-
docs/spec/14-spam.md | 34 ++++-
packages/shared/src/spam.ts | 7 +-
11 files changed, 406 insertions(+), 19 deletions(-)
create mode 100644 apps/api/test/ftc-sync.test.ts
diff --git a/apps/api/src/jobs.ts b/apps/api/src/jobs.ts
index d52a438..9d5ad43 100644
--- a/apps/api/src/jobs.ts
+++ b/apps/api/src/jobs.ts
@@ -32,6 +32,7 @@ import { checkDirectory } from './sso/directory.ts';
import { checkOidcSessions } from './sso/oidcCheck.ts';
import { logEvent } from './services/events.ts';
import { spamQuotaCheck } from './spam/check.ts';
+import { ftcSync } from './spam/ftc.ts';
import { deliverDue } from './services/webhooks.ts';
import type { SendMail } from './services/mail.ts';
import { getSetting, setSetting } from './services/settings.ts';
@@ -44,7 +45,11 @@ export type JobDeps = {
generator: Pick;
voicemail: { scan(ext: string): Promise };
send: SendMail;
- log: { info(o: object, m: string): void; error(o: object, m: string): void };
+ log: {
+ info(o: object, m: string): void;
+ warn(o: object, m: string): void;
+ error(o: object, m: string): void;
+ };
now?: () => Date;
statfs?: typeof statfs;
};
@@ -279,6 +284,11 @@ export function createJobs(d: JobDeps) {
return {
spamQuotaCheck: () => spamQuotaCheck(d.db),
+ /** 14 §2.2: a failure is logged when it changes, and shown on Settings → Spam check. */
+ spamFtcSync: async () => {
+ const reason = await ftcSync(d.db, { now: now() });
+ if (reason) d.log.warn({ provider: 'ftc', reason }, 'FTC complaint download failed');
+ },
oidcCheck,
directoryCheck,
daily,
@@ -317,6 +327,7 @@ export function startJobs(d: JobDeps): () => void {
if (minute % 5 === 0) run('oidcCheck', j.oidcCheck);
if (minute % 60 === 0) run('directoryCheck', j.directoryCheck);
if (minute % 360 === 0) run('spamQuotaCheck', j.spamQuotaCheck);
+ run('spamFtcSync', j.spamFtcSync);
run('callTranscripts', async () => {
await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log });
});
diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts
index 13a25b7..9b743d3 100644
--- a/apps/api/src/routes/settings.ts
+++ b/apps/api/src/routes/settings.ts
@@ -576,6 +576,13 @@ export async function settingsRoutes(app: FastifyInstance) {
return { items };
});
+ /** Settings → Spam check → Download now (14 §2.2): due at the next minute, unless under way. */
+ app.post('/api/settings/spam/ftc/sync', async () => {
+ if (!(await getSetting(db, 'spam')).ftc) throw badRequest('Set up the FTC list first');
+ await patchSpamState(db, { ftcLastSyncAt: null });
+ return { ok: true };
+ });
+
/** Hidden until the next time the credits run out (14 §3). */
app.post('/api/system/warnings/spam_quota/dismiss', async () => {
await patchSpamState(db, { warningDismissedAt: new Date().toISOString() });
diff --git a/apps/api/src/spam/ftc.ts b/apps/api/src/spam/ftc.ts
index b7d600a..c7e43b0 100644
--- a/apps/api/src/spam/ftc.ts
+++ b/apps/api/src/spam/ftc.ts
@@ -1,9 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The FTC's Do Not Call complaints (14 §2.2): a check reads the local copy, so it sends nothing
-// out. The copy is synced by spamFtcSync (SD3).
-import { sql } from 'drizzle-orm';
+// out. spamFtcSync keeps the copy.
+import { lt, sql } from 'drizzle-orm';
import type { FtcInfo, SpamRisk } from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
+import { ftcComplaints } from '../db/schema.ts';
+import { decrypt } from '../crypto/secrets.ts';
+import { getSetting } from '../services/settings.ts';
+import { todayIn } from '../generator/load.ts';
+import { isCheckable, patchSpamState } from './check.ts';
import { SpamError, getJson } from './http.ts';
export const FTC_API = 'https://api.ftc.gov/v0/dnc-complaints';
@@ -41,15 +46,126 @@ export async function ftcLookup(
return { risk: ftcRisk(info.complaints), info };
}
-/** Settings → Spam check → Test: one request with the api.data.gov key. */
-export async function ftcTest(key: string): Promise {
+const OVER_LIMIT = 'api.data.gov says the key is over its hourly limit';
+
+async function ftcGet(key: string, q: Record) {
try {
- await getJson(`${FTC_API}?items_per_page=1`, { 'X-Api-Key': key });
+ return await getJson(`${FTC_API}?${new URLSearchParams(q)}`, { 'X-Api-Key': key });
} catch (err) {
const m = (err as Error).message;
if (m === 'HTTP 403') throw new SpamError('api.data.gov refused the key');
- if (m === 'HTTP 429') throw new SpamError('api.data.gov says the key is over its hourly limit');
+ if (m === 'HTTP 429') throw new SpamError(OVER_LIMIT);
throw err;
}
+}
+
+/** Settings → Spam check → Test: one request with the api.data.gov key. */
+export async function ftcTest(key: string): Promise {
+ await ftcGet(key, { items_per_page: '1' });
return 'The FTC answered.';
}
+
+// ponytail: the FTC's dates have no zone; taken as Eastern (it's in Washington). Hours off at
+// worst, against a window of weeks.
+const FTC_TZ = 'America/New_York';
+const PAGE = 50;
+const DATE_TIME = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
+const nextDay = (day: string) =>
+ new Date(Date.parse(`${day}T00:00:00Z`) + 86_400_000).toISOString().slice(0, 10);
+const daysBefore = (day: string, n: number) =>
+ new Date(Date.parse(`${day}T00:00:00Z`) - n * 86_400_000).toISOString().slice(0, 10);
+
+/** One complaint as stored, or null for one without a usable US number or date. */
+function complaint(c: unknown) {
+ const { id, attributes: a } = (c ?? {}) as { id?: unknown; attributes?: Record };
+ const phone = `1${String(a?.['company-phone-number'] ?? '')}`;
+ const at = a?.['created-date'];
+ if (
+ typeof id !== 'string' ||
+ !isCheckable(phone) ||
+ typeof at !== 'string' ||
+ !DATE_TIME.test(at)
+ )
+ return null;
+ const robo = a?.['recorded-message-or-robocall'];
+ return {
+ id,
+ number: phone,
+ reportedAt: sql`${at}::timestamp at time zone ${FTC_TZ}`,
+ subject: typeof a?.subject === 'string' && a.subject ? a.subject : null,
+ robocall: robo === 'Y' ? true : robo === 'N' ? false : null,
+ };
+}
+
+/**
+ * Job spamFtcSync (14 §2.2), every minute: when a sync is due or under way, read up to `budget`
+ * pages (15 a minute is 900 an hour, under api.data.gov's 1,000) a day at a time, oldest first,
+ * saving where it got to. A failure (a 429 among them) is saved for the Settings tab and the next
+ * minute carries on from the same page. Returns the error, for the job's log line once.
+ */
+export async function ftcSync(
+ db: Db,
+ opts: { now?: Date; budget?: number } = {},
+): Promise {
+ const now = opts.now ?? new Date();
+ const s = await getSetting(db, 'spam');
+ if (!s.enabled || !s.ftc) return null;
+ const st = await getSetting(db, 'spam.state');
+ let cursor = st.ftcCursor;
+ if (!cursor) {
+ const due =
+ !st.ftcLastSyncAt ||
+ now.getTime() - Date.parse(st.ftcLastSyncAt) >= s.ftc.syncHours * 3_600_000;
+ if (!due) return null;
+ // From the newest complaint kept, less a day for ones posted late; at most the window.
+ const r = await db.execute<{ day: string | null }>(
+ sql`select to_char(max(reported_at) at time zone ${FTC_TZ}, 'YYYY-MM-DD') as day from ftc_complaints`,
+ );
+ const first = daysBefore(todayIn(FTC_TZ, now), s.ftc.windowDays);
+ const last = r.rows[0]?.day ? daysBefore(r.rows[0].day, 1) : first;
+ cursor = { day: last > first ? last : first, offset: 0 };
+ }
+ const key = decrypt(s.ftc.keyEnc);
+ const today = todayIn(FTC_TZ, now);
+ for (let n = 0; n < (opts.budget ?? 15); n++) {
+ let page: unknown[];
+ try {
+ const j = await ftcGet(key, {
+ created_date_from: `"${cursor.day} 00:00:00"`,
+ created_date_to: `"${cursor.day} 23:59:59"`,
+ sort_order: 'ASC',
+ items_per_page: String(PAGE),
+ offset: String(cursor.offset),
+ });
+ if (!Array.isArray(j.data)) throw new SpamError('the answer had no complaints list');
+ page = j.data;
+ } catch (err) {
+ const error = err instanceof SpamError ? err.message : 'the sync failed';
+ await patchSpamState(db, { ftcCursor: cursor, ftcLastError: error });
+ if (!(err instanceof SpamError)) throw err;
+ return error === st.ftcLastError ? null : error;
+ }
+ const rows = page.map(complaint).filter((c) => c !== null);
+ if (rows.length) await db.insert(ftcComplaints).values(rows).onConflictDoNothing();
+ if (page.length === PAGE) cursor = { day: cursor.day, offset: cursor.offset + PAGE };
+ else if (cursor.day < today) cursor = { day: nextDay(cursor.day), offset: 0 };
+ else {
+ await db
+ .delete(ftcComplaints)
+ .where(
+ lt(
+ ftcComplaints.reportedAt,
+ sql`${now.toISOString()}::timestamptz - make_interval(days => ${s.ftc.windowDays})`,
+ ),
+ );
+ await patchSpamState(db, {
+ ftcCursor: null,
+ ftcLastSyncAt: now.toISOString(),
+ ftcLastError: null,
+ });
+ return null;
+ }
+ }
+ await patchSpamState(db, { ftcCursor: cursor, ftcLastError: null });
+ return null;
+}
diff --git a/apps/api/test/ftc-sync.test.ts b/apps/api/test/ftc-sync.test.ts
new file mode 100644
index 0000000..ed9b29a
--- /dev/null
+++ b/apps/api/test/ftc-sync.test.ts
@@ -0,0 +1,177 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// SD3: the FTC complaint download (14 §2.2) against a fake api.ftc.gov with three days of data.
+import { randomBytes } from 'node:crypto';
+import { eq, sql } from 'drizzle-orm';
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { SPAM_DEFAULTS } from '@selfphoneng/shared';
+import { encrypt, setSecretKey } from '../src/crypto/secrets.ts';
+import type { Db } from '../src/db/client.ts';
+import { ftcComplaints, settings } from '../src/db/schema.ts';
+import { ftcSync } from '../src/spam/ftc.ts';
+import { getSetting, setSetting } from '../src/services/settings.ts';
+import { createTestDb, hasTestDb } from './db.ts';
+
+const KEY = 'datagov-secret-key-456';
+const NOW = new Date('2026-10-27T18:00:00Z'); // 14:00 in Washington
+const hour = 3_600_000;
+
+const at = (day: string, i: number) =>
+ `${day} ${String(Math.floor(i / 60)).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}:00`;
+const ftc = (id: string, phone: string, created: string, robo = 'Y') => ({
+ type: 'dnc_complaint',
+ id,
+ attributes: {
+ 'company-phone-number': phone,
+ 'created-date': created,
+ subject: 'Warranties',
+ 'recorded-message-or-robocall': robo,
+ },
+});
+/** 120 complaints on the 25th (three pages), 3 on the 26th, 3 today (one usable). */
+const DATA = [
+ ...Array.from({ length: 120 }, (_, i) => ftc(`a${i}`, '3125550100', at('2026-10-25', i))),
+ ...[0, 1, 2].map((i) => ftc(`b${i}`, '3125550101', at('2026-10-26', i), 'N')),
+ ftc('c0', '3125550102', '2026-10-27 09:00:00', ''),
+ ftc('c1', '', '2026-10-27 09:01:00'), // no number
+ ftc('c2', '3125550102', '2026-10-27'), // no time
+];
+
+describe.skipIf(!hasTestDb)('FTC complaint download', () => {
+ let db: Db;
+ let drop: () => Promise;
+ /** Each request's day and offset. */
+ const asked: string[] = [];
+ let fail: { at: number; status: number } | null = null;
+
+ const turnOn = (keyEnc = encrypt(KEY)) =>
+ setSetting(db, 'spam', {
+ ...SPAM_DEFAULTS,
+ enabled: true,
+ ftc: { keyEnc, syncHours: 24, windowDays: 7 },
+ });
+ const stored = async () => (await db.select().from(ftcComplaints)).length;
+ const reset = async () => {
+ await db.delete(ftcComplaints);
+ await db.delete(settings).where(eq(settings.key, 'spam.state'));
+ };
+
+ beforeAll(async () => {
+ ({ db, drop } = await createTestDb());
+ setSecretKey(randomBytes(32).toString('base64'));
+ });
+ afterAll(() => drop?.());
+
+ beforeEach(() => {
+ asked.length = 0;
+ fail = null;
+ vi.stubGlobal('fetch', async (url: string, init?: RequestInit) => {
+ const json = (j: unknown, status = 200) => new Response(JSON.stringify(j), { status });
+ if ((init?.headers as Record)['X-Api-Key'] !== KEY) return json({}, 403);
+ const q = new URL(url).searchParams;
+ const day = /^"(\d{4}-\d{2}-\d{2}) 00:00:00"$/.exec(q.get('created_date_from')!)![1]!;
+ expect(q.get('created_date_to')).toBe(`"${day} 23:59:59"`);
+ expect(q.get('sort_order')).toBe('ASC');
+ const offset = Number(q.get('offset'));
+ asked.push(`${day}+${offset}`);
+ if (fail && asked.length === fail.at) return json({}, fail.status);
+ const all = DATA.filter((c) => c.attributes['created-date'].startsWith(day));
+ return json({ data: all.slice(offset, offset + 50), meta: { 'record-total': all.length } });
+ });
+ });
+ afterEach(async () => {
+ vi.unstubAllGlobals();
+ await reset();
+ });
+
+ it('downloads nothing while spam check or the FTC list is off', async () => {
+ await setSetting(db, 'spam', SPAM_DEFAULTS);
+ expect(await ftcSync(db, { now: NOW })).toBeNull();
+ await setSetting(db, 'spam', { ...SPAM_DEFAULTS, enabled: true });
+ expect(await ftcSync(db, { now: NOW })).toBeNull();
+ expect(asked).toEqual([]);
+ });
+
+ it('the first download fills the window a day at a time and drops old complaints', async () => {
+ await turnOn();
+ await db.insert(ftcComplaints).values({
+ id: 'old',
+ number: '13125550109',
+ reportedAt: sql`${NOW.toISOString()}::timestamptz - interval '8 days'`,
+ });
+ expect(await ftcSync(db, { now: NOW, budget: 100 })).toBeNull();
+ expect(asked).toEqual([
+ '2026-10-20+0',
+ '2026-10-21+0',
+ '2026-10-22+0',
+ '2026-10-23+0',
+ '2026-10-24+0',
+ '2026-10-25+0',
+ '2026-10-25+50',
+ '2026-10-25+100',
+ '2026-10-26+0',
+ '2026-10-27+0',
+ ]);
+ expect(await stored()).toBe(124);
+ const row = async (id: string) =>
+ (await db.select().from(ftcComplaints).where(eq(ftcComplaints.id, id)))[0];
+ expect(await row('b0')).toMatchObject({
+ number: '13125550101',
+ subject: 'Warranties',
+ robocall: false,
+ reportedAt: new Date('2026-10-26T04:00:00Z'), // midnight in Washington
+ });
+ expect((await row('c0'))?.robocall).toBeNull();
+ expect(await getSetting(db, 'spam.state')).toMatchObject({
+ ftcCursor: null,
+ ftcLastSyncAt: NOW.toISOString(),
+ ftcLastError: null,
+ });
+ });
+
+ it('not again until syncHours; then from the newest day kept, less one', async () => {
+ await turnOn();
+ await ftcSync(db, { now: NOW, budget: 100 });
+ asked.length = 0;
+ await ftcSync(db, { now: new Date(NOW.getTime() + 23 * hour) });
+ expect(asked).toEqual([]);
+ await ftcSync(db, { now: new Date(NOW.getTime() + 24 * hour) });
+ expect(asked).toEqual(['2026-10-26+0', '2026-10-27+0', '2026-10-28+0']);
+ });
+
+ it('15 pages a minute, the place saved; a 429 stops there and the next minute carries on', async () => {
+ await turnOn();
+ await ftcSync(db, { now: NOW });
+ expect(asked).toHaveLength(10);
+ await reset();
+ asked.length = 0;
+
+ await ftcSync(db, { now: NOW, budget: 2 });
+ expect((await getSetting(db, 'spam.state')).ftcCursor).toEqual({
+ day: '2026-10-22',
+ offset: 0,
+ });
+ fail = { at: 7, status: 429 }; // the 25th's second page
+ const said = 'api.data.gov says the key is over its hourly limit';
+ expect(await ftcSync(db, { now: NOW })).toBe(said);
+ expect(await getSetting(db, 'spam.state')).toMatchObject({
+ ftcCursor: { day: '2026-10-25', offset: 50 },
+ ftcLastError: said,
+ ftcLastSyncAt: null,
+ });
+ fail = { at: 8, status: 429 }; // the same failure isn't returned (logged) twice
+ expect(await ftcSync(db, { now: NOW })).toBeNull();
+ fail = null;
+ expect(await ftcSync(db, { now: NOW })).toBeNull();
+ expect(await stored()).toBe(124);
+ expect(await getSetting(db, 'spam.state')).toMatchObject({
+ ftcCursor: null,
+ ftcLastError: null,
+ });
+ });
+
+ it('a refused key is saved for the Settings tab', async () => {
+ await turnOn(encrypt('wrong'));
+ expect(await ftcSync(db, { now: NOW })).toBe('api.data.gov refused the key');
+ expect((await getSetting(db, 'spam.state')).ftcLastError).toBe('api.data.gov refused the key');
+ });
+});
diff --git a/apps/api/test/spam.test.ts b/apps/api/test/spam.test.ts
index 5aad69a..340b66f 100644
--- a/apps/api/test/spam.test.ts
+++ b/apps/api/test/spam.test.ts
@@ -315,6 +315,20 @@ describe.skipIf(!hasTestDb)('spam check', () => {
},
});
+ it('Download now: due at the next minute; refused without the FTC list', async () => {
+ await settings(keys.withIpqs);
+ const r = await admin('POST', '/api/settings/spam/ftc/sync');
+ expect(r.statusCode).toBe(400);
+ expect(r.body).toMatch('Set up the FTC list first');
+ await settings(keys.withFtc);
+ await setSetting(t.db, 'spam.state', {
+ ...(await getSetting(t.db, 'spam.state')),
+ ftcLastSyncAt: new Date().toISOString(),
+ });
+ expect((await admin('POST', '/api/settings/spam/ftc/sync')).statusCode).toBe(200);
+ expect((await getSetting(t.db, 'spam.state')).ftcLastSyncAt).toBeNull();
+ });
+
it('keys are write-only, and empty keeps the saved one', async () => {
await setSetting(t.db, 'spam', SPAM_DEFAULTS);
const r = await admin('PATCH', '/api/settings', body());
diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
index bbf04a1..110ae75 100644
--- a/apps/web/src/api/client.ts
+++ b/apps/web/src/api/client.ts
@@ -263,6 +263,7 @@ export const settingsApi = {
testLdap: (v: LdapInput & { testLogin?: string; testPassword?: string }) =>
api('POST', '/api/settings/auth/ldap/test', v),
/** 14 §7.1: the typed key, else the saved one. */
+ syncFtc: () => api<{ ok: true }>('POST', '/api/settings/spam/ftc/sync'),
testSpam: (provider: 'ipqs' | 'ftc', key?: string) =>
api('POST', `/api/settings/spam/${provider}/test`, { key }),
};
diff --git a/apps/web/src/pages/admin/SettingsPage.tsx b/apps/web/src/pages/admin/SettingsPage.tsx
index e880127..ef25cd3 100644
--- a/apps/web/src/pages/admin/SettingsPage.tsx
+++ b/apps/web/src/pages/admin/SettingsPage.tsx
@@ -1400,6 +1400,39 @@ function SpamTest({ provider, keyText }: { provider: 'ipqs' | 'ftc'; keyText: st
const when = (iso: string | null) => (iso ? new Date(iso).toLocaleString() : null);
+/** Where the FTC download is, and Download now (14 §2.2). */
+function FtcSyncStatus({ st }: { st: SettingsDto['spam.state'] }) {
+ const qc = useQueryClient();
+ const sync = useMutation({
+ mutationFn: settingsApi.syncFtc,
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['settings'] }),
+ });
+ return (
+
+
+ {st.ftcCursor
+ ? `Downloading: at the complaints of ${st.ftcCursor.day}.`
+ : st.ftcLastSyncAt
+ ? `Last download ${when(st.ftcLastSyncAt)}.`
+ : 'Not downloaded yet: it starts within a minute.'}
+
+ {st.ftcLastError && (
+
+ The last try failed: {st.ftcLastError}. It tries again every minute.
+
+ )}
+ {!st.ftcCursor && (
+
+
+
+ )}
+ {sync.isError && {sync.error.message}}
+
+ );
+}
+
/** Spam check (spec 14 §7.1): IPQualityScore first, then the FTC list. Information only. */
function SpamTab({ s }: { s: SettingsDto }) {
const save = useSave();
@@ -1540,12 +1573,7 @@ function SpamTab({ s }: { s: SettingsDto }) {
{num('syncHours', 'Download again every (hours, 1–168)')}
{num('windowDays', 'Keep complaints for (days, 7–365)')}
- )}
+ {sp.ftc && }
>
)}
diff --git a/docs/spec/04-backend-api.md b/docs/spec/04-backend-api.md
index ce09217..c32a04d 100644
--- a/docs/spec/04-backend-api.md
+++ b/docs/spec/04-backend-api.md
@@ -433,6 +433,7 @@ As built (M11-T6): `routes/reports.ts`. `from`/`to` are company-time-zone days,
| GET | `/api/audit-log` | |
| GET/PATCH | `/api/settings` | whole settings object; PATCH merges known keys only. `smtp` comes back as `{host, port, secure, username, from, hasPassword}` and is written with an optional `password` (empty keeps the stored one). `system.*`, `sms.webhookToken` and `setup.completed` are read-only. References (DIDs, extensions, destinations) are checked, and a transport a trunk uses can't be turned off |
| POST | `/api/settings/smtp/test` | `{to}` sends a test email |
+| POST | `/api/settings/spam/ftc/sync` | Download now: the FTC download starts at the next minute (14 §9.3) |
| POST | `/api/settings/spam/ipqs/test`, `/api/settings/spam/ftc/test` | `{key?}`: the typed key, else the saved one; `{ok, detail}` or `{ok: false, error}` (14 §7.1) |
| POST | `/api/settings/emergency/test` | runs 04 §5.3 steps 2–4 with `TEST` in front (email, ring-out, banner); records nothing and dials nothing (06 §6.5). Returns `{notified}` |
| GET | `/api/system/warnings` | `{items: [{code, message, link, dismissable?}]}` for the dashboard banner: `restart_required`, `e911_cid_missing` (a trunk exists, no default E911 caller ID), `e911_address_unconfirmed` (that DID has no `e911_address`), `smtp_missing`, `tls_cert` (TLS on and the cert missing or expiring within 14 days, M11-T2), `backup_old` (backups on and today's not made yet though the job had its chance, M11-T3), `directory_check` (12 §4.2, §4.5: the directory unreachable at a sign-in or by `directoryCheck`, or that job refusing to disable most people), `spam_quota` (14 §3, dismissable). "Trunk down" comes from live state in the browser |
@@ -476,6 +477,7 @@ Provisioning and inbound hooks (outside `/api`, no session):
| `contactsWarm` | every 5 min, Contrack mode | refresh the company's and each connected person's Contrack book, so a ringing phone's caller-name lookup (11 §6.1) finds them |
| `contrackLog` | every 60 s, Contrack mode | calls ended over 1 min ago (and within 2 days) of people logging calls: one `call` interaction on the matching contact's Contrack timeline (11 §4.4) |
| `callReports` | every 60 s | each user whose call report period (05 §4) ended over 5 min ago and isn't reported yet: email their calls of that period (by hang-up time) if there were any; `extensions.call_report_sent_at` = the period's end. Changing the schedule sets it to now, so the first report is the next period's |
+| `spamFtcSync` | every 60 s, when due or under way | the FTC complaint download, 15 pages a minute (14 §2.2, §9.3) |
| `spamQuotaCheck` | every 6 hours, while IPQualityScore is paused | ask its Credit Usage API; credits back → resume (14 §3) |
Jobs must never overlap with themselves (use a running flag).
diff --git a/docs/spec/09-roadmap.md b/docs/spec/09-roadmap.md
index 946a6e5..b61705b 100644
--- a/docs/spec/09-roadmap.md
+++ b/docs/spec/09-roadmap.md
@@ -9,7 +9,7 @@ Legend: 📖 read · 🔨 build · ✅ done when
---
## Backlog (owner's order, 2026-10-21)
-0. **Spam check** (owner, 2026-10-27: SD1, SD2 done; SD3 next), tasks SD1–SD5
+0. **Spam check** (owner, 2026-10-27: SD1–SD3 done; SD4 next), tasks SD1–SD5
in [14 §9](14-spam.md#9-order-of-work): IPQualityScore and the FTC complaint list for outside
callers who aren't in anyone's contacts; information only, off by default.
0. **Mobile app** (owner, 2026-10-26: next), tasks MB1–MB8 in [13 §5](13-mobile.md#5-order-of-work):
diff --git a/docs/spec/14-spam.md b/docs/spec/14-spam.md
index 2cb7391..972bf59 100644
--- a/docs/spec/14-spam.md
+++ b/docs/spec/14-spam.md
@@ -187,7 +187,7 @@ Settings (02 §3):
| Key | Type | Default | Notes |
|---|---|---|---|
| `spam` | object | below | the admin's choices |
-| `spam.state` | object | `{}` | read-only to the API, like `system.*`: `ipqsPausedAt`, `warningDismissedAt`, `ftcSyncedTo`, `ftcCursor`, `ftcLastSyncAt`, `ftcLastError` |
+| `spam.state` | object | `{}` | read-only to the API, like `system.*`: `ipqsPausedAt`, `warningDismissedAt`, `ftcCursor` (`{day, offset}` while a download is under way), `ftcLastSyncAt`, `ftcLastError` |
```ts
spam = {
@@ -235,7 +235,7 @@ log masks it. Validated with zod (`packages/shared`).
|---|---|---|
| SD1 | Decision 0006; this spec; the §7 migration and settings; `spam/check.ts` with both providers' lookups, the cache and the shared requests; Settings → Spam check with keys, Test, privacy confirmation | `pnpm lint && typecheck && test` pass; checks, cache times, the order (IPQualityScore, then the FTC list) and failures are tested against fake HTTP. Done, see §9.1 |
| SD2 | §3: pause, events, the `spam_quota` warning and Dismiss, `spamQuotaCheck` | an exhausted fake pauses, falls back to the FTC list, warns once and resumes; the owner's real key shows the credit call is free. Done but the real-key check, see §9.2 |
-| SD3 | §2.2 sync: paging, pacing, the saved cursor, the window, Sync now | a fake FTC API with 3 days of data syncs, resumes after a 429 and drops old complaints; one real sync with the owner's key |
+| SD3 | §2.2 sync: paging, pacing, the saved cursor, the window, Sync now | a fake FTC API with 3 days of data syncs, resumes after a 429 and drops old complaints; one real sync with the owner's key. Done but the real sync, see §9.3 |
| SD4 | §5: `/api/me/caller-check`, the softphone badge and line, the desk phone labels | an unknown number rings the softphone and shows its answer; a contact or a low risk shows nothing; a desk phone shows "SPAM LIKELY" when the answer is in time |
| SD5 | §6 call history; the admin guide page | a checked call keeps its badge in call history after its cache expires |
@@ -245,7 +245,7 @@ log masks it. Validated with zod (`packages/shared`).
can't wait (14 §5.1, §5.2) races it against its own timer, and the check carries on and fills
the cache. The cache is only read while IPQualityScore is set up; turning it off leaves the
rows to expire.
-- `spam/ipqs.ts`, `spam/ftc.ts` (the local lookup and Test; the sync is SD3), `spam/http.ts`
+- `spam/ipqs.ts`, `spam/ftc.ts` (the local lookup and Test; the sync is §9.3), `spam/http.ts`
(`getJson`, 4 s timeout, and `SpamError`, the only error text that is logged or shown: never
the URL or fetch's own error). A provider message is cut to 200 characters with the key taken
out, in case it quotes it.
@@ -286,6 +286,34 @@ log masks it. Validated with zod (`packages/shared`).
0 credits and resuming above; the "are off" text without the FTC list; a new key; the job
asking nothing unless paused and failing without the key).
+### 9.3 As built: SD3
+
+- `spam/ftc.ts` `ftcSync(db, { now, budget })`, job `spamFtcSync` **every minute**. It does
+ nothing unless spam check and the FTC list are on and a download is due (`ftcLastSyncAt`
+ older than `syncHours`, or never) or under way (`ftcCursor`).
+- One FTC date at a time (`created_date_from` / `_to` that day, `sort_order=ASC`, 50 a page,
+ `offset`), so an offset never grows past one day's complaints. A short page moves on to the
+ next day; today's short page ends the download: complaints older than `windowDays` are
+ deleted and `ftcLastSyncAt` set.
+- Pacing: at most 15 pages a minute (900 an hour), and the place (`ftcCursor`) saved after each
+ run, so a restart carries on. The first 30 days take about 7 hours.
+- Where a download starts: the newest complaint kept, less a day (for ones posted late, as on
+ weekends), but no further back than `windowDays`. Ids make the overlap harmless.
+- The FTC's dates have no zone: taken as Eastern, so a day is midnight to midnight in
+ Washington. Complaints without a 10-digit US number or a full date are skipped.
+- A failure (403 "api.data.gov refused the key", a 429 "…over its hourly limit", a timeout)
+ keeps the place and is saved in `ftcLastError`; the next minute tries again from the same page.
+ The job logs it (`warn`) only when it differs from the last one, so a long outage is one line.
+- Settings → Spam check: "Downloading: at the complaints of {day}", "Last download {time}", the
+ last failure, and **Download now** (`POST /api/settings/spam/ftc/sync`, which clears
+ `ftcLastSyncAt` so the next minute starts one; 400 without the FTC list).
+- Not done: one real download with the owner's api.data.gov key.
+- Tests: `test/ftc-sync.test.ts` against a fake api.ftc.gov (off; the first download a day at a
+ time with three pages on one day, old complaints dropped, robocall and Eastern time; not again
+ until `syncHours`, then from the newest day less one; 15 pages a minute with the place saved; a
+ 429 stopping and the next minute carrying on without duplicates; the same failure logged once;
+ a refused key). `test/spam.test.ts`: Download now.
+
Later, not now:
- **Report as spam** (owner, 2026-10-27: not now): a button in call history that reports the
number to a provider that takes reports (OpenSpam has `POST /api/report`).
diff --git a/packages/shared/src/spam.ts b/packages/shared/src/spam.ts
index 1e8f4c9..f4dd3c1 100644
--- a/packages/shared/src/spam.ts
+++ b/packages/shared/src/spam.ts
@@ -73,8 +73,11 @@ export const SPAM_DEFAULTS: SpamSettings = {
export const SpamStateSchema = z.object({
ipqsPausedAt: z.string().nullable().default(null),
warningDismissedAt: z.string().nullable().default(null),
- ftcSyncedTo: z.string().nullable().default(null),
- ftcCursor: z.number().int().nullable().default(null),
+ /** A sync under way (14 §2.2): the FTC's date (Eastern) being read and how far into it. */
+ ftcCursor: z
+ .object({ day: z.iso.date(), offset: z.number().int().min(0) })
+ .nullable()
+ .default(null),
ftcLastSyncAt: z.string().nullable().default(null),
ftcLastError: z.string().nullable().default(null),
});
--
2.34.1
From e05065599e41e3b442c2752072a98f28f4790e82 Mon Sep 17 00:00:00 2001
From: iamdoubz
Date: Tue, 27 Oct 2026 16:51:29 -0500
Subject: [PATCH 5/7] web: show the spam check while a call rings
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
SD4 (14 §5): GET /api/me/caller-check for callers ringing my extensions
now, a badge and line in the softphone's ringing dialog and call card,
and the desk phone label in the caller-name lookup when the answer comes
within 1.2 s. Contacts are never checked; low risk shows nothing.
Follow-up: try it on a real call, in the browser and on a desk phone.
---
apps/api/src/routes/contacts.ts | 50 ++++++++++++++--
apps/api/src/spam/check.ts | 38 ++++++++++++
apps/api/test/spam.test.ts | 74 +++++++++++++++++++++++-
apps/web/src/api/client.ts | 7 +++
apps/web/src/components/SpamNote.test.ts | 32 ++++++++++
apps/web/src/components/SpamNote.tsx | 50 ++++++++++++++++
apps/web/src/softphone/SoftphoneDock.tsx | 17 ++++++
docs/spec/04-backend-api.md | 1 +
docs/spec/09-roadmap.md | 2 +-
docs/spec/11-contacts.md | 2 +
docs/spec/14-spam.md | 35 +++++++++--
11 files changed, 295 insertions(+), 13 deletions(-)
create mode 100644 apps/web/src/components/SpamNote.test.ts
create mode 100644 apps/web/src/components/SpamNote.tsx
diff --git a/apps/api/src/routes/contacts.ts b/apps/api/src/routes/contacts.ts
index 4d021e6..32cead4 100644
--- a/apps/api/src/routes/contacts.ts
+++ b/apps/api/src/routes/contacts.ts
@@ -15,6 +15,7 @@ import {
type ContactImportResult,
type ContactShareDto,
type ListResponse,
+ type SpamCheck,
} from '@selfphoneng/shared';
import { NO_LIMIT, SLOW, requireAdmin, requireAuth } from '../auth/guards.ts';
import {
@@ -37,6 +38,7 @@ import { getSetting } from '../services/settings.ts';
import { decrypt } from '../crypto/secrets.ts';
import { contrackCheck } from '../contacts/contrack.ts';
import { callerNameToken, phoneSafeName, sameToken } from '../contacts/callername.ts';
+import { checkCaller, usNumber } from '../spam/check.ts';
const IdParams = z.object({ id: z.uuid() });
const CommitQuery = z.object({ commit: z.enum(['0', '1']).default('0') });
@@ -187,12 +189,52 @@ export async function contactRoutes(app: FastifyInstance) {
.select({ userId: extensions.userId })
.from(extensions)
.where(eq(extensions.number, q.data.ext));
- const names = await contactNames(db, e?.userId ?? null, [q.data.number], {
+ const owner = e?.userId ?? null;
+ const names = await contactNames(db, owner, [q.data.number], {
wait: false,
}).catch(() => new Map());
- return reply
- .type('text/plain; charset=utf-8')
- .send(phoneSafeName(names.get(q.data.number) ?? ''));
+ let name = names.get(q.data.number) ?? '';
+ // 14 §5.2: no contact → the spam check's label, if it answers in 1.2 s (of the 2 s).
+ // app.log: this route's own log is silent (the token is in the query).
+ // ponytail: the label replaces the carrier's caller ID name, which the dialplan doesn't
+ // send; IPQualityScore's name for the line follows it when it has one.
+ if (!name) {
+ const c = await checkCaller({ db, log: app.log }, owner, usNumber(q.data.number), 1200);
+ if (c && c.risk !== 'low') {
+ const labels = (await getSetting(db, 'spam')).deskPhoneLabels;
+ name = `${labels[c.risk]} ${c.provider === 'ipqs' ? (c.info.name ?? '') : ''}`;
+ }
+ }
+ return reply.type('text/plain; charset=utf-8').send(phoneSafeName(name));
+ },
+ );
+
+ /**
+ * The softphone's spam check (14 §5.1): only for a caller ringing one of my extensions right
+ * now, so nobody can spend the credits on numbers that never called.
+ */
+ app.get(
+ '/api/me/caller-check',
+ { preHandler: requireAuth },
+ async (req): Promise<{ check: SpamCheck | null }> => {
+ const { number } = z.object({ number: z.string().max(30) }).parse(req.query);
+ const n = usNumber(number);
+ const mine = (
+ await db
+ .select({ number: extensions.number })
+ .from(extensions)
+ .where(eq(extensions.userId, req.user!.id))
+ ).map((x) => x.number);
+ const ringing = app.live.calls
+ .list()
+ .some(
+ (c) =>
+ c.direction === 'inbound' &&
+ usNumber(c.from.number) === n &&
+ c.extensions.some((x) => mine.includes(x)),
+ );
+ if (!n || !ringing) return { check: null };
+ return { check: await checkCaller({ db, log: req.log }, req.user!.id, n, 5000) };
},
);
diff --git a/apps/api/src/spam/check.ts b/apps/api/src/spam/check.ts
index 9762a5a..4a99a43 100644
--- a/apps/api/src/spam/check.ts
+++ b/apps/api/src/spam/check.ts
@@ -6,12 +6,14 @@ import { and, eq, gt, isNull, sql, type SQL } from 'drizzle-orm';
import type { FastifyBaseLogger } from 'fastify';
import {
SpamStateSchema,
+ normalizeNumber,
type IpqsInfo,
type SpamCheck,
type SpamRisk,
type SpamSettings,
type SpamState,
} from '@selfphoneng/shared';
+import { contactNames } from '../contacts/book.ts';
import type { Db } from '../db/client.ts';
import { settings, spamLookups } from '../db/schema.ts';
import { decrypt } from '../crypto/secrets.ts';
@@ -26,6 +28,15 @@ export type SpamDeps = { db: Db; log: Pick };
/** US numbers only (owner, 2026-10-27): 1NXXNXXXXXX after normalizeNumber (00 §8). */
export const isCheckable = (n: string) => /^1[2-9]\d{2}[2-9]\d{6}$/.test(n);
+/** `n` as 1NXXNXXXXXX-style digits (00 §8), or '' when it isn't a number. */
+export const usNumber = (n: string) => {
+ try {
+ return normalizeNumber(n);
+ } catch {
+ return '';
+ }
+};
+
/** A check under way, by number: a ring group ringing five people costs one credit (14 §4). */
const pending = new Map>();
@@ -72,6 +83,33 @@ export async function checkNumber(
}
}
+/**
+ * The check for a call ringing `userId` (14 §5): nothing for a number in their books (or when
+ * the books can't be read), else the answer if it comes within `waitMs`. A later answer still
+ * fills the cache.
+ */
+export async function checkCaller(
+ d: SpamDeps,
+ userId: string | null,
+ number: string,
+ waitMs: number,
+): Promise {
+ if (!isCheckable(number)) return null;
+ const known = await contactNames(d.db, userId, [number], { wait: false }).catch(() => null);
+ if (!known || known.size) return null;
+ const check = checkNumber(d, number).catch((err: unknown) => {
+ warn(d, 'check', err);
+ return null;
+ });
+ let timer: NodeJS.Timeout | undefined;
+ const late = new Promise((r) => (timer = setTimeout(r, waitMs, null)));
+ try {
+ return await Promise.race([check, late]);
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
async function askIpqs(
d: SpamDeps,
s: SpamSettings,
diff --git a/apps/api/test/spam.test.ts b/apps/api/test/spam.test.ts
index 340b66f..ab78d4e 100644
--- a/apps/api/test/spam.test.ts
+++ b/apps/api/test/spam.test.ts
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
-// SD1, SD2: the spam check (14): IPQualityScore first, then the local FTC copy, against a fake fetch.
+// SD1, SD2, SD4: the spam check (14): IPQualityScore first, then the local FTC copy, against a fake fetch.
import { eq, like, sql } from 'drizzle-orm';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -11,7 +11,10 @@ import {
settings as settingsTable,
spamLookups,
systemEvents,
+ users,
} from '../src/db/schema.ts';
+import type { ActiveCall } from '@selfphoneng/shared';
+import { callerNameToken } from '../src/contacts/callername.ts';
import { checkNumber, spamQuotaCheck } from '../src/spam/check.ts';
import { getSetting, setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts';
@@ -37,7 +40,7 @@ describe.skipIf(!hasTestDb)('spam check', () => {
let admin: Req;
const urls: string[] = [];
/** What the fake IPQualityScore does: answer, fail, or say the credits are used up. */
- let ipqs: 'ok' | 'down' | 'exhausted' | 'slow' = 'ok';
+ let ipqs: 'ok' | 'down' | 'exhausted' | 'slow' | 'late' = 'ok';
let credits = 912;
const log = { warn: vi.fn() };
const d = () => ({ db: t.db, log });
@@ -102,7 +105,7 @@ describe.skipIf(!hasTestDb)('spam check', () => {
success: false,
message: 'You have insufficient credits to make this query.',
});
- await new Promise((r) => setTimeout(r, 20));
+ await new Promise((r) => setTimeout(r, ipqs === 'late' ? 1500 : 20));
return json({ success: true, valid: true, ...(ANSWERS[phone[1]!] ?? { fraud_score: 0 }) });
});
});
@@ -303,6 +306,71 @@ describe.skipIf(!hasTestDb)('spam check', () => {
expect(urls).toHaveLength(1);
});
+ describe('while it rings', () => {
+ let ringing: ActiveCall[] = [];
+ const call = (from: string, ext: string): ActiveCall => ({
+ linkedid: `l-${from}`,
+ startedAt: new Date().toISOString(),
+ direction: 'inbound',
+ from: { number: from },
+ to: { number: '13125559999' },
+ state: 'ringing',
+ extensions: [ext],
+ channels: [],
+ recording: false,
+ });
+ const check = async (number: string) =>
+ (await admin('GET', `/api/me/caller-check?number=${number}`)).json().check;
+ const deskName = async (number: string, ext = '101') =>
+ (
+ await t.app.inject({
+ method: 'GET',
+ url: `/api/internal/caller-name?ext=${ext}&number=${number}&token=${callerNameToken(t.app.config.APP_SECRET)}`,
+ })
+ ).body;
+
+ beforeAll(async () => {
+ const [me] = await t.db.select().from(users).where(eq(users.email, ADMIN.email));
+ expect(
+ (await admin('POST', '/api/extensions', { number: '101', name: 'Admin', userId: me!.id }))
+ .statusCode,
+ ).toBe(201);
+ await admin('POST', '/api/me/contacts', {
+ name: 'Pizza Place',
+ numbers: [{ number: SHADY.slice(1) }],
+ });
+ t.app.live.calls.list = () => ringing;
+ });
+ beforeEach(() => settings({ ...keys.withIpqs, ...keys.withFtc }));
+
+ it('the softphone: only callers ringing me now, never my contacts', async () => {
+ ringing = [call(SPAMMER.slice(1), '101'), call(CLEAN, '102'), call(SHADY, '101')];
+ expect(await check(SPAMMER)).toMatchObject({ risk: 'high', provider: 'ipqs' });
+ expect(await check(`+1 ${SPAMMER.slice(1)}`)).toMatchObject({ risk: 'high' });
+ expect(await check(CLEAN)).toBeNull(); // ringing someone else
+ expect(await check(REPORTED)).toBeNull(); // not ringing
+ expect(await check(SHADY)).toBeNull(); // a contact
+ expect(urls).toHaveLength(1);
+ ringing = [call(CLEAN, '101')];
+ expect(await check(CLEAN)).toMatchObject({ risk: 'low' }); // low: the softphone hides it
+ expect((await admin('GET', '/api/me/caller-check?number=nonsense')).json().check).toBeNull();
+ });
+
+ it('desk phones: the label in time, nothing for a contact, low risk or a late answer', async () => {
+ expect(await deskName(SPAMMER)).toBe('SPAM LIKELY');
+ expect(await deskName(SHADY)).toBe('Pizza Place');
+ expect(await deskName(SHADY, '999')).toBe('Spam risk'); // nobody's contact there
+ expect(await deskName(CLEAN)).toBe('');
+ await t.db.delete(spamLookups);
+ ipqs = 'late';
+ expect(await deskName(SPAMMER)).toBe('');
+ await vi.waitFor(async () => expect(await t.db.select().from(spamLookups)).toHaveLength(1));
+ expect(await deskName(SPAMMER)).toBe('SPAM LIKELY'); // from the cache
+ await settings({ ...keys.withFtc, deskPhoneLabels: { high: 'JUNK', medium: 'Maybe junk' } });
+ expect(await deskName(REPORTED)).toBe('JUNK');
+ });
+ });
+
describe('Settings → Spam check', () => {
const body = (s: Record = {}) => ({
spam: {
diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
index 110ae75..94c711e 100644
--- a/apps/web/src/api/client.ts
+++ b/apps/web/src/api/client.ts
@@ -3,6 +3,7 @@
import type {
LdapInput,
LdapTestResult,
+ SpamCheck,
SpamTestResult,
AuthMethods,
OidcInput,
@@ -306,6 +307,12 @@ export const meApi = {
calls: (cursor?: string) =>
api>('GET', `/api/me/calls${cursor ? `?cursor=${cursor}` : ''}`),
get: () => api('GET', '/api/me'),
+ /** 14 §5.1: only for a caller ringing me now. */
+ callerCheck: (number: string) =>
+ api<{ check: SpamCheck | null }>(
+ 'GET',
+ `/api/me/caller-check?number=${encodeURIComponent(number)}`,
+ ).then((r) => r.check),
softphoneQr: () => api('GET', '/api/me/softphone-qr'),
update: (m: MeUpdate) => api('PATCH', '/api/me', m),
connectContrack: (token: string) => api('PUT', '/api/me/contrack', { token }),
diff --git a/apps/web/src/components/SpamNote.test.ts b/apps/web/src/components/SpamNote.test.ts
new file mode 100644
index 0000000..470853b
--- /dev/null
+++ b/apps/web/src/components/SpamNote.test.ts
@@ -0,0 +1,32 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+import { expect, it } from 'vitest';
+import { spamLine } from './SpamNote.tsx';
+
+it('says what the spam check knows, in one line (14 §5.1)', () => {
+ expect(
+ spamLine({
+ risk: 'high',
+ provider: 'ipqs',
+ info: {
+ fraudScore: 92,
+ spammer: true,
+ recentAbuse: false,
+ lineType: 'VOIP',
+ carrier: 'Verizon',
+ name: null,
+ city: 'Austin',
+ region: 'TX',
+ },
+ }),
+ ).toBe('Fraud score 92 · spammer · VOIP · Verizon · Austin, TX');
+ const ftc = (complaints: number, robocalls: number, subject: string | null) =>
+ spamLine({
+ risk: 'high',
+ provider: 'ftc',
+ info: { complaints, robocalls, lastReportedAt: '2026-10-20T15:00:00Z', subject },
+ });
+ expect(ftc(4, 3, 'Warranties')).toMatch(
+ /^Reported to the FTC 4 times \(3 robocalls\), last on .+: Warranties$/,
+ );
+ expect(ftc(1, 0, null)).toMatch(/^Reported to the FTC once, last on [^:]+$/);
+});
diff --git a/apps/web/src/components/SpamNote.tsx b/apps/web/src/components/SpamNote.tsx
new file mode 100644
index 0000000..85e5670
--- /dev/null
+++ b/apps/web/src/components/SpamNote.tsx
@@ -0,0 +1,50 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// What the spam check said about a caller (14 §5.1): a badge and a line, for high and medium
+// risk only. Shown in the softphone and call history.
+import type { SpamCheck } from '@selfphoneng/shared';
+
+const day = (iso: string) =>
+ new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
+
+/** "Fraud score 92 · spammer · VoIP · Verizon · Austin, TX", or "Reported to the FTC 4 times…". */
+export function spamLine(c: SpamCheck): string {
+ if (c.provider === 'ftc') {
+ const i = c.info;
+ const times = i.complaints === 1 ? 'once' : `${i.complaints} times`;
+ const robo = i.robocalls ? ` (${i.robocalls} robocall${i.robocalls === 1 ? '' : 's'})` : '';
+ const last = i.lastReportedAt ? `, last on ${day(i.lastReportedAt)}` : '';
+ return `Reported to the FTC ${times}${robo}${last}${i.subject ? `: ${i.subject}` : ''}`;
+ }
+ const i = c.info;
+ const place = [i.city, i.region].filter(Boolean).join(', ');
+ return [
+ i.name,
+ `Fraud score ${i.fraudScore}`,
+ i.spammer && 'spammer',
+ i.recentAbuse && 'recent abuse',
+ i.lineType,
+ i.carrier,
+ place,
+ ]
+ .filter(Boolean)
+ .join(' · ');
+}
+
+export function SpamNote({ check }: { check: SpamCheck | null | undefined }) {
+ if (!check || check.risk === 'low') return null;
+ const high = check.risk === 'high';
+ return (
+
+
+ {high ? 'Likely spam' : 'Possible spam'}
+
+ {spamLine(check)}
+
+ );
+}
diff --git a/apps/web/src/softphone/SoftphoneDock.tsx b/apps/web/src/softphone/SoftphoneDock.tsx
index ec77164..ac9d110 100644
--- a/apps/web/src/softphone/SoftphoneDock.tsx
+++ b/apps/web/src/softphone/SoftphoneDock.tsx
@@ -2,10 +2,12 @@
// Softphone on every /me page (05 §5.3): a bar at the bottom that opens a panel with the
// dialpad and audio devices; call cards with mute/hold/keypad/transfer; the incoming call dialog.
// Not yet: DND toggle (M6), Record via *1 and recent calls (M7).
+import { useQuery } from '@tanstack/react-query';
import { useEffect, useRef, useState } from 'react';
import { formatNumber } from '@selfphoneng/shared';
import { meApi } from '../api/client.ts';
import { Dialog } from '../components/Dialog.tsx';
+import { SpamNote } from '../components/SpamNote.tsx';
import { Button, inputClass } from '../components/ui.tsx';
import { useLive } from '../live/store.ts';
import { onTakeOver, stepDown, takeOver, tryLead } from './leader.ts';
@@ -78,6 +80,19 @@ function useNameOf() {
(c.remote.length > 6 ? formatNumber(c.remote) : c.remote || 'Unknown');
}
+/** The spam check for an outside caller (14 §5.1): asked once, as it starts ringing. */
+function CallerCheck({ call }: { call: CallInfo }) {
+ const contact = useContactName();
+ const check = useQuery({
+ queryKey: ['caller-check', call.remote],
+ queryFn: () => meApi.callerCheck(call.remote),
+ enabled: call.direction === 'in' && call.remote.length >= 10 && !contact(call.remote),
+ staleTime: Infinity,
+ retry: false,
+ });
+ return ;
+}
+
function useNow(on: boolean) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
@@ -257,6 +272,7 @@ export function SoftphoneDock() {
)}
)}
+ {dialogCall && }
);
@@ -361,6 +377,7 @@ function CallCard({
{call.poorQuality && (
Poor connection
)}
+
{call.state === 'incoming' ? (
<>
diff --git a/docs/spec/04-backend-api.md b/docs/spec/04-backend-api.md
index c32a04d..1bcecd8 100644
--- a/docs/spec/04-backend-api.md
+++ b/docs/spec/04-backend-api.md
@@ -391,6 +391,7 @@ Auth & self:
| GET | `/api/me/greetings/:kind/audio` | user | the wav |
| PUT/DELETE | `/api/me/greetings/:kind` | user | PUT body is the raw audio (a MediaRecorder Blob or a picked File, any `Content-Type`, max 20 MB), not multipart: no extra dependency. ffmpeg converts it (§6) |
| GET | `/api/me/voicemail/:id/audio?download=1` | user | same, as an attachment |
+| GET | `/api/me/caller-check?number=` | user | the spam check for a caller ringing one of my extensions now, else `{check: null}` (14 §5.1, §9.4) |
| GET/POST/PATCH/DELETE | `/api/me/contacts[/:id]` | user | my contacts; GET lists the company's too (11 §3). `POST /import`, `POST /share`, `GET /shares`, `POST /shares/accept\|deny` (11 §2.1, §3.1), `POST /copy-to-contrack` (11 §6.2) |
| POST | `/api/me/queues/:id/pause`, `/unpause` | user | log out of / in to a queue I'm an agent of (Home, 05 §4), same as `*45` |
| POST | `/api/me/click-to-call` | user | `{number}` → AMI `Originate Channel: PJSIP/, Context: sp-from-ext, Exten: , Priority: 1, CallerID: "" <>, Async: true`. The user's phone rings first, then the call goes out |
diff --git a/docs/spec/09-roadmap.md b/docs/spec/09-roadmap.md
index b61705b..d95c3d1 100644
--- a/docs/spec/09-roadmap.md
+++ b/docs/spec/09-roadmap.md
@@ -9,7 +9,7 @@ Legend: 📖 read · 🔨 build · ✅ done when
---
## Backlog (owner's order, 2026-10-21)
-0. **Spam check** (owner, 2026-10-27: SD1–SD3 done; SD4 next), tasks SD1–SD5
+0. **Spam check** (owner, 2026-10-27: SD1–SD4 done; SD5 next), tasks SD1–SD5
in [14 §9](14-spam.md#9-order-of-work): IPQualityScore and the FTC complaint list for outside
callers who aren't in anyone's contacts; information only, off by default.
0. **Mobile app** (owner, 2026-10-26: next), tasks MB1–MB8 in [13 §5](13-mobile.md#5-order-of-work):
diff --git a/docs/spec/11-contacts.md b/docs/spec/11-contacts.md
index 87b0522..e1cc7f7 100644
--- a/docs/spec/11-contacts.md
+++ b/docs/spec/11-contacts.md
@@ -302,6 +302,8 @@ As built (C5, `contacts/timeline.ts`):
agent or a forward does its own lookup in its own user's book, and never sees another's.
- The prefix of a number, ring group or queue ("Sales:") is kept in front:
`__SP_CNAM_PREFIX` grows the same way `CALLERID(name)` does.
+- No contact: with spam check on, the route waits up to 1.2 s for its answer and names a likely
+ or possible spammer with `spam.deskPhoneLabels` (14 §5.2, §9.4).
- Globals `SP_CNAM_URL` (http://127.0.0.1:API_PORT/…) and `SP_CNAM_TOKEN` (HMAC-SHA256 of
APP_SECRET, so it survives restarts). The route answers 404 unless the request comes from this
machine without `X-Forwarded-For` (not through the reverse proxy) and with the key, and isn't
diff --git a/docs/spec/14-spam.md b/docs/spec/14-spam.md
index 972bf59..27d15ff 100644
--- a/docs/spec/14-spam.md
+++ b/docs/spec/14-spam.md
@@ -111,9 +111,9 @@ list until they come back." (or "…are off until they come back." without the F
## 5. Where checks happen
-Everything goes through `spam/check.ts` `checkNumber(number, { wait })` → `{ risk, provider,
-info } | null`. It runs the §1 rules, then the cache, then the providers (§2). It doesn't do the
-contacts test: each caller of it does that for its own user.
+Everything goes through `spam/check.ts` `checkNumber(number, { cachedOnly })` → `{ risk,
+provider, info } | null`. It runs the §1 rules, then the cache, then the providers (§2). It
+doesn't do the contacts test: `checkCaller` (§9.4) does that for the user being rung.
### 5.1 Browser softphone
@@ -123,7 +123,6 @@ contacts test: each caller of it does that for its own user.
- Only for a number that is ringing at one of the user's extensions, or rang there in the last
2 minutes (live state, 04 §5.1); otherwise `null` without checking. A user can't spend
credits on numbers that never called.
- `// TODO(spec): live state must keep the caller's number per ringing channel; check in SD4.`
- The softphone asks once per incoming call, as it starts ringing, and doesn't retry.
- Shown under the caller's name, in the ringing dialog and on the call card, for **high** and
**medium** only; **low** shows nothing (owner, 2026-10-27):
@@ -236,7 +235,7 @@ log masks it. Validated with zod (`packages/shared`).
| SD1 | Decision 0006; this spec; the §7 migration and settings; `spam/check.ts` with both providers' lookups, the cache and the shared requests; Settings → Spam check with keys, Test, privacy confirmation | `pnpm lint && typecheck && test` pass; checks, cache times, the order (IPQualityScore, then the FTC list) and failures are tested against fake HTTP. Done, see §9.1 |
| SD2 | §3: pause, events, the `spam_quota` warning and Dismiss, `spamQuotaCheck` | an exhausted fake pauses, falls back to the FTC list, warns once and resumes; the owner's real key shows the credit call is free. Done but the real-key check, see §9.2 |
| SD3 | §2.2 sync: paging, pacing, the saved cursor, the window, Sync now | a fake FTC API with 3 days of data syncs, resumes after a 429 and drops old complaints; one real sync with the owner's key. Done but the real sync, see §9.3 |
-| SD4 | §5: `/api/me/caller-check`, the softphone badge and line, the desk phone labels | an unknown number rings the softphone and shows its answer; a contact or a low risk shows nothing; a desk phone shows "SPAM LIKELY" when the answer is in time |
+| SD4 | §5: `/api/me/caller-check`, the softphone badge and line, the desk phone labels | an unknown number rings the softphone and shows its answer; a contact or a low risk shows nothing; a desk phone shows "SPAM LIKELY" when the answer is in time. Done but a real call, see §9.4 |
| SD5 | §6 call history; the admin guide page | a checked call keeps its badge in call history after its cache expires |
### 9.1 As built: SD1
@@ -314,6 +313,32 @@ log masks it. Validated with zod (`packages/shared`).
429 stopping and the next minute carrying on without duplicates; the same failure logged once;
a refused key). `test/spam.test.ts`: Download now.
+### 9.4 As built: SD4
+
+- `spam/check.ts` `checkCaller(deps, userId, number, waitMs)`: the contacts test (that user's
+ books and the company's, `contactNames` without waiting on Contrack; books that can't be read
+ count as "known", so nothing is shown), then `checkNumber` raced against `waitMs`. A late
+ answer still fills the cache. `usNumber` normalizes (00 §8) or gives ''.
+- `GET /api/me/caller-check?number=` (5 s): only for an inbound call in live state (04 §5.1)
+ from that number with one of my extensions among its channels (the browser's
+ `PJSIP/101-web-…` and ring group / queue `Local/101@…` legs count). Live state already keeps
+ the caller's number (the first channel's caller ID), so nothing was added there.
+- Not built: "or rang there in the last 2 minutes". The softphone asks as the call starts
+ ringing, so it's in live state; a call gone before its question arrives has nothing to show.
+- `GET /api/internal/caller-name`: no contact → `checkCaller(…, 1200)`; high or medium →
+ `deskPhoneLabels` and, from IPQualityScore, its name for the line. The label **replaces** the
+ carrier's caller ID name rather than going in front of it: the dialplan doesn't send that
+ name, and §5.2 keeps the dialplan as it is. Its failures log through the app's logger (the
+ route's own is silent: the token is in the query).
+- Softphone: `CallerCheck` in `SoftphoneDock.tsx`, in the ringing dialog and on each call card
+ (so the note stays after answering). Asked once per number (react-query, no retry), only for
+ an incoming call from 10+ digits that the browser doesn't already know as a contact.
+- `components/SpamNote.tsx`: the badge and line (`spamLine`), for SD5's call history too.
+- Tests: `test/spam.test.ts` "while it rings" (only callers ringing me, +1 formatting, someone
+ else's call, not ringing, a contact, low; desk labels, a contact's name, a number nobody has,
+ a late answer then the cache, custom labels from the FTC list), `SpamNote.test.ts` (the line).
+- Not checked: a real call in the browser and on a desk phone.
+
Later, not now:
- **Report as spam** (owner, 2026-10-27: not now): a button in call history that reports the
number to a provider that takes reports (OpenSpam has `POST /api/report`).
--
2.34.1
From 6b0c5aa827afaf4d52f2aba46306f5a21a2d1de3 Mon Sep 17 00:00:00 2001
From: iamdoubz
Date: Tue, 27 Oct 2026 18:16:24 -0500
Subject: [PATCH 6/7] web: spam check in call history
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
SD5 (14 §9.5): each incoming call keeps what the spam check knew when it
ended (saved answers and the FTC list only, no new lookup), so the badge
stays in call history after the saved answer expires. Hidden when the
viewer's contacts name the number. New admin guide page, spam-check.md.
Follow-up: the owner's checks with real keys and a real call (14 §9).
---
apps/api/src/routes/calls.ts | 23 +++++--
apps/api/src/services/cdr.ts | 18 +++--
apps/api/src/spam/check.ts | 3 +-
apps/api/test/spam.test.ts | 42 +++++++++++-
apps/web/src/pages/admin/CallsPage.tsx | 2 +
apps/web/src/pages/user/MyCallsPage.tsx | 2 +
docs/admin-guide/README.md | 1 +
docs/admin-guide/spam-check.md | 87 +++++++++++++++++++++++++
docs/spec/09-roadmap.md | 2 +-
docs/spec/14-spam.md | 16 ++++-
packages/shared/src/calls.ts | 3 +
11 files changed, 185 insertions(+), 14 deletions(-)
create mode 100644 docs/admin-guide/spam-check.md
diff --git a/apps/api/src/routes/calls.ts b/apps/api/src/routes/calls.ts
index 2ab2443..bbfb10c 100644
--- a/apps/api/src/routes/calls.ts
+++ b/apps/api/src/routes/calls.ts
@@ -10,10 +10,12 @@ import {
type CallDto,
type ListResponse,
type MyCallDto,
+ type SpamCheck,
} from '@selfphoneng/shared';
import { requireAdmin, requireAuth } from '../auth/guards.ts';
import type { Db } from '../db/client.ts';
import { callExtensions, calls, cdr, dids, extensions } from '../db/schema.ts';
+import { contactNames } from '../contacts/book.ts';
import { badRequest, notFound } from '../errors.ts';
import { getSetting } from '../services/settings.ts';
@@ -65,7 +67,8 @@ async function conditions(db: Db, f: Filter): Promise {
return where;
}
-async function page(db: Db, where: SQL[], limit: number) {
+/** `viewer`: whose books hide the spam check for a number they name (14 §6). */
+async function page(db: Db, where: SQL[], limit: number, viewer: string) {
const rows = await db
.select({ c: calls, did: dids, a: answerer })
.from(calls)
@@ -76,6 +79,12 @@ async function page(db: Db, where: SQL[], limit: number) {
.limit(limit + 1);
const more = rows.length > limit;
const items = rows.slice(0, limit);
+ const known = await contactNames(
+ db,
+ viewer,
+ items.filter(({ c }) => c.spamRisk).map(({ c }) => c.fromNumber),
+ { wait: false },
+ ).catch(() => null);
return {
items: items.map(({ c, did, a }): CallDto => ({
id: c.id,
@@ -91,6 +100,10 @@ async function page(db: Db, where: SQL[], limit: number) {
answeredBy: a ? { id: a.id, number: a.number, name: a.name } : null,
talkSeconds: c.talkSeconds,
recordingId: c.recordingId,
+ spam:
+ c.spamRisk && known && !known.has(c.fromNumber ?? '')
+ ? ({ risk: c.spamRisk, ...(c.spamInfo as object) } as SpamCheck)
+ : null,
})),
nextCursor: more ? encodeCursor(items.at(-1)!.c) : null,
};
@@ -111,7 +124,7 @@ export async function callRoutes(app: FastifyInstance) {
{ preHandler: requireAdmin },
async (req): Promise> => {
const f = CallFilterSchema.parse(req.query);
- return page(db, await conditions(db, f), f.limit);
+ return page(db, await conditions(db, f), f.limit, req.user!.id);
},
);
@@ -123,7 +136,7 @@ export async function callRoutes(app: FastifyInstance) {
'started,direction,status,from,from_name,to,phone_number,answered_by,talk_seconds',
];
// ponytail: one query capped at 50,000 rows; stream with a cursor if exports get bigger.
- const { items } = await page(db, where, 50_000);
+ const { items } = await page(db, where, 50_000, req.user!.id);
const fmt = new Intl.DateTimeFormat('sv-SE', {
timeZone: tz,
dateStyle: 'short',
@@ -156,7 +169,7 @@ export async function callRoutes(app: FastifyInstance) {
app.get('/api/calls/:id', { preHandler: requireAdmin }, async (req): Promise => {
const { id } = z.object({ id: z.uuid() }).parse(req.params);
- const { items } = await page(db, [eq(calls.id, id)], 1);
+ const { items } = await page(db, [eq(calls.id, id)], 1, req.user!.id);
const [c] = await db.select({ linkedid: calls.linkedid }).from(calls).where(eq(calls.id, id));
if (!items[0] || !c) throw notFound('Call not found');
const legs = await db
@@ -189,7 +202,7 @@ export async function callRoutes(app: FastifyInstance) {
.where(eq(extensions.userId, req.user!.id));
if (!ext) throw notFound("You don't have an extension yet. Ask your admin.");
const where = await conditions(db, { ...f, extensionId: ext.id });
- const result = await page(db, where, f.limit);
+ const result = await page(db, where, f.limit, req.user!.id);
const ids = result.items.map((c) => c.id);
const roles = ids.length
? await db
diff --git a/apps/api/src/services/cdr.ts b/apps/api/src/services/cdr.ts
index 67865fd..50c1726 100644
--- a/apps/api/src/services/cdr.ts
+++ b/apps/api/src/services/cdr.ts
@@ -9,6 +9,7 @@ import { join } from 'node:path';
import { and, asc, eq, inArray, isNull, lt } from 'drizzle-orm';
import { field, type AmiMessage } from '../ami/events.ts';
import type { Db } from '../db/client.ts';
+import { checkNumber, usNumber, type SpamDeps } from '../spam/check.ts';
import { callExtensions, calls, cdr, dids, extensions, recordings } from '../db/schema.ts';
import { durationMs } from '../media/audio.ts';
@@ -160,7 +161,7 @@ async function linkRecording(db: Db | Tx, dir: string, s: CallSummary, linkedid:
export async function aggregateCall(
db: Db,
linkedid: string,
- opts: { recordingsDir?: string } = {},
+ opts: { recordingsDir?: string; log?: SpamDeps['log'] } = {},
): Promise {
const rows = await db
.select()
@@ -180,6 +181,13 @@ export async function aggregateCall(
const [d] = s.did
? await db.select({ id: dids.id }).from(dids).where(eq(dids.number, s.did))
: [];
+ // 14 §6: what the spam check knows now (the cache, the local FTC copy), kept with the call.
+ const spam =
+ s.direction === 'inbound' && s.fromNumber
+ ? await checkNumber({ db, log: opts.log ?? { warn() {} } }, usNumber(s.fromNumber), {
+ cachedOnly: true,
+ })
+ : null;
await db.transaction(async (tx) => {
await tx.delete(calls).where(eq(calls.linkedid, linkedid));
const [call] = await tx
@@ -197,6 +205,8 @@ export async function aggregateCall(
didId: d?.id ?? null,
answeredByExtensionId: idOf(s.answeredBy),
talkSeconds: s.talkSeconds,
+ spamRisk: spam?.risk ?? null,
+ spamInfo: spam && { provider: spam.provider, info: spam.info },
recordingId: opts.recordingsDir
? await linkRecording(tx, opts.recordingsDir, s, linkedid)
: null,
@@ -218,7 +228,7 @@ export async function aggregateCall(
export type CdrDeps = {
db: Db;
- log: { error(o: object, msg: string): void };
+ log: { error(o: object, msg: string): void; warn(o: object, msg: string): void };
/** How long after a call's last Cdr event to aggregate it (04 §5.4: 5 s). */
settleMs?: number;
/** Where MixMonitor writes (RECORDINGS_DIR); recordings are linked to their calls. */
@@ -233,7 +243,7 @@ export function watchCdr(d: CdrDeps, ami: Pick) {
linkedid,
setTimeout(() => {
timers.delete(linkedid);
- void aggregateCall(d.db, linkedid, { recordingsDir: d.recordingsDir }).catch(
+ void aggregateCall(d.db, linkedid, { recordingsDir: d.recordingsDir, log: d.log }).catch(
(err: unknown) => d.log.error({ err, linkedid }, 'call aggregation failed'),
);
}, d.settleMs ?? 5000),
@@ -257,7 +267,7 @@ export function watchCdr(d: CdrDeps, ami: Pick) {
.leftJoin(calls, eq(calls.linkedid, cdr.linkedid))
.where(and(isNull(calls.id), lt(cdr.createdAt, new Date(Date.now() - 60_000))));
for (const o of orphans)
- await aggregateCall(d.db, o.linkedid, { recordingsDir: d.recordingsDir });
+ await aggregateCall(d.db, o.linkedid, { recordingsDir: d.recordingsDir, log: d.log });
},
stop: () => timers.forEach(clearTimeout),
};
diff --git a/apps/api/src/spam/check.ts b/apps/api/src/spam/check.ts
index 4a99a43..953a40a 100644
--- a/apps/api/src/spam/check.ts
+++ b/apps/api/src/spam/check.ts
@@ -3,7 +3,6 @@
// only: nothing here changes a call, and a failure only means there's no answer (and one warning
// in the log). The contacts test (14 §1) is the caller's: it knows whose books to look in.
import { and, eq, gt, isNull, sql, type SQL } from 'drizzle-orm';
-import type { FastifyBaseLogger } from 'fastify';
import {
SpamStateSchema,
normalizeNumber,
@@ -23,7 +22,7 @@ import { ftcLookup } from './ftc.ts';
import { SpamError } from './http.ts';
import { ipqsCredits, ipqsLookup } from './ipqs.ts';
-export type SpamDeps = { db: Db; log: Pick };
+export type SpamDeps = { db: Db; log: { warn(o: object, msg: string): void } };
/** US numbers only (owner, 2026-10-27): 1NXXNXXXXXX after normalizeNumber (00 §8). */
export const isCheckable = (n: string) => /^1[2-9]\d{2}[2-9]\d{6}$/.test(n);
diff --git a/apps/api/test/spam.test.ts b/apps/api/test/spam.test.ts
index ab78d4e..4e3721d 100644
--- a/apps/api/test/spam.test.ts
+++ b/apps/api/test/spam.test.ts
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
-// SD1, SD2, SD4: the spam check (14): IPQualityScore first, then the local FTC copy, against a fake fetch.
+// SD1, SD2, SD4, SD5: the spam check (14): IPQualityScore first, then the local FTC copy, against a fake fetch.
+import { readFileSync } from 'node:fs';
import { eq, like, sql } from 'drizzle-orm';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -7,6 +8,7 @@ import { SPAM_DEFAULTS, type SpamSettings } from '@selfphoneng/shared';
import { encrypt } from '../src/crypto/secrets.ts';
import {
auditLog,
+ cdr,
ftcComplaints,
settings as settingsTable,
spamLookups,
@@ -14,7 +16,9 @@ import {
users,
} from '../src/db/schema.ts';
import type { ActiveCall } from '@selfphoneng/shared';
+import type { AmiMessage } from '../src/ami/events.ts';
import { callerNameToken } from '../src/contacts/callername.ts';
+import { aggregateCall, toCdrRow } from '../src/services/cdr.ts';
import { checkNumber, spamQuotaCheck } from '../src/spam/check.ts';
import { getSetting, setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts';
@@ -369,6 +373,42 @@ describe.skipIf(!hasTestDb)('spam check', () => {
await settings({ ...keys.withFtc, deskPhoneLabels: { high: 'JUNK', medium: 'Maybe junk' } });
expect(await deskName(REPORTED)).toBe('JUNK');
});
+
+ it('call history keeps what the check knew, after the cache expires; contacts first', async () => {
+ const fixture = readFileSync(
+ new URL('./fixtures/cdr/inbound-user.json', import.meta.url),
+ 'utf8',
+ );
+ const callFrom = async (number: string) => {
+ const events = (JSON.parse(fixture.replaceAll('13125559999', number)) as AmiMessage[]).map(
+ (e) => ({ ...e, LinkedID: `${number}-${String(e.LinkedID)}` }),
+ );
+ for (const e of events) await t.db.insert(cdr).values(toCdrRow(e));
+ await aggregateCall(t.db, String(events[0]!.LinkedID));
+ };
+ await checkNumber(d(), SPAMMER);
+ urls.length = 0;
+ for (const n of [SPAMMER, REPORTED, SHADY, CLEAN]) await callFrom(n);
+ expect(urls).toEqual([]); // cachedOnly: CLEAN isn't asked
+ await t.db.delete(spamLookups);
+ const spamOf = async (n: string) =>
+ (await admin('GET', '/api/calls?direction=inbound'))
+ .json()
+ .items.find((c: { fromNumber: string }) => c.fromNumber === n)?.spam;
+ expect(await spamOf(SPAMMER)).toMatchObject({
+ risk: 'high',
+ provider: 'ipqs',
+ info: { spammer: true },
+ });
+ expect(await spamOf(REPORTED)).toMatchObject({
+ risk: 'high',
+ provider: 'ftc',
+ info: { complaints: 3 },
+ });
+ expect(await spamOf(SHADY)).toBeNull(); // the admin's contact
+ // Not in the cache, never reported: low (the page shows nothing for it).
+ expect(await spamOf(CLEAN)).toMatchObject({ risk: 'low', provider: 'ftc' });
+ });
});
describe('Settings → Spam check', () => {
diff --git a/apps/web/src/pages/admin/CallsPage.tsx b/apps/web/src/pages/admin/CallsPage.tsx
index 9c6cf2e..46a733d 100644
--- a/apps/web/src/pages/admin/CallsPage.tsx
+++ b/apps/web/src/pages/admin/CallsPage.tsx
@@ -11,6 +11,7 @@ import {
type CallFilter,
} from '@selfphoneng/shared';
import { callsApi, extensionsApi, recordingsApi } from '../../api/client.ts';
+import { SpamNote } from '../../components/SpamNote.tsx';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
export const fmtNumber = (n: string | null) =>
@@ -170,6 +171,7 @@ export function CallsPage() {
{c.answeredBy && (
· answered by {c.answeredBy.name}
)}
+ {fmtDuration(c.talkSeconds)}
diff --git a/apps/web/src/pages/user/MyCallsPage.tsx b/apps/web/src/pages/user/MyCallsPage.tsx
index d5db8d5..7faf69e 100644
--- a/apps/web/src/pages/user/MyCallsPage.tsx
+++ b/apps/web/src/pages/user/MyCallsPage.tsx
@@ -9,6 +9,7 @@ import { Alert, Button } from '../../components/ui.tsx';
import { softphone } from '../../softphone/SoftphoneEngine.ts';
import { DIRECTION_TEXT, StatusBadge, fmtDuration, fmtNumber } from '../admin/CallsPage.tsx';
import { AddToContacts, useContactName } from './ContactsPage.tsx';
+import { SpamNote } from '../../components/SpamNote.tsx';
/** The other side of the call, from where I sit. */
export const otherParty = (c: MyCallDto) =>
@@ -84,6 +85,7 @@ export function MyCallsPage() {
{!known && other.number && c.direction !== 'internal' && (
)}
+ {c.myRole === 'in' && !known && }
{c.recordingId && (
and copy your API key from the account settings.
+2. In **Settings → Spam check**, tick **Use IPQualityScore**, paste the key and press **Test**.
+ It shows how many lookups are left this billing period.
+3. Tick **Spam check on** and **Save**. You're asked to confirm that callers' numbers will be
+ sent to IPQualityScore (see [Privacy](#privacy)).
+
+Each number is looked up once and the answer kept: 14 days for possible and likely spam, 3 days
+for the rest (**Keep IPQualityScore answers** changes these). Five phones ringing for the same
+call cost one lookup.
+
+**When the lookups run out**, SelfPhoneNG stops asking IPQualityScore and the dashboard says so
+(**Dismiss** hides it until the next time). Checks use the FTC list meanwhile, if it's set up.
+Every 6 hours it asks IPQualityScore whether lookups are back and carries on by itself. Saving a
+new key carries on at once.
+
+## FTC complaint list
+
+1. Get a free key at .
+2. In **Settings → Spam check**, tick **Use the FTC list**, paste the key, press **Test**, then
+ **Save** (with **Spam check on**).
+
+The first download takes the last 30 days (**Keep complaints for**) and runs for about 7 hours:
+api.data.gov allows a key 1,000 requests an hour, and there are 5,000 to 15,000 complaints a day.
+The tab shows how far it got. After that it fetches what's new every 24 hours (**Download again
+every**); **Download now** starts one at once. If the FTC refuses the key or is down, the tab
+shows why and it tries again every minute from where it stopped.
+
+## Privacy
+
+- With **IPQualityScore**, the number of every outside caller who isn't a contact is sent to
+ IPQualityScore, a company in the US. If your privacy notice lists who you share data with, add
+ it. In the EU and UK the GDPR applies: you need a lawful basis (usually legitimate interest in
+ protecting staff from fraud) and a data processing agreement with IPQualityScore.
+- With the **FTC list** only, nothing about your calls leaves the server.
+- Only a number that's ringing one of the user's own phones is looked up: nobody can use the
+ browser phone to look up numbers that never called.
+- The keys are stored encrypted and never shown again; the audit log hides them.
+
+## If nothing shows
+
+- Is **Spam check on**, and is the caller in someone's contacts (contacts always win)?
+- Check the api log for `spam check failed` (it says which source and why) or
+ `FTC complaint download failed`:
+
+ ```sh
+ docker compose logs api | grep -iE 'spam|ftc'
+ ```
+
+- Desk phones only show the label when the answer is quick. A first call from a number may show
+ nothing there while the browser phone shows it; the next call uses the kept answer.
diff --git a/docs/spec/09-roadmap.md b/docs/spec/09-roadmap.md
index d95c3d1..d992c00 100644
--- a/docs/spec/09-roadmap.md
+++ b/docs/spec/09-roadmap.md
@@ -9,7 +9,7 @@ Legend: 📖 read · 🔨 build · ✅ done when
---
## Backlog (owner's order, 2026-10-21)
-0. **Spam check** (owner, 2026-10-27: SD1–SD4 done; SD5 next), tasks SD1–SD5
+0. **Spam check** (owner, 2026-10-27: SD1–SD5 done; the owner's real-key checks are left), tasks SD1–SD5
in [14 §9](14-spam.md#9-order-of-work): IPQualityScore and the FTC complaint list for outside
callers who aren't in anyone's contacts; information only, off by default.
0. **Mobile app** (owner, 2026-10-26: next), tasks MB1–MB8 in [13 §5](13-mobile.md#5-order-of-work):
diff --git a/docs/spec/14-spam.md b/docs/spec/14-spam.md
index 27d15ff..8413f0f 100644
--- a/docs/spec/14-spam.md
+++ b/docs/spec/14-spam.md
@@ -236,7 +236,7 @@ log masks it. Validated with zod (`packages/shared`).
| SD2 | §3: pause, events, the `spam_quota` warning and Dismiss, `spamQuotaCheck` | an exhausted fake pauses, falls back to the FTC list, warns once and resumes; the owner's real key shows the credit call is free. Done but the real-key check, see §9.2 |
| SD3 | §2.2 sync: paging, pacing, the saved cursor, the window, Sync now | a fake FTC API with 3 days of data syncs, resumes after a 429 and drops old complaints; one real sync with the owner's key. Done but the real sync, see §9.3 |
| SD4 | §5: `/api/me/caller-check`, the softphone badge and line, the desk phone labels | an unknown number rings the softphone and shows its answer; a contact or a low risk shows nothing; a desk phone shows "SPAM LIKELY" when the answer is in time. Done but a real call, see §9.4 |
-| SD5 | §6 call history; the admin guide page | a checked call keeps its badge in call history after its cache expires |
+| SD5 | §6 call history; the admin guide page | a checked call keeps its badge in call history after its cache expires. Done, see §9.5 |
### 9.1 As built: SD1
@@ -339,6 +339,20 @@ log masks it. Validated with zod (`packages/shared`).
a late answer then the cache, custom labels from the FTC list), `SpamNote.test.ts` (the line).
- Not checked: a real call in the browser and on a desk phone.
+### 9.5 As built: SD5
+
+- `aggregateCall` (04 §5.4) runs `checkNumber(…, { cachedOnly: true })` for inbound calls and
+ writes `spam_risk` and `spam_info` (`{ provider, info }`) with the row. A low answer is kept
+ too (the FTC list answers low for a number nobody reported); null means not checked.
+- `CallDto.spam` on `/api/calls`, `/api/calls/:id` and `/api/me/calls`: null when the viewer's
+ books (theirs and the company's) name the number, or when the books can't be read.
+- Calls pages: `SpamNote` under the caller, on `/me/calls` only for calls to me that the browser
+ doesn't know as a contact. The CSV export has no spam columns.
+- Admin guide: `docs/admin-guide/spam-check.md`.
+- Tests: `test/spam.test.ts` (four calls aggregated: IPQualityScore's answer and the FTC's kept
+ after the cache is emptied, a contact's call shows nothing, an unreported number low, and no
+ request out).
+
Later, not now:
- **Report as spam** (owner, 2026-10-27: not now): a button in call history that reports the
number to a provider that takes reports (OpenSpam has `POST /api/report`).
diff --git a/packages/shared/src/calls.ts b/packages/shared/src/calls.ts
index eeee007..7b815a2 100644
--- a/packages/shared/src/calls.ts
+++ b/packages/shared/src/calls.ts
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Call history (02 calls, call_extensions; 04 §5.4, §7.3).
import { z } from 'zod';
+import type { SpamCheck } from './spam.ts';
export const CALL_DIRECTIONS = ['inbound', 'outbound', 'internal'] as const;
export const CALL_STATUSES = [
@@ -43,6 +44,8 @@ export type CallDto = {
answeredBy: { id: string; number: string; name: string } | null;
talkSeconds: number;
recordingId: string | null;
+ /** 14 §6: what the spam check knew as it ended; null when not checked or in the viewer's books. */
+ spam: SpamCheck | null;
};
/** One raw CDR leg, for the expanded row. */
--
2.34.1
From 70083f8f839c75cfaf6c2961577e4acba29722e6 Mon Sep 17 00:00:00 2001
From: iamdoubz
Date: Tue, 27 Oct 2026 18:32:04 -0500
Subject: [PATCH 7/7] api: share a spam check from its start
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
SD5 follow-up (14 §4): checks for the same number were shared only once
they reached the IPQualityScore request, after reading the cache and the
pause state. A check that finished in between cost a second request
(CI: "five at once cost one request" failed). Now shared before anything
is awaited.
---
apps/api/src/spam/check.ts | 23 +++++++++++++++--------
1 file changed, 15 insertions(+), 8 deletions(-)
diff --git a/apps/api/src/spam/check.ts b/apps/api/src/spam/check.ts
index 953a40a..8607e95 100644
--- a/apps/api/src/spam/check.ts
+++ b/apps/api/src/spam/check.ts
@@ -48,11 +48,23 @@ function warn(d: SpamDeps, provider: string, err: unknown) {
* IPQualityScore first when it's set up, then the FTC list (owner, 2026-10-27). `cachedOnly`:
* the cache and the local FTC copy, never a request out (call history, 14 §6).
*/
-export async function checkNumber(
+export function checkNumber(
d: SpamDeps,
number: string,
opts: { cachedOnly?: boolean } = {},
): Promise {
+ if (opts.cachedOnly) return check(d, number, true);
+ // Shared from the start, before anything is awaited: else a check that finishes between
+ // another's cache read and its look here would cost a second request.
+ let p = pending.get(number);
+ if (!p) {
+ p = check(d, number, false).finally(() => pending.delete(number));
+ pending.set(number, p);
+ }
+ return p;
+}
+
+async function check(d: SpamDeps, number: string, cachedOnly: boolean): Promise {
if (!isCheckable(number)) return null;
const s = await getSetting(d.db, 'spam');
if (!s.enabled) return null;
@@ -63,13 +75,8 @@ export async function checkNumber(
.where(and(eq(spamLookups.number, number), gt(spamLookups.expiresAt, sql`now()`)));
if (hit) return { risk: hit.risk as SpamRisk, provider: 'ipqs', info: hit.info as IpqsInfo };
// Paused (14 §3): the cache still answers, IPQualityScore isn't asked.
- if (!opts.cachedOnly && !(await getSetting(d.db, 'spam.state')).ipqsPausedAt) {
- let p = pending.get(number);
- if (!p) {
- p = askIpqs(d, s, s.ipqs.keyEnc, number).finally(() => pending.delete(number));
- pending.set(number, p);
- }
- const answer = await p;
+ if (!cachedOnly && !(await getSetting(d.db, 'spam.state')).ipqsPausedAt) {
+ const answer = await askIpqs(d, s, s.ipqs.keyEnc, number);
if (answer) return answer;
}
}
--
2.34.1