mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
feat(consent): mint AI consent receipt from the web client
The server-side gate requires an active ConsentReceipt before any health snapshot egresses via the operator's server-managed key, but the web client never minted one — so web users on a shared-key deployment hit the fail-closed no-key fallback on every AI surface. Add ensureWebAiConsentReceipt() and POST /api/consent/ai/web: an idempotent ai_full grant mirroring the iOS master grant (a user with an active receipt is a no-op). The AI-settings card calls it on mount — the web equivalent of the iOS shell-mount heal — so existing accounts gain a receipt without a re-consent step. Surface an honest signal: resolveReadOnlyStatusMiss now returns a consent-missing outcome when a provider is configured but the gate would block egress, distinct from no-provider, and skips the wasted enqueue. Document the receipt precondition + the consent surface in the OpenAPI contract (it shipped without a bump): the /api/consent/ai family, a Consent tag, and a 403 consent.ai.required response on the AI-egress route.
This commit is contained in:
1 parent
7fa10b43a0
commit
f9876f9492
19 files changed
+563
-12
No files matched your search
@@ -65,6 +65,7 @@ const openApiBase: Pick<
|
||||
{ name: "Mood" },
|
||||
{ name: "Analytics" },
|
||||
{ name: "Insights" },
|
||||
{ name: "Consent" },
|
||||
{ name: "Dashboard" },
|
||||
{ name: "Notifications" },
|
||||
{ name: "Devices" },
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 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).
|
||||
*
|
||||
* - POST /api/consent/ai — explicit grant with a signed artefact (iOS).
|
||||
* - POST /api/consent/ai/web — idempotent `ai_full` grant for the web
|
||||
* client (mints if none active; heals
|
||||
* existing accounts on the AI-settings mount).
|
||||
* - 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 } from "@/lib/validations/consent";
|
||||
import { dataEnvelope, stdResponses } from "./shared";
|
||||
|
||||
consentPostBody.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 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.",
|
||||
});
|
||||
|
||||
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; the web AI-settings surface calls this on mount so existing web accounts on a shared-key deployment gain a receipt without a re-consent step. Revocation flows through DELETE /api/consent/ai/latest.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Grant outcome.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
webConsentGrantResponse,
|
||||
"WebConsentGrantResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -24,6 +24,7 @@ import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { adminDiagnosticPaths, adminInvitePaths } from "./admin";
|
||||
import { authPaths } from "./auth";
|
||||
import { coachFeedbackPaths, coachPaths } from "./coach";
|
||||
import { consentPaths } from "./consent";
|
||||
import { cyclePaths } from "./cycle";
|
||||
import { devicePaths } from "./devices";
|
||||
import { healthRecordPaths } from "./health-record";
|
||||
@@ -55,6 +56,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...insightsPaths,
|
||||
...moodPaths,
|
||||
...settingsPaths,
|
||||
...consentPaths,
|
||||
};
|
||||
|
||||
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
|
||||
|
||||
@@ -14,7 +14,11 @@ import {
|
||||
VITALS_BASELINE_TYPES,
|
||||
} from "@/lib/insights/derived/registry";
|
||||
import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta";
|
||||
import { dataEnvelope, stdResponses } from "./shared";
|
||||
import {
|
||||
consentRequiredResponse,
|
||||
dataEnvelope,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
const insightsComprehensiveResponse = z
|
||||
.object({
|
||||
@@ -679,7 +683,7 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Insights"],
|
||||
summary: "Comprehensive AI insights bundle",
|
||||
description:
|
||||
"Full Insights surface — daily briefing, recommendations with rationale, optional weekly report + storyboard annotations. Strict-schema validated server-side.",
|
||||
"Full Insights surface — daily briefing, recommendations with rationale, optional weekly report + storyboard annotations. Strict-schema validated server-side. Requires an active ConsentReceipt when the resolved provider chain egresses via the operator's server-managed key (see POST /api/consent/ai).",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Insights bundle.",
|
||||
@@ -693,6 +697,7 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
...consentRequiredResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -107,3 +107,20 @@ export const stdResponses = {
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
};
|
||||
|
||||
// ── AI-consent precondition (v1.16.13) ───────────────────────────────
|
||||
// The server-managed AI-egress gate requires an active ConsentReceipt
|
||||
// (`ai_full`, or the surface-specific `ai_insights_only` / `ai_coach`)
|
||||
// before any health snapshot leaves for the operator's global LLM key.
|
||||
// Interactive routes surface this as a 403 with
|
||||
// `meta.errorCode = "consent.ai.required"`; clients render an inline
|
||||
// grant-consent notice and call POST /api/consent/ai (or, on web, POST
|
||||
// /api/consent/ai/web) to mint the receipt. BYOK / local / ChatGPT-OAuth
|
||||
// chains are the user's own egress and never trip this gate.
|
||||
export const consentRequiredResponse = {
|
||||
"403": {
|
||||
description:
|
||||
"AI consent required: no active ConsentReceipt for the server-managed provider. `meta.errorCode` = `consent.ai.required`. Mint a receipt via POST /api/consent/ai before retrying.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user