feat(consent): add the extraction consent kind and purge on withdrawal

A new receipt kind, ai_extraction, covers reading documents, lab report
scans and typed medication text with an external model. Switching the
document auto-read on now mints ai_extraction instead of ai_full, so it
no longer grants the Coach or the analysis as a side effect. Existing
ai_full receipts keep covering everything.

DELETE /api/consent/ai/latest now runs through withdrawConsent: the
revoke and, when it leaves no receipt covering the analysis, the
deletion of the text a model wrote under it commit in one transaction.
That is the status notes, the cached briefing, model-written period
narratives, reaction lines and workout paragraphs. Coach conversations,
facts and plans and document summaries stay. The purge is audit-logged
as consent.ai.purge with counts only.

Refs #975
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 09:05:09 +02:00
1 parent 2710dfc041
commit 8325f3964d
14 files changed
+607 -185

No files matched your search

+38 -15
View File
@@ -7948,12 +7948,13 @@ paths:
Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user.
Turning it ON does two further things a caller should expect. It is itself the standing consent act, so the
write appends an `ai_full` `ConsentReceipt` — the same receipt POST /api/consent/ai/web mints, and one DELETE
/api/consent/ai/latest revokes without touching this flag. And a genuine OFF→ON flip schedules a bounded
catch-up over the documents already in the vault, because the summary job is enqueued at upload time and would
otherwise only ever apply to future uploads. The catch-up is fire-and-forget and re-runs every consent and
budget gate per document.
Turning it ON does two further things a caller should expect. It is the consent act for reading documents, so
the write appends an `ai_extraction` `ConsentReceipt`: the narrow kind that covers documents, lab report scans
and typed medication text and nothing else (not the Coach, not the analysis). DELETE /api/consent/ai/latest
revokes it without touching this flag, and the revocation wins: document reading stops even while the flag stays
on. And a genuine OFF→ON flip schedules a bounded catch-up over the documents already in the vault, because the
summary job is enqueued at upload time and would otherwise only ever apply to future uploads. The catch-up is
fire-and-forget and re-runs every consent and budget gate per document.
The body cap here is 1 KB, far tighter than the 64 KB its siblings allow — a payload above it is refused with
@@ -12691,6 +12692,7 @@ paths:
- ai_full
- ai_insights_only
- ai_coach
- ai_extraction
description: Narrow to one consent kind. Omit for the full keyspace.
responses:
"200":
@@ -12702,8 +12704,9 @@ paths:
- $ref: "#/components/schemas/ConsentLatestForKindEnvelope"
- $ref: "#/components/schemas/ConsentLatestByKindEnvelope"
"400": &a22
description: "`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`. Multi-issue envelope;
nothing was read or revoked. The consent family answers 400 here where the rest of the API answers 422."
description: "`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`, `ai_extraction`.
Multi-issue envelope; nothing was read or revoked. The consent family answers 400 here where the rest of the
API answers 422."
content:
application/json:
schema:
@@ -12726,9 +12729,18 @@ paths:
each revocation is audit-logged.
Revoking `ai_full` does NOT switch off `documentsAutoAiRead`; that flag is written through PATCH
/api/auth/me/documents-auto-ai-read and turning it on mints a fresh receipt. A client offering both controls
should show them as the two separate things they are.
Revoking `ai_full` or `ai_extraction` does NOT switch off `documentsAutoAiRead`, but it does stop document
reading: the document gate reads the receipt, not the switch, so a revocation wins over a switch left on.
Turning the switch on again mints a fresh `ai_extraction` receipt through PATCH
/api/auth/me/documents-auto-ai-read. A client offering both controls should show them as the two separate things
they are.
When a revocation leaves no receipt covering the analysis (`ai_insights_only` or `ai_full`), the same
transaction deletes the text a model wrote under it that can be written again: per-metric status notes, the
cached briefing, model-written period narratives, reaction lines and workout paragraphs. Coach conversations,
facts and plans and document summaries are the person's records and stay. A deletion is audit-logged with counts
only (`consent.ai.purge`).
parameters:
- in: query
name: kind
@@ -12739,6 +12751,7 @@ paths:
- ai_full
- ai_insights_only
- ai_coach
- ai_extraction
description: Revoke one kind. Omit to revoke the latest active receipt across every kind.
responses:
"200":
@@ -23846,8 +23859,8 @@ components:
type: boolean
required:
- documentsAutoAiRead
description: Turn automatic AI reading of newly uploaded documents on or off. Turning it on also mints an `ai_full`
consent receipt and schedules a catch-up over the existing vault.
description: Turn automatic AI reading of newly uploaded documents on or off. Turning it on also mints an
`ai_extraction` consent receipt and schedules a catch-up over the existing vault.
InjectionSitePrefsPatchRequest:
type: object
properties:
@@ -26005,6 +26018,7 @@ components:
- ai_full
- ai_insights_only
- ai_coach
- ai_extraction
artefact:
type: string
minLength: 1
@@ -58019,6 +58033,7 @@ components:
- ai_full
- ai_insights_only
- ai_coach
- ai_extraction
signedAt:
type: string
revokedAt:
@@ -58115,6 +58130,7 @@ components:
- ai_full
- ai_insights_only
- ai_coach
- ai_extraction
receipt:
anyOf:
- $ref: "#/components/schemas/SerialisedConsentReceipt"
@@ -58137,6 +58153,7 @@ components:
- ai_full
- ai_insights_only
- ai_coach
- ai_extraction
signedAt:
type: string
format: date-time
@@ -58193,13 +58210,18 @@ components:
anyOf:
- $ref: "#/components/schemas/SerialisedConsentReceipt"
- type: "null"
ai_extraction:
anyOf:
- $ref: "#/components/schemas/SerialisedConsentReceipt"
- type: "null"
required:
- ai_full
- ai_insights_only
- ai_coach
- ai_extraction
additionalProperties: false
description: "The latest active receipt for every kind. All three keys are always present: a null value means the kind
is not granted, so a client never has to read absence as ambiguity."
description: "The latest active receipt for every kind. Every key is always present: a null value means the kind is not
granted, so a client never has to read absence as ambiguity."
ConsentRevokeForKindEnvelope:
type: object
properties:
@@ -58248,6 +58270,7 @@ components:
- ai_full
- ai_insights_only
- ai_coach
- ai_extraction
receipt:
$ref: "#/components/schemas/SerialisedConsentReceipt"
required:
@@ -22,7 +22,7 @@ vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/consent/web-grant", () => ({
ensureWebAiConsentReceipt: vi.fn().mockResolvedValue(undefined),
ensureExtractionConsentReceipt: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/jobs/document-summary-catchup", () => ({
enqueueSummaryCatchUp: vi.fn().mockResolvedValue({ enqueued: true }),
@@ -47,7 +47,7 @@ vi.mock("next/headers", () => ({
import { PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant";
import { ensureExtractionConsentReceipt } from "@/lib/consent/web-grant";
import { enqueueSummaryCatchUp } from "@/lib/jobs/document-summary-catchup";
const SESSION_OK = {
@@ -103,23 +103,20 @@ describe("PATCH /api/auth/me/documents-auto-ai-read — catch-up scheduling", ()
await PATCH(mkPatch(false));
expect(enqueueSummaryCatchUp).not.toHaveBeenCalled();
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
expect(ensureExtractionConsentReceipt).not.toHaveBeenCalled();
});
it("still mints the consent receipt on the flip that schedules the pass", async () => {
// The catch-up rides the same act of consent, never around it. The mint
// is marked `affirmative` because switching the toggle on IS the consent
// act: unlike the settings-mount heal, it may lift an earlier
// withdrawal, which is the user deciding again rather than the page
// deciding for them.
// is the extraction kind (documents, lab scans, medication text) and
// nothing wider: switching document reading on does not consent to the
// Coach or to the analysis. It is the person's own affirmative act, so
// it may lift an earlier withdrawal of the same kind.
withPrevious(false);
await PATCH(mkPatch(true));
expect(ensureWebAiConsentReceipt).toHaveBeenCalledWith(
"user-1",
"affirmative",
);
expect(ensureExtractionConsentReceipt).toHaveBeenCalledWith("user-1");
});
it("persists the flag field-by-field", async () => {
@@ -30,7 +30,7 @@ import {
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant";
import { ensureExtractionConsentReceipt } from "@/lib/consent/web-grant";
import { prisma } from "@/lib/db";
import { enqueueSummaryCatchUp } from "@/lib/jobs/document-summary-catchup";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -102,11 +102,12 @@ export const PATCH = apiHandler(async (req: Request) => {
data: { documentsAutoAiRead: next },
});
// Turning the toggle ON is the standing consent act — mint an append-only
// `ai_full` receipt (idempotent) so the durable audit trail records it, in
// addition to the runtime short-circuit the document consent gate reads.
// Turning the toggle ON is the consent act for reading documents — mint an
// append-only `ai_extraction` receipt (idempotent). The document consent
// gate reads that receipt, not the toggle, so a later revocation wins over
// a toggle left on. It does not grant the Coach or the analysis.
if (next) {
await ensureWebAiConsentReceipt(user.id, "affirmative");
await ensureExtractionConsentReceipt(user.id);
}
// A genuine OFF→ON flip schedules a catch-up over the documents already in
@@ -1,24 +1,30 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// v1.16.16 — `createReceipt` + `revokeLatest` run inside a transaction; the
// mock runs the callback against the same proxy.
// v1.16.16 — `createReceipt` and the withdrawal run inside a transaction;
// the mock runs the callback against the same proxy. The withdrawal also
// purges the regenerable AI text, so the proxy carries those delegates too.
type TxFn = (tx: unknown) => unknown;
vi.mock("@/lib/db", () => {
const consentReceipt = {
create: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
};
return {
prisma: {
consentReceipt,
$transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })),
const prisma = {
consentReceipt: {
create: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
count: vi.fn(),
},
insightStatusCache: { deleteMany: vi.fn() },
user: { updateMany: vi.fn() },
insightNarrative: { deleteMany: vi.fn() },
arrivalReaction: { updateMany: vi.fn() },
workoutInsight: { deleteMany: vi.fn() },
$transaction: vi.fn(),
};
prisma.$transaction.mockImplementation((fn: TxFn) => fn(prisma));
return { prisma };
});
vi.mock("@/lib/rate-limit", () => ({
@@ -78,9 +84,21 @@ beforeEach(() => {
vi.mocked(auditLog).mockResolvedValue(undefined);
// Re-arm rate-limit + transaction pass-throughs cleared by reset.
vi.mocked(checkConsentRateLimit).mockResolvedValue(RL_OK);
$transaction.mockImplementation((fn: TxFn) =>
fn({ consentReceipt: prisma.consentReceipt }),
);
$transaction.mockImplementation((fn: TxFn) => fn(prisma));
// Default: another receipt still covers the analysis, so no purge runs.
// The purge cases below set this to 0.
vi.mocked(prisma.consentReceipt.count).mockResolvedValue(1 as never);
for (const deleteMany of [
prisma.insightStatusCache.deleteMany,
prisma.insightNarrative.deleteMany,
prisma.workoutInsight.deleteMany,
]) {
vi.mocked(deleteMany).mockResolvedValue({ count: 0 } as never);
}
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
vi.mocked(prisma.arrivalReaction.updateMany).mockResolvedValue({
count: 0,
} as never);
});
describe("GET /api/consent/ai/latest", () => {
@@ -278,14 +296,15 @@ describe("DELETE /api/consent/ai/latest", () => {
const createdAt = new Date("2026-05-18T10:00:01.000Z");
const revokedAt = new Date("2026-05-18T11:00:00.000Z");
// v1.16.16 — `revokeLatest` runs per kind in `consentKindEnum.options`
// order: ai_full → ai_insights_only → ai_coach. The `updateMany` count
// gates the follow-up re-read. ai_full + ai_coach revoke a row;
// ai_insights_only has none (count 0, no re-read).
// The withdrawal runs per kind in `consentKindEnum.options` order:
// ai_full → ai_insights_only → ai_coach → ai_extraction. The
// `updateMany` count gates the follow-up re-read. ai_full + ai_coach
// revoke a row; the others have none (count 0, no re-read).
vi.mocked(prisma.consentReceipt.updateMany)
.mockResolvedValueOnce({ count: 1 } as never) // ai_full
.mockResolvedValueOnce({ count: 0 } as never) // ai_insights_only
.mockResolvedValueOnce({ count: 1 } as never); // ai_coach
.mockResolvedValueOnce({ count: 1 } as never) // ai_coach
.mockResolvedValueOnce({ count: 0 } as never); // ai_extraction
vi.mocked(prisma.consentReceipt.findFirst)
.mockResolvedValueOnce({
id: "rcpt-full",
@@ -323,6 +342,80 @@ describe("DELETE /api/consent/ai/latest", () => {
});
});
describe("DELETE /api/consent/ai/latest — purge of regenerable AI text", () => {
const revokedRow = {
id: "rcpt-1",
userId: "user-1",
kind: "ai_insights_only",
artefact: "PDF",
signedAt: new Date("2026-05-18T10:00:00.000Z"),
revokedAt: new Date("2026-05-18T11:00:00.000Z"),
createdAt: new Date("2026-05-18T10:00:01.000Z"),
};
it("deletes the regenerable text and writes a counts-only purge audit row when nothing covers the analysis any more", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(
revokedRow as never,
);
vi.mocked(prisma.consentReceipt.count).mockResolvedValue(0 as never);
vi.mocked(prisma.insightStatusCache.deleteMany).mockResolvedValue({
count: 4,
} as never);
const res = await DELETE(
new NextRequest(
"http://localhost/api/consent/ai/latest?kind=ai_insights_only",
{ method: "DELETE" },
),
);
expect(res.status).toBe(200);
expect(prisma.insightStatusCache.deleteMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
});
expect(prisma.workoutInsight.deleteMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
});
expect(auditLog).toHaveBeenCalledWith("consent.ai.purge", {
userId: "user-1",
details: {
statusNotes: 4,
briefing: 0,
narratives: 0,
reactionLines: 0,
workoutParagraphs: 0,
},
});
});
it("keeps the text while another receipt still covers the analysis", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(
revokedRow as never,
);
vi.mocked(prisma.consentReceipt.count).mockResolvedValue(1 as never);
const res = await DELETE(
new NextRequest(
"http://localhost/api/consent/ai/latest?kind=ai_insights_only",
{ method: "DELETE" },
),
);
expect(res.status).toBe(200);
expect(prisma.insightStatusCache.deleteMany).not.toHaveBeenCalled();
expect(auditLog).not.toHaveBeenCalledWith(
"consent.ai.purge",
expect.anything(),
);
});
});
describe("append-only invariant", () => {
/**
* v1.4.40 SB-10 — the receipts table is the legal audit trail.
+43 -38
View File
@@ -13,6 +13,11 @@
* DELETE /api/consent/ai/latest
* Master toggle — revokes the latest active receipt across every
* consent kind. Returns the list of revoked rows.
*
* Either DELETE also deletes the regenerable model-written text (status
* notes, the cached briefing, model-written narratives, reaction lines,
* workout paragraphs) when it leaves no receipt covering the analysis, in the
* same transaction as the revoke. Coach memory and document summaries stay.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
@@ -28,8 +33,8 @@ import {
import {
latestActiveReceipt,
latestActiveReceiptsByKind,
revokeLatest,
} from "@/lib/consent/receipts";
import { withdrawConsent } from "@/lib/consent/withdrawal";
import { serialiseReceipt } from "../route";
export const GET = apiHandler(async (request: NextRequest) => {
@@ -81,6 +86,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
? serialiseReceipt(byKind.ai_insights_only)
: null,
ai_coach: byKind.ai_coach ? serialiseReceipt(byKind.ai_coach) : null,
ai_extraction: byKind.ai_extraction
? serialiseReceipt(byKind.ai_extraction)
: null,
};
annotate({
@@ -119,53 +127,50 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
const { kind } = parsed.data;
if (kind) {
const revoked = await revokeLatest(user.id, kind);
if (!revoked) {
// Idempotent: "no active receipt to revoke" is success-shaped.
// The iOS toggle hammers this on every flip; returning 404
// would surface as a spurious error in the client.
annotate({
action: { name: "consent.ai.revoke" },
meta: { kind, revoked: false },
});
return apiSuccess({ kind, receipt: null });
}
// One kind, or every kind for the master "turn AI off" toggle. The revoke
// and the purge of the regenerable text the consent covered commit
// together (`withdrawConsent`).
const kinds = kind ? [kind] : consentKindEnum.options;
const { revoked, purged } = await withdrawConsent(user.id, kinds);
for (const { kind: k, receipt } of revoked) {
auditLog("consent.ai.revoke", {
userId: user.id,
details: { kind, receiptId: revoked.id },
details: { kind: k, receiptId: receipt.id },
}).catch(() => {});
annotate({
action: { name: "consent.ai.revoke" },
meta: { kind, receiptId: revoked.id },
});
return apiSuccess({ kind, receipt: serialiseReceipt(revoked) });
}
// No kind specified — master "AI deaktivieren" toggle. Revoke the
// latest active row for every kind that currently has one.
const revokedRows: Array<{
kind: ConsentKind;
receipt: ReturnType<typeof serialiseReceipt>;
}> = [];
for (const k of consentKindEnum.options) {
const r = await revokeLatest(user.id, k);
if (r) {
revokedRows.push({ kind: k, receipt: serialiseReceipt(r) });
auditLog("consent.ai.revoke", {
userId: user.id,
details: { kind: k, receiptId: r.id },
}).catch(() => {});
}
if (purged) {
// Counts only, never content.
auditLog("consent.ai.purge", {
userId: user.id,
details: { ...purged },
}).catch(() => {});
}
annotate({
action: { name: "consent.ai.revoke" },
meta: {
kinds_revoked: revokedRows.map((r) => r.kind),
count: revokedRows.length,
kinds_revoked: revoked.map((r) => r.kind),
count: revoked.length,
purged: purged !== null,
},
});
return apiSuccess({ revoked: revokedRows });
if (kind) {
// Idempotent: "no active receipt to revoke" is success-shaped. The iOS
// toggle hammers this on every flip; returning 404 would surface as a
// spurious error in the client.
const receipt = revoked[0]?.receipt ?? null;
return apiSuccess({
kind,
receipt: receipt ? serialiseReceipt(receipt) : null,
});
}
return apiSuccess({
revoked: revoked.map((r) => ({
kind: r.kind,
receipt: serialiseReceipt(r.receipt),
})),
});
});
+1 -39
View File
@@ -1,7 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// `createReceipt` + `revokeLatest` run their supersede/revoke logic inside
// a transaction. Run the callback against the same mock proxy. The Prisma
// `createReceipt` runs its supersede logic inside a transaction. Run the callback against the same mock proxy. The Prisma
// `$transaction` signature is generic; the mock takes a loose callback.
type TxFn = (tx: unknown) => unknown;
@@ -26,7 +25,6 @@ import {
createReceipt,
latestActiveReceipt,
latestActiveReceiptsByKind,
revokeLatest,
} from "../receipts";
const $transaction = vi.mocked(prisma.$transaction) as unknown as {
@@ -196,39 +194,3 @@ describe("latestActiveReceiptsByKind", () => {
expect(result.ai_insights_only).toBeUndefined();
});
});
describe("revokeLatest", () => {
it("atomically revokes the active row via updateMany and returns it", async () => {
const now = new Date("2026-05-18T11:00:00.000Z");
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(
row({ revokedAt: now }) as never,
);
const result = await revokeLatest("user-1", "ai_full", now);
expect(prisma.consentReceipt.updateMany).toHaveBeenCalledWith({
where: { userId: "user-1", kind: "ai_full", revokedAt: null },
data: { revokedAt: now },
});
// Re-reads the just-revoked row so the audit log keeps the receipt id.
expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith({
where: { userId: "user-1", kind: "ai_full", revokedAt: now },
orderBy: { createdAt: "desc" },
});
expect(result?.revokedAt).toEqual(now);
});
it("returns null without re-reading when no active receipt exists", async () => {
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
count: 0,
} as never);
const result = await revokeLatest("user-1", "ai_full");
expect(result).toBeNull();
expect(prisma.consentReceipt.findFirst).not.toHaveBeenCalled();
});
});
+45 -1
View File
@@ -21,7 +21,10 @@ vi.mock("@/lib/db", () => {
});
import { prisma } from "@/lib/db";
import { ensureWebAiConsentReceipt } from "../web-grant";
import {
ensureExtractionConsentReceipt,
ensureWebAiConsentReceipt,
} from "../web-grant";
const $transaction = vi.mocked(prisma.$transaction) as unknown as {
mockImplementation: (impl: (fn: TxFn) => unknown) => void;
@@ -246,3 +249,44 @@ describe("ensureWebAiConsentReceipt — a revocation is a standing decision", ()
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
});
});
describe("ensureExtractionConsentReceipt", () => {
it("mints an ai_extraction receipt, never the master grant", async () => {
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
vi.mocked(prisma.consentReceipt.create).mockResolvedValue(
row({ kind: "ai_extraction" }) as never,
);
const result = await ensureExtractionConsentReceipt("user-1");
expect(result.minted).toBe(true);
const createArg = vi.mocked(prisma.consentReceipt.create).mock.calls[0][0];
expect(createArg.data.kind).toBe("ai_extraction");
expect(JSON.parse(createArg.data.artefact as string).kind).toBe(
"ai_extraction",
);
for (const [args] of vi.mocked(prisma.consentReceipt.findFirst).mock
.calls) {
expect((args as { where: { kind: string } }).where.kind).toBe(
"ai_extraction",
);
}
});
it("supersedes an earlier revocation of the same kind (it is the person's own act)", async () => {
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
vi.mocked(prisma.consentReceipt.create).mockResolvedValue(
row({ kind: "ai_extraction" }) as never,
);
await ensureExtractionConsentReceipt("user-1");
// An affirmative mint never looks for a past revocation.
for (const [args] of vi.mocked(prisma.consentReceipt.findFirst).mock
.calls) {
expect(
(args as { where: { revokedAt: unknown } }).where.revokedAt,
).toBeNull();
}
});
});
@@ -0,0 +1,119 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// The revoke and the purge run inside one transaction; the mock runs the
// callback against the same proxy. Atomicity against a real database is
// pinned by `tests/integration/consent-withdrawal-purge.test.ts`.
type TxFn = (tx: unknown) => unknown;
vi.mock("@/lib/db", () => {
const consentReceipt = {
updateMany: vi.fn(),
findFirst: vi.fn(),
count: vi.fn(),
};
return {
prisma: {
consentReceipt,
$transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })),
},
};
});
import { prisma } from "@/lib/db";
import { withdrawConsent, type AiTextPurgeCounts } from "../withdrawal";
const COUNTS: AiTextPurgeCounts = {
statusNotes: 3,
briefing: 1,
narratives: 2,
reactionLines: 1,
workoutParagraphs: 1,
};
const now = new Date("2026-09-20T08:00:00.000Z");
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.$transaction).mockImplementation(((fn: TxFn) =>
fn({ consentReceipt: prisma.consentReceipt })) as never);
});
/** Receipts active before the call, by kind. */
function active(kinds: string[]) {
const live = new Set(kinds);
vi.mocked(prisma.consentReceipt.updateMany).mockImplementation((async (args: {
where: { kind: string };
}) => {
const had = live.delete(args.where.kind);
return { count: had ? 1 : 0 };
}) as never);
vi.mocked(prisma.consentReceipt.findFirst).mockImplementation((async (args: {
where: { kind: string };
}) => ({ id: `r-${args.where.kind}`, kind: args.where.kind })) as never);
vi.mocked(prisma.consentReceipt.count).mockImplementation(
(async (args: { where: { kind: { in: string[] } } }) =>
args.where.kind.in.filter((k) => live.has(k)).length) as never,
);
}
describe("withdrawConsent", () => {
it("revokes each active kind conditionally and re-reads it for the audit trail", async () => {
active(["ai_coach"]);
const purge = vi.fn();
const result = await withdrawConsent("u1", ["ai_coach"], now, purge);
expect(prisma.consentReceipt.updateMany).toHaveBeenCalledWith({
where: { userId: "u1", kind: "ai_coach", revokedAt: null },
data: { revokedAt: now },
});
expect(result.revoked.map((r) => r.kind)).toEqual(["ai_coach"]);
});
it("purges when the revoke leaves no receipt covering the analysis", async () => {
active(["ai_full"]);
const purge = vi.fn(async () => COUNTS);
const result = await withdrawConsent("u1", ["ai_full"], now, purge);
expect(purge).toHaveBeenCalledTimes(1);
expect(result.purged).toEqual(COUNTS);
});
it("keeps the text while another receipt still covers the analysis", async () => {
active(["ai_full", "ai_insights_only"]);
const purge = vi.fn(async () => COUNTS);
const result = await withdrawConsent("u1", ["ai_full"], now, purge);
expect(purge).not.toHaveBeenCalled();
expect(result.purged).toBeNull();
});
it("never purges for a revoke that did not touch the analysis (Coach or extraction only)", async () => {
active(["ai_coach", "ai_extraction"]);
const purge = vi.fn(async () => COUNTS);
const result = await withdrawConsent(
"u1",
["ai_coach", "ai_extraction"],
now,
purge,
);
expect(purge).not.toHaveBeenCalled();
expect(result.purged).toBeNull();
expect(result.revoked).toHaveLength(2);
});
it("does nothing, and purges nothing, when no receipt was active", async () => {
active([]);
const purge = vi.fn(async () => COUNTS);
const result = await withdrawConsent("u1", ["ai_full"], now, purge);
expect(result).toEqual({ revoked: [], purged: null });
expect(prisma.consentReceipt.findFirst).not.toHaveBeenCalled();
expect(purge).not.toHaveBeenCalled();
});
it("propagates a purge failure out of the transaction (the revoke rolls back with it)", async () => {
active(["ai_insights_only"]);
const purge = vi.fn(async () => {
throw new Error("purge failed");
});
await expect(
withdrawConsent("u1", ["ai_insights_only"], now, purge),
).rejects.toThrow("purge failed");
});
});
+23 -37
View File
@@ -2,10 +2,10 @@
* v1.4.40 SB-10 — AI consent receipts CRUD helper.
*
* Append-only audit trail. Every grant + revoke mints a fresh row;
* no updates, no deletes outside of cascade-on-user-delete. The
* `revokeLatest` helper writes the current row's `revokedAt` and
* returns it — that single field flip is the only mutation the
* helper performs. A subsequent re-grant inserts a new row, so the
* no updates, no deletes outside of cascade-on-user-delete. A
* revocation (`withdrawConsent` in `./withdrawal`) writes the current
* row's `revokedAt` — that single field flip is the only mutation a
* receipt ever sees. A subsequent re-grant inserts a new row, so the
* historical chain stays intact for legal review.
*
* Source of truth for "is AI active right now?" is
@@ -19,6 +19,25 @@ import { isP2002 } from "@/lib/prisma-errors";
export type ConsentReceipt = ConsentReceiptModel;
/**
* The consent kind for reading documents, lab report scans and typed
* medication text with an external model. Minted by switching the document
* auto-read on; required (or the master `ai_full`) by every extraction path.
* It does not satisfy the Coach or the analysis.
*/
export const AI_EXTRACTION_CONSENT_KIND = "ai_extraction" satisfies ConsentKind;
/**
* The kinds whose active receipt authorises model-written analysis over the
* person's own readings (briefing, status notes, narratives, workout
* paragraphs, reaction lines). When none of these is left active, the
* regenerable text those surfaces stored is purged.
*/
export const INSIGHTS_CONSENT_KINDS: readonly ConsentKind[] = [
"ai_insights_only",
"ai_full",
];
/**
* Insert a fresh consent receipt for (userId, kind). The caller has
* already Zod-validated `artefact` (≤ 64 KB) and `signedAt` (ISO-8601).
@@ -119,36 +138,3 @@ export async function latestActiveReceiptsByKind(
}
return out;
}
/**
* Mark the active receipt for (userId, kind) as revoked. Returns the
* updated row, or `null` if no active receipt exists.
*
* v1.16.16 — the partial unique index guarantees at most one active row
* per (user, kind), so a single atomic `updateMany` over the active
* predicate revokes "the" active receipt without a separate read. Two
* concurrent revokes converge: the first flips `revoked_at`, the second's
* predicate no longer matches and writes nothing. The follow-up read
* returns the row the caller (audit log) needs; it is `null` only when no
* active receipt existed at all.
*/
export async function revokeLatest(
userId: string,
kind: ConsentKind,
now: Date = new Date(),
): Promise<ConsentReceipt | null> {
return prisma.$transaction(async (tx) => {
const updated = await tx.consentReceipt.updateMany({
where: { userId, kind, revokedAt: null },
data: { revokedAt: now },
});
if (updated.count === 0) return null;
// Re-read the just-revoked row (revoked at exactly `now`) so the audit
// trail keeps the receipt id. The active predicate guaranteed a single
// row, so this resolves it unambiguously.
return tx.consentReceipt.findFirst({
where: { userId, kind, revokedAt: now },
orderBy: { createdAt: "desc" },
});
});
}
+43 -9
View File
@@ -35,7 +35,10 @@
import { prisma } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
import { isP2002 } from "@/lib/prisma-errors";
import { latestActiveReceipt } from "@/lib/consent/receipts";
import {
AI_EXTRACTION_CONSENT_KIND,
latestActiveReceipt,
} from "@/lib/consent/receipts";
import type { ConsentReceipt } from "@/lib/consent/receipts";
/**
@@ -63,24 +66,55 @@ export type WebConsentGrantResult =
*/
export type WebConsentGrantIntent = "heal" | "affirmative";
/** The receipt kinds the web mints: the master grant, or extraction alone. */
type WebConsentKind = "ai_full" | typeof AI_EXTRACTION_CONSENT_KIND;
/**
* 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.
* satisfies every surface gate (insights + coach + extraction), matching the
* iOS app's single master toggle rather than per-surface web grants.
*/
export async function ensureWebAiConsentReceipt(
userId: string,
intent: WebConsentGrantIntent,
now: Date = new Date(),
): Promise<WebConsentGrantResult> {
return ensureWebConsentReceipt(userId, "ai_full", intent, now);
}
/**
* Ensure an active `ai_extraction` receipt: the consent to read documents,
* lab report scans and typed medication text with an external model. Minted
* by switching the document auto-read on, which is the person's own
* affirmative act, so it supersedes an earlier revocation of the same kind.
* It does not satisfy the Coach or the analysis.
*/
export async function ensureExtractionConsentReceipt(
userId: string,
now: Date = new Date(),
): Promise<WebConsentGrantResult> {
return ensureWebConsentReceipt(
userId,
AI_EXTRACTION_CONSENT_KIND,
"affirmative",
now,
);
}
async function ensureWebConsentReceipt(
userId: string,
kind: WebConsentKind,
intent: WebConsentGrantIntent,
now: 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.
const existing = await latestActiveReceipt(userId, "ai_full");
const existing = await latestActiveReceipt(userId, kind);
if (existing) return { minted: false, reason: "already_active" };
// A revoked receipt anywhere in this account's history means the user made
@@ -88,7 +122,7 @@ export async function ensureWebAiConsentReceipt(
// 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 } },
where: { userId, kind, revokedAt: { not: null } },
select: { id: true },
});
if (revoked) return { minted: false, reason: "previously_revoked" };
@@ -96,7 +130,7 @@ export async function ensureWebAiConsentReceipt(
const artefact = JSON.stringify({
source: "web",
kind: "ai_full",
kind,
grantedAt: now.toISOString(),
note: "In-app affirmative AI consent granted via the web client.",
});
@@ -112,7 +146,7 @@ export async function ensureWebAiConsentReceipt(
return await prisma.$transaction(
async (tx) => {
const inTx = await tx.consentReceipt.findFirst({
where: { userId, kind: "ai_full", revokedAt: null },
where: { userId, kind, revokedAt: null },
orderBy: { createdAt: "desc" },
});
if (inTx) return { minted: false, reason: "already_active" } as const;
@@ -122,7 +156,7 @@ export async function ensureWebAiConsentReceipt(
// 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 } },
where: { userId, kind, revokedAt: { not: null } },
select: { id: true },
});
if (revokedInTx)
@@ -130,7 +164,7 @@ export async function ensureWebAiConsentReceipt(
}
const receipt = await tx.consentReceipt.create({
data: { userId, kind: "ai_full", artefact, signedAt: now },
data: { userId, kind, artefact, signedAt: now },
});
return { minted: true, receipt } as const;
},
+149
View File
@@ -0,0 +1,149 @@
/**
* Consent withdrawal: revoking receipts, and deleting the model-written text
* that the withdrawn consent covered.
*
* A revocation stops new AI work at once (every capability reads the active
* receipts). What it also owes the person is the text already written under
* that consent. Two kinds of stored text are treated differently:
*
* - Regenerable caches are DELETED, in the same transaction as the revoke,
* when the revoke leaves no active receipt covering the analysis
* (`ai_insights_only` or `ai_full`): the per-metric status notes, the
* cached briefing, model-written period narratives (the deterministic ones
* are data and stay), arrival reaction lines (the markers stay), and the
* workout paragraphs. Nothing of value is lost; granting consent again
* regenerates them.
* - The person's own records stay: Coach conversations, facts and plans,
* document summaries and extracted text. They are hidden from AI surfaces
* while a capability is unavailable, but they remain the person's to read
* and delete.
*
* Revoke and purge commit together or not at all: a purge that fails leaves
* the receipt active, so the person sees the withdrawal fail and can retry,
* rather than a revoked consent with its text still stored.
*/
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { DETERMINISTIC_PROVIDER_TYPE } from "@/lib/insights/narrative/period-narrative-deterministic";
import type { ConsentKind } from "@/lib/validations/consent";
import { INSIGHTS_CONSENT_KINDS, type ConsentReceipt } from "./receipts";
/** How many stored items one withdrawal deleted, per surface. */
export interface AiTextPurgeCounts {
statusNotes: number;
/** 1 when a cached briefing was cleared, else 0. */
briefing: number;
narratives: number;
reactionLines: number;
workoutParagraphs: number;
}
export interface ConsentWithdrawal {
/** The receipts this call revoked, in the order of `kinds`. */
revoked: Array<{ kind: ConsentKind; receipt: ConsentReceipt }>;
/** What was deleted, or `null` when the withdrawal left the analysis covered. */
purged: AiTextPurgeCounts | null;
}
/**
* Delete the regenerable model-written text for one account, inside `tx`.
* Exported for the transaction below and its tests; call it through
* `withdrawConsent`, never on its own.
*/
export async function purgeRegenerableAiText(
tx: Prisma.TransactionClient,
userId: string,
): Promise<AiTextPurgeCounts> {
const [statusNotes, briefing, narratives, reactionLines, workoutParagraphs] =
await Promise.all([
tx.insightStatusCache.deleteMany({ where: { userId } }),
tx.user.updateMany({
where: {
id: userId,
OR: [
{ insightsCachedText: { not: null } },
{ insightsCachedAt: { not: null } },
{ insightsSnapshotHash: { not: null } },
],
},
data: {
insightsCachedText: null,
insightsCachedAt: null,
insightsSnapshotHash: null,
},
}),
tx.insightNarrative.deleteMany({
where: {
userId,
OR: [
{ providerType: null },
{ providerType: { not: DETERMINISTIC_PROVIDER_TYPE } },
],
},
}),
// The marker is data (it drives the "just in" chip); only the sentence
// goes. `generatedAt` stays, so the worker does not write it again.
tx.arrivalReaction.updateMany({
where: { userId, lineEncrypted: { not: null } },
data: { lineEncrypted: null },
}),
tx.workoutInsight.deleteMany({ where: { userId } }),
]);
return {
statusNotes: statusNotes.count,
briefing: briefing.count,
narratives: narratives.count,
reactionLines: reactionLines.count,
workoutParagraphs: workoutParagraphs.count,
};
}
/**
* Revoke the active receipt of each of `kinds`, and purge the regenerable
* text when that leaves the analysis without a covering receipt. One
* transaction. The purge runs only when this call revoked a receipt that
* covered the analysis; revoking the Coach consent alone never deletes a
* status note.
*/
export async function withdrawConsent(
userId: string,
kinds: readonly ConsentKind[],
now: Date = new Date(),
purge: typeof purgeRegenerableAiText = purgeRegenerableAiText,
): Promise<ConsentWithdrawal> {
return prisma.$transaction(async (tx) => {
const revoked: ConsentWithdrawal["revoked"] = [];
for (const kind of kinds) {
// The partial unique index keeps at most one active row per (user,
// kind), so one conditional update revokes "the" active receipt; a
// concurrent revoke finds nothing left to match and writes nothing.
const updated = await tx.consentReceipt.updateMany({
where: { userId, kind, revokedAt: null },
data: { revokedAt: now },
});
if (updated.count === 0) continue;
const receipt = await tx.consentReceipt.findFirst({
where: { userId, kind, revokedAt: now },
orderBy: { createdAt: "desc" },
});
if (receipt) revoked.push({ kind, receipt });
}
const touchedAnalysis = revoked.some(({ kind }) =>
INSIGHTS_CONSENT_KINDS.includes(kind),
);
if (!touchedAnalysis) return { revoked, purged: null };
const stillCovered = await tx.consentReceipt.count({
where: {
userId,
revokedAt: null,
kind: { in: [...INSIGHTS_CONSENT_KINDS] },
},
});
if (stillCovered > 0) return { revoked, purged: null };
return { revoked, purged: await purge(tx, userId) };
});
}
+9 -4
View File
@@ -9,7 +9,9 @@
* clients have a stable reference.
*
* Receipt kinds: `ai_full` (master — satisfies every surface),
* `ai_insights_only` (Insights only), `ai_coach` (Coach only).
* `ai_insights_only` (Insights only), `ai_coach` (Coach only),
* `ai_extraction` (reading documents, lab report scans and typed medication
* text; minted by the document auto-read switch).
*
* - POST /api/consent/ai — explicit grant with a signed artefact (iOS).
* - POST /api/consent/ai/web — idempotent `ai_full` grant for the web
@@ -110,11 +112,12 @@ const consentLatestAllKinds = z
ai_full: serialisedReceipt.nullable(),
ai_insights_only: serialisedReceipt.nullable(),
ai_coach: serialisedReceipt.nullable(),
ai_extraction: serialisedReceipt.nullable(),
})
.meta({
id: "ConsentLatestByKind",
description:
"The latest active receipt for every kind. All three keys are always present: a null value means the kind is not granted, so a client never has to read absence as ambiguity.",
"The latest active receipt for every kind. Every key is always present: a null value means the kind is not granted, so a client never has to read absence as ambiguity.",
});
const consentRevokeAllKinds = z
@@ -137,7 +140,7 @@ const consentRevokeAllKinds = z
const consentQueryValidation400 = {
"400": {
description:
"`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`. Multi-issue envelope; nothing was read or revoked. The consent family answers 400 here where the rest of the API answers 422.",
"`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`, `ai_extraction`. Multi-issue envelope; nothing was read or revoked. The consent family answers 400 here where the rest of the API answers 422.",
content: { "application/json": { schema: errorEnvelope } },
},
};
@@ -257,7 +260,9 @@ export const consentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"\n" +
"Idempotent on purpose, and it is worth knowing why: revoking a kind that has nothing active answers 200 with `receipt: null` rather than 404, because the client toggle fires this on every flip and a 404 would render as a failure the user cannot act on. Revocation is append-only in effect — the row is stamped, never deleted — and each revocation is audit-logged.\n" +
"\n" +
"Revoking `ai_full` does NOT switch off `documentsAutoAiRead`; that flag is written through PATCH /api/auth/me/documents-auto-ai-read and turning it on mints a fresh receipt. A client offering both controls should show them as the two separate things they are.",
"Revoking `ai_full` or `ai_extraction` does NOT switch off `documentsAutoAiRead`, but it does stop document reading: the document gate reads the receipt, not the switch, so a revocation wins over a switch left on. Turning the switch on again mints a fresh `ai_extraction` receipt through PATCH /api/auth/me/documents-auto-ai-read. A client offering both controls should show them as the two separate things they are.\n" +
"\n" +
"When a revocation leaves no receipt covering the analysis (`ai_insights_only` or `ai_full`), the same transaction deletes the text a model wrote under it that can be written again: per-metric status notes, the cached briefing, model-written period narratives, reaction lines and workout paragraphs. Coach conversations, facts and plans and document summaries are the person's records and stay. A deletion is audit-logged with counts only (`consent.ai.purge`).",
requestParams: {
query: z.object({
kind: consentKindEnum
+2 -2
View File
@@ -709,7 +709,7 @@ const glucoseUnitResponse = z
const documentsAutoAiReadPatchRequest = documentsAutoAiReadPatchSchema.meta({
id: "DocumentsAutoAiReadPatchRequest",
description:
"Turn automatic AI reading of newly uploaded documents on or off. Turning it on also mints an `ai_full` consent receipt and schedules a catch-up over the existing vault.",
"Turn automatic AI reading of newly uploaded documents on or off. Turning it on also mints an `ai_extraction` consent receipt and schedules a catch-up over the existing vault.",
});
const documentsAutoAiReadResponse = z
@@ -1820,7 +1820,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "Set the automatic document-reading opt-in",
description:
"Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user.\n\n" +
"Turning it ON does two further things a caller should expect. It is itself the standing consent act, so the write appends an `ai_full` `ConsentReceipt` — the same receipt POST /api/consent/ai/web mints, and one DELETE /api/consent/ai/latest revokes without touching this flag. And a genuine OFF→ON flip schedules a bounded catch-up over the documents already in the vault, because the summary job is enqueued at upload time and would otherwise only ever apply to future uploads. The catch-up is fire-and-forget and re-runs every consent and budget gate per document.\n\n" +
"Turning it ON does two further things a caller should expect. It is the consent act for reading documents, so the write appends an `ai_extraction` `ConsentReceipt`: the narrow kind that covers documents, lab report scans and typed medication text and nothing else (not the Coach, not the analysis). DELETE /api/consent/ai/latest revokes it without touching this flag, and the revocation wins: document reading stops even while the flag stays on. And a genuine OFF→ON flip schedules a bounded catch-up over the documents already in the vault, because the summary job is enqueued at upload time and would otherwise only ever apply to future uploads. The catch-up is fire-and-forget and re-runs every consent and budget gate per document.\n\n" +
"The body cap here is 1 KB, far tighter than the 64 KB its siblings allow — a payload above it is refused with 413 rather than parsed.",
requestBody: {
required: true,
+7 -3
View File
@@ -4,11 +4,14 @@ import { z } from "zod/v4";
* v1.4.40 SB-10 — AI consent receipts (App-Store Guideline 5.1.2(i) +
* GDPR Art. 7 audit trail).
*
* The discriminator covers the three consent surfaces the iOS client
* collects independently:
* The discriminator covers the consent surfaces collected independently:
* - `ai_full` full assistant + on-device personalisation
* - `ai_insights_only` read-only Insights generation (no Coach)
* - `ai_coach` chat-style Coach access
* - `ai_extraction` reading documents, lab report scans and typed
* medication text with an external model (minted by
* the document auto-read toggle; it satisfies neither
* the Coach nor the analysis)
*
* Keeping each surface as its own row means the legal team can prove
* which exact scope the user agreed to without parsing the signed
@@ -20,6 +23,7 @@ export const consentKindEnum = z.enum([
"ai_full",
"ai_insights_only",
"ai_coach",
"ai_extraction",
]);
export type ConsentKind = z.infer<typeof consentKindEnum>;
@@ -73,7 +77,7 @@ export type WebConsentGrantBodyInput = z.input<typeof webConsentGrantBody>;
*
* Omitting `kind` on GET returns the latest active receipt per kind
* (a `Record<ConsentKind, …>` shape). Omitting it on DELETE revokes
* the latest receipt across all three kinds — handy for the iOS
* the latest receipt across every kind — handy for the iOS
* "deaktivieren alle KI-Funktionen" master toggle.
*/
export const consentLatestQuery = z.object({