mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Let the AI-consent card grant again after a withdrawal
POST /api/consent/ai/web now accepts an optional { intent } body. Without
one (the AI-settings mount heal) a standing revocation still blocks the
mint; intent "affirmative" is the user's own consent act and may
supersede an earlier revocation, exactly as a first grant would. The
consent card offers the explicit grant control whenever consent does not
stand, and the audit row records which intent minted the receipt.
The document auto-read opt-in already declared "affirmative" on enable;
the settings-mount heal posts no body. Both stay as they are.
This commit is contained in:
1 parent
fd104dd0ac
commit
e83b58c9da
13 files changed
+268
-41
No files matched your search
+18
-4
@@ -8904,10 +8904,24 @@ paths:
|
||||
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.
|
||||
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. Without a body (the mount heal) it grants only to an account with no
|
||||
consent history, so a revocation stands until the user grants again; with `intent: \"affirmative\"` (the
|
||||
explicit grant control) it records the user's own consent act, which may supersede an earlier revocation.
|
||||
Revocation flows through DELETE /api/consent/ai/latest."
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
intent:
|
||||
default: heal
|
||||
type: string
|
||||
enum:
|
||||
- heal
|
||||
- affirmative
|
||||
responses:
|
||||
"200":
|
||||
description: Grant outcome.
|
||||
|
||||
+3
-1
@@ -5651,7 +5651,9 @@
|
||||
"withdraw": "Einwilligung widerrufen",
|
||||
"confirmBody": "Die KI-Funktionen sind sofort abgeschaltet. Bereits erfasste Daten werden nicht gelöscht — dafür gibt es Daten & Privatsphäre.",
|
||||
"confirmWithdraw": "Widerrufen",
|
||||
"regrantHint": "Um die KI-Funktionen wieder zu nutzen, schalte das automatische Lesen von Dokumenten ein oder erteile die Einwilligung in der App.",
|
||||
"regrantHint": "Mit einer neuen Einwilligung dürfen die KI-Funktionen deine Gesundheitsdaten wieder an den eingestellten Anbieter senden.",
|
||||
"grant": "Einwilligung erteilen",
|
||||
"grantError": "Die Einwilligung konnte nicht gespeichert werden. Bitte versuche es erneut.",
|
||||
"error": "Der Widerruf konnte nicht gespeichert werden. Bitte versuche es erneut."
|
||||
}
|
||||
},
|
||||
|
||||
+3
-1
@@ -5651,7 +5651,9 @@
|
||||
"withdraw": "Withdraw consent",
|
||||
"confirmBody": "The AI features stop working straight away. Nothing already recorded is deleted — use Data & privacy to export or erase your record.",
|
||||
"confirmWithdraw": "Withdraw",
|
||||
"regrantHint": "To use the AI features again, switch on automatic document reading or grant consent from the app.",
|
||||
"regrantHint": "Granting again lets the AI features send your health data to the configured provider.",
|
||||
"grant": "Grant consent",
|
||||
"grantError": "The consent could not be saved. Please try again.",
|
||||
"error": "The withdrawal could not be saved. Please try again."
|
||||
}
|
||||
},
|
||||
|
||||
+3
-1
@@ -5651,7 +5651,9 @@
|
||||
"withdraw": "Retirar el consentimiento",
|
||||
"confirmBody": "Las funciones de IA dejan de funcionar de inmediato. No se borra nada de lo ya registrado: usa Datos y privacidad para exportar o eliminar tu historial.",
|
||||
"confirmWithdraw": "Retirar",
|
||||
"regrantHint": "Para volver a usar las funciones de IA, activa la lectura automática de documentos u otorga el consentimiento desde la aplicación.",
|
||||
"regrantHint": "Si vuelves a otorgarlo, las funciones de IA podrán enviar de nuevo tus datos de salud al proveedor configurado.",
|
||||
"grant": "Otorgar el consentimiento",
|
||||
"grantError": "No se pudo guardar el consentimiento. Inténtalo de nuevo.",
|
||||
"error": "No se pudo guardar la retirada. Inténtalo de nuevo."
|
||||
}
|
||||
},
|
||||
|
||||
+3
-1
@@ -5651,7 +5651,9 @@
|
||||
"withdraw": "Retirer le consentement",
|
||||
"confirmBody": "Les fonctions IA s'arrêtent immédiatement. Rien de ce qui est déjà enregistré n'est supprimé — utilise Données et confidentialité pour exporter ou effacer ton dossier.",
|
||||
"confirmWithdraw": "Retirer",
|
||||
"regrantHint": "Pour réutiliser les fonctions IA, active la lecture automatique des documents ou donne ton consentement depuis l'application.",
|
||||
"regrantHint": "En le redonnant, les fonctions IA pourront à nouveau transmettre tes données de santé au fournisseur configuré.",
|
||||
"grant": "Donner le consentement",
|
||||
"grantError": "Le consentement n'a pas pu être enregistré. Réessaie.",
|
||||
"error": "Le retrait n'a pas pu être enregistré. Réessaie."
|
||||
}
|
||||
},
|
||||
|
||||
+3
-1
@@ -5651,7 +5651,9 @@
|
||||
"withdraw": "Revoca il consenso",
|
||||
"confirmBody": "Le funzioni IA si fermano subito. Nulla di quanto già registrato viene eliminato — usa Dati e privacy per esportare o cancellare il tuo archivio.",
|
||||
"confirmWithdraw": "Revoca",
|
||||
"regrantHint": "Per usare di nuovo le funzioni IA, attiva la lettura automatica dei documenti o concedi il consenso dall'app.",
|
||||
"regrantHint": "Concedendolo di nuovo, le funzioni IA potranno inviare ancora i tuoi dati sanitari al provider configurato.",
|
||||
"grant": "Concedi il consenso",
|
||||
"grantError": "Non è stato possibile salvare il consenso. Riprova.",
|
||||
"error": "Non è stato possibile salvare la revoca. Riprova."
|
||||
}
|
||||
},
|
||||
|
||||
+3
-1
@@ -5651,7 +5651,9 @@
|
||||
"withdraw": "Wycofaj zgodę",
|
||||
"confirmBody": "Funkcje AI przestają działać natychmiast. Nic z już zapisanych danych nie zostaje usunięte — użyj sekcji Dane i prywatność, aby wyeksportować lub usunąć swoją historię.",
|
||||
"confirmWithdraw": "Wycofaj",
|
||||
"regrantHint": "Aby ponownie korzystać z funkcji AI, włącz automatyczne czytanie dokumentów lub udziel zgody w aplikacji.",
|
||||
"regrantHint": "Po ponownym udzieleniu zgody funkcje AI znów będą mogły wysyłać Twoje dane zdrowotne do skonfigurowanego dostawcy.",
|
||||
"grant": "Udziel zgody",
|
||||
"grantError": "Nie udało się zapisać zgody. Spróbuj ponownie.",
|
||||
"error": "Nie udało się zapisać wycofania. Spróbuj ponownie."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -22,7 +22,7 @@ vi.mock("next/headers", () => ({
|
||||
}));
|
||||
|
||||
// The mint logic + its TOCTOU close are covered by web-grant.test.ts; the
|
||||
// route test stubs it to focus on auth + rate-limit + serialisation.
|
||||
// route test stubs it to focus on auth + rate-limit + intent + serialisation.
|
||||
vi.mock("@/lib/consent/web-grant", () => ({
|
||||
ensureWebAiConsentReceipt: vi.fn(),
|
||||
}));
|
||||
@@ -45,6 +45,22 @@ const SESSION_OK = {
|
||||
|
||||
const RL_OK = { allowed: true, remaining: 19, resetAt: Date.now() + 60_000 };
|
||||
|
||||
/** The mount heal posts no body and no content-type. */
|
||||
function mkHeal(): Request {
|
||||
return new Request("http://localhost/api/consent/ai/web", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
/** The explicit grant control posts a JSON intent. */
|
||||
function mkIntent(body: unknown): Request {
|
||||
return new Request("http://localhost/api/consent/ai/web", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
@@ -54,7 +70,7 @@ beforeEach(() => {
|
||||
describe("POST /api/consent/ai/web", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await POST();
|
||||
const res = await POST(mkHeal());
|
||||
expect(res.status).toBe(401);
|
||||
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -66,9 +82,9 @@ describe("POST /api/consent/ai/web", () => {
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
const res = await POST();
|
||||
const res = await POST(mkHeal());
|
||||
expect(res.status).toBe(429);
|
||||
// The throttle fires before the heal mint runs.
|
||||
// The throttle fires before the mint runs.
|
||||
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -79,7 +95,7 @@ describe("POST /api/consent/ai/web", () => {
|
||||
receipt: { id: "rcpt-web-1" } as never,
|
||||
});
|
||||
|
||||
const res = await POST();
|
||||
const res = await POST(mkHeal());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { minted: boolean; kind: string };
|
||||
@@ -95,11 +111,57 @@ describe("POST /api/consent/ai/web", () => {
|
||||
reason: "already_active",
|
||||
});
|
||||
|
||||
const res = await POST();
|
||||
const res = await POST(mkHeal());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { minted: boolean } };
|
||||
expect(body.data.minted).toBe(false);
|
||||
// No audit row on the no-op path.
|
||||
expect(auditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes a bodyless call to the mint as a heal", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(ensureWebAiConsentReceipt).mockResolvedValue({
|
||||
minted: false,
|
||||
reason: "previously_revoked",
|
||||
});
|
||||
|
||||
const res = await POST(mkHeal());
|
||||
expect(res.status).toBe(200);
|
||||
expect(ensureWebAiConsentReceipt).toHaveBeenCalledWith("user-1", "heal");
|
||||
});
|
||||
|
||||
it("passes an explicit affirmative body to the mint as the consent act", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(ensureWebAiConsentReceipt).mockResolvedValue({
|
||||
minted: true,
|
||||
receipt: { id: "rcpt-web-2" } as never,
|
||||
});
|
||||
|
||||
const res = await POST(mkIntent({ intent: "affirmative" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(ensureWebAiConsentReceipt).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
"affirmative",
|
||||
);
|
||||
// The audit row records that this was the user's own grant, not a heal.
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"consent.ai.grant",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
source: "web",
|
||||
intent: "affirmative",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an unknown intent with 422 and never reaches the mint", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await POST(mkIntent({ intent: "resurrect" }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,26 +1,41 @@
|
||||
/**
|
||||
* v1.16.13 — web AI-consent grant (heal endpoint).
|
||||
* v1.16.13 — web AI-consent grant.
|
||||
*
|
||||
* 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.
|
||||
* Mints an `ai_full` consent receipt for the calling web user.
|
||||
* Idempotent — a user with an active `ai_full` receipt is a no-op.
|
||||
* The body decides what a missing receipt means:
|
||||
*
|
||||
* no body (heal) The AI-settings surface calls this on mount
|
||||
* (the web equivalent of the iOS shell-mount
|
||||
* heal) so an account that predates the consent
|
||||
* gate gains a receipt without a re-consent
|
||||
* step. A past revocation stands; the heal
|
||||
* never resurrects a withdrawn consent.
|
||||
* `{ intent: "affirmative" }` The explicit grant control on the same
|
||||
* surface. This IS the user's consent act, so
|
||||
* it may supersede an earlier revocation
|
||||
* exactly as the first grant would.
|
||||
*
|
||||
* 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 { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkConsentRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant";
|
||||
import type { WebConsentGrantIntent } from "@/lib/consent/web-grant";
|
||||
import { webConsentGrantBody } from "@/lib/validations/consent";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// The web AI-settings surface calls this on every mount; the bucket is
|
||||
@@ -37,14 +52,39 @@ export const POST = apiHandler(async () => {
|
||||
});
|
||||
}
|
||||
|
||||
const result = await ensureWebAiConsentReceipt(user.id, "heal");
|
||||
// The mount heal posts no body; only the explicit grant control sends one.
|
||||
// Absence of a JSON body therefore reads as a heal, and a present body is
|
||||
// Zod-validated so a malformed intent is a 422, never a silent heal.
|
||||
let intent: WebConsentGrantIntent = "heal";
|
||||
const contentType = req.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = webConsentGrantBody.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
annotate({ action: { name: "consent.ai.web-grant.invalid_shape" } });
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
intent = parsed.data.intent;
|
||||
}
|
||||
|
||||
const result = await ensureWebAiConsentReceipt(user.id, intent);
|
||||
|
||||
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.
|
||||
// the legal team can reconstruct web-originated consent; `intent`
|
||||
// separates a migration heal from the user's own re-grant click.
|
||||
// Fire-and-forget.
|
||||
auditLog("consent.ai.grant", {
|
||||
userId: user.id,
|
||||
details: { kind: "ai_full", source: "web", receiptId: result.receipt.id },
|
||||
details: {
|
||||
kind: "ai_full",
|
||||
source: "web",
|
||||
intent,
|
||||
receiptId: result.receipt.id,
|
||||
},
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -52,6 +92,7 @@ export const POST = apiHandler(async () => {
|
||||
action: { name: "consent.ai.web-grant" },
|
||||
meta: {
|
||||
minted: result.minted,
|
||||
intent,
|
||||
...(result.minted ? { receiptId: result.receipt.id } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ describe("AiConsentCard", () => {
|
||||
expect(html).toContain("Withdraw consent");
|
||||
});
|
||||
|
||||
it("reports a withdrawal and stops offering it again", () => {
|
||||
it("reports a withdrawal, stops offering it again, and offers the grant", () => {
|
||||
const html = renderWith({
|
||||
id: "rcpt-1",
|
||||
kind: "ai_full",
|
||||
@@ -60,6 +60,11 @@ describe("AiConsentCard", () => {
|
||||
|
||||
expect(html).toContain("Withdrawn.");
|
||||
expect(html).not.toContain('data-slot="ai-consent-withdraw"');
|
||||
// Withdrawal must be as reversible as it was easy: the same surface
|
||||
// carries the explicit grant control, and its hint says what granting
|
||||
// lets the app do.
|
||||
expect(html).toContain('data-slot="ai-consent-grant"');
|
||||
expect(html).toContain("Grant consent");
|
||||
});
|
||||
|
||||
it("treats no receipt at all as consent not given", () => {
|
||||
@@ -67,6 +72,18 @@ describe("AiConsentCard", () => {
|
||||
|
||||
expect(html).toContain("Withdrawn.");
|
||||
expect(html).not.toContain('data-slot="ai-consent-withdraw"');
|
||||
expect(html).toContain('data-slot="ai-consent-grant"');
|
||||
});
|
||||
|
||||
it("never shows the grant control while consent stands", () => {
|
||||
const html = renderWith({
|
||||
id: "rcpt-1",
|
||||
kind: "ai_full",
|
||||
signedAt: "2026-07-01T08:00:00.000Z",
|
||||
revokedAt: null,
|
||||
});
|
||||
|
||||
expect(html).not.toContain('data-slot="ai-consent-grant"');
|
||||
});
|
||||
|
||||
it("renders nothing for a signed-out visitor", () => {
|
||||
|
||||
@@ -10,11 +10,14 @@
|
||||
* surface, not behind a support request.
|
||||
*
|
||||
* The card shows the standing decision in plain words and offers the one
|
||||
* move that changes it. Withdrawing takes effect immediately — the consent
|
||||
* gate fails closed without an active receipt, so every AI surface falls
|
||||
* back to its no-consent state on the next call. Nothing already stored is
|
||||
* deleted by this; that is the export/erase path in Data & Privacy, and the
|
||||
* copy says so rather than implying more than it does.
|
||||
* move that changes it: withdraw while consent stands, grant while it does
|
||||
* not. Withdrawing takes effect immediately: the consent gate fails closed
|
||||
* without an active receipt, so every AI surface falls back to its
|
||||
* no-consent state on the next call. Granting posts an affirmative intent,
|
||||
* which is the consent act itself and therefore may supersede an earlier
|
||||
* revocation; the silent mount heal never does. Nothing already stored is
|
||||
* deleted by a withdrawal; that is the export/erase path in Data & Privacy,
|
||||
* and the copy says so rather than implying more than it does.
|
||||
* ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
import { useState } from "react";
|
||||
@@ -75,6 +78,29 @@ export function AiConsentCard({
|
||||
},
|
||||
});
|
||||
|
||||
const grant = useMutation({
|
||||
mutationKey: queryKeys.aiConsentReceipt("ai_full"),
|
||||
mutationFn: async () => {
|
||||
// The affirmative intent marks this as the user's own consent act,
|
||||
// the only web path that may lift a standing revocation. The silent
|
||||
// mount heal posts no body and cannot.
|
||||
const res = await apiFetchRaw("/api/consent/ai/web", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ intent: "affirmative" }),
|
||||
});
|
||||
if (!res.ok) throw new Error("grant failed");
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.aiConsentReceipt("ai_full"),
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.insightsProviderChain(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Say nothing until the state is known: a card that flashes "withdrawn"
|
||||
// before the receipt arrives would misreport the user's own decision.
|
||||
if (!isAuthenticated || isLoading) return null;
|
||||
@@ -144,9 +170,24 @@ export function AiConsentCard({
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.ai.consent.regrantHint")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.ai.consent.regrantHint")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-slot="ai-consent-grant"
|
||||
onClick={() => grant.mutate()}
|
||||
disabled={grant.isPending}
|
||||
>
|
||||
{grant.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("settings.ai.consent.grant")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{revoke.isError ? (
|
||||
@@ -154,6 +195,12 @@ export function AiConsentCard({
|
||||
{t("settings.ai.consent.error")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{grant.isError ? (
|
||||
<p className="text-destructive text-xs" role="alert">
|
||||
{t("settings.ai.consent.grantError")}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -13,8 +13,10 @@
|
||||
*
|
||||
* - 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).
|
||||
* client. Without a body it is the AI-settings
|
||||
* mount heal (never lifts a revocation); with
|
||||
* `intent: "affirmative"` it is the explicit
|
||||
* grant control on the same surface.
|
||||
* - 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).
|
||||
@@ -22,7 +24,11 @@
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { consentKindEnum, consentPostBody } from "@/lib/validations/consent";
|
||||
import {
|
||||
consentKindEnum,
|
||||
consentPostBody,
|
||||
webConsentGrantBody,
|
||||
} from "@/lib/validations/consent";
|
||||
import { dataEnvelope, stdResponses } from "./shared";
|
||||
|
||||
consentPostBody.meta({
|
||||
@@ -31,6 +37,12 @@ consentPostBody.meta({
|
||||
"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.",
|
||||
});
|
||||
|
||||
webConsentGrantBody.meta({
|
||||
id: "WebConsentGrantBody",
|
||||
description:
|
||||
'Optional. Omitting the body (or sending `intent: "heal"`) is the AI-settings mount heal: it mints only for an account with no `ai_full` consent history at all, so a standing revocation is never resurrected. `intent: "affirmative"` is the user\'s own grant action and may supersede an earlier revocation, exactly as a first grant would.',
|
||||
});
|
||||
|
||||
const consentReceiptResponse = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
@@ -106,7 +118,13 @@ export const consentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
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.",
|
||||
'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. Without a body (the mount heal) it grants only to an account with no consent history, so a revocation stands until the user grants again; with `intent: "affirmative"` (the explicit grant control) it records the user\'s own consent act, which may supersede an earlier revocation. Revocation flows through DELETE /api/consent/ai/latest.',
|
||||
requestBody: {
|
||||
required: false,
|
||||
content: {
|
||||
"application/json": { schema: webConsentGrantBody },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Grant outcome.",
|
||||
|
||||
@@ -52,6 +52,22 @@ export const consentPostBody = z.object({
|
||||
|
||||
export type ConsentPostBody = z.infer<typeof consentPostBody>;
|
||||
|
||||
/**
|
||||
* Optional body for `POST /api/consent/ai/web`. The AI-settings mount posts
|
||||
* no body at all (a heal: mints only for an account with no consent history);
|
||||
* the explicit re-grant control on the same surface posts
|
||||
* `{ intent: "affirmative" }`, the user's own consent act, which may
|
||||
* supersede an earlier revocation the way any first grant would. The enum
|
||||
* must stay in lockstep with `WebConsentGrantIntent` in
|
||||
* `src/lib/consent/web-grant.ts`; the route narrows the parsed value to that
|
||||
* type, so a drift fails the typecheck.
|
||||
*/
|
||||
export const webConsentGrantBody = z.object({
|
||||
intent: z.enum(["heal", "affirmative"]).default("heal"),
|
||||
});
|
||||
|
||||
export type WebConsentGrantBodyInput = z.input<typeof webConsentGrantBody>;
|
||||
|
||||
/**
|
||||
* Query schema for both `/api/consent/ai/latest` GET and DELETE.
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user