api: pause spam check when IPQualityScore credits run out

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).
This commit is contained in:
iamdoubz committed 2026-10-27 16:17:42 -05:00
1 parent 6f87d6f663
commit 70f14288ca
11 files changed
+254 -29

No files matched your search

+3
View File
@@ -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 });
});
+17
View File
@@ -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 };
});
}
+79 -7
View File
@@ -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<FastifyBaseLogger, 'warn'> };
@@ -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<SpamState>,
onlyIf?: SQL,
): Promise<boolean> {
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`);
}
+99 -8
View File
@@ -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');
+2
View File
@@ -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<OidcTestResult>('POST', '/api/settings/auth/oidc/test', v),
/** 12 §4.1 */
testLdap: (v: LdapInput & { testLogin?: string; testPassword?: string }) =>
+21 -3
View File
@@ -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 }) {
<Link to={w.link} className="font-medium underline">
Fix
</Link>
{w.dismissable && (
<>
{' '}
<button
type="button"
className="font-medium underline"
disabled={dismiss.isPending}
onClick={() => dismiss.mutate(w.code)}
>
Dismiss
</button>
</>
)}
</li>
))}
</ul>
+3 -1
View File
@@ -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).
+2 -2
View File
@@ -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`)
+1 -1
View File
@@ -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):
+25 -6
View File
@@ -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
+2 -1
View File
@@ -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 };