Let the AI-consent card grant again after a withdrawal

POST /api/consent/ai/web now accepts an optional { intent } body. Without
one (the AI-settings mount heal) a standing revocation still blocks the
mint; intent "affirmative" is the user's own consent act and may
supersede an earlier revocation, exactly as a first grant would. The
consent card offers the explicit grant control whenever consent does not
stand, and the audit row records which intent minted the receipt.

The document auto-read opt-in already declared "affirmative" on enable;
the settings-mount heal posts no body. Both stay as they are.
This commit is contained in:
Marc-André Bombeck committed 2026-08-13 02:27:05 +02:00
1 parent fd104dd0ac
commit e83b58c9da
13 files changed
+268 -41

No files matched your search

@@ -22,7 +22,7 @@ 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 + serialisation.
// route test stubs it to focus on auth + rate-limit + intent + serialisation.
vi.mock("@/lib/consent/web-grant", () => ({
ensureWebAiConsentReceipt: vi.fn(),
}));
@@ -45,6 +45,22 @@ const SESSION_OK = {
const RL_OK = { allowed: true, remaining: 19, resetAt: Date.now() + 60_000 };
/** The mount heal posts no body and 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 {
return new Request("http://localhost/api/consent/ai/web", {
method: "POST",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(auditLog).mockResolvedValue(undefined);
@@ -54,7 +70,7 @@ beforeEach(() => {
describe("POST /api/consent/ai/web", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await POST();
const res = await POST(mkHeal());
expect(res.status).toBe(401);
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
});
@@ -66,9 +82,9 @@ describe("POST /api/consent/ai/web", () => {
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await POST();
const res = await POST(mkHeal());
expect(res.status).toBe(429);
// The throttle fires before the heal mint runs.
// The throttle fires before the mint runs.
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
});
@@ -79,7 +95,7 @@ describe("POST /api/consent/ai/web", () => {
receipt: { id: "rcpt-web-1" } as never,
});
const res = await POST();
const res = await POST(mkHeal());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { minted: boolean; kind: string };
@@ -95,11 +111,57 @@ describe("POST /api/consent/ai/web", () => {
reason: "already_active",
});
const res = await POST();
const res = await POST(mkHeal());
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();
});
it("passes a bodyless call to the mint as a heal", async () => {
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");
});
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 () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await POST(mkIntent({ intent: "resurrect" }));
expect(res.status).toBe(422);
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
});
});
+53 -12
View File
@@ -1,26 +1,41 @@
/**
* v1.16.13 — web AI-consent grant (heal endpoint).
* v1.16.13 — web AI-consent grant.
*
* POST /api/consent/ai/web
* Mints an `ai_full` consent receipt for the calling web user if none
* is active, mirroring the iOS master-grant. Idempotent — a user with
* an active `ai_full` receipt is a no-op. The web AI-settings surface
* calls this on mount (the web equivalent of the iOS shell-mount heal)
* so existing web accounts gain a receipt without a re-consent step and
* stop hitting the server-managed consent gate's no-key fallback.
* 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:
*
* 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.
*
* 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`.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } 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 { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant";
import type { WebConsentGrantIntent } from "@/lib/consent/web-grant";
import { webConsentGrantBody } from "@/lib/validations/consent";
export const POST = apiHandler(async () => {
export const POST = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
// The web AI-settings surface calls this on every mount; the bucket is
@@ -37,14 +52,39 @@ export const POST = apiHandler(async () => {
});
}
const result = await ensureWebAiConsentReceipt(user.id, "heal");
// 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";
const contentType = req.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
const { data: body, error: jsonError } = await safeJson(req, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
const parsed = webConsentGrantBody.safeParse(body);
if (!parsed.success) {
annotate({ action: { name: "consent.ai.web-grant.invalid_shape" } });
return returnAllZodIssues(parsed.error, 422);
}
intent = parsed.data.intent;
}
const result = await ensureWebAiConsentReceipt(user.id, intent);
if (result.minted) {
// Audit trail parity with the iOS-driven POST /api/consent/ai grant so
// the legal team can reconstruct web-originated consent. Fire-and-forget.
// the legal team can reconstruct web-originated consent; `intent`
// separates a migration heal from the user's own re-grant click.
// Fire-and-forget.
auditLog("consent.ai.grant", {
userId: user.id,
details: { kind: "ai_full", source: "web", receiptId: result.receipt.id },
details: {
kind: "ai_full",
source: "web",
intent,
receiptId: result.receipt.id,
},
}).catch(() => {});
}
@@ -52,6 +92,7 @@ export const POST = apiHandler(async () => {
action: { name: "consent.ai.web-grant" },
meta: {
minted: result.minted,
intent,
...(result.minted ? { receiptId: result.receipt.id } : {}),
},
});