Files
HealthLog/src/lib/openapi/routes/consent.ts
T
Marc-André Bombeck 8325f3964d feat(consent): add the extraction consent kind and purge on withdrawal
A new receipt kind, ai_extraction, covers reading documents, lab report
scans and typed medication text with an external model. Switching the
document auto-read on now mints ai_extraction instead of ai_full, so it
no longer grants the Coach or the analysis as a side effect. Existing
ai_full receipts keep covering everything.

DELETE /api/consent/ai/latest now runs through withdrawConsent: the
revoke and, when it leaves no receipt covering the analysis, the
deletion of the text a model wrote under it commit in one transaction.
That is the status notes, the cached briefing, model-written period
narratives, reaction lines and workout paragraphs. Coach conversations,
facts and plans and document summaries stay. The purge is audit-logged
as consent.ai.purge with counts only.

Refs #975
2026-09-24 09:05:09 +02:00

297 lines
12 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).
* - 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.
* - 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";
// `.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. 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.',
});
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 (idempotent 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.",
});
const serialisedReceipt = z
.object({
id: z.string(),
userId: z.string(),
kind: consentKindEnum,
signedAt: z.iso.datetime({ offset: true }),
revokedAt: z.iso.datetime({ offset: true }).nullable(),
createdAt: z.iso.datetime({ offset: true }),
})
.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); BYOK / local / ChatGPT-OAuth chains are the user's own egress and are not gated. Append-only audit trail.",
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).",
content: {
"application/json": {
schema: dataEnvelope(z.null(), "ConsentValidationErrorEnvelope"),
},
},
},
},
},
},
"/api/consent/ai/web": {
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.',
requestBody: {
required: false,
content: {
"application/json": { schema: webConsentGrantBody },
},
},
responses: {
"200": {
description: "Grant outcome.",
content: {
"application/json": {
schema: dataEnvelope(
webConsentGrantResponse,
"WebConsentGrantResponseEnvelope",
),
},
},
},
...stdResponses,
},
},
},
// ── 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,
},
},
},
};