Initial commit

This commit is contained in:
fraqtal
2026-07-12 08:15:46 +00:00
commit ee0fec0691
1397 changed files with 127242 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
# @repo/core-analytics
Optional core package providing a vendor-neutral product analytics interface. Scaffold via `pnpm turbo gen core-package analytics`.
## Structure
```
src/
analytics.interface.ts # IAnalytics — track, identify, pageView, flush
noop-analytics.ts # NoopAnalytics (default no-op implementation)
index.ts # Barrel export
```
## Design
`IAnalytics` exposes four methods:
- `track(event, attributes?)` — record a named event with optional attributes
- `identify(user)` — associate subsequent events with a user
- `pageView(path, attributes?)` — record a page-view event
- `flush()` — drain any in-flight queued events (returns `Promise<void>`)
The interface is vendor-neutral: no third-party analytics SDK is bundled. Feature
packages depend on `IAnalytics` only; concrete implementations (e.g. a PostHog
or Segment adapter) are wired at DI bind time in `bind-production`.
`NoopAnalytics` is the default implementation — all methods are no-ops and
`flush()` resolves immediately via `Promise.resolve()`. Use it in dev-seed
bindings and unit tests.
See `docs/architecture/agent-first-workflow-and-conformance.md` for the
dependency-injection conventions.

View File

@@ -0,0 +1,3 @@
import baseConfig from "@repo/core-eslint/base";
export default baseConfig;

View File

@@ -0,0 +1,23 @@
{
"name": "@repo/core-analytics",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"devDependencies": {
"@repo/core-eslint": "workspace:*",
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@vitest/coverage-v8": "^3.0.0",
"typescript": "^5.8.0",
"vitest": "^3.0.0"
}
}

View File

@@ -0,0 +1,2 @@
// placeholder — populated by story 01-scaffold-core-analytics-package
export {};

View File

@@ -0,0 +1,12 @@
{
"extends": "@repo/core-typescript/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,4 @@
{
"extends": ["//"],
"tags": ["core"]
}

View File

@@ -0,0 +1,9 @@
import path from "node:path";
import { mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
export default mergeConfig(nodeVitestConfig, {
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
});

View File

@@ -0,0 +1,28 @@
# @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.

View File

@@ -0,0 +1,68 @@
---
package: "@trpc/server"
version: "^11.0.0"
tier: core
decision: approved
date: 2026-05-14
deciders: [scaffolded]
adr: adr-018
filter-results:
license: MIT
types: native
maintenance: active
boundary-fit: pass
shadow-check: pass
eu-residency: n/a
cve-scan: clean
named-consumer: pass
verification-commands:
- pnpm audit --audit-level=moderate
- npm view @trpc/server license
accepted-cves: []
---
## Filter: license
MIT — on the workspace allowlist.
## Filter: types
Ships first-party TypeScript types; fully type-safe by design.
## Filter: maintenance
Active. Maintained by the tRPC team; v11 is the current stable line.
## Filter: boundary-fit
Core package. `@trpc/server` is already present in `core-api` (workspace dependency). Using it in `core-audit` for the audit API router does not violate boundary rules.
## Filter: shadow-check
`@trpc/server` is workspace-present via `core-api`. Same major version; no shadow.
## Filter: eu-residency
Server-side RPC library; no vendor data transmission. n/a.
## Filter: cve-scan
No advisories at adoption time.
## Filter: named-consumer
`core-audit` exposes an audit-log tRPC router used by the `apps/web-next` API layer.
## Prompt: replaces
Nothing new — tRPC is already the API layer; this extends it to the audit surface.
## Prompt: migration-cost-out
Hard: router procedures, input schemas, and error-mapping middleware are tRPC-shaped. Removal would require replacing the audit API surface.
## Prompt: alternatives-considered
1. **REST endpoints** — loses end-to-end type safety already established via tRPC.
2. **GraphQL** — much heavier; not aligned with existing workspace API shape.
tRPC is the locked workspace API library; extending it to audit is the natural fit.

View File

@@ -0,0 +1,66 @@
---
package: zod
version: "^3.23.0"
tier: core
decision: approved
date: 2026-05-14
deciders: [scaffolded]
adr: adr-018
filter-results:
license: MIT
types: native
maintenance: active
boundary-fit: pass
shadow-check: pass
eu-residency: n/a
cve-scan: clean
named-consumer: pass
verification-commands:
- pnpm audit --audit-level=moderate
- npm view zod license
accepted-cves: []
---
## Filter: license
MIT — on the workspace allowlist.
## Filter: types
Ships first-party TypeScript types in its distribution (`.d.ts` included).
## Filter: maintenance
Active. Regular releases by Colin McDonnell; widely adopted.
## Filter: boundary-fit
Core package. Zod is the workspace-canonical validation library locked in `core-shared` (ADR-018).
## Filter: shadow-check
Zod is already the workspace-locked validation library. No shadow.
## Filter: eu-residency
Pure computation; no network calls or vendor data transmission. n/a.
## Filter: cve-scan
No advisories at adoption time.
## Filter: named-consumer
`core-audit` uses zod to validate audit-log record input schemas.
## Prompt: replaces
Nothing — zod is the pre-existing workspace validation library.
## Prompt: migration-cost-out
Mechanical: swap schema definitions at call sites. No data-format lock-in.
## Prompt: alternatives-considered
Zod is workspace-locked (see `core-shared`). A replacement would require a workspace-wide ADR; no alternative was evaluated here.

View File

@@ -0,0 +1,3 @@
import baseConfig from "@repo/core-eslint/base";
export default baseConfig;

View File

@@ -0,0 +1,46 @@
{
"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": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.55.0",
"@opentelemetry/context-async-hooks": "^1.28.0",
"@opentelemetry/sdk-trace-base": "^1.27.0",
"@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"
}
}

View File

@@ -0,0 +1,22 @@
import type { AuditLogProtocol } from "@repo/core-shared/di/bind-protocols";
import type { 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 };

View File

@@ -0,0 +1,47 @@
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) | undefined>;
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);
});
});

View File

@@ -0,0 +1,82 @@
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" },
],
};

View File

@@ -0,0 +1,58 @@
import "reflect-metadata";
import { describe, it, expect } 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 { TraceIdEnrichingAuditLog } from "../trace-id-enriching-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(TraceIdEnrichingAuditLog);
expect((auditLog as unknown as { inner: unknown }).inner).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(TraceIdEnrichingAuditLog);
expect((auditLog as unknown as { inner: unknown }).inner).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(TraceIdEnrichingAuditLog);
expect((auditLog as unknown as { inner: unknown }).inner).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(TraceIdEnrichingAuditLog);
expect((auditLog as unknown as { inner: unknown }).inner).toBeInstanceOf(PayloadAuditLog);
});
it("validates AUDIT_PSEUDONYM_SALT in production", () => {
const env = process.env as Record<string, string | undefined>;
const oldEnv = env["NODE_ENV"];
const oldSalt = env["AUDIT_PSEUDONYM_SALT"];
env["NODE_ENV"] = "production";
delete env["AUDIT_PSEUDONYM_SALT"];
expect(() => bindAudit(new Container(), { sinks: ["stdout"] })).toThrow(
/AUDIT_PSEUDONYM_SALT/,
);
env["NODE_ENV"] = oldEnv;
if (oldSalt) env["AUDIT_PSEUDONYM_SALT"] = oldSalt;
});
});

View File

@@ -0,0 +1,66 @@
import "reflect-metadata";
import type { Container } from "inversify";
import { getPayload as _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";
import { TraceIdEnrichingAuditLog } from "../trace-id-enriching-audit-log";
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.
*
* The returned auditLog is wrapped in TraceIdEnrichingAuditLog
* so all sinks receive AuditEntry.correlationId auto-populated from the
* active OTel span. The inner sink/fan-out is accessible via `.inner`.
*/
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) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
sinks.push(new PayloadAuditLog(opts.payloadConfig, _getPayload as any));
}
if (sinkList.includes("stdout")) {
sinks.push(new StdoutJsonAuditLog());
}
const inner: IAuditLog =
sinks.length > 1 ? new MultiSinkAuditLog(sinks)
: sinks.length === 1 ? sinks[0]!
: new NoopAuditLog();
const auditLog: IAuditLog = new TraceIdEnrichingAuditLog(inner);
if (container.isBound(AUDIT_SYMBOLS.IAuditLog)) {
container.unbind(AUDIT_SYMBOLS.IAuditLog);
}
container.bind<IAuditLog>(AUDIT_SYMBOLS.IAuditLog).toConstantValue(auditLog);
return { auditLog };
}

View File

@@ -0,0 +1,3 @@
export const AUDIT_SYMBOLS = {
IAuditLog: Symbol.for("core-audit:IAuditLog"),
} as const;

View File

@@ -0,0 +1,103 @@
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();
});
});

View File

@@ -0,0 +1,105 @@
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 async ({ 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;
}

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, vi } from "vitest";
import { createAuditErasureHook } from "./audit-erasure-hook";
import type { IAuditLog } from "../audit-log.interface";
function makeAuditLog(): IAuditLog {
return {
record: vi.fn().mockResolvedValue(undefined),
eraseSubject: vi.fn().mockResolvedValue(undefined),
};
}
/** Minimal CollectionAfterDeleteHook args shape (only `doc` matters here). */
function hookArgs(id: unknown) {
return {
doc: { id },
req: {} as never,
id: String(id),
collection: {} as never,
context: {},
};
}
describe("createAuditErasureHook", () => {
it("defaults to 'pseudonymize' mode", async () => {
const auditLog = makeAuditLog();
const hook = createAuditErasureHook({ auditLog });
await hook(hookArgs("user_1") as never);
expect(auditLog.eraseSubject).toHaveBeenCalledWith("user_1", "pseudonymize");
});
it("respects explicit mode='delete'", async () => {
const auditLog = makeAuditLog();
const hook = createAuditErasureHook({ auditLog, mode: "delete" });
await hook(hookArgs("user_2") as never);
expect(auditLog.eraseSubject).toHaveBeenCalledWith("user_2", "delete");
});
it("coerces numeric id to string", async () => {
const auditLog = makeAuditLog();
const hook = createAuditErasureHook({ auditLog });
await hook(hookArgs(42) as never);
expect(auditLog.eraseSubject).toHaveBeenCalledWith("42", "pseudonymize");
});
it("skips when doc.id is undefined", async () => {
const auditLog = makeAuditLog();
const hook = createAuditErasureHook({ auditLog });
await hook(hookArgs(undefined) as never);
expect(auditLog.eraseSubject).not.toHaveBeenCalled();
});
it("skips when doc.id is null", async () => {
const auditLog = makeAuditLog();
const hook = createAuditErasureHook({ auditLog });
await hook(hookArgs(null) as never);
expect(auditLog.eraseSubject).not.toHaveBeenCalled();
});
it("skips when doc.id is an object", async () => {
const auditLog = makeAuditLog();
const hook = createAuditErasureHook({ auditLog });
await hook(hookArgs({ nested: true }) as never);
expect(auditLog.eraseSubject).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,38 @@
import type { CollectionAfterDeleteHook } from "payload";
import type { IAuditLog } from "../audit-log.interface";
export type AuditErasureHookOpts = {
/** The audit log impl that will perform the erasure. */
auditLog: IAuditLog;
/**
* Erasure mode. Defaults to "pseudonymize" — the softer option that
* retains the audit trail shape while removing PII linkage. Use
* "delete" only when the data-subject specifically requests hard removal.
*/
mode?: "pseudonymize" | "delete";
};
/**
* Payload `afterDelete` hook factory for GDPR erasure.
*
* Wire this on any collection whose `id` doubles as an audit subject
* (e.g., the users collection). When Payload deletes a document, the
* hook calls `auditLog.eraseSubject(String(doc.id), mode)`, removing
* or pseudonymizing all audit entries recorded for that actor.
*
* The hook has no schema-specific knowledge — it works on any collection
* that stores the subject identifier as its document `id`.
*
* Non-string, non-numeric ids are silently skipped (safe guard against
* undefined/null that Payload may produce in edge cases).
*/
export function createAuditErasureHook(
opts: AuditErasureHookOpts,
): CollectionAfterDeleteHook {
const mode = opts.mode ?? "pseudonymize";
return async ({ doc }) => {
if (typeof doc.id === "string" || typeof doc.id === "number") {
await opts.auditLog.eraseSubject(String(doc.id), mode);
}
};
}

View File

@@ -0,0 +1,8 @@
export {
createAuditErasureHook,
type AuditErasureHookOpts,
} from "./audit-erasure-hook";
export {
createAuditAfterReadHook,
type AuditAfterReadHookOpts,
} from "./audit-after-read-hook";

View File

@@ -0,0 +1,30 @@
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 { TraceIdEnrichingAuditLog } from "./trace-id-enriching-audit-log";
export { AUDIT_SYMBOLS } from "./di/symbols";
// GDPR erasure
export { pseudonymize } from "./pseudonymize";
export {
createAuditErasureHook,
type AuditErasureHookOpts,
} from "./hooks/audit-erasure-hook";
// VIEW capture
export {
createAuditAfterReadHook,
type AuditAfterReadHookOpts,
} from "./hooks";
export {
createAuditRouter,
auditRouter,
type AuditRouter,
} from "./integrations/api/router";
export {
auditProcedure,
type AdminTrpcUser,
} from "./integrations/api/procedures";

View File

@@ -0,0 +1,43 @@
import { TRPCError } from "@trpc/server";
import { t } from "@repo/core-shared/trpc/init";
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
/**
* The minimum user shape that the adminOnly middleware expects to find on `ctx`.
* Apps must include this field when creating their tRPC context for requests
* that may reach admin procedures. Unauthenticated requests leave `user`
* undefined, which the middleware treats as non-admin.
*/
export type AdminTrpcUser = {
roles: string[];
};
/**
* Middleware that blocks non-admin callers.
*
* Reads `ctx.user?.roles` from the tRPC context. Throws FORBIDDEN if the
* user is absent or lacks the "admin" role. Apps that mount the auditRouter
* must set `ctx.user` with the authenticated user's roles.
*/
const adminOnly = t.middleware(({ ctx, next }) => {
const user = (ctx as { user?: AdminTrpcUser }).user;
if (!user?.roles.includes("admin")) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Admin role required",
});
}
return next({ ctx: { ...ctx, user } });
});
/**
* Base procedure for all audit admin routes.
*
* - `adminOnly` middleware gates every mutation/query.
* - `defineErrorMiddleware([])` — no audit-specific domain errors need tRPC
* mapping; the FORBIDDEN thrown by `adminOnly` is a plain TRPCError and
* propagates unchanged.
*/
export const auditProcedure = t.procedure
.use(adminOnly)
.use(defineErrorMiddleware([]));

View File

@@ -0,0 +1,93 @@
import { describe, it, expect, vi } from "vitest";
import { createAuditRouter } from "./router";
import type { IAuditLog } from "../../audit-log.interface";
import type { AdminTrpcUser } from "./procedures";
/**
* Minimal harness: create a tRPC caller directly from the router so we
* don't need a real HTTP layer.
*/
function makeCallerWithUser(
auditLog: IAuditLog,
user?: AdminTrpcUser,
) {
const router = createAuditRouter(auditLog);
// Use the tRPC caller factory to invoke mutations directly in tests.
return router.createCaller({ user } as Record<string, unknown>);
}
function makeAuditLog(): IAuditLog {
return {
record: vi.fn().mockResolvedValue(undefined),
eraseSubject: vi.fn().mockResolvedValue(undefined),
};
}
describe("auditRouter.eraseSubject", () => {
it("throws FORBIDDEN when ctx.user is absent", async () => {
const auditLog = makeAuditLog();
const caller = makeCallerWithUser(auditLog, undefined);
await expect(
caller.eraseSubject({ actorId: "user_1", mode: "pseudonymize" }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(auditLog.eraseSubject).not.toHaveBeenCalled();
});
it("throws FORBIDDEN when user lacks admin role", async () => {
const auditLog = makeAuditLog();
const caller = makeCallerWithUser(auditLog, { roles: ["editor", "viewer"] });
await expect(
caller.eraseSubject({ actorId: "user_1", mode: "pseudonymize" }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(auditLog.eraseSubject).not.toHaveBeenCalled();
});
it("calls eraseSubject with pseudonymize mode for an admin user", async () => {
const auditLog = makeAuditLog();
const caller = makeCallerWithUser(auditLog, { roles: ["admin"] });
const result = await caller.eraseSubject({
actorId: "user_1",
mode: "pseudonymize",
});
expect(result).toEqual({ ok: true });
expect(auditLog.eraseSubject).toHaveBeenCalledWith("user_1", "pseudonymize");
});
it("calls eraseSubject with delete mode for an admin user", async () => {
const auditLog = makeAuditLog();
const caller = makeCallerWithUser(auditLog, { roles: ["admin"] });
const result = await caller.eraseSubject({
actorId: "user_2",
mode: "delete",
});
expect(result).toEqual({ ok: true });
expect(auditLog.eraseSubject).toHaveBeenCalledWith("user_2", "delete");
});
it("defaults mode to 'pseudonymize' when not provided", async () => {
const auditLog = makeAuditLog();
const caller = makeCallerWithUser(auditLog, { roles: ["admin"] });
// mode has a .default("pseudonymize") in the schema
await caller.eraseSubject({ actorId: "user_3", mode: "pseudonymize" });
expect(auditLog.eraseSubject).toHaveBeenCalledWith("user_3", "pseudonymize");
});
it("rejects empty actorId (schema validation)", async () => {
const auditLog = makeAuditLog();
const caller = makeCallerWithUser(auditLog, { roles: ["admin"] });
await expect(
caller.eraseSubject({ actorId: "", mode: "pseudonymize" }),
).rejects.toThrow();
});
});

View File

@@ -0,0 +1,54 @@
import { z } from "zod";
import { t } from "@repo/core-shared/trpc/init";
import type { IAuditLog } from "../../audit-log.interface";
import { auditProcedure } from "./procedures";
/**
* Creates the audit admin tRPC router.
*
* The `auditLog` parameter is captured at router-creation time. Apps that
* mount this router must pass the `IAuditLog` impl returned by `bindAudit`.
*
* @example
* ```ts
* const { auditLog } = bindAudit(container, { payloadConfig, sinks: ["payload", "stdout"] });
* const appRouter = t.router({ ..., audit: createAuditRouter(auditLog) });
* ```
*/
export function createAuditRouter(auditLog: IAuditLog) {
return t.router({
eraseSubject: auditProcedure
.input(
z
.object({
actorId: z.string().min(1),
mode: z.enum(["pseudonymize", "delete"]).default("pseudonymize"),
})
.strict(),
)
.mutation(async ({ input }) => {
await auditLog.eraseSubject(input.actorId, input.mode);
return { ok: true as const };
}),
});
}
/**
* Convenience singleton for projects that have a single audit log instance.
* Most callers should use `createAuditRouter` and pass the IAuditLog explicitly.
* This export is a stub that throws at call time if auditLog has not been
* provided — it exists for type inference purposes (`AuditRouter`).
*/
export const auditRouter = createAuditRouter(
new Proxy({} as IAuditLog, {
get(_target, prop) {
if (prop === "then") return undefined; // not a Promise
throw new Error(
`auditRouter singleton used without providing an IAuditLog. ` +
`Use createAuditRouter(auditLog) instead.`,
);
},
}),
);
export type AuditRouter = ReturnType<typeof createAuditRouter>;

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, vi } from "vitest";
import { MultiSinkAuditLog } from "./multi-sink-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"]);
});
});

View File

@@ -0,0 +1,45 @@
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");
}
}

View File

@@ -0,0 +1,29 @@
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();
});
});

View File

@@ -0,0 +1,11 @@
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
}
}

View File

@@ -0,0 +1,128 @@
import { describe, it, expect, vi, beforeEach, afterEach } 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");
});
});
describe("PayloadAuditLog.eraseSubject", () => {
const originalSalt = process.env["AUDIT_PSEUDONYM_SALT"];
beforeEach(() => {
process.env["AUDIT_PSEUDONYM_SALT"] = "test-salt-erase";
});
afterEach(() => {
if (originalSalt === undefined) {
delete process.env["AUDIT_PSEUDONYM_SALT"];
} else {
process.env["AUDIT_PSEUDONYM_SALT"] = originalSalt;
}
});
it("mode='delete' calls payload.delete with the correct where clause + overrideAccess", async () => {
const mockDelete = vi.fn().mockResolvedValue({});
const mockGetPayload = vi.fn().mockResolvedValue({ delete: mockDelete });
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: Record<string, unknown>;
overrideAccess: boolean;
};
expect(call.collection).toBe("audit-logs");
expect(call.where).toEqual({ actorId: { equals: "user_1" } });
expect(call.overrideAccess).toBe(true);
});
it("mode='pseudonymize' finds matching docs and updates each actorId to the pseudonym", async () => {
const mockFind = vi.fn().mockResolvedValue({
docs: [{ id: "doc_a" }, { id: "doc_b" }],
});
const mockUpdate = vi.fn().mockResolvedValue({});
const mockGetPayload = vi.fn().mockResolvedValue({
find: mockFind,
update: mockUpdate,
});
const log = new PayloadAuditLog({} as never, mockGetPayload);
await log.eraseSubject("user_1", "pseudonymize");
// find must use overrideAccess + limit=10_000
const findCall = mockFind.mock.calls[0]![0] as {
collection: string;
where: Record<string, unknown>;
limit: number;
overrideAccess: boolean;
};
expect(findCall.collection).toBe("audit-logs");
expect(findCall.where).toEqual({ actorId: { equals: "user_1" } });
expect(findCall.limit).toBe(10_000);
expect(findCall.overrideAccess).toBe(true);
// update called for each doc
expect(mockUpdate).toHaveBeenCalledTimes(2);
const updateCalls = mockUpdate.mock.calls as Array<
[{ collection: string; id: string; data: Record<string, unknown>; overrideAccess: boolean }]
>;
expect(updateCalls[0]![0].id).toBe("doc_a");
expect(updateCalls[1]![0].id).toBe("doc_b");
// both updates replace actorId with the same pseudonym
const pseudonym = updateCalls[0]![0].data["actorId"] as string;
expect(pseudonym).toMatch(/^erased-[0-9a-f]{16}$/);
expect(updateCalls[1]![0].data["actorId"]).toBe(pseudonym);
// overrideAccess bypasses the append-only rule
expect(updateCalls[0]![0].overrideAccess).toBe(true);
});
it("mode='pseudonymize' with no matching docs does not call update", async () => {
const mockFind = vi.fn().mockResolvedValue({ docs: [] });
const mockUpdate = vi.fn();
const mockGetPayload = vi.fn().mockResolvedValue({ find: mockFind, update: mockUpdate });
const log = new PayloadAuditLog({} as never, mockGetPayload);
await log.eraseSubject("unknown_user", "pseudonymize");
expect(mockUpdate).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,103 @@
import type { SanitizedConfig } from "payload";
import type { AuditEntry } from "@repo/core-shared/audit";
import type { IAuditLog } from "./audit-log.interface";
import { pseudonymize } from "./pseudonymize";
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> {
const payload = await this.getPayload({ config: this.config });
if (mode === "delete") {
await payload.delete({
collection: "audit-logs",
where: { actorId: { equals: actorId } },
overrideAccess: true,
});
return;
}
// mode === "pseudonymize"
// Fetch all matching docs. Limit is 10_000 — a subject with more than
// 10k audit entries will not have all entries pseudonymized in one call.
// This is an accepted v1 limitation; callers may loop if needed.
const { docs } = await payload.find({
collection: "audit-logs",
where: { actorId: { equals: actorId } },
limit: 10_000,
overrideAccess: true,
});
const pseudonym = pseudonymize(actorId);
for (const doc of docs) {
await payload.update({
collection: "audit-logs",
id: doc.id,
data: { actorId: pseudonym },
overrideAccess: true,
});
}
}
}

View File

@@ -0,0 +1,58 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { pseudonymize } from "./pseudonymize";
describe("pseudonymize", () => {
const originalSalt = process.env["AUDIT_PSEUDONYM_SALT"];
beforeEach(() => {
process.env["AUDIT_PSEUDONYM_SALT"] = "test-salt-1";
});
afterEach(() => {
if (originalSalt === undefined) {
delete process.env["AUDIT_PSEUDONYM_SALT"];
} else {
process.env["AUDIT_PSEUDONYM_SALT"] = originalSalt;
}
});
it("returns a string prefixed with 'erased-'", () => {
const result = pseudonymize("user_42");
expect(result).toMatch(/^erased-/);
});
it("produces exactly 16 hex chars after the prefix", () => {
const result = pseudonymize("user_42");
const hex = result.slice("erased-".length);
expect(hex).toHaveLength(16);
expect(hex).toMatch(/^[0-9a-f]+$/);
});
it("is deterministic — same salt + actorId always yields the same token", () => {
const a = pseudonymize("user_42");
const b = pseudonymize("user_42");
expect(a).toBe(b);
});
it("differs when actorId differs (same salt)", () => {
const a = pseudonymize("user_42");
const b = pseudonymize("user_99");
expect(a).not.toBe(b);
});
it("differs when the salt changes", () => {
const withSalt1 = pseudonymize("user_42");
process.env["AUDIT_PSEUDONYM_SALT"] = "test-salt-2";
const withSalt2 = pseudonymize("user_42");
expect(withSalt1).not.toBe(withSalt2);
});
it("uses the fallback salt when env var is absent", () => {
delete process.env["AUDIT_PSEUDONYM_SALT"];
// Should not throw; just use the fallback.
const result = pseudonymize("user_1");
expect(result).toMatch(/^erased-[0-9a-f]{16}$/);
});
});

View File

@@ -0,0 +1,22 @@
import { createHash } from "node:crypto";
/**
* Produces a stable, irreversible token for a GDPR-erased actorId.
*
* Format: `erased-<first-16-hex-chars-of-sha256(salt:actorId)>`
*
* The salt is read from `AUDIT_PSEUDONYM_SALT` env at call time so that
* production binding can pre-validate the var at boot (see `bindAudit`)
* while tests can override it per-test via `process.env`.
*
* Fallback salt is intentionally weak and labelled so that any token
* produced with it is recognisable as a dev/test artefact.
*/
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)}`;
}

View File

@@ -0,0 +1,49 @@
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", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let writeSpy: ReturnType<typeof vi.spyOn<any, any>>;
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
});
});

View File

@@ -0,0 +1,35 @@
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");
}
}

View File

@@ -0,0 +1,104 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { context, trace } from "@opentelemetry/api";
import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { TraceIdEnrichingAuditLog } from "./trace-id-enriching-audit-log";
import type { AuditEntry } from "@repo/core-shared/audit";
import type { IAuditLog } from "./audit-log.interface";
// Register async context manager so startActiveSpan propagates context.
const ctxManager = new AsyncLocalStorageContextManager();
ctxManager.enable();
context.setGlobalContextManager(ctxManager);
function setupProvider(): { exporter: InMemorySpanExporter; provider: BasicTracerProvider } {
const exporter = new InMemorySpanExporter();
const provider = new BasicTracerProvider({
spanProcessors: [new SimpleSpanProcessor(exporter)],
});
trace.setGlobalTracerProvider(provider);
return { exporter, provider };
}
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", () => {
let exporter: InMemorySpanExporter;
let provider: BasicTracerProvider;
beforeEach(() => {
({ exporter, provider } = setupProvider());
});
afterEach(async () => {
await provider.shutdown();
trace.disable();
void exporter;
});
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");
});
});

View File

@@ -0,0 +1,30 @@
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(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);
}
}

View File

@@ -0,0 +1,12 @@
{
"extends": "@repo/core-typescript/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,4 @@
{
"extends": ["//"],
"tags": ["core"]
}

View File

@@ -0,0 +1,9 @@
import path from "node:path";
import { mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
export default mergeConfig(nodeVitestConfig, {
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
});

View File

@@ -0,0 +1,29 @@
# @repo/core-consent
Optional core package providing a vendor-neutral consent management interface. Scaffold via `pnpm turbo gen core-package consent`.
## Structure
```
src/
consent-types.ts # ConsentCategory, ConsentState, UserConsentState
consent.interface.ts # IConsent — isGranted, grant, withdraw, getCategories
with-consent.ts # withConsent wrapper attaching ConsentChecked brand
index.ts # Barrel export
```
## Design
`IConsent` exposes four methods:
- `isGranted(category)` — synchronous check whether consent is granted
- `grant(category)` — record consent grant for a category
- `withdraw(category)` — record consent withdrawal for a category
- `getCategories()` — list all known consent states
The interface is vendor-neutral: no storage implementation is bundled here. Concrete implementations (e.g. a Payload-backed store) are wired at DI bind time in `bind-production`.
`withConsent` wraps a use-case factory at bind time, attaches the `__consentChecked` brand, and is the innermost wrapper in the composition chain:
`withSpan → withCapture → withAudit → withAnalytics → withConsent → factory(deps)`
See `docs/architecture/agent-first-workflow-and-conformance.md` for the dependency-injection conventions.

View File

@@ -0,0 +1,3 @@
import baseConfig from "@repo/core-eslint/base";
export default baseConfig;

View File

@@ -0,0 +1,26 @@
{
"name": "@repo/core-consent",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@repo/core-shared": "workspace:*"
},
"devDependencies": {
"@repo/core-eslint": "workspace:*",
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@vitest/coverage-v8": "^3.0.0",
"typescript": "^5.8.0",
"vitest": "^3.0.0"
}
}

View File

@@ -0,0 +1,2 @@
// placeholder — populated by story 03-core-consent-foundation
export {};

View File

@@ -0,0 +1,12 @@
{
"extends": "@repo/core-typescript/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,4 @@
{
"extends": ["//"],
"tags": ["core"]
}

View File

@@ -0,0 +1,9 @@
import path from "node:path";
import { mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
export default mergeConfig(nodeVitestConfig, {
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
});

View File

@@ -0,0 +1,32 @@
# @repo/core-dsr
Optional core package providing GDPR Data Subject Rights (DSR) interfaces and implementations. Scaffold via `pnpm turbo gen core-package dsr`.
## Structure
```
src/
data-export.interface.ts # IDataExport — exportSubjectData
data-delete.interface.ts # IDataDelete — deleteSubjectData
data-rectify.interface.ts # IDataRectify — updateSubjectField
processing-restriction.interface.ts # IProcessingRestriction — setRestriction, isRestricted
dsr-types.ts # UserDataBundle, DeletionCertificate, DSR value types
contexts/
user-data.jsonld # schema.org JSON-LD @context
index.ts # Barrel export
```
## Design
Four interfaces map directly to GDPR Articles 1518 + 20:
- `IDataExport` (Art. 15/20) — export a subject's data as `UserDataBundle`
- `IDataDelete` (Art. 17) — soft-delete or cascade-hard-delete subject data; returns `DeletionCertificate`
- `IDataRectify` (Art. 16) — update a specific field for a subject
- `IProcessingRestriction` (Art. 18) — toggle and read the processing restriction flag
Implementations walk `custom.pii`-tagged fields and `custom.subject`-linked collections. Row semantics:
- `kind: "self" | "owner"` → directly owned by the subject (export full; delete hard or soft)
- `kind: "reference"` → references the subject from another entity (export redacted; redact link on delete)
See `docs/architecture/agent-first-workflow-and-conformance.md` for the DI conventions.

View File

@@ -0,0 +1,3 @@
import baseConfig from "@repo/core-eslint/base";
export default baseConfig;

View File

@@ -0,0 +1,27 @@
{
"name": "@repo/core-dsr",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@repo/core-shared": "workspace:*",
"zod": "^3.24.0"
},
"devDependencies": {
"@repo/core-eslint": "workspace:*",
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@vitest/coverage-v8": "^3.0.0",
"typescript": "^5.8.0",
"vitest": "^3.0.0"
}
}

View File

@@ -0,0 +1,2 @@
// placeholder — populated by story 06-core-dsr
export {};

View File

@@ -0,0 +1,12 @@
{
"extends": "@repo/core-typescript/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,4 @@
{
"extends": ["//"],
"tags": ["core"]
}

View File

@@ -0,0 +1,9 @@
import path from "node:path";
import { mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
export default mergeConfig(nodeVitestConfig, {
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
});

View File

@@ -0,0 +1,9 @@
# @repo/core-events
Owns the cross-feature event bus: `IEventBus`, `defineEvent`, and two implementations (`InMemoryEventBus`, `PayloadJobsEventBus`).
**Boundary tag:** core. May be imported by feature, core, core-composition, app. May import from core-shared, tooling.
**Public surface:** `IEventBus`, `EventDescriptor`, `defineEvent`, `EventHandler`, `CORE_EVENTS_SYMBOLS`, both implementations.
**See:** `docs/decisions/adr-015-events-and-jobs.md` (pending), `docs/guides/events-and-jobs.md` (pending), `docs/superpowers/specs/2026-05-08-events-and-jobs-design.md`.

View File

@@ -0,0 +1,66 @@
---
package: zod
version: "^3.23.0"
tier: core
decision: approved
date: 2026-05-14
deciders: [scaffolded]
adr: adr-015
filter-results:
license: MIT
types: native
maintenance: active
boundary-fit: pass
shadow-check: pass
eu-residency: n/a
cve-scan: clean
named-consumer: pass
verification-commands:
- pnpm audit --audit-level=moderate
- npm view zod license
accepted-cves: []
---
## Filter: license
MIT — on the workspace allowlist.
## Filter: types
Ships first-party TypeScript types in its distribution (`.d.ts` included).
## Filter: maintenance
Active. Regular releases by Colin McDonnell; widely adopted.
## Filter: boundary-fit
Core package. Zod is the workspace-canonical validation library locked in `core-shared` (ADR-015).
## Filter: shadow-check
Zod is already the workspace-locked validation library. No shadow.
## Filter: eu-residency
Pure computation; no network calls or vendor data transmission. n/a.
## Filter: cve-scan
No advisories at adoption time.
## Filter: named-consumer
`core-events` uses zod for event-descriptor payload schemas.
## Prompt: replaces
Nothing — zod is the pre-existing workspace validation library.
## Prompt: migration-cost-out
Mechanical: swap schema definitions at call sites. No data-format lock-in.
## Prompt: alternatives-considered
Zod is workspace-locked (see `core-shared`). A replacement would require a workspace-wide ADR; no alternative was evaluated here.

View File

@@ -0,0 +1,3 @@
import baseConfig from "@repo/core-eslint/base";
export default baseConfig;

View File

@@ -0,0 +1,32 @@
{
"name": "@repo/core-events",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@repo/core-shared": "workspace:*",
"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:*",
"typescript": "^5.8.0",
"vitest": "^3.0.0"
}
}

View File

@@ -0,0 +1,24 @@
import type { z } from "zod";
import type { EventBusProtocol } from "@repo/core-shared/di/bind-protocols";
import type { EventDescriptor } from "./event-descriptor";
export type EventHandler<T> = (event: T) => Promise<void>;
export interface IEventBus extends EventBusProtocol {
publish<T>(
descriptor: EventDescriptor<string, z.ZodType<T>>,
payload: T,
): Promise<void>;
/**
* Subscribe a handler. `consumerFeature` is the kebab-case name of the
* subscribing feature (e.g., "marketing-pages"). It is unused by
* InMemoryEventBus; PayloadJobsEventBus uses it to name the fan-out task
* slug deterministically (`__events.<event>.<consumerFeature>`).
*/
subscribe<T>(
descriptor: EventDescriptor<string, z.ZodType<T>>,
consumerFeature: string,
handler: EventHandler<T>,
): void;
}

View File

@@ -0,0 +1,24 @@
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { defineEvent } from "@/event-descriptor";
describe("defineEvent", () => {
it("returns a descriptor with name and schema", () => {
const schema = z.object({ id: z.string() }).strict();
const descriptor = defineEvent("test.thing.happened", schema);
expect(descriptor.name).toBe("test.thing.happened");
expect(descriptor.schema).toBe(schema);
});
it("descriptor.schema parses valid payloads", () => {
const schema = z.object({ id: z.string() }).strict();
const d = defineEvent("test.evt", schema);
expect(() => d.schema.parse({ id: "abc" })).not.toThrow();
});
it("descriptor.schema rejects invalid payloads", () => {
const schema = z.object({ id: z.string() }).strict();
const d = defineEvent("test.evt", schema);
expect(() => d.schema.parse({ id: 123 })).toThrow();
});
});

View File

@@ -0,0 +1,13 @@
import type { z } from "zod";
export type EventDescriptor<TName extends string, TSchema extends z.ZodType> = {
readonly name: TName;
readonly schema: TSchema;
};
export function defineEvent<TName extends string, TSchema extends z.ZodType>(
name: TName,
schema: TSchema,
): EventDescriptor<TName, TSchema> {
return { name, schema };
}

View File

@@ -0,0 +1,49 @@
import { describe, it, expect, vi } from "vitest";
import { z } from "zod";
import { defineEvent } from "@/event-descriptor";
import { InMemoryEventBus } from "@/in-memory-event-bus";
const evt = defineEvent("test.thing", z.object({ id: z.string() }).strict());
describe("InMemoryEventBus", () => {
it("validates the payload via the descriptor's schema before fanout", async () => {
const bus = new InMemoryEventBus();
const handler = vi.fn();
bus.subscribe(evt, "test-consumer", handler);
await expect(bus.publish(evt, { id: 123 } as unknown as { id: string })).rejects.toThrow();
expect(handler).not.toHaveBeenCalled();
});
it("delivers to all registered handlers in parallel", async () => {
const bus = new InMemoryEventBus();
const a = vi.fn();
const b = vi.fn();
bus.subscribe(evt, "consumer-a", a);
bus.subscribe(evt, "consumer-b", b);
await bus.publish(evt, { id: "x" });
expect(a).toHaveBeenCalledWith({ id: "x" });
expect(b).toHaveBeenCalledWith({ id: "x" });
});
it("swallows handler errors by default (publisher's publish does not throw)", async () => {
const bus = new InMemoryEventBus();
bus.subscribe(evt, "boom", async () => {
throw new Error("subscriber blew up");
});
await expect(bus.publish(evt, { id: "x" })).resolves.toBeUndefined();
});
it("rethrows the first handler error when failFast is true", async () => {
const bus = new InMemoryEventBus({ failFast: true });
bus.subscribe(evt, "first", async () => {
throw new Error("first failure");
});
bus.subscribe(evt, "second", vi.fn());
await expect(bus.publish(evt, { id: "x" })).rejects.toThrow("first failure");
});
it("delivers nothing when no handlers are registered", async () => {
const bus = new InMemoryEventBus();
await expect(bus.publish(evt, { id: "x" })).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,42 @@
import type { z } from "zod";
import type { EventDescriptor } from "./event-descriptor";
import type { EventHandler, IEventBus } from "./event-bus.interface";
export type InMemoryEventBusOptions = {
/** When true, rethrow the first handler error (default: false — errors swallowed). */
failFast?: boolean;
};
export class InMemoryEventBus implements IEventBus {
private readonly handlers = new Map<string, EventHandler<unknown>[]>();
constructor(private readonly options: InMemoryEventBusOptions = {}) {}
async publish<T>(
descriptor: EventDescriptor<string, z.ZodType<T>>,
payload: T,
): Promise<void> {
descriptor.schema.parse(payload);
const subscribers = this.handlers.get(descriptor.name) ?? [];
if (subscribers.length === 0) return;
const settled = await Promise.allSettled(
subscribers.map((h) => h(payload)),
);
if (this.options.failFast) {
const failure = settled.find((s) => s.status === "rejected");
// Only the first rejection is rethrown. Other failures are intentionally
// dropped — `failFast` is a test-affordance, not a fault-tolerance design.
if (failure && failure.status === "rejected") throw failure.reason;
}
}
subscribe<T>(
descriptor: EventDescriptor<string, z.ZodType<T>>,
_consumerFeature: string,
handler: EventHandler<T>,
): void {
const arr = this.handlers.get(descriptor.name) ?? [];
arr.push(handler as EventHandler<unknown>);
this.handlers.set(descriptor.name, arr);
}
}

View File

@@ -0,0 +1,6 @@
export type { EventDescriptor } from "./event-descriptor";
export { defineEvent } from "./event-descriptor";
export type { IEventBus, EventHandler } from "./event-bus.interface";
export { CORE_EVENTS_SYMBOLS } from "./symbols";
export { InMemoryEventBus, type InMemoryEventBusOptions } from "./in-memory-event-bus";
export { PayloadJobsEventBus } from "./payload-jobs-event-bus";

View File

@@ -0,0 +1,51 @@
import { describe, it, expect, vi } from "vitest";
import { z } from "zod";
import { defineEvent } from "@/event-descriptor";
import { PayloadJobsEventBus } from "@/payload-jobs-event-bus";
import type { IJobQueue } from "@repo/core-shared/jobs";
const evt = defineEvent("auth.user.signed-up", z.object({ userId: z.string() }).strict());
function recordingQueue(): IJobQueue & { enqueued: { taskSlug: string; input: unknown }[] } {
const enqueued: { taskSlug: string; input: unknown }[] = [];
const q: IJobQueue = {
async enqueue(taskSlug, input) {
enqueued.push({ taskSlug, input });
return { jobId: `recording-${enqueued.length}` };
},
};
return Object.assign(q, { enqueued });
}
describe("PayloadJobsEventBus", () => {
it("validates the payload before enqueueing", async () => {
const queue = recordingQueue();
const bus = new PayloadJobsEventBus(queue);
bus.subscribe(evt, "marketing-pages", vi.fn());
await expect(
bus.publish(evt, { userId: 42 } as unknown as { userId: string }),
).rejects.toThrow();
expect(queue.enqueued).toHaveLength(0);
});
it("enqueues one task per subscriber, naming `__events.<event>.<consumer>`", async () => {
const queue = recordingQueue();
const bus = new PayloadJobsEventBus(queue);
bus.subscribe(evt, "marketing-pages", vi.fn());
bus.subscribe(evt, "blog", vi.fn());
await bus.publish(evt, { userId: "u1" });
expect(queue.enqueued).toHaveLength(2);
expect(queue.enqueued.map((e) => e.taskSlug).sort()).toEqual([
"__events.auth.user.signed-up.blog",
"__events.auth.user.signed-up.marketing-pages",
]);
expect(queue.enqueued[0]!.input).toEqual({ userId: "u1" });
});
it("enqueues nothing when no subscribers are registered", async () => {
const queue = recordingQueue();
const bus = new PayloadJobsEventBus(queue);
await bus.publish(evt, { userId: "u1" });
expect(queue.enqueued).toHaveLength(0);
});
});

View File

@@ -0,0 +1,43 @@
import type { z } from "zod";
import type { IJobQueue } from "@repo/core-shared/jobs";
import type { EventDescriptor } from "./event-descriptor";
import type { EventHandler, IEventBus } from "./event-bus.interface";
/**
* Production-grade bus: for each subscriber, enqueues one Payload task per
* `publish()` call. Subscribers register with their consumer-feature name so
* fan-out tasks are named deterministically: `__events.<event>.<consumer>`.
* The actual handler invocation happens inside Payload's job runner — see the
* matching task config generated by `gen event consume` (Task 39).
*/
export class PayloadJobsEventBus implements IEventBus {
private readonly subscribers = new Map<string, string[]>();
constructor(private readonly queue: IJobQueue) {}
async publish<T>(
descriptor: EventDescriptor<string, z.ZodType<T>>,
payload: T,
): Promise<void> {
descriptor.schema.parse(payload);
const consumers = this.subscribers.get(descriptor.name) ?? [];
await Promise.all(
consumers.map((consumerFeature) =>
this.queue.enqueue(
`__events.${descriptor.name}.${consumerFeature}`,
payload,
),
),
);
}
subscribe<T>(
descriptor: EventDescriptor<string, z.ZodType<T>>,
consumerFeature: string,
_handler: EventHandler<T>,
): void {
const arr = this.subscribers.get(descriptor.name) ?? [];
if (!arr.includes(consumerFeature)) arr.push(consumerFeature);
this.subscribers.set(descriptor.name, arr);
}
}

View File

@@ -0,0 +1,3 @@
export const CORE_EVENTS_SYMBOLS = {
IEventBus: Symbol.for("@repo/core-events/IEventBus"),
} as const;

View File

@@ -0,0 +1,12 @@
{
"extends": "@repo/core-typescript/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,4 @@
{
"extends": ["//"],
"tags": ["core"]
}

View File

@@ -0,0 +1,9 @@
import path from "node:path";
import { mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
export default mergeConfig(nodeVitestConfig, {
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
});

View File

@@ -0,0 +1,34 @@
// packages/core-eslint/rules/no-direct-socket-io.js
const ALLOWED = [
/\/packages\/core-realtime\/src\//,
/\/apps\/[^/]+\/server\.ts$/,
/\/apps\/[^/]+\/src\/.*\.test\.ts$/,
];
export default {
meta: {
type: "problem",
docs: { description: "Block direct socket.io imports outside core-realtime + app servers" },
messages: {
noDirectSocketIO: 'Import from "@repo/core-realtime" instead of "socket.io". Direct imports allowed only in packages/core-realtime/src/ and apps/*/server.ts.',
noDirectSocketIOClient: 'Use the realtime helpers from "@repo/core-realtime" / "@repo/core-testing/instrumentation" instead of "socket.io-client".',
},
schema: [],
},
create(context) {
const filename = context.filename ?? context.getFilename();
const allowed = ALLOWED.some((re) => re.test(filename));
if (allowed) return {};
return {
ImportDeclaration(node) {
const source = node.source.value;
if (source === "socket.io") {
context.report({ node, messageId: "noDirectSocketIO" });
} else if (source === "socket.io-client") {
context.report({ node, messageId: "noDirectSocketIOClient" });
}
},
};
},
};

View File

@@ -0,0 +1,33 @@
// packages/core-eslint/rules/no-direct-socket-io.test.js
import { RuleTester } from "eslint";
import rule from "./no-direct-socket-io.js";
const tester = new RuleTester({
languageOptions: { ecmaVersion: 2022, sourceType: "module" },
});
tester.run("no-direct-socket-io", rule, {
valid: [
// Allowed inside core-realtime
{ code: 'import { Server } from "socket.io";', filename: "/repo/packages/core-realtime/src/socket-io-realtime-server.ts" },
// Allowed in app servers
{ code: 'import { Server } from "socket.io";', filename: "/repo/apps/web-next/server.ts" },
// Allowed in app integration tests (e.g. realtime-ping e2e)
{ code: 'import { Server } from "socket.io";', filename: "/repo/apps/web-next/src/__tests__/realtime-ping.test.ts" },
{ code: 'import { io } from "socket.io-client";', filename: "/repo/apps/web-next/src/__tests__/realtime-ping.test.ts" },
// Allowed elsewhere when not importing socket.io
{ code: 'import { foo } from "bar";', filename: "/repo/packages/blog/src/foo.ts" },
],
invalid: [
{
code: 'import { Server } from "socket.io";',
filename: "/repo/packages/blog/src/foo.ts",
errors: [{ messageId: "noDirectSocketIO" }],
},
{
code: 'import { io } from "socket.io-client";',
filename: "/repo/packages/blog/src/ui/Component.tsx",
errors: [{ messageId: "noDirectSocketIOClient" }],
},
],
});

View File

@@ -0,0 +1,47 @@
// packages/core-eslint/rules/no-realtime-handler-reexport.js
// Realtime handlers are private. A feature's realtime/handlers/*.handler.ts
// must only be wired in the feature's own bind-production / bind-dev-seed files.
// They must never be re-exported from barrel files or other public surfaces.
const BIND_FILE = /\bdi\/bind-(?:production|dev-seed)\b/;
const REALTIME_HANDLERS_IN_SOURCE = /\/realtime\/handlers\//;
const HANDLERS_IN_SOURCE = /\/handlers\//;
const REALTIME_IN_FILENAME = /\/realtime\//;
export default {
meta: {
type: "problem",
docs: {
description:
"Block re-exports of realtime/handlers/** outside feature bind-* files (ADR-016 R1)",
},
messages: {
noRealtimeHandlerReexport:
"Realtime handlers (realtime/handlers/*.handler.ts) must not be re-exported (ADR-016 R1). " +
"Wire them only inside the feature's own bind-production / bind-dev-seed files.",
},
schema: [],
},
create(context) {
const filename = context.filename ?? context.getFilename();
// Bind-* files are the only allowed place for these exports/imports
if (BIND_FILE.test(filename)) return {};
function checkExportSource(node) {
if (!node.source) return;
const source = node.source.value;
const isRealtimeHandler =
REALTIME_HANDLERS_IN_SOURCE.test(source) ||
(HANDLERS_IN_SOURCE.test(source) && REALTIME_IN_FILENAME.test(filename));
if (isRealtimeHandler) {
context.report({ node, messageId: "noRealtimeHandlerReexport" });
}
}
return {
ExportNamedDeclaration: checkExportSource,
ExportAllDeclaration: checkExportSource,
};
},
};

View File

@@ -0,0 +1,35 @@
// packages/core-eslint/rules/no-realtime-handler-reexport.test.js
import { RuleTester } from "eslint";
import rule from "./no-realtime-handler-reexport.js";
const tester = new RuleTester({
languageOptions: { ecmaVersion: 2022, sourceType: "module" },
});
tester.run("no-realtime-handler-reexport", rule, {
valid: [
// Importing a handler from inside a feature's bind-* file is allowed.
{
code: 'import { onPingHandler } from "../realtime/handlers/on-ping.handler";',
filename: "/repo/packages/blog/src/di/bind-production.ts",
},
// Re-exporting a channel descriptor is allowed.
{
code: 'export { presenceChannel } from "./realtime/presence.channel";',
filename: "/repo/packages/blog/src/index.ts",
},
],
invalid: [
// Re-exporting a handler from any non-bind file is forbidden.
{
code: 'export { onPingHandler } from "./realtime/handlers/on-ping.handler";',
filename: "/repo/packages/blog/src/index.ts",
errors: [{ messageId: "noRealtimeHandlerReexport" }],
},
{
code: 'export * from "./handlers/on-ping.handler";',
filename: "/repo/packages/blog/src/realtime/index.ts",
errors: [{ messageId: "noRealtimeHandlerReexport" }],
},
],
});

View File

@@ -0,0 +1,19 @@
# @repo/core-realtime
Vendor-isolated realtime abstractions over Socket.IO. Feature packages depend only on the interfaces; only this package imports `socket.io`.
ADR-016 (`docs/decisions/adr-016-realtime-layer.md`).
## Public exports
- `IRealtimeBroadcaster` — server → client broadcasts
- `IRealtimeServer` — lifecycle, used at app boot only
- `IRealtimeAuthenticator` — connect-time identity attachment (cookie / header → user)
- `IRealtimeHandlerRegistry` + `RealtimeHandlerRegistry` — inbound handler registration
- `defineRealtimeChannel`, `RealtimeChannelDescriptor`, `ChannelScope`
- `InMemoryRealtimeBroadcaster` (test/dev), `SocketIORealtimeBroadcaster`, `SocketIORealtimeServer` (production)
- `CORE_REALTIME_SYMBOLS`
## Boundary
Tagged `core`. The only place in the repo where `import "socket.io"` is allowed is `src/socket-io-*.ts` here, plus `apps/*/server.ts`. Enforced by the ESLint rule `core-eslint/no-direct-socket-io`.

View File

@@ -0,0 +1,72 @@
---
package: socket.io
version: "^4.7.0"
tier: core
decision: approved
date: 2026-05-14
deciders: [scaffolded]
adr: adr-016
filter-results:
license: MIT
types: native
maintenance: active
boundary-fit: pass
shadow-check: pass
eu-residency: self-hostable
cve-scan: clean
named-consumer: pass
verification-commands:
- pnpm audit --audit-level=moderate
- npm view socket.io license
accepted-cves: []
---
## Filter: license
MIT — on the workspace allowlist.
## Filter: types
Ships first-party TypeScript types in its distribution.
## Filter: maintenance
Active. Maintained by the Socket.IO team; frequent releases and active issue tracker.
## Filter: maintenance
Active. Regular releases; widely deployed in production.
## Filter: boundary-fit
ADR-016 §R2 explicitly designates `core-realtime` as the sole allowed home for `socket.io`. Boundary rule `no-direct-socket-io` enforces this in ESLint.
## Filter: shadow-check
No competing realtime transport in the workspace. No shadow.
## Filter: eu-residency
Self-hosted server; the library itself does not transmit data to any vendor endpoint.
## Filter: cve-scan
No advisories at adoption time.
## Filter: named-consumer
`core-realtime` wraps socket.io to provide the `IRealtimeServer` abstraction (ADR-016).
## Prompt: replaces
Nothing — this is the initial realtime scaffolding. No prior transport to retire.
## Prompt: migration-cost-out
Hard: channel descriptors, handler signatures, and server-side broadcast API are all shaped around socket.io semantics. Replacing requires re-implementing the abstraction layer.
## Prompt: alternatives-considered
1. **ws** — lower-level, no rooms or namespaces; would require significant protocol work.
2. **Ably / Pusher** — vendor-hosted; eu-residency risk and ongoing cost.
Socket.IO is the established standard for this use-case and is fully self-hostable.

View File

@@ -0,0 +1,66 @@
---
package: zod
version: "^3.23.0"
tier: core
decision: approved
date: 2026-05-14
deciders: [scaffolded]
adr: adr-016
filter-results:
license: MIT
types: native
maintenance: active
boundary-fit: pass
shadow-check: pass
eu-residency: n/a
cve-scan: clean
named-consumer: pass
verification-commands:
- pnpm audit --audit-level=moderate
- npm view zod license
accepted-cves: []
---
## Filter: license
MIT — on the workspace allowlist.
## Filter: types
Ships first-party TypeScript types in its distribution (`.d.ts` included).
## Filter: maintenance
Active. Regular releases by Colin McDonnell; widely adopted.
## Filter: boundary-fit
Core package. Zod is the workspace-canonical validation library locked in `core-shared` (ADR-016).
## Filter: shadow-check
Zod is already the workspace-locked validation library. No shadow.
## Filter: eu-residency
Pure computation; no network calls or vendor data transmission. n/a.
## Filter: cve-scan
No advisories at adoption time.
## Filter: named-consumer
`core-realtime` uses zod for channel descriptor and payload schema validation.
## Prompt: replaces
Nothing — zod is the pre-existing workspace validation library.
## Prompt: migration-cost-out
Mechanical: swap schema definitions at call sites. No data-format lock-in.
## Prompt: alternatives-considered
Zod is workspace-locked (see `core-shared`). A replacement would require a workspace-wide ADR; no alternative was evaluated here.

View File

@@ -0,0 +1,3 @@
import baseConfig from "@repo/core-eslint/base";
export default baseConfig;

View File

@@ -0,0 +1,35 @@
{
"name": "@repo/core-realtime",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@repo/core-shared": "workspace:*",
"socket.io": "^4.7.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:*",
"@types/node": "^22.0.0",
"socket.io-client": "^4.7.0",
"typescript": "^5.8.0",
"vitest": "^3.0.0"
}
}

View File

@@ -0,0 +1,60 @@
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { authorize } from "@/authorize";
import { defineRealtimeChannel } from "@/realtime-channel";
const schema = z.object({}).strict();
describe("authorize", () => {
describe("public", () => {
const ch = defineRealtimeChannel("a", schema, { scope: "public" });
it("allows anonymous", async () => {
expect(await authorize(ch, {}, null)).toBe(true);
});
it("allows authenticated", async () => {
expect(await authorize(ch, {}, { userId: "u1", roles: [] })).toBe(true);
});
});
describe("authenticated", () => {
const ch = defineRealtimeChannel("a", schema, { scope: "authenticated" });
it("rejects anonymous", async () => {
expect(await authorize(ch, {}, null)).toBe(false);
});
it("allows any user", async () => {
expect(await authorize(ch, {}, { userId: "u1", roles: [] })).toBe(true);
});
});
describe("{ role }", () => {
const ch = defineRealtimeChannel("a", schema, { scope: { role: "admin" } });
it("rejects anonymous", async () => {
expect(await authorize(ch, {}, null)).toBe(false);
});
it("rejects user without role", async () => {
expect(await authorize(ch, {}, { userId: "u1", roles: ["editor"] })).toBe(false);
});
it("allows user with role", async () => {
expect(await authorize(ch, {}, { userId: "u1", roles: ["admin", "editor"] })).toBe(true);
});
});
describe("{ userScoped }", () => {
const ch = defineRealtimeChannel("a", schema, {
scope: { userScoped: true, template: "notifications.user.{userId}" },
});
it("rejects anonymous", async () => {
expect(await authorize(ch, { userId: "u1" }, null)).toBe(false);
});
it("rejects user requesting someone else's channel", async () => {
expect(
await authorize(ch, { userId: "u_other" }, { userId: "u1", roles: [] }),
).toBe(false);
});
it("allows user requesting own channel", async () => {
expect(
await authorize(ch, { userId: "u1" }, { userId: "u1", roles: [] }),
).toBe(true);
});
});
});

View File

@@ -0,0 +1,22 @@
import type { z } from "zod";
import type { RealtimeChannelDescriptor } from "./realtime-channel";
export async function authorize(
descriptor: RealtimeChannelDescriptor<string, z.ZodType>,
params: Record<string, string>,
user: { userId: string; roles: string[] } | null,
): Promise<boolean> {
const scope = descriptor.scope;
if (scope === "public") return true;
if (scope === "authenticated") return user !== null;
if (typeof scope === "object" && "role" in scope) {
return user !== null && user.roles.includes(scope.role);
}
if (typeof scope === "object" && "userScoped" in scope) {
return user !== null && params.userId === user.userId;
}
return false;
}

View File

@@ -0,0 +1,3 @@
export const CHANNEL_ROOM_PREFIX = "ch:";
export const channelRoom = (channelName: string): string =>
`${CHANNEL_ROOM_PREFIX}${channelName}`;

View File

@@ -0,0 +1,33 @@
import { describe, it, expect } from "vitest";
import { matchChannelTemplate } from "@/channel-template";
describe("matchChannelTemplate", () => {
it("matches a plain channel name exactly", () => {
expect(matchChannelTemplate("blog.feed", "blog.feed")).toEqual({ params: {} });
expect(matchChannelTemplate("blog.feed", "blog.other")).toBeNull();
});
it("matches a templated channel and extracts params", () => {
expect(
matchChannelTemplate("notifications.user.{userId}", "notifications.user.user_42"),
).toEqual({ params: { userId: "user_42" } });
});
it("returns null when a templated channel doesn't match the shape", () => {
expect(
matchChannelTemplate("notifications.user.{userId}", "notifications.user"),
).toBeNull();
expect(
matchChannelTemplate("notifications.user.{userId}", "blog.feed"),
).toBeNull();
});
it("supports multiple placeholders", () => {
expect(
matchChannelTemplate(
"rooms.{roomId}.user.{userId}",
"rooms.r1.user.u1",
),
).toEqual({ params: { roomId: "r1", userId: "u1" } });
});
});

View File

@@ -0,0 +1,26 @@
export function matchChannelTemplate(
template: string,
candidate: string,
): { params: Record<string, string> } | null {
// No placeholders: exact match.
if (!template.includes("{")) {
return template === candidate ? { params: {} } : null;
}
// Build a regex from the template, replacing {name} with named groups.
const names: string[] = [];
const escaped = template.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); // escape regex specials
// The above escapes `{` and `}` too — restore them around placeholders.
const pattern = escaped.replace(/\\\{([a-zA-Z_][a-zA-Z0-9_]*)\\\}/g, (_m, name) => {
names.push(name);
return `([^.]+)`;
});
const re = new RegExp(`^${pattern}$`);
const match = candidate.match(re);
if (!match) return null;
const params: Record<string, string> = {};
names.forEach((name, i) => {
params[name] = match[i + 1]!;
});
return { params };
}

View File

@@ -0,0 +1,34 @@
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { InMemoryRealtimeBroadcaster } from "@/in-memory-realtime-broadcaster";
import { defineRealtimeChannel } from "@/realtime-channel";
const ch = defineRealtimeChannel(
"a.b",
z.object({ x: z.number() }).strict(),
{ scope: "public" },
);
describe("InMemoryRealtimeBroadcaster", () => {
it("validates payload via the descriptor schema", async () => {
const b = new InMemoryRealtimeBroadcaster();
await expect(
b.broadcast(ch, { x: "not a number" } as never),
).rejects.toThrow();
});
it("delivers to subscribers in order", async () => {
const b = new InMemoryRealtimeBroadcaster();
const got: number[] = [];
b.subscribe(ch, async (p) => { got.push(p.x); });
await b.broadcast(ch, { x: 1 });
await b.broadcast(ch, { x: 2 });
expect(got).toEqual([1, 2]);
});
it("does nothing when no subscribers", async () => {
const b = new InMemoryRealtimeBroadcaster();
await b.broadcast(ch, { x: 1 });
// does not throw
});
});

View File

@@ -0,0 +1,28 @@
import type { z } from "zod";
import type { IRealtimeBroadcaster } from "./realtime-broadcaster.interface";
import type { RealtimeChannelDescriptor } from "./realtime-channel";
type Listener<T> = (payload: T) => Promise<void> | void;
export class InMemoryRealtimeBroadcaster implements IRealtimeBroadcaster {
private readonly listeners = new Map<string, Listener<unknown>[]>();
async broadcast<T>(
descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
payload: T,
): Promise<void> {
descriptor.schema.parse(payload);
const arr = this.listeners.get(descriptor.name) ?? [];
for (const l of arr) await l(payload);
}
// Test-friendly: lets unit tests subscribe directly without a Socket.IO server.
subscribe<T>(
descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
listener: Listener<T>,
): void {
const arr = this.listeners.get(descriptor.name) ?? [];
arr.push(listener as Listener<unknown>);
this.listeners.set(descriptor.name, arr);
}
}

View File

@@ -0,0 +1,22 @@
export type { ChannelScope, RealtimeChannelDescriptor } from "./realtime-channel";
export { defineRealtimeChannel } from "./realtime-channel";
export { CHANNEL_ROOM_PREFIX, channelRoom } from "./channel-room";
export type { IRealtimeBroadcaster } from "./realtime-broadcaster.interface";
export type { IRealtimeHandler, IInboundDescriptor, RealtimeContext } from "./realtime-handler.interface";
export type { IRealtimeServer, IRealtimeServerOptions } from "./realtime-server.interface";
export type { IRealtimeAuthenticator } from "./realtime-authenticator.interface";
export type { IRealtimeHandlerRegistry } from "./realtime-handler-registry";
export { RealtimeHandlerRegistry } from "./realtime-handler-registry";
export { CORE_REALTIME_SYMBOLS } from "./symbols";
export { InMemoryRealtimeBroadcaster } from "./in-memory-realtime-broadcaster";
export { SocketIORealtimeBroadcaster } from "./socket-io-realtime-broadcaster";
export { SocketIORealtimeServer } from "./socket-io-realtime-server";
export { authorize } from "./authorize";
export { matchChannelTemplate } from "./channel-template";
export {
realtimePingChannel,
realtimePongChannel,
realtimePingInboundDescriptor,
type PingPayload,
type PongPayload,
} from "./realtime-ping";

View File

@@ -0,0 +1,6 @@
export interface IRealtimeAuthenticator {
authenticate(handshake: {
cookies: Record<string, string>;
headers: Record<string, string>;
}): Promise<{ userId: string; roles: string[] } | null>;
}

View File

@@ -0,0 +1,10 @@
import type { z } from "zod";
import type { RealtimeBroadcasterProtocol } from "@repo/core-shared/di/bind-protocols";
import type { RealtimeChannelDescriptor } from "./realtime-channel";
export interface IRealtimeBroadcaster extends RealtimeBroadcasterProtocol {
broadcast<T>(
descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
payload: T,
): Promise<void>;
}

View File

@@ -0,0 +1,25 @@
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { defineRealtimeChannel } from "@/realtime-channel";
describe("defineRealtimeChannel", () => {
it("returns a descriptor with name, schema, and scope", () => {
const ch = defineRealtimeChannel(
"test.channel",
z.object({ id: z.string() }).strict(),
{ scope: "public" },
);
expect(ch.name).toBe("test.channel");
expect(ch.scope).toBe("public");
expect(() => ch.schema.parse({ id: "x" })).not.toThrow();
});
it("preserves all four scope shapes", () => {
expect(defineRealtimeChannel("a", z.object({}), { scope: "public" }).scope).toBe("public");
expect(defineRealtimeChannel("a", z.object({}), { scope: "authenticated" }).scope).toBe("authenticated");
expect(defineRealtimeChannel("a", z.object({}), { scope: { role: "admin" } }).scope).toEqual({ role: "admin" });
expect(
defineRealtimeChannel("a", z.object({}), { scope: { userScoped: true, template: "x.{id}" } }).scope,
).toEqual({ userScoped: true, template: "x.{id}" });
});
});

View File

@@ -0,0 +1,28 @@
import type { z } from "zod";
/**
* `userScoped` channels include a `{userId}` placeholder in the channel name.
* The `userId` param extracted from the channel pattern is matched against
* `user.userId` at subscribe time. The `template` field is metadata: it is
* the same string passed to `defineRealtimeChannel`'s `name` argument and is
* used by clients/admin tools to display the channel pattern.
*/
export type ChannelScope =
| "public"
| "authenticated"
| { role: string }
| { userScoped: true; template: string };
export type RealtimeChannelDescriptor<TName extends string, TSchema extends z.ZodType> = {
readonly name: TName;
readonly schema: TSchema;
readonly scope: ChannelScope;
};
export function defineRealtimeChannel<TName extends string, TSchema extends z.ZodType>(
name: TName,
schema: TSchema,
options: { scope: ChannelScope },
): RealtimeChannelDescriptor<TName, TSchema> {
return { name, schema, scope: options.scope };
}

View File

@@ -0,0 +1,91 @@
import { describe, it, expect, vi } from "vitest";
import { z } from "zod";
import { RealtimeHandlerRegistry } from "@/realtime-handler-registry";
import { defineRealtimeChannel } from "@/realtime-channel";
const ch = defineRealtimeChannel(
"test.ch",
z.object({ x: z.number() }).strict(),
{ scope: "authenticated" },
);
describe("RealtimeHandlerRegistry", () => {
it("registers and retrieves a handler by channel name", () => {
const reg = new RealtimeHandlerRegistry();
const handler = vi.fn();
reg.register({ descriptor: ch, handler });
const got = reg.getInboundDescriptor("test.ch");
expect(got).not.toBeNull();
expect(got!.descriptor.name).toBe("test.ch");
expect(got!.handler).toBe(handler);
});
it("returns null for unknown channel name", () => {
const reg = new RealtimeHandlerRegistry();
expect(reg.getInboundDescriptor("unknown")).toBeNull();
});
it("list() returns all registered descriptors", () => {
const reg = new RealtimeHandlerRegistry();
reg.register({ descriptor: ch, handler: vi.fn() });
expect(reg.list()).toHaveLength(1);
expect(reg.list()[0]!.descriptor.name).toBe("test.ch");
});
it("re-registering the same channel replaces the previous entry", () => {
const reg = new RealtimeHandlerRegistry();
const h1 = vi.fn();
const h2 = vi.fn();
reg.register({ descriptor: ch, handler: h1 });
reg.register({ descriptor: ch, handler: h2 });
expect(reg.getInboundDescriptor("test.ch")!.handler).toBe(h2);
expect(reg.list()).toHaveLength(1);
});
it("registerChannel stores a descriptor that appears in listChannels() but not in list()", () => {
const reg = new RealtimeHandlerRegistry();
const outboundCh = defineRealtimeChannel(
"test.outbound",
z.object({ y: z.string() }).strict(),
{ scope: "authenticated" },
);
reg.registerChannel(outboundCh);
expect(reg.listChannels()).toHaveLength(1);
expect(reg.listChannels()[0]!.name).toBe("test.outbound");
expect(reg.list()).toHaveLength(0);
});
it("register auto-populates listChannels()", () => {
const reg = new RealtimeHandlerRegistry();
reg.register({ descriptor: ch, handler: vi.fn() });
expect(reg.listChannels()).toHaveLength(1);
expect(reg.listChannels()[0]!.name).toBe("test.ch");
});
it("listChannels() returns both inbound and outbound-only channels when both are registered", () => {
const reg = new RealtimeHandlerRegistry();
const outboundCh = defineRealtimeChannel(
"test.outbound",
z.object({ y: z.string() }).strict(),
{ scope: "authenticated" },
);
reg.register({ descriptor: ch, handler: vi.fn() });
reg.registerChannel(outboundCh);
expect(reg.listChannels()).toHaveLength(2);
const names = reg.listChannels().map((c) => c.name).sort();
expect(names).toEqual(["test.ch", "test.outbound"]);
});
it("re-registering an outbound-only channel via registerChannel replaces the previous entry", () => {
const reg = new RealtimeHandlerRegistry();
const outboundCh = defineRealtimeChannel(
"test.outbound",
z.object({ y: z.string() }).strict(),
{ scope: "authenticated" },
);
reg.registerChannel(outboundCh);
reg.registerChannel(outboundCh);
expect(reg.listChannels()).toHaveLength(1);
expect(reg.listChannels()[0]!.name).toBe("test.outbound");
});
});

View File

@@ -0,0 +1,40 @@
import type { z } from "zod";
import type { RealtimeRegistryProtocol } from "@repo/core-shared/di/bind-protocols";
import type { RealtimeChannelDescriptor } from "./realtime-channel";
import type { IInboundDescriptor } from "./realtime-handler.interface";
export interface IRealtimeHandlerRegistry extends RealtimeRegistryProtocol {
register<T>(entry: IInboundDescriptor<string, z.ZodType<T>>): void;
getInboundDescriptor(channelName: string): IInboundDescriptor<string, z.ZodType> | null;
list(): IInboundDescriptor<string, z.ZodType>[];
/** Register an outbound-only channel so Gate 2 can authorize subscriptions to it. */
registerChannel(descriptor: RealtimeChannelDescriptor<string, z.ZodType>): void;
listChannels(): RealtimeChannelDescriptor<string, z.ZodType>[];
}
export class RealtimeHandlerRegistry implements IRealtimeHandlerRegistry {
private readonly entries = new Map<string, IInboundDescriptor<string, z.ZodType>>();
private readonly channels = new Map<string, RealtimeChannelDescriptor<string, z.ZodType>>();
register<T>(entry: IInboundDescriptor<string, z.ZodType<T>>): void {
this.entries.set(entry.descriptor.name, entry as IInboundDescriptor<string, z.ZodType>);
// Also add the descriptor to the channel map so Gate 2 can authorize subscriptions.
this.channels.set(entry.descriptor.name, entry.descriptor);
}
getInboundDescriptor(channelName: string): IInboundDescriptor<string, z.ZodType> | null {
return this.entries.get(channelName) ?? null;
}
list(): IInboundDescriptor<string, z.ZodType>[] {
return Array.from(this.entries.values());
}
registerChannel(descriptor: RealtimeChannelDescriptor<string, z.ZodType>): void {
this.channels.set(descriptor.name, descriptor);
}
listChannels(): RealtimeChannelDescriptor<string, z.ZodType>[] {
return Array.from(this.channels.values());
}
}

View File

@@ -0,0 +1,14 @@
import type { z } from "zod";
import type { RealtimeChannelDescriptor } from "./realtime-channel";
export type RealtimeContext = {
userId: string | null;
roles: string[];
};
export type IRealtimeHandler<T> = (input: T, ctx: RealtimeContext) => Promise<void>;
export type IInboundDescriptor<TName extends string, TSchema extends z.ZodType> = {
readonly descriptor: RealtimeChannelDescriptor<TName, TSchema>;
readonly handler: IRealtimeHandler<z.infer<TSchema>>;
};

View File

@@ -0,0 +1,36 @@
import { z } from "zod";
import { defineRealtimeChannel } from "./realtime-channel";
import type { IRealtimeBroadcaster } from "./realtime-broadcaster.interface";
import type { IInboundDescriptor, RealtimeContext } from "./realtime-handler.interface";
const pingSchema = z.object({ at: z.string().datetime() }).strict();
const pongSchema = z.object({ at: z.string().datetime(), echo: z.string() }).strict();
export type PingPayload = z.infer<typeof pingSchema>;
export type PongPayload = z.infer<typeof pongSchema>;
export const realtimePingChannel = defineRealtimeChannel(
"realtime.ping",
pingSchema,
{ scope: "authenticated" },
);
export const realtimePongChannel = defineRealtimeChannel(
"realtime.pong",
pongSchema,
{ scope: "authenticated" },
);
export function realtimePingInboundDescriptor(
broadcaster: IRealtimeBroadcaster,
): IInboundDescriptor<"realtime.ping", z.ZodType<PingPayload>> {
return {
descriptor: realtimePingChannel,
handler: async (input: PingPayload, ctx: RealtimeContext): Promise<void> => {
await broadcaster.broadcast(realtimePongChannel, {
at: input.at,
echo: ctx.userId ?? "anonymous",
});
},
};
}

View File

@@ -0,0 +1,16 @@
import type { Server as HttpServer } from "node:http";
import type { Server as IOServer } from "socket.io";
import type { IRealtimeAuthenticator } from "./realtime-authenticator.interface";
import type { IRealtimeHandlerRegistry } from "./realtime-handler-registry";
export type IRealtimeServerOptions = {
httpServer: HttpServer;
io: IOServer;
authenticator: IRealtimeAuthenticator;
registry: IRealtimeHandlerRegistry;
};
export interface IRealtimeServer {
start(): Promise<void>;
stop(): Promise<void>;
}

View File

@@ -0,0 +1,34 @@
import { describe, it, expect, vi } from "vitest";
import { z } from "zod";
import { channelRoom } from "@/channel-room";
import { SocketIORealtimeBroadcaster } from "@/socket-io-realtime-broadcaster";
import { defineRealtimeChannel } from "@/realtime-channel";
const ch = defineRealtimeChannel(
"a.b",
z.object({ x: z.number() }).strict(),
{ scope: "public" },
);
describe("SocketIORealtimeBroadcaster", () => {
it("emits to the channel's room with the channel name as event", async () => {
const emit = vi.fn();
const to = vi.fn(() => ({ emit }));
const io = { to } as never;
const b = new SocketIORealtimeBroadcaster(io);
await b.broadcast(ch, { x: 1 });
expect(to).toHaveBeenCalledWith(channelRoom("a.b"));
expect(emit).toHaveBeenCalledWith("a.b", { x: 1 });
});
it("validates payload before emitting", async () => {
const emit = vi.fn();
const to = vi.fn(() => ({ emit }));
const io = { to } as never;
const b = new SocketIORealtimeBroadcaster(io);
await expect(
b.broadcast(ch, { x: "not a number" } as never),
).rejects.toThrow();
expect(emit).not.toHaveBeenCalled();
});
});

Some files were not shown because too many files have changed in this diff Show More