api: spam check settings, lookups and cache

SD1 (14 §9): the spam/lookups tables and calls columns, the
IPQualityScore and FTC lookups with the cache, the spam settings with
write-only keys, the provider Test routes and Settings → Spam check.
This commit is contained in:
iamdoubz committed 2026-10-27 15:32:31 -05:00
1 parent a3dfcb6b14
commit 6f87d6f663
22 files changed
+5907 -23

No files matched your search

@@ -0,0 +1,25 @@
CREATE TABLE "ftc_complaints" (
"id" text PRIMARY KEY NOT NULL,
"number" text NOT NULL,
"reported_at" timestamp with time zone NOT NULL,
"subject" text,
"robocall" boolean,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "spam_lookups" (
"number" text PRIMARY KEY NOT NULL,
"provider" text NOT NULL,
"risk" text NOT NULL,
"info" jsonb NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "spam_lookups_risk_check" CHECK ("spam_lookups"."risk" in ('low', 'medium', 'high'))
);
--> statement-breakpoint
ALTER TABLE "calls" ADD COLUMN "spam_risk" text;--> statement-breakpoint
ALTER TABLE "calls" ADD COLUMN "spam_info" jsonb;--> statement-breakpoint
CREATE INDEX "ftc_complaints_number_index" ON "ftc_complaints" USING btree ("number");--> statement-breakpoint
CREATE INDEX "ftc_complaints_reported_at_index" ON "ftc_complaints" USING btree ("reported_at");--> statement-breakpoint
CREATE INDEX "spam_lookups_expires_at_index" ON "spam_lookups" USING btree ("expires_at");
File diff suppressed because it is too large. Load diff
@@ -99,6 +99,13 @@
"when": 1793052019332,
"tag": "0013_mobile_devices",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1793127210386,
"tag": "0014_spam_check",
"breakpoints": true
}
]
}
+35
View File
@@ -630,11 +630,46 @@ export const calls = pgTable(
answeredByExtensionId: uuid().references(() => extensions.id, { onDelete: 'set null' }),
talkSeconds: integer().notNull().default(0),
recordingId: uuid().references(() => recordings.id, { onDelete: 'set null' }),
/** The spam check of an incoming call's number (14 §6); null = not checked. */
spamRisk: text(),
spamInfo: jsonb(),
...timestamps,
},
(t) => [index().on(t.startedAt.desc())],
);
// ───────────── spam check (14 §7) ─────────────
/** IPQualityScore answers, so a caller who rings again costs no credit (14 §4). */
export const spamLookups = pgTable(
'spam_lookups',
{
number: text().primaryKey(),
provider: text().notNull(),
risk: text().notNull(),
info: jsonb().notNull(),
expiresAt: tstz().notNull(),
...timestamps,
},
(t) => [
index().on(t.expiresAt),
check('spam_lookups_risk_check', sql`${t.risk} in ('low', 'medium', 'high')`),
],
);
/** The local copy of the FTC's Do Not Call complaints (14 §2.2). */
export const ftcComplaints = pgTable(
'ftc_complaints',
{
id: text().primaryKey(),
number: text().notNull(),
reportedAt: tstz().notNull(),
subject: text(),
robocall: boolean(),
createdAt: createdAt(),
},
(t) => [index().on(t.number), index().on(t.reportedAt)],
);
export const callExtensions = pgTable(
'call_extensions',
{
+4 -1
View File
@@ -10,6 +10,7 @@ import type { Db } from './db/client.ts';
import {
extensions,
sessions,
spamLookups,
systemEvents,
trunks,
users,
@@ -70,7 +71,8 @@ export async function mailAdmins(d: JobDeps, subject: string, text: string) {
await d.send({ to, subject, text }).catch((err: unknown) => d.log.error({ err }, subject));
}
/** Recordings, voicemail, expired sessions and old webhook deliveries (04 §8 retentionCleanup). */
/** Recordings, voicemail, expired sessions, old webhook deliveries and spam check answers
* (04 §8 retentionCleanup, 14 §4). */
export async function retentionCleanup(d: JobDeps) {
const recordings = await cleanupRecordings(
d.db,
@@ -113,6 +115,7 @@ export async function retentionCleanup(d: JobDeps) {
await d.db
.delete(systemEvents)
.where(lt(systemEvents.createdAt, sql`now() - interval '90 days'`));
await d.db.delete(spamLookups).where(lt(spamLookups.expiresAt, sql`now()`));
await cleanupContactShares(d.db);
if (recordings || voicemail) d.log.info({ recordings, voicemail }, 'old messages deleted');
}
+69 -1
View File
@@ -11,6 +11,7 @@ import {
OidcInputSchema,
SettingsSchemas,
SmtpInputSchema,
SpamInputSchema,
type AiTestResult,
type AuthMode,
type Destination,
@@ -19,13 +20,16 @@ import {
type OidcTestResult,
type SettingKey,
type SettingsDto,
type SpamInput,
type SpamSettings,
type SpamTestResult,
type Warning,
} from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { dids, extensions, trunks } from '../db/schema.ts';
import { HttpError, badRequest } from '../errors.ts';
import { SLOW, requireAdmin } from '../auth/guards.ts';
import { encrypt } from '../crypto/secrets.ts';
import { decrypt, encrypt } from '../crypto/secrets.ts';
import { audit } from '../services/audit.ts';
import { assertDestinationExists } from '../services/destinations.ts';
import { getAllSettings, getSetting, setSetting } from '../services/settings.ts';
@@ -39,6 +43,9 @@ import { forgetBook } from '../contacts/contrack.ts';
import { assertLdap, testLdap, type LdapSettings } from '../sso/ldap.ts';
import { directoryUserFor } from '../sso/directory.ts';
import { assertIssuer, oidcConfig, oidcError, testOidc, type OidcSettings } from '../sso/oidc.ts';
import { ftcTest } from '../spam/ftc.ts';
import { SpamError } from '../spam/http.ts';
import { ipqsCredits } from '../spam/ipqs.ts';
declare module 'fastify' {
interface FastifyInstance {
@@ -52,6 +59,7 @@ const READ_ONLY = new Set<string>([
'system.restartRequired',
'system.transportHash',
'system.directoryWarning',
'spam.state',
'sms.webhookToken',
'setup.completed',
]);
@@ -62,10 +70,20 @@ async function toDto(db: Db): Promise<SettingsDto> {
'contacts.contrack': ctk,
'auth.oidc': oidc,
'auth.ldap': ldap,
spam,
...rest
} = await getAllSettings(db);
return {
...rest,
spam: {
...spam,
ipqs: spam.ipqs && { hasKey: true },
ftc: spam.ftc && {
hasKey: true,
syncHours: spam.ftc.syncHours,
windowDays: spam.ftc.windowDays,
},
},
'auth.ldap': ldap && {
enabled: ldap.enabled,
preset: ldap.preset,
@@ -209,6 +227,28 @@ export async function settingsRoutes(app: FastifyInstance) {
trustEmail: input.trustEmail,
};
}
/** The stored form of `spam` (14 §7): keys are write-only (empty keeps the stored one). */
async function spamFromInput(input: SpamInput): Promise<SpamSettings> {
const v = SpamInputSchema.parse(input);
const old = await getSetting(db, 'spam');
const keyEnc = (typed: string | undefined, stored: string | undefined, name: string) => {
if (typed) return encrypt(typed);
if (stored) return stored;
throw badRequest(`Enter the ${name} key`);
};
const ipqs = v.ipqs && {
keyEnc: keyEnc(v.ipqs.key, old.ipqs?.keyEnc, 'IPQualityScore'),
};
const ftc = v.ftc && {
keyEnc: keyEnc(v.ftc.key, old.ftc?.keyEnc, 'api.data.gov'),
syncHours: v.ftc.syncHours,
windowDays: v.ftc.windowDays,
};
if (v.enabled && !ipqs && !ftc)
throw badRequest('Set up IPQualityScore or the FTC list to turn spam check on');
return { ...v, ipqs, ftc };
}
app.addHook('preHandler', requireAdmin);
app.get('/api/settings', () => toDto(db));
@@ -258,6 +298,8 @@ export async function settingsRoutes(app: FastifyInstance) {
forgetBook(null);
} else if (key === 'auth.ldap') {
values[key] = await ldapFromInput(raw);
} else if (key === 'spam') {
values[key] = await spamFromInput(raw as SpamInput);
} else if (key === 'auth.oidc') {
values[key] = await oidcFromInput(raw === null ? null : OidcInputSchema.parse(raw));
} else values[key] = SettingsSchemas[key as SettingKey].parse(raw);
@@ -403,6 +445,32 @@ export async function settingsRoutes(app: FastifyInstance) {
},
);
/**
* Settings → Spam check → Test (14 §7.1), with the typed key or else the saved one. IPQualityScore
* answers with its credits left, which spends none.
*/
for (const provider of ['ipqs', 'ftc'] as const)
app.post(
`/api/settings/spam/${provider}/test`,
{ config: SLOW },
async (req): Promise<SpamTestResult> => {
const { key } = z
.object({ key: z.string().trim().max(200).optional() })
.parse(req.body ?? {});
const keyEnc = (await getSetting(db, 'spam'))[provider]?.keyEnc;
const k = key || (keyEnc && decrypt(keyEnc));
if (!k) return { ok: false, error: 'Enter the key first' };
try {
if (provider === 'ftc') return { ok: true, detail: await ftcTest(k) };
const credits = await ipqsCredits(k);
return { ok: true, detail: `Works: ${credits} credits left this billing period.` };
} catch (err) {
if (err instanceof SpamError) return { ok: false, error: err.message };
throw err;
}
},
);
/** The server's public IPv4, as an outside service sees it (only sent when asked). */
app.post('/api/settings/network/detect-public-ip', { config: SLOW }, async () => {
const ip = await detectPublicIp();
+93
View File
@@ -0,0 +1,93 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// 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 type { FastifyBaseLogger } from 'fastify';
import type { IpqsInfo, SpamCheck, SpamRisk, SpamSettings } from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { spamLookups } from '../db/schema.ts';
import { decrypt } from '../crypto/secrets.ts';
import { getSetting } from '../services/settings.ts';
import { ftcLookup } from './ftc.ts';
import { SpamError } from './http.ts';
import { ipqsLookup } from './ipqs.ts';
export type SpamDeps = { db: Db; log: Pick<FastifyBaseLogger, 'warn'> };
/** 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);
/** A check under way, by number: a ring group ringing five people costs one credit (14 §4). */
const pending = new Map<string, Promise<SpamCheck | null>>();
function warn(d: SpamDeps, provider: string, err: unknown) {
if (err instanceof SpamError) d.log.warn({ provider, reason: err.message }, 'spam check failed');
else d.log.warn({ provider, err }, 'spam check failed');
}
/**
* 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(
d: SpamDeps,
number: string,
opts: { cachedOnly?: boolean } = {},
): Promise<SpamCheck | null> {
if (!isCheckable(number)) return null;
const s = await getSetting(d.db, 'spam');
if (!s.enabled) return null;
if (s.ipqs) {
const [hit] = await d.db
.select()
.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) {
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 (answer) return answer;
}
}
if (!s.ftc) return null;
try {
return { provider: 'ftc', ...(await ftcLookup(d.db, number, s.ftc.windowDays)) };
} catch (err) {
warn(d, 'ftc', err);
return null;
}
}
async function askIpqs(
d: SpamDeps,
s: SpamSettings,
keyEnc: string,
number: string,
): Promise<SpamCheck | null> {
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');
return null;
}
const days = a.risk === 'low' ? s.cache.cleanDays : s.cache.flaggedDays;
const expiresAt = sql`now() + make_interval(days => ${days})`;
await d.db
.insert(spamLookups)
.values({ number, provider: 'ipqs', risk: a.risk, info: a.info, expiresAt })
.onConflictDoUpdate({
target: spamLookups.number,
set: { risk: a.risk, info: a.info, expiresAt, updatedAt: new Date() },
});
return { risk: a.risk, provider: 'ipqs', info: a.info };
} catch (err) {
warn(d, 'ipqs', err);
return null;
}
}
+55
View File
@@ -0,0 +1,55 @@
// 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';
import type { FtcInfo, SpamRisk } from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { SpamError, getJson } from './http.ts';
export const FTC_API = 'https://api.ftc.gov/v0/dnc-complaints';
/** Risk (owner, 2026-10-27): 3 or more complaints in the window high, 1–2 medium. */
export const ftcRisk = (complaints: number): SpamRisk =>
complaints >= 3 ? 'high' : complaints >= 1 ? 'medium' : 'low';
/** The complaints about `number` (1NXXNXXXXXX) in the last `windowDays`. */
export async function ftcLookup(
db: Db,
number: string,
windowDays: number,
): Promise<{ risk: SpamRisk; info: FtcInfo }> {
const r = await db.execute<{
complaints: number;
robocalls: number;
last: Date | string | null;
subject: string | null;
}>(sql`
select count(*)::int as complaints,
(count(*) filter (where robocall))::int as robocalls,
max(reported_at) as last,
mode() within group (order by subject) as subject
from ftc_complaints
where number = ${number}
and reported_at > now() - make_interval(days => ${windowDays})`);
const row = r.rows[0]!;
const info: FtcInfo = {
complaints: row.complaints,
robocalls: row.robocalls,
lastReportedAt: row.last === null ? null : new Date(row.last).toISOString(),
subject: row.subject,
};
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<string> {
try {
await getJson(`${FTC_API}?items_per_page=1`, { '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');
throw err;
}
return 'The FTC answered.';
}
+26
View File
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The spam check's providers over HTTPS (14 §2). Errors carry only these texts: never the URL
// or fetch's own error, which can quote it (IPQualityScore's key is in the path).
/** A failure whose message is safe to log and show. */
export class SpamError extends Error {}
export async function getJson(
url: string,
headers: Record<string, string> = {},
): Promise<Record<string, unknown>> {
let r: Response;
try {
r = await fetch(url, { headers, signal: AbortSignal.timeout(4000) });
} catch (err) {
throw new SpamError(
(err as Error).name === 'TimeoutError' ? 'no answer in 4 s' : "couldn't connect",
);
}
if (!r.ok) throw new SpamError(`HTTP ${r.status}`);
try {
return (await r.json()) as Record<string, unknown>;
} catch {
throw new SpamError('the answer was not JSON');
}
}
+60
View File
@@ -0,0 +1,60 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// IPQualityScore (14 §2.1). Its key goes in the URL's path: see http.ts.
import type { IpqsInfo, SpamRisk } from '@selfphoneng/shared';
import { SpamError, getJson } from './http.ts';
const BASE = 'https://www.ipqualityscore.com/api/json';
export type IpqsAnswer = { kind: 'ok'; risk: SpamRisk; info: IpqsInfo } | { kind: 'exhausted' };
const get = (path: string) => getJson(`${BASE}/${path}`);
const str = (v: unknown) => (typeof v === 'string' && v.trim() && v !== 'N/A' ? v.trim() : null);
const used = (msg: unknown) => typeof msg === 'string' && /insufficient credits/i.test(msg);
/** The provider's own message, cut short and without the key in case it quotes it. */
const said = (msg: unknown, key: string) =>
new SpamError(
typeof msg === 'string' && msg
? msg.replaceAll(key, '***').slice(0, 200)
: 'the request failed',
);
/** Risk (owner, 2026-10-27): IPQualityScore's own advice, 75 suspicious and 85 high risk. */
export function ipqsRisk(a: {
fraudScore: number;
spammer: boolean;
recentAbuse: boolean;
risky: boolean;
}): SpamRisk {
if (a.spammer || a.fraudScore >= 85) return 'high';
if (a.fraudScore >= 75 || a.recentAbuse || a.risky) return 'medium';
return 'low';
}
/** One phone lookup: `number` is 1NXXNXXXXXX. */
export async function ipqsLookup(key: string, number: string): Promise<IpqsAnswer> {
const j = await get(`phone/${encodeURIComponent(key)}/${number}?country[]=US`);
if (j.success !== true) {
if (used(j.message)) return { kind: 'exhausted' };
throw said(j.message, key);
}
const info: IpqsInfo = {
fraudScore: typeof j.fraud_score === 'number' ? j.fraud_score : 0,
spammer: j.spammer === true,
recentAbuse: j.recent_abuse === true,
lineType: str(j.line_type),
carrier: str(j.carrier),
name: str(j.name),
city: str(j.city),
region: str(j.region),
};
return { kind: 'ok', risk: ipqsRisk({ ...info, risky: j.risky === true }), info };
}
/** The Credit Usage API: credits left in this billing period (Test, and 14 §3). */
export async function ipqsCredits(key: string): Promise<number> {
const j = await get(`account/${encodeURIComponent(key)}`);
if (j.success === false) throw said(j.message, key);
const credits = Number(j.credits);
if (!Number.isFinite(credits)) throw new SpamError('the answer had no credit count');
return credits;
}
+4 -2
View File
@@ -94,6 +94,8 @@ const EXPECTED: Record<string, Record<string, string>> = {
answered_by_extension_id: 'uuid|YES|',
talk_seconds: 'integer|NO|0',
recording_id: 'uuid|YES|',
spam_risk: 'text|YES|',
spam_info: 'jsonb|YES|',
...ts,
},
audit_log: {
@@ -115,11 +117,11 @@ describe.skipIf(!hasTestDb)('schema matches spec 02', () => {
beforeAll(async () => ({ db, drop } = await createTestDb()));
afterAll(() => drop?.());
it('creates all 45 tables', async () => {
it('creates all 47 tables', async () => {
const r = await db.execute(
sql`select count(*)::int as n from information_schema.tables where table_schema = 'public'`,
);
expect(r.rows[0]).toEqual({ n: 45 });
expect(r.rows[0]).toEqual({ n: 47 });
});
it.each(Object.keys(EXPECTED))('%s columns', async (table) => {
+293
View File
@@ -0,0 +1,293 @@
// 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';
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 { getSetting, setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
const IPQS_KEY = 'ipqs-secret-key-123';
const FTC_KEY = 'datagov-secret-key-456';
const SPAMMER = '13125550001';
const SHADY = '13125550002';
const CLEAN = '13125550003';
const REPORTED = '13125550004';
/** IPQualityScore's phone answers by number; anything else is a valid, clean line. */
const ANSWERS: Record<string, Record<string, unknown>> = {
[SPAMMER]: { fraud_score: 40, spammer: true, line_type: 'VOIP', carrier: 'Acme Tel' },
[SHADY]: { fraud_score: 78, city: 'Austin', region: 'TX', name: 'N/A' },
[CLEAN]: { fraud_score: 10, line_type: 'Wireless', carrier: 'Verizon' },
};
describe.skipIf(!hasTestDb)('spam check', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
type Req = (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
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';
const log = { warn: vi.fn() };
const d = () => ({ db: t.db, log });
const settings = (s: Partial<SpamSettings>) =>
setSetting(t.db, 'spam', { ...SPAM_DEFAULTS, enabled: true, ...s });
// Getters: the secret key is only set once createTestApp has run.
const keys = {
get withIpqs() {
return { ipqs: { keyEnc: encrypt(IPQS_KEY) } };
},
get withFtc() {
return { ftc: { keyEnc: encrypt(FTC_KEY), syncHours: 24, windowDays: 30 } };
},
};
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
const cookie = await t.login(ADMIN.email, ADMIN.password);
admin = (method, url, payload) =>
t.app.inject({
method: method as 'GET',
url,
payload: payload as object,
headers: { ...H, cookie },
});
const day = (n: number) => sql`now() - make_interval(days => ${n})`;
await t.db.insert(ftcComplaints).values([
{ id: 'a1', number: REPORTED, reportedAt: day(1), subject: 'Warranties', robocall: true },
{ id: 'a2', number: REPORTED, reportedAt: day(2), subject: 'Warranties', robocall: true },
{ id: 'a3', number: REPORTED, reportedAt: day(3), subject: 'Debt', robocall: false },
{ id: 'a4', number: REPORTED, reportedAt: day(40), subject: 'Debt', robocall: true },
{ id: 'b1', number: SHADY, reportedAt: day(5), subject: null, robocall: null },
]);
});
afterAll(() => t?.close());
beforeEach(() => {
urls.length = 0;
log.warn.mockClear();
ipqs = 'ok';
vi.stubGlobal('fetch', async (url: string, init?: RequestInit) => {
urls.push(url);
const json = (j: unknown, status = 200) => new Response(JSON.stringify(j), { status });
if (url.startsWith('https://api.ftc.gov/')) {
const key = (init?.headers as Record<string, string>)['X-Api-Key'];
return key === FTC_KEY ? json({ data: [] }) : json({ error: {} }, 403);
}
const account = /\/api\/json\/account\/([^/]+)$/.exec(url);
if (account)
return account[1] === IPQS_KEY
? json({ success: true, credits: 912, 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);
if (ipqs === 'down') return json({}, 503);
if (ipqs === 'slow') throw Object.assign(new Error('aborted'), { name: 'TimeoutError' });
if (ipqs === 'exhausted')
return json({
success: false,
message: 'You have insufficient credits to make this query.',
});
await new Promise((r) => setTimeout(r, 20));
return json({ success: true, valid: true, ...(ANSWERS[phone[1]!] ?? { fraud_score: 0 }) });
});
});
afterEach(async () => {
vi.unstubAllGlobals();
await t.db.delete(spamLookups);
});
it('checks nothing while off, and only US numbers', async () => {
await setSetting(t.db, 'spam', { ...SPAM_DEFAULTS, ...keys.withIpqs });
expect(await checkNumber(d(), SPAMMER)).toBeNull();
await settings(keys.withIpqs);
for (const n of ['442079460958', '911', '101', '13125550', '10005550001'])
expect(await checkNumber(d(), n)).toBeNull();
expect(urls).toEqual([]);
});
it('IPQualityScore: risk from its flags and score, only the kept fields', async () => {
await settings(keys.withIpqs);
expect(await checkNumber(d(), SPAMMER)).toEqual({
risk: 'high',
provider: 'ipqs',
info: {
fraudScore: 40,
spammer: true,
recentAbuse: false,
lineType: 'VOIP',
carrier: 'Acme Tel',
name: null,
city: null,
region: null,
},
});
expect((await checkNumber(d(), SHADY))?.risk).toBe('medium');
expect(await checkNumber(d(), CLEAN)).toMatchObject({
risk: 'low',
info: { carrier: 'Verizon' },
});
expect(urls).toHaveLength(3);
});
it('caches answers: flagged for flaggedDays, the rest for cleanDays', async () => {
await settings({ ...keys.withIpqs, cache: { flaggedDays: 14, cleanDays: 3 } });
await checkNumber(d(), SPAMMER);
await checkNumber(d(), CLEAN);
await checkNumber(d(), SPAMMER);
await checkNumber(d(), CLEAN);
expect(urls).toHaveLength(2);
const days = await t.db.execute<{ number: string; days: number }>(
sql`select number, round(extract(epoch from expires_at - now()) / 86400)::int as days from spam_lookups order by number`,
);
expect(days.rows).toEqual([
{ number: SPAMMER, days: 14 },
{ number: CLEAN, days: 3 },
]);
// An expired answer is asked again.
await t.db.update(spamLookups).set({ expiresAt: sql`now() - interval '1 second'` });
await checkNumber(d(), CLEAN);
expect(urls).toHaveLength(3);
});
it('a check under way is shared: five at once cost one request', async () => {
await settings(keys.withIpqs);
const all = await Promise.all([1, 2, 3, 4, 5].map(() => checkNumber(d(), SPAMMER)));
expect(new Set(all.map((a) => a?.risk))).toEqual(new Set(['high']));
expect(urls).toHaveLength(1);
});
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) {
ipqs = mode;
expect(await checkNumber(d(), REPORTED)).toEqual({
risk: 'high',
provider: 'ftc',
info: {
complaints: 3,
robocalls: 2,
lastReportedAt: expect.any(String),
subject: 'Warranties',
},
});
}
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([]);
});
it('without IPQualityScore the FTC list answers; without either, nothing', async () => {
ipqs = 'down';
await settings(keys.withFtc);
expect((await checkNumber(d(), SHADY))?.info).toEqual({
complaints: 1,
robocalls: 0,
lastReportedAt: expect.any(String),
subject: null,
});
expect((await checkNumber(d(), SHADY))?.risk).toBe('medium');
expect(await checkNumber(d(), CLEAN)).toMatchObject({ risk: 'low', provider: 'ftc' });
expect(urls).toEqual([]);
await settings({});
expect(await checkNumber(d(), REPORTED)).toBeNull();
});
it('cachedOnly never asks IPQualityScore', async () => {
await settings({ ...keys.withIpqs, ...keys.withFtc });
expect((await checkNumber(d(), REPORTED, { cachedOnly: true }))?.provider).toBe('ftc');
await checkNumber(d(), SPAMMER);
expect((await checkNumber(d(), SPAMMER, { cachedOnly: true }))?.provider).toBe('ipqs');
expect(urls).toHaveLength(1);
});
describe('Settings → Spam check', () => {
const body = (s: Record<string, unknown> = {}) => ({
spam: {
enabled: true,
ipqs: { key: IPQS_KEY },
ftc: null,
cache: { flaggedDays: 14, cleanDays: 3 },
deskPhoneLabels: { high: 'SPAM LIKELY', medium: 'Spam risk' },
...s,
},
});
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());
expect(r.statusCode).toBe(200);
expect(r.json().spam).toEqual({
...SPAM_DEFAULTS,
enabled: true,
ipqs: { hasKey: true },
});
expect(r.body).not.toContain(IPQS_KEY);
const saved = (await getSetting(t.db, 'spam')).ipqs!.keyEnc;
expect((await admin('PATCH', '/api/settings', body({ ipqs: {} }))).statusCode).toBe(200);
expect((await getSetting(t.db, 'spam')).ipqs!.keyEnc).toBe(saved);
const [a] = await t.db
.select()
.from(auditLog)
.where(eq(auditLog.summary, 'Changed settings: spam'));
expect(JSON.stringify(a!.diff)).not.toContain(saved);
});
it('refuses a provider without a key, nothing to check with, and odd labels', async () => {
await setSetting(t.db, 'spam', SPAM_DEFAULTS);
const no = async (b: unknown, error: RegExp) => {
const r = await admin('PATCH', '/api/settings', b);
expect(r.statusCode).toBe(400);
expect(r.body).toMatch(error);
};
await no(body({ ipqs: {} }), /Enter the IPQualityScore key/);
await no(
body({ ipqs: null, ftc: { syncHours: 24, windowDays: 30 } }),
/Enter the api.data.gov key/,
);
await no(body({ ipqs: null }), /Set up IPQualityScore or the FTC list/);
await no(body({ deskPhoneLabels: { high: 'Spam?', medium: 'Spam risk' } }), /Letters/);
await no(
body({ ftc: { key: FTC_KEY, syncHours: 0, windowDays: 30 } }),
/syncHours|too small|>=1/i,
);
await no({ 'spam.state': { ipqsPausedAt: null } }, /can't be changed/);
// Off with no provider is fine.
expect(
(await admin('PATCH', '/api/settings', body({ enabled: false, ipqs: null }))).statusCode,
).toBe(200);
});
it('Test uses the typed key, else the saved one, and never shows it', async () => {
await setSetting(t.db, 'spam', SPAM_DEFAULTS);
const test = async (provider: string, key?: string) =>
(await admin('POST', `/api/settings/spam/${provider}/test`, { key })).json();
expect(await test('ipqs')).toEqual({ ok: false, error: 'Enter the key first' });
expect(await test('ipqs', IPQS_KEY)).toEqual({
ok: true,
detail: 'Works: 912 credits left this billing period.',
});
const bad = await test('ipqs', 'wrong-key-789');
expect(bad.ok).toBe(false);
expect(JSON.stringify(bad)).not.toContain('wrong-key-789');
await settings({ ...keys.withIpqs, ...keys.withFtc });
expect((await test('ipqs')).ok).toBe(true);
expect(await test('ftc')).toEqual({ ok: true, detail: 'The FTC answered.' });
expect(await test('ftc', 'nope')).toEqual({
ok: false,
error: 'api.data.gov refused the key',
});
});
});
});
+4
View File
@@ -3,6 +3,7 @@
import type {
LdapInput,
LdapTestResult,
SpamTestResult,
AuthMethods,
OidcInput,
OidcTestResult,
@@ -259,6 +260,9 @@ export const settingsApi = {
/** 12 §4.1 */
testLdap: (v: LdapInput & { testLogin?: string; testPassword?: string }) =>
api<LdapTestResult>('POST', '/api/settings/auth/ldap/test', v),
/** 14 §7.1: the typed key, else the saved one. */
testSpam: (provider: 'ipqs' | 'ftc', key?: string) =>
api<SpamTestResult>('POST', `/api/settings/spam/${provider}/test`, { key }),
};
/** Sign out, and when the session came from OpenID, at the provider too (12 §3.5). */
+248 -1
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Settings (05 §2). M3: Email and Emergency; M7: Recording; M11: Network (public IP watch), Security (phone ACL, provisioning, SIP TLS), Backups,
// Webhooks, API tokens; AI (spec 10).
// Webhooks, API tokens; AI (spec 10); Spam check (spec 14).
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Fragment, useState } from 'react';
import { NavLink, useParams } from 'react-router';
@@ -43,6 +43,7 @@ const TABS = [
{ id: 'ai', label: 'AI' },
{ id: 'contacts', label: 'Contacts' },
{ id: 'sign-in', label: 'Sign-in' },
{ id: 'spam', label: 'Spam check' },
] as const;
export function SettingsPage() {
@@ -79,6 +80,7 @@ export function SettingsPage() {
{tab === 'tokens' && <TokensTab />}
{settings.data && tab === 'ai' && <AiTab s={settings.data} />}
{settings.data && tab === 'contacts' && <ContactsTab s={settings.data} />}
{settings.data && tab === 'spam' && <SpamTab s={settings.data} />}
{settings.data && tab === 'sign-in' && (
<div className="flex flex-col gap-10">
<SignInTab s={settings.data} />
@@ -1373,6 +1375,251 @@ export function ContactsTab({ s }: { s: SettingsDto }) {
);
}
/** Test for one spam check provider (14 §7.1): the typed key, else the saved one. */
function SpamTest({ provider, keyText }: { provider: 'ipqs' | 'ftc'; keyText: string }) {
const test = useMutation({
mutationFn: () => settingsApi.testSpam(provider, keyText || undefined),
});
return (
<div className="flex flex-wrap items-center gap-2">
<Button onClick={() => test.mutate()} disabled={test.isPending}>
Test
</Button>
{test.isError && <Alert>{test.error.message}</Alert>}
{test.data && (
<span
role="status"
className={`text-sm ${test.data.ok ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark:text-red-400'}`}
>
{test.data.ok ? test.data.detail : test.data.error}
</span>
)}
</div>
);
}
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) => (
<Field label={label}>
{(a) => (
<input {...a} inputMode="numeric" className={inputClass} value={v[k]} onChange={text(k)} />
)}
</Field>
);
const keyField = (k: 'ipqsKey' | 'ftcKey', label: string, saved: boolean, hint: string) => (
<Field label={label} hint={saved ? 'Saved. Paste a new one to replace it.' : hint}>
{(a) => (
<input
{...a}
type="password"
autoComplete="off"
placeholder={saved ? '••••••••' : undefined}
className={inputClass}
value={v[k]}
onChange={text(k)}
/>
)}
</Field>
);
return (
<form
className="flex flex-col gap-4"
noValidate
onSubmit={(e) => {
e.preventDefault();
if (sendsOut) setConfirming(true);
else submit();
}}
>
<p className="text-sm text-zinc-500">
When an outside caller from the US isn&rsquo;t in anyone&rsquo;s contacts, look the number
up and show what&rsquo;s known about them while it rings. Information only: calls are never
blocked or sent elsewhere. IPQualityScore answers first when it&rsquo;s set up; the FTC list
answers when it isn&rsquo;t, or can&rsquo;t.
</p>
<label className="flex items-center gap-2 text-sm font-medium">
<input type="checkbox" checked={v.enabled} onChange={flag('enabled')} className="size-4" />
Spam check on
</label>
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 font-medium">IPQualityScore</legend>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={v.useIpqs}
onChange={flag('useIpqs')}
className="size-4"
/>
Use IPQualityScore: a live lookup with a fraud score. It sends the caller&rsquo;s number
to IPQualityScore. The free plan has 1,000 lookups a month.
</label>
{v.useIpqs && (
<>
{keyField(
'ipqsKey',
'API key',
!!sp.ipqs?.hasKey,
'From your IPQualityScore account settings.',
)}
<SpamTest provider="ipqs" keyText={v.ipqsKey} />
{st.ipqsPausedAt && (
<p className="text-sm text-amber-700 dark:text-amber-400">
Paused since {when(st.ipqsPausedAt)}: its lookups are used up until they come back.
</p>
)}
</>
)}
</fieldset>
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 font-medium">FTC complaint list</legend>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={v.useFtc} onChange={flag('useFtc')} className="size-4" />
Use the FTC list: complaints people filed with the FTC about unwanted calls, downloaded to
this server. Free, and nothing about your calls is sent out. The reports aren&rsquo;t
verified, and spammers fake real people&rsquo;s numbers.
</label>
{v.useFtc && (
<>
{keyField(
'ftcKey',
'api.data.gov key',
!!sp.ftc?.hasKey,
'Free, from api.data.gov/signup.',
)}
<SpamTest provider="ftc" keyText={v.ftcKey} />
<div className="grid grid-cols-2 gap-3">
{num('syncHours', 'Download again every (hours, 1–168)')}
{num('windowDays', 'Keep complaints for (days, 7–365)')}
</div>
{st.ftcLastSyncAt && (
<p className="text-sm text-zinc-500">
Last download {when(st.ftcLastSyncAt)}
{st.ftcLastError ? `: ${st.ftcLastError}` : '.'}
</p>
)}
</>
)}
</fieldset>
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 font-medium">Keep IPQualityScore answers</legend>
<div className="grid grid-cols-2 gap-3">
{num('flaggedDays', 'Possible or likely spam (days, 1–365)')}
{num('cleanDays', 'Everything else (days, 1–365)')}
</div>
</fieldset>
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 font-medium">Desk phones</legend>
<p className="text-sm text-zinc-500">
Shown in front of the caller&rsquo;s name when the answer comes in before the phone rings.
Letters, digits, spaces and . &apos; &amp; @ - only.
</p>
<div className="grid grid-cols-2 gap-3">
<Field label="Likely spam">
{(a) => (
<input
{...a}
maxLength={20}
className={inputClass}
value={v.high}
onChange={text('high')}
/>
)}
</Field>
<Field label="Possible spam">
{(a) => (
<input
{...a}
maxLength={20}
className={inputClass}
value={v.medium}
onChange={text('medium')}
/>
)}
</Field>
</div>
</fieldset>
{save.isError && <Alert>{save.error.message}</Alert>}
{save.isSuccess && (
<p role="status" className="text-sm text-green-700 dark:text-green-400">
Saved.
</p>
)}
<div>
<Button type="submit" variant="primary" disabled={save.isPending}>
Save
</Button>
</div>
<ConfirmDialog
open={confirming}
title="Send callers' numbers to IPQualityScore?"
message={
<>
The numbers of outside callers who aren&rsquo;t in your contacts will be sent to
IPQualityScore. You may need to say so in your privacy notice, and to have a data
processing agreement with them. In the EU and UK the GDPR applies.
</>
}
confirmLabel="Turn on"
onConfirm={submit}
onCancel={() => setConfirming(false)}
busy={save.isPending}
/>
</form>
);
}
const lines = (s: string) =>
s
.split(/[\n,]/)