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,98 @@
# AGENTS.md — core-shared
Generic, reusable primitives with **zero business knowledge**. This package is the foundation for all other packages and exports utilities, Payload field/hook definitions, and tRPC initialization.
## Responsibilities
- **Generic primitives** — environment helpers, date utilities, type guards
- **Payload utilities** — field definitions (slug, SEO), blocks (CTA), access controls (is-admin), hooks (slugify, publish timestamp)
- **tRPC platform** — `initTRPC.create()`, shared context factory, procedure builders
- **No business domain knowledge** — no awareness of articles, users, media, or any feature
## Must NOT import
- Any feature package (`@repo/auth`, `@repo/blog`, etc.)
- Any app package
- Framework-specific code (Next.js, TanStack React Query)
## Public exports
From `package.json`:
- `.` — all utilities, Payload exports, tRPC init
- `./payload` — Payload field/hook/block utilities only
- `./trpc/init` — tRPC `initTRPC` instance + builders; also exports `t` (the raw `initTRPC.create({...})` instance) so feature packages can build their own procedures via `t.procedure.use(...)`
- `./trpc/context` — tRPC context factory only
- `./trpc/define-error-middleware` — factory that builds a tRPC middleware translating domain errors to `TRPCError`. Takes `ReadonlyArray<readonly [ErrorCtor, TRPC_ERROR_CODE_KEY]>` tuples; uses `instanceof` discrimination; preserves the original error as `.cause`. **Owned by features:** each feature passes its own constructors in via `integrations/api/procedures.ts`. core-shared never enumerates feature-specific error classes — this stays boundary-clean
## Test conventions
- Tests colocated: `src/lib/slug-field.ts``src/lib/slug-field.test.ts`
- Vitest environment: `node`
- Alias: `@/` resolves to `src/`
- Run: `pnpm test --filter @repo/core-shared`
Covered areas:
- Slug field generation + validation
- Payload hooks (publish-at timestamp, slugify-if-missing)
- Access control helpers
## src/instrumentation/
**Substrate:** OpenTelemetry SDK (ADR-017). Sentry is the exporter via `@sentry/opentelemetry`. Feature packages depend only on the interfaces below — no Sentry or OTel SDK imports.
**Three interfaces:** `ITracer` (`tracer.interface.ts`), `ILogger` (`logger.interface.ts`), `IMetrics` (`metrics.interface.ts`).
**Three implementation pairs:**
- `NoopTracer` / `NoopLogger` / `NoopMetrics` — pass-through. Default everywhere.
- `OtelTracer` / `OtelLogger` / `OtelMetrics` — emit via OTel API (`@opentelemetry/api`, `@opentelemetry/api-logs`). Live in `otel/` subfolder. **The `otel/` subfolder is the only path in `packages/` permitted to import `@opentelemetry/sdk-*` or `@sentry/opentelemetry`** (R52).
- `RecordingTracer` / `RecordingLogger` / `RecordingMetrics` — in `@repo/core-testing/instrumentation`, not here.
**`with-span.ts` + `with-capture.ts`:** two higher-order helpers used at DI binding time to wrap use case + controller factory results. The binders apply them as a sandwich — `withSpan` outermost, `withCapture` between span and factory:
```ts
const wrapped = withSpan(
tracer,
{ name: "blog.getArticles", op: "use-case" },
withCapture(
logger,
{ feature: "blog", layer: "use-case", name: "blog.getArticles" },
factory(deps),
),
);
```
`withSpan` is pure delegation to `tracer.startSpan`. `withCapture` catches thrown errors, calls `logger.captureException(err, { tags })`, marks the `__sentryReported` flag, and re-throws — but bails if the flag is already set so the inner-most layer wins.
**`reported-flag.ts`:** small module exporting `markReported(err)` and `isReported(err)`. Used by `withCapture` and `OtelLogger`. `RecordingLogger` carries an inlined copy (tooling → core import is disallowed by the boundary rule).
**Symbols:** `INSTRUMENTATION_SYMBOLS.ITracer`, `INSTRUMENTATION_SYMBOLS.ILogger`, `INSTRUMENTATION_SYMBOLS.IMetrics` (all `Symbol.for(...)` so cross-realm equality holds).
**`otel/pii-fields.ts`:** vendor-neutral PII substring list (`PII_KEY_SUBSTRINGS`, `PII_QUERY_PARAM_SUBSTRINGS`). Used by `otel/pii-scrub-processor.ts` (server) and imported by `sentry/init-client*.ts` (browser).
**`otel/pii-scrub-processor.ts`:** `PiiScrubSpanProcessor` + `PiiScrubLogRecordProcessor`. Registered FIRST in the OTel processor chain so all downstream exporters (Sentry) see scrubbed data. Replaces Sentry's `beforeSend`/`beforeSendTransaction` hooks on the server side (ADR-017 §7).
**`sentry/init-server.ts` + `sentry/init-client.ts`:** centralized init helpers (Next.js flavor). `init-server.ts` calls `Sentry.init` with `sendDefaultPii: false` (R31) — no `beforeSend` hook (PII scrubbed at OTel layer). `init-client.ts` retains `beforeSend`/`beforeSendTransaction` because browser does not use the OTel pipeline.
**`sentry/init-server-node.ts` + `sentry/init-client-react.ts`:** Vite/non-Next variants used by `apps/web-tanstack`. Same posture as their Next.js counterparts.
**`di/bind-noop-instrumentation.ts` + `bind-otel-instrumentation.ts`:** bind ITracer + ILogger + IMetrics symbols to a Container. Returns the resolved instances so callers can use them without container lookup. `bindSentryInstrumentation` kept as a deprecated alias for one release.
**Subpath exports** (`package.json#exports`):
- `./instrumentation` — barrel (interfaces + Noops + OtelTracer/OtelLogger + withSpan + withCapture + reported-flag + symbols + binders)
- `./instrumentation/otel` — OTel init helper + resource builder barrel
- `./instrumentation/otel/init-server-node``initOtelServerNode` (app bootstrap, server-side OTel SDK)
- `./instrumentation/sentry/init-server` — Next.js server Sentry.init helper
- `./instrumentation/sentry/init-client` — Next.js browser Sentry.init helper
- `./instrumentation/sentry/init-server-node``@sentry/node` server init (TanStack Start)
- `./instrumentation/sentry/init-client-react``@sentry/react` browser init (TanStack Start)
**Boundaries:**
- `core-shared/instrumentation/otel/**` MAY import from `@opentelemetry/sdk-*` and `@sentry/opentelemetry`.
- `core-shared/instrumentation/sentry/**` MAY import from `@sentry/*` (browser init files).
- Everything else in `packages/core-shared/src/` MUST NOT.
- ESLint rules R40 + R52 in `core-eslint/base.js` enforce the broader monorepo boundary.

View File

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

View File

@@ -0,0 +1,85 @@
{
"name": "@repo/core-shared",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./audit": "./src/audit/index.ts",
"./rate-limit": "./src/rate-limit/index.ts",
"./conformance": "./src/conformance/index.ts",
"./conformance/coverage": "./src/conformance/coverage.ts",
"./di": "./src/di/index.ts",
"./di/bind-protocols": "./src/di/bind-protocols.ts",
"./di/bind-context": "./src/di/bind-context.ts",
"./jobs": "./src/jobs/index.ts",
"./payload": "./src/payload/index.ts",
"./trpc/init": "./src/trpc/init.ts",
"./trpc/context": "./src/trpc/context.ts",
"./trpc/define-error-middleware": "./src/trpc/define-error-middleware.ts",
"./instrumentation": "./src/instrumentation/index.ts",
"./instrumentation/otel": "./src/instrumentation/otel/index.ts",
"./instrumentation/otel/init-server-node": "./src/instrumentation/otel/init-server-node.ts",
"./instrumentation/sentry/init-client": "./src/instrumentation/sentry/init-client.ts",
"./instrumentation/sentry/init-client-react": "./src/instrumentation/sentry/init-client-react.ts",
"./security": "./src/security/index.ts",
"./security/next": "./src/security/next/index.ts",
"./security/tanstack": "./src/security/tanstack/index.ts"
},
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.55.0",
"@opentelemetry/instrumentation": "^0.55.0",
"@opentelemetry/instrumentation-http": "^0.55.0",
"@opentelemetry/instrumentation-pg": "^0.50.0",
"@opentelemetry/instrumentation-undici": "^0.10.0",
"@opentelemetry/resources": "^1.27.0",
"@opentelemetry/sdk-logs": "^0.55.0",
"@opentelemetry/sdk-metrics": "^1.27.0",
"@opentelemetry/sdk-node": "^0.55.0",
"@opentelemetry/sdk-trace-base": "^1.27.0",
"@opentelemetry/semantic-conventions": "^1.27.0",
"@sentry/nextjs": "^10.51.0",
"@sentry/opentelemetry": "^10.51.0",
"@trpc/server": "^11.0.0",
"payload": "^3.14.0",
"superjson": "^2.2.1",
"zod": "^3.24.0"
},
"peerDependencies": {
"@sentry/node": "^10.51.0",
"@sentry/react": "^10.51.0",
"next": ">=15.0.0"
},
"peerDependenciesMeta": {
"@sentry/node": {
"optional": true
},
"@sentry/react": {
"optional": true
},
"next": {
"optional": true
}
},
"devDependencies": {
"next": "^15.3.0",
"@opentelemetry/context-async-hooks": "^1.28.0",
"@repo/core-eslint": "workspace:*",
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@sentry/node": "^10.51.0",
"@sentry/react": "^10.51.0",
"@types/node": "^22.0.0",
"@vitest/coverage-v8": "^3.2.4",
"inversify": "^6.2.0",
"reflect-metadata": "^0.2.2",
"vitest": "^3.1.0"
}
}

View File

@@ -0,0 +1,60 @@
import { describe, it, expectTypeOf } from "vitest";
import type { AuditEntry, AuditAction, AuditFrom } from "./audit-entry";
describe("AuditAction", () => {
it("is a closed enum of 10 values", () => {
expectTypeOf<AuditAction>().toEqualTypeOf<
| "VIEW"
| "CREATE"
| "UPDATE"
| "DELETE"
| "EXPORT"
| "PERMISSION_CHANGE"
| "CONSENT_GRANT"
| "CONSENT_WITHDRAW"
| "RESTRICT"
| "UNRESTRICT"
>();
});
});
describe("AuditFrom", () => {
it("requires ipTruncated and userAgent", () => {
expectTypeOf<AuditFrom>().toEqualTypeOf<{
ipTruncated: string;
userAgent: string;
}>();
});
});
describe("AuditEntry", () => {
it("requires the WHO/WHAT/WHEN/SCOPE/FROM/PII/OUTCOME fields", () => {
const entry: AuditEntry = {
actorId: "user_1",
actorType: "user",
actorRoles: ["admin"],
action: "VIEW",
resource: { type: "articles" },
at: new Date(),
scope: { feature: "blog", environment: "test", tenant: "default" },
from: { ipTruncated: "10.0.0.0", userAgent: "test" },
containsPii: false,
outcome: "success",
};
expectTypeOf(entry).toMatchTypeOf<AuditEntry>();
});
it("makes optional fields actually optional", () => {
type Entry = AuditEntry;
type OptionalKeys =
| "changedFields"
| "reason"
| "correlationId"
| "requestId"
| "piiCategories"
| "errorCode";
expectTypeOf<Pick<Entry, OptionalKeys>>().toEqualTypeOf<
Partial<Pick<Entry, OptionalKeys>>
>();
});
});

View File

@@ -0,0 +1,82 @@
/**
* Closed enum of audited actions per DPA. New action types require an
* explicit type bump — compliance auditors sample by enum value.
*/
export type AuditAction =
| "VIEW"
| "CREATE"
| "UPDATE"
| "DELETE"
| "EXPORT"
| "PERMISSION_CHANGE"
| "CONSENT_GRANT"
| "CONSENT_WITHDRAW"
| "RESTRICT"
| "UNRESTRICT";
/**
* `from_where` fragment per DPA. IP truncated to /24 (IPv4) or /48 (IPv6)
* before storage; use `truncateIp(rawIp)` to enforce. For non-HTTP contexts,
* sentinels are conventional: `{ ipTruncated: "system", userAgent: "background-job" }`.
*/
export type AuditFrom = {
ipTruncated: string;
userAgent: string;
};
/**
* Universal audit entry. By construction, this type has NO `payload`/`body`/
* `oldValue`/`newValue` fields — the DPA "what NOT to log" exclusion list is
* enforced by the type itself. UPDATE actions capture field NAMES only
* (`changedFields`); per-collection value capture is a separate API (out of
* scope for v1).
*/
export type AuditEntry = {
// WHO
/** User id, or "system"/"service-{name}" for non-user actors. NEVER email or name. */
actorId: string;
actorType: "user" | "system" | "service";
/** Snapshot of actor's roles AT TIME OF ACTION — preserves historical state. */
actorRoles: string[];
// WHAT
action: AuditAction;
resource: { type: string; id?: string };
/** UPDATE only: names of fields that changed (NOT values — PII risk). */
changedFields?: string[];
// WHEN
/** Server time. Sinks serialize as ISO 8601. */
at: Date;
// SCOPE (where)
scope: {
feature: string;
environment: string;
/** Required field. Single-tenant projects use "default" as the sentinel. */
tenant: string;
};
// WHY
reason?: string;
/** OTel trace ID. Auto-populated by `TraceIdEnrichingAuditLog` decorator at bind time. */
correlationId?: string;
requestId?: string;
// FROM (per DPA)
from: AuditFrom;
// PII CLASSIFICATION
/** Caller MUST declare. Drives downstream retention/access policies. */
containsPii: boolean;
/**
* Free-form list. Conventions (suggested, not enforced): "email", "name",
* "phone", "address", "ssn", "financial", "health". Free-form because
* regulatory categories differ by jurisdiction.
*/
piiCategories?: string[];
// OUTCOME
outcome: "success" | "denied" | "error";
errorCode?: string;
};

View File

@@ -0,0 +1,2 @@
export type { AuditEntry, AuditAction, AuditFrom } from "./audit-entry";
export { truncateIp } from "./truncate-ip";

View File

@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { truncateIp } from "./truncate-ip";
describe("truncateIp", () => {
describe("IPv4", () => {
it("truncates to /24 (zeros the last octet)", () => {
expect(truncateIp("192.168.1.42")).toBe("192.168.1.0");
expect(truncateIp("10.0.0.255")).toBe("10.0.0.0");
expect(truncateIp("8.8.8.8")).toBe("8.8.8.0");
});
it("throws on malformed input", () => {
expect(() => truncateIp("192.168.1")).toThrow(/malformed IPv4/);
expect(() => truncateIp("192.168.1.foo")).toThrow(/malformed IPv4/);
expect(() => truncateIp("a.b.c.d")).toThrow(/malformed IPv4/);
});
it("throws on empty string", () => {
expect(() => truncateIp("")).toThrow(/malformed IPv4/);
});
});
describe("IPv6", () => {
it("truncates to /48 (keeps first 3 hextets)", () => {
expect(truncateIp("2001:0db8:1234:5678:abcd:ef00:1234:5678")).toBe("2001:0db8:1234::");
expect(truncateIp("2001:0db8:abcd:1234::")).toBe("2001:0db8:abcd::");
});
it("lowercases hextets", () => {
expect(truncateIp("2001:0DB8:ABCD:5678::")).toBe("2001:0db8:abcd::");
});
it("throws on too-few hextets", () => {
expect(() => truncateIp("2001:0db8")).toThrow(/malformed IPv6/);
});
});
});

View File

@@ -0,0 +1,27 @@
/**
* Truncates an IP address per DPA:
* IPv4 → /24 ("192.168.1.42" → "192.168.1.0")
* IPv6 → /48 ("2001:0db8:1234:5678:..." → "2001:0db8:1234::")
*
* Throws on malformed input rather than silently returning the raw value —
* compliance regimes prefer hard failures over partial scrubbing.
*/
export function truncateIp(raw: string): string {
if (raw.includes(":")) {
// IPv6: keep first 3 hextets (48 bits)
const parts = raw.toLowerCase().split(":").filter((p) => p !== "");
if (parts.length < 3) {
throw new Error(`truncateIp: malformed IPv6 address "${raw}"`);
}
return `${parts[0]}:${parts[1]}:${parts[2]}::`;
}
// IPv4: keep first 3 octets (24 bits)
const parts = raw.split(".");
if (
parts.length !== 4 ||
parts.some((p) => p === "" || isNaN(Number(p)) || !/^\d+$/.test(p))
) {
throw new Error(`truncateIp: malformed IPv4 address "${raw}"`);
}
return `${parts[0]}.${parts[1]}.${parts[2]}.0`;
}

View File

@@ -0,0 +1,522 @@
import { describe, it, expect, vi } from "vitest";
import "reflect-metadata";
import { Container } from "inversify";
import { defineFeature } from "@/conformance/define-feature";
import { ConformanceError } from "@/conformance/conformance-error";
import { assertFeatureConformance } from "@/conformance/assert-bindings";
import { withSpan } from "@/instrumentation/with-span";
import { withCapture } from "@/instrumentation/with-capture";
import { attachBrand } from "@/conformance/brand-runtime";
import type { ITracer, ISpan } from "@/instrumentation/tracer.interface";
import type { ILogger } from "@/instrumentation/logger.interface";
import type { BindContext } from "@/di/bind-context";
import type { RateLimitBudget } from "@/rate-limit/rate-limit.interface";
function makeTracer(): ITracer {
return {
startSpan: vi.fn(async (_opts, fn) => {
const span: ISpan = { setAttribute: () => {}, setStatus: () => {} };
return fn(span);
}),
};
}
function makeLogger(): ILogger {
return {
captureException: vi.fn(),
captureMessage: vi.fn(),
addBreadcrumb: vi.fn(),
setUser: vi.fn(),
};
}
function makeCtx(): BindContext {
return { tracer: makeTracer(), logger: makeLogger() };
}
describe("assertFeatureConformance", () => {
it("passes when every use case is bound through withSpan + withCapture", () => {
const container = new Container();
const sym = Symbol("test.signIn");
const ctx = makeCtx();
const bound = withSpan(
ctx.tracer,
{ name: "test.signIn", op: "use-case" },
withCapture(
ctx.logger,
{ feature: "test", layer: "use-case" },
async (x: number) => x + 1,
),
);
container.bind(sym).toConstantValue(bound);
const manifest = defineFeature({
name: "test",
requiredCores: [],
useCases: {
signIn: { mutates: false, audits: [], publishes: [], consumes: [] },
},
realtimeChannels: [],
jobs: [],
} as const);
expect(() =>
assertFeatureConformance(container, manifest, { signIn: sym }, ctx),
).not.toThrow();
});
it("throws when a use case binding is missing the __instrumented brand", () => {
const container = new Container();
const sym = Symbol("test.signIn");
const ctx = makeCtx();
const unwrapped = async (x: number) => x + 1;
container.bind(sym).toConstantValue(unwrapped);
const manifest = defineFeature({
name: "test",
requiredCores: [],
useCases: {
signIn: { mutates: false, audits: [], publishes: [], consumes: [] },
},
realtimeChannels: [],
jobs: [],
} as const);
expect(() =>
assertFeatureConformance(container, manifest, { signIn: sym }, ctx),
).toThrow(ConformanceError);
expect(() =>
assertFeatureConformance(container, manifest, { signIn: sym }, ctx),
).toThrow(/test\.signIn.*__instrumented/);
});
it("throws when a use case binding is missing the __captured brand", () => {
const container = new Container();
const sym = Symbol("test.signIn");
const ctx = makeCtx();
// withSpan-only — no withCapture wrap
const partial = withSpan(
ctx.tracer,
{ name: "test.signIn", op: "use-case" },
async (x: number) => x + 1,
);
container.bind(sym).toConstantValue(partial);
const manifest = defineFeature({
name: "test",
requiredCores: [],
useCases: {
signIn: { mutates: false, audits: [], publishes: [], consumes: [] },
},
realtimeChannels: [],
jobs: [],
} as const);
expect(() =>
assertFeatureConformance(container, manifest, { signIn: sym }, ctx),
).toThrow(/__captured/);
});
it("throws when a mutating use case with audits is missing the __audited brand", () => {
const container = new Container();
const sym = Symbol("test.signUp");
const ctx = makeCtx();
const wrappedNoAudit = withSpan(
ctx.tracer,
{ name: "test.signUp", op: "use-case" },
withCapture(ctx.logger, { feature: "test" }, async (x: number) => x + 1),
);
container.bind(sym).toConstantValue(wrappedNoAudit);
const manifest = defineFeature({
name: "test",
requiredCores: [],
useCases: {
signUp: {
mutates: true,
audits: ["user.created"],
publishes: [],
consumes: [],
},
},
realtimeChannels: [],
jobs: [],
} as const);
expect(() =>
assertFeatureConformance(container, manifest, { signUp: sym }, ctx),
).toThrow(/__audited/);
});
it("passes for a mutating use case with empty audits (no __audited required)", () => {
const container = new Container();
const sym = Symbol("test.signUp");
const ctx = makeCtx();
const wrappedNoAudit = withSpan(
ctx.tracer,
{ name: "test.signUp", op: "use-case" },
withCapture(ctx.logger, { feature: "test" }, async (x: number) => x + 1),
);
container.bind(sym).toConstantValue(wrappedNoAudit);
const manifest = defineFeature({
name: "test",
requiredCores: [],
useCases: {
signUp: {
mutates: true,
audits: [],
publishes: [],
consumes: [],
},
},
realtimeChannels: [],
jobs: [],
} as const);
expect(() =>
assertFeatureConformance(container, manifest, { signUp: sym }, ctx),
).not.toThrow();
});
it("throws when no symbol is provided for a manifest use case", () => {
const container = new Container();
const ctx = makeCtx();
const manifest = defineFeature({
name: "test",
requiredCores: [],
useCases: {
signIn: { mutates: false, audits: [], publishes: [], consumes: [] },
},
realtimeChannels: [],
jobs: [],
} as const);
expect(() =>
assertFeatureConformance(container, manifest, {}, ctx),
).toThrow(/no symbol provided/);
});
it("throws when the container cannot resolve the symbol", () => {
const container = new Container();
const sym = Symbol("test.signIn");
const ctx = makeCtx();
const manifest = defineFeature({
name: "test",
requiredCores: [],
useCases: {
signIn: { mutates: false, audits: [], publishes: [], consumes: [] },
},
realtimeChannels: [],
jobs: [],
} as const);
expect(() =>
assertFeatureConformance(container, manifest, { signIn: sym }, ctx),
).toThrow(ConformanceError);
});
it("passes when analyticsEvents declared and binding carries __analyzed brand", () => {
const container = new Container();
const sym = Symbol("test.trackClick");
const ctx = makeCtx();
const base = withSpan(
ctx.tracer,
{ name: "test.trackClick", op: "use-case" },
withCapture(
ctx.logger,
{ feature: "test", layer: "use-case" },
async (x: number) => x,
),
);
attachBrand(base, "__analyzed");
container.bind(sym).toConstantValue(base);
const manifest = defineFeature({
name: "test",
requiredCores: [],
useCases: {
trackClick: {
mutates: false,
audits: [],
publishes: [],
consumes: [],
analyticsEvents: ["button.clicked"],
},
},
realtimeChannels: [],
jobs: [],
} as const);
expect(() =>
assertFeatureConformance(container, manifest, { trackClick: sym }, ctx),
).not.toThrow();
});
it("throws when analyticsEvents declared but binding is missing __analyzed brand", () => {
const container = new Container();
const sym = Symbol("test.trackClick");
const ctx = makeCtx();
const wrappedNoAnalytics = withSpan(
ctx.tracer,
{ name: "test.trackClick", op: "use-case" },
withCapture(ctx.logger, { feature: "test" }, async (x: number) => x),
);
container.bind(sym).toConstantValue(wrappedNoAnalytics);
const manifest = defineFeature({
name: "test",
requiredCores: [],
useCases: {
trackClick: {
mutates: false,
audits: [],
publishes: [],
consumes: [],
analyticsEvents: ["button.clicked"],
},
},
realtimeChannels: [],
jobs: [],
} as const);
expect(() =>
assertFeatureConformance(container, manifest, { trackClick: sym }, ctx),
).toThrow(ConformanceError);
expect(() =>
assertFeatureConformance(container, manifest, { trackClick: sym }, ctx),
).toThrow(/Analyzed|__analyzed/);
});
it("throws when feature requiresConsent but binding is missing __consentChecked brand", () => {
const container = new Container();
const sym = Symbol("test.processData");
const ctx = makeCtx();
const wrappedNoConsent = withSpan(
ctx.tracer,
{ name: "test.processData", op: "use-case" },
withCapture(
ctx.logger,
{ feature: "test", layer: "use-case" },
async (x: number) => x,
),
);
container.bind(sym).toConstantValue(wrappedNoConsent);
const manifest = defineFeature({
name: "test",
requiredCores: [],
useCases: {
processData: {
mutates: false,
audits: [],
publishes: [],
consumes: [],
},
},
realtimeChannels: [],
jobs: [],
requiresConsent: ["analytics"],
} as const);
expect(() =>
assertFeatureConformance(container, manifest, { processData: sym }, ctx),
).toThrow(ConformanceError);
expect(() =>
assertFeatureConformance(container, manifest, { processData: sym }, ctx),
).toThrow(/__consentChecked/);
});
it("passes when feature requiresConsent and binding carries __consentChecked brand", () => {
const container = new Container();
const sym = Symbol("test.processData");
const ctx = makeCtx();
const base = withSpan(
ctx.tracer,
{ name: "test.processData", op: "use-case" },
withCapture(
ctx.logger,
{ feature: "test", layer: "use-case" },
async (x: number) => x,
),
);
attachBrand(base, "__consentChecked");
container.bind(sym).toConstantValue(base);
const manifest = defineFeature({
name: "test",
requiredCores: [],
useCases: {
processData: {
mutates: false,
audits: [],
publishes: [],
consumes: [],
},
},
realtimeChannels: [],
jobs: [],
requiresConsent: ["analytics"],
} as const);
expect(() =>
assertFeatureConformance(container, manifest, { processData: sym }, ctx),
).not.toThrow();
});
it("passes when analyticsEvents is empty and __analyzed brand is absent", () => {
const container = new Container();
const sym = Symbol("test.signIn");
const ctx = makeCtx();
const bound = withSpan(
ctx.tracer,
{ name: "test.signIn", op: "use-case" },
withCapture(
ctx.logger,
{ feature: "test", layer: "use-case" },
async (x: number) => x,
),
);
container.bind(sym).toConstantValue(bound);
const manifest = defineFeature({
name: "test",
requiredCores: [],
useCases: {
signIn: {
mutates: false,
audits: [],
publishes: [],
consumes: [],
analyticsEvents: [],
},
},
realtimeChannels: [],
jobs: [],
} as const);
expect(() =>
assertFeatureConformance(container, manifest, { signIn: sym }, ctx),
).not.toThrow();
});
it("throws when use case declares rateLimit but binding is missing __rateLimited brand", () => {
const container = new Container();
const sym = Symbol("test.signIn");
const ctx = makeCtx();
const wrappedNoRateLimit = withSpan(
ctx.tracer,
{ name: "test.signIn", op: "use-case" },
withCapture(
ctx.logger,
{ feature: "test", layer: "use-case" },
async (x: number) => x,
),
);
container.bind(sym).toConstantValue(wrappedNoRateLimit);
const rateLimitBudget: RateLimitBudget = {
name: "global",
window: "1m",
budget: 60,
};
const manifest = defineFeature({
name: "test",
requiredCores: [],
useCases: {
signIn: {
mutates: false,
audits: [],
publishes: [],
consumes: [],
rateLimit: [rateLimitBudget],
},
},
realtimeChannels: [],
jobs: [],
} as const);
expect(() =>
assertFeatureConformance(container, manifest, { signIn: sym }, ctx),
).toThrow(ConformanceError);
expect(() =>
assertFeatureConformance(container, manifest, { signIn: sym }, ctx),
).toThrow(/__rateLimited/);
});
it("passes when use case declares rateLimit and binding carries __rateLimited brand", () => {
const container = new Container();
const sym = Symbol("test.signIn");
const ctx = makeCtx();
const base = withSpan(
ctx.tracer,
{ name: "test.signIn", op: "use-case" },
withCapture(
ctx.logger,
{ feature: "test", layer: "use-case" },
async (x: number) => x,
),
);
attachBrand(base, "__rateLimited");
container.bind(sym).toConstantValue(base);
const rateLimitBudget: RateLimitBudget = {
name: "global",
window: "1m",
budget: 60,
};
const manifest = defineFeature({
name: "test",
requiredCores: [],
useCases: {
signIn: {
mutates: false,
audits: [],
publishes: [],
consumes: [],
rateLimit: [rateLimitBudget],
},
},
realtimeChannels: [],
jobs: [],
} as const);
expect(() =>
assertFeatureConformance(container, manifest, { signIn: sym }, ctx),
).not.toThrow();
});
it("passes when rateLimit is empty and __rateLimited brand is absent", () => {
const container = new Container();
const sym = Symbol("test.signIn");
const ctx = makeCtx();
const bound = withSpan(
ctx.tracer,
{ name: "test.signIn", op: "use-case" },
withCapture(
ctx.logger,
{ feature: "test", layer: "use-case" },
async (x: number) => x,
),
);
container.bind(sym).toConstantValue(bound);
const manifest = defineFeature({
name: "test",
requiredCores: [],
useCases: {
signIn: {
mutates: false,
audits: [],
publishes: [],
consumes: [],
rateLimit: [],
},
},
realtimeChannels: [],
jobs: [],
} as const);
expect(() =>
assertFeatureConformance(container, manifest, { signIn: sym }, ctx),
).not.toThrow();
});
});

View File

@@ -0,0 +1,109 @@
import type { Container } from "inversify";
import type { FeatureManifest, UseCaseManifest } from "./define-feature";
import {
isInstrumented,
isCaptured,
isAudited,
isAnalyzed,
isConsentChecked,
isRateLimited,
} from "./brand-runtime";
import { ConformanceError } from "./conformance-error";
import type { BindContext } from "../di/bind-context";
function requireBrand(ok: boolean, label: string, message: string): void {
if (!ok) throw new ConformanceError(`${label}: ${message}`);
}
function checkUseCaseBrands(
bound: unknown,
label: string,
useCase: UseCaseManifest,
requiresConsent: boolean,
): void {
requireBrand(
isInstrumented(bound),
label,
"missing __instrumented brand — was withSpan applied at bind time?",
);
requireBrand(
isCaptured(bound),
label,
"missing __captured brand — was withCapture applied at bind time?",
);
if (useCase.mutates && useCase.audits.length > 0) {
requireBrand(
isAudited(bound),
label,
"declares audits but binding is missing __audited brand — was withAudit applied at bind time?",
);
}
if ((useCase.analyticsEvents?.length ?? 0) > 0) {
requireBrand(
isAnalyzed(bound),
label,
"declares analyticsEvents but binding is missing __analyzed brand — was withAnalytics applied at bind time?",
);
}
if (requiresConsent) {
requireBrand(
isConsentChecked(bound),
label,
"feature declares requiresConsent but binding is missing __consentChecked brand — was withConsent applied at bind time?",
);
}
if ((useCase.rateLimit?.length ?? 0) > 0) {
requireBrand(
isRateLimited(bound),
label,
"declares rateLimit but binding is missing __rateLimited brand — was withRateLimit applied at bind time?",
);
}
}
/**
* Runtime check that every manifest-declared use case is bound through the
* brand-attaching wrappers (`withSpan` → `__instrumented`,
* `withCapture` → `__captured`, `withAudit` → `__audited` when required).
*
* Called at the tail of each feature's `bindProductionX(ctx)` so:
* - `pnpm dev` refuses to boot on drift (synchronous throw)
* - The check runs once per feature, not per request
* - Future apps that wire `bindProductionX` inherit the check for free
*
* The `symbols` map is declared inline by the feature's binder; the manifest
* holds the contract, the binder holds the container symbols. This keeps
* the manifest free of DI-coupling while letting each feature declare its
* own wiring keys.
*/
export function assertFeatureConformance(
container: Container,
manifest: FeatureManifest,
symbols: Record<string, symbol>,
_ctx: BindContext,
): void {
void _ctx; // future: also check ctx.bus / ctx.auditLog presence vs requiredCores
const requiresConsent = (manifest.requiresConsent?.length ?? 0) > 0;
for (const [name, useCase] of Object.entries(manifest.useCases)) {
const sym = symbols[name];
if (!sym) {
throw new ConformanceError(
`${manifest.name}.${name}: no symbol provided in symbols map`,
);
}
let bound: unknown;
try {
bound = container.get(sym);
} catch (cause) {
throw new ConformanceError(
`${manifest.name}.${name}: container could not resolve symbol (${String(cause)})`,
);
}
checkUseCaseBrands(
bound,
`${manifest.name}.${name}`,
useCase,
requiresConsent,
);
}
}

View File

@@ -0,0 +1,91 @@
import { describe, it, expect } from "vitest";
import {
attachBrand,
isInstrumented,
isCaptured,
isAudited,
isAnalyzed,
isRateLimited,
} from "@/conformance/brand-runtime";
describe("brand-runtime", () => {
it("attachBrand adds a non-enumerable property and returns the same reference", () => {
const fn = () => {};
const result = attachBrand(fn, "__instrumented");
expect(result).toBe(fn);
expect(isInstrumented(fn)).toBe(true);
// Non-enumerable: must not show up in Object.keys
expect(Object.keys(fn)).not.toContain("__instrumented");
});
it("predicates return false for unwrapped functions", () => {
const fn = () => {};
expect(isInstrumented(fn)).toBe(false);
expect(isCaptured(fn)).toBe(false);
expect(isAudited(fn)).toBe(false);
expect(isAnalyzed(fn)).toBe(false);
expect(isRateLimited(fn)).toBe(false);
});
it("predicates discriminate between brands", () => {
const instrumented = () => {};
attachBrand(instrumented, "__instrumented");
expect(isInstrumented(instrumented)).toBe(true);
expect(isCaptured(instrumented)).toBe(false);
expect(isAudited(instrumented)).toBe(false);
const captured = () => {};
attachBrand(captured, "__captured");
expect(isInstrumented(captured)).toBe(false);
expect(isCaptured(captured)).toBe(true);
expect(isAudited(captured)).toBe(false);
const audited = () => {};
attachBrand(audited, "__audited");
expect(isAudited(audited)).toBe(true);
const analyzed = () => {};
attachBrand(analyzed, "__analyzed");
expect(isAnalyzed(analyzed)).toBe(true);
const rateLimited = () => {};
attachBrand(rateLimited, "__rateLimited");
expect(isRateLimited(rateLimited)).toBe(true);
expect(isInstrumented(rateLimited)).toBe(false);
});
it("composing brands stacks them on the same function", () => {
const fn = () => {};
attachBrand(fn, "__instrumented");
attachBrand(fn, "__captured");
attachBrand(fn, "__audited");
attachBrand(fn, "__analyzed");
attachBrand(fn, "__rateLimited");
expect(isInstrumented(fn)).toBe(true);
expect(isCaptured(fn)).toBe(true);
expect(isAudited(fn)).toBe(true);
expect(isAnalyzed(fn)).toBe(true);
expect(isRateLimited(fn)).toBe(true);
});
it("attached brands are non-writable and non-configurable", () => {
const fn = () => {};
attachBrand(fn, "__instrumented");
const desc = Object.getOwnPropertyDescriptor(fn, "__instrumented");
expect(desc?.writable).toBe(false);
expect(desc?.configurable).toBe(false);
expect(desc?.enumerable).toBe(false);
expect(desc?.value).toBe(true);
});
it("predicates return false for non-function inputs", () => {
expect(isInstrumented(null)).toBe(false);
expect(isInstrumented(undefined)).toBe(false);
expect(isInstrumented(42)).toBe(false);
expect(isInstrumented("string")).toBe(false);
expect(isInstrumented({})).toBe(false);
expect(isAnalyzed(null)).toBe(false);
expect(isAnalyzed(undefined)).toBe(false);
expect(isAnalyzed(42)).toBe(false);
});
});

View File

@@ -0,0 +1,82 @@
/**
* Runtime brand attachment + predicates. The companion to the phantom-type
* brands defined in `./brands.ts`: at compile time the brand is a structural
* intersection; at runtime it is a non-enumerable, non-writable,
* non-configurable property with the same name.
*
* Why non-enumerable: a wrapped function should not leak the marker through
* `Object.keys`, `JSON.stringify`, `for…in`, or spread. The marker only
* shows up to explicit lookups via `Reflect.has` or direct property access.
*
* Why non-writable + non-configurable: the marker is meant to be permanent
* once attached. The wrapper is the only caller; the marker is a one-shot
* commitment, not a mutable flag.
*/
import type { Analyzed, ConsentChecked, RateLimited, ReadOnly } from "./brands";
type Brand =
| "__instrumented"
| "__captured"
| "__audited"
| "__analyzed"
| "__consentChecked"
| "__rateLimited"
| "__readonly";
/**
* Attaches the brand as a non-enumerable property on the given function.
* Returns the same reference (no allocation). Calling twice with the same
* brand on the same fn is a no-op (matching descriptors) — the engine silently
* accepts a redundant `defineProperty` call when every descriptor attribute
* matches the existing one. Redefining with different attributes (e.g. flipping
* `configurable`) would throw, but wrappers never do that.
*/
export function attachBrand<F extends object>(fn: F, brand: Brand): F {
Object.defineProperty(fn, brand, {
value: true,
enumerable: false,
writable: false,
configurable: false,
});
return fn;
}
function hasBrand(fn: unknown, brand: Brand): boolean {
if (typeof fn !== "function" && (typeof fn !== "object" || fn === null)) {
return false;
}
return (fn as Record<string, unknown>)[brand] === true;
}
export function isInstrumented(fn: unknown): boolean {
return hasBrand(fn, "__instrumented");
}
export function isCaptured(fn: unknown): boolean {
return hasBrand(fn, "__captured");
}
export function isAudited(fn: unknown): boolean {
return hasBrand(fn, "__audited");
}
export function isAnalyzed<F extends object>(fn: unknown): fn is Analyzed<F> {
return hasBrand(fn, "__analyzed");
}
export function isConsentChecked<F extends object>(
fn: unknown,
): fn is ConsentChecked<F> {
return hasBrand(fn, "__consentChecked");
}
export function isRateLimited<F extends object>(
fn: unknown,
): fn is RateLimited<F> {
return hasBrand(fn, "__rateLimited");
}
export function isReadOnly<F extends object>(fn: unknown): fn is ReadOnly<F> {
return hasBrand(fn, "__readonly");
}

View File

@@ -0,0 +1,32 @@
import { describe, it, expectTypeOf } from "vitest";
import type { Instrumented, Captured, RateLimited } from "@/conformance/brands";
describe("brand types", () => {
it("Instrumented<F> is structurally F plus a phantom flag", () => {
type Fn = (n: number) => Promise<string>;
expectTypeOf<Instrumented<Fn>>().toBeCallableWith(1);
expectTypeOf<Instrumented<Fn>>().returns.resolves.toEqualTypeOf<string>();
// The flag is readonly and required for assignability checks.
expectTypeOf<Instrumented<Fn>["__instrumented"]>().toEqualTypeOf<true>();
});
it("Captured<F> is structurally F plus a phantom flag", () => {
type Fn = (n: number) => Promise<string>;
expectTypeOf<Captured<Fn>>().toBeCallableWith(1);
expectTypeOf<Captured<Fn>["__captured"]>().toEqualTypeOf<true>();
});
it("RateLimited<F> is structurally F plus a phantom flag", () => {
type Fn = (n: number) => Promise<string>;
expectTypeOf<RateLimited<Fn>>().toBeCallableWith(1);
expectTypeOf<RateLimited<Fn>["__rateLimited"]>().toEqualTypeOf<true>();
});
it("brands compose without conflict", () => {
type Fn = (n: number) => Promise<string>;
type Both = Instrumented<Fn> & Captured<Fn>;
expectTypeOf<Both>().toBeCallableWith(1);
expectTypeOf<Both["__instrumented"]>().toEqualTypeOf<true>();
expectTypeOf<Both["__captured"]>().toEqualTypeOf<true>();
});
});

View File

@@ -0,0 +1,20 @@
/**
* Phantom-type brands attached at wrap time by `withSpan`, `withCapture`,
* and `withAudit`. The brand has a type-level form (this file) and a
* non-enumerable runtime counterpart (see `./brand-runtime.ts`). The runtime
* cost is one `Object.defineProperty` call per wrap. The conformance system
* uses these as the type-level seam the binding signature checks; a use-case
* factory that hasn't been wrapped is not assignable to a
* `ProductionUseCase<...>` slot.
*/
export type Instrumented<F> = F & { readonly __instrumented: true };
export type Captured<F> = F & { readonly __captured: true };
export type Analyzed<F> = F & { readonly __analyzed: true };
export type ConsentChecked<F> = F & { readonly __consentChecked: true };
export type RateLimited<F> = F & { readonly __rateLimited: true };
/**
* Brand for use cases declared `mutates: false`. Readers only accept
* `ReadOnly`-branded use cases in their constructor — prevents mutating
* use cases from being wired into a reader at compile time.
*/
export type ReadOnly<F> = F & { readonly __readonly: true };

View File

@@ -0,0 +1,17 @@
import { describe, it, expect } from "vitest";
import { ConformanceError } from "@/conformance/conformance-error";
describe("ConformanceError", () => {
it("extends Error with the standard shape", () => {
const err = new ConformanceError("auth.signIn: missing __instrumented brand");
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(ConformanceError);
expect(err.message).toBe("auth.signIn: missing __instrumented brand");
expect(err.name).toBe("ConformanceError");
});
it("preserves a stack trace", () => {
const err = new ConformanceError("test");
expect(typeof err.stack).toBe("string");
});
});

View File

@@ -0,0 +1,13 @@
/**
* Thrown by `assertFeatureConformance` when a binding does not match the
* manifest's declared shape. The boot assertion lets this propagate
* synchronously so `pnpm dev` refuses to start on drift.
*/
export class ConformanceError extends Error {
constructor(message: string) {
super(message);
this.name = "ConformanceError";
// Maintain a proper prototype chain across down-compilation.
Object.setPrototypeOf(this, ConformanceError.prototype);
}
}

View File

@@ -0,0 +1,171 @@
/**
* Coverage primitives — types, defaults, and the vitest-threshold helper.
*
* This file is intentionally **self-contained** (no relative imports) so it
* can be loaded at vitest config time, where the Node ESM resolver doesn't
* auto-resolve `.ts` extensions on relative imports. Other files in
* `conformance/` (including `define-feature.ts`) import their coverage types
* from here, not the reverse. See ADR-020.
*/
/**
* A coverage threshold band for a specific path or layer. All four percentages
* are required; partial bands are not supported (matches vitest's v8 coverage
* threshold shape exactly).
*/
export type CoverageBand = {
readonly statements: number;
readonly branches: number;
readonly functions: number;
readonly lines: number;
};
/**
* The named coverage bands a feature declares. Keys are layer aliases
* resolved to glob patterns under `src/`; `baseline` is the fallthrough
* applied to everything not matched by a layer band.
*
* Layer aliases (relative to `packages/<feature>/src/`):
* - "entities" -> entities/**
* - "use-cases" -> application/use-cases/**
* - "controllers" -> interface-adapters/controllers/**
*
* `baseline` is REQUIRED. Layer bands are OPTIONAL.
*/
export type CoverageBands = {
readonly baseline: CoverageBand;
readonly entities?: CoverageBand;
readonly "use-cases"?: CoverageBand;
readonly controllers?: CoverageBand;
};
/**
* The coverage section of a feature manifest.
*/
export type CoverageManifest = {
readonly bands: CoverageBands;
readonly mutationTargets?: readonly ("entities" | "use-cases")[];
readonly mutationScore?: number;
};
/**
* The defaults applied when a manifest omits the `coverage` section.
* Matches the historical per-feature vitest.config.ts thresholds documented
* in ADR-011 (TDD foundation).
*/
export const DEFAULT_COVERAGE_BANDS: CoverageBands = {
baseline: { statements: 80, branches: 75, functions: 80, lines: 80 },
entities: { statements: 100, branches: 100, functions: 100, lines: 100 },
"use-cases": { statements: 100, branches: 95, functions: 100, lines: 100 },
controllers: { statements: 100, branches: 95, functions: 100, lines: 100 },
};
export const DEFAULT_MUTATION_SCORE = 80;
export const DEFAULT_MUTATION_TARGETS = ["entities", "use-cases"] as const;
/**
* Maps a CoverageBands layer alias to the glob pattern vitest matches files
* against. Per ADR-020.
*/
const LAYER_GLOBS = {
entities: "src/entities/**",
"use-cases": "src/application/use-cases/**",
controllers: "src/interface-adapters/controllers/**",
} as const;
type ThresholdNumbers = {
statements: number;
branches: number;
functions: number;
lines: number;
};
/**
* The shape vitest's `coverage.thresholds` config accepts: a baseline plus
* per-glob overrides.
*/
export type VitestThresholds = ThresholdNumbers & {
[glob: string]: number | ThresholdNumbers;
};
/**
* The minimum manifest shape this file consumes. Generic constraint so
* callers can pass any object satisfying the structural minimum (e.g. a
* full `FeatureManifest` with extra properties).
*/
type ManifestWithCoverage = {
readonly coverage?: CoverageManifest;
};
/**
* Resolve a manifest's effective coverage bands, filling in DEFAULT_* for
* missing layers. Always go through this helper rather than reading
* `manifest.coverage` directly.
*/
export function getCoverageBands<M extends ManifestWithCoverage>(
manifest: M,
): CoverageBands {
const declared = manifest.coverage?.bands;
if (!declared) return DEFAULT_COVERAGE_BANDS;
return {
baseline: declared.baseline,
entities: declared.entities ?? DEFAULT_COVERAGE_BANDS.entities,
"use-cases": declared["use-cases"] ?? DEFAULT_COVERAGE_BANDS["use-cases"],
controllers: declared.controllers ?? DEFAULT_COVERAGE_BANDS.controllers,
};
}
/**
* Resolve a manifest's mutation configuration, falling back to defaults.
*/
export function getMutationConfig<M extends ManifestWithCoverage>(
manifest: M,
): {
targets: readonly ("entities" | "use-cases")[];
score: number;
} {
return {
targets: manifest.coverage?.mutationTargets ?? DEFAULT_MUTATION_TARGETS,
score: manifest.coverage?.mutationScore ?? DEFAULT_MUTATION_SCORE,
};
}
/**
* Convert resolved CoverageBands into the shape vitest expects under
* `coverage.thresholds`. Layer aliases become glob keys; baseline becomes
* the top-level keys.
*/
export function vitestThresholdsFromBands(
bands: CoverageBands,
): VitestThresholds {
const result: VitestThresholds = {
statements: bands.baseline.statements,
branches: bands.baseline.branches,
functions: bands.baseline.functions,
lines: bands.baseline.lines,
};
for (const [alias, glob] of Object.entries(LAYER_GLOBS) as Array<
[keyof typeof LAYER_GLOBS, string]
>) {
const band = bands[alias];
if (band) {
result[glob] = {
statements: band.statements,
branches: band.branches,
functions: band.functions,
lines: band.lines,
};
}
}
return result;
}
/**
* Convenience wrapper: resolve a manifest's effective bands then emit the
* vitest shape. Use this from a feature's `vitest.config.ts`.
*/
export function vitestThresholdsFromManifest<M extends ManifestWithCoverage>(
manifest: M,
): VitestThresholds {
return vitestThresholdsFromBands(getCoverageBands(manifest));
}

View File

@@ -0,0 +1,306 @@
import { describe, it, expect, expectTypeOf } from "vitest";
import {
defineFeature,
type FeatureManifest,
type UseCaseManifest,
} from "@/conformance/define-feature";
import type { RateLimitBudget } from "@/rate-limit/rate-limit.interface";
import {
DEFAULT_COVERAGE_BANDS,
DEFAULT_MUTATION_SCORE,
DEFAULT_MUTATION_TARGETS,
getCoverageBands,
getMutationConfig,
vitestThresholdsFromBands,
vitestThresholdsFromManifest,
type CoverageBand,
} from "@/conformance/coverage";
describe("defineFeature", () => {
it("preserves literal types of a manifest declared with `as const`", () => {
const manifest = defineFeature({
name: "auth",
requiredCores: ["audit"],
useCases: {
signIn: {
mutates: false,
audits: [],
publishes: [],
consumes: [],
},
signUp: {
mutates: true,
audits: ["user.created"],
publishes: ["auth.signed-up"],
consumes: [],
},
},
realtimeChannels: [],
jobs: [],
} as const);
// Literal preservation: name is the literal "auth", not string
expectTypeOf(manifest.name).toEqualTypeOf<"auth">();
// Use-case keys preserved
expectTypeOf(manifest.useCases.signUp.audits).toEqualTypeOf<
readonly ["user.created"]
>();
expectTypeOf(manifest.useCases.signUp.mutates).toEqualTypeOf<true>();
expectTypeOf(manifest.useCases.signIn.mutates).toEqualTypeOf<false>();
});
it("FeatureManifest type accepts the shape", () => {
const manifest = defineFeature({
name: "blog",
requiredCores: [],
useCases: {},
realtimeChannels: [],
jobs: [],
} as const);
expectTypeOf(manifest).toMatchTypeOf<FeatureManifest>();
});
it("accepts an optional rateLimit array on a use case", () => {
const manifest = defineFeature({
name: "auth",
requiredCores: [],
useCases: {
signIn: {
mutates: false,
audits: [],
publishes: [],
consumes: [],
rateLimit: [{ name: "login", window: "1m", budget: 5 }],
},
},
realtimeChannels: [],
jobs: [],
} as const);
expectTypeOf(manifest).toMatchTypeOf<FeatureManifest>();
expectTypeOf(manifest.useCases.signIn.rateLimit).toMatchTypeOf<
readonly RateLimitBudget[] | undefined
>();
});
it("use case without rateLimit satisfies UseCaseManifest", () => {
const uc: UseCaseManifest = {
mutates: false,
audits: [],
publishes: [],
consumes: [],
};
expectTypeOf(uc.rateLimit).toEqualTypeOf<
readonly RateLimitBudget[] | undefined
>();
});
it("accepts an optional coverage section", () => {
const manifest = defineFeature({
name: "auth",
requiredCores: [],
useCases: {},
realtimeChannels: [],
jobs: [],
coverage: {
bands: {
baseline: { statements: 80, branches: 75, functions: 80, lines: 80 },
entities: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
mutationTargets: ["entities"],
mutationScore: 85,
},
} as const);
expectTypeOf(manifest.coverage).toMatchTypeOf<
| undefined
| {
readonly bands: {
readonly baseline: CoverageBand;
readonly entities?: CoverageBand;
readonly "use-cases"?: CoverageBand;
readonly controllers?: CoverageBand;
};
}
>();
expect(manifest.coverage?.mutationScore).toBe(85);
});
});
describe("getCoverageBands", () => {
const blankManifest: FeatureManifest = {
name: "test",
requiredCores: [],
useCases: {},
realtimeChannels: [],
jobs: [],
};
it("returns DEFAULT_COVERAGE_BANDS when manifest has no coverage section", () => {
expect(getCoverageBands(blankManifest)).toEqual(DEFAULT_COVERAGE_BANDS);
});
it("returns DEFAULT_COVERAGE_BANDS when coverage section is undefined", () => {
expect(getCoverageBands({ ...blankManifest, coverage: undefined })).toEqual(
DEFAULT_COVERAGE_BANDS,
);
});
it("uses declared baseline + fills missing layers from defaults", () => {
const result = getCoverageBands({
...blankManifest,
coverage: {
bands: {
baseline: { statements: 90, branches: 85, functions: 90, lines: 90 },
},
},
});
expect(result.baseline).toEqual({
statements: 90,
branches: 85,
functions: 90,
lines: 90,
});
expect(result.entities).toEqual(DEFAULT_COVERAGE_BANDS.entities);
expect(result["use-cases"]).toEqual(DEFAULT_COVERAGE_BANDS["use-cases"]);
expect(result.controllers).toEqual(DEFAULT_COVERAGE_BANDS.controllers);
});
it("uses declared layer bands when present", () => {
const customEntities: CoverageBand = {
statements: 95,
branches: 90,
functions: 95,
lines: 95,
};
const result = getCoverageBands({
...blankManifest,
coverage: {
bands: {
baseline: DEFAULT_COVERAGE_BANDS.baseline,
entities: customEntities,
},
},
});
expect(result.entities).toEqual(customEntities);
});
});
describe("getMutationConfig", () => {
const blankManifest: FeatureManifest = {
name: "test",
requiredCores: [],
useCases: {},
realtimeChannels: [],
jobs: [],
};
it("returns defaults when manifest has no coverage section", () => {
const config = getMutationConfig(blankManifest);
expect(config.targets).toEqual(DEFAULT_MUTATION_TARGETS);
expect(config.score).toBe(DEFAULT_MUTATION_SCORE);
});
it("honors declared mutationTargets + mutationScore", () => {
const config = getMutationConfig({
...blankManifest,
coverage: {
bands: { baseline: DEFAULT_COVERAGE_BANDS.baseline },
mutationTargets: ["entities"],
mutationScore: 90,
},
});
expect(config.targets).toEqual(["entities"]);
expect(config.score).toBe(90);
});
});
describe("DEFAULT_COVERAGE_BANDS", () => {
it("matches the ADR-011 / ADR-020 documented bands", () => {
expect(DEFAULT_COVERAGE_BANDS.baseline).toEqual({
statements: 80,
branches: 75,
functions: 80,
lines: 80,
});
expect(DEFAULT_COVERAGE_BANDS.entities).toEqual({
statements: 100,
branches: 100,
functions: 100,
lines: 100,
});
expect(DEFAULT_COVERAGE_BANDS["use-cases"]).toEqual({
statements: 100,
branches: 95,
functions: 100,
lines: 100,
});
expect(DEFAULT_COVERAGE_BANDS.controllers).toEqual({
statements: 100,
branches: 95,
functions: 100,
lines: 100,
});
});
});
describe("vitestThresholdsFromBands", () => {
it("emits baseline at the top level and each declared layer under its glob", () => {
const result = vitestThresholdsFromBands(DEFAULT_COVERAGE_BANDS);
expect(result.statements).toBe(80);
expect(result.branches).toBe(75);
expect(result["src/entities/**"]).toEqual({
statements: 100,
branches: 100,
functions: 100,
lines: 100,
});
expect(result["src/application/use-cases/**"]).toEqual({
statements: 100,
branches: 95,
functions: 100,
lines: 100,
});
expect(result["src/interface-adapters/controllers/**"]).toEqual({
statements: 100,
branches: 95,
functions: 100,
lines: 100,
});
});
it("omits layer glob when its band is undefined", () => {
const result = vitestThresholdsFromBands({
baseline: DEFAULT_COVERAGE_BANDS.baseline,
entities: DEFAULT_COVERAGE_BANDS.entities,
});
expect(result["src/entities/**"]).toBeDefined();
expect(result["src/application/use-cases/**"]).toBeUndefined();
expect(result["src/interface-adapters/controllers/**"]).toBeUndefined();
});
});
describe("vitestThresholdsFromManifest", () => {
it("uses DEFAULT_COVERAGE_BANDS when the manifest omits coverage", () => {
const manifest: FeatureManifest = {
name: "test",
requiredCores: [],
useCases: {},
realtimeChannels: [],
jobs: [],
};
const result = vitestThresholdsFromManifest(manifest);
expect(result.statements).toBe(80);
expect(result["src/entities/**"]).toEqual({
statements: 100,
branches: 100,
functions: 100,
lines: 100,
});
});
});

View File

@@ -0,0 +1,59 @@
import type { CoverageManifest } from "./coverage";
import type { RateLimitBudget } from "../rate-limit/rate-limit.interface";
/**
* Per-use-case manifest entry. Declares what the use case does at the contract
* level: whether it mutates state, what audit events it emits, what cross-feature
* events it publishes or consumes, and what cross-feature readers it depends on.
* The conformance system reads these to derive binding-slot types and to verify
* code against manifest declarations.
*/
export type UseCaseManifest = {
readonly mutates: boolean;
readonly audits: readonly string[];
readonly publishes: readonly string[];
readonly consumes: readonly string[];
/** Feature names whose readers this use case queries. */
readonly reads?: readonly string[];
readonly analyticsEvents?: readonly string[];
readonly rateLimit?: readonly RateLimitBudget[];
};
/**
* The feature-level manifest. One per feature package, conventionally exported
* as `<featureName>Manifest` from `src/feature.manifest.ts`.
*
* `coverage` is optional for backward compatibility — features without a
* declared coverage section fall back to `DEFAULT_COVERAGE_BANDS` via
* `getCoverageBands(manifest)` (see `./coverage.ts`).
*/
export type FeatureManifest = {
readonly name: string;
readonly requiredCores: readonly string[];
readonly useCases: { readonly [k: string]: UseCaseManifest };
readonly realtimeChannels: readonly string[];
readonly jobs: readonly string[];
readonly coverage?: CoverageManifest;
/**
* Consent categories this feature's use cases require before processing
* personal data. When non-empty, `assertFeatureConformance` requires every
* bound use case to carry the `__consentChecked` brand from `withConsent`.
* Defaults to `[]` — existing features with no consent requirements omit
* or declare an empty array.
*/
readonly requiresConsent?: readonly string[];
};
/**
* Identity helper that exists purely to widen the input type to satisfy
* `FeatureManifest` while preserving the literal types of the `as const`
* input. Downstream types (`ProductionUseCase<I, O, M>`) consume the
* preserved literals to derive binding-slot brand requirements.
*
* Usage:
*
* export const authManifest = defineFeature({ name: "auth", ... } as const);
*/
export function defineFeature<const M extends FeatureManifest>(manifest: M): M {
return manifest;
}

View File

@@ -0,0 +1,40 @@
export type {
Instrumented,
Captured,
Analyzed,
ConsentChecked,
RateLimited,
ReadOnly,
} from "./brands";
export type { FeatureManifest, UseCaseManifest } from "./define-feature";
export { defineFeature } from "./define-feature";
export type {
CoverageBand,
CoverageBands,
CoverageManifest,
VitestThresholds,
} from "./coverage";
export {
DEFAULT_COVERAGE_BANDS,
DEFAULT_MUTATION_SCORE,
DEFAULT_MUTATION_TARGETS,
getCoverageBands,
getMutationConfig,
vitestThresholdsFromBands,
vitestThresholdsFromManifest,
} from "./coverage";
export type { ProductionUseCase } from "./production-use-case";
export {
attachBrand,
isInstrumented,
isCaptured,
isAudited,
isAnalyzed,
isConsentChecked,
isRateLimited,
isReadOnly,
} from "./brand-runtime";
export { ConformanceError } from "./conformance-error";
export { assertFeatureConformance } from "./assert-bindings";
export { wireUseCase } from "./wire-use-case";
export type { WireUseCaseOptions } from "./wire-use-case";

View File

@@ -0,0 +1,34 @@
import { describe, it, expectTypeOf } from "vitest";
import type { ProductionUseCase } from "@/conformance/production-use-case";
import type { Instrumented, Captured } from "@/conformance/brands";
describe("ProductionUseCase<I, O, M>", () => {
it("requires Instrumented + Captured for any use case", () => {
type Manifest = {
mutates: false;
audits: readonly [];
publishes: readonly [];
consumes: readonly [];
};
type Slot = ProductionUseCase<{ x: number }, { y: string }, Manifest>;
type Wrapped = Instrumented<(input: { x: number }) => Promise<{ y: string }>> &
Captured<(input: { x: number }) => Promise<{ y: string }>>;
expectTypeOf<Wrapped>().toMatchTypeOf<Slot>();
});
it("a plain factory is NOT assignable to the slot", () => {
type Manifest = {
mutates: false;
audits: readonly [];
publishes: readonly [];
consumes: readonly [];
};
type Slot = ProductionUseCase<{ x: number }, { y: string }, Manifest>;
const factory = async (input: { x: number }) => ({ y: String(input.x) });
// @ts-expect-error — factory has no __instrumented / __captured brand
const bad: Slot = factory;
void bad;
});
});

View File

@@ -0,0 +1,24 @@
import type { UseCaseManifest } from "./define-feature";
import type { Instrumented, Captured } from "./brands";
/**
* Type-level binding slot for production use cases. Derived from the manifest
* entry: every binding must be Instrumented + Captured; mutating use cases
* that declare audits additionally must be Audited. The Audited brand lives
* in `@repo/core-audit` because the wrap helper that attaches it depends on
* `IAuditLog` — feature packages import the merged slot type implicitly
* by typing their bindings as `ProductionUseCase<I, O, AuthManifest["useCases"]["signIn"]>`.
*
* The Audited requirement is encoded conditionally so this type stays usable
* without depending on core-audit. When `mutates: true` AND `audits` is
* non-empty, the slot demands a marker type with a `__audited` flag; the
* concrete `Audited<F>` from core-audit satisfies it.
*/
export type ProductionUseCase<I, O, M extends UseCaseManifest> =
& Instrumented<(input: I) => Promise<O>>
& Captured<(input: I) => Promise<O>>
& (M["mutates"] extends true
? M["audits"]["length"] extends 0
? unknown
: { readonly __audited: true }
: unknown);

View File

@@ -0,0 +1,512 @@
import { describe, it, expect, vi } from "vitest";
import "reflect-metadata";
import { Container } from "inversify";
import { wireUseCase } from "@/conformance/wire-use-case";
import {
isInstrumented,
isCaptured,
isAudited,
isAnalyzed,
isRateLimited,
} from "@/conformance/brand-runtime";
import type {
ITracer,
ISpan,
SpanOpts,
} from "@/instrumentation/tracer.interface";
import type { ILogger } from "@/instrumentation/logger.interface";
import type { AuditLogProtocol, AnalyticsProtocol } from "@/di/bind-protocols";
import { NoopRateLimit } from "@/rate-limit/noop-rate-limit";
function makeTracer() {
const calls: SpanOpts[] = [];
const tracer: ITracer = {
startSpan: vi.fn(async (opts, fn) => {
calls.push(opts);
const span: ISpan = { setAttribute: () => {}, setStatus: () => {} };
return fn(span);
}),
};
return { tracer, calls };
}
function makeLogger(): ILogger & {
captureException: ReturnType<typeof vi.fn>;
} {
return {
captureException: vi.fn(),
captureMessage: vi.fn(),
addBreadcrumb: vi.fn(),
setUser: vi.fn(),
};
}
function makeAuditLog(): AuditLogProtocol {
return { record: vi.fn() };
}
function makeAnalytics(): AnalyticsProtocol {
return { track: vi.fn() };
}
const doubleFactory = () => async (x: number) => x * 2;
describe("wireUseCase — no-audit path", () => {
it("attaches Instrumented + Captured brands, no Audited brand", () => {
const { tracer } = makeTracer();
const logger = makeLogger();
const container = new Container();
const sym = Symbol("test.double");
const wired = wireUseCase({
container,
symbol: sym,
factory: doubleFactory,
deps: [],
feature: "test",
layer: "use-case",
name: "double",
tracer,
logger,
});
expect(isInstrumented(wired)).toBe(true);
expect(isCaptured(wired)).toBe(true);
expect(isAudited(wired)).toBe(false);
});
it("executes the factory result on invocation", async () => {
const { tracer } = makeTracer();
const logger = makeLogger();
const container = new Container();
const sym = Symbol("test.double");
const wired = wireUseCase({
container,
symbol: sym,
factory: doubleFactory,
deps: [],
feature: "test",
layer: "use-case",
name: "double",
tracer,
logger,
});
await expect(wired(3)).resolves.toBe(6);
});
});
describe("wireUseCase — audit path", () => {
it("attaches Instrumented + Captured + Audited brands when auditLog is provided", () => {
const { tracer } = makeTracer();
const logger = makeLogger();
const container = new Container();
const sym = Symbol("test.double");
const auditLog = makeAuditLog();
const wired = wireUseCase({
container,
symbol: sym,
factory: doubleFactory,
deps: [],
feature: "test",
layer: "use-case",
name: "double",
tracer,
logger,
auditLog,
});
expect(isInstrumented(wired)).toBe(true);
expect(isCaptured(wired)).toBe(true);
expect(isAudited(wired)).toBe(true);
});
it("executes the factory result on invocation (audit path)", async () => {
const { tracer } = makeTracer();
const logger = makeLogger();
const container = new Container();
const sym = Symbol("test.double");
const auditLog = makeAuditLog();
const wired = wireUseCase({
container,
symbol: sym,
factory: doubleFactory,
deps: [],
feature: "test",
layer: "use-case",
name: "double",
tracer,
logger,
auditLog,
});
await expect(wired(5)).resolves.toBe(10);
});
});
describe("wireUseCase — span-name derivation", () => {
it("derives span name as <feature>.<name> and uses layer as op", async () => {
const { tracer, calls } = makeTracer();
const logger = makeLogger();
const container = new Container();
const sym = Symbol("auth.signIn");
const wired = wireUseCase({
container,
symbol: sym,
factory: doubleFactory,
deps: [],
feature: "auth",
layer: "use-case",
name: "signIn",
tracer,
logger,
});
await wired(1);
expect(calls).toHaveLength(1);
expect(calls[0]).toMatchObject({ name: "auth.signIn", op: "use-case" });
});
});
describe("wireUseCase — capture-tag structure", () => {
it("captures with { feature, layer, name: '<feature>.<name>' } when an error is thrown", async () => {
const { tracer } = makeTracer();
const logger = makeLogger();
const container = new Container();
const sym = Symbol("blog.getArticles");
const err = new Error("boom");
const wired = wireUseCase({
container,
symbol: sym,
factory: () => async () => {
throw err;
},
deps: [],
feature: "blog",
layer: "use-case",
name: "getArticles",
tracer,
logger,
});
await expect(wired()).rejects.toBe(err);
expect(logger.captureException).toHaveBeenCalledWith(err, {
tags: { feature: "blog", layer: "use-case", name: "blog.getArticles" },
});
});
});
describe("wireUseCase — container binding", () => {
it("binds the wired value to the container symbol", () => {
const { tracer } = makeTracer();
const logger = makeLogger();
const container = new Container();
const sym = Symbol("test.double");
const wired = wireUseCase({
container,
symbol: sym,
factory: doubleFactory,
deps: [],
feature: "test",
layer: "use-case",
name: "double",
tracer,
logger,
});
expect(container.get(sym)).toBe(wired);
});
it("passes deps tuple to the factory", async () => {
const { tracer } = makeTracer();
const logger = makeLogger();
const container = new Container();
const sym = Symbol("test.scale");
const scaleFactory = (multiplier: number) => async (x: number) =>
x * multiplier;
const wired = wireUseCase({
container,
symbol: sym,
factory: scaleFactory,
deps: [3] as [number],
feature: "test",
layer: "use-case",
name: "scale",
tracer,
logger,
});
await expect(wired(4)).resolves.toBe(12);
});
});
describe("wireUseCase — analytics path", () => {
it("attaches Analyzed brand when analytics is provided", () => {
const { tracer } = makeTracer();
const logger = makeLogger();
const container = new Container();
const sym = Symbol("test.double");
const analytics = makeAnalytics();
const wired = wireUseCase({
container,
symbol: sym,
factory: doubleFactory,
deps: [],
feature: "test",
layer: "use-case",
name: "double",
tracer,
logger,
analytics,
});
expect(isAnalyzed(wired)).toBe(true);
expect(isInstrumented(wired)).toBe(true);
expect(isCaptured(wired)).toBe(true);
});
it("attaches Analyzed + Audited brands when both analytics and auditLog are provided", () => {
const { tracer } = makeTracer();
const logger = makeLogger();
const container = new Container();
const sym = Symbol("test.double");
const analytics = makeAnalytics();
const auditLog = makeAuditLog();
const wired = wireUseCase({
container,
symbol: sym,
factory: doubleFactory,
deps: [],
feature: "test",
layer: "use-case",
name: "double",
tracer,
logger,
analytics,
auditLog,
});
expect(isAnalyzed(wired)).toBe(true);
expect(isAudited(wired)).toBe(true);
expect(isInstrumented(wired)).toBe(true);
expect(isCaptured(wired)).toBe(true);
});
it("executes the factory result on invocation (analytics path)", async () => {
const { tracer } = makeTracer();
const logger = makeLogger();
const container = new Container();
const sym = Symbol("test.double");
const analytics = makeAnalytics();
const wired = wireUseCase({
container,
symbol: sym,
factory: doubleFactory,
deps: [],
feature: "test",
layer: "use-case",
name: "double",
tracer,
logger,
analytics,
});
await expect(wired(7)).resolves.toBe(14);
});
});
describe("wireUseCase — no-analytics path", () => {
it("does not attach Analyzed brand when analytics is absent", () => {
const { tracer } = makeTracer();
const logger = makeLogger();
const container = new Container();
const sym = Symbol("test.double");
const wired = wireUseCase({
container,
symbol: sym,
factory: doubleFactory,
deps: [],
feature: "test",
layer: "use-case",
name: "double",
tracer,
logger,
});
expect(isAnalyzed(wired)).toBe(false);
});
});
describe("wireUseCase — idempotent re-bind", () => {
it("unbinds the existing binding and replaces it when the symbol is already bound", () => {
const { tracer } = makeTracer();
const logger = makeLogger();
const container = new Container();
const sym = Symbol("test.double");
wireUseCase({
container,
symbol: sym,
factory: doubleFactory,
deps: [],
feature: "test",
layer: "use-case",
name: "double",
tracer,
logger,
});
const second = wireUseCase({
container,
symbol: sym,
factory: doubleFactory,
deps: [],
feature: "test",
layer: "use-case",
name: "double",
tracer,
logger,
});
expect(container.get(sym)).toBe(second);
});
});
describe("wireUseCase — rate limit path", () => {
it("attaches RateLimited brand when rateLimit is provided", () => {
const { tracer } = makeTracer();
const logger = makeLogger();
const container = new Container();
const sym = Symbol("test.double");
const rateLimit = new NoopRateLimit();
const wired = wireUseCase({
container,
symbol: sym,
factory: doubleFactory,
deps: [],
feature: "test",
layer: "use-case",
name: "double",
tracer,
logger,
rateLimit,
});
expect(isRateLimited(wired)).toBe(true);
expect(isInstrumented(wired)).toBe(true);
expect(isCaptured(wired)).toBe(true);
});
it("does not attach RateLimited brand when rateLimit is absent", () => {
const { tracer } = makeTracer();
const logger = makeLogger();
const container = new Container();
const sym = Symbol("test.double");
const wired = wireUseCase({
container,
symbol: sym,
factory: doubleFactory,
deps: [],
feature: "test",
layer: "use-case",
name: "double",
tracer,
logger,
});
expect(isRateLimited(wired)).toBe(false);
});
it("executes factory correctly when rateLimit is provided", async () => {
const { tracer } = makeTracer();
const logger = makeLogger();
const container = new Container();
const sym = Symbol("test.double");
const rateLimit = new NoopRateLimit();
const wired = wireUseCase({
container,
symbol: sym,
factory: doubleFactory,
deps: [],
feature: "test",
layer: "use-case",
name: "double",
tracer,
logger,
rateLimit,
});
await expect(wired(5)).resolves.toBe(10);
});
it("attaches RateLimited + Analyzed brands when both rateLimit and analytics are provided", () => {
const { tracer } = makeTracer();
const logger = makeLogger();
const container = new Container();
const sym = Symbol("test.double");
const rateLimit = new NoopRateLimit();
const analytics = makeAnalytics();
const wired = wireUseCase({
container,
symbol: sym,
factory: doubleFactory,
deps: [],
feature: "test",
layer: "use-case",
name: "double",
tracer,
logger,
rateLimit,
analytics,
});
expect(isRateLimited(wired)).toBe(true);
expect(isAnalyzed(wired)).toBe(true);
expect(isInstrumented(wired)).toBe(true);
expect(isCaptured(wired)).toBe(true);
});
it("attaches RateLimited + Audited brands when both rateLimit and auditLog are provided", () => {
const { tracer } = makeTracer();
const logger = makeLogger();
const container = new Container();
const sym = Symbol("test.double");
const rateLimit = new NoopRateLimit();
const auditLog = makeAuditLog();
const wired = wireUseCase({
container,
symbol: sym,
factory: doubleFactory,
deps: [],
feature: "test",
layer: "use-case",
name: "double",
tracer,
logger,
rateLimit,
auditLog,
});
expect(isRateLimited(wired)).toBe(true);
expect(isAudited(wired)).toBe(true);
expect(isInstrumented(wired)).toBe(true);
expect(isCaptured(wired)).toBe(true);
});
});

View File

@@ -0,0 +1,120 @@
import type { Container } from "inversify";
import type { ITracer } from "../instrumentation/tracer.interface";
import type { ILogger } from "../instrumentation/logger.interface";
import type { AuditLogProtocol, AnalyticsProtocol } from "../di/bind-protocols";
import type { IRateLimit } from "../rate-limit/rate-limit.interface";
import { withSpan } from "../instrumentation/with-span";
import { withCapture } from "../instrumentation/with-capture";
import { withRateLimit } from "../rate-limit/with-rate-limit";
import { attachBrand } from "./brand-runtime";
export type WireUseCaseOptions<
Deps extends unknown[],
FnArgs extends unknown[],
R,
> = {
container: Container;
symbol: symbol;
factory: (...deps: Deps) => (...args: FnArgs) => Promise<R>;
deps: Deps;
feature: string;
layer: string;
name: string;
tracer: ITracer;
logger: ILogger;
analytics?: AnalyticsProtocol;
auditLog?: AuditLogProtocol;
rateLimit?: IRateLimit;
};
/**
* Encapsulates the withSpan(withCapture(withAudit?(factory(deps)))) composition
* and performs the container bind step. Callers pass options and get back a
* brand-stacked wired value that is also bound to the container symbol.
*
* Idempotent: if the symbol is already bound, the old binding is replaced.
*
* withAudit lives in @repo/core-audit which core-shared cannot import (dependency
* inversion: core-audit depends on core-shared, not vice versa). The audit branch
* here replicates the same semantics — forwarding wrapper + __audited brand —
* without introducing the circular dependency.
*/
export function wireUseCase<
Deps extends unknown[],
FnArgs extends unknown[],
R,
>(opts: WireUseCaseOptions<Deps, FnArgs, R>): (...args: FnArgs) => Promise<R> {
const {
container,
symbol,
factory,
deps,
feature,
layer,
name,
tracer,
logger,
analytics,
auditLog,
rateLimit,
} = opts;
const spanName = `${feature}.${name}`;
const captureTags = { feature, layer, name: spanName };
const raw = factory(...deps);
// rateLimit is innermost — wraps raw before analytics/audit. Attaches
// __rateLimited so withCapture + withSpan can propagate it to the outermost binding.
let toWrap: (...args: FnArgs) => Promise<R> =
rateLimit !== undefined ? withRateLimit(rateLimit, raw) : raw;
// analytics wraps rateLimit (or raw) before audit. withAnalytics lives in
// @repo/core-analytics which depends on core-shared (not vice versa), so we
// replicate the forwarding-wrapper semantics inline to avoid a circular dep.
if (analytics !== undefined) {
void analytics; // reserved for future automated event recording from manifest declarations
const prev = toWrap;
const analyzed: (...args: FnArgs) => Promise<R> = (...args) =>
prev(...args);
attachBrand(analyzed, "__analyzed");
// propagate __rateLimited from inner so withCapture/withSpan see it
if (
(prev as unknown as Record<string, unknown>)["__rateLimited"] === true
) {
attachBrand(analyzed, "__rateLimited");
}
toWrap = analyzed;
}
if (auditLog !== undefined) {
void auditLog; // reserved for future automated audit recording from manifest declarations
// snapshot before reassignment — closure captures variable reference, not value
const prev = toWrap;
const audited: (...args: FnArgs) => Promise<R> = (...args) => prev(...args);
attachBrand(audited, "__audited");
// propagate __analyzed and __rateLimited from inner so withCapture/withSpan
// can see them on the outermost binding
if ((prev as unknown as Record<string, unknown>)["__analyzed"] === true) {
attachBrand(audited, "__analyzed");
}
if (
(prev as unknown as Record<string, unknown>)["__rateLimited"] === true
) {
attachBrand(audited, "__rateLimited");
}
toWrap = audited;
}
const wired = withSpan(
tracer,
{ name: spanName, op: layer },
withCapture(logger, captureTags, toWrap),
);
if (container.isBound(symbol)) {
container.unbind(symbol);
}
container.bind(symbol).toConstantValue(wired);
return wired;
}

View File

@@ -0,0 +1,62 @@
import type { SanitizedConfig } from "payload";
import type { ITracer, ILogger } from "../instrumentation";
import type { IJobQueue } from "../jobs";
import type { IRateLimit } from "../rate-limit/rate-limit.interface";
import type {
EventBusProtocol,
RealtimeBroadcasterProtocol,
RealtimeRegistryProtocol,
MetricsProtocol,
AuditLogProtocol,
AnalyticsProtocol,
ConsentFactoryProtocol,
} from "./bind-protocols";
/** Always-present fields. Feature binders rely on these unconditionally. */
type BindContextBase = {
tracer: ITracer;
logger: ILogger;
};
/**
* Optional cross-cutting deps. Generics let the app aggregator narrow the
* shape to full interfaces (`IEventBus`, `IRealtimeBroadcaster`, `IMetrics`,
* `IAuditLog`, etc.); feature binders see only the protocol surface, which is
* enough for the methods they call. When an optional core package is absent the
* corresponding generic defaults to its protocol type, and `ctx.bus` /
* `ctx.realtime` / `ctx.metrics` / `ctx.auditLog` are undefined at runtime.
*
* The 4th generic `Metrics` and 5th generic `Audit` both default to their
* protocol types so existing call sites that pass fewer explicit args remain
* backward-compatible.
*/
export type BindContext<
Bus extends EventBusProtocol = EventBusProtocol,
Realtime extends RealtimeBroadcasterProtocol = RealtimeBroadcasterProtocol,
RealtimeReg extends RealtimeRegistryProtocol = RealtimeRegistryProtocol,
Metrics extends MetricsProtocol = MetricsProtocol,
Audit extends AuditLogProtocol = AuditLogProtocol,
Analytics extends AnalyticsProtocol = AnalyticsProtocol,
> = BindContextBase & {
bus?: Bus;
queue?: IJobQueue;
realtime?: Realtime;
realtimeRegistry?: RealtimeReg;
metrics?: Metrics;
auditLog?: Audit;
analytics?: Analytics;
consentFactory?: ConsentFactoryProtocol;
rateLimit?: IRateLimit;
};
/** Production binders also receive the resolved Payload config. */
export type BindProductionContext<
Bus extends EventBusProtocol = EventBusProtocol,
Realtime extends RealtimeBroadcasterProtocol = RealtimeBroadcasterProtocol,
RealtimeReg extends RealtimeRegistryProtocol = RealtimeRegistryProtocol,
Metrics extends MetricsProtocol = MetricsProtocol,
Audit extends AuditLogProtocol = AuditLogProtocol,
Analytics extends AnalyticsProtocol = AnalyticsProtocol,
> = BindContext<Bus, Realtime, RealtimeReg, Metrics, Audit, Analytics> & {
config: SanitizedConfig;
};

View File

@@ -0,0 +1,46 @@
import { describe, it, expectTypeOf } from "vitest";
import type {
EventBusProtocol,
RealtimeBroadcasterProtocol,
RealtimeRegistryProtocol,
AnalyticsProtocol,
} from "./bind-protocols";
// Protocol-shape tests. These verify each protocol type EXPORTS the
// expected method names. Full assignability of `IEventBus` /
// `IRealtimeBroadcaster` / `IRealtimeHandlerRegistry` to their respective
// protocols is verified at the optional-package level — when those packages
// are scaffolded back via `pnpm turbo gen core-package <name>`, the
// `extends`-link in their interface declarations forces a typecheck
// failure if the protocol surface ever drifts.
describe("EventBusProtocol", () => {
it("requires publish(event, payload) and subscribe(event, consumer, handler)", () => {
type Bus = EventBusProtocol;
expectTypeOf<Bus["publish"]>().toBeFunction();
expectTypeOf<Bus["subscribe"]>().toBeFunction();
});
});
describe("RealtimeBroadcasterProtocol", () => {
it("requires broadcast(channel, payload)", () => {
type Rt = RealtimeBroadcasterProtocol;
expectTypeOf<Rt["broadcast"]>().toBeFunction();
});
});
describe("RealtimeRegistryProtocol", () => {
it("requires register, registerChannel, listChannels", () => {
type Reg = RealtimeRegistryProtocol;
expectTypeOf<Reg["register"]>().toBeFunction();
expectTypeOf<Reg["registerChannel"]>().toBeFunction();
expectTypeOf<Reg["listChannels"]>().toBeFunction();
});
});
describe("AnalyticsProtocol", () => {
it("requires track(event, attributes?)", () => {
type A = AnalyticsProtocol;
expectTypeOf<A["track"]>().toBeFunction();
});
});

View File

@@ -0,0 +1,102 @@
/**
* Minimal protocol surfaces used by feature binders to interact with optional
* cross-cutting infrastructure (event bus, realtime broadcaster, realtime
* handler registry, metrics, audit log). Lives in `core-shared` so `BindContext`
* can reference these unconditionally — features depend on `core-shared`, never
* on the optional packages directly.
*
* The optional packages' full interfaces (`IEventBus`, `IRealtimeBroadcaster`,
* `IRealtimeHandlerRegistry`, `IMetrics`, `IAuditLog`) `extends` these —
* typechecks fail if a refactor narrows the protocol surface in a way the full
* interface would lose.
*/
import type { AuditEntry } from "../audit/audit-entry";
export type EventBusProtocol = {
publish<T>(event: { name: string }, payload: T): Promise<void>;
subscribe<T>(
event: { name: string },
consumer: string,
handler: (payload: T) => Promise<void>,
): void;
};
export type RealtimeBroadcasterProtocol = {
broadcast<T>(
channel: { name: string; key?: unknown },
payload: T,
): Promise<void>;
};
export type RealtimeRegistryProtocol = {
register(entry: { descriptor: unknown; handler: unknown }): void;
registerChannel(descriptor: unknown): void;
listChannels(): unknown[];
};
/**
* Minimal metrics protocol surface. `IMetrics` in `core-shared/instrumentation`
* extends this — typechecks fail if `IMetrics` is narrowed below this surface.
* Feature binders that receive `ctx.metrics` see only this protocol type.
*/
export type MetricsProtocol = {
counter(
name: string,
value?: number,
attributes?: Record<string, string | number | boolean>,
): void;
histogram(
name: string,
value: number,
attributes?: Record<string, string | number | boolean>,
): void;
gauge(
name: string,
value: number,
attributes?: Record<string, string | number | boolean>,
): void;
};
/**
* Minimal audit-log protocol surface. `IAuditLog` (in optional `@repo/core-audit`)
* extends this — typechecks fail if narrowed below. Feature binders that
* receive `ctx.auditLog` see only this protocol type.
*
* `eraseSubject` is NOT on the protocol — it's a privileged op exposed only
* on the full `IAuditLog` interface in the optional package.
*/
export type AuditLogProtocol = {
record(entry: AuditEntry): Promise<void>;
};
/**
* Minimal analytics protocol surface. `IAnalytics` (in `@repo/core-analytics`)
* extends this — typechecks fail if narrowed below. Feature binders that
* receive `ctx.analytics` see only this protocol type.
*/
export type AnalyticsProtocol = {
track(
event: string,
attributes?: Record<string, string | number | boolean>,
): void;
};
/**
* Minimal consent protocol surface. `IConsent` (in optional `@repo/core-consent`)
* extends this — typechecks fail if narrowed below. Feature binders that
* receive `ctx.consentFactory` see only this protocol type.
*/
export type ConsentGrantMeta = {
method?: string;
bannerVersion?: string;
policyVersion?: string;
};
export type ConsentProtocol = {
grant(category: string, meta?: ConsentGrantMeta): Promise<void>;
};
/** Factory that creates a per-user consent instance. Mirrors ConsentFactory in `@repo/core-consent`. */
export type ConsentFactoryProtocol = (
userId: string,
) => Promise<ConsentProtocol>;

View File

@@ -0,0 +1,2 @@
export * from "./bind-protocols";
export * from "./bind-context";

View File

@@ -0,0 +1,7 @@
export { requireEnv } from "./lib/env";
export { toIsoString } from "./lib/date";
export * from "./audit";
export * from "./di";
export * from "./instrumentation/index";
export * from "./rate-limit/index";
export * from "./security/index";

View File

@@ -0,0 +1,37 @@
// packages/core-shared/src/instrumentation/di/bind-noop-instrumentation.test.ts
import "reflect-metadata";
import { describe, it, expect } from "vitest";
import { Container } from "inversify";
import { bindNoopInstrumentation } from "@/instrumentation/di/bind-noop-instrumentation";
import { INSTRUMENTATION_SYMBOLS } from "@/instrumentation/symbols";
import { NoopTracer } from "@/instrumentation/noop-tracer";
import { NoopLogger } from "@/instrumentation/noop-logger";
import type { ITracer, ILogger } from "@/instrumentation";
describe("bindNoopInstrumentation", () => {
it("returns a tracer + logger pair", () => {
const c = new Container();
const { tracer, logger } = bindNoopInstrumentation(c);
expect(tracer).toBeInstanceOf(NoopTracer);
expect(logger).toBeInstanceOf(NoopLogger);
});
it("binds TRACER and LOGGER symbols on the container", () => {
const c = new Container();
bindNoopInstrumentation(c);
const tracer = c.get<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER);
const logger = c.get<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER);
expect(tracer).toBeInstanceOf(NoopTracer);
expect(logger).toBeInstanceOf(NoopLogger);
});
it("is idempotent — second call rebinds the same instances", () => {
const c = new Container();
const first = bindNoopInstrumentation(c);
const second = bindNoopInstrumentation(c);
// Implementations are NoopX, but instances may differ — that's fine
expect(c.get(INSTRUMENTATION_SYMBOLS.TRACER)).toBe(second.tracer);
expect(c.get(INSTRUMENTATION_SYMBOLS.LOGGER)).toBe(second.logger);
expect(first.tracer).toBeInstanceOf(NoopTracer);
});
});

View File

@@ -0,0 +1,30 @@
// packages/core-shared/src/instrumentation/di/bind-noop-instrumentation.ts
import type { Container } from "inversify";
import { NoopTracer } from "../noop-tracer";
import { NoopLogger } from "../noop-logger";
import { NoopMetrics } from "../noop-metrics";
import { INSTRUMENTATION_SYMBOLS } from "../symbols";
import type { ITracer, ILogger, IMetrics } from "../index";
export function bindNoopInstrumentation(container: Container): {
tracer: ITracer;
logger: ILogger;
metrics: IMetrics;
} {
const tracer = new NoopTracer();
const logger = new NoopLogger();
const metrics = new NoopMetrics();
if (container.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
container.unbind(INSTRUMENTATION_SYMBOLS.TRACER);
}
if (container.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
container.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
}
if (container.isBound(INSTRUMENTATION_SYMBOLS.METRICS)) {
container.unbind(INSTRUMENTATION_SYMBOLS.METRICS);
}
container.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
container.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);
container.bind<IMetrics>(INSTRUMENTATION_SYMBOLS.METRICS).toConstantValue(metrics);
return { tracer, logger, metrics };
}

View File

@@ -0,0 +1,55 @@
// packages/core-shared/src/instrumentation/di/bind-otel-instrumentation.test.ts
import "reflect-metadata";
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@sentry/nextjs", () => ({
init: vi.fn(),
captureException: vi.fn(),
captureMessage: vi.fn(),
addBreadcrumb: vi.fn(),
setUser: vi.fn(),
}));
import { Container } from "inversify";
import { bindOtelInstrumentation } from "@/instrumentation/di/bind-otel-instrumentation";
import { INSTRUMENTATION_SYMBOLS } from "@/instrumentation/symbols";
import { OtelTracer } from "@/instrumentation/otel/otel-tracer";
import { OtelLogger } from "@/instrumentation/otel/otel-logger";
// NOTE: initOtelServerNode is intentionally NOT called by bindOtelInstrumentation.
// The SDK is initialized by each app's instrumentation.ts register() hook so PII
// scrub processors are active before the first request (C1 fix). There is therefore
// no initOtelServerNode mock or call-count assertion here.
describe("bindOtelInstrumentation", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("binds OtelTracer + OtelLogger to the container", () => {
const c = new Container();
bindOtelInstrumentation(c, { dsn: "https://x@y/1", app: "web-next" });
expect(c.get(INSTRUMENTATION_SYMBOLS.TRACER)).toBeInstanceOf(OtelTracer);
expect(c.get(INSTRUMENTATION_SYMBOLS.LOGGER)).toBeInstanceOf(OtelLogger);
});
it("returns the tracer + logger instances", () => {
const c = new Container();
const { tracer, logger } = bindOtelInstrumentation(c, {
dsn: "https://x@y/1",
app: "web-next",
});
expect(tracer).toBeInstanceOf(OtelTracer);
expect(logger).toBeInstanceOf(OtelLogger);
});
it("rebinds when called a second time (idempotent container state)", () => {
const c = new Container();
bindOtelInstrumentation(c, { dsn: "https://x@y/1", app: "web-next" });
// Second call should not throw even if TRACER is already bound.
expect(() =>
bindOtelInstrumentation(c, { dsn: "https://x@y/2", app: "cms" }),
).not.toThrow();
expect(c.get(INSTRUMENTATION_SYMBOLS.TRACER)).toBeInstanceOf(OtelTracer);
});
});

View File

@@ -0,0 +1,48 @@
// packages/core-shared/src/instrumentation/di/bind-otel-instrumentation.ts
import type { Container } from "inversify";
import { OtelTracer } from "../otel/otel-tracer";
import { OtelLogger } from "../otel/otel-logger";
import { OtelMetrics } from "../otel/otel-metrics";
import { INSTRUMENTATION_SYMBOLS } from "../symbols";
import type { ITracer, ILogger, IMetrics } from "../index";
export type BindOtelOpts = {
dsn: string;
app: "web-next" | "cms" | "web-tanstack";
release?: string;
};
/**
* Binds OtelTracer, OtelLogger, and OtelMetrics to the DI container.
*
* NOTE: The OTel NodeSDK is NOT initialized here. It is initialized by each
* app's instrumentation.ts `register()` hook (Next.js convention / server-entry
* hook for TanStack) so that PII scrub processors are active before the very
* first request handler runs — before bindAll() fires. Calling initOtelServerNode
* here as well would create a second SDK init path and reintroduce the startup
* window vulnerability (C1 fix).
*/
export function bindOtelInstrumentation(
container: Container,
// opts is accepted for API compatibility with call sites that still pass dsn + app.
// The SDK is initialized by instrumentation.ts register() — no fields are used here.
_opts: BindOtelOpts,
): { tracer: ITracer; logger: ILogger; metrics: IMetrics } {
const tracer = new OtelTracer();
const logger = new OtelLogger();
const metrics = new OtelMetrics();
if (container.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
container.unbind(INSTRUMENTATION_SYMBOLS.TRACER);
}
if (container.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
container.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
}
if (container.isBound(INSTRUMENTATION_SYMBOLS.METRICS)) {
container.unbind(INSTRUMENTATION_SYMBOLS.METRICS);
}
container.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
container.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);
container.bind<IMetrics>(INSTRUMENTATION_SYMBOLS.METRICS).toConstantValue(metrics);
return { tracer, logger, metrics };
}

View File

@@ -0,0 +1,35 @@
export type {
ITracer,
ISpan,
SpanOpts,
AttributeValue,
} from "./tracer.interface";
export type {
ILogger,
Breadcrumb,
CaptureContext,
} from "./logger.interface";
export type { IMetrics, MetricAttributeValue } from "./metrics.interface";
export { NoopTracer } from "./noop-tracer";
export { NoopLogger } from "./noop-logger";
export { NoopMetrics } from "./noop-metrics";
export { withSpan } from "./with-span";
export { withCapture } from "./with-capture";
export { isReported, markReported } from "./reported-flag";
export { INSTRUMENTATION_SYMBOLS } from "./symbols";
export { bindNoopInstrumentation } from "./di/bind-noop-instrumentation";
export {
bindOtelInstrumentation,
type BindOtelOpts,
} from "./di/bind-otel-instrumentation";
// Deprecated alias for one release cycle — callers should migrate to bindOtelInstrumentation.
export { bindOtelInstrumentation as bindSentryInstrumentation } from "./di/bind-otel-instrumentation";
export type { BindOtelOpts as BindSentryOpts } from "./di/bind-otel-instrumentation";
export { initSentryClientReact } from "./sentry/init-client-react";
export { currentTraceId } from "./otel/current-trace-id";
// Re-export brand types alongside the wrappers that attach them, so callers
// can `import { withSpan, type Instrumented } from "@repo/core-shared/instrumentation"`.
export type { Instrumented, Captured } from "../conformance/brands";

View File

@@ -0,0 +1,23 @@
export type Breadcrumb = {
category: string;
message: string;
level?: "info" | "warning" | "error";
data?: Record<string, unknown>;
};
export type CaptureContext = {
tags?: Record<string, string>;
extras?: Record<string, unknown>;
fingerprint?: string[];
};
export interface ILogger {
captureException(err: unknown, ctx?: CaptureContext): void;
captureMessage(
msg: string,
level?: "info" | "warning" | "error",
ctx?: CaptureContext,
): void;
addBreadcrumb(b: Breadcrumb): void;
setUser(user: { id: string } | null): void;
}

View File

@@ -0,0 +1,42 @@
import type { MetricsProtocol } from "../di/bind-protocols";
export type MetricAttributeValue = string | number | boolean;
/**
* Vendor-neutral metrics signal interface. Mirrors the pattern of ITracer / ILogger.
* Three impls: NoopMetrics (noop), OtelMetrics (OTel API), RecordingMetrics (core-testing).
*
* Extends MetricsProtocol from `core-shared/di/bind-protocols` so the type
* system enforces structural compatibility — narrowing IMetrics below the
* protocol surface causes a typecheck error.
*
* gauge() limitation: uses UpDownCounter under the hood, which accumulates deltas.
* True "set to absolute value" semantics require ObservableGauge with a callback —
* deferred to a v2 interface when the first true-gauge use case lands.
*/
export interface IMetrics extends MetricsProtocol {
/** Monotonic counter. Use for event counts (signups, errors, requests). */
counter(
name: string,
value?: number,
attributes?: Record<string, MetricAttributeValue>,
): void;
/** Distribution. Use for measured quantities (latency, payload size). */
histogram(
name: string,
value: number,
attributes?: Record<string, MetricAttributeValue>,
): void;
/**
* Point-in-time value. UpDownCounter under the hood — true gauge semantics
* (set to absolute value) require ObservableGauge with an async callback;
* that is deferred to a future v2 spec when the first true-gauge use case arrives.
*/
gauge(
name: string,
value: number,
attributes?: Record<string, MetricAttributeValue>,
): void;
}

View File

@@ -0,0 +1,34 @@
import { describe, it, expect } from "vitest";
import { NoopLogger } from "@/instrumentation/noop-logger";
describe("NoopLogger", () => {
it("captureException is callable with err and ctx", () => {
const logger = new NoopLogger();
expect(() => logger.captureException(new Error("x"))).not.toThrow();
expect(() =>
logger.captureException(new Error("x"), { tags: { feature: "blog" } }),
).not.toThrow();
});
it("captureMessage is callable", () => {
const logger = new NoopLogger();
expect(() => logger.captureMessage("hello")).not.toThrow();
expect(() => logger.captureMessage("hello", "warning")).not.toThrow();
expect(() =>
logger.captureMessage("hello", "error", { extras: { foo: 1 } }),
).not.toThrow();
});
it("addBreadcrumb is callable", () => {
const logger = new NoopLogger();
expect(() =>
logger.addBreadcrumb({ category: "test", message: "x" }),
).not.toThrow();
});
it("setUser accepts opaque id and null", () => {
const logger = new NoopLogger();
expect(() => logger.setUser({ id: "u1" })).not.toThrow();
expect(() => logger.setUser(null)).not.toThrow();
});
});

View File

@@ -0,0 +1,12 @@
import type { ILogger, Breadcrumb, CaptureContext } from "./logger.interface";
export class NoopLogger implements ILogger {
captureException(_err: unknown, _ctx?: CaptureContext): void {}
captureMessage(
_msg: string,
_level?: "info" | "warning" | "error",
_ctx?: CaptureContext,
): void {}
addBreadcrumb(_b: Breadcrumb): void {}
setUser(_user: { id: string } | null): void {}
}

View File

@@ -0,0 +1,29 @@
import { describe, it, expect } from "vitest";
import { NoopMetrics } from "./noop-metrics";
describe("NoopMetrics", () => {
it("counter() returns undefined without throwing", () => {
const metrics = new NoopMetrics();
expect(() => metrics.counter("my.counter")).not.toThrow();
expect(() => metrics.counter("my.counter", 5)).not.toThrow();
expect(() =>
metrics.counter("my.counter", 1, { feature: "auth", success: true }),
).not.toThrow();
});
it("histogram() returns undefined without throwing", () => {
const metrics = new NoopMetrics();
expect(() => metrics.histogram("my.latency", 42)).not.toThrow();
expect(() =>
metrics.histogram("my.latency", 100, { route: "/api/me" }),
).not.toThrow();
});
it("gauge() returns undefined without throwing", () => {
const metrics = new NoopMetrics();
expect(() => metrics.gauge("queue.depth", 7)).not.toThrow();
expect(() =>
metrics.gauge("queue.depth", 3, { queue: "emails" }),
).not.toThrow();
});
});

View File

@@ -0,0 +1,21 @@
import type { IMetrics, MetricAttributeValue } from "./metrics.interface";
export class NoopMetrics implements IMetrics {
counter(
_name: string,
_value?: number,
_attributes?: Record<string, MetricAttributeValue>,
): void {}
histogram(
_name: string,
_value: number,
_attributes?: Record<string, MetricAttributeValue>,
): void {}
gauge(
_name: string,
_value: number,
_attributes?: Record<string, MetricAttributeValue>,
): void {}
}

View File

@@ -0,0 +1,41 @@
import { describe, it, expect, vi } from "vitest";
import { NoopTracer } from "@/instrumentation/noop-tracer";
import type { ISpan } from "@/instrumentation/tracer.interface";
describe("NoopTracer", () => {
it("startSpan returns the function result", async () => {
const tracer = new NoopTracer();
const result = await tracer.startSpan({ name: "test.op" }, async () => 42);
expect(result).toBe(42);
});
it("startSpan passes a no-op ISpan to the function", async () => {
const tracer = new NoopTracer();
let received: ISpan | undefined;
await tracer.startSpan({ name: "test.op" }, async (span) => {
received = span;
return undefined;
});
expect(received).toBeDefined();
expect(() => received!.setAttribute("k", "v")).not.toThrow();
expect(() => received!.setStatus("ok")).not.toThrow();
expect(() => received!.setStatus("error", "msg")).not.toThrow();
});
it("propagates exceptions from the wrapped function", async () => {
const tracer = new NoopTracer();
const err = new Error("boom");
await expect(
tracer.startSpan({ name: "test.op" }, async () => {
throw err;
}),
).rejects.toBe(err);
});
it("does not invoke external services", async () => {
const tracer = new NoopTracer();
const fn = vi.fn(async () => "ok");
await tracer.startSpan({ name: "test.op" }, fn);
expect(fn).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,12 @@
import type { ITracer, ISpan, SpanOpts } from "./tracer.interface";
const NOOP_SPAN: ISpan = {
setAttribute: () => {},
setStatus: () => {},
};
export class NoopTracer implements ITracer {
async startSpan<T>(_opts: SpanOpts, fn: (span: ISpan) => Promise<T>): Promise<T> {
return fn(NOOP_SPAN);
}
}

View File

@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach, afterEach } 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 { currentTraceId } from "./current-trace-id";
// 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 };
}
describe("currentTraceId", () => {
let exporter: InMemorySpanExporter;
let provider: BasicTracerProvider;
beforeEach(() => {
({ exporter, provider } = setupProvider());
});
afterEach(async () => {
await provider.shutdown();
trace.disable();
});
it("returns undefined when no active span", () => {
expect(currentTraceId()).toBeUndefined();
});
it("returns the active span's traceId when inside startActiveSpan", async () => {
const tracer = trace.getTracer("test");
await new Promise<void>((resolve) => {
tracer.startActiveSpan("test-span", (span) => {
const id = currentTraceId();
expect(id).toBeDefined();
expect(id).toMatch(/^[a-f0-9]{32}$/);
span.end();
resolve();
});
});
});
it("filters all-zeros invalid traceId", () => {
// The INVALID_SPAN (no-op) has traceId "00000000000000000000000000000000"
// which is what getActiveSpan() returns when there is no real span.
// currentTraceId() must treat this as absent.
expect(currentTraceId()).toBeUndefined();
});
// Suppress unused variable warning — exporter used via closure
it("returns distinct traceIds for independent spans", async () => {
const tracer = trace.getTracer("test");
const ids: string[] = [];
for (let i = 0; i < 2; i++) {
await new Promise<void>((resolve) => {
tracer.startActiveSpan(`span-${i}`, (span) => {
const id = currentTraceId();
if (id) ids.push(id);
span.end();
resolve();
});
});
}
// Both spans are in fresh traces — IDs should be valid hex strings
expect(ids).toHaveLength(2);
for (const id of ids) {
expect(id).toMatch(/^[a-f0-9]{32}$/);
}
void exporter; // suppress lint
});
});

View File

@@ -0,0 +1,20 @@
import { trace } from "@opentelemetry/api";
/**
* Returns the trace ID of the currently active OTel span, or undefined if
* there is no active span (e.g., outside any request context, in unit tests
* without an OTel SDK).
*
* Used by core-audit's TraceIdEnrichingAuditLog decorator to auto-populate
* AuditEntry.correlationId so callers don't have to thread it explicitly.
*
* Returns undefined for the all-zeros invalid trace ID — OTel emits this
* when context propagation hasn't kicked in.
*/
export function currentTraceId(): string | undefined {
const span = trace.getActiveSpan();
if (!span) return undefined;
const ctx = span.spanContext();
if (!ctx.traceId || /^0+$/.test(ctx.traceId)) return undefined;
return ctx.traceId;
}

View File

@@ -0,0 +1,3 @@
export { initOtelServerNode, type InitOtelServerNodeOpts } from "./init-server-node";
export { buildResource, type BuildResourceOpts } from "./resource";
export { currentTraceId } from "./current-trace-id";

View File

@@ -0,0 +1,23 @@
import { describe, it, expect } from "vitest";
import { initOtelServerNode } from "./init-server-node";
describe("initOtelServerNode", () => {
it("returns an SDK handle with shutdown()", () => {
const sdk = initOtelServerNode({
dsn: "",
serviceName: "test-service",
environment: "test",
});
expect(sdk).toBeDefined();
expect(typeof sdk.shutdown).toBe("function");
});
it("accepts a DSN and wires the Sentry bridge", () => {
const sdk = initOtelServerNode({
dsn: "https://test@sentry.io/1",
serviceName: "test-service",
environment: "test",
});
expect(sdk).toBeDefined();
});
});

View File

@@ -0,0 +1,98 @@
import { NodeSDK, tracing } from "@opentelemetry/sdk-node";
import { registerInstrumentations } from "@opentelemetry/instrumentation";
import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
import { UndiciInstrumentation } from "@opentelemetry/instrumentation-undici";
import { PgInstrumentation } from "@opentelemetry/instrumentation-pg";
import { buildResource } from "./resource";
import { createSentryOtelBridge } from "./sentry-bridge";
import {
PiiScrubSpanProcessor,
PiiScrubLogRecordProcessor,
} from "./pii-scrub-processor";
const { BatchSpanProcessor } = tracing;
export type InitOtelServerNodeOpts = {
/** Sentry DSN. When empty, OTel SDK boots without the Sentry exporter. */
dsn: string;
serviceName: string;
serviceVersion?: string;
environment: string;
release?: string;
namespace?: string;
};
/**
* Initializes the OpenTelemetry NodeSDK for a server-side app.
* - Configures Resource attributes per OTel semantic conventions.
* - Registers PII scrub processors FIRST so all downstream exporters see clean data.
* - Registers Sentry span processor (via createSentryOtelBridge) when DSN is set.
* - Registers Sentry log record processor when DSN is set.
* - Registers an in-process MeterProvider (no exporter — Sentry metrics not yet
* wired; a future revision or vendor-specific exporter can add a MetricReader).
*
* Caller is responsible for `sdk.shutdown()` on process exit.
*/
export function initOtelServerNode(opts: InitOtelServerNodeOpts): NodeSDK {
const resource = buildResource({
serviceName: opts.serviceName,
serviceVersion: opts.serviceVersion,
environment: opts.environment,
namespace: opts.namespace,
});
const bridge = createSentryOtelBridge({ dsn: opts.dsn });
// PiiScrubSpanProcessor runs FIRST so the Sentry exporter never sees raw PII.
const spanProcessors = bridge.spanProcessor
? [
new PiiScrubSpanProcessor(),
// `as never` works around a TypeScript version conflict: `core-shared`'s direct
// dep on `@opentelemetry/sdk-trace-base@1.30.1` has subtly incompatible types
// vs the 1.28.0 bundled by `sdk-node@0.55.0`. The runtime objects are compatible;
// the structural mismatch is type-only — chosen rather than
// constraining sdk-trace-base to 1.28.x to avoid losing future bug fixes.
new BatchSpanProcessor(bridge.spanProcessor as never),
]
: [new PiiScrubSpanProcessor()];
// PiiScrubLogRecordProcessor runs FIRST for the same reason.
// SentryLogRecordForwarder forwards synchronously (no batching wrapper needed).
const logRecordProcessors = bridge.logRecordProcessor
? [new PiiScrubLogRecordProcessor(), bridge.logRecordProcessor]
: [new PiiScrubLogRecordProcessor()];
// In-process MeterProvider with no reader/exporter. Sentry metrics ingestion
// is experimental in @sentry/opentelemetry 10.x and not wired here; metrics
// emit through the API but are not exported anywhere. Add a
// PeriodicExportingMetricReader when a vendor exporter is available.
const metricReader = undefined;
const sdk = new NodeSDK({
resource,
spanProcessors,
logRecordProcessors,
metricReader,
});
sdk.start();
registerInstrumentations({
instrumentations: [
new HttpInstrumentation({
requestHook: (span, request) => {
const url = (request as { url?: string }).url ?? "";
span.setAttribute("http.url.path", url.split("?")[0] ?? "");
},
ignoreIncomingRequestHook: (req) => {
const url = (req as { url?: string }).url ?? "";
return url === "/_health" || url === "/_otel-export";
},
}),
new UndiciInstrumentation(),
new PgInstrumentation({ enhancedDatabaseReporting: false }),
],
});
return sdk;
}

View File

@@ -0,0 +1,230 @@
// packages/core-shared/src/instrumentation/otel/otel-logger.test.ts
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { context, trace } from "@opentelemetry/api";
import { logs, SeverityNumber } from "@opentelemetry/api-logs";
import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
import {
BasicTracerProvider,
InMemorySpanExporter,
SimpleSpanProcessor,
} from "@opentelemetry/sdk-trace-base";
import {
LoggerProvider,
InMemoryLogRecordExporter,
SimpleLogRecordProcessor,
} from "@opentelemetry/sdk-logs";
import { OtelLogger } from "./otel-logger";
// Enable async context manager for span context propagation
const ctxManager = new AsyncLocalStorageContextManager();
ctxManager.enable();
context.setGlobalContextManager(ctxManager);
function setupProviders(): {
logExporter: InMemoryLogRecordExporter;
spanExporter: InMemorySpanExporter;
loggerProvider: LoggerProvider;
tracerProvider: BasicTracerProvider;
} {
const logExporter = new InMemoryLogRecordExporter();
const loggerProvider = new LoggerProvider();
loggerProvider.addLogRecordProcessor(new SimpleLogRecordProcessor(logExporter));
logs.setGlobalLoggerProvider(loggerProvider);
const spanExporter = new InMemorySpanExporter();
const tracerProvider = new BasicTracerProvider({
spanProcessors: [new SimpleSpanProcessor(spanExporter)],
});
trace.setGlobalTracerProvider(tracerProvider);
return { logExporter, spanExporter, loggerProvider, tracerProvider };
}
describe("OtelLogger", () => {
let logExporter: InMemoryLogRecordExporter;
let spanExporter: InMemorySpanExporter;
let loggerProvider: LoggerProvider;
let tracerProvider: BasicTracerProvider;
beforeEach(() => {
({ logExporter, spanExporter, loggerProvider, tracerProvider } = setupProviders());
});
afterEach(async () => {
await loggerProvider.shutdown();
await tracerProvider.shutdown();
logs.disable();
trace.disable();
});
describe("captureException", () => {
it("emits a log record with ERROR severity and exception attributes", () => {
const logger = new OtelLogger();
const err = new Error("something broke");
err.name = "CustomError";
logger.captureException(err, { tags: { feature: "blog" }, extras: { userId: "u1" } });
const records = logExporter.getFinishedLogRecords();
expect(records).toHaveLength(1);
const [record] = records;
expect(record!.severityNumber).toBe(SeverityNumber.ERROR);
expect(record!.severityText).toBe("ERROR");
expect(record!.body).toBe("something broke");
expect(record!.attributes["exception.type"]).toBe("CustomError");
expect(record!.attributes["exception.message"]).toBe("something broke");
expect(typeof record!.attributes["exception.stacktrace"]).toBe("string");
// Tags are prefixed with "tag."
expect(record!.attributes["tag.feature"]).toBe("blog");
// Extras are prefixed with "extra."
expect(record!.attributes["extra.userId"]).toBe("u1");
});
it("applies sentry.fingerprint attribute when provided", () => {
const logger = new OtelLogger();
const err = new Error("fingerprinted");
logger.captureException(err, { fingerprint: ["type-a", "src-blog"] });
const [record] = logExporter.getFinishedLogRecords();
expect(record!.attributes["sentry.fingerprint"]).toBe("type-a|src-blog");
});
it("is a no-op on second call for the same error (double-report guard)", () => {
const logger = new OtelLogger();
const err = new Error("once");
logger.captureException(err);
logger.captureException(err); // should be skipped
expect(logExporter.getFinishedLogRecords()).toHaveLength(1);
});
it("marks error as reported after first call", () => {
const logger = new OtelLogger();
const err = new Error("mark-test");
logger.captureException(err);
expect(
(err as unknown as Record<string, unknown>)["__sentryReported"],
).toBe(true);
});
it("wraps non-Error values into an Error object", () => {
const logger = new OtelLogger();
logger.captureException("plain string error");
const [record] = logExporter.getFinishedLogRecords();
expect(record!.body).toBe("plain string error");
expect(record!.attributes["exception.message"]).toBe("plain string error");
});
});
describe("captureMessage severity mapping", () => {
it.each([
["info" as const, SeverityNumber.INFO, "INFO"],
["warning" as const, SeverityNumber.WARN, "WARNING"],
["error" as const, SeverityNumber.ERROR, "ERROR"],
] as const)(
"level %s → severityNumber %d, severityText %s",
(level, expectedNumber, expectedText) => {
const logger = new OtelLogger();
logger.captureMessage("test message", level);
const [record] = logExporter.getFinishedLogRecords();
expect(record!.severityNumber).toBe(expectedNumber);
expect(record!.severityText).toBe(expectedText);
expect(record!.body).toBe("test message");
},
);
it("defaults to INFO when level is omitted", () => {
const logger = new OtelLogger();
logger.captureMessage("default level");
const [record] = logExporter.getFinishedLogRecords();
expect(record!.severityNumber).toBe(SeverityNumber.INFO);
expect(record!.severityText).toBe("INFO");
});
it("includes tags and extras as prefixed attributes", () => {
const logger = new OtelLogger();
logger.captureMessage("msg", "warning", {
tags: { service: "auth" },
extras: { count: 5 },
});
const [record] = logExporter.getFinishedLogRecords();
expect(record!.attributes["tag.service"]).toBe("auth");
expect(record!.attributes["extra.count"]).toBe("5");
});
});
describe("addBreadcrumb", () => {
it("adds a span event when there is an active span", async () => {
const logger = new OtelLogger();
const otelTracer = trace.getTracer("test");
await otelTracer.startActiveSpan("test-span", async (span) => {
logger.addBreadcrumb({
category: "http",
message: "GET /api/blog",
level: "info",
data: { status: 200 },
});
span.end();
});
const spans = spanExporter.getFinishedSpans();
expect(spans).toHaveLength(1);
const [span] = spans;
const breadcrumbEvent = span!.events.find((e) => e.name === "GET /api/blog");
expect(breadcrumbEvent).toBeDefined();
expect(breadcrumbEvent!.attributes!["breadcrumb.category"]).toBe("http");
expect(breadcrumbEvent!.attributes!["breadcrumb.level"]).toBe("info");
expect(breadcrumbEvent!.attributes!["extra.status"]).toBe("200");
});
it("is a no-op when there is no active span", () => {
const logger = new OtelLogger();
// No span active — should not throw
expect(() =>
logger.addBreadcrumb({ category: "nav", message: "page changed" }),
).not.toThrow();
});
});
describe("setUser", () => {
it("sets user.id attribute on the active span", async () => {
const logger = new OtelLogger();
const otelTracer = trace.getTracer("test");
await otelTracer.startActiveSpan("user-span", async (span) => {
logger.setUser({ id: "user-123" });
span.end();
});
const [span] = spanExporter.getFinishedSpans();
expect(span!.attributes["user.id"]).toBe("user-123");
});
it("sets user.id to empty string when called with null", async () => {
const logger = new OtelLogger();
const otelTracer = trace.getTracer("test");
await otelTracer.startActiveSpan("logout-span", async (span) => {
logger.setUser(null);
span.end();
});
const [span] = spanExporter.getFinishedSpans();
expect(span!.attributes["user.id"]).toBe("");
});
it("is a no-op when there is no active span", () => {
const logger = new OtelLogger();
expect(() => logger.setUser({ id: "u1" })).not.toThrow();
});
});
});

View File

@@ -0,0 +1,76 @@
// packages/core-shared/src/instrumentation/otel/otel-logger.ts
import { logs, SeverityNumber } from "@opentelemetry/api-logs";
import { trace } from "@opentelemetry/api";
import { isReported, markReported } from "../reported-flag";
import type { ILogger, Breadcrumb, CaptureContext } from "../logger.interface";
export class OtelLogger implements ILogger {
private readonly logger = logs.getLogger("@repo/core-shared", "1.0.0");
captureException(err: unknown, ctx?: CaptureContext): void {
if (isReported(err)) return;
markReported(err);
const error = err instanceof Error ? err : new Error(String(err));
this.logger.emit({
severityNumber: SeverityNumber.ERROR,
severityText: "ERROR",
body: error.message,
attributes: {
"exception.type": error.name,
"exception.message": error.message,
"exception.stacktrace": error.stack ?? "",
...flattenTags(ctx?.tags),
...flattenExtras(ctx?.extras),
...(ctx?.fingerprint ? { "sentry.fingerprint": ctx.fingerprint.join("|") } : {}),
},
});
}
captureMessage(msg: string, level?: "info" | "warning" | "error", ctx?: CaptureContext): void {
const severityNumber =
level === "error"
? SeverityNumber.ERROR
: level === "warning"
? SeverityNumber.WARN
: SeverityNumber.INFO;
const severityText =
level === "error" ? "ERROR" : level === "warning" ? "WARNING" : "INFO";
this.logger.emit({
severityNumber,
severityText,
body: msg,
attributes: { ...flattenTags(ctx?.tags), ...flattenExtras(ctx?.extras) },
});
}
addBreadcrumb(b: Breadcrumb): void {
const span = trace.getActiveSpan();
if (!span) return;
span.addEvent(b.message, {
"breadcrumb.category": b.category,
"breadcrumb.level": b.level ?? "info",
...(b.data ? flattenExtras(b.data) : {}),
});
}
setUser(user: { id: string } | null): void {
const span = trace.getActiveSpan();
if (!span) return;
span.setAttribute("user.id", user?.id ?? "");
}
}
function flattenTags(tags?: Record<string, string>): Record<string, string> {
if (!tags) return {};
return Object.fromEntries(Object.entries(tags).map(([k, v]) => [`tag.${k}`, v]));
}
function flattenExtras(extras?: Record<string, unknown>): Record<string, string> {
if (!extras) return {};
return Object.fromEntries(
Object.entries(extras).map(([k, v]) => [
`extra.${k}`,
typeof v === "string" ? v : String(v),
]),
);
}

View File

@@ -0,0 +1,120 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { metrics } from "@opentelemetry/api";
import {
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
AggregationTemporality,
} from "@opentelemetry/sdk-metrics";
import { OtelMetrics } from "./otel-metrics";
function setupMeterProvider(): {
exporter: InMemoryMetricExporter;
provider: MeterProvider;
} {
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 100,
});
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
return { exporter, provider };
}
describe("OtelMetrics", () => {
let exporter: InMemoryMetricExporter;
let provider: MeterProvider;
beforeEach(() => {
({ exporter, provider } = setupMeterProvider());
});
afterEach(async () => {
await provider.shutdown();
metrics.disable();
});
it("counter() records a counter measurement", async () => {
const otelMetrics = new OtelMetrics();
otelMetrics.counter("http.requests", 1, { method: "GET" });
await provider.forceFlush();
const metrics_ = exporter.getMetrics();
const counterMetric = metrics_
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.find((m) => m.descriptor.name === "http.requests");
expect(counterMetric).toBeDefined();
expect(counterMetric!.dataPoints).toHaveLength(1);
expect(counterMetric!.dataPoints[0]!.value).toBe(1);
});
it("counter() defaults value to 1 when omitted", async () => {
const otelMetrics = new OtelMetrics();
otelMetrics.counter("events.processed");
await provider.forceFlush();
const metrics_ = exporter.getMetrics();
const counterMetric = metrics_
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.find((m) => m.descriptor.name === "events.processed");
expect(counterMetric).toBeDefined();
expect(counterMetric!.dataPoints[0]!.value).toBe(1);
});
it("histogram() records a histogram measurement", async () => {
const otelMetrics = new OtelMetrics();
otelMetrics.histogram("http.duration", 250, { route: "/api/me" });
await provider.forceFlush();
const metrics_ = exporter.getMetrics();
const histogramMetric = metrics_
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.find((m) => m.descriptor.name === "http.duration");
expect(histogramMetric).toBeDefined();
expect(histogramMetric!.dataPoints).toHaveLength(1);
// Histogram data points carry a Histogram aggregate value with sum/count/buckets.
const dp = histogramMetric!.dataPoints[0] as {
value: { sum?: number; count: number };
};
expect(dp.value.sum).toBe(250);
expect(dp.value.count).toBe(1);
});
it("gauge() records via UpDownCounter", async () => {
const otelMetrics = new OtelMetrics();
otelMetrics.gauge("queue.depth", 5, { queue: "emails" });
await provider.forceFlush();
const metrics_ = exporter.getMetrics();
const gaugeMetric = metrics_
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.find((m) => m.descriptor.name === "queue.depth");
expect(gaugeMetric).toBeDefined();
expect(gaugeMetric!.dataPoints).toHaveLength(1);
});
it("lazily caches instrument instances — same counter object reused across calls", async () => {
const otelMetrics = new OtelMetrics();
otelMetrics.counter("reuse.test", 1);
otelMetrics.counter("reuse.test", 2);
await provider.forceFlush();
const metrics_ = exporter.getMetrics();
const counterMetric = metrics_
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.find((m) => m.descriptor.name === "reuse.test");
// Cumulative: should accumulate both adds (1+2=3)
expect(counterMetric!.dataPoints[0]!.value).toBe(3);
});
});

View File

@@ -0,0 +1,70 @@
import { metrics } from "@opentelemetry/api";
import type { Counter, Histogram, UpDownCounter } from "@opentelemetry/api";
import type { IMetrics, MetricAttributeValue } from "../metrics.interface";
/**
* OTel-backed IMetrics implementation.
*
* - counter → OTel Counter (monotonic, add-only)
* - histogram → OTel Histogram
* - gauge → OTel UpDownCounter (synchronous emit). Known limitation: this
* accumulates deltas, not point-in-time values. True "set to
* absolute" semantics require ObservableGauge with a callback;
* deferred to a v2 interface.
*
* Instrument instances are lazily created and cached per name so repeated
* calls to the same metric name reuse the same OTel instrument.
*/
export class OtelMetrics implements IMetrics {
private readonly meter = metrics.getMeter("@repo/core-shared", "1.0.0");
private readonly counters = new Map<string, Counter>();
private readonly histograms = new Map<string, Histogram>();
private readonly gauges = new Map<string, UpDownCounter>();
counter(
name: string,
value = 1,
attributes?: Record<string, MetricAttributeValue>,
): void {
let counter = this.counters.get(name);
if (!counter) {
counter = this.meter.createCounter(name);
this.counters.set(name, counter);
}
counter.add(value, attributes);
}
histogram(
name: string,
value: number,
attributes?: Record<string, MetricAttributeValue>,
): void {
let histogram = this.histograms.get(name);
if (!histogram) {
histogram = this.meter.createHistogram(name);
this.histograms.set(name, histogram);
}
histogram.record(value, attributes);
}
/**
* Records a gauge value via UpDownCounter.
*
* Note: UpDownCounter accumulates a running delta — each call adds to the
* previous value rather than replacing it. This is a synchronous approximation
* of gauge semantics. For true "set to absolute value" behaviour, use an
* ObservableGauge with a periodic callback instead (a future v2 addition).
*/
gauge(
name: string,
value: number,
attributes?: Record<string, MetricAttributeValue>,
): void {
let gauge = this.gauges.get(name);
if (!gauge) {
gauge = this.meter.createUpDownCounter(name);
this.gauges.set(name, gauge);
}
gauge.add(value, attributes);
}
}

View File

@@ -0,0 +1,106 @@
import { describe, it, expect, beforeEach, afterEach } 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 { OtelTracer } from "./otel-tracer";
// Register the async context manager once for the entire test file.
// This must be done before any OtelTracer is constructed.
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 };
}
describe("OtelTracer", () => {
let exporter: InMemorySpanExporter;
let provider: BasicTracerProvider;
beforeEach(() => {
({ exporter, provider } = setupProvider());
});
afterEach(async () => {
await provider.shutdown();
trace.disable();
});
it("records span name and op attribute", async () => {
const tracer = new OtelTracer();
await tracer.startSpan({ name: "blog.getArticles", op: "use-case" }, async () => "value");
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(1);
const [span] = spans;
expect(span!.name).toBe("blog.getArticles");
expect(span!.attributes["span.op"]).toBe("use-case");
});
it("records additional attributes, filtering out null values", async () => {
const tracer = new OtelTracer();
await tracer.startSpan(
{
name: "articles.findAll",
op: "repository",
attributes: { collection: "articles", limit: 10, tag: null },
},
async () => undefined,
);
const [span] = exporter.getFinishedSpans();
expect(span!.attributes["collection"]).toBe("articles");
expect(span!.attributes["limit"]).toBe(10);
expect(span!.attributes["tag"]).toBeUndefined();
});
it("nested spans — child span has parent span as its parent", async () => {
const tracer = new OtelTracer();
await tracer.startSpan({ name: "parent" }, async () => {
await tracer.startSpan({ name: "child" }, async () => "child-result");
return "parent-result";
});
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(2);
const child = spans.find((s) => s.name === "child")!;
const parent = spans.find((s) => s.name === "parent")!;
// In sdk-trace-base@1.30.x the parent-child link is tracked via parentSpanId (string)
// Both spans are in the same trace
expect(child.spanContext().traceId).toBe(parent.spanContext().traceId);
// The child's parentSpanId should be the parent's span ID
expect(child.parentSpanId).toBe(parent.spanContext().spanId);
});
it("records exception and sets ERROR status on throw", async () => {
const tracer = new OtelTracer();
await expect(
tracer.startSpan({ name: "failing-op" }, async () => {
throw new Error("boom");
}),
).rejects.toThrow("boom");
const [span] = exporter.getFinishedSpans();
expect(span!.status.code).toBe(2); // SpanStatusCode.ERROR = 2
const exceptionEvent = span!.events.find((e) => e.name === "exception");
expect(exceptionEvent).toBeDefined();
expect(exceptionEvent!.attributes!["exception.message"]).toBe("boom");
});
it("ISpan adapter: setAttribute ignores null; setStatus maps ok/error", async () => {
const tracer = new OtelTracer();
await tracer.startSpan({ name: "adapter-test" }, async (span) => {
span.setAttribute("key", "value");
span.setAttribute("nullable", null);
span.setStatus("ok");
return undefined;
});
const [span] = exporter.getFinishedSpans();
expect(span!.attributes["key"]).toBe("value");
expect(span!.attributes["nullable"]).toBeUndefined();
expect(span!.status.code).toBe(1); // SpanStatusCode.OK = 1
});
});

View File

@@ -0,0 +1,46 @@
import { trace, SpanKind, SpanStatusCode } from "@opentelemetry/api";
import type { ITracer, ISpan, SpanOpts } from "../tracer.interface";
export class OtelTracer implements ITracer {
private readonly tracer = trace.getTracer("@repo/core-shared", "1.0.0");
async startSpan<T>(opts: SpanOpts, fn: (span: ISpan) => Promise<T>): Promise<T> {
const attributes: Record<string, string | number | boolean> = {
...(opts.attributes
? (Object.fromEntries(
Object.entries(opts.attributes).filter(([, v]) => v !== null),
) as Record<string, string | number | boolean>)
: {}),
...(opts.op ? { "span.op": opts.op } : {}),
};
return this.tracer.startActiveSpan(
opts.name,
{ kind: SpanKind.INTERNAL, attributes },
async (otelSpan) => {
const adapter: ISpan = {
setAttribute(key, value) {
if (value !== null) {
otelSpan.setAttribute(key, value as string | number | boolean);
}
},
setStatus(status, message) {
otelSpan.setStatus({
code: status === "ok" ? SpanStatusCode.OK : SpanStatusCode.ERROR,
message,
});
},
};
try {
return await fn(adapter);
} catch (err) {
otelSpan.recordException(err as Error);
otelSpan.setStatus({ code: SpanStatusCode.ERROR });
throw err;
} finally {
otelSpan.end();
}
},
);
}
}

View File

@@ -0,0 +1,67 @@
// packages/core-shared/src/instrumentation/otel/pii-fields.ts
// Substring match on event keys (case-insensitive).
// IP address attribute KEYS from OTel HttpInstrumentation (semconv 1.20 and 1.27+)
// are listed here so they are key-redacted in addition to the value-level regex
// scrubbing in pii-scrub-processor.ts.
export const PII_KEY_SUBSTRINGS = [
"email",
"password",
"token",
"cookie",
"authorization",
"set-cookie",
"x-api-key",
"apikey",
"api_key",
"secret",
"ipaddress",
// OTel HTTP semantic conventions — IP / client address attributes
"client.address",
"client_ip",
"client.ip",
"net.peer.ip",
"net.sock.peer.addr",
"net.peer.addr",
"http.client_ip",
"server.address",
"host.ip",
] as const;
// Substring match on URL query-param keys (case-insensitive)
export const PII_QUERY_PARAM_SUBSTRINGS = [
"token",
"email",
"password",
"key",
"sig",
"signature",
"access_token",
"accesstoken",
"secret",
] as const;
export const REDACTED_VALUE = "[redacted]" as const;
export const REDACTED_IP = "[redacted-ip]" as const;
// IPv4: simple dotted-quad.
export const IPV4_REGEX = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g;
// IPv6: covers the three common forms:
// 1. Full / no-:: form: 2001:0db8:0000:0000:0000:0000:0000:0001
// 2. Compressed prefix::rest: 2001:0db8::1 (one or more groups before ::)
// 3. Leading ::suffix: ::1 or ::ffff:192.0.2.1
// The alternation order puts the longer prefix:: pattern first so it captures
// the full address rather than leaving the prefix unmatched.
export const IPV6_REGEX =
/\b(?:[0-9a-fA-F]{1,4}:){1,7}:[0-9a-fA-F]{0,4}\b|\b(?:[0-9a-fA-F]{1,4}:){2,7}[0-9a-fA-F]{1,4}\b|::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{0,4}/g;
export function keyContainsPii(key: string): boolean {
const lower = key.toLowerCase();
return PII_KEY_SUBSTRINGS.some((s) => lower.includes(s));
}
export function queryParamContainsPii(key: string): boolean {
const lower = key.toLowerCase();
return PII_QUERY_PARAM_SUBSTRINGS.some((s) => lower.includes(s));
}

View File

@@ -0,0 +1,184 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
BasicTracerProvider,
InMemorySpanExporter,
SimpleSpanProcessor,
} from "@opentelemetry/sdk-trace-base";
import {
LoggerProvider,
InMemoryLogRecordExporter,
SimpleLogRecordProcessor,
} from "@opentelemetry/sdk-logs";
import { SeverityNumber } from "@opentelemetry/api-logs";
import {
PiiScrubSpanProcessor,
PiiScrubLogRecordProcessor,
} from "./pii-scrub-processor";
const spanExporter = new InMemorySpanExporter();
const tracerProvider = new BasicTracerProvider({
spanProcessors: [
new PiiScrubSpanProcessor(),
new SimpleSpanProcessor(spanExporter),
],
});
// Use addLogRecordProcessor to chain processors in the right order.
const logExporter = new InMemoryLogRecordExporter();
const logProvider = new LoggerProvider();
logProvider.addLogRecordProcessor(new PiiScrubLogRecordProcessor());
logProvider.addLogRecordProcessor(new SimpleLogRecordProcessor(logExporter));
beforeEach(() => {
spanExporter.reset();
logExporter.reset();
});
describe("PiiScrubSpanProcessor", () => {
it("redacts attributes whose names contain PII substrings", () => {
const tracer = tracerProvider.getTracer("test");
const span = tracer.startSpan("test-span", {
attributes: {
"user.email": "alice@example.com",
"user.id": "u_123",
"auth.token": "secret-token",
"request.path": "/api/users",
},
});
span.end();
const exported = spanExporter.getFinishedSpans();
expect(exported[0]!.attributes["user.email"]).toBe("[redacted]");
expect(exported[0]!.attributes["auth.token"]).toBe("[redacted]");
expect(exported[0]!.attributes["user.id"]).toBe("u_123"); // id is fine
expect(exported[0]!.attributes["request.path"]).toBe("/api/users");
});
it("preserves non-PII attributes unchanged", () => {
const tracer = tracerProvider.getTracer("test");
const span = tracer.startSpan("test-span", {
attributes: {
"http.method": "GET",
"span.op": "use-case",
feature: "blog",
},
});
span.end();
const exported = spanExporter.getFinishedSpans();
expect(exported[0]!.attributes["http.method"]).toBe("GET");
expect(exported[0]!.attributes["span.op"]).toBe("use-case");
expect(exported[0]!.attributes["feature"]).toBe("blog");
});
it("redacts attributes with cookie and apikey substrings", () => {
const tracer = tracerProvider.getTracer("test");
const span = tracer.startSpan("test-span", {
attributes: {
"request.cookie": "session=xyz",
"x-api-key": "key123",
"secret.value": "mysecret",
},
});
span.end();
const exported = spanExporter.getFinishedSpans();
expect(exported[0]!.attributes["request.cookie"]).toBe("[redacted]");
expect(exported[0]!.attributes["x-api-key"]).toBe("[redacted]");
expect(exported[0]!.attributes["secret.value"]).toBe("[redacted]");
});
});
describe("PiiScrubLogRecordProcessor", () => {
it("redacts log record attributes whose names contain PII substrings", () => {
const logger = logProvider.getLogger("test");
logger.emit({
severityNumber: SeverityNumber.ERROR,
severityText: "ERROR",
body: "test",
attributes: {
"user.email": "alice@example.com",
"exception.message": "boom",
},
});
const records = logExporter.getFinishedLogRecords();
expect(records[0]!.attributes!["user.email"]).toBe("[redacted]");
expect(records[0]!.attributes!["exception.message"]).toBe("boom");
});
it("redacts log body when it contains PII substrings", () => {
const logger = logProvider.getLogger("test");
logger.emit({
severityNumber: SeverityNumber.INFO,
severityText: "INFO",
body: "user signed in with email alice@example.com",
});
const records = logExporter.getFinishedLogRecords();
expect(records[0]!.body).toBe("[redacted]");
});
it("preserves log body when it contains no PII substrings", () => {
const logger = logProvider.getLogger("test");
logger.emit({
severityNumber: SeverityNumber.INFO,
severityText: "INFO",
body: "user signed in successfully",
});
const records = logExporter.getFinishedLogRecords();
expect(records[0]!.body).toBe("user signed in successfully");
});
it("scrubs IPv4 in log record body", () => {
const logger = logProvider.getLogger("test");
logger.emit({
severityNumber: SeverityNumber.INFO,
severityText: "INFO",
body: "request from 10.0.0.1 finished",
});
const records = logExporter.getFinishedLogRecords();
expect(records[0]!.body).toContain("[redacted-ip]");
expect(records[0]!.body).not.toContain("10.0.0.1");
});
});
describe("PiiScrubSpanProcessor — IP address scrubbing", () => {
it("scrubs IPv4 addresses in attribute values", () => {
const tracer = tracerProvider.getTracer("test");
const span = tracer.startSpan("test-span", {
attributes: { "request.note": "request from 10.0.0.1" },
});
span.end();
const exported = spanExporter.getFinishedSpans();
expect(exported[0]!.attributes["request.note"]).toBe(
"request from [redacted-ip]",
);
});
it("scrubs IPv6 addresses in attribute values", () => {
const tracer = tracerProvider.getTracer("test");
const span = tracer.startSpan("test-span", {
attributes: { "request.note": "request from 2001:0db8::1" },
});
span.end();
const exported = spanExporter.getFinishedSpans();
expect(exported[0]!.attributes["request.note"]).toContain("[redacted-ip]");
expect(exported[0]!.attributes["request.note"]).not.toContain("2001:0db8");
});
it("redacts http.client_ip via key match (semconv 1.20)", () => {
const tracer = tracerProvider.getTracer("test");
const span = tracer.startSpan("test-span", {
attributes: { "http.client_ip": "10.0.0.1" },
});
span.end();
const exported = spanExporter.getFinishedSpans();
expect(exported[0]!.attributes["http.client_ip"]).toBe("[redacted]");
});
it("redacts client.address via key match (semconv 1.27+)", () => {
const tracer = tracerProvider.getTracer("test");
const span = tracer.startSpan("test-span", {
attributes: { "client.address": "10.0.0.1" },
});
span.end();
const exported = spanExporter.getFinishedSpans();
expect(exported[0]!.attributes["client.address"]).toBe("[redacted]");
});
});

View File

@@ -0,0 +1,126 @@
// packages/core-shared/src/instrumentation/otel/pii-scrub-processor.ts
//
// PII scrub processors for OTel spans and log records.
// These run FIRST in their respective processor chains so downstream exporters
// (including the Sentry exporter) see scrubbed data. This replaces the old
// Sentry beforeSend / beforeSendTransaction hooks — scrubbing now
// happens at the OTel layer, vendor-agnostic.
import type {
ReadableSpan,
SpanProcessor,
} from "@opentelemetry/sdk-trace-base";
import type { Span } from "@opentelemetry/api";
import type { Context } from "@opentelemetry/api";
import type { LogRecord, LogRecordProcessor } from "@opentelemetry/sdk-logs";
import {
PII_KEY_SUBSTRINGS,
REDACTED_VALUE,
IPV4_REGEX,
IPV6_REGEX,
REDACTED_IP,
} from "./pii-fields";
function isPiiKey(key: string): boolean {
const lower = key.toLowerCase();
return PII_KEY_SUBSTRINGS.some((s) => lower.includes(s));
}
function containsPiiSubstring(s: string): boolean {
const lower = s.toLowerCase();
return PII_KEY_SUBSTRINGS.some((sub) => lower.includes(sub));
}
/**
* Scrubs IP addresses from a string value using regex replacement.
* Called for attribute values whose KEYS did not match a PII substring — the
* old Sentry beforeSend hook performed this kind of value-level scrubbing; we
* replicate it here so IP addresses embedded in non-IP-keyed attributes
* (e.g. "request.note": "from 10.0.0.1") are still redacted.
*/
function scrubValue(value: unknown): unknown {
if (typeof value !== "string") return value;
// The regexes are global (`/g`) so they must be reset between calls via new RegExp
// or by relying on the fact that each string replace runs against a fresh lastIndex.
// String.prototype.replace with a regex literal (global) resets lastIndex automatically.
let scrubbed = value.replace(IPV4_REGEX, REDACTED_IP);
scrubbed = scrubbed.replace(IPV6_REGEX, REDACTED_IP);
return scrubbed;
}
function scrubAttributes(
attrs: Record<string, unknown>,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(attrs)) {
if (isPiiKey(key)) {
out[key] = REDACTED_VALUE;
} else {
out[key] = scrubValue(value);
}
}
return out;
}
/**
* Runs FIRST in the span processor chain so downstream exporters see scrubbed attributes.
* Redacts any span attribute whose key contains a PII substring (case-insensitive).
* Attribute-key-based PII redaction.
*/
export class PiiScrubSpanProcessor implements SpanProcessor {
forceFlush(): Promise<void> {
return Promise.resolve();
}
shutdown(): Promise<void> {
return Promise.resolve();
}
onStart(_span: Span, _parentContext: Context): void {
// no-op — scrub on completion when all attributes are set
}
onEnd(span: ReadableSpan): void {
const scrubbed = scrubAttributes(
span.attributes as Record<string, unknown>,
);
Object.assign(span.attributes, scrubbed);
}
}
/**
* Runs FIRST in the log processor chain.
* - Strips PII from attributes (key-based substring match, case-insensitive).
* - Strips PII from the log body string (substring match — if any PII substring
* appears in the body, the entire body is redacted to avoid partial leakage).
* Attribute-key-based PII redaction; body-level redaction.
*/
export class PiiScrubLogRecordProcessor implements LogRecordProcessor {
forceFlush(): Promise<void> {
return Promise.resolve();
}
shutdown(): Promise<void> {
return Promise.resolve();
}
onEmit(record: LogRecord): void {
if (record.attributes) {
const scrubbed = scrubAttributes(
record.attributes as Record<string, unknown>,
);
Object.assign(record.attributes, scrubbed);
}
if (typeof record.body === "string") {
if (containsPiiSubstring(record.body)) {
// Body contains a PII keyword (email, password, etc.) — redact entirely
// to avoid partial leakage.
record.body = REDACTED_VALUE;
} else {
// No PII keyword, but may still contain IP addresses embedded in text.
// Apply value-level regex scrubbing.
record.body = scrubValue(record.body) as string;
}
}
}
}

View File

@@ -0,0 +1,38 @@
import { describe, it, expect } from "vitest";
import { buildResource } from "./resource";
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
ATTR_DEPLOYMENT_ENVIRONMENT_NAME,
} from "@opentelemetry/semantic-conventions/incubating";
describe("buildResource", () => {
it("populates service name, version, and environment", () => {
const r = buildResource({
serviceName: "web-next",
serviceVersion: "1.0.0",
environment: "production",
});
expect(r.attributes[ATTR_SERVICE_NAME]).toBe("web-next");
expect(r.attributes[ATTR_SERVICE_VERSION]).toBe("1.0.0");
expect(r.attributes[ATTR_DEPLOYMENT_ENVIRONMENT_NAME]).toBe("production");
});
it("populates namespace when provided", () => {
const r = buildResource({
serviceName: "web-next",
environment: "production",
namespace: "template-vertical",
});
expect(r.attributes["service.namespace"]).toBe("template-vertical");
});
it("omits version and namespace when not provided", () => {
const r = buildResource({
serviceName: "web-next",
environment: "production",
});
expect(r.attributes[ATTR_SERVICE_VERSION]).toBeUndefined();
expect(r.attributes["service.namespace"]).toBeUndefined();
});
});

View File

@@ -0,0 +1,22 @@
import { Resource } from "@opentelemetry/resources";
export type BuildResourceOpts = {
serviceName: string;
serviceVersion?: string;
environment: string;
namespace?: string;
};
/**
* Builds an OpenTelemetry Resource with semantic-convention attributes.
* Each app constructs its own resource at startup (per-app service name).
*/
export function buildResource(opts: BuildResourceOpts): Resource {
const attrs: Record<string, string> = {
"service.name": opts.serviceName,
"deployment.environment.name": opts.environment,
};
if (opts.serviceVersion) attrs["service.version"] = opts.serviceVersion;
if (opts.namespace) attrs["service.namespace"] = opts.namespace;
return new Resource(attrs);
}

View File

@@ -0,0 +1,152 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { LogRecord } from "@opentelemetry/sdk-logs";
beforeEach(() => vi.resetModules());
// Use raw OTel SeverityNumber values to avoid module-reset issues with the
// top-level SeverityNumber import in test.each definitions.
// SeverityNumber.INFO = 9, SeverityNumber.WARN = 13, SeverityNumber.ERROR = 17
const SEVERITY_INFO = 9;
const SEVERITY_WARN = 13;
const SEVERITY_ERROR = 17;
describe("createSentryOtelBridge", () => {
it("returns a span processor and log record processor when given a DSN", async () => {
vi.doMock("@sentry/opentelemetry", () => ({
SentrySpanProcessor: class {
onStart() {}
onEnd() {}
forceFlush() {
return Promise.resolve();
}
shutdown() {
return Promise.resolve();
}
},
}));
vi.doMock("@sentry/nextjs", () => ({
captureException: vi.fn(),
captureMessage: vi.fn(),
}));
const { createSentryOtelBridge } = await import("./sentry-bridge");
const bridge = createSentryOtelBridge({ dsn: "https://test@sentry.io/1" });
expect(bridge.spanProcessor).toBeDefined();
// logRecordProcessor is wired (SentryLogRecordForwarder)
expect(bridge.logRecordProcessor).toBeDefined();
expect(bridge.logRecordProcessor).not.toBeNull();
});
it("returns null processors when no DSN provided", async () => {
const { createSentryOtelBridge } = await import("./sentry-bridge");
const bridge = createSentryOtelBridge({ dsn: "" });
expect(bridge.spanProcessor).toBeNull();
expect(bridge.logRecordProcessor).toBeNull();
});
});
describe("SentryLogRecordForwarder", () => {
it("calls Sentry.captureException for ERROR records with exception attributes", async () => {
const { SentryLogRecordForwarder } = await import("./sentry-bridge");
const captureException = vi.fn();
const captureMessage = vi.fn();
const forwarder = new SentryLogRecordForwarder({
captureException,
captureMessage,
});
const record = {
severityNumber: SEVERITY_ERROR,
attributes: {
"exception.type": "TypeError",
"exception.message": "Cannot read property",
"exception.stacktrace": "TypeError: ...\n at foo.ts:10",
"tag.feature": "blog",
"extra.count": "5",
"sentry.fingerprint": "type-a|src-blog",
},
body: "Cannot read property",
} as unknown as LogRecord;
forwarder.onEmit(record);
expect(captureException).toHaveBeenCalledTimes(1);
const [err, opts] = captureException.mock.calls[0]!;
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe("TypeError");
expect(err.message).toBe("Cannot read property");
expect(err.stack).toBe("TypeError: ...\n at foo.ts:10");
expect(opts.tags).toEqual({ feature: "blog" });
expect(opts.extra).toEqual({ count: "5" });
expect(opts.fingerprint).toEqual(["type-a", "src-blog"]);
expect(captureMessage).not.toHaveBeenCalled();
});
it("calls Sentry.captureException for ERROR records without exception.stacktrace", async () => {
const { SentryLogRecordForwarder } = await import("./sentry-bridge");
const captureException = vi.fn();
const captureMessage = vi.fn();
const forwarder = new SentryLogRecordForwarder({
captureException,
captureMessage,
});
const record = {
severityNumber: SEVERITY_ERROR,
attributes: {
"exception.message": "Something failed",
},
body: "Something failed",
} as unknown as LogRecord;
forwarder.onEmit(record);
expect(captureException).toHaveBeenCalledTimes(1);
expect(captureMessage).not.toHaveBeenCalled();
});
it.each([
[SEVERITY_INFO, "info"],
[SEVERITY_WARN, "warning"],
] as const)(
"calls Sentry.captureMessage with level '%s' for non-error severity %d",
async (severityNumber, expectedLevel) => {
const { SentryLogRecordForwarder } = await import("./sentry-bridge");
const captureException = vi.fn();
const captureMessage = vi.fn();
const forwarder = new SentryLogRecordForwarder({
captureException,
captureMessage,
});
const record = {
severityNumber,
attributes: { "tag.service": "auth", "extra.req": "abc" },
body: "log message",
} as unknown as LogRecord;
forwarder.onEmit(record);
expect(captureMessage).toHaveBeenCalledTimes(1);
const [msg, level, opts] = captureMessage.mock.calls[0]!;
expect(msg).toBe("log message");
expect(level).toBe(expectedLevel);
expect(opts.tags).toEqual({ service: "auth" });
expect(opts.extra).toEqual({ req: "abc" });
expect(captureException).not.toHaveBeenCalled();
},
);
it("forceFlush and shutdown resolve immediately", async () => {
const { SentryLogRecordForwarder } = await import("./sentry-bridge");
const forwarder = new SentryLogRecordForwarder({
captureException: vi.fn(),
captureMessage: vi.fn(),
});
await expect(forwarder.forceFlush()).resolves.toBeUndefined();
await expect(forwarder.shutdown()).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,148 @@
import type {
tracing as sdkTracing,
logs as sdkLogs,
} from "@opentelemetry/sdk-node";
import { SeverityNumber } from "@opentelemetry/api-logs";
import type { LogRecord } from "@opentelemetry/sdk-logs";
type SpanProcessor = sdkTracing.SpanProcessor;
type LogRecordProcessor = sdkLogs.LogRecordProcessor;
export type SentryOtelBridgeOpts = {
/** Sentry DSN. When empty, no Sentry processors are returned (Noop boot). */
dsn: string;
};
export type SentryOtelBridge = {
spanProcessor: SpanProcessor | null;
logRecordProcessor: LogRecordProcessor | null;
};
type SentryModule = {
captureException: (
err: Error,
opts: {
tags?: Record<string, string>;
extra?: Record<string, unknown>;
fingerprint?: string[];
},
) => void;
captureMessage: (
msg: string,
level: string,
opts: { tags?: Record<string, string>; extra?: Record<string, unknown> },
) => void;
};
/**
* Consumes OTel LogRecords and forwards them to Sentry via the user-facing
* Sentry SDK API. This is the Sentry-coupled bridge — the only file in
* core-shared (besides sentry/init-server.ts etc.) that imports `@sentry/*`.
*
* Double-report note: ERROR records may also arrive via the span-event path
* (OtelTracer.recordException → SentrySpanProcessor). Sentry's native dedup
* handles this (same stack + message). Future hardening can refine.
*
* @param sentry — injectable Sentry module reference; defaults to lazy-require
* of `@sentry/nextjs`. Pass a mock in tests to avoid require() interception
* limitations with Vitest's vi.doMock.
*/
export class SentryLogRecordForwarder implements LogRecordProcessor {
private readonly sentry: SentryModule;
constructor(sentry?: SentryModule) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
this.sentry = sentry ?? (require("@sentry/nextjs") as SentryModule);
}
onEmit(record: LogRecord): void {
const Sentry = this.sentry;
const attrs = record.attributes ?? {};
const tags = extractTags(attrs);
const extra = extractExtras(attrs);
const severityNumber = record.severityNumber ?? SeverityNumber.INFO;
if (severityNumber >= SeverityNumber.ERROR) {
// Reconstruct the error from OTel semantic convention attributes
const message =
(attrs["exception.message"] as string | undefined) ??
String(record.body ?? "");
const err = new Error(message);
if (attrs["exception.type"]) {
err.name = attrs["exception.type"] as string;
}
if (attrs["exception.stacktrace"]) {
err.stack = attrs["exception.stacktrace"] as string;
}
const fingerprint = attrs["sentry.fingerprint"]
? (attrs["sentry.fingerprint"] as string).split("|")
: undefined;
Sentry.captureException(err, {
tags,
extra,
...(fingerprint ? { fingerprint } : {}),
});
} else {
// Map severity to Sentry level
const level = severityNumber >= SeverityNumber.WARN ? "warning" : "info";
Sentry.captureMessage(String(record.body ?? ""), level, { tags, extra });
}
}
forceFlush(): Promise<void> {
return Promise.resolve();
}
shutdown(): Promise<void> {
return Promise.resolve();
}
}
/**
* Creates Sentry-as-OTel-exporter processors. The OTel SDK uses these to
* forward spans and log records to Sentry. This is the ONLY file in
* core-shared that imports from `@sentry/opentelemetry` — all other Sentry
* coupling is excluded by the ESLint allowlist.
*/
export function createSentryOtelBridge(
opts: SentryOtelBridgeOpts,
): SentryOtelBridge {
if (!opts.dsn) {
return { spanProcessor: null, logRecordProcessor: null };
}
// eslint-disable-next-line @typescript-eslint/no-require-imports
const sentryOtel = require("@sentry/opentelemetry");
return {
spanProcessor: new sentryOtel.SentrySpanProcessor() as SpanProcessor,
logRecordProcessor: new SentryLogRecordForwarder(),
};
}
// ── Attribute helpers ────────────────────────────────────────────────────────
function extractTags(attrs: Record<string, unknown>): Record<string, string> {
const tags: Record<string, string> = {};
for (const [k, v] of Object.entries(attrs)) {
if (k.startsWith("tag.")) {
tags[k.slice(4)] = String(v);
}
}
return tags;
}
function extractExtras(
attrs: Record<string, unknown>,
): Record<string, unknown> {
const extras: Record<string, unknown> = {};
for (const [k, v] of Object.entries(attrs)) {
if (k.startsWith("extra.")) {
extras[k.slice(6)] = v;
}
}
return extras;
}

View File

@@ -0,0 +1,24 @@
// Non-enumerable flag used by every ILogger implementation to skip
// already-reported errors. The flag is non-enumerable so JSON.stringify
// and {...err} spread won't surface it.
const REPORTED = "__sentryReported" as const;
export function isReported(err: unknown): boolean {
return (
err !== null &&
typeof err === "object" &&
Boolean((err as Record<string, unknown>)[REPORTED])
);
}
export function markReported(err: unknown): void {
if (err !== null && typeof err === "object" && !isReported(err)) {
Object.defineProperty(err, REPORTED, {
value: true,
enumerable: false,
configurable: false,
writable: false,
});
}
}

View File

@@ -0,0 +1,92 @@
// packages/core-shared/src/instrumentation/sentry/init-client-react.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
const { replayIntegration, feedbackIntegration } = vi.hoisted(() => {
const replayIntegration = vi.fn((opts: unknown) => ({
name: "Replay",
_opts: opts,
}));
const feedbackIntegration = vi.fn((opts: unknown) => ({
name: "Feedback",
_opts: opts,
}));
return { replayIntegration, feedbackIntegration };
});
vi.mock("@sentry/react", () => ({
init: vi.fn(),
replayIntegration,
feedbackIntegration,
}));
import * as SentryReact from "@sentry/react";
import { initSentryClientReact } from "@/instrumentation/sentry/init-client-react";
describe("initSentryClientReact", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("calls SentryReact.init with sendDefaultPii: false", () => {
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock
.calls[0]![0] as Record<string, unknown>;
expect(call["sendDefaultPii"]).toBe(false);
});
it("attaches replay integration with mask flags", () => {
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
expect(replayIntegration).toHaveBeenCalledTimes(1);
const replayOpts = (replayIntegration as ReturnType<typeof vi.fn>).mock
.calls[0]![0] as Record<string, unknown>;
expect(replayOpts["maskAllText"]).toBe(true);
expect(replayOpts["maskAllInputs"]).toBe(true);
expect(replayOpts["blockAllMedia"]).toBe(true);
});
it("defaults replay sample rates", () => {
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock
.calls[0]![0] as Record<string, unknown>;
expect(call["replaysSessionSampleRate"]).toBe(0.0);
expect(call["replaysOnErrorSampleRate"]).toBe(1.0);
});
it("attaches beforeSend + beforeSendTransaction scrubbers", () => {
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock
.calls[0]![0] as Record<string, unknown>;
expect(typeof call["beforeSend"]).toBe("function");
expect(typeof call["beforeSendTransaction"]).toBe("function");
});
it("is a no-op when dsn is missing", () => {
initSentryClientReact({ dsn: "", app: "web-tanstack" });
expect(SentryReact.init).not.toHaveBeenCalled();
});
it("attaches feedbackIntegration when SentryReact.feedbackIntegration is available", () => {
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
expect(feedbackIntegration).toHaveBeenCalledTimes(1);
});
it("passes styleNonce and scriptNonce to feedbackIntegration when nonce provided", () => {
initSentryClientReact({
dsn: "https://x@y/1",
app: "web-tanstack",
nonce: "abc123",
});
const feedbackOpts = (feedbackIntegration as ReturnType<typeof vi.fn>).mock
.calls[0]![0] as Record<string, unknown>;
expect(feedbackOpts["styleNonce"]).toBe("abc123");
expect(feedbackOpts["scriptNonce"]).toBe("abc123");
});
it("omits nonce props from feedbackIntegration when nonce not provided", () => {
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
const feedbackOpts = (feedbackIntegration as ReturnType<typeof vi.fn>).mock
.calls[0]![0] as Record<string, unknown>;
expect(feedbackOpts["styleNonce"]).toBeUndefined();
expect(feedbackOpts["scriptNonce"]).toBeUndefined();
});
});

View File

@@ -0,0 +1,141 @@
// packages/core-shared/src/instrumentation/sentry/init-client-react.ts
// Browser-side Sentry init for Vite/React runtimes (TanStack Start). PII scrubbing is
// applied via beforeSend/beforeSendTransaction because browser does NOT use the OTel pipeline.
// PII field lists imported from otel/pii-fields.ts (vendor-neutral).
import * as SentryReact from "@sentry/react";
import type { InitClientOpts } from "./init-client";
import {
PII_KEY_SUBSTRINGS,
PII_QUERY_PARAM_SUBSTRINGS,
REDACTED_VALUE,
REDACTED_IP,
IPV4_REGEX,
IPV6_REGEX,
} from "../otel/pii-fields";
// Inline scrub helpers for browser-side Sentry (server uses OTel processors instead).
function keyContainsPii(key: string): boolean {
const lower = key.toLowerCase();
return PII_KEY_SUBSTRINGS.some((s) => lower.includes(s));
}
function queryParamContainsPii(key: string): boolean {
const lower = key.toLowerCase();
return PII_QUERY_PARAM_SUBSTRINGS.some((s) => lower.includes(s));
}
function redactString(s: string): string {
const ipv4 = new RegExp(IPV4_REGEX.source, "g");
const ipv6 = new RegExp(IPV6_REGEX.source, "g");
return s.replace(ipv4, REDACTED_IP).replace(ipv6, REDACTED_IP);
}
function deepScrub(value: unknown, parentKey = ""): unknown {
if (value === null || value === undefined) return value;
if (typeof value === "string") {
return parentKey && keyContainsPii(parentKey)
? REDACTED_VALUE
: redactString(value);
}
if (typeof value === "number" || typeof value === "boolean") {
return parentKey && keyContainsPii(parentKey) ? REDACTED_VALUE : value;
}
if (Array.isArray(value)) return value.map((v) => deepScrub(v, parentKey));
if (typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = keyContainsPii(k) ? REDACTED_VALUE : deepScrub(v, k);
}
return out;
}
return value;
}
function scrubUrl(url: string): string {
try {
const u = new URL(url, "http://placeholder.local");
for (const [k] of Array.from(u.searchParams.entries())) {
if (queryParamContainsPii(k)) u.searchParams.set(k, REDACTED_VALUE);
}
return url.startsWith("/") ? `${u.pathname}${u.search}` : u.toString();
} catch {
return url;
}
}
/**
* Client-side init for non-Next.js (Vite/React) runtimes (TanStack Start).
* Mirrors init-client.ts but uses @sentry/react directly. Same PII,
* replay, and scrubbing requirements apply.
*/
export function initSentryClientReact(opts: InitClientOpts): void {
if (!opts.dsn) return;
const isProd = process.env["NODE_ENV"] === "production";
const { nonce } = opts;
const tracesSampleRate =
process.env["SENTRY_TRACES_SAMPLE_RATE"] !== undefined
? Number(process.env["SENTRY_TRACES_SAMPLE_RATE"])
: isProd
? 0.1
: 1.0;
const environment =
process.env["SENTRY_ENVIRONMENT"] ??
process.env["NODE_ENV"] ??
"development";
const release = opts.release ?? "unknown";
type InitOpts = Parameters<typeof SentryReact.init>[0];
type SentryEvent = {
extra?: Record<string, unknown> | null;
contexts?: Record<string, Record<string, unknown> | undefined>;
request?: {
url?: string;
headers?: Record<string, string | undefined>;
[key: string]: unknown;
};
transaction?: string;
[key: string]: unknown;
};
SentryReact.init({
dsn: opts.dsn,
environment,
release,
tracesSampleRate,
sendDefaultPii: false,
beforeSend: ((event: SentryEvent) =>
deepScrub(event)) as unknown as NonNullable<InitOpts>["beforeSend"],
beforeSendTransaction: ((event: SentryEvent) => {
const out = { ...event };
if (out.request?.url)
out.request = { ...out.request, url: scrubUrl(out.request.url) };
if (
out.transaction &&
(out.transaction.includes("?") || out.transaction.includes("="))
) {
out.transaction = scrubUrl(out.transaction);
}
return out;
}) as unknown as NonNullable<InitOpts>["beforeSendTransaction"],
replaysSessionSampleRate: 0.0,
replaysOnErrorSampleRate: 1.0,
integrations: [
// mandatory mask flags; allowlist starts empty
SentryReact.replayIntegration({
maskAllText: true,
maskAllInputs: true,
blockAllMedia: true,
}),
...(SentryReact.feedbackIntegration
? [
SentryReact.feedbackIntegration({
...(nonce ? { styleNonce: nonce, scriptNonce: nonce } : {}),
}),
]
: []),
],
initialScope: { tags: { app: opts.app } },
});
}

View File

@@ -0,0 +1,98 @@
// packages/core-shared/src/instrumentation/sentry/init-client.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
const { replayIntegration, feedbackIntegration } = vi.hoisted(() => {
const replayIntegration = vi.fn((opts: unknown) => ({
name: "Replay",
_opts: opts,
}));
const feedbackIntegration = vi.fn((opts: unknown) => ({
name: "Feedback",
_opts: opts,
}));
return { replayIntegration, feedbackIntegration };
});
vi.mock("@sentry/nextjs", () => ({
init: vi.fn(),
replayIntegration,
feedbackIntegration,
}));
import * as Sentry from "@sentry/nextjs";
import { initSentryClient } from "@/instrumentation/sentry/init-client";
describe("initSentryClient", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("calls Sentry.init with sendDefaultPii: false", () => {
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock
.calls[0]![0] as Record<string, unknown>;
expect(call["sendDefaultPii"]).toBe(false);
});
it("attaches replay integration with maskAllText/maskAllInputs/blockAllMedia: true", () => {
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
expect(replayIntegration).toHaveBeenCalledTimes(1);
const replayOpts = (replayIntegration as ReturnType<typeof vi.fn>).mock
.calls[0]![0] as Record<string, unknown>;
expect(replayOpts["maskAllText"]).toBe(true);
expect(replayOpts["maskAllInputs"]).toBe(true);
expect(replayOpts["blockAllMedia"]).toBe(true);
});
it("defaults replaysSessionSampleRate to 0.0", () => {
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock
.calls[0]![0] as Record<string, unknown>;
expect(call["replaysSessionSampleRate"]).toBe(0.0);
});
it("defaults replaysOnErrorSampleRate to 1.0", () => {
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock
.calls[0]![0] as Record<string, unknown>;
expect(call["replaysOnErrorSampleRate"]).toBe(1.0);
});
it("attaches beforeSend + beforeSendTransaction", () => {
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock
.calls[0]![0] as Record<string, unknown>;
expect(typeof call["beforeSend"]).toBe("function");
expect(typeof call["beforeSendTransaction"]).toBe("function");
});
it("is a no-op when dsn is empty", () => {
initSentryClient({ dsn: "", app: "web-next" });
expect(Sentry.init).not.toHaveBeenCalled();
});
it("attaches feedbackIntegration when Sentry.feedbackIntegration is available", () => {
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
expect(feedbackIntegration).toHaveBeenCalledTimes(1);
});
it("passes styleNonce and scriptNonce to feedbackIntegration when nonce provided", () => {
initSentryClient({
dsn: "https://x@y/1",
app: "web-next",
nonce: "abc123",
});
const feedbackOpts = (feedbackIntegration as ReturnType<typeof vi.fn>).mock
.calls[0]![0] as Record<string, unknown>;
expect(feedbackOpts["styleNonce"]).toBe("abc123");
expect(feedbackOpts["scriptNonce"]).toBe("abc123");
});
it("omits nonce props from feedbackIntegration when nonce not provided", () => {
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
const feedbackOpts = (feedbackIntegration as ReturnType<typeof vi.fn>).mock
.calls[0]![0] as Record<string, unknown>;
expect(feedbackOpts["styleNonce"]).toBeUndefined();
expect(feedbackOpts["scriptNonce"]).toBeUndefined();
});
});

View File

@@ -0,0 +1,142 @@
// packages/core-shared/src/instrumentation/sentry/init-client.ts
// Browser-side Sentry init. PII scrubbing is applied via beforeSend/beforeSendTransaction
// hooks because browser does NOT use the OTel pipeline (server-only migration). The
// PII field lists come from otel/pii-fields.ts (vendor-neutral location).
import * as Sentry from "@sentry/nextjs";
import {
PII_KEY_SUBSTRINGS,
PII_QUERY_PARAM_SUBSTRINGS,
REDACTED_VALUE,
REDACTED_IP,
IPV4_REGEX,
IPV6_REGEX,
} from "../otel/pii-fields";
export type InitClientOpts = {
dsn: string | undefined;
app: "web-next" | "cms" | "web-tanstack";
release?: string;
nonce?: string;
};
// Inline scrub helpers for browser-side Sentry (server uses OTel processors instead).
function keyContainsPii(key: string): boolean {
const lower = key.toLowerCase();
return PII_KEY_SUBSTRINGS.some((s) => lower.includes(s));
}
function queryParamContainsPii(key: string): boolean {
const lower = key.toLowerCase();
return PII_QUERY_PARAM_SUBSTRINGS.some((s) => lower.includes(s));
}
function redactString(s: string): string {
const ipv4 = new RegExp(IPV4_REGEX.source, "g");
const ipv6 = new RegExp(IPV6_REGEX.source, "g");
return s.replace(ipv4, REDACTED_IP).replace(ipv6, REDACTED_IP);
}
function deepScrub(value: unknown, parentKey = ""): unknown {
if (value === null || value === undefined) return value;
if (typeof value === "string") {
return parentKey && keyContainsPii(parentKey)
? REDACTED_VALUE
: redactString(value);
}
if (typeof value === "number" || typeof value === "boolean") {
return parentKey && keyContainsPii(parentKey) ? REDACTED_VALUE : value;
}
if (Array.isArray(value)) return value.map((v) => deepScrub(v, parentKey));
if (typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = keyContainsPii(k) ? REDACTED_VALUE : deepScrub(v, k);
}
return out;
}
return value;
}
function scrubUrl(url: string): string {
try {
const u = new URL(url, "http://placeholder.local");
for (const [k] of Array.from(u.searchParams.entries())) {
if (queryParamContainsPii(k)) u.searchParams.set(k, REDACTED_VALUE);
}
return url.startsWith("/") ? `${u.pathname}${u.search}` : u.toString();
} catch {
return url;
}
}
export function initSentryClient(opts: InitClientOpts): void {
if (!opts.dsn) return;
const { nonce } = opts;
const isProd = process.env["NODE_ENV"] === "production";
const tracesSampleRate =
process.env["SENTRY_TRACES_SAMPLE_RATE"] !== undefined
? Number(process.env["SENTRY_TRACES_SAMPLE_RATE"])
: isProd
? 0.1
: 1.0;
const environment =
process.env["SENTRY_ENVIRONMENT"] ??
process.env["NODE_ENV"] ??
"development";
const release = opts.release ?? "unknown";
type InitOpts = Parameters<typeof Sentry.init>[0];
type SentryEvent = {
extra?: Record<string, unknown> | null;
contexts?: Record<string, Record<string, unknown> | undefined>;
request?: {
url?: string;
headers?: Record<string, string | undefined>;
[key: string]: unknown;
};
transaction?: string;
[key: string]: unknown;
};
Sentry.init({
dsn: opts.dsn,
environment,
release,
tracesSampleRate,
sendDefaultPii: false,
beforeSend: ((event: SentryEvent) =>
deepScrub(event)) as unknown as InitOpts["beforeSend"],
beforeSendTransaction: ((event: SentryEvent) => {
const out = { ...event };
if (out.request?.url)
out.request = { ...out.request, url: scrubUrl(out.request.url) };
if (
out.transaction &&
(out.transaction.includes("?") || out.transaction.includes("="))
) {
out.transaction = scrubUrl(out.transaction);
}
return out;
}) as unknown as InitOpts["beforeSendTransaction"],
replaysSessionSampleRate: 0.0, // privacy default
replaysOnErrorSampleRate: 1.0,
integrations: [
// mandatory mask flags; allowlist starts empty
Sentry.replayIntegration({
maskAllText: true,
maskAllInputs: true,
blockAllMedia: true,
}),
...(Sentry.feedbackIntegration
? [
Sentry.feedbackIntegration({
...(nonce ? { styleNonce: nonce, scriptNonce: nonce } : {}),
}),
]
: []),
],
initialScope: { tags: { app: opts.app } },
});
}

View File

@@ -0,0 +1,5 @@
export const INSTRUMENTATION_SYMBOLS = {
TRACER: Symbol.for("core-shared.TRACER"),
LOGGER: Symbol.for("core-shared.LOGGER"),
METRICS: Symbol.for("core-shared.METRICS"),
} as const;

View File

@@ -0,0 +1,16 @@
export type AttributeValue = string | number | boolean | null;
export type SpanOpts = {
name: string;
op?: "use-case" | "controller" | "repository" | "service" | string;
attributes?: Record<string, AttributeValue>;
};
export interface ISpan {
setAttribute(key: string, value: AttributeValue): void;
setStatus(status: "ok" | "error", message?: string): void;
}
export interface ITracer {
startSpan<T>(opts: SpanOpts, fn: (span: ISpan) => Promise<T>): Promise<T>;
}

View File

@@ -0,0 +1,83 @@
import { describe, it, expect, expectTypeOf, vi } from "vitest";
import { withCapture } from "@/instrumentation/with-capture";
import type { ILogger } from "@/instrumentation/logger.interface";
import { isReported } from "@/instrumentation/reported-flag";
import type { Captured } from "@/conformance/brands";
import { isCaptured } from "@/conformance/brand-runtime";
function makeLogger(): ILogger & { captureException: ReturnType<typeof vi.fn> } {
return {
captureException: vi.fn(),
captureMessage: vi.fn(),
addBreadcrumb: vi.fn(),
setUser: vi.fn(),
};
}
describe("withCapture", () => {
it("does not capture on success", async () => {
const logger = makeLogger();
const wrapped = withCapture(logger, { layer: "use-case" }, async (x: number) => x + 1);
await expect(wrapped(1)).resolves.toBe(2);
expect(logger.captureException).not.toHaveBeenCalled();
});
it("captures with tags and re-throws on failure", async () => {
const logger = makeLogger();
const err = new Error("boom");
const wrapped = withCapture(logger, { layer: "use-case", name: "blog.x" }, async () => {
throw err;
});
await expect(wrapped()).rejects.toBe(err);
expect(logger.captureException).toHaveBeenCalledTimes(1);
expect(logger.captureException).toHaveBeenCalledWith(err, {
tags: { layer: "use-case", name: "blog.x" },
});
});
it("marks the error as reported after first capture", async () => {
const logger = makeLogger();
const err = new Error("boom");
const wrapped = withCapture(logger, { layer: "use-case" }, async () => {
throw err;
});
await expect(wrapped()).rejects.toBe(err);
expect(isReported(err)).toBe(true);
});
it("does NOT capture again when the same error already carries the flag", async () => {
const logger = makeLogger();
const err = new Error("boom");
// Simulate an inner layer (repo) having already captured + marked.
const inner = withCapture(logger, { layer: "repo" }, async () => {
throw err;
});
const outer = withCapture(logger, { layer: "use-case" }, () => inner());
await expect(outer()).rejects.toBe(err);
// Only the inner layer captured it; outer saw the flag and bailed.
expect(logger.captureException).toHaveBeenCalledTimes(1);
expect(logger.captureException).toHaveBeenCalledWith(err, {
tags: { layer: "repo" },
});
});
});
describe("withCapture — brand", () => {
it("returns a Captured<F>", () => {
const logger = makeLogger();
const fn = async (a: number) => a + 1;
const wrapped = withCapture(logger, { layer: "use-case" }, fn);
expectTypeOf(wrapped).toMatchTypeOf<Captured<typeof fn>>();
});
});
describe("withCapture — runtime brand", () => {
it("attaches __captured as a non-enumerable property on the wrapped function", async () => {
const logger = makeLogger();
const fn = async (a: number) => a + 1;
const wrapped = withCapture(logger, { layer: "use-case" }, fn);
expect(isCaptured(wrapped)).toBe(true);
expect(Object.keys(wrapped)).not.toContain("__captured");
});
});

View File

@@ -0,0 +1,60 @@
import type { ILogger } from "./logger.interface";
import type { Captured } from "../conformance/brands";
import { attachBrand } from "../conformance/brand-runtime";
import { isReported, markReported } from "./reported-flag";
/**
* Higher-order wrapper applied at DI bind time. Mirrors `withSpan`: takes a
* factory result `(args) => Promise<R>` and returns the same shape, but any
* thrown error is captured via `logger.captureException(err, { tags })` before
* being re-thrown.
*
* Skips capture if the error already carries the `__sentryReported` flag —
* this is what prevents double-capture when the same error bubbles through
* a wrapped repo → use case → controller chain (the repo's catch site
* captures first; outer wrappers see the flag and bail).
*
* Usage at bind time:
*
* const captured = withCapture(logger, { feature: "blog", layer: "use-case", name: "blog.getArticles" }, factory(deps));
* const wrapped = withSpan(tracer, opts, captured);
*
* Span wraps capture: the span timing reflects the captured-and-rethrown
* failure (errored span gets a duration), and the capture has accurate
* tags by the time it fires.
*/
export function withCapture<Args extends unknown[], R>(
logger: ILogger,
tags: Record<string, string>,
fn: (...args: Args) => Promise<R>,
): Captured<(...args: Args) => Promise<R>> {
const PROPAGATED_BRANDS = [
"__instrumented",
"__audited",
"__analyzed",
"__consentChecked",
"__rateLimited",
] as const;
const wrapped: (...args: Args) => Promise<R> = async (...args) => {
try {
return await fn(...args);
} catch (err) {
if (!isReported(err)) {
logger.captureException(err, { tags });
markReported(err);
}
throw err;
}
};
// Propagate brands from the inner function (e.g. __audited from withAudit,
// __instrumented if already spanned) so the outermost binding carries all brands.
// __captured is omitted here because it is attached explicitly below.
for (const brand of PROPAGATED_BRANDS) {
if ((fn as unknown as Record<string, unknown>)[brand] === true) {
attachBrand(wrapped, brand);
}
}
attachBrand(wrapped, "__captured");
return wrapped as Captured<(...args: Args) => Promise<R>>;
}

View File

@@ -0,0 +1,83 @@
import { describe, it, expect, expectTypeOf, vi } from "vitest";
import { withSpan } from "@/instrumentation/with-span";
import type { ITracer, ISpan, SpanOpts } from "@/instrumentation/tracer.interface";
import type { Instrumented } from "@/conformance/brands";
import { isInstrumented } from "@/conformance/brand-runtime";
function makeRecordingTracer() {
const calls: SpanOpts[] = [];
const tracer: ITracer = {
startSpan: vi.fn(async (opts, fn) => {
calls.push(opts);
const span: ISpan = { setAttribute: () => {}, setStatus: () => {} };
return fn(span);
}),
};
return { tracer, calls };
}
describe("withSpan", () => {
it("wraps fn with a span using static opts", async () => {
const { tracer, calls } = makeRecordingTracer();
const fn = async (a: number, b: number) => a + b;
const wrapped = withSpan(tracer, { name: "test.add", op: "use-case" }, fn);
const result = await wrapped(2, 3);
expect(result).toBe(5);
expect(calls).toHaveLength(1);
expect(calls[0]).toEqual({ name: "test.add", op: "use-case" });
});
it("wraps fn with span opts derived from args (function form)", async () => {
const { tracer, calls } = makeRecordingTracer();
const fn = async (id: string) => `result-${id}`;
const wrapped = withSpan(
tracer,
([id]) => ({ name: "test.byId", op: "repository", attributes: { id } }),
fn,
);
const result = await wrapped("abc");
expect(result).toBe("result-abc");
expect(calls).toHaveLength(1);
expect(calls[0]).toEqual({
name: "test.byId",
op: "repository",
attributes: { id: "abc" },
});
});
it("propagates errors thrown by fn", async () => {
const { tracer } = makeRecordingTracer();
const wrapped = withSpan(tracer, { name: "test.err" }, async () => {
throw new Error("boom");
});
await expect(wrapped()).rejects.toThrow("boom");
});
it("preserves identity across multiple invocations (closure stable)", async () => {
const { tracer, calls } = makeRecordingTracer();
const wrapped = withSpan(tracer, { name: "test.same" }, async (n: number) => n);
await wrapped(1);
await wrapped(2);
expect(calls).toHaveLength(2);
expect(calls.every((c) => c.name === "test.same")).toBe(true);
});
});
describe("withSpan — brand", () => {
it("returns an Instrumented<F>", () => {
const { tracer } = makeRecordingTracer();
const fn = async (a: number) => a + 1;
const wrapped = withSpan(tracer, { name: "test.brand", op: "use-case" }, fn);
expectTypeOf(wrapped).toMatchTypeOf<Instrumented<typeof fn>>();
});
});
describe("withSpan — runtime brand", () => {
it("attaches __instrumented as a non-enumerable property on the wrapped function", async () => {
const { tracer } = makeRecordingTracer();
const fn = async (a: number) => a + 1;
const wrapped = withSpan(tracer, { name: "test.brand", op: "use-case" }, fn);
expect(isInstrumented(wrapped)).toBe(true);
expect(Object.keys(wrapped)).not.toContain("__instrumented");
});
});

View File

@@ -0,0 +1,45 @@
import type { ITracer, SpanOpts } from "./tracer.interface";
import type { Instrumented } from "../conformance/brands";
import { attachBrand } from "../conformance/brand-runtime";
const PROPAGATED_BRANDS = [
"__captured",
"__audited",
"__analyzed",
"__consentChecked",
"__rateLimited",
] as const;
export function withSpan<Args extends unknown[], R, Extra extends object>(
tracer: ITracer,
opts: SpanOpts | ((args: Args) => SpanOpts),
fn: ((...args: Args) => Promise<R>) & Extra,
): Instrumented<((...args: Args) => Promise<R>) & Extra>;
export function withSpan<Args extends unknown[], R>(
tracer: ITracer,
opts: SpanOpts | ((args: Args) => SpanOpts),
fn: (...args: Args) => Promise<R>,
): Instrumented<(...args: Args) => Promise<R>>;
export function withSpan<Args extends unknown[], R>(
tracer: ITracer,
opts: SpanOpts | ((args: Args) => SpanOpts),
fn: (...args: Args) => Promise<R>,
): Instrumented<(...args: Args) => Promise<R>> {
const wrapped: (...args: Args) => Promise<R> = (...args) => {
const resolved = typeof opts === "function" ? opts(args) : opts;
return tracer.startSpan(resolved, () => fn(...args));
};
attachBrand(wrapped, "__instrumented");
// Propagate brands from the inner function (e.g. __captured from withCapture,
// __audited from withAudit) so the outermost binding carries all brands.
// withSpan is always outermost — the assertFeatureConformance check reads the
// container-resolved value (the withSpan result), so brands must be visible here.
for (const brand of PROPAGATED_BRANDS) {
if ((fn as unknown as Record<string, unknown>)[brand] === true) {
attachBrand(wrapped, brand);
}
}
// Cast is the type-level concession — the brand is now also a non-enumerable
// runtime property attached above by `attachBrand`.
return wrapped as Instrumented<(...args: Args) => Promise<R>>;
}

View File

@@ -0,0 +1,54 @@
import { describe, it, expect, vi } from "vitest";
import { InMemoryJobQueue } from "@/jobs/in-memory-job-queue";
import type { IJobQueue } from "@/jobs/job-queue.interface";
describe("InMemoryJobQueue", () => {
it("returns a synthetic jobId on enqueue", async () => {
const handler = vi.fn();
const queue: IJobQueue = new InMemoryJobQueue({
"test.task": handler,
});
const result = await queue.enqueue("test.task", { x: 1 });
expect(result.jobId).toMatch(/^in-memory-/);
});
it("invokes the registered handler asynchronously with the input", async () => {
const handler = vi.fn();
const queue = new InMemoryJobQueue({ "test.task": handler });
await queue.enqueue("test.task", { x: 42 });
await new Promise((r) => setImmediate(r));
expect(handler).toHaveBeenCalledWith({ x: 42 });
});
it("throws if the task slug has no registered handler", async () => {
const queue = new InMemoryJobQueue({});
await expect(queue.enqueue("missing.task", {})).rejects.toThrow(
/no handler registered for task slug: missing\.task/,
);
});
it("delays execution when runAt is in the future", async () => {
vi.useFakeTimers();
try {
const handler = vi.fn();
const queue = new InMemoryJobQueue({ "test.task": handler });
const future = new Date(Date.now() + 1000);
await queue.enqueue("test.task", {}, { runAt: future });
expect(handler).not.toHaveBeenCalled();
vi.advanceTimersByTime(1000);
await Promise.resolve();
expect(handler).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
it("register adds a handler that can be enqueued against", async () => {
const queue = new InMemoryJobQueue();
const handler = vi.fn();
queue.register("late.task", handler);
await queue.enqueue("late.task", { z: 1 });
await new Promise((r) => setImmediate(r));
expect(handler).toHaveBeenCalledWith({ z: 1 });
});
});

View File

@@ -0,0 +1,36 @@
import type { IJobQueue } from "./job-queue.interface";
export type InMemoryHandler = (input: unknown) => Promise<void> | void;
export class InMemoryJobQueue implements IJobQueue {
private counter = 0;
private readonly handlers: Record<string, InMemoryHandler>;
constructor(handlers: Record<string, InMemoryHandler> = {}) {
this.handlers = { ...handlers };
}
register(slug: string, handler: InMemoryHandler): void {
this.handlers[slug] = handler;
}
async enqueue<T>(
taskSlug: string,
input: T,
options?: { runAt?: Date },
): Promise<{ jobId: string }> {
const handler = this.handlers[taskSlug];
if (!handler) {
throw new Error(`no handler registered for task slug: ${taskSlug}`);
}
this.counter += 1;
const jobId = `in-memory-${this.counter}`;
const delay = options?.runAt ? options.runAt.getTime() - Date.now() : 0;
if (delay > 0) {
setTimeout(() => void handler(input), delay);
} else {
setImmediate(() => void handler(input));
}
return { jobId };
}
}

View File

@@ -0,0 +1,4 @@
export type { IJobQueue } from "./job-queue.interface";
export { CORE_SHARED_JOBS_SYMBOLS } from "./symbols";
export { InMemoryJobQueue, type InMemoryHandler } from "./in-memory-job-queue";
export { PayloadJobQueue } from "./payload-job-queue";

View File

@@ -0,0 +1,7 @@
export interface IJobQueue {
enqueue<T>(
taskSlug: string,
input: T,
options?: { runAt?: Date },
): Promise<{ jobId: string }>;
}

View File

@@ -0,0 +1,28 @@
import { describe, it, expect, vi } from "vitest";
import { PayloadJobQueue } from "@/jobs/payload-job-queue";
describe("PayloadJobQueue", () => {
it("delegates to payload.jobs.queue with the task slug and input", async () => {
const queueMock = vi.fn().mockResolvedValue({ id: "payload-job-1" });
const payload = { jobs: { queue: queueMock } } as never;
const queue = new PayloadJobQueue(payload);
const result = await queue.enqueue("blog.republish", { id: 1 });
expect(queueMock).toHaveBeenCalledWith({
task: "blog.republish",
input: { id: 1 },
waitUntil: undefined,
});
expect(result).toEqual({ jobId: "payload-job-1" });
});
it("forwards runAt as waitUntil", async () => {
const queueMock = vi.fn().mockResolvedValue({ id: "payload-job-2" });
const payload = { jobs: { queue: queueMock } } as never;
const queue = new PayloadJobQueue(payload);
const future = new Date(Date.now() + 5000);
await queue.enqueue("blog.task", {}, { runAt: future });
expect(queueMock).toHaveBeenCalledWith(
expect.objectContaining({ waitUntil: future }),
);
});
});

View File

@@ -0,0 +1,20 @@
import type { Payload } from "payload";
import type { IJobQueue } from "./job-queue.interface";
export class PayloadJobQueue implements IJobQueue {
constructor(private readonly payload: Payload) {}
async enqueue<T>(
taskSlug: string,
input: T,
options?: { runAt?: Date },
): Promise<{ jobId: string }> {
const result = await this.payload.jobs.queue({
task: taskSlug,
input: input as never,
waitUntil: options?.runAt,
} as never);
const jobId = (result as { id: string | number }).id;
return { jobId: String(jobId) };
}
}

View File

@@ -0,0 +1,3 @@
export const CORE_SHARED_JOBS_SYMBOLS = {
IJobQueue: Symbol.for("@repo/core-shared/jobs/IJobQueue"),
} as const;

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { toIsoString } from "./date";
describe("toIsoString", () => {
it("converts a Date to ISO string", () => {
const d = new Date("2026-05-04T12:00:00.000Z");
expect(toIsoString(d)).toBe("2026-05-04T12:00:00.000Z");
});
it("passes through an existing ISO string", () => {
expect(toIsoString("2026-05-04T12:00:00.000Z")).toBe(
"2026-05-04T12:00:00.000Z",
);
});
it("returns null for null input", () => {
expect(toIsoString(null)).toBeNull();
});
it("returns null for undefined input", () => {
expect(toIsoString(undefined)).toBeNull();
});
});

View File

@@ -0,0 +1,5 @@
export function toIsoString(input: Date | string | null | undefined): string | null {
if (input === null || input === undefined) return null;
if (input instanceof Date) return input.toISOString();
return input;
}

View File

@@ -0,0 +1,28 @@
import { describe, expect, it, afterEach } from "vitest";
import { requireEnv } from "./env";
describe("requireEnv", () => {
const originalEnv = process.env.SOME_KEY;
afterEach(() => {
if (originalEnv === undefined) delete process.env.SOME_KEY;
else process.env.SOME_KEY = originalEnv;
});
it("returns the value when set", () => {
process.env.SOME_KEY = "value";
expect(requireEnv("SOME_KEY")).toBe("value");
});
it("throws when missing", () => {
delete process.env.SOME_KEY;
expect(() => requireEnv("SOME_KEY")).toThrow(
/Missing required env var: SOME_KEY/,
);
});
it("throws when empty string", () => {
process.env.SOME_KEY = "";
expect(() => requireEnv("SOME_KEY")).toThrow();
});
});

View File

@@ -0,0 +1,7 @@
export function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required env var: ${name}`);
}
return value;
}

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { isAdmin } from "./is-admin";
describe("isAdmin", () => {
it("returns true when user role is 'admin'", () => {
expect(isAdmin({ req: { user: { role: "admin" } } })).toBe(true);
});
it("returns false when user role is not 'admin'", () => {
expect(isAdmin({ req: { user: { role: "editor" } } })).toBe(false);
});
it("returns false when user has no role", () => {
expect(isAdmin({ req: { user: {} } })).toBe(false);
});
it("returns false when there is no user", () => {
expect(isAdmin({ req: {} })).toBe(false);
});
});

View File

@@ -0,0 +1,7 @@
export function isAdmin({
req,
}: {
req: { user?: { role?: string } };
}): boolean {
return req.user?.role === "admin";
}

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { cta } from "./cta";
describe("cta block", () => {
it("has slug 'cta'", () => {
expect(cta.slug).toBe("cta");
});
it("requires title, buttonLabel, and href", () => {
const fieldNames = cta.fields.map((f) => ("name" in f ? f.name : null));
expect(fieldNames).toEqual(["title", "buttonLabel", "href"]);
cta.fields.forEach((f) => {
if ("required" in f) expect(f.required).toBe(true);
});
});
});

View File

@@ -0,0 +1,10 @@
import type { Block } from "payload";
export const cta: Block = {
slug: "cta",
fields: [
{ name: "title", type: "text", required: true },
{ name: "buttonLabel", type: "text", required: true },
{ name: "href", type: "text", required: true },
],
};

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { seoFields } from "./seo-fields";
describe("seoFields", () => {
it("is a group field named 'seo'", () => {
if (seoFields.type !== "group" || !("name" in seoFields)) {
throw new Error("seoFields must be a named group");
}
expect(seoFields.name).toBe("seo");
expect(seoFields.type).toBe("group");
});
it("contains required title and optional description", () => {
if (seoFields.type !== "group") {
throw new Error("seoFields must be a group");
}
const fieldNames = seoFields.fields.map((f) =>
"name" in f ? f.name : null,
);
expect(fieldNames).toContain("title");
expect(fieldNames).toContain("description");
const titleField = seoFields.fields.find(
(f) => "name" in f && f.name === "title",
);
expect(titleField && "required" in titleField && titleField.required).toBe(
true,
);
});
});

View File

@@ -0,0 +1,10 @@
import type { Field } from "payload";
export const seoFields: Field = {
name: "seo",
type: "group",
fields: [
{ name: "title", type: "text", required: true },
{ name: "description", type: "textarea" },
],
};

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { slugField } from "./slug-field";
describe("slugField", () => {
it("returns a Payload Field with default name 'slug'", () => {
const field = slugField();
if (field.type !== "text") throw new Error("expected text field");
expect(field.name).toBe("slug");
expect(field.required).toBe(true);
expect(field.unique).toBe(true);
expect(field.index).toBe(true);
});
it("accepts a custom field name", () => {
const field = slugField("permalink");
if (field.type !== "text") throw new Error("expected text field");
expect(field.name).toBe("permalink");
});
});

View File

@@ -0,0 +1,11 @@
import type { Field } from "payload";
export function slugField(name = "slug"): Field {
return {
name,
type: "text",
required: true,
unique: true,
index: true,
};
}

View File

@@ -0,0 +1,34 @@
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { setPublishedAt } from "./set-published-at";
describe("setPublishedAt", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-04T12:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("sets publishedAt to now when status is published and publishedAt is missing", () => {
const result = setPublishedAt({ data: { status: "published" } });
expect(result?.publishedAt).toBe("2026-05-04T12:00:00.000Z");
});
it("does not overwrite an existing publishedAt", () => {
const result = setPublishedAt({
data: { status: "published", publishedAt: "2025-01-01T00:00:00.000Z" },
});
expect(result?.publishedAt).toBe("2025-01-01T00:00:00.000Z");
});
it("does not set publishedAt when status is not published", () => {
const result = setPublishedAt({ data: { status: "draft" } });
expect(result?.publishedAt).toBeUndefined();
});
it("returns data unchanged when data is missing", () => {
expect(setPublishedAt({})).toBeUndefined();
});
});

View File

@@ -0,0 +1,13 @@
export function setPublishedAt({
data,
}: {
data?: { status?: string; publishedAt?: string | null };
}) {
if (!data) return data;
if (data.status === "published" && !data.publishedAt) {
data.publishedAt = new Date().toISOString();
}
return data;
}

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { slugifyIfMissing } from "./slugify-if-missing";
describe("slugifyIfMissing", () => {
it("derives slug from title on create when slug is empty", () => {
const result = slugifyIfMissing({
data: { title: "Hello World" },
operation: "create",
});
expect(result?.slug).toBe("hello-world");
});
it("does not overwrite an existing slug", () => {
const result = slugifyIfMissing({
data: { title: "New Title", slug: "kept-slug" },
operation: "create",
});
expect(result?.slug).toBe("kept-slug");
});
it("does nothing on update", () => {
const result = slugifyIfMissing({
data: { title: "Hello World" },
operation: "update",
});
expect(result?.slug).toBeUndefined();
});
it("strips non-alphanumerics and trims hyphens", () => {
const result = slugifyIfMissing({
data: { title: " Hello, World!! 2026 " },
operation: "create",
});
expect(result?.slug).toBe("hello-world-2026");
});
it("returns data unchanged when title is missing", () => {
const result = slugifyIfMissing({
data: {},
operation: "create",
});
expect(result?.slug).toBeUndefined();
});
});

View File

@@ -0,0 +1,19 @@
export function slugifyIfMissing({
data,
operation,
}: {
data?: { title?: string; slug?: string };
operation?: string;
}) {
if (!data) return data;
if (operation !== "create") return data;
if (data.slug) return data;
if (!data.title) return data;
data.slug = data.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return data;
}

View File

@@ -0,0 +1,32 @@
export { isAdmin } from "./access/is-admin";
export { slugField } from "./fields/slug-field";
export { seoFields } from "./fields/seo-fields";
export { cta } from "./blocks/cta";
export { setPublishedAt } from "./hooks/set-published-at";
export { slugifyIfMissing } from "./hooks/slugify-if-missing";
export type {
PiiCategory,
DataProcessingPurpose,
RetentionTrigger,
RetentionAction,
FieldRetention,
FieldPii,
} from "./pii-types";
export { PAYLOAD_AUTH_PII_DEFAULTS } from "./pii-types";
export type { PurgeSchedule, CollectionRetention } from "./retention-types";
export type {
SubjectLinkKind,
SubjectLink,
CollectionSubject,
} from "./subject-linkage-types";
export {
parseDurationMs,
scheduleDelayMs,
buildPurgeHandler,
registerRetentionPurgeJobs,
} from "./retention-purge/retention-purge.job";
export type {
PayloadPurgeApi,
GetPayloadFn,
RetentionPurgeJobDeps,
} from "./retention-purge/retention-purge.job";

View File

@@ -0,0 +1,18 @@
import type { FieldPii } from "./pii-types";
import type { CollectionRetention } from "./retention-types";
import type { CollectionSubject } from "./subject-linkage-types";
declare module "payload" {
// FieldBase.custom is typed as FieldCustom (interface extending Record<string, any>).
// Augmenting it makes pii available on every field type.
interface FieldCustom {
pii?: FieldPii;
}
// CollectionConfig.custom is typed as CollectionCustom (interface extending Record<string, any>).
interface CollectionCustom {
retention?: CollectionRetention;
authPii?: Record<string, FieldPii | null>;
subject?: CollectionSubject | CollectionSubject[];
}
}

View File

@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import { PAYLOAD_AUTH_PII_DEFAULTS, type FieldPii } from "./pii-types";
const CREDENTIAL_FIELDS = [
"password",
"salt",
"hash",
"resetPasswordToken",
"resetPasswordExpiration",
"loginAttempts",
"lockUntil",
"apiKey",
"apiKeyIndex",
] as const;
const DSR_MANAGED_FIELDS = ["processingRestrictedAt", "consentState"] as const;
describe("FieldPii type safety", () => {
it("rejects FieldPii missing required fields at compile time", () => {
// @ts-expect-error — 'purpose', 'exportable', 'restrictable' are required
const _missingRequired: FieldPii = { category: "contact-email" };
void _missingRequired;
});
it("rejects FieldPii missing exportable at compile time", () => {
// @ts-expect-error — 'exportable' is required
const _missingExportable: FieldPii = {
category: "contact-email",
purpose: ["account-authentication"],
restrictable: true,
};
void _missingExportable;
});
});
describe("FieldPii valid shapes", () => {
it("accepts a minimal valid FieldPii", () => {
const valid: FieldPii = {
category: "contact-email",
purpose: ["account-authentication"],
exportable: true,
restrictable: false,
};
expect(valid.category).toBe("contact-email");
expect(valid.retention).toBeUndefined();
});
it("accepts FieldPii with optional retention", () => {
const withRetention: FieldPii = {
category: "network-ip",
purpose: ["analytics-aggregation"],
exportable: false,
restrictable: false,
retention: {
duration: "P30D",
trigger: "from-creation",
action: "hard-delete",
},
};
expect(withRetention.retention?.duration).toBe("P30D");
expect(withRetention.retention?.trigger).toBe("from-creation");
expect(withRetention.retention?.action).toBe("hard-delete");
});
it("accepts a custom PiiCategory string via extension escape hatch", () => {
const extended: FieldPii = {
category: "custom-biometric-data",
purpose: ["legal-compliance"],
exportable: false,
restrictable: true,
};
expect(extended.category).toBe("custom-biometric-data");
});
});
describe("PAYLOAD_AUTH_PII_DEFAULTS", () => {
it("sets all credential fields to null", () => {
for (const field of CREDENTIAL_FIELDS) {
expect(PAYLOAD_AUTH_PII_DEFAULTS[field]).toBeNull();
}
});
it("maps email to a non-null FieldPii with correct shape", () => {
const emailPii = PAYLOAD_AUTH_PII_DEFAULTS["email"];
expect(emailPii).not.toBeNull();
expect(emailPii?.category).toBe("contact-email");
expect(emailPii?.purpose).toContain("account-authentication");
expect(emailPii?.purpose).toContain("transactional-notifications");
expect(emailPii?.exportable).toBe(true);
expect(emailPii?.restrictable).toBe(true);
});
it("has exactly 12 keys: email, 9 credential fields, and 2 DSR-managed fields", () => {
expect(Object.keys(PAYLOAD_AUTH_PII_DEFAULTS)).toHaveLength(12);
});
it("sets DSR-managed fields to null", () => {
for (const field of DSR_MANAGED_FIELDS) {
expect(PAYLOAD_AUTH_PII_DEFAULTS[field]).toBeNull();
}
});
it("email has no retention override (falls back to collection-level)", () => {
const emailPii = PAYLOAD_AUTH_PII_DEFAULTS["email"];
expect(emailPii?.retention).toBeUndefined();
});
});

View File

@@ -0,0 +1,66 @@
export type PiiCategory =
| "contact-email"
| "contact-phone"
| "contact-address"
| "identification-name"
| "identification-username"
| "identification-government-id"
| "auth-credential"
| "auth-token"
| "network-ip"
| "network-user-agent"
| "financial-info"
| "behavioral-engagement"
| "document-content"
| "derived-metric"
| (string & Record<never, never>);
export type DataProcessingPurpose =
| "account-authentication"
| "transactional-notifications"
| "marketing-communications"
| "analytics-aggregation"
| "legal-compliance"
| "service-delivery"
| (string & Record<never, never>);
export type RetentionTrigger =
| "from-creation"
| "from-last-access"
| "after-deletion";
export type RetentionAction = "hard-delete" | "pseudonymize";
export type FieldRetention = {
duration: string;
trigger: RetentionTrigger;
action: RetentionAction;
};
export type FieldPii = {
category: PiiCategory;
purpose: DataProcessingPurpose[];
retention?: FieldRetention;
exportable: boolean;
restrictable: boolean;
};
export const PAYLOAD_AUTH_PII_DEFAULTS: Record<string, FieldPii | null> = {
email: {
category: "contact-email",
purpose: ["account-authentication", "transactional-notifications"],
exportable: true,
restrictable: true,
},
password: null,
salt: null,
hash: null,
resetPasswordToken: null,
resetPasswordExpiration: null,
loginAttempts: null,
lockUntil: null,
apiKey: null,
apiKeyIndex: null,
processingRestrictedAt: null,
consentState: null,
};

View File

@@ -0,0 +1,608 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { SanitizedConfig } from "payload";
import type { IJobQueue } from "@/jobs/job-queue.interface";
import type { AuditLogProtocol } from "@/di/bind-protocols";
import {
parseDurationMs,
scheduleDelayMs,
buildPurgeHandler,
registerRetentionPurgeJobs,
type PayloadPurgeApi,
type RetentionPurgeJobDeps,
} from "./retention-purge.job";
// ---- test helpers ----
type MockCollection = {
slug: string;
custom?: { retention?: Record<string, unknown> };
fields?: Array<{ name?: string; custom?: { pii?: unknown } }>;
};
function makeConfig(collections: MockCollection[]): SanitizedConfig {
return { collections } as unknown as SanitizedConfig;
}
function makeQueue() {
const enqueue = vi.fn().mockResolvedValue({ jobId: "job-1" });
const queue = { enqueue } as unknown as IJobQueue;
return { queue, enqueue };
}
function makePayloadApi(
docs: Array<Record<string, unknown>> = [],
): PayloadPurgeApi {
return {
find: vi.fn().mockResolvedValue({ docs }),
update: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
};
}
function makeAuditLog(): {
auditLog: AuditLogProtocol;
record: ReturnType<typeof vi.fn>;
} {
const record = vi.fn().mockResolvedValue(undefined);
return { auditLog: { record } as AuditLogProtocol, record };
}
// ---- parseDurationMs ----
describe("parseDurationMs", () => {
it("parses years: P2Y → 2 × 365 days", () => {
expect(parseDurationMs("P2Y")).toBe(2 * 365 * 86_400_000);
});
it("parses months: P1M → 30 days", () => {
expect(parseDurationMs("P1M")).toBe(30 * 86_400_000);
});
it("parses weeks: P1W → 7 days", () => {
expect(parseDurationMs("P1W")).toBe(7 * 86_400_000);
});
it("parses days: P30D → 30 days", () => {
expect(parseDurationMs("P30D")).toBe(30 * 86_400_000);
});
it("combines components: P1Y2M3D", () => {
expect(parseDurationMs("P1Y2M3D")).toBe(
365 * 86_400_000 + 60 * 86_400_000 + 3 * 86_400_000,
);
});
it("returns 0 for P0D", () => {
expect(parseDurationMs("P0D")).toBe(0);
});
it("returns 0 for unrecognised strings", () => {
expect(parseDurationMs("invalid")).toBe(0);
expect(parseDurationMs("")).toBe(0);
expect(parseDurationMs("PT2H")).toBe(0);
});
});
// ---- scheduleDelayMs ----
describe("scheduleDelayMs", () => {
it("returns 1 day for 'daily'", () => {
expect(scheduleDelayMs("daily")).toBe(86_400_000);
});
it("returns 7 days for 'weekly'", () => {
expect(scheduleDelayMs("weekly")).toBe(7 * 86_400_000);
});
it("returns 30 days for 'monthly'", () => {
expect(scheduleDelayMs("monthly")).toBe(30 * 86_400_000);
});
it("falls back to 1 day for cron-style strings", () => {
expect(scheduleDelayMs("0 3 * * 0")).toBe(86_400_000);
});
});
// ---- registerRetentionPurgeJobs ----
describe("registerRetentionPurgeJobs", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("enqueues one job per collection with a purgeSchedule", async () => {
const { queue, enqueue } = makeQueue();
const config = makeConfig([
{ slug: "users", custom: { retention: { purgeSchedule: "daily" } } },
{ slug: "articles", custom: { retention: { purgeSchedule: "weekly" } } },
]);
await registerRetentionPurgeJobs({ queue, config, getPayload: vi.fn() });
expect(enqueue).toHaveBeenCalledTimes(2);
});
it("skips collections without a retention config", async () => {
const { queue, enqueue } = makeQueue();
const config = makeConfig([{ slug: "media" }]);
await registerRetentionPurgeJobs({ queue, config, getPayload: vi.fn() });
expect(enqueue).not.toHaveBeenCalled();
});
it("uses the correct taskSlug and runAt for each schedule type", async () => {
const { queue, enqueue } = makeQueue();
const config = makeConfig([
{ slug: "users", custom: { retention: { purgeSchedule: "weekly" } } },
]);
await registerRetentionPurgeJobs({ queue, config, getPayload: vi.fn() });
expect(enqueue).toHaveBeenCalledWith(
"retention-purge--users",
{},
{ runAt: new Date("2026-01-08T00:00:00.000Z") },
);
});
it("schedules daily purge 1 day from now", async () => {
const { queue, enqueue } = makeQueue();
const config = makeConfig([
{ slug: "sessions", custom: { retention: { purgeSchedule: "daily" } } },
]);
await registerRetentionPurgeJobs({ queue, config, getPayload: vi.fn() });
expect(enqueue).toHaveBeenCalledWith(
"retention-purge--sessions",
{},
{ runAt: new Date("2026-01-02T00:00:00.000Z") },
);
});
});
// ---- buildPurgeHandler — input validation ----
describe("buildPurgeHandler — input validation", () => {
it("throws when the collection slug is not found in the config", () => {
const { queue } = makeQueue();
const config = makeConfig([]);
expect(() =>
buildPurgeHandler("missing", { queue, config, getPayload: vi.fn() }),
).toThrow("collection not found: missing");
});
it("throws when the collection has no retention config", () => {
const { queue } = makeQueue();
const config = makeConfig([{ slug: "media" }]);
expect(() =>
buildPurgeHandler("media", { queue, config, getPayload: vi.fn() }),
).toThrow("no retention config on collection: media");
});
});
// ---- buildPurgeHandler — hard-delete branch ----
describe("buildPurgeHandler — hard-delete", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("queries by createdAt for from-creation trigger", async () => {
const { queue } = makeQueue();
const payload = makePayloadApi([]);
const config = makeConfig([
{
slug: "users",
custom: {
retention: {
purgeSchedule: "daily",
activeRetention: { duration: "P2Y", trigger: "from-creation" },
postDeletion: {
action: "hard-delete",
duration: "P30D",
trigger: "after-deletion",
},
},
},
fields: [],
},
]);
const deps: RetentionPurgeJobDeps = {
queue,
config,
getPayload: vi.fn().mockResolvedValue(payload),
};
await buildPurgeHandler("users", deps)();
expect(payload.find).toHaveBeenCalledWith(
expect.objectContaining({
where: {
createdAt: {
less_than: new Date(
Date.now() - parseDurationMs("P2Y"),
).toISOString(),
},
},
}),
);
});
it("queries by updatedAt for from-last-access trigger", async () => {
const { queue } = makeQueue();
const payload = makePayloadApi([]);
const config = makeConfig([
{
slug: "sessions",
custom: {
retention: {
purgeSchedule: "daily",
activeRetention: { duration: "P30D", trigger: "from-last-access" },
postDeletion: {
action: "hard-delete",
duration: "P0D",
trigger: "after-deletion",
},
},
},
fields: [],
},
]);
const deps: RetentionPurgeJobDeps = {
queue,
config,
getPayload: vi.fn().mockResolvedValue(payload),
};
await buildPurgeHandler("sessions", deps)();
expect(payload.find).toHaveBeenCalledWith(
expect.objectContaining({
where: { updatedAt: { less_than: expect.any(String) } },
}),
);
expect(payload.find).not.toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ createdAt: expect.anything() }),
}),
);
});
it("deletes each returned row", async () => {
const { queue } = makeQueue();
const payload = makePayloadApi([{ id: "row-1" }, { id: "row-2" }]);
const config = makeConfig([
{
slug: "users",
custom: {
retention: {
purgeSchedule: "daily",
activeRetention: { duration: "P1Y", trigger: "from-creation" },
postDeletion: {
action: "hard-delete",
duration: "P0D",
trigger: "after-deletion",
},
},
},
fields: [],
},
]);
const deps: RetentionPurgeJobDeps = {
queue,
config,
getPayload: vi.fn().mockResolvedValue(payload),
};
await buildPurgeHandler("users", deps)();
expect(payload.delete).toHaveBeenCalledTimes(2);
expect(payload.delete).toHaveBeenCalledWith({
collection: "users",
id: "row-1",
overrideAccess: true,
});
expect(payload.delete).toHaveBeenCalledWith({
collection: "users",
id: "row-2",
overrideAccess: true,
});
expect(payload.update).not.toHaveBeenCalled();
});
it("re-enqueues itself for the next purge cycle", async () => {
const { queue, enqueue } = makeQueue();
const payload = makePayloadApi([]);
const config = makeConfig([
{
slug: "users",
custom: {
retention: {
purgeSchedule: "daily",
activeRetention: { duration: "P1Y", trigger: "from-creation" },
postDeletion: {
action: "hard-delete",
duration: "P0D",
trigger: "after-deletion",
},
},
},
fields: [],
},
]);
const deps: RetentionPurgeJobDeps = {
queue,
config,
getPayload: vi.fn().mockResolvedValue(payload),
};
await buildPurgeHandler("users", deps)();
expect(enqueue).toHaveBeenCalledOnce();
expect(enqueue).toHaveBeenCalledWith(
"retention-purge--users",
{},
{ runAt: new Date("2026-01-02T00:00:00.000Z") },
);
});
it("defaults to hard-delete when postDeletion is not declared", async () => {
const { queue } = makeQueue();
const payload = makePayloadApi([{ id: "row-x" }]);
const config = makeConfig([
{
slug: "logs",
custom: {
retention: {
purgeSchedule: "daily",
activeRetention: { duration: "P1Y", trigger: "from-creation" },
},
},
fields: [],
},
]);
const deps: RetentionPurgeJobDeps = {
queue,
config,
getPayload: vi.fn().mockResolvedValue(payload),
};
await buildPurgeHandler("logs", deps)();
expect(payload.delete).toHaveBeenCalledWith({
collection: "logs",
id: "row-x",
overrideAccess: true,
});
expect(payload.update).not.toHaveBeenCalled();
});
});
// ---- buildPurgeHandler — pseudonymize branch ----
describe("buildPurgeHandler — pseudonymize", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("nulls only PII-annotated fields for each matched row", async () => {
const { queue } = makeQueue();
const payload = makePayloadApi([{ id: "row-2" }]);
const config = makeConfig([
{
slug: "contacts",
custom: {
retention: {
purgeSchedule: "monthly",
activeRetention: { duration: "P1Y", trigger: "from-creation" },
postDeletion: {
action: "pseudonymize",
duration: "P30D",
trigger: "after-deletion",
},
},
},
fields: [
{ name: "email", custom: { pii: { category: "contact-email" } } },
{ name: "phone", custom: { pii: { category: "contact-phone" } } },
{ name: "status" }, // no pii — must NOT be nulled
],
},
]);
const deps: RetentionPurgeJobDeps = {
queue,
config,
getPayload: vi.fn().mockResolvedValue(payload),
};
await buildPurgeHandler("contacts", deps)();
expect(payload.update).toHaveBeenCalledWith({
collection: "contacts",
id: "row-2",
data: { email: null, phone: null },
overrideAccess: true,
});
expect(payload.delete).not.toHaveBeenCalled();
});
it("skips update when no PII fields are declared on the collection", async () => {
const { queue } = makeQueue();
const payload = makePayloadApi([{ id: "row-3" }]);
const config = makeConfig([
{
slug: "tags",
custom: {
retention: {
purgeSchedule: "daily",
activeRetention: { duration: "P1Y", trigger: "from-creation" },
postDeletion: {
action: "pseudonymize",
duration: "P0D",
trigger: "after-deletion",
},
},
},
fields: [
{ name: "label" }, // no pii
],
},
]);
const deps: RetentionPurgeJobDeps = {
queue,
config,
getPayload: vi.fn().mockResolvedValue(payload),
};
await buildPurgeHandler("tags", deps)();
expect(payload.update).not.toHaveBeenCalled();
expect(payload.delete).not.toHaveBeenCalled();
});
});
// ---- buildPurgeHandler — audit emission ----
describe("buildPurgeHandler — audit emission", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("emits one audit record per processed row", async () => {
const { queue } = makeQueue();
const payload = makePayloadApi([{ id: "a" }, { id: "b" }]);
const { auditLog, record } = makeAuditLog();
const config = makeConfig([
{
slug: "users",
custom: {
retention: {
purgeSchedule: "daily",
activeRetention: { duration: "P1Y", trigger: "from-creation" },
postDeletion: {
action: "hard-delete",
duration: "P0D",
trigger: "after-deletion",
},
},
},
fields: [],
},
]);
const deps: RetentionPurgeJobDeps = {
queue,
config,
getPayload: vi.fn().mockResolvedValue(payload),
auditLog,
};
await buildPurgeHandler("users", deps)();
expect(record).toHaveBeenCalledTimes(2);
expect(record).toHaveBeenCalledWith(
expect.objectContaining({
actorId: "system",
actorType: "system",
action: "DELETE",
reason: "retention-policy",
outcome: "success",
from: { ipTruncated: "system", userAgent: "background-job" },
}),
);
});
it("includes resource type and id in the audit entry", async () => {
const { queue } = makeQueue();
const payload = makePayloadApi([{ id: "row-42" }]);
const { auditLog, record } = makeAuditLog();
const config = makeConfig([
{
slug: "orders",
custom: {
retention: {
purgeSchedule: "daily",
activeRetention: { duration: "P1Y", trigger: "from-creation" },
postDeletion: {
action: "hard-delete",
duration: "P0D",
trigger: "after-deletion",
},
},
},
fields: [],
},
]);
const deps: RetentionPurgeJobDeps = {
queue,
config,
getPayload: vi.fn().mockResolvedValue(payload),
auditLog,
};
await buildPurgeHandler("orders", deps)();
expect(record).toHaveBeenCalledWith(
expect.objectContaining({
resource: { type: "orders", id: "row-42" },
}),
);
});
it("gracefully skips audit emission when auditLog is not provided", async () => {
const { queue } = makeQueue();
const payload = makePayloadApi([{ id: "x" }]);
const config = makeConfig([
{
slug: "users",
custom: {
retention: {
purgeSchedule: "daily",
activeRetention: { duration: "P1Y", trigger: "from-creation" },
postDeletion: {
action: "hard-delete",
duration: "P0D",
trigger: "after-deletion",
},
},
},
fields: [],
},
]);
const deps: RetentionPurgeJobDeps = {
queue,
config,
getPayload: vi.fn().mockResolvedValue(payload),
};
await expect(buildPurgeHandler("users", deps)()).resolves.toBeUndefined();
});
it("skips all processing and audit when activeRetention is not declared", async () => {
const { queue, enqueue } = makeQueue();
const payload = makePayloadApi([{ id: "y" }]);
const { auditLog, record } = makeAuditLog();
const config = makeConfig([
{
slug: "logs",
custom: { retention: { purgeSchedule: "daily" } },
fields: [],
},
]);
const deps: RetentionPurgeJobDeps = {
queue,
config,
getPayload: vi.fn().mockResolvedValue(payload),
auditLog,
};
await buildPurgeHandler("logs", deps)();
expect(payload.find).not.toHaveBeenCalled();
expect(record).not.toHaveBeenCalled();
// Still re-enqueues for the next cycle
expect(enqueue).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,200 @@
import type { SanitizedConfig } from "payload";
import type { IJobQueue } from "../../jobs/job-queue.interface";
import type { AuditLogProtocol } from "../../di/bind-protocols";
/**
* Minimal Payload API surface needed by the retention purge job.
* Injected via getPayload for testability.
*/
export type PayloadPurgeApi = {
find(args: {
collection: string;
where: Record<string, unknown>;
limit: number;
overrideAccess: true;
}): Promise<{ docs: Array<Record<string, unknown>> }>;
update(args: {
collection: string;
id: string | number;
data: Record<string, unknown>;
overrideAccess: true;
}): Promise<unknown>;
delete(args: {
collection: string;
id: string | number;
overrideAccess: true;
}): Promise<unknown>;
};
export type GetPayloadFn = (args: {
config: SanitizedConfig;
}) => Promise<PayloadPurgeApi>;
export type RetentionPurgeJobDeps = {
queue: IJobQueue;
config: SanitizedConfig;
getPayload: GetPayloadFn;
auditLog?: AuditLogProtocol;
};
const MS_PER_DAY = 86_400_000;
/**
* Parse a subset of ISO 8601 duration notation (date components: Y, M, W, D)
* to milliseconds. Returns 0 for unrecognised patterns.
* Approximations: 1 year = 365 days, 1 month = 30 days.
*/
export function parseDurationMs(iso: string): number {
const match = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?$/.exec(iso);
if (!match) return 0;
const years = parseInt(match[1] ?? "0", 10);
const months = parseInt(match[2] ?? "0", 10);
const weeks = parseInt(match[3] ?? "0", 10);
const days = parseInt(match[4] ?? "0", 10);
return (
years * 365 * MS_PER_DAY +
months * 30 * MS_PER_DAY +
weeks * 7 * MS_PER_DAY +
days * MS_PER_DAY
);
}
/**
* Convert a PurgeSchedule value to the delay in ms before the next run.
* Cron-style strings fall back to daily cadence; the Payload scheduler handles
* actual cron-aligned firing.
*/
export function scheduleDelayMs(schedule: string): number {
if (schedule === "weekly") return 7 * MS_PER_DAY;
if (schedule === "monthly") return 30 * MS_PER_DAY;
return MS_PER_DAY; // "daily" and cron fallback
}
/**
* Build the purge handler for a single collection. The returned async function
* is intended to be registered as a Payload job task handler.
*
* Per run:
* 1. Query rows past their activeRetention period.
* 2. Apply postDeletion.action (pseudonymize | hard-delete).
* 3. Emit one audit entry per processed row (skipped when auditLog is absent).
* 4. Re-enqueue itself for the next purge cycle.
*
* `from-last-access` uses updatedAt as a proxy; a dedicated lastAccessedAt hook
* is deferred to Q2 per the PRD.
*/
export function buildPurgeHandler(
collectionSlug: string,
deps: RetentionPurgeJobDeps,
): () => Promise<void> {
const { queue, config, getPayload, auditLog } = deps;
const collection = config.collections.find((c) => c.slug === collectionSlug);
if (!collection) {
throw new Error(`retention-purge: collection not found: ${collectionSlug}`);
}
const retention = collection.custom?.retention;
if (!retention) {
throw new Error(
`retention-purge: no retention config on collection: ${collectionSlug}`,
);
}
const taskSlug = `retention-purge--${collectionSlug}`;
return async () => {
const payload = await getPayload({ config });
const now = Date.now();
if (retention.activeRetention) {
const { duration, trigger } = retention.activeRetention;
const retentionMs = parseDurationMs(duration);
const cutoff = new Date(now - retentionMs).toISOString();
const dateField = trigger === "from-creation" ? "createdAt" : "updatedAt";
const { docs } = await payload.find({
collection: collectionSlug,
where: { [dateField]: { less_than: cutoff } },
limit: 1000,
overrideAccess: true,
});
const action = retention.postDeletion?.action ?? "hard-delete";
for (const doc of docs) {
const id = doc["id"] as string | number;
if (action === "pseudonymize") {
const piiFields: Record<string, null> = {};
for (const field of collection.fields) {
const f = field as { name?: string; custom?: { pii?: unknown } };
if (f.name && f.custom?.pii) {
piiFields[f.name] = null;
}
}
if (Object.keys(piiFields).length > 0) {
await payload.update({
collection: collectionSlug,
id,
data: piiFields,
overrideAccess: true,
});
}
} else {
await payload.delete({
collection: collectionSlug,
id,
overrideAccess: true,
});
}
if (auditLog) {
await auditLog.record({
actorId: "system",
actorType: "system",
actorRoles: [],
action: "DELETE",
resource: { type: collectionSlug, id: String(id) },
at: new Date(),
scope: {
feature: "core-shared",
environment: process.env["NODE_ENV"] ?? "production",
tenant: "default",
},
reason: "retention-policy",
from: { ipTruncated: "system", userAgent: "background-job" },
containsPii: false,
outcome: "success",
});
}
}
}
const delay = scheduleDelayMs(retention.purgeSchedule);
await queue.enqueue(taskSlug, {}, { runAt: new Date(now + delay) });
};
}
/**
* Walk all Payload collections that declare `custom.retention.purgeSchedule`
* and schedule the first purge run for each via the provided IJobQueue.
*
* Call once at app startup (inside bindAll or equivalent). Idempotent per
* queue implementation — duplicate enqueues are the queue's responsibility.
*/
export async function registerRetentionPurgeJobs(
deps: RetentionPurgeJobDeps,
): Promise<void> {
const { queue, config } = deps;
const now = Date.now();
for (const collection of config.collections) {
const retention = collection.custom?.retention;
if (!retention?.purgeSchedule) continue;
const taskSlug = `retention-purge--${collection.slug}`;
const delay = scheduleDelayMs(retention.purgeSchedule);
await queue.enqueue(taskSlug, {}, { runAt: new Date(now + delay) });
}
}

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