mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
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:
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,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user