@cosmicdrift/kumiko-dev-server 0.38.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.38.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>",
@@ -46,8 +46,8 @@
46
46
  "kumiko-schema-check": "./bin/kumiko-schema-check.ts"
47
47
  },
48
48
  "dependencies": {
49
- "@cosmicdrift/kumiko-bundled-features": "0.37.0",
50
- "@cosmicdrift/kumiko-framework": "0.37.0",
49
+ "@cosmicdrift/kumiko-bundled-features": "0.38.0",
50
+ "@cosmicdrift/kumiko-framework": "0.38.0",
51
51
  "ts-morph": "^28.0.0"
52
52
  },
53
53
  "publishConfig": {
@@ -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 { ROLES } from "@cosmicdrift/kumiko-framework/auth";
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
- redis: stack.redis,
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: ({ handlerQn, payload, tenantId }) =>
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
+ }
@@ -90,6 +90,9 @@ export type RunDevAppAuthOptions = {
90
90
  readonly signup?: SignupSetup;
91
91
  /** Tenant-Invite flow (Magic-Link). Symmetric. */
92
92
  readonly invite?: InviteSetup;
93
+ /** Domain attribute for both auth cookies (see
94
+ * AuthRoutesConfig.cookieDomain). Symmetric zu RunProdAppAuthOptions. */
95
+ readonly cookieDomain?: string;
93
96
  };
94
97
 
95
98
  /** Hook for app-specific seeding (demo data, fixtures). Runs after the
@@ -268,6 +271,9 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
268
271
  [AuthErrors.invalidCredentials]: 401,
269
272
  [AuthErrors.noMembership]: 403,
270
273
  },
274
+ ...(options.auth.cookieDomain !== undefined && {
275
+ cookieDomain: options.auth.cookieDomain,
276
+ }),
271
277
  ...sessionAuthFragment,
272
278
  ...(options.auth.passwordReset && {
273
279
  passwordReset: {
@@ -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
 
@@ -276,6 +273,10 @@ export type RunProdAppAuthOptions = {
276
273
  * /api/auth/invite-accept-with-login, /api/auth/invite-signup-complete
277
274
  * are mounted. */
278
275
  readonly invite?: InviteSetup;
276
+ /** Domain attribute for both auth cookies (see
277
+ * AuthRoutesConfig.cookieDomain). Set to the registrable parent
278
+ * domain when login and app live on different subdomains. */
279
+ readonly cookieDomain?: string;
279
280
  };
280
281
 
281
282
  /** Hook for app-specific seeding — runs after the admin (when auth is
@@ -334,6 +335,10 @@ export type HostDispatchResult =
334
335
  export type HostDispatchFn = (req: {
335
336
  readonly host: string;
336
337
  readonly path: string;
338
+ /** Query-String inkl. führendem `?`, `""` wenn keiner. Redirects die
339
+ * den Pfad auf einen anderen Host umbiegen (z.B. Auth-Routen mit
340
+ * `?token=` aus alten Mail-Links) MÜSSEN ihn an `to` anhängen. */
341
+ readonly search: string;
337
342
  }) => HostDispatchResult;
338
343
 
339
344
  export type RunProdAppOptions = {
@@ -420,26 +425,7 @@ export type RunProdAppOptions = {
420
425
  * Naming: `deps` statt `ctx` weil im Framework `ctx` der HandlerContext
421
426
  * mit user/tenant/registry ist — hier ist der Scope absichtlich kleiner
422
427
  * (Routes laufen außerhalb der Auth/Tenant-Pipeline). */
423
- readonly extraRoutes?: (
424
- app: import("hono").Hono,
425
- deps: {
426
- db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
427
- redis: import("ioredis").default;
428
- /** Feature-registry — z.B. für Plugin-Lookups via
429
- * `registry.getExtensionUsages("subscriptionProvider")`. */
430
- registry: Registry;
431
- /** Schreibt durch den /api/*-Command-Dispatcher (gleiche Idempotency/
432
- * Job-Hooks) — aber als auto-konstruierter SystemAdmin des Ziel-
433
- * Tenants, OHNE Access-Check der Route. Nur für Pfade, die ihre
434
- * Authentizität selbst beweisen (Provider-Webhook-Signaturen,
435
- * createSubscriptionWebhookHandler et al.). */
436
- dispatchSystemWrite: (args: {
437
- readonly handlerQn: string;
438
- readonly payload: unknown;
439
- readonly tenantId: TenantId;
440
- }) => Promise<WriteResult>;
441
- },
442
- ) => void;
428
+ readonly extraRoutes?: (app: import("hono").Hono, deps: ExtraRoutesSystemDeps) => void;
443
429
  /** When true (default), Bun.serve is started before runProdApp resolves —
444
430
  * the common case: `await runProdApp({...})` boots the server and the
445
431
  * process stays up listening on PORT. Set to false in tests that drive
@@ -714,6 +700,9 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
714
700
  [AuthErrors.invalidCredentials]: 401,
715
701
  [AuthErrors.noMembership]: 403,
716
702
  },
703
+ ...(options.auth.cookieDomain !== undefined && {
704
+ cookieDomain: options.auth.cookieDomain,
705
+ }),
717
706
  ...sessionAuthFragment,
718
707
  ...(options.auth.passwordReset && {
719
708
  passwordReset: {
@@ -829,12 +818,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
829
818
  db,
830
819
  redis,
831
820
  registry,
832
- dispatchSystemWrite: ({ handlerQn, payload, tenantId }) =>
833
- entrypoint.dispatcher.write(
834
- handlerQn,
835
- payload,
836
- createSystemUser(tenantId, [ROLES.SystemAdmin]),
837
- ),
821
+ dispatchSystemWrite: makeDispatchSystemWrite(entrypoint.dispatcher),
838
822
  });
839
823
  }
840
824
 
@@ -1027,7 +1011,7 @@ function buildStaticFallback(
1027
1011
  if (!hostDispatch) return null;
1028
1012
  const url = new URL(req.url);
1029
1013
  const host = req.headers.get("host") ?? url.host;
1030
- const result = hostDispatch({ host, path: url.pathname });
1014
+ const result = hostDispatch({ host, path: url.pathname, search: url.search });
1031
1015
  if (result.kind === "not-found") {
1032
1016
  return new Response("Not Found", { status: 404 });
1033
1017
  }