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

@@ -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,