mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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.
305 lines
13 KiB
TypeScript
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,
|
|
},
|
|
},
|
|
},
|
|
};
|