From f9876f949252d896e9108ebafba207511eccea05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 10:25:18 +0200 Subject: [PATCH] feat(consent): mint AI consent receipt from the web client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/api/openapi.yaml | 182 +++++++++++++++++- src/app/api/consent/ai/web/route.ts | 48 +++++ .../settings/ai/ai-insights-card.tsx | 16 +- src/lib/consent/__tests__/web-grant.test.ts | 64 ++++++ src/lib/consent/web-grant.ts | 57 ++++++ src/lib/insights/blood-pressure-status.ts | 4 +- src/lib/insights/bmi-status.ts | 5 +- src/lib/insights/general-status.ts | 4 +- .../insights/medication-compliance-status.ts | 4 +- src/lib/insights/metric-status.ts | 4 +- src/lib/insights/mood-status.ts | 4 +- src/lib/insights/pulse-status.ts | 4 +- src/lib/insights/status-cache.ts | 20 ++ src/lib/insights/weight-status.ts | 4 +- src/lib/openapi/registry.ts | 1 + src/lib/openapi/routes/consent.ts | 126 ++++++++++++ src/lib/openapi/routes/index.ts | 2 + src/lib/openapi/routes/insights.ts | 9 +- src/lib/openapi/routes/shared.ts | 17 ++ 19 files changed, 563 insertions(+), 12 deletions(-) create mode 100644 src/app/api/consent/ai/web/route.ts create mode 100644 src/lib/consent/__tests__/web-grant.test.ts create mode 100644 src/lib/consent/web-grant.ts create mode 100644 src/lib/openapi/routes/consent.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 99531be46..55d91a168 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -32,6 +32,7 @@ tags: - name: Mood - name: Analytics - name: Insights + - name: Consent - name: Dashboard - name: Notifications - name: Devices @@ -3979,7 +3980,8 @@ paths: - 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. + 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. @@ -3988,6 +3990,13 @@ paths: schema: $ref: "#/components/schemas/InsightsComprehensiveResponseEnvelope" "401": *a1 + "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: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 /api/insights/pregenerate: @@ -4866,6 +4875,72 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /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: + type: object + properties: + kind: + type: string + enum: + - ai_full + - ai_insights_only + - ai_coach + artefact: + type: string + minLength: 1 + signedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - kind + - artefact + - signedAt + responses: + "200": + description: The minted receipt. + content: + application/json: + schema: + $ref: "#/components/schemas/ConsentReceiptResponseEnvelope" + "400": + description: Request validation failed (consent routes use 400). + content: + application/json: + schema: + $ref: "#/components/schemas/ConsentValidationErrorEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /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: + $ref: "#/components/schemas/WebConsentGrantResponseEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 components: schemas: PasskeyLoginVerifyRequest: @@ -14317,6 +14392,111 @@ components: additionalProperties: false description: "Medication reminder thresholds: the operator-level late/missed minute marks plus the calling user's low-stock runway threshold (v1.16.11)." + ConsentReceiptResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ConsentReceiptResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ConsentReceiptResponse: + type: object + properties: + id: + type: string + receipt: + type: object + properties: + id: + type: string + userId: + type: string + kind: + type: string + enum: + - ai_full + - ai_insights_only + - ai_coach + signedAt: + type: string + revokedAt: + anyOf: + - type: string + - type: "null" + createdAt: + type: string + required: + - id + - userId + - kind + - signedAt + - revokedAt + - createdAt + additionalProperties: false + required: + - id + - receipt + additionalProperties: false + description: The minted receipt. `artefact` is deliberately stripped from the response — it is opaque and only read at + audit time directly from the DB. + ConsentValidationErrorEnvelope: + type: object + properties: + data: + type: "null" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + WebConsentGrantResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/WebConsentGrantResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + WebConsentGrantResponse: + type: object + properties: + minted: + type: boolean + description: True when a new `ai_full` receipt was minted; false when an active one already existed (idempotent no-op). + kind: + type: string + const: ai_full + required: + - minted + - kind + additionalProperties: false + description: Outcome of the idempotent web `ai_full` grant. Safe to call on every AI-settings mount. MedicationScheduleOutput: type: object properties: diff --git a/src/app/api/consent/ai/web/route.ts b/src/app/api/consent/ai/web/route.ts new file mode 100644 index 000000000..cccf41864 --- /dev/null +++ b/src/app/api/consent/ai/web/route.ts @@ -0,0 +1,48 @@ +/** + * v1.16.13 — web AI-consent grant (heal endpoint). + * + * POST /api/consent/ai/web + * Mints an `ai_full` consent receipt for the calling web user if none + * is active, mirroring the iOS master-grant. Idempotent — a user with + * an active `ai_full` receipt is a no-op. The web AI-settings surface + * calls this on mount (the web equivalent of the iOS shell-mount heal) + * so existing web accounts gain a receipt without a re-consent step and + * stop hitting the server-managed consent gate's no-key fallback. + * + * Revocation stays on `DELETE /api/consent/ai/latest`; this route only + * grants. The full CRUD (explicit grant with a signed artefact, reader, + * revoke) lives in `../route.ts` + `../latest/route.ts`. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant"; + +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + + const result = await ensureWebAiConsentReceipt(user.id); + + if (result.minted) { + // Audit trail parity with the iOS-driven POST /api/consent/ai grant so + // the legal team can reconstruct web-originated consent. Fire-and-forget. + auditLog("consent.ai.grant", { + userId: user.id, + details: { kind: "ai_full", source: "web", receiptId: result.receipt.id }, + }).catch(() => {}); + } + + annotate({ + action: { name: "consent.ai.web-grant" }, + meta: { + minted: result.minted, + ...(result.minted ? { receiptId: result.receipt.id } : {}), + }, + }); + + return apiSuccess({ + minted: result.minted, + kind: "ai_full" as const, + }); +}); diff --git a/src/components/settings/ai/ai-insights-card.tsx b/src/components/settings/ai/ai-insights-card.tsx index 1367024bd..0bd064e26 100644 --- a/src/components/settings/ai/ai-insights-card.tsx +++ b/src/components/settings/ai/ai-insights-card.tsx @@ -13,7 +13,7 @@ * they were editing. */ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Sparkles } from "lucide-react"; @@ -87,6 +87,20 @@ export function AiInsightsCard({ enabled: isAuthenticated, }); + // v1.16.13 — heal the web AI-consent receipt on mount. The server-side + // consent gate (admin-openai egress) requires an active `ai_full` + // receipt; the web client never minted one, so existing web users on a + // shared-key deployment saw the no-key fallback. This mirrors the iOS + // shell-mount heal: idempotent server-side mint (a user with an active + // receipt is a no-op), fire-and-forget so a failure never blocks the + // settings UI. Runs once per mount of the AI-settings surface. + useEffect(() => { + if (!isAuthenticated) return; + void apiFetchRaw("/api/consent/ai/web", { method: "POST" }).catch(() => { + /* best-effort heal — the explicit grant path stays available */ + }); + }, [isAuthenticated]); + // The Select is URL-driven so the SSR test can pick the branch and // a deep link works. Default = `?provider=…` query param when the // user navigated here intentionally, else the chain's active entry, diff --git a/src/lib/consent/__tests__/web-grant.test.ts b/src/lib/consent/__tests__/web-grant.test.ts new file mode 100644 index 000000000..e20b04be4 --- /dev/null +++ b/src/lib/consent/__tests__/web-grant.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + consentReceipt: { + create: vi.fn(), + findFirst: vi.fn(), + }, + }, +})); + +import { prisma } from "@/lib/db"; +import { ensureWebAiConsentReceipt } from "../web-grant"; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +function row(overrides: Partial> = {}) { + return { + id: "rcpt-web-1", + userId: "user-1", + kind: "ai_full", + artefact: "{}", + signedAt: new Date("2026-06-14T10:00:00.000Z"), + revokedAt: null, + createdAt: new Date("2026-06-14T10:00:01.000Z"), + ...overrides, + }; +} + +describe("ensureWebAiConsentReceipt", () => { + it("mints an ai_full receipt when none is active", async () => { + vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null); + vi.mocked(prisma.consentReceipt.create).mockResolvedValue(row() as never); + + const now = new Date("2026-06-14T10:00:00.000Z"); + const result = await ensureWebAiConsentReceipt("user-1", now); + + expect(result.minted).toBe(true); + // Reads the active master grant first. + expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith({ + where: { userId: "user-1", kind: "ai_full", revokedAt: null }, + orderBy: { createdAt: "desc" }, + }); + // Mints ai_full with a web-source artefact + the supplied timestamp. + const createArg = vi.mocked(prisma.consentReceipt.create).mock.calls[0][0]; + expect(createArg.data.userId).toBe("user-1"); + expect(createArg.data.kind).toBe("ai_full"); + expect(createArg.data.signedAt).toEqual(now); + const artefact = JSON.parse(createArg.data.artefact as string); + expect(artefact.source).toBe("web"); + expect(artefact.kind).toBe("ai_full"); + }); + + it("is a no-op when an active ai_full receipt already exists", async () => { + vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(row() as never); + + const result = await ensureWebAiConsentReceipt("user-1"); + + expect(result.minted).toBe(false); + expect(prisma.consentReceipt.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/consent/web-grant.ts b/src/lib/consent/web-grant.ts new file mode 100644 index 000000000..084b10055 --- /dev/null +++ b/src/lib/consent/web-grant.ts @@ -0,0 +1,57 @@ +/** + * v1.16.13 — web AI-consent receipt minting. + * + * The `ConsentReceipt` gate (`src/lib/ai/consent-guard.ts`, shipped in + * commit 37e9f32f) requires an active receipt before any health snapshot + * egresses via the operator's server-managed OpenAI key (`admin-openai`). + * iOS mints an `ai_full` receipt on consent grant + once per shell mount + * to heal existing accounts. The web client never minted one, so web users + * on a shared-key deployment saw the fail-closed no-key fallback on every + * AI surface. + * + * This helper closes that gap: it mints an `ai_full` receipt for the web + * user when none is active, mirroring the iOS master-grant pattern. It is + * idempotent — a user with an active `ai_full` receipt is a no-op, so it is + * safe to call on every AI-settings mount (the web equivalent of the iOS + * shell-mount heal). Explicit revocation flows through the existing + * `DELETE /api/consent/ai/latest`; this helper only ever grants. + * + * The artefact is a small JSON record of the web grant context (no signed + * PDF/JWT — the web grant is an in-app affirmative action recorded with its + * timestamp + source, which is the GDPR Art. 7 audit signal the legal team + * needs). It stays well under the 64 KB artefact cap. + */ +import { createReceipt, latestActiveReceipt } from "@/lib/consent/receipts"; +import type { ConsentReceipt } from "@/lib/consent/receipts"; + +/** Outcome of an idempotent web-grant call. */ +export type WebConsentGrantResult = + | { minted: true; receipt: ConsentReceipt } + | { minted: false }; + +/** + * Ensure the web user has an active `ai_full` consent receipt, minting one + * if absent. Idempotent: returns `{ minted: false }` when an active master + * grant already exists, so callers can invoke it freely on mount. + * + * Mirrors the iOS `ai_full` grant. `ai_full` is the master kind that + * satisfies every surface gate (insights + coach), matching the iOS app's + * single master toggle rather than per-surface web grants. + */ +export async function ensureWebAiConsentReceipt( + userId: string, + now: Date = new Date(), +): Promise { + const existing = await latestActiveReceipt(userId, "ai_full"); + if (existing) return { minted: false }; + + const artefact = JSON.stringify({ + source: "web", + kind: "ai_full", + grantedAt: now.toISOString(), + note: "In-app affirmative AI consent granted via the web client.", + }); + + const receipt = await createReceipt(userId, "ai_full", artefact, now); + return { minted: true, receipt }; +} diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index 8dfadfa97..787a76970 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -146,7 +146,9 @@ export async function generateBloodPressureStatusForUser( metric: "blood-pressure", locale, }); - if (outcome.kind === "no-provider") { + // v1.16.13 — `consent-missing` serves the same no-key fallback (see + // bmi-status); no enqueue happens for it. + if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { return { hasProvider: false, text: getNoKeyBloodPressureStatusText(locale), diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index 6bf7244b9..0fa917af6 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -82,7 +82,10 @@ export async function generateBmiStatusForUser( metric: "bmi", locale, }); - if (outcome.kind === "no-provider") { + // v1.16.13 — `consent-missing` (provider configured but the + // server-managed consent gate blocks egress) serves the same no-key + // fallback; no enqueue happens for it (the resolver short-circuits). + if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { return { hasProvider: false, text: getNoKeyBmiStatusText(locale), diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index f1aeec4df..f1be66c83 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -96,7 +96,9 @@ export async function generateGeneralStatusForUser( metric: "general", locale, }); - if (outcome.kind === "no-provider") { + // v1.16.13 — `consent-missing` serves the same no-key fallback (see + // bmi-status); no enqueue happens for it. + if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { return { hasProvider: false, text: getNoKeyGeneralStatusText(locale), diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index 7ff050aff..69637f6e4 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -130,7 +130,9 @@ export async function generateMedicationComplianceStatusForUser( metric: "medication-compliance", locale, }); - if (outcome.kind === "no-provider") { + // v1.16.13 — `consent-missing` serves the same no-key fallback (see + // bmi-status); no enqueue happens for it. + if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { return { hasProvider: false, summary: getNoKeyMedicationComplianceStatusText(locale), diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index 67eac88eb..e46ddc1c3 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -186,7 +186,9 @@ export async function generateMetricStatus(args: { metric: scope, locale, }); - if (outcome.kind === "no-provider") { + // v1.16.13 — `consent-missing` serves the same no-key fallback (see + // bmi-status); no enqueue happens for it. + if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { return { hasProvider: false, text: getNoKeyGeneralStatusText(locale), diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index f4a97999c..e41790659 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -129,7 +129,9 @@ export async function generateMoodStatusForUser( metric: "mood", locale, }); - if (outcome.kind === "no-provider") { + // v1.16.13 — `consent-missing` serves the same no-key fallback (see + // bmi-status); no enqueue happens for it. + if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { return { hasProvider: false, text: getNoKeyMoodStatusText(locale), diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index 17b658e89..877a7c097 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -90,7 +90,9 @@ export async function generatePulseStatusForUser( metric: "pulse", locale, }); - if (outcome.kind === "no-provider") { + // v1.16.13 — `consent-missing` serves the same no-key fallback (see + // bmi-status); no enqueue happens for it. + if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { return { hasProvider: false, text: getNoKeyPulseStatusText(locale), diff --git a/src/lib/insights/status-cache.ts b/src/lib/insights/status-cache.ts index 45434b73b..67db6e30d 100644 --- a/src/lib/insights/status-cache.ts +++ b/src/lib/insights/status-cache.ts @@ -261,6 +261,13 @@ export async function readLastGoodStatusText(args: { */ export type ReadOnlyMissOutcome = | { kind: "no-provider" } + // v1.16.13 — a provider IS configured, but it resolves via the operator's + // server-managed key and the user has no active consent receipt, so the + // generation gate (`assertConsentForChain` / `runStatusCompletion`) would + // serve the no-key fallback. Distinct from `no-provider` so the status DTO + // can render an honest "consent required" signal instead of conflating it + // with "no AI configured at all". + | { kind: "consent-missing" } | { kind: "preparing"; lastGood: LastGoodStatusHit | null; @@ -291,6 +298,19 @@ export async function resolveReadOnlyStatusMiss(args: { const hasProvider = await hasUsableStatusProvider(args.userId); if (!hasProvider) return { kind: "no-provider" }; + // v1.16.13 — a provider is configured but the server-managed consent gate + // would block egress (no active receipt for the surface). Surface this as + // a distinct outcome so the card renders an honest "consent required" + // signal rather than the generic no-key fallback. Enqueuing here would be + // wasted work — the generator's own gate would short-circuit to `none`. + if (await statusConsentBlocksGeneration(args.userId, "insights")) { + annotate({ + action: { name: "insights.status.consent_required" }, + meta: { metric: args.metric, read_only_miss: true }, + }); + return { kind: "consent-missing" }; + } + const cacheAction = statusCacheAction(args.metric, args.locale); // v1.8.7 — stale-while-revalidate. Surface the last good (non-stub) diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index 6363cee8c..1d06e5d41 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -143,7 +143,9 @@ export async function generateWeightStatusForUser( metric: "weight", locale, }); - if (outcome.kind === "no-provider") { + // v1.16.13 — `consent-missing` serves the same no-key fallback (see + // bmi-status); no enqueue happens for it. + if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { return { hasProvider: false, text: getNoKeyWeightStatusText(locale), diff --git a/src/lib/openapi/registry.ts b/src/lib/openapi/registry.ts index 8d9cd9832..0b6a54a3b 100644 --- a/src/lib/openapi/registry.ts +++ b/src/lib/openapi/registry.ts @@ -65,6 +65,7 @@ const openApiBase: Pick< { name: "Mood" }, { name: "Analytics" }, { name: "Insights" }, + { name: "Consent" }, { name: "Dashboard" }, { name: "Notifications" }, { name: "Devices" }, diff --git a/src/lib/openapi/routes/consent.ts b/src/lib/openapi/routes/consent.ts new file mode 100644 index 000000000..3de58f9a2 --- /dev/null +++ b/src/lib/openapi/routes/consent.ts @@ -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 = { + "/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, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 3388752f5..24bf82812 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -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 = { ...insightsPaths, ...moodPaths, ...settingsPaths, + ...consentPaths, }; export const openApiComponents: NonNullable = { diff --git a/src/lib/openapi/routes/insights.ts b/src/lib/openapi/routes/insights.ts index edc57407a..6a97d605b 100644 --- a/src/lib/openapi/routes/insights.ts +++ b/src/lib/openapi/routes/insights.ts @@ -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 = { 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 = { }, }, ...stdResponses, + ...consentRequiredResponse, }, }, }, diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 2beef9b97..43c60cb71 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -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 } }, + }, +};