@cosmicdrift/kumiko-dev-server 1.0.0 → 2.0.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 (66) hide show
  1. package/bin/kumiko-schema-check.ts +7 -0
  2. package/package.json +12 -7
  3. package/src/__tests__/build-prod-bundle.integration.test.ts +1 -1
  4. package/src/__tests__/build-server-bundle.test.ts +62 -0
  5. package/src/__tests__/compose-stacks.test.ts +271 -0
  6. package/src/__tests__/create-kumiko-server-errors.test.ts +54 -0
  7. package/src/__tests__/create-kumiko-server-options.test.ts +33 -0
  8. package/src/__tests__/create-kumiko-server.integration.test.ts +128 -1
  9. package/src/__tests__/discover-format.test.ts +1 -1
  10. package/src/__tests__/env-schema.integration.test.ts +1 -1
  11. package/src/__tests__/few-shot-corpus.test.ts +16 -11
  12. package/src/__tests__/merge-extra-context.test.ts +56 -0
  13. package/src/__tests__/resolve-stylesheet.test.ts +16 -0
  14. package/src/__tests__/saas-identity-wire.integration.test.ts +430 -0
  15. package/src/__tests__/scaffold-app-feature.test.ts +59 -6
  16. package/src/__tests__/scaffold-app.test.ts +34 -2
  17. package/src/__tests__/schema-apply.integration.test.ts +5 -2
  18. package/src/__tests__/setup-test-stack-from-features.integration.test.ts +30 -0
  19. package/src/__tests__/walkthrough.integration.test.ts +22 -6
  20. package/src/build-server-bundle.ts +33 -15
  21. package/src/build.ts +1 -2
  22. package/src/codegen/__tests__/render-codegen.test.ts +2 -1
  23. package/src/codegen/__tests__/run-codegen.test.ts +2 -2
  24. package/src/codegen/__tests__/strict-mode-diagnostics.test.ts +6 -1
  25. package/src/codegen/__tests__/watch.test.ts +1 -2
  26. package/src/codegen/render.ts +6 -1
  27. package/src/codegen/scan-events.ts +7 -5
  28. package/src/codegen/watch.ts +7 -4
  29. package/src/compose-stacks.ts +184 -0
  30. package/src/create-kumiko-server.ts +28 -5
  31. package/src/few-shot-corpus.ts +4 -3
  32. package/src/index.ts +30 -12
  33. package/src/run-dev-app.ts +195 -61
  34. package/src/scaffold-app-feature.ts +118 -15
  35. package/src/scaffold-app.ts +151 -79
  36. package/src/scaffold-demo-tasks.ts +233 -0
  37. package/src/schema-apply.ts +15 -2
  38. package/src/schema-check-core.ts +1 -1
  39. package/src/setup-test-stack-from-features.ts +61 -0
  40. package/src/welcome-banner.ts +1 -4
  41. package/src/__tests__/boot-extra-context.test.ts +0 -140
  42. package/src/__tests__/build-prod-bundle.test.ts +0 -311
  43. package/src/__tests__/cache-headers.test.ts +0 -83
  44. package/src/__tests__/compose-features-wiring.integration.test.ts +0 -382
  45. package/src/__tests__/compose-features.test.ts +0 -129
  46. package/src/__tests__/config-seed-boot.integration.test.ts +0 -158
  47. package/src/__tests__/inject-schema.test.ts +0 -62
  48. package/src/__tests__/renderer-web-css-relocation.integration.test.ts +0 -85
  49. package/src/__tests__/renderer-web-shell-sentinel.test.ts +0 -35
  50. package/src/__tests__/require-env.test.ts +0 -29
  51. package/src/__tests__/resolve-auth-mail.test.ts +0 -69
  52. package/src/__tests__/resolve-tailwind-cli.test.ts +0 -81
  53. package/src/__tests__/run-prod-app-env-source.test.ts +0 -157
  54. package/src/__tests__/run-prod-app-spec.test.ts +0 -57
  55. package/src/__tests__/run-prod-app.integration.test.ts +0 -840
  56. package/src/__tests__/session-wiring.test.ts +0 -51
  57. package/src/__tests__/try-hono-first.test.ts +0 -63
  58. package/src/boot/apply-boot-seeds.ts +0 -18
  59. package/src/build-prod-bundle.ts +0 -697
  60. package/src/compose-features.ts +0 -145
  61. package/src/extra-routes-deps.ts +0 -47
  62. package/src/inject-schema.ts +0 -24
  63. package/src/resolve-tailwind-cli.ts +0 -45
  64. package/src/run-prod-app.ts +0 -1492
  65. package/src/session-wiring.ts +0 -29
  66. package/src/try-hono-first.ts +0 -46
@@ -1,1492 +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+>
24
- // PORT=3000
25
- // KUMIKO_INSTANCE_ID=<stable per replica>
26
-
27
- import {
28
- AuthErrors,
29
- AuthHandlers,
30
- type AuthMailLocale,
31
- type AuthPaths,
32
- type EmailVerificationOptions,
33
- type InviteOptions,
34
- makeAuthPaths,
35
- type PasswordResetOptions,
36
- type SignupOptions,
37
- } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
38
- import {
39
- type SeedAdminOptions,
40
- seedAdmin,
41
- } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
42
- import { createSmtpTransportFromEnv } from "@cosmicdrift/kumiko-bundled-features/channel-email";
43
- import {
44
- buildEnvConfigOverrides,
45
- createConfigAccessorFactory,
46
- createConfigResolver,
47
- } from "@cosmicdrift/kumiko-bundled-features/config";
48
- import {
49
- collectChannels,
50
- createDeliveryService,
51
- DELIVERY_FEATURE,
52
- } from "@cosmicdrift/kumiko-bundled-features/delivery";
53
- import {
54
- createSecretsContext,
55
- SECRETS_FEATURE_NAME,
56
- } from "@cosmicdrift/kumiko-bundled-features/secrets";
57
- import {
58
- createSessionCallbacks,
59
- SESSIONS_FEATURE,
60
- } from "@cosmicdrift/kumiko-bundled-features/sessions";
61
- import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
62
- import { createTextContentApi } from "@cosmicdrift/kumiko-bundled-features/text-content";
63
- import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
64
- import {
65
- type CachePolicy,
66
- cachedResponse,
67
- computeStrongEtag,
68
- computeWeakEtag,
69
- createSseBroker,
70
- type SseBroker,
71
- } from "@cosmicdrift/kumiko-framework/api";
72
- import {
73
- createDbConnection,
74
- type DbConnection,
75
- type DbRunner,
76
- } from "@cosmicdrift/kumiko-framework/db";
77
- import {
78
- buildAppSchema,
79
- type ConfigResolver,
80
- collectWriteHandlerQns,
81
- createRegistry,
82
- type EffectiveFeaturesResolver,
83
- type FeatureDefinition,
84
- findTierResolverUsage,
85
- type NotifyFactory,
86
- type Registry,
87
- type TierResolverPlugin,
88
- validateAppCustomScreenWriteQns,
89
- validateBoot,
90
- } from "@cosmicdrift/kumiko-framework/engine";
91
- import {
92
- type AllInOneEntrypoint,
93
- type ApiEntrypoint,
94
- createAllInOneEntrypoint,
95
- createApiEntrypoint,
96
- } from "@cosmicdrift/kumiko-framework/entrypoint";
97
- import {
98
- type ComposedEnvSchema,
99
- KumikoBootError,
100
- parseEnv,
101
- } from "@cosmicdrift/kumiko-framework/env";
102
- import { type DryRunMode, renderDryRun } from "@cosmicdrift/kumiko-framework/env/dry-run";
103
- import {
104
- createEsOperationsTable,
105
- createSeedMigrationContext,
106
- runPendingSeedMigrations,
107
- } from "@cosmicdrift/kumiko-framework/es-ops";
108
- import {
109
- assertKumikoSchemaCurrent,
110
- SchemaDriftError,
111
- } from "@cosmicdrift/kumiko-framework/migrations";
112
- import {
113
- createDispatcher,
114
- createEntityCache,
115
- createEventDedup,
116
- createIdempotencyGuard,
117
- } from "@cosmicdrift/kumiko-framework/pipeline";
118
- import {
119
- createEnvMasterKeyProvider,
120
- type MasterKeyProvider,
121
- } from "@cosmicdrift/kumiko-framework/secrets";
122
- import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
123
- import Redis from "ioredis";
124
- import { applyBootSeeds } from "./boot/apply-boot-seeds";
125
- import { ASSETS_DIR } from "./build-prod-bundle";
126
- import { buildComposeAuthOptions, composeFeatures } from "./compose-features";
127
- import { type ExtraRoutesSystemDeps, makeDispatchSystemWrite } from "./extra-routes-deps";
128
- import { injectSchema } from "./inject-schema";
129
- import {
130
- type ProdSessionsConfig,
131
- type ProdSessionsOption,
132
- resolveProdSessionsConfig,
133
- shouldWireProdSessions,
134
- } from "./session-wiring";
135
- import { tryHonoFirst } from "./try-hono-first";
136
-
137
- /**
138
- * Bun.serve-Options für Production.
139
- *
140
- * Spec: idleTimeout: 0 (= disabled). SSE-Streams werden via Heartbeat
141
- * lebend gehalten (siehe SSE_HEARTBEAT_INTERVAL_MS in framework/api/
142
- * sse-route.ts), kein Bun-side Idle-Cleanup nötig. Mit dem Default
143
- * von 10 s killt Bun nach jedem Heartbeat-Gap die Connection mit
144
- * halbem HTTP/2-RST_STREAM → Browser ERR_HTTP2_PROTOCOL_ERROR.
145
- *
146
- * Spec-Test in __tests__/run-prod-app-spec.test.ts pinst die 0 gegen
147
- * "looks like a leak"-Reverts.
148
- */
149
- export function buildBunServeOptions(
150
- port: number,
151
- fetchHandler: (req: Request) => Response | Promise<Response>,
152
- ): {
153
- readonly port: number;
154
- readonly fetch: (req: Request) => Response | Promise<Response>;
155
- readonly idleTimeout: number;
156
- } {
157
- return { port, fetch: fetchHandler, idleTimeout: 0 };
158
- }
159
-
160
- // Strict env-var read. Throws with a clear hint when missing — better
161
- // than discovering a Postgres-connection-refused 30s into the boot.
162
- // `src` defaults to process.env but is threaded from the caller's envSource
163
- // so the boot-path reads the SAME env-quelle that was validated above —
164
- // injected dummies in test-mode must not silently fall back to process.env.
165
- export function requireEnv(
166
- name: string,
167
- src: Record<string, string | undefined> = process.env,
168
- context = "runProdApp",
169
- ): string {
170
- const value = src[name];
171
- if (value === undefined || value === "") {
172
- const advice =
173
- context === "runProdApp"
174
- ? "Set it in your container env / .env.production / Coolify secrets."
175
- : "Set it in your .env / shell before running the dev server.";
176
- throw new Error(`${context}: required env var "${name}" is missing or empty. ${advice}`);
177
- }
178
- return value;
179
- }
180
-
181
- // Optional env helper — returns undefined for missing, string for set.
182
- // Used for KUMIKO_INSTANCE_ID, JWT_ISSUER and other "nice to have" knobs.
183
- function readEnv(
184
- name: string,
185
- src: Record<string, string | undefined> = process.env,
186
- ): string | undefined {
187
- const value = src[name];
188
- return value === undefined || value === "" ? undefined : value;
189
- }
190
-
191
- // `boot` is the C1 smoke-test path — validators run, no DB/Redis connect,
192
- // exit after registry-build. Render-modes (human|json|pulumi|k8s|1)
193
- // inspect the env-schema and exit before any feature wiring.
194
- type RunMode = DryRunMode | "boot";
195
-
196
- function parseRunMode(raw: string | undefined): RunMode | null {
197
- if (!raw) return null;
198
- const v = raw.toLowerCase();
199
- if (v === "1" || v === "true" || v === "human") return "human";
200
- if (v === "json" || v === "pulumi" || v === "k8s" || v === "boot") return v;
201
- // biome-ignore lint/suspicious/noConsole: boot-time warn for typo discovery
202
- console.warn(
203
- `[runProdApp] KUMIKO_DRY_RUN_ENV="${raw}" unrecognized ` +
204
- `(expected 1|human|json|pulumi|k8s|boot); continuing with normal boot.`,
205
- );
206
- return null;
207
- }
208
-
209
- function isRenderMode(mode: RunMode | null): mode is DryRunMode {
210
- return mode !== null && mode !== "boot";
211
- }
212
-
213
- function defaultBootErrorReporter(err: KumikoBootError): never {
214
- // biome-ignore lint/suspicious/noConsole: boot-time error, no logger configured yet
215
- console.error(err.format());
216
- process.exit(1);
217
- }
218
-
219
- // Returned from runProdApp when KUMIKO_DRY_RUN_ENV is set AND envSource
220
- // was passed (= test-mode). The handle is intentionally inert — listen()
221
- // and stop() are no-ops; tests inspect the dry-run console output and
222
- // move on.
223
- function makeDryRunHandle(): ProdAppHandle {
224
- const noop = async () => {
225
- /* dry-run handle: no server was constructed */
226
- };
227
- return {
228
- // @cast-boundary dry-run-mode: no ApiEntrypoint exists because no
229
- // boot ran; the handle only surfaces test/CLI inspection and the
230
- // entrypoint is never reached by callers in dry-run.
231
- entrypoint: undefined as unknown as ApiEntrypoint,
232
- fetch: () => new Response("dry-run", { status: 503 }),
233
- listen: noop,
234
- stop: noop,
235
- };
236
- }
237
-
238
- /** Wrapper-API für den Password-Reset-Flow.
239
- *
240
- * Seit der delivery-Migration trägt PasswordResetOptions selbst `appUrl`
241
- * (+ appName/locale) und der Handler mailt via ctx.notify — kein
242
- * sendResetEmail-Callback mehr. Apps geben `auth.mail` (Convenience,
243
- * resolveAuthMail baut die appUrl) ODER einen expliziten Block. */
244
- export type PasswordResetSetup = PasswordResetOptions;
245
-
246
- /** Wrapper-API für den Email-Verification-Flow. Symmetrisch zu
247
- * PasswordResetSetup — = EmailVerificationOptions (appUrl via delivery). */
248
- export type EmailVerificationSetup = EmailVerificationOptions;
249
-
250
- /** Wrapper-API für Magic-Link Self-Signup. = SignupOptions (appUrl, die
251
- * Mail geht via delivery/ctx.notify wie reset/verify). Anders als reset/
252
- * verify gibt's KEIN hmacSecret — Signup-Tokens sind opaque random in
253
- * Redis, nicht HMAC-signed. */
254
- export type SignupSetup = SignupOptions;
255
-
256
- /** Wrapper-API für Tenant-Invite Magic-Link. = InviteOptions (appUrl, die
257
- * Invite-Mail geht via delivery wie reset/verify/signup). Drei accept-
258
- * Branches im framework; handler-names sind hardcoded in run-prod-app aus
259
- * AuthHandlers (analog signup). */
260
- export type InviteSetup = InviteOptions;
261
-
262
- /** Auth-Mail-Convenience-Optionen — shared zwischen runProdApp + runDevApp.
263
- * Verdrahtet alle 4 Mail-Flows aus einem env-SMTP-Transport + Standard-
264
- * Templates (siehe `auth.mail` + resolveAuthMail). */
265
- export type AuthMailOptions = {
266
- /** App-Basis-URL inkl. Schema (z.B. "https://app.example.com"). */
267
- readonly baseUrl: string;
268
- /** App-Name für Mail-Subject + Body. Default "Account". */
269
- readonly appName?: string;
270
- /** Locale für die Mail-Templates. Default "de". */
271
- readonly locale?: AuthMailLocale;
272
- /** "strict" blockt Login bis emailVerified; "off" mountet ohne Gate. */
273
- readonly emailVerificationMode?: "strict" | "off";
274
- /** Fallback-From-Adresse wenn `SMTP_FROM`-env fehlt (env gewinnt). */
275
- readonly from?: string;
276
- /** Einzelne Auth-Pfade überschreiben (Default DEFAULT_AUTH_PATHS). */
277
- readonly paths?: Partial<AuthPaths>;
278
- };
279
-
280
- export type RunProdAppAuthOptions = {
281
- /** Initial admin user. Seeded once (idempotent — re-boots check first
282
- * whether the email is already in the users table). */
283
- readonly admin: SeedAdminOptions;
284
- /** Optional override of the login error → HTTP status map. */
285
- readonly loginErrorStatusMap?: Readonly<Record<string, number>>;
286
- /** Opt-in: revocable server-side sessions. Caller MUSS
287
- * `createSessionsFeature()` zu `features` adden — runProdApp wired
288
- * hier nur die Auth-Callbacks (creator/revoker/checker) gegen die
289
- * echte db-connection, plus sessionStrictMode=true.
290
- *
291
- * Standardverhalten ohne diese Option: stateless JWTs ohne sid
292
- * (legacy-Verhalten, Karten­haus existing-Apps unangefasst). */
293
- readonly sessions?: ProdSessionsOption;
294
- /** Auth-Mail-Convenience: verdrahtet alle 4 Mail-Flows (passwordReset,
295
- * emailVerification, signup, invite) aus `auth.mail.baseUrl` + Standard-
296
- * Pfaden. Alle vier mailen via delivery (ctx.notify) — ersetzt das per-App
297
- * hand-gerollte `send*Email`-Callback-Wiring.
298
- *
299
- * Null-Transport-Guard: ohne `SMTP_HOST`-env wird KEIN Mail-Flow
300
- * verdrahtet (Routes blieben sonst 500). Eine App die einen einzelnen
301
- * Flow custom braucht, setzt `passwordReset`/`signup`/… explizit —
302
- * der explizite Block gewinnt über den mail-Default.
303
- *
304
- * Bewusste Entscheidung: `mail` mountet ALLE 4 Flows inkl. signup +
305
- * invite (Self-Registration on). Wer nur reset+verify will, lässt `mail`
306
- * weg und setzt die gewünschten Flows explizit — kein impliziter
307
- * Self-Signup. */
308
- readonly mail?: AuthMailOptions;
309
- /** Password-reset flow. When set, /api/auth/request-password-reset +
310
- * /api/auth/reset-password are mounted as public routes UND der
311
- * request/confirm-Handler im auth-email-password-Feature wird
312
- * registriert (sonst dispatchen die Routes ins Leere → 500).
313
- * Überschreibt den `mail`-Default für genau diesen Flow. */
314
- readonly passwordReset?: PasswordResetSetup;
315
- /** Email-verification flow. Symmetric to passwordReset. */
316
- readonly emailVerification?: EmailVerificationSetup;
317
- /** Self-Signup flow (Magic-Link). When set, /api/auth/signup-request +
318
- * /api/auth/signup-confirm are mounted; signup-confirm mintet JWT +
319
- * Cookies wie ein erfolgreicher login (Auto-Login direkt nach
320
- * Activation). */
321
- readonly signup?: SignupSetup;
322
- /** Tenant-Invite flow (Magic-Link). When set, /api/auth/invite-accept,
323
- * /api/auth/invite-accept-with-login, /api/auth/invite-signup-complete
324
- * are mounted. */
325
- readonly invite?: InviteSetup;
326
- /** Domain attribute for both auth cookies (see
327
- * AuthRoutesConfig.cookieDomain). Set to the registrable parent
328
- * domain when login and app live on different subdomains. */
329
- readonly cookieDomain?: string;
330
- /** Server-side Origin allowlist for the CSRF guard (see
331
- * AuthRoutesConfig.allowedOrigins). REQUIRED once `cookieDomain` is set —
332
- * buildServer fails closed otherwise. Apex + admin host, never tenant
333
- * subdomains. */
334
- readonly allowedOrigins?: readonly string[];
335
- /** Opt out of the Origin guard (see AuthRoutesConfig.unsafeSkipOriginCheck)
336
- * — accept the wide-cookie CSRF risk explicitly instead of setting
337
- * `allowedOrigins`. */
338
- readonly unsafeSkipOriginCheck?: boolean;
339
- };
340
-
341
- /** Hook for app-specific seeding — runs after the admin (when auth is
342
- * active). Each seed is responsible for its own idempotence (seeds are
343
- * expected to check "is my row already there?" before inserting). */
344
- export type ProdSeedFn = (deps: {
345
- db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
346
- }) => Promise<void>;
347
-
348
- /** Boot-Time-Deps die `extraContext` + `anonymousAccess` Factories als
349
- * Argument bekommen. Closure dann in der returned Config (z.B. ein
350
- * TenantResolver der gegen `db` queriet, oder ein extraContext-Provider
351
- * der direkt SSE-Events publishen will). Single-source: identisch zu
352
- * setupTestStack's extraContext-Factory-Shape damit Test/Prod gleich
353
- * aussehen. */
354
- export type RunProdAppDeps = {
355
- readonly db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
356
- readonly redis: import("ioredis").default;
357
- readonly registry: import("@cosmicdrift/kumiko-framework/engine").Registry;
358
- readonly sseBroker: SseBroker;
359
- };
360
-
361
- export type AnonymousAccessOption =
362
- | import("@cosmicdrift/kumiko-framework/api").ServerOptions["anonymousAccess"]
363
- | ((
364
- deps: RunProdAppDeps,
365
- ) => import("@cosmicdrift/kumiko-framework/api").ServerOptions["anonymousAccess"]);
366
-
367
- export type ExtraContextOption =
368
- | Record<string, unknown>
369
- | ((deps: RunProdAppDeps) => Record<string, unknown>);
370
-
371
- /** Per-Host Routing-Entscheidung für den staticDir-Fallback. Wird aus
372
- * hostDispatch returned. Drei Modi:
373
- * - "html": eine bestimmte HTML-Datei (relativ zu staticDir) servieren,
374
- * mit optionaler Schema-Injection und CSP. Schema-Injection MUSS
375
- * explizit eingeschaltet werden (default false) — Public-Domain-
376
- * Antworten leaken sonst die volle Admin-UI-Schema-Topologie.
377
- * - "redirect": 301/302 an die angegebene Location.
378
- * - "not-found": klar abweisen (z.B. unbekannte Subdomain).
379
- *
380
- * Wird NUR konsultiert wenn der Pfad sonst auf den HTML-Fallback gehen
381
- * würde — also für "/", "/index.html", oder SPA-Routen die weder Hono
382
- * matched noch eine konkrete Disk-Datei treffen. Asset-Pfade (/assets/*)
383
- * und API-Pfade laufen unabhängig vom Host. */
384
- export type HostDispatchResult =
385
- | {
386
- readonly kind: "html";
387
- readonly file: string;
388
- readonly injectSchema?: boolean;
389
- readonly csp?: string;
390
- }
391
- | { readonly kind: "redirect"; readonly to: string; readonly status?: 301 | 302 }
392
- | { readonly kind: "not-found" };
393
-
394
- export type HostDispatchFn = (req: {
395
- readonly host: string;
396
- readonly path: string;
397
- /** Query-String inkl. führendem `?`, `""` wenn keiner. Redirects die
398
- * den Pfad auf einen anderen Host umbiegen (z.B. Auth-Routen mit
399
- * `?token=` aus alten Mail-Links) MÜSSEN ihn an `to` anhängen. */
400
- readonly search: string;
401
- }) => HostDispatchResult;
402
-
403
- export type RunProdAppOptions = {
404
- /** App-specific features. config/user/tenant/auth-email-password are
405
- * auto-mixed when `auth:` is set — don't add them yourself. */
406
- readonly features: readonly FeatureDefinition[];
407
- /** Listen-Port. Default 3000 (or $PORT). */
408
- readonly port?: number;
409
- /** Auth-mode: standard features + routes wired, admin seeded. */
410
- readonly auth?: RunProdAppAuthOptions;
411
- /** Custom seed functions, run after the admin seed (when auth-mode). */
412
- readonly seeds?: readonly ProdSeedFn[];
413
- /** Pfad zum seeds-Directory für ES-Operations / Seed-Migrations
414
- * (file-basiert wie drizzle-migrate). Wenn gesetzt + KUMIKO_SKIP_ES_OPS
415
- * != "1": runProdApp scannt das Verzeichnis nach `<id>.ts` Files,
416
- * diff vs kumiko_es_operations-Table, läuft pending in Tx.
417
- * Plan: kumiko-platform/docs/plans/features/es-ops.md */
418
- readonly seedsDir?: string;
419
- /** Anonymous-access for public endpoints (same shape as runDevApp).
420
- * Akzeptiert entweder einen statischen Config-Object ODER eine
421
- * Factory `({db, redis, registry}) => Config` — die Factory wird
422
- * einmal zur Boot-Zeit aufgerufen, NACHDEM db/redis/registry konstruiert
423
- * sind. Der Caller closure'd typischerweise db/redis/registry in den
424
- * TenantResolver damit z.B. ein Subdomain → Tenant-Lookup gegen die
425
- * DB möglich ist (siehe samples/showcases/publicstatus für das
426
- * Multi-Tenant-Pattern). */
427
- readonly anonymousAccess?: AnonymousAccessOption;
428
- /** Static-file root for HTML / assets. Served on the catch-all route
429
- * for any path that doesn't match an /api/ handler. Use this for the
430
- * public status page HTML, embed widget JS, etc. */
431
- readonly staticDir?: string;
432
- /** Host-aware Routing-Hook für Multi-Tenant + Multi-App-Deployments
433
- * (z.B. publicstatus's `<sub>.publicstatus.eu` (Public-Page) +
434
- * `admin.publicstatus.eu` (Admin-UI) + `publicstatus.eu` (Apex/
435
- * Marketing) im SELBEN Container).
436
- *
437
- * Wird aufgerufen wenn der staticDir-Fallback einen HTML-Response
438
- * generieren würde (Root oder SPA-Route). Default-Verhalten ohne
439
- * hostDispatch: index.html mit Schema-Injection (Single-App).
440
- *
441
- * Sicherheitshinweis: Schema-Injection (`__KUMIKO_SCHEMA__`) leakt
442
- * die Admin-UI-Topologie (alle Screens, Felder, Layouts) ans HTML.
443
- * Public-Domain-Antworten sollen das NIEMALS — `injectSchema` ist
444
- * daher default false und MUSS pro Host explizit eingeschaltet
445
- * werden. CSP-Header pro Host können zusätzlich Asset-Pfade
446
- * einschränken. */
447
- readonly hostDispatch?: HostDispatchFn;
448
- /** Pfad zu kumiko/migrations für den Boot-Gate. Default "./kumiko/
449
- * migrations" relativ zum process-cwd (wo die App gestartet wird —
450
- * bei Container-Deploys typischerweise der App-Workspace-Root, weil
451
- * WORKDIR im Dockerfile dorthin zeigt). Boot wirft SchemaDriftError
452
- * wenn Migrations pending sind oder erwartete Tabellen fehlen.
453
- * Setze auf `false` um den Gate komplett zu deaktivieren — nur für
454
- * Setups die ihren eigenen Schema-Check fahren (z.B. bring-your-own-
455
- * ORM). Standard-Apps lassen das default. */
456
- readonly migrations?: { readonly dir: string } | false;
457
- /** Extra AppContext keys. Framework-Defaults werden zur Boot-Zeit
458
- * unter den Factory-Werten ergänzt, App-Werte gewinnen immer:
459
- * - `textContent` (createTextContentApi(db)) — immer
460
- * - `secrets` (createSecretsContext) — nur wenn das `secrets`-Feature
461
- * gemountet ist (sonst kein KEK-env-Zwang für Apps ohne secrets)
462
- * - `configResolver` — im Auth-Mode (env-config-overrides)
463
- * Akzeptiert einen statischen Object ODER eine Factory
464
- * `({db, redis, registry}) => Record<string, unknown>` — gleiches
465
- * Pattern wie `anonymousAccess`. Eine App die einen dieser Keys selbst
466
- * setzt (z.B. eigener configResolver) überschreibt den Default. */
467
- readonly extraContext?: ExtraContextOption;
468
- /** MasterKeyProvider für die auto-verdrahtete `ctx.secrets`. Default:
469
- * `createEnvMasterKeyProvider` (KEK aus `KUMIKO_SECRETS_MASTER_KEY_V<n>`).
470
- * Override für KMS-Backends (AWS/GCP/Azure) statt env-KEK. Nur relevant
471
- * wenn das `secrets`-Feature gemountet ist. */
472
- readonly masterKey?: MasterKeyProvider;
473
- /** Deploy-Topologie. Default `true` (Single-Container): dieser Prozess
474
- * fährt HTTP + BEIDE Job-Lanes (api + worker) + den Event-Dispatcher
475
- * (MSP-Anwendung) inline — via `createAllInOneEntrypoint`. Damit laufen
476
- * worker-Lane-Crons (z.B. der Daten-Export `run-export-jobs`, default
477
- * `runIn:"worker"`) und r.multiStreamProjection ohne separaten Worker.
478
- *
479
- * `false` NUR mit einem dezidierten Worker-Deployment setzen: dann fährt
480
- * dieser Prozess API-only (`createApiEntrypoint`), und worker-Lane-Jobs
481
- * + MSPs werden NICHT mehr lokal angewandt — der Worker muss sie
482
- * übernehmen, sonst bleiben Export-Jobs pending und die Read-Side leer
483
- * (2026-06-11-Incident-Klasse). */
484
- readonly runSingleInstance?: boolean;
485
- /** Job-Block. Wenn das Feature `r.job(...)` registriert, wird er
486
- * automatisch verdrahtet (siehe runSingleInstance). */
487
- readonly jobs?: {
488
- /** BullMQ-Queue-Prefix (default "kumiko"). */
489
- readonly queueNamePrefix?: string;
490
- };
491
- /** Event-Dispatcher (MSP-Anwendung) im API-Process. Default AN —
492
- * runProdApp ist das Single-Container-Deployment, es gibt keinen
493
- * Worker-Process der multiStreamProjections anwenden könnte. Bis
494
- * 2026-06-11 fehlte der Dispatcher hier komplett: jede MSP-basierte
495
- * Read-Projektion (z.B. custom-fields jsonb) blieb in Prod leer,
496
- * kumiko_event_consumers blieb ohne Rows. `disabled: true` nur für
497
- * Setups mit dezidiertem Worker-Process. */
498
- readonly eventDispatcher?: {
499
- readonly disabled?: boolean;
500
- /** Poll-Intervall des Dispatcher-Loops (default siehe
501
- * createEventDispatcher). LISTEN/NOTIFY-Wiring kommt mit einem
502
- * späteren pgClient-Pass-through. */
503
- readonly pollIntervalMs?: number;
504
- };
505
- /** Mount-Point für app-eigene HTTP-Routes außerhalb des Dispatcher-
506
- * Systems. Aufgerufen NACH /api/* + /health, VOR der static-fallback —
507
- * perfekt für GET-Endpoints die kein JSON liefern: /feed.xml,
508
- * /og-image, /sitemap.xml, /robots.txt-mit-Logik. Bekommt das raw
509
- * Hono-app + die Connection-Deps (db/redis) zum Querying.
510
- *
511
- * Naming: `deps` statt `ctx` weil im Framework `ctx` der HandlerContext
512
- * mit user/tenant/registry ist — hier ist der Scope absichtlich kleiner
513
- * (Routes laufen außerhalb der Auth/Tenant-Pipeline). */
514
- readonly extraRoutes?: (app: import("hono").Hono, deps: ExtraRoutesSystemDeps) => void;
515
- /** When true (default), Bun.serve is started before runProdApp resolves —
516
- * the common case: `await runProdApp({...})` boots the server and the
517
- * process stays up listening on PORT. Set to false in tests that drive
518
- * the fetch-handler directly (Bun.serve isn't available under vitest +
519
- * node), then call handle.listen() manually if needed. */
520
- readonly autoListen?: boolean;
521
- /** Feature-toggle resolver — durchgereicht an createApiEntrypoint's
522
- * dispatcherOptions. Sprint-8 Tier-Composition: per-Tenant unterschied-
523
- * liche features aktiv via globalFeatureToggleRuntime. Pattern:
524
- * createLateBoundHolder + post-boot runtime.initialize in einem
525
- * seed-fn (db ist erst nach migrations + features ready). */
526
- readonly effectiveFeatures?: EffectiveFeaturesResolver;
527
- /** Composed Zod-schema for env-validation (from `composeEnvSchema({
528
- * features, extend })` in @cosmicdrift/kumiko-framework/env). When set:
529
- * - `process.env` is parsed against it BEFORE any boot work; missing
530
- * or invalid vars throw a `KumikoBootError` listing ALL problems
531
- * at once (not first-fail).
532
- * - `KUMIKO_DRY_RUN_ENV=human|json|pulumi|k8s` introspects the schema
533
- * and prints the env-var inventory, then exits without booting.
534
- *
535
- * 9.1 is additive: features that still read `process.env` directly
536
- * keep working. Migration to the schema is Sprint-9.2-9.5. */
537
- readonly envSchema?: ComposedEnvSchema;
538
- /** Prefix for `pulumi config set <prefix><CamelCase(VAR)>` in dry-run
539
- * output and boot-error suggestions. Without this, suggestions use
540
- * bare `camelCase(VAR)` and ops has to guess the app prefix. */
541
- readonly pulumiPrefix?: string;
542
- /** Handler for KumikoBootError. Default: print formatted error to
543
- * stderr and `process.exit(1)` so the container restarts with a
544
- * visible log line. Override in tests that drive runProdApp directly
545
- * (avoid the exit). Return type is `void` rather than `never` to keep
546
- * test-overrides honest — if a reporter returns, runProdApp falls
547
- * through to a regular `throw err` as the safety net. */
548
- readonly bootErrorReporter?: (err: KumikoBootError) => void;
549
- /** Override `process.env` for env-validation. Default: `process.env`.
550
- * Tests use this to feed crafted env-maps without polluting the
551
- * global. */
552
- readonly envSource?: Record<string, string | undefined>;
553
- };
554
-
555
- export type ProdAppHandle = {
556
- /** The composed entrypoint — AllInOne (runSingleInstance, default) or
557
- * Api-only (runSingleInstance:false). In KUMIKO_DRY_RUN_ENV mode WITH
558
- * `envSource` injected (test path), no boot ran and this slot is an
559
- * undefined-cast — do not access. Production dry-run hits
560
- * `process.exit(0)` before returning a handle. */
561
- readonly entrypoint: ApiEntrypoint | AllInOneEntrypoint;
562
- /** The fetch-handler — wired into Bun.serve in production, called
563
- * directly in tests. Composes Hono + static-fallback. */
564
- readonly fetch: (req: Request) => Promise<Response> | Response;
565
- /** Active Bun-server (only set when listen() was called — tests skip
566
- * listen() because Bun.serve isn't available under vitest/node). */
567
- server?: ReturnType<typeof Bun.serve>;
568
- /** Bind to PORT and start serving. Production calls this; tests don't. */
569
- readonly listen: (port?: number) => Promise<void>;
570
- readonly stop: () => Promise<void>;
571
- };
572
-
573
- // Mint `ctx.config` per request: the dispatcher only builds a per-user
574
- // ConfigAccessor when `_configAccessorFactory` is on the AppContext
575
- // (pipeline/dispatcher.ts). Without it `ctx.config` stays undefined and any
576
- // handler reading it — e.g. createFileProviderForTenant for the GDPR export
577
- // download — throws "ctx.config is missing". Built from the EFFECTIVE resolver
578
- // so an app-supplied configResolver override (its appOverrides) is the one
579
- // ctx.config reads. Shared with runDevApp (mergeConfigResolverDefault) for
580
- // dev/prod parity.
581
- export function addConfigAccessorFactory<T extends { readonly configResolver?: ConfigResolver }>(
582
- resolved: T,
583
- registry: Registry,
584
- ): T {
585
- if (!resolved.configResolver) return resolved;
586
- return {
587
- ...resolved,
588
- _configAccessorFactory: createConfigAccessorFactory(registry, resolved.configResolver),
589
- };
590
- }
591
-
592
- // Framework-Default-Provider für den AppContext — gleicher Mechanismus wie
593
- // der tenantTierResolver-Autowire (findTierResolverUsage): deklarierter
594
- // Bedarf (Feature gemountet) → Default aus db/env, App überschreibt nur die
595
- // Ausnahme. textContent ist unbedingt (createTextContentApi wirft nie, baut
596
- // nur einen db-gebundenen Accessor). secrets wird nur auto-verdrahtet wenn
597
- // das secrets-Feature gemountet ist UND ein KEK tatsächlich verfügbar ist
598
- // (masterKey-Override ODER env-KEK present) — sonst skip, damit der eager
599
- // createEnvMasterKeyProvider nicht wirft. Das deckt zwei Fälle: (a) App ohne
600
- // secrets → kein KEK-Zwang; (b) dev mit App-eigenem DEV-KEK in extraContext
601
- // (kein env-KEK) → kein Boot-Crash, die App-explizite secrets-Wiring gewinnt.
602
- // Prod-Misconfig (secrets gemountet, kein KEK) fängt schon secretsEnvSchema
603
- // beim Boot; fehlt der env-Schema-Pfad, wirft requireSecretsContext beim
604
- // ersten ctx.secrets-Zugriff mit Wiring-Hinweis. configResolver nur im
605
- // Auth-Mode. Exportiert + pure für Unit-Tests; der merge mit App-Werten
606
- // passiert beim Caller (App gewinnt).
607
- const MASTER_KEK_VAR = /^KUMIKO_SECRETS_MASTER_KEY_V\d+$/;
608
- function envHasMasterKek(env: Record<string, string | undefined>): boolean {
609
- return Object.entries(env).some(([k, v]) => MASTER_KEK_VAR.test(k) && !!v);
610
- }
611
-
612
- // Prod/dev parity for ctx.notify: without this `_notifyFactory` is only wired
613
- // in tests (createDeliveryTestContext), so ctx.notify is undefined at runtime
614
- // and every notification silently skips. sseBroker optional (email/push don't
615
- // need it, in-app SSE does); no jobRunner → queued channels send inline.
616
- function buildDeliveryNotifyFactory(opts: {
617
- readonly db: DbConnection;
618
- readonly registry: Registry;
619
- readonly sseBroker?: SseBroker;
620
- }): NotifyFactory {
621
- const deliveryService = createDeliveryService({
622
- db: opts.db,
623
- registry: opts.registry,
624
- channels: collectChannels(opts.registry),
625
- ...(opts.sseBroker && { sseBroker: opts.sseBroker }),
626
- });
627
- return (user, tenantId) => (notificationType, options) =>
628
- deliveryService.notify(notificationType, options, user, tenantId);
629
- }
630
-
631
- export function buildBootExtraContext(opts: {
632
- readonly db: DbConnection;
633
- readonly features: readonly FeatureDefinition[];
634
- readonly envSource: Record<string, string | undefined>;
635
- readonly registry: Registry;
636
- readonly hasAuth: boolean;
637
- readonly masterKey?: MasterKeyProvider;
638
- readonly sseBroker?: SseBroker;
639
- }): Record<string, unknown> {
640
- const hasSecretsFeature = opts.features.some((f) => f.name === SECRETS_FEATURE_NAME);
641
- const wireSecrets =
642
- hasSecretsFeature && (opts.masterKey !== undefined || envHasMasterKek(opts.envSource));
643
- const hasDeliveryFeature = opts.features.some((f) => f.name === DELIVERY_FEATURE);
644
- return {
645
- textContent: createTextContentApi(opts.db),
646
- ...(hasDeliveryFeature && {
647
- _notifyFactory: buildDeliveryNotifyFactory({
648
- db: opts.db,
649
- registry: opts.registry,
650
- ...(opts.sseBroker && { sseBroker: opts.sseBroker }),
651
- }),
652
- }),
653
- ...(wireSecrets && {
654
- secrets: createSecretsContext({
655
- db: opts.db,
656
- masterKeyProvider:
657
- opts.masterKey ??
658
- createEnvMasterKeyProvider({
659
- // CURRENT_VERSION default "1" spiegelt secretsEnvSchema — ohne
660
- // ihn wirft der raw-env-Provider, obwohl V1 gesetzt ist.
661
- env: {
662
- ...opts.envSource,
663
- KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION:
664
- opts.envSource["KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION"] ?? "1",
665
- },
666
- }),
667
- }),
668
- }),
669
- ...(opts.hasAuth && {
670
- configResolver: createConfigResolver({
671
- appOverrides: buildEnvConfigOverrides(opts.registry, opts.envSource),
672
- }),
673
- }),
674
- };
675
- }
676
-
677
- // auth.mail-Convenience → normalisiert in die expliziten passwordReset/
678
- // emailVerification/signup/invite-Felder, BEVOR buildComposeAuthOptions
679
- // (Feature-Side: hmacSecret/mode) und das auth-routes-Fragment sie lesen —
680
- // so speist EIN mail-Block beide Pfade. App-explizite Flows gewinnen über
681
- // den Default. Null-Transport-Guard: ohne SMTP_HOST-env bleibt alles
682
- // unverdrahtet (sonst lieferten die reset/verify-Routes 500).
683
- /** Die Auth-Felder die resolveAuthMail liest/normalisiert — beide
684
- * App-Auth-Typen (prod + dev) erfüllen das strukturell. */
685
- type AuthMailNormalizable = {
686
- readonly mail?: AuthMailOptions;
687
- readonly passwordReset?: PasswordResetSetup;
688
- readonly emailVerification?: EmailVerificationSetup;
689
- readonly signup?: SignupSetup;
690
- readonly invite?: InviteSetup;
691
- };
692
-
693
- export function resolveAuthMail<T extends AuthMailNormalizable>(
694
- auth: T,
695
- hmacSecret: string,
696
- envSource: Record<string, string | undefined>,
697
- ): T {
698
- if (!auth.mail) return auth;
699
- // SMTP-presence gate: ohne SMTP_HOST-env wird KEIN Flow verdrahtet (Routes
700
- // blieben sonst 500). Der eigentliche Mail-Versand läuft über delivery
701
- // (channel-email), nicht über diesen Transport — er ist nur der Detektor
702
- // "ist Mail konfiguriert?".
703
- if (
704
- !createSmtpTransportFromEnv(envSource, { fallbackFrom: auth.mail.from ?? "noreply@localhost" })
705
- ) {
706
- return auth;
707
- }
708
- const paths = makeAuthPaths(auth.mail.paths);
709
- // appName/locale fließen in alle vier Flow-Options (alle mailen via delivery).
710
- const mailPresentation = {
711
- ...(auth.mail.appName !== undefined && { appName: auth.mail.appName }),
712
- ...(auth.mail.locale !== undefined && { locale: auth.mail.locale }),
713
- };
714
- return {
715
- ...auth,
716
- passwordReset: auth.passwordReset ?? {
717
- hmacSecret,
718
- appUrl: `${auth.mail.baseUrl}${paths.resetPassword}`,
719
- ...mailPresentation,
720
- },
721
- emailVerification: auth.emailVerification ?? {
722
- hmacSecret,
723
- appUrl: `${auth.mail.baseUrl}${paths.verifyEmail}`,
724
- ...(auth.mail.emailVerificationMode !== undefined && {
725
- mode: auth.mail.emailVerificationMode,
726
- }),
727
- ...mailPresentation,
728
- },
729
- signup: auth.signup ?? {
730
- appUrl: `${auth.mail.baseUrl}${paths.signupComplete}`,
731
- ...mailPresentation,
732
- },
733
- invite: auth.invite ?? {
734
- appUrl: `${auth.mail.baseUrl}${paths.inviteAccept}`,
735
- ...mailPresentation,
736
- },
737
- };
738
- }
739
-
740
- export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHandle> {
741
- // 0. Env-Schema validation + dry-run modes. Runs FIRST so:
742
- // - operators can introspect env-requirements without a real boot
743
- // (no DB connection needed, KUMIKO_DRY_RUN_ENV=… → render + exit)
744
- // - missing/invalid env-vars produce a structured KumikoBootError
745
- // with ALL problems aggregated (not first-fail), before we waste
746
- // seconds on a Postgres connection that was never configured.
747
- //
748
- // Both code paths are no-ops when no envSchema is passed — Sprint-9
749
- // migration is per-feature additive; pre-migration apps keep the
750
- // legacy `requireEnv("DATABASE_URL")` checks below.
751
- //
752
- // Ordering invariant: this step runs BEFORE the Temporal polyfill,
753
- // so env-schemas MUST use only Temporal-free Zod types. Don't author
754
- // `z.iso.date()`/`Temporal.Instant` fields on env-vars — they'd crash
755
- // at parse-time before the polyfill loads. Plain strings + .regex /
756
- // .min / .email / .url cover every env-var shape we've actually
757
- // needed in 9.1's audit (37 references, 25 distinct vars).
758
- const envSource = options.envSource ?? process.env;
759
- const runMode = parseRunMode(envSource["KUMIKO_DRY_RUN_ENV"]);
760
- if (options.envSchema) {
761
- if (isRenderMode(runMode)) {
762
- // biome-ignore lint/suspicious/noConsole: dry-run output IS the deliverable
763
- console.log(
764
- renderDryRun(options.envSchema, runMode, {
765
- ...(options.pulumiPrefix ? { pulumiPrefix: options.pulumiPrefix } : {}),
766
- sources: options.envSchema.sources,
767
- }),
768
- );
769
- // Tests inject envSource and want a return-value, not exit. Detecting
770
- // "this is a test" via envSource is brittle; instead exit when running
771
- // against the real process.env (the deploy-flow), return otherwise.
772
- if (options.envSource === undefined) {
773
- process.exit(0);
774
- }
775
- return makeDryRunHandle();
776
- }
777
- // boot-mode AND normal-boot both run env-validation. boot-mode wants
778
- // a real env-check (all required vars present + schema-valid) before
779
- // it asserts feature-wiring works.
780
- try {
781
- parseEnv(options.envSchema.schema, envSource, {
782
- sources: options.envSchema.sources,
783
- ...(options.pulumiPrefix ? { pulumiPrefix: options.pulumiPrefix } : {}),
784
- });
785
- } catch (err) {
786
- if (err instanceof KumikoBootError) {
787
- const reporter = options.bootErrorReporter ?? defaultBootErrorReporter;
788
- reporter(err);
789
- }
790
- throw err;
791
- }
792
- }
793
-
794
- // 1. Polyfill before anything else — feature code references Temporal.
795
- const { ensureTemporalPolyfill } = await import("@cosmicdrift/kumiko-framework/time");
796
- await ensureTemporalPolyfill();
797
-
798
- // 2. Env-vars: fail-fast. Better a 0s boot crash with a clear error
799
- // than a 30s timeout chasing a Postgres connection that was never
800
- // configured.
801
- const databaseUrl = requireEnv("DATABASE_URL", envSource);
802
- const redisUrl = requireEnv("REDIS_URL", envSource);
803
- const jwtSecret = requireEnv("JWT_SECRET", envSource);
804
- const jwtIssuer = readEnv("JWT_ISSUER", envSource);
805
- const instanceId = readEnv("KUMIKO_INSTANCE_ID", envSource);
806
- const port = options.port ?? Number.parseInt(envSource["PORT"] ?? "3000", 10);
807
-
808
- // biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
809
- console.log(`[runProdApp] booting Kumiko stack on port ${port}…`);
810
-
811
- // auth.mail → expandiert in die expliziten Flow-Felder, bevor sie sowohl
812
- // die Feature-Side (buildComposeAuthOptions) als auch das Routes-Fragment
813
- // unten speisen. Ab hier IMMER effectiveAuth statt options.auth lesen.
814
- const effectiveAuth = options.auth
815
- ? resolveAuthMail(options.auth, jwtSecret, envSource)
816
- : undefined;
817
-
818
- // 3. Feature registry. Auth-mode auto-mixes config/user/tenant/auth-email-
819
- // password via composeFeatures — same source-of-truth as runDevApp
820
- // AND the per-app drizzle-Schema-Generator, so Migration und Runtime
821
- // sehen exakt dieselbe Liste. Built BEFORE any connection so boot-mode
822
- // can validate wiring and exit without opening a Postgres/Redis socket.
823
- const composeAuthOptions = buildComposeAuthOptions(effectiveAuth);
824
- const features = composeFeatures(options.features, {
825
- includeBundled: !!effectiveAuth,
826
- ...(composeAuthOptions && { authOptions: composeAuthOptions }),
827
- });
828
-
829
- validateBoot(features);
830
- warnIfNonUtcServerTimeZone();
831
- validateAppCustomScreenWriteQns(process.cwd(), collectWriteHandlerQns(features));
832
- const registry = createRegistry(features);
833
-
834
- // C1 boot-mode exit: validators ran + registry built; no DB/Redis client
835
- // is constructed at all in this branch (the eager `new Redis(...)` below
836
- // would otherwise open a TCP connect just to immediately disconnect it).
837
- if (runMode === "boot") {
838
- // biome-ignore lint/suspicious/noConsole: boot-mode output IS the deliverable
839
- console.log(
840
- `[runProdApp] boot validation OK (${features.length} features, ${registry.features.size} registry entries)`,
841
- );
842
- if (options.envSource === undefined) {
843
- process.exit(0);
844
- }
845
- return makeDryRunHandle();
846
- }
847
-
848
- // 4. Connections — Postgres + Redis. The Redis client is shared by
849
- // idempotency, event-dedup, entity-cache, rate-limit; failing to
850
- // construct here surfaces the misconfig immediately. `new Redis(...)`
851
- // connects eagerly, so it must stay AFTER the boot-mode exit above.
852
- const { db, close: closeDb } = createDbConnection(databaseUrl);
853
- const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
854
-
855
- // Sprint-8a Tier-Composition auto-wire: scan features for a
856
- // tenantTierResolver-extension. If found AND user didn't supply own
857
- // effectiveFeatures, build the resolver here (db + registry are
858
- // available) before the dispatcher is constructed. App-Author sees
859
- // nothing — `createTierEngineFeature(opts)` mounts + framework auto-wires.
860
- let resolvedEffectiveFeatures: EffectiveFeaturesResolver | undefined = options.effectiveFeatures;
861
- if (resolvedEffectiveFeatures === undefined) {
862
- const tierResolverUsage = findTierResolverUsage(features);
863
- if (tierResolverUsage) {
864
- const plugin = tierResolverUsage.options as TierResolverPlugin;
865
- resolvedEffectiveFeatures = await plugin.build({ db, registry });
866
- }
867
- }
868
-
869
- // 5. Schema-Drift-Gate (drizzle-frei, kumiko/migrations). `kumiko schema
870
- // apply` läuft als Deploy-Step VOR dem Container-Rollout. Boot prüft nur:
871
- // (a) Alle Migrations aus kumiko/migrations/*.sql sind in
872
- // _kumiko_migrations applied (+ checksum unverändert)
873
- // (b) Alle Tabellen aus kumiko/migrations/.snapshot.json existieren
874
- // Drift = Boot-Error mit klarer Meldung (kein Auto-Heal — mehrere
875
- // Container-Replicas würden sonst race-conditionen fahren). Opt-out via
876
- // `migrations: false` für custom Schema-Setups.
877
- if (options.migrations !== false) {
878
- const migrationsDir = options.migrations?.dir ?? "./kumiko/migrations";
879
- // biome-ignore lint/suspicious/noConsole: boot-time progress hint
880
- console.log(`[runProdApp] checking schema drift (${migrationsDir})…`);
881
- try {
882
- await assertKumikoSchemaCurrent(db, migrationsDir);
883
- } catch (err) {
884
- if (err instanceof SchemaDriftError) {
885
- // biome-ignore lint/suspicious/noConsole: terminal error message
886
- console.error(`\n[runProdApp] BOOT ABORTED — ${err.message}\n`);
887
- }
888
- throw err;
889
- }
890
- }
891
-
892
- // 6. Pipeline pieces — same default config as runDevApp's setupTestStack.
893
- const idempotency = createIdempotencyGuard(redis, { ttlSeconds: 60 });
894
- const eventDedup = createEventDedup(redis, { ttlSeconds: 60 });
895
- const entityCache = createEntityCache(redis, { ttlSeconds: 60 });
896
-
897
- // 7. Lifecycle is built by createApiEntrypoint when not supplied —
898
- // we let the entrypoint own it and read it back through the handle
899
- // for SIGTERM.
900
- //
901
- // extraContext + anonymousAccess sind factory-union: entweder direktes
902
- // Object oder Function die {db, redis, registry} bekommt und das Object
903
- // returned. Factory-Form gilt als bevorzugt für Cases die zur Boot-Zeit
904
- // gegen die DB resolven müssen (z.B. Subdomain-Tenant-Lookup im
905
- // tenantResolver) — die Factory closure'd `db` und der Resolver kann
906
- // sie zur Request-Zeit aufrufen.
907
- // sseBroker hier bauen (statt's createApiEntrypoint intern machen zu
908
- // lassen) damit extraContext-Factories ihn schon zur Boot-Zeit closure'n
909
- // können — z.B. ein extraContext-Provider der direkt SSE-Events
910
- // publisht. Wir reichen denselben Broker dann an createApiEntrypoint
911
- // durch (sseBroker?-option), damit der Server-internal-Broadcast und
912
- // App-spezifische Publishes über genau einen Broker laufen.
913
- const sseBroker = createSseBroker();
914
- const deps: RunProdAppDeps = { db, redis, registry, sseBroker };
915
- const resolvedExtraContext =
916
- typeof options.extraContext === "function"
917
- ? options.extraContext(deps)
918
- : (options.extraContext ?? {});
919
-
920
- // Framework-Default-Provider zuerst, App-Werte (resolvedExtraContext)
921
- // gewinnen immer (z.B. money-horse's eigener configResolver).
922
- const autoExtraContext = buildBootExtraContext({
923
- db,
924
- features,
925
- envSource,
926
- registry,
927
- hasAuth: !!effectiveAuth,
928
- sseBroker,
929
- ...(options.masterKey && { masterKey: options.masterKey }),
930
- });
931
- const extraContext = addConfigAccessorFactory(
932
- { ...autoExtraContext, ...resolvedExtraContext },
933
- registry,
934
- );
935
- const resolvedAnonymousAccess =
936
- typeof options.anonymousAccess === "function"
937
- ? options.anonymousAccess(deps)
938
- : options.anonymousAccess;
939
-
940
- // Sessions opt-in: db ist hier schon konkret (createDbConnection oben),
941
- // also direkt verdrahten — kein late-bound nötig wie bei runDevApp.
942
- // sessionStrictMode=true: Prod-Sessions sollen nicht stillschweigend
943
- // von einem JWT-ohne-sid umgangen werden können. sessionMassRevoker
944
- // (4. callback aus createSessionCallbacks) ist nicht Teil der
945
- // AuthRoutesConfig-Surface — der wird vom sessions-Feature selbst über
946
- // die `autoRevokeOnPasswordChange`-Option konsumiert, nicht über die
947
- // auth-routes.
948
- // Secure-by-default: if the sessions feature is mounted, server-side revocation +
949
- // sessionStrictMode are wired automatically; `auth.sessions` only overrides the config,
950
- // and `auth.sessions: false` is the explicit opt-out (back to stateless JWTs).
951
- const sessionsFeatureMounted = features.some((f) => f.name === SESSIONS_FEATURE);
952
- const sessionAuthFragment = shouldWireProdSessions(
953
- Boolean(effectiveAuth),
954
- sessionsFeatureMounted,
955
- effectiveAuth?.sessions,
956
- )
957
- ? buildProdSessionAuth(db, resolveProdSessionsConfig(effectiveAuth?.sessions))
958
- : undefined;
959
-
960
- const baseEntrypointOptions = {
961
- registry,
962
- context: {
963
- db,
964
- redis,
965
- entityCache,
966
- registry,
967
- ...extraContext,
968
- },
969
- sseBroker,
970
- jwtSecret,
971
- ...(jwtIssuer && { jwtIssuer }),
972
- ...(instanceId && { instanceId }),
973
- dispatcherOptions: {
974
- idempotency,
975
- ...(resolvedEffectiveFeatures && { effectiveFeatures: resolvedEffectiveFeatures }),
976
- },
977
- eventDedup,
978
- ...(effectiveAuth && {
979
- auth: {
980
- membershipQuery: TenantQueries.memberships,
981
- userQuery: UserQueries.findForAuth,
982
- loginHandler: AuthHandlers.login,
983
- loginErrorStatusMap: effectiveAuth.loginErrorStatusMap ?? {
984
- [AuthErrors.invalidCredentials]: 401,
985
- [AuthErrors.noMembership]: 403,
986
- },
987
- ...(effectiveAuth.cookieDomain !== undefined && {
988
- cookieDomain: effectiveAuth.cookieDomain,
989
- }),
990
- ...(effectiveAuth.allowedOrigins !== undefined && {
991
- allowedOrigins: effectiveAuth.allowedOrigins,
992
- }),
993
- ...(effectiveAuth.unsafeSkipOriginCheck !== undefined && {
994
- unsafeSkipOriginCheck: effectiveAuth.unsafeSkipOriginCheck,
995
- }),
996
- ...sessionAuthFragment,
997
- ...(effectiveAuth.passwordReset && {
998
- passwordReset: {
999
- requestHandler: AuthHandlers.requestPasswordReset,
1000
- confirmHandler: AuthHandlers.resetPassword,
1001
- },
1002
- }),
1003
- ...(effectiveAuth.emailVerification && {
1004
- emailVerification: {
1005
- requestHandler: AuthHandlers.requestEmailVerification,
1006
- confirmHandler: AuthHandlers.verifyEmail,
1007
- },
1008
- }),
1009
- ...(effectiveAuth.signup && {
1010
- signup: {
1011
- requestHandler: AuthHandlers.signupRequest,
1012
- confirmHandler: AuthHandlers.signupConfirm,
1013
- },
1014
- }),
1015
- ...(effectiveAuth.invite && {
1016
- invite: {
1017
- acceptHandler: AuthHandlers.inviteAccept,
1018
- acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
1019
- signupCompleteHandler: AuthHandlers.inviteSignupComplete,
1020
- },
1021
- }),
1022
- },
1023
- }),
1024
- ...(resolvedAnonymousAccess && { anonymousAccess: resolvedAnonymousAccess }),
1025
- };
1026
-
1027
- // Deploy-Topologie. Default (Single-Container): createAllInOneEntrypoint
1028
- // fährt HTTP + BEIDE Job-Lanes (zwei Runner, jeder schedult seine eigene
1029
- // Lane-Crons → kein Double-Fire) + Event-Dispatcher inline. So laufen
1030
- // worker-Lane-Crons (run-export-jobs default runIn:"worker") UND MSPs ohne
1031
- // separaten Worker-Process — die Asymmetrie, an der der Daten-Export hing.
1032
- // runSingleInstance:false → API-only; ein dezidierter Worker MUSS dann die
1033
- // worker-Lane + MSPs übernehmen (api-Lane-Jobs laufen weiter lokal).
1034
- // Default single-instance. eventDispatcher.disabled ist die Alt-Art, MSPs
1035
- // diesem Prozess wegzunehmen (dezidierter Worker) — als runSingleInstance:
1036
- // false honorieren (api-only, kein lokaler Dispatcher), damit der explizite
1037
- // Flag Vorrang behält aber Bestands-Caller nicht brechen.
1038
- const runSingleInstance = options.runSingleInstance ?? options.eventDispatcher?.disabled !== true;
1039
- const hasJobs = registry.getAllJobs().size > 0;
1040
- const queueNamePrefix = options.jobs?.queueNamePrefix;
1041
- const dispatcherTunables =
1042
- options.eventDispatcher?.pollIntervalMs !== undefined
1043
- ? { pollIntervalMs: options.eventDispatcher.pollIntervalMs }
1044
- : {};
1045
-
1046
- const entrypoint: ApiEntrypoint | AllInOneEntrypoint = runSingleInstance
1047
- ? createAllInOneEntrypoint({
1048
- ...baseEntrypointOptions,
1049
- // Worker-Seite liest die JobsBlock TOP-LEVEL (nicht nested `jobs` wie
1050
- // die api-Seite); beide Lane-Runner ziehen redisUrl/prefix von hier.
1051
- redisUrl,
1052
- ...(queueNamePrefix !== undefined && { queueNamePrefix }),
1053
- ...(!options.eventDispatcher?.disabled && { eventDispatcher: dispatcherTunables }),
1054
- })
1055
- : createApiEntrypoint({
1056
- ...baseEntrypointOptions,
1057
- // API-only: api-Lane-Jobs laufen lokal, ein dezidierter Worker fährt
1058
- // worker-Lane + MSPs. createApiEntrypoint liest den nested `jobs`-Block.
1059
- ...(hasJobs && {
1060
- jobs: {
1061
- redisUrl,
1062
- runLocalJobs: true,
1063
- ...(queueNamePrefix !== undefined && { queueNamePrefix }),
1064
- },
1065
- }),
1066
- });
1067
-
1068
- // 8. Build the AppSchema once + serialize. Wird beim Static-Fallback
1069
- // in die index.html injiziert damit createKumikoApp() im Browser
1070
- // `window.__KUMIKO_SCHEMA__` synchron lesen kann — gleicher Pfad
1071
- // wie im dev-server, damit der Client-Code keine Sonderfall-
1072
- // Branch zwischen dev/prod braucht. Boot-once weil Features
1073
- // nach dem Start nicht mehr ändern.
1074
- // TODO: Sobald per-Tenant- oder per-User-Schema kommt (Feature-Toggles
1075
- // pro Tenant, Auth-Rolle gated Screens), muss die Injection pro
1076
- // Request rendern — staticDir-Fallback einen render(req)-Hook bekommen
1077
- // statt eines fixed JSON-Strings. Heute: registry-static, also OK.
1078
- const appSchemaJson = JSON.stringify(buildAppSchema(registry));
1079
-
1080
- // 9. Seeds: admin first, then config-seeds from r.config({seeds}),
1081
- // then app-specific. All idempotent — runProdApp doesn't gate
1082
- // "first boot" via flag, every seed-step checks its own
1083
- // preconditions. Config-seeds rely on a deterministic
1084
- // aggregate-id so re-boot becomes a version_conflict skip.
1085
- if (effectiveAuth) {
1086
- await seedAdmin(db, effectiveAuth.admin);
1087
- }
1088
- await applyBootSeeds({ registry, db });
1089
- for (const seed of options.seeds ?? []) {
1090
- await seed({ db });
1091
- }
1092
-
1093
- // ES-Operations / Seed-Migrations (Phase 1). Läuft NACH applyBootSeeds +
1094
- // existing seeds-array — die deklarativen Seeds sind die "always-insert-
1095
- // if-missing"-Schicht; seed-migrations sind die "diff-and-update"-
1096
- // Schicht für Drift den existing Seeds nicht erfassen können (z.B.
1097
- // Membership-Roles-Change nach initialer Seed-Erstellung).
1098
- if (options.seedsDir !== undefined && envSource["KUMIKO_SKIP_ES_OPS"] !== "1") {
1099
- await createEsOperationsTable(db);
1100
- const seedDispatcher = createDispatcher(registry, {
1101
- db,
1102
- redis,
1103
- entityCache,
1104
- registry,
1105
- ...extraContext,
1106
- });
1107
- await runPendingSeedMigrations({
1108
- db,
1109
- seedsDir: options.seedsDir,
1110
- appliedBy: "boot",
1111
- registry, // → dry-run-validator catched handler-QN-typos vor dem write
1112
- // @wrapper-known semantic-alias
1113
- createContext: (dbRunner: DbRunner) =>
1114
- createSeedMigrationContext({ dispatcher: seedDispatcher, dbRunner }),
1115
- });
1116
- }
1117
-
1118
- await entrypoint.start();
1119
-
1120
- // 10. App-eigene HTTP-Routes mounten — vor dem static-fallback. Hono
1121
- // matcht in Eintrags-Reihenfolge, also greifen explizite Routen
1122
- // der App (z.B. /feed.xml) bevor der Static-Fallback nach Disk-
1123
- // Files sucht. Eingehende /api/*-Pfade sind schon vom dispatcher
1124
- // belegt; extraRoutes sollte die nicht überschreiben (kein
1125
- // enforce, das ist Author-Verantwortung).
1126
- if (options.extraRoutes) {
1127
- options.extraRoutes(entrypoint.app, {
1128
- db,
1129
- redis,
1130
- registry,
1131
- dispatchSystemWrite: makeDispatchSystemWrite(entrypoint.dispatcher),
1132
- });
1133
- }
1134
-
1135
- // 11. Build the fetch-handler. Static-fallback for non-/api/ paths
1136
- // wired via a wrapper so Hono owns /api/* + extraRoutes and disk
1137
- // owns the rest. Tests use this directly; listen() wraps it in
1138
- // Bun.serve.
1139
- const fetchHandler = options.staticDir
1140
- ? buildStaticFallback(
1141
- entrypoint.app.fetch.bind(entrypoint.app),
1142
- options.staticDir,
1143
- appSchemaJson,
1144
- options.hostDispatch,
1145
- )
1146
- : entrypoint.app.fetch.bind(entrypoint.app);
1147
-
1148
- // 11. Mark lifecycle ready — health/ready flips to 200 after this.
1149
- entrypoint.lifecycle.markReady();
1150
-
1151
- const handle: ProdAppHandle = {
1152
- entrypoint,
1153
- fetch: fetchHandler,
1154
- listen: async (listenPort = port) => {
1155
- // Bun.serve is the production HTTP. Tests don't call listen()
1156
- // because vitest runs under Node where Bun.serve doesn't exist.
1157
- // Options-Shape (inkl. idleTimeout: 0 für SSE) liegt in der
1158
- // exportierten buildBunServeOptions-Funktion — siehe ihren
1159
- // Header für die Begründung.
1160
- if (typeof (globalThis as { Bun?: unknown }).Bun === "undefined") {
1161
- // Klare Fehlermeldung statt nackter ReferenceError. Trifft wenn
1162
- // jemand listen() unter Node/vitest aufruft ohne autoListen:false
1163
- // — hilft beim Debug, statt sich an "Bun is not defined" abzumühen.
1164
- throw new Error(
1165
- "[runProdApp] listen() requires Bun runtime (Bun.serve). " +
1166
- "Under Node/vitest pass `autoListen: false` and call the returned `fetch()` directly.",
1167
- );
1168
- }
1169
- handle.server = Bun.serve(buildBunServeOptions(listenPort, fetchHandler));
1170
-
1171
- // SIGTERM/SIGINT — graceful shutdown. Only registered when we
1172
- // actually own a Bun-server, otherwise the test process picks up
1173
- // signals it shouldn't respond to.
1174
- let shuttingDown = false;
1175
- const shutdown = async (signal: string) => {
1176
- if (shuttingDown) return;
1177
- shuttingDown = true;
1178
- // biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
1179
- console.log(`[runProdApp] ${signal} received — draining…`);
1180
- try {
1181
- await handle.stop();
1182
- // biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
1183
- console.log("[runProdApp] graceful shutdown complete.");
1184
- } catch (e) {
1185
- // biome-ignore lint/suspicious/noConsole: shutdown-time error, only path is stderr
1186
- console.error("[runProdApp] error during shutdown:", e);
1187
- } finally {
1188
- process.exit(0);
1189
- }
1190
- };
1191
- process.on("SIGTERM", () => void shutdown("SIGTERM"));
1192
- process.on("SIGINT", () => void shutdown("SIGINT"));
1193
-
1194
- // biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
1195
- console.log(`[runProdApp] ready on http://0.0.0.0:${listenPort}`);
1196
- },
1197
- stop: async () => {
1198
- await entrypoint.stop();
1199
- handle.server?.stop();
1200
- await closeDb();
1201
- redis.disconnect();
1202
- },
1203
- };
1204
-
1205
- // 12. Auto-listen unless explicitly suppressed (tests pass autoListen:
1206
- // false because Bun.serve isn't available under vitest/node).
1207
- // Production path: `await runProdApp({...})` and the server is up.
1208
- if (options.autoListen !== false) {
1209
- await handle.listen();
1210
- }
1211
-
1212
- return handle;
1213
- }
1214
-
1215
- // Static-fallback: try the Hono app first, fall back to a file in
1216
- // staticDir if Hono returns 404. Keeps /api/* on the dispatcher and
1217
- // everything else (HTML, JS, CSS, images) on the disk.
1218
- //
1219
- // Cache-Header-Strategie:
1220
- // /assets/* → public, max-age=31536000, immutable
1221
- // (gehashte Filenames vom Build, sicher cachebar)
1222
- // /index.html → no-cache, must-revalidate
1223
- // (HTML-Shell, must reload on deploy)
1224
- // /manifest.json, /sw.js → no-cache
1225
- // (Update-Detection-Mechanismen, müssen frisch sein)
1226
- // alles andere → kein expliziter Header
1227
- // (Browser-Default, public/-Files wie favicon)
1228
- // File-reader für den static-fallback. Nutzt node:fs/promises statt
1229
- // Bun.file damit der Pfad in vitest+node integration-tests laufen kann
1230
- // (Bun.file ist Bun-only). Performance-cost ist marginal: die Disk-
1231
- // Files in einem prod-staticDir sind 1-200 KB, full-buffer-Read ist
1232
- // ein paar Mikrosekunden. Streaming via Bun.file wäre nur relevant ab
1233
- // ~1 MB.
1234
- async function readStaticFile(
1235
- filePath: string,
1236
- ): Promise<
1237
- { readonly bytes: Uint8Array; readonly mime: string; readonly mtimeMs: number } | undefined
1238
- > {
1239
- try {
1240
- const { readFile, stat } = await import("node:fs/promises");
1241
- const [bytes, fileStat] = await Promise.all([readFile(filePath), stat(filePath)]);
1242
- return { bytes, mime: mimeTypeFor(filePath), mtimeMs: fileStat.mtimeMs };
1243
- } catch (err) {
1244
- if ((err as { code?: string }).code === "ENOENT") return undefined;
1245
- throw err;
1246
- }
1247
- }
1248
-
1249
- function serveDiskFile(
1250
- req: Request,
1251
- pathname: string,
1252
- file: {
1253
- readonly bytes: Uint8Array;
1254
- readonly mime: string;
1255
- readonly mtimeMs: number;
1256
- },
1257
- ): Response {
1258
- return cachedResponse(req, {
1259
- // @cast-boundary bun-types — Response BodyInit narrowing
1260
- body: file.bytes as unknown as BodyInit,
1261
- etag: computeWeakEtag(file.mtimeMs, file.bytes.byteLength),
1262
- cache: staticCachePolicy(pathname),
1263
- headers: { "content-type": file.mime },
1264
- lastModified: new Date(file.mtimeMs),
1265
- });
1266
- }
1267
-
1268
- // Minimal-Mime-Map — deckt die Files ab die kumiko-build und typische
1269
- // public/-Inhalte produzieren. Bun.file leitet das aus dem Suffix ab,
1270
- // im node-Pfad müssen wir es selbst tun. Default: octet-stream (Browser
1271
- // fragt bei unbekanntem MIME nach).
1272
- function mimeTypeFor(filePath: string): string {
1273
- const ext = filePath.toLowerCase().split(".").pop() ?? "";
1274
- switch (ext) {
1275
- case "html":
1276
- return "text/html; charset=utf-8";
1277
- case "js":
1278
- case "mjs":
1279
- return "text/javascript; charset=utf-8";
1280
- case "css":
1281
- return "text/css; charset=utf-8";
1282
- case "json":
1283
- return "application/json; charset=utf-8";
1284
- case "svg":
1285
- return "image/svg+xml";
1286
- case "png":
1287
- return "image/png";
1288
- case "jpg":
1289
- case "jpeg":
1290
- return "image/jpeg";
1291
- case "ico":
1292
- return "image/x-icon";
1293
- case "txt":
1294
- return "text/plain; charset=utf-8";
1295
- case "xml":
1296
- return "application/xml; charset=utf-8";
1297
- case "webmanifest":
1298
- return "application/manifest+json";
1299
- default:
1300
- return "application/octet-stream";
1301
- }
1302
- }
1303
-
1304
- function buildStaticFallback(
1305
- apiHandler: (req: Request) => Response | Promise<Response>,
1306
- staticDir: string,
1307
- appSchemaJson: string,
1308
- hostDispatch?: HostDispatchFn,
1309
- ): (req: Request) => Promise<Response> {
1310
- const indexHtml = `${staticDir}/index.html`;
1311
-
1312
- // Helper: liest eine HTML-Datei von der Disk + (optional) injiziert
1313
- // das pre-serialized AppSchema vor dem client.js-Tag. Schema-Injection
1314
- // ist explicit-opt-in damit Public-Domain-Antworten die Admin-UI-
1315
- // Topologie nicht leaken. injectSchema ist idempotent, doppelte Calls
1316
- // produzieren keinen doppelten Tag.
1317
- async function readHtmlFile(
1318
- path: string,
1319
- injectSchemaInto: boolean,
1320
- ): Promise<{ bytes: ArrayBuffer; mime: string; etag: string; mtimeMs: number } | null> {
1321
- const file = await readStaticFile(path);
1322
- if (!file) return null;
1323
- if (!injectSchemaInto) {
1324
- return {
1325
- bytes: file.bytes.buffer.slice(
1326
- file.bytes.byteOffset,
1327
- file.bytes.byteOffset + file.bytes.byteLength,
1328
- ) as ArrayBuffer,
1329
- mime: file.mime,
1330
- etag: computeWeakEtag(file.mtimeMs, file.bytes.byteLength),
1331
- mtimeMs: file.mtimeMs,
1332
- };
1333
- }
1334
- const text = new TextDecoder().decode(file.bytes);
1335
- const injected = injectSchema(text, appSchemaJson);
1336
- const bytes = new TextEncoder().encode(injected).buffer as ArrayBuffer;
1337
- return {
1338
- bytes,
1339
- mime: file.mime,
1340
- etag: computeStrongEtag(new Uint8Array(bytes)),
1341
- mtimeMs: file.mtimeMs,
1342
- };
1343
- }
1344
-
1345
- function serveHtmlFile(
1346
- req: Request,
1347
- pathname: string,
1348
- html: { bytes: ArrayBuffer; mime: string; etag: string; mtimeMs: number },
1349
- extraHeaders?: Record<string, string>,
1350
- ): Response {
1351
- return cachedResponse(req, {
1352
- body: html.bytes,
1353
- etag: html.etag,
1354
- cache: staticCachePolicy(pathname),
1355
- headers: { "content-type": html.mime, ...extraHeaders },
1356
- lastModified: new Date(html.mtimeMs),
1357
- });
1358
- }
1359
-
1360
- // hostDispatch konsultieren wenn gesetzt UND der Request auf den
1361
- // HTML-Fallback fällt (Root oder SPA-Route). Returnt entweder die
1362
- // resolved Response (redirect/404/html) oder null wenn der Default-
1363
- // Pfad weiterlaufen soll.
1364
- async function tryHostDispatch(req: Request): Promise<Response | null> {
1365
- if (!hostDispatch) return null;
1366
- const url = new URL(req.url);
1367
- const host = req.headers.get("host") ?? url.host;
1368
- const result = hostDispatch({ host, path: url.pathname, search: url.search });
1369
- if (result.kind === "not-found") {
1370
- return new Response("Not Found", { status: 404 });
1371
- }
1372
- if (result.kind === "redirect") {
1373
- return new Response(null, {
1374
- status: result.status ?? 302,
1375
- headers: { Location: result.to },
1376
- });
1377
- }
1378
- // result.kind === "html"
1379
- const filePath = `${staticDir}/${result.file}`;
1380
- const html = await readHtmlFile(filePath, result.injectSchema === true);
1381
- if (!html) {
1382
- // Author-Fehler: hostDispatch verweist auf nicht-existente Datei.
1383
- // Liefer 500 statt silent-404 damit der Bug schnell auffällt.
1384
- return new Response(`hostDispatch: file not found: ${result.file}`, { status: 500 });
1385
- }
1386
- // Per-Host-Body (hostDispatch wählt die Datei nach Host) → Vary: Host,
1387
- // sonst darf ein Shared-Cache Tenant-As Schema an Tenant B liefern.
1388
- const extraHeaders: Record<string, string> = { vary: "Host" };
1389
- if (result.csp) extraHeaders["content-security-policy"] = result.csp;
1390
- return serveHtmlFile(req, "/index.html", html, extraHeaders);
1391
- }
1392
-
1393
- return async (req: Request): Promise<Response> => {
1394
- const url = new URL(req.url);
1395
- // /api/* and /health → always Hono (Dispatcher + Health-Probe).
1396
- if (url.pathname.startsWith("/api/") || url.pathname === "/health") {
1397
- return apiHandler(req);
1398
- }
1399
-
1400
- // Hono-First für andere Pfade: extraRoutes (z.B. /feed.xml,
1401
- // /sitemap.xml) UND r.httpRoute-Features (z.B. /legal/*) müssen vor
1402
- // dem Disk-Lookup greifen, sonst schluckt der SPA-Fallback unten
1403
- // unbekannte Pfade als index.html. Shared mit dev-server's
1404
- // createKumikoServer.handleFetch damit beide IDENTISCHE Semantik haben.
1405
- const honoTry = await tryHonoFirst({ fetch: apiHandler }, req);
1406
- if (honoTry.matched) {
1407
- return honoTry.response;
1408
- }
1409
- const honoRes = honoTry.response;
1410
-
1411
- // Disk-/SPA-Fallback ist GET/HEAD-only. Ein non-GET ohne Hono-Match
1412
- // (z.B. POST auf einen falsch konfigurierten Webhook-Pfad) muss den
1413
- // Hono-404 durchreichen — 200 index.html würde dem Provider
1414
- // "delivered" signalisieren und Events gingen still verloren (#259).
1415
- if (req.method !== "GET" && req.method !== "HEAD") {
1416
- return honoRes;
1417
- }
1418
-
1419
- // Disk-Datei (Asset oder konkrete File). Asset-Pfade laufen
1420
- // host-unabhängig — die Bundles in /assets/* werden vom client
1421
- // aktiv geladen, kein Server-side Routing nötig.
1422
- const isIndexRequest = url.pathname === "/" || url.pathname === "/index.html";
1423
- if (!isIndexRequest) {
1424
- const relPath = url.pathname.slice(1);
1425
- const filePath = `${staticDir}/${relPath}`;
1426
- const file = await readStaticFile(filePath);
1427
- if (file) {
1428
- return serveDiskFile(req, url.pathname, file);
1429
- }
1430
- }
1431
-
1432
- // Root oder SPA-Route — hier greift hostDispatch wenn gesetzt.
1433
- // Ohne hostDispatch: alter Single-App-Pfad (index.html mit Schema).
1434
- const dispatched = await tryHostDispatch(req);
1435
- if (dispatched) return dispatched;
1436
-
1437
- // Default Single-App-Pfad: index.html, schema injected.
1438
- const index = await readHtmlFile(indexHtml, true);
1439
- if (index) {
1440
- return serveHtmlFile(req, "/index.html", index);
1441
- }
1442
-
1443
- // Kein Hono-Match, keine Disk-Datei, kein index.html → liefer den
1444
- // ursprünglichen 404 von Hono durch (statt einen neuen Roundtrip).
1445
- return honoRes;
1446
- };
1447
- }
1448
-
1449
- // Map URL-Pfad → Cache-Policy. Hashed-Asset-Pfade (/assets/*) sind
1450
- // unveränderlich, der Rest bleibt revalidate/no-cache damit Updates ohne
1451
- // Hard-Reload greifen. Exported für Unit-Tests; Konsumenten gehen via
1452
- // runProdApp.
1453
- export function staticCachePolicy(pathname: string): CachePolicy {
1454
- if (pathname.startsWith(`/${ASSETS_DIR}/`)) {
1455
- return { kind: "immutable" };
1456
- }
1457
- if (pathname === "/" || pathname === "/index.html") {
1458
- return { kind: "revalidate" };
1459
- }
1460
- if (
1461
- pathname === "/manifest.json" ||
1462
- pathname === "/sw.js" ||
1463
- // ponytail: build-info.json ist statisch — kein /api/version-Endpoint
1464
- // nötig, der Disk-Fallback serviert sie. no-cache, sonst pollt der
1465
- // UpdateChecker eine veraltete id.
1466
- pathname === "/build-info.json"
1467
- ) {
1468
- return { kind: "no-cache" };
1469
- }
1470
- return { kind: "none" };
1471
- }
1472
-
1473
- function buildProdSessionAuth(
1474
- db: import("@cosmicdrift/kumiko-framework/db").DbConnection,
1475
- opts: ProdSessionsConfig,
1476
- ): {
1477
- readonly sessionCreator: ReturnType<typeof createSessionCallbacks>["sessionCreator"];
1478
- readonly sessionRevoker: ReturnType<typeof createSessionCallbacks>["sessionRevoker"];
1479
- readonly sessionChecker: ReturnType<typeof createSessionCallbacks>["sessionChecker"];
1480
- readonly sessionStrictMode: true;
1481
- } {
1482
- const cbs = createSessionCallbacks({
1483
- db,
1484
- ...(opts.expiresInMs !== undefined && { expiresInMs: opts.expiresInMs }),
1485
- });
1486
- return {
1487
- sessionCreator: cbs.sessionCreator,
1488
- sessionRevoker: cbs.sessionRevoker,
1489
- sessionChecker: cbs.sessionChecker,
1490
- sessionStrictMode: true,
1491
- };
1492
- }