@cosmicdrift/kumiko-dev-server 0.27.0 → 0.31.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-dev-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "Development server bootstrap for Kumiko apps. Bundles the client, mints dev-JWTs, injects the resolved AppSchema, and seeds an admin. Not for production.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -68,6 +68,19 @@ const widgetFeature = defineFeature("prod-probe", (r) => {
|
|
|
68
68
|
access: { roles: ["anonymous"] },
|
|
69
69
|
handler: async () => ({ pong: true }),
|
|
70
70
|
});
|
|
71
|
+
// SystemAdmin-gated write — Ziel des extraRoutes.dispatchSystemWrite-
|
|
72
|
+
// Tests: Echo von user.tenantId + roles beweist, dass der Dispatch
|
|
73
|
+
// durch den echten Dispatcher (Zod + Access-Check) läuft und der
|
|
74
|
+
// auto-konstruierte SystemUser den Ziel-Tenant trägt.
|
|
75
|
+
r.writeHandler({
|
|
76
|
+
name: "probe-write",
|
|
77
|
+
schema: z.object({ note: z.string() }),
|
|
78
|
+
access: { roles: ["SystemAdmin"] },
|
|
79
|
+
handler: async (event) => ({
|
|
80
|
+
isSuccess: true as const,
|
|
81
|
+
data: { tenantSeen: event.user.tenantId, roles: event.user.roles },
|
|
82
|
+
}),
|
|
83
|
+
});
|
|
71
84
|
});
|
|
72
85
|
|
|
73
86
|
const TENANT_ID = "00000000-0000-4000-8000-000000000001";
|
|
@@ -206,6 +219,41 @@ describe("runProdApp", () => {
|
|
|
206
219
|
expect(body).toContain('<probe ok="true" />');
|
|
207
220
|
});
|
|
208
221
|
|
|
222
|
+
test("extraRoutes-deps: dispatchSystemWrite schreibt als SystemAdmin des Ziel-Tenants, registry verfügbar", async () => {
|
|
223
|
+
// Das ist das Wiring für Provider-Webhook-Routes (billing-foundation
|
|
224
|
+
// createSubscriptionWebhookHandler): die Route authentifiziert via
|
|
225
|
+
// Provider-Signatur und schreibt dann am JWT-Pfad vorbei durch den
|
|
226
|
+
// Command-Dispatcher. Beweist: (a) registry liegt in den deps,
|
|
227
|
+
// (b) dispatchSystemWrite geht durch Zod + Access-Check des Handlers,
|
|
228
|
+
// (c) der SystemUser trägt den Ziel-Tenant (Event-Store-Konsistenz).
|
|
229
|
+
let registryHasProbe = false;
|
|
230
|
+
const handle = await boot(undefined, {
|
|
231
|
+
extraRoutes: (app, deps) => {
|
|
232
|
+
registryHasProbe = deps.registry.features.has("prod-probe");
|
|
233
|
+
app.post("/webhook-probe", async (c) => {
|
|
234
|
+
const result = await deps.dispatchSystemWrite({
|
|
235
|
+
handlerQn: "prod-probe:write:probe-write",
|
|
236
|
+
payload: { note: "from-webhook" },
|
|
237
|
+
tenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
|
|
238
|
+
});
|
|
239
|
+
return c.json(result);
|
|
240
|
+
});
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
expect(registryHasProbe).toBe(true);
|
|
245
|
+
|
|
246
|
+
const res = await handle.fetch(new Request("http://test/webhook-probe", { method: "POST" }));
|
|
247
|
+
expect(res.status).toBe(200);
|
|
248
|
+
const body = (await res.json()) as {
|
|
249
|
+
isSuccess: boolean;
|
|
250
|
+
data?: { tenantSeen: string; roles: string[] };
|
|
251
|
+
};
|
|
252
|
+
expect(body.isSuccess).toBe(true);
|
|
253
|
+
expect(body.data?.tenantSeen).toBe(TENANT_ID);
|
|
254
|
+
expect(body.data?.roles).toContain("SystemAdmin");
|
|
255
|
+
});
|
|
256
|
+
|
|
209
257
|
test("static-fallback: extraRoute beats Disk-File at colliding path (Hono-First)", async () => {
|
|
210
258
|
// Regression-Test für den static-fallback-Bug von Phase 2 Step 1:
|
|
211
259
|
// wenn ein extraRoute (z.B. /feed.xml) UND eine gleichnamige Disk-
|
|
@@ -265,6 +313,38 @@ describe("runProdApp", () => {
|
|
|
265
313
|
expect(await res.text()).toContain("SPA shell");
|
|
266
314
|
});
|
|
267
315
|
|
|
316
|
+
test("static-fallback: non-GET ohne Hono-Match → 404, nicht SPA-Shell (#259)", async () => {
|
|
317
|
+
// Prod-Szenario: POST auf einen falsch konfigurierten Webhook-Pfad
|
|
318
|
+
// (Route nicht gemountet). 200 index.html würde dem Provider
|
|
319
|
+
// "delivered" signalisieren — Events gingen still verloren.
|
|
320
|
+
const tmpStaticDir = await createTempStaticDir({
|
|
321
|
+
"index.html": "<html>SPA shell</html>",
|
|
322
|
+
"robots.txt": "User-agent: *\nAllow: /",
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const handle = await boot(undefined, { staticDir: tmpStaticDir });
|
|
326
|
+
|
|
327
|
+
const unmatched = await handle.fetch(
|
|
328
|
+
new Request("http://test/webhooks/subscription/stripe", { method: "POST" }),
|
|
329
|
+
);
|
|
330
|
+
expect(unmatched.status).toBe(404);
|
|
331
|
+
|
|
332
|
+
// Disk-Files werden ebenfalls nicht auf non-GET serviert.
|
|
333
|
+
const diskFile = await handle.fetch(new Request("http://test/robots.txt", { method: "POST" }));
|
|
334
|
+
expect(diskFile.status).toBe(404);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("static-fallback: HEAD auf SPA-Route bleibt 200 (spiegelt GET)", async () => {
|
|
338
|
+
const tmpStaticDir = await createTempStaticDir({
|
|
339
|
+
"index.html": "<html>SPA shell</html>",
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const handle = await boot(undefined, { staticDir: tmpStaticDir });
|
|
343
|
+
|
|
344
|
+
const res = await handle.fetch(new Request("http://test/some/spa/route", { method: "HEAD" }));
|
|
345
|
+
expect(res.status).toBe(200);
|
|
346
|
+
});
|
|
347
|
+
|
|
268
348
|
test("hostDispatch: per-host html-Datei + Schema-Gating", async () => {
|
|
269
349
|
// Multi-App-Deployment: zwei HTML-Dateien für unterschiedliche
|
|
270
350
|
// Hosts. Schema wird NUR für admin-Host injected — Public-Host
|
|
@@ -20,7 +20,11 @@ import { readFile, watch } from "node:fs/promises";
|
|
|
20
20
|
import { tmpdir } from "node:os";
|
|
21
21
|
import { join, resolve } from "node:path";
|
|
22
22
|
import { type AuthRoutesConfig, generateToken } from "@cosmicdrift/kumiko-framework/api";
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
buildAppSchema,
|
|
25
|
+
createSystemUser,
|
|
26
|
+
type FeatureDefinition,
|
|
27
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
24
28
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
25
29
|
import {
|
|
26
30
|
pushEntityProjectionTables,
|
|
@@ -190,7 +194,21 @@ export type CreateKumikoServerOptions = {
|
|
|
190
194
|
* `ctx` weil dies kein HandlerContext ist — kein user/tenant. */
|
|
191
195
|
readonly extraRoutes?: (
|
|
192
196
|
app: import("hono").Hono,
|
|
193
|
-
deps: {
|
|
197
|
+
deps: {
|
|
198
|
+
db: TestStack["db"];
|
|
199
|
+
redis: TestStack["redis"];
|
|
200
|
+
/** Feature-registry — z.B. für Plugin-Lookups via
|
|
201
|
+
* `registry.getExtensionUsages("subscriptionProvider")`. */
|
|
202
|
+
registry: TestStack["registry"];
|
|
203
|
+
/** System-Write durch den Command-Dispatcher — Semantik identisch zu
|
|
204
|
+
* runProdApp.extraRoutes (SystemAdmin des Ziel-Tenants, kein
|
|
205
|
+
* Access-Check; nur für signatur-authentifizierte Pfade). */
|
|
206
|
+
dispatchSystemWrite: (args: {
|
|
207
|
+
readonly handlerQn: string;
|
|
208
|
+
readonly payload: unknown;
|
|
209
|
+
readonly tenantId: import("@cosmicdrift/kumiko-framework/engine").TenantId;
|
|
210
|
+
}) => Promise<import("@cosmicdrift/kumiko-framework/engine").WriteResult>;
|
|
211
|
+
},
|
|
194
212
|
) => void;
|
|
195
213
|
};
|
|
196
214
|
|
|
@@ -681,7 +699,13 @@ export async function createKumikoServer(
|
|
|
681
699
|
// (HTML/JS/CSS-Serving via handleFetch unten) registriert, damit
|
|
682
700
|
// explizite Routen wie /feed.xml den Asset-Pfad schlagen.
|
|
683
701
|
if (options.extraRoutes !== undefined) {
|
|
684
|
-
options.extraRoutes(stack.app, {
|
|
702
|
+
options.extraRoutes(stack.app, {
|
|
703
|
+
db: stack.db,
|
|
704
|
+
redis: stack.redis,
|
|
705
|
+
registry: stack.registry,
|
|
706
|
+
dispatchSystemWrite: ({ handlerQn, payload, tenantId }) =>
|
|
707
|
+
stack.dispatcher.write(handlerQn, payload, createSystemUser(tenantId, ["SystemAdmin"])),
|
|
708
|
+
});
|
|
685
709
|
}
|
|
686
710
|
|
|
687
711
|
// setupTestStack konfiguriert den eventDispatcher, startet ihn aber
|
package/src/run-prod-app.ts
CHANGED
|
@@ -45,12 +45,15 @@ import { createDbConnection, type DbRunner } from "@cosmicdrift/kumiko-framework
|
|
|
45
45
|
import {
|
|
46
46
|
buildAppSchema,
|
|
47
47
|
createRegistry,
|
|
48
|
+
createSystemUser,
|
|
48
49
|
type EffectiveFeaturesResolver,
|
|
49
50
|
type FeatureDefinition,
|
|
50
51
|
findTierResolverUsage,
|
|
52
|
+
type Registry,
|
|
51
53
|
type TenantId,
|
|
52
54
|
type TierResolverPlugin,
|
|
53
55
|
validateBoot,
|
|
56
|
+
type WriteResult,
|
|
54
57
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
55
58
|
import {
|
|
56
59
|
type ApiEntrypoint,
|
|
@@ -421,6 +424,19 @@ export type RunProdAppOptions = {
|
|
|
421
424
|
deps: {
|
|
422
425
|
db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
|
|
423
426
|
redis: import("ioredis").default;
|
|
427
|
+
/** Feature-registry — z.B. für Plugin-Lookups via
|
|
428
|
+
* `registry.getExtensionUsages("subscriptionProvider")`. */
|
|
429
|
+
registry: Registry;
|
|
430
|
+
/** Schreibt durch den /api/*-Command-Dispatcher (gleiche Idempotency/
|
|
431
|
+
* Job-Hooks) — aber als auto-konstruierter SystemAdmin des Ziel-
|
|
432
|
+
* Tenants, OHNE Access-Check der Route. Nur für Pfade, die ihre
|
|
433
|
+
* Authentizität selbst beweisen (Provider-Webhook-Signaturen,
|
|
434
|
+
* createSubscriptionWebhookHandler et al.). */
|
|
435
|
+
dispatchSystemWrite: (args: {
|
|
436
|
+
readonly handlerQn: string;
|
|
437
|
+
readonly payload: unknown;
|
|
438
|
+
readonly tenantId: TenantId;
|
|
439
|
+
}) => Promise<WriteResult>;
|
|
424
440
|
},
|
|
425
441
|
) => void;
|
|
426
442
|
/** When true (default), Bun.serve is started before runProdApp resolves —
|
|
@@ -808,7 +824,17 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
808
824
|
// belegt; extraRoutes sollte die nicht überschreiben (kein
|
|
809
825
|
// enforce, das ist Author-Verantwortung).
|
|
810
826
|
if (options.extraRoutes) {
|
|
811
|
-
options.extraRoutes(entrypoint.app, {
|
|
827
|
+
options.extraRoutes(entrypoint.app, {
|
|
828
|
+
db,
|
|
829
|
+
redis,
|
|
830
|
+
registry,
|
|
831
|
+
dispatchSystemWrite: ({ handlerQn, payload, tenantId }) =>
|
|
832
|
+
entrypoint.dispatcher.write(
|
|
833
|
+
handlerQn,
|
|
834
|
+
payload,
|
|
835
|
+
createSystemUser(tenantId, ["SystemAdmin"]),
|
|
836
|
+
),
|
|
837
|
+
});
|
|
812
838
|
}
|
|
813
839
|
|
|
814
840
|
// 11. Build the fetch-handler. Static-fallback for non-/api/ paths
|
|
@@ -1044,6 +1070,14 @@ function buildStaticFallback(
|
|
|
1044
1070
|
}
|
|
1045
1071
|
const honoRes = honoTry.response;
|
|
1046
1072
|
|
|
1073
|
+
// Disk-/SPA-Fallback ist GET/HEAD-only. Ein non-GET ohne Hono-Match
|
|
1074
|
+
// (z.B. POST auf einen falsch konfigurierten Webhook-Pfad) muss den
|
|
1075
|
+
// Hono-404 durchreichen — 200 index.html würde dem Provider
|
|
1076
|
+
// "delivered" signalisieren und Events gingen still verloren (#259).
|
|
1077
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1078
|
+
return honoRes;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1047
1081
|
// Disk-Datei (Asset oder konkrete File). Asset-Pfade laufen
|
|
1048
1082
|
// host-unabhängig — die Bundles in /assets/* werden vom client
|
|
1049
1083
|
// aktiv geladen, kein Server-side Routing nötig.
|