fix(consent): close the receipt mint race, rate-limit, and share the back link

Mint the web AI-consent receipt inside a serializable transaction with an
in-transaction recheck, backed by a partial unique index over one active
receipt per user and kind, so two concurrent mounts can no longer leave two
active grants; a duplicate maps to a no-op mint. Supersede an active row
against the server clock rather than the client-supplied timestamp so a
backdated grant cannot invert the audit chain, and revoke through a single
atomic update. Rate-limit the consent endpoints per user. Extract the
hand-rolled sub-page back link into one component and adopt it across the
four pages that duplicated it.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 17:26:36 +02:00
commit 555240cd28
19 files changed
+780 -176

No files matched your search

+83 -20
View File
@@ -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<Record<string, unknown>> = {}) {
@@ -37,12 +54,23 @@ function row(overrides: Partial<Record<string, unknown>> = {}) {
}
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. 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: expect.any(Date) },
});
expect(prisma.consentReceipt.create).toHaveBeenCalledWith({
data: {
userId: "user-1",
@@ -52,6 +80,32 @@ describe("createReceipt", () => {
},
});
expect(result.id).toBe("rcpt-1");
// 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);
});
});
@@ -96,28 +150,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();
});
});
+110 -8
View File
@@ -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<Record<string, unknown>> = {}) {
@@ -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<typeof row> | 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<unknown> = 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);
});
});
+56 -23
View File
@@ -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,29 @@ export async function createReceipt(
artefact: string,
signedAt: Date,
): Promise<ConsentReceipt> {
return prisma.consentReceipt.create({
data: {
userId,
kind,
artefact,
signedAt,
},
// `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: supersededAt },
});
return tx.consentReceipt.create({
data: {
userId,
kind,
artefact,
signedAt,
},
});
});
}
@@ -81,25 +105,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<ConsentReceipt | null> {
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" },
});
});
}
+35 -3
View File
@@ -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<WebConsentGrantResult> {
// 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;
}
}
+19
View File
@@ -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<RateLimitResult> {
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