@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.
|
|
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.
|
|
84
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
85
|
-
"@cosmicdrift/kumiko-headless": "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
|
|
101
|
-
//
|
|
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
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
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:
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
// (b) dispatchSystemWrite
|
|
354
|
-
// (c)
|
|
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:
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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:
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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:
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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:
|
|
462
|
-
|
|
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:
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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").
|
|
1010
|
+
let dispatchSystemWrite: import("../extra-routes-deps").SystemWireDeps["dispatchSystemWrite"];
|
|
919
1011
|
const handle = await boot(undefined, {
|
|
920
1012
|
eventDispatcher: { pollIntervalMs: 50 },
|
|
921
|
-
|
|
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",
|
package/src/extra-routes-deps.ts
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 —
|
|
23
|
+
/** Feature-registry — e.g. for plugin-lookups via
|
|
21
24
|
* `registry.getExtensionUsages("subscriptionProvider")`. */
|
|
22
25
|
readonly registry: Registry;
|
|
23
|
-
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
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
|
}
|
package/src/run-prod-app.ts
CHANGED
|
@@ -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
|
|
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 = (
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
/**
|
|
588
|
-
*
|
|
589
|
-
*
|
|
590
|
-
*
|
|
591
|
-
*
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
*
|
|
595
|
-
*
|
|
596
|
-
|
|
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
|
-
//
|
|
1227
|
-
//
|
|
1228
|
-
//
|
|
1229
|
-
//
|
|
1230
|
-
//
|
|
1231
|
-
|
|
1232
|
-
|
|
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
|
package/src/run-worker-app.ts
CHANGED
|
@@ -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
|
|
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
|
|
83
|
-
* `registerShutdownHook`. */
|
|
84
|
-
export type WorkerWireDeps =
|
|
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
|
|