feat(widgets): dedup validation-failed audit rows per user 60s

A misbehaving iOS client retrying a 422 in a tight loop could write
thousands of dashboard.widgets.validation-failed audit rows per minute.
Gate the audit-ledger write behind an in-process (userId, action) memo
with a 60 s window so the breadcrumb stays useful for grep traffic
without flooding the ledger. The 422 response itself is unchanged --
every retry still receives the full multi-issue envelope. Cluster
deployments cap at one row per process per minute; a process restart
resets the map (one extra row worst case).
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 21:46:12 +02:00
1 parent 825284c724
commit f9cf396f2e
2 files changed
+79 -13

No files matched your search

@@ -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<unknown> };
};
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"),
+57 -12
View File
@@ -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<string, number>();
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);
}