diff --git a/src/app/api/consent/ai/latest/route.ts b/src/app/api/consent/ai/latest/route.ts new file mode 100644 index 000000000..f3719d4cb --- /dev/null +++ b/src/app/api/consent/ai/latest/route.ts @@ -0,0 +1,143 @@ +/** + * v1.4.40 SB-10 — AI consent latest-receipt reader + revoke endpoint. + * + * GET /api/consent/ai/latest?kind=ai_full + * Returns the latest non-revoked receipt for that kind, or null. + * + * GET /api/consent/ai/latest + * Returns the latest non-revoked receipt per kind, keyed by kind. + * + * DELETE /api/consent/ai/latest?kind=ai_full + * Marks the latest active receipt for that kind as revoked. + * + * DELETE /api/consent/ai/latest + * Master toggle — revokes the latest active receipt across every + * consent kind. Returns the list of revoked rows. + */ +import { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + consentKindEnum, + consentLatestQuery, + type ConsentKind, +} from "@/lib/validations/consent"; +import { + latestActiveReceipt, + latestActiveReceiptsByKind, + revokeLatest, +} from "@/lib/consent/receipts"; +import { serialiseReceipt } from "../route"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const parsed = consentLatestQuery.safeParse( + Object.fromEntries(request.nextUrl.searchParams), + ); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 400); + } + + const { kind } = parsed.data; + + if (kind) { + const receipt = await latestActiveReceipt(user.id, kind); + annotate({ + action: { name: "consent.ai.latest" }, + meta: { kind, found: receipt != null }, + }); + return apiSuccess({ + kind, + receipt: receipt ? serialiseReceipt(receipt) : null, + }); + } + + const byKind = await latestActiveReceiptsByKind(user.id); + // Map each enum value to either the serialised row or `null` so the + // client always sees the full keyspace and never has to guess + // whether a missing key means "not granted" or "schema gap". + const result: Record | null> = { + ai_full: byKind.ai_full ? serialiseReceipt(byKind.ai_full) : null, + ai_insights_only: byKind.ai_insights_only + ? serialiseReceipt(byKind.ai_insights_only) + : null, + ai_coach: byKind.ai_coach ? serialiseReceipt(byKind.ai_coach) : null, + }; + + annotate({ + action: { name: "consent.ai.latest" }, + meta: { + kinds_active: Object.entries(result) + .filter(([, v]) => v != null) + .map(([k]) => k), + }, + }); + + return apiSuccess(result); +}); + +export const DELETE = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const parsed = consentLatestQuery.safeParse( + Object.fromEntries(request.nextUrl.searchParams), + ); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 400); + } + + 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 }); + } + auditLog("consent.ai.revoke", { + userId: user.id, + details: { kind, receiptId: revoked.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; + }> = []; + 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(() => {}); + } + } + + annotate({ + action: { name: "consent.ai.revoke" }, + meta: { + kinds_revoked: revokedRows.map((r) => r.kind), + count: revokedRows.length, + }, + }); + + return apiSuccess({ revoked: revokedRows }); +}); diff --git a/src/app/api/consent/ai/route.ts b/src/app/api/consent/ai/route.ts new file mode 100644 index 000000000..39d25faf3 --- /dev/null +++ b/src/app/api/consent/ai/route.ts @@ -0,0 +1,84 @@ +/** + * v1.4.40 SB-10 — AI consent receipts. + * + * POST /api/consent/ai + * Body: { kind, artefact, signedAt } + * Persists a fresh consent receipt. Returns the inserted row. + * + * Reader + revoke handlers live in `./latest/route.ts`. + */ +import { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess, safeJson } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { consentPostBody } from "@/lib/validations/consent"; +import { createReceipt } from "@/lib/consent/receipts"; + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const { data: body, error } = await safeJson(request); + if (error) return error; + + const parsed = consentPostBody.safeParse(body); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 400); + } + + const { kind, artefact, signedAt } = parsed.data; + const receipt = await createReceipt(user.id, kind, artefact, signedAt); + + // Audit trail — separate from the row itself so the legal team can + // reconstruct "who minted this and when" even if the receipts table + // is rebuilt from a backup. Fire-and-forget; audit failures must + // never bubble into a user-facing 500. + auditLog("consent.ai.grant", { + userId: user.id, + details: { kind, receiptId: receipt.id }, + }).catch(() => {}); + + annotate({ + action: { name: "consent.ai.grant" }, + meta: { kind, receiptId: receipt.id }, + }); + + return apiSuccess({ + id: receipt.id, + receipt: serialiseReceipt(receipt), + }); +}); + +interface SerialisedReceipt { + id: string; + userId: string; + kind: string; + signedAt: string; + revokedAt: string | null; + createdAt: string; +} + +/** + * Public projection of a `ConsentReceipt`. We deliberately strip + * `artefact` from the API response — the artefact is large, opaque, + * and only useful at audit time (read directly from the DB by an + * operator). Echoing it on every grant would balloon the payload and + * leak the signed token over network paths it doesn't need to cross. + */ +export function serialiseReceipt(receipt: { + id: string; + userId: string; + kind: string; + signedAt: Date; + revokedAt: Date | null; + createdAt: Date; +}): SerialisedReceipt { + return { + id: receipt.id, + userId: receipt.userId, + kind: receipt.kind, + signedAt: receipt.signedAt.toISOString(), + revokedAt: receipt.revokedAt?.toISOString() ?? null, + createdAt: receipt.createdAt.toISOString(), + }; +}