@cosmicdrift/kumiko-dev-server 0.146.3 → 0.147.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.
@@ -31,7 +31,6 @@ import {
31
31
  type AuthPaths,
32
32
  type EmailVerificationOptions,
33
33
  type InviteOptions,
34
- makeAuthPaths,
35
34
  type PasswordResetOptions,
36
35
  type SignupOptions,
37
36
  } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
@@ -39,44 +38,20 @@ import {
39
38
  type SeedAdminOptions,
40
39
  seedAdmin,
41
40
  } 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
41
  import {
54
42
  createPatResolver,
55
43
  PAT_FEATURE,
56
44
  patRateLimitFromFeature,
57
45
  patScopesFromFeature,
58
46
  } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
59
- import {
60
- createSecretsContext,
61
- SECRETS_FEATURE_NAME,
62
- } from "@cosmicdrift/kumiko-bundled-features/secrets";
63
- import {
64
- bindAutoRevokeFromFeature,
65
- createSessionCallbacks,
66
- SESSIONS_FEATURE,
67
- } from "@cosmicdrift/kumiko-bundled-features/sessions";
47
+ import { SESSIONS_FEATURE } from "@cosmicdrift/kumiko-bundled-features/sessions";
68
48
  import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
69
49
  import {
70
50
  resolveTenantLifecycleGate,
71
51
  TENANT_LIFECYCLE_FEATURE,
72
52
  } from "@cosmicdrift/kumiko-bundled-features/tenant-lifecycle";
73
- import { createTextContentApi } from "@cosmicdrift/kumiko-bundled-features/text-content";
74
53
  import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
75
54
  import {
76
- type CachePolicy,
77
- cachedResponse,
78
- computeStrongEtag,
79
- computeWeakEtag,
80
55
  createInMemoryLoginRateLimiter,
81
56
  createSseBroker,
82
57
  type SseBroker,
@@ -89,19 +64,15 @@ import {
89
64
  import {
90
65
  configureEntityFieldEncryption,
91
66
  createDbConnection,
92
- type DbConnection,
93
67
  type DbRunner,
94
68
  } from "@cosmicdrift/kumiko-framework/db";
95
69
  import {
96
70
  buildAppSchema,
97
- type ConfigResolver,
98
71
  collectWriteHandlerQns,
99
72
  createRegistry,
100
73
  type EffectiveFeaturesResolver,
101
74
  type FeatureDefinition,
102
75
  findTierResolverUsage,
103
- type NotifyFactory,
104
- type Registry,
105
76
  type TierResolverPlugin,
106
77
  validateAppCustomScreenWriteQns,
107
78
  validateBoot,
@@ -127,6 +98,7 @@ import {
127
98
  assertKumikoSchemaCurrent,
128
99
  SchemaDriftError,
129
100
  } from "@cosmicdrift/kumiko-framework/migrations";
101
+ import type { ObservabilityProvider } from "@cosmicdrift/kumiko-framework/observability";
130
102
  import {
131
103
  createDispatcher,
132
104
  createEntityCache,
@@ -137,20 +109,23 @@ import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
137
109
  import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
138
110
  import Redis from "ioredis";
139
111
  import { applyBootSeeds } from "./boot/apply-boot-seeds";
140
- import { type BootCrypto, resolveBootCrypto } from "./boot/boot-crypto";
112
+ import { resolveBootCrypto } from "./boot/boot-crypto";
141
113
  import { jobRunLoggerCallbacks } from "./boot/job-run-logger";
142
- import { ASSETS_DIR } from "./build-prod-bundle";
143
114
  import { buildComposeAuthOptions, composeFeatures } from "./compose-features";
144
115
  import { type ExtraRoutesSystemDeps, makeDispatchSystemWrite } from "./extra-routes-deps";
145
- import { injectSchema } from "./inject-schema";
146
116
  import { assertPiiBootInvariants } from "./pii-boot-gate";
147
117
  import {
148
- type ProdSessionsConfig,
118
+ addConfigAccessorFactory,
119
+ buildBootExtraContext,
120
+ buildProdSessionAuth,
121
+ resolveAuthMail,
122
+ } from "./run-prod-app-boot-context";
123
+ import { buildStaticFallback } from "./run-prod-app-static-files";
124
+ import {
149
125
  type ProdSessionsOption,
150
126
  resolveProdSessionsConfig,
151
127
  shouldWireProdSessions,
152
128
  } from "./session-wiring";
153
- import { tryHonoFirst } from "./try-hono-first";
154
129
 
155
130
  /**
156
131
  * Bun.serve-Options für Production.
@@ -164,6 +139,13 @@ import { tryHonoFirst } from "./try-hono-first";
164
139
  * Spec-Test in __tests__/run-prod-app-spec.test.ts pinst die 0 gegen
165
140
  * "looks like a leak"-Reverts.
166
141
  */
142
+ export {
143
+ addConfigAccessorFactory,
144
+ buildBootExtraContext,
145
+ resolveAuthMail,
146
+ } from "./run-prod-app-boot-context";
147
+ export { staticCachePolicy } from "./run-prod-app-static-files";
148
+
167
149
  export function buildBunServeOptions(
168
150
  port: number,
169
151
  fetchHandler: (req: Request) => Response | Promise<Response>,
@@ -588,6 +570,14 @@ export type RunProdAppOptions = {
588
570
  * Tests use this to feed crafted env-maps without polluting the
589
571
  * global. */
590
572
  readonly envSource?: Record<string, string | undefined>;
573
+ /** Observability-Provider (Tracer + Meter). Default: Noop (kein Overhead,
574
+ * kein `/metrics`-Endpoint). Setze `createPrometheusMeter()`-basierten
575
+ * Provider + `metrics` um `/metrics` real zu exposen (publicstatus#91). */
576
+ readonly observability?: ObservabilityProvider;
577
+ /** Aktiviert den `/metrics`-Endpoint (Open-Metrics-Format). Ohne
578
+ * `observability` bleibt der Meter ein Noop und die Route liefert leere
579
+ * Ausgabe — beides zusammen setzen. */
580
+ readonly metrics?: import("@cosmicdrift/kumiko-framework/api").ServerOptions["metrics"];
591
581
  };
592
582
 
593
583
  export type ProdAppHandle = {
@@ -608,167 +598,6 @@ export type ProdAppHandle = {
608
598
  readonly stop: () => Promise<void>;
609
599
  };
610
600
 
611
- // Shared with runDevApp (mergeConfigResolverDefault) for dev/prod parity.
612
- export function addConfigAccessorFactory<T extends { readonly configResolver?: ConfigResolver }>(
613
- resolved: T,
614
- registry: Registry,
615
- ): T {
616
- if (!resolved.configResolver) return resolved;
617
- return {
618
- ...resolved,
619
- _configAccessorFactory: createConfigAccessorFactory(registry, resolved.configResolver),
620
- };
621
- }
622
-
623
- // Framework-Default-Provider für den AppContext — gleicher Mechanismus wie
624
- // der tenantTierResolver-Autowire (findTierResolverUsage): deklarierter
625
- // Bedarf (Feature gemountet) → Default aus db/env, App überschreibt nur die
626
- // Ausnahme. textContent ist unbedingt (createTextContentApi wirft nie, baut
627
- // nur einen db-gebundenen Accessor). secrets wird nur auto-verdrahtet wenn
628
- // das secrets-Feature gemountet ist UND ein KEK tatsächlich verfügbar ist
629
- // (masterKey-Override ODER env-KEK present) — sonst skip, damit der eager
630
- // KEK-Detection + Provider/Cipher-Aufbau leben in boot/boot-crypto.ts
631
- // (envHasMasterKek, resolveBootCrypto) — gemeinsam mit runDevApp.
632
- // Prod-Misconfig (secrets gemountet, kein KEK) fängt schon secretsEnvSchema
633
- // beim Boot; fehlt der env-Schema-Pfad, wirft requireSecretsContext beim
634
- // ersten ctx.secrets-Zugriff mit Wiring-Hinweis. configResolver nur im
635
- // Auth-Mode. Exportiert + pure für Unit-Tests; der merge mit App-Werten
636
- // passiert beim Caller (App gewinnt).
637
-
638
- // Prod/dev parity for ctx.notify: without this `_notifyFactory` is only wired
639
- // in tests (createDeliveryTestContext), so ctx.notify is undefined at runtime
640
- // and every notification silently skips. sseBroker optional (email/push don't
641
- // need it, in-app SSE does); no jobRunner → queued channels send inline.
642
- function buildDeliveryNotifyFactory(opts: {
643
- readonly db: DbConnection;
644
- readonly registry: Registry;
645
- readonly sseBroker?: SseBroker;
646
- }): NotifyFactory {
647
- const deliveryService = createDeliveryService({
648
- db: opts.db,
649
- registry: opts.registry,
650
- channels: collectChannels(opts.registry),
651
- ...(opts.sseBroker && { sseBroker: opts.sseBroker }),
652
- });
653
- return (user, tenantId) => (notificationType, options) =>
654
- deliveryService.notify(notificationType, options, user, tenantId);
655
- }
656
-
657
- export function buildBootExtraContext(opts: {
658
- readonly db: DbConnection;
659
- readonly features: readonly FeatureDefinition[];
660
- readonly envSource: Record<string, string | undefined>;
661
- readonly registry: Registry;
662
- readonly hasAuth: boolean;
663
- // Resolved once per boot via resolveBootCrypto — shared by secrets,
664
- // config-resolver, config-set-handler and boot-seeds. Absent (tests
665
- // that don't care about encryption) ⇒ resolved from envSource here.
666
- readonly crypto?: BootCrypto;
667
- readonly masterKey?: MasterKeyProvider;
668
- readonly sseBroker?: SseBroker;
669
- readonly kms?: KmsAdapter;
670
- }): Record<string, unknown> {
671
- const crypto = opts.crypto ?? resolveBootCrypto(opts.envSource, opts.masterKey);
672
- const hasSecretsFeature = opts.features.some((f) => f.name === SECRETS_FEATURE_NAME);
673
- const wireSecrets = hasSecretsFeature && crypto.masterKeyProvider !== undefined;
674
- const hasDeliveryFeature = opts.features.some((f) => f.name === DELIVERY_FEATURE);
675
- return {
676
- textContent: createTextContentApi(opts.db),
677
- ...(opts.kms && { kms: opts.kms }),
678
- ...(hasDeliveryFeature && {
679
- _notifyFactory: buildDeliveryNotifyFactory({
680
- db: opts.db,
681
- registry: opts.registry,
682
- ...(opts.sseBroker && { sseBroker: opts.sseBroker }),
683
- }),
684
- }),
685
- // Top-level provider so feature jobs (secrets rotate, config reencrypt)
686
- // reach it via ctx — previously only test-stack wired it.
687
- ...(crypto.masterKeyProvider && { masterKeyProvider: crypto.masterKeyProvider }),
688
- // Encrypt/decrypt partner for `encrypted: true` config keys. Wired
689
- // whenever a master key exists — NOT gated on the secrets feature,
690
- // config encryption must work without mounting ctx.secrets.
691
- ...(crypto.configCipher && { configEncryption: crypto.configCipher }),
692
- ...(wireSecrets &&
693
- crypto.masterKeyProvider && {
694
- secrets: createSecretsContext({
695
- db: opts.db,
696
- masterKeyProvider: crypto.masterKeyProvider,
697
- dekCache: crypto.dekCache,
698
- }),
699
- }),
700
- ...(opts.hasAuth && {
701
- configResolver: createConfigResolver({
702
- appOverrides: buildEnvConfigOverrides(opts.registry, opts.envSource),
703
- ...(crypto.configCipher && { cipher: crypto.configCipher }),
704
- }),
705
- }),
706
- };
707
- }
708
-
709
- // auth.mail-Convenience → normalisiert in die expliziten passwordReset/
710
- // emailVerification/signup/invite-Felder, BEVOR buildComposeAuthOptions
711
- // (Feature-Side: hmacSecret/mode) und das auth-routes-Fragment sie lesen —
712
- // so speist EIN mail-Block beide Pfade. App-explizite Flows gewinnen über
713
- // den Default. Null-Transport-Guard: ohne SMTP_HOST-env bleibt alles
714
- // unverdrahtet (sonst lieferten die reset/verify-Routes 500).
715
- /** Die Auth-Felder die resolveAuthMail liest/normalisiert — beide
716
- * App-Auth-Typen (prod + dev) erfüllen das strukturell. */
717
- type AuthMailNormalizable = {
718
- readonly mail?: AuthMailOptions;
719
- readonly passwordReset?: PasswordResetSetup;
720
- readonly emailVerification?: EmailVerificationSetup;
721
- readonly signup?: SignupSetup;
722
- readonly invite?: InviteSetup;
723
- };
724
-
725
- export function resolveAuthMail<T extends AuthMailNormalizable>(
726
- auth: T,
727
- hmacSecret: string,
728
- envSource: Record<string, string | undefined>,
729
- ): T {
730
- if (!auth.mail) return auth;
731
- // SMTP-presence gate: ohne SMTP_HOST-env wird KEIN Flow verdrahtet (Routes
732
- // blieben sonst 500). Der eigentliche Mail-Versand läuft über delivery
733
- // (channel-email), nicht über diesen Transport — er ist nur der Detektor
734
- // "ist Mail konfiguriert?".
735
- if (
736
- !createSmtpTransportFromEnv(envSource, { fallbackFrom: auth.mail.from ?? "noreply@localhost" })
737
- ) {
738
- return auth;
739
- }
740
- const paths = makeAuthPaths(auth.mail.paths);
741
- // appName/locale fließen in alle vier Flow-Options (alle mailen via delivery).
742
- const mailPresentation = {
743
- ...(auth.mail.appName !== undefined && { appName: auth.mail.appName }),
744
- ...(auth.mail.locale !== undefined && { locale: auth.mail.locale }),
745
- };
746
- return {
747
- ...auth,
748
- passwordReset: auth.passwordReset ?? {
749
- hmacSecret,
750
- appUrl: `${auth.mail.baseUrl}${paths.resetPassword}`,
751
- ...mailPresentation,
752
- },
753
- emailVerification: auth.emailVerification ?? {
754
- hmacSecret,
755
- appUrl: `${auth.mail.baseUrl}${paths.verifyEmail}`,
756
- ...(auth.mail.emailVerificationMode !== undefined && {
757
- mode: auth.mail.emailVerificationMode,
758
- }),
759
- ...mailPresentation,
760
- },
761
- signup: auth.signup ?? {
762
- appUrl: `${auth.mail.baseUrl}${paths.signupComplete}`,
763
- ...mailPresentation,
764
- },
765
- invite: auth.invite ?? {
766
- appUrl: `${auth.mail.baseUrl}${paths.inviteAccept}`,
767
- ...mailPresentation,
768
- },
769
- };
770
- }
771
-
772
601
  export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHandle> {
773
602
  // 0. Env-Schema validation + dry-run modes. Runs FIRST so:
774
603
  // - operators can introspect env-requirements without a real boot
@@ -1069,6 +898,8 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1069
898
  ...(resolvedEffectiveFeatures && { effectiveFeatures: resolvedEffectiveFeatures }),
1070
899
  },
1071
900
  eventDedup,
901
+ ...(options.observability && { observability: options.observability }),
902
+ ...(options.metrics && { metrics: options.metrics }),
1072
903
  ...(effectiveAuth && {
1073
904
  auth: {
1074
905
  membershipQuery: TenantQueries.memberships,
@@ -1310,288 +1141,3 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1310
1141
 
1311
1142
  return handle;
1312
1143
  }
1313
-
1314
- // Static-fallback: try the Hono app first, fall back to a file in
1315
- // staticDir if Hono returns 404. Keeps /api/* on the dispatcher and
1316
- // everything else (HTML, JS, CSS, images) on the disk.
1317
- //
1318
- // Cache-Header-Strategie:
1319
- // /assets/* → public, max-age=31536000, immutable
1320
- // (gehashte Filenames vom Build, sicher cachebar)
1321
- // /index.html → no-cache, must-revalidate
1322
- // (HTML-Shell, must reload on deploy)
1323
- // /manifest.json, /sw.js → no-cache
1324
- // (Update-Detection-Mechanismen, müssen frisch sein)
1325
- // alles andere → kein expliziter Header
1326
- // (Browser-Default, public/-Files wie favicon)
1327
- // File-reader für den static-fallback. Nutzt node:fs/promises statt
1328
- // Bun.file damit der Pfad in vitest+node integration-tests laufen kann
1329
- // (Bun.file ist Bun-only). Performance-cost ist marginal: die Disk-
1330
- // Files in einem prod-staticDir sind 1-200 KB, full-buffer-Read ist
1331
- // ein paar Mikrosekunden. Streaming via Bun.file wäre nur relevant ab
1332
- // ~1 MB.
1333
- async function readStaticFile(
1334
- filePath: string,
1335
- ): Promise<
1336
- { readonly bytes: Uint8Array; readonly mime: string; readonly mtimeMs: number } | undefined
1337
- > {
1338
- try {
1339
- const { readFile, stat } = await import("node:fs/promises");
1340
- const [bytes, fileStat] = await Promise.all([readFile(filePath), stat(filePath)]);
1341
- return { bytes, mime: mimeTypeFor(filePath), mtimeMs: fileStat.mtimeMs };
1342
- } catch (err) {
1343
- if ((err as { code?: string }).code === "ENOENT") return undefined;
1344
- throw err;
1345
- }
1346
- }
1347
-
1348
- function serveDiskFile(
1349
- req: Request,
1350
- pathname: string,
1351
- file: {
1352
- readonly bytes: Uint8Array;
1353
- readonly mime: string;
1354
- readonly mtimeMs: number;
1355
- },
1356
- ): Response {
1357
- return cachedResponse(req, {
1358
- // @cast-boundary bun-types — Response BodyInit narrowing
1359
- body: file.bytes as unknown as BodyInit,
1360
- etag: computeWeakEtag(file.mtimeMs, file.bytes.byteLength),
1361
- cache: staticCachePolicy(pathname),
1362
- headers: { "content-type": file.mime },
1363
- lastModified: new Date(file.mtimeMs),
1364
- });
1365
- }
1366
-
1367
- // Minimal-Mime-Map — deckt die Files ab die kumiko-build und typische
1368
- // public/-Inhalte produzieren. Bun.file leitet das aus dem Suffix ab,
1369
- // im node-Pfad müssen wir es selbst tun. Default: octet-stream (Browser
1370
- // fragt bei unbekanntem MIME nach).
1371
- function mimeTypeFor(filePath: string): string {
1372
- const ext = filePath.toLowerCase().split(".").pop() ?? "";
1373
- switch (ext) {
1374
- case "html":
1375
- return "text/html; charset=utf-8";
1376
- case "js":
1377
- case "mjs":
1378
- return "text/javascript; charset=utf-8";
1379
- case "css":
1380
- return "text/css; charset=utf-8";
1381
- case "json":
1382
- return "application/json; charset=utf-8";
1383
- case "svg":
1384
- return "image/svg+xml";
1385
- case "png":
1386
- return "image/png";
1387
- case "jpg":
1388
- case "jpeg":
1389
- return "image/jpeg";
1390
- case "ico":
1391
- return "image/x-icon";
1392
- case "txt":
1393
- return "text/plain; charset=utf-8";
1394
- case "xml":
1395
- return "application/xml; charset=utf-8";
1396
- case "webmanifest":
1397
- return "application/manifest+json";
1398
- default:
1399
- return "application/octet-stream";
1400
- }
1401
- }
1402
-
1403
- function buildStaticFallback(
1404
- apiHandler: (req: Request) => Response | Promise<Response>,
1405
- staticDir: string,
1406
- appSchemaJson: string,
1407
- hostDispatch?: HostDispatchFn,
1408
- ): (req: Request) => Promise<Response> {
1409
- const indexHtml = `${staticDir}/index.html`;
1410
-
1411
- // Helper: liest eine HTML-Datei von der Disk + (optional) injiziert
1412
- // das pre-serialized AppSchema vor dem client.js-Tag. Schema-Injection
1413
- // ist explicit-opt-in damit Public-Domain-Antworten die Admin-UI-
1414
- // Topologie nicht leaken. injectSchema ist idempotent, doppelte Calls
1415
- // produzieren keinen doppelten Tag.
1416
- async function readHtmlFile(
1417
- path: string,
1418
- injectSchemaInto: boolean,
1419
- ): Promise<{ bytes: ArrayBuffer; mime: string; etag: string; mtimeMs: number } | null> {
1420
- const file = await readStaticFile(path);
1421
- if (!file) return null;
1422
- if (!injectSchemaInto) {
1423
- return {
1424
- bytes: file.bytes.buffer.slice(
1425
- file.bytes.byteOffset,
1426
- file.bytes.byteOffset + file.bytes.byteLength,
1427
- ) as ArrayBuffer,
1428
- mime: file.mime,
1429
- etag: computeWeakEtag(file.mtimeMs, file.bytes.byteLength),
1430
- mtimeMs: file.mtimeMs,
1431
- };
1432
- }
1433
- const text = new TextDecoder().decode(file.bytes);
1434
- const injected = injectSchema(text, appSchemaJson);
1435
- const bytes = new TextEncoder().encode(injected).buffer as ArrayBuffer;
1436
- return {
1437
- bytes,
1438
- mime: file.mime,
1439
- etag: computeStrongEtag(new Uint8Array(bytes)),
1440
- mtimeMs: file.mtimeMs,
1441
- };
1442
- }
1443
-
1444
- function serveHtmlFile(
1445
- req: Request,
1446
- pathname: string,
1447
- html: { bytes: ArrayBuffer; mime: string; etag: string; mtimeMs: number },
1448
- extraHeaders?: Record<string, string>,
1449
- ): Response {
1450
- return cachedResponse(req, {
1451
- body: html.bytes,
1452
- etag: html.etag,
1453
- cache: staticCachePolicy(pathname),
1454
- headers: { "content-type": html.mime, ...extraHeaders },
1455
- lastModified: new Date(html.mtimeMs),
1456
- });
1457
- }
1458
-
1459
- // hostDispatch konsultieren wenn gesetzt UND der Request auf den
1460
- // HTML-Fallback fällt (Root oder SPA-Route). Returnt entweder die
1461
- // resolved Response (redirect/404/html) oder null wenn der Default-
1462
- // Pfad weiterlaufen soll.
1463
- async function tryHostDispatch(req: Request): Promise<Response | null> {
1464
- if (!hostDispatch) return null;
1465
- const url = new URL(req.url);
1466
- const host = req.headers.get("host") ?? url.host;
1467
- const result = hostDispatch({ host, path: url.pathname, search: url.search });
1468
- if (result.kind === "not-found") {
1469
- return new Response("Not Found", { status: 404 });
1470
- }
1471
- if (result.kind === "redirect") {
1472
- return new Response(null, {
1473
- status: result.status ?? 302,
1474
- headers: { Location: result.to },
1475
- });
1476
- }
1477
- // result.kind === "html"
1478
- const filePath = `${staticDir}/${result.file}`;
1479
- const html = await readHtmlFile(filePath, result.injectSchema === true);
1480
- if (!html) {
1481
- // Author-Fehler: hostDispatch verweist auf nicht-existente Datei.
1482
- // Liefer 500 statt silent-404 damit der Bug schnell auffällt.
1483
- return new Response(`hostDispatch: file not found: ${result.file}`, { status: 500 });
1484
- }
1485
- // Per-Host-Body (hostDispatch wählt die Datei nach Host) → Vary: Host,
1486
- // sonst darf ein Shared-Cache Tenant-As Schema an Tenant B liefern.
1487
- const extraHeaders: Record<string, string> = { vary: "Host" };
1488
- if (result.csp) extraHeaders["content-security-policy"] = result.csp;
1489
- return serveHtmlFile(req, "/index.html", html, extraHeaders);
1490
- }
1491
-
1492
- return async (req: Request): Promise<Response> => {
1493
- const url = new URL(req.url);
1494
- // /api/* and /health → always Hono (Dispatcher + Health-Probe).
1495
- if (url.pathname.startsWith("/api/") || url.pathname === "/health") {
1496
- return apiHandler(req);
1497
- }
1498
-
1499
- // Hono-First für andere Pfade: extraRoutes (z.B. /feed.xml,
1500
- // /sitemap.xml) UND r.httpRoute-Features (z.B. /legal/*) müssen vor
1501
- // dem Disk-Lookup greifen, sonst schluckt der SPA-Fallback unten
1502
- // unbekannte Pfade als index.html. Shared mit dev-server's
1503
- // createKumikoServer.handleFetch damit beide IDENTISCHE Semantik haben.
1504
- const honoTry = await tryHonoFirst({ fetch: apiHandler }, req);
1505
- if (honoTry.matched) {
1506
- return honoTry.response;
1507
- }
1508
- const honoRes = honoTry.response;
1509
-
1510
- // Disk-/SPA-Fallback ist GET/HEAD-only. Ein non-GET ohne Hono-Match
1511
- // (z.B. POST auf einen falsch konfigurierten Webhook-Pfad) muss den
1512
- // Hono-404 durchreichen — 200 index.html würde dem Provider
1513
- // "delivered" signalisieren und Events gingen still verloren (#259).
1514
- if (req.method !== "GET" && req.method !== "HEAD") {
1515
- return honoRes;
1516
- }
1517
-
1518
- // Disk-Datei (Asset oder konkrete File). Asset-Pfade laufen
1519
- // host-unabhängig — die Bundles in /assets/* werden vom client
1520
- // aktiv geladen, kein Server-side Routing nötig.
1521
- const isIndexRequest = url.pathname === "/" || url.pathname === "/index.html";
1522
- if (!isIndexRequest) {
1523
- const relPath = url.pathname.slice(1);
1524
- const filePath = `${staticDir}/${relPath}`;
1525
- const file = await readStaticFile(filePath);
1526
- if (file) {
1527
- return serveDiskFile(req, url.pathname, file);
1528
- }
1529
- }
1530
-
1531
- // Root oder SPA-Route — hier greift hostDispatch wenn gesetzt.
1532
- // Ohne hostDispatch: alter Single-App-Pfad (index.html mit Schema).
1533
- const dispatched = await tryHostDispatch(req);
1534
- if (dispatched) return dispatched;
1535
-
1536
- // Default Single-App-Pfad: index.html, schema injected.
1537
- const index = await readHtmlFile(indexHtml, true);
1538
- if (index) {
1539
- return serveHtmlFile(req, "/index.html", index);
1540
- }
1541
-
1542
- // Kein Hono-Match, keine Disk-Datei, kein index.html → liefer den
1543
- // ursprünglichen 404 von Hono durch (statt einen neuen Roundtrip).
1544
- return honoRes;
1545
- };
1546
- }
1547
-
1548
- // Map URL-Pfad → Cache-Policy. Hashed-Asset-Pfade (/assets/*) sind
1549
- // unveränderlich, der Rest bleibt revalidate/no-cache damit Updates ohne
1550
- // Hard-Reload greifen. Exported für Unit-Tests; Konsumenten gehen via
1551
- // runProdApp.
1552
- export function staticCachePolicy(pathname: string): CachePolicy {
1553
- if (pathname.startsWith(`/${ASSETS_DIR}/`)) {
1554
- return { kind: "immutable" };
1555
- }
1556
- if (pathname === "/" || pathname === "/index.html") {
1557
- return { kind: "revalidate" };
1558
- }
1559
- if (
1560
- pathname === "/manifest.json" ||
1561
- pathname === "/sw.js" ||
1562
- // ponytail: build-info.json ist statisch — kein /api/version-Endpoint
1563
- // nötig, der Disk-Fallback serviert sie. no-cache, sonst pollt der
1564
- // UpdateChecker eine veraltete id.
1565
- pathname === "/build-info.json"
1566
- ) {
1567
- return { kind: "no-cache" };
1568
- }
1569
- return { kind: "none" };
1570
- }
1571
-
1572
- function buildProdSessionAuth(
1573
- db: import("@cosmicdrift/kumiko-framework/db").DbConnection,
1574
- opts: ProdSessionsConfig,
1575
- sessionsFeature: import("@cosmicdrift/kumiko-framework/engine").FeatureDefinition | undefined,
1576
- ): {
1577
- readonly sessionCreator: ReturnType<typeof createSessionCallbacks>["sessionCreator"];
1578
- readonly sessionRevoker: ReturnType<typeof createSessionCallbacks>["sessionRevoker"];
1579
- readonly sessionChecker: ReturnType<typeof createSessionCallbacks>["sessionChecker"];
1580
- readonly sessionStrictMode: true;
1581
- } {
1582
- const cbs = createSessionCallbacks({
1583
- db,
1584
- ...(opts.expiresInMs !== undefined && { expiresInMs: opts.expiresInMs }),
1585
- });
1586
- // Secure-by-default: password-change/-reset mass-revokes the user's live
1587
- // sessions without the app opting in via autoRevokeOnPasswordChange.
1588
- if (sessionsFeature) {
1589
- bindAutoRevokeFromFeature(sessionsFeature)?.(cbs.sessionMassRevoker);
1590
- }
1591
- return {
1592
- sessionCreator: cbs.sessionCreator,
1593
- sessionRevoker: cbs.sessionRevoker,
1594
- sessionChecker: cbs.sessionChecker,
1595
- sessionStrictMode: true,
1596
- };
1597
- }
@@ -138,7 +138,7 @@ function renderIndex(name: string): string {
138
138
  }
139
139
 
140
140
  function renderConstants(name: string): string {
141
- return `export const ${constName(name)} = "${name}";\n`;
141
+ return `export const ${constName(name)} = "${name}" as const;\n`;
142
142
  }
143
143
 
144
144
  function renderI18n(name: string): string {
@@ -158,12 +158,13 @@ function renderEntity(name: string): string {
158
158
  const camel = kebabToCamel(name);
159
159
  return `// Entity-Definition — Felder, Annotationen, Transitions.
160
160
  // Starter: eine Text-Spalte. Ersetze sie durch deine Domain.
161
+ import { createEntity, createTextField } from "@cosmicdrift/kumiko-framework/engine";
161
162
 
162
- export const ${camel}ItemEntity = {
163
+ export const ${camel}ItemEntity = createEntity({
163
164
  fields: {
164
- title: { type: "text", required: true, sortable: true },
165
+ title: createTextField({ required: true, sortable: true }),
165
166
  },
166
- };
167
+ });
167
168
  `;
168
169
  }
169
170
 
@@ -19,7 +19,7 @@ export type SetupTestStackFromFeaturesOptions = Omit<TestStackOptions, "features
19
19
  readonly presets?: readonly TestStackPreset[];
20
20
  };
21
21
 
22
- function mergeExtraContext(
22
+ export function mergeExtraContext(
23
23
  base: TestStackOptions["extraContext"],
24
24
  presets: readonly TestStackPreset[],
25
25
  ): TestStackOptions["extraContext"] {