feat(consent): POST + GET + DELETE endpoints at /api/consent/ai

Wires the iOS onboarding + Settings "AI deaktivieren" flow to the
persistence layer landed in the previous commit:

- POST /api/consent/ai — accepts { kind, artefact, signedAt }, persists
  a fresh receipt, returns id + serialised row (artefact stripped from
  the response so the signed token doesn't echo over network paths it
  doesn't need to cross).

- GET /api/consent/ai/latest?kind=… — latest non-revoked receipt for
  the given kind. Omit ?kind to get the full three-kind keyspace
  keyed by kind, with null slots for kinds the user hasn't granted.

- DELETE /api/consent/ai/latest?kind=… — flips revokedAt on the
  latest active row. Idempotent: a no-op returns 200 with null receipt
  rather than 404 so the iOS toggle can hammer the endpoint without
  spurious error states. Omit ?kind for the master "deaktivieren alle
  KI-Funktionen" sweep across every kind.

Both grant + revoke write to auditLog so the trail is reconstructible
even if the receipts table is rebuilt from a backup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-21 11:49:11 +02:00
1 parent 8692a97e3f
commit d6809e3d54
2 files changed
+227

No files matched your search

+143
View File
@@ -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<ConsentKind, ReturnType<typeof serialiseReceipt> | 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<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(() => {});
}
}
annotate({
action: { name: "consent.ai.revoke" },
meta: {
kinds_revoked: revokedRows.map((r) => r.kind),
count: revokedRows.length,
},
});
return apiSuccess({ revoked: revokedRows });
});
+84
View File
@@ -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(),
};
}