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..dae858430
--- /dev/null
+++ b/prisma/migrations/0159_v11616_consent_active_unique/migration.sql
@@ -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;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 8fcc91704..3a07aacde 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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")
}
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/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={
-
+