diff --git a/src/app/api/measurements/batch/__tests__/duplicate-prefilter.test.ts b/src/app/api/measurements/batch/__tests__/duplicate-prefilter.test.ts index 63d2c32bf..09ed7da58 100644 --- a/src/app/api/measurements/batch/__tests__/duplicate-prefilter.test.ts +++ b/src/app/api/measurements/batch/__tests__/duplicate-prefilter.test.ts @@ -493,13 +493,14 @@ describe("a batch of duplicates only (v1.42)", () => { await post([weight("uuid-new", new Date(NOW - DAY))]); expect(enqueuePrDetection).toHaveBeenCalledTimes(1); expect(enqueueReminderSatisfy).toHaveBeenCalledTimes(1); + // The enqueue is telemetry on the wide event, never an audit row. expect( vi .mocked(auditLog) .mock.calls.some( ([action]) => action === "personal_records.detection_enqueued", ), - ).toBe(true); + ).toBe(false); }); }); diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 876d7a75a..bbc465868 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -1302,13 +1302,9 @@ async function postBatch(request: NextRequest): Promise { }); try { await enqueuePrDetection(user.id, { silent }); - await auditLog("personal_records.detection_enqueued", { - userId: user.id, - details: { - source: "measurement.batch", - batchSize: entries.length, - silent, - }, + // Telemetry, not an audit event: it rides the wide event. + annotate({ + meta: { pr_detection_enqueued: true, pr_detection_silent: silent }, }); } catch (err) { // Enqueue failure is non-fatal — the 30-minute fallback cron diff --git a/src/app/api/workouts/batch/route.ts b/src/app/api/workouts/batch/route.ts index 6d3e30d85..7e82deb9b 100644 --- a/src/app/api/workouts/batch/route.ts +++ b/src/app/api/workouts/batch/route.ts @@ -893,13 +893,9 @@ async function postBatch(request: NextRequest): Promise { const silent = workouts.length > PR_DETECTION_SILENT_THRESHOLD; try { await enqueuePrDetection(user.id, { silent }); - await auditLog("personal_records.detection_enqueued", { - userId: user.id, - details: { - source: "workout.batch", - batchSize: workouts.length, - silent, - }, + // Telemetry, not an audit event: it rides the wide event. + annotate({ + meta: { pr_detection_enqueued: true, pr_detection_silent: silent }, }); } catch (err) { annotate({ diff --git a/src/lib/auth/__tests__/audit.test.ts b/src/lib/auth/__tests__/audit.test.ts index ae4a8b4fe..7372cfce6 100644 --- a/src/lib/auth/__tests__/audit.test.ts +++ b/src/lib/auth/__tests__/audit.test.ts @@ -320,3 +320,51 @@ describe("auditLog", () => { }); }); }); + +describe("auditLog — refused attempts", () => { + it("keeps a refusal on the caller's own record out of audit_logs and on the wide event", async () => { + const { WideEventBuilder } = await import("@/lib/logging/event-builder"); + const { eventStorage } = await import("@/lib/logging/context"); + const evt = new WideEventBuilder("http"); + evt.setAuth({ user_id: "owner-1", auth_method: "session" }); + + await eventStorage.run(evt, () => + auditLog("measurements.list.validation-failed", { + userId: "owner-1", + details: { issues: [] }, + }), + ); + + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + expect( + (evt.toJSON() as { meta?: Record }).meta + ?.rejected_attempt, + ).toBe("measurements.list.validation-failed"); + }); + + it("writes a delegate's refusal under the record it targeted, for the owner's feed", async () => { + const { WideEventBuilder } = await import("@/lib/logging/event-builder"); + const { eventStorage } = await import("@/lib/logging/context"); + const evt = new WideEventBuilder("http"); + evt.setAuth({ user_id: "delegate-1", auth_method: "session" }); + evt.setActingAs("owner-1"); + + await eventStorage.run(evt, () => + auditLog("measurements.create.validation-failed", { userId: "owner-1" }), + ); + + expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); + expect( + vi.mocked(prisma.auditLog.create).mock.calls[0][0].data, + ).toMatchObject({ + action: "measurements.create.validation-failed", + userId: "owner-1", + actorUserId: "delegate-1", + }); + }); + + it("still writes every other action on the caller's own record", async () => { + await auditLog("measurement.create", { userId: "owner-1" }); + expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/auth/audit.ts b/src/lib/auth/audit.ts index 0a294a3bb..23378f1c0 100644 --- a/src/lib/auth/audit.ts +++ b/src/lib/auth/audit.ts @@ -1,7 +1,7 @@ import { settleWithin } from "@/lib/async/settle-within"; import { prisma } from "@/lib/db"; import { lookupIpGeo } from "@/lib/geo"; -import { getEvent } from "@/lib/logging/context"; +import { annotate, getEvent } from "@/lib/logging/context"; /** * v1.36.0 — the action a delegated request records against the record it read. @@ -46,6 +46,26 @@ function actingActorFor(filedUnder: string | null): string | null { return filedUnder === owner ? actor : null; } +/** + * A request that was refused before it did anything: `.validation-failed`. + * + * Every route that parses a body or a query files one of these when the parse + * fails, through `auditLog`, and until v1.43.7 every one of them became an + * `audit_logs` row, on the owner's own requests too. A person's app sending a + * malformed query is not a security event, and nothing reads those rows: the + * activity feed shows refusals only where somebody else acted, and the wide + * event already carries the refusal. They were telemetry in the audit table, + * a few per sync on a busy client, growing the one table a self-hoster keeps. + * + * So a refused attempt is written only where it means something to someone + * other than the caller: a delegated request, filed under the record it + * targeted, which is what the owner's activity feed reads. Everywhere else it + * stays on the wide event (`meta.rejected_attempt`). + */ +export function isRejectedAttemptAction(action: string): boolean { + return action.endsWith(".validation-failed"); +} + export async function auditLog( action: string, opts: { @@ -60,14 +80,17 @@ export async function auditLog( ) { const userId = opts.userId ?? null; const client = opts.client ?? prisma; + const actorUserId = + opts.actorUserId === undefined ? actingActorFor(userId) : opts.actorUserId; + if (isRejectedAttemptAction(action) && actorUserId === null) { + annotate({ meta: { rejected_attempt: action } }); + return; + } const entry = await client.auditLog.create({ data: { action, userId, - actorUserId: - opts.actorUserId === undefined - ? actingActorFor(userId) - : opts.actorUserId, + actorUserId, details: opts.details ? JSON.stringify(opts.details) : null, ipAddress: opts.ipAddress ?? null, }, diff --git a/tests/integration/dashboard-widgets-save.test.ts b/tests/integration/dashboard-widgets-save.test.ts index 4f6354a41..8239afb40 100644 --- a/tests/integration/dashboard-widgets-save.test.ts +++ b/tests/integration/dashboard-widgets-save.test.ts @@ -377,8 +377,9 @@ describe("PUT /api/dashboard/widgets — Save persists full layouts", () => { // v1.4.42 W2 — the route used to return only `issues[0].message`, // which forced one round-trip per wrong field during iOS contract // debugging. The route now returns every issue under `details.issues` - // AND writes a `dashboard.widgets.validation-failed` row to the - // append-only audit ledger so the operator can grep `/api/admin/audit`. + // and keeps the refusal on the wide event (`meta.rejected_attempt`); + // since v1.43.7 a refusal on the caller's own record no longer writes an + // audit row. describe("PUT 422 — multi-issue envelope (v1.4.42 W2)", () => { it("surfaces TWO simultaneous validation errors", async () => { const { userId } = await seedUser(); @@ -406,25 +407,15 @@ describe("PUT /api/dashboard/widgets — Save persists full layouts", () => { const paths = body.details.issues.map((i) => i.path).sort(); expect(paths).toEqual(["version", "widgets"]); - // Audit-ledger breadcrumb. The route emits the row fire-and-forget, - // so poll until it materialises instead of asserting after one fixed - // tick — under container load the detached insert can land well past - // 25 ms, which made this assertion flaky. Bounded at ~2 s. - const deadline = Date.now() + 2_000; - let audit: { details: string | null } | null = null; - while (audit === null && Date.now() < deadline) { - audit = await getPrismaClient().auditLog.findFirst({ - where: { userId, action: "dashboard.widgets.validation-failed" }, - }); - if (audit === null) { - await new Promise((r) => setTimeout(r, 50)); - } - } - expect(audit).not.toBeNull(); - const details = JSON.parse(audit?.details ?? "{}") as { - issues: Array<{ path: string }>; - }; - expect(details.issues.length).toBe(2); + // A refusal on the caller's own record is telemetry, not an audit + // event: it stays on the wide event and never reaches the ledger + // (only a delegate's refusal is filed, for the owner's feed). Give a + // detached insert time to land before asserting it did not. + await new Promise((r) => setTimeout(r, 300)); + const audit = await getPrismaClient().auditLog.findFirst({ + where: { userId, action: "dashboard.widgets.validation-failed" }, + }); + expect(audit).toBeNull(); }); it("surfaces THREE simultaneous validation errors", async () => {