From 6a173a89ddb7fa54f15f6697b0f135a45de6b727 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 17:21:30 +0200 Subject: [PATCH 1/4] Enforce one active consent receipt per kind atomically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two concurrent web-grant heals could each pass the read-then-create gap and insert a duplicate active ai_full receipt; the revoke path then had to chase one row at a time. Wrap the web-grant mint in a Serializable transaction with an in-transaction re-check, and add migration 0159 — a partial unique index over (user_id, kind) WHERE revoked_at IS NULL — as the structural backstop. createReceipt now supersedes any active row of the same kind before appending, and revokeLatest flips revoked_at via a single atomic updateMany, so the append-only audit chain is preserved while at most one active row ever exists. --- .../migration.sql | 16 +++ prisma/schema.prisma | 8 ++ src/lib/consent/__tests__/receipts.test.ts | 77 +++++++++--- src/lib/consent/__tests__/web-grant.test.ts | 118 ++++++++++++++++-- src/lib/consent/receipts.ts | 72 +++++++---- src/lib/consent/web-grant.ts | 38 +++++- 6 files changed, 275 insertions(+), 54 deletions(-) create mode 100644 prisma/migrations/0159_v11616_consent_active_unique/migration.sql diff --git a/prisma/migrations/0159_v11616_consent_active_unique/migration.sql b/prisma/migrations/0159_v11616_consent_active_unique/migration.sql new file mode 100644 index 000000000..d17081d82 --- /dev/null +++ b/prisma/migrations/0159_v11616_consent_active_unique/migration.sql @@ -0,0 +1,16 @@ +-- v1.16.16 — at most one active consent receipt per (user, kind). +-- +-- The web-grant heal mint reads `latestActiveReceipt` then `createReceipt` +-- non-atomically. Two concurrent web mounts could each see "no active +-- receipt" and both insert an active `ai_full` row, leaving duplicate +-- active grants the revoke path then has to chase one at a time. +-- +-- The application wraps the mint in a transaction with a re-check, but the +-- structural guarantee lives here: a partial unique index over +-- (user_id, kind) restricted to non-revoked rows. Revoked rows carry a +-- non-NULL `revoked_at` and fall outside the predicate, so the append-only +-- grant/revoke history is unaffected — only two *active* rows for the same +-- (user, kind) collide. +CREATE UNIQUE INDEX IF NOT EXISTS "consent_receipts_user_id_kind_active_key" + ON "consent_receipts" ("user_id", "kind") + WHERE "revoked_at" IS NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6a09c701b..f88f0e403 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3354,6 +3354,14 @@ model ConsentReceipt { user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId, createdAt(sort: Desc)]) + // `consent_receipts_user_id_kind_active_key` is a partial UNIQUE index + // over (user_id, kind) `WHERE revoked_at IS NULL` — see migration 0159. + // It enforces at most one *active* receipt per (user, kind) so two + // concurrent web-grant heals can't both mint an active row. Prisma can't + // express a partial-predicate unique in the schema language, so it's + // introduced via raw SQL and intentionally omitted here. The append-only + // history (revoked rows) stays unconstrained because revoked rows carry a + // non-NULL `revoked_at` and fall outside the predicate. @@map("consent_receipts") } diff --git a/src/lib/consent/__tests__/receipts.test.ts b/src/lib/consent/__tests__/receipts.test.ts index 85cee22a8..9de301f90 100644 --- a/src/lib/consent/__tests__/receipts.test.ts +++ b/src/lib/consent/__tests__/receipts.test.ts @@ -1,15 +1,25 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -vi.mock("@/lib/db", () => ({ - prisma: { - consentReceipt: { - create: vi.fn(), - findFirst: vi.fn(), - findMany: vi.fn(), - update: vi.fn(), +// `createReceipt` + `revokeLatest` run their supersede/revoke logic inside +// a transaction. Run the callback against the same mock proxy. The Prisma +// `$transaction` signature is generic; the mock takes a loose callback. +type TxFn = (tx: unknown) => unknown; + +vi.mock("@/lib/db", () => { + const consentReceipt = { + create: vi.fn(), + findFirst: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }; + return { + prisma: { + consentReceipt, + $transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })), }, - }, -})); + }; +}); import { prisma } from "@/lib/db"; import { @@ -19,8 +29,15 @@ import { revokeLatest, } from "../receipts"; +const $transaction = vi.mocked(prisma.$transaction) as unknown as { + mockImplementation: (impl: (fn: TxFn) => unknown) => void; +}; + beforeEach(() => { vi.resetAllMocks(); + $transaction.mockImplementation((fn: TxFn) => + fn({ consentReceipt: prisma.consentReceipt }), + ); }); function row(overrides: Partial> = {}) { @@ -37,12 +54,21 @@ function row(overrides: Partial> = {}) { } describe("createReceipt", () => { - it("inserts a row scoped to (userId, kind) with the artefact + signedAt", async () => { + it("supersedes any active row then inserts scoped to (userId, kind)", async () => { + vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({ + count: 0, + } as never); vi.mocked(prisma.consentReceipt.create).mockResolvedValue(row() as never); const signedAt = new Date("2026-05-18T10:00:00.000Z"); const result = await createReceipt("user-1", "ai_full", "PDF…", signedAt); + // Revokes any currently-active receipt of the same kind first, so the + // fresh grant doesn't collide with the partial unique index. + expect(prisma.consentReceipt.updateMany).toHaveBeenCalledWith({ + where: { userId: "user-1", kind: "ai_full", revokedAt: null }, + data: { revokedAt: signedAt }, + }); expect(prisma.consentReceipt.create).toHaveBeenCalledWith({ data: { userId: "user-1", @@ -52,6 +78,8 @@ describe("createReceipt", () => { }, }); expect(result.id).toBe("rcpt-1"); + // The whole mint runs in a transaction. + expect(prisma.$transaction).toHaveBeenCalledTimes(1); }); }); @@ -96,28 +124,37 @@ describe("latestActiveReceiptsByKind", () => { }); describe("revokeLatest", () => { - it("flips revokedAt on the latest active row and returns the updated row", async () => { - vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(row() as never); - vi.mocked(prisma.consentReceipt.update).mockResolvedValue( - row({ revokedAt: new Date("2026-05-18T11:00:00.000Z") }) as never, + it("atomically revokes the active row via updateMany and returns it", async () => { + const now = new Date("2026-05-18T11:00:00.000Z"); + vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({ + count: 1, + } as never); + vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue( + row({ revokedAt: now }) as never, ); - const now = new Date("2026-05-18T11:00:00.000Z"); const result = await revokeLatest("user-1", "ai_full", now); - expect(prisma.consentReceipt.update).toHaveBeenCalledWith({ - where: { id: "rcpt-1" }, + expect(prisma.consentReceipt.updateMany).toHaveBeenCalledWith({ + where: { userId: "user-1", kind: "ai_full", revokedAt: null }, data: { revokedAt: now }, }); + // Re-reads the just-revoked row so the audit log keeps the receipt id. + expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith({ + where: { userId: "user-1", kind: "ai_full", revokedAt: now }, + orderBy: { createdAt: "desc" }, + }); expect(result?.revokedAt).toEqual(now); }); - it("returns null without writing when no active receipt exists", async () => { - vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null); + it("returns null without re-reading when no active receipt exists", async () => { + vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({ + count: 0, + } as never); const result = await revokeLatest("user-1", "ai_full"); expect(result).toBeNull(); - expect(prisma.consentReceipt.update).not.toHaveBeenCalled(); + expect(prisma.consentReceipt.findFirst).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/consent/__tests__/web-grant.test.ts b/src/lib/consent/__tests__/web-grant.test.ts index e20b04be4..a5fc8037e 100644 --- a/src/lib/consent/__tests__/web-grant.test.ts +++ b/src/lib/consent/__tests__/web-grant.test.ts @@ -1,19 +1,40 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -vi.mock("@/lib/db", () => ({ - prisma: { - consentReceipt: { - create: vi.fn(), - findFirst: vi.fn(), +// The web-grant mint runs its re-check + insert inside a transaction. The +// mock runs the callback against the same `prisma` proxy so the in-tx +// reads/writes share the test's mock surface. The Prisma `$transaction` +// signature is generic; the mock takes a loose callback and casts. +type TxFn = (tx: unknown) => unknown; + +vi.mock("@/lib/db", () => { + const consentReceipt = { + create: vi.fn(), + findFirst: vi.fn(), + updateMany: vi.fn(), + }; + return { + prisma: { + consentReceipt, + $transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })), }, - }, -})); + }; +}); import { prisma } from "@/lib/db"; import { ensureWebAiConsentReceipt } from "../web-grant"; +const $transaction = vi.mocked(prisma.$transaction) as unknown as { + mockImplementation: (impl: (fn: TxFn) => unknown) => void; + mockReset: () => void; +}; + beforeEach(() => { vi.resetAllMocks(); + // `resetAllMocks` clears the `$transaction` pass-through implementation; + // re-arm it so the mint callback still runs against the mock surface. + $transaction.mockImplementation((fn: TxFn) => + fn({ consentReceipt: prisma.consentReceipt }), + ); }); function row(overrides: Partial> = {}) { @@ -38,11 +59,14 @@ describe("ensureWebAiConsentReceipt", () => { const result = await ensureWebAiConsentReceipt("user-1", now); expect(result.minted).toBe(true); - // Reads the active master grant first. + // Reads the active master grant first (fast-path + in-tx re-check both + // hit the same predicate). expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith({ where: { userId: "user-1", kind: "ai_full", revokedAt: null }, orderBy: { createdAt: "desc" }, }); + // The mint is wrapped in a transaction (TOCTOU close). + expect(prisma.$transaction).toHaveBeenCalledTimes(1); // Mints ai_full with a web-source artefact + the supplied timestamp. const createArg = vi.mocked(prisma.consentReceipt.create).mock.calls[0][0]; expect(createArg.data.userId).toBe("user-1"); @@ -60,5 +84,83 @@ describe("ensureWebAiConsentReceipt", () => { expect(result.minted).toBe(false); expect(prisma.consentReceipt.create).not.toHaveBeenCalled(); + // No transaction is opened on the already-granted fast path. + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it("re-checks inside the transaction: a row that appears between the fast-path read and the mint short-circuits the insert", async () => { + // Fast-path read sees nothing; the in-tx re-check sees a row a + // concurrent grant inserted. The mint must no-op rather than insert a + // second active row. + vi.mocked(prisma.consentReceipt.findFirst) + .mockResolvedValueOnce(null) // fast path + .mockResolvedValueOnce(row() as never); // in-tx re-check + + const result = await ensureWebAiConsentReceipt("user-1"); + + expect(result.minted).toBe(false); + expect(prisma.consentReceipt.create).not.toHaveBeenCalled(); + }); + + it("maps the partial-unique violation (P2002) to a no-op when two grants race to the insert", async () => { + // Both reads miss (the re-check loses the race too); the partial unique + // index rejects the second insert. The structural backstop must surface + // as success-shaped — the user ends with exactly one active receipt. + vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null); + vi.mocked(prisma.consentReceipt.create).mockRejectedValue({ + code: "P2002", + } as never); + + const result = await ensureWebAiConsentReceipt("user-1"); + + expect(result.minted).toBe(false); + }); + + it("rethrows non-P2002 errors", async () => { + vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null); + vi.mocked(prisma.consentReceipt.create).mockRejectedValue( + new Error("connection reset") as never, + ); + + await expect(ensureWebAiConsentReceipt("user-1")).rejects.toThrow( + "connection reset", + ); + }); +}); + +describe("ensureWebAiConsentReceipt — two concurrent grants", () => { + it("yields exactly one active receipt across parallel mounts", async () => { + // Model the race at the DB layer: a single shared "active row" slot. + // Both callers pass the fast-path read (slot empty). The transaction + // serialises their re-checks: the first sees the empty slot and + // inserts; the second sees the now-filled slot and no-ops. + let activeRow: ReturnType | null = null; + + vi.mocked(prisma.consentReceipt.findFirst).mockImplementation((async () => + activeRow) as never); + vi.mocked(prisma.consentReceipt.create).mockImplementation((async () => { + activeRow = row(); + return activeRow; + }) as never); + // Serialise the two transactions so the second observes the first's + // committed insert (Serializable isolation in production). + let chain: Promise = Promise.resolve(); + $transaction.mockImplementation((fn: TxFn) => { + const run = chain.then(() => + fn({ consentReceipt: prisma.consentReceipt }), + ); + chain = run.catch(() => {}); + return run; + }); + + const [a, b] = await Promise.all([ + ensureWebAiConsentReceipt("user-1"), + ensureWebAiConsentReceipt("user-1"), + ]); + + // Exactly one mint, exactly one row inserted. + const mintedCount = [a, b].filter((r) => r.minted).length; + expect(mintedCount).toBe(1); + expect(prisma.consentReceipt.create).toHaveBeenCalledTimes(1); }); }); diff --git a/src/lib/consent/receipts.ts b/src/lib/consent/receipts.ts index 4883f4700..a6aedf229 100644 --- a/src/lib/consent/receipts.ts +++ b/src/lib/consent/receipts.ts @@ -19,9 +19,17 @@ import type { ConsentKind } from "@/lib/validations/consent"; export type ConsentReceipt = ConsentReceiptModel; /** - * Insert a fresh consent receipt for (userId, kind). Always appends; - * never collides with an earlier row. The caller has already - * Zod-validated `artefact` (≤ 64 KB) and `signedAt` (ISO-8601). + * Insert a fresh consent receipt for (userId, kind). The caller has + * already Zod-validated `artefact` (≤ 64 KB) and `signedAt` (ISO-8601). + * + * v1.16.16 — the partial unique index `consent_receipts_user_id_kind_ + * active_key` (`WHERE revoked_at IS NULL`, migration 0159) caps the table + * at one *active* receipt per (user, kind). A plain re-grant of an already + * active kind would otherwise collide, so the mint runs in a transaction + * that first revokes any active row of the same kind, then appends the new + * one. The append-only audit chain is preserved — the superseded row keeps + * its history with a `revoked_at` marker — and the constraint is satisfied + * because at most one active row ever exists. */ export async function createReceipt( userId: string, @@ -29,13 +37,22 @@ export async function createReceipt( artefact: string, signedAt: Date, ): Promise { - return prisma.consentReceipt.create({ - data: { - userId, - kind, - artefact, - signedAt, - }, + return prisma.$transaction(async (tx) => { + // Supersede any currently-active receipt of this kind so the fresh + // grant doesn't collide with the partial unique index. `updateMany` + // over the active predicate is a no-op when none is active. + await tx.consentReceipt.updateMany({ + where: { userId, kind, revokedAt: null }, + data: { revokedAt: signedAt }, + }); + return tx.consentReceipt.create({ + data: { + userId, + kind, + artefact, + signedAt, + }, + }); }); } @@ -81,25 +98,34 @@ export async function latestActiveReceiptsByKind( } /** - * Mark the latest active receipt for (userId, kind) as revoked. - * Returns the updated row, or `null` if no active receipt exists. + * Mark the active receipt for (userId, kind) as revoked. Returns the + * updated row, or `null` if no active receipt exists. * - * Implementation note: a two-step "read latest → update by id" is - * race-tolerant for the single-user revoke flow (iOS toggle is a - * per-device action; no concurrent revoke from a second device is - * plausible inside the same millisecond). If two devices race we end - * up writing `revokedAt` to the same row twice with identical - * timestamps — semantically idempotent. + * v1.16.16 — the partial unique index guarantees at most one active row + * per (user, kind), so a single atomic `updateMany` over the active + * predicate revokes "the" active receipt without a separate read. Two + * concurrent revokes converge: the first flips `revoked_at`, the second's + * predicate no longer matches and writes nothing. The follow-up read + * returns the row the caller (audit log) needs; it is `null` only when no + * active receipt existed at all. */ export async function revokeLatest( userId: string, kind: ConsentKind, now: Date = new Date(), ): Promise { - const latest = await latestActiveReceipt(userId, kind); - if (!latest) return null; - return prisma.consentReceipt.update({ - where: { id: latest.id }, - data: { revokedAt: now }, + return prisma.$transaction(async (tx) => { + const updated = await tx.consentReceipt.updateMany({ + where: { userId, kind, revokedAt: null }, + data: { revokedAt: now }, + }); + if (updated.count === 0) return null; + // Re-read the just-revoked row (revoked at exactly `now`) so the audit + // trail keeps the receipt id. The active predicate guaranteed a single + // row, so this resolves it unambiguously. + return tx.consentReceipt.findFirst({ + where: { userId, kind, revokedAt: now }, + orderBy: { createdAt: "desc" }, + }); }); } diff --git a/src/lib/consent/web-grant.ts b/src/lib/consent/web-grant.ts index 084b10055..b61cb9f66 100644 --- a/src/lib/consent/web-grant.ts +++ b/src/lib/consent/web-grant.ts @@ -21,7 +21,10 @@ * timestamp + source, which is the GDPR Art. 7 audit signal the legal team * needs). It stays well under the 64 KB artefact cap. */ -import { createReceipt, latestActiveReceipt } from "@/lib/consent/receipts"; +import { prisma } from "@/lib/db"; +import { Prisma } from "@/generated/prisma/client"; +import { isP2002 } from "@/lib/prisma-errors"; +import { latestActiveReceipt } from "@/lib/consent/receipts"; import type { ConsentReceipt } from "@/lib/consent/receipts"; /** Outcome of an idempotent web-grant call. */ @@ -42,6 +45,9 @@ export async function ensureWebAiConsentReceipt( userId: string, now: Date = new Date(), ): Promise { + // Fast path: a cheap read outside the transaction short-circuits the + // common already-granted case (every mount after the first) without + // opening a transaction. const existing = await latestActiveReceipt(userId, "ai_full"); if (existing) return { minted: false }; @@ -52,6 +58,32 @@ export async function ensureWebAiConsentReceipt( note: "In-app affirmative AI consent granted via the web client.", }); - const receipt = await createReceipt(userId, "ai_full", artefact, now); - return { minted: true, receipt }; + // Two web mounts can pass the fast-path read concurrently and both reach + // the mint. The transaction re-checks under a Serializable isolation so a + // second mint sees the first's row and no-ops; the partial unique index + // (migration 0159, `WHERE revoked_at IS NULL`) is the structural backstop + // if the re-check still races. A unique violation from the backstop means + // a concurrent grant already won — that is success-shaped (the user ends + // up with exactly one active receipt), so we map it to `{ minted: false }`. + try { + return await prisma.$transaction( + async (tx) => { + const inTx = await tx.consentReceipt.findFirst({ + where: { userId, kind: "ai_full", revokedAt: null }, + orderBy: { createdAt: "desc" }, + }); + if (inTx) return { minted: false } as const; + + const receipt = await tx.consentReceipt.create({ + data: { userId, kind: "ai_full", artefact, signedAt: now }, + }); + return { minted: true, receipt } as const; + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ); + } catch (err) { + // Partial-unique violation — a concurrent grant landed first. + if (isP2002(err)) return { minted: false }; + throw err; + } } From cc34cbd03d71ce0b03360138ea2d1f7dda374183 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 17:21:38 +0200 Subject: [PATCH 2/4] Rate-limit the AI-consent endpoints per user POST /api/consent/ai, POST /api/consent/ai/web, and GET/DELETE /api/consent/ai/latest had no rate limit, so a scripted loop or a stolen session could pin the receipts table. Add a shared per-user bucket (20/min, keyed by user id) mirroring the coach-chat ceiling, checked right after requireAuth, returning 429 with the X-RateLimit headers attached. The web heal endpoint runs on every AI-settings mount, so the bucket is generous enough to never throttle normal use. --- .../api/consent/ai/__tests__/route.test.ts | 67 +++++++- .../consent/ai/latest/__tests__/route.test.ts | 159 +++++++++++------- src/app/api/consent/ai/latest/route.ts | 24 +++ src/app/api/consent/ai/route.ts | 22 ++- .../consent/ai/web/__tests__/route.test.ts | 102 +++++++++++ src/app/api/consent/ai/web/route.ts | 17 +- src/lib/rate-limit.ts | 19 +++ 7 files changed, 343 insertions(+), 67 deletions(-) create mode 100644 src/app/api/consent/ai/web/__tests__/route.test.ts diff --git a/src/app/api/consent/ai/__tests__/route.test.ts b/src/app/api/consent/ai/__tests__/route.test.ts index dfe3c312e..8c24521b2 100644 --- a/src/app/api/consent/ai/__tests__/route.test.ts +++ b/src/app/api/consent/ai/__tests__/route.test.ts @@ -1,15 +1,31 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; -vi.mock("@/lib/db", () => ({ - prisma: { - consentReceipt: { - create: vi.fn(), - findFirst: vi.fn(), - findMany: vi.fn(), - update: vi.fn(), +// v1.16.16 — `createReceipt` runs its supersede + insert inside a +// transaction; run the callback against the same mock proxy. +type TxFn = (tx: unknown) => unknown; + +vi.mock("@/lib/db", () => { + const consentReceipt = { + create: vi.fn(), + findFirst: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }; + return { + prisma: { + consentReceipt, + $transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })), }, - }, + }; +}); + +// v1.16.16 — rate limit is exercised by its own suite; here we default to +// "allowed" and flip a single test to assert the 429 wiring. +vi.mock("@/lib/rate-limit", () => ({ + checkConsentRateLimit: vi.fn(), + rateLimitHeaders: vi.fn(() => ({})), })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); @@ -38,6 +54,13 @@ import { POST } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { auditLog } from "@/lib/auth/audit"; +import { checkConsentRateLimit } from "@/lib/rate-limit"; + +const RL_OK = { allowed: true, remaining: 19, resetAt: Date.now() + 60_000 }; + +const $transaction = vi.mocked(prisma.$transaction) as unknown as { + mockImplementation: (impl: (fn: TxFn) => unknown) => void; +}; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -59,6 +82,15 @@ beforeEach(() => { // Re-arm it here so the fire-and-forget `.catch()` in the route // doesn't crash on an undefined return. vi.mocked(auditLog).mockResolvedValue(undefined); + // Re-arm the rate-limit + transaction pass-throughs cleared by + // `resetAllMocks`. + vi.mocked(checkConsentRateLimit).mockResolvedValue(RL_OK); + $transaction.mockImplementation((fn: TxFn) => + fn({ consentReceipt: prisma.consentReceipt }), + ); + vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({ + count: 0, + } as never); }); describe("POST /api/consent/ai", () => { @@ -74,6 +106,25 @@ describe("POST /api/consent/ai", () => { expect(res.status).toBe(401); }); + it("returns 429 when the per-user consent bucket is exhausted", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkConsentRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + }); + const res = await POST( + req({ + kind: "ai_full", + artefact: "PDF", + signedAt: "2026-05-18T10:00:00.000Z", + }), + ); + expect(res.status).toBe(429); + // The throttle fires before any write touches the receipts table. + expect(prisma.consentReceipt.create).not.toHaveBeenCalled(); + }); + it("returns 400 for an invalid kind", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); const res = await POST( diff --git a/src/app/api/consent/ai/latest/__tests__/route.test.ts b/src/app/api/consent/ai/latest/__tests__/route.test.ts index 67bea48bf..4431592ad 100644 --- a/src/app/api/consent/ai/latest/__tests__/route.test.ts +++ b/src/app/api/consent/ai/latest/__tests__/route.test.ts @@ -1,15 +1,29 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; -vi.mock("@/lib/db", () => ({ - prisma: { - consentReceipt: { - create: vi.fn(), - findFirst: vi.fn(), - findMany: vi.fn(), - update: vi.fn(), +// v1.16.16 — `createReceipt` + `revokeLatest` run inside a transaction; the +// mock runs the callback against the same proxy. +type TxFn = (tx: unknown) => unknown; + +vi.mock("@/lib/db", () => { + const consentReceipt = { + create: vi.fn(), + findFirst: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }; + return { + prisma: { + consentReceipt, + $transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })), }, - }, + }; +}); + +vi.mock("@/lib/rate-limit", () => ({ + checkConsentRateLimit: vi.fn(), + rateLimitHeaders: vi.fn(() => ({})), })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); @@ -38,6 +52,13 @@ import { POST as POST_GRANT } from "../../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { auditLog } from "@/lib/auth/audit"; +import { checkConsentRateLimit } from "@/lib/rate-limit"; + +const RL_OK = { allowed: true, remaining: 19, resetAt: Date.now() + 60_000 }; + +const $transaction = vi.mocked(prisma.$transaction) as unknown as { + mockImplementation: (impl: (fn: TxFn) => unknown) => void; +}; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -50,6 +71,11 @@ beforeEach(() => { // on `auditLog`. Re-arm so the route's fire-and-forget `.catch()` // chain doesn't NPE on an undefined return. vi.mocked(auditLog).mockResolvedValue(undefined); + // Re-arm rate-limit + transaction pass-throughs cleared by reset. + vi.mocked(checkConsentRateLimit).mockResolvedValue(RL_OK); + $transaction.mockImplementation((fn: TxFn) => + fn({ consentReceipt: prisma.consentReceipt }), + ); }); describe("GET /api/consent/ai/latest", () => { @@ -69,6 +95,20 @@ describe("GET /api/consent/ai/latest", () => { expect(res.status).toBe(400); }); + it("returns 429 when the per-user consent bucket is exhausted", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkConsentRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + }); + const res = await GET( + new NextRequest("http://localhost/api/consent/ai/latest?kind=ai_full"), + ); + expect(res.status).toBe(429); + expect(prisma.consentReceipt.findFirst).not.toHaveBeenCalled(); + }); + it("returns null receipt when nothing has been granted", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null); @@ -153,21 +193,33 @@ describe("DELETE /api/consent/ai/latest", () => { expect(res.status).toBe(401); }); + it("returns 429 when the per-user consent bucket is exhausted", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkConsentRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + }); + const res = await DELETE( + new NextRequest("http://localhost/api/consent/ai/latest?kind=ai_full", { + method: "DELETE", + }), + ); + expect(res.status).toBe(429); + expect(prisma.consentReceipt.updateMany).not.toHaveBeenCalled(); + }); + it("marks the latest receipt as revoked and returns it", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); const signedAt = new Date("2026-05-18T10:00:00.000Z"); const createdAt = new Date("2026-05-18T10:00:01.000Z"); const revokedAt = new Date("2026-05-18T11:00:00.000Z"); - vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue({ - id: "rcpt-1", - userId: "user-1", - kind: "ai_full", - artefact: "PDF", - signedAt, - revokedAt: null, - createdAt, + // v1.16.16 — `revokeLatest` atomically flips `revoked_at` via + // `updateMany` (count) then re-reads the revoked row for the audit id. + vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({ + count: 1, } as never); - vi.mocked(prisma.consentReceipt.update).mockResolvedValue({ + vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue({ id: "rcpt-1", userId: "user-1", kind: "ai_full", @@ -188,15 +240,17 @@ describe("DELETE /api/consent/ai/latest", () => { }; expect(body.data.receipt?.id).toBe("rcpt-1"); expect(body.data.receipt?.revokedAt).toBe(revokedAt.toISOString()); - expect(prisma.consentReceipt.update).toHaveBeenCalledWith({ - where: { id: "rcpt-1" }, + expect(prisma.consentReceipt.updateMany).toHaveBeenCalledWith({ + where: { userId: "user-1", kind: "ai_full", revokedAt: null }, data: { revokedAt: expect.any(Date) }, }); }); - it("is idempotent — returns null receipt without writing when nothing to revoke", async () => { + it("is idempotent — returns null receipt without re-reading when nothing to revoke", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null); + vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({ + count: 0, + } as never); const res = await DELETE( new NextRequest("http://localhost/api/consent/ai/latest?kind=ai_full", { @@ -208,7 +262,7 @@ describe("DELETE /api/consent/ai/latest", () => { data: { kind: string; receipt: unknown | null }; }; expect(body.data.receipt).toBeNull(); - expect(prisma.consentReceipt.update).not.toHaveBeenCalled(); + expect(prisma.consentReceipt.findFirst).not.toHaveBeenCalled(); }); it("master toggle revokes the latest active row per kind", async () => { @@ -217,8 +271,14 @@ describe("DELETE /api/consent/ai/latest", () => { const createdAt = new Date("2026-05-18T10:00:01.000Z"); const revokedAt = new Date("2026-05-18T11:00:00.000Z"); - // First call (ai_full) finds a row, second (ai_insights_only) - // finds none, third (ai_coach) finds a row. + // v1.16.16 — `revokeLatest` runs per kind in `consentKindEnum.options` + // order: ai_full → ai_insights_only → ai_coach. The `updateMany` count + // gates the follow-up re-read. ai_full + ai_coach revoke a row; + // ai_insights_only has none (count 0, no re-read). + vi.mocked(prisma.consentReceipt.updateMany) + .mockResolvedValueOnce({ count: 1 } as never) // ai_full + .mockResolvedValueOnce({ count: 0 } as never) // ai_insights_only + .mockResolvedValueOnce({ count: 1 } as never); // ai_coach vi.mocked(prisma.consentReceipt.findFirst) .mockResolvedValueOnce({ id: "rcpt-full", @@ -226,33 +286,19 @@ describe("DELETE /api/consent/ai/latest", () => { kind: "ai_full", artefact: "PDF", signedAt, - revokedAt: null, + revokedAt, createdAt, } as never) - .mockResolvedValueOnce(null) .mockResolvedValueOnce({ id: "rcpt-coach", userId: "user-1", kind: "ai_coach", artefact: "PDF", signedAt, - revokedAt: null, + revokedAt, createdAt, } as never); - vi.mocked(prisma.consentReceipt.update).mockImplementation( - ((args: { where: { id: string }; data: { revokedAt: Date } }) => - Promise.resolve({ - id: args.where.id, - userId: "user-1", - kind: args.where.id === "rcpt-full" ? "ai_full" : "ai_coach", - artefact: "PDF", - signedAt, - revokedAt, - createdAt, - })) as never, - ); - const res = await DELETE( new NextRequest("http://localhost/api/consent/ai/latest", { method: "DELETE", @@ -287,7 +333,11 @@ describe("append-only invariant", () => { const signedAt2 = new Date("2026-05-18T12:00:00.000Z"); const createdAt2 = new Date("2026-05-18T12:00:01.000Z"); - // Step 1: initial grant. + // Step 1: initial grant. `createReceipt` supersedes any active row + // (none yet → count 0) then inserts. + vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({ + count: 0, + } as never); vi.mocked(prisma.consentReceipt.create).mockResolvedValueOnce({ id: "rcpt-1", userId: "user-1", @@ -311,17 +361,13 @@ describe("append-only invariant", () => { ); expect(grant1.status).toBe(200); - // Step 2: revoke. The DELETE handler reads + updates. - vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValueOnce({ - id: "rcpt-1", - userId: "user-1", - kind: "ai_full", - artefact: "PDF-v1", - signedAt: signedAt1, - revokedAt: null, - createdAt: createdAt1, + // Step 2: revoke. `revokeLatest` atomically flips `revoked_at` via + // `updateMany` (count 1) then re-reads the revoked row. The row is + // NEVER deleted — only marked revoked, so the audit chain survives. + vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValueOnce({ + count: 1, } as never); - vi.mocked(prisma.consentReceipt.update).mockResolvedValueOnce({ + vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValueOnce({ id: "rcpt-1", userId: "user-1", kind: "ai_full", @@ -337,12 +383,11 @@ describe("append-only invariant", () => { }), ); expect(revokeRes.status).toBe(200); - expect(prisma.consentReceipt.update).toHaveBeenCalledTimes(1); - // Critical invariant — `delete` was NEVER called on the receipt. - // Only an update setting `revokedAt`. The old row stays in place. - const updateCall = vi.mocked(prisma.consentReceipt.update).mock.calls[0][0]; - expect(updateCall).toMatchObject({ - where: { id: "rcpt-1" }, + // Critical invariant — `delete` was NEVER called on the receipt. The + // revoke is an in-place `updateMany` setting `revokedAt`. + expect(prisma.consentReceipt.delete).toBeUndefined(); + expect(prisma.consentReceipt.updateMany).toHaveBeenLastCalledWith({ + where: { userId: "user-1", kind: "ai_full", revokedAt: null }, data: { revokedAt: expect.any(Date) }, }); diff --git a/src/app/api/consent/ai/latest/route.ts b/src/app/api/consent/ai/latest/route.ts index bf864a9be..4a2f8a8cd 100644 --- a/src/app/api/consent/ai/latest/route.ts +++ b/src/app/api/consent/ai/latest/route.ts @@ -17,11 +17,13 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { + apiError, apiSuccess, returnAllZodIssues, } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; +import { checkConsentRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { consentKindEnum, consentLatestQuery, @@ -37,6 +39,17 @@ import { serialiseReceipt } from "../route"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + const rl = await checkConsentRateLimit(user.id); + if (!rl.allowed) { + annotate({ + action: { name: "consent.ai.rate-limited" }, + meta: { userId: user.id, resetAt: rl.resetAt }, + }); + return apiError("Too many consent requests, please wait a moment", 429, { + headers: rateLimitHeaders(rl), + }); + } + const parsed = consentLatestQuery.safeParse( Object.fromEntries(request.nextUrl.searchParams), ); @@ -86,6 +99,17 @@ export const GET = apiHandler(async (request: NextRequest) => { export const DELETE = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + const rl = await checkConsentRateLimit(user.id); + if (!rl.allowed) { + annotate({ + action: { name: "consent.ai.rate-limited" }, + meta: { userId: user.id, resetAt: rl.resetAt }, + }); + return apiError("Too many consent requests, please wait a moment", 429, { + headers: rateLimitHeaders(rl), + }); + } + const parsed = consentLatestQuery.safeParse( Object.fromEntries(request.nextUrl.searchParams), ); diff --git a/src/app/api/consent/ai/route.ts b/src/app/api/consent/ai/route.ts index 8aa2cbb26..8679be132 100644 --- a/src/app/api/consent/ai/route.ts +++ b/src/app/api/consent/ai/route.ts @@ -9,15 +9,35 @@ */ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; +import { checkConsentRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { consentPostBody } from "@/lib/validations/consent"; import { createReceipt } from "@/lib/consent/receipts"; export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + // Per-user ceiling in front of the receipts write. 20/min is well outside + // any legitimate grant cadence; it caps a scripted loop from filling the + // audit table. + const rl = await checkConsentRateLimit(user.id); + if (!rl.allowed) { + annotate({ + action: { name: "consent.ai.rate-limited" }, + meta: { userId: user.id, resetAt: rl.resetAt }, + }); + return apiError("Too many consent requests, please wait a moment", 429, { + headers: rateLimitHeaders(rl), + }); + } + const { data: body, error } = await safeJson(request, { maxBytes: 128 * 1024, }); diff --git a/src/app/api/consent/ai/web/__tests__/route.test.ts b/src/app/api/consent/ai/web/__tests__/route.test.ts new file mode 100644 index 000000000..08a3050b2 --- /dev/null +++ b/src/app/api/consent/ai/web/__tests__/route.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +// The mint logic + its TOCTOU close are covered by web-grant.test.ts; the +// route test stubs it to focus on auth + rate-limit + serialisation. +vi.mock("@/lib/consent/web-grant", () => ({ + ensureWebAiConsentReceipt: vi.fn(), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkConsentRateLimit: vi.fn(), + rateLimitHeaders: vi.fn(() => ({})), +})); + +import { POST } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; +import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant"; +import { checkConsentRateLimit } from "@/lib/rate-limit"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "testuser", role: "USER" as const }, +}; + +const RL_OK = { allowed: true, remaining: 19, resetAt: Date.now() + 60_000 }; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(auditLog).mockResolvedValue(undefined); + vi.mocked(checkConsentRateLimit).mockResolvedValue(RL_OK); +}); + +describe("POST /api/consent/ai/web", () => { + it("returns 401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await POST(); + expect(res.status).toBe(401); + expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled(); + }); + + it("returns 429 when the per-user consent bucket is exhausted", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkConsentRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + }); + const res = await POST(); + expect(res.status).toBe(429); + // The throttle fires before the heal mint runs. + expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled(); + }); + + it("mints a receipt and reports minted:true", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(ensureWebAiConsentReceipt).mockResolvedValue({ + minted: true, + receipt: { id: "rcpt-web-1" } as never, + }); + + const res = await POST(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { minted: boolean; kind: string }; + }; + expect(body.data.minted).toBe(true); + expect(body.data.kind).toBe("ai_full"); + }); + + it("is a no-op when an active receipt already exists (minted:false)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(ensureWebAiConsentReceipt).mockResolvedValue({ minted: false }); + + const res = await POST(); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { minted: boolean } }; + expect(body.data.minted).toBe(false); + // No audit row on the no-op path. + expect(auditLog).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/consent/ai/web/route.ts b/src/app/api/consent/ai/web/route.ts index cccf41864..1e1e14af3 100644 --- a/src/app/api/consent/ai/web/route.ts +++ b/src/app/api/consent/ai/web/route.ts @@ -14,14 +14,29 @@ * revoke) lives in `../route.ts` + `../latest/route.ts`. */ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess } from "@/lib/api-response"; +import { apiError, apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; +import { checkConsentRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant"; export const POST = apiHandler(async () => { const { user } = await requireAuth(); + // The web AI-settings surface calls this on every mount; the bucket is + // generous enough that the heal path is never throttled in normal use, + // tight enough to cap a scripted loop. + const rl = await checkConsentRateLimit(user.id); + if (!rl.allowed) { + annotate({ + action: { name: "consent.ai.rate-limited" }, + meta: { userId: user.id, resetAt: rl.resetAt }, + }); + return apiError("Too many consent requests, please wait a moment", 429, { + headers: rateLimitHeaders(rl), + }); + } + const result = await ensureWebAiConsentReceipt(user.id); if (result.minted) { diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 1429b7aed..57e970e8e 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -87,6 +87,25 @@ export async function checkAnalyticsReadRateLimit( ); } +/** + * v1.16.16 — per-user ceiling for the AI-consent surfaces + * (`POST /api/consent/ai`, `POST /api/consent/ai/web`, + * `GET/DELETE /api/consent/ai/latest`). The web client calls the heal + * endpoint on every AI-settings mount and the iOS toggle hits grant/revoke + * per flip, so the bucket is generous — 20/min only caps a scripted loop or + * a stolen session pinning the receipts table, mirroring the coach-chat + * ceiling. One shared bucket keyed by `userId` (not per-route) keeps the cap + * meaningful when a loop rotates across the four endpoints. + */ +const CONSENT_LIMIT = 20; +const CONSENT_WINDOW_MS = 60 * 1000; + +export async function checkConsentRateLimit( + userId: string, +): Promise { + return checkRateLimit(`consent:${userId}`, CONSENT_LIMIT, CONSENT_WINDOW_MS); +} + /** * v1.4.43 W13 M-4 — anonymous/trust-violation bucket key shared by every * caller of `checkAuthSurfaceRateLimit`. When `TRUST_PROXY_HOPS` and the From e2f37bbe4064ca397605f5edb2076d46b3a17340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 17:21:45 +0200 Subject: [PATCH 3/4] Extract shared BackLink and adopt on detail pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workout, values, score, and medication detail pages each hand-rolled the same ghost Button + Link + ArrowLeft back-nav block. Fold it into a single BackLink (href + label + optional data-slot) so the affordance reads identically — same hit area, icon, and left-bleed alignment — on every page and lives in one place. Each page passes its existing label translation, so no new i18n keys are introduced and the markup stays byte-identical. --- src/app/insights/scores/[metric]/page.tsx | 21 +++------ src/app/insights/values/[type]/page.tsx | 25 ++++------ src/app/insights/workouts/[id]/page.tsx | 21 +++------ src/app/medications/[id]/page.tsx | 12 ++--- .../ui/__tests__/back-link.test.tsx | 38 +++++++++++++++ src/components/ui/back-link.tsx | 47 +++++++++++++++++++ 6 files changed, 109 insertions(+), 55 deletions(-) create mode 100644 src/components/ui/__tests__/back-link.test.tsx create mode 100644 src/components/ui/back-link.tsx diff --git a/src/app/insights/scores/[metric]/page.tsx b/src/app/insights/scores/[metric]/page.tsx index 3e3c82932..fb417c319 100644 --- a/src/app/insights/scores/[metric]/page.tsx +++ b/src/app/insights/scores/[metric]/page.tsx @@ -1,11 +1,9 @@ "use client"; -import Link from "next/link"; import { useParams } from "next/navigation"; -import { ArrowLeft } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; -import { Button } from "@/components/ui/button"; +import { BackLink } from "@/components/ui/back-link"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { CompositeScoreAnatomy, @@ -88,18 +86,11 @@ export default function CompositeScorePage() { description={t(header.description)} explainerMetric={EXPLAINER_METRIC[metric]} backLink={ - + } coachLaunch > diff --git a/src/app/insights/values/[type]/page.tsx b/src/app/insights/values/[type]/page.tsx index ca7c75b6d..e54f9868c 100644 --- a/src/app/insights/values/[type]/page.tsx +++ b/src/app/insights/values/[type]/page.tsx @@ -1,9 +1,7 @@ "use client"; import { use } from "react"; -import Link from "next/link"; import { notFound, useSearchParams } from "next/navigation"; -import { ArrowLeft } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { @@ -12,7 +10,7 @@ import { } from "@/lib/insights/values-back-link"; import { measurementTypeEnum } from "@/lib/validations/measurement"; import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta"; -import { Button } from "@/components/ui/button"; +import { BackLink } from "@/components/ui/back-link"; import { MeasurementList } from "@/components/measurements/measurement-list"; import { SubPageShell } from "@/components/insights/sub-page-shell"; @@ -63,22 +61,17 @@ export default function InsightsMetricValuesPage({ title={t("insights.subPage.valuesPageTitle", { metric: metricLabel })} description={t("insights.subPage.valuesPageDescription")} backLink={ - + : t("insights.subPage.valuesBack") + } + dataSlot="metric-values-back" + /> } > diff --git a/src/app/insights/workouts/[id]/page.tsx b/src/app/insights/workouts/[id]/page.tsx index 74a81adf6..627a1e2f5 100644 --- a/src/app/insights/workouts/[id]/page.tsx +++ b/src/app/insights/workouts/[id]/page.tsx @@ -1,12 +1,10 @@ "use client"; import { use } from "react"; -import Link from "next/link"; -import { ArrowLeft } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { useWorkoutDetail } from "@/hooks/use-workouts"; -import { Button } from "@/components/ui/button"; +import { BackLink } from "@/components/ui/back-link"; import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { Skeleton } from "@/components/ui/skeleton"; @@ -41,18 +39,11 @@ export default function InsightsWorkoutDetailPage({ title={t("insights.workouts.title")} description={t("insights.workouts.description")} backLink={ - + } > {isLoading ? ( diff --git a/src/app/medications/[id]/page.tsx b/src/app/medications/[id]/page.tsx index b8f898195..ffe82a7be 100644 --- a/src/app/medications/[id]/page.tsx +++ b/src/app/medications/[id]/page.tsx @@ -13,16 +13,15 @@ */ import { use, useEffect } from "react"; -import Link from "next/link"; import { useQuery } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; -import { ArrowLeft, Loader2 } from "lucide-react"; +import { Loader2 } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; -import { Button } from "@/components/ui/button"; +import { BackLink } from "@/components/ui/back-link"; import { Card } from "@/components/ui/card"; import { MedicationDetailTabs, @@ -87,12 +86,7 @@ export default function MedicationDetailPage({ if (isError || !medication) { return (
- + ", () => { + it("renders an anchor to the href carrying the label", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('href="/insights/workouts"'); + expect(html).toContain("Back to workouts"); + // The decorative chevron is hidden from screen readers; the label + // carries the meaning. + expect(html).toContain('aria-hidden="true"'); + }); + + it("keeps the shared left-bleed alignment on the control", () => { + const html = renderToStaticMarkup( + , + ); + // The `-ml-2 w-fit` left-bleed is the standard back-nav placement; it + // must survive on every adopting page. + expect(html).toContain("-ml-2"); + expect(html).toContain("w-fit"); + }); + + it("threads the optional data-slot for e2e/visual targeting", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('data-slot="composite-score-back"'); + }); +}); diff --git a/src/components/ui/back-link.tsx b/src/components/ui/back-link.tsx new file mode 100644 index 000000000..41c71bd25 --- /dev/null +++ b/src/components/ui/back-link.tsx @@ -0,0 +1,47 @@ +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +/** + * v1.16.16 — shared back-navigation control. + * + * The detail / sub-page surfaces (`/insights/workouts/[id]`, + * `/insights/values/[type]`, `/insights/scores/[metric]`, + * `/medications/[id]`) each hand-rolled the identical + * `Button(ghost, sm, -ml-2 w-fit) > Link > ArrowLeft + label` block. This + * folds that into one component so the back-nav reads the same — same hit + * area, same icon, same left-bleed alignment — on every page, and a future + * change to the affordance lives in one place. + * + * The label travels in from the caller (each page already owns its own + * translation key, e.g. `insights.workouts.detail.backToList`), so this + * component adds no new i18n surface. + */ +export interface BackLinkProps { + /** Internal href the back-nav points at. */ + href: string; + /** Already-translated label text. */ + label: string; + /** Optional per-surface `data-slot` for e2e/visual targeting. */ + dataSlot?: string; + className?: string; +} + +export function BackLink({ href, label, dataSlot, className }: BackLinkProps) { + return ( + + ); +} From db770738bf597828d3cac6c3c0fcde81295617a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 17:25:39 +0200 Subject: [PATCH 4/4] Harden consent supersede against backdated grants and dup rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups to the active-receipt uniqueness work: - Migration 0159 now revokes all-but-the-newest active row per (user_id, kind) before building the partial unique index. Without the pre-flight dedup the CREATE UNIQUE INDEX would abort on any tenant that already carries duplicate active grants — exactly the tenants the fix targets — and fail the deploy. - createReceipt stamps the superseded row's revoked_at with a server clock instead of the client-supplied signedAt. A backdated signedAt would otherwise mark the prior row revoked before its own signed_at, inverting the audit chain. The new row keeps the client signedAt. --- .../migration.sql | 20 +++++++++++++ src/lib/consent/__tests__/receipts.test.ts | 30 +++++++++++++++++-- src/lib/consent/receipts.ts | 9 +++++- 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/prisma/migrations/0159_v11616_consent_active_unique/migration.sql b/prisma/migrations/0159_v11616_consent_active_unique/migration.sql index d17081d82..dae858430 100644 --- a/prisma/migrations/0159_v11616_consent_active_unique/migration.sql +++ b/prisma/migrations/0159_v11616_consent_active_unique/migration.sql @@ -11,6 +11,26 @@ -- non-NULL `revoked_at` and fall outside the predicate, so the append-only -- grant/revoke history is unaffected — only two *active* rows for the same -- (user, kind) collide. + +-- Pre-flight dedup. The bug this migration closes already produces duplicate +-- active rows on affected tenants; `CREATE UNIQUE INDEX` scans existing rows +-- and would abort (23505) on any such pair, failing the whole deploy on +-- exactly the tenants the fix targets. Revoke all-but-the-newest active row +-- per (user_id, kind) first so the index can build. The newest active row +-- (latest `created_at`) is the one `latestActiveReceipt` already returns, so +-- the surviving active grant matches current read behaviour; the superseded +-- duplicates get a `revoked_at` marker and stay in the audit history. +UPDATE "consent_receipts" c + SET "revoked_at" = now() + WHERE "revoked_at" IS NULL + AND EXISTS ( + SELECT 1 FROM "consent_receipts" n + WHERE n."user_id" = c."user_id" + AND n."kind" = c."kind" + AND n."revoked_at" IS NULL + AND n."created_at" > c."created_at" + ); + CREATE UNIQUE INDEX IF NOT EXISTS "consent_receipts_user_id_kind_active_key" ON "consent_receipts" ("user_id", "kind") WHERE "revoked_at" IS NULL; diff --git a/src/lib/consent/__tests__/receipts.test.ts b/src/lib/consent/__tests__/receipts.test.ts index 9de301f90..d26fa43df 100644 --- a/src/lib/consent/__tests__/receipts.test.ts +++ b/src/lib/consent/__tests__/receipts.test.ts @@ -64,10 +64,12 @@ describe("createReceipt", () => { const result = await createReceipt("user-1", "ai_full", "PDF…", signedAt); // Revokes any currently-active receipt of the same kind first, so the - // fresh grant doesn't collide with the partial unique index. + // fresh grant doesn't collide with the partial unique index. The + // supersede marker uses a server clock (NOT the client `signedAt`) so a + // backdated grant can't invert the prior row's audit timestamps. expect(prisma.consentReceipt.updateMany).toHaveBeenCalledWith({ where: { userId: "user-1", kind: "ai_full", revokedAt: null }, - data: { revokedAt: signedAt }, + data: { revokedAt: expect.any(Date) }, }); expect(prisma.consentReceipt.create).toHaveBeenCalledWith({ data: { @@ -81,6 +83,30 @@ describe("createReceipt", () => { // The whole mint runs in a transaction. expect(prisma.$transaction).toHaveBeenCalledTimes(1); }); + + it("uses a server clock (not a backdated client signedAt) for the supersede marker", async () => { + vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({ + count: 1, + } as never); + vi.mocked(prisma.consentReceipt.create).mockResolvedValue(row() as never); + + // A client backdates the grant well into the past. + const backdated = new Date("2000-01-01T00:00:00.000Z"); + const before = Date.now(); + await createReceipt("user-1", "ai_full", "PDF…", backdated); + const after = Date.now(); + + const supersedeArg = vi.mocked(prisma.consentReceipt.updateMany).mock + .calls[0][0] as { data: { revokedAt: Date } }; + // The prior row's revocation must be stamped "now", never the backdated + // client value, so the audit chain can't be inverted. + expect(supersedeArg.data.revokedAt.getTime()).not.toBe(backdated.getTime()); + expect(supersedeArg.data.revokedAt.getTime()).toBeGreaterThanOrEqual(before); + expect(supersedeArg.data.revokedAt.getTime()).toBeLessThanOrEqual(after); + // The new row still carries the client signedAt. + const createArg = vi.mocked(prisma.consentReceipt.create).mock.calls[0][0]; + expect(createArg.data.signedAt).toEqual(backdated); + }); }); describe("latestActiveReceipt", () => { diff --git a/src/lib/consent/receipts.ts b/src/lib/consent/receipts.ts index a6aedf229..38e1487fd 100644 --- a/src/lib/consent/receipts.ts +++ b/src/lib/consent/receipts.ts @@ -37,13 +37,20 @@ export async function createReceipt( artefact: string, signedAt: Date, ): Promise { + // `signedAt` is the client-supplied wall-clock for the NEW grant and is + // only validated as well-formed ISO-8601 — it can legitimately be in the + // past or out of order with existing rows. The supersede marker on the + // PRIOR active row must NOT use it: a backdated `signedAt` would stamp the + // old row's `revoked_at` earlier than its own `signed_at`, inverting the + // audit chain. Use a server clock for the revocation instead. + const supersededAt = new Date(); return prisma.$transaction(async (tx) => { // Supersede any currently-active receipt of this kind so the fresh // grant doesn't collide with the partial unique index. `updateMany` // over the active predicate is a no-op when none is active. await tx.consentReceipt.updateMany({ where: { userId, kind, revokedAt: null }, - data: { revokedAt: signedAt }, + data: { revokedAt: supersededAt }, }); return tx.consentReceipt.create({ data: {