fix(consent): mint AI consent receipts only on an explicit agreement

The AI settings page posted to /api/consent/ai/web on every mount and the
server minted an ai_full receipt for it, so an account that had agreed to
nothing sent its health data to the operator's provider from background
jobs. Native builds synced a receipt the same way on every launch.

- Both grant routes now require `confirm: true`; anything else writes
  nothing and answers 400 consent.ai.confirmRequired.
- Remove the mount-time heal. A consent card names the operator's service
  and the data that would go there, with one Agree button, in Settings > AI
  and on the Insights overview while AI is closed by the missing consent
  alone.
- Publish the standing consent as `ai.consent` on /api/auth/me (status,
  since, recipients); the settings card renders active-since and the
  withdraw control from it.
- Existing receipts stay valid.
- Guard that no client grants outside a click handler, route tests for the
  confirmation, and an integration test that a fresh operator-key account
  gets no background AI text until it agrees.
This commit is contained in:
Marc-André Bombeck committed 2026-10-16 17:11:04 +02:00
1 parent bc5ea5a67b
commit e2b7e7350e
45 files changed
+1707 -678

No files matched your search

@@ -188,6 +188,7 @@ describe("POST /api/consent/ai", () => {
kind: "ai_full",
artefact: "PDF",
signedAt: signedAt.toISOString(),
confirm: true,
}),
);
expect(res.status).toBe(200);
@@ -224,6 +225,31 @@ describe("POST /api/consent/ai", () => {
});
});
// v1.43.2 — native builds before it posted this body once per launch to
// sync a receipt the account did not hold, which re-granted a consent the
// person had withdrawn elsewhere. Only an explicit confirmation mints.
it.each([
["without confirm (a launch-time sync)", {}],
["with confirm: false", { confirm: false }],
])("refuses a well-formed grant %s and writes nothing", async (_l, extra) => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await POST(
req({
kind: "ai_full",
artefact: '{"source":"ios.AIConsentSheet","scope":"ai_full"}',
signedAt: "2026-05-18T10:00:00.000Z",
...extra,
}),
);
expect(res.status).toBe(400);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("consent.ai.confirmRequired");
expect(prisma.$transaction).not.toHaveBeenCalled();
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
expect(prisma.consentReceipt.updateMany).not.toHaveBeenCalled();
expect(auditLog).not.toHaveBeenCalled();
});
describe("v1.4.43 W6 — multi-issue 400 envelope (consent uses 400)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
@@ -456,6 +456,7 @@ describe("append-only invariant", () => {
kind: "ai_full",
artefact: "PDF-v1",
signedAt: signedAt1.toISOString(),
confirm: true,
}),
}),
);
@@ -510,6 +511,7 @@ describe("append-only invariant", () => {
kind: "ai_full",
artefact: "PDF-v2",
signedAt: signedAt2.toISOString(),
confirm: true,
}),
}),
);
+27 -3
View File
@@ -2,9 +2,17 @@
* v1.4.40 SB-10 — AI consent receipts.
*
* POST /api/consent/ai
* Body: { kind, artefact, signedAt }
* Body: { kind, artefact, signedAt, confirm: true }
* Persists a fresh consent receipt. Returns the inserted row.
*
* v1.43.2 — `confirm: true` is required. A receipt records a person's
* own consent act, and native builds before it also posted here once per
* launch to sync a receipt the account no longer held (re-granting, for
* example, a consent the person had withdrawn on the web). Without the
* flag nothing is written and the route answers 400
* `consent.ai.confirmRequired`; the client reads the standing state from
* `ai.consent` on `GET /api/auth/me` instead.
*
* Reader + revoke handlers live in `./latest/route.ts`.
*/
import { NextRequest } from "next/server";
@@ -19,7 +27,10 @@ import {
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 {
CONSENT_CONFIRM_REQUIRED_ERROR_CODE,
consentPostBody,
} from "@/lib/validations/consent";
import { createReceipt } from "@/lib/consent/receipts";
export const POST = apiHandler(async (request: NextRequest) => {
@@ -50,7 +61,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error, 400);
}
const { kind, artefact, signedAt } = parsed.data;
const { kind, artefact, signedAt, confirm } = parsed.data;
if (confirm !== true) {
// A launch-time sync from an older app build lands here. It is not the
// person agreeing to anything, so it writes nothing.
annotate({
action: { name: "consent.ai.grant.unconfirmed" },
meta: { kind },
});
return apiError(
"Consent is recorded only from an explicit confirmation",
400,
{ errorCode: CONSENT_CONFIRM_REQUIRED_ERROR_CODE },
);
}
const receipt = await createReceipt(user.id, kind, artefact, signedAt);
// Audit trail — separate from the row itself so the legal team can
@@ -22,9 +22,10 @@ vi.mock("next/headers", () => ({
}));
// The mint logic + its TOCTOU close are covered by web-grant.test.ts; the
// route test stubs it to focus on auth + rate-limit + intent + serialisation.
// route test stubs it to focus on auth + rate-limit + the explicit
// confirmation + serialisation.
vi.mock("@/lib/consent/web-grant", () => ({
ensureWebAiConsentReceipt: vi.fn(),
grantWebAiConsent: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
@@ -35,7 +36,7 @@ vi.mock("@/lib/rate-limit", () => ({
import { POST } from "../route";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant";
import { grantWebAiConsent } from "@/lib/consent/web-grant";
import { checkConsentRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
@@ -50,15 +51,15 @@ const RL_OK = {
resetAt: Date.now() + 60_000,
};
/** The mount heal posts no body and no content-type. */
/** What the pre-v1.43.2 mount heal sent: no body, no content-type. */
function mkHeal(): Request {
return new Request("http://localhost/api/consent/ai/web", {
method: "POST",
});
}
/** The explicit grant control posts a JSON intent. */
function mkIntent(body: unknown): Request {
/** A JSON body, as the agree control (or anything else) sends one. */
function mkJson(body: unknown): Request {
return new Request("http://localhost/api/consent/ai/web", {
method: "POST",
body: JSON.stringify(body),
@@ -77,7 +78,7 @@ describe("POST /api/consent/ai/web", () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await POST(mkHeal());
expect(res.status).toBe(401);
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
expect(grantWebAiConsent).not.toHaveBeenCalled();
});
it("returns 429 when the per-user consent bucket is exhausted", async () => {
@@ -91,33 +92,41 @@ describe("POST /api/consent/ai/web", () => {
const res = await POST(mkHeal());
expect(res.status).toBe(429);
// The throttle fires before the mint runs.
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
expect(grantWebAiConsent).not.toHaveBeenCalled();
});
it("mints a receipt and reports minted:true", async () => {
it("mints a receipt on the explicit confirmation and reports minted:true", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(ensureWebAiConsentReceipt).mockResolvedValue({
vi.mocked(grantWebAiConsent).mockResolvedValue({
minted: true,
receipt: { id: "rcpt-web-1" } as never,
});
const res = await POST(mkHeal());
const res = await POST(mkJson({ confirm: true }));
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");
expect(grantWebAiConsent).toHaveBeenCalledWith("user-1");
expect(auditLog).toHaveBeenCalledWith(
"consent.ai.grant",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({ source: "web", confirmed: true }),
}),
);
});
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({
vi.mocked(grantWebAiConsent).mockResolvedValue({
minted: false,
reason: "already_active",
});
const res = await POST(mkHeal());
const res = await POST(mkJson({ confirm: true }));
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { minted: boolean } };
expect(body.data.minted).toBe(false);
@@ -125,49 +134,34 @@ describe("POST /api/consent/ai/web", () => {
expect(auditLog).not.toHaveBeenCalled();
});
it("passes a bodyless call to the mint as a heal", async () => {
// Every shape that is not the person's own confirmation: the bodyless
// mount heal the settings page sent until v1.43.2, an empty object, the
// retired `{ intent }` bodies, and an explicit `false`.
it.each([
["no body (the old mount heal)", () => mkHeal()],
["an empty object", () => mkJson({})],
['the retired intent "heal"', () => mkJson({ intent: "heal" })],
[
'the retired intent "affirmative"',
() => mkJson({ intent: "affirmative" }),
],
["confirm: false", () => mkJson({ confirm: false })],
])("refuses %s with 400 and never reaches the mint", async (_label, mk) => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(ensureWebAiConsentReceipt).mockResolvedValue({
minted: false,
reason: "previously_revoked",
});
const res = await POST(mkHeal());
expect(res.status).toBe(200);
expect(ensureWebAiConsentReceipt).toHaveBeenCalledWith("user-1", "heal");
const res = await POST(mk());
expect(res.status).toBe(400);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("consent.ai.confirmRequired");
expect(grantWebAiConsent).not.toHaveBeenCalled();
expect(auditLog).not.toHaveBeenCalled();
});
it("passes an explicit affirmative body to the mint as the consent act", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(ensureWebAiConsentReceipt).mockResolvedValue({
minted: true,
receipt: { id: "rcpt-web-2" } as never,
});
const res = await POST(mkIntent({ intent: "affirmative" }));
expect(res.status).toBe(200);
expect(ensureWebAiConsentReceipt).toHaveBeenCalledWith(
"user-1",
"affirmative",
);
// The audit row records that this was the user's own grant, not a heal.
expect(auditLog).toHaveBeenCalledWith(
"consent.ai.grant",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
source: "web",
intent: "affirmative",
}),
}),
);
});
it("rejects an unknown intent with 422 and never reaches the mint", async () => {
it("rejects a malformed confirmation with 422 and never reaches the mint", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await POST(mkIntent({ intent: "resurrect" }));
const res = await POST(mkJson({ confirm: "yes" }));
expect(res.status).toBe(422);
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
expect(grantWebAiConsent).not.toHaveBeenCalled();
});
});
+34 -35
View File
@@ -1,25 +1,20 @@
/**
* v1.16.13 — web AI-consent grant.
* Web AI-consent grant.
*
* POST /api/consent/ai/web
* Mints an `ai_full` consent receipt for the calling web user.
* Idempotent — a user with an active `ai_full` receipt is a no-op.
* The body decides what a missing receipt means:
* POST /api/consent/ai/web body `{ "confirm": true }`
* Records the person's explicit `ai_full` consent: they pressed "Agree"
* on a card that named the provider and the data. Idempotent: an active
* `ai_full` receipt makes it a no-op. As the person's own consent act it
* may follow an earlier withdrawal.
*
* no body (heal) The AI-settings surface calls this on mount
* (the web equivalent of the iOS shell-mount
* heal) so an account that predates the consent
* gate gains a receipt without a re-consent
* step. A past revocation stands; the heal
* never resurrects a withdrawn consent.
* `{ intent: "affirmative" }` The explicit grant control on the same
* surface. This IS the user's consent act, so
* it may supersede an earlier revocation
* exactly as the first grant would.
* Without `confirm: true` (no body, `{}`, the pre-v1.43.2 mount heal or
* its `{ intent }` body) it mints nothing and answers 400
* `consent.ai.confirmRequired`. The settings page used to call this on
* every mount and mint a receipt nobody had agreed to; a request that is
* not the consent act must never create one.
*
* Revocation stays on `DELETE /api/consent/ai/latest`; this route only
* grants. The full CRUD (explicit grant with a signed artefact, reader,
* revoke) lives in `../route.ts` + `../latest/route.ts`.
* grants.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
@@ -31,16 +26,16 @@ import {
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";
import type { WebConsentGrantIntent } from "@/lib/consent/web-grant";
import { webConsentGrantBody } from "@/lib/validations/consent";
import { grantWebAiConsent } from "@/lib/consent/web-grant";
import {
CONSENT_CONFIRM_REQUIRED_ERROR_CODE,
webConsentGrantBody,
} from "@/lib/validations/consent";
export const POST = apiHandler(async (req: Request) => {
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.
// Caps a scripted loop; a person tapping Agree never comes near it.
const rl = await checkConsentRateLimit(user.id);
if (!rl.allowed) {
annotate({
@@ -52,10 +47,9 @@ export const POST = apiHandler(async (req: Request) => {
});
}
// The mount heal posts no body; only the explicit grant control sends one.
// Absence of a JSON body therefore reads as a heal, and a present body is
// Zod-validated so a malformed intent is a 422, never a silent heal.
let intent: WebConsentGrantIntent = "heal";
// Only the explicit grant sends a JSON body. Anything else (no body, an
// empty object, the retired `{ intent }` shape) is not the consent act.
let confirmed = false;
const contentType = req.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
const { data: body, error: jsonError } = await safeJson(req, {
@@ -67,22 +61,28 @@ export const POST = apiHandler(async (req: Request) => {
annotate({ action: { name: "consent.ai.web-grant.invalid_shape" } });
return returnAllZodIssues(parsed.error, 422);
}
intent = parsed.data.intent;
confirmed = parsed.data.confirm === true;
}
if (!confirmed) {
annotate({ action: { name: "consent.ai.web-grant.unconfirmed" } });
return apiError(
"Consent is recorded only from an explicit confirmation",
400,
{ errorCode: CONSENT_CONFIRM_REQUIRED_ERROR_CODE },
);
}
const result = await ensureWebAiConsentReceipt(user.id, intent);
const result = await grantWebAiConsent(user.id);
if (result.minted) {
// Audit trail parity with the iOS-driven POST /api/consent/ai grant so
// the legal team can reconstruct web-originated consent; `intent`
// separates a migration heal from the user's own re-grant click.
// Fire-and-forget.
// Audit trail parity with the native POST /api/consent/ai grant so the
// web-originated consent can be reconstructed. Fire-and-forget.
auditLog("consent.ai.grant", {
userId: user.id,
details: {
kind: "ai_full",
source: "web",
intent,
confirmed: true,
receiptId: result.receipt.id,
},
}).catch(() => {});
@@ -92,7 +92,6 @@ export const POST = apiHandler(async (req: Request) => {
action: { name: "consent.ai.web-grant" },
meta: {
minted: result.minted,
intent,
...(result.minted ? { receiptId: result.receipt.id } : {}),
},
});