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:
Marc-André Bombeck committed 2026-06-14 10:25:18 +02:00
1 parent 7fa10b43a0
commit f9876f9492
19 files changed
+563 -12

No files matched your search

+1
View File
@@ -65,6 +65,7 @@ const openApiBase: Pick<
{ name: "Mood" },
{ name: "Analytics" },
{ name: "Insights" },
{ name: "Consent" },
{ name: "Dashboard" },
{ name: "Notifications" },
{ name: "Devices" },
+126
View File
@@ -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,
},
},
},
};
+2
View File
@@ -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"]> = {
+7 -2
View File
@@ -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,
},
},
},
+17
View File
@@ -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 } },
},
};