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

+181 -1
View File
@@ -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:
+48
View File
@@ -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,
});
});
@@ -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,
@@ -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<Record<string, unknown>> = {}) {
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();
});
});
+57
View File
@@ -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<WebConsentGrantResult> {
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 };
}
+3 -1
View File
@@ -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),
+4 -1
View File
@@ -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),
+3 -1
View File
@@ -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),
@@ -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),
+3 -1
View File
@@ -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),
+3 -1
View File
@@ -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),
+3 -1
View File
@@ -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),
+20
View File
@@ -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)
+3 -1
View File
@@ -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),
+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 } },
},
};