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

+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,
});
});