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({ ) ) : ( -

- {t("settings.ai.consent.regrantHint")} -

+
+

+ {t("settings.ai.consent.regrantHint")} +

+ +
)} {revoke.isError ? ( @@ -154,6 +195,12 @@ export function AiConsentCard({ {t("settings.ai.consent.error")}

) : null} + + {grant.isError ? ( +

+ {t("settings.ai.consent.grantError")} +

+ ) : 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. *