@cosmicdrift/kumiko-dev-server 0.39.0 → 0.40.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.40.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>",
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
createTextField,
|
|
10
10
|
defineFeature,
|
|
11
11
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
12
|
+
import { z } from "zod";
|
|
12
13
|
import { createKumikoServer, type KumikoServerHandle } from "../create-kumiko-server";
|
|
13
14
|
|
|
14
15
|
// Integration-Test: bootet createKumikoServer mit echtem Postgres,
|
|
@@ -27,6 +28,18 @@ const probeEntity = createEntity({
|
|
|
27
28
|
|
|
28
29
|
const probeFeature = defineFeature("dev-server-probe", (r) => {
|
|
29
30
|
r.entity("probe", probeEntity);
|
|
31
|
+
// SystemAdmin-gated write — Ziel des extraRoutes.dispatchSystemWrite-
|
|
32
|
+
// Tests (252/2): Echo von user.tenantId + roles beweist Dispatch durch
|
|
33
|
+
// den echten Dispatcher (Zod + Access-Check) mit Ziel-Tenant-SystemUser.
|
|
34
|
+
r.writeHandler({
|
|
35
|
+
name: "probe-write",
|
|
36
|
+
schema: z.object({ note: z.string() }),
|
|
37
|
+
access: { roles: ["SystemAdmin"] },
|
|
38
|
+
handler: async (event) => ({
|
|
39
|
+
isSuccess: true as const,
|
|
40
|
+
data: { tenantSeen: event.user.tenantId, roles: event.user.roles },
|
|
41
|
+
}),
|
|
42
|
+
});
|
|
30
43
|
});
|
|
31
44
|
|
|
32
45
|
let handle: KumikoServerHandle | undefined;
|
|
@@ -285,3 +298,41 @@ describe("createKumikoServer (Multi-Entry)", () => {
|
|
|
285
298
|
expect(res.status).toBe(404);
|
|
286
299
|
});
|
|
287
300
|
});
|
|
301
|
+
|
|
302
|
+
// 252/2: der Dev-Pfad bekommt dieselbe extraRoutes-deps-Closure wie
|
|
303
|
+
// runProdApp (seit der Extraktion nach extra-routes-deps.ts geteilt) —
|
|
304
|
+
// hier der analoge Beweis gegen createKumikoServer.
|
|
305
|
+
describe("createKumikoServer extraRoutes-deps", () => {
|
|
306
|
+
test("dispatchSystemWrite schreibt als SystemAdmin des Ziel-Tenants, registry verfügbar", async () => {
|
|
307
|
+
const tenantId = "00000000-0000-4000-8000-000000000042";
|
|
308
|
+
let registryHasProbe = false;
|
|
309
|
+
handle = await createKumikoServer({
|
|
310
|
+
features: [probeFeature],
|
|
311
|
+
port: 0,
|
|
312
|
+
installSignalHandlers: false,
|
|
313
|
+
extraRoutes: (app, deps) => {
|
|
314
|
+
registryHasProbe = deps.registry.features.has("dev-server-probe");
|
|
315
|
+
app.post("/webhook-probe", async (c) => {
|
|
316
|
+
const result = await deps.dispatchSystemWrite({
|
|
317
|
+
handlerQn: "dev-server-probe:write:probe-write",
|
|
318
|
+
payload: { note: "from-webhook" },
|
|
319
|
+
tenantId: tenantId as import("@cosmicdrift/kumiko-framework/engine").TenantId,
|
|
320
|
+
});
|
|
321
|
+
return c.json(result);
|
|
322
|
+
});
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
expect(registryHasProbe).toBe(true);
|
|
327
|
+
|
|
328
|
+
const res = await handle.fetch(new Request("http://test/webhook-probe", { method: "POST" }));
|
|
329
|
+
expect(res.status).toBe(200);
|
|
330
|
+
const body = (await res.json()) as {
|
|
331
|
+
isSuccess: boolean;
|
|
332
|
+
data?: { tenantSeen: string; roles: string[] };
|
|
333
|
+
};
|
|
334
|
+
expect(body.isSuccess).toBe(true);
|
|
335
|
+
expect(body.data?.tenantSeen).toBe(tenantId);
|
|
336
|
+
expect(body.data?.roles).toContain("SystemAdmin");
|
|
337
|
+
});
|
|
338
|
+
});
|
|
@@ -92,6 +92,45 @@ describe("runProdApp boot-mode env-source", () => {
|
|
|
92
92
|
await handle.stop();
|
|
93
93
|
});
|
|
94
94
|
|
|
95
|
+
test("boot-mode constructs no eager Redis client — kein TCP-Connect auf REDIS_URL", async () => {
|
|
96
|
+
// Kern-Garantie (224/2), als Netzwerk-Beweis statt Konstruktor-Spy:
|
|
97
|
+
// REDIS_URL zeigt auf einen lokalen Listener — `new Redis(...)`
|
|
98
|
+
// connectet eager, der Boot-Exit MUSS also vorher liegen, sonst
|
|
99
|
+
// zählt der Listener eine Connection.
|
|
100
|
+
let connections = 0;
|
|
101
|
+
const listener = Bun.listen({
|
|
102
|
+
hostname: "127.0.0.1",
|
|
103
|
+
port: 0,
|
|
104
|
+
socket: {
|
|
105
|
+
open(socket) {
|
|
106
|
+
connections += 1;
|
|
107
|
+
socket.end();
|
|
108
|
+
},
|
|
109
|
+
data() {},
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const originalLog = console.log;
|
|
114
|
+
console.log = () => {};
|
|
115
|
+
try {
|
|
116
|
+
const handle = await runProdApp({
|
|
117
|
+
features: [probeFeature],
|
|
118
|
+
autoListen: false,
|
|
119
|
+
migrations: false,
|
|
120
|
+
envSource: { ...DUMMY_ENV, REDIS_URL: `redis://127.0.0.1:${listener.port}` },
|
|
121
|
+
});
|
|
122
|
+
await handle.stop();
|
|
123
|
+
} finally {
|
|
124
|
+
console.log = originalLog;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Ein eager Connect wäre bereits beim runProdApp-await passiert;
|
|
128
|
+
// kleine Nachfrist für asynchrone Socket-Anläufe.
|
|
129
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
130
|
+
listener.stop(true);
|
|
131
|
+
expect(connections).toBe(0);
|
|
132
|
+
});
|
|
133
|
+
|
|
95
134
|
test("resolves PORT from envSource, not process.env", async () => {
|
|
96
135
|
const logs: string[] = [];
|
|
97
136
|
const originalLog = console.log;
|
|
@@ -20,12 +20,7 @@ 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 {
|
|
24
|
-
import {
|
|
25
|
-
buildAppSchema,
|
|
26
|
-
createSystemUser,
|
|
27
|
-
type FeatureDefinition,
|
|
28
|
-
} from "@cosmicdrift/kumiko-framework/engine";
|
|
23
|
+
import { buildAppSchema, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
29
24
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
30
25
|
import {
|
|
31
26
|
pushEntityProjectionTables,
|
|
@@ -34,6 +29,7 @@ import {
|
|
|
34
29
|
type TestStackOptions,
|
|
35
30
|
TestUsers,
|
|
36
31
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
32
|
+
import { type ExtraRoutesSystemDeps, makeDispatchSystemWrite } from "./extra-routes-deps";
|
|
37
33
|
import { injectSchema } from "./inject-schema";
|
|
38
34
|
import { canResolveTailwindStylesheet, resolveTailwindCli } from "./resolve-tailwind-cli";
|
|
39
35
|
import { buildBunServeOptions } from "./run-prod-app";
|
|
@@ -193,24 +189,7 @@ export type CreateKumikoServerOptions = {
|
|
|
193
189
|
* Static/HTML-Auslieferung aufgerufen, sodass eigene GETs (/feed.xml,
|
|
194
190
|
* /og-image, …) Vorrang vor dem Dev-Asset-Pfad haben. `deps` statt
|
|
195
191
|
* `ctx` weil dies kein HandlerContext ist — kein user/tenant. */
|
|
196
|
-
readonly extraRoutes?: (
|
|
197
|
-
app: import("hono").Hono,
|
|
198
|
-
deps: {
|
|
199
|
-
db: TestStack["db"];
|
|
200
|
-
redis: TestStack["redis"];
|
|
201
|
-
/** Feature-registry — z.B. für Plugin-Lookups via
|
|
202
|
-
* `registry.getExtensionUsages("subscriptionProvider")`. */
|
|
203
|
-
registry: TestStack["registry"];
|
|
204
|
-
/** System-Write durch den Command-Dispatcher — Semantik identisch zu
|
|
205
|
-
* runProdApp.extraRoutes (SystemAdmin des Ziel-Tenants, kein
|
|
206
|
-
* Access-Check; nur für signatur-authentifizierte Pfade). */
|
|
207
|
-
dispatchSystemWrite: (args: {
|
|
208
|
-
readonly handlerQn: string;
|
|
209
|
-
readonly payload: unknown;
|
|
210
|
-
readonly tenantId: import("@cosmicdrift/kumiko-framework/engine").TenantId;
|
|
211
|
-
}) => Promise<import("@cosmicdrift/kumiko-framework/engine").WriteResult>;
|
|
212
|
-
},
|
|
213
|
-
) => void;
|
|
192
|
+
readonly extraRoutes?: (app: import("hono").Hono, deps: ExtraRoutesSystemDeps) => void;
|
|
214
193
|
};
|
|
215
194
|
|
|
216
195
|
export type KumikoServerHandle = {
|
|
@@ -702,10 +681,11 @@ export async function createKumikoServer(
|
|
|
702
681
|
if (options.extraRoutes !== undefined) {
|
|
703
682
|
options.extraRoutes(stack.app, {
|
|
704
683
|
db: stack.db,
|
|
705
|
-
|
|
684
|
+
// Der nackte ioredis-Client (nicht der TestRedis-Wrapper) —
|
|
685
|
+
// Parität mit runProdApp, App-Code soll in dev+prod dasselbe sehen.
|
|
686
|
+
redis: stack.redis.redis,
|
|
706
687
|
registry: stack.registry,
|
|
707
|
-
dispatchSystemWrite: (
|
|
708
|
-
stack.dispatcher.write(handlerQn, payload, createSystemUser(tenantId, [ROLES.SystemAdmin])),
|
|
688
|
+
dispatchSystemWrite: makeDispatchSystemWrite(stack.dispatcher),
|
|
709
689
|
});
|
|
710
690
|
}
|
|
711
691
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
|
|
2
|
+
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";
|
|
10
|
+
import type Redis from "ioredis";
|
|
11
|
+
|
|
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 = {
|
|
18
|
+
readonly db: DbConnection;
|
|
19
|
+
readonly redis: Redis;
|
|
20
|
+
/** Feature-registry — z.B. für Plugin-Lookups via
|
|
21
|
+
* `registry.getExtensionUsages("subscriptionProvider")`. */
|
|
22
|
+
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.). */
|
|
31
|
+
readonly dispatchSystemWrite: (args: {
|
|
32
|
+
readonly handlerQn: string;
|
|
33
|
+
readonly payload: unknown;
|
|
34
|
+
readonly tenantId: TenantId;
|
|
35
|
+
}) => Promise<WriteResult>;
|
|
36
|
+
};
|
|
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
|
+
}
|
package/src/run-prod-app.ts
CHANGED
|
@@ -41,20 +41,16 @@ import { createSessionCallbacks } from "@cosmicdrift/kumiko-bundled-features/ses
|
|
|
41
41
|
import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
42
42
|
import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
|
|
43
43
|
import { createSseBroker, type SseBroker } from "@cosmicdrift/kumiko-framework/api";
|
|
44
|
-
import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
|
|
45
44
|
import { createDbConnection, type DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
46
45
|
import {
|
|
47
46
|
buildAppSchema,
|
|
48
47
|
createRegistry,
|
|
49
|
-
createSystemUser,
|
|
50
48
|
type EffectiveFeaturesResolver,
|
|
51
49
|
type FeatureDefinition,
|
|
52
50
|
findTierResolverUsage,
|
|
53
|
-
type Registry,
|
|
54
51
|
type TenantId,
|
|
55
52
|
type TierResolverPlugin,
|
|
56
53
|
validateBoot,
|
|
57
|
-
type WriteResult,
|
|
58
54
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
59
55
|
import {
|
|
60
56
|
type ApiEntrypoint,
|
|
@@ -86,6 +82,7 @@ import Redis from "ioredis";
|
|
|
86
82
|
import { applyBootSeeds } from "./boot/apply-boot-seeds";
|
|
87
83
|
import { ASSETS_DIR } from "./build-prod-bundle";
|
|
88
84
|
import { buildComposeAuthOptions, composeFeatures } from "./compose-features";
|
|
85
|
+
import { type ExtraRoutesSystemDeps, makeDispatchSystemWrite } from "./extra-routes-deps";
|
|
89
86
|
import { injectSchema } from "./inject-schema";
|
|
90
87
|
import { tryHonoFirst } from "./try-hono-first";
|
|
91
88
|
|
|
@@ -428,26 +425,7 @@ export type RunProdAppOptions = {
|
|
|
428
425
|
* Naming: `deps` statt `ctx` weil im Framework `ctx` der HandlerContext
|
|
429
426
|
* mit user/tenant/registry ist — hier ist der Scope absichtlich kleiner
|
|
430
427
|
* (Routes laufen außerhalb der Auth/Tenant-Pipeline). */
|
|
431
|
-
readonly extraRoutes?: (
|
|
432
|
-
app: import("hono").Hono,
|
|
433
|
-
deps: {
|
|
434
|
-
db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
|
|
435
|
-
redis: import("ioredis").default;
|
|
436
|
-
/** Feature-registry — z.B. für Plugin-Lookups via
|
|
437
|
-
* `registry.getExtensionUsages("subscriptionProvider")`. */
|
|
438
|
-
registry: Registry;
|
|
439
|
-
/** Schreibt durch den /api/*-Command-Dispatcher (gleiche Idempotency/
|
|
440
|
-
* Job-Hooks) — aber als auto-konstruierter SystemAdmin des Ziel-
|
|
441
|
-
* Tenants, OHNE Access-Check der Route. Nur für Pfade, die ihre
|
|
442
|
-
* Authentizität selbst beweisen (Provider-Webhook-Signaturen,
|
|
443
|
-
* createSubscriptionWebhookHandler et al.). */
|
|
444
|
-
dispatchSystemWrite: (args: {
|
|
445
|
-
readonly handlerQn: string;
|
|
446
|
-
readonly payload: unknown;
|
|
447
|
-
readonly tenantId: TenantId;
|
|
448
|
-
}) => Promise<WriteResult>;
|
|
449
|
-
},
|
|
450
|
-
) => void;
|
|
428
|
+
readonly extraRoutes?: (app: import("hono").Hono, deps: ExtraRoutesSystemDeps) => void;
|
|
451
429
|
/** When true (default), Bun.serve is started before runProdApp resolves —
|
|
452
430
|
* the common case: `await runProdApp({...})` boots the server and the
|
|
453
431
|
* process stays up listening on PORT. Set to false in tests that drive
|
|
@@ -840,12 +818,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
840
818
|
db,
|
|
841
819
|
redis,
|
|
842
820
|
registry,
|
|
843
|
-
dispatchSystemWrite: (
|
|
844
|
-
entrypoint.dispatcher.write(
|
|
845
|
-
handlerQn,
|
|
846
|
-
payload,
|
|
847
|
-
createSystemUser(tenantId, [ROLES.SystemAdmin]),
|
|
848
|
-
),
|
|
821
|
+
dispatchSystemWrite: makeDispatchSystemWrite(entrypoint.dispatcher),
|
|
849
822
|
});
|
|
850
823
|
}
|
|
851
824
|
|