@cosmicdrift/kumiko-server-runtime 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 (38) hide show
  1. package/LICENSE +57 -0
  2. package/package.json +87 -0
  3. package/src/__tests__/boot-extra-context.test.ts +140 -0
  4. package/src/__tests__/build-prod-bundle.test.ts +278 -0
  5. package/src/__tests__/cache-headers.test.ts +83 -0
  6. package/src/__tests__/compose-features-mfa-wiring.integration.test.ts +308 -0
  7. package/src/__tests__/compose-features-wiring.integration.test.ts +382 -0
  8. package/src/__tests__/compose-features.test.ts +128 -0
  9. package/src/__tests__/config-seed-boot.integration.test.ts +158 -0
  10. package/src/__tests__/inject-schema.test.ts +62 -0
  11. package/src/__tests__/pii-boot-gate.test.ts +68 -0
  12. package/src/__tests__/renderer-web-css-relocation.integration.test.ts +85 -0
  13. package/src/__tests__/renderer-web-shell-sentinel.test.ts +35 -0
  14. package/src/__tests__/require-env.test.ts +29 -0
  15. package/src/__tests__/resolve-auth-mail.test.ts +69 -0
  16. package/src/__tests__/resolve-tailwind-cli.test.ts +81 -0
  17. package/src/__tests__/run-prod-app-env-source.test.ts +157 -0
  18. package/src/__tests__/run-prod-app-spec.test.ts +57 -0
  19. package/src/__tests__/run-prod-app.integration.test.ts +915 -0
  20. package/src/__tests__/session-wiring.test.ts +51 -0
  21. package/src/__tests__/try-hono-first.test.ts +63 -0
  22. package/src/boot/__tests__/job-run-logger.test.ts +26 -0
  23. package/src/boot/apply-boot-seeds.ts +19 -0
  24. package/src/boot/boot-crypto.ts +82 -0
  25. package/src/boot/job-run-logger.ts +51 -0
  26. package/src/build-prod-bundle.ts +692 -0
  27. package/src/bun-serve-options.ts +22 -0
  28. package/src/compose-features.ts +164 -0
  29. package/src/extra-routes-deps.ts +47 -0
  30. package/src/index.ts +16 -0
  31. package/src/inject-schema.ts +30 -0
  32. package/src/pii-boot-gate.ts +62 -0
  33. package/src/resolve-tailwind-cli.ts +45 -0
  34. package/src/run-prod-app-boot-context.ts +241 -0
  35. package/src/run-prod-app-static-files.ts +273 -0
  36. package/src/run-prod-app.ts +1137 -0
  37. package/src/session-wiring.ts +29 -0
  38. package/src/try-hono-first.ts +46 -0
@@ -0,0 +1,915 @@
1
+ // runProdApp Integration: bootet die komplette Production-Chain mit
2
+ // echtem Postgres + Redis. Beweist:
3
+ // - Migration ist idempotent (2× boot mit gleicher DB → kein Crash)
4
+ // - Seeds laufen einmal, beim 2. Boot no-op (idempotent-by-design)
5
+ // - HTTP-Server antwortet auf /api/health
6
+ // - SIGTERM-handler räumt sauber auf
7
+ //
8
+ // NICHT getestet: Bun.serve über echte TCP-Verbindung — wir treiben
9
+ // fetch direkt. Bun.serve-Wiring ist in Production-Coolify selbst
10
+ // getestet wenn der Container hochfährt.
11
+
12
+ import { afterEach, beforeAll, describe, expect, test } from "bun:test";
13
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
14
+ import { tmpdir } from "node:os";
15
+ import { dirname, join } from "node:path";
16
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
17
+ import { InMemoryKmsAdapter, type KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
18
+ import { createDbConnection } from "@cosmicdrift/kumiko-framework/db";
19
+ import {
20
+ createBooleanField,
21
+ createEntity,
22
+ createTextField,
23
+ defineFeature,
24
+ } from "@cosmicdrift/kumiko-framework/engine";
25
+ import {
26
+ createArchivedStreamsTable,
27
+ createEventsTable,
28
+ } from "@cosmicdrift/kumiko-framework/event-store";
29
+ import {
30
+ createEventConsumerStateTable,
31
+ createProjectionStateTable,
32
+ } from "@cosmicdrift/kumiko-framework/pipeline";
33
+ import { unsafeEnsureEntityTable } from "@cosmicdrift/kumiko-framework/stack";
34
+ import { Queue } from "bullmq";
35
+ import postgres from "postgres";
36
+ import { z } from "zod";
37
+ import { type ProdAppHandle, runProdApp } from "../run-prod-app";
38
+
39
+ // tmp-Verzeichnisse pro Test, in afterEach geräumt. Tests die staticDir
40
+ // brauchen registrieren ihren Pfad hier.
41
+ const tempDirs: string[] = [];
42
+
43
+ async function createTempStaticDir(files: Record<string, string>): Promise<string> {
44
+ const dir = await mkdtemp(join(tmpdir(), "kumiko-prod-static-"));
45
+ tempDirs.push(dir);
46
+ for (const [name, content] of Object.entries(files)) {
47
+ const fullPath = join(dir, name);
48
+ await mkdir(dirname(fullPath), { recursive: true });
49
+ await writeFile(fullPath, content);
50
+ }
51
+ return dir;
52
+ }
53
+
54
+ const widgetEntity = createEntity({
55
+ fields: {
56
+ name: createTextField({ required: true }),
57
+ active: createBooleanField({ default: true }),
58
+ },
59
+ table: "prod_widgets",
60
+ });
61
+
62
+ const widgetFeature = defineFeature("prod-probe", (r) => {
63
+ r.entity("widget", widgetEntity);
64
+ // Anonymous query — covers the "anonymousAccess flows from runProdApp
65
+ // through createApiEntrypoint to the auth-middleware" wiring that
66
+ // earlier silently dropped the option in the entrypoint layer.
67
+ r.queryHandler({
68
+ name: "ping",
69
+ schema: z.object({}),
70
+ access: { roles: ["anonymous"] },
71
+ handler: async () => ({ pong: true }),
72
+ });
73
+ r.queryHandler({
74
+ name: "kms-probe",
75
+ schema: z.object({}),
76
+ access: { roles: ["anonymous"] },
77
+ handler: async (_event, ctx) => ({ hasKms: ctx.kms !== undefined }),
78
+ });
79
+ // SystemAdmin-gated write — Ziel des extraRoutes.dispatchSystemWrite-
80
+ // Tests: Echo von user.tenantId + roles beweist, dass der Dispatch
81
+ // durch den echten Dispatcher (Zod + Access-Check) läuft und der
82
+ // auto-konstruierte SystemUser den Ziel-Tenant trägt.
83
+ r.writeHandler({
84
+ name: "probe-write",
85
+ schema: z.object({ note: z.string() }),
86
+ access: { roles: ["SystemAdmin"] },
87
+ handler: async (event) => ({
88
+ isSuccess: true as const,
89
+ data: { tenantSeen: event.user.tenantId, roles: event.user.roles },
90
+ }),
91
+ });
92
+ // Event + MSP-Paar für den lokalen Event-Dispatcher (2026-06-11):
93
+ // runProdApp ist Single-Container — ohne lokalen Dispatcher wendet KEINE
94
+ // multiStreamProjection jemals an (Prod hatte deshalb leere Projektionen
95
+ // + leere kumiko_event_consumers). Der Write appended das Event; die MSP
96
+ // schreibt async in prod_probe_pings — der Test pollt darauf.
97
+ const pingedEvent = r.defineEvent("probe-pinged", z.object({ note: z.string() }));
98
+ r.writeHandler({
99
+ name: "probe-append",
100
+ schema: z.object({ aggregateId: z.string(), note: z.string() }),
101
+ access: { roles: ["SystemAdmin"] },
102
+ handler: async (event, ctx) => {
103
+ const payload = event.payload as { aggregateId: string; note: string }; // @cast-boundary engine-payload
104
+ // unsafeAppendEvent: das Test-Feature augmentiert keine Event-Type-Map,
105
+ // der strict-typed appendEvent narrowt hier auf never.
106
+ await ctx.unsafeAppendEvent({
107
+ aggregateId: payload.aggregateId,
108
+ aggregateType: "probe",
109
+ type: pingedEvent.name,
110
+ payload: { note: payload.note },
111
+ });
112
+ return { isSuccess: true as const, data: { ok: true as const } };
113
+ },
114
+ });
115
+ r.multiStreamProjection({
116
+ name: "probe-ping-projection",
117
+ apply: {
118
+ [pingedEvent.name]: async (event, tx) => {
119
+ const payload = event.payload as { note: string }; // @cast-boundary engine-payload
120
+ await asRawClient(tx).unsafe(
121
+ `INSERT INTO prod_probe_pings (aggregate_id, note) VALUES ($1, $2)`,
122
+ [event.aggregateId, payload.note],
123
+ );
124
+ },
125
+ },
126
+ });
127
+ });
128
+
129
+ // Worker-lane cron — the lane the data-export job (run-export-jobs) lives on.
130
+ // runProdApp must schedule it (single-instance runs both lanes); on the old
131
+ // createApiEntrypoint path it was silently never registered → exports hung.
132
+ const cronProbeFeature = defineFeature("cron-probe", (r) => {
133
+ r.job("worker-lane-cron", { trigger: { cron: "0 0 1 1 *" }, runIn: "worker" }, async () => {});
134
+ });
135
+
136
+ async function workerLaneSchedulers(prefix: string): Promise<{ name?: string; key?: string }[]> {
137
+ const url = new URL(process.env["REDIS_URL"] ?? "redis://localhost:16379");
138
+ const queue = new Queue(`${prefix}-worker`, {
139
+ connection: { host: url.hostname, port: Number(url.port) },
140
+ });
141
+ // Read-only: do NOT obliterate — the running all-in-one worker consumes this
142
+ // same queue, and deleting its keys mid-flight aborts the worker's blocking
143
+ // Redis read with "Connection is closed". The unique prefix isolates the
144
+ // leftover scheduler; the test Redis is ephemeral.
145
+ try {
146
+ return await queue.getJobSchedulers();
147
+ } finally {
148
+ await queue.close();
149
+ }
150
+ }
151
+
152
+ const TENANT_ID = "00000000-0000-4000-8000-000000000001";
153
+
154
+ // Per-suite DB so reboots can be tested without conflicting with other
155
+ // test suites. Created in beforeAll, dropped at module end via the admin
156
+ // connection.
157
+ const TEST_DB = `kumiko_runprod_${Date.now().toString(36)}`;
158
+ const ADMIN_URL = process.env["TEST_DATABASE_URL"] ?? "";
159
+
160
+ let prodAppHandles: ProdAppHandle[] = [];
161
+
162
+ beforeAll(async () => {
163
+ if (!ADMIN_URL) throw new Error("TEST_DATABASE_URL must be set");
164
+ const adminClient = postgres(ADMIN_URL.replace(/\/[^/]+$/, "/postgres"));
165
+ try {
166
+ await adminClient.unsafe(`CREATE DATABASE "${TEST_DB}"`);
167
+ } finally {
168
+ await adminClient.end();
169
+ }
170
+ });
171
+
172
+ afterEach(async () => {
173
+ for (const handle of prodAppHandles) {
174
+ await handle.stop();
175
+ }
176
+ prodAppHandles = [];
177
+ for (const dir of tempDirs) {
178
+ await rm(dir, { recursive: true, force: true });
179
+ }
180
+ tempDirs.length = 0;
181
+ });
182
+
183
+ // Production-Apps booten gegen eine VORHER migrierte DB (CI-Step
184
+ // `kumiko migrate apply`). In diesem Test gibt's keine drizzle-Migration-
185
+ // Files, also imitieren wir den Migration-Step direkt: Framework-Infra-
186
+ // Tables + die widget-Entity-Tabelle anlegen, dann runProdApp mit
187
+ // `migrations: false` (= kein Schema-Drift-Gate) starten. So bleibt der
188
+ // Test fokussiert auf Boot-Wiring (Entrypoint, Hono-Routes, Seeds), ohne
189
+ // den Migrationspfad zu duplizieren.
190
+ async function migrateTestDb(): Promise<void> {
191
+ const url = ADMIN_URL.replace(/\/[^/]+$/, `/${TEST_DB}`);
192
+ const { db, close } = createDbConnection(url);
193
+ try {
194
+ await createEventsTable(db);
195
+ await createArchivedStreamsTable(db);
196
+ await createProjectionStateTable(db);
197
+ await createEventConsumerStateTable(db);
198
+ await unsafeEnsureEntityTable(db, widgetEntity, "widget");
199
+ await asRawClient(db).unsafe(
200
+ `CREATE TABLE IF NOT EXISTS prod_probe_pings (
201
+ id BIGSERIAL PRIMARY KEY,
202
+ aggregate_id UUID NOT NULL,
203
+ note TEXT NOT NULL
204
+ )`,
205
+ );
206
+ } finally {
207
+ await close();
208
+ }
209
+ }
210
+
211
+ let testDbMigrated = false;
212
+
213
+ async function boot(
214
+ seedFn?: (deps: { db: import("@cosmicdrift/kumiko-framework/db").DbConnection }) => Promise<void>,
215
+ extra?: Partial<Parameters<typeof runProdApp>[0]>,
216
+ ): Promise<ProdAppHandle> {
217
+ // Override env per boot to point at the suite's DB.
218
+ const originalDbUrl = process.env["DATABASE_URL"];
219
+ process.env["DATABASE_URL"] = ADMIN_URL.replace(/\/[^/]+$/, `/${TEST_DB}`);
220
+ process.env["REDIS_URL"] = process.env["REDIS_URL"] ?? "redis://localhost:16379";
221
+ process.env["JWT_SECRET"] = "test-runprod-secret-32-chars-min!!";
222
+ process.env["PORT"] = "0"; // Bun.serve picks an ephemeral port
223
+
224
+ if (!testDbMigrated) {
225
+ await migrateTestDb();
226
+ testDbMigrated = true;
227
+ }
228
+
229
+ try {
230
+ const handle = await runProdApp({
231
+ features: [widgetFeature],
232
+ autoListen: false,
233
+ migrations: false,
234
+ ...(seedFn && { seeds: [seedFn] }),
235
+ ...(extra ?? {}),
236
+ });
237
+ prodAppHandles.push(handle);
238
+ return handle;
239
+ } finally {
240
+ if (originalDbUrl !== undefined) process.env["DATABASE_URL"] = originalDbUrl;
241
+ else delete process.env["DATABASE_URL"];
242
+ }
243
+ }
244
+
245
+ describe("runProdApp", () => {
246
+ test("first boot creates entity tables, /api/health responds", async () => {
247
+ const handle = await boot();
248
+
249
+ const res = await handle.entrypoint.app.fetch(new Request("http://test/health"));
250
+ expect(res.status).toBe(200);
251
+ });
252
+
253
+ test("second boot against the same DB is idempotent — no crash, no duplicate tables", async () => {
254
+ await boot();
255
+ // First boot left tables in place. Restart on the same DB —
256
+ // unsafeEnsureEntityTable should be a no-op for the existing rows.
257
+ const second = await boot();
258
+
259
+ const res = await second.entrypoint.app.fetch(new Request("http://test/health"));
260
+ expect(res.status).toBe(200);
261
+ });
262
+
263
+ test("extraRoutes-callback mounts custom HTTP-routes on the Hono-app", async () => {
264
+ // Beweist dass die runProdApp.extraRoutes-Option den Hono-app
265
+ // bekommt und Routes daran VOR dem static-fallback greifen — das
266
+ // ist das Fundament für /feed.xml, /sitemap.xml, /og-image etc.
267
+ let extraInvoked = false;
268
+ const handle = await boot(undefined, {
269
+ extraRoutes: (app, deps) => {
270
+ extraInvoked = true;
271
+ // deps.db + deps.redis sind die runProdApp-Connections — die
272
+ // Route kann gegen die Domain queryen, hier reicht ein simple
273
+ // Echo zum Beweis dass wir ans App-Object kommen.
274
+ app.get("/feed.xml", (c) => {
275
+ const dbAvailable = deps.db !== undefined;
276
+ return c.body(`<?xml version="1.0"?><probe ok="${dbAvailable}" />`, 200, {
277
+ "content-type": "application/rss+xml",
278
+ });
279
+ });
280
+ },
281
+ });
282
+
283
+ expect(extraInvoked).toBe(true);
284
+
285
+ // handle.fetch durchläuft den static-fallback wrapper — dort liegt
286
+ // die "Hono-First, dann Disk"-Logik. entrypoint.app.fetch würde den
287
+ // wrapper umgehen und damit die regression nicht erkennen.
288
+ const res = await handle.fetch(new Request("http://test/feed.xml"));
289
+ expect(res.status).toBe(200);
290
+ expect(res.headers.get("content-type")).toBe("application/rss+xml");
291
+ const body = await res.text();
292
+ expect(body).toContain('<probe ok="true" />');
293
+ });
294
+
295
+ test("extraRoutes-deps: dispatchSystemWrite schreibt als SystemAdmin des Ziel-Tenants, registry verfügbar", async () => {
296
+ // Das ist das Wiring für Provider-Webhook-Routes (billing-foundation
297
+ // createSubscriptionWebhookHandler): die Route authentifiziert via
298
+ // Provider-Signatur und schreibt dann am JWT-Pfad vorbei durch den
299
+ // Command-Dispatcher. Beweist: (a) registry liegt in den deps,
300
+ // (b) dispatchSystemWrite geht durch Zod + Access-Check des Handlers,
301
+ // (c) der SystemUser trägt den Ziel-Tenant (Event-Store-Konsistenz).
302
+ let registryHasProbe = false;
303
+ const handle = await boot(undefined, {
304
+ extraRoutes: (app, deps) => {
305
+ registryHasProbe = deps.registry.features.has("prod-probe");
306
+ app.post("/webhook-probe", async (c) => {
307
+ const result = await deps.dispatchSystemWrite({
308
+ handlerQn: "prod-probe:write:probe-write",
309
+ payload: { note: "from-webhook" },
310
+ tenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
311
+ });
312
+ return c.json(result);
313
+ });
314
+ },
315
+ });
316
+
317
+ expect(registryHasProbe).toBe(true);
318
+
319
+ const res = await handle.fetch(new Request("http://test/webhook-probe", { method: "POST" }));
320
+ expect(res.status).toBe(200);
321
+ const body = (await res.json()) as {
322
+ isSuccess: boolean;
323
+ data?: { tenantSeen: string; roles: string[] };
324
+ };
325
+ expect(body.isSuccess).toBe(true);
326
+ expect(body.data?.tenantSeen).toBe(TENANT_ID);
327
+ expect(body.data?.roles).toContain("SystemAdmin");
328
+ });
329
+
330
+ test("static-fallback: extraRoute beats Disk-File at colliding path (Hono-First)", async () => {
331
+ // Regression-Test für den static-fallback-Bug von Phase 2 Step 1:
332
+ // wenn ein extraRoute (z.B. /feed.xml) UND eine gleichnamige Disk-
333
+ // Datei in staticDir existieren, gewinnt der extraRoute. Sonst
334
+ // schluckt der SPA-Fallback unbekannte Pfade als index.html und
335
+ // der App-Author wundert sich warum sein /feed.xml nichts macht.
336
+ const tmpStaticDir = await createTempStaticDir({
337
+ "feed.xml": "<this-is-the-disk-version />",
338
+ "index.html": "<html>SPA shell</html>",
339
+ });
340
+
341
+ const handle = await boot(undefined, {
342
+ staticDir: tmpStaticDir,
343
+ extraRoutes: (app) => {
344
+ app.get("/feed.xml", (c) =>
345
+ c.body("<this-is-the-hono-version />", 200, {
346
+ "content-type": "application/rss+xml",
347
+ }),
348
+ );
349
+ },
350
+ });
351
+
352
+ const res = await handle.fetch(new Request("http://test/feed.xml"));
353
+ expect(res.status).toBe(200);
354
+ expect(await res.text()).toContain("<this-is-the-hono-version />");
355
+ });
356
+
357
+ test("static-fallback: Disk-File served when no extraRoute matches", async () => {
358
+ // Komplement-Test: ohne kollidierenden extraRoute liefert der
359
+ // static-fallback die Disk-Datei. Beweist dass der Hono-First-Pfad
360
+ // nicht versehentlich Static-Files schluckt.
361
+ const tmpStaticDir = await createTempStaticDir({
362
+ "robots.txt": "User-agent: *\nAllow: /",
363
+ "index.html": "<html>SPA shell</html>",
364
+ });
365
+
366
+ const handle = await boot(undefined, { staticDir: tmpStaticDir });
367
+
368
+ const res = await handle.fetch(new Request("http://test/robots.txt"));
369
+ expect(res.status).toBe(200);
370
+ expect(await res.text()).toContain("User-agent: *");
371
+ expect(res.headers.get("etag")).toBeTruthy();
372
+ });
373
+
374
+ test("static-fallback: If-None-Match → 304 on disk file", async () => {
375
+ const tmpStaticDir = await createTempStaticDir({
376
+ "robots.txt": "User-agent: *\nAllow: /",
377
+ "index.html": "<html>SPA shell</html>",
378
+ });
379
+
380
+ const handle = await boot(undefined, { staticDir: tmpStaticDir });
381
+ const first = await handle.fetch(new Request("http://test/robots.txt"));
382
+ const etag = first.headers.get("etag");
383
+ expect(etag).toBeTruthy();
384
+
385
+ const second = await handle.fetch(
386
+ new Request("http://test/robots.txt", { headers: { "if-none-match": etag ?? "" } }),
387
+ );
388
+ expect(second.status).toBe(304);
389
+ expect(await second.text()).toBe("");
390
+ });
391
+
392
+ test("static-fallback: If-None-Match → 304 on SPA index.html", async () => {
393
+ const tmpStaticDir = await createTempStaticDir({
394
+ "index.html": "<html>SPA shell</html>",
395
+ });
396
+
397
+ const handle = await boot(undefined, { staticDir: tmpStaticDir });
398
+ const first = await handle.fetch(new Request("http://test/some/spa/route"));
399
+ const etag = first.headers.get("etag");
400
+ expect(etag).toBeTruthy();
401
+
402
+ const second = await handle.fetch(
403
+ new Request("http://test/some/spa/route", {
404
+ headers: { "if-none-match": etag ?? "" },
405
+ }),
406
+ );
407
+ expect(second.status).toBe(304);
408
+ });
409
+
410
+ test("static-fallback: unknown path → SPA-fallback to index.html", async () => {
411
+ // Path ohne extraRoute, ohne Disk-File, mit existierendem
412
+ // index.html → liefert die SPA-Shell. Standard-SPA-Routing-Pattern,
413
+ // aber wir wollen sicher sein dass der Hono-First-Refactor das
414
+ // nicht gebrochen hat.
415
+ const tmpStaticDir = await createTempStaticDir({
416
+ "index.html": "<html>SPA shell</html>",
417
+ });
418
+
419
+ const handle = await boot(undefined, { staticDir: tmpStaticDir });
420
+
421
+ const res = await handle.fetch(new Request("http://test/some/spa/route"));
422
+ expect(res.status).toBe(200);
423
+ expect(await res.text()).toContain("SPA shell");
424
+ });
425
+
426
+ test("static-fallback: non-GET ohne Hono-Match → 404, nicht SPA-Shell (#259)", async () => {
427
+ // Prod-Szenario: POST auf einen falsch konfigurierten Webhook-Pfad
428
+ // (Route nicht gemountet). 200 index.html würde dem Provider
429
+ // "delivered" signalisieren — Events gingen still verloren.
430
+ const tmpStaticDir = await createTempStaticDir({
431
+ "index.html": "<html>SPA shell</html>",
432
+ "robots.txt": "User-agent: *\nAllow: /",
433
+ });
434
+
435
+ const handle = await boot(undefined, { staticDir: tmpStaticDir });
436
+
437
+ const unmatched = await handle.fetch(
438
+ new Request("http://test/webhooks/subscription/stripe", { method: "POST" }),
439
+ );
440
+ expect(unmatched.status).toBe(404);
441
+
442
+ // Disk-Files werden ebenfalls nicht auf non-GET serviert.
443
+ const diskFile = await handle.fetch(new Request("http://test/robots.txt", { method: "POST" }));
444
+ expect(diskFile.status).toBe(404);
445
+ });
446
+
447
+ test("static-fallback: HEAD auf SPA-Route bleibt 200 (spiegelt GET)", async () => {
448
+ const tmpStaticDir = await createTempStaticDir({
449
+ "index.html": "<html>SPA shell</html>",
450
+ });
451
+
452
+ const handle = await boot(undefined, { staticDir: tmpStaticDir });
453
+
454
+ const res = await handle.fetch(new Request("http://test/some/spa/route", { method: "HEAD" }));
455
+ expect(res.status).toBe(200);
456
+ });
457
+
458
+ test("hostDispatch: per-host html-Datei + Schema-Gating", async () => {
459
+ // Multi-App-Deployment: zwei HTML-Dateien für unterschiedliche
460
+ // Hosts. Schema wird NUR für admin-Host injected — Public-Host
461
+ // bekommt das pure HTML ohne __KUMIKO_SCHEMA__ Tag (Sicherheit).
462
+ const tmpStaticDir = await createTempStaticDir({
463
+ "index.html": "<html><body>PUBLIC</body><script src=/client.js></script></html>",
464
+ "admin.html": "<html><body>ADMIN</body><script src=/client.js></script></html>",
465
+ });
466
+
467
+ const handle = await boot(undefined, {
468
+ staticDir: tmpStaticDir,
469
+ hostDispatch: ({ host }) => {
470
+ if (host.startsWith("admin.")) {
471
+ return { kind: "html", file: "admin.html", injectSchema: true };
472
+ }
473
+ return { kind: "html", file: "index.html", injectSchema: false };
474
+ },
475
+ });
476
+
477
+ // Public host: index.html, KEIN schema-Tag.
478
+ const pubRes = await handle.fetch(
479
+ new Request("http://demo.example.test/", { headers: { host: "demo.example.test" } }),
480
+ );
481
+ expect(pubRes.status).toBe(200);
482
+ const pubBody = await pubRes.text();
483
+ expect(pubBody).toContain("PUBLIC");
484
+ expect(pubBody).not.toContain("__KUMIKO_SCHEMA__");
485
+
486
+ // Admin host: admin.html MIT schema-Tag.
487
+ const adminRes = await handle.fetch(
488
+ new Request("http://admin.example.test/", { headers: { host: "admin.example.test" } }),
489
+ );
490
+ expect(adminRes.status).toBe(200);
491
+ const adminBody = await adminRes.text();
492
+ expect(adminBody).toContain("ADMIN");
493
+ expect(adminBody).toContain("__KUMIKO_SCHEMA__");
494
+ });
495
+
496
+ test("hostDispatch: redirect-Modus", async () => {
497
+ const tmpStaticDir = await createTempStaticDir({
498
+ "index.html": "<html>fallback</html>",
499
+ });
500
+ const handle = await boot(undefined, {
501
+ staticDir: tmpStaticDir,
502
+ hostDispatch: ({ host }) =>
503
+ host === "apex.example.test"
504
+ ? { kind: "redirect", to: "https://target.example", status: 302 }
505
+ : { kind: "html", file: "index.html", injectSchema: false },
506
+ });
507
+
508
+ const res = await handle.fetch(
509
+ new Request("http://apex.example.test/", { headers: { host: "apex.example.test" } }),
510
+ );
511
+ expect(res.status).toBe(302);
512
+ expect(res.headers.get("Location")).toBe("https://target.example");
513
+ });
514
+
515
+ test("hostDispatch: 404-Modus für unbekannte Hosts", async () => {
516
+ const tmpStaticDir = await createTempStaticDir({
517
+ "index.html": "<html>fallback</html>",
518
+ });
519
+ const handle = await boot(undefined, {
520
+ staticDir: tmpStaticDir,
521
+ hostDispatch: ({ host }) =>
522
+ host === "known.example.test"
523
+ ? { kind: "html", file: "index.html", injectSchema: false }
524
+ : { kind: "not-found" },
525
+ });
526
+
527
+ const res = await handle.fetch(
528
+ new Request("http://unknown.example.test/", { headers: { host: "unknown.example.test" } }),
529
+ );
530
+ expect(res.status).toBe(404);
531
+ });
532
+
533
+ test("hostDispatch: CSP-Header-Passthrough pro Host", async () => {
534
+ const tmpStaticDir = await createTempStaticDir({
535
+ "index.html": "<html>x</html>",
536
+ });
537
+ const csp = "default-src 'self'; script-src 'self'";
538
+ const handle = await boot(undefined, {
539
+ staticDir: tmpStaticDir,
540
+ hostDispatch: () => ({ kind: "html", file: "index.html", injectSchema: false, csp }),
541
+ });
542
+
543
+ const res = await handle.fetch(new Request("http://x.example.test/"));
544
+ expect(res.status).toBe(200);
545
+ expect(res.headers.get("content-security-policy")).toBe(csp);
546
+ });
547
+
548
+ test("hostDispatch: assets bleiben host-unabhängig erreichbar", async () => {
549
+ // /assets/* darf NICHT durch hostDispatch laufen — Bundles werden
550
+ // vom client per absoluter URL nachgeladen, host-Sniffing wäre falsch.
551
+ const tmpStaticDir = await createTempStaticDir({
552
+ "index.html": "<html>x</html>",
553
+ "assets/app-abc.js": "console.log('app');",
554
+ });
555
+ const handle = await boot(undefined, {
556
+ staticDir: tmpStaticDir,
557
+ hostDispatch: () => ({ kind: "not-found" }),
558
+ });
559
+
560
+ const res = await handle.fetch(new Request("http://x.example.test/assets/app-abc.js"));
561
+ expect(res.status).toBe(200);
562
+ expect(await res.text()).toContain("console.log('app')");
563
+ });
564
+
565
+ test("anonymousAccess flows from runProdApp through entrypoint into the auth-middleware", async () => {
566
+ // Regression for the silent-drop bug: ApiEntrypointOptions had no
567
+ // anonymousAccess field, so runProdApp's option went into createApi
568
+ // Entrypoint's spread, vanished, and the auth-middleware never saw
569
+ // it → 401 missing_token even on `roles: ["anonymous"]` handlers.
570
+ const handle = await boot(undefined, {
571
+ anonymousAccess: { defaultTenantId: TENANT_ID },
572
+ });
573
+
574
+ const res = await handle.entrypoint.app.fetch(
575
+ new Request("http://test/api/query", {
576
+ method: "POST",
577
+ headers: { "content-type": "application/json" },
578
+ body: JSON.stringify({
579
+ type: "prod-probe:query:ping",
580
+ payload: {},
581
+ }),
582
+ }),
583
+ );
584
+ expect(res.status).toBe(200);
585
+ const body = (await res.json()) as { data?: { pong?: boolean } };
586
+ expect(body.data?.pong).toBe(true);
587
+ });
588
+
589
+ test("anonymousAccess as factory: receives {db, redis, registry}, resolver closures over db", async () => {
590
+ // Use case: tenantResolver looks up subdomain → tenantId in the DB
591
+ // at request time. The factory is called once at boot with db
592
+ // wired, the resolver inside captures it.
593
+ const seenDeps: { db: boolean; redis: boolean; registry: boolean } = {
594
+ db: false,
595
+ redis: false,
596
+ registry: false,
597
+ };
598
+
599
+ const handle = await boot(undefined, {
600
+ anonymousAccess: ({ db, redis, registry }) => {
601
+ seenDeps.db = db !== undefined;
602
+ seenDeps.redis = redis !== undefined;
603
+ seenDeps.registry = registry !== undefined;
604
+ return { defaultTenantId: TENANT_ID };
605
+ },
606
+ });
607
+
608
+ expect(seenDeps).toEqual({ db: true, redis: true, registry: true });
609
+
610
+ const res = await handle.entrypoint.app.fetch(
611
+ new Request("http://test/api/query", {
612
+ method: "POST",
613
+ headers: { "content-type": "application/json" },
614
+ body: JSON.stringify({ type: "prod-probe:query:ping", payload: {} }),
615
+ }),
616
+ );
617
+ expect(res.status).toBe(200);
618
+ });
619
+
620
+ test("extraContext as factory: factory called with {db, redis, registry} at boot", async () => {
621
+ // Factory-form for extraContext closes over db like anonymousAccess.
622
+ // In auth-mode the framework auto-sets configResolver; Factory-Result
623
+ // wird drauf gemerged. Wichtig: Factory wird genau einmal aufgerufen
624
+ // beim Boot, NACHDEM db/redis/registry konstruiert sind.
625
+ let invocations = 0;
626
+ let factoryDeps: { db: boolean; redis: boolean; registry: boolean } | null = null;
627
+
628
+ const handle = await boot(undefined, {
629
+ extraContext: ({ db, redis, registry }) => {
630
+ invocations++;
631
+ factoryDeps = {
632
+ db: db !== undefined,
633
+ redis: redis !== undefined,
634
+ registry: registry !== undefined,
635
+ };
636
+ return { _appCustomKey: "from-factory" };
637
+ },
638
+ });
639
+
640
+ expect(invocations).toBe(1);
641
+ expect(factoryDeps!).toEqual({ db: true, redis: true, registry: true });
642
+ // Smoke: handle is functional (boot completed without error).
643
+ expect(handle.entrypoint).toBeDefined();
644
+ });
645
+
646
+ test("seed runs once on first boot, but the seed's own idempotence prevents duplication on reboot", async () => {
647
+ let seedInvocations = 0;
648
+ let inserted = false;
649
+
650
+ const seed = async ({
651
+ db,
652
+ }: {
653
+ db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
654
+ }) => {
655
+ seedInvocations++;
656
+ // Seed-side idempotence: check before inserting. runProdApp doesn't
657
+ // gate seeds — the seed itself is responsible.
658
+ const existing = await asRawClient(db).unsafe(`SELECT 1 FROM prod_widgets LIMIT 1`);
659
+ if ((existing as Array<Record<string, unknown>>).length > 0) return;
660
+ await asRawClient(db).unsafe(`INSERT INTO prod_widgets (id, tenant_id, name) VALUES
661
+ (gen_random_uuid(), '00000000-0000-4000-8000-000000000001', 'seeded')`);
662
+ inserted = true;
663
+ };
664
+
665
+ await boot(seed);
666
+ expect(seedInvocations).toBe(1);
667
+ expect(inserted).toBe(true);
668
+
669
+ await boot(seed);
670
+ // Seed function was called both times (runProdApp doesn't track),
671
+ // but the seed's own check kept it from inserting again.
672
+ expect(seedInvocations).toBe(2);
673
+
674
+ // Probe DB — exactly one row.
675
+ const second = prodAppHandles[1];
676
+ if (!second) throw new Error("expected second handle");
677
+ // Use the entrypoint's DB context to query (clean shutdown handles
678
+ // the connection lifecycle).
679
+ const ctx = second.entrypoint as unknown as { app: { fetch: typeof fetch } };
680
+ const res = await ctx.app.fetch(new Request("http://test/health"));
681
+ expect(res.status).toBe(200);
682
+ });
683
+
684
+ test("Hard Boot-Gate: pending kumiko-Migration → SchemaDriftError, kein Boot", async () => {
685
+ // Synthetisches kumiko/migrations-Dir mit einer checked-in Migration die
686
+ // nie applied wurde (kein _kumiko_migrations-Eintrag). runProdApp soll mit
687
+ // SchemaDriftError abbrechen bevor irgendetwas anderes initialisiert wird.
688
+ const driftDir = await mkdtemp(join(tmpdir(), "kumiko-drift-boot-"));
689
+ tempDirs.push(driftDir);
690
+ await writeFile(
691
+ join(driftDir, "0001_pending.sql"),
692
+ `CREATE TABLE "never_created_table" ("id" uuid PRIMARY KEY);`,
693
+ );
694
+ await writeFile(
695
+ join(driftDir, ".snapshot.json"),
696
+ JSON.stringify({
697
+ version: 1,
698
+ tables: [{ tableName: "never_created_table", columns: [] }],
699
+ }),
700
+ );
701
+
702
+ await expect(boot(undefined, { migrations: { dir: driftDir } })).rejects.toThrow(
703
+ /Schema drift detected/,
704
+ );
705
+ });
706
+ });
707
+
708
+ describe("runProdApp: lokaler Event-Dispatcher (MSP-Anwendung im Single-Container)", () => {
709
+ // Regression für den 2026-06-11-Incident: runProdApp baute den
710
+ // Event-Dispatcher nie ({disabled:true} im API-Entrypoint) — jede
711
+ // multiStreamProjection blieb in Prod unangewendet, kumiko_event_consumers
712
+ // blieb leer. Der Test schreibt über den ECHTEN Boot-Pfad und pollt auf
713
+ // die async projizierte Row.
714
+ async function pollFor<T>(probe: () => Promise<T | undefined>, timeoutMs = 8000): Promise<T> {
715
+ const deadline = Date.now() + timeoutMs;
716
+ for (;;) {
717
+ const result = await probe();
718
+ if (result !== undefined) return result;
719
+ if (Date.now() > deadline) throw new Error("pollFor: timeout");
720
+ await new Promise((resolve) => setTimeout(resolve, 100));
721
+ }
722
+ }
723
+
724
+ test("Write → appendEvent → MSP wendet async an; Consumer-Cursor wandert", async () => {
725
+ let dispatchSystemWrite: import("../extra-routes-deps").ExtraRoutesSystemDeps["dispatchSystemWrite"];
726
+ const handle = await boot(undefined, {
727
+ eventDispatcher: { pollIntervalMs: 50 },
728
+ extraRoutes: (_app, deps) => {
729
+ dispatchSystemWrite = deps.dispatchSystemWrite;
730
+ },
731
+ });
732
+
733
+ // Default-Boot baut den lokalen Dispatcher und start() hat ihn gestartet.
734
+ expect(handle.entrypoint.eventDispatcher).toBeDefined();
735
+
736
+ const aggregateId = crypto.randomUUID();
737
+ const result = await dispatchSystemWrite!({
738
+ handlerQn: "prod-probe:write:probe-append",
739
+ payload: { aggregateId, note: "dispatched" },
740
+ tenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
741
+ });
742
+ expect(result.isSuccess).toBe(true);
743
+
744
+ const url = ADMIN_URL.replace(/\/[^/]+$/, `/${TEST_DB}`);
745
+ const { db, close } = createDbConnection(url);
746
+ try {
747
+ const row = await pollFor(async () => {
748
+ const rows = (await asRawClient(db).unsafe(
749
+ `SELECT note FROM prod_probe_pings WHERE aggregate_id = $1`,
750
+ [aggregateId],
751
+ )) as Array<{ note: string }>;
752
+ return rows[0];
753
+ });
754
+ expect(row.note).toBe("dispatched");
755
+
756
+ // Consumer-Registrierung + Cursor-Fortschritt — in Prod war diese
757
+ // Tabelle komplett leer, DER Beweis dass nie ein Dispatcher lief.
758
+ const consumers = (await asRawClient(db).unsafe(
759
+ `SELECT name, last_processed_event_id FROM kumiko_event_consumers
760
+ WHERE name = 'prod-probe:projection:probe-ping-projection'
761
+ OR name LIKE '%probe-ping-projection%'`,
762
+ )) as Array<{ name: string; last_processed_event_id: string | number }>;
763
+ expect(consumers.length).toBeGreaterThan(0);
764
+ expect(Number(consumers[0]?.last_processed_event_id)).toBeGreaterThan(0);
765
+ } finally {
766
+ await close();
767
+ }
768
+ });
769
+
770
+ test("eventDispatcher.disabled: kein lokaler Dispatcher gebaut", async () => {
771
+ const handle = await boot(undefined, {
772
+ eventDispatcher: { disabled: true },
773
+ });
774
+ expect(handle.entrypoint.eventDispatcher).toBeUndefined();
775
+ });
776
+ });
777
+
778
+ // Origin-guard config (framework #340) flows from runProdApp's auth options
779
+ // through to buildServer. Before the forwarding fix, RunProdAppAuthOptions had
780
+ // no `allowedOrigins`, so a cookieDomain app could not satisfy the fail-closed
781
+ // guard — it could only CrashLoop.
782
+ describe("runProdApp — auth allowedOrigins forwarding", () => {
783
+ const ADMIN = {
784
+ email: "origin-guard@example.eu",
785
+ password: "test-pw-strong-1234",
786
+ displayName: "Admin",
787
+ memberships: [],
788
+ };
789
+
790
+ test("cookieDomain without allowedOrigins fails closed — guard is wired through runProdApp", async () => {
791
+ await expect(
792
+ boot(undefined, {
793
+ auth: { admin: ADMIN, cookieDomain: "example.eu" },
794
+ allowPlaintextPii: "test: origin-guard focus, not crypto",
795
+ }),
796
+ ).rejects.toThrow(/allowedOrigins is empty/);
797
+ });
798
+
799
+ test("cookieDomain + allowedOrigins clears the guard — allowlist reaches buildServer", async () => {
800
+ // Without the forwarding fix this would ALSO throw /allowedOrigins is empty/.
801
+ // It may still fail later on the minimal harness (no auth tables migrated),
802
+ // but never on the origin guard — that is the forwarding proof.
803
+ let bootError: unknown;
804
+ try {
805
+ const handle = await boot(undefined, {
806
+ auth: {
807
+ admin: ADMIN,
808
+ cookieDomain: "example.eu",
809
+ allowedOrigins: ["https://app.example.eu"],
810
+ },
811
+ });
812
+ expect(handle).toBeDefined();
813
+ } catch (error) {
814
+ bootError = error;
815
+ }
816
+ if (bootError !== undefined) {
817
+ expect(String(bootError)).not.toMatch(/allowedOrigins is empty/);
818
+ }
819
+ });
820
+ });
821
+
822
+ describe("runProdApp job-lane wiring (runSingleInstance)", () => {
823
+ // Red-then-green for the export bug: on createApiEntrypoint (old default) the
824
+ // worker-lane cron was never registered. createAllInOneEntrypoint (new
825
+ // single-instance default) runs two runners, so it IS registered.
826
+ test("default single-instance schedules the WORKER-lane cron", async () => {
827
+ const prefix = `test-rsi-${Date.now().toString(36)}`;
828
+ const handle = await boot(undefined, {
829
+ features: [cronProbeFeature],
830
+ jobs: { queueNamePrefix: prefix },
831
+ });
832
+ expect(handle.entrypoint.mode).toBe("all-in-one");
833
+ const schedulers = await workerLaneSchedulers(prefix);
834
+ expect(schedulers.some((s) => (s.name ?? s.key ?? "").includes("worker-lane-cron"))).toBe(true);
835
+ });
836
+
837
+ test("runSingleInstance:false → api-only, worker lane left to a dedicated worker", async () => {
838
+ const prefix = `test-rsi-api-${Date.now().toString(36)}`;
839
+ const handle = await boot(undefined, {
840
+ features: [cronProbeFeature],
841
+ jobs: { queueNamePrefix: prefix },
842
+ runSingleInstance: false,
843
+ });
844
+ expect(handle.entrypoint.mode).toBe("api");
845
+ const schedulers = await workerLaneSchedulers(prefix);
846
+ expect(schedulers.some((s) => (s.name ?? s.key ?? "").includes("worker-lane-cron"))).toBe(
847
+ false,
848
+ );
849
+ });
850
+
851
+ test("kms option: adapter reaches handler ctx; without the option ctx.kms is absent", async () => {
852
+ const probe = async (handle: ProdAppHandle): Promise<boolean | undefined> => {
853
+ const res = await handle.entrypoint.app.fetch(
854
+ new Request("http://test/api/query", {
855
+ method: "POST",
856
+ headers: { "content-type": "application/json" },
857
+ body: JSON.stringify({ type: "prod-probe:query:kms-probe", payload: {} }),
858
+ }),
859
+ );
860
+ expect(res.status).toBe(200);
861
+ const body = (await res.json()) as { data?: { hasKms?: boolean } };
862
+ return body.data?.hasKms;
863
+ };
864
+
865
+ const withKms = await boot(undefined, {
866
+ kms: new InMemoryKmsAdapter(),
867
+ anonymousAccess: { defaultTenantId: TENANT_ID },
868
+ });
869
+ expect(await probe(withKms)).toBe(true);
870
+
871
+ const withoutKms = await boot(undefined, {
872
+ anonymousAccess: { defaultTenantId: TENANT_ID },
873
+ });
874
+ expect(await probe(withoutKms)).toBe(false);
875
+ });
876
+
877
+ test("unhealthy kms aborts boot before any connection is opened", async () => {
878
+ const unhealthyKms: KmsAdapter = {
879
+ capabilities: { mode: "local-key" },
880
+ createKey: async () => {},
881
+ getKey: async () => {
882
+ throw new Error("unreachable");
883
+ },
884
+ eraseKey: async () => {},
885
+ health: async () => ({ ok: false, latencyMs: 3 }),
886
+ };
887
+ await expect(boot(undefined, { kms: unhealthyKms })).rejects.toThrow(/KMS health check failed/);
888
+ });
889
+ });
890
+
891
+ describe("hard PII boot gate (#818 step 2)", () => {
892
+ const gatePiiFeature = defineFeature("gate-pii", (r) => {
893
+ r.entity(
894
+ "gate-person",
895
+ createEntity({
896
+ table: "read_gate_persons",
897
+ fields: { email: createTextField({ required: true, pii: true }) },
898
+ }),
899
+ );
900
+ });
901
+
902
+ test("PII entities without a kms abort the boot", async () => {
903
+ await expect(boot(undefined, { features: [gatePiiFeature] })).rejects.toThrow(
904
+ /BOOT ABORTED.*PLAINTEXT.*allowPlaintextPii/s,
905
+ );
906
+ });
907
+
908
+ test("allowPlaintextPii boots with a warning instead", async () => {
909
+ const handle = await boot(undefined, {
910
+ features: [gatePiiFeature],
911
+ allowPlaintextPii: "test: kms rollout pending",
912
+ });
913
+ expect(handle).toBeDefined();
914
+ });
915
+ });