@cosmicdrift/kumiko-server-runtime 0.328.1 → 0.330.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 (90) hide show
  1. package/README.md +15 -0
  2. package/dist/boot/apply-boot-seeds.d.ts +8 -0
  3. package/{src/boot/apply-boot-seeds.ts → dist/boot/apply-boot-seeds.js} +2 -10
  4. package/dist/boot/boot-crypto.d.ts +9 -0
  5. package/dist/boot/boot-crypto.js +39 -0
  6. package/dist/boot/job-run-logger.d.ts +19 -0
  7. package/dist/boot/job-run-logger.js +44 -0
  8. package/dist/boot/pii-event-backfill-on-boot.d.ts +13 -0
  9. package/dist/boot/pii-event-backfill-on-boot.js +25 -0
  10. package/dist/build-prod-bundle.d.ts +103 -0
  11. package/dist/build-prod-bundle.js +751 -0
  12. package/dist/bun-serve-options.d.ts +32 -0
  13. package/dist/bun-serve-options.js +49 -0
  14. package/dist/compose-features.d.ts +31 -0
  15. package/dist/compose-features.js +124 -0
  16. package/dist/extra-routes-deps.d.ts +30 -0
  17. package/dist/extra-routes-deps.js +4 -0
  18. package/dist/index.d.ts +6 -0
  19. package/dist/index.js +7 -0
  20. package/dist/pii-boot-gate.d.ts +13 -0
  21. package/dist/pii-boot-gate.js +46 -0
  22. package/dist/renderer-web-fonts.d.ts +9 -0
  23. package/{src/renderer-web-fonts.ts → dist/renderer-web-fonts.js} +14 -18
  24. package/dist/resolve-tailwind-cli.d.ts +14 -0
  25. package/dist/resolve-tailwind-cli.js +36 -0
  26. package/dist/run-prod-app-boot-context.d.ts +43 -0
  27. package/dist/run-prod-app-boot-context.js +198 -0
  28. package/dist/run-prod-app-static-files.d.ts +32 -0
  29. package/dist/run-prod-app-static-files.js +280 -0
  30. package/dist/run-prod-app.d.ts +471 -0
  31. package/dist/run-prod-app.js +741 -0
  32. package/dist/run-worker-app.d.ts +92 -0
  33. package/dist/run-worker-app.js +220 -0
  34. package/dist/security-headers.d.ts +20 -0
  35. package/dist/security-headers.js +49 -0
  36. package/dist/session-boot-gate.d.ts +11 -0
  37. package/dist/session-boot-gate.js +22 -0
  38. package/dist/session-wiring.d.ts +11 -0
  39. package/{src/session-wiring.ts → dist/session-wiring.js} +2 -6
  40. package/dist/try-hono-first.d.ts +24 -0
  41. package/{src/try-hono-first.ts → dist/try-hono-first.js} +8 -36
  42. package/package.json +102 -36
  43. package/src/changes.json +6 -0
  44. package/src/__tests__/boot-extra-context.test.ts +0 -163
  45. package/src/__tests__/boot-probe-fixture.ts +0 -64
  46. package/src/__tests__/build-prod-bundle.inprocess.test.ts +0 -233
  47. package/src/__tests__/build-prod-bundle.test.ts +0 -724
  48. package/src/__tests__/cache-headers.test.ts +0 -83
  49. package/src/__tests__/compose-features-mfa-wiring.integration.test.ts +0 -308
  50. package/src/__tests__/compose-features-wiring.integration.test.ts +0 -609
  51. package/src/__tests__/compose-features.test.ts +0 -206
  52. package/src/__tests__/config-seed-boot.integration.test.ts +0 -158
  53. package/src/__tests__/pii-boot-gate.test.ts +0 -112
  54. package/src/__tests__/renderer-web-css-relocation.integration.test.ts +0 -85
  55. package/src/__tests__/renderer-web-shell-sentinel.test.ts +0 -35
  56. package/src/__tests__/renderer-web-theme-plugin.integration.test.ts +0 -77
  57. package/src/__tests__/require-env.test.ts +0 -34
  58. package/src/__tests__/resolve-auth-mail.test.ts +0 -144
  59. package/src/__tests__/resolve-tailwind-cli.test.ts +0 -81
  60. package/src/__tests__/run-prod-app-dry-run.test.ts +0 -135
  61. package/src/__tests__/run-prod-app-env-source.test.ts +0 -118
  62. package/src/__tests__/run-prod-app-kms-slots.test.ts +0 -108
  63. package/src/__tests__/run-prod-app-spec.test.ts +0 -167
  64. package/src/__tests__/run-prod-app-static-files.test.ts +0 -298
  65. package/src/__tests__/run-prod-app.integration.test.ts +0 -1525
  66. package/src/__tests__/run-worker-app-boot.test.ts +0 -183
  67. package/src/__tests__/run-worker-app.integration.test.ts +0 -218
  68. package/src/__tests__/security-headers.test.ts +0 -88
  69. package/src/__tests__/session-boot-gate.test.ts +0 -70
  70. package/src/__tests__/session-wiring.test.ts +0 -16
  71. package/src/__tests__/try-hono-first.test.ts +0 -108
  72. package/src/__tests__/websocket-route.integration.test.ts +0 -580
  73. package/src/boot/__tests__/job-run-logger.integration.test.ts +0 -262
  74. package/src/boot/__tests__/job-run-logger.test.ts +0 -26
  75. package/src/boot/boot-crypto.ts +0 -67
  76. package/src/boot/job-run-logger.ts +0 -63
  77. package/src/boot/pii-event-backfill-on-boot.ts +0 -43
  78. package/src/build-prod-bundle.ts +0 -996
  79. package/src/bun-serve-options.ts +0 -110
  80. package/src/compose-features.ts +0 -197
  81. package/src/extra-routes-deps.ts +0 -38
  82. package/src/index.ts +0 -27
  83. package/src/pii-boot-gate.ts +0 -80
  84. package/src/resolve-tailwind-cli.ts +0 -45
  85. package/src/run-prod-app-boot-context.ts +0 -319
  86. package/src/run-prod-app-static-files.ts +0 -359
  87. package/src/run-prod-app.ts +0 -1420
  88. package/src/run-worker-app.ts +0 -366
  89. package/src/security-headers.ts +0 -67
  90. package/src/session-boot-gate.ts +0 -34
@@ -0,0 +1,741 @@
1
+ /// <reference types="bun-types" preserve="true" />
2
+ // runProdApp — production-grade Bootstrap-Wrapper für Kumiko-Apps.
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).
17
+ //
18
+ // App-Author schreibt:
19
+ // await runProdApp({ features, auth, anonymousAccess, seeds });
20
+ //
21
+ // Container/Coolify setzt:
22
+ // DATABASE_URL=postgresql://...
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:
28
+ // JWT_SECRET_V1=<random-32+> (repeat _V2, _V3, ... per rotation)
29
+ // JWT_SECRET_CURRENT_VERSION=1 (which V<n> signs new tokens; the others
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.
40
+ // PORT=3000
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 } 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, parseTrustedProxyHopsEnv, TRUSTED_PROXY_HOPS_ENV, } from "@cosmicdrift/kumiko-framework/api";
51
+ import { configureBlindIndexKey, configurePiiSubjectKms, resolvePlatformKeks, } from "@cosmicdrift/kumiko-framework/crypto";
52
+ import { configureEntityFieldEncryption, createDbConnection, } from "@cosmicdrift/kumiko-framework/db";
53
+ import { collectWriteHandlerQns, createRegistry, findTierResolverUsage, validateAppCustomScreenWriteQns, validateBoot, } from "@cosmicdrift/kumiko-framework/engine";
54
+ import { createAllInOneEntrypoint, createApiEntrypoint, } from "@cosmicdrift/kumiko-framework/entrypoint";
55
+ import { KumikoBootError, kmsSlotsOf, parseEnv, } from "@cosmicdrift/kumiko-framework/env";
56
+ import { renderDryRun } from "@cosmicdrift/kumiko-framework/env/dry-run";
57
+ import { createEsOperationsTable, createSeedMigrationContext, runPendingSeedMigrations, } from "@cosmicdrift/kumiko-framework/es-ops";
58
+ import { assertKumikoSchemaCurrent, SchemaDriftError, } from "@cosmicdrift/kumiko-framework/migrations";
59
+ import { createDispatcher, createEntityCache, createEventDedup, createIdempotencyGuard, } from "@cosmicdrift/kumiko-framework/pipeline";
60
+ import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
61
+ import { Redis } from "ioredis";
62
+ import { applyBootSeeds } from "./boot/apply-boot-seeds.js";
63
+ import { resolveBootCrypto } from "./boot/boot-crypto.js";
64
+ import { jobRunLoggerCallbacks } from "./boot/job-run-logger.js";
65
+ import { startPiiEventBackfillOnBoot } from "./boot/pii-event-backfill-on-boot.js";
66
+ import { buildBunServeOptions, resolveDerivedMaxRequestBodySize } from "./bun-serve-options.js";
67
+ import { buildComposeAuthOptions, composeFeatures } from "./compose-features.js";
68
+ import { makeDispatchSystemWrite } from "./extra-routes-deps.js";
69
+ import { assertPiiBootInvariants } from "./pii-boot-gate.js";
70
+ import { addConfigAccessorFactory, buildBootExtraContext, buildProdSessionAuth, resolveAuthMail, wireProdPatAutoRevoke, } from "./run-prod-app-boot-context.js";
71
+ import { buildStaticFallback } from "./run-prod-app-static-files.js";
72
+ import { withSecurityHeaders } from "./security-headers.js";
73
+ import { assertSessionBootInvariants } from "./session-boot-gate.js";
74
+ import { shouldWireProdSessions } from "./session-wiring.js";
75
+ import { stripNoRouteMatchHeader } from "./try-hono-first.js";
76
+ export { buildBunServeOptions } from "./bun-serve-options.js";
77
+ export { addConfigAccessorFactory, buildBootExtraContext, resolveAuthMail, } from "./run-prod-app-boot-context.js";
78
+ export { staticCachePolicy } from "./run-prod-app-static-files.js";
79
+ // Strict env-var read. Throws with a clear hint when missing — better
80
+ // than discovering a Postgres-connection-refused 30s into the boot.
81
+ // `src` defaults to process.env but is threaded from the caller's envSource
82
+ // so the boot-path reads the SAME env-quelle that was validated above —
83
+ // injected dummies in test-mode must not silently fall back to process.env.
84
+ export function requireEnv(name, src = process.env, context = "runProdApp") {
85
+ const value = src[name];
86
+ if (value === undefined || value === "") {
87
+ const advice = context === "runProdApp"
88
+ ? "Set it in your container env / .env.production / Coolify secrets."
89
+ : "Set it in your .env / shell before running the dev server.";
90
+ throw new Error(`${context}: required env var "${name}" is missing or empty. ${advice}`);
91
+ }
92
+ return value;
93
+ }
94
+ // Optional env helper — returns undefined for missing, string for set.
95
+ // Used for KUMIKO_INSTANCE_ID, JWT_ISSUER and other "nice to have" knobs.
96
+ function readEnv(name, src = process.env) {
97
+ const value = src[name];
98
+ return value === undefined || value === "" ? undefined : value;
99
+ }
100
+ function parseRunMode(raw) {
101
+ if (!raw)
102
+ return null;
103
+ const v = raw.toLowerCase();
104
+ if (v === "1" || v === "true" || v === "human")
105
+ return "human";
106
+ if (v === "json" || v === "pulumi" || v === "k8s" || v === "boot")
107
+ return v;
108
+ // biome-ignore lint/suspicious/noConsole: boot-time warn for typo discovery
109
+ console.warn(`[runProdApp] KUMIKO_DRY_RUN_ENV="${raw}" unrecognized ` +
110
+ `(expected 1|human|json|pulumi|k8s|boot); continuing with normal boot.`);
111
+ return null;
112
+ }
113
+ function isRenderMode(mode) {
114
+ return mode !== null && mode !== "boot";
115
+ }
116
+ function defaultBootErrorReporter(err) {
117
+ // biome-ignore lint/suspicious/noConsole: boot-time error, no logger configured yet
118
+ console.error(err.format());
119
+ process.exit(1);
120
+ }
121
+ // Returned from runProdApp when KUMIKO_DRY_RUN_ENV is set AND envSource
122
+ // was passed (= test-mode). The handle is intentionally inert — listen()
123
+ // and stop() are no-ops; tests inspect the dry-run console output and
124
+ // move on.
125
+ function makeDryRunHandle() {
126
+ const noop = async () => {
127
+ /* dry-run handle: no server was constructed */
128
+ };
129
+ return {
130
+ // @cast-boundary dry-run-mode: no ApiEntrypoint exists because no
131
+ // boot ran; the handle only surfaces test/CLI inspection and the
132
+ // entrypoint is never reached by callers in dry-run.
133
+ entrypoint: undefined,
134
+ fetch: () => new Response("dry-run", { status: 503 }),
135
+ webSocketUpgradeFetch: async () => new Response("dry-run", { status: 503 }),
136
+ listen: noop,
137
+ stop: noop,
138
+ };
139
+ }
140
+ let warnedLegacyJwtSecret = false;
141
+ function warnLegacyJwtSecretOnce() {
142
+ // skip: already warned once this process — avoid log spam on every boot path.
143
+ if (warnedLegacyJwtSecret)
144
+ return;
145
+ warnedLegacyJwtSecret = true;
146
+ // biome-ignore lint/suspicious/noConsole: boot-time ops hint, no logger configured this early
147
+ console.warn("[runProdApp] JWT keyring carries a legacy (pre-rotation) verify-only key — " +
148
+ "it never expires on its own. JWT_SECRET remains a required env (hmacSecret " +
149
+ "fallback + boot requireEnv). To stop verifying with the pre-rotation secret: " +
150
+ "set auth.mail.hmacSecret explicitly, then rotate JWT_SECRET to a fresh value " +
151
+ "that is no longer present in any in-flight token (see resolveAuthMail / " +
152
+ "loadJwtSecretOrKeyring).");
153
+ }
154
+ export async function runProdApp(options) {
155
+ // 0. Env-Schema validation + dry-run modes. Runs FIRST so:
156
+ // - operators can introspect env-requirements without a real boot
157
+ // (no DB connection needed, KUMIKO_DRY_RUN_ENV=… → render + exit)
158
+ // - missing/invalid env-vars produce a structured KumikoBootError
159
+ // with ALL problems aggregated (not first-fail), before we waste
160
+ // seconds on a Postgres connection that was never configured.
161
+ //
162
+ // Both code paths are no-ops when no envSchema is passed — Sprint-9
163
+ // migration is per-feature additive; pre-migration apps keep the
164
+ // legacy `requireEnv("DATABASE_URL")` checks below.
165
+ //
166
+ // Ordering invariant: this step runs BEFORE the Temporal polyfill,
167
+ // so env-schemas MUST use only Temporal-free Zod types. Don't author
168
+ // `z.iso.date()`/`Temporal.Instant` fields on env-vars — they'd crash
169
+ // at parse-time before the polyfill loads. Plain strings + .regex /
170
+ // .min / .email / .url cover every env-var shape we've actually
171
+ // needed in 9.1's audit (37 references, 25 distinct vars).
172
+ const rawEnvSource = options.envSource ?? process.env;
173
+ const runMode = parseRunMode(rawEnvSource["KUMIKO_DRY_RUN_ENV"]);
174
+ if (options.envSchema) {
175
+ if (isRenderMode(runMode)) {
176
+ // biome-ignore lint/suspicious/noConsole: dry-run output IS the deliverable
177
+ console.log(renderDryRun(options.envSchema, runMode, {
178
+ ...(options.pulumiPrefix ? { pulumiPrefix: options.pulumiPrefix } : {}),
179
+ sources: options.envSchema.sources,
180
+ }));
181
+ // Tests inject envSource and want a return-value, not exit. Detecting
182
+ // "this is a test" via envSource is brittle; instead exit when running
183
+ // against the real process.env (the deploy-flow), return otherwise.
184
+ if (options.envSource === undefined) {
185
+ process.exit(0);
186
+ }
187
+ return makeDryRunHandle();
188
+ }
189
+ // boot-mode AND normal-boot both run env-validation. boot-mode wants
190
+ // a real env-check (all required vars present + schema-valid) before
191
+ // it asserts feature-wiring works.
192
+ try {
193
+ parseEnv(options.envSchema.schema, rawEnvSource, {
194
+ sources: options.envSchema.sources,
195
+ ...(options.pulumiPrefix ? { pulumiPrefix: options.pulumiPrefix } : {}),
196
+ });
197
+ }
198
+ catch (err) {
199
+ if (err instanceof KumikoBootError) {
200
+ const reporter = options.bootErrorReporter ?? defaultBootErrorReporter;
201
+ reporter(err);
202
+ }
203
+ throw err;
204
+ }
205
+ }
206
+ // Slots the schema marks `kms` are decrypted once here so every consumer
207
+ // below (boot probe, master-key keyring, config) sees the same resolved env.
208
+ const envSource = options.envSchema
209
+ ? await resolvePlatformKeks(rawEnvSource, {
210
+ slots: kmsSlotsOf(options.envSchema.schema),
211
+ logPrefix: "[runProdApp]",
212
+ })
213
+ : rawEnvSource;
214
+ // 1. Polyfill before anything else — feature code references Temporal.
215
+ const { ensureTemporalPolyfill } = await import("@cosmicdrift/kumiko-framework/time");
216
+ await ensureTemporalPolyfill();
217
+ // 2. Env-vars: fail-fast. Better a 0s boot crash with a clear error
218
+ // than a 30s timeout chasing a Postgres connection that was never
219
+ // configured.
220
+ const databaseUrl = requireEnv("DATABASE_URL", envSource);
221
+ const redisUrl = requireEnv("REDIS_URL", envSource);
222
+ // JWT_SECRET stays mandatory (also the resolveAuthMail hmacSecret
223
+ // fallback below — a separate, non-rotating HMAC token family, not the
224
+ // session JWT). jwtSecretOrKeyring is the OPTIONAL upgrade: set
225
+ // JWT_SECRET_V<n> + JWT_SECRET_CURRENT_VERSION for zero-downtime
226
+ // rotation of the session-JWT signing key — see loadJwtSecretOrKeyring.
227
+ const jwtSecret = requireEnv("JWT_SECRET", envSource);
228
+ const jwtSecretOrKeyring = loadJwtSecretOrKeyring(envSource);
229
+ if (typeof jwtSecretOrKeyring === "object" && Object.hasOwn(jwtSecretOrKeyring.keys, "legacy")) {
230
+ // Once per process — JWT_SECRET stays a required env (requireEnv above +
231
+ // resolveAuthMail hmacSecret fallback). Retiring the *legacy verify key*
232
+ // means setting auth.mail.hmacSecret explicitly and rotating JWT_SECRET
233
+ // to a fresh value that is no longer in any in-flight token; unsetting
234
+ // JWT_SECRET entirely hard-crashes boot.
235
+ warnLegacyJwtSecretOnce();
236
+ }
237
+ const jwtIssuer = readEnv("JWT_ISSUER", envSource);
238
+ const instanceId = readEnv("KUMIKO_INSTANCE_ID", envSource);
239
+ // kumiko-framework#1539 — options.auth.trustedProxyHops wins; falls back
240
+ // to the env var so ops can close the XFF-spoofing hole per-deployment
241
+ // without a code change (mirrors instanceId's env-first pattern above).
242
+ const trustedProxyHops = options.trustedProxyHops ??
243
+ options.auth?.trustedProxyHops ??
244
+ parseTrustedProxyHopsEnv(readEnv(TRUSTED_PROXY_HOPS_ENV, envSource), "runProdApp");
245
+ const port = options.port ?? Number.parseInt(envSource["PORT"] ?? "3000", 10);
246
+ // biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
247
+ console.log(`[runProdApp] booting Kumiko stack on port ${port}…`);
248
+ // auth.mail → expandiert in die expliziten Flow-Felder, bevor sie sowohl
249
+ // die Feature-Side (buildComposeAuthOptions) als auch das Routes-Fragment
250
+ // unten speisen. Ab hier IMMER effectiveAuth statt options.auth lesen.
251
+ const effectiveAuth = options.auth
252
+ ? resolveAuthMail(options.auth, jwtSecret, envSource)
253
+ : undefined;
254
+ // 3. Feature registry. Auth-mode auto-mixes config/user/tenant/auth-email-
255
+ // password via composeFeatures — same source-of-truth as runDevApp
256
+ // AND the per-app drizzle-Schema-Generator, so Migration und Runtime
257
+ // sehen exakt dieselbe Liste. Built BEFORE any connection so boot-mode
258
+ // can validate wiring and exit without opening a Postgres/Redis socket.
259
+ const composeAuthOptions = buildComposeAuthOptions(effectiveAuth);
260
+ const features = composeFeatures(options.features, {
261
+ includeBundled: !!effectiveAuth,
262
+ ...(composeAuthOptions && { authOptions: composeAuthOptions }),
263
+ });
264
+ validateBoot(features, { env: envSource, ...options.validateBootOptions });
265
+ warnIfNonUtcServerTimeZone();
266
+ validateAppCustomScreenWriteQns(process.cwd(), collectWriteHandlerQns(features));
267
+ assertPiiBootInvariants(features, {
268
+ kms: options.kms,
269
+ blindIndexKey: options.blindIndexKey,
270
+ allowPlaintextPii: options.allowPlaintextPii,
271
+ mode: "prod",
272
+ });
273
+ const sessionsFeature = features.find((f) => f.name === SESSIONS_FEATURE);
274
+ const registry = createRegistry(features);
275
+ assertSessionBootInvariants({
276
+ hasAuth: Boolean(effectiveAuth),
277
+ sessionStoreProviderMounted: registry.getExtensionUsages(EXT_SESSION_STORE).length > 0,
278
+ mode: "prod",
279
+ });
280
+ // C1 boot-mode exit: validators ran + registry built; no DB/Redis client
281
+ // is constructed at all in this branch (the eager `new Redis(...)` below
282
+ // would otherwise open a TCP connect just to immediately disconnect it).
283
+ if (runMode === "boot") {
284
+ // biome-ignore lint/suspicious/noConsole: boot-mode output IS the deliverable
285
+ console.log(`[runProdApp] boot validation OK (${features.length} features, ${registry.features.size} registry entries)`);
286
+ if (options.envSource === undefined) {
287
+ process.exit(0);
288
+ }
289
+ return makeDryRunHandle();
290
+ }
291
+ // 4. Connections — Postgres + Redis. The Redis client is shared by
292
+ // idempotency, event-dedup, entity-cache, rate-limit; failing to
293
+ // construct here surfaces the misconfig immediately. `new Redis(...)`
294
+ // connects eagerly, so it must stay AFTER the boot-mode exit above.
295
+ // KMS health gate: an app configured for crypto-shredding must not boot
296
+ // against an unreachable key store — a silent start would answer every
297
+ // PII read with 503. Runs BEFORE connections so an abort leaks nothing.
298
+ if (options.kms) {
299
+ const kmsHealth = await options.kms.health();
300
+ if (!kmsHealth.ok) {
301
+ throw new Error(`[runProdApp] BOOT ABORTED — KMS health check failed (latency ${kmsHealth.latencyMs}ms)`);
302
+ }
303
+ }
304
+ const { db, close: closeDb } = createDbConnection(databaseUrl);
305
+ const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
306
+ // Sprint-8a Tier-Composition auto-wire: scan features for a
307
+ // tenantTierResolver-extension. If found AND user didn't supply own
308
+ // effectiveFeatures, build the resolver here (db + registry are
309
+ // available) before the dispatcher is constructed. App-Author sees
310
+ // nothing — `createTierEngineFeature(opts)` mounts + framework auto-wires.
311
+ let resolvedEffectiveFeatures = options.effectiveFeatures;
312
+ if (resolvedEffectiveFeatures === undefined) {
313
+ const tierResolverUsage = findTierResolverUsage(features);
314
+ if (tierResolverUsage) {
315
+ const plugin = tierResolverUsage.options;
316
+ resolvedEffectiveFeatures = await plugin.build({ db, registry });
317
+ }
318
+ }
319
+ // 5. Schema-Drift-Gate (drizzle-frei, kumiko/migrations). `kumiko schema
320
+ // apply` läuft als Deploy-Step VOR dem Container-Rollout. Boot prüft nur:
321
+ // (a) Alle Migrations aus kumiko/migrations/*.sql sind in
322
+ // _kumiko_migrations applied (+ checksum unverändert)
323
+ // (b) Alle Tabellen aus kumiko/migrations/.snapshot.json existieren
324
+ // Drift = Boot-Error mit klarer Meldung (kein Auto-Heal — mehrere
325
+ // Container-Replicas würden sonst race-conditionen fahren). Opt-out via
326
+ // `migrations: false` für custom Schema-Setups.
327
+ if (options.migrations !== false) {
328
+ const migrationsDir = options.migrations?.dir ?? "./kumiko/migrations";
329
+ // biome-ignore lint/suspicious/noConsole: boot-time progress hint
330
+ console.log(`[runProdApp] checking schema drift (${migrationsDir})…`);
331
+ try {
332
+ await assertKumikoSchemaCurrent(db, migrationsDir);
333
+ }
334
+ catch (err) {
335
+ if (err instanceof SchemaDriftError) {
336
+ // biome-ignore lint/suspicious/noConsole: terminal error message
337
+ console.error(`\n[runProdApp] BOOT ABORTED — ${err.message}\n`);
338
+ }
339
+ throw err;
340
+ }
341
+ }
342
+ // 6. Pipeline pieces — same default config as runDevApp's setupTestStack.
343
+ const idempotency = createIdempotencyGuard(redis, { ttlSeconds: 60 });
344
+ const eventDedup = createEventDedup(redis, { ttlSeconds: 60 });
345
+ const entityCache = createEntityCache(redis, { ttlSeconds: 60 });
346
+ // 7. Lifecycle is built by createApiEntrypoint when not supplied —
347
+ // we let the entrypoint own it and read it back through the handle
348
+ // for SIGTERM.
349
+ //
350
+ // extraContext + anonymousAccess sind factory-union: entweder direktes
351
+ // Object oder Function die {db, redis, registry} bekommt und das Object
352
+ // returned. Factory-Form gilt als bevorzugt für Cases die zur Boot-Zeit
353
+ // gegen die DB resolven müssen (z.B. Subdomain-Tenant-Lookup im
354
+ // tenantResolver) — die Factory closure'd `db` und der Resolver kann
355
+ // sie zur Request-Zeit aufrufen.
356
+ // Build the sseBroker here (instead of letting createApiEntrypoint do it
357
+ // internally) so extraContext factories can close over it at boot time —
358
+ // e.g. an extraContext provider that publishes SSE events directly. We
359
+ // pass the same broker into createApiEntrypoint (sseBroker? option) so
360
+ // the server-internal broadcast and app-specific publishes run over
361
+ // exactly one broker. createDefaultSseBroker (not createSseBroker
362
+ // directly) is what makes that broker Redis-backed under REDIS_URL —
363
+ // an unconditional in-memory broker here would silently win over
364
+ // buildServer's own REDIS_URL default (ServerOptions.sseBroker always
365
+ // wins), which is exactly how fw#2630's cross-replica fanout fix shipped
366
+ // without ever taking effect in production.
367
+ const { sseBroker, ownedRedisSseBroker } = createDefaultSseBroker(envSource);
368
+ const deps = { db, redis, registry, sseBroker };
369
+ const resolvedExtraContext = typeof options.extraContext === "function"
370
+ ? options.extraContext(deps)
371
+ : (options.extraContext ?? {});
372
+ // Framework-Default-Provider zuerst, App-Werte (resolvedExtraContext)
373
+ // gewinnen immer (z.B. money-horse's eigener configResolver).
374
+ const bootCrypto = resolveBootCrypto(envSource, options.masterKey);
375
+ // App-wide cipher for `encrypted: true` entity fields — executors resolve
376
+ // it lazily, entities without encrypted fields never touch it.
377
+ configureEntityFieldEncryption(bootCrypto.entityFieldCipher);
378
+ // Subject-key KMS for pii-annotated fields (crypto-shredding, #724).
379
+ // No adapter = engine off, plaintext as before — the warning keeps the gap
380
+ // visible until the prod-grade PgKmsAdapter (phase E) makes a hard boot
381
+ // gate viable (InMemory in prod would lose every DEK on restart).
382
+ configurePiiSubjectKms(options.kms);
383
+ // Blind-Index-Key (#818) — Boot-Gate: mit aktivem KMS würden lookupable-
384
+ // Felder als Ciphertext gespeichert, ohne Key gäbe es keinen bidx-Arm und
385
+ // jeder Equality-Lookup (Login by email!) liefe ins Leere. Fail-fast statt
386
+ // silent-broken-auth.
387
+ configureBlindIndexKey(options.blindIndexKey);
388
+ const autoExtraContext = buildBootExtraContext({
389
+ db,
390
+ features,
391
+ envSource,
392
+ registry,
393
+ hasAuth: !!effectiveAuth,
394
+ sseBroker,
395
+ crypto: bootCrypto,
396
+ ...(options.kms && { kms: options.kms }),
397
+ });
398
+ const extraContext = {
399
+ ...addConfigAccessorFactory({ ...autoExtraContext, ...resolvedExtraContext }, registry),
400
+ ...(options.defaultLocale !== undefined && { defaultLocale: options.defaultLocale }),
401
+ };
402
+ const baseAnonymousAccess = typeof options.anonymousAccess === "function"
403
+ ? options.anonymousAccess(deps)
404
+ : options.anonymousAccess;
405
+ // #1374: tenantResolver / tenantExists come from auth-foundation providers.
406
+ const resolvedAnonymousAccess = await resolveAnonymousAccessFromRegistry(baseAnonymousAccess, {
407
+ db,
408
+ registry,
409
+ });
410
+ // Sessions opt-in: db ist hier schon konkret (createDbConnection oben),
411
+ // also direkt verdrahten — kein late-bound nötig wie bei runDevApp.
412
+ // Ein JWT ohne sid wird abgelehnt, sobald ein sessionChecker verdrahtet ist —
413
+ // Prod-Sessions können nicht stillschweigend umgangen werden. sessionMassRevoker
414
+ // (4. callback aus createSessionCallbacks) ist nicht Teil der
415
+ // AuthRoutesConfig-Surface — der geht via bindAutoRevokeFromFeature ans
416
+ // sessions-Feature (Password-Change/-Reset revoked alle Sessions), nicht
417
+ // über die auth-routes.
418
+ // Secure-by-default (#1372): sessionStore provider → resolveSessionStore.
419
+ const mfaFeature = features.find((f) => f.name === AUTH_MFA_FEATURE);
420
+ // Hoisted above buildProdSessionAuth: that call also wires MFA-enable/
421
+ // disable → PAT-revoke (bindRevokeAllPatTokensFromFeature), so it needs
422
+ // patFeature too.
423
+ const patFeature = features.find((f) => f.name === PAT_FEATURE);
424
+ const sessionAuthFragment = shouldWireProdSessions(Boolean(effectiveAuth), registry.getExtensionUsages(EXT_SESSION_STORE).length > 0)
425
+ ? await buildProdSessionAuth(db, registry, sessionsFeature, mfaFeature, patFeature)
426
+ : undefined;
427
+ // Password-change → PAT-revoke: independent of sessions/MFA being mounted
428
+ // (buildProdSessionAuth only runs when a sessionStore provider is
429
+ // registered), so wired unconditionally here whenever PAT itself is.
430
+ if (patFeature) {
431
+ wireProdPatAutoRevoke(db, patFeature);
432
+ }
433
+ // Token-verifier opt-in: any provider feature (personal-access-tokens, a
434
+ // future auth-provider-jwt, ...) self-registers via
435
+ // r.useExtension(EXT_TOKEN_VERIFIER, ...) — wire one generic resolver
436
+ // whenever at least one is mounted, resolved by shape at request-time.
437
+ // PAT keeps its own per-token rate limiter (patRateLimiter), unrelated to
438
+ // verification.
439
+ const hasTokenVerifierProviders = registry.getExtensionUsages(EXT_TOKEN_VERIFIER).length > 0;
440
+ let patAuthFragment;
441
+ if (effectiveAuth && patFeature && hasTokenVerifierProviders) {
442
+ const rl = patRateLimitFromFeature(patFeature);
443
+ patAuthFragment = {
444
+ tokenVerifier: (rawToken) => resolveTokenVerifier({ db, registry }, rawToken),
445
+ patRateLimiter: createRedisLoginRateLimiter(redis, rl.maxRequests, rl.windowMs, "pat"),
446
+ };
447
+ }
448
+ const baseEntrypointOptions = {
449
+ registry,
450
+ context: {
451
+ db,
452
+ redis,
453
+ entityCache,
454
+ registry,
455
+ ...extraContext,
456
+ },
457
+ sseBroker,
458
+ jwtSecret: jwtSecretOrKeyring,
459
+ ...(jwtIssuer && { jwtIssuer }),
460
+ ...(instanceId && { instanceId }),
461
+ dispatcherOptions: {
462
+ idempotency,
463
+ ...(resolvedEffectiveFeatures && { effectiveFeatures: resolvedEffectiveFeatures }),
464
+ },
465
+ eventDedup,
466
+ ...(options.observability && { observability: options.observability }),
467
+ ...(options.observabilityOptions && { observabilityOptions: options.observabilityOptions }),
468
+ ...(options.metrics && { metrics: options.metrics }),
469
+ ...(options.rateLimit && { rateLimit: options.rateLimit }),
470
+ ...(trustedProxyHops !== undefined && { trustedProxyHops }),
471
+ ...(options.extraRoutes && { extraRoutes: options.extraRoutes }),
472
+ ...(effectiveAuth && {
473
+ auth: {
474
+ membershipQuery: TenantQueries.memberships,
475
+ userQuery: UserQueries.findForAuth,
476
+ loginHandler: AuthHandlers.login,
477
+ loginErrorStatusMap: effectiveAuth.loginErrorStatusMap ?? {
478
+ [AuthErrors.invalidCredentials]: 401,
479
+ [AuthErrors.noMembership]: 403,
480
+ },
481
+ // Redis-backed, not the in-memory default createAuthRoutes falls
482
+ // back to — an in-process limiter only rate-limits within a single
483
+ // replica, so a multi-instance prod deployment would silently give
484
+ // each replica its own bucket (#1262/#1274). Redis is required infra
485
+ // here already (REDIS_URL), so this is free.
486
+ loginRateLimit: createRedisLoginRateLimiter(redis),
487
+ ...(effectiveAuth.cookieDomain !== undefined && {
488
+ cookieDomain: effectiveAuth.cookieDomain,
489
+ }),
490
+ ...(effectiveAuth.allowedOrigins !== undefined && {
491
+ allowedOrigins: effectiveAuth.allowedOrigins,
492
+ }),
493
+ ...(effectiveAuth.unsafeSkipOriginCheck !== undefined && {
494
+ unsafeSkipOriginCheck: effectiveAuth.unsafeSkipOriginCheck,
495
+ }),
496
+ ...(trustedProxyHops !== undefined && { trustedProxyHops }),
497
+ ...(effectiveAuth.postAuthLanding !== undefined && {
498
+ postAuthLanding: effectiveAuth.postAuthLanding,
499
+ }),
500
+ ...sessionAuthFragment,
501
+ ...patAuthFragment,
502
+ ...(mfaFeature && {
503
+ mfaVerifyHandler: AuthMfaHandlers.verify,
504
+ mfaPreauthEnableStartHandler: AuthMfaHandlers.enableStartPreauth,
505
+ mfaPreauthConfirmHandler: AuthMfaHandlers.enableConfirmPreauth,
506
+ mfaVerifyRateLimit: createRedisLoginRateLimiter(redis, undefined, undefined, "mfa-verify"),
507
+ mfaPreauthEnableStartRateLimit: createRedisLoginRateLimiter(redis, undefined, undefined, "mfa-preauth-start"),
508
+ mfaPreauthConfirmRateLimit: createRedisLoginRateLimiter(redis, undefined, undefined, "mfa-preauth-confirm"),
509
+ }),
510
+ ...(effectiveAuth.passwordReset && {
511
+ passwordReset: {
512
+ requestHandler: AuthHandlers.requestPasswordReset,
513
+ confirmHandler: AuthHandlers.resetPassword,
514
+ },
515
+ }),
516
+ ...(effectiveAuth.emailVerification && {
517
+ emailVerification: {
518
+ requestHandler: AuthHandlers.requestEmailVerification,
519
+ confirmHandler: AuthHandlers.verifyEmail,
520
+ },
521
+ }),
522
+ ...(effectiveAuth.accountUnlock && {
523
+ accountUnlock: {
524
+ requestHandler: AuthHandlers.requestAccountUnlock,
525
+ confirmHandler: AuthHandlers.confirmAccountUnlock,
526
+ },
527
+ }),
528
+ ...(effectiveAuth.signup && {
529
+ signup: {
530
+ requestHandler: AuthHandlers.signupRequest,
531
+ confirmHandler: AuthHandlers.signupConfirm,
532
+ },
533
+ }),
534
+ ...(effectiveAuth.invite && {
535
+ invite: {
536
+ acceptHandler: AuthHandlers.inviteAccept,
537
+ acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
538
+ signupCompleteHandler: AuthHandlers.inviteSignupComplete,
539
+ infoHandler: AuthQueries.inviteInfo,
540
+ },
541
+ }),
542
+ },
543
+ }),
544
+ ...(resolvedAnonymousAccess && { anonymousAccess: resolvedAnonymousAccess }),
545
+ };
546
+ // Deploy-Topologie. Default (Single-Container): createAllInOneEntrypoint
547
+ // fährt HTTP + BEIDE Job-Lanes (zwei Runner, jeder schedult seine eigene
548
+ // Lane-Crons → kein Double-Fire) + Event-Dispatcher inline. So laufen
549
+ // worker-Lane-Crons (run-export-jobs default runIn:"worker") UND MSPs ohne
550
+ // separaten Worker-Process — die Asymmetrie, an der der Daten-Export hing.
551
+ // runSingleInstance:false → API-only; ein dezidierter Worker MUSS dann die
552
+ // worker-Lane + MSPs übernehmen (api-Lane-Jobs laufen weiter lokal).
553
+ // Default single-instance. eventDispatcher.disabled ist die Alt-Art, MSPs
554
+ // diesem Prozess wegzunehmen (dezidierter Worker) — als runSingleInstance:
555
+ // false honorieren (api-only, kein lokaler Dispatcher), damit der explizite
556
+ // Flag Vorrang behält aber Bestands-Caller nicht brechen.
557
+ const runSingleInstance = options.runSingleInstance ?? options.eventDispatcher?.disabled !== true;
558
+ const hasJobs = registry.getAllJobs().size > 0;
559
+ const queueNamePrefix = options.jobs?.queueNamePrefix;
560
+ const jobLogger = jobRunLoggerCallbacks(registry, db);
561
+ const dispatcherTunables = options.eventDispatcher?.pollIntervalMs !== undefined
562
+ ? { pollIntervalMs: options.eventDispatcher.pollIntervalMs }
563
+ : {};
564
+ const entrypoint = runSingleInstance
565
+ ? createAllInOneEntrypoint({
566
+ ...baseEntrypointOptions,
567
+ redisUrl,
568
+ ...jobLogger,
569
+ ...(queueNamePrefix !== undefined && { queueNamePrefix }),
570
+ ...(options.jobs?.getActiveTenantIds !== undefined && {
571
+ getActiveTenantIds: options.jobs.getActiveTenantIds,
572
+ }),
573
+ ...(!options.eventDispatcher?.disabled && { eventDispatcher: dispatcherTunables }),
574
+ })
575
+ : createApiEntrypoint({
576
+ ...baseEntrypointOptions,
577
+ ...(hasJobs && {
578
+ jobs: {
579
+ redisUrl,
580
+ runLocalJobs: true,
581
+ ...jobLogger,
582
+ ...(queueNamePrefix !== undefined && { queueNamePrefix }),
583
+ ...(options.jobs?.getActiveTenantIds !== undefined && {
584
+ getActiveTenantIds: options.jobs.getActiveTenantIds,
585
+ }),
586
+ },
587
+ }),
588
+ });
589
+ // The broker built above is always ours (runProdApp never accepts a
590
+ // caller-supplied sseBroker), so — same reasoning as buildServer's own
591
+ // ownedRedisSseBroker hook — closing it on shutdown is our job, not the
592
+ // entrypoint's. Registered after createApiEntrypoint/createAllInOneEntrypoint
593
+ // (which already registered the eventDispatcher/jobRunner hooks above),
594
+ // so it drains LIFO before those, matching the close-before-teardown
595
+ // order fw#2630 established for buildServer's own hook.
596
+ if (ownedRedisSseBroker) {
597
+ const broker = ownedRedisSseBroker;
598
+ entrypoint.lifecycle.registerShutdownHook("prodAppRedisSseBroker", async () => {
599
+ await broker.close();
600
+ });
601
+ }
602
+ // `extraRoutes` itself is already mounted — buildServer wires it inside
603
+ // createApiEntrypoint/createAllInOneEntrypoint, before this function ever
604
+ // runs. `wire` is the remaining escape-hatch for app-wired co-running
605
+ // components that need the system-write dispatcher but don't declare a
606
+ // route (kumiko-framework#3050).
607
+ if (options.wire) {
608
+ await options.wire({
609
+ db,
610
+ redis,
611
+ registry,
612
+ dispatchSystemWrite: makeDispatchSystemWrite(entrypoint.dispatcher),
613
+ });
614
+ }
615
+ // 9. Seeds: admin first, then config-seeds from r.config({seeds}),
616
+ // then app-specific. All idempotent — runProdApp doesn't gate
617
+ // "first boot" via flag, every seed-step checks its own
618
+ // preconditions. Config-seeds rely on a deterministic
619
+ // aggregate-id so re-boot becomes a version_conflict skip.
620
+ if (effectiveAuth) {
621
+ await seedAdminGuarded(db, effectiveAuth.admin);
622
+ }
623
+ await applyBootSeeds({
624
+ registry,
625
+ db,
626
+ ...(bootCrypto.configCipher && { cipher: bootCrypto.configCipher }),
627
+ });
628
+ for (const seed of options.seeds ?? []) {
629
+ await seed({ db });
630
+ }
631
+ // ES-Operations / Seed-Migrations (Phase 1). Läuft NACH applyBootSeeds +
632
+ // existing seeds-array — die deklarativen Seeds sind die "always-insert-
633
+ // if-missing"-Schicht; seed-migrations sind die "diff-and-update"-
634
+ // Schicht für Drift den existing Seeds nicht erfassen können (z.B.
635
+ // Membership-Roles-Change nach initialer Seed-Erstellung).
636
+ if (options.seedsDir !== undefined && envSource["KUMIKO_SKIP_ES_OPS"] !== "1") {
637
+ await createEsOperationsTable(db);
638
+ const seedDispatcher = createDispatcher(registry, {
639
+ db,
640
+ redis,
641
+ entityCache,
642
+ registry,
643
+ ...extraContext,
644
+ });
645
+ await runPendingSeedMigrations({
646
+ db,
647
+ seedsDir: options.seedsDir,
648
+ appliedBy: "boot",
649
+ registry, // → dry-run-validator catched handler-QN-typos vor dem write
650
+ // @wrapper-known semantic-alias
651
+ createContext: (dbRunner) => createSeedMigrationContext({ dispatcher: seedDispatcher, dbRunner }),
652
+ });
653
+ }
654
+ await entrypoint.start();
655
+ startPiiEventBackfillOnBoot({
656
+ db,
657
+ registry,
658
+ lifecycle: entrypoint.lifecycle,
659
+ envSource,
660
+ });
661
+ // 11. Build the fetch-handler. Static-fallback for non-/api/ paths
662
+ // wired via a wrapper so Hono owns /api/* + extraRoutes and disk
663
+ // owns the rest. Tests use this directly; listen() wraps it in
664
+ // Bun.serve.
665
+ const fetchHandler = withSecurityHeaders(options.staticDir
666
+ ? buildStaticFallback(entrypoint.app.fetch.bind(entrypoint.app), options.staticDir, options.hostDispatch, options.resolvePageHead
667
+ ? { resolvePageHead: options.resolvePageHead, dispatcher: entrypoint.dispatcher }
668
+ : undefined, entrypoint.dispatcher, trustedProxyHops ?? 0)
669
+ : // No staticDir (split-deploy / API-only container) → app.fetch's
670
+ // response goes straight to the client, bypassing buildStaticFallback
671
+ // (and with it tryHonoFirst) entirely. Must strip the router-miss
672
+ // marker here too, same reason as the /api/* passthrough in
673
+ // run-prod-app-static-files.ts (see try-hono-first.ts).
674
+ async (req, socketAddress) => stripNoRouteMatchHeader(await entrypoint.app.fetch(req, socketAddress)), options.securityHeaders);
675
+ // 11. Mark lifecycle ready — health/ready flips to 200 after this.
676
+ entrypoint.lifecycle.markReady();
677
+ // Upgrade rejections (401/403/426...) are ordinary responses and get the same headers as fetchHandler.
678
+ const securedUpgradeFetch = withSecurityHeaders(async (req, env) => stripNoRouteMatchHeader(await entrypoint.app.fetch(req, env)), options.securityHeaders);
679
+ const webSocketUpgradeFetch = async (req, env) => securedUpgradeFetch(req, env);
680
+ const handle = {
681
+ entrypoint,
682
+ fetch: fetchHandler,
683
+ webSocketUpgradeFetch,
684
+ listen: async (listenPort = port) => {
685
+ // Bun.serve is the production HTTP. Tests don't call listen()
686
+ // because vitest runs under Node where Bun.serve doesn't exist.
687
+ // Options-Shape (inkl. idleTimeout: 0 für SSE) liegt in der
688
+ // exportierten buildBunServeOptions-Funktion — siehe ihren
689
+ // Header für die Begründung.
690
+ if (typeof globalThis.Bun === "undefined") {
691
+ // Klare Fehlermeldung statt nackter ReferenceError. Trifft wenn
692
+ // jemand listen() unter Node/vitest aufruft ohne autoListen:false
693
+ // — hilft beim Debug, statt sich an "Bun is not defined" abzumühen.
694
+ throw new Error("[runProdApp] listen() requires Bun runtime (Bun.serve). " +
695
+ "Under Node/vitest pass `autoListen: false` and call the returned `fetch()` directly.");
696
+ }
697
+ handle.server = Bun.serve(buildBunServeOptions(listenPort, fetchHandler, options.maxRequestBodySize ?? resolveDerivedMaxRequestBodySize(registry), { upgradeFetch: webSocketUpgradeFetch }));
698
+ // SIGTERM/SIGINT — graceful shutdown. Only registered when we
699
+ // actually own a Bun-server, otherwise the test process picks up
700
+ // signals it shouldn't respond to.
701
+ let shuttingDown = false;
702
+ const shutdown = async (signal) => {
703
+ // skip: shutdown already in progress, avoid double-drain
704
+ if (shuttingDown)
705
+ return;
706
+ shuttingDown = true;
707
+ // biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
708
+ console.log(`[runProdApp] ${signal} received — draining…`);
709
+ try {
710
+ await handle.stop();
711
+ // biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
712
+ console.log("[runProdApp] graceful shutdown complete.");
713
+ }
714
+ catch (e) {
715
+ // biome-ignore lint/suspicious/noConsole: shutdown-time error, only path is stderr
716
+ console.error("[runProdApp] error during shutdown:", e);
717
+ }
718
+ finally {
719
+ process.exit(0);
720
+ }
721
+ };
722
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
723
+ process.on("SIGINT", () => void shutdown("SIGINT"));
724
+ // biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
725
+ console.log(`[runProdApp] ready on http://0.0.0.0:${listenPort}`);
726
+ },
727
+ stop: async () => {
728
+ await entrypoint.stop();
729
+ handle.server?.stop();
730
+ await closeDb();
731
+ redis.disconnect();
732
+ },
733
+ };
734
+ // 12. Auto-listen unless explicitly suppressed (tests pass autoListen:
735
+ // false because Bun.serve isn't available under vitest/node).
736
+ // Production path: `await runProdApp({...})` and the server is up.
737
+ if (options.autoListen !== false) {
738
+ await handle.listen();
739
+ }
740
+ return handle;
741
+ }