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

@@ -0,0 +1,36 @@
-- 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.
-- 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;
+8
View File
@@ -3363,6 +3363,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")
}
+59 -8
View File
@@ -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(
@@ -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) },
});
+24
View File
@@ -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),
);
+21 -1
View File
@@ -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,
});
@@ -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();
});
});
+16 -1
View File
@@ -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) {
+6 -15
View File
@@ -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={
<Button
asChild
variant="ghost"
size="sm"
data-slot="composite-score-back"
className="-ml-2 w-fit"
>
<Link href="/insights">
<ArrowLeft className="size-4" aria-hidden="true" />
{t("insights.subPage.scoresBack")}
</Link>
</Button>
<BackLink
href="/insights"
label={t("insights.subPage.scoresBack")}
dataSlot="composite-score-back"
/>
}
coachLaunch
>
+9 -16
View File
@@ -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={
<Button
asChild
variant="ghost"
size="sm"
data-slot="metric-values-back"
className="-ml-2 w-fit"
>
<Link href={backHref}>
<ArrowLeft className="size-4" aria-hidden="true" />
{backToOrigin
<BackLink
href={backHref}
label={
backToOrigin
? t("insights.subPage.valuesBackToMetric", {
metric: metricLabel,
})
: t("insights.subPage.valuesBack")}
</Link>
</Button>
: t("insights.subPage.valuesBack")
}
dataSlot="metric-values-back"
/>
}
>
<MeasurementList lockedType={type} />
+6 -15
View File
@@ -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={
<Button
asChild
variant="ghost"
size="sm"
data-slot="workout-detail-back"
className="-ml-2 w-fit"
>
<Link href="/insights/workouts">
<ArrowLeft className="size-4" aria-hidden="true" />
{t("insights.workouts.detail.backToList")}
</Link>
</Button>
<BackLink
href="/insights/workouts"
label={t("insights.workouts.detail.backToList")}
dataSlot="workout-detail-back"
/>
}
>
{isLoading ? (
+3 -9
View File
@@ -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 (
<div className="space-y-6">
<Button variant="ghost" size="sm" className="-ml-2 w-fit" asChild>
<Link href="/medications">
<ArrowLeft className="size-4" aria-hidden="true" />
{t("medications.back")}
</Link>
</Button>
<BackLink href="/medications" label={t("medications.back")} />
<Card
className="p-6"
role="alert"
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { BackLink } from "../back-link";
describe("<BackLink>", () => {
it("renders an anchor to the href carrying the label", () => {
const html = renderToStaticMarkup(
<BackLink href="/insights/workouts" label="Back to workouts" />,
);
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(
<BackLink href="/medications" label="Back" />,
);
// 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(
<BackLink
href="/insights"
label="Back"
dataSlot="composite-score-back"
/>,
);
expect(html).toContain('data-slot="composite-score-back"');
});
});
+47
View File
@@ -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 (
<Button
asChild
variant="ghost"
size="sm"
data-slot={dataSlot}
className={cn("-ml-2 w-fit", className)}
>
<Link href={href}>
<ArrowLeft className="size-4" aria-hidden="true" />
{label}
</Link>
</Button>
);
}
+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