mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
feat(consent): CRUD helper for AI consent receipts
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
55fefb1d72
commit
8692a97e3f
3 files changed
+288
No files matched your search
@@ -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<Record<string, unknown>> = {}) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<ConsentReceipt> {
|
||||
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<ConsentReceipt | null> {
|
||||
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<Partial<Record<ConsentKind, ConsentReceipt>>> {
|
||||
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<Record<ConsentKind, ConsentReceipt>> = {};
|
||||
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<ConsentReceipt | null> {
|
||||
const latest = await latestActiveReceipt(userId, kind);
|
||||
if (!latest) return null;
|
||||
return prisma.consentReceipt.update({
|
||||
where: { id: latest.id },
|
||||
data: { revokedAt: now },
|
||||
});
|
||||
}
|
||||
@@ -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<typeof consentKindEnum>;
|
||||
|
||||
/**
|
||||
* 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<typeof consentPostBody>;
|
||||
|
||||
/**
|
||||
* Query schema for both `/api/consent/ai/latest` GET and DELETE.
|
||||
*
|
||||
* 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
|
||||
* "deaktivieren alle KI-Funktionen" master toggle.
|
||||
*/
|
||||
export const consentLatestQuery = z.object({
|
||||
kind: consentKindEnum.optional(),
|
||||
});
|
||||
|
||||
export type ConsentLatestQuery = z.infer<typeof consentLatestQuery>;
|
||||
Reference in new issue
Block a user