mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +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
+181
-1
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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