@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 +3 -3
- package/src/__tests__/env-schema.test.ts +40 -47
- package/src/__tests__/kumiko-schema-check.test.ts +4 -5
- package/src/create-kumiko-server.ts +5 -2
- package/src/kebab.ts +7 -0
- package/src/run-dev-app.ts +6 -0
- package/src/run-prod-app.ts +14 -2
- package/src/scaffold-app-feature.ts +4 -6
- package/src/scaffold-app.ts +2 -5
- package/src/scaffold-deploy.ts +2 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dev-server",
|
|
3
|
-
"version": "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.
|
|
50
|
-
"@cosmicdrift/kumiko-framework": "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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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("
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
//
|
|
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, [
|
|
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
|
-
|
|
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
|
+
}
|
package/src/run-dev-app.ts
CHANGED
|
@@ -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: {
|
package/src/run-prod-app.ts
CHANGED
|
@@ -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, [
|
|
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
|
-
|
|
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
|
);
|
package/src/scaffold-app.ts
CHANGED
|
@@ -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 (!
|
|
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();
|
package/src/scaffold-deploy.ts
CHANGED
|
@@ -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 (!
|
|
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
|
);
|