From e83b58c9dad007cfcc9a94c86d0dbe7b92067220 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 13 Aug 2026 01:39:38 +0200
Subject: [PATCH] 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.
---
docs/api/openapi.yaml | 22 +++++-
messages/de.json | 4 +-
messages/en.json | 4 +-
messages/es.json | 4 +-
messages/fr.json | 4 +-
messages/it.json | 4 +-
messages/pl.json | 4 +-
.../consent/ai/web/__tests__/route.test.ts | 74 +++++++++++++++++--
src/app/api/consent/ai/web/route.ts | 65 +++++++++++++---
.../ai/__tests__/ai-consent-card.test.tsx | 19 ++++-
.../settings/ai/ai-consent-card.tsx | 63 ++++++++++++++--
src/lib/openapi/routes/consent.ts | 26 ++++++-
src/lib/validations/consent.ts | 16 ++++
13 files changed, 268 insertions(+), 41 deletions(-)
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index e93ce866d..644022880 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -8904,10 +8904,24 @@ paths:
tags:
- Consent
summary: Grant web AI consent (idempotent ai_full)
- description: Mint an `ai_full` consent receipt for the calling web user if none is active; a no-op when one already
- exists. Mirrors the iOS master grant; the web AI-settings surface calls this on mount so existing web accounts
- on a shared-key deployment gain a receipt without a re-consent step. Revocation flows through DELETE
- /api/consent/ai/latest.
+ description: "Mint an `ai_full` consent receipt for the calling web user if none is active; a no-op when one already
+ exists. Mirrors the iOS master grant. Without a body (the mount heal) it grants only to an account with no
+ consent history, so a revocation stands until the user grants again; with `intent: \"affirmative\"` (the
+ explicit grant control) it records the user's own consent act, which may supersede an earlier revocation.
+ Revocation flows through DELETE /api/consent/ai/latest."
+ requestBody:
+ required: false
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ intent:
+ default: heal
+ type: string
+ enum:
+ - heal
+ - affirmative
responses:
"200":
description: Grant outcome.
diff --git a/messages/de.json b/messages/de.json
index a5af150dd..d0ee6aac6 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -5651,7 +5651,9 @@
"withdraw": "Einwilligung widerrufen",
"confirmBody": "Die KI-Funktionen sind sofort abgeschaltet. Bereits erfasste Daten werden nicht gelöscht — dafür gibt es Daten & Privatsphäre.",
"confirmWithdraw": "Widerrufen",
- "regrantHint": "Um die KI-Funktionen wieder zu nutzen, schalte das automatische Lesen von Dokumenten ein oder erteile die Einwilligung in der App.",
+ "regrantHint": "Mit einer neuen Einwilligung dürfen die KI-Funktionen deine Gesundheitsdaten wieder an den eingestellten Anbieter senden.",
+ "grant": "Einwilligung erteilen",
+ "grantError": "Die Einwilligung konnte nicht gespeichert werden. Bitte versuche es erneut.",
"error": "Der Widerruf konnte nicht gespeichert werden. Bitte versuche es erneut."
}
},
diff --git a/messages/en.json b/messages/en.json
index c8463c98b..561c0900f 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -5651,7 +5651,9 @@
"withdraw": "Withdraw consent",
"confirmBody": "The AI features stop working straight away. Nothing already recorded is deleted — use Data & privacy to export or erase your record.",
"confirmWithdraw": "Withdraw",
- "regrantHint": "To use the AI features again, switch on automatic document reading or grant consent from the app.",
+ "regrantHint": "Granting again lets the AI features send your health data to the configured provider.",
+ "grant": "Grant consent",
+ "grantError": "The consent could not be saved. Please try again.",
"error": "The withdrawal could not be saved. Please try again."
}
},
diff --git a/messages/es.json b/messages/es.json
index a381829e1..31f3bf89a 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -5651,7 +5651,9 @@
"withdraw": "Retirar el consentimiento",
"confirmBody": "Las funciones de IA dejan de funcionar de inmediato. No se borra nada de lo ya registrado: usa Datos y privacidad para exportar o eliminar tu historial.",
"confirmWithdraw": "Retirar",
- "regrantHint": "Para volver a usar las funciones de IA, activa la lectura automática de documentos u otorga el consentimiento desde la aplicación.",
+ "regrantHint": "Si vuelves a otorgarlo, las funciones de IA podrán enviar de nuevo tus datos de salud al proveedor configurado.",
+ "grant": "Otorgar el consentimiento",
+ "grantError": "No se pudo guardar el consentimiento. Inténtalo de nuevo.",
"error": "No se pudo guardar la retirada. Inténtalo de nuevo."
}
},
diff --git a/messages/fr.json b/messages/fr.json
index f1e10c144..4f49b35d4 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -5651,7 +5651,9 @@
"withdraw": "Retirer le consentement",
"confirmBody": "Les fonctions IA s'arrêtent immédiatement. Rien de ce qui est déjà enregistré n'est supprimé — utilise Données et confidentialité pour exporter ou effacer ton dossier.",
"confirmWithdraw": "Retirer",
- "regrantHint": "Pour réutiliser les fonctions IA, active la lecture automatique des documents ou donne ton consentement depuis l'application.",
+ "regrantHint": "En le redonnant, les fonctions IA pourront à nouveau transmettre tes données de santé au fournisseur configuré.",
+ "grant": "Donner le consentement",
+ "grantError": "Le consentement n'a pas pu être enregistré. Réessaie.",
"error": "Le retrait n'a pas pu être enregistré. Réessaie."
}
},
diff --git a/messages/it.json b/messages/it.json
index d9e6db62a..f4cbc1387 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -5651,7 +5651,9 @@
"withdraw": "Revoca il consenso",
"confirmBody": "Le funzioni IA si fermano subito. Nulla di quanto già registrato viene eliminato — usa Dati e privacy per esportare o cancellare il tuo archivio.",
"confirmWithdraw": "Revoca",
- "regrantHint": "Per usare di nuovo le funzioni IA, attiva la lettura automatica dei documenti o concedi il consenso dall'app.",
+ "regrantHint": "Concedendolo di nuovo, le funzioni IA potranno inviare ancora i tuoi dati sanitari al provider configurato.",
+ "grant": "Concedi il consenso",
+ "grantError": "Non è stato possibile salvare il consenso. Riprova.",
"error": "Non è stato possibile salvare la revoca. Riprova."
}
},
diff --git a/messages/pl.json b/messages/pl.json
index a90cd2997..683cb4607 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -5651,7 +5651,9 @@
"withdraw": "Wycofaj zgodę",
"confirmBody": "Funkcje AI przestają działać natychmiast. Nic z już zapisanych danych nie zostaje usunięte — użyj sekcji Dane i prywatność, aby wyeksportować lub usunąć swoją historię.",
"confirmWithdraw": "Wycofaj",
- "regrantHint": "Aby ponownie korzystać z funkcji AI, włącz automatyczne czytanie dokumentów lub udziel zgody w aplikacji.",
+ "regrantHint": "Po ponownym udzieleniu zgody funkcje AI znów będą mogły wysyłać Twoje dane zdrowotne do skonfigurowanego dostawcy.",
+ "grant": "Udziel zgody",
+ "grantError": "Nie udało się zapisać zgody. Spróbuj ponownie.",
"error": "Nie udało się zapisać wycofania. Spróbuj ponownie."
}
},
diff --git a/src/app/api/consent/ai/web/__tests__/route.test.ts b/src/app/api/consent/ai/web/__tests__/route.test.ts
index 4915ca8d4..d646dcb1f 100644
--- a/src/app/api/consent/ai/web/__tests__/route.test.ts
+++ b/src/app/api/consent/ai/web/__tests__/route.test.ts
@@ -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();
+ });
});
diff --git a/src/app/api/consent/ai/web/route.ts b/src/app/api/consent/ai/web/route.ts
index 4a86bacf9..29014097e 100644
--- a/src/app/api/consent/ai/web/route.ts
+++ b/src/app/api/consent/ai/web/route.ts
@@ -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 } : {}),
},
});
diff --git a/src/components/settings/ai/__tests__/ai-consent-card.test.tsx b/src/components/settings/ai/__tests__/ai-consent-card.test.tsx
index 01a622c6f..444d2d2f2 100644
--- a/src/components/settings/ai/__tests__/ai-consent-card.test.tsx
+++ b/src/components/settings/ai/__tests__/ai-consent-card.test.tsx
@@ -50,7 +50,7 @@ describe("AiConsentCard", () => {
expect(html).toContain("Withdraw consent");
});
- it("reports a withdrawal and stops offering it again", () => {
+ it("reports a withdrawal, stops offering it again, and offers the grant", () => {
const html = renderWith({
id: "rcpt-1",
kind: "ai_full",
@@ -60,6 +60,11 @@ describe("AiConsentCard", () => {
expect(html).toContain("Withdrawn.");
expect(html).not.toContain('data-slot="ai-consent-withdraw"');
+ // Withdrawal must be as reversible as it was easy: the same surface
+ // carries the explicit grant control, and its hint says what granting
+ // lets the app do.
+ expect(html).toContain('data-slot="ai-consent-grant"');
+ expect(html).toContain("Grant consent");
});
it("treats no receipt at all as consent not given", () => {
@@ -67,6 +72,18 @@ describe("AiConsentCard", () => {
expect(html).toContain("Withdrawn.");
expect(html).not.toContain('data-slot="ai-consent-withdraw"');
+ expect(html).toContain('data-slot="ai-consent-grant"');
+ });
+
+ it("never shows the grant control while consent stands", () => {
+ const html = renderWith({
+ id: "rcpt-1",
+ kind: "ai_full",
+ signedAt: "2026-07-01T08:00:00.000Z",
+ revokedAt: null,
+ });
+
+ expect(html).not.toContain('data-slot="ai-consent-grant"');
});
it("renders nothing for a signed-out visitor", () => {
diff --git a/src/components/settings/ai/ai-consent-card.tsx b/src/components/settings/ai/ai-consent-card.tsx
index 7cca9f1e9..ac0f4ce51 100644
--- a/src/components/settings/ai/ai-consent-card.tsx
+++ b/src/components/settings/ai/ai-consent-card.tsx
@@ -10,11 +10,14 @@
* surface, not behind a support request.
*
* The card shows the standing decision in plain words and offers the one
- * move that changes it. Withdrawing takes effect immediately — the consent
- * gate fails closed without an active receipt, so every AI surface falls
- * back to its no-consent state on the next call. Nothing already stored is
- * deleted by this; that is the export/erase path in Data & Privacy, and the
- * copy says so rather than implying more than it does.
+ * move that changes it: withdraw while consent stands, grant while it does
+ * not. Withdrawing takes effect immediately: the consent gate fails closed
+ * without an active receipt, so every AI surface falls back to its
+ * no-consent state on the next call. Granting posts an affirmative intent,
+ * which is the consent act itself and therefore may supersede an earlier
+ * revocation; the silent mount heal never does. Nothing already stored is
+ * deleted by a withdrawal; that is the export/erase path in Data & Privacy,
+ * and the copy says so rather than implying more than it does.
* ──────────────────────────────────────────────────────────────── */
import { useState } from "react";
@@ -75,6 +78,29 @@ export function AiConsentCard({
},
});
+ const grant = useMutation({
+ mutationKey: queryKeys.aiConsentReceipt("ai_full"),
+ mutationFn: async () => {
+ // The affirmative intent marks this as the user's own consent act,
+ // the only web path that may lift a standing revocation. The silent
+ // mount heal posts no body and cannot.
+ const res = await apiFetchRaw("/api/consent/ai/web", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ intent: "affirmative" }),
+ });
+ if (!res.ok) throw new Error("grant failed");
+ },
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({
+ queryKey: queryKeys.aiConsentReceipt("ai_full"),
+ });
+ await queryClient.invalidateQueries({
+ queryKey: queryKeys.insightsProviderChain(),
+ });
+ },
+ });
+
// Say nothing until the state is known: a card that flashes "withdrawn"
// before the receipt arrives would misreport the user's own decision.
if (!isAuthenticated || isLoading) return null;
@@ -144,9 +170,24 @@ export function AiConsentCard({
)
) : (
-
+ ) : null}
);
}
diff --git a/src/lib/openapi/routes/consent.ts b/src/lib/openapi/routes/consent.ts
index 3de58f9a2..3fbe3be20 100644
--- a/src/lib/openapi/routes/consent.ts
+++ b/src/lib/openapi/routes/consent.ts
@@ -13,8 +13,10 @@
*
* - POST /api/consent/ai — explicit grant with a signed artefact (iOS).
* - POST /api/consent/ai/web — idempotent `ai_full` grant for the web
- * client (mints if none active; heals
- * existing accounts on the AI-settings mount).
+ * client. Without a body it is the AI-settings
+ * mount heal (never lifts a revocation); with
+ * `intent: "affirmative"` it is the explicit
+ * grant control on the same surface.
* - GET /api/consent/ai/latest — latest active receipt(s).
* - DELETE /api/consent/ai/latest — revoke the latest receipt (all kinds
* when `kind` is omitted — the master OFF).
@@ -22,7 +24,11 @@
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
-import { consentKindEnum, consentPostBody } from "@/lib/validations/consent";
+import {
+ consentKindEnum,
+ consentPostBody,
+ webConsentGrantBody,
+} from "@/lib/validations/consent";
import { dataEnvelope, stdResponses } from "./shared";
consentPostBody.meta({
@@ -31,6 +37,12 @@ consentPostBody.meta({
"Explicit AI-consent grant. `artefact` is an opaque signed receipt (base64 PDF or JWT, ≤ 64 KB UTF-8 bytes); `signedAt` is an ISO-8601 instant. Always appends a fresh row — re-granting after a revoke mints a new receipt.",
});
+webConsentGrantBody.meta({
+ id: "WebConsentGrantBody",
+ description:
+ 'Optional. Omitting the body (or sending `intent: "heal"`) is the AI-settings mount heal: it mints only for an account with no `ai_full` consent history at all, so a standing revocation is never resurrected. `intent: "affirmative"` is the user\'s own grant action and may supersede an earlier revocation, exactly as a first grant would.',
+});
+
const consentReceiptResponse = z
.object({
id: z.string(),
@@ -106,7 +118,13 @@ export const consentPaths: NonNullable = {
tags: ["Consent"],
summary: "Grant web AI consent (idempotent ai_full)",
description:
- "Mint an `ai_full` consent receipt for the calling web user if none is active; a no-op when one already exists. Mirrors the iOS master grant; the web AI-settings surface calls this on mount so existing web accounts on a shared-key deployment gain a receipt without a re-consent step. Revocation flows through DELETE /api/consent/ai/latest.",
+ 'Mint an `ai_full` consent receipt for the calling web user if none is active; a no-op when one already exists. Mirrors the iOS master grant. Without a body (the mount heal) it grants only to an account with no consent history, so a revocation stands until the user grants again; with `intent: "affirmative"` (the explicit grant control) it records the user\'s own consent act, which may supersede an earlier revocation. Revocation flows through DELETE /api/consent/ai/latest.',
+ requestBody: {
+ required: false,
+ content: {
+ "application/json": { schema: webConsentGrantBody },
+ },
+ },
responses: {
"200": {
description: "Grant outcome.",
diff --git a/src/lib/validations/consent.ts b/src/lib/validations/consent.ts
index e02ef2731..d0d4f1fb6 100644
--- a/src/lib/validations/consent.ts
+++ b/src/lib/validations/consent.ts
@@ -52,6 +52,22 @@ export const consentPostBody = z.object({
export type ConsentPostBody = z.infer;
+/**
+ * Optional body for `POST /api/consent/ai/web`. The AI-settings mount posts
+ * no body at all (a heal: mints only for an account with no consent history);
+ * the explicit re-grant control on the same surface posts
+ * `{ intent: "affirmative" }`, the user's own consent act, which may
+ * supersede an earlier revocation the way any first grant would. The enum
+ * must stay in lockstep with `WebConsentGrantIntent` in
+ * `src/lib/consent/web-grant.ts`; the route narrows the parsed value to that
+ * type, so a drift fails the typecheck.
+ */
+export const webConsentGrantBody = z.object({
+ intent: z.enum(["heal", "affirmative"]).default("heal"),
+});
+
+export type WebConsentGrantBodyInput = z.input;
+
/**
* Query schema for both `/api/consent/ai/latest` GET and DELETE.
*