@cosmicdrift/kumiko-server-runtime 0.329.0 → 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 -695
  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
@@ -1,1420 +0,0 @@
1
- // runProdApp — production-grade Bootstrap-Wrapper für Kumiko-Apps.
2
- //
3
- // Symmetrisch zu runDevApp, aber:
4
- // - DATABASE_URL / REDIS_URL / JWT_SECRET aus env (fail-fast bei Boot,
5
- // keine ephemeralen Test-DBs)
6
- // - Hard Schema-Drift-Gate: prüft kumiko/migrations vs. _kumiko_migrations
7
- // + tableExists für jede erwartete Tabelle. KEIN Auto-CREATE TABLE im
8
- // Boot — Migration ist ein CI-Step (`bun kumiko schema apply`), Boot
9
- // validiert nur. Verhindert Race-Conditions bei Multi-Replica-Deploys
10
- // + macht Schema-Stand reviewbar in der Pull-Request.
11
- // - Idempotente Seeds: laufen nur wenn DB leer (über `isDbEmpty`-Probe
12
- // pro Seed). Re-Boots nach erstem Seed sind no-op.
13
- // - HTTP-Server via Bun.serve mit graceful SIGTERM/SIGINT → drain().
14
- // - Auth-Routes + bundled-features auto-mix wenn `auth:` gesetzt
15
- // (gleiche Logik wie runDevApp).
16
- //
17
- // App-Author schreibt:
18
- // await runProdApp({ features, auth, anonymousAccess, seeds });
19
- //
20
- // Container/Coolify setzt:
21
- // DATABASE_URL=postgresql://...
22
- // REDIS_URL=redis://...
23
- // JWT_SECRET=<random-32+> (always required — also signs the password-
24
- // reset/email-verification HMAC tokens, a separate non-rotating family)
25
- // — additionally, for zero-downtime rotation of the session-JWT
26
- // signing key specifically:
27
- // JWT_SECRET_V1=<random-32+> (repeat _V2, _V3, ... per rotation)
28
- // JWT_SECRET_CURRENT_VERSION=1 (which V<n> signs new tokens; the others
29
- // still verify in-flight tokens until they expire)
30
- // Adopting rotation for the first time (no JWT_SECRET_V<n> set yet): the
31
- // plain JWT_SECRET above stays set and is carried into the keyring as a
32
- // verify-only legacy key (loadJwtSecretOrKeyring), so sessions signed
33
- // before the cutover keep verifying — no mass-logout at adoption. This
34
- // legacy key never expires on its own (boot logs a warning while it's
35
- // present) — once max token TTL has elapsed since cutover, unset
36
- // JWT_SECRET to retire it. Check first whether an app relies on the
37
- // auth.mail convenience default for passwordReset/emailVerification
38
- // hmacSecret (falls back to this same JWT_SECRET) before unsetting it.
39
- // PORT=3000
40
- // KUMIKO_INSTANCE_ID=<stable per replica>
41
-
42
- import {
43
- type AccountLockoutOptions,
44
- type AccountUnlockOptions,
45
- AuthErrors,
46
- AuthHandlers,
47
- type AuthMailLocale,
48
- type AuthPaths,
49
- AuthQueries,
50
- type EmailVerificationOptions,
51
- type InviteOptions,
52
- type PasswordResetOptions,
53
- type SignupOptions,
54
- } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
55
- import {
56
- type SeedAdminOptions,
57
- seedAdminGuarded,
58
- } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
59
- import {
60
- EXT_SESSION_STORE,
61
- EXT_TOKEN_VERIFIER,
62
- resolveAnonymousAccessFromRegistry,
63
- resolveTokenVerifier,
64
- } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
65
- import { AUTH_MFA_FEATURE, AuthMfaHandlers } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
66
- import {
67
- PAT_FEATURE,
68
- patRateLimitFromFeature,
69
- } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
70
- import { SESSIONS_FEATURE } from "@cosmicdrift/kumiko-bundled-features/sessions";
71
- import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
72
- import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
73
- import {
74
- createDefaultSseBroker,
75
- createRedisLoginRateLimiter,
76
- type ExtraRouteDefinition,
77
- type KumikoServeEnv,
78
- type LoginRateLimiter,
79
- loadJwtSecretOrKeyring,
80
- type PostAuthLandingResolver,
81
- parseTrustedProxyHopsEnv,
82
- type SseBroker,
83
- type TokenVerifier,
84
- TRUSTED_PROXY_HOPS_ENV,
85
- } from "@cosmicdrift/kumiko-framework/api";
86
- import {
87
- configureBlindIndexKey,
88
- configurePiiSubjectKms,
89
- type KmsAdapter,
90
- resolvePlatformKeks,
91
- } from "@cosmicdrift/kumiko-framework/crypto";
92
- import {
93
- configureEntityFieldEncryption,
94
- createDbConnection,
95
- type DbRunner,
96
- } from "@cosmicdrift/kumiko-framework/db";
97
- import {
98
- collectWriteHandlerQns,
99
- createRegistry,
100
- type EffectiveFeaturesResolver,
101
- type FeatureDefinition,
102
- findTierResolverUsage,
103
- type TierResolverPlugin,
104
- type ValidateBootOptions,
105
- validateAppCustomScreenWriteQns,
106
- validateBoot,
107
- } from "@cosmicdrift/kumiko-framework/engine";
108
- import {
109
- type AllInOneEntrypoint,
110
- type ApiEntrypoint,
111
- createAllInOneEntrypoint,
112
- createApiEntrypoint,
113
- } from "@cosmicdrift/kumiko-framework/entrypoint";
114
- import {
115
- type ComposedEnvSchema,
116
- KumikoBootError,
117
- kmsSlotsOf,
118
- parseEnv,
119
- } from "@cosmicdrift/kumiko-framework/env";
120
- import { type DryRunMode, renderDryRun } from "@cosmicdrift/kumiko-framework/env/dry-run";
121
- import {
122
- createEsOperationsTable,
123
- createSeedMigrationContext,
124
- runPendingSeedMigrations,
125
- } from "@cosmicdrift/kumiko-framework/es-ops";
126
- import type { JobRunnerOptions } from "@cosmicdrift/kumiko-framework/jobs";
127
- import {
128
- assertKumikoSchemaCurrent,
129
- SchemaDriftError,
130
- } from "@cosmicdrift/kumiko-framework/migrations";
131
- import type {
132
- ObservabilityOptions,
133
- ObservabilityProvider,
134
- } from "@cosmicdrift/kumiko-framework/observability";
135
- import {
136
- createDispatcher,
137
- createEntityCache,
138
- createEventDedup,
139
- createIdempotencyGuard,
140
- } from "@cosmicdrift/kumiko-framework/pipeline";
141
- import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
142
- import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
143
- import type {
144
- PageHeadMeta,
145
- PageHeadResolver,
146
- PageHeadSystemQuery,
147
- } from "@cosmicdrift/kumiko-headless/apex";
148
- import Redis from "ioredis";
149
- import { applyBootSeeds } from "./boot/apply-boot-seeds";
150
- import { resolveBootCrypto } from "./boot/boot-crypto";
151
- import { jobRunLoggerCallbacks } from "./boot/job-run-logger";
152
- import { startPiiEventBackfillOnBoot } from "./boot/pii-event-backfill-on-boot";
153
- import { buildBunServeOptions, resolveDerivedMaxRequestBodySize } from "./bun-serve-options";
154
- import { buildComposeAuthOptions, composeFeatures } from "./compose-features";
155
- import { makeDispatchSystemWrite, type SystemWireDeps } from "./extra-routes-deps";
156
- import { assertPiiBootInvariants } from "./pii-boot-gate";
157
- import {
158
- addConfigAccessorFactory,
159
- buildBootExtraContext,
160
- buildProdSessionAuth,
161
- resolveAuthMail,
162
- wireProdPatAutoRevoke,
163
- } from "./run-prod-app-boot-context";
164
- import { buildStaticFallback } from "./run-prod-app-static-files";
165
- import { type SecurityHeadersOption, withSecurityHeaders } from "./security-headers";
166
- import { assertSessionBootInvariants } from "./session-boot-gate";
167
- import { shouldWireProdSessions } from "./session-wiring";
168
- import { stripNoRouteMatchHeader } from "./try-hono-first";
169
-
170
- export { buildBunServeOptions } from "./bun-serve-options";
171
- export {
172
- addConfigAccessorFactory,
173
- buildBootExtraContext,
174
- resolveAuthMail,
175
- } from "./run-prod-app-boot-context";
176
- export { staticCachePolicy } from "./run-prod-app-static-files";
177
-
178
- // Strict env-var read. Throws with a clear hint when missing — better
179
- // than discovering a Postgres-connection-refused 30s into the boot.
180
- // `src` defaults to process.env but is threaded from the caller's envSource
181
- // so the boot-path reads the SAME env-quelle that was validated above —
182
- // injected dummies in test-mode must not silently fall back to process.env.
183
- export function requireEnv(
184
- name: string,
185
- src: Record<string, string | undefined> = process.env,
186
- context = "runProdApp",
187
- ): string {
188
- const value = src[name];
189
- if (value === undefined || value === "") {
190
- const advice =
191
- context === "runProdApp"
192
- ? "Set it in your container env / .env.production / Coolify secrets."
193
- : "Set it in your .env / shell before running the dev server.";
194
- throw new Error(`${context}: required env var "${name}" is missing or empty. ${advice}`);
195
- }
196
- return value;
197
- }
198
-
199
- // Optional env helper — returns undefined for missing, string for set.
200
- // Used for KUMIKO_INSTANCE_ID, JWT_ISSUER and other "nice to have" knobs.
201
- function readEnv(
202
- name: string,
203
- src: Record<string, string | undefined> = process.env,
204
- ): string | undefined {
205
- const value = src[name];
206
- return value === undefined || value === "" ? undefined : value;
207
- }
208
-
209
- // `boot` is the C1 smoke-test path — validators run, no DB/Redis connect,
210
- // exit after registry-build. Render-modes (human|json|pulumi|k8s|1)
211
- // inspect the env-schema and exit before any feature wiring.
212
- type RunMode = DryRunMode | "boot";
213
-
214
- function parseRunMode(raw: string | undefined): RunMode | null {
215
- if (!raw) return null;
216
- const v = raw.toLowerCase();
217
- if (v === "1" || v === "true" || v === "human") return "human";
218
- if (v === "json" || v === "pulumi" || v === "k8s" || v === "boot") return v;
219
- // biome-ignore lint/suspicious/noConsole: boot-time warn for typo discovery
220
- console.warn(
221
- `[runProdApp] KUMIKO_DRY_RUN_ENV="${raw}" unrecognized ` +
222
- `(expected 1|human|json|pulumi|k8s|boot); continuing with normal boot.`,
223
- );
224
- return null;
225
- }
226
-
227
- function isRenderMode(mode: RunMode | null): mode is DryRunMode {
228
- return mode !== null && mode !== "boot";
229
- }
230
-
231
- function defaultBootErrorReporter(err: KumikoBootError): never {
232
- // biome-ignore lint/suspicious/noConsole: boot-time error, no logger configured yet
233
- console.error(err.format());
234
- process.exit(1);
235
- }
236
-
237
- // Returned from runProdApp when KUMIKO_DRY_RUN_ENV is set AND envSource
238
- // was passed (= test-mode). The handle is intentionally inert — listen()
239
- // and stop() are no-ops; tests inspect the dry-run console output and
240
- // move on.
241
- function makeDryRunHandle(): ProdAppHandle {
242
- const noop = async () => {
243
- /* dry-run handle: no server was constructed */
244
- };
245
- return {
246
- // @cast-boundary dry-run-mode: no ApiEntrypoint exists because no
247
- // boot ran; the handle only surfaces test/CLI inspection and the
248
- // entrypoint is never reached by callers in dry-run.
249
- entrypoint: undefined as unknown as ApiEntrypoint,
250
- fetch: () => new Response("dry-run", { status: 503 }),
251
- webSocketUpgradeFetch: async () => new Response("dry-run", { status: 503 }),
252
- listen: noop,
253
- stop: noop,
254
- };
255
- }
256
-
257
- /** Wrapper API for the password-reset flow.
258
- *
259
- * Since the delivery migration PasswordResetOptions carries `appUrl`
260
- * (+ appName/locale) itself and the handler mails via ctx.notify — no
261
- * sendResetEmail callback anymore. Apps pass `auth.mail` (convenience,
262
- * resolveAuthMail builds the appUrl) OR an explicit block.
263
- *
264
- * hmacSecret is optional here (unlike PasswordResetOptions): resolveAuthMail
265
- * backfills it, so overriding just appUrl needs no secret of its own. */
266
- export type PasswordResetSetup = Omit<PasswordResetOptions, "hmacSecret"> & {
267
- readonly hmacSecret?: string;
268
- };
269
-
270
- /** Wrapper API for the email-verification flow, symmetric to
271
- * PasswordResetSetup (appUrl via delivery, optional hmacSecret). */
272
- export type EmailVerificationSetup = Omit<EmailVerificationOptions, "hmacSecret"> & {
273
- readonly hmacSecret?: string;
274
- };
275
-
276
- /** Wrapper-API für Magic-Link Self-Signup. = SignupOptions (appUrl, die
277
- * Mail geht via delivery/ctx.notify wie reset/verify). Anders als reset/
278
- * verify gibt's KEIN hmacSecret — Signup-Tokens sind opaque random in
279
- * Redis, nicht HMAC-signed. */
280
- export type SignupSetup = SignupOptions;
281
-
282
- /** Wrapper-API für Tenant-Invite Magic-Link. = InviteOptions (appUrl, die
283
- * Invite-Mail geht via delivery wie reset/verify/signup). Drei accept-
284
- * Branches im framework; handler-names sind hardcoded in run-prod-app aus
285
- * AuthHandlers (analog signup). */
286
- export type InviteSetup = InviteOptions;
287
-
288
- /** Wrapper API for the account-unlock flow (#1266). = AccountUnlockOptions
289
- * (appUrl via delivery, symmetric to PasswordResetSetup). Self-service
290
- * escape hatch for accountLockout's monotonic failure counter — only
291
- * meaningful when `auth.accountLockout` is also set, but wired
292
- * independently like the other flows. */
293
- export type AccountUnlockSetup = AccountUnlockOptions;
294
-
295
- /** Wrapper API for the account-lockout brute-force guard on the login
296
- * handler. = AccountLockoutOptions (maxFailedAttempts, lockoutDurationMinutes
297
- * — no mail/appUrl fields, it doesn't mount any routes). Symmetric to
298
- * RunDevAppAuthOptions.accountLockout (kumiko-framework#1627) — before this,
299
- * `accountUnlock` existed on both wrappers with no way to ever set the
300
- * lockout it's meant to escape. */
301
- export type AccountLockoutSetup = AccountLockoutOptions;
302
-
303
- /** Auth-Mail-Convenience-Optionen — shared zwischen runProdApp + runDevApp.
304
- * Verdrahtet alle 4 Mail-Flows aus einem env-SMTP-Transport + Standard-
305
- * Templates (siehe `auth.mail` + resolveAuthMail). */
306
- export type AuthMailOptions = {
307
- /** App-Basis-URL inkl. Schema (z.B. "https://app.example.com"). */
308
- readonly baseUrl: string;
309
- /** App-Name für Mail-Subject + Body. Default "Account". */
310
- readonly appName?: string;
311
- /** Locale für die Mail-Templates. Default "de". */
312
- readonly locale?: AuthMailLocale;
313
- /** "strict" blockt Login bis emailVerified; "off" mountet ohne Gate. */
314
- readonly emailVerificationMode?: "strict" | "off";
315
- /** Fallback-From-Adresse wenn `SMTP_FROM`-env fehlt (env gewinnt). */
316
- readonly from?: string;
317
- /** Einzelne Auth-Pfade überschreiben (Default DEFAULT_AUTH_PATHS). */
318
- readonly paths?: Partial<AuthPaths>;
319
- /** Reset/verify token secret, decoupled from JWT_SECRET rotation.
320
- * Falls back to resolveAuthMail's call-site secret when unset. */
321
- readonly hmacSecret?: string;
322
- };
323
-
324
- export type RunProdAppAuthOptions = {
325
- /** Initial admin user. Seeded once (idempotent — re-boots check first
326
- * whether the email is already in the users table). */
327
- readonly admin: SeedAdminOptions;
328
- /** Optional override of the login error → HTTP status map. */
329
- readonly loginErrorStatusMap?: Readonly<Record<string, number>>;
330
- /** Auth-Mail-Convenience: verdrahtet alle 4 Mail-Flows (passwordReset,
331
- * emailVerification, signup, invite) aus `auth.mail.baseUrl` + Standard-
332
- * Pfaden. Alle vier mailen via delivery (ctx.notify) — ersetzt das per-App
333
- * hand-gerollte `send*Email`-Callback-Wiring.
334
- *
335
- * Null-Transport-Guard: ohne `SMTP_HOST`-env wird KEIN Mail-Flow
336
- * verdrahtet (Routes blieben sonst 500). Eine App die einen einzelnen
337
- * Flow custom braucht, setzt `passwordReset`/`signup`/… explizit —
338
- * der explizite Block gewinnt über den mail-Default.
339
- *
340
- * Bewusste Entscheidung: `mail` mountet ALLE 4 Flows inkl. signup +
341
- * invite (Self-Registration on). Wer nur reset+verify will, lässt `mail`
342
- * weg und setzt die gewünschten Flows explizit — kein impliziter
343
- * Self-Signup. */
344
- readonly mail?: AuthMailOptions;
345
- /** Password-reset flow. When set, /api/auth/request-password-reset +
346
- * /api/auth/reset-password are mounted as public routes UND der
347
- * request/confirm-Handler im auth-email-password-Feature wird
348
- * registriert (sonst dispatchen die Routes ins Leere → 500).
349
- * Überschreibt den `mail`-Default für genau diesen Flow. */
350
- readonly passwordReset?: PasswordResetSetup;
351
- /** Email-verification flow. Symmetric to passwordReset. */
352
- readonly emailVerification?: EmailVerificationSetup;
353
- /** Self-Signup flow (Magic-Link). When set, /api/auth/signup-request +
354
- * /api/auth/signup-confirm are mounted; signup-confirm mintet JWT +
355
- * Cookies wie ein erfolgreicher login (Auto-Login direkt nach
356
- * Activation). */
357
- readonly signup?: SignupSetup;
358
- /** Tenant-Invite flow (Magic-Link). When set, /api/auth/invite-accept,
359
- * /api/auth/invite-accept-with-login, /api/auth/invite-signup-complete
360
- * are mounted. */
361
- readonly invite?: InviteSetup;
362
- /** Account-unlock flow (#1266). When set, /api/auth/request-account-unlock
363
- * + /api/auth/confirm-account-unlock are mounted. Self-service escape
364
- * hatch for accountLockout's monotonic failure-counter — confirming
365
- * clears the Redis lockout state, no entity write. */
366
- readonly accountUnlock?: AccountUnlockSetup;
367
- /** Brute-force guard on the login handler (maxFailedAttempts,
368
- * lockoutDurationMinutes). Mounts no routes — unlike the other flows,
369
- * it's a login-handler config knob, not a magic-link flow. Pair with
370
- * `accountUnlock` for a self-service escape hatch. */
371
- readonly accountLockout?: AccountLockoutSetup;
372
- /** Domain attribute for both auth cookies (see
373
- * AuthRoutesConfig.cookieDomain). Set to the registrable parent
374
- * domain when login and app live on different subdomains. */
375
- readonly cookieDomain?: string;
376
- /** Server-side Origin allowlist for the CSRF guard (see
377
- * AuthRoutesConfig.allowedOrigins). REQUIRED once `cookieDomain` is set —
378
- * buildServer fails closed otherwise. Apex + admin host, never tenant
379
- * subdomains. */
380
- readonly allowedOrigins?: readonly string[];
381
- /** Opt out of the Origin guard (see AuthRoutesConfig.unsafeSkipOriginCheck)
382
- * — accept the wide-cookie CSRF risk explicitly instead of setting
383
- * `allowedOrigins`. */
384
- readonly unsafeSkipOriginCheck?: boolean;
385
- /** Number of trusted reverse-proxy hops between the client and this
386
- * process for client-IP derivation (see AuthRoutesConfig.trustedProxyHops,
387
- * kumiko-framework#1539) — closes the X-Forwarded-For spoofing hole on
388
- * the auth rate-limiters. Falls back to the `KUMIKO_TRUSTED_PROXY_HOPS`
389
- * env var when unset; both unset means 0 hops, the safe socket-only
390
- * default (only the socket address counts, XFF ignored). Deprecated in
391
- * favor of the top-level `trustedProxyHops` option, which wins over
392
- * this and the env var. */
393
- readonly trustedProxyHops?: number;
394
- /** Server-computed post-auth redirect (see AuthRoutesConfig.postAuthLanding).
395
- * Result lands in the login/signup/invite response as `landingPath`; an
396
- * invalid path or a throwing resolver just omits the field. */
397
- readonly postAuthLanding?: PostAuthLandingResolver;
398
- };
399
-
400
- /** Hook for app-specific seeding — runs after the admin (when auth is
401
- * active). Each seed is responsible for its own idempotence (seeds are
402
- * expected to check "is my row already there?" before inserting). */
403
- export type ProdSeedFn = (deps: {
404
- db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
405
- }) => Promise<void>;
406
-
407
- /** Boot-Time-Deps die `extraContext` + `anonymousAccess` Factories als
408
- * Argument bekommen. Closure dann in der returned Config (z.B. ein
409
- * TenantResolver der gegen `db` queriet, oder ein extraContext-Provider
410
- * der direkt SSE-Events publishen will). Single-source: identisch zu
411
- * setupTestStack's extraContext-Factory-Shape damit Test/Prod gleich
412
- * aussehen. */
413
- export type RunProdAppDeps = {
414
- readonly db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
415
- readonly redis: import("ioredis").default;
416
- readonly registry: import("@cosmicdrift/kumiko-framework/engine").Registry;
417
- readonly sseBroker: SseBroker;
418
- };
419
-
420
- export type AnonymousAccessOption =
421
- | import("@cosmicdrift/kumiko-framework/api").AnonymousAccessConfig
422
- | ((deps: RunProdAppDeps) => import("@cosmicdrift/kumiko-framework/api").AnonymousAccessConfig);
423
-
424
- export type ExtraContextOption =
425
- | Record<string, unknown>
426
- | ((deps: RunProdAppDeps) => Record<string, unknown>);
427
-
428
- /** Per-host routing decision for the staticDir fallback, returned from
429
- * hostDispatch. Three modes:
430
- * - "html": serve a specific HTML file (relative to staticDir), optionally
431
- * with CSP. HTML never carries a schema — createKumikoApp fetches it
432
- * itself from the authenticated GET /api/schema, after the
433
- * clientFeature gates (auth) let it through.
434
- * - "redirect": 301/302 to the given location.
435
- * - "not-found": reject outright (e.g. unknown subdomain).
436
- *
437
- * Only consulted when the path would otherwise fall through to the HTML
438
- * fallback — i.e. for "/", "/index.html", or SPA routes that neither Hono
439
- * matches nor a concrete disk file. Asset paths (/assets/*) and API paths
440
- * run independent of the host. */
441
- export type HostDispatchResult =
442
- | {
443
- readonly kind: "html";
444
- readonly file: string;
445
- /** @deprecated Ignored — HTML never carries the schema; createKumikoApp
446
- * fetches it from the authenticated GET /api/schema. */
447
- readonly injectSchema?: boolean;
448
- readonly csp?: string;
449
- }
450
- | { readonly kind: "redirect"; readonly to: string; readonly status?: 301 | 302 }
451
- | { readonly kind: "not-found" };
452
-
453
- export type HostDispatchFn = (
454
- req: {
455
- readonly host: string;
456
- readonly path: string;
457
- /** Query string incl. leading `?`, `""` if none. Redirects that
458
- * route the path to a different host (e.g. auth routes with
459
- * `?token=` from old mail links) MUST append it to `to`. */
460
- readonly search: string;
461
- },
462
- /** `systemQuery` runs as the anonymous role (like PageHeadSystemQuery) —
463
- * hostDispatch is reachable by any public visitor, same access gate as a
464
- * real anonymous request. Lets a Multi-Tenant hostDispatch look up the
465
- * tenant for a subdomain instead of needing its own db handle. */
466
- deps: { readonly systemQuery: PageHeadSystemQuery },
467
- ) => HostDispatchResult | Promise<HostDispatchResult>;
468
-
469
- // PageHeadMeta/PageHeadResolver/PageHeadSystemQuery moved to
470
- // @cosmicdrift/kumiko-headless/apex (kumiko-framework#3026) — runDevApp needs
471
- // the same types and the same resolve+inject function runProdApp uses, and
472
- // headless is where renderApexHeadTags already lives. Re-exported here so
473
- // existing subpath-imports (`@cosmicdrift/kumiko-server-runtime/run-prod-app`)
474
- // keep working unchanged.
475
- export type { PageHeadMeta, PageHeadResolver, PageHeadSystemQuery };
476
-
477
- export type RunProdAppOptions = {
478
- /** App-specific features. config/user/tenant/auth-email-password are
479
- * auto-mixed when `auth:` is set — don't add them yourself. */
480
- readonly features: readonly FeatureDefinition[];
481
- /** Opt-in boot-validator warnings — see ValidateBootOptions. */
482
- readonly validateBootOptions?: ValidateBootOptions;
483
- /** Listen-Port. Default 3000 (or $PORT). */
484
- readonly port?: number;
485
- /** Ceiling Bun.serve buffers a request body to before any app-level
486
- * body-limit middleware runs. Default is derived from `createFilesFeature`'s
487
- * configured `maxUploadSize`/field `maxSize` (see
488
- * resolveDerivedMaxRequestBodySize in bun-serve-options.ts) — override
489
- * only for a custom ceiling unrelated to that config. */
490
- readonly maxRequestBodySize?: number;
491
- /** Auth-mode: standard features + routes wired, admin seeded. */
492
- readonly auth?: RunProdAppAuthOptions;
493
- /** Custom seed functions, run after the admin seed (when auth-mode). */
494
- readonly seeds?: readonly ProdSeedFn[];
495
- /** Pfad zum seeds-Directory für ES-Operations / Seed-Migrations
496
- * (file-basiert wie drizzle-migrate). Wenn gesetzt + KUMIKO_SKIP_ES_OPS
497
- * != "1": runProdApp scannt das Verzeichnis nach `<id>.ts` Files,
498
- * diff vs kumiko_es_operations-Table, läuft pending in Tx.
499
- * Plan: kumiko-platform/docs/plans/features/es-ops.md */
500
- readonly seedsDir?: string;
501
- /** Anonymous-access for public endpoints (same shape as runDevApp).
502
- * Akzeptiert entweder einen statischen Config-Object ODER eine
503
- * Factory `({db, redis, registry}) => Config` — die Factory wird
504
- * einmal zur Boot-Zeit aufgerufen, NACHDEM db/redis/registry konstruiert
505
- * sind. Der Caller closure'd typischerweise db/redis/registry in den
506
- * TenantResolver damit z.B. ein Subdomain → Tenant-Lookup gegen die
507
- * DB möglich ist (siehe samples/showcases/publicstatus für das
508
- * Multi-Tenant-Pattern). */
509
- readonly anonymousAccess?: AnonymousAccessOption;
510
- /** Static-file root for HTML / assets. Served on the catch-all route
511
- * for any path that doesn't match an /api/ handler. Use this for the
512
- * public status page HTML, embed widget JS, etc. */
513
- readonly staticDir?: string;
514
- /** Host-aware routing hook for multi-tenant + multi-app deployments
515
- * (e.g. publicstatus's `<sub>.publicstatus.eu` (public page) +
516
- * `admin.publicstatus.eu` (admin UI) + `publicstatus.eu` (apex/
517
- * marketing) in the SAME container).
518
- *
519
- * Called when the staticDir fallback would generate an HTML response
520
- * (root or SPA route). Default behavior without hostDispatch: index.html
521
- * (single-app), no schema.
522
- *
523
- * Schema safety: HTML (per-host as well as single-app) never carries an
524
- * AppSchema — createKumikoApp fetches it via GET /api/schema, which sits
525
- * behind the auth middleware and only serves non-anonymous, signed-in
526
- * users. Per-host CSP headers can additionally restrict asset paths. */
527
- readonly hostDispatch?: HostDispatchFn;
528
- /** Per-request head-metadata resolver (Open-Graph/title/description) for
529
- * the static-fallback HTML shell — see `PageHeadResolver`. Consulted on
530
- * the same two HTML-serving paths as `hostDispatch` (host-dispatched
531
- * HTML + the default single-app index.html), right before the response
532
- * is sent. On error, `null`, or a resolve time over 300ms, the shell
533
- * ships unchanged with status 200 — this must never turn into a 500 or
534
- * a blank page. `systemQuery` inside the resolver runs as the
535
- * anonymous role (not system) — the resolver is reachable by any
536
- * public visitor, same access gate as a real anonymous request. */
537
- readonly resolvePageHead?: PageHeadResolver;
538
- /** Pfad zu kumiko/migrations für den Boot-Gate. Default "./kumiko/
539
- * migrations" relativ zum process-cwd (wo die App gestartet wird —
540
- * bei Container-Deploys typischerweise der App-Workspace-Root, weil
541
- * WORKDIR im Dockerfile dorthin zeigt). Boot wirft SchemaDriftError
542
- * wenn Migrations pending sind oder erwartete Tabellen fehlen.
543
- * Setze auf `false` um den Gate komplett zu deaktivieren — nur für
544
- * Setups die ihren eigenen Schema-Check fahren (z.B. bring-your-own-
545
- * ORM). Standard-Apps lassen das default. */
546
- readonly migrations?: { readonly dir: string } | false;
547
- /** Extra AppContext keys. Framework-Defaults werden zur Boot-Zeit
548
- * unter den Factory-Werten ergänzt, App-Werte gewinnen immer:
549
- * - `templateResolver` (createTemplateResolverApi(db)) — immer
550
- * - `secrets` (createSecretsContext) — nur wenn das `secrets`-Feature
551
- * gemountet ist (sonst kein KEK-env-Zwang für Apps ohne secrets)
552
- * - `configResolver` — im Auth-Mode (env-config-overrides)
553
- * Akzeptiert einen statischen Object ODER eine Factory
554
- * `({db, redis, registry}) => Record<string, unknown>` — gleiches
555
- * Pattern wie `anonymousAccess`. Eine App die einen dieser Keys selbst
556
- * setzt (z.B. eigener configResolver) überschreibt den Default. */
557
- /** Boot default locale (BCP-47) for ctx.locale when the request carries
558
- * no locale signal. Merged into AppContext before extraContext (app wins). */
559
- readonly defaultLocale?: string;
560
- readonly extraContext?: ExtraContextOption;
561
- /** MasterKeyProvider für die auto-verdrahtete `ctx.secrets`. Default:
562
- * `createEnvMasterKeyProvider` (KEK aus `KUMIKO_SECRETS_MASTER_KEY_V<n>`).
563
- * Override für KMS-Backends (AWS/GCP/Azure) statt env-KEK. Nur relevant
564
- * wenn das `secrets`-Feature gemountet ist. */
565
- readonly masterKey?: MasterKeyProvider;
566
- /** Subject-Key-Adapter für Crypto-Shredding (DSGVO Art. 17). Wenn gesetzt,
567
- * steht er Feature-Code als `ctx.kms` zur Verfügung und der Boot prüft
568
- * seine health() — die App startet nicht gegen ein unerreichbares KMS
569
- * (Silent-Start würde jeden PII-Read mit 503 beantworten). Kein Default:
570
- * ohne Adapter bleibt Crypto-Shredding aus. */
571
- readonly kms?: KmsAdapter;
572
- /** 32-Byte-Key (base64) für Blind-Index-HMACs auf `lookupable`-Feldern
573
- * (env: KUMIKO_BLIND_INDEX_KEY, `openssl rand -base64 32`). Bewusst
574
- * getrennt vom PLATFORM_KEK und nicht Teil des KmsAdapter-Contracts.
575
- * Pflicht sobald `kms` gesetzt ist UND ein Feature lookupable-Felder
576
- * deklariert — sonst bricht jeder Equality-Lookup auf Ciphertext
577
- * (Login by email!) und der Boot failt hart. */
578
- readonly blindIndexKey?: string;
579
- /** Explizites Opt-out aus dem harten PII-Boot-Gate: Features deklarieren
580
- * pii/userOwned/tenantOwned-Felder, aber es ist (noch) kein `kms`
581
- * provisioniert — die Felder liegen dann als KLARTEXT in Rows und
582
- * Events und DSGVO-Erasure via Crypto-Shredding ist unmöglich. Der Wert
583
- * ist eine Begründung für den Audit-Trail (z.B. "kms rollout pending,
584
- * infra#188"). Ohne Flag und ohne `kms` bricht der Boot ab. */
585
- readonly allowPlaintextPii?: string;
586
- /** Deploy-Topologie. Default `true` (Single-Container): dieser Prozess
587
- * fährt HTTP + BEIDE Job-Lanes (api + worker) + den Event-Dispatcher
588
- * (MSP-Anwendung) inline — via `createAllInOneEntrypoint`. Damit laufen
589
- * worker-Lane-Crons (z.B. der Daten-Export `run-export-jobs`, default
590
- * `runIn:"worker"`) und r.multiStreamProjection ohne separaten Worker.
591
- *
592
- * `false` NUR mit einem dezidierten Worker-Deployment setzen: dann fährt
593
- * dieser Prozess API-only (`createApiEntrypoint`), und worker-Lane-Jobs
594
- * + MSPs werden NICHT mehr lokal angewandt — der Worker muss sie
595
- * übernehmen, sonst bleiben Export-Jobs pending und die Read-Side leer
596
- * (2026-06-11-Incident-Klasse). */
597
- readonly runSingleInstance?: boolean;
598
- /** Job-Block. Wenn das Feature `r.job(...)` registriert, wird er
599
- * automatisch verdrahtet (siehe runSingleInstance). */
600
- readonly jobs?: {
601
- /** BullMQ-Queue-Prefix (default "kumiko"). */
602
- readonly queueNamePrefix?: string;
603
- /** Source of active tenant ids for `perTenant: true` jobs. Omit when the
604
- * tenant feature is mounted — the framework then resolves tenants on
605
- * its own via that feature's active-tenant-ids query. Only needed when
606
- * the app manages tenants outside that feature. */
607
- readonly getActiveTenantIds?: JobRunnerOptions["getActiveTenantIds"];
608
- };
609
- /** Event-Dispatcher (MSP-Anwendung) im API-Process. Default AN —
610
- * runProdApp ist das Single-Container-Deployment, es gibt keinen
611
- * Worker-Process der multiStreamProjections anwenden könnte. Bis
612
- * 2026-06-11 fehlte der Dispatcher hier komplett: jede MSP-basierte
613
- * Read-Projektion (z.B. custom-fields jsonb) blieb in Prod leer,
614
- * kumiko_event_consumers blieb ohne Rows. `disabled: true` nur für
615
- * Setups mit dezidiertem Worker-Process. */
616
- readonly eventDispatcher?: {
617
- readonly disabled?: boolean;
618
- /** Poll-Intervall des Dispatcher-Loops (default siehe
619
- * createEventDispatcher). LISTEN/NOTIFY-Wiring kommt mit einem
620
- * späteren pgClient-Pass-through. */
621
- readonly pollIntervalMs?: number;
622
- };
623
- /** Declarative HTTP-routes outside the /api/write|query|batch pipeline —
624
- * each entry declares its access tier (`entry`: "anonymous" | "user" |
625
- * "signature"), buildServer wires the matching guard + deps. Mounted
626
- * inside buildServer, right after the r.httpRoute loop, before seeds
627
- * (kumiko-framework#3050). */
628
- readonly extraRoutes?: readonly ExtraRouteDefinition[];
629
- /** Hook for app-wired co-running components that need the system-write
630
- * dispatcher (e.g. wiring a webhook plugin's system-scoped secrets, or
631
- * starting a co-running supervisor) — runs after buildServer, before
632
- * seeds, with NO `app` (routes are declared via `extraRoutes`, not
633
- * wired here). */
634
- readonly wire?: (deps: SystemWireDeps) => void | Promise<void>;
635
- /** When true (default), Bun.serve is started before runProdApp resolves —
636
- * the common case: `await runProdApp({...})` boots the server and the
637
- * process stays up listening on PORT. Set to false in tests that drive
638
- * the fetch-handler directly (Bun.serve isn't available under vitest +
639
- * node), then call handle.listen() manually if needed. */
640
- readonly autoListen?: boolean;
641
- /** Feature-toggle resolver — durchgereicht an createApiEntrypoint's
642
- * dispatcherOptions. Sprint-8 Tier-Composition: per-Tenant unterschied-
643
- * liche features aktiv via globalFeatureToggleRuntime. Pattern:
644
- * createLateBoundHolder + post-boot runtime.initialize in einem
645
- * seed-fn (db ist erst nach migrations + features ready). */
646
- readonly effectiveFeatures?: EffectiveFeaturesResolver;
647
- /** Composed Zod-schema for env-validation (from `composeEnvSchema({
648
- * features, extend })` in @cosmicdrift/kumiko-framework/env). When set:
649
- * - `process.env` is parsed against it BEFORE any boot work; missing
650
- * or invalid vars throw a `KumikoBootError` listing ALL problems
651
- * at once (not first-fail).
652
- * - `KUMIKO_DRY_RUN_ENV=human|json|pulumi|k8s` introspects the schema
653
- * and prints the env-var inventory, then exits without booting.
654
- *
655
- * 9.1 is additive: features that still read `process.env` directly
656
- * keep working. Migration to the schema is Sprint-9.2-9.5. */
657
- readonly envSchema?: ComposedEnvSchema;
658
- /** Prefix for `pulumi config set <prefix><CamelCase(VAR)>` in dry-run
659
- * output and boot-error suggestions. Without this, suggestions use
660
- * bare `camelCase(VAR)` and ops has to guess the app prefix. */
661
- readonly pulumiPrefix?: string;
662
- /** Handler for KumikoBootError. Default: print formatted error to
663
- * stderr and `process.exit(1)` so the container restarts with a
664
- * visible log line. Override in tests that drive runProdApp directly
665
- * (avoid the exit). Return type is `void` rather than `never` to keep
666
- * test-overrides honest — if a reporter returns, runProdApp falls
667
- * through to a regular `throw err` as the safety net. */
668
- readonly bootErrorReporter?: (err: KumikoBootError) => void;
669
- /** Override `process.env` for env-validation. Default: `process.env`.
670
- * Tests use this to feed crafted env-maps without polluting the
671
- * global. */
672
- readonly envSource?: Record<string, string | undefined>;
673
- /** Observability-Provider (Tracer + Meter). Default: Noop (kein Overhead,
674
- * kein `/metrics`-Endpoint). Setze `createPrometheusMeter()`-basierten
675
- * Provider + `metrics` um `/metrics` real zu exposen (publicstatus#91). */
676
- readonly observability?: ObservabilityProvider;
677
- /** Konfiguriert die Auto-Instrumentation des resolvierten Observability-
678
- * Providers (siehe `BaseEntrypointOptions.observabilityOptions`). */
679
- readonly observabilityOptions?: ObservabilityOptions;
680
- /** Aktiviert den `/metrics`-Endpoint (Open-Metrics-Format). Ohne einen
681
- * PrometheusMeter-basierten `observability`-Provider antwortet `/metrics`
682
- * mit 503 + Misconfiguration-Hinweis statt echter Metriken. */
683
- readonly metrics?: import("@cosmicdrift/kumiko-framework/api").ServerOptions["metrics"];
684
- /** L1 (Login-Lockout) + L2 (globaler IP-Limit) Rate-Limit-Middleware —
685
- * 1:1 durchgereicht an `buildServer`s `ServerOptions.rateLimit`. Ohne
686
- * dieses Feld wird kein L1/L2 verdrahtet (L3 Handler-`rateLimit:`
687
- * funktioniert unabhängig davon bereits). */
688
- readonly rateLimit?: import("@cosmicdrift/kumiko-framework/api").ServerOptions["rateLimit"];
689
- /** Default security headers on every response (HSTS, X-Frame-Options,
690
- * X-Content-Type-Options, Referrer-Policy; CSP opt-in). Headers already
691
- * set by a response (e.g. hostDispatch's per-host CSP) are never
692
- * overridden. `false` disables the whole block; per-header overrides
693
- * via the object form — see SecurityHeadersOption. */
694
- readonly securityHeaders?: SecurityHeadersOption;
695
- /** Number of trusted reverse-proxy hops for client-IP resolution — 1:1
696
- * durchgereicht an `buildServer`s
697
- * top-level `ServerOptions.trustedProxyHops`. Wins over the deprecated
698
- * `auth.trustedProxyHops` and `KUMIKO_TRUSTED_PROXY_HOPS`. Default 0 =
699
- * trust no proxy header, only the socket address (or "unknown") counts. */
700
- readonly trustedProxyHops?: number;
701
- };
702
-
703
- export type ProdAppHandle = {
704
- /** The composed entrypoint — AllInOne (runSingleInstance, default) or
705
- * Api-only (runSingleInstance:false). In KUMIKO_DRY_RUN_ENV mode WITH
706
- * `envSource` injected (test path), no boot ran and this slot is an
707
- * undefined-cast — do not access. Production dry-run hits
708
- * `process.exit(0)` before returning a handle. */
709
- readonly entrypoint: ApiEntrypoint | AllInOneEntrypoint;
710
- /** The fetch-handler — wired into Bun.serve in production, called
711
- * directly in tests. Composes Hono + static-fallback. Apps with
712
- * `autoListen: false` that call `Bun.serve` themselves must pass the
713
- * 2nd arg from `server.requestIP(req)?.address`, extracted in their
714
- * outermost Bun fetch callback — `requestIP` only resolves for Bun's
715
- * original Request instance, never a cloned/rebuilt one. */
716
- readonly fetch: (req: Request, socketAddress?: string) => Promise<Response> | Response;
717
- /** Serves WebSocket-upgrade requests (r.webSocketRoute) with Bun's original
718
- * Request. Apps with `autoListen: false` that run their own `Bun.serve`
719
- * must pass it as `{ upgradeFetch }` in the 4th argument of `buildBunServeOptions` to get
720
- * WebSockets; `listen()` already wires it. */
721
- readonly webSocketUpgradeFetch: (req: Request, env: KumikoServeEnv) => Promise<Response>;
722
- /** Active Bun-server (only set when listen() was called — tests skip
723
- * listen() because Bun.serve isn't available under vitest/node). */
724
- server?: ReturnType<typeof Bun.serve>;
725
- /** Bind to PORT and start serving. Production calls this; tests don't. */
726
- readonly listen: (port?: number) => Promise<void>;
727
- readonly stop: () => Promise<void>;
728
- };
729
-
730
- let warnedLegacyJwtSecret = false;
731
- function warnLegacyJwtSecretOnce(): void {
732
- // skip: already warned once this process — avoid log spam on every boot path.
733
- if (warnedLegacyJwtSecret) return;
734
- warnedLegacyJwtSecret = true;
735
- // biome-ignore lint/suspicious/noConsole: boot-time ops hint, no logger configured this early
736
- console.warn(
737
- "[runProdApp] JWT keyring carries a legacy (pre-rotation) verify-only key — " +
738
- "it never expires on its own. JWT_SECRET remains a required env (hmacSecret " +
739
- "fallback + boot requireEnv). To stop verifying with the pre-rotation secret: " +
740
- "set auth.mail.hmacSecret explicitly, then rotate JWT_SECRET to a fresh value " +
741
- "that is no longer present in any in-flight token (see resolveAuthMail / " +
742
- "loadJwtSecretOrKeyring).",
743
- );
744
- }
745
-
746
- export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHandle> {
747
- // 0. Env-Schema validation + dry-run modes. Runs FIRST so:
748
- // - operators can introspect env-requirements without a real boot
749
- // (no DB connection needed, KUMIKO_DRY_RUN_ENV=… → render + exit)
750
- // - missing/invalid env-vars produce a structured KumikoBootError
751
- // with ALL problems aggregated (not first-fail), before we waste
752
- // seconds on a Postgres connection that was never configured.
753
- //
754
- // Both code paths are no-ops when no envSchema is passed — Sprint-9
755
- // migration is per-feature additive; pre-migration apps keep the
756
- // legacy `requireEnv("DATABASE_URL")` checks below.
757
- //
758
- // Ordering invariant: this step runs BEFORE the Temporal polyfill,
759
- // so env-schemas MUST use only Temporal-free Zod types. Don't author
760
- // `z.iso.date()`/`Temporal.Instant` fields on env-vars — they'd crash
761
- // at parse-time before the polyfill loads. Plain strings + .regex /
762
- // .min / .email / .url cover every env-var shape we've actually
763
- // needed in 9.1's audit (37 references, 25 distinct vars).
764
- const rawEnvSource = options.envSource ?? process.env;
765
- const runMode = parseRunMode(rawEnvSource["KUMIKO_DRY_RUN_ENV"]);
766
- if (options.envSchema) {
767
- if (isRenderMode(runMode)) {
768
- // biome-ignore lint/suspicious/noConsole: dry-run output IS the deliverable
769
- console.log(
770
- renderDryRun(options.envSchema, runMode, {
771
- ...(options.pulumiPrefix ? { pulumiPrefix: options.pulumiPrefix } : {}),
772
- sources: options.envSchema.sources,
773
- }),
774
- );
775
- // Tests inject envSource and want a return-value, not exit. Detecting
776
- // "this is a test" via envSource is brittle; instead exit when running
777
- // against the real process.env (the deploy-flow), return otherwise.
778
- if (options.envSource === undefined) {
779
- process.exit(0);
780
- }
781
- return makeDryRunHandle();
782
- }
783
- // boot-mode AND normal-boot both run env-validation. boot-mode wants
784
- // a real env-check (all required vars present + schema-valid) before
785
- // it asserts feature-wiring works.
786
- try {
787
- parseEnv(options.envSchema.schema, rawEnvSource, {
788
- sources: options.envSchema.sources,
789
- ...(options.pulumiPrefix ? { pulumiPrefix: options.pulumiPrefix } : {}),
790
- });
791
- } catch (err) {
792
- if (err instanceof KumikoBootError) {
793
- const reporter = options.bootErrorReporter ?? defaultBootErrorReporter;
794
- reporter(err);
795
- }
796
- throw err;
797
- }
798
- }
799
-
800
- // Slots the schema marks `kms` are decrypted once here so every consumer
801
- // below (boot probe, master-key keyring, config) sees the same resolved env.
802
- const envSource: Record<string, string | undefined> = options.envSchema
803
- ? await resolvePlatformKeks(rawEnvSource, {
804
- slots: kmsSlotsOf(options.envSchema.schema),
805
- logPrefix: "[runProdApp]",
806
- })
807
- : rawEnvSource;
808
-
809
- // 1. Polyfill before anything else — feature code references Temporal.
810
- const { ensureTemporalPolyfill } = await import("@cosmicdrift/kumiko-framework/time");
811
- await ensureTemporalPolyfill();
812
-
813
- // 2. Env-vars: fail-fast. Better a 0s boot crash with a clear error
814
- // than a 30s timeout chasing a Postgres connection that was never
815
- // configured.
816
- const databaseUrl = requireEnv("DATABASE_URL", envSource);
817
- const redisUrl = requireEnv("REDIS_URL", envSource);
818
- // JWT_SECRET stays mandatory (also the resolveAuthMail hmacSecret
819
- // fallback below — a separate, non-rotating HMAC token family, not the
820
- // session JWT). jwtSecretOrKeyring is the OPTIONAL upgrade: set
821
- // JWT_SECRET_V<n> + JWT_SECRET_CURRENT_VERSION for zero-downtime
822
- // rotation of the session-JWT signing key — see loadJwtSecretOrKeyring.
823
- const jwtSecret = requireEnv("JWT_SECRET", envSource);
824
- const jwtSecretOrKeyring = loadJwtSecretOrKeyring(envSource);
825
- if (typeof jwtSecretOrKeyring === "object" && Object.hasOwn(jwtSecretOrKeyring.keys, "legacy")) {
826
- // Once per process — JWT_SECRET stays a required env (requireEnv above +
827
- // resolveAuthMail hmacSecret fallback). Retiring the *legacy verify key*
828
- // means setting auth.mail.hmacSecret explicitly and rotating JWT_SECRET
829
- // to a fresh value that is no longer in any in-flight token; unsetting
830
- // JWT_SECRET entirely hard-crashes boot.
831
- warnLegacyJwtSecretOnce();
832
- }
833
- const jwtIssuer = readEnv("JWT_ISSUER", envSource);
834
- const instanceId = readEnv("KUMIKO_INSTANCE_ID", envSource);
835
- // kumiko-framework#1539 — options.auth.trustedProxyHops wins; falls back
836
- // to the env var so ops can close the XFF-spoofing hole per-deployment
837
- // without a code change (mirrors instanceId's env-first pattern above).
838
- const trustedProxyHops =
839
- options.trustedProxyHops ??
840
- options.auth?.trustedProxyHops ??
841
- parseTrustedProxyHopsEnv(readEnv(TRUSTED_PROXY_HOPS_ENV, envSource), "runProdApp");
842
- const port = options.port ?? Number.parseInt(envSource["PORT"] ?? "3000", 10);
843
-
844
- // biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
845
- console.log(`[runProdApp] booting Kumiko stack on port ${port}…`);
846
-
847
- // auth.mail → expandiert in die expliziten Flow-Felder, bevor sie sowohl
848
- // die Feature-Side (buildComposeAuthOptions) als auch das Routes-Fragment
849
- // unten speisen. Ab hier IMMER effectiveAuth statt options.auth lesen.
850
- const effectiveAuth = options.auth
851
- ? resolveAuthMail(options.auth, jwtSecret, envSource)
852
- : undefined;
853
-
854
- // 3. Feature registry. Auth-mode auto-mixes config/user/tenant/auth-email-
855
- // password via composeFeatures — same source-of-truth as runDevApp
856
- // AND the per-app drizzle-Schema-Generator, so Migration und Runtime
857
- // sehen exakt dieselbe Liste. Built BEFORE any connection so boot-mode
858
- // can validate wiring and exit without opening a Postgres/Redis socket.
859
- const composeAuthOptions = buildComposeAuthOptions(effectiveAuth);
860
- const features = composeFeatures(options.features, {
861
- includeBundled: !!effectiveAuth,
862
- ...(composeAuthOptions && { authOptions: composeAuthOptions }),
863
- });
864
-
865
- validateBoot(features, { env: envSource, ...options.validateBootOptions });
866
- warnIfNonUtcServerTimeZone();
867
- validateAppCustomScreenWriteQns(process.cwd(), collectWriteHandlerQns(features));
868
- assertPiiBootInvariants(features, {
869
- kms: options.kms,
870
- blindIndexKey: options.blindIndexKey,
871
- allowPlaintextPii: options.allowPlaintextPii,
872
- mode: "prod",
873
- });
874
- const sessionsFeature = features.find((f) => f.name === SESSIONS_FEATURE);
875
- const registry = createRegistry(features);
876
- assertSessionBootInvariants({
877
- hasAuth: Boolean(effectiveAuth),
878
- sessionStoreProviderMounted: registry.getExtensionUsages(EXT_SESSION_STORE).length > 0,
879
- mode: "prod",
880
- });
881
-
882
- // C1 boot-mode exit: validators ran + registry built; no DB/Redis client
883
- // is constructed at all in this branch (the eager `new Redis(...)` below
884
- // would otherwise open a TCP connect just to immediately disconnect it).
885
- if (runMode === "boot") {
886
- // biome-ignore lint/suspicious/noConsole: boot-mode output IS the deliverable
887
- console.log(
888
- `[runProdApp] boot validation OK (${features.length} features, ${registry.features.size} registry entries)`,
889
- );
890
- if (options.envSource === undefined) {
891
- process.exit(0);
892
- }
893
- return makeDryRunHandle();
894
- }
895
-
896
- // 4. Connections — Postgres + Redis. The Redis client is shared by
897
- // idempotency, event-dedup, entity-cache, rate-limit; failing to
898
- // construct here surfaces the misconfig immediately. `new Redis(...)`
899
- // connects eagerly, so it must stay AFTER the boot-mode exit above.
900
- // KMS health gate: an app configured for crypto-shredding must not boot
901
- // against an unreachable key store — a silent start would answer every
902
- // PII read with 503. Runs BEFORE connections so an abort leaks nothing.
903
- if (options.kms) {
904
- const kmsHealth = await options.kms.health();
905
- if (!kmsHealth.ok) {
906
- throw new Error(
907
- `[runProdApp] BOOT ABORTED — KMS health check failed (latency ${kmsHealth.latencyMs}ms)`,
908
- );
909
- }
910
- }
911
-
912
- const { db, close: closeDb } = createDbConnection(databaseUrl);
913
- const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
914
-
915
- // Sprint-8a Tier-Composition auto-wire: scan features for a
916
- // tenantTierResolver-extension. If found AND user didn't supply own
917
- // effectiveFeatures, build the resolver here (db + registry are
918
- // available) before the dispatcher is constructed. App-Author sees
919
- // nothing — `createTierEngineFeature(opts)` mounts + framework auto-wires.
920
- let resolvedEffectiveFeatures: EffectiveFeaturesResolver | undefined = options.effectiveFeatures;
921
- if (resolvedEffectiveFeatures === undefined) {
922
- const tierResolverUsage = findTierResolverUsage(features);
923
- if (tierResolverUsage) {
924
- const plugin = tierResolverUsage.options as TierResolverPlugin;
925
- resolvedEffectiveFeatures = await plugin.build({ db, registry });
926
- }
927
- }
928
-
929
- // 5. Schema-Drift-Gate (drizzle-frei, kumiko/migrations). `kumiko schema
930
- // apply` läuft als Deploy-Step VOR dem Container-Rollout. Boot prüft nur:
931
- // (a) Alle Migrations aus kumiko/migrations/*.sql sind in
932
- // _kumiko_migrations applied (+ checksum unverändert)
933
- // (b) Alle Tabellen aus kumiko/migrations/.snapshot.json existieren
934
- // Drift = Boot-Error mit klarer Meldung (kein Auto-Heal — mehrere
935
- // Container-Replicas würden sonst race-conditionen fahren). Opt-out via
936
- // `migrations: false` für custom Schema-Setups.
937
- if (options.migrations !== false) {
938
- const migrationsDir = options.migrations?.dir ?? "./kumiko/migrations";
939
- // biome-ignore lint/suspicious/noConsole: boot-time progress hint
940
- console.log(`[runProdApp] checking schema drift (${migrationsDir})…`);
941
- try {
942
- await assertKumikoSchemaCurrent(db, migrationsDir);
943
- } catch (err) {
944
- if (err instanceof SchemaDriftError) {
945
- // biome-ignore lint/suspicious/noConsole: terminal error message
946
- console.error(`\n[runProdApp] BOOT ABORTED — ${err.message}\n`);
947
- }
948
- throw err;
949
- }
950
- }
951
-
952
- // 6. Pipeline pieces — same default config as runDevApp's setupTestStack.
953
- const idempotency = createIdempotencyGuard(redis, { ttlSeconds: 60 });
954
- const eventDedup = createEventDedup(redis, { ttlSeconds: 60 });
955
- const entityCache = createEntityCache(redis, { ttlSeconds: 60 });
956
-
957
- // 7. Lifecycle is built by createApiEntrypoint when not supplied —
958
- // we let the entrypoint own it and read it back through the handle
959
- // for SIGTERM.
960
- //
961
- // extraContext + anonymousAccess sind factory-union: entweder direktes
962
- // Object oder Function die {db, redis, registry} bekommt und das Object
963
- // returned. Factory-Form gilt als bevorzugt für Cases die zur Boot-Zeit
964
- // gegen die DB resolven müssen (z.B. Subdomain-Tenant-Lookup im
965
- // tenantResolver) — die Factory closure'd `db` und der Resolver kann
966
- // sie zur Request-Zeit aufrufen.
967
- // Build the sseBroker here (instead of letting createApiEntrypoint do it
968
- // internally) so extraContext factories can close over it at boot time —
969
- // e.g. an extraContext provider that publishes SSE events directly. We
970
- // pass the same broker into createApiEntrypoint (sseBroker? option) so
971
- // the server-internal broadcast and app-specific publishes run over
972
- // exactly one broker. createDefaultSseBroker (not createSseBroker
973
- // directly) is what makes that broker Redis-backed under REDIS_URL —
974
- // an unconditional in-memory broker here would silently win over
975
- // buildServer's own REDIS_URL default (ServerOptions.sseBroker always
976
- // wins), which is exactly how fw#2630's cross-replica fanout fix shipped
977
- // without ever taking effect in production.
978
- const { sseBroker, ownedRedisSseBroker } = createDefaultSseBroker(envSource);
979
- const deps: RunProdAppDeps = { db, redis, registry, sseBroker };
980
- const resolvedExtraContext =
981
- typeof options.extraContext === "function"
982
- ? options.extraContext(deps)
983
- : (options.extraContext ?? {});
984
-
985
- // Framework-Default-Provider zuerst, App-Werte (resolvedExtraContext)
986
- // gewinnen immer (z.B. money-horse's eigener configResolver).
987
- const bootCrypto = resolveBootCrypto(envSource, options.masterKey);
988
- // App-wide cipher for `encrypted: true` entity fields — executors resolve
989
- // it lazily, entities without encrypted fields never touch it.
990
- configureEntityFieldEncryption(bootCrypto.entityFieldCipher);
991
- // Subject-key KMS for pii-annotated fields (crypto-shredding, #724).
992
- // No adapter = engine off, plaintext as before — the warning keeps the gap
993
- // visible until the prod-grade PgKmsAdapter (phase E) makes a hard boot
994
- // gate viable (InMemory in prod would lose every DEK on restart).
995
- configurePiiSubjectKms(options.kms);
996
- // Blind-Index-Key (#818) — Boot-Gate: mit aktivem KMS würden lookupable-
997
- // Felder als Ciphertext gespeichert, ohne Key gäbe es keinen bidx-Arm und
998
- // jeder Equality-Lookup (Login by email!) liefe ins Leere. Fail-fast statt
999
- // silent-broken-auth.
1000
- configureBlindIndexKey(options.blindIndexKey);
1001
- const autoExtraContext = buildBootExtraContext({
1002
- db,
1003
- features,
1004
- envSource,
1005
- registry,
1006
- hasAuth: !!effectiveAuth,
1007
- sseBroker,
1008
- crypto: bootCrypto,
1009
- ...(options.kms && { kms: options.kms }),
1010
- });
1011
- const extraContext = {
1012
- ...addConfigAccessorFactory({ ...autoExtraContext, ...resolvedExtraContext }, registry),
1013
- ...(options.defaultLocale !== undefined && { defaultLocale: options.defaultLocale }),
1014
- };
1015
- const baseAnonymousAccess =
1016
- typeof options.anonymousAccess === "function"
1017
- ? options.anonymousAccess(deps)
1018
- : options.anonymousAccess;
1019
- // #1374: tenantResolver / tenantExists come from auth-foundation providers.
1020
- const resolvedAnonymousAccess = await resolveAnonymousAccessFromRegistry(baseAnonymousAccess, {
1021
- db,
1022
- registry,
1023
- });
1024
-
1025
- // Sessions opt-in: db ist hier schon konkret (createDbConnection oben),
1026
- // also direkt verdrahten — kein late-bound nötig wie bei runDevApp.
1027
- // Ein JWT ohne sid wird abgelehnt, sobald ein sessionChecker verdrahtet ist —
1028
- // Prod-Sessions können nicht stillschweigend umgangen werden. sessionMassRevoker
1029
- // (4. callback aus createSessionCallbacks) ist nicht Teil der
1030
- // AuthRoutesConfig-Surface — der geht via bindAutoRevokeFromFeature ans
1031
- // sessions-Feature (Password-Change/-Reset revoked alle Sessions), nicht
1032
- // über die auth-routes.
1033
- // Secure-by-default (#1372): sessionStore provider → resolveSessionStore.
1034
- const mfaFeature = features.find((f) => f.name === AUTH_MFA_FEATURE);
1035
- // Hoisted above buildProdSessionAuth: that call also wires MFA-enable/
1036
- // disable → PAT-revoke (bindRevokeAllPatTokensFromFeature), so it needs
1037
- // patFeature too.
1038
- const patFeature = features.find((f) => f.name === PAT_FEATURE);
1039
- const sessionAuthFragment = shouldWireProdSessions(
1040
- Boolean(effectiveAuth),
1041
- registry.getExtensionUsages(EXT_SESSION_STORE).length > 0,
1042
- )
1043
- ? await buildProdSessionAuth(db, registry, sessionsFeature, mfaFeature, patFeature)
1044
- : undefined;
1045
-
1046
- // Password-change → PAT-revoke: independent of sessions/MFA being mounted
1047
- // (buildProdSessionAuth only runs when a sessionStore provider is
1048
- // registered), so wired unconditionally here whenever PAT itself is.
1049
- if (patFeature) {
1050
- wireProdPatAutoRevoke(db, patFeature);
1051
- }
1052
-
1053
- // Token-verifier opt-in: any provider feature (personal-access-tokens, a
1054
- // future auth-provider-jwt, ...) self-registers via
1055
- // r.useExtension(EXT_TOKEN_VERIFIER, ...) — wire one generic resolver
1056
- // whenever at least one is mounted, resolved by shape at request-time.
1057
- // PAT keeps its own per-token rate limiter (patRateLimiter), unrelated to
1058
- // verification.
1059
- const hasTokenVerifierProviders = registry.getExtensionUsages(EXT_TOKEN_VERIFIER).length > 0;
1060
- let patAuthFragment:
1061
- | {
1062
- tokenVerifier: TokenVerifier;
1063
- patRateLimiter: LoginRateLimiter;
1064
- }
1065
- | undefined;
1066
- if (effectiveAuth && patFeature && hasTokenVerifierProviders) {
1067
- const rl = patRateLimitFromFeature(patFeature);
1068
- patAuthFragment = {
1069
- tokenVerifier: (rawToken) => resolveTokenVerifier({ db, registry }, rawToken),
1070
- patRateLimiter: createRedisLoginRateLimiter(redis, rl.maxRequests, rl.windowMs, "pat"),
1071
- };
1072
- }
1073
-
1074
- const baseEntrypointOptions = {
1075
- registry,
1076
- context: {
1077
- db,
1078
- redis,
1079
- entityCache,
1080
- registry,
1081
- ...extraContext,
1082
- },
1083
- sseBroker,
1084
- jwtSecret: jwtSecretOrKeyring,
1085
- ...(jwtIssuer && { jwtIssuer }),
1086
- ...(instanceId && { instanceId }),
1087
- dispatcherOptions: {
1088
- idempotency,
1089
- ...(resolvedEffectiveFeatures && { effectiveFeatures: resolvedEffectiveFeatures }),
1090
- },
1091
- eventDedup,
1092
- ...(options.observability && { observability: options.observability }),
1093
- ...(options.observabilityOptions && { observabilityOptions: options.observabilityOptions }),
1094
- ...(options.metrics && { metrics: options.metrics }),
1095
- ...(options.rateLimit && { rateLimit: options.rateLimit }),
1096
- ...(trustedProxyHops !== undefined && { trustedProxyHops }),
1097
- ...(options.extraRoutes && { extraRoutes: options.extraRoutes }),
1098
- ...(effectiveAuth && {
1099
- auth: {
1100
- membershipQuery: TenantQueries.memberships,
1101
- userQuery: UserQueries.findForAuth,
1102
- loginHandler: AuthHandlers.login,
1103
- loginErrorStatusMap: effectiveAuth.loginErrorStatusMap ?? {
1104
- [AuthErrors.invalidCredentials]: 401,
1105
- [AuthErrors.noMembership]: 403,
1106
- },
1107
- // Redis-backed, not the in-memory default createAuthRoutes falls
1108
- // back to — an in-process limiter only rate-limits within a single
1109
- // replica, so a multi-instance prod deployment would silently give
1110
- // each replica its own bucket (#1262/#1274). Redis is required infra
1111
- // here already (REDIS_URL), so this is free.
1112
- loginRateLimit: createRedisLoginRateLimiter(redis),
1113
- ...(effectiveAuth.cookieDomain !== undefined && {
1114
- cookieDomain: effectiveAuth.cookieDomain,
1115
- }),
1116
- ...(effectiveAuth.allowedOrigins !== undefined && {
1117
- allowedOrigins: effectiveAuth.allowedOrigins,
1118
- }),
1119
- ...(effectiveAuth.unsafeSkipOriginCheck !== undefined && {
1120
- unsafeSkipOriginCheck: effectiveAuth.unsafeSkipOriginCheck,
1121
- }),
1122
- ...(trustedProxyHops !== undefined && { trustedProxyHops }),
1123
- ...(effectiveAuth.postAuthLanding !== undefined && {
1124
- postAuthLanding: effectiveAuth.postAuthLanding,
1125
- }),
1126
- ...sessionAuthFragment,
1127
- ...patAuthFragment,
1128
- ...(mfaFeature && {
1129
- mfaVerifyHandler: AuthMfaHandlers.verify,
1130
- mfaPreauthEnableStartHandler: AuthMfaHandlers.enableStartPreauth,
1131
- mfaPreauthConfirmHandler: AuthMfaHandlers.enableConfirmPreauth,
1132
- mfaVerifyRateLimit: createRedisLoginRateLimiter(
1133
- redis,
1134
- undefined,
1135
- undefined,
1136
- "mfa-verify",
1137
- ),
1138
- mfaPreauthEnableStartRateLimit: createRedisLoginRateLimiter(
1139
- redis,
1140
- undefined,
1141
- undefined,
1142
- "mfa-preauth-start",
1143
- ),
1144
- mfaPreauthConfirmRateLimit: createRedisLoginRateLimiter(
1145
- redis,
1146
- undefined,
1147
- undefined,
1148
- "mfa-preauth-confirm",
1149
- ),
1150
- }),
1151
- ...(effectiveAuth.passwordReset && {
1152
- passwordReset: {
1153
- requestHandler: AuthHandlers.requestPasswordReset,
1154
- confirmHandler: AuthHandlers.resetPassword,
1155
- },
1156
- }),
1157
- ...(effectiveAuth.emailVerification && {
1158
- emailVerification: {
1159
- requestHandler: AuthHandlers.requestEmailVerification,
1160
- confirmHandler: AuthHandlers.verifyEmail,
1161
- },
1162
- }),
1163
- ...(effectiveAuth.accountUnlock && {
1164
- accountUnlock: {
1165
- requestHandler: AuthHandlers.requestAccountUnlock,
1166
- confirmHandler: AuthHandlers.confirmAccountUnlock,
1167
- },
1168
- }),
1169
- ...(effectiveAuth.signup && {
1170
- signup: {
1171
- requestHandler: AuthHandlers.signupRequest,
1172
- confirmHandler: AuthHandlers.signupConfirm,
1173
- },
1174
- }),
1175
- ...(effectiveAuth.invite && {
1176
- invite: {
1177
- acceptHandler: AuthHandlers.inviteAccept,
1178
- acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
1179
- signupCompleteHandler: AuthHandlers.inviteSignupComplete,
1180
- infoHandler: AuthQueries.inviteInfo,
1181
- },
1182
- }),
1183
- },
1184
- }),
1185
- ...(resolvedAnonymousAccess && { anonymousAccess: resolvedAnonymousAccess }),
1186
- };
1187
-
1188
- // Deploy-Topologie. Default (Single-Container): createAllInOneEntrypoint
1189
- // fährt HTTP + BEIDE Job-Lanes (zwei Runner, jeder schedult seine eigene
1190
- // Lane-Crons → kein Double-Fire) + Event-Dispatcher inline. So laufen
1191
- // worker-Lane-Crons (run-export-jobs default runIn:"worker") UND MSPs ohne
1192
- // separaten Worker-Process — die Asymmetrie, an der der Daten-Export hing.
1193
- // runSingleInstance:false → API-only; ein dezidierter Worker MUSS dann die
1194
- // worker-Lane + MSPs übernehmen (api-Lane-Jobs laufen weiter lokal).
1195
- // Default single-instance. eventDispatcher.disabled ist die Alt-Art, MSPs
1196
- // diesem Prozess wegzunehmen (dezidierter Worker) — als runSingleInstance:
1197
- // false honorieren (api-only, kein lokaler Dispatcher), damit der explizite
1198
- // Flag Vorrang behält aber Bestands-Caller nicht brechen.
1199
- const runSingleInstance = options.runSingleInstance ?? options.eventDispatcher?.disabled !== true;
1200
- const hasJobs = registry.getAllJobs().size > 0;
1201
- const queueNamePrefix = options.jobs?.queueNamePrefix;
1202
- const jobLogger = jobRunLoggerCallbacks(registry, db);
1203
- const dispatcherTunables =
1204
- options.eventDispatcher?.pollIntervalMs !== undefined
1205
- ? { pollIntervalMs: options.eventDispatcher.pollIntervalMs }
1206
- : {};
1207
-
1208
- const entrypoint: ApiEntrypoint | AllInOneEntrypoint = runSingleInstance
1209
- ? createAllInOneEntrypoint({
1210
- ...baseEntrypointOptions,
1211
- redisUrl,
1212
- ...jobLogger,
1213
- ...(queueNamePrefix !== undefined && { queueNamePrefix }),
1214
- ...(options.jobs?.getActiveTenantIds !== undefined && {
1215
- getActiveTenantIds: options.jobs.getActiveTenantIds,
1216
- }),
1217
- ...(!options.eventDispatcher?.disabled && { eventDispatcher: dispatcherTunables }),
1218
- })
1219
- : createApiEntrypoint({
1220
- ...baseEntrypointOptions,
1221
- ...(hasJobs && {
1222
- jobs: {
1223
- redisUrl,
1224
- runLocalJobs: true,
1225
- ...jobLogger,
1226
- ...(queueNamePrefix !== undefined && { queueNamePrefix }),
1227
- ...(options.jobs?.getActiveTenantIds !== undefined && {
1228
- getActiveTenantIds: options.jobs.getActiveTenantIds,
1229
- }),
1230
- },
1231
- }),
1232
- });
1233
-
1234
- // The broker built above is always ours (runProdApp never accepts a
1235
- // caller-supplied sseBroker), so — same reasoning as buildServer's own
1236
- // ownedRedisSseBroker hook — closing it on shutdown is our job, not the
1237
- // entrypoint's. Registered after createApiEntrypoint/createAllInOneEntrypoint
1238
- // (which already registered the eventDispatcher/jobRunner hooks above),
1239
- // so it drains LIFO before those, matching the close-before-teardown
1240
- // order fw#2630 established for buildServer's own hook.
1241
- if (ownedRedisSseBroker) {
1242
- const broker = ownedRedisSseBroker;
1243
- entrypoint.lifecycle.registerShutdownHook("prodAppRedisSseBroker", async () => {
1244
- await broker.close();
1245
- });
1246
- }
1247
-
1248
- // `extraRoutes` itself is already mounted — buildServer wires it inside
1249
- // createApiEntrypoint/createAllInOneEntrypoint, before this function ever
1250
- // runs. `wire` is the remaining escape-hatch for app-wired co-running
1251
- // components that need the system-write dispatcher but don't declare a
1252
- // route (kumiko-framework#3050).
1253
- if (options.wire) {
1254
- await options.wire({
1255
- db,
1256
- redis,
1257
- registry,
1258
- dispatchSystemWrite: makeDispatchSystemWrite(entrypoint.dispatcher),
1259
- });
1260
- }
1261
-
1262
- // 9. Seeds: admin first, then config-seeds from r.config({seeds}),
1263
- // then app-specific. All idempotent — runProdApp doesn't gate
1264
- // "first boot" via flag, every seed-step checks its own
1265
- // preconditions. Config-seeds rely on a deterministic
1266
- // aggregate-id so re-boot becomes a version_conflict skip.
1267
- if (effectiveAuth) {
1268
- await seedAdminGuarded(db, effectiveAuth.admin);
1269
- }
1270
- await applyBootSeeds({
1271
- registry,
1272
- db,
1273
- ...(bootCrypto.configCipher && { cipher: bootCrypto.configCipher }),
1274
- });
1275
- for (const seed of options.seeds ?? []) {
1276
- await seed({ db });
1277
- }
1278
-
1279
- // ES-Operations / Seed-Migrations (Phase 1). Läuft NACH applyBootSeeds +
1280
- // existing seeds-array — die deklarativen Seeds sind die "always-insert-
1281
- // if-missing"-Schicht; seed-migrations sind die "diff-and-update"-
1282
- // Schicht für Drift den existing Seeds nicht erfassen können (z.B.
1283
- // Membership-Roles-Change nach initialer Seed-Erstellung).
1284
- if (options.seedsDir !== undefined && envSource["KUMIKO_SKIP_ES_OPS"] !== "1") {
1285
- await createEsOperationsTable(db);
1286
- const seedDispatcher = createDispatcher(registry, {
1287
- db,
1288
- redis,
1289
- entityCache,
1290
- registry,
1291
- ...extraContext,
1292
- });
1293
- await runPendingSeedMigrations({
1294
- db,
1295
- seedsDir: options.seedsDir,
1296
- appliedBy: "boot",
1297
- registry, // → dry-run-validator catched handler-QN-typos vor dem write
1298
- // @wrapper-known semantic-alias
1299
- createContext: (dbRunner: DbRunner) =>
1300
- createSeedMigrationContext({ dispatcher: seedDispatcher, dbRunner }),
1301
- });
1302
- }
1303
-
1304
- await entrypoint.start();
1305
- startPiiEventBackfillOnBoot({
1306
- db,
1307
- registry,
1308
- lifecycle: entrypoint.lifecycle,
1309
- envSource,
1310
- });
1311
-
1312
- // 11. Build the fetch-handler. Static-fallback for non-/api/ paths
1313
- // wired via a wrapper so Hono owns /api/* + extraRoutes and disk
1314
- // owns the rest. Tests use this directly; listen() wraps it in
1315
- // Bun.serve.
1316
- const fetchHandler = withSecurityHeaders(
1317
- options.staticDir
1318
- ? buildStaticFallback(
1319
- entrypoint.app.fetch.bind(entrypoint.app),
1320
- options.staticDir,
1321
- options.hostDispatch,
1322
- options.resolvePageHead
1323
- ? { resolvePageHead: options.resolvePageHead, dispatcher: entrypoint.dispatcher }
1324
- : undefined,
1325
- entrypoint.dispatcher,
1326
- trustedProxyHops ?? 0,
1327
- )
1328
- : // No staticDir (split-deploy / API-only container) → app.fetch's
1329
- // response goes straight to the client, bypassing buildStaticFallback
1330
- // (and with it tryHonoFirst) entirely. Must strip the router-miss
1331
- // marker here too, same reason as the /api/* passthrough in
1332
- // run-prod-app-static-files.ts (see try-hono-first.ts).
1333
- async (req: Request, socketAddress?: string) =>
1334
- stripNoRouteMatchHeader(await entrypoint.app.fetch(req, socketAddress)),
1335
- options.securityHeaders,
1336
- );
1337
-
1338
- // 11. Mark lifecycle ready — health/ready flips to 200 after this.
1339
- entrypoint.lifecycle.markReady();
1340
-
1341
- // Upgrade rejections (401/403/426...) are ordinary responses and get the same headers as fetchHandler.
1342
- const securedUpgradeFetch = withSecurityHeaders<KumikoServeEnv>(
1343
- async (req, env) => stripNoRouteMatchHeader(await entrypoint.app.fetch(req, env)),
1344
- options.securityHeaders,
1345
- );
1346
- const webSocketUpgradeFetch = async (req: Request, env: KumikoServeEnv): Promise<Response> =>
1347
- securedUpgradeFetch(req, env);
1348
-
1349
- const handle: ProdAppHandle = {
1350
- entrypoint,
1351
- fetch: fetchHandler,
1352
- webSocketUpgradeFetch,
1353
- listen: async (listenPort = port) => {
1354
- // Bun.serve is the production HTTP. Tests don't call listen()
1355
- // because vitest runs under Node where Bun.serve doesn't exist.
1356
- // Options-Shape (inkl. idleTimeout: 0 für SSE) liegt in der
1357
- // exportierten buildBunServeOptions-Funktion — siehe ihren
1358
- // Header für die Begründung.
1359
- if (typeof (globalThis as { Bun?: unknown }).Bun === "undefined") {
1360
- // Klare Fehlermeldung statt nackter ReferenceError. Trifft wenn
1361
- // jemand listen() unter Node/vitest aufruft ohne autoListen:false
1362
- // — hilft beim Debug, statt sich an "Bun is not defined" abzumühen.
1363
- throw new Error(
1364
- "[runProdApp] listen() requires Bun runtime (Bun.serve). " +
1365
- "Under Node/vitest pass `autoListen: false` and call the returned `fetch()` directly.",
1366
- );
1367
- }
1368
- handle.server = Bun.serve(
1369
- buildBunServeOptions(
1370
- listenPort,
1371
- fetchHandler,
1372
- options.maxRequestBodySize ?? resolveDerivedMaxRequestBodySize(registry),
1373
- { upgradeFetch: webSocketUpgradeFetch },
1374
- ),
1375
- );
1376
-
1377
- // SIGTERM/SIGINT — graceful shutdown. Only registered when we
1378
- // actually own a Bun-server, otherwise the test process picks up
1379
- // signals it shouldn't respond to.
1380
- let shuttingDown = false;
1381
- const shutdown = async (signal: string) => {
1382
- // skip: shutdown already in progress, avoid double-drain
1383
- if (shuttingDown) return;
1384
- shuttingDown = true;
1385
- // biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
1386
- console.log(`[runProdApp] ${signal} received — draining…`);
1387
- try {
1388
- await handle.stop();
1389
- // biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
1390
- console.log("[runProdApp] graceful shutdown complete.");
1391
- } catch (e) {
1392
- // biome-ignore lint/suspicious/noConsole: shutdown-time error, only path is stderr
1393
- console.error("[runProdApp] error during shutdown:", e);
1394
- } finally {
1395
- process.exit(0);
1396
- }
1397
- };
1398
- process.on("SIGTERM", () => void shutdown("SIGTERM"));
1399
- process.on("SIGINT", () => void shutdown("SIGINT"));
1400
-
1401
- // biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
1402
- console.log(`[runProdApp] ready on http://0.0.0.0:${listenPort}`);
1403
- },
1404
- stop: async () => {
1405
- await entrypoint.stop();
1406
- handle.server?.stop();
1407
- await closeDb();
1408
- redis.disconnect();
1409
- },
1410
- };
1411
-
1412
- // 12. Auto-listen unless explicitly suppressed (tests pass autoListen:
1413
- // false because Bun.serve isn't available under vitest/node).
1414
- // Production path: `await runProdApp({...})` and the server is up.
1415
- if (options.autoListen !== false) {
1416
- await handle.listen();
1417
- }
1418
-
1419
- return handle;
1420
- }