# Audit Logging & DPA Compliance Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Ship `@repo/core-audit` as the 5th optional core package, with `AuditLogProtocol` + `AuditEntry` type in must-have `core-shared`. Provides DPA-compliant audit logging: closed action enum (VIEW/CREATE/UPDATE/DELETE/EXPORT/PERMISSION_CHANGE), append-only Payload collection, structured JSON stdout sink, OTel trace correlation, GDPR erasure (pseudonymize/delete), and Payload `afterRead` + `afterDelete` hook factories. **Architecture:** Six sequential phases. Phase 1 ships the universal kernel in core-shared (no impl). Phase 2 ships the optional package with all impls + binder. Phase 3 adds GDPR erasure plumbing (admin tRPC + Payload hook factory). Phase 4 adds the OTel correlation bridge. Phase 5 adds the `afterRead` hook factory for opt-in automatic VIEW capture. Phase 6 publishes ADR-018, the audit-and-compliance guide, and the generator template (so `pnpm turbo gen core-package audit` becomes available). **Tech Stack:** TypeScript, Node 22, Vitest, Payload CMS, tRPC, OpenTelemetry API (`@opentelemetry/api`), Node crypto (sha256). **Spec:** `docs/superpowers/specs/2026-05-11-audit-and-compliance-design.md` — read first, especially §4 (Phase 1), §5 (Phase 2 package + impls), §6 (erasure), §7 (OTel bridge), §8 (VIEW capture). **Phase numbering:** Plan uses `Phase 0 (Read first)` as preamble; work phases 1-6 map 1:1 to spec phases. --- ## Phase 0 — Read first - [ ] **Step 1: Read the spec end-to-end** Open `docs/superpowers/specs/2026-05-11-audit-and-compliance-design.md`. Pay close attention to §2 (12 decision points), §4 (`AuditEntry` type), §5.4 (impls), §6 (erasure), §7 (OTel bridge), §8 (VIEW capture). - [ ] **Step 2: Read the DPA compliance reference** The user-provided compliance doc in the spec's §1 (and the conversation that produced this plan). The 6 required actions, the 4 required fields (who/what/when/from_where), the immutability requirements, and the "what NOT to log" list are load-bearing. - [ ] **Step 3: Skim existing optional package patterns** Read the structure of the four existing optional packages in their template form: `turbo/generators/templates/core-package/{realtime,events,trpc,ui}/`. `@repo/core-audit` follows the same shape (package.json, eslint.config.js, tsconfig.json, turbo.json, vitest.config.ts, AGENTS.md, src/). - [ ] **Step 4: Skim existing core-shared protocol pattern** Read `packages/core-shared/src/di/bind-protocols.ts` end-to-end. `AuditLogProtocol` is added alongside the existing four (EventBus, RealtimeBroadcaster, RealtimeRegistry, Metrics). Same pattern: minimal surface, optional package extends with full interface. - [ ] **Step 5: Skim OTel `currentTraceId` callsite** The `currentTraceId()` helper used in Phase 4 lives in `packages/core-shared/src/instrumentation/otel/`. The OTel migration (ADR-017) wired the active-span access via `@opentelemetry/api`'s `trace.getActiveSpan()`. Audit just reads from it. --- ## Phase 1 — Protocol + AuditEntry type in core-shared **Goal:** Ship the universal kernel surface. `AuditLogProtocol` + `AuditEntry` type + `truncateIp` helper exist in `core-shared`. `BindContext.auditLog?` field added. No impl. **Files touched:** - Create: `packages/core-shared/src/audit/audit-entry.ts` - Create: `packages/core-shared/src/audit/audit-entry.test.ts` - Create: `packages/core-shared/src/audit/truncate-ip.ts` - Create: `packages/core-shared/src/audit/truncate-ip.test.ts` - Create: `packages/core-shared/src/audit/index.ts` - Modify: `packages/core-shared/src/di/bind-protocols.ts` - Modify: `packages/core-shared/src/di/bind-context.ts` - Modify: `packages/core-shared/src/index.ts` - Modify: `packages/core-shared/package.json` ### Task 1.1: AuditEntry type (TDD) **Files:** - Create: `packages/core-shared/src/audit/audit-entry.ts` - Create: `packages/core-shared/src/audit/audit-entry.test.ts` - [ ] **Step 1: Write the failing type-level test** Create `packages/core-shared/src/audit/audit-entry.test.ts`: ```ts import { describe, it, expectTypeOf } from "vitest"; import type { AuditEntry, AuditAction, AuditFrom } from "./audit-entry"; describe("AuditAction", () => { it("is a closed enum of 6 values", () => { expectTypeOf().toEqualTypeOf< "VIEW" | "CREATE" | "UPDATE" | "DELETE" | "EXPORT" | "PERMISSION_CHANGE" >(); }); }); describe("AuditFrom", () => { it("requires ipTruncated and userAgent", () => { expectTypeOf().toEqualTypeOf<{ ipTruncated: string; userAgent: string }>(); }); }); describe("AuditEntry", () => { it("requires the WHO/WHAT/WHEN/SCOPE/FROM/PII/OUTCOME fields", () => { const entry: AuditEntry = { actorId: "user_1", actorType: "user", actorRoles: ["admin"], action: "VIEW", resource: { type: "articles" }, at: new Date(), scope: { feature: "blog", environment: "test", tenant: "default" }, from: { ipTruncated: "10.0.0.0", userAgent: "test" }, containsPii: false, outcome: "success", }; expectTypeOf(entry).toMatchTypeOf(); }); it("makes optional fields actually optional", () => { type Entry = AuditEntry; type OptionalKeys = "changedFields" | "reason" | "correlationId" | "requestId" | "piiCategories" | "errorCode"; expectTypeOf>().toEqualTypeOf>>(); }); }); ``` - [ ] **Step 2: Run test → FAIL** ```bash pnpm --filter @repo/core-shared test audit-entry.test ``` Expected: FAIL — module not found. - [ ] **Step 3: Implement** Create `packages/core-shared/src/audit/audit-entry.ts`: ```ts /** * Closed enum of audited actions per DPA. New action types require an * explicit type bump — compliance auditors sample by enum value. */ export type AuditAction = | "VIEW" | "CREATE" | "UPDATE" | "DELETE" | "EXPORT" | "PERMISSION_CHANGE"; /** * `from_where` fragment per DPA. IP truncated to /24 (IPv4) or /48 (IPv6) * before storage; use `truncateIp(rawIp)` to enforce. For non-HTTP contexts, * sentinels are conventional: `{ ipTruncated: "system", userAgent: "background-job" }`. */ export type AuditFrom = { ipTruncated: string; userAgent: string; }; /** * Universal audit entry. By construction, this type has NO `payload`/`body`/ * `oldValue`/`newValue` fields — the DPA "what NOT to log" exclusion list is * enforced by the type itself. UPDATE actions capture field NAMES only * (`changedFields`); per-collection value capture is a separate API (out of * scope for v1). */ export type AuditEntry = { // WHO /** User id, or "system"/"service-{name}" for non-user actors. NEVER email or name (R36). */ actorId: string; actorType: "user" | "system" | "service"; /** Snapshot of actor's roles AT TIME OF ACTION — preserves historical state. */ actorRoles: string[]; // WHAT action: AuditAction; resource: { type: string; id?: string }; /** UPDATE only: names of fields that changed (NOT values — PII risk). */ changedFields?: string[]; // WHEN /** Server time. Sinks serialize as ISO 8601. */ at: Date; // SCOPE (where) scope: { feature: string; environment: string; /** Required field. Single-tenant projects use "default" as the sentinel. */ tenant: string; }; // WHY reason?: string; /** OTel trace ID. Auto-populated by `TraceIdEnrichingAuditLog` decorator at bind time. */ correlationId?: string; requestId?: string; // FROM (per DPA) from: AuditFrom; // PII CLASSIFICATION /** Caller MUST declare. Drives downstream retention/access policies. */ containsPii: boolean; /** * Free-form list. Conventions (suggested, not enforced): "email", "name", * "phone", "address", "ssn", "financial", "health". Free-form because * regulatory categories differ by jurisdiction. */ piiCategories?: string[]; // OUTCOME outcome: "success" | "denied" | "error"; errorCode?: string; }; ``` - [ ] **Step 4: Run test → PASS** ```bash pnpm --filter @repo/core-shared test audit-entry.test ``` Expected: PASS, 3 tests. - [ ] **Step 5: Commit** ```bash git add packages/core-shared/src/audit/audit-entry.ts packages/core-shared/src/audit/audit-entry.test.ts git commit -m "feat(core-shared): AuditEntry type with closed action enum + required tenant" ``` ### Task 1.2: truncateIp helper (TDD) **Files:** - Create: `packages/core-shared/src/audit/truncate-ip.ts` - Create: `packages/core-shared/src/audit/truncate-ip.test.ts` - [ ] **Step 1: Write failing tests** Create `packages/core-shared/src/audit/truncate-ip.test.ts`: ```ts import { describe, it, expect } from "vitest"; import { truncateIp } from "./truncate-ip"; describe("truncateIp", () => { describe("IPv4", () => { it("truncates to /24 (zeros the last octet)", () => { expect(truncateIp("192.168.1.42")).toBe("192.168.1.0"); expect(truncateIp("10.0.0.255")).toBe("10.0.0.0"); expect(truncateIp("8.8.8.8")).toBe("8.8.8.0"); }); it("throws on malformed input", () => { expect(() => truncateIp("192.168.1")).toThrow(/malformed IPv4/); expect(() => truncateIp("192.168.1.foo")).toThrow(/malformed IPv4/); expect(() => truncateIp("a.b.c.d")).toThrow(/malformed IPv4/); }); it("throws on empty string", () => { expect(() => truncateIp("")).toThrow(/malformed IPv4/); }); }); describe("IPv6", () => { it("truncates to /48 (keeps first 3 hextets)", () => { expect(truncateIp("2001:0db8:1234:5678:abcd:ef00:1234:5678")).toBe("2001:0db8:1234::"); expect(truncateIp("2001:0db8:abcd:1234::")).toBe("2001:0db8:abcd::"); }); it("lowercases hextets", () => { expect(truncateIp("2001:0DB8:ABCD:5678::")).toBe("2001:0db8:abcd::"); }); it("throws on too-few hextets", () => { expect(() => truncateIp("2001:0db8")).toThrow(/malformed IPv6/); }); }); }); ``` - [ ] **Step 2: Run → FAIL** ```bash pnpm --filter @repo/core-shared test truncate-ip.test ``` - [ ] **Step 3: Implement** Create `packages/core-shared/src/audit/truncate-ip.ts`: ```ts /** * Truncates an IP address per DPA: * IPv4 → /24 ("192.168.1.42" → "192.168.1.0") * IPv6 → /48 ("2001:0db8:1234:5678:..." → "2001:0db8:1234::") * * Throws on malformed input rather than silently returning the raw value — * compliance regimes prefer hard failures over partial scrubbing. */ export function truncateIp(raw: string): string { if (raw.includes(":")) { // IPv6: keep first 3 hextets (48 bits) const parts = raw.toLowerCase().split(":").filter((p) => p !== ""); if (parts.length < 3) { throw new Error(`truncateIp: malformed IPv6 address "${raw}"`); } return `${parts[0]}:${parts[1]}:${parts[2]}::`; } // IPv4: keep first 3 octets (24 bits) const parts = raw.split("."); if ( parts.length !== 4 || parts.some((p) => p === "" || isNaN(Number(p)) || !/^\d+$/.test(p)) ) { throw new Error(`truncateIp: malformed IPv4 address "${raw}"`); } return `${parts[0]}.${parts[1]}.${parts[2]}.0`; } ``` - [ ] **Step 4: Run → PASS** ```bash pnpm --filter @repo/core-shared test truncate-ip.test ``` - [ ] **Step 5: Commit** ```bash git add packages/core-shared/src/audit/truncate-ip.ts packages/core-shared/src/audit/truncate-ip.test.ts git commit -m "feat(core-shared): truncateIp helper (/24 IPv4, /48 IPv6) per DPA" ``` ### Task 1.3: AuditLogProtocol + barrel + subpath export **Files:** - Modify: `packages/core-shared/src/di/bind-protocols.ts` - Create: `packages/core-shared/src/audit/index.ts` - Modify: `packages/core-shared/src/index.ts` - Modify: `packages/core-shared/package.json` - [ ] **Step 1: Add AuditLogProtocol to bind-protocols.ts** Append to `packages/core-shared/src/di/bind-protocols.ts`: ```ts import type { AuditEntry } from "../audit/audit-entry"; /** * Minimal audit-log protocol surface. `IAuditLog` (in optional `@repo/core-audit`) * extends this — typechecks fail if narrowed below. Feature binders that * receive `ctx.auditLog` see only this protocol type. * * `eraseSubject` is NOT on the protocol — it's a privileged op exposed only * on the full `IAuditLog` interface in the optional package. */ export type AuditLogProtocol = { record(entry: AuditEntry): Promise; }; ``` (The import is OK because `audit/audit-entry.ts` is a sibling file in the same package.) - [ ] **Step 2: Create audit barrel** Create `packages/core-shared/src/audit/index.ts`: ```ts export type { AuditEntry, AuditAction, AuditFrom } from "./audit-entry"; export { truncateIp } from "./truncate-ip"; ``` - [ ] **Step 3: Add to core-shared root barrel** Modify `packages/core-shared/src/index.ts` to add a re-export. Find existing `export * from "./..."` lines and add: ```ts export * from "./audit"; ``` - [ ] **Step 4: Add subpath export** In `packages/core-shared/package.json`, add to `exports` (alphabetically among existing entries): ```json "./audit": "./src/audit/index.ts", ``` - [ ] **Step 5: Verify** ```bash pnpm --filter @repo/core-shared typecheck pnpm --filter @repo/core-shared test ``` Expected: PASS. - [ ] **Step 6: Commit** ```bash git add packages/core-shared/src/di/bind-protocols.ts \ packages/core-shared/src/audit/index.ts \ packages/core-shared/src/index.ts \ packages/core-shared/package.json git commit -m "feat(core-shared): AuditLogProtocol + ./audit subpath export" ``` ### Task 1.4: BindContext.auditLog? field (5th generic) **Files:** - Modify: `packages/core-shared/src/di/bind-context.ts` - [ ] **Step 1: Add 5th generic + field** Read the current `BindContext` declaration. Modify to add `Audit` as the 5th generic and `auditLog?: Audit` as the new field: ```ts import type { EventBusProtocol, RealtimeBroadcasterProtocol, RealtimeRegistryProtocol, MetricsProtocol, AuditLogProtocol, // <- new } from "./bind-protocols"; // BindContextBase unchanged export type BindContext< Bus extends EventBusProtocol = EventBusProtocol, Realtime extends RealtimeBroadcasterProtocol = RealtimeBroadcasterProtocol, RealtimeReg extends RealtimeRegistryProtocol = RealtimeRegistryProtocol, Metrics extends MetricsProtocol = MetricsProtocol, Audit extends AuditLogProtocol = AuditLogProtocol, // <- new > = BindContextBase & { bus?: Bus; queue?: IJobQueue; realtime?: Realtime; realtimeRegistry?: RealtimeReg; metrics?: Metrics; auditLog?: Audit; // <- new }; export type BindProductionContext< Bus extends EventBusProtocol = EventBusProtocol, Realtime extends RealtimeBroadcasterProtocol = RealtimeBroadcasterProtocol, RealtimeReg extends RealtimeRegistryProtocol = RealtimeRegistryProtocol, Metrics extends MetricsProtocol = MetricsProtocol, Audit extends AuditLogProtocol = AuditLogProtocol, // <- new > = BindContext & { config: SanitizedConfig; }; ``` - [ ] **Step 2: Verify backward compat** ```bash pnpm typecheck ``` Expected: clean. Existing 4-generic callers still resolve because the 5th generic has a default. - [ ] **Step 3: Commit** ```bash git add packages/core-shared/src/di/bind-context.ts git commit -m "feat(core-shared): BindContext.auditLog? field (5th generic)" ``` ### Task 1.5: Phase 1 verification gate - [ ] **Step 1: Run all gates** ```bash pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries ``` Expected: all green. No new lint warnings. (No commit; verification gate only.) --- ## Phase 2 — @repo/core-audit package with impls **Goal:** Ship the optional package with 4 impls (Noop, Payload, StdoutJson, MultiSink) + `RecordingAuditLog` in core-testing + `bindAudit` binder. Append-only Payload collection definition. **Files touched (new package scaffolding):** - Create: `packages/core-audit/{AGENTS.md, eslint.config.js, package.json, tsconfig.json, turbo.json, vitest.config.ts}` - Create: `packages/core-audit/src/{index.ts, audit-log.interface.ts, audit-logs-collection.ts}` - Create: `packages/core-audit/src/{noop-audit-log.ts, stdout-json-audit-log.ts, payload-audit-log.ts, multi-sink-audit-log.ts}` (+ tests each) - Create: `packages/core-audit/src/di/{bind-audit.ts, symbols.ts}` (+ bind-audit test) - Create: `packages/core-testing/src/instrumentation/recording-audit-log.ts` (+ test) - Modify: `packages/core-testing/src/instrumentation/index.ts` (re-export) - Modify: `apps/web-next/next.config.mjs` (`transpilePackages`) - Modify: `pnpm-lock.yaml` (after `pnpm install`) ### Task 2.1: Scaffold the package skeleton **Files:** All `packages/core-audit/` top-level files. - [ ] **Step 1: Inspect an existing optional package for reference** ```bash ls turbo/generators/templates/core-package/events/ cat turbo/generators/templates/core-package/events/package.json.hbs cat turbo/generators/templates/core-package/events/tsconfig.json.hbs cat turbo/generators/templates/core-package/events/turbo.json.hbs cat turbo/generators/templates/core-package/events/vitest.config.ts.hbs cat turbo/generators/templates/core-package/events/eslint.config.js.hbs ``` These are the canonical shapes. Mirror them for core-audit. - [ ] **Step 2: Create the directory + scaffolding** ```bash mkdir -p packages/core-audit/src/{di,integrations/api,hooks} ``` - [ ] **Step 3: package.json** Create `packages/core-audit/package.json`: ```json { "name": "@repo/core-audit", "version": "0.0.1", "private": true, "type": "module", "exports": { ".": "./src/index.ts", "./collection": "./src/audit-logs-collection.ts", "./di": "./src/di/bind-audit.ts", "./hooks": "./src/hooks/index.ts", "./api": "./src/integrations/api/router.ts" }, "scripts": { "build": "tsc --noEmit", "lint": "eslint .", "typecheck": "tsc --noEmit", "test": "vitest run" }, "dependencies": { "@repo/core-shared": "workspace:*", "@trpc/server": "^11.0.0", "zod": "^3.23.0" }, "peerDependencies": { "payload": "^3.0.0" }, "peerDependenciesMeta": { "payload": { "optional": true } }, "devDependencies": { "@repo/core-eslint": "workspace:*", "@repo/core-testing": "workspace:*", "@repo/core-typescript": "workspace:*", "inversify": "^6.2.0", "payload": "^3.14.0", "reflect-metadata": "^0.2.2", "typescript": "^5.8.0", "vitest": "^3.0.0" } } ``` - [ ] **Step 4: tsconfig.json** Create `packages/core-audit/tsconfig.json`: ```json { "extends": "@repo/core-typescript/base.json", "compilerOptions": { "rootDir": ".", "baseUrl": ".", "paths": { "@/*": ["./src/*"] } }, "include": ["**/*.ts"], "exclude": ["node_modules", "dist"] } ``` - [ ] **Step 5: turbo.json** Create `packages/core-audit/turbo.json`: ```json { "extends": ["//"], "tags": ["core"] } ``` - [ ] **Step 6: vitest.config.ts** Create `packages/core-audit/vitest.config.ts`: ```ts import { defineConfig } from "vitest/config"; import path from "node:path"; export default defineConfig({ resolve: { alias: { "@": path.resolve(__dirname, "./src") }, }, test: { setupFiles: ["@repo/core-testing/setup/no-instrumentation"], }, }); ``` - [ ] **Step 7: eslint.config.js** Create `packages/core-audit/eslint.config.js`: ```js import base from "@repo/core-eslint/base"; export default [...base]; ``` - [ ] **Step 8: AGENTS.md** Create `packages/core-audit/AGENTS.md`: ```markdown # @repo/core-audit Optional core package providing DPA-compliant audit logging. Scaffold via `pnpm turbo gen core-package audit`. ## Structure ``` src/ audit-log.interface.ts # IAuditLog extends AuditLogProtocol audit-logs-collection.ts # Payload collection (append-only) noop-audit-log.ts # NoopAuditLog payload-audit-log.ts # PayloadAuditLog (local cache impl) stdout-json-audit-log.ts # StdoutJsonAuditLog (log-shipper sink) multi-sink-audit-log.ts # MultiSinkAuditLog (fan-out wrapper) trace-id-enriching-audit-log.ts # OTel correlation decorator pseudonymize.ts # sha256-with-salt for GDPR pseudonymization di/bind-audit.ts # bindAudit binder integrations/api/router.ts # admin tRPC procedure hooks/ # Payload hook factories ``` ## Compliance posture - `AuditEntry` type (in `@repo/core-shared/audit`) has no `payload`/`body`/`oldValue`/`newValue` fields — type system enforces DPA "what NOT to log". - Append-only Payload collection (`update: () => false`); erasure uses `overrideAccess: true` for the privileged path. - `AUDIT_PSEUDONYM_SALT` env REQUIRED in production. Validated at bind time. See `docs/guides/audit-and-compliance.md` for the full guide. ``` - [ ] **Step 9: Install + verify** ```bash pnpm install pnpm --filter @repo/core-audit typecheck ``` Expected: no errors. The package exists but has no source files yet. - [ ] **Step 10: Add to transpilePackages** Modify `apps/web-next/next.config.mjs` — find the `transpilePackages` array and add `"@repo/core-audit"` (alphabetically among existing entries). - [ ] **Step 11: Commit** ```bash git add packages/core-audit/ apps/web-next/next.config.mjs pnpm-lock.yaml git commit -m "feat(core-audit): scaffold optional package (no impls yet)" ``` ### Task 2.2: IAuditLog interface + AUDIT_SYMBOLS **Files:** - Create: `packages/core-audit/src/audit-log.interface.ts` - Create: `packages/core-audit/src/di/symbols.ts` - [ ] **Step 1: Create the interface** Create `packages/core-audit/src/audit-log.interface.ts`: ```ts import type { AuditLogProtocol, AuditEntry } from "@repo/core-shared/audit"; /** * Full audit log interface. Extends the minimal `AuditLogProtocol` from * core-shared with the privileged `eraseSubject` op for GDPR erasure. * * Feature binders that receive `ctx.auditLog` see only `AuditLogProtocol` * (record). Admin-path code that needs erasure imports this full interface. * * The `extends` link forces typecheck failure if either side narrows below * the protocol surface — same safety net as IEventBus, IRealtimeBroadcaster, * IRealtimeHandlerRegistry, IMetrics. */ export interface IAuditLog extends AuditLogProtocol { // record(entry: AuditEntry): Promise — inherited from protocol eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise; } // Re-export AuditEntry for convenience (so consumers don't always need // to dual-import from @repo/core-shared/audit). export type { AuditEntry }; ``` - [ ] **Step 2: Create DI symbols** Create `packages/core-audit/src/di/symbols.ts`: ```ts export const AUDIT_SYMBOLS = { IAuditLog: Symbol.for("core-audit:IAuditLog"), } as const; ``` - [ ] **Step 3: Verify** ```bash pnpm --filter @repo/core-audit typecheck ``` - [ ] **Step 4: Commit** ```bash git add packages/core-audit/src/audit-log.interface.ts packages/core-audit/src/di/symbols.ts git commit -m "feat(core-audit): IAuditLog interface + AUDIT_SYMBOLS" ``` ### Task 2.3: NoopAuditLog (TDD) **Files:** - Create: `packages/core-audit/src/noop-audit-log.ts` (+ test) - [ ] **Step 1: Write failing test** Create `packages/core-audit/src/noop-audit-log.test.ts`: ```ts import { describe, it, expect } from "vitest"; import { NoopAuditLog } from "./noop-audit-log"; import type { AuditEntry } from "@repo/core-shared/audit"; describe("NoopAuditLog", () => { const sample: AuditEntry = { actorId: "user_1", actorType: "user", actorRoles: [], action: "VIEW", resource: { type: "articles", id: "1" }, at: new Date(), scope: { feature: "blog", environment: "test", tenant: "default" }, from: { ipTruncated: "10.0.0.0", userAgent: "test" }, containsPii: false, outcome: "success", }; it("record() is a no-op that does not throw", async () => { const log = new NoopAuditLog(); await expect(log.record(sample)).resolves.toBeUndefined(); }); it("eraseSubject() is a no-op that does not throw", async () => { const log = new NoopAuditLog(); await expect(log.eraseSubject("user_1", "pseudonymize")).resolves.toBeUndefined(); await expect(log.eraseSubject("user_1", "delete")).resolves.toBeUndefined(); }); }); ``` - [ ] **Step 2: Run → FAIL** ```bash pnpm --filter @repo/core-audit test noop-audit-log.test ``` - [ ] **Step 3: Implement** Create `packages/core-audit/src/noop-audit-log.ts`: ```ts import type { AuditEntry } from "@repo/core-shared/audit"; import type { IAuditLog } from "./audit-log.interface"; export class NoopAuditLog implements IAuditLog { async record(_entry: AuditEntry): Promise { // intentional no-op } async eraseSubject(_actorId: string, _mode: "pseudonymize" | "delete"): Promise { // intentional no-op } } ``` - [ ] **Step 4: Run → PASS** - [ ] **Step 5: Commit** ```bash git add packages/core-audit/src/noop-audit-log.ts packages/core-audit/src/noop-audit-log.test.ts git commit -m "feat(core-audit): NoopAuditLog impl" ``` ### Task 2.4: StdoutJsonAuditLog (TDD) **Files:** - Create: `packages/core-audit/src/stdout-json-audit-log.ts` (+ test) - [ ] **Step 1: Write failing test** Create `packages/core-audit/src/stdout-json-audit-log.test.ts`: ```ts import { describe, it, expect, vi, beforeEach } from "vitest"; import { StdoutJsonAuditLog } from "./stdout-json-audit-log"; import type { AuditEntry } from "@repo/core-shared/audit"; const sample: AuditEntry = { actorId: "user_1", actorType: "user", actorRoles: ["admin"], action: "CREATE", resource: { type: "articles", id: "abc" }, at: new Date("2026-05-11T10:00:00.000Z"), scope: { feature: "blog", environment: "production", tenant: "default" }, from: { ipTruncated: "10.0.0.0", userAgent: "Mozilla/5.0" }, containsPii: false, outcome: "success", }; describe("StdoutJsonAuditLog", () => { let writeSpy: ReturnType; beforeEach(() => { writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); }); it("record() writes one JSON line per entry to stdout", async () => { const log = new StdoutJsonAuditLog(); await log.record(sample); expect(writeSpy).toHaveBeenCalledOnce(); const written = writeSpy.mock.calls[0]![0] as string; expect(written.endsWith("\n")).toBe(true); const parsed = JSON.parse(written.trimEnd()); expect(parsed._type).toBe("audit"); expect(parsed.actorId).toBe("user_1"); expect(parsed.action).toBe("CREATE"); expect(parsed.at).toBe("2026-05-11T10:00:00.000Z"); // ISO 8601 serialization }); it("eraseSubject() emits a tombstone with mode + actorId", async () => { const log = new StdoutJsonAuditLog(); await log.eraseSubject("user_1", "pseudonymize"); expect(writeSpy).toHaveBeenCalledOnce(); const written = writeSpy.mock.calls[0]![0] as string; const parsed = JSON.parse(written.trimEnd()); expect(parsed._type).toBe("audit-erasure"); expect(parsed.actorId).toBe("user_1"); expect(parsed.mode).toBe("pseudonymize"); expect(typeof parsed.at).toBe("string"); // ISO 8601 timestamp }); }); ``` - [ ] **Step 2: Run → FAIL** - [ ] **Step 3: Implement** Create `packages/core-audit/src/stdout-json-audit-log.ts`: ```ts import type { AuditEntry } from "@repo/core-shared/audit"; import type { IAuditLog } from "./audit-log.interface"; /** * Writes one structured JSON line per audit entry to stdout. A log shipper * (Vector, Fluent Bit) picks these up and forwards to the centralized * aggregator (Grafana Cloud, Datadog, Loki EU, etc.). * * Lines include a `_type` discriminator so the shipper can route: * "audit" → audit entry * "audit-erasure" → GDPR erasure tombstone * * `eraseSubject` is best-effort: past stdout lines can't be retroactively * removed. The tombstone informs the downstream aggregator to filter/delete. */ export class StdoutJsonAuditLog implements IAuditLog { async record(entry: AuditEntry): Promise { const serialized = JSON.stringify({ _type: "audit", ...entry, at: entry.at.toISOString(), }); process.stdout.write(serialized + "\n"); } async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise { const tombstone = { _type: "audit-erasure", actorId, mode, at: new Date().toISOString(), }; process.stdout.write(JSON.stringify(tombstone) + "\n"); } } ``` - [ ] **Step 4: Run → PASS** - [ ] **Step 5: Commit** ```bash git add packages/core-audit/src/stdout-json-audit-log.ts packages/core-audit/src/stdout-json-audit-log.test.ts git commit -m "feat(core-audit): StdoutJsonAuditLog impl with audit + audit-erasure markers" ``` ### Task 2.5: auditLogs Payload collection **Files:** - Create: `packages/core-audit/src/audit-logs-collection.ts` (+ test) - [ ] **Step 1: Write the collection-shape test** Create `packages/core-audit/src/audit-logs-collection.test.ts`: ```ts import { describe, it, expect } from "vitest"; import { auditLogsCollection } from "./audit-logs-collection"; describe("auditLogsCollection", () => { it("uses slug 'audit-logs'", () => { expect(auditLogsCollection.slug).toBe("audit-logs"); }); it("is append-only (update: () => false)", () => { const access = auditLogsCollection.access as Record boolean>; expect(access.update()).toBe(false); }); it("has the required fields", () => { const fieldNames = (auditLogsCollection.fields as Array<{ name: string }>).map((f) => f.name); // WHO expect(fieldNames).toContain("actorId"); expect(fieldNames).toContain("actorType"); expect(fieldNames).toContain("actorRoles"); // WHAT expect(fieldNames).toContain("action"); expect(fieldNames).toContain("resourceType"); expect(fieldNames).toContain("resourceId"); expect(fieldNames).toContain("changedFields"); // SCOPE expect(fieldNames).toContain("scopeFeature"); expect(fieldNames).toContain("scopeEnvironment"); expect(fieldNames).toContain("scopeTenant"); // WHY expect(fieldNames).toContain("reason"); expect(fieldNames).toContain("correlationId"); expect(fieldNames).toContain("requestId"); // FROM expect(fieldNames).toContain("ipTruncated"); expect(fieldNames).toContain("userAgent"); // PII expect(fieldNames).toContain("containsPii"); expect(fieldNames).toContain("piiCategories"); // OUTCOME expect(fieldNames).toContain("outcome"); expect(fieldNames).toContain("errorCode"); }); it("enables timestamps so createdAt maps to AuditEntry.at", () => { expect(auditLogsCollection.timestamps).toBe(true); }); }); ``` - [ ] **Step 2: Run → FAIL** ```bash pnpm --filter @repo/core-audit test audit-logs-collection.test ``` - [ ] **Step 3: Implement** Create `packages/core-audit/src/audit-logs-collection.ts`: ```ts import type { CollectionConfig } from "payload"; /** * Append-only Payload collection for audit entries. Mounted by core-cms * when this package is scaffolded (manual wiring step printed by generator). * * Access rules: * - read: admins only * - create: any authenticated context (filtered upstream by PayloadAuditLog) * - update: NEVER (compliance requires append-only) * - delete: admins only (used by the GDPR erasure path with overrideAccess) * * The `update: () => false` rule is the compliance backbone. The erasure * path uses `overrideAccess: true` to bypass for pseudonymization — that's * Payload's documented escape hatch for privileged operations. */ export const auditLogsCollection: CollectionConfig = { slug: "audit-logs", access: { read: ({ req }) => { const user = req.user as { roles?: string[] } | null | undefined; return Array.isArray(user?.roles) && user.roles.includes("admin"); }, create: () => true, update: () => false, delete: ({ req }) => { const user = req.user as { roles?: string[] } | null | undefined; return Array.isArray(user?.roles) && user.roles.includes("admin"); }, }, timestamps: true, fields: [ // WHO { name: "actorId", type: "text", required: true, index: true }, { name: "actorType", type: "select", options: ["user", "system", "service"], required: true, }, { name: "actorRoles", type: "json", required: true }, // WHAT { name: "action", type: "select", options: ["VIEW", "CREATE", "UPDATE", "DELETE", "EXPORT", "PERMISSION_CHANGE"], required: true, index: true, }, { name: "resourceType", type: "text", required: true, index: true }, { name: "resourceId", type: "text" }, { name: "changedFields", type: "json" }, // SCOPE { name: "scopeFeature", type: "text", required: true, index: true }, { name: "scopeEnvironment", type: "text", required: true }, { name: "scopeTenant", type: "text", required: true, index: true }, // WHY { name: "reason", type: "text" }, { name: "correlationId", type: "text", index: true }, { name: "requestId", type: "text" }, // FROM { name: "ipTruncated", type: "text", required: true }, { name: "userAgent", type: "text", required: true }, // PII { name: "containsPii", type: "checkbox", required: true }, { name: "piiCategories", type: "json" }, // OUTCOME { name: "outcome", type: "select", options: ["success", "denied", "error"], required: true, }, { name: "errorCode", type: "text" }, ], }; ``` - [ ] **Step 4: Run → PASS** - [ ] **Step 5: Commit** ```bash git add packages/core-audit/src/audit-logs-collection.ts packages/core-audit/src/audit-logs-collection.test.ts git commit -m "feat(core-audit): append-only auditLogs Payload collection" ``` ### Task 2.6: PayloadAuditLog (TDD, record only — eraseSubject in Phase 3) **Files:** - Create: `packages/core-audit/src/payload-audit-log.ts` (+ test) - [ ] **Step 1: Write failing test** Create `packages/core-audit/src/payload-audit-log.test.ts`: ```ts import { describe, it, expect, vi } from "vitest"; import { PayloadAuditLog } from "./payload-audit-log"; import type { AuditEntry } from "@repo/core-shared/audit"; const sample: AuditEntry = { actorId: "user_1", actorType: "user", actorRoles: ["admin"], action: "UPDATE", resource: { type: "articles", id: "abc" }, changedFields: ["title", "body"], at: new Date("2026-05-11T10:00:00.000Z"), scope: { feature: "blog", environment: "production", tenant: "default" }, from: { ipTruncated: "10.0.0.0", userAgent: "Mozilla/5.0" }, containsPii: false, outcome: "success", }; describe("PayloadAuditLog.record", () => { it("maps AuditEntry → flat collection doc + calls payload.create", async () => { const mockCreate = vi.fn().mockResolvedValue({ id: "doc_1" }); const mockGetPayload = vi.fn().mockResolvedValue({ create: mockCreate }); const log = new PayloadAuditLog({} as never, mockGetPayload); await log.record(sample); expect(mockCreate).toHaveBeenCalledOnce(); const call = mockCreate.mock.calls[0]![0] as { collection: string; data: Record }; expect(call.collection).toBe("audit-logs"); expect(call.data.actorId).toBe("user_1"); expect(call.data.action).toBe("UPDATE"); expect(call.data.resourceType).toBe("articles"); expect(call.data.resourceId).toBe("abc"); expect(call.data.changedFields).toEqual(["title", "body"]); expect(call.data.scopeFeature).toBe("blog"); expect(call.data.scopeTenant).toBe("default"); expect(call.data.ipTruncated).toBe("10.0.0.0"); expect(call.data.containsPii).toBe(false); expect(call.data.outcome).toBe("success"); }); }); ``` - [ ] **Step 2: Run → FAIL** - [ ] **Step 3: Implement** Create `packages/core-audit/src/payload-audit-log.ts`: ```ts import type { SanitizedConfig } from "payload"; import type { AuditEntry } from "@repo/core-shared/audit"; import type { IAuditLog } from "./audit-log.interface"; type GetPayload = (args: { config: SanitizedConfig }) => Promise<{ create: (args: { collection: string; data: Record }) => Promise; find: (args: { collection: string; where: Record; limit: number; overrideAccess: true; }) => Promise<{ docs: Array<{ id: string | number }> }>; update: (args: { collection: string; id: string | number; data: Record; overrideAccess: true; }) => Promise; delete: (args: { collection: string; where: Record; overrideAccess: true; }) => Promise; }>; /** * Local-cache audit sink: writes entries to the `audit-logs` Payload * collection. The collection is append-only by access-rule * (`update: () => false`); the eraseSubject path uses `overrideAccess: true` * to bypass for the privileged GDPR pseudonymization op. * * The getPayload param is injectable for tests; production callers pass * the real `getPayload` from `payload`. */ export class PayloadAuditLog implements IAuditLog { constructor( private readonly config: SanitizedConfig, private readonly getPayload: GetPayload, ) {} async record(entry: AuditEntry): Promise { const payload = await this.getPayload({ config: this.config }); await payload.create({ collection: "audit-logs", data: { actorId: entry.actorId, actorType: entry.actorType, actorRoles: entry.actorRoles, action: entry.action, resourceType: entry.resource.type, resourceId: entry.resource.id ?? null, changedFields: entry.changedFields ?? null, scopeFeature: entry.scope.feature, scopeEnvironment: entry.scope.environment, scopeTenant: entry.scope.tenant, reason: entry.reason ?? null, correlationId: entry.correlationId ?? null, requestId: entry.requestId ?? null, ipTruncated: entry.from.ipTruncated, userAgent: entry.from.userAgent, containsPii: entry.containsPii, piiCategories: entry.piiCategories ?? null, outcome: entry.outcome, errorCode: entry.errorCode ?? null, }, }); } async eraseSubject(_actorId: string, _mode: "pseudonymize" | "delete"): Promise { // Implemented in Phase 3. throw new Error("PayloadAuditLog.eraseSubject not yet implemented (Phase 3)"); } } ``` - [ ] **Step 4: Run → PASS** - [ ] **Step 5: Commit** ```bash git add packages/core-audit/src/payload-audit-log.ts packages/core-audit/src/payload-audit-log.test.ts git commit -m "feat(core-audit): PayloadAuditLog.record impl (eraseSubject lands in Phase 3)" ``` ### Task 2.7: MultiSinkAuditLog (TDD) **Files:** - Create: `packages/core-audit/src/multi-sink-audit-log.ts` (+ test) - [ ] **Step 1: Write failing test** Create `packages/core-audit/src/multi-sink-audit-log.test.ts`: ```ts import { describe, it, expect, vi, beforeEach } from "vitest"; import { MultiSinkAuditLog } from "./multi-sink-audit-log"; import { NoopAuditLog } from "./noop-audit-log"; import type { AuditEntry } from "@repo/core-shared/audit"; import type { IAuditLog } from "./audit-log.interface"; const sample: AuditEntry = { actorId: "user_1", actorType: "user", actorRoles: [], action: "VIEW", resource: { type: "articles" }, at: new Date(), scope: { feature: "blog", environment: "test", tenant: "default" }, from: { ipTruncated: "10.0.0.0", userAgent: "test" }, containsPii: false, outcome: "success", }; function makeRecorder(): IAuditLog & { records: AuditEntry[]; erasures: string[] } { const records: AuditEntry[] = []; const erasures: string[] = []; return { records, erasures, async record(e) { records.push(e); }, async eraseSubject(actorId) { erasures.push(actorId); }, }; } describe("MultiSinkAuditLog", () => { it("record() fans out to every sink", async () => { const a = makeRecorder(); const b = makeRecorder(); const m = new MultiSinkAuditLog([a, b]); await m.record(sample); expect(a.records).toHaveLength(1); expect(b.records).toHaveLength(1); }); it("settle-all: one sink failing does not skip others", async () => { const errSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); const a: IAuditLog = { record: async () => { throw new Error("a-fail"); }, eraseSubject: async () => {} }; const b = makeRecorder(); const m = new MultiSinkAuditLog([a, b]); await m.record(sample); expect(b.records).toHaveLength(1); // b still received the entry expect(errSpy).toHaveBeenCalledOnce(); const written = errSpy.mock.calls[0]![0] as string; const parsed = JSON.parse(written.trimEnd()); expect(parsed._type).toBe("audit-sink-error"); expect(parsed.error).toContain("a-fail"); errSpy.mockRestore(); }); it("eraseSubject() fans out to every sink", async () => { const a = makeRecorder(); const b = makeRecorder(); const m = new MultiSinkAuditLog([a, b]); await m.eraseSubject("user_1", "delete"); expect(a.erasures).toEqual(["user_1"]); expect(b.erasures).toEqual(["user_1"]); }); }); ``` - [ ] **Step 2: Run → FAIL** - [ ] **Step 3: Implement** Create `packages/core-audit/src/multi-sink-audit-log.ts`: ```ts import type { AuditEntry } from "@repo/core-shared/audit"; import type { IAuditLog } from "./audit-log.interface"; /** * Fan-out wrapper. Delivers each entry to every inner sink with settle-all * semantics — one failing sink doesn't drop the audit entry from others. * * Failures emit a structured `audit-sink-error` JSON line to stderr. * Stderr (not via OTel/Sentry) avoids recursion: if Sentry is one of the * sinks failing and we routed the error back through Sentry's reporter, * we'd loop. Stderr is consumed by the same log shipper as audit entries * themselves, so the operator sees the failure in their aggregator. */ export class MultiSinkAuditLog implements IAuditLog { constructor(private readonly sinks: IAuditLog[]) {} async record(entry: AuditEntry): Promise { const results = await Promise.allSettled(this.sinks.map((s) => s.record(entry))); for (const r of results) { if (r.status === "rejected") { this.reportSinkError(r.reason); } } } async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise { const results = await Promise.allSettled( this.sinks.map((s) => s.eraseSubject(actorId, mode)), ); for (const r of results) { if (r.status === "rejected") { this.reportSinkError(r.reason); } } } private reportSinkError(reason: unknown): void { const line = JSON.stringify({ _type: "audit-sink-error", error: String(reason), at: new Date().toISOString(), }); process.stderr.write(line + "\n"); } } ``` - [ ] **Step 4: Run → PASS** - [ ] **Step 5: Commit** ```bash git add packages/core-audit/src/multi-sink-audit-log.ts packages/core-audit/src/multi-sink-audit-log.test.ts git commit -m "feat(core-audit): MultiSinkAuditLog fan-out with settle-all + stderr fallback" ``` ### Task 2.8: bindAudit binder (TDD) **Files:** - Create: `packages/core-audit/src/di/bind-audit.ts` (+ test) - [ ] **Step 1: Write failing test** Create `packages/core-audit/src/di/bind-audit.test.ts`: ```ts import "reflect-metadata"; import { describe, it, expect, vi } from "vitest"; import { Container } from "inversify"; import { bindAudit } from "./bind-audit"; import { AUDIT_SYMBOLS } from "./symbols"; import { NoopAuditLog } from "../noop-audit-log"; import { StdoutJsonAuditLog } from "../stdout-json-audit-log"; import { PayloadAuditLog } from "../payload-audit-log"; import { MultiSinkAuditLog } from "../multi-sink-audit-log"; import type { IAuditLog } from "../audit-log.interface"; describe("bindAudit", () => { it("defaults to MultiSinkAuditLog([payload, stdout]) when payloadConfig is provided", () => { const container = new Container(); bindAudit(container, { payloadConfig: {} as never }); const auditLog = container.get(AUDIT_SYMBOLS.IAuditLog); expect(auditLog).toBeInstanceOf(MultiSinkAuditLog); }); it("returns StdoutJsonAuditLog alone when payloadConfig omitted + default sinks", () => { const container = new Container(); bindAudit(container, {}); const auditLog = container.get(AUDIT_SYMBOLS.IAuditLog); expect(auditLog).toBeInstanceOf(StdoutJsonAuditLog); }); it("returns NoopAuditLog when sinks=[]", () => { const container = new Container(); bindAudit(container, { sinks: [] }); const auditLog = container.get(AUDIT_SYMBOLS.IAuditLog); expect(auditLog).toBeInstanceOf(NoopAuditLog); }); it("returns PayloadAuditLog when sinks=['payload'] only", () => { const container = new Container(); bindAudit(container, { payloadConfig: {} as never, sinks: ["payload"] }); const auditLog = container.get(AUDIT_SYMBOLS.IAuditLog); expect(auditLog).toBeInstanceOf(PayloadAuditLog); }); it("validates AUDIT_PSEUDONYM_SALT in production", () => { const oldEnv = process.env.NODE_ENV; const oldSalt = process.env.AUDIT_PSEUDONYM_SALT; process.env.NODE_ENV = "production"; delete process.env.AUDIT_PSEUDONYM_SALT; expect(() => bindAudit(new Container(), { sinks: ["stdout"] })).toThrow( /AUDIT_PSEUDONYM_SALT/, ); process.env.NODE_ENV = oldEnv; if (oldSalt) process.env.AUDIT_PSEUDONYM_SALT = oldSalt; }); }); ``` - [ ] **Step 2: Run → FAIL** - [ ] **Step 3: Implement** Create `packages/core-audit/src/di/bind-audit.ts`: ```ts import "reflect-metadata"; import type { Container } from "inversify"; import { getPayload, type SanitizedConfig } from "payload"; import { NoopAuditLog } from "../noop-audit-log"; import { PayloadAuditLog } from "../payload-audit-log"; import { StdoutJsonAuditLog } from "../stdout-json-audit-log"; import { MultiSinkAuditLog } from "../multi-sink-audit-log"; import type { IAuditLog } from "../audit-log.interface"; import { AUDIT_SYMBOLS } from "./symbols"; export type BindAuditOpts = { /** Payload config; required if "payload" is in sinks. */ payloadConfig?: SanitizedConfig; /** Sink selection. Default ["payload", "stdout"]. */ sinks?: ("payload" | "stdout")[]; }; /** * Binds an `IAuditLog` impl to the container under `AUDIT_SYMBOLS.IAuditLog`. * * Default sink set: ["payload", "stdout"] — Payload local cache + structured * JSON to stdout (operator wires a log shipper to the centralized aggregator). * * In production, AUDIT_PSEUDONYM_SALT env var MUST be set. Boot fails fast * if not — better to refuse to start than to ship audit data with a dev-fallback * salt that an attacker could reverse. * * Note: Phase 4 wraps the returned auditLog in TraceIdEnrichingAuditLog * for OTel correlation. Phase 2 returns the inner sink/fan-out directly. */ export function bindAudit( container: Container, opts: BindAuditOpts = {}, ): { auditLog: IAuditLog } { if (process.env.NODE_ENV === "production" && !process.env.AUDIT_PSEUDONYM_SALT) { throw new Error( "AUDIT_PSEUDONYM_SALT environment variable is required in production. " + "Generate via `openssl rand -hex 32` and store in your secrets manager.", ); } const sinkList = opts.sinks ?? ["payload", "stdout"]; const sinks: IAuditLog[] = []; if (sinkList.includes("payload") && opts.payloadConfig) { sinks.push(new PayloadAuditLog(opts.payloadConfig, getPayload)); } if (sinkList.includes("stdout")) { sinks.push(new StdoutJsonAuditLog()); } const auditLog: IAuditLog = sinks.length > 1 ? new MultiSinkAuditLog(sinks) : sinks.length === 1 ? sinks[0]! : new NoopAuditLog(); if (container.isBound(AUDIT_SYMBOLS.IAuditLog)) { container.unbind(AUDIT_SYMBOLS.IAuditLog); } container.bind(AUDIT_SYMBOLS.IAuditLog).toConstantValue(auditLog); return { auditLog }; } ``` - [ ] **Step 4: Run → PASS** - [ ] **Step 5: Commit** ```bash git add packages/core-audit/src/di/bind-audit.ts packages/core-audit/src/di/bind-audit.test.ts git commit -m "feat(core-audit): bindAudit binder with sink selection + prod salt validation" ``` ### Task 2.9: RecordingAuditLog in core-testing (TDD) **Files:** - Create: `packages/core-testing/src/instrumentation/recording-audit-log.ts` (+ test) - Modify: `packages/core-testing/src/instrumentation/index.ts` - [ ] **Step 1: Write failing test** Create `packages/core-testing/src/instrumentation/recording-audit-log.test.ts`: ```ts import { describe, it, expect } from "vitest"; import { RecordingAuditLog } from "./recording-audit-log"; import type { AuditEntry } from "@repo/core-shared/audit"; const sample: AuditEntry = { actorId: "user_1", actorType: "user", actorRoles: [], action: "CREATE", resource: { type: "articles" }, at: new Date(), scope: { feature: "blog", environment: "test", tenant: "default" }, from: { ipTruncated: "10.0.0.0", userAgent: "test" }, containsPii: false, outcome: "success", }; describe("RecordingAuditLog", () => { it("record() pushes to recorded[]", async () => { const log = new RecordingAuditLog(); await log.record(sample); expect(log.recorded).toHaveLength(1); expect(log.recorded[0]!.actorId).toBe("user_1"); }); it("eraseSubject(pseudonymize) tracks erasure + rewrites actorId in recorded", async () => { const log = new RecordingAuditLog(); await log.record(sample); await log.eraseSubject("user_1", "pseudonymize"); expect(log.erasures).toEqual([{ actorId: "user_1", mode: "pseudonymize" }]); expect(log.recorded[0]!.actorId).toBe("erased-user_1"); // sentinel rewrite }); it("eraseSubject(delete) removes matching entries from recorded", async () => { const log = new RecordingAuditLog(); await log.record(sample); await log.record({ ...sample, actorId: "user_2" }); await log.eraseSubject("user_1", "delete"); expect(log.recorded.map((r) => r.actorId)).toEqual(["user_2"]); expect(log.erasures).toEqual([{ actorId: "user_1", mode: "delete" }]); }); it("reset() clears recorded + erasures", async () => { const log = new RecordingAuditLog(); await log.record(sample); await log.eraseSubject("user_1", "pseudonymize"); log.reset(); expect(log.recorded).toEqual([]); expect(log.erasures).toEqual([]); }); }); ``` - [ ] **Step 2: Run → FAIL** ```bash pnpm --filter @repo/core-testing test recording-audit-log.test ``` - [ ] **Step 3: Implement** Create `packages/core-testing/src/instrumentation/recording-audit-log.ts`: ```ts import type { AuditEntry } from "@repo/core-shared/audit"; /** * Test-side recording double for IAuditLog. Mirrors Payload semantics in * eraseSubject (pseudonymize rewrites in place; delete filters out) so tests * can assert against the same observable state the real impl produces. * * Use directly via constructor injection in factory-function tests — no * container manipulation needed. */ export class RecordingAuditLog { public recorded: AuditEntry[] = []; public erasures: { actorId: string; mode: "pseudonymize" | "delete" }[] = []; async record(entry: AuditEntry): Promise { this.recorded.push(entry); } async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise { this.erasures.push({ actorId, mode }); if (mode === "pseudonymize") { for (const r of this.recorded) { if (r.actorId === actorId) { r.actorId = `erased-${actorId}`; } } } else { this.recorded = this.recorded.filter((r) => r.actorId !== actorId); } } reset(): void { this.recorded = []; this.erasures = []; } } ``` - [ ] **Step 4: Run → PASS** - [ ] **Step 5: Update core-testing barrel** Append to `packages/core-testing/src/instrumentation/index.ts`: ```ts export { RecordingAuditLog } from "./recording-audit-log"; ``` - [ ] **Step 6: Commit** ```bash git add packages/core-testing/src/instrumentation/recording-audit-log.ts \ packages/core-testing/src/instrumentation/recording-audit-log.test.ts \ packages/core-testing/src/instrumentation/index.ts git commit -m "feat(core-testing): RecordingAuditLog test double" ``` ### Task 2.10: Package barrel + Phase 2 gate **Files:** - Create: `packages/core-audit/src/index.ts` - [ ] **Step 1: Create barrel** Create `packages/core-audit/src/index.ts`: ```ts export type { IAuditLog } from "./audit-log.interface"; export type { AuditEntry, AuditAction, AuditFrom } from "@repo/core-shared/audit"; export { NoopAuditLog } from "./noop-audit-log"; export { StdoutJsonAuditLog } from "./stdout-json-audit-log"; export { PayloadAuditLog } from "./payload-audit-log"; export { MultiSinkAuditLog } from "./multi-sink-audit-log"; export { auditLogsCollection } from "./audit-logs-collection"; export { bindAudit, type BindAuditOpts } from "./di/bind-audit"; export { AUDIT_SYMBOLS } from "./di/symbols"; ``` - [ ] **Step 2: Run all gates** ```bash pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries ``` Expected: all green. - [ ] **Step 3: Commit** ```bash git add packages/core-audit/src/index.ts git commit -m "feat(core-audit): package barrel exports" ``` --- ## Phase 3 — GDPR erasure plumbing **Goal:** make erasure actually invokable. `pseudonymize` helper, `PayloadAuditLog.eraseSubject` impl, `createAuditErasureHook` factory, admin tRPC procedure. **Files touched:** - Create: `packages/core-audit/src/pseudonymize.ts` (+ test) - Modify: `packages/core-audit/src/payload-audit-log.ts` (eraseSubject impl) - Update: `packages/core-audit/src/payload-audit-log.test.ts` (add eraseSubject tests) - Create: `packages/core-audit/src/hooks/audit-erasure-hook.ts` (+ test) - Create: `packages/core-audit/src/hooks/index.ts` - Create: `packages/core-audit/src/integrations/api/procedures.ts` - Create: `packages/core-audit/src/integrations/api/router.ts` (+ test) - Modify: `packages/core-audit/src/index.ts` ### Task 3.1: pseudonymize helper (TDD) **Files:** - Create: `packages/core-audit/src/pseudonymize.ts` (+ test) - [ ] **Step 1: Write failing test** Create `packages/core-audit/src/pseudonymize.test.ts`: ```ts import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { pseudonymize } from "./pseudonymize"; describe("pseudonymize", () => { let oldSalt: string | undefined; beforeEach(() => { oldSalt = process.env.AUDIT_PSEUDONYM_SALT; process.env.AUDIT_PSEUDONYM_SALT = "test-salt-1"; }); afterEach(() => { if (oldSalt) process.env.AUDIT_PSEUDONYM_SALT = oldSalt; else delete process.env.AUDIT_PSEUDONYM_SALT; }); it("returns 'erased-{16-hex-chars}'", () => { const result = pseudonymize("user_1"); expect(result).toMatch(/^erased-[a-f0-9]{16}$/); }); it("is deterministic — same input → same output", () => { const a = pseudonymize("user_1"); const b = pseudonymize("user_1"); expect(a).toBe(b); }); it("different inputs produce different outputs", () => { const a = pseudonymize("user_1"); const b = pseudonymize("user_2"); expect(a).not.toBe(b); }); it("salt change produces different output", () => { const a = pseudonymize("user_1"); process.env.AUDIT_PSEUDONYM_SALT = "test-salt-2"; const b = pseudonymize("user_1"); expect(a).not.toBe(b); }); }); ``` - [ ] **Step 2: Run → FAIL** - [ ] **Step 3: Implement** Create `packages/core-audit/src/pseudonymize.ts`: ```ts import { createHash } from "node:crypto"; /** * Stable pseudonym for an erased actor. SHA-256 of (salt + ":" + actorId), * truncated to 16 hex chars, prefixed `erased-`. Returns the same pseudonym * for the same input + salt — compliance auditors can verify that two * entries with `erased-abc...` came from the same original actor without * knowing who. * * The salt comes from AUDIT_PSEUDONYM_SALT env. In production, the binder * (bindAudit) validates this env is set; in dev/test, a fallback salt is * used (NOT acceptable for production data). */ export function pseudonymize(actorId: string): string { const salt = process.env.AUDIT_PSEUDONYM_SALT ?? "dev-fallback-salt-replace-in-prod"; const hash = createHash("sha256").update(salt + ":" + actorId).digest("hex"); return `erased-${hash.slice(0, 16)}`; } ``` - [ ] **Step 4: Run → PASS** - [ ] **Step 5: Commit** ```bash git add packages/core-audit/src/pseudonymize.ts packages/core-audit/src/pseudonymize.test.ts git commit -m "feat(core-audit): pseudonymize helper (sha256 + AUDIT_PSEUDONYM_SALT)" ``` ### Task 3.2: PayloadAuditLog.eraseSubject (TDD) **Files:** - Modify: `packages/core-audit/src/payload-audit-log.ts` - Modify: `packages/core-audit/src/payload-audit-log.test.ts` - [ ] **Step 1: Add failing tests to existing test file** Append to `packages/core-audit/src/payload-audit-log.test.ts`: ```ts describe("PayloadAuditLog.eraseSubject", () => { it("mode='delete' calls payload.delete with overrideAccess + where actorId equals", async () => { const mockDelete = vi.fn().mockResolvedValue({ docs: [] }); const mockGetPayload = vi.fn().mockResolvedValue({ delete: mockDelete, create: vi.fn() }); const log = new PayloadAuditLog({} as never, mockGetPayload); await log.eraseSubject("user_1", "delete"); expect(mockDelete).toHaveBeenCalledOnce(); const call = mockDelete.mock.calls[0]![0] as { collection: string; where: { actorId: { equals: string } }; overrideAccess: boolean; }; expect(call.collection).toBe("audit-logs"); expect(call.where.actorId.equals).toBe("user_1"); expect(call.overrideAccess).toBe(true); }); it("mode='pseudonymize' fetches matching docs + updates each with pseudonym + overrideAccess", async () => { process.env.AUDIT_PSEUDONYM_SALT = "fixed-salt-for-test"; const mockFind = vi.fn().mockResolvedValue({ docs: [{ id: "doc_1" }, { id: "doc_2" }] }); const mockUpdate = vi.fn().mockResolvedValue({}); const mockGetPayload = vi.fn().mockResolvedValue({ find: mockFind, update: mockUpdate, create: vi.fn() }); const log = new PayloadAuditLog({} as never, mockGetPayload); await log.eraseSubject("user_1", "pseudonymize"); expect(mockFind).toHaveBeenCalledOnce(); expect(mockUpdate).toHaveBeenCalledTimes(2); const firstUpdate = mockUpdate.mock.calls[0]![0] as { data: { actorId: string }; overrideAccess: boolean; }; expect(firstUpdate.data.actorId).toMatch(/^erased-[a-f0-9]{16}$/); expect(firstUpdate.overrideAccess).toBe(true); }); }); ``` - [ ] **Step 2: Run → FAIL** ```bash pnpm --filter @repo/core-audit test payload-audit-log.test ``` (The existing `record` test still passes; the new tests fail because eraseSubject still throws.) - [ ] **Step 3: Replace the eraseSubject stub with the impl** In `packages/core-audit/src/payload-audit-log.ts`, replace the `eraseSubject` body: ```ts import { pseudonymize } from "./pseudonymize"; // ... existing class ... async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise { const payload = await this.getPayload({ config: this.config }); if (mode === "delete") { await payload.delete({ collection: "audit-logs", where: { actorId: { equals: actorId } }, overrideAccess: true, }); return; } // pseudonymize const pseudonym = pseudonymize(actorId); const matches = await payload.find({ collection: "audit-logs", where: { actorId: { equals: actorId } }, limit: 10_000, overrideAccess: true, }); for (const doc of matches.docs) { await payload.update({ collection: "audit-logs", id: doc.id, data: { actorId: pseudonym }, overrideAccess: true, }); } } ``` - [ ] **Step 4: Run → PASS** - [ ] **Step 5: Commit** ```bash git add packages/core-audit/src/payload-audit-log.ts packages/core-audit/src/payload-audit-log.test.ts git commit -m "feat(core-audit): PayloadAuditLog.eraseSubject (pseudonymize + delete via overrideAccess)" ``` ### Task 3.3: createAuditErasureHook factory (TDD) **Files:** - Create: `packages/core-audit/src/hooks/audit-erasure-hook.ts` (+ test) - Create: `packages/core-audit/src/hooks/index.ts` - [ ] **Step 1: Write failing test** Create `packages/core-audit/src/hooks/audit-erasure-hook.test.ts`: ```ts import { describe, it, expect, vi } from "vitest"; import { createAuditErasureHook } from "./audit-erasure-hook"; import type { IAuditLog } from "../audit-log.interface"; function makeAuditLog(): IAuditLog & { erasures: { actorId: string; mode: string }[] } { const erasures: { actorId: string; mode: string }[] = []; return { erasures, record: vi.fn(), async eraseSubject(actorId, mode) { erasures.push({ actorId, mode }); }, }; } describe("createAuditErasureHook", () => { it("calls auditLog.eraseSubject(doc.id, 'pseudonymize') by default", async () => { const auditLog = makeAuditLog(); const hook = createAuditErasureHook({ auditLog }); await hook({ doc: { id: "user_1" } } as never); expect(auditLog.erasures).toEqual([{ actorId: "user_1", mode: "pseudonymize" }]); }); it("uses mode='delete' when configured", async () => { const auditLog = makeAuditLog(); const hook = createAuditErasureHook({ auditLog, mode: "delete" }); await hook({ doc: { id: "user_1" } } as never); expect(auditLog.erasures).toEqual([{ actorId: "user_1", mode: "delete" }]); }); it("coerces numeric doc.id to string", async () => { const auditLog = makeAuditLog(); const hook = createAuditErasureHook({ auditLog }); await hook({ doc: { id: 42 } } as never); expect(auditLog.erasures).toEqual([{ actorId: "42", mode: "pseudonymize" }]); }); it("skips non-string/non-numeric ids", async () => { const auditLog = makeAuditLog(); const hook = createAuditErasureHook({ auditLog }); await hook({ doc: { id: undefined } } as never); expect(auditLog.erasures).toEqual([]); }); }); ``` - [ ] **Step 2: Run → FAIL** - [ ] **Step 3: Implement** Create `packages/core-audit/src/hooks/audit-erasure-hook.ts`: ```ts import type { CollectionAfterDeleteHook } from "payload"; import type { IAuditLog } from "../audit-log.interface"; export type AuditErasureHookOpts = { auditLog: IAuditLog; /** Default 'pseudonymize'. Use 'delete' for collections requiring hard-erase. */ mode?: "pseudonymize" | "delete"; }; /** * Payload afterDelete hook that triggers GDPR erasure on the audit log * when a subject (typically a user) is deleted. Install on user-shaped * collections via the collection's `hooks.afterDelete` array. * * Has no knowledge of collection schemas beyond expecting a `doc.id` * (string or number). Works for any subject-shaped collection. */ export function createAuditErasureHook( opts: AuditErasureHookOpts, ): CollectionAfterDeleteHook { const mode = opts.mode ?? "pseudonymize"; return async ({ doc }) => { const id = doc.id; if (typeof id !== "string" && typeof id !== "number") return; await opts.auditLog.eraseSubject(String(id), mode); }; } ``` - [ ] **Step 4: Create hooks barrel** Create `packages/core-audit/src/hooks/index.ts`: ```ts export { createAuditErasureHook, type AuditErasureHookOpts, } from "./audit-erasure-hook"; ``` - [ ] **Step 5: Run → PASS** - [ ] **Step 6: Commit** ```bash git add packages/core-audit/src/hooks/audit-erasure-hook.ts \ packages/core-audit/src/hooks/audit-erasure-hook.test.ts \ packages/core-audit/src/hooks/index.ts git commit -m "feat(core-audit): createAuditErasureHook Payload afterDelete factory" ``` ### Task 3.4: Admin tRPC procedure (TDD) **Files:** - Create: `packages/core-audit/src/integrations/api/procedures.ts` - Create: `packages/core-audit/src/integrations/api/router.ts` (+ test) - [ ] **Step 1: Create the procedure helper** Create `packages/core-audit/src/integrations/api/procedures.ts`: ```ts import { initTRPC, TRPCError } from "@trpc/server"; import type { TrpcContext } from "@repo/core-shared/trpc/context"; import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware"; const t = initTRPC.context().create(); const adminOnly = t.middleware(({ ctx, next }) => { const user = ctx.user as { roles?: string[] } | null | undefined; if (!user?.roles?.includes("admin")) { throw new TRPCError({ code: "FORBIDDEN", message: "Admin role required" }); } return next(); }); /** Feature-scoped audit procedure: admin-only, no domain errors yet. */ export const auditProcedure = t.procedure .use(defineErrorMiddleware([])) .use(adminOnly); export const router = t.router; ``` - [ ] **Step 2: Write failing test for the router** Create `packages/core-audit/src/integrations/api/router.test.ts`: ```ts import { describe, it, expect, vi } from "vitest"; import { TRPCError } from "@trpc/server"; import { auditRouter } from "./router"; import type { IAuditLog } from "../../audit-log.interface"; function makeCtx(opts: { admin: boolean; auditLog?: IAuditLog }) { return { user: opts.admin ? { id: "u1", roles: ["admin"] } : { id: "u2", roles: [] }, auditLog: opts.auditLog, }; } describe("auditRouter.eraseSubject", () => { it("admin can invoke and the call delegates to ctx.auditLog.eraseSubject", async () => { const eraseSpy = vi.fn().mockResolvedValue(undefined); const auditLog: IAuditLog = { record: vi.fn(), eraseSubject: eraseSpy, }; const caller = auditRouter.createCaller(makeCtx({ admin: true, auditLog }) as never); const result = await caller.eraseSubject({ actorId: "user_1", mode: "pseudonymize" }); expect(result).toEqual({ ok: true }); expect(eraseSpy).toHaveBeenCalledWith("user_1", "pseudonymize"); }); it("non-admin gets FORBIDDEN", async () => { const caller = auditRouter.createCaller(makeCtx({ admin: false }) as never); await expect( caller.eraseSubject({ actorId: "user_1", mode: "delete" }), ).rejects.toThrow(TRPCError); }); it("defaults mode to 'pseudonymize' when omitted", async () => { const eraseSpy = vi.fn().mockResolvedValue(undefined); const auditLog: IAuditLog = { record: vi.fn(), eraseSubject: eraseSpy }; const caller = auditRouter.createCaller(makeCtx({ admin: true, auditLog }) as never); await caller.eraseSubject({ actorId: "user_1" }); expect(eraseSpy).toHaveBeenCalledWith("user_1", "pseudonymize"); }); }); ``` - [ ] **Step 3: Run → FAIL** - [ ] **Step 4: Implement the router** Create `packages/core-audit/src/integrations/api/router.ts`: ```ts import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { auditProcedure, router } from "./procedures"; export const auditRouter = router({ eraseSubject: auditProcedure .input( z.object({ actorId: z.string().min(1), mode: z.enum(["pseudonymize", "delete"]).default("pseudonymize"), }).strict(), ) .mutation(async ({ input, ctx }) => { const auditLog = (ctx as { auditLog?: { eraseSubject: (id: string, mode: "pseudonymize" | "delete") => Promise } }).auditLog; if (!auditLog) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Audit log not bound on context", }); } await auditLog.eraseSubject(input.actorId, input.mode); return { ok: true as const }; }), }); export type AuditRouter = typeof auditRouter; ``` - [ ] **Step 5: Run → PASS** - [ ] **Step 6: Update package barrel** Modify `packages/core-audit/src/index.ts` to add: ```ts export { auditRouter, type AuditRouter } from "./integrations/api/router"; export { createAuditErasureHook, type AuditErasureHookOpts } from "./hooks"; export { pseudonymize } from "./pseudonymize"; ``` - [ ] **Step 7: Commit** ```bash git add packages/core-audit/src/integrations/api/ \ packages/core-audit/src/index.ts git commit -m "feat(core-audit): admin tRPC procedure for eraseSubject" ``` ### Task 3.5: Phase 3 verification gate - [ ] **Step 1: Run all gates** ```bash pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries ``` Expected: all green. (No commit; verification only.) --- ## Phase 4 — OTel correlation bridge **Goal:** `AuditEntry.correlationId` auto-populates from active OTel span. Decorator pattern at bind time. **Files touched:** - Create: `packages/core-shared/src/instrumentation/otel/current-trace-id.ts` (+ test) - Modify: `packages/core-shared/src/instrumentation/otel/index.ts` - Modify: `packages/core-shared/src/instrumentation/index.ts` - Create: `packages/core-audit/src/trace-id-enriching-audit-log.ts` (+ test) - Modify: `packages/core-audit/src/di/bind-audit.ts` (wrap with decorator) - Modify: `packages/core-audit/src/index.ts` ### Task 4.1: currentTraceId helper (TDD) **Files:** - Create: `packages/core-shared/src/instrumentation/otel/current-trace-id.ts` (+ test) - [ ] **Step 1: Write failing test** Create `packages/core-shared/src/instrumentation/otel/current-trace-id.test.ts`: ```ts import { describe, it, expect, beforeAll, afterEach } from "vitest"; import { trace } from "@opentelemetry/api"; import { tracing } from "@opentelemetry/sdk-node"; import { currentTraceId } from "./current-trace-id"; const exporter = new tracing.InMemorySpanExporter(); const provider = new tracing.BasicTracerProvider({ spanProcessors: [new tracing.SimpleSpanProcessor(exporter)], }); beforeAll(() => { trace.setGlobalTracerProvider(provider); }); afterEach(() => exporter.reset()); describe("currentTraceId", () => { it("returns undefined when no active span", () => { expect(currentTraceId()).toBeUndefined(); }); it("returns the active span's traceId when inside startActiveSpan", async () => { const tracer = trace.getTracer("test"); await new Promise((resolve) => { tracer.startActiveSpan("test-span", (span) => { const id = currentTraceId(); expect(id).toBeDefined(); expect(id).toMatch(/^[a-f0-9]{32}$/); span.end(); resolve(); }); }); }); }); ``` - [ ] **Step 2: Run → FAIL** ```bash pnpm --filter @repo/core-shared test current-trace-id.test ``` - [ ] **Step 3: Implement** Create `packages/core-shared/src/instrumentation/otel/current-trace-id.ts`: ```ts import { trace } from "@opentelemetry/api"; /** * Returns the trace ID of the currently active OTel span, or undefined if * there is no active span (e.g., outside any request context, in unit tests * without an OTel SDK). * * Used by core-audit's TraceIdEnrichingAuditLog decorator to auto-populate * AuditEntry.correlationId so callers don't have to thread it explicitly. * * Returns undefined for the all-zeros invalid trace ID — OTel emits this * when context propagation hasn't kicked in. */ export function currentTraceId(): string | undefined { const span = trace.getActiveSpan(); if (!span) return undefined; const ctx = span.spanContext(); if (!ctx.traceId || /^0+$/.test(ctx.traceId)) return undefined; return ctx.traceId; } ``` - [ ] **Step 4: Run → PASS** - [ ] **Step 5: Update barrels** Append to `packages/core-shared/src/instrumentation/otel/index.ts`: ```ts export { currentTraceId } from "./current-trace-id"; ``` Append to `packages/core-shared/src/instrumentation/index.ts`: ```ts export { currentTraceId } from "./otel/current-trace-id"; ``` - [ ] **Step 6: Commit** ```bash git add packages/core-shared/src/instrumentation/otel/current-trace-id.ts \ packages/core-shared/src/instrumentation/otel/current-trace-id.test.ts \ packages/core-shared/src/instrumentation/otel/index.ts \ packages/core-shared/src/instrumentation/index.ts git commit -m "feat(core-shared): currentTraceId helper for OTel-audit correlation bridge" ``` ### Task 4.2: TraceIdEnrichingAuditLog decorator (TDD) **Files:** - Create: `packages/core-audit/src/trace-id-enriching-audit-log.ts` (+ test) - [ ] **Step 1: Write failing test** Create `packages/core-audit/src/trace-id-enriching-audit-log.test.ts`: ```ts import { describe, it, expect, beforeAll, afterEach, vi } from "vitest"; import { trace } from "@opentelemetry/api"; import { tracing } from "@opentelemetry/sdk-node"; import { TraceIdEnrichingAuditLog } from "./trace-id-enriching-audit-log"; import type { AuditEntry } from "@repo/core-shared/audit"; import type { IAuditLog } from "./audit-log.interface"; const exporter = new tracing.InMemorySpanExporter(); const provider = new tracing.BasicTracerProvider({ spanProcessors: [new tracing.SimpleSpanProcessor(exporter)], }); beforeAll(() => trace.setGlobalTracerProvider(provider)); afterEach(() => exporter.reset()); const sample: AuditEntry = { actorId: "user_1", actorType: "user", actorRoles: [], action: "VIEW", resource: { type: "articles" }, at: new Date(), scope: { feature: "blog", environment: "test", tenant: "default" }, from: { ipTruncated: "10.0.0.0", userAgent: "test" }, containsPii: false, outcome: "success", }; function makeInner(): IAuditLog & { records: AuditEntry[] } { const records: AuditEntry[] = []; return { records, async record(e) { records.push(e); }, eraseSubject: vi.fn(), }; } describe("TraceIdEnrichingAuditLog", () => { it("passes through when no active span", async () => { const inner = makeInner(); const wrapper = new TraceIdEnrichingAuditLog(inner); await wrapper.record(sample); expect(inner.records[0]!.correlationId).toBeUndefined(); }); it("auto-populates correlationId from active span", async () => { const inner = makeInner(); const wrapper = new TraceIdEnrichingAuditLog(inner); const tracer = trace.getTracer("test"); await new Promise((resolve) => { tracer.startActiveSpan("test", async (span) => { await wrapper.record(sample); const expected = span.spanContext().traceId; expect(inner.records[0]!.correlationId).toBe(expected); span.end(); resolve(); }); }); }); it("explicit correlationId wins over auto-populated", async () => { const inner = makeInner(); const wrapper = new TraceIdEnrichingAuditLog(inner); const tracer = trace.getTracer("test"); await new Promise((resolve) => { tracer.startActiveSpan("test", async (span) => { await wrapper.record({ ...sample, correlationId: "explicit-trace-id" }); expect(inner.records[0]!.correlationId).toBe("explicit-trace-id"); span.end(); resolve(); }); }); }); it("eraseSubject passes through unchanged", async () => { const eraseSpy = vi.fn(); const inner: IAuditLog = { record: vi.fn(), eraseSubject: eraseSpy }; const wrapper = new TraceIdEnrichingAuditLog(inner); await wrapper.eraseSubject("user_1", "delete"); expect(eraseSpy).toHaveBeenCalledWith("user_1", "delete"); }); }); ``` - [ ] **Step 2: Run → FAIL** - [ ] **Step 3: Implement** Create `packages/core-audit/src/trace-id-enriching-audit-log.ts`: ```ts import type { AuditEntry } from "@repo/core-shared/audit"; import { currentTraceId } from "@repo/core-shared/instrumentation"; import type { IAuditLog } from "./audit-log.interface"; /** * Decorates any IAuditLog by auto-populating AuditEntry.correlationId from * the active OTel span (when present and the caller didn't supply a value). * Caller-supplied correlationId always wins — explicit > implicit. * * Applied at bind time by bindAudit so all sinks see entries with * correlationId already set. Single source of truth for the OTel-audit bridge. */ export class TraceIdEnrichingAuditLog implements IAuditLog { constructor(private readonly inner: IAuditLog) {} async record(entry: AuditEntry): Promise { if (entry.correlationId) { return this.inner.record(entry); } const traceId = currentTraceId(); if (!traceId) { return this.inner.record(entry); } return this.inner.record({ ...entry, correlationId: traceId }); } eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise { return this.inner.eraseSubject(actorId, mode); } } ``` - [ ] **Step 4: Run → PASS** - [ ] **Step 5: Commit** ```bash git add packages/core-audit/src/trace-id-enriching-audit-log.ts \ packages/core-audit/src/trace-id-enriching-audit-log.test.ts git commit -m "feat(core-audit): TraceIdEnrichingAuditLog decorator for OTel correlation" ``` ### Task 4.3: Wire decorator into bindAudit + update barrel **Files:** - Modify: `packages/core-audit/src/di/bind-audit.ts` - Modify: `packages/core-audit/src/di/bind-audit.test.ts` - Modify: `packages/core-audit/src/index.ts` - [ ] **Step 1: Update bindAudit to wrap with the decorator** In `packages/core-audit/src/di/bind-audit.ts`, find the line: ```ts const auditLog: IAuditLog = sinks.length > 1 ? new MultiSinkAuditLog(sinks) : sinks.length === 1 ? sinks[0]! : new NoopAuditLog(); ``` Replace with: ```ts import { TraceIdEnrichingAuditLog } from "../trace-id-enriching-audit-log"; const inner: IAuditLog = sinks.length > 1 ? new MultiSinkAuditLog(sinks) : sinks.length === 1 ? sinks[0]! : new NoopAuditLog(); const auditLog: IAuditLog = new TraceIdEnrichingAuditLog(inner); ``` - [ ] **Step 2: Update bind-audit test** Existing tests assert `instanceof MultiSinkAuditLog` / `instanceof StdoutJsonAuditLog`. Now those instances are wrapped in `TraceIdEnrichingAuditLog`. Update each `expect(auditLog).toBeInstanceOf(X)` to: ```ts expect(auditLog).toBeInstanceOf(TraceIdEnrichingAuditLog); // Use a typed accessor to assert inner type: expect((auditLog as unknown as { inner: unknown }).inner).toBeInstanceOf(X); ``` (Add `import { TraceIdEnrichingAuditLog } from "../trace-id-enriching-audit-log";` to the test file.) - [ ] **Step 3: Run → PASS** ```bash pnpm --filter @repo/core-audit test bind-audit.test ``` - [ ] **Step 4: Update package barrel** Modify `packages/core-audit/src/index.ts` to add: ```ts export { TraceIdEnrichingAuditLog } from "./trace-id-enriching-audit-log"; ``` - [ ] **Step 5: Phase 4 verification gate** ```bash pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries ``` Expected: all green. - [ ] **Step 6: Commit** ```bash git add packages/core-audit/src/di/bind-audit.ts \ packages/core-audit/src/di/bind-audit.test.ts \ packages/core-audit/src/index.ts git commit -m "feat(core-audit): wrap bound auditLog with TraceIdEnrichingAuditLog" ``` --- ## Phase 5 — VIEW capture: createAuditAfterReadHook **Goal:** ship the `afterRead` hook factory for opt-in automatic VIEW capture. **Files touched:** - Create: `packages/core-audit/src/hooks/audit-after-read-hook.ts` (+ test) - Modify: `packages/core-audit/src/hooks/index.ts` - Modify: `packages/core-audit/src/index.ts` ### Task 5.1: createAuditAfterReadHook (TDD) **Files:** - Create: `packages/core-audit/src/hooks/audit-after-read-hook.ts` (+ test) - [ ] **Step 1: Write failing test** Create `packages/core-audit/src/hooks/audit-after-read-hook.test.ts`: ```ts import { describe, it, expect, vi } from "vitest"; import { createAuditAfterReadHook } from "./audit-after-read-hook"; import type { AuditEntry } from "@repo/core-shared/audit"; import type { IAuditLog } from "../audit-log.interface"; function makeAuditLog(): IAuditLog & { recorded: AuditEntry[] } { const recorded: AuditEntry[] = []; return { recorded, async record(e) { recorded.push(e); }, eraseSubject: vi.fn(), }; } function baseOpts(auditLog: IAuditLog) { return { auditLog, resourceType: "users", feature: "auth", environment: "test", resolveTenant: () => "default", containsPii: true, piiCategories: ["email"], }; } describe("createAuditAfterReadHook", () => { it("emits a VIEW entry with the resource type + feature + tenant", async () => { const auditLog = makeAuditLog(); const hook = createAuditAfterReadHook(baseOpts(auditLog)); const doc = { id: "abc", email: "x@y.com" }; const req = { user: { id: "user_1", roles: ["user"] }, headers: { "user-agent": "Mozilla" }, ip: "10.0.0.5" }; await hook({ doc, req } as never); // Wait one tick for fire-and-forget to flush await new Promise((r) => setImmediate(r)); expect(auditLog.recorded).toHaveLength(1); const e = auditLog.recorded[0]!; expect(e.action).toBe("VIEW"); expect(e.resource.type).toBe("users"); expect(e.resource.id).toBe("abc"); expect(e.actorId).toBe("user_1"); expect(e.actorRoles).toEqual(["user"]); expect(e.scope.feature).toBe("auth"); expect(e.scope.tenant).toBe("default"); expect(e.containsPii).toBe(true); expect(e.piiCategories).toEqual(["email"]); expect(e.outcome).toBe("success"); expect(e.from.ipTruncated).toBe("10.0.0.0"); // /24 truncation applied }); it("uses 'system' actor when req.user is null", async () => { const auditLog = makeAuditLog(); const hook = createAuditAfterReadHook(baseOpts(auditLog)); await hook({ doc: { id: "abc" }, req: { user: null, headers: {} } } as never); await new Promise((r) => setImmediate(r)); expect(auditLog.recorded[0]!.actorId).toBe("system"); expect(auditLog.recorded[0]!.actorType).toBe("system"); }); it("falls back to 'internal' / 'payload-internal' sentinels when no IP/UA", async () => { const auditLog = makeAuditLog(); const hook = createAuditAfterReadHook(baseOpts(auditLog)); await hook({ doc: { id: "abc" }, req: { user: null, headers: {} } } as never); await new Promise((r) => setImmediate(r)); expect(auditLog.recorded[0]!.from.ipTruncated).toBe("internal"); expect(auditLog.recorded[0]!.from.userAgent).toBe("payload-internal"); }); it("shouldSkip predicate prevents emission", async () => { const auditLog = makeAuditLog(); const hook = createAuditAfterReadHook({ ...baseOpts(auditLog), shouldSkip: () => true }); await hook({ doc: { id: "abc" }, req: { user: null, headers: {} } } as never); await new Promise((r) => setImmediate(r)); expect(auditLog.recorded).toHaveLength(0); }); it("returns the doc unchanged (afterRead hook contract)", async () => { const auditLog = makeAuditLog(); const hook = createAuditAfterReadHook(baseOpts(auditLog)); const doc = { id: "abc", title: "Hello" }; const result = await hook({ doc, req: { user: null, headers: {} } } as never); expect(result).toBe(doc); }); it("audit-sink failures do not propagate (fire-and-forget)", async () => { const auditLog: IAuditLog = { record: async () => { throw new Error("sink-failed"); }, eraseSubject: vi.fn(), }; const errSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); const hook = createAuditAfterReadHook(baseOpts(auditLog)); await expect( hook({ doc: { id: "abc" }, req: { user: null, headers: {} } } as never), ).resolves.toBeDefined(); // Give the microtask queue a moment to flush the catch handler await new Promise((r) => setImmediate(r)); expect(errSpy).toHaveBeenCalled(); errSpy.mockRestore(); }); }); ``` - [ ] **Step 2: Run → FAIL** - [ ] **Step 3: Implement** Create `packages/core-audit/src/hooks/audit-after-read-hook.ts`: ```ts import type { CollectionAfterReadHook } from "payload"; import type { AuditEntry } from "@repo/core-shared/audit"; import { truncateIp } from "@repo/core-shared/audit"; import type { IAuditLog } from "../audit-log.interface"; export type AuditAfterReadHookOpts = { auditLog: IAuditLog; /** Resource type for AuditEntry.resource.type (e.g., "users"). */ resourceType: string; /** Feature attribution for AuditEntry.scope.feature. */ feature: string; /** Deployment environment. */ environment: string; /** Tenant resolver — single-tenant projects return "default". */ resolveTenant: (req: { user?: { id: string; tenantId?: string } | null }) => string; /** Whether this collection contains PII. Propagates to every entry. */ containsPii: boolean; /** Optional PII categories applicable to all entries from this collection. */ piiCategories?: string[]; /** Optional predicate; return true to skip emitting an entry. */ shouldSkip?: (args: { req: unknown; doc: { id: string | number } }) => boolean; }; /** * Payload afterRead hook factory. Emits a VIEW AuditEntry per document read. * Per-collection opt-in: install via `hooks.afterRead: [createAuditAfterReadHook(...)]` * on the collection config. * * Fire-and-forget: a failing audit sink does NOT propagate up to break the * user-facing read. Failures emit a structured error to stderr (visible to * the same log shipper as audit entries themselves). * * Combine with use-case-level record() calls for app-facing reads; this hook * covers direct CMS/admin/programmatic reads. The use-case path captures * "why" (reason); this hook captures "the system saw this doc". */ export function createAuditAfterReadHook( opts: AuditAfterReadHookOpts, ): CollectionAfterReadHook { return ({ doc, req }) => { if (opts.shouldSkip?.({ req, doc: doc as { id: string | number } })) { return doc; } const actor = (req as { user?: { id: string; roles?: string[]; tenantId?: string } | null }).user; const entry: AuditEntry = { actorId: actor?.id ?? "system", actorType: actor ? "user" : "system", actorRoles: actor?.roles ?? [], action: "VIEW", resource: { type: opts.resourceType, id: typeof doc.id === "string" || typeof doc.id === "number" ? String(doc.id) : undefined, }, at: new Date(), scope: { feature: opts.feature, environment: opts.environment, tenant: opts.resolveTenant(req as { user?: { id: string; tenantId?: string } | null }), }, reason: "payload-afterRead-hook", from: { ipTruncated: extractIpTruncated(req) ?? "internal", userAgent: extractUserAgent(req) ?? "payload-internal", }, containsPii: opts.containsPii, piiCategories: opts.piiCategories, outcome: "success", }; // Fire-and-forget — never break the read. void opts.auditLog.record(entry).catch((err: unknown) => { process.stderr.write( JSON.stringify({ _type: "audit-hook-error", hook: "afterRead", resourceType: opts.resourceType, error: String(err), at: new Date().toISOString(), }) + "\n", ); }); return doc; }; } function extractIpTruncated(req: unknown): string | undefined { const r = req as { ip?: string; headers?: Record }; const rawIp = r.ip ?? r.headers?.["x-forwarded-for"]; if (!rawIp) return undefined; const candidate = Array.isArray(rawIp) ? rawIp[0]! : rawIp.split(",")[0]!.trim(); try { return truncateIp(candidate); } catch { return undefined; } } function extractUserAgent(req: unknown): string | undefined { const r = req as { headers?: Record }; const ua = r.headers?.["user-agent"]; if (!ua) return undefined; return Array.isArray(ua) ? ua[0] : ua; } ``` - [ ] **Step 4: Run → PASS** - [ ] **Step 5: Update hooks barrel + package barrel** Append to `packages/core-audit/src/hooks/index.ts`: ```ts export { createAuditAfterReadHook, type AuditAfterReadHookOpts, } from "./audit-after-read-hook"; ``` Modify `packages/core-audit/src/index.ts` to add: ```ts export { createAuditAfterReadHook, type AuditAfterReadHookOpts } from "./hooks"; ``` - [ ] **Step 6: Phase 5 verification gate** ```bash pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries ``` - [ ] **Step 7: Commit** ```bash git add packages/core-audit/src/hooks/audit-after-read-hook.ts \ packages/core-audit/src/hooks/audit-after-read-hook.test.ts \ packages/core-audit/src/hooks/index.ts \ packages/core-audit/src/index.ts git commit -m "feat(core-audit): createAuditAfterReadHook factory for opt-in VIEW capture" ``` --- ## Phase 6 — ADR-018 + generator template + docs **Goal:** publish ADR-018, write `docs/guides/audit-and-compliance.md`, capture core-audit as a generator template (`pnpm turbo gen core-package audit`), update CLAUDE.md / AGENTS.md / template-tiers / README / data-flow-explainer / scaffolding-doc. **Files touched:** - Create: `docs/decisions/adr-018-audit-and-compliance.md` - Create: `docs/guides/audit-and-compliance.md` - Create: `turbo/generators/templates/core-package/audit/**` (all package files as `.hbs`) - Create: `turbo/generators/__snapshots__/core-package/audit.snapshot.json` - Create: `turbo/generators/__tests__/core-package-audit.e2e.test.ts` - Modify: `turbo/generators/config.ts` (push `audit` entry; add to choices) - Modify: `docs/architecture/template-tiers.md` - Modify: `docs/scaffolding/core-package-generator.md` - Modify: `CLAUDE.md` - Modify: `AGENTS.md` - Modify: `docs/architecture/data-flow-explainer.html` - Modify: `README.md` ### Task 6.1: Write ADR-018 **Files:** - Create: `docs/decisions/adr-018-audit-and-compliance.md` - [ ] **Step 1: Inspect an existing ADR for format** ```bash head -100 docs/decisions/adr-017-opentelemetry-migration.md ``` - [ ] **Step 2: Write the ADR** Create `docs/decisions/adr-018-audit-and-compliance.md`: ```markdown # ADR-018 — Audit Logging & DPA Compliance **Status:** Accepted **Date:** 2026-05-11 **Spec:** docs/superpowers/specs/2026-05-11-audit-and-compliance-design.md **Plan:** docs/superpowers/plans/2026-05-11-audit-and-compliance.md **Companion guide:** docs/guides/audit-and-compliance.md ## Context DPA compliance mandates audit logging for every personal-data access event: VIEW/CREATE/UPDATE/DELETE/EXPORT/PERMISSION_CHANGE, with immutable storage, GDPR-deletable path, centralized aggregation, and strict "what NOT to log" boundaries. The interface decisions from ADR-014 (R31-R51) carry over but audit needs its own channel — observability data is sampled and short-retention, audit data is lossless and long-retention with privileged erasure. ## Decision (12 points) 1. **`AuditLogProtocol` in `core-shared`** — must-have universal surface. Features call `ctx.auditLog?.record(entry)` without importing the optional package. 2. **`AuditEntry` type with closed action enum** — VIEW/CREATE/UPDATE/DELETE/ EXPORT/PERMISSION_CHANGE; new actions require explicit type bump. No payload/body/oldValue/newValue fields — type enforces "what NOT to log". 3. **`@repo/core-audit` as 5th optional package** — joins realtime, events, trpc, ui. Scaffolded via `pnpm turbo gen core-package audit`. 4. **Four impls + Recording test double**: NoopAuditLog, PayloadAuditLog (local cache), StdoutJsonAuditLog (operator ships via Vector/Fluent Bit), MultiSinkAuditLog (fan-out), RecordingAuditLog (core-testing). 5. **Append-only Payload collection** — `update: () => false` access rule is the compliance backbone; erasure path uses `overrideAccess: true`. 6. **GDPR erasure** — sha256-salted pseudonymization (`erased-{hash[0:16]}`) or hard delete. AUDIT_PSEUDONYM_SALT env REQUIRED in production; bind-time validation fails fast. 7. **Erasure trigger surface** — admin tRPC procedure (`audit.eraseSubject`), Payload `afterDelete` hook factory (`createAuditErasureHook`), auth integration via printed generator next-steps (NOT auto-installed). 8. **OTel correlation bridge** — `currentTraceId()` helper in core-shared; `TraceIdEnrichingAuditLog` decorator at bind time auto-populates `AuditEntry.correlationId` from active OTel span. Explicit caller wins. 9. **VIEW capture via BOTH patterns** — use-case `record()` calls (developer decides per-read-path) AND `createAuditAfterReadHook` factory (opt-in per-collection automatic capture). Fire-and-forget for hooks. 10. **IP/UA explicit at call sites** — no AsyncLocalStorage. Callers use `truncateIp(raw)` (/24 IPv4, /48 IPv6) and pass into `record({ from: { ... } })`. Sentinels for non-HTTP context: `"system"` / `"background-job"`. 11. **Multi-tenancy: tenant field required** — `AuditEntry.scope.tenant` non-optional; single-tenant projects pass `"default"`. Forces multi-tenant thinking from day one. 12. **Six-phase delivery** matching established cadence. ## Alternatives considered - **Vendor-coupled SDK (Datadog/Grafana direct)** — rejected; couples to vendor. - **Payload-only sink** — fails compliance (hostile-actor immutability). - **Aggregator-only sink** — fails dev ergonomics. Fan-out is the balance. - **AsyncLocalStorage for request context** — rejected per user preference; explicit > implicit. - **Optional tenant field** — rejected; DPA-aligned scope discipline benefits from forcing the question on every call. ## Consequences **Positive:** - DPA-compliant baseline ships with the optional package. - Vendor-neutral via stdout JSON + log shipper; any aggregator works. - OTel correlation gives compliance auditors one-click pivot to traces. - Type-enforced exclusion of "what not to log" prevents categories of mistakes. **Negative:** - Boilerplate at every record() call site (IP/UA explicit). - core-audit ↔ auth coupling for the user-collection hook is awkward (manual install via generator next-steps). - StdoutJsonAuditLog's eraseSubject is best-effort (tombstone only; past stdout lines can't be retroactively removed). ## Relationship to other ADRs - ADR-014 (instrumentation interfaces): audit is a parallel channel, not a signal flowing through OTel. The correlationId field is the bridge. - ADR-015 (events/jobs): no overlap; audit is observational, events are reactive. - ADR-017 (OTel migration): provides currentTraceId() helper. ``` - [ ] **Step 3: Commit** ```bash git add docs/decisions/adr-018-audit-and-compliance.md git commit -m "docs(adr): ADR-018 audit logging & DPA compliance" ``` ### Task 6.2: Write the audit-and-compliance guide **Files:** - Create: `docs/guides/audit-and-compliance.md` - [ ] **Step 1: Write the guide** Create `docs/guides/audit-and-compliance.md` with sections: What DPA requires; The two-pattern model; When to use which; Wiring core-audit into your app (7 steps); Sample Vector / Fluent Bit configs; GDPR erasure; Sample-week audit verification; Hostile-actor immutability test; Common mistakes. Content (mirror the structure laid out in spec §9.3): ```markdown # Audit logging & DPA compliance > **Prerequisite:** This guide assumes `@repo/core-audit` is scaffolded. If your project started from the slim template, run `pnpm turbo gen core-package audit` first. ## What DPA requires [Summarize the user-provided compliance doc: 6 action types, 4 required fields, immutability rules, "what NOT to log" list, retention 90d hot / 1y archive, deletable on GDPR request.] ## The two-pattern model Two complementary ways to log a VIEW: 1. **Use-case-level `record()` calls** — in your feature's READ use cases, the developer explicitly calls `ctx.auditLog?.record({ action: "VIEW", ... })`. Captures the WHY (reason: "user-profile-render") and works in any context (HTTP, jobs, CLI). 2. **Payload `afterRead` hook (automatic, opt-in)** — install `createAuditAfterReadHook(...)` on a collection. Captures EVERY read of the collection automatically, including admin UI / direct programmatic reads. Use both for collections under DPA scope. The hook covers reads you might forget at the use-case layer; the use-case calls add the contextual reason. ## When to use which | Read source | Pattern | |---|---| | tRPC procedure (app-facing read) | Use-case-level `record()` call | | Payload admin UI | Hook automatically captures | | Background job | Use-case-level `record()` call with `actorId: "system"` | | Direct programmatic / CMS REST | Hook automatically captures | ## Wiring core-audit into your app (7 steps) [Reproduces the 7-step printed next-steps content from `printAuditNextSteps`.] ## Sample log-shipper configs ### Vector [Sample Vector config that reads stdout, filters by `_type: "audit"`, ships to Grafana Loki EU.] ### Fluent Bit [Sample Fluent Bit config equivalent.] ## GDPR erasure Trigger via admin tRPC: [curl example calling `audit.eraseSubject` with admin auth.] Or rely on the user-delete hook: when a user is deleted via Payload admin, `createAuditErasureHook` automatically pseudonymizes their audit history. ## Sample-week audit verification "Can you tell who accessed any given record?" [Query Payload admin → audit-logs → filter by resourceType + resourceId.] ## Hostile-actor immutability test [How to verify the append-only contract: try to update a row via direct DB access; verify Payload's overrideAccess isn't accidentally enabled elsewhere; confirm the stdout shipper has an independent retention.] ## Common mistakes - Forgetting to set `scope.tenant` (required field). - `containsPii: false` on a collection that actually has PII. - Using `oldValue`/`newValue` (those fields don't exist by design — DPA enforcement). - Forgetting `AUDIT_PSEUDONYM_SALT` in production (bindAudit fails at boot). ``` (Each placeholder section above should be expanded to a few paragraphs with real content. The doc is the canonical user-facing reference.) - [ ] **Step 2: Commit** ```bash git add docs/guides/audit-and-compliance.md git commit -m "docs(guide): audit-and-compliance how-to guide" ``` ### Task 6.3: Capture core-audit as a generator template **Files:** - Create: `turbo/generators/templates/core-package/audit/**` (all current `packages/core-audit/` files as `.hbs`) - Create: `turbo/generators/__snapshots__/core-package/audit.snapshot.json` - [ ] **Step 1: Mirror the package tree as `.hbs` siblings** ```bash mkdir -p turbo/generators/templates/core-package/audit/src/{di,integrations/api,hooks} # Top-level files for f in AGENTS.md eslint.config.js package.json tsconfig.json turbo.json vitest.config.ts; do cp packages/core-audit/$f turbo/generators/templates/core-package/audit/$f.hbs done # src files for f in packages/core-audit/src/*.ts; do base=$(basename "$f") cp "$f" "turbo/generators/templates/core-package/audit/src/$base.hbs" done # src/di for f in packages/core-audit/src/di/*.ts; do base=$(basename "$f") cp "$f" "turbo/generators/templates/core-package/audit/src/di/$base.hbs" done # src/hooks for f in packages/core-audit/src/hooks/*.ts; do base=$(basename "$f") cp "$f" "turbo/generators/templates/core-package/audit/src/hooks/$base.hbs" done # src/integrations/api for f in packages/core-audit/src/integrations/api/*.ts; do base=$(basename "$f") cp "$f" "turbo/generators/templates/core-package/audit/src/integrations/api/$base.hbs" done ``` - [ ] **Step 2: Generate the byte-identical snapshot** ```bash pnpm exec tsx <<'TS' import { computeSnapshot } from "./turbo/generators/lib/snapshot.js"; import { writeFileSync } from "node:fs"; const snap = computeSnapshot("./packages/core-audit"); writeFileSync( "./turbo/generators/__snapshots__/core-package/audit.snapshot.json", JSON.stringify(snap, null, 2) + "\n", ); console.log(`Wrote ${snap.length} entries`); TS ``` - [ ] **Step 3: Commit** ```bash git add turbo/generators/templates/core-package/audit \ turbo/generators/__snapshots__/core-package/audit.snapshot.json git commit -m "feat(generators): capture core-audit as verbatim template files" ``` ### Task 6.4: Wire audit entry into the core-package generator + e2e test **Files:** - Modify: `turbo/generators/config.ts` - Create: `turbo/generators/__tests__/core-package-audit.e2e.test.ts` - [ ] **Step 1: Find an existing per-package e2e test to mirror** ```bash cat turbo/generators/__tests__/core-package-events.e2e.test.ts ``` - [ ] **Step 2: Add audit entry to CORE_PACKAGE_GENERATORS + choices** In `turbo/generators/config.ts`, find `CORE_PACKAGE_GENERATORS` and the `choices` list. Add: ```ts // Add to choices array: choices: ["realtime", "events", "trpc", "ui", "audit"], // Add to CORE_PACKAGE_GENERATORS dispatch table: audit: () => [ () => assertOptionalPackageNotPresent("core-audit"), ...emitTemplateTree("core-package/audit", "packages/core-audit"), () => addToTranspilePackages("apps/web-next/next.config.mjs", "@repo/core-audit"), () => printAuditNextSteps(), ], ``` Also add the `printAuditNextSteps()` function alongside the other print functions: ```ts function printAuditNextSteps(): string { return [ "─────────────────────────────────────────────────────────────", "@repo/core-audit scaffolded into packages/core-audit/.", "", "Manual wiring required (compliance-critical):", "", "1. Set AUDIT_PSEUDONYM_SALT env var (production REQUIRED):", " export AUDIT_PSEUDONYM_SALT=\"$(openssl rand -hex 32)\"", " Add to your deployment secrets manager.", "", "2. Mount the audit-logs Payload collection in packages/core-cms/src/payload.config.ts:", " import { auditLogsCollection } from \"@repo/core-audit/collection\";", " // collections: [..., auditLogsCollection],", "", "3. Mount the admin tRPC router in packages/core-api/src/root.ts:", " import { auditRouter } from \"@repo/core-audit/api\";", " // routers: { ..., audit: auditRouter },", "", "4. Bind audit in apps/web-next/src/server/bind-production.ts:", " const { bindAudit } = await import(\"@repo/core-audit/di\");", " const { auditLog } = bindAudit(sharedContainer, {", " payloadConfig: resolvedConfig,", " sinks: [\"payload\", \"stdout\"],", " });", "", "5. Install user-collection hooks (recommended for DPA compliance):", " In packages/auth/src/di/bind-production.ts, gate on ctx.auditLog:", " if (ctx.auditLog) {", " const { createAuditErasureHook, createAuditAfterReadHook } =", " await import(\"@repo/core-audit/hooks\");", " // wire onto users collection — see docs/guides/audit-and-compliance.md", " }", "", "6. Set up a log shipper (Vector / Fluent Bit) to forward stdout JSON to", " your aggregator. See docs/guides/audit-and-compliance.md for configs.", "", "7. Verify:", " pnpm install", " pnpm lint && pnpm typecheck && pnpm test", " pnpm turbo boundaries", "", "See docs/guides/audit-and-compliance.md for the full guide.", "─────────────────────────────────────────────────────────────", ].join("\n"); } ``` - [ ] **Step 3: Create e2e test** Create `turbo/generators/__tests__/core-package-audit.e2e.test.ts`: ```ts import { describe, it, expect } from "vitest"; import { mkdtempSync, cpSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { execSync } from "node:child_process"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { computeSnapshot } from "../lib/snapshot.js"; import expectedSnapshot from "../__snapshots__/core-package/audit.snapshot.json" with { type: "json" }; const REPO_ROOT = resolve(fileURLToPath(import.meta.url), "..", "..", "..", ".."); function stripDep(pkgJsonPath: string, depName: string): void { const raw = readFileSync(pkgJsonPath, "utf8"); const parsed = JSON.parse(raw) as Record>; for (const section of ["dependencies", "devDependencies", "peerDependencies"] as const) { if (parsed[section]?.[depName]) { delete parsed[section][depName]; } } writeFileSync(pkgJsonPath, JSON.stringify(parsed, null, 2) + "\n"); } describe("e2e: core-package audit", () => { it( "byte-identical reconstruction matches snapshot", { timeout: 120_000 }, () => { const tmp = mkdtempSync(join(tmpdir(), "e2e-audit-")); cpSync(REPO_ROOT, tmp, { recursive: true, filter: (src) => !src.includes("node_modules") && !src.includes(".turbo") && !src.includes(".git") && !src.includes("packages/core-audit"), }); // Strip @repo/core-audit refs from package.jsons so install succeeds without the package stripDep(join(tmp, "apps/web-next/package.json"), "@repo/core-audit"); execSync(`cd ${tmp} && pnpm install --silent`, { stdio: "inherit" }); execSync(`cd ${tmp} && pnpm turbo gen core-package --args audit`, { stdio: "inherit" }); const result = computeSnapshot(join(tmp, "packages/core-audit")); expect(result).toEqual(expectedSnapshot); }, ); }); ``` - [ ] **Step 4: Run the e2e test** ```bash pnpm --filter @repo/turbo-generators test core-package-audit.e2e ``` Expected: PASS (~30-60s). - [ ] **Step 5: Commit** ```bash git add turbo/generators/config.ts turbo/generators/__tests__/core-package-audit.e2e.test.ts git commit -m "feat(generators): wire audit entry + e2e byte-identical reconstruction test" ``` ### Task 6.5: Update doc surfaces **Files:** - Modify: `docs/architecture/template-tiers.md` - Modify: `docs/scaffolding/core-package-generator.md` - Modify: `CLAUDE.md` - Modify: `AGENTS.md` - Modify: `docs/architecture/data-flow-explainer.html` - Modify: `README.md` - [ ] **Step 1: template-tiers.md** Open `docs/architecture/template-tiers.md`. Find the optional packages table. Add a row for core-audit: ```markdown | core-audit | `pnpm turbo gen core-package audit` | ADR-018 | docs/guides/audit-and-compliance.md | ``` - [ ] **Step 2: scaffolding-doc** Open `docs/scaffolding/core-package-generator.md`. Find the templates table and add audit: ```markdown | `audit` | DPA-compliant audit logging (ADR-018) | Phase 7 | ``` - [ ] **Step 3: CLAUDE.md** Find the Project Overview block in `CLAUDE.md` and update the optional packages list to include `core-audit`. Find the "Read first" section and add the audit guide: ```markdown - `docs/guides/audit-and-compliance.md` — DPA-compliant audit logging cookbook (*requires `gen core-package audit`*) ``` - [ ] **Step 4: AGENTS.md** Find the section that lists optional packages. Add `@repo/core-audit` (optional). Add audit row to the generator list if there is one. - [ ] **Step 5: data-flow-explainer.html** Open `docs/architecture/data-flow-explainer.html`. Find where realtime/events/trpc/ui are marked as conditional (dashed lines / optional tag). Add a similar audit-layer marker. If the explainer doesn't model audit yet, add a brief layer entry between "tRPC" and "Storage". - [ ] **Step 6: README.md** Find the Optional packages section. Add: ```bash pnpm turbo gen core-package audit # DPA-compliant audit logging (ADR-018) ``` - [ ] **Step 7: Commit** ```bash git add docs/architecture/template-tiers.md \ docs/scaffolding/core-package-generator.md \ CLAUDE.md AGENTS.md \ docs/architecture/data-flow-explainer.html \ README.md git commit -m "docs: surface core-audit as 5th optional package across discovery points" ``` ### Task 6.6: Final verification gate - [ ] **Step 1: Run all gates from repo root** ```bash pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries ``` Expected: all green. - [ ] **Step 2: Confirm e2e test passes** ```bash pnpm --filter @repo/turbo-generators test ``` Expected: 5 byte-identical reconstruction tests pass (realtime, events, trpc, ui, audit). - [ ] **Step 3: No commit — verification only** Plan complete. --- ## Notes for the executing agent - Phases 1 → 6 are sequenced. Don't start a phase until the previous one's gates are green. - The most subtle piece is Task 4.3's wrapper test update — `instanceof MultiSinkAuditLog` no longer matches because the returned `auditLog` is a `TraceIdEnrichingAuditLog`. The fix is to assert on `.inner` (or use a typed accessor); the test code in Task 4.3 step 2 spells this out. - Phase 6's e2e test (Task 6.4) requires the byte-identical snapshot generated in Task 6.3 to match exactly. If the snapshot was generated AFTER any changes to `packages/core-audit/` (Phases 1-5 should be done first), this is fine. If you regenerate Phase 1-5 between snapshot generation and the e2e run, snapshots won't match. Generate snapshot LAST. - The `printAuditNextSteps()` function in Task 6.4 has 7 manual wiring steps. They're long but each is concrete (copy-paste-ready code blocks). Mirror the existing `printRealtimeNextSteps()` / `printEventsNextSteps()` patterns for formatting. - Auth feature is NOT modified by this plan. Audit's user-collection hook installation is documented in the generator's next-steps; downstream consumers wire it manually after scaffolding. This keeps core-audit truly optional. - Commit cadence: ~25-30 commits across the six phases. Each commit should leave the repo in a green-gate state.