fix(compliance): port DSR/consent/audit/retention audit fixes

Ports the upstream compliance-core audit fixes onto the kept core-dsr,
core-consent, core-audit, core-cms and core-shared packages:

- core-dsr: scope DSR operations to the caller's own subject (A11);
  include the subject's audit trail in exports; resolve the per-request
  binding from ctx instead of a throwing singleton proxy.
- core-consent: build the consent router from the shared superjson
  transformer (A10); merge per-category on persist instead of replacing;
  validate migrated categories against an allow-list.
- core-audit: keyed 128-bit pseudonyms + salted DSR certificate; add the
  audit-logs collection and the req-scoped GDPR audit-erasure afterDelete
  hook (A6).
- core-shared: grace-purge soft-deleted rows via a retention-purge task +
  tombstone field and boot registration (A2/A3); add the
  require-authenticated tRPC helper; derive clientIp + resolve the session
  user in createTrpcContext (B2/A11).
- core-cms: register audit-logs, wire the audit-erasure hook and
  retention-purge tasks; adapted to the clean-slate collection set
  (users only — no workspaces feature on this branch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
2026-07-13 06:07:25 +02:00
parent 4e4cd5fa7c
commit f2f24f7bfa
42 changed files with 2065 additions and 129 deletions

View File

@@ -0,0 +1,89 @@
import { describe, it, expect } from "vitest";
import { clientIpFromHeaders, createTrpcContext } from "@/trpc/context";
describe("clientIpFromHeaders", () => {
it("takes the first x-forwarded-for hop", () => {
const headers = new Headers({
"x-forwarded-for": "203.0.113.7, 10.0.0.1, 10.0.0.2",
});
expect(clientIpFromHeaders(headers)).toBe("203.0.113.7");
});
it("trims whitespace around the first hop", () => {
const headers = new Headers({
"x-forwarded-for": " 203.0.113.7 , 10.0.0.1",
});
expect(clientIpFromHeaders(headers)).toBe("203.0.113.7");
});
it("falls back to x-real-ip when x-forwarded-for is absent", () => {
const headers = new Headers({ "x-real-ip": "198.51.100.4" });
expect(clientIpFromHeaders(headers)).toBe("198.51.100.4");
});
it("returns undefined when neither header is present", () => {
expect(clientIpFromHeaders(new Headers())).toBeUndefined();
});
it("returns undefined for empty header values", () => {
const headers = new Headers({ "x-forwarded-for": " ", "x-real-ip": "" });
expect(clientIpFromHeaders(headers)).toBeUndefined();
});
});
describe("createTrpcContext", () => {
it("attaches the derived clientIp from the request", async () => {
const req = new Request("https://example.test/api/trpc", {
headers: { "x-forwarded-for": "203.0.113.7" },
});
await expect(createTrpcContext(req)).resolves.toEqual({
clientIp: "203.0.113.7",
});
});
it("yields an undefined clientIp without a request", async () => {
await expect(createTrpcContext()).resolves.toEqual({
clientIp: undefined,
});
});
it("attaches the resolved user and mirrors userId (A11)", async () => {
const req = new Request("https://example.test/api/trpc");
const ctx = await createTrpcContext(req, {
resolveUser: async () => ({ id: "user-1", roles: ["admin"] }),
});
expect(ctx.user).toEqual({ id: "user-1", roles: ["admin"] });
expect(ctx.userId).toBe("user-1");
});
it("treats a null resolver result as anonymous", async () => {
const req = new Request("https://example.test/api/trpc");
const ctx = await createTrpcContext(req, {
resolveUser: async () => null,
});
expect(ctx.user).toBeUndefined();
expect(ctx.userId).toBeUndefined();
});
it("treats a throwing resolver as anonymous instead of failing", async () => {
const req = new Request("https://example.test/api/trpc");
const ctx = await createTrpcContext(req, {
resolveUser: async () => {
throw new Error("expired session");
},
});
expect(ctx.user).toBeUndefined();
expect(ctx.clientIp).toBeUndefined();
});
it("does not invoke the resolver without a request", async () => {
let called = false;
await createTrpcContext(undefined, {
resolveUser: async () => {
called = true;
return null;
},
});
expect(called).toBe(false);
});
});