diff --git a/src/app/api/consent/ai/__tests__/route.test.ts b/src/app/api/consent/ai/__tests__/route.test.ts index dfe3c312e..8c24521b2 100644 --- a/src/app/api/consent/ai/__tests__/route.test.ts +++ b/src/app/api/consent/ai/__tests__/route.test.ts @@ -1,15 +1,31 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; -vi.mock("@/lib/db", () => ({ - prisma: { - consentReceipt: { - create: vi.fn(), - findFirst: vi.fn(), - findMany: vi.fn(), - update: vi.fn(), +// v1.16.16 — `createReceipt` runs its supersede + insert inside a +// transaction; run the callback against the same mock proxy. +type TxFn = (tx: unknown) => unknown; + +vi.mock("@/lib/db", () => { + const consentReceipt = { + create: vi.fn(), + findFirst: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }; + return { + prisma: { + consentReceipt, + $transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })), }, - }, + }; +}); + +// v1.16.16 — rate limit is exercised by its own suite; here we default to +// "allowed" and flip a single test to assert the 429 wiring. +vi.mock("@/lib/rate-limit", () => ({ + checkConsentRateLimit: vi.fn(), + rateLimitHeaders: vi.fn(() => ({})), })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); @@ -38,6 +54,13 @@ import { POST } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { auditLog } from "@/lib/auth/audit"; +import { checkConsentRateLimit } from "@/lib/rate-limit"; + +const RL_OK = { allowed: true, remaining: 19, resetAt: Date.now() + 60_000 }; + +const $transaction = vi.mocked(prisma.$transaction) as unknown as { + mockImplementation: (impl: (fn: TxFn) => unknown) => void; +}; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -59,6 +82,15 @@ beforeEach(() => { // Re-arm it here so the fire-and-forget `.catch()` in the route // doesn't crash on an undefined return. vi.mocked(auditLog).mockResolvedValue(undefined); + // Re-arm the rate-limit + transaction pass-throughs cleared by + // `resetAllMocks`. + vi.mocked(checkConsentRateLimit).mockResolvedValue(RL_OK); + $transaction.mockImplementation((fn: TxFn) => + fn({ consentReceipt: prisma.consentReceipt }), + ); + vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({ + count: 0, + } as never); }); describe("POST /api/consent/ai", () => { @@ -74,6 +106,25 @@ describe("POST /api/consent/ai", () => { expect(res.status).toBe(401); }); + it("returns 429 when the per-user consent bucket is exhausted", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkConsentRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + }); + const res = await POST( + req({ + kind: "ai_full", + artefact: "PDF", + signedAt: "2026-05-18T10:00:00.000Z", + }), + ); + expect(res.status).toBe(429); + // The throttle fires before any write touches the receipts table. + expect(prisma.consentReceipt.create).not.toHaveBeenCalled(); + }); + it("returns 400 for an invalid kind", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); const res = await POST( diff --git a/src/app/api/consent/ai/latest/__tests__/route.test.ts b/src/app/api/consent/ai/latest/__tests__/route.test.ts index 67bea48bf..4431592ad 100644 --- a/src/app/api/consent/ai/latest/__tests__/route.test.ts +++ b/src/app/api/consent/ai/latest/__tests__/route.test.ts @@ -1,15 +1,29 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; -vi.mock("@/lib/db", () => ({ - prisma: { - consentReceipt: { - create: vi.fn(), - findFirst: vi.fn(), - findMany: vi.fn(), - update: vi.fn(), +// v1.16.16 — `createReceipt` + `revokeLatest` run inside a transaction; the +// mock runs the callback against the same proxy. +type TxFn = (tx: unknown) => unknown; + +vi.mock("@/lib/db", () => { + const consentReceipt = { + create: vi.fn(), + findFirst: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }; + return { + prisma: { + consentReceipt, + $transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })), }, - }, + }; +}); + +vi.mock("@/lib/rate-limit", () => ({ + checkConsentRateLimit: vi.fn(), + rateLimitHeaders: vi.fn(() => ({})), })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); @@ -38,6 +52,13 @@ import { POST as POST_GRANT } from "../../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { auditLog } from "@/lib/auth/audit"; +import { checkConsentRateLimit } from "@/lib/rate-limit"; + +const RL_OK = { allowed: true, remaining: 19, resetAt: Date.now() + 60_000 }; + +const $transaction = vi.mocked(prisma.$transaction) as unknown as { + mockImplementation: (impl: (fn: TxFn) => unknown) => void; +}; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -50,6 +71,11 @@ beforeEach(() => { // on `auditLog`. Re-arm so the route's fire-and-forget `.catch()` // chain doesn't NPE on an undefined return. vi.mocked(auditLog).mockResolvedValue(undefined); + // Re-arm rate-limit + transaction pass-throughs cleared by reset. + vi.mocked(checkConsentRateLimit).mockResolvedValue(RL_OK); + $transaction.mockImplementation((fn: TxFn) => + fn({ consentReceipt: prisma.consentReceipt }), + ); }); describe("GET /api/consent/ai/latest", () => { @@ -69,6 +95,20 @@ describe("GET /api/consent/ai/latest", () => { expect(res.status).toBe(400); }); + it("returns 429 when the per-user consent bucket is exhausted", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkConsentRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + }); + const res = await GET( + new NextRequest("http://localhost/api/consent/ai/latest?kind=ai_full"), + ); + expect(res.status).toBe(429); + expect(prisma.consentReceipt.findFirst).not.toHaveBeenCalled(); + }); + it("returns null receipt when nothing has been granted", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null); @@ -153,21 +193,33 @@ describe("DELETE /api/consent/ai/latest", () => { expect(res.status).toBe(401); }); + it("returns 429 when the per-user consent bucket is exhausted", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkConsentRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + }); + const res = await DELETE( + new NextRequest("http://localhost/api/consent/ai/latest?kind=ai_full", { + method: "DELETE", + }), + ); + expect(res.status).toBe(429); + expect(prisma.consentReceipt.updateMany).not.toHaveBeenCalled(); + }); + it("marks the latest receipt as revoked and returns it", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); const signedAt = new Date("2026-05-18T10:00:00.000Z"); const createdAt = new Date("2026-05-18T10:00:01.000Z"); const revokedAt = new Date("2026-05-18T11:00:00.000Z"); - vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue({ - id: "rcpt-1", - userId: "user-1", - kind: "ai_full", - artefact: "PDF", - signedAt, - revokedAt: null, - createdAt, + // v1.16.16 — `revokeLatest` atomically flips `revoked_at` via + // `updateMany` (count) then re-reads the revoked row for the audit id. + vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({ + count: 1, } as never); - vi.mocked(prisma.consentReceipt.update).mockResolvedValue({ + vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue({ id: "rcpt-1", userId: "user-1", kind: "ai_full", @@ -188,15 +240,17 @@ describe("DELETE /api/consent/ai/latest", () => { }; expect(body.data.receipt?.id).toBe("rcpt-1"); expect(body.data.receipt?.revokedAt).toBe(revokedAt.toISOString()); - expect(prisma.consentReceipt.update).toHaveBeenCalledWith({ - where: { id: "rcpt-1" }, + expect(prisma.consentReceipt.updateMany).toHaveBeenCalledWith({ + where: { userId: "user-1", kind: "ai_full", revokedAt: null }, data: { revokedAt: expect.any(Date) }, }); }); - it("is idempotent — returns null receipt without writing when nothing to revoke", async () => { + it("is idempotent — returns null receipt without re-reading when nothing to revoke", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null); + vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({ + count: 0, + } as never); const res = await DELETE( new NextRequest("http://localhost/api/consent/ai/latest?kind=ai_full", { @@ -208,7 +262,7 @@ describe("DELETE /api/consent/ai/latest", () => { data: { kind: string; receipt: unknown | null }; }; expect(body.data.receipt).toBeNull(); - expect(prisma.consentReceipt.update).not.toHaveBeenCalled(); + expect(prisma.consentReceipt.findFirst).not.toHaveBeenCalled(); }); it("master toggle revokes the latest active row per kind", async () => { @@ -217,8 +271,14 @@ describe("DELETE /api/consent/ai/latest", () => { const createdAt = new Date("2026-05-18T10:00:01.000Z"); const revokedAt = new Date("2026-05-18T11:00:00.000Z"); - // First call (ai_full) finds a row, second (ai_insights_only) - // finds none, third (ai_coach) finds a row. + // v1.16.16 — `revokeLatest` runs per kind in `consentKindEnum.options` + // order: ai_full → ai_insights_only → ai_coach. The `updateMany` count + // gates the follow-up re-read. ai_full + ai_coach revoke a row; + // ai_insights_only has none (count 0, no re-read). + vi.mocked(prisma.consentReceipt.updateMany) + .mockResolvedValueOnce({ count: 1 } as never) // ai_full + .mockResolvedValueOnce({ count: 0 } as never) // ai_insights_only + .mockResolvedValueOnce({ count: 1 } as never); // ai_coach vi.mocked(prisma.consentReceipt.findFirst) .mockResolvedValueOnce({ id: "rcpt-full", @@ -226,33 +286,19 @@ describe("DELETE /api/consent/ai/latest", () => { kind: "ai_full", artefact: "PDF", signedAt, - revokedAt: null, + revokedAt, createdAt, } as never) - .mockResolvedValueOnce(null) .mockResolvedValueOnce({ id: "rcpt-coach", userId: "user-1", kind: "ai_coach", artefact: "PDF", signedAt, - revokedAt: null, + revokedAt, createdAt, } as never); - vi.mocked(prisma.consentReceipt.update).mockImplementation( - ((args: { where: { id: string }; data: { revokedAt: Date } }) => - Promise.resolve({ - id: args.where.id, - userId: "user-1", - kind: args.where.id === "rcpt-full" ? "ai_full" : "ai_coach", - artefact: "PDF", - signedAt, - revokedAt, - createdAt, - })) as never, - ); - const res = await DELETE( new NextRequest("http://localhost/api/consent/ai/latest", { method: "DELETE", @@ -287,7 +333,11 @@ describe("append-only invariant", () => { const signedAt2 = new Date("2026-05-18T12:00:00.000Z"); const createdAt2 = new Date("2026-05-18T12:00:01.000Z"); - // Step 1: initial grant. + // Step 1: initial grant. `createReceipt` supersedes any active row + // (none yet → count 0) then inserts. + vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({ + count: 0, + } as never); vi.mocked(prisma.consentReceipt.create).mockResolvedValueOnce({ id: "rcpt-1", userId: "user-1", @@ -311,17 +361,13 @@ describe("append-only invariant", () => { ); expect(grant1.status).toBe(200); - // Step 2: revoke. The DELETE handler reads + updates. - vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValueOnce({ - id: "rcpt-1", - userId: "user-1", - kind: "ai_full", - artefact: "PDF-v1", - signedAt: signedAt1, - revokedAt: null, - createdAt: createdAt1, + // Step 2: revoke. `revokeLatest` atomically flips `revoked_at` via + // `updateMany` (count 1) then re-reads the revoked row. The row is + // NEVER deleted — only marked revoked, so the audit chain survives. + vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValueOnce({ + count: 1, } as never); - vi.mocked(prisma.consentReceipt.update).mockResolvedValueOnce({ + vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValueOnce({ id: "rcpt-1", userId: "user-1", kind: "ai_full", @@ -337,12 +383,11 @@ describe("append-only invariant", () => { }), ); expect(revokeRes.status).toBe(200); - expect(prisma.consentReceipt.update).toHaveBeenCalledTimes(1); - // Critical invariant — `delete` was NEVER called on the receipt. - // Only an update setting `revokedAt`. The old row stays in place. - const updateCall = vi.mocked(prisma.consentReceipt.update).mock.calls[0][0]; - expect(updateCall).toMatchObject({ - where: { id: "rcpt-1" }, + // Critical invariant — `delete` was NEVER called on the receipt. The + // revoke is an in-place `updateMany` setting `revokedAt`. + expect(prisma.consentReceipt.delete).toBeUndefined(); + expect(prisma.consentReceipt.updateMany).toHaveBeenLastCalledWith({ + where: { userId: "user-1", kind: "ai_full", revokedAt: null }, data: { revokedAt: expect.any(Date) }, }); diff --git a/src/app/api/consent/ai/latest/route.ts b/src/app/api/consent/ai/latest/route.ts index bf864a9be..4a2f8a8cd 100644 --- a/src/app/api/consent/ai/latest/route.ts +++ b/src/app/api/consent/ai/latest/route.ts @@ -17,11 +17,13 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { + apiError, apiSuccess, returnAllZodIssues, } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; +import { checkConsentRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { consentKindEnum, consentLatestQuery, @@ -37,6 +39,17 @@ import { serialiseReceipt } from "../route"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + const rl = await checkConsentRateLimit(user.id); + if (!rl.allowed) { + annotate({ + action: { name: "consent.ai.rate-limited" }, + meta: { userId: user.id, resetAt: rl.resetAt }, + }); + return apiError("Too many consent requests, please wait a moment", 429, { + headers: rateLimitHeaders(rl), + }); + } + const parsed = consentLatestQuery.safeParse( Object.fromEntries(request.nextUrl.searchParams), ); @@ -86,6 +99,17 @@ export const GET = apiHandler(async (request: NextRequest) => { export const DELETE = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + const rl = await checkConsentRateLimit(user.id); + if (!rl.allowed) { + annotate({ + action: { name: "consent.ai.rate-limited" }, + meta: { userId: user.id, resetAt: rl.resetAt }, + }); + return apiError("Too many consent requests, please wait a moment", 429, { + headers: rateLimitHeaders(rl), + }); + } + const parsed = consentLatestQuery.safeParse( Object.fromEntries(request.nextUrl.searchParams), ); diff --git a/src/app/api/consent/ai/route.ts b/src/app/api/consent/ai/route.ts index 8aa2cbb26..8679be132 100644 --- a/src/app/api/consent/ai/route.ts +++ b/src/app/api/consent/ai/route.ts @@ -9,15 +9,35 @@ */ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; +import { checkConsentRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { consentPostBody } from "@/lib/validations/consent"; import { createReceipt } from "@/lib/consent/receipts"; export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + // Per-user ceiling in front of the receipts write. 20/min is well outside + // any legitimate grant cadence; it caps a scripted loop from filling the + // audit table. + const rl = await checkConsentRateLimit(user.id); + if (!rl.allowed) { + annotate({ + action: { name: "consent.ai.rate-limited" }, + meta: { userId: user.id, resetAt: rl.resetAt }, + }); + return apiError("Too many consent requests, please wait a moment", 429, { + headers: rateLimitHeaders(rl), + }); + } + const { data: body, error } = await safeJson(request, { maxBytes: 128 * 1024, }); diff --git a/src/app/api/consent/ai/web/__tests__/route.test.ts b/src/app/api/consent/ai/web/__tests__/route.test.ts new file mode 100644 index 000000000..08a3050b2 --- /dev/null +++ b/src/app/api/consent/ai/web/__tests__/route.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +// The mint logic + its TOCTOU close are covered by web-grant.test.ts; the +// route test stubs it to focus on auth + rate-limit + serialisation. +vi.mock("@/lib/consent/web-grant", () => ({ + ensureWebAiConsentReceipt: vi.fn(), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkConsentRateLimit: vi.fn(), + rateLimitHeaders: vi.fn(() => ({})), +})); + +import { POST } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; +import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant"; +import { checkConsentRateLimit } from "@/lib/rate-limit"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "testuser", role: "USER" as const }, +}; + +const RL_OK = { allowed: true, remaining: 19, resetAt: Date.now() + 60_000 }; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(auditLog).mockResolvedValue(undefined); + vi.mocked(checkConsentRateLimit).mockResolvedValue(RL_OK); +}); + +describe("POST /api/consent/ai/web", () => { + it("returns 401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await POST(); + expect(res.status).toBe(401); + expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled(); + }); + + it("returns 429 when the per-user consent bucket is exhausted", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkConsentRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + }); + const res = await POST(); + expect(res.status).toBe(429); + // The throttle fires before the heal mint runs. + expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled(); + }); + + it("mints a receipt and reports minted:true", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(ensureWebAiConsentReceipt).mockResolvedValue({ + minted: true, + receipt: { id: "rcpt-web-1" } as never, + }); + + const res = await POST(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { minted: boolean; kind: string }; + }; + expect(body.data.minted).toBe(true); + expect(body.data.kind).toBe("ai_full"); + }); + + it("is a no-op when an active receipt already exists (minted:false)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(ensureWebAiConsentReceipt).mockResolvedValue({ minted: false }); + + const res = await POST(); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { minted: boolean } }; + expect(body.data.minted).toBe(false); + // No audit row on the no-op path. + expect(auditLog).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/consent/ai/web/route.ts b/src/app/api/consent/ai/web/route.ts index cccf41864..1e1e14af3 100644 --- a/src/app/api/consent/ai/web/route.ts +++ b/src/app/api/consent/ai/web/route.ts @@ -14,14 +14,29 @@ * revoke) lives in `../route.ts` + `../latest/route.ts`. */ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess } from "@/lib/api-response"; +import { apiError, apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; +import { checkConsentRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant"; export const POST = apiHandler(async () => { const { user } = await requireAuth(); + // The web AI-settings surface calls this on every mount; the bucket is + // generous enough that the heal path is never throttled in normal use, + // tight enough to cap a scripted loop. + const rl = await checkConsentRateLimit(user.id); + if (!rl.allowed) { + annotate({ + action: { name: "consent.ai.rate-limited" }, + meta: { userId: user.id, resetAt: rl.resetAt }, + }); + return apiError("Too many consent requests, please wait a moment", 429, { + headers: rateLimitHeaders(rl), + }); + } + const result = await ensureWebAiConsentReceipt(user.id); if (result.minted) { diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 1429b7aed..57e970e8e 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -87,6 +87,25 @@ export async function checkAnalyticsReadRateLimit( ); } +/** + * v1.16.16 — per-user ceiling for the AI-consent surfaces + * (`POST /api/consent/ai`, `POST /api/consent/ai/web`, + * `GET/DELETE /api/consent/ai/latest`). The web client calls the heal + * endpoint on every AI-settings mount and the iOS toggle hits grant/revoke + * per flip, so the bucket is generous — 20/min only caps a scripted loop or + * a stolen session pinning the receipts table, mirroring the coach-chat + * ceiling. One shared bucket keyed by `userId` (not per-route) keeps the cap + * meaningful when a loop rotates across the four endpoints. + */ +const CONSENT_LIMIT = 20; +const CONSENT_WINDOW_MS = 60 * 1000; + +export async function checkConsentRateLimit( + userId: string, +): Promise { + return checkRateLimit(`consent:${userId}`, CONSENT_LIMIT, CONSENT_WINDOW_MS); +} + /** * v1.4.43 W13 M-4 — anonymous/trust-violation bucket key shared by every * caller of `checkAuthSurfaceRateLimit`. When `TRUST_PROXY_HOPS` and the