// 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 | 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 { 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 { 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--` 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 { 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 { 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 }; }