fix(consent): mint AI consent receipts only on an explicit agreement

The AI settings page posted to /api/consent/ai/web on every mount and the
server minted an ai_full receipt for it, so an account that had agreed to
nothing sent its health data to the operator's provider from background
jobs. Native builds synced a receipt the same way on every launch.

- Both grant routes now require `confirm: true`; anything else writes
  nothing and answers 400 consent.ai.confirmRequired.
- Remove the mount-time heal. A consent card names the operator's service
  and the data that would go there, with one Agree button, in Settings > AI
  and on the Insights overview while AI is closed by the missing consent
  alone.
- Publish the standing consent as `ai.consent` on /api/auth/me (status,
  since, recipients); the settings card renders active-since and the
  withdraw control from it.
- Existing receipts stay valid.
- Guard that no client grants outside a click handler, route tests for the
  confirmation, and an integration test that a fresh operator-key account
  gets no background AI text until it agrees.
This commit is contained in:
Marc-André Bombeck committed 2026-10-16 17:11:04 +02:00
1 parent bc5ea5a67b
commit e2b7e7350e
45 files changed
+1707 -678

No files matched your search

+111 -44
View File
@@ -14473,8 +14473,16 @@ paths:
tags:
- Consent
summary: Grant AI consent (signed artefact)
description: Persist a fresh AI-consent receipt. Required before server-managed AI egress (the operator's global LLM
key); BYOK / local / ChatGPT-OAuth chains are the user's own egress and are not gated. Append-only audit trail.
description: >-
Persist a fresh AI-consent receipt. Required before server-managed AI egress (the operator's global LLM key or
shared ChatGPT account); BYOK / local / ChatGPT-OAuth chains are the user's own egress and are not gated.
Append-only audit trail.
v1.43.2: the body must carry `confirm: true`, sent only when the person taps an agree control that named the
service and the data. A request without it (a launch-time sync from an older app build) writes nothing and
answers 400 with `meta.errorCode: consent.ai.confirmRequired`; such a client reads the standing consent from
`ai.consent` on GET /api/auth/me instead of re-minting it.
requestBody:
required: true
content:
@@ -14489,7 +14497,8 @@ paths:
schema:
$ref: "#/components/schemas/ConsentReceiptResponseEnvelope"
"400":
description: Request validation failed (consent routes use 400).
description: "Request validation failed (consent routes use 400), or `confirm` was not `true` (`meta.errorCode:
consent.ai.confirmRequired`, nothing written)."
content:
application/json:
schema:
@@ -14501,14 +14510,14 @@ paths:
post:
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. 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."
summary: Grant web AI consent (explicit ai_full)
description: "Record the calling web user's explicit `ai_full` consent: they pressed the agree control on a card that
named the service and the data. Mints a receipt if none is active; a no-op when one already exists. As the
person's own consent act it may follow an earlier withdrawal. The body must be `{ \"confirm\": true }`; anything
else mints nothing and answers 400 `consent.ai.confirmRequired`. Revocation flows through DELETE
/api/consent/ai/latest."
requestBody:
required: false
required: true
content:
application/json:
schema:
@@ -14520,6 +14529,12 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/WebConsentGrantResponseEnvelope"
"400":
description: "`confirm` was not `true` (`meta.errorCode: consent.ai.confirmRequired`). Nothing was written."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"401": *a1
"422": *a4
"429": *a5
@@ -31265,6 +31280,8 @@ components:
- ai_insights_only
- ai_coach
- ai_extraction
confirm:
type: boolean
artefact:
type: string
minLength: 1
@@ -31276,21 +31293,19 @@ components:
- kind
- artefact
- signedAt
description: 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.
description: "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. `confirm` must be `true` (v1.43.2): send this body only from the
person's own tap on an agree control, never from a launch or sync path. Without it nothing is written and the
route answers 400 `consent.ai.confirmRequired`. Always appends a fresh row — re-granting after a revoke mints a
new receipt."
WebConsentGrantBody:
type: object
properties:
intent:
default: heal
type: string
enum:
- heal
- affirmative
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."
confirm:
type: boolean
description: "`confirm` must be `true`: the person pressed the agree control. Any other body, or none, mints nothing and
answers 400 `consent.ai.confirmRequired` (v1.43.2; before, an empty body was a silent heal sent on every
AI-settings visit)."
JsonImportRequest:
type: object
properties:
@@ -36088,26 +36103,27 @@ components:
coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed,
coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid,
coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape;
consent — consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid,
cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid,
cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit;
cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach —
disable-coach.body.invalid_json; dawarich — dawarich.credentials_rejected, dawarich.home_place_unknown,
dawarich.invalid, dawarich.not_connected, dawarich.rate_limited, dawarich.unreachable; day —
day.invalid; demo — demo.readOnly; documents — documents.chat.conversationNotFound,
documents.chat.invalid, documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed,
documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound,
documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound,
documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge,
documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery,
documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notFound,
documents.inbound.notIndexed, documents.inbound.pdfNeedsAnthropic,
documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited,
documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.sourceAliasLimit,
documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound,
documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly,
documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected,
documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed,
consent — consent.ai.confirmRequired, consent.ai.required; cycle — cycle.bulk.invalid,
cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, cycle.cycles.invalid,
cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, cycle.period.invalid,
cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — cycle-prefs.body.invalid_json,
cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; dawarich —
dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid, dawarich.not_connected,
dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo — demo.readOnly; documents —
documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed,
documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded,
documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound,
documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending,
documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, documents.inbound.fileType,
documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate,
documents.inbound.localOcrDisabled, documents.inbound.notFound, documents.inbound.notIndexed,
documents.inbound.pdfNeedsAnthropic, documents.inbound.providerUnsupported,
documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate,
documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, documents.inbound.uploadBusy,
documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, documents.sources.authRefused,
documents.sources.badResponse, documents.sources.browserOnly, documents.sources.invalidAddress,
documents.sources.linkTargetNotFound, documents.sources.notConnected, documents.sources.notFound,
documents.sources.organizationRequired, documents.sources.originNotAllowed,
documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected,
documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable,
documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid,
@@ -68816,7 +68832,8 @@ components:
properties:
minted:
type: boolean
description: True when a new `ai_full` receipt was minted; false when an active one already existed (idempotent no-op).
description: True when a new `ai_full` receipt was minted; false when an active one already existed (a double tap is a
no-op).
kind:
type: string
const: ai_full
@@ -68824,7 +68841,7 @@ components:
- minted
- kind
additionalProperties: false
description: Outcome of the idempotent web `ai_full` grant. Safe to call on every AI-settings mount.
description: Outcome of the explicit web `ai_full` grant.
ConsentLatestForKindEnvelope:
type: object
properties:
@@ -80860,12 +80877,15 @@ components:
$ref: "#/components/schemas/AiCapabilities"
provider:
$ref: "#/components/schemas/AiProviderState"
consent:
$ref: "#/components/schemas/AiConsentState"
usage:
description: v1.43 — the account's AI usage level. Absent on older servers; hide the setting then.
$ref: "#/components/schemas/AiUsageState"
required:
- capabilities
- provider
- consent
additionalProperties: false
description: Which AI capabilities the record this session is inside has, and why not when it has none. Resolved on the
server for the ACTIVE RECORD and masked to the sections the active grant opens, exactly like `modules` and
@@ -80964,6 +80984,53 @@ components:
- responseTimeoutMs
additionalProperties: false
description: The account-level companion to the capability map.
AiConsentState:
type: object
properties:
status:
type: string
enum:
- given
- needed
- not_needed
description: "`given` — an `ai_full` consent receipt is active (`since` says when); show it with a withdraw control.
`needed` — the provider chain runs through a credential the operator holds and at least one capability is
closed by the missing receipt alone (its reason is `consent_required`); ask, naming `recipients`, and grant
only from the person's tap. `not_needed` — nothing to ask and nothing to withdraw."
since:
description: When the active `ai_full` consent was given, ISO-8601. `null` unless `status` is `given`.
type:
- string
- "null"
recipients:
type: array
items:
type: object
properties:
via:
type: string
enum:
- operator_key
- operator_chatgpt
description: "`operator_key` — the operator's API key. `operator_chatgpt` — the operator's shared ChatGPT account."
name:
type: string
description: "The service, named for a person: `OpenAI`, `Anthropic`, `ChatGPT (OpenAI)`, or the host name of the
endpoint the operator set."
required:
- via
- name
additionalProperties: false
description: The operator-held services in the record's provider chain, in chain order. Empty when the chain holds none.
required:
- status
- since
- recipients
additionalProperties: false
description: "v1.43.2 — the standing AI consent for the record. Consent is asked only when data would go through a
credential the operator holds; a person's own key, ChatGPT account or local model needs none. Render from it; a
client never mints a receipt on its own (launch, sync or mount): POST /api/consent/ai and /api/consent/ai/web
require `confirm: true`."
AiUsageState:
type: object
properties:
+5 -1
View File
@@ -74,7 +74,11 @@ test("J5: withdrawing AI consent deletes the notes written under it", async ({
);
await page.locator('[data-slot="ai-consent-withdraw-confirm"]').click();
expect((await withdrawn).status()).toBe(200);
await expect(page.locator('[data-slot="ai-consent-grant"]')).toBeVisible();
// On a local model nothing needs the consent, so once withdrawn there is
// nothing to withdraw and nothing to ask.
await expect(page.locator('[data-slot="ai-consent-withdraw"]')).toHaveCount(
0,
);
await openOverview(page);
await expect(page.getByText(marker)).toHaveCount(0);
-8
View File
@@ -89,14 +89,6 @@ test.describe("central codex opt-in switch (mocked)", () => {
}),
);
await page.route("**/api/consent/ai/web", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: { ok: true }, error: null }),
}),
);
// The opt-in write — flips the mocked state so a re-read reflects it.
await page.route("**/api/auth/me/use-central-codex", (route) => {
useCentralCodex = true;
+10 -3
View File
@@ -6357,18 +6357,25 @@
"consent": {
"title": "KI-Einwilligung",
"activeSince": "Erteilt am {date}: Deine Gesundheitsdaten dürfen für die KI-Funktionen an den eingestellten Anbieter gehen.",
"withdrawn": "Widerrufen. Sofern keine andere Einwilligung sie abdeckt, nutzen Coach und Insights den KI-Zugang dieses Servers nicht mehr, und kein Online-Modell liest deine Dokumente. Mit deinem eigenen Schlüssel oder einem lokalen Modell laufen Coach und Insights weiter.",
"withdraw": "Einwilligung widerrufen",
"confirmBody": "Sofern keine andere Einwilligung sie noch abdeckt, endet sofort: der Coach und die Insights, wo sie den KI-Zugang dieses Servers nutzen, und das Lesen von Dokumenten mit einem Online-Modell. Mit deinem eigenen Schlüssel oder einem lokalen Modell laufen Coach und Insights weiter. Sofern keine andere Einwilligung die Insights noch abdeckt, werden die Notizen gelöscht, die ein Modell für dich geschrieben hat: Statusnotizen, das tägliche Briefing, von einem Modell geschriebene Zeitraum-Zusammenfassungen, Ankunftszeilen und Trainingsnotizen. Deine Coach-Gespräche, was sich der Coach gemerkt hat, deine Pläne und deine Dokument-Zusammenfassungen bleiben, und du kannst sie selbst lesen oder löschen.",
"confirmWithdraw": "Widerrufen",
"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.",
"documentReading": {
"title": "Einwilligung zum Lesen von Dokumenten",
"activeSince": "Erteilt am {date}: Dokumente, Laborscans und Medikamententexte dürfen zum Lesen an deinen KI-Dienst gehen.",
"confirmBody": "Dokumente, gescannte Laborbefunde und Medikamentenbeschreibungen werden nicht mehr zum Lesen gesendet. Nichts, was du bereits hast, wird gelöscht."
},
"activeSinceNamed": "Erteilt am {date}: Deine Gesundheitsdaten dürfen für die KI-Funktionen an {providers} gehen.",
"activeUndated": "Erteilt: Deine Gesundheitsdaten dürfen für die KI-Funktionen an den eingestellten Anbieter gehen.",
"request": {
"title": "Deine Zustimmung, bevor die KI loslegt",
"body": "Die KI auf diesem Server läuft über einen Dienst, den der Betreiber bereitstellt: {providers}. Bevor du zustimmst, geht nichts aus deinen Daten dorthin.",
"data": "Wenn du zustimmst, schickt HealthLog diesem Dienst, was die KI-Funktionen brauchen: Messwerte wie Gewicht, Blutdruck, Puls, Schlaf und Aktivität, deine Medikamente, Stimmung, Laborwerte und Zykluseinträge, falls du sie führst, was du in dein Profil und an den Coach schreibst, und Dokumente, die du lesen lässt. Der Dienst schreibt daraus dein Tagesbriefing, die Einschätzungen zu deinen Werten und die Antworten des Coaches.",
"withdrawHint": "Du kannst deine Zustimmung jederzeit unter Einstellungen, KI zurückziehen. Ab dann wird nichts mehr gesendet.",
"agree": "Zustimmen",
"dismiss": "Nicht jetzt"
}
},
"operatorOff": {
+10 -3
View File
@@ -6357,18 +6357,25 @@
"consent": {
"title": "AI consent",
"activeSince": "Given on {date}: your health data may be sent to the configured provider for the AI features.",
"withdrawn": "Withdrawn. Unless another consent covers them, the Coach and Insights no longer use the AI access this server provides, and no online model reads your documents. On your own key or a local model, the Coach and Insights keep working.",
"withdraw": "Withdraw consent",
"confirmBody": "Unless another consent you gave still covers them, these stop straight away: the Coach and Insights where they use the AI access this server provides, and reading documents with an online model. On your own key or a local model, the Coach and Insights keep working. Unless another consent still covers Insights, the notes a model wrote for you are deleted: status notes, the daily briefing, model-written period summaries, arrival lines and workout notes. Your Coach conversations, what the Coach remembered, your plans and your document summaries stay, and you can read or delete them yourself.",
"confirmWithdraw": "Withdraw",
"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.",
"documentReading": {
"title": "Consent to reading documents",
"activeSince": "Given on {date}: documents, lab scans and medication text may go to your AI service for reading.",
"confirmBody": "Documents, lab report scans and medication descriptions are no longer sent for reading. Nothing you already have is deleted."
},
"activeSinceNamed": "Given on {date}: your health data may go to {providers} for the AI features.",
"activeUndated": "Given: your health data may be sent to the configured provider for the AI features.",
"request": {
"title": "Your agreement before AI starts",
"body": "AI on this server runs on a service the operator provides: {providers}. Nothing from your records goes there until you agree.",
"data": "Once you agree, HealthLog sends that service what the AI features need: readings such as weight, blood pressure, pulse, sleep and activity, your medications, mood, lab values and cycle entries if you keep them, what you write in your profile and to the Coach, and documents you choose to have read. The service uses it to write your daily briefing, the notes on your readings and the Coach's answers.",
"withdrawHint": "You can withdraw your agreement at any time in Settings, AI. Nothing more is sent from that moment on.",
"agree": "Agree",
"dismiss": "Not now"
}
},
"operatorOff": {
+10 -3
View File
@@ -6357,18 +6357,25 @@
"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. Salvo que otro consentimiento lo cubra, el Coach e Insights ya no usan el acceso de IA de este servidor y ningún modelo en línea lee tus documentos. Con tu propia clave o un modelo local, el Coach e Insights siguen funcionando.",
"withdraw": "Retirar el consentimiento",
"confirmBody": "Salvo que otro consentimiento tuyo los siga cubriendo, se detienen de inmediato: el Coach e Insights cuando usan el acceso de IA de este servidor, y la lectura de documentos con un modelo en línea. Con tu propia clave o un modelo local, el Coach e Insights siguen funcionando. Salvo que otro consentimiento siga cubriendo Insights, se eliminan las notas que un modelo escribió para ti: notas de estado, el resumen diario, los resúmenes de periodo escritos por un modelo, las líneas de llegada y las notas de entrenamiento. Tus conversaciones con el Coach, lo que el Coach recordaba, tus planes y los resúmenes de tus documentos se conservan, y puedes leerlos o eliminarlos tú mismo.",
"confirmWithdraw": "Retirar",
"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.",
"documentReading": {
"title": "Consentimiento para leer documentos",
"activeSince": "Dado el {date}: documentos, análisis escaneados y textos de medicamentos pueden ir a tu servicio de IA para leerlos.",
"confirmBody": "Los documentos, los informes de laboratorio escaneados y las descripciones de medicamentos dejan de enviarse para leerlos. No se elimina nada de lo que ya tienes."
},
"activeSinceNamed": "Otorgado el {date}: tus datos de salud pueden enviarse a {providers} para las funciones de IA.",
"activeUndated": "Otorgado: tus datos de salud pueden enviarse al proveedor configurado para las funciones de IA.",
"request": {
"title": "Tu acuerdo antes de que empiece la IA",
"body": "La IA de este servidor funciona con un servicio que proporciona el operador: {providers}. Hasta que aceptes, nada de tus registros va allí.",
"data": "Cuando aceptes, HealthLog envía a ese servicio lo que necesitan las funciones de IA: mediciones como peso, presión arterial, pulso, sueño y actividad, tus medicamentos, estado de ánimo, valores de laboratorio y registros del ciclo si los llevas, lo que escribes en tu perfil y al Coach, y los documentos que decidas que se lean. El servicio lo usa para escribir tu resumen diario, las notas sobre tus mediciones y las respuestas del Coach.",
"withdrawHint": "Puedes retirar tu acuerdo en cualquier momento en Ajustes, IA. Desde ese momento no se envía nada más.",
"agree": "Aceptar",
"dismiss": "Ahora no"
}
},
"operatorOff": {
+10 -3
View File
@@ -6357,18 +6357,25 @@
"consent": {
"title": "Consentement IA",
"activeSince": "Donné le {date} : vos données de santé peuvent être transmises au fournisseur configuré pour les fonctions IA.",
"withdrawn": "Retiré. Sauf si un autre consentement le couvre, le Coach et Insights n'utilisent plus l'accès IA fourni par ce serveur, et aucun modèle en ligne ne lit vos documents. Avec votre propre clé ou un modèle local, le Coach et Insights continuent de fonctionner.",
"withdraw": "Retirer le consentement",
"confirmBody": "Sauf si un autre consentement les couvre encore, ceci s'arrête immédiatement : le Coach et Insights lorsqu'ils utilisent l'accès IA fourni par ce serveur, et la lecture de documents par un modèle en ligne. Avec votre propre clé ou un modèle local, le Coach et Insights continuent de fonctionner. Sauf si un autre consentement couvre encore Insights, les notes qu'un modèle a écrites pour vous sont supprimées : notes d'état, briefing quotidien, résumés de période écrits par un modèle, lignes d'arrivée et notes d'entraînement. Vos conversations avec le Coach, ce que le Coach a retenu, vos plans et les résumés de vos documents sont conservés, et vous pouvez les lire ou les supprimer vous-même.",
"confirmWithdraw": "Retirer",
"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.",
"documentReading": {
"title": "Consentement à la lecture de documents",
"activeSince": "Donné le {date} : documents, analyses numérisées et textes de médicaments peuvent être lus par votre service d'IA.",
"confirmBody": "Les documents, les comptes rendus d'analyses numérisés et les descriptions de médicaments ne sont plus envoyés pour lecture. Rien de ce que vous avez déjà n'est supprimé."
},
"activeSinceNamed": "Donné le {date} : vos données de santé peuvent être transmises à {providers} pour les fonctions IA.",
"activeUndated": "Donné : vos données de santé peuvent être transmises au fournisseur configuré pour les fonctions IA.",
"request": {
"title": "Votre accord avant que l'IA ne démarre",
"body": "L'IA de ce serveur passe par un service fourni par l'opérateur : {providers}. Rien de vos données n'y part tant que vous n'avez pas donné votre accord.",
"data": "Une fois votre accord donné, HealthLog envoie à ce service ce dont les fonctions IA ont besoin : mesures comme le poids, la tension, le pouls, le sommeil et l'activité, vos médicaments, votre humeur, vos résultats d'analyses et votre cycle si vous les suivez, ce que vous écrivez dans votre profil et au Coach, et les documents que vous choisissez de faire lire. Le service s'en sert pour rédiger votre briefing du jour, les notes sur vos mesures et les réponses du Coach.",
"withdrawHint": "Vous pouvez retirer votre accord à tout moment dans Réglages, IA. Plus rien n'est envoyé à partir de là.",
"agree": "J'accepte",
"dismiss": "Pas maintenant"
}
},
"operatorOff": {
+10 -3
View File
@@ -6357,18 +6357,25 @@
"consent": {
"title": "Consenso IA",
"activeSince": "Concesso il {date}: i tuoi dati sanitari possono essere inviati al provider configurato per le funzioni IA.",
"withdrawn": "Revocato. Salvo che un altro consenso lo copra, il Coach e Insights non usano più l'accesso IA fornito da questo server e nessun modello online legge i tuoi documenti. Con la tua chiave o un modello locale, il Coach e Insights continuano a funzionare.",
"withdraw": "Revoca il consenso",
"confirmBody": "Salvo che un altro consenso li copra ancora, si fermano subito: il Coach e Insights quando usano l'accesso IA fornito da questo server, e la lettura dei documenti con un modello online. Con la tua chiave o un modello locale, il Coach e Insights continuano a funzionare. Salvo che un altro consenso copra ancora Insights, le note che un modello ha scritto per te vengono eliminate: note di stato, il riepilogo giornaliero, i riepiloghi di periodo scritti da un modello, le righe di arrivo e le note sugli allenamenti. Le tue conversazioni con il Coach, ciò che il Coach ricordava, i tuoi piani e i riepiloghi dei tuoi documenti restano, e puoi leggerli o eliminarli tu stesso.",
"confirmWithdraw": "Revoca",
"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.",
"documentReading": {
"title": "Consenso alla lettura dei documenti",
"activeSince": "Dato il {date}: documenti, referti scansionati e testi dei farmaci possono andare al tuo servizio di IA per la lettura.",
"confirmBody": "Documenti, referti di laboratorio scansionati e descrizioni di farmaci non vengono più inviati per la lettura. Nulla di ciò che hai già viene eliminato."
},
"activeSinceNamed": "Concesso il {date}: i tuoi dati sanitari possono essere inviati a {providers} per le funzioni IA.",
"activeUndated": "Concesso: i tuoi dati sanitari possono essere inviati al provider configurato per le funzioni IA.",
"request": {
"title": "Il tuo consenso prima che parta l'IA",
"body": "L'IA su questo server usa un servizio fornito dal gestore: {providers}. Finché non accetti, nulla dei tuoi dati arriva lì.",
"data": "Quando accetti, HealthLog invia a quel servizio ciò che serve alle funzioni IA: misurazioni come peso, pressione, polso, sonno e attività, i tuoi farmaci, l'umore, i valori di laboratorio e il ciclo se li registri, ciò che scrivi nel profilo e al Coach, e i documenti che scegli di far leggere. Il servizio li usa per scrivere il tuo riepilogo del giorno, le note sulle tue misurazioni e le risposte del Coach.",
"withdrawHint": "Puoi ritirare il consenso in qualsiasi momento in Impostazioni, IA. Da quel momento non viene inviato più nulla.",
"agree": "Accetto",
"dismiss": "Non ora"
}
},
"operatorOff": {
+10 -3
View File
@@ -6357,18 +6357,25 @@
"consent": {
"title": "AI 동의",
"activeSince": "{date}에 동의했어요: AI 기능을 위해 건강 데이터가 설정한 제공자에게 전송될 수 있어요.",
"withdrawn": "동의를 철회했어요. 다른 동의가 적용되지 않는 한, 코치와 인사이트는 더 이상 이 서버가 제공하는 AI 연결을 사용하지 않고 온라인 모델도 문서를 읽지 않아요. 자신의 키나 로컬 모델을 사용하는 코치와 인사이트는 계속 작동해요.",
"withdraw": "동의 철회",
"confirmBody": "다른 동의가 여전히 적용되지 않는 한, 다음이 즉시 중지됩니다: 이 서버가 제공하는 AI 연결을 사용하는 코치와 인사이트, 그리고 온라인 모델을 통한 문서 읽기. 자신의 키나 로컬 모델을 사용하는 코치와 인사이트는 계속 작동합니다. 인사이트에 적용되는 다른 동의가 없으면 모델이 작성한 메모가 삭제됩니다: 상태 메모, 일일 브리핑, 모델이 작성한 기간 요약, 새 측정값 알림 문장, 운동 메모. 코치 대화, 코치가 기억한 내용, 계획, 문서 요약은 그대로 남으며 직접 읽거나 삭제할 수 있습니다.",
"confirmWithdraw": "철회",
"regrantHint": "다시 동의하면 AI 기능이 건강 데이터를 설정한 제공자에게 보낼 수 있어요.",
"grant": "동의하기",
"grantError": "동의를 저장하지 못했어요. 다시 시도해 주세요.",
"error": "철회를 저장하지 못했어요. 다시 시도해 주세요.",
"documentReading": {
"title": "문서 읽기 동의",
"activeSince": "{date}에 동의했어요: 문서, 스캔한 검사지, 약 설명이 읽기용으로 AI 서비스에 전송될 수 있어요.",
"confirmBody": "문서, 스캔한 검사 결과지, 약 설명이 더 이상 읽기용으로 전송되지 않습니다. 이미 가지고 있는 것은 삭제되지 않습니다."
},
"activeSinceNamed": "{date}에 동의했어요: AI 기능을 위해 건강 데이터가 {providers}(으)로 전송될 수 있어요.",
"activeUndated": "동의했어요: AI 기능을 위해 건강 데이터가 설정한 제공자에게 전송될 수 있어요.",
"request": {
"title": "AI를 시작하기 전에 동의가 필요해요",
"body": "이 서버의 AI는 운영자가 제공하는 서비스를 사용해요: {providers}. 동의하기 전에는 기록의 어떤 내용도 그곳으로 보내지 않아요.",
"data": "동의하면 HealthLog는 AI 기능에 필요한 내용을 그 서비스로 보내요: 체중, 혈압, 맥박, 수면, 활동 같은 측정값, 복용 중인 약, 기분, 검사 수치와 기록 중이라면 생리 주기, 프로필과 코치에게 쓴 내용, 읽도록 선택한 문서예요. 서비스는 이를 바탕으로 오늘의 브리핑, 측정값에 대한 메모, 코치의 답변을 작성해요.",
"withdrawHint": "설정의 AI에서 언제든지 동의를 철회할 수 있어요. 그 순간부터 더 이상 아무것도 보내지 않아요.",
"agree": "동의하기",
"dismiss": "나중에"
}
},
"operatorOff": {
+10 -3
View File
@@ -6357,18 +6357,25 @@
"consent": {
"title": "Zgoda na AI",
"activeSince": "Udzielona {date}: Twoje dane zdrowotne mogą trafiać do skonfigurowanego dostawcy na potrzeby funkcji AI.",
"withdrawn": "Wycofana. Jeśli żadna inna zgoda tego nie obejmuje, Coach i Insights nie korzystają już z dostępu AI zapewnianego przez ten serwer, a żaden model online nie czyta Twoich dokumentów. Na własnym kluczu lub lokalnym modelu Coach i Insights działają dalej.",
"withdraw": "Wycofaj zgodę",
"confirmBody": "Jeśli żadna inna Twoja zgoda ich nie obejmuje, od razu przestają działać: Coach i Insights tam, gdzie korzystają z dostępu AI zapewnianego przez ten serwer, oraz czytanie dokumentów przez model online. Na własnym kluczu lub lokalnym modelu Coach i Insights działają dalej. Jeśli żadna inna zgoda nie obejmuje już Insights, notatki napisane dla Ciebie przez model zostają usunięte: notatki o stanie, codzienne podsumowanie, podsumowania okresu napisane przez model, linie po nowym pomiarze i notatki o treningach. Twoje rozmowy z Coachem, to, co zapamiętał Coach, Twoje plany i podsumowania dokumentów zostają, a Ty możesz je sam przeczytać lub usunąć.",
"confirmWithdraw": "Wycofaj",
"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.",
"documentReading": {
"title": "Zgoda na odczyt dokumentów",
"activeSince": "Udzielona {date}: dokumenty, skany badań i opisy leków mogą trafiać do Twojej usługi AI do odczytu.",
"confirmBody": "Dokumenty, zeskanowane wyniki badań i opisy leków nie są już wysyłane do odczytu. Nic, co już masz, nie zostaje usunięte."
},
"activeSinceNamed": "Udzielona {date}: Twoje dane zdrowotne mogą trafiać do {providers} na potrzeby funkcji AI.",
"activeUndated": "Udzielona: Twoje dane zdrowotne mogą trafiać do skonfigurowanego dostawcy na potrzeby funkcji AI.",
"request": {
"title": "Twoja zgoda, zanim AI zacznie działać",
"body": "AI na tym serwerze korzysta z usługi zapewnianej przez operatora: {providers}. Dopóki się nie zgodzisz, nic z Twoich danych tam nie trafia.",
"data": "Gdy się zgodzisz, HealthLog wysyła tej usłudze to, czego potrzebują funkcje AI: pomiary takie jak waga, ciśnienie, tętno, sen i aktywność, Twoje leki, nastrój, wyniki badań i wpisy cyklu, jeśli je prowadzisz, to, co piszesz w profilu i do Coacha, oraz dokumenty, które zdecydujesz się dać do odczytu. Usługa pisze z tego Twoje dzienne podsumowanie, uwagi do pomiarów i odpowiedzi Coacha.",
"withdrawHint": "Zgodę możesz wycofać w każdej chwili w Ustawieniach, AI. Od tej chwili nic więcej nie jest wysyłane.",
"agree": "Zgadzam się",
"dismiss": "Nie teraz"
}
},
"operatorOff": {
@@ -0,0 +1,176 @@
/**
* An AI consent receipt is minted only by the person's own tap.
*
* From v1.16.13 until v1.43.2 the AI settings page posted to
* `/api/consent/ai/web` in a `useEffect` on every visit and the server minted
* an `ai_full` receipt for it, so an account that had agreed to nothing sent
* its health data to the operator's provider from background jobs. The fix has
* two ends, and this file holds the client one; the server end (both grant
* routes refuse a body without `confirm: true`) is pinned by the route tests
* and re-checked structurally at the bottom.
*
* 1. Exactly two client modules may name a grant route: the `ai_full` hook
* and the document-reading hook. Each sends `confirm: true`. Any other
* file that names `/api/consent/ai` or `/api/consent/ai/web` fails here,
* which is how a second mount-time grant would get in.
* 2. Every `.mutate(` / `.mutateAsync(` of those hooks sits in a click
* handler: on a line that carries `onClick`, or in a named function
* that some `onClick={name}` in the same file hands the click to. None
* sits inside a `useEffect` / `useLayoutEffect`.
*
* Its limits: it reads source text, so a grant reached through a variable
* holding the path, or a hook result passed to another component and called
* there, would slip past rule 2. The counts below fail on an empty match set,
* so a matcher that stops matching is red rather than quietly green.
*/
import { readFileSync, readdirSync } from "node:fs";
import { join, relative, sep } from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = process.cwd();
const SRC = join(ROOT, "src");
/** The two modules allowed to send a grant, and what they must send. */
const GRANT_WRITERS = [
"src/hooks/use-grant-ai-consent.ts",
"src/hooks/use-grant-document-reading-consent.ts",
];
const GRANT_HOOKS = ["useGrantAiConsent", "useGrantDocumentReadingConsent"];
/** A grant route as a string literal: the bare route or `/web`, nothing longer. */
const GRANT_PATH = /["'`]\/api\/consent\/ai(?:\/web)?["'`]/;
function clientSources(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (
entry.name === "__tests__" ||
entry.name === "generated" ||
full === join(SRC, "app", "api") ||
full === join(SRC, "lib", "openapi")
) {
continue;
}
out.push(...clientSources(full));
} else if (/\.(ts|tsx)$/.test(entry.name) && !/\.test\./.test(entry.name)) {
out.push(full);
}
}
return out;
}
function rel(file: string): string {
return relative(ROOT, file).split(sep).join("/");
}
function stripComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
.replace(/(^|[^:"'`])\/\/[^\n]*/g, "$1");
}
/** The source spans of every `useEffect(` / `useLayoutEffect(` call. */
function effectSpans(source: string): Array<[number, number]> {
const spans: Array<[number, number]> = [];
const re = /\buse(?:Layout)?Effect\s*\(/g;
let match: RegExpExecArray | null;
while ((match = re.exec(source)) !== null) {
const open = match.index + match[0].length - 1;
let depth = 0;
for (let i = open; i < source.length; i += 1) {
if (source[i] === "(") depth += 1;
else if (source[i] === ")") {
depth -= 1;
if (depth === 0) {
spans.push([open, i]);
break;
}
}
}
}
return spans;
}
const FILES = clientSources(SRC).map((file) => ({
path: rel(file),
source: stripComments(readFileSync(file, "utf8")),
}));
describe("AI consent is granted only by the person's own tap", () => {
it("names a grant route only in the two grant hooks, each sending confirm: true", () => {
const naming = FILES.filter((f) => GRANT_PATH.test(f.source)).map(
(f) => f.path,
);
expect(naming.sort()).toEqual([...GRANT_WRITERS].sort());
for (const writer of GRANT_WRITERS) {
const source = FILES.find((f) => f.path === writer)!.source;
expect(source, writer).toMatch(/\bconfirm:\s*true\b/);
}
});
it("calls a grant only from a click handler, never from an effect", () => {
let callers = 0;
let sites = 0;
for (const file of FILES) {
for (const hook of GRANT_HOOKS) {
const assign = new RegExp(
`\\bconst\\s+(\\w+)\\s*=\\s*${hook}\\s*\\(`,
"g",
);
let assigned: RegExpExecArray | null;
while ((assigned = assign.exec(file.source)) !== null) {
callers += 1;
const name = assigned[1];
const call = new RegExp(`\\b${name}\\.mutate(?:Async)?\\s*\\(`, "g");
const effects = effectSpans(file.source);
const lines = file.source.split("\n");
let site: RegExpExecArray | null;
while ((site = call.exec(file.source)) !== null) {
sites += 1;
const at = site.index;
const where = `${file.path}: ${name}.mutate`;
expect(
effects.some(([from, to]) => at > from && at < to),
`${where} inside an effect`,
).toBe(false);
const lineNo = file.source.slice(0, at).split("\n").length - 1;
if (/\bonClick\b/.test(lines[lineNo] ?? "")) continue;
// Otherwise the nearest enclosing named function must be the one
// a click hands to.
const before = file.source.slice(0, at);
const fns = [
...before.matchAll(
/\bfunction\s+(\w+)\s*\(|\bconst\s+(\w+)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>/g,
),
];
const enclosing = fns.at(-1);
const fnName = enclosing?.[1] ?? enclosing?.[2];
expect(fnName, `${where} outside any named handler`).toBeDefined();
expect(
new RegExp(`onClick=\\{\\s*${fnName}\\s*\\}`).test(file.source),
`${where} in ${fnName}, which no onClick hands the click to`,
).toBe(true);
}
}
}
}
// Non-zero floors: a matcher that stops matching is red, not green.
expect(callers).toBeGreaterThanOrEqual(3);
expect(sites).toBeGreaterThanOrEqual(3);
});
it("keeps both grant routes refusing a request without the confirmation", () => {
for (const route of [
"src/app/api/consent/ai/route.ts",
"src/app/api/consent/ai/web/route.ts",
]) {
const source = readFileSync(join(ROOT, route), "utf8");
expect(source, route).toMatch(/CONSENT_CONFIRM_REQUIRED_ERROR_CODE/);
}
});
});
@@ -455,10 +455,7 @@ describe("AI consent withdrawal copy matches what withdrawing does", () => {
for (const locale of LOCALES) {
it(`${locale} says what keeps working and when the notes stay`, () => {
const b = bundle(locale);
for (const path of [
"settings.ai.consent.confirmBody",
"settings.ai.consent.withdrawn",
]) {
for (const path of ["settings.ai.consent.confirmBody"]) {
const copy = resolve(b, path).toLowerCase();
for (const phrase of OWN_PROVIDER[locale]) {
expect(copy, `${locale} ${path}`).toContain(phrase);
@@ -188,6 +188,7 @@ describe("POST /api/consent/ai", () => {
kind: "ai_full",
artefact: "PDF",
signedAt: signedAt.toISOString(),
confirm: true,
}),
);
expect(res.status).toBe(200);
@@ -224,6 +225,31 @@ describe("POST /api/consent/ai", () => {
});
});
// v1.43.2 — native builds before it posted this body once per launch to
// sync a receipt the account did not hold, which re-granted a consent the
// person had withdrawn elsewhere. Only an explicit confirmation mints.
it.each([
["without confirm (a launch-time sync)", {}],
["with confirm: false", { confirm: false }],
])("refuses a well-formed grant %s and writes nothing", async (_l, extra) => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await POST(
req({
kind: "ai_full",
artefact: '{"source":"ios.AIConsentSheet","scope":"ai_full"}',
signedAt: "2026-05-18T10:00:00.000Z",
...extra,
}),
);
expect(res.status).toBe(400);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("consent.ai.confirmRequired");
expect(prisma.$transaction).not.toHaveBeenCalled();
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
expect(prisma.consentReceipt.updateMany).not.toHaveBeenCalled();
expect(auditLog).not.toHaveBeenCalled();
});
describe("v1.4.43 W6 — multi-issue 400 envelope (consent uses 400)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
@@ -456,6 +456,7 @@ describe("append-only invariant", () => {
kind: "ai_full",
artefact: "PDF-v1",
signedAt: signedAt1.toISOString(),
confirm: true,
}),
}),
);
@@ -510,6 +511,7 @@ describe("append-only invariant", () => {
kind: "ai_full",
artefact: "PDF-v2",
signedAt: signedAt2.toISOString(),
confirm: true,
}),
}),
);
+27 -3
View File
@@ -2,9 +2,17 @@
* v1.4.40 SB-10 — AI consent receipts.
*
* POST /api/consent/ai
* Body: { kind, artefact, signedAt }
* Body: { kind, artefact, signedAt, confirm: true }
* Persists a fresh consent receipt. Returns the inserted row.
*
* v1.43.2 — `confirm: true` is required. A receipt records a person's
* own consent act, and native builds before it also posted here once per
* launch to sync a receipt the account no longer held (re-granting, for
* example, a consent the person had withdrawn on the web). Without the
* flag nothing is written and the route answers 400
* `consent.ai.confirmRequired`; the client reads the standing state from
* `ai.consent` on `GET /api/auth/me` instead.
*
* Reader + revoke handlers live in `./latest/route.ts`.
*/
import { NextRequest } from "next/server";
@@ -19,7 +27,10 @@ import {
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { checkConsentRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { consentPostBody } from "@/lib/validations/consent";
import {
CONSENT_CONFIRM_REQUIRED_ERROR_CODE,
consentPostBody,
} from "@/lib/validations/consent";
import { createReceipt } from "@/lib/consent/receipts";
export const POST = apiHandler(async (request: NextRequest) => {
@@ -50,7 +61,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error, 400);
}
const { kind, artefact, signedAt } = parsed.data;
const { kind, artefact, signedAt, confirm } = parsed.data;
if (confirm !== true) {
// A launch-time sync from an older app build lands here. It is not the
// person agreeing to anything, so it writes nothing.
annotate({
action: { name: "consent.ai.grant.unconfirmed" },
meta: { kind },
});
return apiError(
"Consent is recorded only from an explicit confirmation",
400,
{ errorCode: CONSENT_CONFIRM_REQUIRED_ERROR_CODE },
);
}
const receipt = await createReceipt(user.id, kind, artefact, signedAt);
// Audit trail — separate from the row itself so the legal team can
@@ -22,9 +22,10 @@ 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 + intent + serialisation.
// route test stubs it to focus on auth + rate-limit + the explicit
// confirmation + serialisation.
vi.mock("@/lib/consent/web-grant", () => ({
ensureWebAiConsentReceipt: vi.fn(),
grantWebAiConsent: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
@@ -35,7 +36,7 @@ vi.mock("@/lib/rate-limit", () => ({
import { POST } from "../route";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant";
import { grantWebAiConsent } from "@/lib/consent/web-grant";
import { checkConsentRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
@@ -50,15 +51,15 @@ const RL_OK = {
resetAt: Date.now() + 60_000,
};
/** The mount heal posts no body and no content-type. */
/** What the pre-v1.43.2 mount heal sent: no body, 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 {
/** A JSON body, as the agree control (or anything else) sends one. */
function mkJson(body: unknown): Request {
return new Request("http://localhost/api/consent/ai/web", {
method: "POST",
body: JSON.stringify(body),
@@ -77,7 +78,7 @@ describe("POST /api/consent/ai/web", () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await POST(mkHeal());
expect(res.status).toBe(401);
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
expect(grantWebAiConsent).not.toHaveBeenCalled();
});
it("returns 429 when the per-user consent bucket is exhausted", async () => {
@@ -91,33 +92,41 @@ describe("POST /api/consent/ai/web", () => {
const res = await POST(mkHeal());
expect(res.status).toBe(429);
// The throttle fires before the mint runs.
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
expect(grantWebAiConsent).not.toHaveBeenCalled();
});
it("mints a receipt and reports minted:true", async () => {
it("mints a receipt on the explicit confirmation and reports minted:true", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(ensureWebAiConsentReceipt).mockResolvedValue({
vi.mocked(grantWebAiConsent).mockResolvedValue({
minted: true,
receipt: { id: "rcpt-web-1" } as never,
});
const res = await POST(mkHeal());
const res = await POST(mkJson({ confirm: true }));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { minted: boolean; kind: string };
};
expect(body.data.minted).toBe(true);
expect(body.data.kind).toBe("ai_full");
expect(grantWebAiConsent).toHaveBeenCalledWith("user-1");
expect(auditLog).toHaveBeenCalledWith(
"consent.ai.grant",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({ source: "web", confirmed: true }),
}),
);
});
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({
vi.mocked(grantWebAiConsent).mockResolvedValue({
minted: false,
reason: "already_active",
});
const res = await POST(mkHeal());
const res = await POST(mkJson({ confirm: true }));
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { minted: boolean } };
expect(body.data.minted).toBe(false);
@@ -125,49 +134,34 @@ describe("POST /api/consent/ai/web", () => {
expect(auditLog).not.toHaveBeenCalled();
});
it("passes a bodyless call to the mint as a heal", async () => {
// Every shape that is not the person's own confirmation: the bodyless
// mount heal the settings page sent until v1.43.2, an empty object, the
// retired `{ intent }` bodies, and an explicit `false`.
it.each([
["no body (the old mount heal)", () => mkHeal()],
["an empty object", () => mkJson({})],
['the retired intent "heal"', () => mkJson({ intent: "heal" })],
[
'the retired intent "affirmative"',
() => mkJson({ intent: "affirmative" }),
],
["confirm: false", () => mkJson({ confirm: false })],
])("refuses %s with 400 and never reaches the mint", async (_label, mk) => {
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");
const res = await POST(mk());
expect(res.status).toBe(400);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("consent.ai.confirmRequired");
expect(grantWebAiConsent).not.toHaveBeenCalled();
expect(auditLog).not.toHaveBeenCalled();
});
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 () => {
it("rejects a malformed confirmation with 422 and never reaches the mint", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await POST(mkIntent({ intent: "resurrect" }));
const res = await POST(mkJson({ confirm: "yes" }));
expect(res.status).toBe(422);
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
expect(grantWebAiConsent).not.toHaveBeenCalled();
});
});
+34 -35
View File
@@ -1,25 +1,20 @@
/**
* v1.16.13 — web AI-consent grant.
* Web AI-consent grant.
*
* POST /api/consent/ai/web
* 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:
* POST /api/consent/ai/web body `{ "confirm": true }`
* Records the person's explicit `ai_full` consent: they pressed "Agree"
* on a card that named the provider and the data. Idempotent: an active
* `ai_full` receipt makes it a no-op. As the person's own consent act it
* may follow an earlier withdrawal.
*
* 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.
* Without `confirm: true` (no body, `{}`, the pre-v1.43.2 mount heal or
* its `{ intent }` body) it mints nothing and answers 400
* `consent.ai.confirmRequired`. The settings page used to call this on
* every mount and mint a receipt nobody had agreed to; a request that is
* not the consent act must never create one.
*
* 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`.
* grants.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
@@ -31,16 +26,16 @@ import {
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";
import { grantWebAiConsent } from "@/lib/consent/web-grant";
import {
CONSENT_CONFIRM_REQUIRED_ERROR_CODE,
webConsentGrantBody,
} from "@/lib/validations/consent";
export const POST = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
// The web AI-settings surface calls this on every mount; the bucket is
// generous enough that the heal path is never throttled in normal use,
// tight enough to cap a scripted loop.
// Caps a scripted loop; a person tapping Agree never comes near it.
const rl = await checkConsentRateLimit(user.id);
if (!rl.allowed) {
annotate({
@@ -52,10 +47,9 @@ export const POST = apiHandler(async (req: Request) => {
});
}
// 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";
// Only the explicit grant sends a JSON body. Anything else (no body, an
// empty object, the retired `{ intent }` shape) is not the consent act.
let confirmed = false;
const contentType = req.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
const { data: body, error: jsonError } = await safeJson(req, {
@@ -67,22 +61,28 @@ export const POST = apiHandler(async (req: Request) => {
annotate({ action: { name: "consent.ai.web-grant.invalid_shape" } });
return returnAllZodIssues(parsed.error, 422);
}
intent = parsed.data.intent;
confirmed = parsed.data.confirm === true;
}
if (!confirmed) {
annotate({ action: { name: "consent.ai.web-grant.unconfirmed" } });
return apiError(
"Consent is recorded only from an explicit confirmation",
400,
{ errorCode: CONSENT_CONFIRM_REQUIRED_ERROR_CODE },
);
}
const result = await ensureWebAiConsentReceipt(user.id, intent);
const result = await grantWebAiConsent(user.id);
if (result.minted) {
// Audit trail parity with the iOS-driven POST /api/consent/ai grant so
// the legal team can reconstruct web-originated consent; `intent`
// separates a migration heal from the user's own re-grant click.
// Fire-and-forget.
// Audit trail parity with the native POST /api/consent/ai grant so the
// web-originated consent can be reconstructed. Fire-and-forget.
auditLog("consent.ai.grant", {
userId: user.id,
details: {
kind: "ai_full",
source: "web",
intent,
confirmed: true,
receiptId: result.receipt.id,
},
}).catch(() => {});
@@ -92,7 +92,6 @@ export const POST = apiHandler(async (req: Request) => {
action: { name: "consent.ai.web-grant" },
meta: {
minted: result.minted,
intent,
...(result.minted ? { receiptId: result.receipt.id } : {}),
},
});
+6
View File
@@ -29,6 +29,7 @@ import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { HeroStrip } from "@/components/insights/hero-strip";
import { AiConsentRequest } from "@/components/ai/ai-consent-request";
import { AiSetupHint } from "@/components/insights/ai-setup-hint";
import { DeferUntilNear } from "@/components/insights/defer-until-near";
import { OverviewSectionBoundary } from "@/components/insights/overview-section-boundary";
@@ -611,6 +612,11 @@ export default function InsightsPageClient() {
provider is configured and this person could set one up. */}
<AiSetupHint />
{/* The operator's provider is configured but nothing goes to it until
the person agrees: the consent question, naming the service and the
data, only while AI is closed by that alone (`ai.consent`). */}
<AiConsentRequest surface="insights" />
{/* v1.15.18 — the inline "Anpassen" toggle was removed. Customising the
overview (section show/hide + order) and sorting the nav pills now
live ONLY in Settings → Insights, reached via the top-right cog on the
+5 -2
View File
@@ -1065,8 +1065,11 @@ export default function PrivacyPage() {
</h3>
<p>
For server-managed AI, the server keeps an account-bound consent
receipt with its kind, signed time, and artefact. The current
state is available through{" "}
receipt with its kind, signed time, and artefact. A receipt is
created only when the account holder confirms it on a consent
screen that names the receiving service and the data; opening a
page or starting an app never creates one. The current state is
available through{" "}
<code className="bg-muted rounded px-1 py-0.5 text-xs">
GET /api/consent/ai/latest
</code>
@@ -0,0 +1,128 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { AiConsentState } from "@/lib/ai/capabilities/types";
import { I18nProvider } from "@/lib/i18n/context";
/**
* The consent question for the operator's provider. What these pin: it is
* asked only while `ai.consent` says it is needed and the person could set AI
* up here; it names the service the server named and the data that would go
* there; its one button is the consent act, and the request that button
* sends carries the explicit confirmation the server requires.
*
* Static markup (the house convention, no jsdom), so the click itself is not
* dispatched: `useMutation` is captured and its `mutationFn` invoked.
*/
const state = vi.hoisted(() => ({
consent: null as AiConsentState | null,
canConfigure: true,
mounted: true,
}));
const mutations = vi.hoisted(
() => [] as Array<{ mutationFn?: () => Promise<unknown> }>,
);
const apiPost = vi.hoisted(() => vi.fn());
vi.mock("@/hooks/use-ai-capability", () => ({
useAiConsent: () => state.consent,
useAiProviderState: () => ({
configured: true,
managedBy: "server",
canConfigure: state.canConfigure,
responseTimeoutMs: 60_000,
}),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: { id: "u1" } }),
}));
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => state.mounted }));
vi.mock("@tanstack/react-query", () => ({
useMutation: (options: { mutationFn?: () => Promise<unknown> }) => {
mutations.push(options);
return { mutate: () => {}, isPending: false, isError: false };
},
useQueryClient: () => ({ invalidateQueries: async () => {} }),
}));
vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
apiPost,
}));
import { AiConsentRequest } from "../ai-consent-request";
const NEEDED: AiConsentState = {
status: "needed",
since: null,
recipients: [{ via: "operator_key", name: "OpenAI" }],
};
function render(surface: "settings" | "insights") {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<AiConsentRequest surface={surface} />
</I18nProvider>,
);
}
beforeEach(() => {
state.consent = NEEDED;
state.canConfigure = true;
state.mounted = true;
mutations.length = 0;
apiPost.mockReset();
try {
window.localStorage.clear();
} catch {
/* no window in this environment */
}
});
describe("AiConsentRequest", () => {
it.each(["settings", "insights"] as const)(
"on %s, names the service and the data and offers one Agree",
(surface) => {
const html = render(surface);
expect(html).toContain('data-slot="ai-consent-request"');
expect(html).toContain("OpenAI");
expect(html).toContain("blood pressure");
expect(html).toContain("withdraw");
expect(html.match(/data-slot="ai-consent-request-agree"/g)).toHaveLength(
1,
);
},
);
it.each(["given", "not_needed"] as const)(
"asks nothing while consent is %s",
(status) => {
state.consent = { ...NEEDED, status };
expect(render("settings")).toBe("");
expect(render("insights")).toBe("");
},
);
it("asks nothing before the account payload has answered", () => {
state.consent = null;
expect(render("settings")).toBe("");
});
it("asks nothing of somebody who cannot set AI up for this record", () => {
state.canConfigure = false;
expect(render("settings")).toBe("");
});
it("paints no tile on the overview before hydration", () => {
state.mounted = false;
expect(render("insights")).toBe("");
});
it("sends the explicit confirmation, and only that", async () => {
render("settings");
expect(mutations).toHaveLength(1);
await mutations[0]!.mutationFn!();
expect(apiPost).toHaveBeenCalledWith("/api/consent/ai/web", {
confirm: true,
});
});
});
+168
View File
@@ -0,0 +1,168 @@
"use client";
import { useState } from "react";
import { Loader2, ShieldCheck, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useAiConsent, useAiProviderState } from "@/hooks/use-ai-capability";
import { useAuth } from "@/hooks/use-auth";
import { useGrantAiConsent } from "@/hooks/use-grant-ai-consent";
import { useMounted } from "@/hooks/use-mounted";
import type { AiConsentState } from "@/lib/ai/capabilities/types";
import { useTranslations } from "@/lib/i18n/context";
/**
* The consent question for AI on the operator's provider, asked once and
* only where it opens something.
*
* Shown while `ai.consent.status` is `needed`: the provider chain runs
* through a credential the operator holds (their API key or their shared
* ChatGPT account) and some AI feature is closed by nothing but the missing
* consent. The card says which service the data goes to (named by the server
* in `ai.consent.recipients`), which data, that nothing goes there before the
* person agrees, and that they can withdraw it. The one "Agree" button is the
* consent act; nothing else on any surface mints a receipt.
*
* Two surfaces. In Settings → AI it is a settings card that stays until the
* question is answered there. On the Insights overview, beside the setup
* hint, it is a tile the person can put away; that is remembered per viewer
* in this browser and does not count as an answer.
*/
const DISMISS_STORAGE_PREFIX = "healthlog.ai-consent-request.dismissed";
function readDismissed(viewerId: string): boolean {
try {
return (
window.localStorage.getItem(`${DISMISS_STORAGE_PREFIX}.${viewerId}`) ===
"1"
);
} catch {
return false;
}
}
/** The services the consent would cover, as one readable list. */
function recipientList(consent: AiConsentState): string {
return consent.recipients.map((recipient) => recipient.name).join(", ");
}
export function AiConsentRequest({
surface,
}: {
/**
* `settings`: a Settings card in Settings → AI.
* `insights`: a dismissible tile on the Insights overview.
*/
surface: "settings" | "insights";
}) {
const { t } = useTranslations();
const { user } = useAuth();
const consent = useAiConsent();
const provider = useAiProviderState();
const grant = useGrantAiConsent();
const mounted = useMounted();
const [dismissedNow, setDismissedNow] = useState(false);
const viewerId = user?.id ?? null;
// Asked only of somebody who could set AI up for this record: not inside a
// record somebody else owns, not while the operator has AI switched off.
if (consent?.status !== "needed" || !provider.canConfigure) return null;
if (viewerId === null) return null;
const body = (
<>
<p className="text-foreground text-sm">
{t("settings.ai.consent.request.body", {
providers: recipientList(consent),
})}
</p>
<p className="text-foreground text-sm">
{t("settings.ai.consent.request.data")}
</p>
<p className="text-muted-foreground text-xs">
{t("settings.ai.consent.request.withdrawHint")}
</p>
{grant.isError ? (
<p className="text-destructive text-xs" role="alert">
{t("settings.ai.consent.grantError")}
</p>
) : null}
</>
);
const agree = (
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="ai-consent-request-agree"
disabled={grant.isPending}
onClick={() => grant.mutate()}
>
{grant.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("settings.ai.consent.request.agree")}
</Button>
);
if (surface === "settings") {
return (
<SettingsCard as="section" data-slot="ai-consent-request">
<SettingsCardHeader
anchor="ai-consent"
icon={ShieldCheck}
title={t("settings.ai.consent.request.title")}
/>
{body}
<SettingsCardActions>{agree}</SettingsCardActions>
</SettingsCard>
);
}
// Storage is browser-only: nothing renders before hydration, so the server
// HTML and the first client render agree.
if (!mounted || dismissedNow || readDismissed(viewerId)) return null;
const dismiss = () => {
setDismissedNow(true);
try {
window.localStorage.setItem(`${DISMISS_STORAGE_PREFIX}.${viewerId}`, "1");
} catch {
// Storage unavailable (private mode): hidden for this visit only.
}
};
return (
<Card data-slot="ai-consent-request" className="gap-2 py-3 md:py-4">
<CardHeader>
<TileHeader
icon={ShieldCheck}
title={t("settings.ai.consent.request.title")}
right={
<Button
type="button"
variant="ghost"
size="icon"
onClick={dismiss}
aria-label={t("settings.ai.consent.request.dismiss")}
title={t("settings.ai.consent.request.dismiss")}
data-slot="ai-consent-request-dismiss"
className="-my-2.5 size-11 shrink-0 sm:-my-2 sm:size-10"
>
<X className="size-4" aria-hidden="true" />
</Button>
}
/>
</CardHeader>
<CardContent className="flex flex-col items-start gap-3">
{body}
{agree}
</CardContent>
</Card>
);
}
@@ -49,10 +49,10 @@ vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ invalidateQueries: async () => {} }),
}));
const apiFetchRaw = vi.hoisted(() => vi.fn());
const apiPost = vi.hoisted(() => vi.fn());
vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
apiFetchRaw,
apiPost,
}));
const toastError = vi.hoisted(() => vi.fn());
@@ -355,10 +355,10 @@ describe("<DoneScreen> shared-provider offer", () => {
},
);
it("grants through the affirmative web intent — the only path that may lift a revocation", async () => {
it("grants only with the explicit confirmation the server requires", async () => {
mutations.length = 0;
apiFetchRaw.mockReset();
apiFetchRaw.mockResolvedValue({ ok: true });
apiPost.mockReset();
apiPost.mockResolvedValue({ minted: true, kind: "ai_full" });
aiProviderState.data = {
managedBy: "server",
serverProviderHealth: "healthy",
@@ -369,19 +369,15 @@ describe("<DoneScreen> shared-provider offer", () => {
const grant = grantMutation();
expect(grant).toBeDefined();
await grant!.mutationFn!();
expect(apiFetchRaw).toHaveBeenCalledWith(
"/api/consent/ai/web",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ intent: "affirmative" }),
}),
);
expect(apiPost).toHaveBeenCalledWith("/api/consent/ai/web", {
confirm: true,
});
});
it("a refused grant throws rather than reporting a consent nobody recorded", async () => {
mutations.length = 0;
apiFetchRaw.mockReset();
apiFetchRaw.mockResolvedValue({ ok: false, status: 429 });
apiPost.mockReset();
apiPost.mockRejectedValue(new Error("429"));
aiProviderState.data = {
managedBy: "server",
serverProviderHealth: "healthy",
+18 -35
View File
@@ -4,7 +4,7 @@ import { useAiProviderState } from "@/hooks/use-ai-capability";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { showErrorToast } from "@/lib/errors/error-toast";
import {
CheckCircle2,
@@ -25,12 +25,9 @@ import { useOnboardingAnswer } from "@/components/onboarding/use-onboarding-flow
import { useAuth } from "@/hooks/use-auth";
import { useAccountSwitch } from "@/hooks/use-account-switch";
import { setTourReferrer } from "@/components/onboarding/tour-launcher";
import {
aiInputDependentKeys,
invalidateKeys,
queryKeys,
} from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { useGrantAiConsent } from "@/hooks/use-grant-ai-consent";
import { useTranslations } from "@/lib/i18n/context";
import { markChecklistExpanded } from "@/lib/onboarding/checklist-storage";
import type { OnboardingStateDto } from "@/lib/onboarding/needs";
@@ -150,33 +147,19 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
// The tap's own outcome, held locally so the line changes the moment the
// receipt is minted rather than a refetch later.
const [justGranted, setJustGranted] = useState(false);
const queryClient = useQueryClient();
const grantConsent = useMutation({
mutationKey: queryKeys.aiConsentReceipt("ai_full"),
mutationFn: async () => {
// `intent: "affirmative"` marks this as the user's own consent act —
// the only web path allowed to supersede an earlier withdrawal. The
// silent mount heal posts no body and deliberately 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("consent grant failed");
},
onSuccess: async () => {
setJustGranted(true);
await invalidateKeys(queryClient, aiInputDependentKeys);
await queryClient.invalidateQueries({
queryKey: queryKeys.aiConsentReceipt("ai_full"),
});
},
onError: (failure) => {
// The button stays. A failed grant is a failed grant; the screen must
// not read as though consent were on file.
showErrorToast(t("onboarding.ai.offer.error"), { cause: failure });
},
});
// The tap on the offer is the consent act; the hook sends the explicit
// confirmation the server requires before it records one.
const grantConsent = useGrantAiConsent();
function agreeToSharedProvider() {
grantConsent.mutate(undefined, {
onSuccess: () => setJustGranted(true),
onError: (failure) => {
// The button stays. A failed grant is a failed grant; the screen
// must not read as though consent were on file.
showErrorToast(t("onboarding.ai.offer.error"), { cause: failure });
},
});
}
const operatorProvides = aiProvider?.managedBy === "server";
const sharedHealth = aiProvider?.serverProviderHealth;
@@ -327,7 +310,7 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
className="min-h-9 w-full"
data-slot="onboarding-ai-offer-grant"
disabled={grantConsent.isPending}
onClick={() => grantConsent.mutate()}
onClick={agreeToSharedProvider}
>
{t("onboarding.ai.offer.cta")}
</Button>
@@ -56,6 +56,12 @@ vi.mock("@/hooks/use-ai-capability", () => ({
useAiCapabilityMap: () => null,
// v1.43 — no usage level published: the setting stays out of the way.
useAiUsage: () => null,
// v1.43.2 — nothing to ask: the provider is the person's own.
useAiConsent: () => ({
status: "not_needed",
since: null,
recipients: [],
}),
useAiProviderState: () => ({
configured: true,
managedBy: "user",
@@ -56,6 +56,12 @@ vi.mock("@/hooks/use-ai-capability", () => ({
useAiCapabilityMap: () => null,
// v1.43 — no usage level published: the setting stays out of the way.
useAiUsage: () => null,
// v1.43.2 — nothing to ask: the provider is the person's own.
useAiConsent: () => ({
status: "not_needed",
since: null,
recipients: [],
}),
useAiProviderState: () => ({
configured: true,
managedBy: "user",
+6 -1
View File
@@ -18,6 +18,7 @@
* provider-configuration screen.
*/
import { AiConsentRequest } from "@/components/ai/ai-consent-request";
import { AiInsightsCard } from "@/components/settings/ai/ai-insights-card";
import { OperatorOffNotice } from "@/components/settings/ai/operator-off-notice";
import { StoredCoachMemory } from "@/components/settings/coach-memory-section";
@@ -43,7 +44,8 @@ export function AiSection() {
//
// v1.39 — above the provider card: what the operator has switched off (so
// nothing below reads as broken), and the one calm setup hint while no
// provider is configured. Below it: what the Coach stored, while Settings →
// provider is configured, or the consent question while the operator's
// provider waits on it. Below it: what the Coach stored, while Settings →
// Coach is not reachable, so it can always be read and deleted.
const coachSettingsReachable = isSurfaceVisible(
"settings:coach",
@@ -53,6 +55,9 @@ export function AiSection() {
<div className="space-y-6">
<OperatorOffNotice />
<AiSetupHint surface="settings" />
{/* v1.43.2 — the consent question, above the provider card, while the
operator's provider is closed by nothing but the missing consent. */}
<AiConsentRequest surface="settings" />
<AiInsightsCard isAuthenticated={authed} />
{authed && !coachSettingsReachable ? (
<StoredCoachMemory isAuthenticated={authed} />
@@ -2,129 +2,111 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { AiConsentState } from "@/lib/ai/capabilities/types";
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.
* The standing AI consent in Settings → AI, rendered from `ai.consent` on
* `/api/auth/me`. What these pin: the card says since when and to which
* service consent was given and offers the withdrawal only then; it never
* offers a grant of its own (the question lives in `AiConsentRequest`, at
* the top of the page, and only there); and it never misreports a decision it
* has not been told.
*/
const receiptKey = ["consent", "ai", "latest", "ai_full"];
const consentState = vi.hoisted(() => ({
value: null as AiConsentState | null,
authLoading: false,
}));
function renderWith(receipt: unknown) {
vi.mock("@/hooks/use-ai-capability", () => ({
useAiConsent: () => consentState.value,
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isLoading: consentState.authLoading }),
}));
import { AiConsentCard } from "../ai-consent-card";
function render(isAuthenticated = true) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
defaultOptions: { queries: { retry: false, enabled: false } },
});
client.setQueryData(receiptKey, receipt);
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<AiConsentCard isAuthenticated />
<AiConsentCard isAuthenticated={isAuthenticated} />
</I18nProvider>
</QueryClientProvider>,
);
}
beforeEach(() => {
vi.restoreAllMocks();
consentState.value = null;
consentState.authLoading = false;
});
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,
});
it("says since when and to which service consent was given, with the withdrawal", () => {
consentState.value = {
status: "given",
since: "2026-07-01T08:00:00.000Z",
recipients: [{ via: "operator_key", name: "OpenAI" }],
};
const html = render();
expect(html).toContain('data-slot="ai-consent-withdraw"');
expect(html).toContain("Withdraw consent");
expect(html).toContain("Given on");
expect(html).toContain("OpenAI");
});
it("reports a withdrawal, stops offering it again, and offers the grant", () => {
const html = renderWith({
id: "rcpt-1",
kind: "ai_full",
signedAt: "2026-07-01T08:00:00.000Z",
revokedAt: "2026-07-10T08:00:00.000Z",
});
it("falls back to the configured provider when no operator service is in the chain", () => {
consentState.value = {
status: "given",
since: "2026-07-01T08:00:00.000Z",
recipients: [],
};
const html = render();
expect(html).toContain("the configured provider");
expect(html).toContain('data-slot="ai-consent-withdraw"');
});
expect(html).toContain("Withdrawn.");
it.each(["needed", "not_needed"] as const)(
"offers no withdrawal and no grant of its own while consent is %s",
(status) => {
consentState.value = {
status,
since: null,
recipients: [{ via: "operator_key", name: "OpenAI" }],
};
const html = render();
expect(html).not.toContain('data-slot="ai-consent-withdraw"');
expect(html).not.toContain("Agree");
expect(html).not.toContain('data-slot="ai-consent"');
},
);
it("holds its space while the account payload is loading, claiming nothing", () => {
consentState.authLoading = true;
const html = render();
expect(html).toContain('data-slot="ai-consent"');
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");
expect(html).not.toContain("Given on");
});
it("treats no receipt at all as consent not given", () => {
const html = renderWith(null);
expect(html).toContain("Withdrawn.");
it("says nothing when the payload carries no consent state", () => {
const html = render();
expect(html).not.toContain('data-slot="ai-consent"');
expect(html).not.toContain('data-slot="ai-consent-withdraw"');
expect(html).toContain('data-slot="ai-consent-grant"');
});
it("never reports a failed read as a withdrawal", () => {
// A refused or failed read (the consent bucket's rate limit, a network
// drop) says nothing about the decision. Reporting it as "Withdrawn" and
// offering a grant would misstate the person's own choice.
const client = new QueryClient({
defaultOptions: { queries: { retry: false, retryOnMount: false } },
});
client
.getQueryCache()
.build(client, { queryKey: receiptKey })
.setState({
status: "error",
error: new Error("read failed"),
data: undefined,
fetchStatus: "idle",
});
const html = renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<AiConsentCard isAuthenticated />
</I18nProvider>
</QueryClientProvider>,
);
expect(html).not.toContain("Withdrawn.");
expect(html).not.toContain('data-slot="ai-consent-grant"');
expect(html).not.toContain('data-slot="ai-consent-withdraw"');
expect(html).toContain('data-slot="ai-consent-load-error"');
});
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", () => {
const client = new QueryClient();
const html = renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<AiConsentCard isAuthenticated={false} />
</I18nProvider>
</QueryClientProvider>,
);
expect(html).toBe("");
consentState.value = {
status: "given",
since: "2026-07-01T08:00:00.000Z",
recipients: [],
};
expect(render(false)).toBe("");
});
});
+72 -126
View File
@@ -9,13 +9,17 @@
* 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: withdraw while consent stands, grant while it does
* not. Withdrawing takes effect immediately: the consent gate fails closed
* The card renders the standing decision from `ai.consent` on `/api/auth/me`
* and offers the one move that changes it. While consent stands it says
* since when and to which service, with the withdraw control. While it does
* not and the operator's provider needs it, the consent question at the top
* of the page (`AiConsentRequest`) asks it: the provider, the data, and an
* "Agree" button that is the consent act. Nothing here grants on mount;
* until v1.43.2 the page minted a receipt on every visit, which is exactly
* what this replaced.
* 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.
* no-consent state on the next call.
*
* v1.39 — a withdrawal also deletes what the app can write again: the notes
* a model wrote from the person's data (status notes, the daily briefing,
@@ -31,14 +35,15 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, ShieldCheck, ShieldOff } from "lucide-react";
import { Loader2, ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Skeleton } from "@/components/ui/skeleton";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useAiConsent } from "@/hooks/use-ai-capability";
import { useAuth } from "@/hooks/use-auth";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
@@ -63,19 +68,11 @@ export function AiConsentCard({
const { t } = useTranslations();
const queryClient = useQueryClient();
const [confirming, setConfirming] = useState(false);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.aiConsentReceipt("ai_full"),
queryFn: async () => {
const res = await apiFetchRaw("/api/consent/ai/latest?kind=ai_full");
// A failed read is not a withdrawal: throw so the card says it could
// not load instead of reporting the person's decision wrongly.
if (!res.ok) throw new Error("consent read failed");
const json = await res.json();
return (json.data?.receipt ?? null) as ConsentReceiptWire;
},
enabled: isAuthenticated,
});
// The standing decision comes off the account payload, resolved on the
// server with the capabilities it governs, so this card and every AI
// surface agree on it. `null` until `/me` has answered.
const consent = useAiConsent();
const { isLoading: authLoading } = useAuth();
const revoke = useMutation({
mutationKey: queryKeys.aiConsentReceipt("ai_full"),
@@ -89,28 +86,8 @@ export function AiConsentCard({
setConfirming(false);
// The receipt gates every AI surface, so anything that reads consent
// state has to re-resolve — not just this card. `/me` carries the
// resolved capabilities every AI surface renders from.
await queryClient.invalidateQueries({
queryKey: queryKeys.aiConsentReceipt("ai_full"),
});
await invalidateKeys(queryClient, aiInputDependentKeys);
},
});
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 () => {
// resolved capabilities and the consent state every AI surface
// renders from.
await queryClient.invalidateQueries({
queryKey: queryKeys.aiConsentReceipt("ai_full"),
});
@@ -120,12 +97,11 @@ export function AiConsentCard({
if (!isAuthenticated) return null;
// Say nothing until the state is known: a card that flashes "withdrawn"
// before the receipt arrives would misreport the user's own decision.
// But hold the block's space with a skeleton mirroring the final shape —
// returning null here made the section pop in and shift everything
// below it once the receipt arrived.
if (isLoading) {
// Say nothing until the state is known: a card that flashes the question
// before the payload arrives would misreport the person's own decision.
// But hold the block's space with a skeleton mirroring the final shape, so
// nothing below moves once it lands.
if (consent === null && authLoading) {
return (
<SettingsCard as="section" data-slot="ai-consent">
<SettingsCardHeader
@@ -139,50 +115,42 @@ export function AiConsentCard({
);
}
if (isError) {
return (
<SettingsCard as="section" data-slot="ai-consent">
<SettingsCardHeader
icon={ShieldCheck}
title={t("settings.ai.consent.title")}
/>
<QueryErrorRow
slot="ai-consent-load-error"
onRetry={() => void refetch()}
/>
</SettingsCard>
);
// Not given: nothing to withdraw. Where agreeing would open something (the
// operator's provider) the question sits at the top of this page
// (`AiConsentRequest`); a person on their own key or a local model has
// nothing to agree to, and reading documents asks its own narrower question
// where a document is read.
if (consent === null || consent.status !== "given") {
return <DocumentReadingConsentRow isAuthenticated={isAuthenticated} />;
}
// 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;
const providers = consent.recipients.map((r) => r.name).join(", ");
const description = consent.since
? providers
? t("settings.ai.consent.activeSinceNamed", {
date: formatDateTime(consent.since),
providers,
})
: t("settings.ai.consent.activeSince", {
date: formatDateTime(consent.since),
})
: t("settings.ai.consent.activeUndated");
return (
<>
<SettingsCard as="section" data-slot="ai-consent">
<SettingsCardHeader
icon={activeReceipt ? ShieldCheck : ShieldOff}
anchor="ai-consent"
icon={ShieldCheck}
title={t("settings.ai.consent.title")}
description={
activeReceipt
? t("settings.ai.consent.activeSince", {
date: formatDateTime(activeReceipt.signedAt),
})
: t("settings.ai.consent.withdrawn")
}
description={description}
/>
{activeReceipt && confirming ? (
<p className="text-muted-foreground text-xs">
{confirming ? (
<p className="text-foreground text-sm">
{t("settings.ai.consent.confirmBody")}
</p>
) : null}
{!activeReceipt ? (
<p className="text-muted-foreground text-xs">
{t("settings.ai.consent.regrantHint")}
</p>
) : null}
{revoke.isError ? (
<p className="text-destructive text-xs" role="alert">
@@ -190,68 +158,46 @@ export function AiConsentCard({
</p>
) : null}
{grant.isError ? (
<p className="text-destructive text-xs" role="alert">
{t("settings.ai.consent.grantError")}
</p>
) : null}
{/* One action row, last in the card; while confirming, Cancel sits
before the destructive confirm (design standards §12). */}
<SettingsCardActions>
{activeReceipt ? (
confirming ? (
<>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setConfirming(false)}
disabled={revoke.isPending}
>
{t("common.cancel")}
</Button>
<Button
type="button"
variant="destructive"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="ai-consent-withdraw-confirm"
onClick={() => revoke.mutate()}
disabled={revoke.isPending}
>
{revoke.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("settings.ai.consent.confirmWithdraw")}
</Button>
</>
) : (
{confirming ? (
<>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="ai-consent-withdraw"
onClick={() => setConfirming(true)}
onClick={() => setConfirming(false)}
disabled={revoke.isPending}
>
{t("settings.ai.consent.withdraw")}
{t("common.cancel")}
</Button>
)
<Button
type="button"
variant="destructive"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="ai-consent-withdraw-confirm"
onClick={() => revoke.mutate()}
disabled={revoke.isPending}
>
{revoke.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("settings.ai.consent.confirmWithdraw")}
</Button>
</>
) : (
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="ai-consent-grant"
onClick={() => grant.mutate()}
disabled={grant.isPending}
data-slot="ai-consent-withdraw"
onClick={() => setConfirming(true)}
>
{grant.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("settings.ai.consent.grant")}
{t("settings.ai.consent.withdraw")}
</Button>
)}
</SettingsCardActions>
@@ -13,7 +13,7 @@
* they were editing.
*/
import { useEffect, useState } from "react";
import { useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Sparkles } from "lucide-react";
@@ -28,11 +28,7 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import {
aiInputDependentKeys,
invalidateKeys,
queryKeys,
} from "@/lib/query-keys";
import { queryKeys } from "@/lib/query-keys";
import { AdminOpenAIProviderForm } from "./admin-openai-provider-form";
import { AiConsentCard } from "./ai-consent-card";
@@ -132,29 +128,10 @@ export function AiInsightsCard({
void refetchChain();
}
// v1.16.13 — heal the web AI-consent receipt on mount. The server-side
// consent gate (admin-openai egress) requires an active `ai_full`
// receipt; the web client never minted one, so existing web users on a
// shared-key deployment saw the no-key fallback. This mirrors the iOS
// shell-mount heal: idempotent server-side mint (a user with an active
// receipt is a no-op), fire-and-forget so a failure never blocks the
// settings UI. Runs once per mount of the AI-settings surface.
useEffect(() => {
if (!isAuthenticated) return;
// A heal that minted a receipt changed an input of the account's AI
// capability answer, so `/me` re-resolves; a no-op heal leaves it alone.
void apiFetchRaw("/api/consent/ai/web", { method: "POST" })
.then(async (res) => {
if (!res.ok) return;
const body = (await res.json()) as { data?: { minted?: boolean } };
if (body.data?.minted) {
void invalidateKeys(queryClient, aiInputDependentKeys);
}
})
.catch(() => {
/* best-effort heal — the explicit grant path stays available */
});
}, [isAuthenticated, queryClient]);
// No consent is granted from here. Until v1.43.2 this card posted to
// `/api/consent/ai/web` on every mount and minted a receipt nobody had
// agreed to; the question is now asked by `AiConsentRequest` inside the
// consent card below, and only its "Agree" button grants.
// The Select is URL-driven so the SSR test can pick the branch and
// a deep link works. Default = `?provider=…` query param when the
+20
View File
@@ -5,6 +5,7 @@ import { useQueryClientMounted } from "@/hooks/_internal/use-query-client-safe";
import type {
AiCapabilityKey,
AiCapabilityState,
AiConsentState,
AiProviderState,
} from "@/lib/ai/capabilities/types";
import { PROVIDER_DEFAULT_TIMEOUT_MS } from "@/lib/ai/effective-timeout";
@@ -105,6 +106,25 @@ function useAiUsageInner(): AiUsageState | null {
return user?.ai?.usage ?? null;
}
/**
* v1.43.2 — the standing AI consent (`ai.consent` on `GET /api/auth/me`):
* given (and since when), needed (and for which services), or not needed.
* `null` while it is not known; a consent surface then says nothing rather
* than misreport the person's decision. Render from it; never decide here
* whether consent is needed.
*/
export function useAiConsent(): AiConsentState | null {
const hasClient = useQueryClientMounted();
if (!hasClient) return null;
// eslint-disable-next-line react-hooks/rules-of-hooks
return useAiConsentInner();
}
function useAiConsentInner(): AiConsentState | null {
const { user } = useAuth();
return user?.ai?.consent ?? null;
}
/**
* The server's answer for one capability, or `null` while there is none yet
* (`/me` loading, no signed-in account, no query client). For the few places
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiPost } from "@/lib/api/api-fetch";
import {
aiInputDependentKeys,
invalidateKeys,
queryKeys,
} from "@/lib/query-keys";
/**
* Record the person's explicit AI consent (`ai_full`).
*
* Call `mutate()` from the click handler of an "Agree" control that has
* named the service and the data, and from nowhere else: the server records
* this request as the person's consent act, and `confirm: true` is how the
* request says so. A request on mount, on launch or after a sync is exactly
* what v1.43.2 removed; `consent-grant-explicit-guard.test.ts` keeps it out.
*
* On success `/api/auth/me` is refetched, because the capabilities and the
* `ai.consent` state every AI surface renders from changed.
*/
export function useGrantAiConsent() {
const queryClient = useQueryClient();
return useMutation({
mutationKey: queryKeys.aiConsentReceipt("ai_full"),
mutationFn: () => apiPost("/api/consent/ai/web", { confirm: true }),
onSuccess: async () => {
await invalidateKeys(queryClient, aiInputDependentKeys);
await queryClient.invalidateQueries({
queryKey: queryKeys.aiConsentReceipt("ai_full"),
});
},
});
}
@@ -32,6 +32,9 @@ export function useGrantDocumentReadingConsent() {
const signedAt = new Date().toISOString();
return apiPost("/api/consent/ai", {
kind: "ai_extraction",
// The tap on the prompt is the consent act; the server records a
// receipt only for a request that says so.
confirm: true,
signedAt,
artefact: JSON.stringify({
source: "web",
@@ -180,6 +180,9 @@ describe("probeProviderChain", () => {
localOcrEnabled: false,
managedBy: "server",
availableTypes: ["admin-openai"],
// The endpoint behind the operator's key names the service the consent
// card asks about; the default column value stands for OpenAI.
operatorKeyBaseUrl: null,
});
});
@@ -534,3 +534,104 @@ describe("the AI usage level (#1217)", () => {
);
});
});
describe("the standing consent (v1.43.2)", () => {
function operatorOnly(baseUrl?: string | null): AiCapabilityInputs {
const inputs = baseline();
inputs.provider = {
entries: [{ providerType: "admin-openai", vision: true }],
localOcrEnabled: false,
managedBy: "server",
operatorKeyBaseUrl: baseUrl,
};
return inputs;
}
it("asks, naming the service, while the operator's key waits on it alone", () => {
const consent = resolveAiBlock(operatorOnly()).consent;
expect(consent).toEqual({
status: "needed",
since: null,
recipients: [{ via: "operator_key", name: "OpenAI" }],
});
});
it("names the service behind the operator's endpoint, not a guess", () => {
const named = (url: string) =>
resolveAiBlock(operatorOnly(url)).consent.recipients[0]?.name;
expect(named("https://api.openai.com/v1")).toBe("OpenAI");
expect(named("https://api.anthropic.com/v1")).toBe("Anthropic");
expect(named("https://openrouter.ai/api/v1")).toBe("openrouter.ai");
});
it("names the operator's shared ChatGPT account", () => {
const inputs = baseline();
inputs.provider = {
entries: [
{ providerType: "openai", vision: true },
{ providerType: "admin-codex", vision: true },
],
localOcrEnabled: false,
managedBy: "user",
};
expect(resolveAiBlock(inputs).consent).toEqual({
status: "needed",
since: null,
recipients: [{ via: "operator_chatgpt", name: "ChatGPT (OpenAI)" }],
});
});
it("reports a given consent with its date and the service it covers", () => {
const inputs = operatorOnly();
inputs.activeConsentKinds = new Set(["ai_full"]);
inputs.fullConsentSince = new Date("2026-07-01T08:00:00.000Z");
expect(resolveAiBlock(inputs).consent).toEqual({
status: "given",
since: "2026-07-01T08:00:00.000Z",
recipients: [{ via: "operator_key", name: "OpenAI" }],
});
});
it("asks nothing of a person on their own key or a local model", () => {
expect(resolveAiBlock(baseline()).consent).toEqual({
status: "not_needed",
since: null,
recipients: [],
});
const own = baseline();
own.provider = {
entries: [{ providerType: "anthropic", vision: true }],
localOcrEnabled: false,
managedBy: "user",
};
// A document read on an own key still needs its narrower receipt, asked
// where the document is read; that is not this question.
expect(resolveAiBlock(own).consent.status).toBe("not_needed");
});
it("asks nothing when agreeing would open nothing", () => {
// The operator switched AI off: the switch is the outermost reason, so no
// capability is closed by the missing consent alone.
const off = operatorOnly();
off.switches = { ...off.switches, enabled: false };
expect(resolveAiBlock(off).consent.status).toBe("not_needed");
// A delegate inside somebody else's record.
const delegate = operatorOnly();
delegate.providerWorkAdmitted = false;
expect(resolveAiBlock(delegate).consent.status).toBe("not_needed");
});
it("keeps asking while a narrower receipt leaves the Coach closed", () => {
const inputs = operatorOnly();
inputs.activeConsentKinds = new Set(["ai_insights_only"]);
expect(resolveAiBlock(inputs).consent.status).toBe("needed");
});
it("asks nothing when the inputs could not be read", () => {
expect(resolveAiBlock(null).consent).toEqual({
status: "not_needed",
since: null,
recipients: [],
});
});
});
+18 -8
View File
@@ -9,7 +9,8 @@
* memoised reads the account payload and every module gate use;
* - provider presence: the record's credential row and the operator's
* provider columns (`probeProviderChain`), presence only;
* - consent: the kinds of the record's active receipts, one indexed read;
* - consent: the kinds of the record's active receipts and when the
* `ai_full` one was signed, one indexed read;
* - the record's AI usage level (v1.43): the operator's bounds (memoised)
* and one point read of the record's choice.
*
@@ -57,16 +58,24 @@ export interface AiCapabilityScope {
recordKind: AiRecordKind;
}
/** The kinds of the record's active consent receipts. */
function loadActiveConsentKinds(recordId: string): Promise<Set<string>> {
/**
* The kinds of the record's active consent receipts, and when the active
* `ai_full` one was signed (the date the consent card shows).
*/
function loadActiveConsents(
recordId: string,
): Promise<{ kinds: Set<string>; fullSince: Date | null }> {
return memoizePerRequest(
`ai-consent-kinds:${recordId}`,
async () => {
const rows = await prisma.consentReceipt.findMany({
where: { userId: recordId, revokedAt: null },
select: { kind: true },
select: { kind: true, signedAt: true },
});
return new Set(rows.map((row) => row.kind));
return {
kinds: new Set(rows.map((row) => row.kind)),
fullSince: rows.find((row) => row.kind === "ai_full")?.signedAt ?? null,
};
},
{ freshInBackground: true },
);
@@ -97,14 +106,14 @@ export function loadAiCapabilityInputs(
modules,
availability,
provider,
activeConsentKinds,
consents,
usageLevel,
] = await Promise.all([
loadAssistantSwitches(),
resolveModuleMap(scope.recordId),
getOperatorModuleAvailability(),
probeProviderChain(scope.recordId, scope.authority),
loadActiveConsentKinds(scope.recordId),
loadActiveConsents(scope.recordId),
loadRecordUsageLevel(scope.recordId),
]);
if (switches === null) return null;
@@ -118,7 +127,8 @@ export function loadAiCapabilityInputs(
moduleAccess,
providerWorkAdmitted: mayEnqueueProviderWork(scope.authority),
provider,
activeConsentKinds,
activeConsentKinds: consents.kinds,
fullConsentSince: consents.fullSince,
recordKind: scope.recordKind,
usageLevel,
};
+86 -1
View File
@@ -35,6 +35,8 @@ import {
type AiCapabilityDefinition,
type AiCapabilityKey,
type AiCapabilityState,
type AiConsentRecipient,
type AiConsentState,
type AiOperatorSwitchSet,
type AiProviderManagedBy,
type AiProviderState,
@@ -73,6 +75,12 @@ export interface ProviderPresence {
* beside the provider and this row is already in hand. Absent → unset.
*/
responseTimeoutSeconds?: number | null;
/**
* The base URL behind the operator's key (`admin-openai`), which names the
* service the consent card asks about. Absent reads as OpenAI's own
* endpoint, the column's default.
*/
operatorKeyBaseUrl?: string | null;
}
/** Whose record this is, as far as configuring a provider for it goes. */
@@ -94,6 +102,11 @@ export interface AiCapabilityInputs {
* sets it; absent (a hand-built set of inputs) means no level restriction.
*/
usageLevel?: AiUsageLevel;
/**
* When the record's active `ai_full` receipt was signed. Absent or `null`
* with `ai_full` among the active kinds publishes the consent without a date.
*/
fullConsentSince?: Date | null;
}
// Which tags are operator-held and which stays local: `@/lib/ai/provider-egress`,
@@ -382,12 +395,84 @@ export function resolveAiProviderState(
};
}
const OPERATOR_KEY_DEFAULT_HOST = "api.openai.com";
/** The service behind the operator's key, named for a person. */
function operatorKeyServiceName(baseUrl: string | null | undefined): string {
let host = OPERATOR_KEY_DEFAULT_HOST;
if (baseUrl) {
try {
host = new URL(baseUrl).hostname.toLowerCase();
} catch {
host = baseUrl;
}
}
if (host === "openai.com" || host.endsWith(".openai.com")) return "OpenAI";
if (host === "anthropic.com" || host.endsWith(".anthropic.com")) {
return "Anthropic";
}
return host;
}
function consentRecipients(provider: ProviderPresence): AiConsentRecipient[] {
const out: AiConsentRecipient[] = [];
for (const entry of provider.entries) {
if (!isOperatorHeldProvider(entry.providerType)) continue;
const recipient: AiConsentRecipient =
entry.providerType === "admin-codex"
? { via: "operator_chatgpt", name: "ChatGPT (OpenAI)" }
: {
via: "operator_key",
name: operatorKeyServiceName(provider.operatorKeyBaseUrl),
};
if (!out.some((seen) => seen.via === recipient.via)) out.push(recipient);
}
return out;
}
/**
* The standing consent. `needed` only where asking would open something: the
* chain runs through an operator-held credential and a capability under that
* rule is closed by the missing receipt alone (its outermost reason is
* `consent_required`). A capability the operator or the person switched off
* reports that layer instead, so a record with nothing to open is not asked.
*/
function resolveAiConsentState(
inputs: AiCapabilityInputs | null,
capabilities: Readonly<Record<AiCapabilityKey, AiCapabilityState>>,
): AiConsentState {
if (inputs === null) {
return { status: "not_needed", since: null, recipients: [] };
}
const recipients = consentRecipients(inputs.provider);
if (inputs.activeConsentKinds.has("ai_full")) {
return {
status: "given",
since: inputs.fullConsentSince?.toISOString() ?? null,
recipients,
};
}
const blockedByConsentAlone = AI_CAPABILITY_KEYS.some(
(key) =>
AI_CAPABILITIES[key].consent.rule === "self-snapshot" &&
capabilities[key].reason === "consent_required",
);
return {
status:
recipients.length > 0 && blockedByConsentAlone ? "needed" : "not_needed",
since: null,
recipients,
};
}
/** The whole `ai` block. */
export function resolveAiBlock(
inputs: AiCapabilityInputs | null,
): AiCapabilities {
const capabilities = resolveAiCapabilities(inputs);
return {
capabilities: resolveAiCapabilities(inputs),
capabilities,
provider: resolveAiProviderState(inputs),
consent: resolveAiConsentState(inputs, capabilities),
};
}
+38
View File
@@ -347,10 +347,48 @@ export interface AiProviderState {
responseTimeoutMs: number;
}
/**
* Whose credential an operator-held provider sends through: the operator's
* API key (`admin-openai`) or the operator's shared ChatGPT account
* (`admin-codex`).
*/
export type AiConsentRecipientVia = "operator_key" | "operator_chatgpt";
/** One service a record's data would go to on the operator's credential. */
export interface AiConsentRecipient {
via: AiConsentRecipientVia;
/**
* The service, named for a person: "OpenAI" or "Anthropic" for their own
* endpoints, otherwise the host name of the endpoint the operator set.
*/
name: string;
}
/**
* The standing AI consent for the record, as a client renders it.
*
* - `given`: an `ai_full` receipt is active (`since` says when).
* - `needed`: the provider chain runs through a credential the operator
* holds, and at least one capability is closed by nothing but the missing
* receipt. Ask, naming `recipients`.
* - `not_needed`: neither. Nothing to ask and nothing to withdraw.
*/
export type AiConsentStatus = "given" | "needed" | "not_needed";
export interface AiConsentState {
status: AiConsentStatus;
/** When the active `ai_full` consent was given, ISO-8601; `null` otherwise. */
since: string | null;
/** The operator-held services in the chain, in chain order. */
recipients: AiConsentRecipient[];
}
/** The `ai` block the account payload publishes. */
export interface AiCapabilities {
capabilities: Record<AiCapabilityKey, AiCapabilityState>;
provider: AiProviderState;
/** v1.43.2 — the standing AI consent and who it would cover. */
consent: AiConsentState;
/**
* v1.43 (#1217) — the record's AI usage level, its bounds and the estimate.
* Absent on servers before v1.43 and on blocks resolved without it.
+3
View File
@@ -910,6 +910,7 @@ const PRESENCE_USER_SELECT = {
const PRESENCE_SETTINGS_SELECT = {
adminAiKeyEncrypted: true,
adminAiModel: true,
adminAiBaseUrl: true,
adminCodexConnectionStatus: true,
adminCodexAccessTokenEncrypted: true,
adminCodexRefreshTokenEncrypted: true,
@@ -996,6 +997,7 @@ export function probeProviderChain(
if (policy === "operator-default") {
return {
entries: adminKey ? [vision("admin-openai")] : [],
operatorKeyBaseUrl: settings?.adminAiBaseUrl ?? null,
// A guardian's in-browser OCR is not the record's opt-in to use.
localOcrEnabled: false,
managedBy: adminKey ? "server" : null,
@@ -1065,6 +1067,7 @@ export function probeProviderChain(
managedBy: entries.length > 0 ? managedBy : null,
availableTypes,
responseTimeoutSeconds: row.aiResponseTimeoutSeconds,
operatorKeyBaseUrl: settings?.adminAiBaseUrl ?? null,
};
},
{ freshInBackground: true },
+32 -84
View File
@@ -21,7 +21,7 @@ vi.mock("@/lib/db", () => {
});
import { prisma } from "@/lib/db";
import { ensureWebAiConsentReceipt } from "../web-grant";
import { grantWebAiConsent } from "../web-grant";
const $transaction = vi.mocked(prisma.$transaction) as unknown as {
mockImplementation: (impl: (fn: TxFn) => unknown) => void;
@@ -50,13 +50,13 @@ function row(overrides: Partial<Record<string, unknown>> = {}) {
};
}
describe("ensureWebAiConsentReceipt", () => {
describe("grantWebAiConsent", () => {
it("mints an ai_full receipt when none is active", async () => {
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
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", "heal", now);
const result = await grantWebAiConsent("user-1", 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", "heal");
const result = await grantWebAiConsent("user-1");
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", "heal");
const result = await grantWebAiConsent("user-1");
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", "heal");
const result = await grantWebAiConsent("user-1");
expect(result.minted).toBe(false);
});
@@ -124,14 +124,14 @@ describe("ensureWebAiConsentReceipt", () => {
new Error("connection reset") as never,
);
await expect(ensureWebAiConsentReceipt("user-1", "heal")).rejects.toThrow(
await expect(grantWebAiConsent("user-1")).rejects.toThrow(
"connection reset",
);
});
});
describe("ensureWebAiConsentReceipt — two concurrent grants", () => {
it("yields exactly one active receipt across parallel mounts", async () => {
describe("grantWebAiConsent — two concurrent grants", () => {
it("yields exactly one active receipt across a double tap", async () => {
// Model the race at the DB layer: a single shared "active row" slot.
// Both callers pass the fast-path read (slot empty). The transaction
// serialises their re-checks: the first sees the empty slot and
@@ -157,8 +157,8 @@ describe("ensureWebAiConsentReceipt — two concurrent grants", () => {
});
const [a, b] = await Promise.all([
ensureWebAiConsentReceipt("user-1", "heal"),
ensureWebAiConsentReceipt("user-1", "heal"),
grantWebAiConsent("user-1"),
grantWebAiConsent("user-1"),
]);
// Exactly one mint, exactly one row inserted.
@@ -168,81 +168,29 @@ describe("ensureWebAiConsentReceipt — two concurrent grants", () => {
});
});
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,
);
describe("grantWebAiConsent — an explicit grant follows a withdrawal", () => {
it("mints after an earlier revocation: the tap is the person deciding again", async () => {
// Only active rows are read; a revoked one in the history is not
// consulted, because every call is the person's own consent act.
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
vi.mocked(prisma.consentReceipt.create).mockResolvedValue(row() as never);
const result = await ensureWebAiConsentReceipt("user-1", "heal");
const result = await grantWebAiConsent("user-1");
expect(result).toEqual({ minted: false, reason: "previously_revoked" });
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
expect(result.minted).toBe(true);
for (const [args] of vi.mocked(prisma.consentReceipt.findFirst).mock
.calls) {
expect(args?.where?.revokedAt).toBeNull();
}
});
it("records that the grant was confirmed in the artefact", async () => {
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
vi.mocked(prisma.consentReceipt.create).mockResolvedValue(row() as never);
await grantWebAiConsent("user-1");
const createArg = vi.mocked(prisma.consentReceipt.create).mock.calls[0][0];
expect(JSON.parse(createArg.data.artefact as string).confirmed).toBe(true);
});
});
+33 -95
View File
@@ -1,36 +1,26 @@
/**
* v1.16.13 — web AI-consent receipt minting.
* Web AI-consent receipt minting.
*
* The `ConsentReceipt` gate (`src/lib/ai/consent-guard.ts`, shipped in
* commit 37e9f32f) requires an active receipt before any health snapshot
* egresses via the operator's server-managed OpenAI key (`admin-openai`).
* iOS mints an `ai_full` receipt on consent grant + once per shell mount
* to heal existing accounts. The web client never minted one, so web users
* on a shared-key deployment saw the fail-closed no-key fallback on every
* AI surface.
* The `ConsentReceipt` gate (`src/lib/ai/consent-guard.ts` and the capability
* resolver) requires an active receipt before any health data leaves through
* a credential the operator holds (the operator's key, the shared ChatGPT
* account). This helper records the web side of that consent: the person
* pressed "Agree" on a card that named the provider and the data.
*
* This helper closes that gap: it mints an `ai_full` receipt for the web
* user when none is active, mirroring the iOS master-grant pattern. It is
* idempotent — a user with an active `ai_full` receipt is a no-op, so it is
* safe to call on every AI-settings mount (the web equivalent of the iOS
* shell-mount heal). Explicit revocation flows through the existing
* `DELETE /api/consent/ai/latest`; this helper only ever grants.
* It is only ever called for that explicit act. From v1.16.13 until v1.43.2
* the AI settings page also called it on mount to "heal" accounts that
* predated the gate, which minted a receipt for people who had agreed to
* nothing and let background jobs send their data to the operator's provider.
* That path is gone; the route refuses a request without `confirm: true`.
*
* 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.
* Idempotent: a person with an active `ai_full` receipt is a no-op, so a
* double tap leaves exactly one. Because every call is the person's own
* consent act, it may follow an earlier withdrawal, exactly as a first grant
* would. Withdrawal stays on `DELETE /api/consent/ai/latest`.
*
* 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
* needs). It stays well under the 64 KB artefact cap.
* The artefact is a small JSON record of the grant (source, kind, time, and
* that it was confirmed); no signed PDF or JWT. It stays well under the 64 KB
* artefact cap.
*/
import { prisma } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
@@ -38,76 +28,38 @@ 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. `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.
*/
/** Outcome of an explicit web grant. */
export type WebConsentGrantResult =
| { minted: true; receipt: ConsentReceipt }
| { minted: false; reason: "already_active" | "previously_revoked" };
| { minted: false; reason: "already_active" };
/**
* 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.
* Record the person's explicit `ai_full` consent, minting a receipt when none
* is active. `ai_full` is the master kind that satisfies every surface gate
* (analysis, Coach, reading documents).
*/
export type WebConsentGrantIntent = "heal" | "affirmative";
/**
* Ensure the web user has an active `ai_full` consent receipt, minting one
* if absent. Idempotent: returns `{ minted: false }` when an active master
* grant already exists, so callers can invoke it freely on mount.
*
* Mirrors the iOS `ai_full` grant. `ai_full` is the master kind that
* satisfies every surface gate (insights + coach), matching the iOS app's
* single master toggle rather than per-surface web grants.
*/
export async function ensureWebAiConsentReceipt(
export async function grantWebAiConsent(
userId: string,
intent: WebConsentGrantIntent,
now: Date = new Date(),
): Promise<WebConsentGrantResult> {
// Fast path: a cheap read outside the transaction short-circuits the
// common already-granted case (every mount after the first) without
// opening a transaction.
// Fast path: a double tap finds the first tap's receipt without opening a
// transaction.
const existing = await latestActiveReceipt(userId, "ai_full");
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",
kind: "ai_full",
grantedAt: now.toISOString(),
note: "In-app affirmative AI consent granted via the web client.",
confirmed: true,
note: "Explicit AI consent: the person pressed Agree on the web consent card.",
});
// Two web mounts can pass the fast-path read concurrently and both reach
// the mint. The transaction re-checks under a Serializable isolation so a
// second mint sees the first's row and no-ops; the partial unique index
// (migration 0159, `WHERE revoked_at IS NULL`) is the structural backstop
// if the re-check still races. A unique violation from the backstop means
// a concurrent grant already won — that is success-shaped (the user ends
// up with exactly one active receipt), so we map it to `{ minted: false }`.
// Two taps can pass the fast-path read concurrently. The transaction
// re-checks under Serializable so the second sees the first's row; the
// partial unique index (migration 0159, `WHERE revoked_at IS NULL`) is the
// structural backstop. A unique violation from it means a concurrent grant
// already won, which is success-shaped.
try {
return await prisma.$transaction(
async (tx) => {
@@ -116,19 +68,6 @@ export async function ensureWebAiConsentReceipt(
orderBy: { createdAt: "desc" },
});
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 },
});
@@ -137,7 +76,6 @@ export async function ensureWebAiConsentReceipt(
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
);
} catch (err) {
// Partial-unique violation — a concurrent grant landed first.
if (isP2002(err)) return { minted: false, reason: "already_active" };
throw err;
}
+1 -1
View File
@@ -112,7 +112,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"coach-prefs.body.invalid_json",
"coach-prefs.body.invalid_shape",
],
consent: ["consent.ai.required"],
consent: ["consent.ai.confirmRequired", "consent.ai.required"],
cycle: [
"cycle.bulk.invalid",
"cycle.bulk.too_large",
+21 -14
View File
@@ -14,11 +14,11 @@
* text; minted by the document auto-read switch).
*
* - POST /api/consent/ai — explicit grant with a signed artefact (iOS).
* Requires `confirm: true` (v1.43.2).
* - POST /api/consent/ai/web — idempotent `ai_full` grant for the web
* 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.
* client, `{ confirm: true }` only (v1.43.2).
* Neither route mints on a request that is not
* the person's own consent act.
* - 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).
@@ -42,13 +42,13 @@ import { isoDateTime } from "@/lib/validations/datetime";
const consentPostBody = consentPostBodyBase.meta({
id: "ConsentPostBody",
description:
"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.",
"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. `confirm` must be `true` (v1.43.2): send this body only from the person's own tap on an agree control, never from a launch or sync path. Without it nothing is written and the route answers 400 `consent.ai.confirmRequired`. Always appends a fresh row — re-granting after a revoke mints a new receipt.",
});
const webConsentGrantBody = webConsentGrantBodyBase.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.',
"`confirm` must be `true`: the person pressed the agree control. Any other body, or none, mints nothing and answers 400 `consent.ai.confirmRequired` (v1.43.2; before, an empty body was a silent heal sent on every AI-settings visit).",
});
const consentReceiptResponse = z
@@ -74,14 +74,13 @@ const webConsentGrantResponse = z
minted: z
.boolean()
.describe(
"True when a new `ai_full` receipt was minted; false when an active one already existed (idempotent no-op).",
"True when a new `ai_full` receipt was minted; false when an active one already existed (a double tap is a no-op).",
),
kind: z.literal("ai_full"),
})
.meta({
id: "WebConsentGrantResponse",
description:
"Outcome of the idempotent web `ai_full` grant. Safe to call on every AI-settings mount.",
description: "Outcome of the explicit web `ai_full` grant.",
});
const serialisedReceipt = z
@@ -152,7 +151,9 @@ export const consentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Consent"],
summary: "Grant AI consent (signed artefact)",
description:
"Persist a fresh AI-consent receipt. Required before server-managed AI egress (the operator's global LLM key); BYOK / local / ChatGPT-OAuth chains are the user's own egress and are not gated. Append-only audit trail.",
"Persist a fresh AI-consent receipt. Required before server-managed AI egress (the operator's global LLM key or shared ChatGPT account); BYOK / local / ChatGPT-OAuth chains are the user's own egress and are not gated. Append-only audit trail.\n" +
"\n" +
"v1.43.2: the body must carry `confirm: true`, sent only when the person taps an agree control that named the service and the data. A request without it (a launch-time sync from an older app build) writes nothing and answers 400 with `meta.errorCode: consent.ai.confirmRequired`; such a client reads the standing consent from `ai.consent` on GET /api/auth/me instead of re-minting it.",
requestBody: {
required: true,
content: {
@@ -173,7 +174,8 @@ export const consentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
...stdResponses,
"400": {
description: "Request validation failed (consent routes use 400).",
description:
"Request validation failed (consent routes use 400), or `confirm` was not `true` (`meta.errorCode: consent.ai.confirmRequired`, nothing written).",
content: {
"application/json": {
schema: dataEnvelope(z.null(), "ConsentValidationErrorEnvelope"),
@@ -186,11 +188,11 @@ export const consentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/consent/ai/web": {
post: {
tags: ["Consent"],
summary: "Grant web AI consent (idempotent ai_full)",
summary: "Grant web AI consent (explicit 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. 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.',
"Record the calling web user's explicit `ai_full` consent: they pressed the agree control on a card that named the service and the data. Mints a receipt if none is active; a no-op when one already exists. As the person's own consent act it may follow an earlier withdrawal. The body must be `{ \"confirm\": true }`; anything else mints nothing and answers 400 `consent.ai.confirmRequired`. Revocation flows through DELETE /api/consent/ai/latest.",
requestBody: {
required: false,
required: true,
content: {
"application/json": { schema: webConsentGrantBody },
},
@@ -208,6 +210,11 @@ export const consentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
"400": {
description:
"`confirm` was not `true` (`meta.errorCode: consent.ai.confirmRequired`). Nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
+39
View File
@@ -199,10 +199,49 @@ export const aiProviderState = z
description: "The account-level companion to the capability map.",
});
export const aiConsentState = z
.object({
status: z
.enum(["given", "needed", "not_needed"])
.describe(
"`given` — an `ai_full` consent receipt is active (`since` says when); show it with a withdraw control. `needed` — the provider chain runs through a credential the operator holds and at least one capability is closed by the missing receipt alone (its reason is `consent_required`); ask, naming `recipients`, and grant only from the person's tap. `not_needed` — nothing to ask and nothing to withdraw.",
),
since: z
.string()
.nullable()
.describe(
"When the active `ai_full` consent was given, ISO-8601. `null` unless `status` is `given`.",
),
recipients: z
.array(
z.object({
via: z
.enum(["operator_key", "operator_chatgpt"])
.describe(
"`operator_key` — the operator's API key. `operator_chatgpt` — the operator's shared ChatGPT account.",
),
name: z
.string()
.describe(
"The service, named for a person: `OpenAI`, `Anthropic`, `ChatGPT (OpenAI)`, or the host name of the endpoint the operator set.",
),
}),
)
.describe(
"The operator-held services in the record's provider chain, in chain order. Empty when the chain holds none.",
),
})
.meta({
id: "AiConsentState",
description:
"v1.43.2 — the standing AI consent for the record. Consent is asked only when data would go through a credential the operator holds; a person's own key, ChatGPT account or local model needs none. Render from it; a client never mints a receipt on its own (launch, sync or mount): POST /api/consent/ai and /api/consent/ai/web require `confirm: true`.",
});
export const aiAccountBlock = z
.object({
capabilities: aiCapabilities,
provider: aiProviderState,
consent: aiConsentState,
usage: aiUsageState
.optional()
.describe(
+19 -11
View File
@@ -44,8 +44,23 @@ export type ConsentKind = z.infer<typeof consentKindEnum>;
*/
const ARTEFACT_MAX_BYTES = 64 * 1024;
/**
* The explicit-confirmation flag every grant must carry. A receipt records a
* person's own consent act, so the server mints one only for a request that
* says it is that act: the tap on an "Agree" control sends `confirm: true`.
* A request without it (an app that synced a receipt on launch, a page that
* healed one on mount) is refused with {@link CONSENT_CONFIRM_REQUIRED_ERROR_CODE}
* and mints nothing. Optional in the schema so the route can answer that
* code rather than a generic validation error.
*/
const confirmFlag = z.boolean().optional();
/** The refusal code for a grant request without `confirm: true`. */
export const CONSENT_CONFIRM_REQUIRED_ERROR_CODE = "consent.ai.confirmRequired";
export const consentPostBody = z.object({
kind: consentKindEnum,
confirm: confirmFlag,
artefact: z
.string()
.min(1, "artefact must not be empty")
@@ -58,21 +73,14 @@ 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.
* Body for `POST /api/consent/ai/web`: `{ "confirm": true }`, sent only by the
* "Agree" control. There is no implicit form any more; a request without the
* flag mints nothing.
*/
export const webConsentGrantBody = z.object({
intent: z.enum(["heal", "affirmative"]).default("heal"),
confirm: confirmFlag,
});
export type WebConsentGrantBodyInput = z.input<typeof webConsentGrantBody>;
/**
* Query schema for both `/api/consent/ai/latest` GET and DELETE.
*
@@ -0,0 +1,254 @@
/**
* Consent to the operator's provider comes from the person, not from a page
* visit or an app launch, against real Postgres.
*
* Until v1.43.2 the web AI settings posted `/api/consent/ai/web` with no body
* on every mount and the server minted an `ai_full` receipt for it; native
* builds posted `/api/consent/ai` once per launch the same way. A fresh account
* on an instance whose only provider is the operator's key therefore had its
* readings sent there by background jobs without anyone having agreed.
*
* The journey below: a fresh account with only the operator's key. The two
* calls those clients made on their own are replayed and must write nothing;
* `/api/auth/me` must say consent is needed and name the service; a
* background status note must not reach the provider. Then the person agrees
* (`{ confirm: true }`), `/me` says so, and the same job does reach it.
*
* Only the provider chain is replaced, by a spy tagged as the operator's key,
* so the capability gate, the egress consent check and the writes all run
* for real. The spy being called after the grant is what proves it could see
* a call at all.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const providerSpy = vi.fn(async () => ({
content: JSON.stringify({ summary: "A calm and steady stretch." }),
model: "spy-model",
tokensUsed: 12,
}));
vi.mock("@/lib/ai/provider", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/ai/provider")>();
return {
...actual,
resolveProviderChain: vi.fn(async () => [
{
providerType: "admin-openai",
instance: { type: "openai", generateCompletion: providerSpy },
},
]),
resolveProvider: vi.fn(async () => ({ type: "none" })),
};
});
import { runInsightStatusGenerate } from "@/lib/jobs/insight-status-generate";
interface ConsentWire {
status: string;
since: string | null;
recipients: Array<{ via: string; name: string }>;
}
async function freshOperatorKeyAccount() {
const prisma = getPrismaClient();
await prisma.appSettings.upsert({
where: { id: "singleton" },
create: {
id: "singleton",
assistantEnabled: true,
adminAiKeyEncrypted: "v1:presence-only",
adminAiModel: "gpt-4o-mini",
},
update: {
assistantEnabled: true,
adminAiKeyEncrypted: "v1:presence-only",
adminAiModel: "gpt-4o-mini",
},
});
const user = await prisma.user.create({
data: {
username: "consent-fresh",
email: "consent-fresh@example.test",
role: "USER",
timezone: "UTC",
locale: "en",
heightCm: 180,
onboardingCompletedAt: new Date(),
},
});
const DAY = 24 * 60 * 60 * 1000;
const now = Date.now();
await prisma.measurement.createMany({
data: Array.from({ length: 30 }, (_, i) => ({
userId: user.id,
type: "WEIGHT" as const,
value: 82 - i * 0.05,
unit: "kg",
source: "MANUAL" as const,
measuredAt: new Date(now - i * DAY),
})),
});
const session = await prisma.session.create({
data: { userId: user.id, expiresAt: new Date(now + 60 * 60_000) },
});
cookieJar.set("healthlog_session", session.id);
return user;
}
async function readConsent(): Promise<{
consent: ConsentWire;
statusText: { available: boolean; reason: string | null };
}> {
const { GET } = await import("@/app/api/auth/me/route");
const res = await GET();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
ai: {
consent: ConsentWire;
capabilities: {
statusText: { available: boolean; reason: string | null };
};
};
};
};
return {
consent: body.data.ai.consent,
statusText: body.data.ai.capabilities.statusText,
};
}
function json(url: string, body?: unknown): Request {
return new Request(url, {
method: "POST",
...(body === undefined
? {}
: {
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
});
}
async function runStatusNote(userId: string) {
await runInsightStatusGenerate({
userId,
metric: "weight",
locale: "en",
authority: {
origin: "owner",
recordUserId: userId,
actorUserId: userId,
grantId: null,
},
});
}
async function wroteStatusText(userId: string): Promise<boolean> {
return (
(await getPrismaClient().insightStatusCache.count({
where: { userId, textEncrypted: { not: null } },
})) > 0
);
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
providerSpy.mockClear();
});
describe("consent to the operator's provider is the person's own act", () => {
it("sends nothing until the person agrees, and does after", async () => {
const prisma = getPrismaClient();
const user = await freshOperatorKeyAccount();
// The fresh account is asked, with the service named.
const before = await readConsent();
expect(before.consent).toEqual({
status: "needed",
since: null,
recipients: [{ via: "operator_key", name: "OpenAI" }],
});
expect(before.statusText).toMatchObject({
available: false,
reason: "consent_required",
});
// What the old clients sent on their own: the settings mount (no body)
// and the app's launch sync (a well-formed body without `confirm`).
const web = await import("@/app/api/consent/ai/web/route");
const heal = await web.POST(
json("http://localhost/api/consent/ai/web") as never,
);
expect(heal.status).toBe(400);
const native = await import("@/app/api/consent/ai/route");
const sync = await native.POST(
json("http://localhost/api/consent/ai", {
kind: "ai_full",
artefact: '{"source":"ios.AIConsentSheet","scope":"ai_full"}',
signedAt: new Date().toISOString(),
}) as never,
);
expect(sync.status).toBe(400);
expect(
await prisma.consentReceipt.count({ where: { userId: user.id } }),
).toBe(0);
// A background job finds no consent and sends nothing.
await runStatusNote(user.id);
expect(providerSpy).not.toHaveBeenCalled();
expect(await wroteStatusText(user.id)).toBe(false);
// The person presses Agree.
const agree = await web.POST(
json("http://localhost/api/consent/ai/web", { confirm: true }) as never,
);
expect(agree.status).toBe(200);
expect(
((await agree.json()) as { data: { minted: boolean } }).data.minted,
).toBe(true);
const after = await readConsent();
expect(after.consent.status).toBe("given");
expect(after.consent.since).not.toBeNull();
expect(after.consent.recipients).toEqual([
{ via: "operator_key", name: "OpenAI" },
]);
expect(after.statusText.available).toBe(true);
// The same job now reaches the provider and writes its note.
await runStatusNote(user.id);
expect(providerSpy).toHaveBeenCalled();
expect(await wroteStatusText(user.id)).toBe(true);
});
});