@cosmicdrift/kumiko-dev-server 0.1.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 (44) hide show
  1. package/bin/kumiko-build.ts +85 -0
  2. package/bin/kumiko-dev.ts +90 -0
  3. package/package.json +45 -0
  4. package/src/__tests__/build-prod-bundle.integration.ts +265 -0
  5. package/src/__tests__/build-prod-bundle.test.ts +262 -0
  6. package/src/__tests__/cache-headers.test.ts +70 -0
  7. package/src/__tests__/classify-change.test.ts +87 -0
  8. package/src/__tests__/compose-features-wiring.integration.ts +352 -0
  9. package/src/__tests__/compose-features.test.ts +81 -0
  10. package/src/__tests__/crash-tracker.test.ts +89 -0
  11. package/src/__tests__/create-kumiko-server.integration.ts +286 -0
  12. package/src/__tests__/few-shot-corpus.test.ts +311 -0
  13. package/src/__tests__/inject-schema.test.ts +62 -0
  14. package/src/__tests__/resolve-stylesheet.test.ts +90 -0
  15. package/src/__tests__/resolve-tailwind-cli.test.ts +49 -0
  16. package/src/__tests__/run-prod-app-spec.test.ts +57 -0
  17. package/src/__tests__/run-prod-app.integration.ts +535 -0
  18. package/src/__tests__/scaffold-feature.test.ts +143 -0
  19. package/src/__tests__/try-hono-first.test.ts +63 -0
  20. package/src/build-prod-bundle.ts +587 -0
  21. package/src/build-server-bundle.ts +308 -0
  22. package/src/build.ts +28 -0
  23. package/src/codegen/__tests__/run-codegen.test.ts +494 -0
  24. package/src/codegen/__tests__/strict-mode-diagnostics.test.ts +467 -0
  25. package/src/codegen/__tests__/watch.test.ts +186 -0
  26. package/src/codegen/index.ts +17 -0
  27. package/src/codegen/render.ts +225 -0
  28. package/src/codegen/run-codegen.ts +157 -0
  29. package/src/codegen/scan-events.ts +574 -0
  30. package/src/codegen/watch.ts +127 -0
  31. package/src/compose-features.ts +128 -0
  32. package/src/crash-tracker.ts +56 -0
  33. package/src/create-kumiko-server.ts +1010 -0
  34. package/src/drizzle-config.ts +44 -0
  35. package/src/drizzle-tables-auth-mode.ts +32 -0
  36. package/src/drizzle-tables-minimal.ts +22 -0
  37. package/src/few-shot-corpus.ts +369 -0
  38. package/src/index.ts +57 -0
  39. package/src/inject-schema.ts +24 -0
  40. package/src/resolve-tailwind-cli.ts +28 -0
  41. package/src/run-dev-app.ts +290 -0
  42. package/src/run-prod-app.ts +892 -0
  43. package/src/scaffold-feature.ts +226 -0
  44. package/src/try-hono-first.ts +46 -0
@@ -0,0 +1,892 @@
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 drizzle/migrations/_journal vs.
7
+ // __drizzle_migrations + tableExists für jede erwartete Tabelle.
8
+ // KEIN Auto-CREATE TABLE im Boot — Migration ist ein CI-Step
9
+ // (`yarn kumiko migrate apply`), Boot validiert nur. Verhindert
10
+ // Race-Conditions bei Multi-Replica-Deploys + macht Schema-Stand
11
+ // reviewbar in der Pull-Request.
12
+ // - Idempotente Seeds: laufen nur wenn DB leer (über `isDbEmpty`-Probe
13
+ // pro Seed). Re-Boots nach erstem Seed sind no-op.
14
+ // - HTTP-Server via Bun.serve mit graceful SIGTERM/SIGINT → drain().
15
+ // - Auth-Routes + bundled-features auto-mix wenn `auth:` gesetzt
16
+ // (gleiche Logik wie runDevApp).
17
+ //
18
+ // App-Author schreibt:
19
+ // await runProdApp({ features, auth, anonymousAccess, seeds });
20
+ //
21
+ // Container/Coolify setzt:
22
+ // DATABASE_URL=postgresql://...
23
+ // REDIS_URL=redis://...
24
+ // JWT_SECRET=<random-32+>
25
+ // PORT=3000
26
+ // KUMIKO_INSTANCE_ID=<stable per replica>
27
+
28
+ import {
29
+ AuthErrors,
30
+ AuthHandlers,
31
+ type EmailVerificationOptions,
32
+ type InviteOptions,
33
+ type PasswordResetOptions,
34
+ type SignupOptions,
35
+ } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
36
+ import {
37
+ type SeedAdminOptions,
38
+ seedAdmin,
39
+ } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
40
+ import { createConfigResolver } from "@cosmicdrift/kumiko-bundled-features/config";
41
+ import { createSessionCallbacks } from "@cosmicdrift/kumiko-bundled-features/sessions";
42
+ import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
43
+ import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
44
+ import { createSseBroker, type SseBroker } from "@cosmicdrift/kumiko-framework/api";
45
+ import { createDbConnection } from "@cosmicdrift/kumiko-framework/db";
46
+ import {
47
+ buildAppSchema,
48
+ createRegistry,
49
+ type FeatureDefinition,
50
+ validateBoot,
51
+ } from "@cosmicdrift/kumiko-framework/engine";
52
+ import {
53
+ type ApiEntrypoint,
54
+ type ApiEntrypointOptions,
55
+ createApiEntrypoint,
56
+ } from "@cosmicdrift/kumiko-framework/entrypoint";
57
+ import { assertSchemaCurrent, SchemaDriftError } from "@cosmicdrift/kumiko-framework/migrations";
58
+ import {
59
+ createEntityCache,
60
+ createEventDedup,
61
+ createIdempotencyGuard,
62
+ } from "@cosmicdrift/kumiko-framework/pipeline";
63
+ import Redis from "ioredis";
64
+ import { ASSETS_DIR } from "./build-prod-bundle";
65
+ import { buildComposeAuthOptions, composeFeatures } from "./compose-features";
66
+ import { injectSchema } from "./inject-schema";
67
+ import { tryHonoFirst } from "./try-hono-first";
68
+
69
+ /**
70
+ * Bun.serve-Options für Production.
71
+ *
72
+ * Spec: idleTimeout: 0 (= disabled). SSE-Streams werden via Heartbeat
73
+ * lebend gehalten (siehe SSE_HEARTBEAT_INTERVAL_MS in framework/api/
74
+ * sse-route.ts), kein Bun-side Idle-Cleanup nötig. Mit dem Default
75
+ * von 10 s killt Bun nach jedem Heartbeat-Gap die Connection mit
76
+ * halbem HTTP/2-RST_STREAM → Browser ERR_HTTP2_PROTOCOL_ERROR.
77
+ *
78
+ * Spec-Test in __tests__/run-prod-app-spec.test.ts pinst die 0 gegen
79
+ * "looks like a leak"-Reverts.
80
+ */
81
+ export function buildBunServeOptions(
82
+ port: number,
83
+ fetchHandler: (req: Request) => Response | Promise<Response>,
84
+ ): {
85
+ readonly port: number;
86
+ readonly fetch: (req: Request) => Response | Promise<Response>;
87
+ readonly idleTimeout: number;
88
+ } {
89
+ return { port, fetch: fetchHandler, idleTimeout: 0 };
90
+ }
91
+
92
+ // Strict env-var read. Throws with a clear hint when missing — better
93
+ // than discovering a Postgres-connection-refused 30s into the boot.
94
+ function requireEnv(name: string): string {
95
+ const value = process.env[name];
96
+ if (value === undefined || value === "") {
97
+ throw new Error(
98
+ `runProdApp: required env var "${name}" is missing or empty. ` +
99
+ `Set it in your container env / .env.production / Coolify secrets.`,
100
+ );
101
+ }
102
+ return value;
103
+ }
104
+
105
+ // Optional env helper — returns undefined for missing, string for set.
106
+ // Used for KUMIKO_INSTANCE_ID, JWT_ISSUER and other "nice to have" knobs.
107
+ function readEnv(name: string): string | undefined {
108
+ const value = process.env[name];
109
+ return value === undefined || value === "" ? undefined : value;
110
+ }
111
+
112
+ /** Wrapper-API für den Password-Reset-Flow.
113
+ *
114
+ * Setup = Feature-Options (PasswordResetOptions = hmacSecret +
115
+ * tokenTtlMinutes) PLUS die Mail-Side die der Wrapper an die
116
+ * auth-routes-config durchreicht (sendResetEmail-callback +
117
+ * appResetUrl). Apps geben EINEN Block; run{Prod,Dev}App splittet
118
+ * intern auf composeFeatures(authOptions) für die Feature-Options
119
+ * und auth-routes-config für die Mail-Side. extends-Beziehung
120
+ * pinst die Synchronität: jede Feature-Option ist auch Wrapper-Option. */
121
+ export type PasswordResetSetup = PasswordResetOptions & {
122
+ readonly sendResetEmail: (args: {
123
+ email: string;
124
+ resetUrl: string;
125
+ expiresAt: string;
126
+ }) => Promise<void>;
127
+ /** App-URL des ResetPasswordScreen. Framework appended `?token=…`;
128
+ * KEIN trailing `?` oder `#`. Beispiel: "https://admin.example.com/reset-password" */
129
+ readonly appResetUrl: string;
130
+ };
131
+
132
+ /** Wrapper-API für den Email-Verification-Flow. Symmetrisch zu
133
+ * PasswordResetSetup — extends EmailVerificationOptions + Mail-Side. */
134
+ export type EmailVerificationSetup = EmailVerificationOptions & {
135
+ readonly sendVerificationEmail: (args: {
136
+ email: string;
137
+ verificationUrl: string;
138
+ expiresAt: string;
139
+ }) => Promise<void>;
140
+ readonly appVerifyUrl: string;
141
+ };
142
+
143
+ /** Wrapper-API für Magic-Link Self-Signup. Mirror der existing
144
+ * PasswordResetSetup-Struktur — Feature-Options (tokenTtlMinutes,
145
+ * tokenLength) plus die Mail-Side die der Wrapper an die auth-routes-
146
+ * config durchreicht. Anders als reset/verify gibt's KEIN hmacSecret —
147
+ * Signup-Tokens sind opaque random in Redis, nicht HMAC-signed. */
148
+ export type SignupSetup = SignupOptions & {
149
+ readonly sendActivationEmail: (args: {
150
+ email: string;
151
+ activationUrl: string;
152
+ expiresAt: string;
153
+ }) => Promise<void>;
154
+ readonly appActivationUrl: string;
155
+ };
156
+
157
+ /** Wrapper-API für Tenant-Invite Magic-Link. Drei accept-Branches im
158
+ * framework, der Wrapper reicht NUR die Mail-Side + appAcceptUrl
159
+ * durch — handler-names sind hardcoded in run-prod-app aus
160
+ * AuthHandlers (analog signup). */
161
+ export type InviteSetup = InviteOptions & {
162
+ readonly sendInviteEmail: (args: {
163
+ email: string;
164
+ inviteUrl: string;
165
+ expiresAt: string;
166
+ role: string;
167
+ }) => Promise<void>;
168
+ readonly appAcceptUrl: string;
169
+ };
170
+
171
+ export type RunProdAppAuthOptions = {
172
+ /** Initial admin user. Seeded once (idempotent — re-boots check first
173
+ * whether the email is already in the users table). */
174
+ readonly admin: SeedAdminOptions;
175
+ /** Optional override of the login error → HTTP status map. */
176
+ readonly loginErrorStatusMap?: Readonly<Record<string, number>>;
177
+ /** Opt-in: revocable server-side sessions. Caller MUSS
178
+ * `createSessionsFeature()` zu `features` adden — runProdApp wired
179
+ * hier nur die Auth-Callbacks (creator/revoker/checker) gegen die
180
+ * echte db-connection, plus sessionStrictMode=true.
181
+ *
182
+ * Standardverhalten ohne diese Option: stateless JWTs ohne sid
183
+ * (legacy-Verhalten, Karten­haus existing-Apps unangefasst). */
184
+ readonly sessions?: {
185
+ readonly expiresInMs?: number;
186
+ };
187
+ /** Password-reset flow. When set, /api/auth/request-password-reset +
188
+ * /api/auth/reset-password are mounted as public routes UND der
189
+ * request/confirm-Handler im auth-email-password-Feature wird
190
+ * registriert (sonst dispatchen die Routes ins Leere → 500). */
191
+ readonly passwordReset?: PasswordResetSetup;
192
+ /** Email-verification flow. Symmetric to passwordReset. */
193
+ readonly emailVerification?: EmailVerificationSetup;
194
+ /** Self-Signup flow (Magic-Link). When set, /api/auth/signup-request +
195
+ * /api/auth/signup-confirm are mounted; signup-confirm mintet JWT +
196
+ * Cookies wie ein erfolgreicher login (Auto-Login direkt nach
197
+ * Activation). */
198
+ readonly signup?: SignupSetup;
199
+ /** Tenant-Invite flow (Magic-Link). When set, /api/auth/invite-accept,
200
+ * /api/auth/invite-accept-with-login, /api/auth/invite-signup-complete
201
+ * are mounted. */
202
+ readonly invite?: InviteSetup;
203
+ };
204
+
205
+ /** Hook for app-specific seeding — runs after the admin (when auth is
206
+ * active). Each seed is responsible for its own idempotence (seeds are
207
+ * expected to check "is my row already there?" before inserting). */
208
+ export type ProdSeedFn = (deps: {
209
+ db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
210
+ }) => Promise<void>;
211
+
212
+ /** Boot-Time-Deps die `extraContext` + `anonymousAccess` Factories als
213
+ * Argument bekommen. Closure dann in der returned Config (z.B. ein
214
+ * TenantResolver der gegen `db` queriet, oder ein extraContext-Provider
215
+ * der direkt SSE-Events publishen will). Single-source: identisch zu
216
+ * setupTestStack's extraContext-Factory-Shape damit Test/Prod gleich
217
+ * aussehen. */
218
+ export type RunProdAppDeps = {
219
+ readonly db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
220
+ readonly redis: import("ioredis").default;
221
+ readonly registry: import("@cosmicdrift/kumiko-framework/engine").Registry;
222
+ readonly sseBroker: SseBroker;
223
+ };
224
+
225
+ export type AnonymousAccessOption =
226
+ | import("@cosmicdrift/kumiko-framework/api").ServerOptions["anonymousAccess"]
227
+ | ((
228
+ deps: RunProdAppDeps,
229
+ ) => import("@cosmicdrift/kumiko-framework/api").ServerOptions["anonymousAccess"]);
230
+
231
+ export type ExtraContextOption =
232
+ | Record<string, unknown>
233
+ | ((deps: RunProdAppDeps) => Record<string, unknown>);
234
+
235
+ /** Per-Host Routing-Entscheidung für den staticDir-Fallback. Wird aus
236
+ * hostDispatch returned. Drei Modi:
237
+ * - "html": eine bestimmte HTML-Datei (relativ zu staticDir) servieren,
238
+ * mit optionaler Schema-Injection und CSP. Schema-Injection MUSS
239
+ * explizit eingeschaltet werden (default false) — Public-Domain-
240
+ * Antworten leaken sonst die volle Admin-UI-Schema-Topologie.
241
+ * - "redirect": 301/302 an die angegebene Location.
242
+ * - "not-found": klar abweisen (z.B. unbekannte Subdomain).
243
+ *
244
+ * Wird NUR konsultiert wenn der Pfad sonst auf den HTML-Fallback gehen
245
+ * würde — also für "/", "/index.html", oder SPA-Routen die weder Hono
246
+ * matched noch eine konkrete Disk-Datei treffen. Asset-Pfade (/assets/*)
247
+ * und API-Pfade laufen unabhängig vom Host. */
248
+ export type HostDispatchResult =
249
+ | {
250
+ readonly kind: "html";
251
+ readonly file: string;
252
+ readonly injectSchema?: boolean;
253
+ readonly csp?: string;
254
+ }
255
+ | { readonly kind: "redirect"; readonly to: string; readonly status?: 301 | 302 }
256
+ | { readonly kind: "not-found" };
257
+
258
+ export type HostDispatchFn = (req: {
259
+ readonly host: string;
260
+ readonly path: string;
261
+ }) => HostDispatchResult;
262
+
263
+ export type RunProdAppOptions = {
264
+ /** App-specific features. config/user/tenant/auth-email-password are
265
+ * auto-mixed when `auth:` is set — don't add them yourself. */
266
+ readonly features: readonly FeatureDefinition[];
267
+ /** Listen-Port. Default 3000 (or $PORT). */
268
+ readonly port?: number;
269
+ /** Auth-mode: standard features + routes wired, admin seeded. */
270
+ readonly auth?: RunProdAppAuthOptions;
271
+ /** Custom seed functions, run after the admin seed (when auth-mode). */
272
+ readonly seeds?: readonly ProdSeedFn[];
273
+ /** Anonymous-access for public endpoints (same shape as runDevApp).
274
+ * Akzeptiert entweder einen statischen Config-Object ODER eine
275
+ * Factory `({db, redis, registry}) => Config` — die Factory wird
276
+ * einmal zur Boot-Zeit aufgerufen, NACHDEM db/redis/registry konstruiert
277
+ * sind. Der Caller closure'd typischerweise db/redis/registry in den
278
+ * TenantResolver damit z.B. ein Subdomain → Tenant-Lookup gegen die
279
+ * DB möglich ist (siehe samples/showcases/publicstatus für das
280
+ * Multi-Tenant-Pattern). */
281
+ readonly anonymousAccess?: AnonymousAccessOption;
282
+ /** Static-file root for HTML / assets. Served on the catch-all route
283
+ * for any path that doesn't match an /api/ handler. Use this for the
284
+ * public status page HTML, embed widget JS, etc. */
285
+ readonly staticDir?: string;
286
+ /** Host-aware Routing-Hook für Multi-Tenant + Multi-App-Deployments
287
+ * (z.B. publicstatus's `<sub>.publicstatus.eu` (Public-Page) +
288
+ * `admin.publicstatus.eu` (Admin-UI) + `publicstatus.eu` (Apex/
289
+ * Marketing) im SELBEN Container).
290
+ *
291
+ * Wird aufgerufen wenn der staticDir-Fallback einen HTML-Response
292
+ * generieren würde (Root oder SPA-Route). Default-Verhalten ohne
293
+ * hostDispatch: index.html mit Schema-Injection (Single-App).
294
+ *
295
+ * Sicherheitshinweis: Schema-Injection (`__KUMIKO_SCHEMA__`) leakt
296
+ * die Admin-UI-Topologie (alle Screens, Felder, Layouts) ans HTML.
297
+ * Public-Domain-Antworten sollen das NIEMALS — `injectSchema` ist
298
+ * daher default false und MUSS pro Host explizit eingeschaltet
299
+ * werden. CSP-Header pro Host können zusätzlich Asset-Pfade
300
+ * einschränken. */
301
+ readonly hostDispatch?: HostDispatchFn;
302
+ /** Pfad zu drizzle/migrations für den Boot-Gate. Default "./drizzle/
303
+ * migrations" relativ zum process-cwd (wo die App gestartet wird —
304
+ * bei Container-Deploys typischerweise der App-Workspace-Root, weil
305
+ * WORKDIR im Dockerfile dorthin zeigt). Boot wirft SchemaDriftError
306
+ * wenn Migrations pending sind oder erwartete Tabellen fehlen.
307
+ * Setze auf `false` um den Gate komplett zu deaktivieren — nur für
308
+ * Setups die ihren eigenen Schema-Check fahren (z.B. bring-your-own-
309
+ * ORM). Standard-Apps lassen das default. */
310
+ readonly migrations?: { readonly dir: string } | false;
311
+ /** Extra AppContext keys. configResolver is auto-set in auth-mode.
312
+ * Akzeptiert entweder einen statischen Object ODER eine Factory
313
+ * `({db, redis, registry}) => Record<string, unknown>` — gleiches
314
+ * Pattern wie `anonymousAccess`. Im Auth-Mode wird `configResolver`
315
+ * weiterhin automatisch ergänzt; Factory-Result + auto-resolver
316
+ * werden gemerged (Factory-Werte überschreiben). */
317
+ readonly extraContext?: ExtraContextOption;
318
+ /** Job-Block. Wenn das Feature `r.job(...)` registriert, MUSS dieser
319
+ * Block gesetzt sein — sonst wirft createApiEntrypoint mit dem
320
+ * expliziten "registry declares N job(s)..."-Fehler. Default-Pattern
321
+ * für Single-Container-Deployments (publicstatus, kleine SaaS):
322
+ * `{ runLocalJobs: true }` — der API-Process consumiert auch die
323
+ * Worker-Lane, kein separates worker-Image nötig. Für skalierende
324
+ * Setups (mehrere API-Replicas + dezidierte Worker): runLocalJobs
325
+ * weglassen + workers via separatem `runWorkerApp` (kommt Phase 4). */
326
+ readonly jobs?: {
327
+ /** Default true (Single-Container). */
328
+ readonly runLocalJobs?: boolean;
329
+ /** BullMQ-Queue-Prefix (default "kumiko"). */
330
+ readonly queueNamePrefix?: string;
331
+ };
332
+ /** Mount-Point für app-eigene HTTP-Routes außerhalb des Dispatcher-
333
+ * Systems. Aufgerufen NACH /api/* + /health, VOR der static-fallback —
334
+ * perfekt für GET-Endpoints die kein JSON liefern: /feed.xml,
335
+ * /og-image, /sitemap.xml, /robots.txt-mit-Logik. Bekommt das raw
336
+ * Hono-app + die Connection-Deps (db/redis) zum Querying.
337
+ *
338
+ * Naming: `deps` statt `ctx` weil im Framework `ctx` der HandlerContext
339
+ * mit user/tenant/registry ist — hier ist der Scope absichtlich kleiner
340
+ * (Routes laufen außerhalb der Auth/Tenant-Pipeline). */
341
+ readonly extraRoutes?: (
342
+ app: import("hono").Hono,
343
+ deps: {
344
+ db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
345
+ redis: import("ioredis").default;
346
+ },
347
+ ) => void;
348
+ /** When true (default), Bun.serve is started before runProdApp resolves —
349
+ * the common case: `await runProdApp({...})` boots the server and the
350
+ * process stays up listening on PORT. Set to false in tests that drive
351
+ * the fetch-handler directly (Bun.serve isn't available under vitest +
352
+ * node), then call handle.listen() manually if needed. */
353
+ readonly autoListen?: boolean;
354
+ };
355
+
356
+ export type ProdAppHandle = {
357
+ readonly entrypoint: ApiEntrypoint;
358
+ /** The fetch-handler — wired into Bun.serve in production, called
359
+ * directly in tests. Composes Hono + static-fallback. */
360
+ readonly fetch: (req: Request) => Promise<Response> | Response;
361
+ /** Active Bun-server (only set when listen() was called — tests skip
362
+ * listen() because Bun.serve isn't available under vitest/node). */
363
+ server?: ReturnType<typeof Bun.serve>;
364
+ /** Bind to PORT and start serving. Production calls this; tests don't. */
365
+ readonly listen: (port?: number) => Promise<void>;
366
+ readonly stop: () => Promise<void>;
367
+ };
368
+
369
+ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHandle> {
370
+ // 1. Polyfill before anything else — feature code references Temporal.
371
+ const { ensureTemporalPolyfill } = await import("@cosmicdrift/kumiko-framework/time");
372
+ await ensureTemporalPolyfill();
373
+
374
+ // 2. Env-vars: fail-fast. Better a 0s boot crash with a clear error
375
+ // than a 30s timeout chasing a Postgres connection that was never
376
+ // configured.
377
+ const databaseUrl = requireEnv("DATABASE_URL");
378
+ const redisUrl = requireEnv("REDIS_URL");
379
+ const jwtSecret = requireEnv("JWT_SECRET");
380
+ const jwtIssuer = readEnv("JWT_ISSUER");
381
+ const instanceId = readEnv("KUMIKO_INSTANCE_ID");
382
+ const port = options.port ?? Number.parseInt(process.env["PORT"] ?? "3000", 10);
383
+
384
+ // biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
385
+ console.log(`[runProdApp] booting Kumiko stack on port ${port}…`);
386
+
387
+ // 3. Connections — Postgres + Redis. The Redis client is shared by
388
+ // idempotency, event-dedup, entity-cache, rate-limit; failing to
389
+ // construct here surfaces the misconfig immediately.
390
+ const { db, close: closeDb } = createDbConnection(databaseUrl);
391
+ const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
392
+
393
+ // 4. Feature registry. Auth-mode auto-mixes config/user/tenant/auth-email-
394
+ // password via composeFeatures — same source-of-truth as runDevApp
395
+ // AND the per-app drizzle-Schema-Generator, so Migration und Runtime
396
+ // sehen exakt dieselbe Liste.
397
+ const composeAuthOptions = buildComposeAuthOptions(options.auth);
398
+ const features = composeFeatures(options.features, {
399
+ includeBundled: !!options.auth,
400
+ ...(composeAuthOptions && { authOptions: composeAuthOptions }),
401
+ });
402
+
403
+ validateBoot(features);
404
+ const registry = createRegistry(features);
405
+
406
+ // 5. Schema-Drift-Gate. Drizzle-kit migrate (yarn kumiko migrate apply)
407
+ // läuft als CI-Step VOR dem Container-Rollout. Boot prüft hier nur:
408
+ // (a) Alle Migrations aus drizzle/migrations/meta/_journal.json
409
+ // sind in __drizzle_migrations applied
410
+ // (b) Alle erwarteten Tabellen existieren physisch
411
+ // Drift = Boot-Error mit klarer Meldung (kein Auto-Heal — mehrere
412
+ // Container-Replicas würden sonst race-conditionen beim ALTER TABLE
413
+ // fahren). Opt-out via `migrations: false` für custom Schema-Setups.
414
+ if (options.migrations !== false) {
415
+ const migrationsDir = options.migrations?.dir ?? "./drizzle/migrations";
416
+ // biome-ignore lint/suspicious/noConsole: boot-time progress hint
417
+ console.log(`[runProdApp] checking schema drift (${migrationsDir})…`);
418
+ try {
419
+ await assertSchemaCurrent(db, migrationsDir);
420
+ } catch (err) {
421
+ if (err instanceof SchemaDriftError) {
422
+ // biome-ignore lint/suspicious/noConsole: terminal error message
423
+ console.error(`\n[runProdApp] BOOT ABORTED — ${err.message}\n`);
424
+ }
425
+ throw err;
426
+ }
427
+ }
428
+
429
+ // 6. Pipeline pieces — same default config as runDevApp's setupTestStack.
430
+ const idempotency = createIdempotencyGuard(redis, { ttlSeconds: 60 });
431
+ const eventDedup = createEventDedup(redis, { ttlSeconds: 60 });
432
+ const entityCache = createEntityCache(redis, { ttlSeconds: 60 });
433
+
434
+ // 7. Lifecycle is built by createApiEntrypoint when not supplied —
435
+ // we let the entrypoint own it and read it back through the handle
436
+ // for SIGTERM.
437
+ //
438
+ // extraContext + anonymousAccess sind factory-union: entweder direktes
439
+ // Object oder Function die {db, redis, registry} bekommt und das Object
440
+ // returned. Factory-Form gilt als bevorzugt für Cases die zur Boot-Zeit
441
+ // gegen die DB resolven müssen (z.B. Subdomain-Tenant-Lookup im
442
+ // tenantResolver) — die Factory closure'd `db` und der Resolver kann
443
+ // sie zur Request-Zeit aufrufen.
444
+ // sseBroker hier bauen (statt's createApiEntrypoint intern machen zu
445
+ // lassen) damit extraContext-Factories ihn schon zur Boot-Zeit closure'n
446
+ // können — z.B. ein extraContext-Provider der direkt SSE-Events
447
+ // publisht. Wir reichen denselben Broker dann an createApiEntrypoint
448
+ // durch (sseBroker?-option), damit der Server-internal-Broadcast und
449
+ // App-spezifische Publishes über genau einen Broker laufen.
450
+ const sseBroker = createSseBroker();
451
+ const deps: RunProdAppDeps = { db, redis, registry, sseBroker };
452
+ const resolvedExtraContext =
453
+ typeof options.extraContext === "function"
454
+ ? options.extraContext(deps)
455
+ : (options.extraContext ?? {});
456
+ const extraContext = options.auth
457
+ ? { configResolver: createConfigResolver(), ...resolvedExtraContext }
458
+ : resolvedExtraContext;
459
+ const resolvedAnonymousAccess =
460
+ typeof options.anonymousAccess === "function"
461
+ ? options.anonymousAccess(deps)
462
+ : options.anonymousAccess;
463
+
464
+ // Sessions opt-in: db ist hier schon konkret (createDbConnection oben),
465
+ // also direkt verdrahten — kein late-bound nötig wie bei runDevApp.
466
+ // sessionStrictMode=true: Prod-Sessions sollen nicht stillschweigend
467
+ // von einem JWT-ohne-sid umgangen werden können. sessionMassRevoker
468
+ // (4. callback aus createSessionCallbacks) ist nicht Teil der
469
+ // AuthRoutesConfig-Surface — der wird vom sessions-Feature selbst über
470
+ // die `autoRevokeOnPasswordChange`-Option konsumiert, nicht über die
471
+ // auth-routes.
472
+ const sessionAuthFragment = options.auth?.sessions
473
+ ? buildProdSessionAuth(db, options.auth.sessions)
474
+ : undefined;
475
+
476
+ const entrypoint = createApiEntrypoint({
477
+ registry,
478
+ context: {
479
+ db,
480
+ redis,
481
+ entityCache,
482
+ registry,
483
+ ...extraContext,
484
+ },
485
+ sseBroker,
486
+ jwtSecret,
487
+ ...(jwtIssuer && { jwtIssuer }),
488
+ ...(instanceId && { instanceId }),
489
+ dispatcherOptions: { idempotency },
490
+ eventDedup,
491
+ ...(options.auth && {
492
+ auth: {
493
+ membershipQuery: TenantQueries.memberships,
494
+ userQuery: UserQueries.findForAuth,
495
+ loginHandler: AuthHandlers.login,
496
+ loginErrorStatusMap: options.auth.loginErrorStatusMap ?? {
497
+ [AuthErrors.invalidCredentials]: 401,
498
+ [AuthErrors.noMembership]: 403,
499
+ },
500
+ ...sessionAuthFragment,
501
+ ...(options.auth.passwordReset && {
502
+ passwordReset: {
503
+ requestHandler: AuthHandlers.requestPasswordReset,
504
+ confirmHandler: AuthHandlers.resetPassword,
505
+ sendResetEmail: options.auth.passwordReset.sendResetEmail,
506
+ appResetUrl: options.auth.passwordReset.appResetUrl,
507
+ },
508
+ }),
509
+ ...(options.auth.emailVerification && {
510
+ emailVerification: {
511
+ requestHandler: AuthHandlers.requestEmailVerification,
512
+ confirmHandler: AuthHandlers.verifyEmail,
513
+ sendVerificationEmail: options.auth.emailVerification.sendVerificationEmail,
514
+ appVerifyUrl: options.auth.emailVerification.appVerifyUrl,
515
+ },
516
+ }),
517
+ ...(options.auth.signup && {
518
+ signup: {
519
+ requestHandler: AuthHandlers.signupRequest,
520
+ confirmHandler: AuthHandlers.signupConfirm,
521
+ sendActivationEmail: options.auth.signup.sendActivationEmail,
522
+ appActivationUrl: options.auth.signup.appActivationUrl,
523
+ },
524
+ }),
525
+ ...(options.auth.invite && {
526
+ invite: {
527
+ acceptHandler: AuthHandlers.inviteAccept,
528
+ acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
529
+ signupCompleteHandler: AuthHandlers.inviteSignupComplete,
530
+ sendInviteEmail: options.auth.invite.sendInviteEmail,
531
+ appAcceptUrl: options.auth.invite.appAcceptUrl,
532
+ },
533
+ }),
534
+ },
535
+ }),
536
+ ...(resolvedAnonymousAccess && { anonymousAccess: resolvedAnonymousAccess }),
537
+ // Auto-Pass-Through für r.job-Wiring: wenn das Registry Jobs
538
+ // deklariert, MUSS der jobs-Block gesetzt sein — sonst stoppt
539
+ // createApiEntrypoint mit explizitem Fehler. Default für Single-
540
+ // Container-Deployments: runLocalJobs=true (API-Process consumiert
541
+ // auch worker-Lane). Caller kann override'n via options.jobs.
542
+ ...(registry.getAllJobs().size > 0 && {
543
+ jobs: {
544
+ redisUrl,
545
+ runLocalJobs: options.jobs?.runLocalJobs ?? true,
546
+ ...(options.jobs?.queueNamePrefix !== undefined && {
547
+ queueNamePrefix: options.jobs.queueNamePrefix,
548
+ }),
549
+ },
550
+ }),
551
+ } satisfies ApiEntrypointOptions);
552
+
553
+ // 8. Build the AppSchema once + serialize. Wird beim Static-Fallback
554
+ // in die index.html injiziert damit createKumikoApp() im Browser
555
+ // `window.__KUMIKO_SCHEMA__` synchron lesen kann — gleicher Pfad
556
+ // wie im dev-server, damit der Client-Code keine Sonderfall-
557
+ // Branch zwischen dev/prod braucht. Boot-once weil Features
558
+ // nach dem Start nicht mehr ändern.
559
+ // TODO: Sobald per-Tenant- oder per-User-Schema kommt (Feature-Toggles
560
+ // pro Tenant, Auth-Rolle gated Screens), muss die Injection pro
561
+ // Request rendern — staticDir-Fallback einen render(req)-Hook bekommen
562
+ // statt eines fixed JSON-Strings. Heute: registry-static, also OK.
563
+ const appSchemaJson = JSON.stringify(buildAppSchema(registry));
564
+
565
+ // 9. Seeds: admin first, then app-specific. Both expected to be
566
+ // idempotent — runProdApp doesn't gate "first boot" via flag,
567
+ // seeds check their own preconditions. seedAdmin checks email,
568
+ // app seeds typically check "is my fixture row there?".
569
+ if (options.auth) {
570
+ await seedAdmin(db, options.auth.admin);
571
+ }
572
+ for (const seed of options.seeds ?? []) {
573
+ await seed({ db });
574
+ }
575
+
576
+ await entrypoint.start();
577
+
578
+ // 10. App-eigene HTTP-Routes mounten — vor dem static-fallback. Hono
579
+ // matcht in Eintrags-Reihenfolge, also greifen explizite Routen
580
+ // der App (z.B. /feed.xml) bevor der Static-Fallback nach Disk-
581
+ // Files sucht. Eingehende /api/*-Pfade sind schon vom dispatcher
582
+ // belegt; extraRoutes sollte die nicht überschreiben (kein
583
+ // enforce, das ist Author-Verantwortung).
584
+ if (options.extraRoutes) {
585
+ options.extraRoutes(entrypoint.app, { db, redis });
586
+ }
587
+
588
+ // 11. Build the fetch-handler. Static-fallback for non-/api/ paths
589
+ // wired via a wrapper so Hono owns /api/* + extraRoutes and disk
590
+ // owns the rest. Tests use this directly; listen() wraps it in
591
+ // Bun.serve.
592
+ const fetchHandler = options.staticDir
593
+ ? buildStaticFallback(
594
+ entrypoint.app.fetch.bind(entrypoint.app),
595
+ options.staticDir,
596
+ appSchemaJson,
597
+ options.hostDispatch,
598
+ )
599
+ : entrypoint.app.fetch.bind(entrypoint.app);
600
+
601
+ // 11. Mark lifecycle ready — health/ready flips to 200 after this.
602
+ entrypoint.lifecycle.markReady();
603
+
604
+ const handle: ProdAppHandle = {
605
+ entrypoint,
606
+ fetch: fetchHandler,
607
+ listen: async (listenPort = port) => {
608
+ // Bun.serve is the production HTTP. Tests don't call listen()
609
+ // because vitest runs under Node where Bun.serve doesn't exist.
610
+ // Options-Shape (inkl. idleTimeout: 0 für SSE) liegt in der
611
+ // exportierten buildBunServeOptions-Funktion — siehe ihren
612
+ // Header für die Begründung.
613
+ if (typeof (globalThis as { Bun?: unknown }).Bun === "undefined") {
614
+ // Klare Fehlermeldung statt nackter ReferenceError. Trifft wenn
615
+ // jemand listen() unter Node/vitest aufruft ohne autoListen:false
616
+ // — hilft beim Debug, statt sich an "Bun is not defined" abzumühen.
617
+ throw new Error(
618
+ "[runProdApp] listen() requires Bun runtime (Bun.serve). " +
619
+ "Under Node/vitest pass `autoListen: false` and call the returned `fetch()` directly.",
620
+ );
621
+ }
622
+ handle.server = Bun.serve(buildBunServeOptions(listenPort, fetchHandler));
623
+
624
+ // SIGTERM/SIGINT — graceful shutdown. Only registered when we
625
+ // actually own a Bun-server, otherwise the test process picks up
626
+ // signals it shouldn't respond to.
627
+ let shuttingDown = false;
628
+ const shutdown = async (signal: string) => {
629
+ if (shuttingDown) return;
630
+ shuttingDown = true;
631
+ // biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
632
+ console.log(`[runProdApp] ${signal} received — draining…`);
633
+ try {
634
+ await handle.stop();
635
+ // biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
636
+ console.log("[runProdApp] graceful shutdown complete.");
637
+ } catch (e) {
638
+ // biome-ignore lint/suspicious/noConsole: shutdown-time error, only path is stderr
639
+ console.error("[runProdApp] error during shutdown:", e);
640
+ } finally {
641
+ process.exit(0);
642
+ }
643
+ };
644
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
645
+ process.on("SIGINT", () => void shutdown("SIGINT"));
646
+
647
+ // biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
648
+ console.log(`[runProdApp] ready on http://0.0.0.0:${listenPort}`);
649
+ },
650
+ stop: async () => {
651
+ await entrypoint.stop();
652
+ handle.server?.stop();
653
+ await closeDb();
654
+ redis.disconnect();
655
+ },
656
+ };
657
+
658
+ // 12. Auto-listen unless explicitly suppressed (tests pass autoListen:
659
+ // false because Bun.serve isn't available under vitest/node).
660
+ // Production path: `await runProdApp({...})` and the server is up.
661
+ if (options.autoListen !== false) {
662
+ await handle.listen();
663
+ }
664
+
665
+ return handle;
666
+ }
667
+
668
+ // Static-fallback: try the Hono app first, fall back to a file in
669
+ // staticDir if Hono returns 404. Keeps /api/* on the dispatcher and
670
+ // everything else (HTML, JS, CSS, images) on the disk.
671
+ //
672
+ // Cache-Header-Strategie:
673
+ // /assets/* → public, max-age=31536000, immutable
674
+ // (gehashte Filenames vom Build, sicher cachebar)
675
+ // /index.html → no-cache, must-revalidate
676
+ // (HTML-Shell, must reload on deploy)
677
+ // /manifest.json, /sw.js → no-cache
678
+ // (Update-Detection-Mechanismen, müssen frisch sein)
679
+ // alles andere → kein expliziter Header
680
+ // (Browser-Default, public/-Files wie favicon)
681
+ // File-reader für den static-fallback. Nutzt node:fs/promises statt
682
+ // Bun.file damit der Pfad in vitest+node integration-tests laufen kann
683
+ // (Bun.file ist Bun-only). Performance-cost ist marginal: die Disk-
684
+ // Files in einem prod-staticDir sind 1-200 KB, full-buffer-Read ist
685
+ // ein paar Mikrosekunden. Streaming via Bun.file wäre nur relevant ab
686
+ // ~1 MB.
687
+ async function readStaticFile(
688
+ filePath: string,
689
+ ): Promise<{ readonly bytes: Uint8Array; readonly mime: string } | undefined> {
690
+ try {
691
+ const { readFile } = await import("node:fs/promises");
692
+ const bytes = await readFile(filePath);
693
+ return { bytes, mime: mimeTypeFor(filePath) };
694
+ } catch (err) {
695
+ if ((err as { code?: string }).code === "ENOENT") return undefined;
696
+ throw err;
697
+ }
698
+ }
699
+
700
+ // Minimal-Mime-Map — deckt die Files ab die kumiko-build und typische
701
+ // public/-Inhalte produzieren. Bun.file leitet das aus dem Suffix ab,
702
+ // im node-Pfad müssen wir es selbst tun. Default: octet-stream (Browser
703
+ // fragt bei unbekanntem MIME nach).
704
+ function mimeTypeFor(filePath: string): string {
705
+ const ext = filePath.toLowerCase().split(".").pop() ?? "";
706
+ switch (ext) {
707
+ case "html":
708
+ return "text/html; charset=utf-8";
709
+ case "js":
710
+ case "mjs":
711
+ return "text/javascript; charset=utf-8";
712
+ case "css":
713
+ return "text/css; charset=utf-8";
714
+ case "json":
715
+ return "application/json; charset=utf-8";
716
+ case "svg":
717
+ return "image/svg+xml";
718
+ case "png":
719
+ return "image/png";
720
+ case "jpg":
721
+ case "jpeg":
722
+ return "image/jpeg";
723
+ case "ico":
724
+ return "image/x-icon";
725
+ case "txt":
726
+ return "text/plain; charset=utf-8";
727
+ case "xml":
728
+ return "application/xml; charset=utf-8";
729
+ case "webmanifest":
730
+ return "application/manifest+json";
731
+ default:
732
+ return "application/octet-stream";
733
+ }
734
+ }
735
+
736
+ function buildStaticFallback(
737
+ apiHandler: (req: Request) => Response | Promise<Response>,
738
+ staticDir: string,
739
+ appSchemaJson: string,
740
+ hostDispatch?: HostDispatchFn,
741
+ ): (req: Request) => Promise<Response> {
742
+ const indexHtml = `${staticDir}/index.html`;
743
+
744
+ // Helper: liest eine HTML-Datei von der Disk + (optional) injiziert
745
+ // das pre-serialized AppSchema vor dem client.js-Tag. Schema-Injection
746
+ // ist explicit-opt-in damit Public-Domain-Antworten die Admin-UI-
747
+ // Topologie nicht leaken. injectSchema ist idempotent, doppelte Calls
748
+ // produzieren keinen doppelten Tag.
749
+ async function readHtmlFile(
750
+ path: string,
751
+ injectSchemaInto: boolean,
752
+ ): Promise<{ bytes: ArrayBuffer; mime: string } | null> {
753
+ const file = await readStaticFile(path);
754
+ if (!file) return null;
755
+ if (!injectSchemaInto) {
756
+ return {
757
+ bytes: file.bytes.buffer.slice(
758
+ file.bytes.byteOffset,
759
+ file.bytes.byteOffset + file.bytes.byteLength,
760
+ ) as ArrayBuffer,
761
+ mime: file.mime,
762
+ };
763
+ }
764
+ const text = new TextDecoder().decode(file.bytes);
765
+ const injected = injectSchema(text, appSchemaJson);
766
+ return { bytes: new TextEncoder().encode(injected).buffer as ArrayBuffer, mime: file.mime };
767
+ }
768
+
769
+ // hostDispatch konsultieren wenn gesetzt UND der Request auf den
770
+ // HTML-Fallback fällt (Root oder SPA-Route). Returnt entweder die
771
+ // resolved Response (redirect/404/html) oder null wenn der Default-
772
+ // Pfad weiterlaufen soll.
773
+ async function tryHostDispatch(req: Request): Promise<Response | null> {
774
+ if (!hostDispatch) return null;
775
+ const url = new URL(req.url);
776
+ const host = req.headers.get("host") ?? url.host;
777
+ const result = hostDispatch({ host, path: url.pathname });
778
+ if (result.kind === "not-found") {
779
+ return new Response("Not Found", { status: 404 });
780
+ }
781
+ if (result.kind === "redirect") {
782
+ return new Response(null, {
783
+ status: result.status ?? 302,
784
+ headers: { Location: result.to },
785
+ });
786
+ }
787
+ // result.kind === "html"
788
+ const filePath = `${staticDir}/${result.file}`;
789
+ const html = await readHtmlFile(filePath, result.injectSchema === true);
790
+ if (!html) {
791
+ // Author-Fehler: hostDispatch verweist auf nicht-existente Datei.
792
+ // Liefer 500 statt silent-404 damit der Bug schnell auffällt.
793
+ return new Response(`hostDispatch: file not found: ${result.file}`, { status: 500 });
794
+ }
795
+ const headers: Record<string, string> = {
796
+ ...cacheHeadersFor("/index.html"),
797
+ "content-type": html.mime,
798
+ };
799
+ if (result.csp) headers["content-security-policy"] = result.csp;
800
+ return new Response(html.bytes, { headers });
801
+ }
802
+
803
+ return async (req: Request): Promise<Response> => {
804
+ const url = new URL(req.url);
805
+ // /api/* and /health → always Hono (Dispatcher + Health-Probe).
806
+ if (url.pathname.startsWith("/api/") || url.pathname === "/health") {
807
+ return apiHandler(req);
808
+ }
809
+
810
+ // Hono-First für andere Pfade: extraRoutes (z.B. /feed.xml,
811
+ // /sitemap.xml) UND r.httpRoute-Features (z.B. /legal/*) müssen vor
812
+ // dem Disk-Lookup greifen, sonst schluckt der SPA-Fallback unten
813
+ // unbekannte Pfade als index.html. Shared mit dev-server's
814
+ // createKumikoServer.handleFetch damit beide IDENTISCHE Semantik haben.
815
+ const honoTry = await tryHonoFirst({ fetch: apiHandler }, req);
816
+ if (honoTry.matched) {
817
+ return honoTry.response;
818
+ }
819
+ const honoRes = honoTry.response;
820
+
821
+ // Disk-Datei (Asset oder konkrete File). Asset-Pfade laufen
822
+ // host-unabhängig — die Bundles in /assets/* werden vom client
823
+ // aktiv geladen, kein Server-side Routing nötig.
824
+ const isIndexRequest = url.pathname === "/" || url.pathname === "/index.html";
825
+ if (!isIndexRequest) {
826
+ const relPath = url.pathname.slice(1);
827
+ const filePath = `${staticDir}/${relPath}`;
828
+ const file = await readStaticFile(filePath);
829
+ if (file) {
830
+ // @cast-boundary bun-types — Response BodyInit narrowing
831
+ return new Response(file.bytes as unknown as BodyInit, {
832
+ headers: { ...cacheHeadersFor(url.pathname), "content-type": file.mime },
833
+ });
834
+ }
835
+ }
836
+
837
+ // Root oder SPA-Route — hier greift hostDispatch wenn gesetzt.
838
+ // Ohne hostDispatch: alter Single-App-Pfad (index.html mit Schema).
839
+ const dispatched = await tryHostDispatch(req);
840
+ if (dispatched) return dispatched;
841
+
842
+ // Default Single-App-Pfad: index.html, schema injected.
843
+ const index = await readHtmlFile(indexHtml, true);
844
+ if (index) {
845
+ return new Response(index.bytes, {
846
+ headers: { ...cacheHeadersFor("/index.html"), "content-type": index.mime },
847
+ });
848
+ }
849
+
850
+ // Kein Hono-Match, keine Disk-Datei, kein index.html → liefer den
851
+ // ursprünglichen 404 von Hono durch (statt einen neuen Roundtrip).
852
+ return honoRes;
853
+ };
854
+ }
855
+
856
+ // Map URL-Pfad → Cache-Control. Hashed-Asset-Pfade (/assets/*) sind
857
+ // unveränderlich, der Rest bleibt no-cache damit Updates ohne Hard-
858
+ // Reload greifen. Exported für Unit-Tests; Konsumenten gehen via
859
+ // runProdApp.
860
+ export function cacheHeadersFor(pathname: string): Record<string, string> {
861
+ if (pathname.startsWith(`/${ASSETS_DIR}/`)) {
862
+ return { "cache-control": "public, max-age=31536000, immutable" };
863
+ }
864
+ if (pathname === "/" || pathname === "/index.html") {
865
+ return { "cache-control": "no-cache, must-revalidate" };
866
+ }
867
+ if (pathname === "/manifest.json" || pathname === "/sw.js") {
868
+ return { "cache-control": "no-cache" };
869
+ }
870
+ return {};
871
+ }
872
+
873
+ function buildProdSessionAuth(
874
+ db: import("@cosmicdrift/kumiko-framework/db").DbConnection,
875
+ opts: NonNullable<RunProdAppAuthOptions["sessions"]>,
876
+ ): {
877
+ readonly sessionCreator: ReturnType<typeof createSessionCallbacks>["sessionCreator"];
878
+ readonly sessionRevoker: ReturnType<typeof createSessionCallbacks>["sessionRevoker"];
879
+ readonly sessionChecker: ReturnType<typeof createSessionCallbacks>["sessionChecker"];
880
+ readonly sessionStrictMode: true;
881
+ } {
882
+ const cbs = createSessionCallbacks({
883
+ db,
884
+ ...(opts.expiresInMs !== undefined && { expiresInMs: opts.expiresInMs }),
885
+ });
886
+ return {
887
+ sessionCreator: cbs.sessionCreator,
888
+ sessionRevoker: cbs.sessionRevoker,
889
+ sessionChecker: cbs.sessionChecker,
890
+ sessionStrictMode: true,
891
+ };
892
+ }