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>
293 lines
10 KiB
TypeScript
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 };
|
|
}
|