From 1c2cfffdb8e78c6388dd2f9c79e26904cf4a68f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 11:52:34 +0200 Subject: [PATCH] fix(consent): a withdrawn AI consent stays withdrawn, and the web can withdraw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings mount fires an idempotent consent heal so an account that predates the gate does not fall into the no-key state. It looked only for an active receipt, so a deliberate withdrawal — which sets revokedAt — read the same as never having consented, and the next visit to the AI settings granted again, writing an audit row for an act the user never performed. GDPR Art. 7 (3) puts withdrawal on the same footing as the grant; one undone by a page load is not a withdrawal. The mint now distinguishes the two callers. The mount heal grants only to an account with no consent history at all and leaves a revocation standing; switching document auto-read on is the consent act itself and may supersede an earlier revocation. The revocation is re-checked inside the transaction so a withdrawal landing mid-mint is not overwritten. The revoke endpoint has existed since v1.4.40 but only the native client called it: on the web, consent could be given and never taken back. The AI settings now carry the standing decision and the control that changes it, in all six languages. --- docs/api/openapi.yaml | 2 +- messages/de.json | 10 ++ messages/en.json | 10 ++ messages/es.json | 10 ++ messages/fr.json | 10 ++ messages/it.json | 10 ++ messages/pl.json | 10 ++ package.json | 2 +- public/sw.js | 2 +- .../auth/me/documents-auto-ai-read/route.ts | 2 +- .../consent/ai/web/__tests__/route.test.ts | 5 +- src/app/api/consent/ai/web/route.ts | 2 +- .../ai/__tests__/ai-consent-card.test.tsx | 84 +++++++++ .../settings/ai/ai-consent-card.tsx | 159 ++++++++++++++++++ .../settings/ai/ai-insights-card.tsx | 6 + src/lib/consent/__tests__/web-grant.test.ts | 93 +++++++++- src/lib/consent/web-grant.ts | 66 +++++++- src/lib/query-keys/settings.ts | 8 + 18 files changed, 473 insertions(+), 18 deletions(-) create mode 100644 src/components/settings/ai/__tests__/ai-consent-card.test.tsx create mode 100644 src/components/settings/ai/ai-consent-card.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 61dbe9a77..30d1a9028 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.30.31 + version: 1.30.32 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/messages/de.json b/messages/de.json index 4db496e0c..ad9268086 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4999,6 +4999,16 @@ "honesty": "Der gemeinsame Zugang ist ein einzelnes angemeldetes KI-Konto, das der Betreiber für alle auf diesem Server verbunden hat — nicht dein eigener Schlüssel. Anfragen darüber werden vom Anbieter standardmäßig zum Training genutzt und von keiner Auftragsverarbeitungsvereinbarung gedeckt, die Stunden- und Wochenlimits des Kontos teilst du dir mit allen anderen, die zugestimmt haben, und deine eigenen Anbieter werden weiterhin zuerst versucht. Aktiviere es nur, wenn du diesen Kompromiss akzeptierst.", "confirm": "Verstanden — gemeinsame Verbindung nutzen", "cancel": "Abbrechen" + }, + "consent": { + "title": "KI-Einwilligung", + "activeSince": "Erteilt am {date}. Deine Gesundheitsdaten dürfen für die KI-Funktionen an den eingestellten Anbieter gesendet werden.", + "withdrawn": "Widerrufen. Es werden keine Gesundheitsdaten an einen KI-Anbieter gesendet; die KI-Funktionen bleiben aus.", + "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.", + "error": "Der Widerruf konnte nicht gespeichert werden. Bitte versuche es erneut." } }, "dashboard": { diff --git a/messages/en.json b/messages/en.json index f51db32a0..c78f6c78c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4999,6 +4999,16 @@ "honesty": "The shared access is a single signed-in AI account the operator connected for everyone on this server — not your own key. Requests you make on it are trained on by the provider by default and no data-processing agreement covers them, the account's hourly and weekly limits are shared with every other user who opted in, and your own configured providers are still tried first. Turn it on only if you accept this trade.", "confirm": "I understand — use the shared connection", "cancel": "Cancel" + }, + "consent": { + "title": "AI consent", + "activeSince": "Given on {date}. Your health data may be sent to the configured provider for the AI features.", + "withdrawn": "Withdrawn. No health data is sent to an AI provider; the AI features stay switched off.", + "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.", + "error": "The withdrawal could not be saved. Please try again." } }, "dashboard": { diff --git a/messages/es.json b/messages/es.json index f389b4aa7..7e77e5ed2 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4999,6 +4999,16 @@ "honesty": "El acceso compartido es una única cuenta de IA con sesión iniciada que el operador conectó para todos en este servidor, no tu propia clave. Las solicitudes que hagas se usan de forma predeterminada para entrenar los modelos del proveedor y ningún acuerdo de tratamiento de datos las cubre, los límites por hora y semanales de la cuenta se comparten con todos los demás que se han unido, y tus propios proveedores se prueban primero. Actívalo solo si aceptas este compromiso.", "confirm": "Entendido: usar la conexión compartida", "cancel": "Cancelar" + }, + "consent": { + "title": "Consentimiento de IA", + "activeSince": "Otorgado el {date}. Tus datos de salud pueden enviarse al proveedor configurado para las funciones de IA.", + "withdrawn": "Retirado. No se envían datos de salud a ningún proveedor de IA; las funciones de IA permanecen desactivadas.", + "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.", + "error": "No se pudo guardar la retirada. Inténtalo de nuevo." } }, "dashboard": { diff --git a/messages/fr.json b/messages/fr.json index 3fefa494d..a390a4c5f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4999,6 +4999,16 @@ "honesty": "L'accès partagé est un unique compte IA connecté que l'opérateur a relié pour tout le monde sur ce serveur, et non votre propre clé. Les requêtes que vous y faites servent par défaut à entraîner les modèles du fournisseur et aucun accord de traitement des données ne les couvre, les limites horaires et hebdomadaires du compte sont partagées avec tous les autres qui ont accepté, et vos propres fournisseurs sont essayés en premier. Ne l'activez que si vous acceptez ce compromis.", "confirm": "J'ai compris — utiliser la connexion partagée", "cancel": "Annuler" + }, + "consent": { + "title": "Consentement IA", + "activeSince": "Donné le {date}. Tes données de santé peuvent être transmises au fournisseur configuré pour les fonctions IA.", + "withdrawn": "Retiré. Aucune donnée de santé n'est transmise à un fournisseur IA ; les fonctions IA restent désactivées.", + "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.", + "error": "Le retrait n'a pas pu être enregistré. Réessaie." } }, "dashboard": { diff --git a/messages/it.json b/messages/it.json index d384c10fd..827c43c5f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4999,6 +4999,16 @@ "honesty": "L'accesso condiviso è un unico account IA con accesso eseguito che l'operatore ha collegato per tutti su questo server, non la tua chiave. Le richieste che vi effettui vengono usate per impostazione predefinita per addestrare i modelli del provider e nessun accordo sul trattamento dei dati le copre, i limiti orari e settimanali dell'account sono condivisi con tutti gli altri che hanno aderito, e i tuoi provider vengono comunque provati per primi. Attivalo solo se accetti questo compromesso.", "confirm": "Ho capito — usa la connessione condivisa", "cancel": "Annulla" + }, + "consent": { + "title": "Consenso IA", + "activeSince": "Concesso il {date}. I tuoi dati sanitari possono essere inviati al provider configurato per le funzioni IA.", + "withdrawn": "Revocato. Nessun dato sanitario viene inviato a un provider IA; le funzioni IA restano disattivate.", + "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.", + "error": "Non è stato possibile salvare la revoca. Riprova." } }, "dashboard": { diff --git a/messages/pl.json b/messages/pl.json index d8ddc0f3d..a43e94b7f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4999,6 +4999,16 @@ "honesty": "Wspólny dostęp to jedno zalogowane konto AI, które operator podłączył dla wszystkich na tym serwerze — nie Twój własny klucz. Zapytania, które przez nie wysyłasz, są domyślnie wykorzystywane do trenowania modeli dostawcy i nie obejmuje ich żadna umowa powierzenia przetwarzania danych, godzinowe i tygodniowe limity konta dzielisz z wszystkimi innymi, którzy się zgodzili, a Twoi właśni dostawcy są próbowani jako pierwsi. Włącz to tylko, jeśli akceptujesz ten kompromis.", "confirm": "Rozumiem — użyj wspólnego połączenia", "cancel": "Anuluj" + }, + "consent": { + "title": "Zgoda na AI", + "activeSince": "Udzielona {date}. Twoje dane zdrowotne mogą być wysyłane do skonfigurowanego dostawcy na potrzeby funkcji AI.", + "withdrawn": "Wycofana. Żadne dane zdrowotne nie są wysyłane do dostawcy AI; funkcje AI pozostają wyłączone.", + "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.", + "error": "Nie udało się zapisać wycofania. Spróbuj ponownie." } }, "dashboard": { diff --git a/package.json b/package.json index 31ea6793e..b1653770f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.30.31", + "version": "1.30.32", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 69ce584f4..d35934c1b 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.30.31"; + /* @sw-version-fallback */ "v1.30.32"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/auth/me/documents-auto-ai-read/route.ts b/src/app/api/auth/me/documents-auto-ai-read/route.ts index 4bd8f1a62..03eb31123 100644 --- a/src/app/api/auth/me/documents-auto-ai-read/route.ts +++ b/src/app/api/auth/me/documents-auto-ai-read/route.ts @@ -107,7 +107,7 @@ export const PATCH = apiHandler(async (req: Request) => { // `ai_full` receipt (idempotent) so the durable audit trail records it, in // addition to the runtime short-circuit the document consent gate reads. if (next) { - await ensureWebAiConsentReceipt(user.id); + await ensureWebAiConsentReceipt(user.id, "affirmative"); } await auditLog("user.documentsAutoAiRead.update", { 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 08a3050b2..4915ca8d4 100644 --- a/src/app/api/consent/ai/web/__tests__/route.test.ts +++ b/src/app/api/consent/ai/web/__tests__/route.test.ts @@ -90,7 +90,10 @@ describe("POST /api/consent/ai/web", () => { 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({ minted: false }); + vi.mocked(ensureWebAiConsentReceipt).mockResolvedValue({ + minted: false, + reason: "already_active", + }); const res = await POST(); expect(res.status).toBe(200); diff --git a/src/app/api/consent/ai/web/route.ts b/src/app/api/consent/ai/web/route.ts index 1e1e14af3..4a86bacf9 100644 --- a/src/app/api/consent/ai/web/route.ts +++ b/src/app/api/consent/ai/web/route.ts @@ -37,7 +37,7 @@ export const POST = apiHandler(async () => { }); } - const result = await ensureWebAiConsentReceipt(user.id); + const result = await ensureWebAiConsentReceipt(user.id, "heal"); if (result.minted) { // Audit trail parity with the iOS-driven POST /api/consent/ai grant so diff --git a/src/components/settings/ai/__tests__/ai-consent-card.test.tsx b/src/components/settings/ai/__tests__/ai-consent-card.test.tsx new file mode 100644 index 000000000..01a622c6f --- /dev/null +++ b/src/components/settings/ai/__tests__/ai-consent-card.test.tsx @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { AiConsentCard } from "../ai-consent-card"; + +/** + * The web withdrawal affordance. Before this card the revoke endpoint was + * reachable only from the native client, so a web-only account could give + * AI consent and never take it back. + * + * These render the card against a pre-seeded query cache (server-side + * markup, so the mutation path is not exercised here — the endpoint it + * calls has its own route tests). What they pin is that the card reports + * the standing decision correctly and offers the withdrawal only when + * there is something to withdraw. + */ + +const receiptKey = ["consent", "ai", "latest", "ai_full"]; + +function renderWith(receipt: unknown) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + client.setQueryData(receiptKey, receipt); + return renderToStaticMarkup( + + + + + , + ); +} + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +describe("AiConsentCard", () => { + it("offers the withdrawal while consent stands", () => { + const html = renderWith({ + id: "rcpt-1", + kind: "ai_full", + signedAt: "2026-07-01T08:00:00.000Z", + revokedAt: null, + }); + + expect(html).toContain('data-slot="ai-consent-withdraw"'); + expect(html).toContain("Withdraw consent"); + }); + + it("reports a withdrawal and stops offering it again", () => { + const html = renderWith({ + id: "rcpt-1", + kind: "ai_full", + signedAt: "2026-07-01T08:00:00.000Z", + revokedAt: "2026-07-10T08:00:00.000Z", + }); + + expect(html).toContain("Withdrawn."); + expect(html).not.toContain('data-slot="ai-consent-withdraw"'); + }); + + it("treats no receipt at all as consent not given", () => { + const html = renderWith(null); + + expect(html).toContain("Withdrawn."); + expect(html).not.toContain('data-slot="ai-consent-withdraw"'); + }); + + it("renders nothing for a signed-out visitor", () => { + const client = new QueryClient(); + const html = renderToStaticMarkup( + + + + + , + ); + + expect(html).toBe(""); + }); +}); diff --git a/src/components/settings/ai/ai-consent-card.tsx b/src/components/settings/ai/ai-consent-card.tsx new file mode 100644 index 000000000..7cca9f1e9 --- /dev/null +++ b/src/components/settings/ai/ai-consent-card.tsx @@ -0,0 +1,159 @@ +"use client"; + +/* ──────────────────────────────────────────────────────────────── + * Standing AI consent — the web side of the withdrawal. + * + * The revoke endpoint (`DELETE /api/consent/ai/latest?kind=ai_full`) has + * existed since v1.4.40, but only the native client ever called it: on the + * web, consent could be given and never taken back. GDPR Art. 7 (3) puts + * withdrawal on the same footing as the grant, so it belongs on the same + * 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. + * ──────────────────────────────────────────────────────────────── */ + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Loader2, ShieldCheck, ShieldOff } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { formatDateTime } from "@/lib/format"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +type ConsentReceiptWire = { + id: string; + kind: string; + signedAt: string; + revokedAt: string | null; +} | null; + +export function AiConsentCard({ + isAuthenticated, +}: { + isAuthenticated: boolean; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [confirming, setConfirming] = useState(false); + + const { data, isLoading } = useQuery({ + queryKey: queryKeys.aiConsentReceipt("ai_full"), + queryFn: async () => { + const res = await apiFetchRaw("/api/consent/ai/latest?kind=ai_full"); + if (!res.ok) return null; + const json = await res.json(); + return (json.data?.receipt ?? null) as ConsentReceiptWire; + }, + enabled: isAuthenticated, + }); + + const revoke = useMutation({ + mutationKey: queryKeys.aiConsentReceipt("ai_full"), + mutationFn: async () => { + const res = await apiFetchRaw("/api/consent/ai/latest?kind=ai_full", { + method: "DELETE", + }); + if (!res.ok) throw new Error("revoke failed"); + }, + onSuccess: async () => { + setConfirming(false); + // The receipt gates every AI surface, so anything that reads consent + // state has to re-resolve — not just this card. + 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; + + // Narrow to the receipt itself rather than a boolean, so the branches + // below can read its fields without an assertion. + const activeReceipt = data && data.revokedAt === null ? data : null; + + return ( +
+
+

+ {activeReceipt ? ( + + ) : ( + + )} + {t("settings.ai.consent.title")} +

+

+ {activeReceipt + ? t("settings.ai.consent.activeSince", { + date: formatDateTime(activeReceipt.signedAt), + }) + : t("settings.ai.consent.withdrawn")} +

+
+ + {activeReceipt ? ( + confirming ? ( +
+

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

+ + +
+ ) : ( + + ) + ) : ( +

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

+ )} + + {revoke.isError ? ( +

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

+ ) : null} +
+ ); +} diff --git a/src/components/settings/ai/ai-insights-card.tsx b/src/components/settings/ai/ai-insights-card.tsx index ff7efb9b6..47bcc65c5 100644 --- a/src/components/settings/ai/ai-insights-card.tsx +++ b/src/components/settings/ai/ai-insights-card.tsx @@ -30,6 +30,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { AdminOpenAIProviderForm } from "./admin-openai-provider-form"; +import { AiConsentCard } from "./ai-consent-card"; import { AnthropicProviderForm } from "./anthropic-provider-form"; import { AutoReadCard } from "./auto-read-card"; import { CentralCodexSwitch } from "./central-codex-switch"; @@ -186,6 +187,11 @@ export function AiInsightsCard({ renders when the operator has connected it). */} + {/* The standing consent and the one control that withdraws it. It + sits last because it governs everything above rather than + configuring any single provider. */} + + { vi.mocked(prisma.consentReceipt.create).mockResolvedValue(row() as never); const now = new Date("2026-06-14T10:00:00.000Z"); - const result = await ensureWebAiConsentReceipt("user-1", now); + const result = await ensureWebAiConsentReceipt("user-1", "heal", now); expect(result.minted).toBe(true); // Reads the active master grant first (fast-path + in-tx re-check both @@ -82,7 +82,7 @@ describe("ensureWebAiConsentReceipt", () => { row() as never, ); - const result = await ensureWebAiConsentReceipt("user-1"); + const result = await ensureWebAiConsentReceipt("user-1", "heal"); expect(result.minted).toBe(false); expect(prisma.consentReceipt.create).not.toHaveBeenCalled(); @@ -98,7 +98,7 @@ describe("ensureWebAiConsentReceipt", () => { .mockResolvedValueOnce(null) // fast path .mockResolvedValueOnce(row() as never); // in-tx re-check - const result = await ensureWebAiConsentReceipt("user-1"); + const result = await ensureWebAiConsentReceipt("user-1", "heal"); expect(result.minted).toBe(false); expect(prisma.consentReceipt.create).not.toHaveBeenCalled(); @@ -113,7 +113,7 @@ describe("ensureWebAiConsentReceipt", () => { code: "P2002", } as never); - const result = await ensureWebAiConsentReceipt("user-1"); + const result = await ensureWebAiConsentReceipt("user-1", "heal"); expect(result.minted).toBe(false); }); @@ -124,7 +124,7 @@ describe("ensureWebAiConsentReceipt", () => { new Error("connection reset") as never, ); - await expect(ensureWebAiConsentReceipt("user-1")).rejects.toThrow( + await expect(ensureWebAiConsentReceipt("user-1", "heal")).rejects.toThrow( "connection reset", ); }); @@ -157,8 +157,8 @@ describe("ensureWebAiConsentReceipt — two concurrent grants", () => { }); const [a, b] = await Promise.all([ - ensureWebAiConsentReceipt("user-1"), - ensureWebAiConsentReceipt("user-1"), + ensureWebAiConsentReceipt("user-1", "heal"), + ensureWebAiConsentReceipt("user-1", "heal"), ]); // Exactly one mint, exactly one row inserted. @@ -167,3 +167,82 @@ describe("ensureWebAiConsentReceipt — two concurrent grants", () => { expect(prisma.consentReceipt.create).toHaveBeenCalledTimes(1); }); }); + +describe("ensureWebAiConsentReceipt — a revocation is a standing decision", () => { + // The mount heal and the revocation check share one `findFirst` mock, so + // route by predicate: `revokedAt: null` is the active-grant lookup, + // `revokedAt: { not: null }` is the revocation-history lookup. + function arrange({ hasRevoked }: { hasRevoked: boolean }) { + vi.mocked(prisma.consentReceipt.findFirst).mockImplementation( + (async (args: { where: { revokedAt?: unknown } }) => { + const wantsRevoked = + args.where.revokedAt !== null && args.where.revokedAt !== undefined; + if (wantsRevoked) + return hasRevoked + ? (row({ + revokedAt: new Date("2026-07-01T09:00:00.000Z"), + }) as never) + : null; + return null; + }) as never, + ); + vi.mocked(prisma.consentReceipt.create).mockResolvedValue(row() as never); + } + + it("does not re-grant on the settings mount after the user revoked", async () => { + arrange({ hasRevoked: true }); + + const result = await ensureWebAiConsentReceipt("user-1", "heal"); + + expect(result).toEqual({ minted: false, reason: "previously_revoked" }); + expect(prisma.consentReceipt.create).not.toHaveBeenCalled(); + // No transaction is opened — the decision is settled on the read. + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it("still heals an account that never had a receipt at all", async () => { + arrange({ hasRevoked: false }); + + const result = await ensureWebAiConsentReceipt("user-1", "heal"); + + expect(result.minted).toBe(true); + expect(prisma.consentReceipt.create).toHaveBeenCalledTimes(1); + }); + + it("lets the user grant again through an affirmative act", async () => { + arrange({ hasRevoked: true }); + + const result = await ensureWebAiConsentReceipt("user-1", "affirmative"); + + expect(result.minted).toBe(true); + expect(prisma.consentReceipt.create).toHaveBeenCalledTimes(1); + }); + + it("holds the line when the revocation lands mid-transaction", async () => { + // The fast-path reads see a clean history; the revocation appears only + // once the transaction is open. Without the in-transaction re-check the + // mint would overwrite a decision made a moment earlier. + let inTransaction = false; + $transaction.mockImplementation((fn: TxFn) => { + inTransaction = true; + return fn({ consentReceipt: prisma.consentReceipt }); + }); + vi.mocked(prisma.consentReceipt.findFirst).mockImplementation( + (async (args: { where: { revokedAt?: unknown } }) => { + const wantsRevoked = + args.where.revokedAt !== null && args.where.revokedAt !== undefined; + if (wantsRevoked && inTransaction) + return row({ + revokedAt: new Date("2026-07-01T09:00:00.000Z"), + }) as never; + return null; + }) as never, + ); + vi.mocked(prisma.consentReceipt.create).mockResolvedValue(row() as never); + + const result = await ensureWebAiConsentReceipt("user-1", "heal"); + + expect(result).toEqual({ minted: false, reason: "previously_revoked" }); + expect(prisma.consentReceipt.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/consent/web-grant.ts b/src/lib/consent/web-grant.ts index b9d5f864d..2a122207d 100644 --- a/src/lib/consent/web-grant.ts +++ b/src/lib/consent/web-grant.ts @@ -16,6 +16,17 @@ * shell-mount heal). Explicit revocation flows through the existing * `DELETE /api/consent/ai/latest`; this helper only ever grants. * + * It heals an account that never gave consent — never one that took it + * back. "No active receipt" has two very different causes: an account that + * predates the gate and has no receipt at all, and an account whose owner + * deliberately revoked. Both look identical to a `revokedAt: null` lookup, + * and healing the second one re-grants a consent the user withdrew, writing + * a `consent.ai.grant` audit row for an action they never took. GDPR Art. 7 + * (3) requires withdrawal to be as easy as granting; a withdrawal undone by + * the next page visit is not a withdrawal. So the mint is gated on the + * account having no revoked `ai_full` receipt in its history: a prior + * revocation is a standing decision, and only an explicit grant may lift it. + * * The artefact is a small JSON record of the web grant context (no signed * PDF/JWT — the web grant is an in-app affirmative action recorded with its * timestamp + source, which is the GDPR Art. 7 audit signal the legal team @@ -27,9 +38,30 @@ import { isP2002 } from "@/lib/prisma-errors"; import { latestActiveReceipt } from "@/lib/consent/receipts"; import type { ConsentReceipt } from "@/lib/consent/receipts"; -/** Outcome of an idempotent web-grant call. */ +/** + * Outcome of an idempotent web-grant call. `minted: false` carries a reason + * so callers can tell "already granted" apart from "withheld because the + * user revoked" — the two are the same non-event to the UI, but only the + * second one is worth an audit annotation. + */ export type WebConsentGrantResult = - { minted: true; receipt: ConsentReceipt } | { minted: false }; + | { minted: true; receipt: ConsentReceipt } + | { minted: false; reason: "already_active" | "previously_revoked" }; + +/** + * How the call came about. The distinction decides whether a past revocation + * blocks the mint, so it is a required argument rather than a default — + * a caller must say which one it is. + * + * `heal` — fired by the AI-settings mount, not by a user gesture. + * Grants only to an account that has no consent history at + * all. A revocation stands. + * `affirmative`— the user just performed the opt-in itself (switching the + * document auto-read on). This IS the consent act, so it + * supersedes an earlier revocation, exactly as the first + * grant would. + */ +export type WebConsentGrantIntent = "heal" | "affirmative"; /** * Ensure the web user has an active `ai_full` consent receipt, minting one @@ -42,13 +74,25 @@ export type WebConsentGrantResult = */ export async function ensureWebAiConsentReceipt( userId: string, + intent: WebConsentGrantIntent, now: Date = new Date(), ): Promise { // Fast path: a cheap read outside the transaction short-circuits the // common already-granted case (every mount after the first) without // opening a transaction. const existing = await latestActiveReceipt(userId, "ai_full"); - if (existing) return { minted: false }; + if (existing) return { minted: false, reason: "already_active" }; + + // A revoked receipt anywhere in this account's history means the user made + // a decision. Healing past it would re-grant what they withdrew. An + // affirmative act is the user deciding again, so it may proceed. + if (intent === "heal") { + const revoked = await prisma.consentReceipt.findFirst({ + where: { userId, kind: "ai_full", revokedAt: { not: null } }, + select: { id: true }, + }); + if (revoked) return { minted: false, reason: "previously_revoked" }; + } const artefact = JSON.stringify({ source: "web", @@ -71,7 +115,19 @@ export async function ensureWebAiConsentReceipt( where: { userId, kind: "ai_full", revokedAt: null }, orderBy: { createdAt: "desc" }, }); - if (inTx) return { minted: false } as const; + if (inTx) return { minted: false, reason: "already_active" } as const; + + // Re-check the revocation inside the transaction too: a revoke can + // land between the fast-path read and here, and without this the + // mint would overwrite a decision made a moment earlier. + if (intent === "heal") { + const revokedInTx = await tx.consentReceipt.findFirst({ + where: { userId, kind: "ai_full", revokedAt: { not: null } }, + select: { id: true }, + }); + if (revokedInTx) + return { minted: false, reason: "previously_revoked" } as const; + } const receipt = await tx.consentReceipt.create({ data: { userId, kind: "ai_full", artefact, signedAt: now }, @@ -82,7 +138,7 @@ export async function ensureWebAiConsentReceipt( ); } catch (err) { // Partial-unique violation — a concurrent grant landed first. - if (isP2002(err)) return { minted: false }; + if (isP2002(err)) return { minted: false, reason: "already_active" }; throw err; } } diff --git a/src/lib/query-keys/settings.ts b/src/lib/query-keys/settings.ts index 4261a02a2..91d44dc20 100644 --- a/src/lib/query-keys/settings.ts +++ b/src/lib/query-keys/settings.ts @@ -47,4 +47,12 @@ export const settingsKeys = { shareLinks: () => ["share-links"] as const, featureFlags: () => ["feature-flags"] as const, + + /** + * The account's standing AI consent receipt (`GET /api/consent/ai/latest`). + * Keyed by kind because the endpoint answers per kind and a shared key + * across kinds would serve one kind's receipt for another. + */ + aiConsentReceipt: (kind: string) => + ["consent", "ai", "latest", kind] as const, };