diff --git a/src/app/api/dashboard/chart-overlay-prefs/__tests__/route.test.ts b/src/app/api/dashboard/chart-overlay-prefs/__tests__/route.test.ts index cfc2b2d3e..51cf6b4c9 100644 --- a/src/app/api/dashboard/chart-overlay-prefs/__tests__/route.test.ts +++ b/src/app/api/dashboard/chart-overlay-prefs/__tests__/route.test.ts @@ -51,10 +51,13 @@ function putReq(body: unknown): NextRequest { }); } +import { __resetAuditDedupMemoForTests } from "@/lib/audit-dedup"; + beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + __resetAuditDedupMemoForTests(); }); describe("PUT /api/dashboard/chart-overlay-prefs — 422 multi-issue (v1.4.43 W6)", () => { diff --git a/src/app/api/dashboard/chart-overlay-prefs/route.ts b/src/app/api/dashboard/chart-overlay-prefs/route.ts index 89c69a5ab..87a8f4050 100644 --- a/src/app/api/dashboard/chart-overlay-prefs/route.ts +++ b/src/app/api/dashboard/chart-overlay-prefs/route.ts @@ -22,6 +22,7 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { shouldEmitAuditRow } from "@/lib/audit-dedup"; import { annotate } from "@/lib/logging/context"; import { prisma, toJson } from "@/lib/db"; import { @@ -57,23 +58,30 @@ export const PUT = apiHandler(async (request: NextRequest) => { // v1.4.43 W6 — sibling of `/api/dashboard/widgets`; the per-chart // overlay popover hits this on every toggle so the multi-issue // envelope matches widgets exactly. Audit breadcrumb keyed - // `dashboard.chart-overlay.validation-failed`. + // `dashboard.chart-overlay.validation-failed`, deduped via the + // shared `shouldEmitAuditRow` 60 s `(userId, action)` window so a + // misbehaving iOS client looping the popover cannot flood the + // audit ledger. const issues = sanitiseZodIssues(parsed.error.issues); annotate({ action: { name: "dashboard.chart-overlay.validation-failed" }, meta: { issue_count: issues.length }, }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "dashboard.chart-overlay.validation-failed", - details: JSON.stringify({ issues }), - }, - }) - .catch(() => { - /* swallow — 422 response is the contract */ - }); + if ( + shouldEmitAuditRow(user.id, "dashboard.chart-overlay.validation-failed") + ) { + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "dashboard.chart-overlay.validation-failed", + details: JSON.stringify({ issues }), + }, + }) + .catch(() => { + /* swallow — 422 response is the contract */ + }); + } return returnAllZodIssues(parsed.error, 422); } diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 17af5d029..ca3873505 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -42,39 +42,11 @@ import type { NextRequest } from "next/server"; // the enum from `DASHBOARD_WIDGET_IDS` so the two lists cannot drift. const widgetIdEnum = z.enum(DASHBOARD_WIDGET_IDS); -// v1.4.43 B2 — in-process dedup memo for the validation-failed audit row. -// A misbehaving iOS client looping on a 422 can otherwise write thousands -// of identical audit rows per minute. We dedup on `(userId, action)` with -// a 60 s window: only the first 422 inside the window writes; subsequent -// 422s still return the full multi-issue envelope but skip the breadcrumb. -// In-memory only — a process restart resets the map, which is the safe -// failure mode (worst case: one extra audit row per restart). Cluster -// deployments still get one row per process per minute, which is fine for -// the operator-grep use case the audit row exists for. -const AUDIT_DEDUP_WINDOW_MS = 60_000; -const auditDedupMemo = new Map(); - -function shouldEmitAuditRow(userId: string, action: string, now: number): boolean { - const key = `${userId}:${action}`; - const last = auditDedupMemo.get(key); - if (last !== undefined && now - last < AUDIT_DEDUP_WINDOW_MS) { - return false; - } - auditDedupMemo.set(key, now); - // Opportunistic GC — drop entries older than the window on every miss so - // the map cannot grow unbounded across a long-running process. - if (auditDedupMemo.size > 512) { - for (const [k, t] of auditDedupMemo) { - if (now - t >= AUDIT_DEDUP_WINDOW_MS) auditDedupMemo.delete(k); - } - } - return true; -} - -/** @internal — exported for the dedup unit test only. */ -export function __resetAuditDedupMemoForTests(): void { - auditDedupMemo.clear(); -} +// v1.4.43 B2 — dedup helper extracted to `src/lib/audit-dedup.ts` so both +// `/api/dashboard/widgets` and `/api/dashboard/chart-overlay-prefs` share +// the same 60 s `(userId, action)` window. +export { __resetAuditDedupMemoForTests } from "@/lib/audit-dedup"; +import { shouldEmitAuditRow } from "@/lib/audit-dedup"; const layoutSchema = z.object({ version: z.literal(1), diff --git a/src/lib/audit-dedup.ts b/src/lib/audit-dedup.ts new file mode 100644 index 000000000..664294ac3 --- /dev/null +++ b/src/lib/audit-dedup.ts @@ -0,0 +1,44 @@ +/** + * In-process dedup helper for fire-and-forget audit-log rows. + * + * A misbehaving iOS client looping on a 422 can otherwise write + * thousands of identical audit rows per minute. Routes that emit a + * `.validation-failed` audit row alongside a multi-issue Zod + * 422 envelope wrap the write through `shouldEmitAuditRow` to gate + * on a 60 s `(userId, action)` window — only the first 422 inside + * the window writes; subsequent 422s still return the full envelope + * but skip the breadcrumb. + * + * In-memory only — a process restart resets the map, which is the + * safe failure mode (worst case: one extra audit row per restart). + * Cluster deployments still get one row per process per minute, + * which is fine for the operator-grep use case the audit row + * exists for. + */ + +const AUDIT_DEDUP_WINDOW_MS = 60_000; +const auditDedupMemo = new Map(); + +export function shouldEmitAuditRow( + userId: string, + action: string, + now: number = Date.now(), +): boolean { + const key = `${userId}:${action}`; + const last = auditDedupMemo.get(key); + if (last !== undefined && now - last < AUDIT_DEDUP_WINDOW_MS) { + return false; + } + auditDedupMemo.set(key, now); + if (auditDedupMemo.size > 512) { + for (const [k, t] of auditDedupMemo) { + if (now - t >= AUDIT_DEDUP_WINDOW_MS) auditDedupMemo.delete(k); + } + } + return true; +} + +/** @internal — exported for unit tests only. */ +export function __resetAuditDedupMemoForTests(): void { + auditDedupMemo.clear(); +}