@cosmicdrift/kumiko-server-runtime 0.329.0 → 0.330.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +15 -0
  2. package/dist/boot/apply-boot-seeds.d.ts +8 -0
  3. package/{src/boot/apply-boot-seeds.ts → dist/boot/apply-boot-seeds.js} +2 -10
  4. package/dist/boot/boot-crypto.d.ts +9 -0
  5. package/dist/boot/boot-crypto.js +39 -0
  6. package/dist/boot/job-run-logger.d.ts +19 -0
  7. package/dist/boot/job-run-logger.js +44 -0
  8. package/dist/boot/pii-event-backfill-on-boot.d.ts +13 -0
  9. package/dist/boot/pii-event-backfill-on-boot.js +25 -0
  10. package/dist/build-prod-bundle.d.ts +103 -0
  11. package/dist/build-prod-bundle.js +751 -0
  12. package/dist/bun-serve-options.d.ts +32 -0
  13. package/dist/bun-serve-options.js +49 -0
  14. package/dist/compose-features.d.ts +31 -0
  15. package/dist/compose-features.js +124 -0
  16. package/dist/extra-routes-deps.d.ts +30 -0
  17. package/dist/extra-routes-deps.js +4 -0
  18. package/dist/index.d.ts +6 -0
  19. package/dist/index.js +7 -0
  20. package/dist/pii-boot-gate.d.ts +13 -0
  21. package/dist/pii-boot-gate.js +46 -0
  22. package/dist/renderer-web-fonts.d.ts +9 -0
  23. package/{src/renderer-web-fonts.ts → dist/renderer-web-fonts.js} +14 -18
  24. package/dist/resolve-tailwind-cli.d.ts +14 -0
  25. package/dist/resolve-tailwind-cli.js +36 -0
  26. package/dist/run-prod-app-boot-context.d.ts +43 -0
  27. package/dist/run-prod-app-boot-context.js +198 -0
  28. package/dist/run-prod-app-static-files.d.ts +32 -0
  29. package/dist/run-prod-app-static-files.js +280 -0
  30. package/dist/run-prod-app.d.ts +471 -0
  31. package/dist/run-prod-app.js +741 -0
  32. package/dist/run-worker-app.d.ts +92 -0
  33. package/dist/run-worker-app.js +220 -0
  34. package/dist/security-headers.d.ts +20 -0
  35. package/dist/security-headers.js +49 -0
  36. package/dist/session-boot-gate.d.ts +11 -0
  37. package/dist/session-boot-gate.js +22 -0
  38. package/dist/session-wiring.d.ts +11 -0
  39. package/{src/session-wiring.ts → dist/session-wiring.js} +2 -6
  40. package/dist/try-hono-first.d.ts +24 -0
  41. package/{src/try-hono-first.ts → dist/try-hono-first.js} +8 -36
  42. package/package.json +102 -36
  43. package/src/changes.json +6 -0
  44. package/src/__tests__/boot-extra-context.test.ts +0 -163
  45. package/src/__tests__/boot-probe-fixture.ts +0 -64
  46. package/src/__tests__/build-prod-bundle.inprocess.test.ts +0 -233
  47. package/src/__tests__/build-prod-bundle.test.ts +0 -724
  48. package/src/__tests__/cache-headers.test.ts +0 -83
  49. package/src/__tests__/compose-features-mfa-wiring.integration.test.ts +0 -308
  50. package/src/__tests__/compose-features-wiring.integration.test.ts +0 -609
  51. package/src/__tests__/compose-features.test.ts +0 -206
  52. package/src/__tests__/config-seed-boot.integration.test.ts +0 -158
  53. package/src/__tests__/pii-boot-gate.test.ts +0 -112
  54. package/src/__tests__/renderer-web-css-relocation.integration.test.ts +0 -85
  55. package/src/__tests__/renderer-web-shell-sentinel.test.ts +0 -35
  56. package/src/__tests__/renderer-web-theme-plugin.integration.test.ts +0 -77
  57. package/src/__tests__/require-env.test.ts +0 -34
  58. package/src/__tests__/resolve-auth-mail.test.ts +0 -144
  59. package/src/__tests__/resolve-tailwind-cli.test.ts +0 -81
  60. package/src/__tests__/run-prod-app-dry-run.test.ts +0 -135
  61. package/src/__tests__/run-prod-app-env-source.test.ts +0 -118
  62. package/src/__tests__/run-prod-app-kms-slots.test.ts +0 -108
  63. package/src/__tests__/run-prod-app-spec.test.ts +0 -167
  64. package/src/__tests__/run-prod-app-static-files.test.ts +0 -298
  65. package/src/__tests__/run-prod-app.integration.test.ts +0 -1525
  66. package/src/__tests__/run-worker-app-boot.test.ts +0 -183
  67. package/src/__tests__/run-worker-app.integration.test.ts +0 -218
  68. package/src/__tests__/security-headers.test.ts +0 -88
  69. package/src/__tests__/session-boot-gate.test.ts +0 -70
  70. package/src/__tests__/session-wiring.test.ts +0 -16
  71. package/src/__tests__/try-hono-first.test.ts +0 -108
  72. package/src/__tests__/websocket-route.integration.test.ts +0 -695
  73. package/src/boot/__tests__/job-run-logger.integration.test.ts +0 -262
  74. package/src/boot/__tests__/job-run-logger.test.ts +0 -26
  75. package/src/boot/boot-crypto.ts +0 -67
  76. package/src/boot/job-run-logger.ts +0 -63
  77. package/src/boot/pii-event-backfill-on-boot.ts +0 -43
  78. package/src/build-prod-bundle.ts +0 -996
  79. package/src/bun-serve-options.ts +0 -110
  80. package/src/compose-features.ts +0 -197
  81. package/src/extra-routes-deps.ts +0 -38
  82. package/src/index.ts +0 -27
  83. package/src/pii-boot-gate.ts +0 -80
  84. package/src/resolve-tailwind-cli.ts +0 -45
  85. package/src/run-prod-app-boot-context.ts +0 -319
  86. package/src/run-prod-app-static-files.ts +0 -359
  87. package/src/run-prod-app.ts +0 -1420
  88. package/src/run-worker-app.ts +0 -366
  89. package/src/security-headers.ts +0 -67
  90. package/src/session-boot-gate.ts +0 -34
@@ -1,1525 +0,0 @@
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 { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
17
- import { createPersonalAccessTokensFeature } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
18
- import {
19
- createSessionsFeature,
20
- userSessionEntity,
21
- } from "@cosmicdrift/kumiko-bundled-features/sessions";
22
- import { userEntity } from "@cosmicdrift/kumiko-bundled-features/user";
23
- import {
24
- createRedisSseBroker,
25
- ExtraRouteRejection,
26
- isRedisSseBroker,
27
- NO_ROUTE_MATCH_HEADER_NAME,
28
- requestContext,
29
- type SseEvent,
30
- signatureRoute,
31
- } from "@cosmicdrift/kumiko-framework/api";
32
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
33
- import { InMemoryKmsAdapter, type KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
34
- import { createDbConnection } from "@cosmicdrift/kumiko-framework/db";
35
- import {
36
- createBooleanField,
37
- createEntity,
38
- createTextField,
39
- defineFeature,
40
- } from "@cosmicdrift/kumiko-framework/engine";
41
- import {
42
- createArchivedStreamsTable,
43
- createEventsTable,
44
- } from "@cosmicdrift/kumiko-framework/event-store";
45
- import {
46
- createNoopProvider,
47
- createPrometheusMeter,
48
- } from "@cosmicdrift/kumiko-framework/observability";
49
- import {
50
- createEventConsumerStateTable,
51
- createProjectionStateTable,
52
- } from "@cosmicdrift/kumiko-framework/pipeline";
53
- import { unsafeEnsureEntityTable } from "@cosmicdrift/kumiko-framework/stack";
54
- import { waitFor } from "@cosmicdrift/kumiko-framework/testing";
55
- import { generateId } from "@cosmicdrift/kumiko-framework/utils";
56
- import { Queue } from "bullmq";
57
- import postgres from "postgres";
58
- import * as z from "zod";
59
- import { type ProdAppHandle, runProdApp } from "../run-prod-app";
60
-
61
- // tmp-Verzeichnisse pro Test, in afterEach geräumt. Tests die staticDir
62
- // brauchen registrieren ihren Pfad hier.
63
- const tempDirs: string[] = [];
64
-
65
- async function createTempStaticDir(files: Record<string, string>): Promise<string> {
66
- const dir = await mkdtemp(join(tmpdir(), "kumiko-prod-static-"));
67
- tempDirs.push(dir);
68
- for (const [name, content] of Object.entries(files)) {
69
- const fullPath = join(dir, name);
70
- await mkdir(dirname(fullPath), { recursive: true });
71
- await writeFile(fullPath, content);
72
- }
73
- return dir;
74
- }
75
-
76
- const widgetEntity = createEntity({
77
- fields: {
78
- name: createTextField({ personal: false, reason: "test_fixture", required: true }),
79
- active: createBooleanField({ default: true }),
80
- },
81
- table: "prod_widgets",
82
- });
83
-
84
- const widgetFeature = defineFeature("prod-probe", (r) => {
85
- r.entity("widget", widgetEntity);
86
- // Anonymous query — covers the "anonymousAccess flows from runProdApp
87
- // through createApiEntrypoint to the auth-middleware" wiring that
88
- // earlier silently dropped the option in the entrypoint layer.
89
- r.queryHandler({
90
- name: "ping",
91
- schema: z.object({}),
92
- access: { roles: ["anonymous"] },
93
- rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
94
- handler: async () => ({ pong: true }),
95
- });
96
- // Echoes the resolved client IP — the only way to prove a real Bun.serve
97
- // socket address reaches requestContext.ip end-to-end (fw#3260 addendum:
98
- // every other test in this file drives fetchHandler directly, which never
99
- // exercises server.requestIP()).
100
- r.queryHandler({
101
- name: "ip-probe",
102
- schema: z.object({}),
103
- access: { roles: ["anonymous"] },
104
- rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
105
- handler: async () => ({ ip: requestContext.get()?.ip }),
106
- });
107
- r.queryHandler({
108
- name: "kms-probe",
109
- schema: z.object({}),
110
- access: { roles: ["anonymous"] },
111
- rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
112
- handler: async (_event, ctx) => ({ hasKms: ctx.kms !== undefined }),
113
- });
114
- // SystemAdmin-gated write — Ziel der entry:"signature" / `wire`-Tests:
115
- // Echo von user.tenantId + roles beweist, dass der Dispatch
116
- // durch den echten Dispatcher (Zod + Access-Check) läuft und der
117
- // auto-konstruierte SystemUser den Ziel-Tenant trägt.
118
- r.writeHandler({
119
- name: "probe-write",
120
- schema: z.object({ note: z.string() }),
121
- access: { roles: ["SystemAdmin"] },
122
- handler: async (event) => ({
123
- isSuccess: true as const,
124
- data: { tenantSeen: event.user.tenantId, roles: event.user.roles },
125
- }),
126
- });
127
- // Event + MSP-Paar für den lokalen Event-Dispatcher (2026-06-11):
128
- // runProdApp ist Single-Container — ohne lokalen Dispatcher wendet KEINE
129
- // multiStreamProjection jemals an (Prod hatte deshalb leere Projektionen
130
- // + leere kumiko_event_consumers). Der Write appended das Event; die MSP
131
- // schreibt async in prod_probe_pings — der Test pollt darauf.
132
- const pingedEvent = r.defineEvent("probe-pinged", z.object({ note: z.string() }), {
133
- piiFields: "none",
134
- });
135
- r.writeHandler({
136
- name: "probe-append",
137
- schema: z.object({ aggregateId: z.string(), note: z.string() }),
138
- access: { roles: ["SystemAdmin"] },
139
- handler: async (event, ctx) => {
140
- const payload = event.payload as { aggregateId: string; note: string }; // @cast-boundary engine-payload
141
- // unsafeAppendEvent: das Test-Feature augmentiert keine Event-Type-Map,
142
- // der strict-typed appendEvent narrowt hier auf never.
143
- await ctx.unsafeAppendEvent({
144
- aggregateId: payload.aggregateId,
145
- aggregateType: "probe",
146
- type: pingedEvent.name,
147
- payload: { note: payload.note },
148
- });
149
- return { isSuccess: true as const, data: { ok: true as const } };
150
- },
151
- });
152
- r.multiStreamProjection({
153
- name: "probe-ping-projection",
154
- apply: {
155
- [pingedEvent.name]: async (event, tx) => {
156
- const payload = event.payload as { note: string }; // @cast-boundary engine-payload
157
- await asRawClient(tx).unsafe(
158
- `INSERT INTO prod_probe_pings (aggregate_id, note) VALUES ($1, $2)`,
159
- [event.aggregateId, payload.note],
160
- );
161
- },
162
- },
163
- });
164
- });
165
-
166
- // Worker-lane cron — the lane the data-export job (run-export-jobs) lives on.
167
- // runProdApp must schedule it (single-instance runs both lanes); on the old
168
- // createApiEntrypoint path it was silently never registered → exports hung.
169
- const cronProbeFeature = defineFeature("cron-probe", (r) => {
170
- r.job("worker-lane-cron", { trigger: { cron: "0 0 1 1 *" }, runIn: "worker" }, async () => {});
171
- });
172
-
173
- async function workerLaneSchedulers(prefix: string): Promise<{ name?: string; key?: string }[]> {
174
- const url = new URL(process.env["REDIS_URL"] ?? "redis://localhost:16379");
175
- const queue = new Queue(`${prefix}-worker`, {
176
- connection: { host: url.hostname, port: Number(url.port) },
177
- });
178
- // A post-close 'error' here is otherwise unhandled and bun:test
179
- // attributes it to whichever test runs next (fw#1805).
180
- queue.on("error", () => {});
181
- // Read-only: do NOT obliterate — the running all-in-one worker consumes this
182
- // same queue, and deleting its keys mid-flight aborts the worker's blocking
183
- // Redis read with "Connection is closed". The unique prefix isolates the
184
- // leftover scheduler; the test Redis is ephemeral.
185
- try {
186
- return await queue.getJobSchedulers();
187
- } finally {
188
- await queue.close();
189
- }
190
- }
191
-
192
- const TENANT_ID = "00000000-0000-4000-8000-000000000001";
193
-
194
- // Per-suite DB so reboots can be tested without conflicting with other
195
- // test suites. Created in beforeAll, dropped at module end via the admin
196
- // connection.
197
- const TEST_DB = `kumiko_runprod_${Date.now().toString(36)}`;
198
- const ADMIN_URL = process.env["TEST_DATABASE_URL"] ?? "";
199
-
200
- let prodAppHandles: ProdAppHandle[] = [];
201
-
202
- beforeAll(async () => {
203
- if (!ADMIN_URL) throw new Error("TEST_DATABASE_URL must be set");
204
- const adminClient = postgres(ADMIN_URL.replace(/\/[^/]+$/, "/postgres"));
205
- try {
206
- await adminClient.unsafe(`CREATE DATABASE "${TEST_DB}"`);
207
- } finally {
208
- await adminClient.end();
209
- }
210
- });
211
-
212
- afterEach(async () => {
213
- for (const handle of prodAppHandles) {
214
- await handle.stop();
215
- }
216
- prodAppHandles = [];
217
- for (const dir of tempDirs) {
218
- await rm(dir, { recursive: true, force: true });
219
- }
220
- tempDirs.length = 0;
221
- });
222
-
223
- // Production-Apps booten gegen eine VORHER migrierte DB (CI-Step
224
- // `kumiko migrate apply`). In diesem Test gibt's keine drizzle-Migration-
225
- // Files, also imitieren wir den Migration-Step direkt: Framework-Infra-
226
- // Tables + die widget-Entity-Tabelle anlegen, dann runProdApp mit
227
- // `migrations: false` (= kein Schema-Drift-Gate) starten. So bleibt der
228
- // Test fokussiert auf Boot-Wiring (Entrypoint, Hono-Routes, Seeds), ohne
229
- // den Migrationspfad zu duplizieren.
230
- async function migrateTestDb(): Promise<void> {
231
- const url = ADMIN_URL.replace(/\/[^/]+$/, `/${TEST_DB}`);
232
- const { db, close } = createDbConnection(url);
233
- try {
234
- await createEventsTable(db);
235
- await createArchivedStreamsTable(db);
236
- await createProjectionStateTable(db);
237
- await createEventConsumerStateTable(db);
238
- await unsafeEnsureEntityTable(db, widgetEntity, "widget");
239
- await unsafeEnsureEntityTable(db, userEntity, "user");
240
- await unsafeEnsureEntityTable(db, userSessionEntity, "user-session");
241
- await asRawClient(db).unsafe(
242
- `CREATE TABLE IF NOT EXISTS prod_probe_pings (
243
- id BIGSERIAL PRIMARY KEY,
244
- aggregate_id UUID NOT NULL,
245
- note TEXT NOT NULL
246
- )`,
247
- );
248
- } finally {
249
- await close();
250
- }
251
- }
252
-
253
- let testDbMigrated = false;
254
-
255
- async function boot(
256
- seedFn?: (deps: { db: import("@cosmicdrift/kumiko-framework/db").DbConnection }) => Promise<void>,
257
- extra?: Partial<Parameters<typeof runProdApp>[0]>,
258
- ): Promise<ProdAppHandle> {
259
- // Override env per boot to point at the suite's DB.
260
- const originalDbUrl = process.env["DATABASE_URL"];
261
- process.env["DATABASE_URL"] = ADMIN_URL.replace(/\/[^/]+$/, `/${TEST_DB}`);
262
- process.env["REDIS_URL"] = process.env["REDIS_URL"] ?? "redis://localhost:16379";
263
- process.env["JWT_SECRET"] = "test-runprod-secret-32-chars-min!!";
264
- process.env["PORT"] = "0"; // Bun.serve picks an ephemeral port
265
-
266
- if (!testDbMigrated) {
267
- await migrateTestDb();
268
- testDbMigrated = true;
269
- }
270
-
271
- try {
272
- const handle = await runProdApp({
273
- features: [widgetFeature],
274
- autoListen: false,
275
- migrations: false,
276
- ...(seedFn && { seeds: [seedFn] }),
277
- ...(extra ?? {}),
278
- });
279
- prodAppHandles.push(handle);
280
- return handle;
281
- } finally {
282
- if (originalDbUrl !== undefined) process.env["DATABASE_URL"] = originalDbUrl;
283
- else delete process.env["DATABASE_URL"];
284
- }
285
- }
286
-
287
- describe("runProdApp", () => {
288
- test("first boot creates entity tables, /api/health responds", async () => {
289
- const handle = await boot();
290
-
291
- const res = await handle.entrypoint.app.fetch(new Request("http://test/health"));
292
- expect(res.status).toBe(200);
293
- });
294
-
295
- test("unrecognized KUMIKO_DRY_RUN_ENV value warns and falls through to a normal boot", async () => {
296
- const originalWarn = console.warn;
297
- const warnings: string[] = [];
298
- console.warn = (...args: unknown[]) => {
299
- warnings.push(args.map(String).join(" "));
300
- };
301
- const original = process.env["KUMIKO_DRY_RUN_ENV"];
302
- process.env["KUMIKO_DRY_RUN_ENV"] = "not-a-real-mode";
303
- try {
304
- const handle = await boot();
305
- const res = await handle.entrypoint.app.fetch(new Request("http://test/health"));
306
- expect(res.status).toBe(200);
307
- } finally {
308
- console.warn = originalWarn;
309
- if (original === undefined) delete process.env["KUMIKO_DRY_RUN_ENV"];
310
- else process.env["KUMIKO_DRY_RUN_ENV"] = original;
311
- }
312
- expect(
313
- warnings.some(
314
- (line) =>
315
- line.includes('KUMIKO_DRY_RUN_ENV="not-a-real-mode"') && line.includes("unrecognized"),
316
- ),
317
- ).toBe(true);
318
- });
319
-
320
- test("second boot against the same DB is idempotent — no crash, no duplicate tables", async () => {
321
- await boot();
322
- // First boot left tables in place. Restart on the same DB —
323
- // unsafeEnsureEntityTable should be a no-op for the existing rows.
324
- const second = await boot();
325
-
326
- const res = await second.entrypoint.app.fetch(new Request("http://test/health"));
327
- expect(res.status).toBe(200);
328
- });
329
-
330
- test("extraRoutes: entry:anonymous mounts custom HTTP-routes on the Hono-app", async () => {
331
- // Proves runProdApp.extraRoutes mounts declarative route entries that
332
- // take effect BEFORE the static fallback — the foundation for
333
- // /feed.xml, /sitemap.xml, /og-image etc.
334
- let extraInvoked = false;
335
- const handle = await boot(undefined, {
336
- extraRoutes: [
337
- {
338
- method: "GET",
339
- path: "/feed.xml",
340
- entry: "anonymous",
341
- handler: (c) => {
342
- extraInvoked = true;
343
- return c.body('<?xml version="1.0"?><probe ok="true" />', 200, {
344
- "content-type": "application/rss+xml",
345
- });
346
- },
347
- },
348
- ],
349
- });
350
-
351
- // handle.fetch durchläuft den static-fallback wrapper — dort liegt
352
- // die "Hono-First, dann Disk"-Logik. entrypoint.app.fetch würde den
353
- // wrapper umgehen und damit die regression nicht erkennen.
354
- const res = await handle.fetch(new Request("http://test/feed.xml"));
355
- expect(res.status).toBe(200);
356
- expect(res.headers.get("content-type")).toBe("application/rss+xml");
357
- expect(extraInvoked).toBe(true);
358
- const body = await res.text();
359
- expect(body).toContain('<probe ok="true" />');
360
- });
361
-
362
- test("handle.fetch threads the socket address into deps.clientIp, ignoring a spoofed X-Forwarded-For at the default trustedProxyHops (0)", async () => {
363
- const handle = await boot(undefined, {
364
- extraRoutes: [
365
- {
366
- method: "GET",
367
- path: "/client-ip-probe",
368
- entry: "anonymous",
369
- handler: (c, deps) => c.body(deps.clientIp, 200),
370
- },
371
- ],
372
- });
373
- const req = () =>
374
- new Request("http://test/client-ip-probe", { headers: { "x-forwarded-for": "1.2.3.4" } });
375
-
376
- const withSocket = await handle.fetch(req(), "198.51.100.7");
377
- expect(await withSocket.text()).toBe("198.51.100.7");
378
-
379
- const withoutSocket = await handle.fetch(req());
380
- expect(await withoutSocket.text()).toBe("unknown");
381
- });
382
-
383
- test("extraRoutes: entry:signature dispatchSystemWrite schreibt als SystemAdmin des Ziel-Tenants, registry verfügbar", async () => {
384
- // Wiring for provider webhook routes (billing-foundation
385
- // createSubscriptionWebhookRoute): the route authenticates via
386
- // provider signature (verify()) and then writes past the JWT path
387
- // through the command dispatcher. Proves: (a) registry is in the
388
- // deps, (b) dispatchSystemWrite goes through Zod + the handler's
389
- // access check, (c) the SystemUser carries the target tenant.
390
- let registryHasProbe = false;
391
- const handle = await boot(undefined, {
392
- extraRoutes: [
393
- signatureRoute({
394
- method: "POST",
395
- path: "/webhook-probe",
396
- entry: "signature",
397
- verify: async (req) => {
398
- if (req.headers["x-probe-signature"] !== "test-secret") {
399
- throw new ExtraRouteRejection(401, { error: "bad-signature" });
400
- }
401
- },
402
- handler: async (c, _verified, deps) => {
403
- registryHasProbe = deps.registry.features.has("prod-probe");
404
- const result = await deps.dispatchSystemWrite({
405
- handlerQn: "prod-probe:write:probe-write",
406
- payload: { note: "from-webhook" },
407
- tenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
408
- });
409
- return c.json(result);
410
- },
411
- }),
412
- ],
413
- });
414
-
415
- const res = await handle.fetch(
416
- new Request("http://test/webhook-probe", {
417
- method: "POST",
418
- headers: { "x-probe-signature": "test-secret" },
419
- }),
420
- );
421
- expect(res.status).toBe(200);
422
- expect(registryHasProbe).toBe(true);
423
- const body = (await res.json()) as {
424
- isSuccess: boolean;
425
- data?: { tenantSeen: string; roles: string[] };
426
- };
427
- expect(body.isSuccess).toBe(true);
428
- expect(body.data?.tenantSeen).toBe(TENANT_ID);
429
- expect(body.data?.roles).toContain("SystemAdmin");
430
- });
431
-
432
- test("extraRoutes: entry:signature with a wrong signature rejects with 401 before dispatchSystemWrite runs", async () => {
433
- let dispatched = false;
434
- const handle = await boot(undefined, {
435
- extraRoutes: [
436
- signatureRoute({
437
- method: "POST",
438
- path: "/webhook-probe",
439
- entry: "signature",
440
- verify: async (req) => {
441
- if (req.headers["x-probe-signature"] !== "test-secret") {
442
- throw new ExtraRouteRejection(401, { error: "bad-signature" });
443
- }
444
- },
445
- handler: async (c, _verified, deps) => {
446
- dispatched = true;
447
- const result = await deps.dispatchSystemWrite({
448
- handlerQn: "prod-probe:write:probe-write",
449
- payload: { note: "from-webhook" },
450
- tenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
451
- });
452
- return c.json(result);
453
- },
454
- }),
455
- ],
456
- });
457
-
458
- const res = await handle.fetch(
459
- new Request("http://test/webhook-probe", {
460
- method: "POST",
461
- headers: { "x-probe-signature": "wrong" },
462
- }),
463
- );
464
- expect(res.status).toBe(401);
465
- const body = (await res.json()) as { error: string };
466
- expect(body.error).toBe("bad-signature");
467
- expect(dispatched).toBe(false);
468
- });
469
-
470
- test("static-fallback: extraRoute beats Disk-File at colliding path (Hono-First)", async () => {
471
- // Regression-Test für den static-fallback-Bug von Phase 2 Step 1:
472
- // wenn ein extraRoute (z.B. /feed.xml) UND eine gleichnamige Disk-
473
- // Datei in staticDir existieren, gewinnt der extraRoute. Sonst
474
- // schluckt der SPA-Fallback unbekannte Pfade als index.html und
475
- // der App-Author wundert sich warum sein /feed.xml nichts macht.
476
- const tmpStaticDir = await createTempStaticDir({
477
- "feed.xml": "<this-is-the-disk-version />",
478
- "index.html": "<html>SPA shell</html>",
479
- });
480
-
481
- const handle = await boot(undefined, {
482
- staticDir: tmpStaticDir,
483
- extraRoutes: [
484
- {
485
- method: "GET",
486
- path: "/feed.xml",
487
- entry: "anonymous",
488
- handler: (c) =>
489
- c.body("<this-is-the-hono-version />", 200, {
490
- "content-type": "application/rss+xml",
491
- }),
492
- },
493
- ],
494
- });
495
-
496
- const res = await handle.fetch(new Request("http://test/feed.xml"));
497
- expect(res.status).toBe(200);
498
- expect(await res.text()).toContain("<this-is-the-hono-version />");
499
- });
500
-
501
- test("static-fallback: Disk-File served when no extraRoute matches", async () => {
502
- // Komplement-Test: ohne kollidierenden extraRoute liefert der
503
- // static-fallback die Disk-Datei. Beweist dass der Hono-First-Pfad
504
- // nicht versehentlich Static-Files schluckt.
505
- const tmpStaticDir = await createTempStaticDir({
506
- "robots.txt": "User-agent: *\nAllow: /",
507
- "index.html": "<html>SPA shell</html>",
508
- });
509
-
510
- const handle = await boot(undefined, { staticDir: tmpStaticDir });
511
-
512
- const res = await handle.fetch(new Request("http://test/robots.txt"));
513
- expect(res.status).toBe(200);
514
- expect(await res.text()).toContain("User-agent: *");
515
- expect(res.headers.get("etag")).toBeTruthy();
516
- });
517
-
518
- test("static-fallback: a matched extraRoute's own deliberate 404 stays 404, not masked as the SPA shell (#2435)", async () => {
519
- // kumiko-framework#2435: tryHonoFirst used to treat ANY 404 as "no
520
- // route matched" and fall through to index.html with status 200 —
521
- // masking a matched route's intentional 404 (e.g. default-deny reads
522
- // like file-derivatives' public-variant route). An index.html exists
523
- // here specifically to prove the SPA-fallback does NOT win.
524
- const tmpStaticDir = await createTempStaticDir({
525
- "index.html": "<html>SPA shell</html>",
526
- });
527
-
528
- const handle = await boot(undefined, {
529
- staticDir: tmpStaticDir,
530
- extraRoutes: [
531
- {
532
- method: "GET",
533
- path: "/probe/:id",
534
- entry: "anonymous",
535
- handler: (c) => {
536
- if (c.req.param("id") === "missing") return c.text("not found", 404);
537
- return c.text(`probe:${c.req.param("id")}`, 200);
538
- },
539
- },
540
- ],
541
- });
542
-
543
- const res = await handle.fetch(new Request("http://test/probe/missing"));
544
- expect(res.status).toBe(404);
545
- expect(await res.text()).toBe("not found");
546
- expect(res.headers.get("content-type") ?? "").not.toMatch(/text\/html/);
547
- expect(res.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false);
548
- });
549
-
550
- test("static-fallback: an unknown SPA route still gets the SPA shell alongside a route that 404s (#2435)", async () => {
551
- const tmpStaticDir = await createTempStaticDir({
552
- "index.html": "<html>SPA shell</html>",
553
- });
554
-
555
- const handle = await boot(undefined, {
556
- staticDir: tmpStaticDir,
557
- extraRoutes: [
558
- {
559
- method: "GET",
560
- path: "/probe/:id",
561
- entry: "anonymous",
562
- handler: (c) => c.text("not found", 404),
563
- },
564
- ],
565
- });
566
-
567
- const res = await handle.fetch(new Request("http://test/some/client-route"));
568
- expect(res.status).toBe(200);
569
- expect(await res.text()).toContain("SPA shell");
570
- expect(res.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false);
571
- });
572
-
573
- test("static-fallback: an unmatched /api/* path stays a plain 404, no marker leak (#2435)", async () => {
574
- // /api/* is a passthrough in buildStaticFallback (always Hono, never
575
- // the SPA shell) — bypasses tryHonoFirst entirely, so the strip at
576
- // that passthrough site is what's under test here. anonymousAccess is
577
- // wired so this anonymous GET clears the auth middleware and actually
578
- // reaches Hono's router (unauthenticated /api/* requests get a plain
579
- // 401 from the auth guard before routing is even attempted, which
580
- // would test the auth guard instead of the router-miss path).
581
- const tmpStaticDir = await createTempStaticDir({
582
- "index.html": "<html>SPA shell</html>",
583
- });
584
- const handle = await boot(undefined, {
585
- staticDir: tmpStaticDir,
586
- anonymousAccess: { defaultTenantId: TENANT_ID },
587
- });
588
-
589
- const res = await handle.fetch(new Request("http://test/api/totally-unknown-route"));
590
- expect(res.status).toBe(404);
591
- expect(await res.text()).not.toContain("SPA shell");
592
- expect(res.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false);
593
- });
594
-
595
- test("no staticDir (split-deploy/API-only boot): a router-miss never leaks the internal marker header to the client (#2435)", async () => {
596
- // Without staticDir, fetchHandler is app.fetch directly — it never goes
597
- // through buildStaticFallback/tryHonoFirst, so the strip has to happen
598
- // at this bypass site too (run-prod-app.ts's fetchHandler assembly).
599
- const handle = await boot(undefined, {
600
- extraRoutes: [
601
- {
602
- method: "GET",
603
- path: "/probe/:id",
604
- entry: "anonymous",
605
- handler: (c) => {
606
- if (c.req.param("id") === "missing") return c.text("not found", 404);
607
- return c.text(`probe:${c.req.param("id")}`, 200);
608
- },
609
- },
610
- ],
611
- });
612
-
613
- const routerMiss = await handle.fetch(new Request("http://test/totally/unknown/path"));
614
- expect(routerMiss.status).toBe(404);
615
- expect(routerMiss.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false);
616
-
617
- const deliberate404 = await handle.fetch(new Request("http://test/probe/missing"));
618
- expect(deliberate404.status).toBe(404);
619
- expect(await deliberate404.text()).toBe("not found");
620
- expect(deliberate404.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false);
621
- });
622
-
623
- test("hostDispatch: async fn receives deps.systemQuery, 'not-found' result → 404", async () => {
624
- const tmpStaticDir = await createTempStaticDir({
625
- "index.html": "<html>SPA shell</html>",
626
- });
627
- let systemQueryPonged = false;
628
- const handle = await boot(undefined, {
629
- staticDir: tmpStaticDir,
630
- hostDispatch: async (_req, deps) => {
631
- const result = await deps.systemQuery("prod-probe:query:ping", {}, TENANT_ID);
632
- systemQueryPonged = (result as { pong: boolean }).pong === true; // @cast-boundary engine-payload
633
- return { kind: "not-found" };
634
- },
635
- });
636
-
637
- const res = await handle.fetch(new Request("http://test/"));
638
- expect(res.status).toBe(404);
639
- expect(systemQueryPonged).toBe(true);
640
- });
641
-
642
- test("static-fallback: If-None-Match → 304 on disk file", async () => {
643
- const tmpStaticDir = await createTempStaticDir({
644
- "robots.txt": "User-agent: *\nAllow: /",
645
- "index.html": "<html>SPA shell</html>",
646
- });
647
-
648
- const handle = await boot(undefined, { staticDir: tmpStaticDir });
649
- const first = await handle.fetch(new Request("http://test/robots.txt"));
650
- const etag = first.headers.get("etag");
651
- expect(etag).toBeTruthy();
652
-
653
- const second = await handle.fetch(
654
- new Request("http://test/robots.txt", { headers: { "if-none-match": etag ?? "" } }),
655
- );
656
- expect(second.status).toBe(304);
657
- expect(await second.text()).toBe("");
658
- });
659
-
660
- test("static-fallback: If-None-Match → 304 on SPA index.html", async () => {
661
- const tmpStaticDir = await createTempStaticDir({
662
- "index.html": "<html>SPA shell</html>",
663
- });
664
-
665
- const handle = await boot(undefined, { staticDir: tmpStaticDir });
666
- const first = await handle.fetch(new Request("http://test/some/spa/route"));
667
- const etag = first.headers.get("etag");
668
- expect(etag).toBeTruthy();
669
-
670
- const second = await handle.fetch(
671
- new Request("http://test/some/spa/route", {
672
- headers: { "if-none-match": etag ?? "" },
673
- }),
674
- );
675
- expect(second.status).toBe(304);
676
- });
677
-
678
- test("static-fallback: unknown path → SPA-fallback to index.html", async () => {
679
- // Path ohne extraRoute, ohne Disk-File, mit existierendem
680
- // index.html → liefert die SPA-Shell. Standard-SPA-Routing-Pattern,
681
- // aber wir wollen sicher sein dass der Hono-First-Refactor das
682
- // nicht gebrochen hat.
683
- const tmpStaticDir = await createTempStaticDir({
684
- "index.html": "<html>SPA shell</html>",
685
- });
686
-
687
- const handle = await boot(undefined, { staticDir: tmpStaticDir });
688
-
689
- const res = await handle.fetch(new Request("http://test/some/spa/route"));
690
- expect(res.status).toBe(200);
691
- expect(await res.text()).toContain("SPA shell");
692
- });
693
-
694
- test("static-fallback: non-GET ohne Hono-Match → 404, nicht SPA-Shell (#259)", async () => {
695
- // Prod-Szenario: POST auf einen falsch konfigurierten Webhook-Pfad
696
- // (Route nicht gemountet). 200 index.html würde dem Provider
697
- // "delivered" signalisieren — Events gingen still verloren.
698
- const tmpStaticDir = await createTempStaticDir({
699
- "index.html": "<html>SPA shell</html>",
700
- "robots.txt": "User-agent: *\nAllow: /",
701
- });
702
-
703
- const handle = await boot(undefined, { staticDir: tmpStaticDir });
704
-
705
- const unmatched = await handle.fetch(
706
- new Request("http://test/webhooks/subscription/stripe", { method: "POST" }),
707
- );
708
- expect(unmatched.status).toBe(404);
709
-
710
- // Disk-Files werden ebenfalls nicht auf non-GET serviert.
711
- const diskFile = await handle.fetch(new Request("http://test/robots.txt", { method: "POST" }));
712
- expect(diskFile.status).toBe(404);
713
- });
714
-
715
- test("static-fallback: HEAD auf SPA-Route bleibt 200 (spiegelt GET)", async () => {
716
- const tmpStaticDir = await createTempStaticDir({
717
- "index.html": "<html>SPA shell</html>",
718
- });
719
-
720
- const handle = await boot(undefined, { staticDir: tmpStaticDir });
721
-
722
- const res = await handle.fetch(new Request("http://test/some/spa/route", { method: "HEAD" }));
723
- expect(res.status).toBe(200);
724
- });
725
-
726
- test("hostDispatch: per-host html-Datei + Schema-Gating", async () => {
727
- // Multi-app deployment: two HTML files for different hosts. Neither
728
- // ever carries __KUMIKO_SCHEMA__ (kumiko-framework#3314) —
729
- // createKumikoApp fetches the schema itself from GET /api/schema
730
- // after the auth gates let it through, regardless of host.
731
- const tmpStaticDir = await createTempStaticDir({
732
- "index.html": "<html><body>PUBLIC</body><script src=/client.js></script></html>",
733
- "admin.html": "<html><body>ADMIN</body><script src=/client.js></script></html>",
734
- });
735
-
736
- const handle = await boot(undefined, {
737
- staticDir: tmpStaticDir,
738
- hostDispatch: ({ host }) => {
739
- if (host.startsWith("admin.")) {
740
- return { kind: "html", file: "admin.html" };
741
- }
742
- return { kind: "html", file: "index.html" };
743
- },
744
- });
745
-
746
- const pubRes = await handle.fetch(
747
- new Request("http://demo.example.test/", { headers: { host: "demo.example.test" } }),
748
- );
749
- expect(pubRes.status).toBe(200);
750
- const pubBody = await pubRes.text();
751
- expect(pubBody).toContain("PUBLIC");
752
- expect(pubBody).not.toContain("__KUMIKO_SCHEMA__");
753
-
754
- const adminRes = await handle.fetch(
755
- new Request("http://admin.example.test/", { headers: { host: "admin.example.test" } }),
756
- );
757
- expect(adminRes.status).toBe(200);
758
- const adminBody = await adminRes.text();
759
- expect(adminBody).toContain("ADMIN");
760
- expect(adminBody).not.toContain("__KUMIKO_SCHEMA__");
761
- });
762
-
763
- test("staticDir without hostDispatch: HTML never carries the schema; GET /api/schema needs a signed-in user (kumiko-framework#3314)", async () => {
764
- // A real POST /api/auth/login round-trip needs the tenant/user bundled
765
- // features' own projection tables (e.g. read_tenants), which this
766
- // suite's boot() doesn't migrate (migrations: false, only the app's own
767
- // entity tables are auto-created — see unsafeEnsureEntityTable above).
768
- // Signing a token straight through the entrypoint's JwtHelper exercises
769
- // the exact same cookie transport auth-middleware reads, without
770
- // pulling in that migration surface just for this schema-fetch test.
771
- const tmpStaticDir = await createTempStaticDir({
772
- "index.html": "<html><body>SHELL</body><script src=/client.js></script></html>",
773
- });
774
-
775
- const handle = await boot(undefined, { staticDir: tmpStaticDir });
776
-
777
- const rootRes = await handle.fetch(new Request("http://test/"));
778
- expect(rootRes.status).toBe(200);
779
- expect(await rootRes.text()).not.toContain("__KUMIKO_SCHEMA__");
780
-
781
- const spaRes = await handle.fetch(new Request("http://test/some/spa/route"));
782
- expect(spaRes.status).toBe(200);
783
- expect(await spaRes.text()).not.toContain("__KUMIKO_SCHEMA__");
784
-
785
- const anonSchemaRes = await handle.fetch(new Request("http://test/api/schema"));
786
- expect(anonSchemaRes.status).toBe(401);
787
-
788
- const token = await handle.entrypoint.jwt.sign({
789
- id: "schema-fetch-user",
790
- tenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
791
- roles: ["User"],
792
- });
793
- const schemaRes = await handle.fetch(
794
- new Request("http://test/api/schema", { headers: { Cookie: `kumiko_auth=${token}` } }),
795
- );
796
- expect(schemaRes.status).toBe(200);
797
- const schema = (await schemaRes.json()) as { features: readonly unknown[] };
798
- expect(schema.features.length).toBeGreaterThan(0);
799
- });
800
-
801
- test("anonymousAccess wired → GET /api/schema still 401s for the synthesised anonymous user", async () => {
802
- const handle = await boot(undefined, {
803
- anonymousAccess: {
804
- defaultTenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
805
- },
806
- });
807
-
808
- const res = await handle.entrypoint.app.fetch(new Request("http://test/api/schema"));
809
- expect(res.status).toBe(401);
810
- });
811
-
812
- test("extraContext.searchAdapter absent → GET /api/schema marks every feature searchAdapterMissing (#2062)", async () => {
813
- const handle = await boot();
814
- const token = await handle.entrypoint.jwt.sign({
815
- id: "schema-probe-user",
816
- tenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
817
- roles: ["User"],
818
- });
819
-
820
- const res = await handle.entrypoint.app.fetch(
821
- new Request("http://test/api/schema", { headers: { Authorization: `Bearer ${token}` } }),
822
- );
823
- expect(res.status).toBe(200);
824
- const schema = (await res.json()) as {
825
- features: ReadonlyArray<{ searchAdapterMissing?: boolean }>;
826
- };
827
- // Guards against a vacuous pass — an empty features array would make
828
- // `.every(...)` below vacuously true too.
829
- expect(schema.features.length).toBeGreaterThan(0);
830
- expect(schema.features.every((f) => f.searchAdapterMissing === true)).toBe(true);
831
- });
832
-
833
- test("extraContext.searchAdapter wired → GET /api/schema omits searchAdapterMissing (#2062)", async () => {
834
- const handle = await boot(undefined, {
835
- // Presence-only probe — run-prod-app's cast-boundary read only checks
836
- // truthiness, so a minimal stand-in is enough without pulling in the
837
- // real SearchAdapter shape.
838
- extraContext: { searchAdapter: { configure: async () => {}, search: async () => [] } },
839
- });
840
- const token = await handle.entrypoint.jwt.sign({
841
- id: "schema-probe-user",
842
- tenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
843
- roles: ["User"],
844
- });
845
-
846
- const res = await handle.entrypoint.app.fetch(
847
- new Request("http://test/api/schema", { headers: { Authorization: `Bearer ${token}` } }),
848
- );
849
- expect(res.status).toBe(200);
850
- const schema = (await res.json()) as {
851
- features: ReadonlyArray<{ searchAdapterMissing?: boolean }>;
852
- };
853
- expect(schema.features.length).toBeGreaterThan(0);
854
- expect(schema.features.some((f) => f.searchAdapterMissing === true)).toBe(false);
855
- });
856
-
857
- test("hostDispatch: redirect-Modus", async () => {
858
- const tmpStaticDir = await createTempStaticDir({
859
- "index.html": "<html>fallback</html>",
860
- });
861
- const handle = await boot(undefined, {
862
- staticDir: tmpStaticDir,
863
- hostDispatch: ({ host }) =>
864
- host === "apex.example.test"
865
- ? { kind: "redirect", to: "https://target.example", status: 302 }
866
- : { kind: "html", file: "index.html", injectSchema: false },
867
- });
868
-
869
- const res = await handle.fetch(
870
- new Request("http://apex.example.test/", { headers: { host: "apex.example.test" } }),
871
- );
872
- expect(res.status).toBe(302);
873
- expect(res.headers.get("Location")).toBe("https://target.example");
874
- });
875
-
876
- test("hostDispatch: 404-Modus für unbekannte Hosts", async () => {
877
- const tmpStaticDir = await createTempStaticDir({
878
- "index.html": "<html>fallback</html>",
879
- });
880
- const handle = await boot(undefined, {
881
- staticDir: tmpStaticDir,
882
- hostDispatch: ({ host }) =>
883
- host === "known.example.test"
884
- ? { kind: "html", file: "index.html", injectSchema: false }
885
- : { kind: "not-found" },
886
- });
887
-
888
- const res = await handle.fetch(
889
- new Request("http://unknown.example.test/", { headers: { host: "unknown.example.test" } }),
890
- );
891
- expect(res.status).toBe(404);
892
- });
893
-
894
- test("hostDispatch: CSP-Header-Passthrough pro Host", async () => {
895
- const tmpStaticDir = await createTempStaticDir({
896
- "index.html": "<html>x</html>",
897
- });
898
- const csp = "default-src 'self'; script-src 'self'";
899
- const handle = await boot(undefined, {
900
- staticDir: tmpStaticDir,
901
- hostDispatch: () => ({ kind: "html", file: "index.html", injectSchema: false, csp }),
902
- });
903
-
904
- const res = await handle.fetch(new Request("http://x.example.test/"));
905
- expect(res.status).toBe(200);
906
- expect(res.headers.get("content-security-policy")).toBe(csp);
907
- });
908
-
909
- test("hostDispatch: assets bleiben host-unabhängig erreichbar", async () => {
910
- // /assets/* darf NICHT durch hostDispatch laufen — Bundles werden
911
- // vom client per absoluter URL nachgeladen, host-Sniffing wäre falsch.
912
- const tmpStaticDir = await createTempStaticDir({
913
- "index.html": "<html>x</html>",
914
- "assets/app-abc.js": "console.log('app');",
915
- });
916
- const handle = await boot(undefined, {
917
- staticDir: tmpStaticDir,
918
- hostDispatch: () => ({ kind: "not-found" }),
919
- });
920
-
921
- const res = await handle.fetch(new Request("http://x.example.test/assets/app-abc.js"));
922
- expect(res.status).toBe(200);
923
- expect(await res.text()).toContain("console.log('app')");
924
- });
925
-
926
- test("anonymousAccess flows from runProdApp through entrypoint into the auth-middleware", async () => {
927
- // Regression for the silent-drop bug: ApiEntrypointOptions had no
928
- // anonymousAccess field, so runProdApp's option went into createApi
929
- // Entrypoint's spread, vanished, and the auth-middleware never saw
930
- // it → 401 missing_token even on `roles: ["anonymous"]` handlers.
931
- const handle = await boot(undefined, {
932
- anonymousAccess: { defaultTenantId: TENANT_ID },
933
- });
934
-
935
- const res = await handle.entrypoint.app.fetch(
936
- new Request("http://test/api/query", {
937
- method: "POST",
938
- headers: { "content-type": "application/json" },
939
- body: JSON.stringify({
940
- type: "prod-probe:query:ping",
941
- payload: {},
942
- }),
943
- }),
944
- );
945
- expect(res.status).toBe(200);
946
- const body = (await res.json()) as { data?: { pong?: boolean } };
947
- expect(body.data?.pong).toBe(true);
948
- });
949
-
950
- test("anonymousAccess as factory: receives {db, redis, registry}, resolver closures over db", async () => {
951
- // Use case: tenantResolver looks up subdomain → tenantId in the DB
952
- // at request time. The factory is called once at boot with db
953
- // wired, the resolver inside captures it.
954
- const seenDeps: { db: boolean; redis: boolean; registry: boolean } = {
955
- db: false,
956
- redis: false,
957
- registry: false,
958
- };
959
-
960
- const handle = await boot(undefined, {
961
- anonymousAccess: ({ db, redis, registry }) => {
962
- seenDeps.db = db !== undefined;
963
- seenDeps.redis = redis !== undefined;
964
- seenDeps.registry = registry !== undefined;
965
- return { defaultTenantId: TENANT_ID };
966
- },
967
- });
968
-
969
- expect(seenDeps).toEqual({ db: true, redis: true, registry: true });
970
-
971
- const res = await handle.entrypoint.app.fetch(
972
- new Request("http://test/api/query", {
973
- method: "POST",
974
- headers: { "content-type": "application/json" },
975
- body: JSON.stringify({ type: "prod-probe:query:ping", payload: {} }),
976
- }),
977
- );
978
- expect(res.status).toBe(200);
979
- });
980
-
981
- test("extraContext as factory: factory called with {db, redis, registry} at boot", async () => {
982
- // Factory-form for extraContext closes over db like anonymousAccess.
983
- // In auth-mode the framework auto-sets configResolver; Factory-Result
984
- // wird drauf gemerged. Wichtig: Factory wird genau einmal aufgerufen
985
- // beim Boot, NACHDEM db/redis/registry konstruiert sind.
986
- let invocations = 0;
987
- let factoryDeps: { db: boolean; redis: boolean; registry: boolean } | null = null;
988
-
989
- const handle = await boot(undefined, {
990
- extraContext: ({ db, redis, registry }) => {
991
- invocations++;
992
- factoryDeps = {
993
- db: db !== undefined,
994
- redis: redis !== undefined,
995
- registry: registry !== undefined,
996
- };
997
- return { _appCustomKey: "from-factory" };
998
- },
999
- });
1000
-
1001
- expect(invocations).toBe(1);
1002
- expect(factoryDeps!).toEqual({ db: true, redis: true, registry: true });
1003
- // Smoke: handle is functional (boot completed without error).
1004
- expect(handle.entrypoint).toBeDefined();
1005
- });
1006
-
1007
- test("seed runs once on first boot, but the seed's own idempotence prevents duplication on reboot", async () => {
1008
- let seedInvocations = 0;
1009
- let inserted = false;
1010
-
1011
- const seed = async ({
1012
- db,
1013
- }: {
1014
- db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
1015
- }) => {
1016
- seedInvocations++;
1017
- // Seed-side idempotence: check before inserting. runProdApp doesn't
1018
- // gate seeds — the seed itself is responsible.
1019
- const existing = await asRawClient(db).unsafe(`SELECT 1 FROM prod_widgets LIMIT 1`);
1020
- if ((existing as Array<Record<string, unknown>>).length > 0) return;
1021
- await asRawClient(db).unsafe(`INSERT INTO prod_widgets (id, tenant_id, name) VALUES
1022
- (gen_random_uuid(), '00000000-0000-4000-8000-000000000001', 'seeded')`);
1023
- inserted = true;
1024
- };
1025
-
1026
- await boot(seed);
1027
- expect(seedInvocations).toBe(1);
1028
- expect(inserted).toBe(true);
1029
-
1030
- await boot(seed);
1031
- // Seed function was called both times (runProdApp doesn't track),
1032
- // but the seed's own check kept it from inserting again.
1033
- expect(seedInvocations).toBe(2);
1034
-
1035
- // Probe DB — exactly one row.
1036
- const second = prodAppHandles[1];
1037
- if (!second) throw new Error("expected second handle");
1038
- // Use the entrypoint's DB context to query (clean shutdown handles
1039
- // the connection lifecycle).
1040
- const ctx = second.entrypoint as unknown as { app: { fetch: typeof fetch } };
1041
- const res = await ctx.app.fetch(new Request("http://test/health"));
1042
- expect(res.status).toBe(200);
1043
- });
1044
-
1045
- test("Hard Boot-Gate: pending kumiko-Migration → SchemaDriftError, kein Boot", async () => {
1046
- // Synthetisches kumiko/migrations-Dir mit einer checked-in Migration die
1047
- // nie applied wurde (kein _kumiko_migrations-Eintrag). runProdApp soll mit
1048
- // SchemaDriftError abbrechen bevor irgendetwas anderes initialisiert wird.
1049
- const driftDir = await mkdtemp(join(tmpdir(), "kumiko-drift-boot-"));
1050
- tempDirs.push(driftDir);
1051
- await writeFile(
1052
- join(driftDir, "0001_pending.sql"),
1053
- `CREATE TABLE "never_created_table" ("id" uuid PRIMARY KEY);`,
1054
- );
1055
- await writeFile(
1056
- join(driftDir, ".snapshot.json"),
1057
- JSON.stringify({
1058
- version: 1,
1059
- tables: [{ tableName: "never_created_table", columns: [] }],
1060
- }),
1061
- );
1062
-
1063
- await expect(boot(undefined, { migrations: { dir: driftDir } })).rejects.toThrow(
1064
- /Schema drift detected/,
1065
- );
1066
- });
1067
- });
1068
-
1069
- describe("runProdApp — real Bun.serve socket resolves the client IP (fw#3260 addendum)", () => {
1070
- // Every other test in this file drives handle.fetch()/entrypoint.app.fetch()
1071
- // directly, which never goes through Bun.serve's real TCP socket — so none
1072
- // of them can catch a regression where the socket address gets lost before
1073
- // reaching requestContext.ip (see client-ip.ts: falls back to "unknown"
1074
- // rather than throwing, so a wiring bug here stays silent otherwise).
1075
- test("a real loopback connection resolves to the actual socket address, not 'unknown'", async () => {
1076
- const handle = await boot(undefined, {
1077
- autoListen: true,
1078
- anonymousAccess: { defaultTenantId: TENANT_ID },
1079
- });
1080
- const port = handle.server?.port;
1081
- if (port === undefined) throw new Error("expected handle.server to be listening");
1082
-
1083
- const res = await fetch(`http://127.0.0.1:${port}/api/query`, {
1084
- method: "POST",
1085
- headers: { "content-type": "application/json" },
1086
- body: JSON.stringify({ type: "prod-probe:query:ip-probe", payload: {} }),
1087
- });
1088
- expect(res.status).toBe(200);
1089
- const body = (await res.json()) as { data?: { ip?: string } };
1090
- // Exact loopback notation (bare "127.0.0.1" vs. the IPv4-mapped IPv6
1091
- // form "::ffff:127.0.0.1") depends on the platform's dual-stack
1092
- // listener behavior, not on the code under test — matching the
1093
- // family instead of a fixed literal keeps the assertion about what
1094
- // this test actually proves: a real socket address reached
1095
- // requestContext.ip instead of the "unknown" fallback.
1096
- expect(body.data?.ip).toMatch(/^(::ffff:)?127\.0\.0\.1$/);
1097
- });
1098
- });
1099
-
1100
- describe("runProdApp — rejected WebSocket upgrades carry the security headers", () => {
1101
- const wsProbeFeature = defineFeature("ws-probe", (r) => {
1102
- r.webSocketRoute({ path: "/api/ws/probe", connect: () => ({}) });
1103
- });
1104
-
1105
- test("a real upgrade request refused by the route (no session) still gets nosniff", async () => {
1106
- const handle = await boot(undefined, {
1107
- features: [widgetFeature, wsProbeFeature],
1108
- autoListen: true,
1109
- anonymousAccess: { defaultTenantId: TENANT_ID },
1110
- });
1111
- const port = handle.server?.port;
1112
- if (port === undefined) throw new Error("expected handle.server to be listening");
1113
-
1114
- const res = await fetch(`http://127.0.0.1:${port}/api/ws/probe`, {
1115
- headers: {
1116
- Connection: "Upgrade",
1117
- Upgrade: "websocket",
1118
- "Sec-WebSocket-Version": "13",
1119
- "Sec-WebSocket-Key": Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString(
1120
- "base64",
1121
- ),
1122
- },
1123
- });
1124
- expect(res.status).toBe(401);
1125
- expect(res.headers.get("x-content-type-options")).toBe("nosniff");
1126
- });
1127
- });
1128
-
1129
- describe("runProdApp: lokaler Event-Dispatcher (MSP-Anwendung im Single-Container)", () => {
1130
- // Regression für den 2026-06-11-Incident: runProdApp baute den
1131
- // Event-Dispatcher nie ({disabled:true} im API-Entrypoint) — jede
1132
- // multiStreamProjection blieb in Prod unangewendet, kumiko_event_consumers
1133
- // blieb leer. Der Test schreibt über den ECHTEN Boot-Pfad und pollt auf
1134
- // die async projizierte Row.
1135
- // Both polls normally settle in well under a second; this only sizes the
1136
- // harness limit to the two 8s poll budgets so a slow retry is not cut short
1137
- // by bun's 5s default before pollFor can give up on its own terms.
1138
- const BOOT_AND_POLL_TIMEOUT_MS = 20_000;
1139
-
1140
- async function pollFor<T>(probe: () => Promise<T | undefined>): Promise<T> {
1141
- let result: T | undefined;
1142
- await waitFor(
1143
- async () => {
1144
- result = await probe();
1145
- return result !== undefined;
1146
- },
1147
- { delays: Array(80).fill(100) },
1148
- );
1149
- if (result === undefined) throw new Error("pollFor: timeout");
1150
- return result;
1151
- }
1152
-
1153
- test(
1154
- "Write → appendEvent → MSP wendet async an; Consumer-Cursor wandert",
1155
- async () => {
1156
- let dispatchSystemWrite: import("../extra-routes-deps").SystemWireDeps["dispatchSystemWrite"];
1157
- const handle = await boot(undefined, {
1158
- eventDispatcher: { pollIntervalMs: 50 },
1159
- wire: (deps) => {
1160
- dispatchSystemWrite = deps.dispatchSystemWrite;
1161
- },
1162
- });
1163
-
1164
- // Default-Boot baut den lokalen Dispatcher und start() hat ihn gestartet.
1165
- expect(handle.entrypoint.eventDispatcher).toBeDefined();
1166
-
1167
- const aggregateId = crypto.randomUUID();
1168
- const result = await dispatchSystemWrite!({
1169
- handlerQn: "prod-probe:write:probe-append",
1170
- payload: { aggregateId, note: "dispatched" },
1171
- tenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
1172
- });
1173
- expect(result.isSuccess).toBe(true);
1174
-
1175
- const url = ADMIN_URL.replace(/\/[^/]+$/, `/${TEST_DB}`);
1176
- const { db, close } = createDbConnection(url);
1177
- try {
1178
- const row = await pollFor(async () => {
1179
- const rows = (await asRawClient(db).unsafe(
1180
- `SELECT note FROM prod_probe_pings WHERE aggregate_id = $1`,
1181
- [aggregateId],
1182
- )) as Array<{ note: string }>;
1183
- return rows[0];
1184
- });
1185
- expect(row.note).toBe("dispatched");
1186
-
1187
- // Consumer-Registrierung + Cursor-Fortschritt — in Prod war diese
1188
- // Tabelle komplett leer, DER Beweis dass nie ein Dispatcher lief.
1189
- // The cursor is committed after the projection row, so poll on the
1190
- // cursor value itself — a registered consumer still reads 0 for a moment.
1191
- const consumers = await pollFor(async () => {
1192
- const rows = (await asRawClient(db).unsafe(
1193
- `SELECT name, last_processed_event_id FROM kumiko_event_consumers
1194
- WHERE name = 'prod-probe:projection:probe-ping-projection'
1195
- OR name LIKE '%probe-ping-projection%'`,
1196
- )) as Array<{ name: string; last_processed_event_id: string | number }>;
1197
- return Number(rows[0]?.last_processed_event_id) > 0 ? rows : undefined;
1198
- });
1199
- expect(consumers.length).toBeGreaterThan(0);
1200
- expect(Number(consumers[0]?.last_processed_event_id)).toBeGreaterThan(0);
1201
- } finally {
1202
- await close();
1203
- }
1204
- },
1205
- BOOT_AND_POLL_TIMEOUT_MS,
1206
- );
1207
-
1208
- test("eventDispatcher.disabled: kein lokaler Dispatcher gebaut", async () => {
1209
- const handle = await boot(undefined, {
1210
- eventDispatcher: { disabled: true },
1211
- });
1212
- expect(handle.entrypoint.eventDispatcher).toBeUndefined();
1213
- });
1214
- });
1215
-
1216
- // Origin-guard config (framework #340) flows from runProdApp's auth options
1217
- // through to buildServer. Before the forwarding fix, RunProdAppAuthOptions had
1218
- // no `allowedOrigins`, so a cookieDomain app could not satisfy the fail-closed
1219
- // guard — it could only CrashLoop.
1220
- describe("runProdApp — auth allowedOrigins forwarding", () => {
1221
- const ADMIN = {
1222
- email: "origin-guard@example.eu",
1223
- password: "test-pw-strong-1234",
1224
- displayName: "Admin",
1225
- memberships: [],
1226
- };
1227
-
1228
- test("cookieDomain without allowedOrigins fails closed — guard is wired through runProdApp", async () => {
1229
- await expect(
1230
- boot(undefined, {
1231
- features: [
1232
- authFoundationFeature,
1233
- createPersonalAccessTokensFeature({ scopes: {} }),
1234
- createSessionsFeature(),
1235
- ],
1236
- auth: { admin: ADMIN, cookieDomain: "example.eu" },
1237
- allowPlaintextPii: "test: origin-guard focus, not crypto",
1238
- }),
1239
- ).rejects.toThrow(/allowedOrigins is empty/);
1240
- });
1241
-
1242
- test("cookieDomain + allowedOrigins clears the guard — allowlist reaches buildServer", async () => {
1243
- // Without the forwarding fix this would ALSO throw /allowedOrigins is empty/.
1244
- // auth tables are migrated for this file (migrateTestDb() above); this test
1245
- // isolates only that a later boot failure is never the origin-guard message.
1246
- let bootError: unknown;
1247
- try {
1248
- const handle = await boot(undefined, {
1249
- features: [
1250
- authFoundationFeature,
1251
- createPersonalAccessTokensFeature({ scopes: {} }),
1252
- createSessionsFeature(),
1253
- ],
1254
- auth: {
1255
- admin: ADMIN,
1256
- cookieDomain: "example.eu",
1257
- allowedOrigins: ["https://app.example.eu"],
1258
- },
1259
- });
1260
- expect(handle).toBeDefined();
1261
- } catch (error) {
1262
- bootError = error;
1263
- }
1264
- if (bootError !== undefined) {
1265
- expect(String(bootError)).not.toMatch(/allowedOrigins is empty/);
1266
- }
1267
- });
1268
- });
1269
-
1270
- describe("runProdApp — session boot gate (#1262/#1275)", () => {
1271
- const ADMIN = {
1272
- email: "session-gate@example.eu",
1273
- password: "test-pw-strong-1234",
1274
- displayName: "Admin",
1275
- memberships: [],
1276
- };
1277
-
1278
- test("auth mounted, sessions feature missing → aborts boot", async () => {
1279
- await expect(
1280
- boot(undefined, {
1281
- auth: {
1282
- admin: ADMIN,
1283
- cookieDomain: "example.eu",
1284
- allowedOrigins: ["https://app.example.eu"],
1285
- },
1286
- allowPlaintextPii: "test: session-gate focus, not crypto",
1287
- }),
1288
- ).rejects.toThrow(/BOOT ABORTED.*sessionStore/);
1289
- });
1290
-
1291
- test("auth mounted, sessions feature mounted → boots cleanly (the happy path the gate guards)", async () => {
1292
- const handle = await boot(undefined, {
1293
- // "user" is auto-mounted via includeBundled whenever auth.admin is set.
1294
- // sessions requires auth-foundation, which needs a tokenVerifier
1295
- // provider — PAT.
1296
- features: [
1297
- authFoundationFeature,
1298
- createPersonalAccessTokensFeature({ scopes: {} }),
1299
- createSessionsFeature(),
1300
- ],
1301
- auth: {
1302
- admin: ADMIN,
1303
- cookieDomain: "example.eu",
1304
- allowedOrigins: ["https://app.example.eu"],
1305
- },
1306
- allowPlaintextPii: "test: session-gate focus, not crypto",
1307
- });
1308
- expect(handle).toBeDefined();
1309
- });
1310
- });
1311
-
1312
- describe("runProdApp job-lane wiring (runSingleInstance)", () => {
1313
- // Red-then-green for the export bug: on createApiEntrypoint (old default) the
1314
- // worker-lane cron was never registered. createAllInOneEntrypoint (new
1315
- // single-instance default) runs two runners, so it IS registered.
1316
- test("default single-instance schedules the WORKER-lane cron", async () => {
1317
- const prefix = `test-rsi-${Date.now().toString(36)}`;
1318
- const handle = await boot(undefined, {
1319
- features: [cronProbeFeature],
1320
- jobs: { queueNamePrefix: prefix },
1321
- });
1322
- expect(handle.entrypoint.mode).toBe("all-in-one");
1323
- const schedulers = await workerLaneSchedulers(prefix);
1324
- expect(schedulers.some((s) => (s.name ?? s.key ?? "").includes("worker-lane-cron"))).toBe(true);
1325
- });
1326
-
1327
- test("runSingleInstance:false → api-only, worker lane left to a dedicated worker", async () => {
1328
- const prefix = `test-rsi-api-${Date.now().toString(36)}`;
1329
- const handle = await boot(undefined, {
1330
- features: [cronProbeFeature],
1331
- jobs: { queueNamePrefix: prefix },
1332
- runSingleInstance: false,
1333
- });
1334
- expect(handle.entrypoint.mode).toBe("api");
1335
- const schedulers = await workerLaneSchedulers(prefix);
1336
- expect(schedulers.some((s) => (s.name ?? s.key ?? "").includes("worker-lane-cron"))).toBe(
1337
- false,
1338
- );
1339
- });
1340
-
1341
- test("kms option: adapter reaches handler ctx; without the option ctx.kms is absent", async () => {
1342
- const probe = async (handle: ProdAppHandle): Promise<boolean | undefined> => {
1343
- const res = await handle.entrypoint.app.fetch(
1344
- new Request("http://test/api/query", {
1345
- method: "POST",
1346
- headers: { "content-type": "application/json" },
1347
- body: JSON.stringify({ type: "prod-probe:query:kms-probe", payload: {} }),
1348
- }),
1349
- );
1350
- expect(res.status).toBe(200);
1351
- const body = (await res.json()) as { data?: { hasKms?: boolean } };
1352
- return body.data?.hasKms;
1353
- };
1354
-
1355
- const withKms = await boot(undefined, {
1356
- kms: new InMemoryKmsAdapter(),
1357
- anonymousAccess: { defaultTenantId: TENANT_ID },
1358
- });
1359
- expect(await probe(withKms)).toBe(true);
1360
-
1361
- const withoutKms = await boot(undefined, {
1362
- anonymousAccess: { defaultTenantId: TENANT_ID },
1363
- });
1364
- expect(await probe(withoutKms)).toBe(false);
1365
- });
1366
-
1367
- test("unhealthy kms aborts boot before any connection is opened", async () => {
1368
- const unhealthyKms: KmsAdapter = {
1369
- capabilities: { mode: "local-key" },
1370
- createKey: async () => {},
1371
- getKey: async () => {
1372
- throw new Error("unreachable");
1373
- },
1374
- eraseKey: async () => {},
1375
- health: async () => ({ ok: false, latencyMs: 3 }),
1376
- };
1377
- await expect(boot(undefined, { kms: unhealthyKms })).rejects.toThrow(/KMS health check failed/);
1378
- });
1379
-
1380
- test("rateLimit flows from runProdApp through entrypoint into the L1/L2 middleware (#1101)", async () => {
1381
- const handle = await boot(undefined, {
1382
- rateLimit: { global: { limit: 1, windowSeconds: 60 } },
1383
- anonymousAccess: { defaultTenantId: TENANT_ID },
1384
- // handle.entrypoint.app.fetch has no real socket, and the client-ip
1385
- // resolver ignores X-Forwarded-For without a trusted hop — without
1386
- // this, every call below (and every other untrusted-XFF call this
1387
- // suite makes) resolves to the same shared "unknown" bucket, so the
1388
- // probeIp uniqueness below is silently defeated by whichever test
1389
- // happens to run first.
1390
- trustedProxyHops: 1,
1391
- });
1392
-
1393
- // Unique per test run: the L1 limiter's Redis bucket is keyed only on
1394
- // this IP (`l1:${ip}`, globalIpRateLimit in rate-limit/middleware.ts) —
1395
- // a fixed literal here would collide with whatever this same test's
1396
- // last run left in a non-ephemeral local Redis, well within the 60s
1397
- // window, and see 429 on the very first request instead of 200.
1398
- const probeIp = `203.0.113.${crypto.randomUUID()}`;
1399
- const fetchOnce = () =>
1400
- handle.entrypoint.app.fetch(
1401
- new Request("http://test/api/query", {
1402
- method: "POST",
1403
- headers: { "content-type": "application/json", "x-forwarded-for": probeIp },
1404
- body: JSON.stringify({ type: "prod-probe:query:ping", payload: {} }),
1405
- }),
1406
- );
1407
-
1408
- expect((await fetchOnce()).status).toBe(200);
1409
- expect((await fetchOnce()).status).toBe(429);
1410
- });
1411
- });
1412
-
1413
- describe("hard PII boot gate (#818 step 2)", () => {
1414
- const gatePiiFeature = defineFeature("gate-pii", (r) => {
1415
- r.entity(
1416
- "gate-person",
1417
- createEntity({
1418
- table: "read_gate_persons",
1419
- fields: { email: createTextField({ required: true, personal: "self", find: "none" }) },
1420
- }),
1421
- );
1422
- });
1423
-
1424
- test("PII entities without a kms abort the boot", async () => {
1425
- await expect(boot(undefined, { features: [gatePiiFeature] })).rejects.toThrow(
1426
- /BOOT ABORTED.*PLAINTEXT.*allowPlaintextPii/s,
1427
- );
1428
- });
1429
-
1430
- test("allowPlaintextPii boots with a warning instead", async () => {
1431
- const handle = await boot(undefined, {
1432
- features: [gatePiiFeature],
1433
- allowPlaintextPii: "test: kms rollout pending",
1434
- });
1435
- expect(handle).toBeDefined();
1436
- });
1437
- });
1438
-
1439
- // Regression for fw#1352: runProdApp wires the metrics route through two
1440
- // independently forwarded options (observability, metrics). Wrong nesting
1441
- // or a renamed key in ApiEntrypointOptions would silently no-op instead of
1442
- // erroring the boot, and /metrics would stay 404 or empty.
1443
- describe("runProdApp — /metrics endpoint (fw#1352)", () => {
1444
- test("observability (PrometheusMeter) + metrics.token wired → GET /metrics mit Bearer liefert OpenMetrics-Body", async () => {
1445
- const meter = createPrometheusMeter();
1446
- meter.registerMetric({ name: "kumiko_probe_total", type: "counter" });
1447
- meter.counter("kumiko_probe_total").inc(2);
1448
-
1449
- const handle = await boot(undefined, {
1450
- observability: { ...createNoopProvider(), meter },
1451
- metrics: { token: "t" },
1452
- });
1453
-
1454
- const res = await handle.entrypoint.app.fetch(
1455
- new Request("http://test/metrics", { headers: { Authorization: "Bearer t" } }),
1456
- );
1457
- expect(res.status).toBe(200);
1458
- expect(res.headers.get("Content-Type")).toMatch(/openmetrics-text/);
1459
- const body = await res.text();
1460
- expect(body).toContain("kumiko_probe_total 2");
1461
- expect(body).toMatch(/# EOF\n$/);
1462
- });
1463
-
1464
- test("ohne observability und ohne metrics-Option → /metrics ist keine Route (404)", async () => {
1465
- const handle = await boot();
1466
-
1467
- const res = await handle.entrypoint.app.fetch(new Request("http://test/metrics"));
1468
- expect(res.status).toBe(404);
1469
- });
1470
-
1471
- // fw#2630 shipped a Redis Pub/Sub fanout fix for SSE/access-invalidation
1472
- // across replicas, but runProdApp built its own in-memory sseBroker
1473
- // unconditionally and passed it in as ServerOptions.sseBroker — which
1474
- // always wins over buildServer's REDIS_URL default — so the fix never
1475
- // took effect in production. These tests exercise the level prod
1476
- // actually boots through (runProdApp), not buildServer/createRedisSseBroker
1477
- // directly, which is why the tests above were green while prod was broken.
1478
- describe("sseBroker wiring (fw#2630 Redis fanout)", () => {
1479
- test("wires a Redis-backed sseBroker so SSE events fan out across replicas", async () => {
1480
- const handle = await boot();
1481
- const redisUrl = process.env["REDIS_URL"] ?? "redis://localhost:16379";
1482
- const channel = `runprod-sse-${generateId()}`;
1483
- const received: SseEvent[] = [];
1484
- const otherReplica = createRedisSseBroker({ redisUrl });
1485
- try {
1486
- otherReplica.addClient(
1487
- channel,
1488
- (event) => received.push(event),
1489
- () => {},
1490
- );
1491
- await waitFor(() => {
1492
- handle.entrypoint.sseBroker.pushToChannel(channel, {
1493
- type: "unit.updated",
1494
- data: { id: "1" },
1495
- });
1496
- return received.length >= 1;
1497
- });
1498
- expect(received[0]).toEqual({ type: "unit.updated", data: { id: "1" } });
1499
- } finally {
1500
- await otherReplica.close();
1501
- }
1502
- });
1503
-
1504
- test("closes the Redis-backed sseBroker on shutdown (no leaked connection)", async () => {
1505
- const handle = await boot();
1506
- const broker = handle.entrypoint.sseBroker;
1507
- if (!isRedisSseBroker(broker)) {
1508
- throw new Error("expected a Redis-backed sseBroker under REDIS_URL");
1509
- }
1510
- let closeCalls = 0;
1511
- const originalClose = broker.close.bind(broker);
1512
- broker.close = async () => {
1513
- closeCalls += 1;
1514
- await originalClose();
1515
- };
1516
-
1517
- await handle.stop();
1518
- // avoid a second stop()/close() from the module-level afterEach
1519
- const idx = prodAppHandles.indexOf(handle);
1520
- if (idx >= 0) prodAppHandles.splice(idx, 1);
1521
-
1522
- expect(closeCalls).toBe(1);
1523
- });
1524
- });
1525
- });