@cosmicdrift/kumiko-dev-server 0.37.0 → 0.39.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.37.0",
3
+ "version": "0.39.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.35.0",
50
- "@cosmicdrift/kumiko-framework": "0.35.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": {
@@ -3,6 +3,20 @@ import { composeEnvSchema, KumikoBootError, parseEnv } from "@cosmicdrift/kumiko
3
3
  import { z } from "zod";
4
4
  import { type FrameworkCoreEnv, frameworkCoreEnvSchema } from "../env-schema";
5
5
 
6
+ // Statt Sentinel-throw IM try (ein nicht-werfender parseEnv ließe den
7
+ // Sentinel in den catch fallen und produziert eine irreführende
8
+ // instanceOf-Failure): laufen lassen, danach unconditional asserten.
9
+ function expectBootError(run: () => void): KumikoBootError {
10
+ let thrown: unknown;
11
+ try {
12
+ run();
13
+ } catch (err) {
14
+ thrown = err;
15
+ }
16
+ expect(thrown).toBeInstanceOf(KumikoBootError);
17
+ return thrown as KumikoBootError;
18
+ }
19
+
6
20
  describe("frameworkCoreEnvSchema", () => {
7
21
  it("accepts a valid minimal env", () => {
8
22
  const env = parseEnv(frameworkCoreEnvSchema, {
@@ -18,32 +32,23 @@ describe("frameworkCoreEnvSchema", () => {
18
32
  });
19
33
 
20
34
  it("aggregates missing required vars (DATABASE_URL + REDIS_URL)", () => {
21
- try {
22
- parseEnv(frameworkCoreEnvSchema, {});
23
- throw new Error("should have thrown");
24
- } catch (err) {
25
- expect(err).toBeInstanceOf(KumikoBootError);
26
- const boot = err as KumikoBootError;
27
- const names = boot.errors.map((e) => e.name).sort();
28
- expect(names).toContain("DATABASE_URL");
29
- expect(names).toContain("REDIS_URL");
30
- // PORT has a default → not in the error set
31
- expect(names).not.toContain("PORT");
32
- }
35
+ const boot = expectBootError(() => parseEnv(frameworkCoreEnvSchema, {}));
36
+ const names = boot.errors.map((e) => e.name).sort();
37
+ expect(names).toContain("DATABASE_URL");
38
+ expect(names).toContain("REDIS_URL");
39
+ // PORT has a default → not in the error set
40
+ expect(names).not.toContain("PORT");
33
41
  });
34
42
 
35
43
  it("rejects a non-postgres DATABASE_URL even when it is a valid WHATWG URL", () => {
36
- try {
44
+ const boot = expectBootError(() =>
37
45
  parseEnv(frameworkCoreEnvSchema, {
38
46
  DATABASE_URL: "https://example.com/db",
39
47
  REDIS_URL: "redis://localhost:6379",
40
- });
41
- throw new Error("should have thrown");
42
- } catch (err) {
43
- expect(err).toBeInstanceOf(KumikoBootError);
44
- const db = (err as KumikoBootError).errors.find((e) => e.name === "DATABASE_URL");
45
- expect(db?.kind).toBe("invalid");
46
- }
48
+ }),
49
+ );
50
+ const db = boot.errors.find((e) => e.name === "DATABASE_URL");
51
+ expect(db?.kind).toBe("invalid");
47
52
  });
48
53
 
49
54
  it("accepts postgres:// and postgresql:// for DATABASE_URL", () => {
@@ -57,17 +62,14 @@ describe("frameworkCoreEnvSchema", () => {
57
62
  });
58
63
 
59
64
  it("rejects a non-redis REDIS_URL and accepts redis:// + rediss://", () => {
60
- try {
65
+ const boot = expectBootError(() =>
61
66
  parseEnv(frameworkCoreEnvSchema, {
62
67
  DATABASE_URL: "postgres://localhost:5432/db",
63
68
  REDIS_URL: "https://example.com/cache",
64
- });
65
- throw new Error("should have thrown");
66
- } catch (err) {
67
- expect(err).toBeInstanceOf(KumikoBootError);
68
- const redis = (err as KumikoBootError).errors.find((e) => e.name === "REDIS_URL");
69
- expect(redis?.kind).toBe("invalid");
70
- }
69
+ }),
70
+ );
71
+ const redis = boot.errors.find((e) => e.name === "REDIS_URL");
72
+ expect(redis?.kind).toBe("invalid");
71
73
  for (const url of ["redis://localhost:6379", "rediss://localhost:6379"]) {
72
74
  const env = parseEnv(frameworkCoreEnvSchema, {
73
75
  DATABASE_URL: "postgres://localhost:5432/db",
@@ -78,18 +80,15 @@ describe("frameworkCoreEnvSchema", () => {
78
80
  });
79
81
 
80
82
  it("rejects an invalid PORT (non-numeric)", () => {
81
- try {
83
+ const boot = expectBootError(() =>
82
84
  parseEnv(frameworkCoreEnvSchema, {
83
85
  DATABASE_URL: "postgres://localhost:5432/db",
84
86
  REDIS_URL: "redis://localhost:6379",
85
87
  PORT: "not-a-port",
86
- });
87
- throw new Error("should have thrown");
88
- } catch (err) {
89
- expect(err).toBeInstanceOf(KumikoBootError);
90
- const port = (err as KumikoBootError).errors.find((e) => e.name === "PORT");
91
- expect(port?.kind).toBe("invalid");
92
- }
88
+ }),
89
+ );
90
+ const port = boot.errors.find((e) => e.name === "PORT");
91
+ expect(port?.kind).toBe("invalid");
93
92
  });
94
93
 
95
94
  it("KUMIKO_SKIP_ES_OPS accepts any string (matches runtime semantics)", () => {
@@ -112,17 +111,11 @@ describe("frameworkCoreEnvSchema", () => {
112
111
  expect(sources["DATABASE_URL"]).toBe("framework-core");
113
112
  expect(sources["PORT"]).toBe("framework-core");
114
113
  expect(sources["STUDIO_ADMIN_EMAIL"]).toBe("app");
115
-
116
- try {
117
- parseEnv(schema, {}, { sources });
118
- throw new Error("should have thrown");
119
- } catch (err) {
120
- const boot = err as KumikoBootError;
121
- const formatted = boot.format();
122
- expect(formatted).toContain("✗ DATABASE_URL (framework-core, required, missing)");
123
- expect(formatted).toContain("✗ REDIS_URL (framework-core, required, missing)");
124
- expect(formatted).toContain("✗ STUDIO_ADMIN_EMAIL (app, required, missing)");
125
- }
114
+ const boot = expectBootError(() => parseEnv(schema, {}, { sources }));
115
+ const formatted = boot.format();
116
+ expect(formatted).toContain("✗ DATABASE_URL (framework-core, required, missing)");
117
+ expect(formatted).toContain("✗ REDIS_URL (framework-core, required, missing)");
118
+ expect(formatted).toContain("✗ STUDIO_ADMIN_EMAIL (app, required, missing)");
126
119
  });
127
120
 
128
121
  it("z.infer<typeof frameworkCoreEnvSchema> typechecks the expected shape", () => {
@@ -4,7 +4,6 @@ import { describe, expect, test } from "bun:test";
4
4
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
- import { composeFeatures } from "../compose-features";
8
7
  import { implicitAuthModeFeatureNames, resolveGeneratePath } from "../schema-check-core";
9
8
 
10
9
  describe("resolveGeneratePath", () => {
@@ -43,10 +42,10 @@ describe("resolveGeneratePath", () => {
43
42
  });
44
43
 
45
44
  describe("implicitAuthModeFeatureNames", () => {
46
- test("matches composeFeatures' auth-mode prepend exactly (no hardcoded drift)", () => {
47
- const fromCompose = composeFeatures([], { includeBundled: true }).map((f) => f.name);
48
- expect(implicitAuthModeFeatureNames()).toEqual(fromCompose);
49
- // Sanity: the current bundled-foundation set.
45
+ test("liefert exakt das bundled-foundation-Set", () => {
46
+ // Bewusst hartkodierte Erwartung: ein Vergleich gegen composeFeatures
47
+ // wäre tautologisch (die Funktion IST dieser Ausdruck). Ändert sich das
48
+ // Foundation-Set, muss dieser Test bewusst angefasst werden.
50
49
  expect([...implicitAuthModeFeatureNames()].sort()).toEqual([
51
50
  "auth-email-password",
52
51
  "config",
@@ -20,6 +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";
23
24
  import {
24
25
  buildAppSchema,
25
26
  createSystemUser,
@@ -704,7 +705,7 @@ export async function createKumikoServer(
704
705
  redis: stack.redis,
705
706
  registry: stack.registry,
706
707
  dispatchSystemWrite: ({ handlerQn, payload, tenantId }) =>
707
- stack.dispatcher.write(handlerQn, payload, createSystemUser(tenantId, ["SystemAdmin"])),
708
+ stack.dispatcher.write(handlerQn, payload, createSystemUser(tenantId, [ROLES.SystemAdmin])),
708
709
  });
709
710
  }
710
711
 
@@ -868,7 +869,9 @@ export async function createKumikoServer(
868
869
  // staticDir-fallback") und macht r.httpRoute mit non-/api paths im
869
870
  // dev-server symmetrisch zu prod.
870
871
  if (
871
- req.method === "GET" &&
872
+ // HEAD mitnehmen — prod (runProdApp) fällt für GET UND HEAD auf die
873
+ // SPA zurück; ohne das liefert dev 404 wo prod 200 liefert.
874
+ (req.method === "GET" || req.method === "HEAD") &&
872
875
  !url.pathname.startsWith("/api/") &&
873
876
  !url.pathname.startsWith("/sse") &&
874
877
  !url.pathname.includes(".")
package/src/kebab.ts ADDED
@@ -0,0 +1,7 @@
1
+ // Segment-strict: rejects trailing/double hyphen so the name is a valid
2
+ // package-name + folder (`my-shop`, not `my-` or `my--shop`).
3
+ const KEBAB_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
4
+
5
+ export function isKebabSegment(value: string): boolean {
6
+ return KEBAB_RE.test(value);
7
+ }
@@ -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,6 +41,7 @@ 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";
44
45
  import { createDbConnection, type DbRunner } from "@cosmicdrift/kumiko-framework/db";
45
46
  import {
46
47
  buildAppSchema,
@@ -275,6 +276,10 @@ export type RunProdAppAuthOptions = {
275
276
  * /api/auth/invite-accept-with-login, /api/auth/invite-signup-complete
276
277
  * are mounted. */
277
278
  readonly invite?: InviteSetup;
279
+ /** Domain attribute for both auth cookies (see
280
+ * AuthRoutesConfig.cookieDomain). Set to the registrable parent
281
+ * domain when login and app live on different subdomains. */
282
+ readonly cookieDomain?: string;
278
283
  };
279
284
 
280
285
  /** Hook for app-specific seeding — runs after the admin (when auth is
@@ -333,6 +338,10 @@ export type HostDispatchResult =
333
338
  export type HostDispatchFn = (req: {
334
339
  readonly host: string;
335
340
  readonly path: string;
341
+ /** Query-String inkl. führendem `?`, `""` wenn keiner. Redirects die
342
+ * den Pfad auf einen anderen Host umbiegen (z.B. Auth-Routen mit
343
+ * `?token=` aus alten Mail-Links) MÜSSEN ihn an `to` anhängen. */
344
+ readonly search: string;
336
345
  }) => HostDispatchResult;
337
346
 
338
347
  export type RunProdAppOptions = {
@@ -713,6 +722,9 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
713
722
  [AuthErrors.invalidCredentials]: 401,
714
723
  [AuthErrors.noMembership]: 403,
715
724
  },
725
+ ...(options.auth.cookieDomain !== undefined && {
726
+ cookieDomain: options.auth.cookieDomain,
727
+ }),
716
728
  ...sessionAuthFragment,
717
729
  ...(options.auth.passwordReset && {
718
730
  passwordReset: {
@@ -832,7 +844,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
832
844
  entrypoint.dispatcher.write(
833
845
  handlerQn,
834
846
  payload,
835
- createSystemUser(tenantId, ["SystemAdmin"]),
847
+ createSystemUser(tenantId, [ROLES.SystemAdmin]),
836
848
  ),
837
849
  });
838
850
  }
@@ -1026,7 +1038,7 @@ function buildStaticFallback(
1026
1038
  if (!hostDispatch) return null;
1027
1039
  const url = new URL(req.url);
1028
1040
  const host = req.headers.get("host") ?? url.host;
1029
- const result = hostDispatch({ host, path: url.pathname });
1041
+ const result = hostDispatch({ host, path: url.pathname, search: url.search });
1030
1042
  if (result.kind === "not-found") {
1031
1043
  return new Response("Not Found", { status: 404 });
1032
1044
  }
@@ -14,6 +14,7 @@
14
14
  import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
15
15
  import { join, resolve } from "node:path";
16
16
  import { Project, SyntaxKind } from "ts-morph";
17
+ import { isKebabSegment } from "./kebab";
17
18
 
18
19
  export type ScaffoldAppFeatureOptions = {
19
20
  /** kebab-case feature name (e.g. "product-catalog"). */
@@ -31,13 +32,10 @@ export type ScaffoldAppFeatureResult = {
31
32
  readonly autoMounted: boolean;
32
33
  };
33
34
 
34
- // Segment-strict: rejects trailing/double hyphen (`product-`, `foo--bar`),
35
- // which kebabToCamel would otherwise turn into an invalid identifier
36
- // (`productFeature` vs the broken `product-Feature`).
37
- const KEBAB_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
38
-
39
35
  export function scaffoldAppFeature(options: ScaffoldAppFeatureOptions): ScaffoldAppFeatureResult {
40
- if (!KEBAB_RE.test(options.name)) {
36
+ // Segment-strict guard: a trailing/double hyphen (`product-`, `foo--bar`)
37
+ // would make kebabToCamel produce an invalid identifier.
38
+ if (!isKebabSegment(options.name)) {
41
39
  throw new Error(
42
40
  `scaffoldAppFeature: name must be kebab-case (a-z, 0-9, -); got "${options.name}"`,
43
41
  );
@@ -14,6 +14,7 @@
14
14
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
15
15
  import { join, resolve } from "node:path";
16
16
  import { IndentationText, Project, VariableDeclarationKind } from "ts-morph";
17
+ import { isKebabSegment } from "./kebab";
17
18
 
18
19
  export type ScaffoldAppOptions = {
19
20
  /** kebab-case app name (e.g. "my-shop"). Becomes package-name + folder. */
@@ -35,12 +36,8 @@ export type ScaffoldAppResult = {
35
36
  readonly appName: string;
36
37
  };
37
38
 
38
- // Segment-strict: rejects trailing/double hyphen so the name is a valid
39
- // package-name + folder (`my-shop`, not `my-` or `my--shop`).
40
- const KEBAB_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
41
-
42
39
  export function scaffoldApp(options: ScaffoldAppOptions): ScaffoldAppResult {
43
- if (!KEBAB_RE.test(options.name)) {
40
+ if (!isKebabSegment(options.name)) {
44
41
  throw new Error(`scaffoldApp: name must be kebab-case (a-z, 0-9, -); got "${options.name}"`);
45
42
  }
46
43
  const cwd = options.cwd ?? process.cwd();
@@ -10,6 +10,7 @@
10
10
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
11
  import { dirname, join } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
+ import { isKebabSegment } from "./kebab";
13
14
 
14
15
  export type ScaffoldDeployOptions = {
15
16
  /** App name, kebab-case (e.g. "publicstatus", "kumiko-studio"). */
@@ -64,12 +65,8 @@ const TEMPLATE_FILES = [
64
65
  { template: "migrate-step.sh.template", output: "migrate-step.sh" },
65
66
  ] as const;
66
67
 
67
- // Segment-strict: rejects trailing/double hyphen so the appName is a valid
68
- // image-tag + folder segment (`kumiko-studio`, not `kumiko-` / `kumiko--x`).
69
- const KEBAB_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
70
-
71
68
  export function scaffoldDeploy(options: ScaffoldDeployOptions): ScaffoldDeployResult {
72
- if (!KEBAB_RE.test(options.appName)) {
69
+ if (!isKebabSegment(options.appName)) {
73
70
  throw new Error(
74
71
  `scaffoldDeploy: appName must be kebab-case (a-z, 0-9, -); got "${options.appName}"`,
75
72
  );