diff --git a/src/app/api/dashboard/widgets/__tests__/route.test.ts b/src/app/api/dashboard/widgets/__tests__/route.test.ts index 4ebb3840c..da27da073 100644 --- a/src/app/api/dashboard/widgets/__tests__/route.test.ts +++ b/src/app/api/dashboard/widgets/__tests__/route.test.ts @@ -45,7 +45,7 @@ vi.mock("next/headers", () => ({ })), })); -import { PUT } from "../route"; +import { PUT, __resetAuditDedupMemoForTests } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; @@ -73,6 +73,7 @@ function makeReq(body: unknown): NextRequest { beforeEach(() => { vi.resetAllMocks(); __resetAllCachesForTests(); + __resetAuditDedupMemoForTests(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); }); @@ -144,6 +145,26 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)", } }); + it("dedups the audit-ledger write across two sequential 422s for the same user (v1.4.43 B2)", async () => { + // First 422 writes one row; the second 422 inside the 60 s window + // returns the same envelope but skips the audit insert. + const res1 = await callPut(makeReq({ version: 2, widgets: [] })); + expect(res1.status).toBe(422); + const res2 = await callPut(makeReq({ version: 2, widgets: [] })); + expect(res2.status).toBe(422); + + await new Promise((r) => setTimeout(r, 5)); + + expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); + + // The full multi-issue envelope still rides on every 422 — the + // dedup only suppresses the breadcrumb write, never the response. + const body2 = (await res2.json()) as { + details: { issues: Array }; + }; + expect(body2.details.issues.length).toBe(2); + }); + it("does not block the 422 response when the audit-row write rejects", async () => { vi.mocked(prisma.auditLog.create).mockRejectedValueOnce( new Error("db down"), diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index dec1978be..17af5d029 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -42,6 +42,40 @@ 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(); +} + const layoutSchema = z.object({ version: z.literal(1), widgets: z @@ -143,18 +177,29 @@ export const PUT = apiHandler(async (request: NextRequest) => { action: { name: "dashboard.widgets.validation-failed" }, meta: { issue_count: issues.length }, }); - // Best-effort breadcrumb — never block the 422 on a write miss. - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "dashboard.widgets.validation-failed", - details: JSON.stringify({ issues }), - }, - }) - .catch(() => { - /* swallow — validation response is the contract, audit row is best-effort */ - }); + // v1.4.43 B2 — gate the breadcrumb behind a 60 s (userId, action) dedup + // so a misbehaving iOS client retrying every second cannot pump + // thousands of identical rows into the audit ledger. + if ( + shouldEmitAuditRow( + user.id, + "dashboard.widgets.validation-failed", + Date.now(), + ) + ) { + // Best-effort breadcrumb — never block the 422 on a write miss. + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "dashboard.widgets.validation-failed", + details: JSON.stringify({ issues }), + }, + }) + .catch(() => { + /* swallow — validation response is the contract, audit row is best-effort */ + }); + } return returnAllZodIssues(parsed.error, 422); }