@cosmicdrift/kumiko-server-runtime 0.386.0 → 0.388.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +21 -57
  2. package/dist/boot/boot-crypto.js +4 -5
  3. package/dist/boot/shared-boot-steps.d.ts +43 -0
  4. package/dist/boot/shared-boot-steps.js +154 -0
  5. package/dist/boot/wait-for-database.d.ts +3 -0
  6. package/dist/boot/wait-for-database.js +3 -3
  7. package/dist/boot/worker-boot-core.js +20 -40
  8. package/dist/boot-options/billing-option.d.ts +18 -9
  9. package/dist/boot-options/billing-option.js +44 -18
  10. package/dist/boot-options/file-storage-option.js +6 -6
  11. package/dist/boot-options/option-env-schema.js +11 -2
  12. package/dist/boot-options/resolve-boot-options.js +9 -3
  13. package/dist/boot-options/stack-boot-requirements.js +1 -1
  14. package/dist/build-prod-bundle.d.ts +16 -15
  15. package/dist/build-prod-bundle.js +106 -108
  16. package/dist/csp-hashes.js +1 -1
  17. package/dist/extra-routes-deps.d.ts +1 -1
  18. package/dist/fatal-error-handlers.js +7 -0
  19. package/dist/index.js +4 -4
  20. package/dist/pii-boot-gate.js +1 -1
  21. package/dist/require-env.d.ts +2 -0
  22. package/dist/require-env.js +21 -0
  23. package/dist/resolve-tailwind-cli.js +5 -7
  24. package/dist/run-bootstrap.d.ts +1 -1
  25. package/dist/run-bootstrap.js +1 -1
  26. package/dist/run-prod-app-auth.d.ts +17 -0
  27. package/dist/run-prod-app-auth.js +83 -0
  28. package/dist/run-prod-app-boot-context.d.ts +2 -2
  29. package/dist/run-prod-app-boot-context.js +28 -25
  30. package/dist/run-prod-app-config.d.ts +36 -0
  31. package/dist/run-prod-app-config.js +169 -0
  32. package/dist/run-prod-app-connections.d.ts +16 -0
  33. package/dist/run-prod-app-connections.js +55 -0
  34. package/dist/run-prod-app-entrypoint.d.ts +14 -0
  35. package/dist/run-prod-app-entrypoint.js +147 -0
  36. package/dist/run-prod-app-handle.d.ts +20 -0
  37. package/dist/run-prod-app-handle.js +104 -0
  38. package/dist/run-prod-app-request-context.d.ts +15 -0
  39. package/dist/run-prod-app-request-context.js +103 -0
  40. package/dist/run-prod-app-seeds.d.ts +13 -0
  41. package/dist/run-prod-app-seeds.js +86 -0
  42. package/dist/run-prod-app-static-files.js +43 -50
  43. package/dist/run-prod-app.d.ts +93 -103
  44. package/dist/run-prod-app.js +75 -772
  45. package/dist/run-worker-app.d.ts +1 -1
  46. package/dist/run-worker-app.js +5 -5
  47. package/dist/session-boot-gate.d.ts +1 -1
  48. package/dist/session-boot-gate.js +1 -1
  49. package/dist/session-wiring.d.ts +1 -1
  50. package/dist/session-wiring.js +1 -1
  51. package/dist/try-hono-first.js +1 -1
  52. package/package.json +16 -8
  53. package/src/changes.json +25 -0
@@ -1,119 +1,61 @@
1
1
  /// <reference types="bun-types" preserve="true" />
2
- // runProdApp — production-grade Bootstrap-Wrapper für Kumiko-Apps.
2
+ // runProdApp — production-grade bootstrap wrapper for Kumiko apps.
3
3
  //
4
- // Symmetrisch zu runDevApp, aber:
5
- // - DATABASE_URL / REDIS_URL / JWT_SECRET aus env (fail-fast bei Boot,
6
- // keine ephemeralen Test-DBs)
7
- // - Hard Schema-Drift-Gate: prüft kumiko/migrations vs. _kumiko_migrations
8
- // + tableExists für jede erwartete Tabelle. KEIN Auto-CREATE TABLE im
9
- // Boot — Migration ist ein CI-Step (`bun kumiko schema apply`), Boot
10
- // validiert nur. Verhindert Race-Conditions bei Multi-Replica-Deploys
11
- // + macht Schema-Stand reviewbar in der Pull-Request.
12
- // - Idempotente Seeds: laufen nur wenn DB leer (über `isDbEmpty`-Probe
13
- // pro Seed). Re-Boots nach erstem Seed sind no-op.
14
- // - HTTP-Server via Bun.serve mit graceful SIGTERM/SIGINT → drain().
15
- // - Auth-Routes + bundled-features auto-mix wenn `auth:` gesetzt
16
- // (gleiche Logik wie runDevApp).
4
+ // Symmetric with runDevApp, but:
5
+ // - DATABASE_URL / REDIS_URL / JWT_SECRET come from env (fail-fast at boot;
6
+ // no ephemeral test databases)
7
+ // - Hard schema-drift gate: checks kumiko/migrations against
8
+ // _kumiko_migrations and verifies every expected table with tableExists.
9
+ // Boot never runs CREATE TABLE automatically; migrations are applied by
10
+ // CI (`bun kumiko schema apply`) and boot only validates them. This avoids
11
+ // races in multi-replica deployments and keeps schema state reviewable.
12
+ // - Idempotent seeds run only when the database is empty, using an
13
+ // `isDbEmpty` probe per seed. Reboots after the first seed are no-ops.
14
+ // - HTTP server via Bun.serve with graceful SIGTERM/SIGINT → drain().
15
+ // - Auth routes and bundled features are mixed automatically when `auth:` is
16
+ // set, using the same logic as runDevApp.
17
17
  //
18
- // App-Author schreibt:
18
+ // App authors write:
19
19
  // await runProdApp({ features, envSchema, auth, anonymousAccess, seeds });
20
20
  //
21
- // Container/Coolify setzt:
21
+ // Container/Coolify sets:
22
22
  // DATABASE_URL=postgresql://...
23
23
  // REDIS_URL=redis://...
24
- // JWT_SECRET=<random-32+> (always required — also signs the password-
25
- // reset/email-verification HMAC tokens, a separate non-rotating family)
26
- // — additionally, for zero-downtime rotation of the session-JWT
27
- // signing key specifically:
24
+ // JWT_SECRET=<random-32+> (always required — also signs password-reset and
25
+ // email-verification HMAC tokens, a separate non-rotating token family)
26
+ // — additionally, for zero-downtime rotation of the session-JWT signing
27
+ // key specifically:
28
28
  // JWT_SECRET_V1=<random-32+> (repeat _V2, _V3, ... per rotation)
29
29
  // JWT_SECRET_CURRENT_VERSION=1 (which V<n> signs new tokens; the others
30
30
  // still verify in-flight tokens until they expire)
31
- // Adopting rotation for the first time (no JWT_SECRET_V<n> set yet): the
32
- // plain JWT_SECRET above stays set and is carried into the keyring as a
33
- // verify-only legacy key (loadJwtSecretOrKeyring), so sessions signed
34
- // before the cutover keep verifying — no mass-logout at adoption. This
35
- // legacy key never expires on its own (boot logs a warning while it's
36
- // present) — once max token TTL has elapsed since cutover, unset
37
- // JWT_SECRET to retire it. Check first whether an app relies on the
38
- // auth.mail convenience default for passwordReset/emailVerification
39
- // hmacSecret (falls back to this same JWT_SECRET) before unsetting it.
31
+ // When adopting rotation for the first time (with no JWT_SECRET_V<n> set),
32
+ // the plain JWT_SECRET remains set and is carried into the keyring as a
33
+ // verify-only legacy key (loadJwtSecretOrKeyring), so sessions signed before
34
+ // cutover continue to verify without a mass logout. This legacy key never
35
+ // expires automatically; boot logs a warning while it is present. Once the
36
+ // maximum token TTL has elapsed since cutover, unset JWT_SECRET to retire
37
+ // it. First check whether the app relies on auth.mail's convenience default
38
+ // for passwordReset/emailVerification hmacSecret, which falls back to this
39
+ // same JWT_SECRET.
40
40
  // PORT=3000
41
41
  // KUMIKO_INSTANCE_ID=<stable per replica>
42
- import { AuthErrors, AuthHandlers, AuthQueries, } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
43
- import { seedAdminGuarded, } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
44
- import { EXT_SESSION_STORE, EXT_TOKEN_VERIFIER, resolveAnonymousAccessFromRegistry, resolveTokenVerifier, } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
45
- import { AUTH_MFA_FEATURE, AuthMfaHandlers, bindMfaTokenSecretsFromFeature, } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
46
- import { PAT_FEATURE, patRateLimitFromFeature, } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
47
- import { SESSIONS_FEATURE } from "@cosmicdrift/kumiko-bundled-features/sessions";
48
- import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
49
- import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
50
- import { createDefaultSseBroker, createRedisLoginRateLimiter, loadJwtSecretOrKeyring, TRUSTED_PROXY_HOPS_ENV, withFileProviderResolver, } from "@cosmicdrift/kumiko-framework/api";
51
- import { configureBlindIndexKey, configurePiiSubjectKms, } from "@cosmicdrift/kumiko-framework/crypto";
52
- import { configureEntityFieldEncryption, createDbConnection, dbConnectionOptionsFromEnv, outsideTransactionPoolOptionsFromEnv, serverTimeoutOptionsFromEnv, } from "@cosmicdrift/kumiko-framework/db";
53
- import { collectWriteHandlerQns, createRegistry, findTierResolverUsage, requireTierResolverPlugin, validateAppCustomScreenWriteQns, validateBoot, } from "@cosmicdrift/kumiko-framework/engine";
54
- import { createAllInOneEntrypoint, createApiEntrypoint, } from "@cosmicdrift/kumiko-framework/entrypoint";
55
- import { createEsOperationsTable, createSeedMigrationContext, runPendingSeedMigrations, } from "@cosmicdrift/kumiko-framework/es-ops";
56
42
  import { assertSeedModeNotInProduction } from "@cosmicdrift/kumiko-framework/event-store";
57
- import { createFallbackLogger, setProcessLogger } from "@cosmicdrift/kumiko-framework/logging";
58
- import { assertKumikoSchemaCurrent, SchemaDriftError, } from "@cosmicdrift/kumiko-framework/migrations";
59
- import { createMetricsForFactory, } from "@cosmicdrift/kumiko-framework/observability";
60
- import { createDispatcher, createEntityCache, createEventDedup, createIdempotencyGuard, createUnlimitedRateLimitResolver, flushEscapeHatchAudits, } from "@cosmicdrift/kumiko-framework/pipeline";
61
- import { createRedisCacheSyncBus, logRedisConnectionState, redisClientOptionsFromEnv, } from "@cosmicdrift/kumiko-framework/redis";
62
- import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
63
- import { Redis } from "ioredis";
64
- import { applyBootSeeds } from "./boot/apply-boot-seeds.js";
65
- import { resolveBootCrypto } from "./boot/boot-crypto.js";
66
43
  import { runEnvGate } from "./boot/env-gate.js";
67
- import { jobRunLoggerCallbacks } from "./boot/job-run-logger.js";
68
- import { startPiiEventBackfillOnBoot } from "./boot/pii-event-backfill-on-boot.js";
69
- import { recordAppStartedOnBoot } from "./boot/record-app-started-on-boot.js";
70
- import { waitForDatabase, waitUntilDatabaseReachable } from "./boot/wait-for-database.js";
71
- import { installShutdownSignals } from "./boot-options/process-signals.js";
72
- import { resolveBootOptionWiring } from "./boot-options/resolve-boot-options.js";
73
- import { assertStackBootRequirements } from "./boot-options/stack-boot-requirements.js";
74
- import { buildBunServeOptions, resolveDerivedMaxRequestBodySize } from "./bun-serve-options.js";
75
- import { buildComposeAuthOptions, composeFeatures } from "./compose-features.js";
76
- import { makeDispatchSystemQuery, makeDispatchSystemWrite, } from "./extra-routes-deps.js";
77
- import { installFatalErrorHandlers } from "./fatal-error-handlers.js";
78
- import { assertPiiBootInvariants } from "./pii-boot-gate.js";
44
+ import { buildEffectiveFeaturesResolver } from "./boot/shared-boot-steps.js";
79
45
  import { installProcessLogger, uninstallProcessLogger } from "./process-logger.js";
80
- import { resolveRateLimit } from "./rate-limit-defaults.js";
81
- import { resolveAuthPublicOrigin } from "./resolve-auth-public-origin.js";
82
- import { resolveUnsubscribeWiring } from "./resolve-unsubscribe-wiring.js";
83
- import { addConfigAccessorFactory, buildBootExtraContext, buildProdSessionAuth, loggerFromExtraContext, resolveAuthMail, wireProdPatAutoRevoke, } from "./run-prod-app-boot-context.js";
84
- import { buildStaticFallback, resolveCspScriptHashes, resolveHostDispatchScriptHashes, } from "./run-prod-app-static-files.js";
85
- import { withSecurityHeaders } from "./security-headers.js";
86
- import { makeSeedCreateFileRef, withSeedErrorContext, } from "./seed-dispatcher.js";
87
- import { assertSessionBootInvariants } from "./session-boot-gate.js";
88
- import { shouldWireProdSessions } from "./session-wiring.js";
89
- import { resolveTrustedProxyHops } from "./trusted-proxy-hops.js";
90
- import { stripNoRouteMatchHeader } from "./try-hono-first.js";
46
+ import { wireProdAuth } from "./run-prod-app-auth.js";
47
+ import { composeProdFeatures, resolveProdBootConfig, runWithBootAbortCleanups, } from "./run-prod-app-config.js";
48
+ import { assertProdSchemaCurrent, connectProdDependencies } from "./run-prod-app-connections.js";
49
+ import { buildProdEntrypointOptions, createProdEntrypoint, registerProdShutdownHooks, runProdWireHook, startProdBackgroundWork, } from "./run-prod-app-entrypoint.js";
50
+ import { buildProdFetchHandler, createProdAppHandle } from "./run-prod-app-handle.js";
51
+ import { buildProdRequestContext } from "./run-prod-app-request-context.js";
52
+ import { runProdBootSeeds } from "./run-prod-app-seeds.js";
91
53
  export { parseRunMode } from "./boot/env-gate.js";
92
54
  export { buildBunServeOptions } from "./bun-serve-options.js";
55
+ export { requireEnv } from "./require-env.js";
93
56
  export { resolveUnsubscribeWiring, } from "./resolve-unsubscribe-wiring.js";
94
57
  export { addConfigAccessorFactory, buildBootExtraContext, loggerFromExtraContext, resolveAuthMail, } from "./run-prod-app-boot-context.js";
95
58
  export { staticCachePolicy } from "./run-prod-app-static-files.js";
96
- // Strict env-var read. Throws with a clear hint when missing — better
97
- // than discovering a Postgres-connection-refused 30s into the boot.
98
- // `src` defaults to process.env but is threaded from the caller's envSource
99
- // so the boot-path reads the SAME env-quelle that was validated above —
100
- // injected dummies in test-mode must not silently fall back to process.env.
101
- export function requireEnv(name, src = process.env, context = "runProdApp") {
102
- const value = src[name];
103
- if (value === undefined || value === "") {
104
- const advice = context === "runProdApp"
105
- ? "Set it in your container env / .env.production / Coolify secrets."
106
- : "Set it in your .env / shell before running the dev server.";
107
- throw new Error(`${context}: required env var "${name}" is missing or empty. ${advice}`);
108
- }
109
- return value;
110
- }
111
- // Optional env helper — returns undefined for missing, string for set.
112
- // Used for KUMIKO_INSTANCE_ID, JWT_ISSUER and other "nice to have" knobs.
113
- function readEnv(name, src = process.env) {
114
- const value = src[name];
115
- return value === undefined || value === "" ? undefined : value;
116
- }
117
59
  // Returned from runProdApp when KUMIKO_DRY_RUN_ENV is set AND envSource
118
60
  // was passed (= test-mode). The handle is intentionally inert — listen()
119
61
  // and stop() are no-ops; tests inspect the dry-run console output and
@@ -133,35 +75,10 @@ function makeDryRunHandle() {
133
75
  stop: noop,
134
76
  };
135
77
  }
136
- const SERVER_GRACEFUL_STOP_MS = 10_000;
137
- // stop() without force waits for open connections (SSE never ends on its own); force-close after the grace period.
138
- async function stopServerGracefully(server) {
139
- let graceTimer;
140
- const graceElapsed = new Promise((resolve) => {
141
- graceTimer = setTimeout(resolve, SERVER_GRACEFUL_STOP_MS);
142
- });
143
- try {
144
- await Promise.race([server.stop(), graceElapsed]);
145
- }
146
- finally {
147
- clearTimeout(graceTimer);
148
- }
149
- await server.stop(true);
150
- }
151
- let warnedLegacyJwtSecret = false;
152
- function warnLegacyJwtSecretOnce() {
153
- // skip: already warned once this process — avoid log spam on every boot path.
154
- if (warnedLegacyJwtSecret)
155
- return;
156
- warnedLegacyJwtSecret = true;
157
- createFallbackLogger("runProdApp").warn("JWT keyring carries a legacy (pre-rotation) verify-only key — " +
158
- "it never expires on its own. JWT_SECRET remains a required env (hmacSecret " +
159
- "fallback + boot requireEnv). To stop verifying with the pre-rotation secret: " +
160
- "set auth.mail.hmacSecret explicitly, then rotate JWT_SECRET to a fresh value " +
161
- "that is no longer present in any in-flight token (see resolveAuthMail / " +
162
- "loadJwtSecretOrKeyring).");
163
- }
164
78
  export async function runProdApp(options) {
79
+ return runWithBootAbortCleanups((register) => bootProdApp(options, register));
80
+ }
81
+ async function bootProdApp(options, registerAbortCleanup) {
165
82
  assertSeedModeNotInProduction();
166
83
  // 0. Env-Schema validation + dry-run modes. Runs FIRST so:
167
84
  // - operators can introspect env-requirements without a real boot
@@ -185,109 +102,10 @@ export async function runProdApp(options) {
185
102
  return makeDryRunHandle();
186
103
  const { runMode, decryptedEnvSource } = envGate;
187
104
  const log = installProcessLogger(options.extraContext, "runProdApp");
188
- // Stacks whose boot options are missing and the declarative boot options
189
- // (mail, kmsMode, fileStorage, billing, demoMode, seed shortcuts) fail here, before any connection. Their defaults sit under
190
- // the env so an explicitly set variable wins.
191
- assertStackBootRequirements(options);
192
- const bootOptionWiring = await resolveBootOptionWiring(options, decryptedEnvSource);
193
- const envSource = Object.keys(bootOptionWiring.envDefaults).length > 0
194
- ? { ...bootOptionWiring.envDefaults, ...decryptedEnvSource }
195
- : decryptedEnvSource;
196
- const bootKms = bootOptionWiring.kms;
197
- // 1. Polyfill before anything else — feature code references Temporal.
198
- const { ensureTemporalPolyfill } = await import("@cosmicdrift/kumiko-framework/time");
199
- await ensureTemporalPolyfill();
200
- // 2. Env-vars: fail-fast. Better a 0s boot crash with a clear error
201
- // than a 30s timeout chasing a Postgres connection that was never
202
- // configured.
203
- const databaseUrl = requireEnv("DATABASE_URL", envSource);
204
- const redisUrl = requireEnv("REDIS_URL", envSource);
205
- // JWT_SECRET stays mandatory (also the resolveAuthMail hmacSecret
206
- // fallback below — a separate, non-rotating HMAC token family, not the
207
- // session JWT). jwtSecretOrKeyring is the OPTIONAL upgrade: set
208
- // JWT_SECRET_V<n> + JWT_SECRET_CURRENT_VERSION for zero-downtime
209
- // rotation of the session-JWT signing key — see loadJwtSecretOrKeyring.
210
- const jwtSecret = requireEnv("JWT_SECRET", envSource);
211
- const jwtSecretOrKeyring = loadJwtSecretOrKeyring(envSource);
212
- if (typeof jwtSecretOrKeyring === "object" && Object.hasOwn(jwtSecretOrKeyring.keys, "legacy")) {
213
- // Once per process — JWT_SECRET stays a required env (requireEnv above +
214
- // resolveAuthMail hmacSecret fallback). Retiring the *legacy verify key*
215
- // means setting auth.mail.hmacSecret explicitly and rotating JWT_SECRET
216
- // to a fresh value that is no longer in any in-flight token; unsetting
217
- // JWT_SECRET entirely hard-crashes boot.
218
- warnLegacyJwtSecretOnce();
219
- }
220
- const jwtIssuer = readEnv("JWT_ISSUER", envSource);
221
- const instanceId = readEnv("KUMIKO_INSTANCE_ID", envSource);
222
- // kumiko-framework#1539 — options.auth.trustedProxyHops wins; falls back
223
- // to the env var so ops can close the XFF-spoofing hole per-deployment
224
- // without a code change (mirrors instanceId's env-first pattern above).
225
- const trustedProxyHops = resolveTrustedProxyHops({
226
- option: options.trustedProxyHops,
227
- authOption: options.auth?.trustedProxyHops,
228
- envValue: readEnv(TRUSTED_PROXY_HOPS_ENV, envSource),
229
- nodeEnv: process.env["NODE_ENV"],
230
- });
231
- if (trustedProxyHops === undefined) {
232
- // With 0 hops every request appears to come from the proxy pod, so all IP-keyed
233
- // rate limiters collapse into one shared bucket behind a reverse proxy.
234
- log.warn(`trustedProxyHops / ${TRUSTED_PROXY_HOPS_ENV} is not set; client IP falls back to the socket address. ` +
235
- "Behind a reverse proxy all clients share one rate-limit bucket, so the default L1/L2 limits throttle the whole site at once.");
236
- }
237
- const resolvedRateLimit = resolveRateLimit(options.rateLimit);
238
- const port = options.port ?? Number.parseInt(envSource["PORT"] ?? "3000", 10);
239
- log.info(`booting Kumiko stack on port ${port}`);
240
- // auth.mail → expandiert in die expliziten Flow-Felder, bevor sie sowohl
241
- // die Feature-Side (buildComposeAuthOptions) als auch das Routes-Fragment
242
- // unten speisen. Ab hier IMMER effectiveAuth statt options.auth lesen.
243
- const effectiveAuth = options.auth
244
- ? resolveAuthMail(options.auth, jwtSecret, envSource, {
245
- mailConfigured: bootOptionWiring.mailConfigured,
246
- })
247
- : undefined;
248
- const authPublicOrigin = effectiveAuth
249
- ? resolveAuthPublicOrigin(effectiveAuth, envSource, (message) => {
250
- log.warn(message);
251
- })
252
- : undefined;
253
- const { unsubscribeLinks, unsubscribeRoutes } = resolveUnsubscribeWiring({
254
- unsubscribe: options.unsubscribe,
255
- publicOrigin: effectiveAuth?.publicOrigin,
256
- mailBaseUrl: effectiveAuth?.mail?.baseUrl,
257
- envSource,
258
- warn: (message) => createFallbackLogger("runProdApp").warn(message),
259
- });
260
- // 3. Feature registry. Auth-mode auto-mixes config/user/tenant/auth-email-
261
- // password via composeFeatures — same source-of-truth as runDevApp
262
- // AND the per-app drizzle-Schema-Generator, so Migration und Runtime
263
- // sehen exakt dieselbe Liste. Built BEFORE any connection so boot-mode
264
- // can validate wiring and exit without opening a Postgres/Redis socket.
265
- const composeAuthOptions = buildComposeAuthOptions(effectiveAuth);
266
- const features = composeFeatures([...options.features, ...bootOptionWiring.features], {
267
- includeBundled: !!effectiveAuth,
268
- ...(composeAuthOptions && { authOptions: composeAuthOptions }),
269
- });
270
- const bootCrypto = resolveBootCrypto(envSource, options.masterKey);
271
- validateBoot(features, {
272
- env: envSource,
273
- ...(bootCrypto.entityFieldCipher && { entityFieldCipher: bootCrypto.entityFieldCipher }),
274
- ...options.validateBootOptions,
275
- });
276
- warnIfNonUtcServerTimeZone();
277
- validateAppCustomScreenWriteQns(process.cwd(), collectWriteHandlerQns(features));
278
- assertPiiBootInvariants(features, {
279
- kms: bootKms.kms,
280
- blindIndexKey: bootKms.blindIndexKey,
281
- allowPlaintextPii: bootKms.allowPlaintextPii,
282
- mode: "prod",
283
- });
284
- const sessionsFeature = features.find((f) => f.name === SESSIONS_FEATURE);
285
- const registry = createRegistry(features);
286
- assertSessionBootInvariants({
287
- hasAuth: Boolean(effectiveAuth),
288
- sessionStoreProviderMounted: registry.getExtensionUsages(EXT_SESSION_STORE).length > 0,
289
- mode: "prod",
290
- });
105
+ const config = await resolveProdBootConfig(options, decryptedEnvSource, log, registerAbortCleanup);
106
+ const composed = composeProdFeatures(options, config, log);
107
+ const { bootKms } = config;
108
+ const { features, registry } = composed;
291
109
  // C1 boot-mode exit: validators ran + registry built; no DB/Redis client
292
110
  // is constructed at all in this branch (the eager `new Redis(...)` below
293
111
  // would otherwise open a TCP connect just to immediately disconnect it).
@@ -301,556 +119,41 @@ export async function runProdApp(options) {
301
119
  }
302
120
  return makeDryRunHandle();
303
121
  }
304
- // 4. Connections — Postgres + Redis. The Redis client is shared by
305
- // idempotency, event-dedup, entity-cache, rate-limit; failing to
306
- // construct here surfaces the misconfig immediately. `new Redis(...)`
307
- // connects eagerly, so it must stay AFTER the boot-mode exit above.
308
- // KMS health gate: an app configured for crypto-shredding must not boot
309
- // against an unreachable key store — a silent start would answer every
310
- // PII read with 503. Runs BEFORE connections so an abort leaks nothing.
311
- if (bootKms.kms) {
312
- const kms = bootKms.kms;
313
- const kmsHealth = await waitUntilDatabaseReachable(() => kms.health(), {
314
- processName: "runProdApp",
315
- });
316
- if (!kmsHealth.ok) {
317
- throw new Error(`[runProdApp] BOOT ABORTED — KMS health check failed (latency ${kmsHealth.latencyMs}ms)`);
318
- }
319
- }
320
- const { db, close: closeDb } = createDbConnection({
321
- url: databaseUrl,
322
- ...dbConnectionOptionsFromEnv(envSource),
323
- ...serverTimeoutOptionsFromEnv(envSource),
324
- });
325
- const { db: outsideTransactionDb, close: closeOutsideTransactionDb } = createDbConnection({
326
- url: databaseUrl,
327
- ...outsideTransactionPoolOptionsFromEnv(envSource),
328
- ...serverTimeoutOptionsFromEnv(envSource),
329
- });
330
- const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
331
- logRedisConnectionState(redis, log);
332
- await waitForDatabase(db, { processName: "runProdApp" });
333
- // Sprint-8a Tier-Composition auto-wire: scan features for a
334
- // tenantTierResolver-extension. If found, build the resolver here (db +
335
- // registry are available) before the dispatcher is constructed. App-Author sees
336
- // nothing — `createTierEngineFeature(opts)` mounts + framework auto-wires.
337
- // One bus per process: the tier resolver, the dispatcher and the file-provider
338
- // resolver all subscribe to it (invalidation across the API pods).
339
- const cacheSync = createRedisCacheSyncBus({
340
- redisUrl,
341
- clientOptions: redisClientOptionsFromEnv(envSource),
342
- });
343
- let resolvedEffectiveFeatures;
344
- const tierResolverUsage = findTierResolverUsage(features);
345
- if (tierResolverUsage) {
346
- resolvedEffectiveFeatures = await requireTierResolverPlugin(tierResolverUsage).build({
347
- db,
348
- registry,
349
- cacheSync,
350
- });
351
- }
352
- if (bootOptionWiring.featureToggles) {
353
- const bound = await bootOptionWiring.featureToggles.bind({
354
- db,
355
- registry,
356
- cacheSync,
357
- tierResolver: resolvedEffectiveFeatures,
358
- });
359
- resolvedEffectiveFeatures = bound.resolver;
360
- }
361
- // 5. Schema-Drift-Gate (drizzle-frei, kumiko/migrations). `kumiko schema
362
- // apply` läuft als Deploy-Step VOR dem Container-Rollout. Boot prüft nur:
363
- // (a) Alle Migrations aus kumiko/migrations/*.sql sind in
364
- // _kumiko_migrations applied (+ checksum unverändert)
365
- // (b) Alle Tabellen aus kumiko/migrations/.snapshot.json existieren
366
- // Drift = Boot-Error mit klarer Meldung (kein Auto-Heal — mehrere
367
- // Container-Replicas würden sonst race-conditionen fahren). Opt-out via
368
- // `migrations: false` für custom Schema-Setups.
369
- if (options.migrations !== false) {
370
- const migrationsDir = options.migrations?.dir ?? "./kumiko/migrations";
371
- log.info(`checking schema drift (${migrationsDir})`);
372
- try {
373
- await assertKumikoSchemaCurrent(db, migrationsDir);
374
- }
375
- catch (err) {
376
- if (err instanceof SchemaDriftError) {
377
- log.error(`BOOT ABORTED — ${err.message}`);
378
- }
379
- throw err;
380
- }
381
- }
382
- // 6. Pipeline pieces — same default config as runDevApp's setupTestStack.
383
- const idempotency = createIdempotencyGuard(redis, { ttlSeconds: 60 });
384
- const eventDedup = createEventDedup(redis, { ttlSeconds: 60 });
385
- const entityCache = createEntityCache(redis, { ttlSeconds: 60 });
386
- // 7. Lifecycle is built by createApiEntrypoint when not supplied —
387
- // we let the entrypoint own it and read it back through the handle
388
- // for SIGTERM.
389
- //
390
- // extraContext + anonymousAccess sind factory-union: entweder direktes
391
- // Object oder Function die {db, redis, registry} bekommt und das Object
392
- // returned. Factory-Form gilt als bevorzugt für Cases die zur Boot-Zeit
393
- // gegen die DB resolven müssen (z.B. Subdomain-Tenant-Lookup im
394
- // tenantResolver) — die Factory closure'd `db` und der Resolver kann
395
- // sie zur Request-Zeit aufrufen.
396
- // Build the sseBroker here (instead of letting createApiEntrypoint do it
397
- // internally) so extraContext factories can close over it at boot time —
398
- // e.g. an extraContext provider that publishes SSE events directly. We
399
- // pass the same broker into createApiEntrypoint (sseBroker? option) so
400
- // the server-internal broadcast and app-specific publishes run over
401
- // exactly one broker. createDefaultSseBroker (not createSseBroker
402
- // directly) is what makes that broker Redis-backed under REDIS_URL —
403
- // an unconditional in-memory broker here would silently win over
404
- // buildServer's own REDIS_URL default (ServerOptions.sseBroker always
405
- // wins), which is exactly how fw#2630's cross-replica fanout fix shipped
406
- // without ever taking effect in production.
407
- const { sseBroker, ownedRedisSseBroker } = createDefaultSseBroker(envSource);
408
- const deps = {
409
- db,
410
- redis,
411
- registry,
412
- sseBroker,
413
- env: envSource,
414
- ...(bootCrypto.masterKeyProvider && { masterKeyProvider: bootCrypto.masterKeyProvider }),
415
- ...(bootCrypto.configCipher && { configEncryption: bootCrypto.configCipher }),
416
- };
417
- const resolvedExtraContext = typeof options.extraContext === "function"
418
- ? options.extraContext(deps)
419
- : (options.extraContext ?? {});
420
- // Framework-Default-Provider zuerst, App-Werte (resolvedExtraContext)
421
- // gewinnen immer (z.B. money-horse's eigener configResolver).
422
- // App-wide cipher for `encrypted: true` entity fields — executors resolve
423
- // it lazily, entities without encrypted fields never touch it.
424
- configureEntityFieldEncryption(bootCrypto.entityFieldCipher);
425
- // Subject-key KMS for pii-annotated fields (crypto-shredding, #724).
426
- // No adapter only gets this far when assertPiiBootInvariants above let it
427
- // through: no PII carriers, or an explicit allowPlaintextPii opt-out. The
428
- // engine then stays off and those fields are stored in plaintext.
429
- configurePiiSubjectKms(bootKms.kms);
430
- // Blind-Index-Key (#818) — Boot-Gate: mit aktivem KMS würden lookupable-
431
- // Felder als Ciphertext gespeichert, ohne Key gäbe es keinen bidx-Arm und
432
- // jeder Equality-Lookup (Login by email!) liefe ins Leere. Fail-fast statt
433
- // silent-broken-auth.
434
- configureBlindIndexKey(bootKms.blindIndexKey);
435
- const bootLogger = loggerFromExtraContext(resolvedExtraContext);
436
- if (bootLogger)
437
- setProcessLogger(bootLogger);
438
- const autoExtraContext = buildBootExtraContext({
439
- db,
122
+ const connections = await connectProdDependencies(config, log, registerAbortCleanup);
123
+ // Tier-composition auto-wire: if a feature carries a tenantTierResolver extension, the
124
+ // resolver is built here, before the dispatcher exists. The app author only mounts
125
+ // `createTierEngineFeature(opts)`; the framework does the wiring.
126
+ const effectiveFeatures = await buildEffectiveFeaturesResolver({
440
127
  features,
441
- envSource,
442
- registry,
443
- hasAuth: !!effectiveAuth,
444
- sseBroker,
445
- redis,
446
- crypto: bootCrypto,
447
- ...(bootKms.kms && { kms: bootKms.kms }),
448
- ...(bootLogger && { log: bootLogger }),
449
- ...(unsubscribeLinks && { unsubscribeLinks }),
450
- ...(options.appOverrides && { appOverrides: options.appOverrides }),
451
- });
452
- const extraContext = {
453
- ...addConfigAccessorFactory({ ...autoExtraContext, ...resolvedExtraContext }, registry),
454
- ...(options.defaultLocale !== undefined && { defaultLocale: options.defaultLocale }),
455
- };
456
- const baseAnonymousAccess = typeof options.anonymousAccess === "function"
457
- ? options.anonymousAccess(deps)
458
- : options.anonymousAccess;
459
- // #1374: tenantResolver / tenantExists come from auth-foundation providers.
460
- const resolvedAnonymousAccess = await resolveAnonymousAccessFromRegistry(baseAnonymousAccess, {
461
- db,
462
- registry,
463
- warn: (message) => createFallbackLogger("runProdApp").warn(message),
464
- });
465
- // Sessions opt-in: db ist hier schon konkret (createDbConnection oben),
466
- // also direkt verdrahten — kein late-bound nötig wie bei runDevApp.
467
- // Ein JWT ohne sid wird abgelehnt, sobald ein sessionChecker verdrahtet ist —
468
- // Prod-Sessions können nicht stillschweigend umgangen werden. sessionMassRevoker
469
- // (4. callback aus createSessionCallbacks) ist nicht Teil der
470
- // AuthRoutesConfig-Surface — der geht via bindAutoRevokeFromFeature ans
471
- // sessions-Feature (Password-Change/-Reset revoked alle Sessions), nicht
472
- // über die auth-routes.
473
- // Secure-by-default (#1372): sessionStore provider → resolveSessionStore.
474
- const mfaFeature = features.find((f) => f.name === AUTH_MFA_FEATURE);
475
- if (mfaFeature)
476
- bindMfaTokenSecretsFromFeature(mfaFeature)?.(jwtSecret, envSource);
477
- if (mfaFeature && registry.getWriteHandler(AuthHandlers.switchTenantMfaGate) === undefined) {
478
- throw new Error(`[runProdApp] auth-mfa is mounted but ${AuthHandlers.switchTenantMfaGate} is not registered: ` +
479
- "createAuthEmailPasswordFeature registers it only when mfaStatusChecker is set, and without it " +
480
- "POST /auth/switch-tenant would mint sessions without the second factor");
481
- }
482
- // Hoisted above buildProdSessionAuth: that call also wires MFA-enable/
483
- // disable → PAT-revoke (bindRevokeAllPatTokensFromFeature), so it needs
484
- // patFeature too.
485
- const patFeature = features.find((f) => f.name === PAT_FEATURE);
486
- const sessionAuthFragment = shouldWireProdSessions(Boolean(effectiveAuth), registry.getExtensionUsages(EXT_SESSION_STORE).length > 0)
487
- ? await buildProdSessionAuth(db, registry, sessionsFeature, mfaFeature, patFeature)
488
- : undefined;
489
- // Password-change → PAT-revoke: independent of sessions/MFA being mounted
490
- // (buildProdSessionAuth only runs when a sessionStore provider is
491
- // registered), so wired unconditionally here whenever PAT itself is.
492
- if (patFeature) {
493
- wireProdPatAutoRevoke(db, patFeature);
494
- }
495
- // Token-verifier opt-in: any provider feature (personal-access-tokens, a
496
- // future auth-provider-jwt, ...) self-registers via
497
- // r.useExtension(EXT_TOKEN_VERIFIER, ...) — wire one generic resolver
498
- // whenever at least one is mounted, resolved by shape at request-time.
499
- // PAT keeps its own per-token rate limiter (patRateLimiter), unrelated to
500
- // verification.
501
- const hasTokenVerifierProviders = registry.getExtensionUsages(EXT_TOKEN_VERIFIER).length > 0;
502
- let patAuthFragment;
503
- if (effectiveAuth && patFeature && hasTokenVerifierProviders) {
504
- const rl = patRateLimitFromFeature(patFeature);
505
- patAuthFragment = {
506
- tokenVerifier: (rawToken) => resolveTokenVerifier({ db, registry }, rawToken),
507
- patRateLimiter: createRedisLoginRateLimiter(redis, rl.maxRequests, rl.windowMs, "pat"),
508
- };
509
- }
510
- const baseEntrypointOptions = {
511
- registry,
512
- context: {
513
- db,
514
- redis,
515
- entityCache,
516
- registry,
517
- ...extraContext,
518
- },
519
- sseBroker,
520
- jwtSecret: jwtSecretOrKeyring,
521
- ...(jwtIssuer && { jwtIssuer }),
522
- ...(instanceId && { instanceId }),
523
- dispatcherOptions: {
524
- idempotency,
525
- cacheSync,
526
- outsideTransactionDb,
527
- ...(resolvedEffectiveFeatures && { effectiveFeatures: resolvedEffectiveFeatures }),
528
- },
529
- eventDedup,
530
- ...(options.observability && { observability: options.observability }),
531
- ...(options.metrics && { metrics: options.metrics }),
532
- ...(resolvedRateLimit && { rateLimit: resolvedRateLimit }),
533
- ...(trustedProxyHops !== undefined && { trustedProxyHops }),
534
- ...(options.errorDocs && { errorDocs: options.errorDocs }),
535
- ...((options.extraRoutes || options.unsubscribe || bootOptionWiring.extraRoutes.length > 0) && {
536
- extraRoutes: [
537
- ...(options.extraRoutes ?? []),
538
- ...unsubscribeRoutes,
539
- ...bootOptionWiring.extraRoutes,
540
- ],
541
- }),
542
- ...(effectiveAuth && {
543
- auth: {
544
- membershipQuery: TenantQueries.memberships,
545
- userQuery: UserQueries.findForAuth,
546
- loginHandler: AuthHandlers.login,
547
- loginErrorStatusMap: effectiveAuth.loginErrorStatusMap ?? {
548
- [AuthErrors.invalidCredentials]: 401,
549
- [AuthErrors.noMembership]: 403,
550
- },
551
- // Redis-backed, not the in-memory default createAuthRoutes falls
552
- // back to — an in-process limiter only rate-limits within a single
553
- // replica, so a multi-instance prod deployment would silently give
554
- // each replica its own bucket (#1262/#1274). Redis is required infra
555
- // here already (REDIS_URL), so this is free.
556
- loginRateLimit: createRedisLoginRateLimiter(redis),
557
- ...(effectiveAuth.cookieDomain !== undefined && {
558
- cookieDomain: effectiveAuth.cookieDomain,
559
- }),
560
- ...(effectiveAuth.secureCookies !== undefined && {
561
- secureCookies: effectiveAuth.secureCookies,
562
- }),
563
- ...(effectiveAuth.retiredCookieDomains !== undefined && {
564
- retiredCookieDomains: effectiveAuth.retiredCookieDomains,
565
- }),
566
- ...(effectiveAuth.allowedOrigins !== undefined && {
567
- allowedOrigins: effectiveAuth.allowedOrigins,
568
- }),
569
- ...(effectiveAuth.unsafeSkipOriginCheck !== undefined && {
570
- unsafeSkipOriginCheck: effectiveAuth.unsafeSkipOriginCheck,
571
- }),
572
- ...(authPublicOrigin !== undefined && { publicOrigin: authPublicOrigin }),
573
- ...(trustedProxyHops !== undefined && { trustedProxyHops }),
574
- ...(effectiveAuth.postAuthLanding !== undefined && {
575
- postAuthLanding: effectiveAuth.postAuthLanding,
576
- }),
577
- ...sessionAuthFragment,
578
- ...patAuthFragment,
579
- ...(mfaFeature && {
580
- mfaVerifyHandler: AuthMfaHandlers.verify,
581
- switchTenantMfaGateHandler: AuthHandlers.switchTenantMfaGate,
582
- mfaPreauthEnableStartHandler: AuthMfaHandlers.enableStartPreauth,
583
- mfaPreauthConfirmHandler: AuthMfaHandlers.enableConfirmPreauth,
584
- mfaVerifyRateLimit: createRedisLoginRateLimiter(redis, undefined, undefined, "mfa-verify"),
585
- mfaPreauthEnableStartRateLimit: createRedisLoginRateLimiter(redis, undefined, undefined, "mfa-preauth-start"),
586
- mfaPreauthConfirmRateLimit: createRedisLoginRateLimiter(redis, undefined, undefined, "mfa-preauth-confirm"),
587
- }),
588
- ...(effectiveAuth.passwordReset && {
589
- passwordReset: {
590
- requestHandler: AuthHandlers.requestPasswordReset,
591
- confirmHandler: AuthHandlers.resetPassword,
592
- },
593
- }),
594
- ...(effectiveAuth.emailVerification && {
595
- emailVerification: {
596
- requestHandler: AuthHandlers.requestEmailVerification,
597
- confirmHandler: AuthHandlers.verifyEmail,
598
- },
599
- }),
600
- ...(effectiveAuth.accountUnlock && {
601
- accountUnlock: {
602
- requestHandler: AuthHandlers.requestAccountUnlock,
603
- confirmHandler: AuthHandlers.confirmAccountUnlock,
604
- },
605
- }),
606
- ...(effectiveAuth.signup && {
607
- signup: {
608
- requestHandler: AuthHandlers.signupRequest,
609
- confirmHandler: AuthHandlers.signupConfirm,
610
- },
611
- }),
612
- ...(effectiveAuth.invite && {
613
- invite: {
614
- acceptHandler: AuthHandlers.inviteAccept,
615
- acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
616
- signupCompleteHandler: AuthHandlers.inviteSignupComplete,
617
- infoHandler: AuthQueries.inviteInfo,
618
- },
619
- }),
620
- },
621
- }),
622
- ...(resolvedAnonymousAccess && { anonymousAccess: resolvedAnonymousAccess }),
623
- };
624
- // Deploy-Topologie. Default (Single-Container): createAllInOneEntrypoint
625
- // fährt HTTP + BEIDE Job-Lanes (zwei Runner, jeder schedult seine eigene
626
- // Lane-Crons → kein Double-Fire) + Event-Dispatcher inline. So laufen
627
- // worker-Lane-Crons (run-export-jobs default runIn:"worker") UND MSPs ohne
628
- // separaten Worker-Process — die Asymmetrie, an der der Daten-Export hing.
629
- // runSingleInstance:false → API-only; ein dezidierter Worker MUSS dann die
630
- // worker-Lane + MSPs übernehmen (api-Lane-Jobs laufen weiter lokal).
631
- // Default single-instance. eventDispatcher.disabled ist die Alt-Art, MSPs
632
- // diesem Prozess wegzunehmen (dezidierter Worker) — als runSingleInstance:
633
- // false honorieren (api-only, kein lokaler Dispatcher), damit der explizite
634
- // Flag Vorrang behält aber Bestands-Caller nicht brechen.
635
- const runSingleInstance = options.runSingleInstance ?? options.eventDispatcher?.disabled !== true;
636
- const hasJobs = registry.getAllJobs().size > 0;
637
- const queueNamePrefix = options.jobs?.queueNamePrefix;
638
- const jobLogger = jobRunLoggerCallbacks(registry, db);
639
- const dispatcherTunables = options.eventDispatcher?.pollIntervalMs !== undefined
640
- ? { pollIntervalMs: options.eventDispatcher.pollIntervalMs }
641
- : {};
642
- const entrypoint = runSingleInstance
643
- ? createAllInOneEntrypoint({
644
- ...baseEntrypointOptions,
645
- redisUrl,
646
- ...jobLogger,
647
- ...(queueNamePrefix !== undefined && { queueNamePrefix }),
648
- ...(options.jobs?.getActiveTenantIds !== undefined && {
649
- getActiveTenantIds: options.jobs.getActiveTenantIds,
650
- }),
651
- ...(!options.eventDispatcher?.disabled && { eventDispatcher: dispatcherTunables }),
652
- })
653
- : createApiEntrypoint({
654
- ...baseEntrypointOptions,
655
- ...(hasJobs && {
656
- jobs: {
657
- redisUrl,
658
- runLocalJobs: true,
659
- ...jobLogger,
660
- ...(queueNamePrefix !== undefined && { queueNamePrefix }),
661
- ...(options.jobs?.getActiveTenantIds !== undefined && {
662
- getActiveTenantIds: options.jobs.getActiveTenantIds,
663
- }),
664
- },
665
- }),
666
- });
667
- // The broker built above is always ours (runProdApp never accepts a
668
- // caller-supplied sseBroker), so — same reasoning as buildServer's own
669
- // ownedRedisSseBroker hook — closing it on shutdown is our job, not the
670
- // entrypoint's. Registered after createApiEntrypoint/createAllInOneEntrypoint
671
- // (which already registered the eventDispatcher/jobRunner hooks above),
672
- // so it drains LIFO before those, matching the close-before-teardown
673
- // order fw#2630 established for buildServer's own hook.
674
- entrypoint.lifecycle.registerShutdownHook("prodAppCacheSync", async () => {
675
- await cacheSync.close();
676
- });
677
- if (ownedRedisSseBroker) {
678
- const broker = ownedRedisSseBroker;
679
- entrypoint.lifecycle.registerShutdownHook("prodAppRedisSseBroker", async () => {
680
- await broker.close();
681
- });
682
- }
683
- // `extraRoutes` itself is already mounted — buildServer wires it inside
684
- // createApiEntrypoint/createAllInOneEntrypoint, before this function ever
685
- // runs. `wire` is the remaining escape-hatch for app-wired co-running
686
- // components that need the system-write dispatcher but don't declare a
687
- // route (kumiko-framework#3050).
688
- if (options.wire) {
689
- await options.wire({
690
- db,
691
- redis,
692
- registry,
693
- ...(bootCrypto.masterKeyProvider && { masterKeyProvider: bootCrypto.masterKeyProvider }),
694
- ...(bootCrypto.configCipher && { configEncryption: bootCrypto.configCipher }),
695
- metricsFor: createMetricsForFactory(entrypoint.observability.meter),
696
- dispatchSystemWrite: makeDispatchSystemWrite(entrypoint.dispatcher),
697
- dispatchSystemQuery: makeDispatchSystemQuery(entrypoint.dispatcher),
698
- });
699
- }
700
- // 9. Seeds: admin first, then config-seeds from r.config({seeds}),
701
- // then app-specific. All idempotent — runProdApp doesn't gate
702
- // "first boot" via flag, every seed-step checks its own
703
- // preconditions. Config-seeds rely on a deterministic
704
- // aggregate-id so re-boot becomes a version_conflict skip.
705
- if (effectiveAuth?.admin) {
706
- await seedAdminGuarded(db, effectiveAuth.admin);
707
- }
708
- for (const seed of bootOptionWiring.shortcutSeeds) {
709
- await seed(db);
710
- }
711
- await applyBootSeeds({
128
+ featureToggles: config.bootOptionWiring.featureToggles,
129
+ db: connections.db,
712
130
  registry,
713
- db,
714
- ...(bootCrypto.configCipher && { cipher: bootCrypto.configCipher }),
131
+ cacheSync: connections.cacheSync,
715
132
  });
716
- const customSeeds = bootOptionWiring.customSeeds;
717
- const hasSeeds = customSeeds.length > 0 || options.seedsDir !== undefined;
718
- // Seeds are bootstrap writes, not tenant traffic: they always get the unlimited resolver,
719
- // even when the app has a real one, so a handler's rateLimit can neither fail nor drain them.
720
- const seedContext = hasSeeds
721
- ? withFileProviderResolver(registry, {
722
- db,
723
- redis,
724
- entityCache,
725
- registry,
726
- ...extraContext,
727
- rateLimit: createUnlimitedRateLimitResolver(),
728
- }, cacheSync)
729
- : undefined;
730
- const seedDispatcher = seedContext
731
- ? withSeedErrorContext(createDispatcher(registry, seedContext, {
732
- ...baseEntrypointOptions.dispatcherOptions,
733
- ...(entrypoint.mode === "all-in-one" && { jobRunner: entrypoint.jobRunner }),
734
- }))
735
- : undefined;
736
- if (seedDispatcher && seedContext) {
737
- const createFileRef = makeSeedCreateFileRef({ db, registry, context: seedContext });
738
- for (const seed of customSeeds) {
739
- await seed({
740
- db,
741
- registry,
742
- dispatcher: seedDispatcher,
743
- createFileRef,
744
- ...(bootCrypto.masterKeyProvider && { masterKeyProvider: bootCrypto.masterKeyProvider }),
745
- ...(bootCrypto.configCipher && { configEncryption: bootCrypto.configCipher }),
746
- });
747
- }
748
- }
749
- // ES-Operations / Seed-Migrations (Phase 1). Läuft NACH applyBootSeeds +
750
- // existing seeds-array — die deklarativen Seeds sind die "always-insert-
751
- // if-missing"-Schicht; seed-migrations sind die "diff-and-update"-
752
- // Schicht für Drift den existing Seeds nicht erfassen können (z.B.
753
- // Membership-Roles-Change nach initialer Seed-Erstellung).
754
- if (seedDispatcher && options.seedsDir !== undefined && envSource["KUMIKO_SKIP_ES_OPS"] !== "1") {
755
- await createEsOperationsTable(db);
756
- await runPendingSeedMigrations({
757
- db,
758
- seedsDir: options.seedsDir,
759
- appliedBy: "boot",
760
- registry, // → dry-run-validator catched handler-QN-typos vor dem write
761
- // @wrapper-known semantic-alias
762
- createContext: (dbRunner) => createSeedMigrationContext({ dispatcher: seedDispatcher, dbRunner }),
763
- });
764
- }
765
- await entrypoint.start();
766
- await recordAppStartedOnBoot({ db, envSource });
767
- startPiiEventBackfillOnBoot({
768
- db,
769
- registry,
770
- lifecycle: entrypoint.lifecycle,
771
- envSource,
772
- });
773
- // 11. Build the fetch-handler. Static-fallback for non-/api/ paths
774
- // wired via a wrapper so Hono owns /api/* + extraRoutes and disk
775
- // owns the rest. Tests use this directly; listen() wraps it in
776
- // Bun.serve.
777
- const cspScriptHashes = await resolveCspScriptHashes(options.staticDir, options.securityHeaders);
778
- const hostDispatchScriptHashes = options.hostDispatch !== undefined && options.staticDir !== undefined
779
- ? await resolveHostDispatchScriptHashes(options.staticDir, options.securityHeaders, cspScriptHashes)
780
- : [];
781
- const fetchHandler = withSecurityHeaders(bootOptionWiring.wrapFetch(options.staticDir
782
- ? buildStaticFallback(entrypoint.app.fetch.bind(entrypoint.app), options.staticDir, options.hostDispatch, options.resolvePageHead
783
- ? { resolvePageHead: options.resolvePageHead, dispatcher: entrypoint.dispatcher }
784
- : undefined, entrypoint.dispatcher, trustedProxyHops ?? 0, hostDispatchScriptHashes)
785
- : // No staticDir (split-deploy / API-only container) → app.fetch's
786
- // response goes straight to the client, bypassing buildStaticFallback
787
- // (and with it tryHonoFirst) entirely. Must strip the router-miss
788
- // marker here too, same reason as the /api/* passthrough in
789
- // run-prod-app-static-files.ts (see try-hono-first.ts).
790
- async (req, socketAddress) => stripNoRouteMatchHeader(await entrypoint.app.fetch(req, socketAddress))), options.securityHeaders, cspScriptHashes);
791
- // 11. Mark lifecycle ready — health/ready flips to 200 after this.
133
+ await assertProdSchemaCurrent(connections.db, options.migrations, log);
134
+ const requestContext = await buildProdRequestContext(options, config, composed, connections);
135
+ const authWiring = await wireProdAuth(config, composed, connections);
136
+ const entrypointOptions = buildProdEntrypointOptions(options, config, composed, connections, requestContext, authWiring, effectiveFeatures);
137
+ const entrypoint = createProdEntrypoint(options, config, composed, connections, entrypointOptions);
138
+ registerProdShutdownHooks(entrypoint, connections.cacheSync, requestContext.ownedRedisSseBroker);
139
+ await runProdWireHook(options, composed, connections, entrypoint);
140
+ await runProdBootSeeds({ options, config, composed, connections, requestContext }, entrypoint, entrypointOptions.dispatcherOptions);
141
+ await startProdBackgroundWork(entrypoint, config, composed, connections);
142
+ const fetch = await buildProdFetchHandler(options, config, entrypoint);
143
+ // Mark lifecycle ready — health/ready flips to 200 after this.
792
144
  entrypoint.lifecycle.markReady();
793
- // Upgrade rejections (401/403/426...) are ordinary responses and get the same headers as fetchHandler.
794
- const securedUpgradeFetch = withSecurityHeaders(async (req, env) => stripNoRouteMatchHeader(await entrypoint.app.fetch(req, env)), options.securityHeaders, cspScriptHashes);
795
- const webSocketUpgradeFetch = async (req, env) => securedUpgradeFetch(req, env);
796
- let uninstallShutdownSignals;
797
- let uninstallFatalHandlers;
798
- const handle = {
145
+ const handle = createProdAppHandle({
146
+ options,
147
+ config,
148
+ composed,
149
+ connections,
799
150
  entrypoint,
800
- fetch: fetchHandler,
801
- webSocketUpgradeFetch,
802
- listen: async (listenPort = port) => {
803
- // Bun.serve is the production HTTP. Tests don't call listen()
804
- // because vitest runs under Node where Bun.serve doesn't exist.
805
- // Options-Shape (inkl. idleTimeout: 0 für SSE) liegt in der
806
- // exportierten buildBunServeOptions-Funktion — siehe ihren
807
- // Header für die Begründung.
808
- if (typeof Bun === "undefined") {
809
- // Klare Fehlermeldung statt nackter ReferenceError. Trifft wenn
810
- // jemand listen() unter Node/vitest aufruft ohne autoListen:false
811
- // — hilft beim Debug, statt sich an "Bun is not defined" abzumühen.
812
- throw new Error("[runProdApp] listen() requires Bun runtime (Bun.serve). " +
813
- "Under Node/vitest pass `autoListen: false` and call the returned `fetch()` directly.");
814
- }
815
- handle.server = Bun.serve(buildBunServeOptions(listenPort, fetchHandler, options.maxRequestBodySize ?? resolveDerivedMaxRequestBodySize(registry), { upgradeFetch: webSocketUpgradeFetch }));
816
- // SIGTERM/SIGINT — graceful shutdown. Only registered when we
817
- // actually own a Bun-server, otherwise the test process picks up
818
- // signals it shouldn't respond to.
819
- if (options.signals !== false) {
820
- uninstallShutdownSignals?.();
821
- uninstallShutdownSignals = installShutdownSignals({
822
- stop: () => handle.stop(),
823
- ...(options.signals?.drainTimeoutMs !== undefined && {
824
- drainTimeoutMs: options.signals.drainTimeoutMs,
825
- }),
826
- });
827
- uninstallFatalHandlers?.();
828
- uninstallFatalHandlers = installFatalErrorHandlers({
829
- logger: log,
830
- stop: () => handle.stop(),
831
- });
832
- }
833
- log.info(`ready on http://0.0.0.0:${listenPort}`);
834
- },
835
- stop: async () => {
836
- uninstallFatalHandlers?.();
837
- uninstallFatalHandlers = undefined;
838
- await entrypoint.stop();
839
- if (handle.server)
840
- await stopServerGracefully(handle.server);
841
- await flushEscapeHatchAudits();
842
- await closeDb();
843
- await closeOutsideTransactionDb();
844
- redis.disconnect();
845
- await bootKms.close();
846
- uninstallShutdownSignals?.();
847
- uninstallShutdownSignals = undefined;
848
- uninstallProcessLogger();
849
- },
850
- };
851
- // 12. Auto-listen unless explicitly suppressed (tests pass autoListen:
852
- // false because Bun.serve isn't available under vitest/node).
853
- // Production path: `await runProdApp({...})` and the server is up.
151
+ fetch,
152
+ log,
153
+ });
154
+ // Auto-listen unless explicitly suppressed (tests pass autoListen:
155
+ // false because Bun.serve isn't available under vitest/node).
156
+ // Production path: `await runProdApp({...})` and the server is up.
854
157
  if (options.autoListen !== false) {
855
158
  await handle.listen();
856
159
  }