Implementation plan for the spec at docs/superpowers/specs/2026-05-11- audit-and-compliance-design.md. Six phases: - Phase 1: AuditLogProtocol + AuditEntry type + truncateIp helper in core-shared; BindContext.auditLog? (5th generic). - Phase 2: @repo/core-audit optional package — NoopAuditLog, StdoutJsonAuditLog, append-only audit-logs Payload collection, PayloadAuditLog (record), MultiSinkAuditLog fan-out, bindAudit binder with prod salt validation, RecordingAuditLog in core-testing. - Phase 3: GDPR erasure plumbing — pseudonymize helper (sha256 + AUDIT_PSEUDONYM_SALT), PayloadAuditLog.eraseSubject via overrideAccess, createAuditErasureHook Payload afterDelete factory, admin tRPC procedure (audit.eraseSubject). - Phase 4: OTel correlation bridge — currentTraceId() in core-shared, TraceIdEnrichingAuditLog decorator wraps inner sinks at bindAudit time. Explicit caller correlationId wins. - Phase 5: createAuditAfterReadHook factory for opt-in per-collection VIEW capture; fire-and-forget semantics with stderr fallback. - Phase 6: ADR-018 + audit-and-compliance.md guide + generator template at turbo/generators/templates/core-package/audit/ + byte-identical snapshot + e2e test + 6 doc surface refreshes (template-tiers, scaffolding-doc, CLAUDE.md, AGENTS.md, data-flow-explainer, README). Total: ~30 commits expected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
107 KiB
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
currentTraceIdcallsite
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:
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<AuditAction>().toEqualTypeOf<
"VIEW" | "CREATE" | "UPDATE" | "DELETE" | "EXPORT" | "PERMISSION_CHANGE"
>();
});
});
describe("AuditFrom", () => {
it("requires ipTruncated and userAgent", () => {
expectTypeOf<AuditFrom>().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<AuditEntry>();
});
it("makes optional fields actually optional", () => {
type Entry = AuditEntry;
type OptionalKeys = "changedFields" | "reason" | "correlationId" | "requestId" | "piiCategories" | "errorCode";
expectTypeOf<Pick<Entry, OptionalKeys>>().toEqualTypeOf<Partial<Pick<Entry, OptionalKeys>>>();
});
});
- Step 2: Run test → FAIL
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:
/**
* 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
pnpm --filter @repo/core-shared test audit-entry.test
Expected: PASS, 3 tests.
- Step 5: Commit
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:
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
pnpm --filter @repo/core-shared test truncate-ip.test
- Step 3: Implement
Create packages/core-shared/src/audit/truncate-ip.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
pnpm --filter @repo/core-shared test truncate-ip.test
- Step 5: Commit
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:
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<void>;
};
(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:
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:
export * from "./audit";
- Step 4: Add subpath export
In packages/core-shared/package.json, add to exports (alphabetically among existing entries):
"./audit": "./src/audit/index.ts",
- Step 5: Verify
pnpm --filter @repo/core-shared typecheck
pnpm --filter @repo/core-shared test
Expected: PASS.
- Step 6: Commit
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:
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<Bus, Realtime, RealtimeReg, Metrics, Audit> & {
config: SanitizedConfig;
};
- Step 2: Verify backward compat
pnpm typecheck
Expected: clean. Existing 4-generic callers still resolve because the 5th generic has a default.
- Step 3: Commit
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
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(afterpnpm 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
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
mkdir -p packages/core-audit/src/{di,integrations/api,hooks}
- Step 3: package.json
Create packages/core-audit/package.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:
{
"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:
{
"extends": ["//"],
"tags": ["core"]
}
- Step 6: vitest.config.ts
Create packages/core-audit/vitest.config.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:
import base from "@repo/core-eslint/base";
export default [...base];
- Step 8: AGENTS.md
Create packages/core-audit/AGENTS.md:
# @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
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
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:
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<void> — inherited from protocol
eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void>;
}
// 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:
export const AUDIT_SYMBOLS = {
IAuditLog: Symbol.for("core-audit:IAuditLog"),
} as const;
- Step 3: Verify
pnpm --filter @repo/core-audit typecheck
- Step 4: Commit
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:
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
pnpm --filter @repo/core-audit test noop-audit-log.test
- Step 3: Implement
Create packages/core-audit/src/noop-audit-log.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<void> {
// intentional no-op
}
async eraseSubject(_actorId: string, _mode: "pseudonymize" | "delete"): Promise<void> {
// intentional no-op
}
}
-
Step 4: Run → PASS
-
Step 5: Commit
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:
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<typeof vi.spyOn>;
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:
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<void> {
const serialized = JSON.stringify({
_type: "audit",
...entry,
at: entry.at.toISOString(),
});
process.stdout.write(serialized + "\n");
}
async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void> {
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
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:
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<string, () => 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
pnpm --filter @repo/core-audit test audit-logs-collection.test
- Step 3: Implement
Create packages/core-audit/src/audit-logs-collection.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
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:
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<string, unknown> };
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:
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<string, unknown> }) => Promise<unknown>;
find: (args: {
collection: string;
where: Record<string, unknown>;
limit: number;
overrideAccess: true;
}) => Promise<{ docs: Array<{ id: string | number }> }>;
update: (args: {
collection: string;
id: string | number;
data: Record<string, unknown>;
overrideAccess: true;
}) => Promise<unknown>;
delete: (args: {
collection: string;
where: Record<string, unknown>;
overrideAccess: true;
}) => Promise<unknown>;
}>;
/**
* 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<void> {
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<void> {
// Implemented in Phase 3.
throw new Error("PayloadAuditLog.eraseSubject not yet implemented (Phase 3)");
}
}
-
Step 4: Run → PASS
-
Step 5: Commit
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:
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:
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<void> {
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<void> {
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
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:
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<IAuditLog>(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<IAuditLog>(AUDIT_SYMBOLS.IAuditLog);
expect(auditLog).toBeInstanceOf(StdoutJsonAuditLog);
});
it("returns NoopAuditLog when sinks=[]", () => {
const container = new Container();
bindAudit(container, { sinks: [] });
const auditLog = container.get<IAuditLog>(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<IAuditLog>(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:
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<IAuditLog>(AUDIT_SYMBOLS.IAuditLog).toConstantValue(auditLog);
return { auditLog };
}
-
Step 4: Run → PASS
-
Step 5: Commit
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:
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
pnpm --filter @repo/core-testing test recording-audit-log.test
- Step 3: Implement
Create packages/core-testing/src/instrumentation/recording-audit-log.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<void> {
this.recorded.push(entry);
}
async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void> {
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:
export { RecordingAuditLog } from "./recording-audit-log";
- Step 6: Commit
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:
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
pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries
Expected: all green.
- Step 3: Commit
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:
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:
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
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:
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
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:
import { pseudonymize } from "./pseudonymize";
// ... existing class ...
async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void> {
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
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:
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:
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:
export {
createAuditErasureHook,
type AuditErasureHookOpts,
} from "./audit-erasure-hook";
-
Step 5: Run → PASS
-
Step 6: Commit
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:
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<TrpcContext>().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:
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:
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<void> } }).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:
export { auditRouter, type AuditRouter } from "./integrations/api/router";
export { createAuditErasureHook, type AuditErasureHookOpts } from "./hooks";
export { pseudonymize } from "./pseudonymize";
- Step 7: Commit
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
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:
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<void>((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
pnpm --filter @repo/core-shared test current-trace-id.test
- Step 3: Implement
Create packages/core-shared/src/instrumentation/otel/current-trace-id.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:
export { currentTraceId } from "./current-trace-id";
Append to packages/core-shared/src/instrumentation/index.ts:
export { currentTraceId } from "./otel/current-trace-id";
- Step 6: Commit
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:
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<void>((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<void>((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:
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<void> {
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<void> {
return this.inner.eraseSubject(actorId, mode);
}
}
-
Step 4: Run → PASS
-
Step 5: Commit
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:
const auditLog: IAuditLog =
sinks.length > 1 ? new MultiSinkAuditLog(sinks)
: sinks.length === 1 ? sinks[0]!
: new NoopAuditLog();
Replace with:
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:
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
pnpm --filter @repo/core-audit test bind-audit.test
- Step 4: Update package barrel
Modify packages/core-audit/src/index.ts to add:
export { TraceIdEnrichingAuditLog } from "./trace-id-enriching-audit-log";
- Step 5: Phase 4 verification gate
pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries
Expected: all green.
- Step 6: Commit
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:
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:
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<string, string | string[] | undefined> };
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<string, string | string[] | undefined> };
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:
export {
createAuditAfterReadHook,
type AuditAfterReadHookOpts,
} from "./audit-after-read-hook";
Modify packages/core-audit/src/index.ts to add:
export { createAuditAfterReadHook, type AuditAfterReadHookOpts } from "./hooks";
- Step 6: Phase 5 verification gate
pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries
- Step 7: Commit
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(pushauditentry; 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
head -100 docs/decisions/adr-017-opentelemetry-migration.md
- Step 2: Write the ADR
Create docs/decisions/adr-018-audit-and-compliance.md:
# 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
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):
# 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
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 currentpackages/core-audit/files as.hbs) -
Create:
turbo/generators/__snapshots__/core-package/audit.snapshot.json -
Step 1: Mirror the package tree as
.hbssiblings
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
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
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
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:
// 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:
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:
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<string, Record<string, string>>;
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
pnpm --filter @repo/turbo-generators test core-package-audit.e2e
Expected: PASS (~30-60s).
- Step 5: Commit
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:
| 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:
| `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:
- `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:
pnpm turbo gen core-package audit # DPA-compliant audit logging (ADR-018)
- Step 7: Commit
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
pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries
Expected: all green.
- Step 2: Confirm e2e test passes
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 MultiSinkAuditLogno longer matches because the returnedauditLogis aTraceIdEnrichingAuditLog. 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 existingprintRealtimeNextSteps()/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.