mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +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
@@ -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,
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user