Files
agentic-dev/apps/web-next/src/server/bind-production.ts
Danijel Martinek a2be5d5488 feat(core-cms): register audit-logs + wire GDPR audit erasure
The audit-logs collection was never registered (record() would throw),
bindAudit/createAuditErasureHook were unused, and DSR cascade-hard never
touched the audit trail (audit finding A6). core-cms now registers the
collection and wires a req-scoped afterDelete erasure hook on users;
bindAllProduction binds the audit log into consent/DSR; cascade-hard
pseudonymizes the subject's audit entries; the action select accepts
the full AuditAction enum so consent/DSR entries pass validation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:46 +02:00

293 lines
10 KiB
TypeScript

// apps/web-next/src/server/bind-production.ts
// SERVER-ONLY: this module imports Payload config and must never be bundled into the browser.
import "reflect-metadata";
import { Container } from "inversify";
import { getPayload } from "payload";
import config from "@repo/core-cms";
import {
bindNoopInstrumentation,
bindOtelInstrumentation,
type ITracer,
type ILogger,
} from "@repo/core-shared/instrumentation";
import type { BindProductionContext, BindContext } from "@repo/core-shared/di";
import {
InMemoryJobQueue,
PayloadJobQueue,
type IJobQueue,
} from "@repo/core-shared/jobs";
import {
InMemoryRateLimit,
NoopRateLimit,
type RateLimitBudget,
} from "@repo/core-shared/rate-limit";
import {
registerRetentionPurgeJobs,
type GetPayloadFn,
} from "@repo/core-shared/payload";
import { bindAudit, type IAuditLog } from "@repo/core-audit";
import {
bindProductionConsent,
bindDevSeedConsent,
type ConsentFactory,
} from "@repo/core-consent";
import {
bindProductionDsr,
bindDevSeedDsr,
type DsrBinding,
} from "@repo/core-dsr";
import { authManifest } from "@repo/auth";
import { bindProductionBlog } from "@repo/blog/di/bind-production";
import { bindProductionAuth } from "@repo/auth/di/bind-production";
import { bindProductionMarketingPages } from "@repo/marketing-pages/di/bind-production";
import { bindProductionNavigation } from "@repo/navigation/di/bind-production";
import { bindProductionMedia } from "@repo/media/di/bind-production";
import { bindDevSeedBlog } from "@repo/blog/di/bind-dev-seed";
import { bindDevSeedAuth } from "@repo/auth/di/bind-dev-seed";
import { bindDevSeedMarketingPages } from "@repo/marketing-pages/di/bind-dev-seed";
import { bindDevSeedNavigation } from "@repo/navigation/di/bind-dev-seed";
import { bindDevSeedMedia } from "@repo/media/di/bind-dev-seed";
let bindPromise: Promise<void> | null = null;
// Shared container holds TRACER + LOGGER bindings; per-feature containers
// receive references via parameter passing. This separates the instrumentation
// container (one) from feature containers (per-feature, ADR-008).
const sharedContainer = new Container();
let resolvedTracer: ITracer | null = null;
let resolvedLogger: ILogger | null = null;
let resolvedQueue: IJobQueue | null = null;
/**
* Compliance bindings constructed once per boot (audit finding A11): the
* consent factory and DSR binding that the tRPC createContext threads into
* every request so the mounted consent/dsr routers are live. `auditLog` is
* present only on the production path.
*/
export type ComplianceBindings = {
consentFactory: ConsentFactory;
dsrBinding: DsrBinding;
auditLog?: IAuditLog;
};
let complianceBindings: ComplianceBindings | null = null;
export type BindingMode = "production" | "dev-seed";
/** Env → binding mode, mirroring bindAll()'s resolution rules. */
export function resolveBindingMode(): BindingMode {
if (process.env.USE_DEV_SEED === "false") return "production";
if (process.env.USE_DEV_SEED === "true") return "dev-seed";
if (process.env.NODE_ENV === "production") return "production";
return "dev-seed";
}
/**
* Resolve the boot-time compliance bindings, running bindAll() first if
* needed. Called by the app's tRPC createContext on every request (cheap
* after the first call — bindAll is memoized).
*/
export async function getComplianceBindings(): Promise<ComplianceBindings> {
await bindAll();
if (!complianceBindings) {
throw new Error(
"compliance bindings missing after bindAll() — binder did not construct them",
);
}
return complianceBindings;
}
/** Rule 0: pick instrumentation backend from DSN env (orthogonal to repo mode). */
function resolveInstrumentation(): { tracer: ITracer; logger: ILogger } {
if (resolvedTracer && resolvedLogger) {
return { tracer: resolvedTracer, logger: resolvedLogger };
}
const dsn = process.env.WEB_NEXT_SENTRY_DSN;
const result = dsn
? bindOtelInstrumentation(sharedContainer, { dsn, app: "web-next" })
: bindNoopInstrumentation(sharedContainer);
resolvedTracer = result.tracer;
resolvedLogger = result.logger;
return result;
}
/**
* Production-mode job queue: backed by Payload's job system so ad-hoc jobs go
* through `PayloadJobQueue.enqueue`. Cached after first resolution.
*
* Note: @repo/core-events (IEventBus) is optional — scaffold via
* `pnpm turbo gen core-package events` to re-enable cross-feature event fanout.
*/
async function resolveJobsProduction(): Promise<{ queue: IJobQueue }> {
if (resolvedQueue) return { queue: resolvedQueue };
const resolvedConfig = await config;
const payload = await getPayload({ config: resolvedConfig });
const queue = new PayloadJobQueue(payload);
resolvedQueue = queue;
return { queue };
}
/**
* Dev-seed mode: in-process job queue. Per-feature binders register their job
* handlers via `queue.register(slug, handler)` at bind time so dev/test
* exercises the enqueue path without booting Payload.
*/
function resolveJobsDevSeed(): { queue: IJobQueue } {
if (resolvedQueue) return { queue: resolvedQueue };
const queue = new InMemoryJobQueue();
resolvedQueue = queue;
return { queue };
}
/**
* Collect every per-use-case rate-limit budget declared in the feature
* manifests. Budgets are the manifests' source of truth (auth declares
* signIn ip/account budgets today); add further manifests here as features
* declare `rateLimit` entries.
*/
function collectManifestRateLimitBudgets(): RateLimitBudget[] {
return Object.values(authManifest.useCases).flatMap((useCase) =>
"rateLimit" in useCase && useCase.rateLimit ? [...useCase.rateLimit] : [],
);
}
/**
* Production path: swap each feature's mock repository binding for the real
* Payload-backed one. Constructs `new XRepository(config, tracer, logger)` per
* feature via `bindProductionX` exports.
*/
export async function bindAllProduction(): Promise<void> {
const { tracer, logger } = resolveInstrumentation(); // Rule 0
const { queue } = await resolveJobsProduction();
const resolvedConfig = await config;
// Compliance cores (A6/A11): the audit log fans into the Payload
// `audit-logs` collection + stdout; consent + DSR bindings share it so
// every grant/withdraw/export/delete leaves an audit trail.
const { auditLog } = bindAudit(sharedContainer, {
payloadConfig: resolvedConfig,
});
const { consentFactory } = bindProductionConsent({
config: resolvedConfig,
auditLog,
});
const dsrBinding = bindProductionDsr({
config: resolvedConfig,
auditLog,
// cascade-hard deletions pseudonymize the subject's audit trail (A6)
auditErasure: auditLog,
});
complianceBindings = { consentFactory, dsrBinding, auditLog };
const ctx: BindProductionContext = {
config: resolvedConfig,
tracer,
logger,
queue,
auditLog,
// Enables the anonymous→authenticated consent migration inside the auth
// sign-up use case (audit finding A12).
consentFactory,
// Real limiter in production (audit finding A4/B3): budgets come from the
// feature manifests, so manifest edits change enforcement without touching
// this file. In-memory ⇒ per-process counters; multi-instance deployments
// need a shared backend behind IRateLimit.
rateLimit: new InMemoryRateLimit(collectManifestRateLimitBudgets()),
};
bindProductionAuth(ctx);
bindProductionBlog(ctx);
bindProductionMarketingPages(ctx);
bindProductionNavigation(ctx);
bindProductionMedia(ctx);
// Kick off the retention purge cycle (audit finding A3): enqueue the first
// `retention-purge--<slug>` job for every collection declaring a
// custom.retention.purgeSchedule. The task definitions live in the Payload
// config (core-cms jobs.tasks); each run re-enqueues the next cycle.
await registerRetentionPurgeJobs({
queue,
config: resolvedConfig,
getPayload: getPayload as unknown as GetPayloadFn,
auditLog,
});
}
/**
* Dev-seed path: keep each feature's MockXRepository in place but populate it
* with realistic seed data so the running app shows non-empty UI without
* Payload booted. Mutually exclusive with `bindAllProduction()`.
*/
export async function bindAllDevSeed(): Promise<void> {
const { tracer, logger } = resolveInstrumentation(); // Rule 0
const { queue } = resolveJobsDevSeed();
// In-memory compliance bindings so the mounted consent/dsr routers work
// without Payload booted (A11). No audit sink in dev seed.
const { consentFactory } = bindDevSeedConsent();
const dsrBinding = bindDevSeedDsr();
complianceBindings = { consentFactory, dsrBinding };
const ctx: BindContext = {
tracer,
logger,
queue,
consentFactory,
// Dev seed intentionally keeps the no-op limiter so local iteration and
// seeded demos are never throttled; production binds InMemoryRateLimit.
rateLimit: new NoopRateLimit(),
};
await bindDevSeedAuth(ctx);
await bindDevSeedBlog(ctx);
await bindDevSeedMarketingPages(ctx);
await bindDevSeedNavigation(ctx);
await bindDevSeedMedia(ctx);
}
/**
* Boot dispatcher: pick the binder based on the environment.
*
* Resolution order (first match wins):
*
* Rule 0 (always): instrumentation (Noop vs Sentry) from WEB_NEXT_SENTRY_DSN
* presence — runs inside both bindAllProduction and
* bindAllDevSeed via resolveInstrumentation().
* Rule 1: USE_DEV_SEED === "true" → dev seed (explicit override)
* Rule 2: NODE_ENV === "production" → real Payload via bindAllProduction
* Rule 3: otherwise → dev seed (developer-friendly default)
*
* When @repo/core-events is scaffolded via `pnpm turbo gen core-package events`,
* extend to construct IEventBus and pass it via ctx.bus to per-feature binders.
* When @repo/core-realtime is scaffolded, extend to accept realtime deps
* (IRealtimeBroadcaster, IRealtimeHandlerRegistry) and pass them through.
*/
export function bindAll(): Promise<void> {
if (bindPromise) return bindPromise;
bindPromise =
resolveBindingMode() === "production"
? bindAllProduction()
: bindAllDevSeed();
return bindPromise;
}
/** Test-only resets — not exported via package. Used by bind-production.test.ts. */
export function __resetBindStateForTests(): void {
bindPromise = null;
resolvedTracer = null;
resolvedLogger = null;
resolvedQueue = null;
complianceBindings = null;
}
/** Test-only accessor for resolved instrumentation. */
export function __getInstrumentationForTests(): {
tracer: ITracer | null;
logger: ILogger | null;
} {
return { tracer: resolvedTracer, logger: resolvedLogger };
}