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

+18 -4
View File
@@ -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.
+3 -1
View File
@@ -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."
}
},
+3 -1
View File
@@ -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."
}
},
+3 -1
View File
@@ -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."
}
},
+3 -1
View File
@@ -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."
}
},
+3 -1
View File
@@ -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."
}
},
+3 -1
View File
@@ -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."
}
},
@@ -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 } : {}),
},
});
@@ -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", () => {
+55 -8
View File
@@ -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({
</Button>
)
) : (
<p className="text-muted-foreground text-xs">
{t("settings.ai.consent.regrantHint")}
</p>
<div className="space-y-2">
<p className="text-muted-foreground text-xs">
{t("settings.ai.consent.regrantHint")}
</p>
<Button
type="button"
variant="outline"
size="sm"
data-slot="ai-consent-grant"
onClick={() => grant.mutate()}
disabled={grant.isPending}
>
{grant.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("settings.ai.consent.grant")}
</Button>
</div>
)}
{revoke.isError ? (
@@ -154,6 +195,12 @@ export function AiConsentCard({
{t("settings.ai.consent.error")}
</p>
) : null}
{grant.isError ? (
<p className="text-destructive text-xs" role="alert">
{t("settings.ai.consent.grantError")}
</p>
) : null}
</section>
);
}
+22 -4
View File
@@ -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<ZodOpenApiObject["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.",
'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.",
+16
View File
@@ -52,6 +52,22 @@ export const consentPostBody = z.object({
export type ConsentPostBody = z.infer<typeof consentPostBody>;
/**
* 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<typeof webConsentGrantBody>;
/**
* Query schema for both `/api/consent/ai/latest` GET and DELETE.
*