From 8692a97e3fbbe4e218be63e30cd33639ea220438 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 21 May 2026 11:48:56 +0200 Subject: [PATCH] feat(consent): CRUD helper for AI consent receipts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the persistence layer for the AI consent audit trail required by App-Store Guideline 5.1.2(i) and GDPR Art. 7: - src/lib/validations/consent.ts — Zod schemas with a three-kind discriminator (ai_full / ai_insights_only / ai_coach) and a 64 KB cap on the opaque artefact so a misbehaving client can't fill the audit table with multi-megabyte rows. - src/lib/consent/receipts.ts — append-only helpers: createReceipt, latestActiveReceipt, latestActiveReceiptsByKind, revokeLatest. Revoke flips a single field on the existing row; a re-grant always mints a fresh row, keeping the historical chain intact for legal review. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/consent/__tests__/receipts.test.ts | 123 +++++++++++++++++++++ src/lib/consent/receipts.ts | 105 ++++++++++++++++++ src/lib/validations/consent.ts | 60 ++++++++++ 3 files changed, 288 insertions(+) create mode 100644 src/lib/consent/__tests__/receipts.test.ts create mode 100644 src/lib/consent/receipts.ts create mode 100644 src/lib/validations/consent.ts diff --git a/src/lib/consent/__tests__/receipts.test.ts b/src/lib/consent/__tests__/receipts.test.ts new file mode 100644 index 000000000..85cee22a8 --- /dev/null +++ b/src/lib/consent/__tests__/receipts.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + consentReceipt: { + create: vi.fn(), + findFirst: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + }, + }, +})); + +import { prisma } from "@/lib/db"; +import { + createReceipt, + latestActiveReceipt, + latestActiveReceiptsByKind, + revokeLatest, +} from "../receipts"; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +function row(overrides: Partial> = {}) { + return { + id: "rcpt-1", + userId: "user-1", + kind: "ai_full", + artefact: "PDF…", + signedAt: new Date("2026-05-18T10:00:00.000Z"), + revokedAt: null, + createdAt: new Date("2026-05-18T10:00:01.000Z"), + ...overrides, + }; +} + +describe("createReceipt", () => { + it("inserts a row scoped to (userId, kind) with the artefact + signedAt", async () => { + vi.mocked(prisma.consentReceipt.create).mockResolvedValue(row() as never); + + const signedAt = new Date("2026-05-18T10:00:00.000Z"); + const result = await createReceipt("user-1", "ai_full", "PDF…", signedAt); + + expect(prisma.consentReceipt.create).toHaveBeenCalledWith({ + data: { + userId: "user-1", + kind: "ai_full", + artefact: "PDF…", + signedAt, + }, + }); + expect(result.id).toBe("rcpt-1"); + }); +}); + +describe("latestActiveReceipt", () => { + it("filters by user + kind + revokedAt:null and orders by createdAt desc", async () => { + vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(row() as never); + + const result = await latestActiveReceipt("user-1", "ai_full"); + + expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith({ + where: { userId: "user-1", kind: "ai_full", revokedAt: null }, + orderBy: { createdAt: "desc" }, + }); + expect(result?.id).toBe("rcpt-1"); + }); + + it("returns null when no active receipt exists", async () => { + vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null); + + const result = await latestActiveReceipt("user-1", "ai_full"); + + expect(result).toBeNull(); + }); +}); + +describe("latestActiveReceiptsByKind", () => { + it("returns the freshest row per kind (first-wins over createdAt desc)", async () => { + vi.mocked(prisma.consentReceipt.findMany).mockResolvedValue([ + row({ id: "fresh-coach", kind: "ai_coach" }), + row({ id: "fresh-full", kind: "ai_full" }), + // Older `ai_full` row — must be ignored because we already saw + // the freshest one above (the mock list is pre-sorted desc). + row({ id: "older-full", kind: "ai_full" }), + ] as never); + + const result = await latestActiveReceiptsByKind("user-1"); + + expect(result.ai_full?.id).toBe("fresh-full"); + expect(result.ai_coach?.id).toBe("fresh-coach"); + expect(result.ai_insights_only).toBeUndefined(); + }); +}); + +describe("revokeLatest", () => { + it("flips revokedAt on the latest active row and returns the updated row", async () => { + vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(row() as never); + vi.mocked(prisma.consentReceipt.update).mockResolvedValue( + row({ revokedAt: new Date("2026-05-18T11:00:00.000Z") }) as never, + ); + + const now = new Date("2026-05-18T11:00:00.000Z"); + const result = await revokeLatest("user-1", "ai_full", now); + + expect(prisma.consentReceipt.update).toHaveBeenCalledWith({ + where: { id: "rcpt-1" }, + data: { revokedAt: now }, + }); + expect(result?.revokedAt).toEqual(now); + }); + + it("returns null without writing when no active receipt exists", async () => { + vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null); + + const result = await revokeLatest("user-1", "ai_full"); + + expect(result).toBeNull(); + expect(prisma.consentReceipt.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/consent/receipts.ts b/src/lib/consent/receipts.ts new file mode 100644 index 000000000..4883f4700 --- /dev/null +++ b/src/lib/consent/receipts.ts @@ -0,0 +1,105 @@ +/** + * 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 + * historical chain stays intact for legal review. + * + * Source of truth for "is AI active right now?" is + * `latestActiveReceipt(userId, kind)` returning non-null. Reader + * routes never inspect older rows. + */ +import { prisma } from "@/lib/db"; +import type { ConsentReceiptModel } from "@/generated/prisma/models/ConsentReceipt"; +import type { ConsentKind } from "@/lib/validations/consent"; + +export type ConsentReceipt = ConsentReceiptModel; + +/** + * Insert a fresh consent receipt for (userId, kind). Always appends; + * never collides with an earlier row. The caller has already + * Zod-validated `artefact` (≤ 64 KB) and `signedAt` (ISO-8601). + */ +export async function createReceipt( + userId: string, + kind: ConsentKind, + artefact: string, + signedAt: Date, +): Promise { + return prisma.consentReceipt.create({ + data: { + userId, + kind, + artefact, + signedAt, + }, + }); +} + +/** + * Return the most recent non-revoked receipt for (userId, kind), or + * `null` if none exists. Ordering is by `createdAt DESC` — the same + * index direction declared on the schema, so the planner walks the + * compound index forward without a sort. + * + * "Non-revoked" = `revokedAt IS NULL`. A revoked row never resurrects; + * a new grant must mint a new row. + */ +export async function latestActiveReceipt( + userId: string, + kind: ConsentKind, +): Promise { + return prisma.consentReceipt.findFirst({ + where: { userId, kind, revokedAt: null }, + orderBy: { createdAt: "desc" }, + }); +} + +/** + * Return the latest non-revoked receipt across every kind for the + * user, keyed by kind. Used by `GET /api/consent/ai/latest` when the + * caller omits the `?kind` query param. + */ +export async function latestActiveReceiptsByKind( + userId: string, +): Promise>> { + const rows = await prisma.consentReceipt.findMany({ + where: { userId, revokedAt: null }, + orderBy: { createdAt: "desc" }, + }); + // First-wins reduce over `createdAt DESC`. `findMany` returns every + // historical grant for the user; we keep the freshest per kind. + const out: Partial> = {}; + for (const r of rows) { + const k = r.kind as ConsentKind; + if (!(k in out)) out[k] = r; + } + return out; +} + +/** + * Mark the latest active receipt for (userId, kind) as revoked. + * Returns the updated row, or `null` if no active receipt exists. + * + * Implementation note: a two-step "read latest → update by id" is + * race-tolerant for the single-user revoke flow (iOS toggle is a + * per-device action; no concurrent revoke from a second device is + * plausible inside the same millisecond). If two devices race we end + * up writing `revokedAt` to the same row twice with identical + * timestamps — semantically idempotent. + */ +export async function revokeLatest( + userId: string, + kind: ConsentKind, + now: Date = new Date(), +): Promise { + const latest = await latestActiveReceipt(userId, kind); + if (!latest) return null; + return prisma.consentReceipt.update({ + where: { id: latest.id }, + data: { revokedAt: now }, + }); +} diff --git a/src/lib/validations/consent.ts b/src/lib/validations/consent.ts new file mode 100644 index 000000000..bc03788cc --- /dev/null +++ b/src/lib/validations/consent.ts @@ -0,0 +1,60 @@ +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: + * - `ai_full` full assistant + on-device personalisation + * - `ai_insights_only` read-only Insights generation (no Coach) + * - `ai_coach` chat-style Coach access + * + * Keeping each surface as its own row means the legal team can prove + * which exact scope the user agreed to without parsing the signed + * artefact server-side. The server stays opaque to the artefact's + * format — we accept either a base64-encoded PDF or a signed JWT, the + * audit trail *is* the storage plus the `signedAt` timestamp. + */ +export const consentKindEnum = z.enum([ + "ai_full", + "ai_insights_only", + "ai_coach", +]); + +export type ConsentKind = z.infer; + +/** + * 64 KB upper bound on the artefact. A signed PDF receipt sits well + * under 32 KB; a signed JWT is a few hundred bytes. The cap exists so + * a misbehaving client (or a malicious one) can't fill the audit table + * with multi-megabyte rows. + */ +const ARTEFACT_MAX_BYTES = 64 * 1024; + +export const consentPostBody = z.object({ + kind: consentKindEnum, + artefact: z + .string() + .min(1, "artefact must not be empty") + .max(ARTEFACT_MAX_BYTES, "artefact exceeds 64 KB cap"), + signedAt: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)), +}); + +export type ConsentPostBody = z.infer; + +/** + * Query schema for both `/api/consent/ai/latest` GET and DELETE. + * + * Omitting `kind` on GET returns the latest active receipt per kind + * (a `Record` shape). Omitting it on DELETE revokes + * the latest receipt across all three kinds — handy for the iOS + * "deaktivieren alle KI-Funktionen" master toggle. + */ +export const consentLatestQuery = z.object({ + kind: consentKindEnum.optional(), +}); + +export type ConsentLatestQuery = z.infer;