fix(consent): mint AI consent receipts only on an explicit agreement

The AI settings page posted to /api/consent/ai/web on every mount and the
server minted an ai_full receipt for it, so an account that had agreed to
nothing sent its health data to the operator's provider from background
jobs. Native builds synced a receipt the same way on every launch.

- Both grant routes now require `confirm: true`; anything else writes
  nothing and answers 400 consent.ai.confirmRequired.
- Remove the mount-time heal. A consent card names the operator's service
  and the data that would go there, with one Agree button, in Settings > AI
  and on the Insights overview while AI is closed by the missing consent
  alone.
- Publish the standing consent as `ai.consent` on /api/auth/me (status,
  since, recipients); the settings card renders active-since and the
  withdraw control from it.
- Existing receipts stay valid.
- Guard that no client grants outside a click handler, route tests for the
  confirmation, and an integration test that a fresh operator-key account
  gets no background AI text until it agrees.
This commit is contained in:
Marc-André Bombeck committed 2026-10-16 17:11:04 +02:00
1 parent bc5ea5a67b
commit e2b7e7350e
45 files changed
+1707 -678

No files matched your search

@@ -0,0 +1,176 @@
/**
* An AI consent receipt is minted only by the person's own tap.
*
* From v1.16.13 until v1.43.2 the AI settings page posted to
* `/api/consent/ai/web` in a `useEffect` on every visit and the server minted
* an `ai_full` receipt for it, so an account that had agreed to nothing sent
* its health data to the operator's provider from background jobs. The fix has
* two ends, and this file holds the client one; the server end (both grant
* routes refuse a body without `confirm: true`) is pinned by the route tests
* and re-checked structurally at the bottom.
*
* 1. Exactly two client modules may name a grant route: the `ai_full` hook
* and the document-reading hook. Each sends `confirm: true`. Any other
* file that names `/api/consent/ai` or `/api/consent/ai/web` fails here,
* which is how a second mount-time grant would get in.
* 2. Every `.mutate(` / `.mutateAsync(` of those hooks sits in a click
* handler: on a line that carries `onClick`, or in a named function
* that some `onClick={name}` in the same file hands the click to. None
* sits inside a `useEffect` / `useLayoutEffect`.
*
* Its limits: it reads source text, so a grant reached through a variable
* holding the path, or a hook result passed to another component and called
* there, would slip past rule 2. The counts below fail on an empty match set,
* so a matcher that stops matching is red rather than quietly green.
*/
import { readFileSync, readdirSync } from "node:fs";
import { join, relative, sep } from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = process.cwd();
const SRC = join(ROOT, "src");
/** The two modules allowed to send a grant, and what they must send. */
const GRANT_WRITERS = [
"src/hooks/use-grant-ai-consent.ts",
"src/hooks/use-grant-document-reading-consent.ts",
];
const GRANT_HOOKS = ["useGrantAiConsent", "useGrantDocumentReadingConsent"];
/** A grant route as a string literal: the bare route or `/web`, nothing longer. */
const GRANT_PATH = /["'`]\/api\/consent\/ai(?:\/web)?["'`]/;
function clientSources(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (
entry.name === "__tests__" ||
entry.name === "generated" ||
full === join(SRC, "app", "api") ||
full === join(SRC, "lib", "openapi")
) {
continue;
}
out.push(...clientSources(full));
} else if (/\.(ts|tsx)$/.test(entry.name) && !/\.test\./.test(entry.name)) {
out.push(full);
}
}
return out;
}
function rel(file: string): string {
return relative(ROOT, file).split(sep).join("/");
}
function stripComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
.replace(/(^|[^:"'`])\/\/[^\n]*/g, "$1");
}
/** The source spans of every `useEffect(` / `useLayoutEffect(` call. */
function effectSpans(source: string): Array<[number, number]> {
const spans: Array<[number, number]> = [];
const re = /\buse(?:Layout)?Effect\s*\(/g;
let match: RegExpExecArray | null;
while ((match = re.exec(source)) !== null) {
const open = match.index + match[0].length - 1;
let depth = 0;
for (let i = open; i < source.length; i += 1) {
if (source[i] === "(") depth += 1;
else if (source[i] === ")") {
depth -= 1;
if (depth === 0) {
spans.push([open, i]);
break;
}
}
}
}
return spans;
}
const FILES = clientSources(SRC).map((file) => ({
path: rel(file),
source: stripComments(readFileSync(file, "utf8")),
}));
describe("AI consent is granted only by the person's own tap", () => {
it("names a grant route only in the two grant hooks, each sending confirm: true", () => {
const naming = FILES.filter((f) => GRANT_PATH.test(f.source)).map(
(f) => f.path,
);
expect(naming.sort()).toEqual([...GRANT_WRITERS].sort());
for (const writer of GRANT_WRITERS) {
const source = FILES.find((f) => f.path === writer)!.source;
expect(source, writer).toMatch(/\bconfirm:\s*true\b/);
}
});
it("calls a grant only from a click handler, never from an effect", () => {
let callers = 0;
let sites = 0;
for (const file of FILES) {
for (const hook of GRANT_HOOKS) {
const assign = new RegExp(
`\\bconst\\s+(\\w+)\\s*=\\s*${hook}\\s*\\(`,
"g",
);
let assigned: RegExpExecArray | null;
while ((assigned = assign.exec(file.source)) !== null) {
callers += 1;
const name = assigned[1];
const call = new RegExp(`\\b${name}\\.mutate(?:Async)?\\s*\\(`, "g");
const effects = effectSpans(file.source);
const lines = file.source.split("\n");
let site: RegExpExecArray | null;
while ((site = call.exec(file.source)) !== null) {
sites += 1;
const at = site.index;
const where = `${file.path}: ${name}.mutate`;
expect(
effects.some(([from, to]) => at > from && at < to),
`${where} inside an effect`,
).toBe(false);
const lineNo = file.source.slice(0, at).split("\n").length - 1;
if (/\bonClick\b/.test(lines[lineNo] ?? "")) continue;
// Otherwise the nearest enclosing named function must be the one
// a click hands to.
const before = file.source.slice(0, at);
const fns = [
...before.matchAll(
/\bfunction\s+(\w+)\s*\(|\bconst\s+(\w+)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>/g,
),
];
const enclosing = fns.at(-1);
const fnName = enclosing?.[1] ?? enclosing?.[2];
expect(fnName, `${where} outside any named handler`).toBeDefined();
expect(
new RegExp(`onClick=\\{\\s*${fnName}\\s*\\}`).test(file.source),
`${where} in ${fnName}, which no onClick hands the click to`,
).toBe(true);
}
}
}
}
// Non-zero floors: a matcher that stops matching is red, not green.
expect(callers).toBeGreaterThanOrEqual(3);
expect(sites).toBeGreaterThanOrEqual(3);
});
it("keeps both grant routes refusing a request without the confirmation", () => {
for (const route of [
"src/app/api/consent/ai/route.ts",
"src/app/api/consent/ai/web/route.ts",
]) {
const source = readFileSync(join(ROOT, route), "utf8");
expect(source, route).toMatch(/CONSENT_CONFIRM_REQUIRED_ERROR_CODE/);
}
});
});
@@ -455,10 +455,7 @@ describe("AI consent withdrawal copy matches what withdrawing does", () => {
for (const locale of LOCALES) {
it(`${locale} says what keeps working and when the notes stay`, () => {
const b = bundle(locale);
for (const path of [
"settings.ai.consent.confirmBody",
"settings.ai.consent.withdrawn",
]) {
for (const path of ["settings.ai.consent.confirmBody"]) {
const copy = resolve(b, path).toLowerCase();
for (const phrase of OWN_PROVIDER[locale]) {
expect(copy, `${locale} ${path}`).toContain(phrase);
@@ -188,6 +188,7 @@ describe("POST /api/consent/ai", () => {
kind: "ai_full",
artefact: "PDF",
signedAt: signedAt.toISOString(),
confirm: true,
}),
);
expect(res.status).toBe(200);
@@ -224,6 +225,31 @@ describe("POST /api/consent/ai", () => {
});
});
// v1.43.2 — native builds before it posted this body once per launch to
// sync a receipt the account did not hold, which re-granted a consent the
// person had withdrawn elsewhere. Only an explicit confirmation mints.
it.each([
["without confirm (a launch-time sync)", {}],
["with confirm: false", { confirm: false }],
])("refuses a well-formed grant %s and writes nothing", async (_l, extra) => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await POST(
req({
kind: "ai_full",
artefact: '{"source":"ios.AIConsentSheet","scope":"ai_full"}',
signedAt: "2026-05-18T10:00:00.000Z",
...extra,
}),
);
expect(res.status).toBe(400);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("consent.ai.confirmRequired");
expect(prisma.$transaction).not.toHaveBeenCalled();
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
expect(prisma.consentReceipt.updateMany).not.toHaveBeenCalled();
expect(auditLog).not.toHaveBeenCalled();
});
describe("v1.4.43 W6 — multi-issue 400 envelope (consent uses 400)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
@@ -456,6 +456,7 @@ describe("append-only invariant", () => {
kind: "ai_full",
artefact: "PDF-v1",
signedAt: signedAt1.toISOString(),
confirm: true,
}),
}),
);
@@ -510,6 +511,7 @@ describe("append-only invariant", () => {
kind: "ai_full",
artefact: "PDF-v2",
signedAt: signedAt2.toISOString(),
confirm: true,
}),
}),
);
+27 -3
View File
@@ -2,9 +2,17 @@
* v1.4.40 SB-10 — AI consent receipts.
*
* POST /api/consent/ai
* Body: { kind, artefact, signedAt }
* Body: { kind, artefact, signedAt, confirm: true }
* Persists a fresh consent receipt. Returns the inserted row.
*
* v1.43.2 — `confirm: true` is required. A receipt records a person's
* own consent act, and native builds before it also posted here once per
* launch to sync a receipt the account no longer held (re-granting, for
* example, a consent the person had withdrawn on the web). Without the
* flag nothing is written and the route answers 400
* `consent.ai.confirmRequired`; the client reads the standing state from
* `ai.consent` on `GET /api/auth/me` instead.
*
* Reader + revoke handlers live in `./latest/route.ts`.
*/
import { NextRequest } from "next/server";
@@ -19,7 +27,10 @@ import {
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { checkConsentRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { consentPostBody } from "@/lib/validations/consent";
import {
CONSENT_CONFIRM_REQUIRED_ERROR_CODE,
consentPostBody,
} from "@/lib/validations/consent";
import { createReceipt } from "@/lib/consent/receipts";
export const POST = apiHandler(async (request: NextRequest) => {
@@ -50,7 +61,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error, 400);
}
const { kind, artefact, signedAt } = parsed.data;
const { kind, artefact, signedAt, confirm } = parsed.data;
if (confirm !== true) {
// A launch-time sync from an older app build lands here. It is not the
// person agreeing to anything, so it writes nothing.
annotate({
action: { name: "consent.ai.grant.unconfirmed" },
meta: { kind },
});
return apiError(
"Consent is recorded only from an explicit confirmation",
400,
{ errorCode: CONSENT_CONFIRM_REQUIRED_ERROR_CODE },
);
}
const receipt = await createReceipt(user.id, kind, artefact, signedAt);
// Audit trail — separate from the row itself so the legal team can
@@ -22,9 +22,10 @@ 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 + intent + serialisation.
// route test stubs it to focus on auth + rate-limit + the explicit
// confirmation + serialisation.
vi.mock("@/lib/consent/web-grant", () => ({
ensureWebAiConsentReceipt: vi.fn(),
grantWebAiConsent: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
@@ -35,7 +36,7 @@ vi.mock("@/lib/rate-limit", () => ({
import { POST } from "../route";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant";
import { grantWebAiConsent } from "@/lib/consent/web-grant";
import { checkConsentRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
@@ -50,15 +51,15 @@ const RL_OK = {
resetAt: Date.now() + 60_000,
};
/** The mount heal posts no body and no content-type. */
/** What the pre-v1.43.2 mount heal sent: no body, 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 {
/** A JSON body, as the agree control (or anything else) sends one. */
function mkJson(body: unknown): Request {
return new Request("http://localhost/api/consent/ai/web", {
method: "POST",
body: JSON.stringify(body),
@@ -77,7 +78,7 @@ describe("POST /api/consent/ai/web", () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await POST(mkHeal());
expect(res.status).toBe(401);
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
expect(grantWebAiConsent).not.toHaveBeenCalled();
});
it("returns 429 when the per-user consent bucket is exhausted", async () => {
@@ -91,33 +92,41 @@ describe("POST /api/consent/ai/web", () => {
const res = await POST(mkHeal());
expect(res.status).toBe(429);
// The throttle fires before the mint runs.
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
expect(grantWebAiConsent).not.toHaveBeenCalled();
});
it("mints a receipt and reports minted:true", async () => {
it("mints a receipt on the explicit confirmation and reports minted:true", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(ensureWebAiConsentReceipt).mockResolvedValue({
vi.mocked(grantWebAiConsent).mockResolvedValue({
minted: true,
receipt: { id: "rcpt-web-1" } as never,
});
const res = await POST(mkHeal());
const res = await POST(mkJson({ confirm: true }));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { minted: boolean; kind: string };
};
expect(body.data.minted).toBe(true);
expect(body.data.kind).toBe("ai_full");
expect(grantWebAiConsent).toHaveBeenCalledWith("user-1");
expect(auditLog).toHaveBeenCalledWith(
"consent.ai.grant",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({ source: "web", confirmed: true }),
}),
);
});
it("is a no-op when an active receipt already exists (minted:false)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(ensureWebAiConsentReceipt).mockResolvedValue({
vi.mocked(grantWebAiConsent).mockResolvedValue({
minted: false,
reason: "already_active",
});
const res = await POST(mkHeal());
const res = await POST(mkJson({ confirm: true }));
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { minted: boolean } };
expect(body.data.minted).toBe(false);
@@ -125,49 +134,34 @@ describe("POST /api/consent/ai/web", () => {
expect(auditLog).not.toHaveBeenCalled();
});
it("passes a bodyless call to the mint as a heal", async () => {
// Every shape that is not the person's own confirmation: the bodyless
// mount heal the settings page sent until v1.43.2, an empty object, the
// retired `{ intent }` bodies, and an explicit `false`.
it.each([
["no body (the old mount heal)", () => mkHeal()],
["an empty object", () => mkJson({})],
['the retired intent "heal"', () => mkJson({ intent: "heal" })],
[
'the retired intent "affirmative"',
() => mkJson({ intent: "affirmative" }),
],
["confirm: false", () => mkJson({ confirm: false })],
])("refuses %s with 400 and never reaches the mint", async (_label, mk) => {
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");
const res = await POST(mk());
expect(res.status).toBe(400);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("consent.ai.confirmRequired");
expect(grantWebAiConsent).not.toHaveBeenCalled();
expect(auditLog).not.toHaveBeenCalled();
});
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 () => {
it("rejects a malformed confirmation with 422 and never reaches the mint", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await POST(mkIntent({ intent: "resurrect" }));
const res = await POST(mkJson({ confirm: "yes" }));
expect(res.status).toBe(422);
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
expect(grantWebAiConsent).not.toHaveBeenCalled();
});
});
+34 -35
View File
@@ -1,25 +1,20 @@
/**
* v1.16.13 — web AI-consent grant.
* Web AI-consent grant.
*
* POST /api/consent/ai/web
* 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:
* POST /api/consent/ai/web body `{ "confirm": true }`
* Records the person's explicit `ai_full` consent: they pressed "Agree"
* on a card that named the provider and the data. Idempotent: an active
* `ai_full` receipt makes it a no-op. As the person's own consent act it
* may follow an earlier withdrawal.
*
* 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.
* Without `confirm: true` (no body, `{}`, the pre-v1.43.2 mount heal or
* its `{ intent }` body) it mints nothing and answers 400
* `consent.ai.confirmRequired`. The settings page used to call this on
* every mount and mint a receipt nobody had agreed to; a request that is
* not the consent act must never create one.
*
* 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`.
* grants.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
@@ -31,16 +26,16 @@ import {
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";
import { grantWebAiConsent } from "@/lib/consent/web-grant";
import {
CONSENT_CONFIRM_REQUIRED_ERROR_CODE,
webConsentGrantBody,
} from "@/lib/validations/consent";
export const POST = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
// The web AI-settings surface calls this on every mount; the bucket is
// generous enough that the heal path is never throttled in normal use,
// tight enough to cap a scripted loop.
// Caps a scripted loop; a person tapping Agree never comes near it.
const rl = await checkConsentRateLimit(user.id);
if (!rl.allowed) {
annotate({
@@ -52,10 +47,9 @@ export const POST = apiHandler(async (req: Request) => {
});
}
// 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";
// Only the explicit grant sends a JSON body. Anything else (no body, an
// empty object, the retired `{ intent }` shape) is not the consent act.
let confirmed = false;
const contentType = req.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
const { data: body, error: jsonError } = await safeJson(req, {
@@ -67,22 +61,28 @@ export const POST = apiHandler(async (req: Request) => {
annotate({ action: { name: "consent.ai.web-grant.invalid_shape" } });
return returnAllZodIssues(parsed.error, 422);
}
intent = parsed.data.intent;
confirmed = parsed.data.confirm === true;
}
if (!confirmed) {
annotate({ action: { name: "consent.ai.web-grant.unconfirmed" } });
return apiError(
"Consent is recorded only from an explicit confirmation",
400,
{ errorCode: CONSENT_CONFIRM_REQUIRED_ERROR_CODE },
);
}
const result = await ensureWebAiConsentReceipt(user.id, intent);
const result = await grantWebAiConsent(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; `intent`
// separates a migration heal from the user's own re-grant click.
// Fire-and-forget.
// Audit trail parity with the native POST /api/consent/ai grant so the
// web-originated consent can be reconstructed. Fire-and-forget.
auditLog("consent.ai.grant", {
userId: user.id,
details: {
kind: "ai_full",
source: "web",
intent,
confirmed: true,
receiptId: result.receipt.id,
},
}).catch(() => {});
@@ -92,7 +92,6 @@ export const POST = apiHandler(async (req: Request) => {
action: { name: "consent.ai.web-grant" },
meta: {
minted: result.minted,
intent,
...(result.minted ? { receiptId: result.receipt.id } : {}),
},
});
+6
View File
@@ -29,6 +29,7 @@ import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { HeroStrip } from "@/components/insights/hero-strip";
import { AiConsentRequest } from "@/components/ai/ai-consent-request";
import { AiSetupHint } from "@/components/insights/ai-setup-hint";
import { DeferUntilNear } from "@/components/insights/defer-until-near";
import { OverviewSectionBoundary } from "@/components/insights/overview-section-boundary";
@@ -611,6 +612,11 @@ export default function InsightsPageClient() {
provider is configured and this person could set one up. */}
<AiSetupHint />
{/* The operator's provider is configured but nothing goes to it until
the person agrees: the consent question, naming the service and the
data, only while AI is closed by that alone (`ai.consent`). */}
<AiConsentRequest surface="insights" />
{/* v1.15.18 — the inline "Anpassen" toggle was removed. Customising the
overview (section show/hide + order) and sorting the nav pills now
live ONLY in Settings → Insights, reached via the top-right cog on the
+5 -2
View File
@@ -1065,8 +1065,11 @@ export default function PrivacyPage() {
</h3>
<p>
For server-managed AI, the server keeps an account-bound consent
receipt with its kind, signed time, and artefact. The current
state is available through{" "}
receipt with its kind, signed time, and artefact. A receipt is
created only when the account holder confirms it on a consent
screen that names the receiving service and the data; opening a
page or starting an app never creates one. The current state is
available through{" "}
<code className="bg-muted rounded px-1 py-0.5 text-xs">
GET /api/consent/ai/latest
</code>
@@ -0,0 +1,128 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { AiConsentState } from "@/lib/ai/capabilities/types";
import { I18nProvider } from "@/lib/i18n/context";
/**
* The consent question for the operator's provider. What these pin: it is
* asked only while `ai.consent` says it is needed and the person could set AI
* up here; it names the service the server named and the data that would go
* there; its one button is the consent act, and the request that button
* sends carries the explicit confirmation the server requires.
*
* Static markup (the house convention, no jsdom), so the click itself is not
* dispatched: `useMutation` is captured and its `mutationFn` invoked.
*/
const state = vi.hoisted(() => ({
consent: null as AiConsentState | null,
canConfigure: true,
mounted: true,
}));
const mutations = vi.hoisted(
() => [] as Array<{ mutationFn?: () => Promise<unknown> }>,
);
const apiPost = vi.hoisted(() => vi.fn());
vi.mock("@/hooks/use-ai-capability", () => ({
useAiConsent: () => state.consent,
useAiProviderState: () => ({
configured: true,
managedBy: "server",
canConfigure: state.canConfigure,
responseTimeoutMs: 60_000,
}),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: { id: "u1" } }),
}));
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => state.mounted }));
vi.mock("@tanstack/react-query", () => ({
useMutation: (options: { mutationFn?: () => Promise<unknown> }) => {
mutations.push(options);
return { mutate: () => {}, isPending: false, isError: false };
},
useQueryClient: () => ({ invalidateQueries: async () => {} }),
}));
vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
apiPost,
}));
import { AiConsentRequest } from "../ai-consent-request";
const NEEDED: AiConsentState = {
status: "needed",
since: null,
recipients: [{ via: "operator_key", name: "OpenAI" }],
};
function render(surface: "settings" | "insights") {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<AiConsentRequest surface={surface} />
</I18nProvider>,
);
}
beforeEach(() => {
state.consent = NEEDED;
state.canConfigure = true;
state.mounted = true;
mutations.length = 0;
apiPost.mockReset();
try {
window.localStorage.clear();
} catch {
/* no window in this environment */
}
});
describe("AiConsentRequest", () => {
it.each(["settings", "insights"] as const)(
"on %s, names the service and the data and offers one Agree",
(surface) => {
const html = render(surface);
expect(html).toContain('data-slot="ai-consent-request"');
expect(html).toContain("OpenAI");
expect(html).toContain("blood pressure");
expect(html).toContain("withdraw");
expect(html.match(/data-slot="ai-consent-request-agree"/g)).toHaveLength(
1,
);
},
);
it.each(["given", "not_needed"] as const)(
"asks nothing while consent is %s",
(status) => {
state.consent = { ...NEEDED, status };
expect(render("settings")).toBe("");
expect(render("insights")).toBe("");
},
);
it("asks nothing before the account payload has answered", () => {
state.consent = null;
expect(render("settings")).toBe("");
});
it("asks nothing of somebody who cannot set AI up for this record", () => {
state.canConfigure = false;
expect(render("settings")).toBe("");
});
it("paints no tile on the overview before hydration", () => {
state.mounted = false;
expect(render("insights")).toBe("");
});
it("sends the explicit confirmation, and only that", async () => {
render("settings");
expect(mutations).toHaveLength(1);
await mutations[0]!.mutationFn!();
expect(apiPost).toHaveBeenCalledWith("/api/consent/ai/web", {
confirm: true,
});
});
});
+168
View File
@@ -0,0 +1,168 @@
"use client";
import { useState } from "react";
import { Loader2, ShieldCheck, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useAiConsent, useAiProviderState } from "@/hooks/use-ai-capability";
import { useAuth } from "@/hooks/use-auth";
import { useGrantAiConsent } from "@/hooks/use-grant-ai-consent";
import { useMounted } from "@/hooks/use-mounted";
import type { AiConsentState } from "@/lib/ai/capabilities/types";
import { useTranslations } from "@/lib/i18n/context";
/**
* The consent question for AI on the operator's provider, asked once and
* only where it opens something.
*
* Shown while `ai.consent.status` is `needed`: the provider chain runs
* through a credential the operator holds (their API key or their shared
* ChatGPT account) and some AI feature is closed by nothing but the missing
* consent. The card says which service the data goes to (named by the server
* in `ai.consent.recipients`), which data, that nothing goes there before the
* person agrees, and that they can withdraw it. The one "Agree" button is the
* consent act; nothing else on any surface mints a receipt.
*
* Two surfaces. In Settings → AI it is a settings card that stays until the
* question is answered there. On the Insights overview, beside the setup
* hint, it is a tile the person can put away; that is remembered per viewer
* in this browser and does not count as an answer.
*/
const DISMISS_STORAGE_PREFIX = "healthlog.ai-consent-request.dismissed";
function readDismissed(viewerId: string): boolean {
try {
return (
window.localStorage.getItem(`${DISMISS_STORAGE_PREFIX}.${viewerId}`) ===
"1"
);
} catch {
return false;
}
}
/** The services the consent would cover, as one readable list. */
function recipientList(consent: AiConsentState): string {
return consent.recipients.map((recipient) => recipient.name).join(", ");
}
export function AiConsentRequest({
surface,
}: {
/**
* `settings`: a Settings card in Settings → AI.
* `insights`: a dismissible tile on the Insights overview.
*/
surface: "settings" | "insights";
}) {
const { t } = useTranslations();
const { user } = useAuth();
const consent = useAiConsent();
const provider = useAiProviderState();
const grant = useGrantAiConsent();
const mounted = useMounted();
const [dismissedNow, setDismissedNow] = useState(false);
const viewerId = user?.id ?? null;
// Asked only of somebody who could set AI up for this record: not inside a
// record somebody else owns, not while the operator has AI switched off.
if (consent?.status !== "needed" || !provider.canConfigure) return null;
if (viewerId === null) return null;
const body = (
<>
<p className="text-foreground text-sm">
{t("settings.ai.consent.request.body", {
providers: recipientList(consent),
})}
</p>
<p className="text-foreground text-sm">
{t("settings.ai.consent.request.data")}
</p>
<p className="text-muted-foreground text-xs">
{t("settings.ai.consent.request.withdrawHint")}
</p>
{grant.isError ? (
<p className="text-destructive text-xs" role="alert">
{t("settings.ai.consent.grantError")}
</p>
) : null}
</>
);
const agree = (
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="ai-consent-request-agree"
disabled={grant.isPending}
onClick={() => grant.mutate()}
>
{grant.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("settings.ai.consent.request.agree")}
</Button>
);
if (surface === "settings") {
return (
<SettingsCard as="section" data-slot="ai-consent-request">
<SettingsCardHeader
anchor="ai-consent"
icon={ShieldCheck}
title={t("settings.ai.consent.request.title")}
/>
{body}
<SettingsCardActions>{agree}</SettingsCardActions>
</SettingsCard>
);
}
// Storage is browser-only: nothing renders before hydration, so the server
// HTML and the first client render agree.
if (!mounted || dismissedNow || readDismissed(viewerId)) return null;
const dismiss = () => {
setDismissedNow(true);
try {
window.localStorage.setItem(`${DISMISS_STORAGE_PREFIX}.${viewerId}`, "1");
} catch {
// Storage unavailable (private mode): hidden for this visit only.
}
};
return (
<Card data-slot="ai-consent-request" className="gap-2 py-3 md:py-4">
<CardHeader>
<TileHeader
icon={ShieldCheck}
title={t("settings.ai.consent.request.title")}
right={
<Button
type="button"
variant="ghost"
size="icon"
onClick={dismiss}
aria-label={t("settings.ai.consent.request.dismiss")}
title={t("settings.ai.consent.request.dismiss")}
data-slot="ai-consent-request-dismiss"
className="-my-2.5 size-11 shrink-0 sm:-my-2 sm:size-10"
>
<X className="size-4" aria-hidden="true" />
</Button>
}
/>
</CardHeader>
<CardContent className="flex flex-col items-start gap-3">
{body}
{agree}
</CardContent>
</Card>
);
}
@@ -49,10 +49,10 @@ vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ invalidateQueries: async () => {} }),
}));
const apiFetchRaw = vi.hoisted(() => vi.fn());
const apiPost = vi.hoisted(() => vi.fn());
vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
apiFetchRaw,
apiPost,
}));
const toastError = vi.hoisted(() => vi.fn());
@@ -355,10 +355,10 @@ describe("<DoneScreen> shared-provider offer", () => {
},
);
it("grants through the affirmative web intent — the only path that may lift a revocation", async () => {
it("grants only with the explicit confirmation the server requires", async () => {
mutations.length = 0;
apiFetchRaw.mockReset();
apiFetchRaw.mockResolvedValue({ ok: true });
apiPost.mockReset();
apiPost.mockResolvedValue({ minted: true, kind: "ai_full" });
aiProviderState.data = {
managedBy: "server",
serverProviderHealth: "healthy",
@@ -369,19 +369,15 @@ describe("<DoneScreen> shared-provider offer", () => {
const grant = grantMutation();
expect(grant).toBeDefined();
await grant!.mutationFn!();
expect(apiFetchRaw).toHaveBeenCalledWith(
"/api/consent/ai/web",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ intent: "affirmative" }),
}),
);
expect(apiPost).toHaveBeenCalledWith("/api/consent/ai/web", {
confirm: true,
});
});
it("a refused grant throws rather than reporting a consent nobody recorded", async () => {
mutations.length = 0;
apiFetchRaw.mockReset();
apiFetchRaw.mockResolvedValue({ ok: false, status: 429 });
apiPost.mockReset();
apiPost.mockRejectedValue(new Error("429"));
aiProviderState.data = {
managedBy: "server",
serverProviderHealth: "healthy",
+18 -35
View File
@@ -4,7 +4,7 @@ import { useAiProviderState } from "@/hooks/use-ai-capability";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { showErrorToast } from "@/lib/errors/error-toast";
import {
CheckCircle2,
@@ -25,12 +25,9 @@ import { useOnboardingAnswer } from "@/components/onboarding/use-onboarding-flow
import { useAuth } from "@/hooks/use-auth";
import { useAccountSwitch } from "@/hooks/use-account-switch";
import { setTourReferrer } from "@/components/onboarding/tour-launcher";
import {
aiInputDependentKeys,
invalidateKeys,
queryKeys,
} from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { useGrantAiConsent } from "@/hooks/use-grant-ai-consent";
import { useTranslations } from "@/lib/i18n/context";
import { markChecklistExpanded } from "@/lib/onboarding/checklist-storage";
import type { OnboardingStateDto } from "@/lib/onboarding/needs";
@@ -150,33 +147,19 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
// The tap's own outcome, held locally so the line changes the moment the
// receipt is minted rather than a refetch later.
const [justGranted, setJustGranted] = useState(false);
const queryClient = useQueryClient();
const grantConsent = useMutation({
mutationKey: queryKeys.aiConsentReceipt("ai_full"),
mutationFn: async () => {
// `intent: "affirmative"` marks this as the user's own consent act —
// the only web path allowed to supersede an earlier withdrawal. The
// silent mount heal posts no body and deliberately 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("consent grant failed");
},
onSuccess: async () => {
setJustGranted(true);
await invalidateKeys(queryClient, aiInputDependentKeys);
await queryClient.invalidateQueries({
queryKey: queryKeys.aiConsentReceipt("ai_full"),
});
},
onError: (failure) => {
// The button stays. A failed grant is a failed grant; the screen must
// not read as though consent were on file.
showErrorToast(t("onboarding.ai.offer.error"), { cause: failure });
},
});
// The tap on the offer is the consent act; the hook sends the explicit
// confirmation the server requires before it records one.
const grantConsent = useGrantAiConsent();
function agreeToSharedProvider() {
grantConsent.mutate(undefined, {
onSuccess: () => setJustGranted(true),
onError: (failure) => {
// The button stays. A failed grant is a failed grant; the screen
// must not read as though consent were on file.
showErrorToast(t("onboarding.ai.offer.error"), { cause: failure });
},
});
}
const operatorProvides = aiProvider?.managedBy === "server";
const sharedHealth = aiProvider?.serverProviderHealth;
@@ -327,7 +310,7 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
className="min-h-9 w-full"
data-slot="onboarding-ai-offer-grant"
disabled={grantConsent.isPending}
onClick={() => grantConsent.mutate()}
onClick={agreeToSharedProvider}
>
{t("onboarding.ai.offer.cta")}
</Button>
@@ -56,6 +56,12 @@ vi.mock("@/hooks/use-ai-capability", () => ({
useAiCapabilityMap: () => null,
// v1.43 — no usage level published: the setting stays out of the way.
useAiUsage: () => null,
// v1.43.2 — nothing to ask: the provider is the person's own.
useAiConsent: () => ({
status: "not_needed",
since: null,
recipients: [],
}),
useAiProviderState: () => ({
configured: true,
managedBy: "user",
@@ -56,6 +56,12 @@ vi.mock("@/hooks/use-ai-capability", () => ({
useAiCapabilityMap: () => null,
// v1.43 — no usage level published: the setting stays out of the way.
useAiUsage: () => null,
// v1.43.2 — nothing to ask: the provider is the person's own.
useAiConsent: () => ({
status: "not_needed",
since: null,
recipients: [],
}),
useAiProviderState: () => ({
configured: true,
managedBy: "user",
+6 -1
View File
@@ -18,6 +18,7 @@
* provider-configuration screen.
*/
import { AiConsentRequest } from "@/components/ai/ai-consent-request";
import { AiInsightsCard } from "@/components/settings/ai/ai-insights-card";
import { OperatorOffNotice } from "@/components/settings/ai/operator-off-notice";
import { StoredCoachMemory } from "@/components/settings/coach-memory-section";
@@ -43,7 +44,8 @@ export function AiSection() {
//
// v1.39 — above the provider card: what the operator has switched off (so
// nothing below reads as broken), and the one calm setup hint while no
// provider is configured. Below it: what the Coach stored, while Settings →
// provider is configured, or the consent question while the operator's
// provider waits on it. Below it: what the Coach stored, while Settings →
// Coach is not reachable, so it can always be read and deleted.
const coachSettingsReachable = isSurfaceVisible(
"settings:coach",
@@ -53,6 +55,9 @@ export function AiSection() {
<div className="space-y-6">
<OperatorOffNotice />
<AiSetupHint surface="settings" />
{/* v1.43.2 — the consent question, above the provider card, while the
operator's provider is closed by nothing but the missing consent. */}
<AiConsentRequest surface="settings" />
<AiInsightsCard isAuthenticated={authed} />
{authed && !coachSettingsReachable ? (
<StoredCoachMemory isAuthenticated={authed} />
@@ -2,129 +2,111 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { AiConsentState } from "@/lib/ai/capabilities/types";
import { I18nProvider } from "@/lib/i18n/context";
import { AiConsentCard } from "../ai-consent-card";
/**
* The web withdrawal affordance. Before this card the revoke endpoint was
* reachable only from the native client, so a web-only account could give
* AI consent and never take it back.
*
* These render the card against a pre-seeded query cache (server-side
* markup, so the mutation path is not exercised here — the endpoint it
* calls has its own route tests). What they pin is that the card reports
* the standing decision correctly and offers the withdrawal only when
* there is something to withdraw.
* The standing AI consent in Settings → AI, rendered from `ai.consent` on
* `/api/auth/me`. What these pin: the card says since when and to which
* service consent was given and offers the withdrawal only then; it never
* offers a grant of its own (the question lives in `AiConsentRequest`, at
* the top of the page, and only there); and it never misreports a decision it
* has not been told.
*/
const receiptKey = ["consent", "ai", "latest", "ai_full"];
const consentState = vi.hoisted(() => ({
value: null as AiConsentState | null,
authLoading: false,
}));
function renderWith(receipt: unknown) {
vi.mock("@/hooks/use-ai-capability", () => ({
useAiConsent: () => consentState.value,
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isLoading: consentState.authLoading }),
}));
import { AiConsentCard } from "../ai-consent-card";
function render(isAuthenticated = true) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
defaultOptions: { queries: { retry: false, enabled: false } },
});
client.setQueryData(receiptKey, receipt);
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<AiConsentCard isAuthenticated />
<AiConsentCard isAuthenticated={isAuthenticated} />
</I18nProvider>
</QueryClientProvider>,
);
}
beforeEach(() => {
vi.restoreAllMocks();
consentState.value = null;
consentState.authLoading = false;
});
describe("AiConsentCard", () => {
it("offers the withdrawal while consent stands", () => {
const html = renderWith({
id: "rcpt-1",
kind: "ai_full",
signedAt: "2026-07-01T08:00:00.000Z",
revokedAt: null,
});
it("says since when and to which service consent was given, with the withdrawal", () => {
consentState.value = {
status: "given",
since: "2026-07-01T08:00:00.000Z",
recipients: [{ via: "operator_key", name: "OpenAI" }],
};
const html = render();
expect(html).toContain('data-slot="ai-consent-withdraw"');
expect(html).toContain("Withdraw consent");
expect(html).toContain("Given on");
expect(html).toContain("OpenAI");
});
it("reports a withdrawal, stops offering it again, and offers the grant", () => {
const html = renderWith({
id: "rcpt-1",
kind: "ai_full",
signedAt: "2026-07-01T08:00:00.000Z",
revokedAt: "2026-07-10T08:00:00.000Z",
});
it("falls back to the configured provider when no operator service is in the chain", () => {
consentState.value = {
status: "given",
since: "2026-07-01T08:00:00.000Z",
recipients: [],
};
const html = render();
expect(html).toContain("the configured provider");
expect(html).toContain('data-slot="ai-consent-withdraw"');
});
expect(html).toContain("Withdrawn.");
it.each(["needed", "not_needed"] as const)(
"offers no withdrawal and no grant of its own while consent is %s",
(status) => {
consentState.value = {
status,
since: null,
recipients: [{ via: "operator_key", name: "OpenAI" }],
};
const html = render();
expect(html).not.toContain('data-slot="ai-consent-withdraw"');
expect(html).not.toContain("Agree");
expect(html).not.toContain('data-slot="ai-consent"');
},
);
it("holds its space while the account payload is loading, claiming nothing", () => {
consentState.authLoading = true;
const html = render();
expect(html).toContain('data-slot="ai-consent"');
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");
expect(html).not.toContain("Given on");
});
it("treats no receipt at all as consent not given", () => {
const html = renderWith(null);
expect(html).toContain("Withdrawn.");
it("says nothing when the payload carries no consent state", () => {
const html = render();
expect(html).not.toContain('data-slot="ai-consent"');
expect(html).not.toContain('data-slot="ai-consent-withdraw"');
expect(html).toContain('data-slot="ai-consent-grant"');
});
it("never reports a failed read as a withdrawal", () => {
// A refused or failed read (the consent bucket's rate limit, a network
// drop) says nothing about the decision. Reporting it as "Withdrawn" and
// offering a grant would misstate the person's own choice.
const client = new QueryClient({
defaultOptions: { queries: { retry: false, retryOnMount: false } },
});
client
.getQueryCache()
.build(client, { queryKey: receiptKey })
.setState({
status: "error",
error: new Error("read failed"),
data: undefined,
fetchStatus: "idle",
});
const html = renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<AiConsentCard isAuthenticated />
</I18nProvider>
</QueryClientProvider>,
);
expect(html).not.toContain("Withdrawn.");
expect(html).not.toContain('data-slot="ai-consent-grant"');
expect(html).not.toContain('data-slot="ai-consent-withdraw"');
expect(html).toContain('data-slot="ai-consent-load-error"');
});
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", () => {
const client = new QueryClient();
const html = renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<AiConsentCard isAuthenticated={false} />
</I18nProvider>
</QueryClientProvider>,
);
expect(html).toBe("");
consentState.value = {
status: "given",
since: "2026-07-01T08:00:00.000Z",
recipients: [],
};
expect(render(false)).toBe("");
});
});
+72 -126
View File
@@ -9,13 +9,17 @@
* withdrawal on the same footing as the grant, so it belongs on the same
* surface, not behind a support request.
*
* The card shows the standing decision in plain words and offers the one
* move that changes it: withdraw while consent stands, grant while it does
* not. Withdrawing takes effect immediately: the consent gate fails closed
* The card renders the standing decision from `ai.consent` on `/api/auth/me`
* and offers the one move that changes it. While consent stands it says
* since when and to which service, with the withdraw control. While it does
* not and the operator's provider needs it, the consent question at the top
* of the page (`AiConsentRequest`) asks it: the provider, the data, and an
* "Agree" button that is the consent act. Nothing here grants on mount;
* until v1.43.2 the page minted a receipt on every visit, which is exactly
* what this replaced.
* 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.
* no-consent state on the next call.
*
* v1.39 — a withdrawal also deletes what the app can write again: the notes
* a model wrote from the person's data (status notes, the daily briefing,
@@ -31,14 +35,15 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, ShieldCheck, ShieldOff } from "lucide-react";
import { Loader2, ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Skeleton } from "@/components/ui/skeleton";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useAiConsent } from "@/hooks/use-ai-capability";
import { useAuth } from "@/hooks/use-auth";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
@@ -63,19 +68,11 @@ export function AiConsentCard({
const { t } = useTranslations();
const queryClient = useQueryClient();
const [confirming, setConfirming] = useState(false);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.aiConsentReceipt("ai_full"),
queryFn: async () => {
const res = await apiFetchRaw("/api/consent/ai/latest?kind=ai_full");
// A failed read is not a withdrawal: throw so the card says it could
// not load instead of reporting the person's decision wrongly.
if (!res.ok) throw new Error("consent read failed");
const json = await res.json();
return (json.data?.receipt ?? null) as ConsentReceiptWire;
},
enabled: isAuthenticated,
});
// The standing decision comes off the account payload, resolved on the
// server with the capabilities it governs, so this card and every AI
// surface agree on it. `null` until `/me` has answered.
const consent = useAiConsent();
const { isLoading: authLoading } = useAuth();
const revoke = useMutation({
mutationKey: queryKeys.aiConsentReceipt("ai_full"),
@@ -89,28 +86,8 @@ export function AiConsentCard({
setConfirming(false);
// The receipt gates every AI surface, so anything that reads consent
// state has to re-resolve — not just this card. `/me` carries the
// resolved capabilities every AI surface renders from.
await queryClient.invalidateQueries({
queryKey: queryKeys.aiConsentReceipt("ai_full"),
});
await invalidateKeys(queryClient, aiInputDependentKeys);
},
});
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 () => {
// resolved capabilities and the consent state every AI surface
// renders from.
await queryClient.invalidateQueries({
queryKey: queryKeys.aiConsentReceipt("ai_full"),
});
@@ -120,12 +97,11 @@ export function AiConsentCard({
if (!isAuthenticated) return null;
// Say nothing until the state is known: a card that flashes "withdrawn"
// before the receipt arrives would misreport the user's own decision.
// But hold the block's space with a skeleton mirroring the final shape —
// returning null here made the section pop in and shift everything
// below it once the receipt arrived.
if (isLoading) {
// Say nothing until the state is known: a card that flashes the question
// before the payload arrives would misreport the person's own decision.
// But hold the block's space with a skeleton mirroring the final shape, so
// nothing below moves once it lands.
if (consent === null && authLoading) {
return (
<SettingsCard as="section" data-slot="ai-consent">
<SettingsCardHeader
@@ -139,50 +115,42 @@ export function AiConsentCard({
);
}
if (isError) {
return (
<SettingsCard as="section" data-slot="ai-consent">
<SettingsCardHeader
icon={ShieldCheck}
title={t("settings.ai.consent.title")}
/>
<QueryErrorRow
slot="ai-consent-load-error"
onRetry={() => void refetch()}
/>
</SettingsCard>
);
// Not given: nothing to withdraw. Where agreeing would open something (the
// operator's provider) the question sits at the top of this page
// (`AiConsentRequest`); a person on their own key or a local model has
// nothing to agree to, and reading documents asks its own narrower question
// where a document is read.
if (consent === null || consent.status !== "given") {
return <DocumentReadingConsentRow isAuthenticated={isAuthenticated} />;
}
// Narrow to the receipt itself rather than a boolean, so the branches
// below can read its fields without an assertion.
const activeReceipt = data && data.revokedAt === null ? data : null;
const providers = consent.recipients.map((r) => r.name).join(", ");
const description = consent.since
? providers
? t("settings.ai.consent.activeSinceNamed", {
date: formatDateTime(consent.since),
providers,
})
: t("settings.ai.consent.activeSince", {
date: formatDateTime(consent.since),
})
: t("settings.ai.consent.activeUndated");
return (
<>
<SettingsCard as="section" data-slot="ai-consent">
<SettingsCardHeader
icon={activeReceipt ? ShieldCheck : ShieldOff}
anchor="ai-consent"
icon={ShieldCheck}
title={t("settings.ai.consent.title")}
description={
activeReceipt
? t("settings.ai.consent.activeSince", {
date: formatDateTime(activeReceipt.signedAt),
})
: t("settings.ai.consent.withdrawn")
}
description={description}
/>
{activeReceipt && confirming ? (
<p className="text-muted-foreground text-xs">
{confirming ? (
<p className="text-foreground text-sm">
{t("settings.ai.consent.confirmBody")}
</p>
) : null}
{!activeReceipt ? (
<p className="text-muted-foreground text-xs">
{t("settings.ai.consent.regrantHint")}
</p>
) : null}
{revoke.isError ? (
<p className="text-destructive text-xs" role="alert">
@@ -190,68 +158,46 @@ export function AiConsentCard({
</p>
) : null}
{grant.isError ? (
<p className="text-destructive text-xs" role="alert">
{t("settings.ai.consent.grantError")}
</p>
) : null}
{/* One action row, last in the card; while confirming, Cancel sits
before the destructive confirm (design standards §12). */}
<SettingsCardActions>
{activeReceipt ? (
confirming ? (
<>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setConfirming(false)}
disabled={revoke.isPending}
>
{t("common.cancel")}
</Button>
<Button
type="button"
variant="destructive"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="ai-consent-withdraw-confirm"
onClick={() => revoke.mutate()}
disabled={revoke.isPending}
>
{revoke.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("settings.ai.consent.confirmWithdraw")}
</Button>
</>
) : (
{confirming ? (
<>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="ai-consent-withdraw"
onClick={() => setConfirming(true)}
onClick={() => setConfirming(false)}
disabled={revoke.isPending}
>
{t("settings.ai.consent.withdraw")}
{t("common.cancel")}
</Button>
)
<Button
type="button"
variant="destructive"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="ai-consent-withdraw-confirm"
onClick={() => revoke.mutate()}
disabled={revoke.isPending}
>
{revoke.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("settings.ai.consent.confirmWithdraw")}
</Button>
</>
) : (
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="ai-consent-grant"
onClick={() => grant.mutate()}
disabled={grant.isPending}
data-slot="ai-consent-withdraw"
onClick={() => setConfirming(true)}
>
{grant.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("settings.ai.consent.grant")}
{t("settings.ai.consent.withdraw")}
</Button>
)}
</SettingsCardActions>
@@ -13,7 +13,7 @@
* they were editing.
*/
import { useEffect, useState } from "react";
import { useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Sparkles } from "lucide-react";
@@ -28,11 +28,7 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import {
aiInputDependentKeys,
invalidateKeys,
queryKeys,
} from "@/lib/query-keys";
import { queryKeys } from "@/lib/query-keys";
import { AdminOpenAIProviderForm } from "./admin-openai-provider-form";
import { AiConsentCard } from "./ai-consent-card";
@@ -132,29 +128,10 @@ export function AiInsightsCard({
void refetchChain();
}
// 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;
// A heal that minted a receipt changed an input of the account's AI
// capability answer, so `/me` re-resolves; a no-op heal leaves it alone.
void apiFetchRaw("/api/consent/ai/web", { method: "POST" })
.then(async (res) => {
if (!res.ok) return;
const body = (await res.json()) as { data?: { minted?: boolean } };
if (body.data?.minted) {
void invalidateKeys(queryClient, aiInputDependentKeys);
}
})
.catch(() => {
/* best-effort heal — the explicit grant path stays available */
});
}, [isAuthenticated, queryClient]);
// No consent is granted from here. Until v1.43.2 this card posted to
// `/api/consent/ai/web` on every mount and minted a receipt nobody had
// agreed to; the question is now asked by `AiConsentRequest` inside the
// consent card below, and only its "Agree" button grants.
// The Select is URL-driven so the SSR test can pick the branch and
// a deep link works. Default = `?provider=…` query param when the
+20
View File
@@ -5,6 +5,7 @@ import { useQueryClientMounted } from "@/hooks/_internal/use-query-client-safe";
import type {
AiCapabilityKey,
AiCapabilityState,
AiConsentState,
AiProviderState,
} from "@/lib/ai/capabilities/types";
import { PROVIDER_DEFAULT_TIMEOUT_MS } from "@/lib/ai/effective-timeout";
@@ -105,6 +106,25 @@ function useAiUsageInner(): AiUsageState | null {
return user?.ai?.usage ?? null;
}
/**
* v1.43.2 — the standing AI consent (`ai.consent` on `GET /api/auth/me`):
* given (and since when), needed (and for which services), or not needed.
* `null` while it is not known; a consent surface then says nothing rather
* than misreport the person's decision. Render from it; never decide here
* whether consent is needed.
*/
export function useAiConsent(): AiConsentState | null {
const hasClient = useQueryClientMounted();
if (!hasClient) return null;
// eslint-disable-next-line react-hooks/rules-of-hooks
return useAiConsentInner();
}
function useAiConsentInner(): AiConsentState | null {
const { user } = useAuth();
return user?.ai?.consent ?? null;
}
/**
* The server's answer for one capability, or `null` while there is none yet
* (`/me` loading, no signed-in account, no query client). For the few places
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiPost } from "@/lib/api/api-fetch";
import {
aiInputDependentKeys,
invalidateKeys,
queryKeys,
} from "@/lib/query-keys";
/**
* Record the person's explicit AI consent (`ai_full`).
*
* Call `mutate()` from the click handler of an "Agree" control that has
* named the service and the data, and from nowhere else: the server records
* this request as the person's consent act, and `confirm: true` is how the
* request says so. A request on mount, on launch or after a sync is exactly
* what v1.43.2 removed; `consent-grant-explicit-guard.test.ts` keeps it out.
*
* On success `/api/auth/me` is refetched, because the capabilities and the
* `ai.consent` state every AI surface renders from changed.
*/
export function useGrantAiConsent() {
const queryClient = useQueryClient();
return useMutation({
mutationKey: queryKeys.aiConsentReceipt("ai_full"),
mutationFn: () => apiPost("/api/consent/ai/web", { confirm: true }),
onSuccess: async () => {
await invalidateKeys(queryClient, aiInputDependentKeys);
await queryClient.invalidateQueries({
queryKey: queryKeys.aiConsentReceipt("ai_full"),
});
},
});
}
@@ -32,6 +32,9 @@ export function useGrantDocumentReadingConsent() {
const signedAt = new Date().toISOString();
return apiPost("/api/consent/ai", {
kind: "ai_extraction",
// The tap on the prompt is the consent act; the server records a
// receipt only for a request that says so.
confirm: true,
signedAt,
artefact: JSON.stringify({
source: "web",
@@ -180,6 +180,9 @@ describe("probeProviderChain", () => {
localOcrEnabled: false,
managedBy: "server",
availableTypes: ["admin-openai"],
// The endpoint behind the operator's key names the service the consent
// card asks about; the default column value stands for OpenAI.
operatorKeyBaseUrl: null,
});
});
@@ -534,3 +534,104 @@ describe("the AI usage level (#1217)", () => {
);
});
});
describe("the standing consent (v1.43.2)", () => {
function operatorOnly(baseUrl?: string | null): AiCapabilityInputs {
const inputs = baseline();
inputs.provider = {
entries: [{ providerType: "admin-openai", vision: true }],
localOcrEnabled: false,
managedBy: "server",
operatorKeyBaseUrl: baseUrl,
};
return inputs;
}
it("asks, naming the service, while the operator's key waits on it alone", () => {
const consent = resolveAiBlock(operatorOnly()).consent;
expect(consent).toEqual({
status: "needed",
since: null,
recipients: [{ via: "operator_key", name: "OpenAI" }],
});
});
it("names the service behind the operator's endpoint, not a guess", () => {
const named = (url: string) =>
resolveAiBlock(operatorOnly(url)).consent.recipients[0]?.name;
expect(named("https://api.openai.com/v1")).toBe("OpenAI");
expect(named("https://api.anthropic.com/v1")).toBe("Anthropic");
expect(named("https://openrouter.ai/api/v1")).toBe("openrouter.ai");
});
it("names the operator's shared ChatGPT account", () => {
const inputs = baseline();
inputs.provider = {
entries: [
{ providerType: "openai", vision: true },
{ providerType: "admin-codex", vision: true },
],
localOcrEnabled: false,
managedBy: "user",
};
expect(resolveAiBlock(inputs).consent).toEqual({
status: "needed",
since: null,
recipients: [{ via: "operator_chatgpt", name: "ChatGPT (OpenAI)" }],
});
});
it("reports a given consent with its date and the service it covers", () => {
const inputs = operatorOnly();
inputs.activeConsentKinds = new Set(["ai_full"]);
inputs.fullConsentSince = new Date("2026-07-01T08:00:00.000Z");
expect(resolveAiBlock(inputs).consent).toEqual({
status: "given",
since: "2026-07-01T08:00:00.000Z",
recipients: [{ via: "operator_key", name: "OpenAI" }],
});
});
it("asks nothing of a person on their own key or a local model", () => {
expect(resolveAiBlock(baseline()).consent).toEqual({
status: "not_needed",
since: null,
recipients: [],
});
const own = baseline();
own.provider = {
entries: [{ providerType: "anthropic", vision: true }],
localOcrEnabled: false,
managedBy: "user",
};
// A document read on an own key still needs its narrower receipt, asked
// where the document is read; that is not this question.
expect(resolveAiBlock(own).consent.status).toBe("not_needed");
});
it("asks nothing when agreeing would open nothing", () => {
// The operator switched AI off: the switch is the outermost reason, so no
// capability is closed by the missing consent alone.
const off = operatorOnly();
off.switches = { ...off.switches, enabled: false };
expect(resolveAiBlock(off).consent.status).toBe("not_needed");
// A delegate inside somebody else's record.
const delegate = operatorOnly();
delegate.providerWorkAdmitted = false;
expect(resolveAiBlock(delegate).consent.status).toBe("not_needed");
});
it("keeps asking while a narrower receipt leaves the Coach closed", () => {
const inputs = operatorOnly();
inputs.activeConsentKinds = new Set(["ai_insights_only"]);
expect(resolveAiBlock(inputs).consent.status).toBe("needed");
});
it("asks nothing when the inputs could not be read", () => {
expect(resolveAiBlock(null).consent).toEqual({
status: "not_needed",
since: null,
recipients: [],
});
});
});
+18 -8
View File
@@ -9,7 +9,8 @@
* memoised reads the account payload and every module gate use;
* - provider presence: the record's credential row and the operator's
* provider columns (`probeProviderChain`), presence only;
* - consent: the kinds of the record's active receipts, one indexed read;
* - consent: the kinds of the record's active receipts and when the
* `ai_full` one was signed, one indexed read;
* - the record's AI usage level (v1.43): the operator's bounds (memoised)
* and one point read of the record's choice.
*
@@ -57,16 +58,24 @@ export interface AiCapabilityScope {
recordKind: AiRecordKind;
}
/** The kinds of the record's active consent receipts. */
function loadActiveConsentKinds(recordId: string): Promise<Set<string>> {
/**
* The kinds of the record's active consent receipts, and when the active
* `ai_full` one was signed (the date the consent card shows).
*/
function loadActiveConsents(
recordId: string,
): Promise<{ kinds: Set<string>; fullSince: Date | null }> {
return memoizePerRequest(
`ai-consent-kinds:${recordId}`,
async () => {
const rows = await prisma.consentReceipt.findMany({
where: { userId: recordId, revokedAt: null },
select: { kind: true },
select: { kind: true, signedAt: true },
});
return new Set(rows.map((row) => row.kind));
return {
kinds: new Set(rows.map((row) => row.kind)),
fullSince: rows.find((row) => row.kind === "ai_full")?.signedAt ?? null,
};
},
{ freshInBackground: true },
);
@@ -97,14 +106,14 @@ export function loadAiCapabilityInputs(
modules,
availability,
provider,
activeConsentKinds,
consents,
usageLevel,
] = await Promise.all([
loadAssistantSwitches(),
resolveModuleMap(scope.recordId),
getOperatorModuleAvailability(),
probeProviderChain(scope.recordId, scope.authority),
loadActiveConsentKinds(scope.recordId),
loadActiveConsents(scope.recordId),
loadRecordUsageLevel(scope.recordId),
]);
if (switches === null) return null;
@@ -118,7 +127,8 @@ export function loadAiCapabilityInputs(
moduleAccess,
providerWorkAdmitted: mayEnqueueProviderWork(scope.authority),
provider,
activeConsentKinds,
activeConsentKinds: consents.kinds,
fullConsentSince: consents.fullSince,
recordKind: scope.recordKind,
usageLevel,
};
+86 -1
View File
@@ -35,6 +35,8 @@ import {
type AiCapabilityDefinition,
type AiCapabilityKey,
type AiCapabilityState,
type AiConsentRecipient,
type AiConsentState,
type AiOperatorSwitchSet,
type AiProviderManagedBy,
type AiProviderState,
@@ -73,6 +75,12 @@ export interface ProviderPresence {
* beside the provider and this row is already in hand. Absent → unset.
*/
responseTimeoutSeconds?: number | null;
/**
* The base URL behind the operator's key (`admin-openai`), which names the
* service the consent card asks about. Absent reads as OpenAI's own
* endpoint, the column's default.
*/
operatorKeyBaseUrl?: string | null;
}
/** Whose record this is, as far as configuring a provider for it goes. */
@@ -94,6 +102,11 @@ export interface AiCapabilityInputs {
* sets it; absent (a hand-built set of inputs) means no level restriction.
*/
usageLevel?: AiUsageLevel;
/**
* When the record's active `ai_full` receipt was signed. Absent or `null`
* with `ai_full` among the active kinds publishes the consent without a date.
*/
fullConsentSince?: Date | null;
}
// Which tags are operator-held and which stays local: `@/lib/ai/provider-egress`,
@@ -382,12 +395,84 @@ export function resolveAiProviderState(
};
}
const OPERATOR_KEY_DEFAULT_HOST = "api.openai.com";
/** The service behind the operator's key, named for a person. */
function operatorKeyServiceName(baseUrl: string | null | undefined): string {
let host = OPERATOR_KEY_DEFAULT_HOST;
if (baseUrl) {
try {
host = new URL(baseUrl).hostname.toLowerCase();
} catch {
host = baseUrl;
}
}
if (host === "openai.com" || host.endsWith(".openai.com")) return "OpenAI";
if (host === "anthropic.com" || host.endsWith(".anthropic.com")) {
return "Anthropic";
}
return host;
}
function consentRecipients(provider: ProviderPresence): AiConsentRecipient[] {
const out: AiConsentRecipient[] = [];
for (const entry of provider.entries) {
if (!isOperatorHeldProvider(entry.providerType)) continue;
const recipient: AiConsentRecipient =
entry.providerType === "admin-codex"
? { via: "operator_chatgpt", name: "ChatGPT (OpenAI)" }
: {
via: "operator_key",
name: operatorKeyServiceName(provider.operatorKeyBaseUrl),
};
if (!out.some((seen) => seen.via === recipient.via)) out.push(recipient);
}
return out;
}
/**
* The standing consent. `needed` only where asking would open something: the
* chain runs through an operator-held credential and a capability under that
* rule is closed by the missing receipt alone (its outermost reason is
* `consent_required`). A capability the operator or the person switched off
* reports that layer instead, so a record with nothing to open is not asked.
*/
function resolveAiConsentState(
inputs: AiCapabilityInputs | null,
capabilities: Readonly<Record<AiCapabilityKey, AiCapabilityState>>,
): AiConsentState {
if (inputs === null) {
return { status: "not_needed", since: null, recipients: [] };
}
const recipients = consentRecipients(inputs.provider);
if (inputs.activeConsentKinds.has("ai_full")) {
return {
status: "given",
since: inputs.fullConsentSince?.toISOString() ?? null,
recipients,
};
}
const blockedByConsentAlone = AI_CAPABILITY_KEYS.some(
(key) =>
AI_CAPABILITIES[key].consent.rule === "self-snapshot" &&
capabilities[key].reason === "consent_required",
);
return {
status:
recipients.length > 0 && blockedByConsentAlone ? "needed" : "not_needed",
since: null,
recipients,
};
}
/** The whole `ai` block. */
export function resolveAiBlock(
inputs: AiCapabilityInputs | null,
): AiCapabilities {
const capabilities = resolveAiCapabilities(inputs);
return {
capabilities: resolveAiCapabilities(inputs),
capabilities,
provider: resolveAiProviderState(inputs),
consent: resolveAiConsentState(inputs, capabilities),
};
}
+38
View File
@@ -347,10 +347,48 @@ export interface AiProviderState {
responseTimeoutMs: number;
}
/**
* Whose credential an operator-held provider sends through: the operator's
* API key (`admin-openai`) or the operator's shared ChatGPT account
* (`admin-codex`).
*/
export type AiConsentRecipientVia = "operator_key" | "operator_chatgpt";
/** One service a record's data would go to on the operator's credential. */
export interface AiConsentRecipient {
via: AiConsentRecipientVia;
/**
* The service, named for a person: "OpenAI" or "Anthropic" for their own
* endpoints, otherwise the host name of the endpoint the operator set.
*/
name: string;
}
/**
* The standing AI consent for the record, as a client renders it.
*
* - `given`: an `ai_full` receipt is active (`since` says when).
* - `needed`: the provider chain runs through a credential the operator
* holds, and at least one capability is closed by nothing but the missing
* receipt. Ask, naming `recipients`.
* - `not_needed`: neither. Nothing to ask and nothing to withdraw.
*/
export type AiConsentStatus = "given" | "needed" | "not_needed";
export interface AiConsentState {
status: AiConsentStatus;
/** When the active `ai_full` consent was given, ISO-8601; `null` otherwise. */
since: string | null;
/** The operator-held services in the chain, in chain order. */
recipients: AiConsentRecipient[];
}
/** The `ai` block the account payload publishes. */
export interface AiCapabilities {
capabilities: Record<AiCapabilityKey, AiCapabilityState>;
provider: AiProviderState;
/** v1.43.2 — the standing AI consent and who it would cover. */
consent: AiConsentState;
/**
* v1.43 (#1217) — the record's AI usage level, its bounds and the estimate.
* Absent on servers before v1.43 and on blocks resolved without it.
+3
View File
@@ -910,6 +910,7 @@ const PRESENCE_USER_SELECT = {
const PRESENCE_SETTINGS_SELECT = {
adminAiKeyEncrypted: true,
adminAiModel: true,
adminAiBaseUrl: true,
adminCodexConnectionStatus: true,
adminCodexAccessTokenEncrypted: true,
adminCodexRefreshTokenEncrypted: true,
@@ -996,6 +997,7 @@ export function probeProviderChain(
if (policy === "operator-default") {
return {
entries: adminKey ? [vision("admin-openai")] : [],
operatorKeyBaseUrl: settings?.adminAiBaseUrl ?? null,
// A guardian's in-browser OCR is not the record's opt-in to use.
localOcrEnabled: false,
managedBy: adminKey ? "server" : null,
@@ -1065,6 +1067,7 @@ export function probeProviderChain(
managedBy: entries.length > 0 ? managedBy : null,
availableTypes,
responseTimeoutSeconds: row.aiResponseTimeoutSeconds,
operatorKeyBaseUrl: settings?.adminAiBaseUrl ?? null,
};
},
{ freshInBackground: true },
+32 -84
View File
@@ -21,7 +21,7 @@ vi.mock("@/lib/db", () => {
});
import { prisma } from "@/lib/db";
import { ensureWebAiConsentReceipt } from "../web-grant";
import { grantWebAiConsent } from "../web-grant";
const $transaction = vi.mocked(prisma.$transaction) as unknown as {
mockImplementation: (impl: (fn: TxFn) => unknown) => void;
@@ -50,13 +50,13 @@ function row(overrides: Partial<Record<string, unknown>> = {}) {
};
}
describe("ensureWebAiConsentReceipt", () => {
describe("grantWebAiConsent", () => {
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", "heal", now);
const result = await grantWebAiConsent("user-1", now);
expect(result.minted).toBe(true);
// Reads the active master grant first (fast-path + in-tx re-check both
@@ -82,7 +82,7 @@ describe("ensureWebAiConsentReceipt", () => {
row() as never,
);
const result = await ensureWebAiConsentReceipt("user-1", "heal");
const result = await grantWebAiConsent("user-1");
expect(result.minted).toBe(false);
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
@@ -98,7 +98,7 @@ describe("ensureWebAiConsentReceipt", () => {
.mockResolvedValueOnce(null) // fast path
.mockResolvedValueOnce(row() as never); // in-tx re-check
const result = await ensureWebAiConsentReceipt("user-1", "heal");
const result = await grantWebAiConsent("user-1");
expect(result.minted).toBe(false);
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
@@ -113,7 +113,7 @@ describe("ensureWebAiConsentReceipt", () => {
code: "P2002",
} as never);
const result = await ensureWebAiConsentReceipt("user-1", "heal");
const result = await grantWebAiConsent("user-1");
expect(result.minted).toBe(false);
});
@@ -124,14 +124,14 @@ describe("ensureWebAiConsentReceipt", () => {
new Error("connection reset") as never,
);
await expect(ensureWebAiConsentReceipt("user-1", "heal")).rejects.toThrow(
await expect(grantWebAiConsent("user-1")).rejects.toThrow(
"connection reset",
);
});
});
describe("ensureWebAiConsentReceipt — two concurrent grants", () => {
it("yields exactly one active receipt across parallel mounts", async () => {
describe("grantWebAiConsent — two concurrent grants", () => {
it("yields exactly one active receipt across a double tap", async () => {
// Model the race at the DB layer: a single shared "active row" slot.
// Both callers pass the fast-path read (slot empty). The transaction
// serialises their re-checks: the first sees the empty slot and
@@ -157,8 +157,8 @@ describe("ensureWebAiConsentReceipt — two concurrent grants", () => {
});
const [a, b] = await Promise.all([
ensureWebAiConsentReceipt("user-1", "heal"),
ensureWebAiConsentReceipt("user-1", "heal"),
grantWebAiConsent("user-1"),
grantWebAiConsent("user-1"),
]);
// Exactly one mint, exactly one row inserted.
@@ -168,81 +168,29 @@ describe("ensureWebAiConsentReceipt — two concurrent grants", () => {
});
});
describe("ensureWebAiConsentReceipt — a revocation is a standing decision", () => {
// The mount heal and the revocation check share one `findFirst` mock, so
// route by predicate: `revokedAt: null` is the active-grant lookup,
// `revokedAt: { not: null }` is the revocation-history lookup.
function arrange({ hasRevoked }: { hasRevoked: boolean }) {
vi.mocked(prisma.consentReceipt.findFirst).mockImplementation(
(async (args: { where: { revokedAt?: unknown } }) => {
const wantsRevoked =
args.where.revokedAt !== null && args.where.revokedAt !== undefined;
if (wantsRevoked)
return hasRevoked
? (row({
revokedAt: new Date("2026-07-01T09:00:00.000Z"),
}) as never)
: null;
return null;
}) as never,
);
vi.mocked(prisma.consentReceipt.create).mockResolvedValue(row() as never);
}
it("does not re-grant on the settings mount after the user revoked", async () => {
arrange({ hasRevoked: true });
const result = await ensureWebAiConsentReceipt("user-1", "heal");
expect(result).toEqual({ minted: false, reason: "previously_revoked" });
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
// No transaction is opened — the decision is settled on the read.
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it("still heals an account that never had a receipt at all", async () => {
arrange({ hasRevoked: false });
const result = await ensureWebAiConsentReceipt("user-1", "heal");
expect(result.minted).toBe(true);
expect(prisma.consentReceipt.create).toHaveBeenCalledTimes(1);
});
it("lets the user grant again through an affirmative act", async () => {
arrange({ hasRevoked: true });
const result = await ensureWebAiConsentReceipt("user-1", "affirmative");
expect(result.minted).toBe(true);
expect(prisma.consentReceipt.create).toHaveBeenCalledTimes(1);
});
it("holds the line when the revocation lands mid-transaction", async () => {
// The fast-path reads see a clean history; the revocation appears only
// once the transaction is open. Without the in-transaction re-check the
// mint would overwrite a decision made a moment earlier.
let inTransaction = false;
$transaction.mockImplementation((fn: TxFn) => {
inTransaction = true;
return fn({ consentReceipt: prisma.consentReceipt });
});
vi.mocked(prisma.consentReceipt.findFirst).mockImplementation(
(async (args: { where: { revokedAt?: unknown } }) => {
const wantsRevoked =
args.where.revokedAt !== null && args.where.revokedAt !== undefined;
if (wantsRevoked && inTransaction)
return row({
revokedAt: new Date("2026-07-01T09:00:00.000Z"),
}) as never;
return null;
}) as never,
);
describe("grantWebAiConsent — an explicit grant follows a withdrawal", () => {
it("mints after an earlier revocation: the tap is the person deciding again", async () => {
// Only active rows are read; a revoked one in the history is not
// consulted, because every call is the person's own consent act.
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
vi.mocked(prisma.consentReceipt.create).mockResolvedValue(row() as never);
const result = await ensureWebAiConsentReceipt("user-1", "heal");
const result = await grantWebAiConsent("user-1");
expect(result).toEqual({ minted: false, reason: "previously_revoked" });
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
expect(result.minted).toBe(true);
for (const [args] of vi.mocked(prisma.consentReceipt.findFirst).mock
.calls) {
expect(args?.where?.revokedAt).toBeNull();
}
});
it("records that the grant was confirmed in the artefact", async () => {
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
vi.mocked(prisma.consentReceipt.create).mockResolvedValue(row() as never);
await grantWebAiConsent("user-1");
const createArg = vi.mocked(prisma.consentReceipt.create).mock.calls[0][0];
expect(JSON.parse(createArg.data.artefact as string).confirmed).toBe(true);
});
});
+33 -95
View File
@@ -1,36 +1,26 @@
/**
* v1.16.13 — web AI-consent receipt minting.
* 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.
* The `ConsentReceipt` gate (`src/lib/ai/consent-guard.ts` and the capability
* resolver) requires an active receipt before any health data leaves through
* a credential the operator holds (the operator's key, the shared ChatGPT
* account). This helper records the web side of that consent: the person
* pressed "Agree" on a card that named the provider and the data.
*
* 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.
* It is only ever called for that explicit act. From v1.16.13 until v1.43.2
* the AI settings page also called it on mount to "heal" accounts that
* predated the gate, which minted a receipt for people who had agreed to
* nothing and let background jobs send their data to the operator's provider.
* That path is gone; the route refuses a request without `confirm: true`.
*
* It heals an account that never gave consent — never one that took it
* back. "No active receipt" has two very different causes: an account that
* predates the gate and has no receipt at all, and an account whose owner
* deliberately revoked. Both look identical to a `revokedAt: null` lookup,
* and healing the second one re-grants a consent the user withdrew, writing
* a `consent.ai.grant` audit row for an action they never took. GDPR Art. 7
* (3) requires withdrawal to be as easy as granting; a withdrawal undone by
* the next page visit is not a withdrawal. So the mint is gated on the
* account having no revoked `ai_full` receipt in its history: a prior
* revocation is a standing decision, and only an explicit grant may lift it.
* Idempotent: a person with an active `ai_full` receipt is a no-op, so a
* double tap leaves exactly one. Because every call is the person's own
* consent act, it may follow an earlier withdrawal, exactly as a first grant
* would. Withdrawal stays on `DELETE /api/consent/ai/latest`.
*
* 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.
* The artefact is a small JSON record of the grant (source, kind, time, and
* that it was confirmed); no signed PDF or JWT. It stays well under the 64 KB
* artefact cap.
*/
import { prisma } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
@@ -38,76 +28,38 @@ import { isP2002 } from "@/lib/prisma-errors";
import { latestActiveReceipt } from "@/lib/consent/receipts";
import type { ConsentReceipt } from "@/lib/consent/receipts";
/**
* Outcome of an idempotent web-grant call. `minted: false` carries a reason
* so callers can tell "already granted" apart from "withheld because the
* user revoked" — the two are the same non-event to the UI, but only the
* second one is worth an audit annotation.
*/
/** Outcome of an explicit web grant. */
export type WebConsentGrantResult =
| { minted: true; receipt: ConsentReceipt }
| { minted: false; reason: "already_active" | "previously_revoked" };
| { minted: false; reason: "already_active" };
/**
* How the call came about. The distinction decides whether a past revocation
* blocks the mint, so it is a required argument rather than a default —
* a caller must say which one it is.
*
* `heal` — fired by the AI-settings mount, not by a user gesture.
* Grants only to an account that has no consent history at
* all. A revocation stands.
* `affirmative`— the user just performed the opt-in itself (switching the
* document auto-read on). This IS the consent act, so it
* supersedes an earlier revocation, exactly as the first
* grant would.
* Record the person's explicit `ai_full` consent, minting a receipt when none
* is active. `ai_full` is the master kind that satisfies every surface gate
* (analysis, Coach, reading documents).
*/
export type WebConsentGrantIntent = "heal" | "affirmative";
/**
* 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(
export async function grantWebAiConsent(
userId: string,
intent: WebConsentGrantIntent,
now: Date = new Date(),
): Promise<WebConsentGrantResult> {
// Fast path: a cheap read outside the transaction short-circuits the
// common already-granted case (every mount after the first) without
// opening a transaction.
// Fast path: a double tap finds the first tap's receipt without opening a
// transaction.
const existing = await latestActiveReceipt(userId, "ai_full");
if (existing) return { minted: false, reason: "already_active" };
// A revoked receipt anywhere in this account's history means the user made
// a decision. Healing past it would re-grant what they withdrew. An
// affirmative act is the user deciding again, so it may proceed.
if (intent === "heal") {
const revoked = await prisma.consentReceipt.findFirst({
where: { userId, kind: "ai_full", revokedAt: { not: null } },
select: { id: true },
});
if (revoked) return { minted: false, reason: "previously_revoked" };
}
const artefact = JSON.stringify({
source: "web",
kind: "ai_full",
grantedAt: now.toISOString(),
note: "In-app affirmative AI consent granted via the web client.",
confirmed: true,
note: "Explicit AI consent: the person pressed Agree on the web consent card.",
});
// Two web mounts can pass the fast-path read concurrently and both reach
// the mint. The transaction re-checks under a Serializable isolation so a
// second mint sees the first's row and no-ops; the partial unique index
// (migration 0159, `WHERE revoked_at IS NULL`) is the structural backstop
// if the re-check still races. A unique violation from the backstop means
// a concurrent grant already won — that is success-shaped (the user ends
// up with exactly one active receipt), so we map it to `{ minted: false }`.
// Two taps can pass the fast-path read concurrently. The transaction
// re-checks under Serializable so the second sees the first's row; the
// partial unique index (migration 0159, `WHERE revoked_at IS NULL`) is the
// structural backstop. A unique violation from it means a concurrent grant
// already won, which is success-shaped.
try {
return await prisma.$transaction(
async (tx) => {
@@ -116,19 +68,6 @@ export async function ensureWebAiConsentReceipt(
orderBy: { createdAt: "desc" },
});
if (inTx) return { minted: false, reason: "already_active" } as const;
// Re-check the revocation inside the transaction too: a revoke can
// land between the fast-path read and here, and without this the
// mint would overwrite a decision made a moment earlier.
if (intent === "heal") {
const revokedInTx = await tx.consentReceipt.findFirst({
where: { userId, kind: "ai_full", revokedAt: { not: null } },
select: { id: true },
});
if (revokedInTx)
return { minted: false, reason: "previously_revoked" } as const;
}
const receipt = await tx.consentReceipt.create({
data: { userId, kind: "ai_full", artefact, signedAt: now },
});
@@ -137,7 +76,6 @@ export async function ensureWebAiConsentReceipt(
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
);
} catch (err) {
// Partial-unique violation — a concurrent grant landed first.
if (isP2002(err)) return { minted: false, reason: "already_active" };
throw err;
}
+1 -1
View File
@@ -112,7 +112,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"coach-prefs.body.invalid_json",
"coach-prefs.body.invalid_shape",
],
consent: ["consent.ai.required"],
consent: ["consent.ai.confirmRequired", "consent.ai.required"],
cycle: [
"cycle.bulk.invalid",
"cycle.bulk.too_large",
+21 -14
View File
@@ -14,11 +14,11 @@
* text; minted by the document auto-read switch).
*
* - POST /api/consent/ai — explicit grant with a signed artefact (iOS).
* Requires `confirm: true` (v1.43.2).
* - POST /api/consent/ai/web — idempotent `ai_full` grant for the web
* 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.
* client, `{ confirm: true }` only (v1.43.2).
* Neither route mints on a request that is not
* the person's own consent act.
* - 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).
@@ -42,13 +42,13 @@ import { isoDateTime } from "@/lib/validations/datetime";
const consentPostBody = consentPostBodyBase.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.",
"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. `confirm` must be `true` (v1.43.2): send this body only from the person's own tap on an agree control, never from a launch or sync path. Without it nothing is written and the route answers 400 `consent.ai.confirmRequired`. Always appends a fresh row — re-granting after a revoke mints a new receipt.",
});
const webConsentGrantBody = webConsentGrantBodyBase.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.',
"`confirm` must be `true`: the person pressed the agree control. Any other body, or none, mints nothing and answers 400 `consent.ai.confirmRequired` (v1.43.2; before, an empty body was a silent heal sent on every AI-settings visit).",
});
const consentReceiptResponse = z
@@ -74,14 +74,13 @@ const webConsentGrantResponse = z
minted: z
.boolean()
.describe(
"True when a new `ai_full` receipt was minted; false when an active one already existed (idempotent no-op).",
"True when a new `ai_full` receipt was minted; false when an active one already existed (a double tap is a 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.",
description: "Outcome of the explicit web `ai_full` grant.",
});
const serialisedReceipt = z
@@ -152,7 +151,9 @@ export const consentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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.",
"Persist a fresh AI-consent receipt. Required before server-managed AI egress (the operator's global LLM key or shared ChatGPT account); BYOK / local / ChatGPT-OAuth chains are the user's own egress and are not gated. Append-only audit trail.\n" +
"\n" +
"v1.43.2: the body must carry `confirm: true`, sent only when the person taps an agree control that named the service and the data. A request without it (a launch-time sync from an older app build) writes nothing and answers 400 with `meta.errorCode: consent.ai.confirmRequired`; such a client reads the standing consent from `ai.consent` on GET /api/auth/me instead of re-minting it.",
requestBody: {
required: true,
content: {
@@ -173,7 +174,8 @@ export const consentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
...stdResponses,
"400": {
description: "Request validation failed (consent routes use 400).",
description:
"Request validation failed (consent routes use 400), or `confirm` was not `true` (`meta.errorCode: consent.ai.confirmRequired`, nothing written).",
content: {
"application/json": {
schema: dataEnvelope(z.null(), "ConsentValidationErrorEnvelope"),
@@ -186,11 +188,11 @@ export const consentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/consent/ai/web": {
post: {
tags: ["Consent"],
summary: "Grant web AI consent (idempotent ai_full)",
summary: "Grant web AI consent (explicit 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. 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.',
"Record the calling web user's explicit `ai_full` consent: they pressed the agree control on a card that named the service and the data. Mints a receipt if none is active; a no-op when one already exists. As the person's own consent act it may follow an earlier withdrawal. The body must be `{ \"confirm\": true }`; anything else mints nothing and answers 400 `consent.ai.confirmRequired`. Revocation flows through DELETE /api/consent/ai/latest.",
requestBody: {
required: false,
required: true,
content: {
"application/json": { schema: webConsentGrantBody },
},
@@ -208,6 +210,11 @@ export const consentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
"400": {
description:
"`confirm` was not `true` (`meta.errorCode: consent.ai.confirmRequired`). Nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
+39
View File
@@ -199,10 +199,49 @@ export const aiProviderState = z
description: "The account-level companion to the capability map.",
});
export const aiConsentState = z
.object({
status: z
.enum(["given", "needed", "not_needed"])
.describe(
"`given` — an `ai_full` consent receipt is active (`since` says when); show it with a withdraw control. `needed` — the provider chain runs through a credential the operator holds and at least one capability is closed by the missing receipt alone (its reason is `consent_required`); ask, naming `recipients`, and grant only from the person's tap. `not_needed` — nothing to ask and nothing to withdraw.",
),
since: z
.string()
.nullable()
.describe(
"When the active `ai_full` consent was given, ISO-8601. `null` unless `status` is `given`.",
),
recipients: z
.array(
z.object({
via: z
.enum(["operator_key", "operator_chatgpt"])
.describe(
"`operator_key` — the operator's API key. `operator_chatgpt` — the operator's shared ChatGPT account.",
),
name: z
.string()
.describe(
"The service, named for a person: `OpenAI`, `Anthropic`, `ChatGPT (OpenAI)`, or the host name of the endpoint the operator set.",
),
}),
)
.describe(
"The operator-held services in the record's provider chain, in chain order. Empty when the chain holds none.",
),
})
.meta({
id: "AiConsentState",
description:
"v1.43.2 — the standing AI consent for the record. Consent is asked only when data would go through a credential the operator holds; a person's own key, ChatGPT account or local model needs none. Render from it; a client never mints a receipt on its own (launch, sync or mount): POST /api/consent/ai and /api/consent/ai/web require `confirm: true`.",
});
export const aiAccountBlock = z
.object({
capabilities: aiCapabilities,
provider: aiProviderState,
consent: aiConsentState,
usage: aiUsageState
.optional()
.describe(
+19 -11
View File
@@ -44,8 +44,23 @@ export type ConsentKind = z.infer<typeof consentKindEnum>;
*/
const ARTEFACT_MAX_BYTES = 64 * 1024;
/**
* The explicit-confirmation flag every grant must carry. A receipt records a
* person's own consent act, so the server mints one only for a request that
* says it is that act: the tap on an "Agree" control sends `confirm: true`.
* A request without it (an app that synced a receipt on launch, a page that
* healed one on mount) is refused with {@link CONSENT_CONFIRM_REQUIRED_ERROR_CODE}
* and mints nothing. Optional in the schema so the route can answer that
* code rather than a generic validation error.
*/
const confirmFlag = z.boolean().optional();
/** The refusal code for a grant request without `confirm: true`. */
export const CONSENT_CONFIRM_REQUIRED_ERROR_CODE = "consent.ai.confirmRequired";
export const consentPostBody = z.object({
kind: consentKindEnum,
confirm: confirmFlag,
artefact: z
.string()
.min(1, "artefact must not be empty")
@@ -58,21 +73,14 @@ 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.
* Body for `POST /api/consent/ai/web`: `{ "confirm": true }`, sent only by the
* "Agree" control. There is no implicit form any more; a request without the
* flag mints nothing.
*/
export const webConsentGrantBody = z.object({
intent: z.enum(["heal", "affirmative"]).default("heal"),
confirm: confirmFlag,
});
export type WebConsentGrantBodyInput = z.input<typeof webConsentGrantBody>;
/**
* Query schema for both `/api/consent/ai/latest` GET and DELETE.
*