Files
HealthLog/src/lib/openapi/routes/consent.ts
T
Marc-André Bombeck e2b7e7350e 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.
2026-10-16 17:11:04 +02:00

305 lines
13 KiB
TypeScript

/**
* OpenAPI route table — AI consent receipts (v1.16.13).
*
* The `ConsentReceipt` family is the GDPR Art. 7 / App-Store 5.1.2(i)
* audit trail and the precondition the server-managed AI-egress gate
* (`src/lib/ai/consent-guard.ts`) enforces before any health snapshot
* leaves for the operator's global LLM key. It shipped (commit 37e9f32f)
* without a contract bump; this module documents the surface so iOS + web
* clients have a stable reference.
*
* Receipt kinds: `ai_full` (master — satisfies every surface),
* `ai_insights_only` (Insights only), `ai_coach` (Coach only),
* `ai_extraction` (reading documents, lab report scans and typed medication
* 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, `{ 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).
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import {
consentKindEnum,
consentPostBody as consentPostBodyBase,
webConsentGrantBody as webConsentGrantBodyBase,
} from "@/lib/validations/consent";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
import { isoDateTime } from "@/lib/validations/datetime";
// `.meta()` CLONES in Zod 4 rather than annotating in place, so the returned
// schema has to be captured and referenced. A bare `schema.meta({...})`
// statement registers nothing and the component id it names never reaches the
// emitted document. The imports are aliased so the annotated clone can keep the
// name every use site below already spells.
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. `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:
"`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
.object({
id: z.string(),
receipt: z.object({
id: z.string(),
userId: z.string(),
kind: consentKindEnum,
signedAt: z.string(),
revokedAt: z.string().nullable(),
createdAt: z.string(),
}),
})
.meta({
id: "ConsentReceiptResponse",
description:
"The minted receipt. `artefact` is deliberately stripped from the response — it is opaque and only read at audit time directly from the DB.",
});
const webConsentGrantResponse = z
.object({
minted: z
.boolean()
.describe(
"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 explicit web `ai_full` grant.",
});
const serialisedReceipt = z
.object({
id: z.string(),
userId: z.string(),
kind: consentKindEnum,
signedAt: isoDateTime(),
revokedAt: isoDateTime().nullable(),
createdAt: isoDateTime(),
})
.meta({
id: "SerialisedConsentReceipt",
description:
"A consent receipt as the API returns it. `artefact` is stripped — it is opaque, large, and only read at audit time straight from the database.",
});
const consentLatestOneKind = z
.object({
kind: consentKindEnum,
receipt: serialisedReceipt
.nullable()
.describe("Null when no active receipt of that kind exists."),
})
.meta({ id: "ConsentLatestForKind" });
const consentLatestAllKinds = z
.object({
ai_full: serialisedReceipt.nullable(),
ai_insights_only: serialisedReceipt.nullable(),
ai_coach: serialisedReceipt.nullable(),
ai_extraction: serialisedReceipt.nullable(),
})
.meta({
id: "ConsentLatestByKind",
description:
"The latest active receipt for every kind. Every key is always present: a null value means the kind is not granted, so a client never has to read absence as ambiguity.",
});
const consentRevokeAllKinds = z
.object({
revoked: z
.array(z.object({ kind: consentKindEnum, receipt: serialisedReceipt }))
.describe(
"One entry per kind that HAD an active receipt. A kind already revoked contributes nothing, so an empty array means there was nothing left to revoke.",
),
})
.meta({ id: "ConsentRevokeAllResponse" });
/**
* The 400 the two `/latest` verbs answer for a bad `kind`.
*
* Spread AFTER `...stdResponses`: the consent family validates to 400 rather
* than the 422 the rest of the API uses, and the generic entry would otherwise
* leave the reader looking for a status this route never sends.
*/
const consentQueryValidation400 = {
"400": {
description:
"`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`, `ai_extraction`. Multi-issue envelope; nothing was read or revoked. The consent family answers 400 here where the rest of the API answers 422.",
content: { "application/json": { schema: errorEnvelope } },
},
};
export const consentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/consent/ai": {
post: {
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 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: {
"application/json": { schema: consentPostBody },
},
},
responses: {
"200": {
description: "The minted receipt.",
content: {
"application/json": {
schema: dataEnvelope(
consentReceiptResponse,
"ConsentReceiptResponseEnvelope",
),
},
},
},
...stdResponses,
"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"),
},
},
},
},
},
},
"/api/consent/ai/web": {
post: {
tags: ["Consent"],
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: true,
content: {
"application/json": { schema: webConsentGrantBody },
},
},
responses: {
"200": {
description: "Grant outcome.",
content: {
"application/json": {
schema: dataEnvelope(
webConsentGrantResponse,
"WebConsentGrantResponseEnvelope",
),
},
},
},
...stdResponses,
"400": {
description:
"`confirm` was not `true` (`meta.errorCode: consent.ai.confirmRequired`). Nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
// ── Appended: the latest-receipt reader and the revoke, both long shipped
// and neither ever registered.
"/api/consent/ai/latest": {
get: {
tags: ["Consent"],
summary: "Read the latest active consent receipt(s)",
description:
"The response SHAPE depends on the query, which is the one thing a client has to get right here. With `?kind=` it is `{ kind, receipt }` for that kind alone; WITHOUT it, a map keyed by all three kinds. There is no shape that covers both — decode on whether you sent the parameter.\n" +
"\n" +
"Only non-revoked receipts are considered: a revoked one is never returned, and the reader does not distinguish “revoked” from “never granted”. Auth via cookie or a wildcard Bearer token; rate-limited on the shared per-user consent bucket.",
requestParams: {
query: z.object({
kind: consentKindEnum
.optional()
.describe(
"Narrow to one consent kind. Omit for the full keyspace.",
),
}),
},
responses: {
"200": {
description:
"The receipt for the requested kind, or the map across all three when `kind` was omitted.",
content: {
"application/json": {
schema: z.union([
dataEnvelope(
consentLatestOneKind,
"ConsentLatestForKindEnvelope",
),
dataEnvelope(
consentLatestAllKinds,
"ConsentLatestByKindEnvelope",
),
]),
},
},
},
...stdResponses,
...consentQueryValidation400,
},
},
delete: {
tags: ["Consent"],
summary: "Revoke the latest consent receipt, or all of them",
description:
"With `?kind=` it revokes the latest active receipt of that kind; WITHOUT it, it revokes the latest active receipt of EVERY kind — the master “turn AI off” switch. As with the GET, the two arms answer different shapes.\n" +
"\n" +
"Idempotent on purpose, and it is worth knowing why: revoking a kind that has nothing active answers 200 with `receipt: null` rather than 404, because the client toggle fires this on every flip and a 404 would render as a failure the user cannot act on. Revocation is append-only in effect — the row is stamped, never deleted — and each revocation is audit-logged.\n" +
"\n" +
"Revoking `ai_full` or `ai_extraction` does NOT switch off `documentsAutoAiRead`, but it does stop document reading: the document gate reads the receipt, not the switch, so a revocation wins over a switch left on. Turning the switch on again mints a fresh `ai_extraction` receipt through PATCH /api/auth/me/documents-auto-ai-read. A client offering both controls should show them as the two separate things they are.\n" +
"\n" +
"When a revocation leaves no receipt covering the analysis (`ai_insights_only` or `ai_full`), the same transaction deletes the text a model wrote under it that can be written again: per-metric status notes, the cached briefing, model-written period narratives, reaction lines and workout paragraphs. Coach conversations, facts and plans and document summaries are the person's records and stay. A deletion is audit-logged with counts only (`consent.ai.purge`).",
requestParams: {
query: z.object({
kind: consentKindEnum
.optional()
.describe(
"Revoke one kind. Omit to revoke the latest active receipt across every kind.",
),
}),
},
responses: {
"200": {
description:
"The revoked receipt for the named kind (or `receipt: null` when there was none), or the list of everything revoked when `kind` was omitted.",
content: {
"application/json": {
schema: z.union([
dataEnvelope(
consentLatestOneKind,
"ConsentRevokeForKindEnvelope",
),
dataEnvelope(consentRevokeAllKinds, "ConsentRevokeAllEnvelope"),
]),
},
},
},
...stdResponses,
...consentQueryValidation400,
},
},
},
};