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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 17:21:38 +02:00
1 parent 6a173a89dd
commit cc34cbd03d
7 files changed
+343 -67

No files matched your search

+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) {
+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