@cosmicdrift/kumiko-dev-server 0.37.0 → 0.38.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.38.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.37.0",
50
+ "@cosmicdrift/kumiko-framework": "0.37.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
+ }
@@ -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,
@@ -832,7 +833,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
832
833
  entrypoint.dispatcher.write(
833
834
  handlerQn,
834
835
  payload,
835
- createSystemUser(tenantId, ["SystemAdmin"]),
836
+ createSystemUser(tenantId, [ROLES.SystemAdmin]),
836
837
  ),
837
838
  });
838
839
  }
@@ -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
  );