@cosmicdrift/kumiko-server-runtime 0.296.0 → 0.299.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-server-runtime",
3
- "version": "0.296.0",
3
+ "version": "0.299.0",
4
4
  "description": "Production server-boot runtime for Kumiko apps: connections, schema-drift-gate, seeds, lifecycle, graceful shutdown. Symmetric to kumiko-dev-server's runDevApp, without dev/scaffold/codegen tooling.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -80,9 +80,9 @@
80
80
  }
81
81
  },
82
82
  "dependencies": {
83
- "@cosmicdrift/kumiko-bundled-features": "0.296.0",
84
- "@cosmicdrift/kumiko-framework": "0.296.0",
85
- "@cosmicdrift/kumiko-headless": "0.296.0",
83
+ "@cosmicdrift/kumiko-bundled-features": "0.299.0",
84
+ "@cosmicdrift/kumiko-framework": "0.299.0",
85
+ "@cosmicdrift/kumiko-headless": "0.299.0",
86
86
  "temporal-polyfill": "^0.3.2"
87
87
  },
88
88
  "publishConfig": {
@@ -22,9 +22,11 @@ import {
22
22
  import { userEntity } from "@cosmicdrift/kumiko-bundled-features/user";
23
23
  import {
24
24
  createRedisSseBroker,
25
+ ExtraRouteRejection,
25
26
  isRedisSseBroker,
26
27
  NO_ROUTE_MATCH_HEADER_NAME,
27
28
  type SseEvent,
29
+ signatureRoute,
28
30
  } from "@cosmicdrift/kumiko-framework/api";
29
31
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
30
32
  import { InMemoryKmsAdapter, type KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
@@ -97,8 +99,8 @@ const widgetFeature = defineFeature("prod-probe", (r) => {
97
99
  rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
98
100
  handler: async (_event, ctx) => ({ hasKms: ctx.kms !== undefined }),
99
101
  });
100
- // SystemAdmin-gated write — Ziel des extraRoutes.dispatchSystemWrite-
101
- // Tests: Echo von user.tenantId + roles beweist, dass der Dispatch
102
+ // SystemAdmin-gated write — Ziel der entry:"signature" / `wire`-Tests:
103
+ // Echo von user.tenantId + roles beweist, dass der Dispatch
102
104
  // durch den echten Dispatcher (Zod + Access-Check) läuft und der
103
105
  // auto-konstruierte SystemUser den Ziel-Tenant trägt.
104
106
  r.writeHandler({
@@ -313,64 +315,78 @@ describe("runProdApp", () => {
313
315
  expect(res.status).toBe(200);
314
316
  });
315
317
 
316
- test("extraRoutes-callback mounts custom HTTP-routes on the Hono-app", async () => {
317
- // Beweist dass die runProdApp.extraRoutes-Option den Hono-app
318
- // bekommt und Routes daran VOR dem static-fallback greifen — das
319
- // ist das Fundament für /feed.xml, /sitemap.xml, /og-image etc.
318
+ test("extraRoutes: entry:anonymous mounts custom HTTP-routes on the Hono-app", async () => {
319
+ // Proves runProdApp.extraRoutes mounts declarative route entries that
320
+ // take effect BEFORE the static fallback — the foundation for
321
+ // /feed.xml, /sitemap.xml, /og-image etc.
320
322
  let extraInvoked = false;
321
323
  const handle = await boot(undefined, {
322
- extraRoutes: (app, deps) => {
323
- extraInvoked = true;
324
- // deps.db + deps.redis sind die runProdApp-Connections — die
325
- // Route kann gegen die Domain queryen, hier reicht ein simple
326
- // Echo zum Beweis dass wir ans App-Object kommen.
327
- app.get("/feed.xml", (c) => {
328
- const dbAvailable = deps.db !== undefined;
329
- return c.body(`<?xml version="1.0"?><probe ok="${dbAvailable}" />`, 200, {
330
- "content-type": "application/rss+xml",
331
- });
332
- });
333
- },
324
+ extraRoutes: [
325
+ {
326
+ method: "GET",
327
+ path: "/feed.xml",
328
+ entry: "anonymous",
329
+ handler: (c) => {
330
+ extraInvoked = true;
331
+ return c.body('<?xml version="1.0"?><probe ok="true" />', 200, {
332
+ "content-type": "application/rss+xml",
333
+ });
334
+ },
335
+ },
336
+ ],
334
337
  });
335
338
 
336
- expect(extraInvoked).toBe(true);
337
-
338
339
  // handle.fetch durchläuft den static-fallback wrapper — dort liegt
339
340
  // die "Hono-First, dann Disk"-Logik. entrypoint.app.fetch würde den
340
341
  // wrapper umgehen und damit die regression nicht erkennen.
341
342
  const res = await handle.fetch(new Request("http://test/feed.xml"));
342
343
  expect(res.status).toBe(200);
343
344
  expect(res.headers.get("content-type")).toBe("application/rss+xml");
345
+ expect(extraInvoked).toBe(true);
344
346
  const body = await res.text();
345
347
  expect(body).toContain('<probe ok="true" />');
346
348
  });
347
349
 
348
- test("extraRoutes-deps: dispatchSystemWrite schreibt als SystemAdmin des Ziel-Tenants, registry verfügbar", async () => {
349
- // Das ist das Wiring für Provider-Webhook-Routes (billing-foundation
350
- // createSubscriptionWebhookHandler): die Route authentifiziert via
351
- // Provider-Signatur und schreibt dann am JWT-Pfad vorbei durch den
352
- // Command-Dispatcher. Beweist: (a) registry liegt in den deps,
353
- // (b) dispatchSystemWrite geht durch Zod + Access-Check des Handlers,
354
- // (c) der SystemUser trägt den Ziel-Tenant (Event-Store-Konsistenz).
350
+ test("extraRoutes: entry:signature dispatchSystemWrite schreibt als SystemAdmin des Ziel-Tenants, registry verfügbar", async () => {
351
+ // Wiring for provider webhook routes (billing-foundation
352
+ // createSubscriptionWebhookRoute): the route authenticates via
353
+ // provider signature (verify()) and then writes past the JWT path
354
+ // through the command dispatcher. Proves: (a) registry is in the
355
+ // deps, (b) dispatchSystemWrite goes through Zod + the handler's
356
+ // access check, (c) the SystemUser carries the target tenant.
355
357
  let registryHasProbe = false;
356
358
  const handle = await boot(undefined, {
357
- extraRoutes: (app, deps) => {
358
- registryHasProbe = deps.registry.features.has("prod-probe");
359
- app.post("/webhook-probe", async (c) => {
360
- const result = await deps.dispatchSystemWrite({
361
- handlerQn: "prod-probe:write:probe-write",
362
- payload: { note: "from-webhook" },
363
- tenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
364
- });
365
- return c.json(result);
366
- });
367
- },
359
+ extraRoutes: [
360
+ signatureRoute({
361
+ method: "POST",
362
+ path: "/webhook-probe",
363
+ entry: "signature",
364
+ verify: async (req) => {
365
+ if (req.headers["x-probe-signature"] !== "test-secret") {
366
+ throw new ExtraRouteRejection(401, { error: "bad-signature" });
367
+ }
368
+ },
369
+ handler: async (c, _verified, deps) => {
370
+ registryHasProbe = deps.registry.features.has("prod-probe");
371
+ const result = await deps.dispatchSystemWrite({
372
+ handlerQn: "prod-probe:write:probe-write",
373
+ payload: { note: "from-webhook" },
374
+ tenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
375
+ });
376
+ return c.json(result);
377
+ },
378
+ }),
379
+ ],
368
380
  });
369
381
 
370
- expect(registryHasProbe).toBe(true);
371
-
372
- const res = await handle.fetch(new Request("http://test/webhook-probe", { method: "POST" }));
382
+ const res = await handle.fetch(
383
+ new Request("http://test/webhook-probe", {
384
+ method: "POST",
385
+ headers: { "x-probe-signature": "test-secret" },
386
+ }),
387
+ );
373
388
  expect(res.status).toBe(200);
389
+ expect(registryHasProbe).toBe(true);
374
390
  const body = (await res.json()) as {
375
391
  isSuccess: boolean;
376
392
  data?: { tenantSeen: string; roles: string[] };
@@ -380,6 +396,44 @@ describe("runProdApp", () => {
380
396
  expect(body.data?.roles).toContain("SystemAdmin");
381
397
  });
382
398
 
399
+ test("extraRoutes: entry:signature with a wrong signature rejects with 401 before dispatchSystemWrite runs", async () => {
400
+ let dispatched = false;
401
+ const handle = await boot(undefined, {
402
+ extraRoutes: [
403
+ signatureRoute({
404
+ method: "POST",
405
+ path: "/webhook-probe",
406
+ entry: "signature",
407
+ verify: async (req) => {
408
+ if (req.headers["x-probe-signature"] !== "test-secret") {
409
+ throw new ExtraRouteRejection(401, { error: "bad-signature" });
410
+ }
411
+ },
412
+ handler: async (c, _verified, deps) => {
413
+ dispatched = true;
414
+ const result = await deps.dispatchSystemWrite({
415
+ handlerQn: "prod-probe:write:probe-write",
416
+ payload: { note: "from-webhook" },
417
+ tenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
418
+ });
419
+ return c.json(result);
420
+ },
421
+ }),
422
+ ],
423
+ });
424
+
425
+ const res = await handle.fetch(
426
+ new Request("http://test/webhook-probe", {
427
+ method: "POST",
428
+ headers: { "x-probe-signature": "wrong" },
429
+ }),
430
+ );
431
+ expect(res.status).toBe(401);
432
+ const body = (await res.json()) as { error: string };
433
+ expect(body.error).toBe("bad-signature");
434
+ expect(dispatched).toBe(false);
435
+ });
436
+
383
437
  test("static-fallback: extraRoute beats Disk-File at colliding path (Hono-First)", async () => {
384
438
  // Regression-Test für den static-fallback-Bug von Phase 2 Step 1:
385
439
  // wenn ein extraRoute (z.B. /feed.xml) UND eine gleichnamige Disk-
@@ -393,13 +447,17 @@ describe("runProdApp", () => {
393
447
 
394
448
  const handle = await boot(undefined, {
395
449
  staticDir: tmpStaticDir,
396
- extraRoutes: (app) => {
397
- app.get("/feed.xml", (c) =>
398
- c.body("<this-is-the-hono-version />", 200, {
399
- "content-type": "application/rss+xml",
400
- }),
401
- );
402
- },
450
+ extraRoutes: [
451
+ {
452
+ method: "GET",
453
+ path: "/feed.xml",
454
+ entry: "anonymous",
455
+ handler: (c) =>
456
+ c.body("<this-is-the-hono-version />", 200, {
457
+ "content-type": "application/rss+xml",
458
+ }),
459
+ },
460
+ ],
403
461
  });
404
462
 
405
463
  const res = await handle.fetch(new Request("http://test/feed.xml"));
@@ -436,12 +494,17 @@ describe("runProdApp", () => {
436
494
 
437
495
  const handle = await boot(undefined, {
438
496
  staticDir: tmpStaticDir,
439
- extraRoutes: (app) => {
440
- app.get("/probe/:id", (c) => {
441
- if (c.req.param("id") === "missing") return c.text("not found", 404);
442
- return c.text(`probe:${c.req.param("id")}`, 200);
443
- });
444
- },
497
+ extraRoutes: [
498
+ {
499
+ method: "GET",
500
+ path: "/probe/:id",
501
+ entry: "anonymous",
502
+ handler: (c) => {
503
+ if (c.req.param("id") === "missing") return c.text("not found", 404);
504
+ return c.text(`probe:${c.req.param("id")}`, 200);
505
+ },
506
+ },
507
+ ],
445
508
  });
446
509
 
447
510
  const res = await handle.fetch(new Request("http://test/probe/missing"));
@@ -458,9 +521,14 @@ describe("runProdApp", () => {
458
521
 
459
522
  const handle = await boot(undefined, {
460
523
  staticDir: tmpStaticDir,
461
- extraRoutes: (app) => {
462
- app.get("/probe/:id", (c) => c.text("not found", 404));
463
- },
524
+ extraRoutes: [
525
+ {
526
+ method: "GET",
527
+ path: "/probe/:id",
528
+ entry: "anonymous",
529
+ handler: (c) => c.text("not found", 404),
530
+ },
531
+ ],
464
532
  });
465
533
 
466
534
  const res = await handle.fetch(new Request("http://test/some/client-route"));
@@ -496,12 +564,17 @@ describe("runProdApp", () => {
496
564
  // through buildStaticFallback/tryHonoFirst, so the strip has to happen
497
565
  // at this bypass site too (run-prod-app.ts's fetchHandler assembly).
498
566
  const handle = await boot(undefined, {
499
- extraRoutes: (app) => {
500
- app.get("/probe/:id", (c) => {
501
- if (c.req.param("id") === "missing") return c.text("not found", 404);
502
- return c.text(`probe:${c.req.param("id")}`, 200);
503
- });
504
- },
567
+ extraRoutes: [
568
+ {
569
+ method: "GET",
570
+ path: "/probe/:id",
571
+ entry: "anonymous",
572
+ handler: (c) => {
573
+ if (c.req.param("id") === "missing") return c.text("not found", 404);
574
+ return c.text(`probe:${c.req.param("id")}`, 200);
575
+ },
576
+ },
577
+ ],
505
578
  });
506
579
 
507
580
  const routerMiss = await handle.fetch(new Request("http://test/totally/unknown/path"));
@@ -514,6 +587,25 @@ describe("runProdApp", () => {
514
587
  expect(deliberate404.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false);
515
588
  });
516
589
 
590
+ test("hostDispatch: async fn receives deps.systemQuery, 'not-found' result → 404", async () => {
591
+ const tmpStaticDir = await createTempStaticDir({
592
+ "index.html": "<html>SPA shell</html>",
593
+ });
594
+ let systemQueryPonged = false;
595
+ const handle = await boot(undefined, {
596
+ staticDir: tmpStaticDir,
597
+ hostDispatch: async (_req, deps) => {
598
+ const result = await deps.systemQuery("prod-probe:query:ping", {}, TENANT_ID);
599
+ systemQueryPonged = (result as { pong: boolean }).pong === true; // @cast-boundary engine-payload
600
+ return { kind: "not-found" };
601
+ },
602
+ });
603
+
604
+ const res = await handle.fetch(new Request("http://test/"));
605
+ expect(res.status).toBe(404);
606
+ expect(systemQueryPonged).toBe(true);
607
+ });
608
+
517
609
  test("static-fallback: If-None-Match → 304 on disk file", async () => {
518
610
  const tmpStaticDir = await createTempStaticDir({
519
611
  "robots.txt": "User-agent: *\nAllow: /",
@@ -915,10 +1007,10 @@ describe("runProdApp: lokaler Event-Dispatcher (MSP-Anwendung im Single-Containe
915
1007
  test(
916
1008
  "Write → appendEvent → MSP wendet async an; Consumer-Cursor wandert",
917
1009
  async () => {
918
- let dispatchSystemWrite: import("../extra-routes-deps").ExtraRoutesSystemDeps["dispatchSystemWrite"];
1010
+ let dispatchSystemWrite: import("../extra-routes-deps").SystemWireDeps["dispatchSystemWrite"];
919
1011
  const handle = await boot(undefined, {
920
1012
  eventDispatcher: { pollIntervalMs: 50 },
921
- extraRoutes: (_app, deps) => {
1013
+ wire: (deps) => {
922
1014
  dispatchSystemWrite = deps.dispatchSystemWrite;
923
1015
  },
924
1016
  });
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.298.0",
4
+ "type": "breaking",
5
+ "title": "ExtraRoutesSystemDeps renamed to SystemWireDeps; hostDispatch gains systemQuery",
6
+ "migration": "ExtraRoutesSystemDeps is renamed to SystemWireDeps and now backs the new wire hook instead of extraRoutes; WorkerWireDeps is derived from it. A consumer importing ExtraRoutesSystemDeps from server-runtime must switch to SystemWireDeps (same shape: db, redis, registry, dispatchSystemWrite). HostDispatchFn passed to runProdApp now takes a second argument { systemQuery } - a dispatch function reading db directly for routing decisions must switch to systemQuery."
7
+ },
2
8
  {
3
9
  "version": "0.291.0",
4
10
  "type": "fix",
@@ -1,47 +1,38 @@
1
- import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
2
1
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
- import {
4
- createSystemUser,
5
- type Registry,
6
- type SessionUser,
7
- type TenantId,
8
- type WriteResult,
9
- } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { Registry, WriteResult } from "@cosmicdrift/kumiko-framework/engine";
10
3
  import type Redis from "ioredis";
11
4
 
12
- /** Deps für `extraRoutes` — geteilt zwischen runProdApp (prod) und
13
- * createKumikoServer (dev), damit die beiden Pfade nicht driften.
14
- * Naming: `deps` statt `ctx` weil im Framework `ctx` der HandlerContext
15
- * mit user/tenant/registry ist — hier ist der Scope absichtlich kleiner
16
- * (Routes laufen außerhalb der Auth/Tenant-Pipeline). */
17
- export type ExtraRoutesSystemDeps = {
5
+ export type { SystemDispatchArgs } from "@cosmicdrift/kumiko-framework/api";
6
+ // Re-export the SystemAdmin write/query builders — the single source of
7
+ // truth lives in framework/api next to buildServer's own `extraRoutes`
8
+ // mount so the two never drift on how the SystemAdmin identity is built.
9
+ export {
10
+ makeDispatchSystemQuery,
11
+ makeDispatchSystemWrite,
12
+ } from "@cosmicdrift/kumiko-framework/api";
13
+
14
+ /** Deps for the `wire` hook (runProdApp/createKumikoServer, after
15
+ * buildServer has mounted `extraRoutes` — see kumiko-framework#3050) and
16
+ * for `runWorkerApp.wireComponents`. Naming: `deps` not `ctx` because
17
+ * `ctx` is the framework's HandlerContext (user/tenant/registry) — this
18
+ * scope is deliberately smaller and has NO `app` (routes are declared via
19
+ * `extraRoutes`, not wired here). */
20
+ export type SystemWireDeps = {
18
21
  readonly db: DbConnection;
19
22
  readonly redis: Redis;
20
- /** Feature-registry — z.B. für Plugin-Lookups via
23
+ /** Feature-registry — e.g. for plugin-lookups via
21
24
  * `registry.getExtensionUsages("subscriptionProvider")`. */
22
25
  readonly registry: Registry;
23
- /** Schreibt durch den /api/*-Command-Dispatcher (gleiche Idempotency/
24
- * Job-Hooks) — aber als auto-konstruierter SystemAdmin des Ziel-
25
- * Tenants, OHNE Access-Check der Route. Privilege-Scope: SystemAdmin
26
- * ist die höchste nicht-tenant-scoped Rolle — der Call erreicht JEDEN
27
- * SystemAdmin-gegateten Handler auf jedem Tenant; das Rollen-Set ist
28
- * nicht konfigurierbar. Nur für Pfade, die ihre Authentizität selbst
29
- * beweisen (Provider-Webhook-Signaturen,
30
- * createSubscriptionWebhookHandler et al.). */
26
+ /** Writes through the /api/*-command-dispatcher (same idempotency/job
27
+ * hooks) as an auto-constructed SystemAdmin of the target tenant,
28
+ * WITHOUT the route's own access-check. Privilege-scope: SystemAdmin is
29
+ * the highest non-tenant-scoped role — reaches ANY SystemAdmin-gated
30
+ * handler on ANY tenant; the role-set is not configurable. Only for
31
+ * co-running components that already proved their own authenticity
32
+ * (e.g. an IMAP supervisor authenticated against its own mailbox). */
31
33
  readonly dispatchSystemWrite: (args: {
32
34
  readonly handlerQn: string;
33
35
  readonly payload: unknown;
34
- readonly tenantId: TenantId;
36
+ readonly tenantId: import("@cosmicdrift/kumiko-framework/engine").TenantId;
35
37
  }) => Promise<WriteResult>;
36
38
  };
37
-
38
- type SystemWriteDispatcher = {
39
- readonly write: (handlerQn: string, payload: unknown, user: SessionUser) => Promise<WriteResult>;
40
- };
41
-
42
- export function makeDispatchSystemWrite(
43
- dispatcher: SystemWriteDispatcher,
44
- ): ExtraRoutesSystemDeps["dispatchSystemWrite"] {
45
- return ({ handlerQn, payload, tenantId }) =>
46
- dispatcher.write(handlerQn, payload, createSystemUser(tenantId, [ROLES.SystemAdmin]));
47
- }
@@ -132,6 +132,11 @@ export function buildStaticFallback(
132
132
  appSchemaJson: string,
133
133
  hostDispatch?: HostDispatchFn,
134
134
  pageHead?: PageHeadOptions,
135
+ /** Backs hostDispatch's `systemQuery` — required whenever `hostDispatch`
136
+ * is set (a hostDispatch that reads `deps.systemQuery` without it would
137
+ * throw). Separate from `pageHead.dispatcher` since resolvePageHead is
138
+ * optional independently of hostDispatch. */
139
+ hostDispatchDispatcher?: QueryDispatcher,
135
140
  ): (req: Request) => Promise<Response> {
136
141
  const indexHtml = `${staticDir}/index.html`;
137
142
 
@@ -230,7 +235,21 @@ export function buildStaticFallback(
230
235
  if (!hostDispatch) return null;
231
236
  const url = new URL(req.url);
232
237
  const host = req.headers.get("host") ?? url.host;
233
- const result = hostDispatch({ host, path: url.pathname, search: url.search });
238
+ const systemQuery: PageHeadSystemQuery = (type, payload, tenantId) => {
239
+ if (!hostDispatchDispatcher) {
240
+ throw new Error(
241
+ "hostDispatch called deps.systemQuery but buildStaticFallback got no hostDispatchDispatcher",
242
+ );
243
+ }
244
+ return requestContext.run(
245
+ requestContext.get() ?? buildRequestContextDataFromRequest(req),
246
+ () => hostDispatchDispatcher.query(type, payload, createAnonymousUser(tenantId)),
247
+ );
248
+ };
249
+ const result = await hostDispatch(
250
+ { host, path: url.pathname, search: url.search },
251
+ { systemQuery },
252
+ );
234
253
  if (result.kind === "not-found") {
235
254
  return new Response("Not Found", { status: 404 });
236
255
  }
@@ -72,6 +72,7 @@ import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
72
72
  import {
73
73
  createDefaultSseBroker,
74
74
  createRedisLoginRateLimiter,
75
+ type ExtraRouteDefinition,
75
76
  type LoginRateLimiter,
76
77
  loadJwtSecretOrKeyring,
77
78
  type SseBroker,
@@ -146,7 +147,7 @@ import { resolveBootCrypto } from "./boot/boot-crypto";
146
147
  import { jobRunLoggerCallbacks } from "./boot/job-run-logger";
147
148
  import { buildBunServeOptions } from "./bun-serve-options";
148
149
  import { buildComposeAuthOptions, composeFeatures } from "./compose-features";
149
- import { type ExtraRoutesSystemDeps, makeDispatchSystemWrite } from "./extra-routes-deps";
150
+ import { makeDispatchSystemWrite, type SystemWireDeps } from "./extra-routes-deps";
150
151
  import { assertPiiBootInvariants } from "./pii-boot-gate";
151
152
  import {
152
153
  addConfigAccessorFactory,
@@ -425,14 +426,21 @@ export type HostDispatchResult =
425
426
  | { readonly kind: "redirect"; readonly to: string; readonly status?: 301 | 302 }
426
427
  | { readonly kind: "not-found" };
427
428
 
428
- export type HostDispatchFn = (req: {
429
- readonly host: string;
430
- readonly path: string;
431
- /** Query-String inkl. führendem `?`, `""` wenn keiner. Redirects die
432
- * den Pfad auf einen anderen Host umbiegen (z.B. Auth-Routen mit
433
- * `?token=` aus alten Mail-Links) MÜSSEN ihn an `to` anhängen. */
434
- readonly search: string;
435
- }) => HostDispatchResult;
429
+ export type HostDispatchFn = (
430
+ req: {
431
+ readonly host: string;
432
+ readonly path: string;
433
+ /** Query string incl. leading `?`, `""` if none. Redirects that
434
+ * route the path to a different host (e.g. auth routes with
435
+ * `?token=` from old mail links) MUST append it to `to`. */
436
+ readonly search: string;
437
+ },
438
+ /** `systemQuery` runs as the anonymous role (like PageHeadSystemQuery) —
439
+ * hostDispatch is reachable by any public visitor, same access gate as a
440
+ * real anonymous request. Lets a Multi-Tenant hostDispatch look up the
441
+ * tenant for a subdomain instead of needing its own db handle. */
442
+ deps: { readonly systemQuery: PageHeadSystemQuery },
443
+ ) => HostDispatchResult | Promise<HostDispatchResult>;
436
444
 
437
445
  // PageHeadMeta/PageHeadResolver/PageHeadSystemQuery moved to
438
446
  // @cosmicdrift/kumiko-headless/apex (kumiko-framework#3026) — runDevApp needs
@@ -584,16 +592,18 @@ export type RunProdAppOptions = {
584
592
  * späteren pgClient-Pass-through. */
585
593
  readonly pollIntervalMs?: number;
586
594
  };
587
- /** Mount-Point für app-eigene HTTP-Routes außerhalb des Dispatcher-
588
- * Systems. Aufgerufen NACH /api/* + /health, VOR der static-fallback —
589
- * perfekt für GET-Endpoints die kein JSON liefern: /feed.xml,
590
- * /og-image, /sitemap.xml, /robots.txt-mit-Logik. Bekommt das raw
591
- * Hono-app + die Connection-Deps (db/redis) zum Querying.
592
- *
593
- * Naming: `deps` statt `ctx` weil im Framework `ctx` der HandlerContext
594
- * mit user/tenant/registry ist — hier ist der Scope absichtlich kleiner
595
- * (Routes laufen außerhalb der Auth/Tenant-Pipeline). */
596
- readonly extraRoutes?: (app: import("hono").Hono, deps: ExtraRoutesSystemDeps) => void;
595
+ /** Declarative HTTP-routes outside the /api/write|query|batch pipeline —
596
+ * each entry declares its access tier (`entry`: "anonymous" | "user" |
597
+ * "signature"), buildServer wires the matching guard + deps. Mounted
598
+ * inside buildServer, right after the r.httpRoute loop, before seeds
599
+ * (kumiko-framework#3050). */
600
+ readonly extraRoutes?: readonly ExtraRouteDefinition[];
601
+ /** Hook for app-wired co-running components that need the system-write
602
+ * dispatcher (e.g. wiring a webhook plugin's system-scoped secrets, or
603
+ * starting a co-running supervisor) — runs after buildServer, before
604
+ * seeds, with NO `app` (routes are declared via `extraRoutes`, not
605
+ * wired here). */
606
+ readonly wire?: (deps: SystemWireDeps) => void | Promise<void>;
597
607
  /** When true (default), Bun.serve is started before runProdApp resolves —
598
608
  * the common case: `await runProdApp({...})` boots the server and the
599
609
  * process stays up listening on PORT. Set to false in tests that drive
@@ -1065,6 +1075,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1065
1075
  ...(options.observabilityOptions && { observabilityOptions: options.observabilityOptions }),
1066
1076
  ...(options.metrics && { metrics: options.metrics }),
1067
1077
  ...(options.rateLimit && { rateLimit: options.rateLimit }),
1078
+ ...(options.extraRoutes && { extraRoutes: options.extraRoutes }),
1068
1079
  ...(effectiveAuth && {
1069
1080
  auth: {
1070
1081
  membershipQuery: TenantQueries.memberships,
@@ -1223,14 +1234,13 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1223
1234
  // statt eines fixed JSON-Strings. Heute: registry-static, also OK.
1224
1235
  const appSchemaJson = JSON.stringify(buildAppSchema(registry, { searchAdapterMissing }));
1225
1236
 
1226
- // App-eigene HTTP-Routes mounten — VOR den Seeds (symmetrisch zum
1227
- // dev-server, siehe create-kumiko-server.ts). Ein Seed, der über den
1228
- // Dispatcher dispatcht, baut Honos Matcher; danach wirft jedes weitere
1229
- // app.get() "Can not add a route since the matcher is already built".
1230
- // Eingehende /api/*-Pfade sind schon vom dispatcher belegt; extraRoutes
1231
- // sollte die nicht überschreiben (kein enforce, das ist Author-Verantwortung).
1232
- if (options.extraRoutes) {
1233
- options.extraRoutes(entrypoint.app, {
1237
+ // `extraRoutes` itself is already mounted — buildServer wires it inside
1238
+ // createApiEntrypoint/createAllInOneEntrypoint, before this function ever
1239
+ // runs. `wire` is the remaining escape-hatch for app-wired co-running
1240
+ // components that need the system-write dispatcher but don't declare a
1241
+ // route (kumiko-framework#3050).
1242
+ if (options.wire) {
1243
+ await options.wire({
1234
1244
  db,
1235
1245
  redis,
1236
1246
  registry,
@@ -1296,6 +1306,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1296
1306
  options.resolvePageHead
1297
1307
  ? { resolvePageHead: options.resolvePageHead, dispatcher: entrypoint.dispatcher }
1298
1308
  : undefined,
1309
+ entrypoint.dispatcher,
1299
1310
  )
1300
1311
  : // No staticDir (split-deploy / API-only container) → app.fetch's
1301
1312
  // response goes straight to the client, bypassing buildStaticFallback
@@ -61,7 +61,7 @@ import Redis from "ioredis";
61
61
  import { resolveBootCrypto } from "./boot/boot-crypto";
62
62
  import { jobRunLoggerCallbacks } from "./boot/job-run-logger";
63
63
  import { composeFeatures } from "./compose-features";
64
- import { type ExtraRoutesSystemDeps, makeDispatchSystemWrite } from "./extra-routes-deps";
64
+ import { makeDispatchSystemWrite, type SystemWireDeps } from "./extra-routes-deps";
65
65
  import { assertPiiBootInvariants } from "./pii-boot-gate";
66
66
  import { requireEnv } from "./run-prod-app";
67
67
  import { addConfigAccessorFactory, buildBootExtraContext } from "./run-prod-app-boot-context";
@@ -79,9 +79,9 @@ export type WorkerDeps = {
79
79
  /** Deps for the `wireComponents` hook — app-wired co-running components
80
80
  * (an analysis runner, an IMAP supervisor, ...) that need the system-
81
81
  * write dispatcher and register their own shutdown hooks on the worker
82
- * lifecycle. Same deps shape as extraRoutes, plus `lifecycle` for
83
- * `registerShutdownHook`. */
84
- export type WorkerWireDeps = ExtraRoutesSystemDeps & {
82
+ * lifecycle. Same shape as the `wire` hook's SystemWireDeps, plus
83
+ * `lifecycle` for `registerShutdownHook`. */
84
+ export type WorkerWireDeps = SystemWireDeps & {
85
85
  readonly lifecycle: WorkerEntrypoint["lifecycle"];
86
86
  };
87
87