@cosmicdrift/kumiko-dev-server 0.158.2 → 0.160.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 +4 -4
- package/src/__tests__/compose-stacks.test.ts +13 -3
- package/src/__tests__/create-kumiko-server-errors.test.ts +63 -0
- package/src/__tests__/create-kumiko-server.integration.test.ts +121 -1
- package/src/__tests__/resolve-stylesheet.test.ts +16 -0
- package/src/__tests__/saas-identity-wire.integration.test.ts +13 -4
- package/src/__tests__/scaffold-app.test.ts +1 -0
- package/src/__tests__/walkthrough.integration.test.ts +21 -5
- package/src/run-dev-app.ts +23 -18
- package/src/scaffold-app.ts +22 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dev-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.160.0",
|
|
4
4
|
"description": "Dev-tooling for Kumiko apps: local dev-server bootstrap (runDevApp), scaffolding, codegen. Not shipped into production node_modules — see @cosmicdrift/kumiko-server-runtime for the prod boot path.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -54,9 +54,9 @@
|
|
|
54
54
|
"kumiko-schema-check": "./bin/kumiko-schema-check.ts"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
58
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
59
|
-
"@cosmicdrift/kumiko-server-runtime": "0.
|
|
57
|
+
"@cosmicdrift/kumiko-bundled-features": "0.160.0",
|
|
58
|
+
"@cosmicdrift/kumiko-framework": "0.160.0",
|
|
59
|
+
"@cosmicdrift/kumiko-server-runtime": "0.160.0",
|
|
60
60
|
"ts-morph": "^28.0.0"
|
|
61
61
|
},
|
|
62
62
|
"publishConfig": {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
3
|
+
import { createPersonalAccessTokensFeature } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
|
|
2
4
|
import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
5
|
import { composeFeatures } from "@cosmicdrift/kumiko-server-runtime/compose-features";
|
|
4
6
|
import {
|
|
@@ -110,9 +112,15 @@ describe("composeStacks", () => {
|
|
|
110
112
|
expect(names.filter((n) => n === "sessions")).toHaveLength(2);
|
|
111
113
|
expect(() =>
|
|
112
114
|
validateBoot(
|
|
113
|
-
composeFeatures(
|
|
114
|
-
|
|
115
|
-
|
|
115
|
+
composeFeatures(
|
|
116
|
+
[
|
|
117
|
+
authFoundationFeature,
|
|
118
|
+
createPersonalAccessTokensFeature({ scopes: {} }),
|
|
119
|
+
...composeIdentityStack(),
|
|
120
|
+
...composeGdprStack({ sessions: true }),
|
|
121
|
+
],
|
|
122
|
+
{ includeBundled: true },
|
|
123
|
+
),
|
|
116
124
|
),
|
|
117
125
|
).toThrow(/duplicate feature/i);
|
|
118
126
|
});
|
|
@@ -123,6 +131,8 @@ describe("composeStacks boots for real", () => {
|
|
|
123
131
|
test("studio-shaped combined stack passes validateBoot (not just name-list comparison)", () => {
|
|
124
132
|
const features = composeFeatures(
|
|
125
133
|
[
|
|
134
|
+
authFoundationFeature,
|
|
135
|
+
createPersonalAccessTokensFeature({ scopes: {} }),
|
|
126
136
|
...composeOpsStack({ rateLimiting: true }),
|
|
127
137
|
...composePagesStack(),
|
|
128
138
|
...composeMailStack({ transports: ["inmemory"] }),
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// createKumikoServer error / graceful-degradation paths — boot rejects,
|
|
2
|
+
// stylesheet pipeline failures, missing CSS route.
|
|
3
|
+
|
|
4
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
5
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import { createKumikoServer, type KumikoServerHandle } from "../create-kumiko-server";
|
|
10
|
+
|
|
11
|
+
const emptyFeature = defineFeature("dev-server-errors-probe", () => {});
|
|
12
|
+
|
|
13
|
+
let handle: KumikoServerHandle | undefined;
|
|
14
|
+
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
if (handle) {
|
|
17
|
+
await handle.stop();
|
|
18
|
+
handle = undefined;
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("createKumikoServer — client bundle failure", () => {
|
|
23
|
+
test("broken clientEntry rejects at boot with client bundle failed", async () => {
|
|
24
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "kumiko-bundle-fail-"));
|
|
25
|
+
const entry = join(tmpDir, "client.tsx");
|
|
26
|
+
writeFileSync(entry, "const x = {{{\n");
|
|
27
|
+
try {
|
|
28
|
+
await expect(
|
|
29
|
+
createKumikoServer({
|
|
30
|
+
features: [emptyFeature],
|
|
31
|
+
port: 0,
|
|
32
|
+
installSignalHandlers: false,
|
|
33
|
+
clientEntry: entry,
|
|
34
|
+
stylesheet: false,
|
|
35
|
+
}),
|
|
36
|
+
).rejects.toThrow(/client bundle failed|Bundle failed/);
|
|
37
|
+
} finally {
|
|
38
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("_buildBundle throw propagates at boot", async () => {
|
|
43
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "kumiko-stub-fail-"));
|
|
44
|
+
const entry = join(tmpDir, "client.tsx");
|
|
45
|
+
writeFileSync(entry, "// noop\n");
|
|
46
|
+
try {
|
|
47
|
+
await expect(
|
|
48
|
+
createKumikoServer({
|
|
49
|
+
features: [emptyFeature],
|
|
50
|
+
port: 0,
|
|
51
|
+
installSignalHandlers: false,
|
|
52
|
+
clientEntry: entry,
|
|
53
|
+
stylesheet: false,
|
|
54
|
+
_buildBundle: async () => {
|
|
55
|
+
throw new Error("stub build blew up");
|
|
56
|
+
},
|
|
57
|
+
}),
|
|
58
|
+
).rejects.toThrow(/stub build blew up/);
|
|
59
|
+
} finally {
|
|
60
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
@@ -336,3 +336,123 @@ describe("createKumikoServer extraRoutes-deps", () => {
|
|
|
336
336
|
expect(body.data?.roles).toContain("SystemAdmin");
|
|
337
337
|
});
|
|
338
338
|
});
|
|
339
|
+
|
|
340
|
+
describe("createKumikoServer — stylesheet tailwind failure (graceful)", () => {
|
|
341
|
+
test("missing stylesheet entry boots without CSS — GET /styles.css → 404", async () => {
|
|
342
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "kumiko-css-fail-"));
|
|
343
|
+
const entry = join(tmpDir, "client.tsx");
|
|
344
|
+
writeFileSync(entry, "export const x = 1;\n");
|
|
345
|
+
try {
|
|
346
|
+
handle = await createKumikoServer({
|
|
347
|
+
features: [probeFeature],
|
|
348
|
+
port: 0,
|
|
349
|
+
installSignalHandlers: false,
|
|
350
|
+
clientEntry: entry,
|
|
351
|
+
stylesheet: join(tmpDir, "does-not-exist.css"),
|
|
352
|
+
_buildBundle: async () => ({ js: "// stub", map: "" }),
|
|
353
|
+
});
|
|
354
|
+
const cssRes = await handle.fetch(new Request("http://localhost/styles.css"));
|
|
355
|
+
expect(cssRes.status).toBe(404);
|
|
356
|
+
expect(await cssRes.text()).toBe("no stylesheet");
|
|
357
|
+
} finally {
|
|
358
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
describe("createKumikoServer — real Bun.build (buildClient)", () => {
|
|
364
|
+
test("clientEntry without _buildBundle produces a JS bundle via Bun.build", async () => {
|
|
365
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "kumiko-real-build-"));
|
|
366
|
+
const entry = join(tmpDir, "client.tsx");
|
|
367
|
+
// Minimal entry Bun.build can emit — no JSX, no imports.
|
|
368
|
+
writeFileSync(entry, "export const ping = 1;\n");
|
|
369
|
+
// Empty dirs matching a glob — exercises expandWatchPatterns without
|
|
370
|
+
// writing files (avoids process.exit(75) from bare .tsx events).
|
|
371
|
+
mkdirSync(join(tmpDir, "pkg-a"));
|
|
372
|
+
mkdirSync(join(tmpDir, "pkg-b"));
|
|
373
|
+
try {
|
|
374
|
+
handle = await createKumikoServer({
|
|
375
|
+
features: [probeFeature],
|
|
376
|
+
port: 0,
|
|
377
|
+
installSignalHandlers: false,
|
|
378
|
+
clientEntry: entry,
|
|
379
|
+
stylesheet: false,
|
|
380
|
+
watchDirs: [join(tmpDir, "pkg-*")],
|
|
381
|
+
});
|
|
382
|
+
const res = await handle.fetch(new Request("http://localhost/client.js"));
|
|
383
|
+
expect(res.status).toBe(200);
|
|
384
|
+
expect(res.headers.get("content-type")).toMatch(/application\/javascript/);
|
|
385
|
+
const body = await res.text();
|
|
386
|
+
expect(body.length).toBeGreaterThan(0);
|
|
387
|
+
expect(body).toMatch(/ping/);
|
|
388
|
+
} finally {
|
|
389
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
describe("createKumikoServer — hot-reload broadcast", () => {
|
|
395
|
+
test("file change under web/ rebuilds and broadcasts SSE reload", async () => {
|
|
396
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "kumiko-watch-"));
|
|
397
|
+
const entry = join(tmpDir, "client.tsx");
|
|
398
|
+
const webDir = join(tmpDir, "web");
|
|
399
|
+
mkdirSync(webDir);
|
|
400
|
+
writeFileSync(entry, "export const ping = 1;\n");
|
|
401
|
+
|
|
402
|
+
let builds = 0;
|
|
403
|
+
try {
|
|
404
|
+
handle = await createKumikoServer({
|
|
405
|
+
features: [probeFeature],
|
|
406
|
+
port: 0,
|
|
407
|
+
installSignalHandlers: false,
|
|
408
|
+
clientEntry: entry,
|
|
409
|
+
stylesheet: false,
|
|
410
|
+
// Only the entry dir is watched (no extra watchDirs) — a nested
|
|
411
|
+
// web/page.tsx event arrives as "web/page.tsx" → hot-reload.
|
|
412
|
+
// Watching web/ separately would fire bare "page.tsx" → restart
|
|
413
|
+
// → process.exit(75) and kill the test runner.
|
|
414
|
+
_buildBundle: async () => {
|
|
415
|
+
builds += 1;
|
|
416
|
+
return { js: `// build-${builds}`, map: "" };
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
const sseRes = await handle.fetch(new Request("http://localhost/_reload"));
|
|
421
|
+
expect(sseRes.status).toBe(200);
|
|
422
|
+
const reader = sseRes.body?.getReader();
|
|
423
|
+
expect(reader).toBeDefined();
|
|
424
|
+
if (!reader) return;
|
|
425
|
+
|
|
426
|
+
await reader.read(); // drain connected comment
|
|
427
|
+
|
|
428
|
+
const initialBuilds = builds;
|
|
429
|
+
writeFileSync(join(webDir, "page.tsx"), "export const x = 1;\n");
|
|
430
|
+
|
|
431
|
+
const deadline = Date.now() + 3000;
|
|
432
|
+
let sawReload = false;
|
|
433
|
+
while (Date.now() < deadline && !sawReload) {
|
|
434
|
+
const readPromise = reader.read();
|
|
435
|
+
const timeout = new Promise<{ done: true; value: undefined }>((resolve) =>
|
|
436
|
+
setTimeout(() => resolve({ done: true, value: undefined }), 200),
|
|
437
|
+
);
|
|
438
|
+
const { value, done } = await Promise.race([readPromise, timeout]);
|
|
439
|
+
if (done || value === undefined) continue;
|
|
440
|
+
const chunk = new TextDecoder().decode(value);
|
|
441
|
+
if (chunk.includes("event: reload")) sawReload = true;
|
|
442
|
+
}
|
|
443
|
+
await reader.cancel();
|
|
444
|
+
|
|
445
|
+
expect(builds).toBeGreaterThan(initialBuilds);
|
|
446
|
+
expect(sawReload).toBe(true);
|
|
447
|
+
|
|
448
|
+
const js = await handle.fetch(new Request("http://localhost/client.js"));
|
|
449
|
+
expect(await js.text()).toMatch(/build-/);
|
|
450
|
+
|
|
451
|
+
// Abort watchers before teardown rmSync can fire a restart event.
|
|
452
|
+
await handle.stop();
|
|
453
|
+
handle = undefined;
|
|
454
|
+
} finally {
|
|
455
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
});
|
|
@@ -64,6 +64,22 @@ describe("resolveStylesheet", () => {
|
|
|
64
64
|
}
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
+
test("Bun.resolveSync failure → undefined (catch path, no throw)", () => {
|
|
68
|
+
const tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "kumiko-resolve-catch-")));
|
|
69
|
+
const cwdBefore = process.cwd();
|
|
70
|
+
process.chdir(tmpDir);
|
|
71
|
+
try {
|
|
72
|
+
const out = resolveStylesheet({
|
|
73
|
+
features: [],
|
|
74
|
+
clientEntry: "./entry.tsx",
|
|
75
|
+
});
|
|
76
|
+
expect(out).toBeUndefined();
|
|
77
|
+
} finally {
|
|
78
|
+
process.chdir(cwdBefore);
|
|
79
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
67
83
|
test("undefined + clientEntry + src/styles.css existiert → returns App-Theme-Override", () => {
|
|
68
84
|
// Auto-Detection greift VOR dem renderer-web-Fallback: Wenn die App
|
|
69
85
|
// ein eigenes src/styles.css hat (App-Theme-Pattern), wird das
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
seedAdmin,
|
|
13
13
|
seedUserWithPassword,
|
|
14
14
|
} from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
|
|
15
|
+
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
15
16
|
import {
|
|
16
17
|
AuthMfaHandlers,
|
|
17
18
|
base32Decode,
|
|
@@ -94,6 +95,13 @@ async function markVerified(userId: string): Promise<void> {
|
|
|
94
95
|
await updateRows(stack.db, userTable, { emailVerified: true }, { id: userId });
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
// writeOk drives real HTTP through the auth middleware, so a hand-built
|
|
99
|
+
// actor needs a live sid — sessionChecker rejects sidless JWTs.
|
|
100
|
+
async function withSession(user: SessionUser): Promise<SessionUser> {
|
|
101
|
+
const sid = await callbacks.get().sessionCreator(user, { ip: "127.0.0.1", userAgent: "test" });
|
|
102
|
+
return { ...user, sid };
|
|
103
|
+
}
|
|
104
|
+
|
|
97
105
|
beforeAll(async () => {
|
|
98
106
|
configureEntityFieldEncryption(createTestEnvelopeCipher());
|
|
99
107
|
const bound = sessionCallbacksFromLateBound(callbacks);
|
|
@@ -110,6 +118,7 @@ beforeAll(async () => {
|
|
|
110
118
|
|
|
111
119
|
const features = composeFeatures(
|
|
112
120
|
[
|
|
121
|
+
authFoundationFeature,
|
|
113
122
|
...identity,
|
|
114
123
|
...composeOpsStack({ delivery: true, audit: false, jobs: false }),
|
|
115
124
|
...composeRendererStack(),
|
|
@@ -147,8 +156,8 @@ beforeAll(async () => {
|
|
|
147
156
|
}),
|
|
148
157
|
authConfig: {
|
|
149
158
|
...bound.asAuthConfig(),
|
|
150
|
-
//
|
|
151
|
-
//
|
|
159
|
+
// sessionChecker rejects sidless JWTs — hand-built actors used with
|
|
160
|
+
// writeOk go through withSession() to get a live sid first.
|
|
152
161
|
membershipQuery: "tenant:query:memberships",
|
|
153
162
|
loginHandler: AuthHandlers.login,
|
|
154
163
|
mfaVerifyHandler: AuthMfaHandlers.verify,
|
|
@@ -293,7 +302,7 @@ describe("saas-identity-wire", () => {
|
|
|
293
302
|
});
|
|
294
303
|
await markVerified(adminId);
|
|
295
304
|
|
|
296
|
-
const admin
|
|
305
|
+
const admin = await withSession({ id: adminId, tenantId, roles: ["Admin"] });
|
|
297
306
|
const invitee = "carol-wire@example.com";
|
|
298
307
|
await stack.http.writeOk(AuthHandlers.inviteCreate, { email: invitee, role: "User" }, admin);
|
|
299
308
|
expect(emailTransport.sent.length).toBeGreaterThanOrEqual(1);
|
|
@@ -368,7 +377,7 @@ describe("saas-identity-wire", () => {
|
|
|
368
377
|
],
|
|
369
378
|
});
|
|
370
379
|
await markVerified(userId);
|
|
371
|
-
const user
|
|
380
|
+
const user = await withSession({ id: userId, tenantId, roles: ["User"] });
|
|
372
381
|
|
|
373
382
|
const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
|
|
374
383
|
AuthMfaHandlers.enableStart,
|
|
@@ -92,6 +92,7 @@ describe("scaffoldApp", () => {
|
|
|
92
92
|
|
|
93
93
|
const kumikoBin = readFileSync(join(dest, "bin/kumiko.ts"), "utf-8");
|
|
94
94
|
expect(kumikoBin).toContain("runSchemaCli");
|
|
95
|
+
expect(kumikoBin).toContain("runConsumerCli");
|
|
95
96
|
expect(kumikoBin).toContain("includeBundled: HAS_AUTH");
|
|
96
97
|
});
|
|
97
98
|
|
|
@@ -13,6 +13,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
13
13
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
14
14
|
import { tmpdir } from "node:os";
|
|
15
15
|
import { join } from "node:path";
|
|
16
|
+
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
17
|
+
import { createPersonalAccessTokensFeature } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
|
|
16
18
|
import { createSecretsFeature } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
17
19
|
import { createSessionsFeature } from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
18
20
|
import { createRegistry, defineFeature, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -77,18 +79,32 @@ describe("walkthrough — DX-3.1 snapshot", () => {
|
|
|
77
79
|
// a dummy defineFeature here — the scaffold-side of "notesFeature"
|
|
78
80
|
// (file-content) is pinned in test 2; this test pins the runtime-side
|
|
79
81
|
// (composeFeatures auto-prepend behaviour the walkthrough claims).
|
|
82
|
+
//
|
|
83
|
+
// sessions now requires auth-foundation (#1370/#1371), which itself
|
|
84
|
+
// needs a tokenVerifier provider mounted (#1368) — PAT here, mirroring
|
|
85
|
+
// the scaffold picker's real-world default. Not part of the walkthrough's
|
|
86
|
+
// advertised 7-feature count (that count is about includeBundled's
|
|
87
|
+
// auto-mount, unrelated to this coupling), so both are added on top.
|
|
80
88
|
const notesFeature = defineFeature("notes", () => {});
|
|
81
|
-
const APP_FEATURES = [
|
|
89
|
+
const APP_FEATURES = [
|
|
90
|
+
createSecretsFeature(),
|
|
91
|
+
authFoundationFeature,
|
|
92
|
+
createPersonalAccessTokensFeature({ scopes: {} }),
|
|
93
|
+
createSessionsFeature(),
|
|
94
|
+
notesFeature,
|
|
95
|
+
];
|
|
82
96
|
|
|
83
97
|
const composed = composeFeatures(APP_FEATURES, { includeBundled: true });
|
|
84
|
-
//
|
|
85
|
-
expect(composed.length).toBe(
|
|
98
|
+
// 5 explicit + 4 auto-mounted bundled = 9 total features.
|
|
99
|
+
expect(composed.length).toBe(9);
|
|
86
100
|
|
|
87
101
|
const composedNames = composed.map((f) => f.name).sort();
|
|
88
102
|
expect(composedNames).toEqual([
|
|
89
103
|
"auth-email-password",
|
|
104
|
+
"auth-foundation",
|
|
90
105
|
"config",
|
|
91
106
|
"notes",
|
|
107
|
+
"personal-access-tokens",
|
|
92
108
|
"secrets",
|
|
93
109
|
"sessions",
|
|
94
110
|
"tenant",
|
|
@@ -97,9 +113,9 @@ describe("walkthrough — DX-3.1 snapshot", () => {
|
|
|
97
113
|
|
|
98
114
|
// validateBoot must pass (no missing-requires, no schema-errors).
|
|
99
115
|
expect(() => validateBoot(composed)).not.toThrow();
|
|
100
|
-
// Registry must contain all
|
|
116
|
+
// Registry must contain all 9 features.
|
|
101
117
|
const registry = createRegistry(composed);
|
|
102
|
-
expect(registry.features.size).toBe(
|
|
118
|
+
expect(registry.features.size).toBe(9);
|
|
103
119
|
});
|
|
104
120
|
|
|
105
121
|
test("bin/main.ts contains the auth.admin stub the walkthrough relies on", async () => {
|
package/src/run-dev-app.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type SeedAdminOptions,
|
|
19
19
|
seedAdmin,
|
|
20
20
|
} from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
|
|
21
|
+
import { resolveTokenVerifier } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
21
22
|
import {
|
|
22
23
|
AUTH_MFA_FEATURE,
|
|
23
24
|
AuthMfaHandlers,
|
|
@@ -28,10 +29,8 @@ import {
|
|
|
28
29
|
createConfigResolver,
|
|
29
30
|
} from "@cosmicdrift/kumiko-bundled-features/config";
|
|
30
31
|
import {
|
|
31
|
-
createPatResolver,
|
|
32
32
|
PAT_FEATURE,
|
|
33
33
|
patRateLimitFromFeature,
|
|
34
|
-
patScopesFromFeature,
|
|
35
34
|
} from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
|
|
36
35
|
import {
|
|
37
36
|
bindAutoRevokeFromFeature,
|
|
@@ -44,7 +43,7 @@ import {
|
|
|
44
43
|
resolveTenantLifecycleGate,
|
|
45
44
|
TENANT_LIFECYCLE_FEATURE,
|
|
46
45
|
} from "@cosmicdrift/kumiko-bundled-features/tenant-lifecycle";
|
|
47
|
-
import type {
|
|
46
|
+
import type { SessionMetadata, TokenVerifier } from "@cosmicdrift/kumiko-framework/api";
|
|
48
47
|
import { createInMemoryLoginRateLimiter } from "@cosmicdrift/kumiko-framework/api";
|
|
49
48
|
import {
|
|
50
49
|
configureBlindIndexKey,
|
|
@@ -89,6 +88,7 @@ import { renderWelcomeBanner } from "./welcome-banner";
|
|
|
89
88
|
// @cosmicdrift/kumiko-server-runtime (single source of truth) — hier nur
|
|
90
89
|
// durchgereicht.
|
|
91
90
|
export type {
|
|
91
|
+
AccountUnlockSetup,
|
|
92
92
|
AuthMailOptions,
|
|
93
93
|
EmailVerificationSetup,
|
|
94
94
|
InviteSetup,
|
|
@@ -97,6 +97,7 @@ export type {
|
|
|
97
97
|
} from "@cosmicdrift/kumiko-server-runtime/run-prod-app";
|
|
98
98
|
|
|
99
99
|
import type {
|
|
100
|
+
AccountUnlockSetup,
|
|
100
101
|
AuthMailOptions,
|
|
101
102
|
EmailVerificationSetup,
|
|
102
103
|
InviteSetup,
|
|
@@ -120,7 +121,7 @@ export type RunDevAppAuthOptions = {
|
|
|
120
121
|
/** Opt-in: revocable server-side sessions. Caller MUSS
|
|
121
122
|
* `createSessionsFeature()` zu `features` adden — runDevApp wired
|
|
122
123
|
* hier nur die Auth-Callbacks (creator/revoker/checker) gegen
|
|
123
|
-
* stack.db
|
|
124
|
+
* stack.db (sidless JWTs werden dann abgelehnt).
|
|
124
125
|
*
|
|
125
126
|
* Standardverhalten ohne diese Option: stateless JWTs ohne sid,
|
|
126
127
|
* Logout ist client-side cookie-clear, Kartenhaus existing-Apps
|
|
@@ -145,6 +146,8 @@ export type RunDevAppAuthOptions = {
|
|
|
145
146
|
readonly signup?: SignupSetup;
|
|
146
147
|
/** Tenant-Invite flow (Magic-Link). Symmetric. */
|
|
147
148
|
readonly invite?: InviteSetup;
|
|
149
|
+
/** Account-unlock flow (#1266). Symmetric zu RunProdAppAuthOptions. */
|
|
150
|
+
readonly accountUnlock?: AccountUnlockSetup;
|
|
148
151
|
/** Domain attribute for both auth cookies (see
|
|
149
152
|
* AuthRoutesConfig.cookieDomain). Symmetric zu RunProdAppAuthOptions. */
|
|
150
153
|
readonly cookieDomain?: string;
|
|
@@ -406,21 +409,21 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
406
409
|
}
|
|
407
410
|
return sessionCallbacks;
|
|
408
411
|
};
|
|
409
|
-
//
|
|
410
|
-
// db (only concrete after setupTestStack). Wired
|
|
411
|
-
//
|
|
412
|
-
let
|
|
412
|
+
// Token-verifier opt-in: same late-bound holder pattern — resolveTokenVerifier
|
|
413
|
+
// needs the real db+registry (only concrete after setupTestStack). Wired
|
|
414
|
+
// whenever a provider feature (personal-access-tokens today) is mounted.
|
|
415
|
+
let tokenVerifier: TokenVerifier | undefined;
|
|
413
416
|
let lifecycleDb: DbConnection | undefined;
|
|
414
417
|
const patFeature = features.find((f) => f.name === PAT_FEATURE);
|
|
415
418
|
const mfaFeature = features.find((f) => f.name === AUTH_MFA_FEATURE);
|
|
416
419
|
const tenantLifecycleFeature = features.find((f) => f.name === TENANT_LIFECYCLE_FEATURE);
|
|
417
420
|
const patAuthFragment = patFeature
|
|
418
421
|
? {
|
|
419
|
-
|
|
420
|
-
if (!
|
|
421
|
-
throw new Error("[runDevApp]
|
|
422
|
+
tokenVerifier: (rawToken: string) => {
|
|
423
|
+
if (!tokenVerifier) {
|
|
424
|
+
throw new Error("[runDevApp] token-verifier accessed before onAfterSetup");
|
|
422
425
|
}
|
|
423
|
-
return
|
|
426
|
+
return tokenVerifier(rawToken);
|
|
424
427
|
},
|
|
425
428
|
patRateLimiter: (() => {
|
|
426
429
|
const rl = patRateLimitFromFeature(patFeature);
|
|
@@ -449,11 +452,6 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
449
452
|
sessionRevoker: (sid: string) => requireSessions().sessionRevoker(sid),
|
|
450
453
|
sessionChecker: (sid: string, userId: string) =>
|
|
451
454
|
requireSessions().sessionChecker(sid, userId),
|
|
452
|
-
// strict-mode: jede neue Plattform-App startet ohne legacy-
|
|
453
|
-
// JWTs ohne sid, daher safe als Default. Wer Sessions opt-in
|
|
454
|
-
// wählt, will explizite Server-side Revocation — strict-mode
|
|
455
|
-
// ist der einzige Modus der das tatsächlich erzwingt.
|
|
456
|
-
sessionStrictMode: true,
|
|
457
455
|
}
|
|
458
456
|
: {};
|
|
459
457
|
|
|
@@ -509,6 +507,12 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
509
507
|
confirmHandler: AuthHandlers.verifyEmail,
|
|
510
508
|
},
|
|
511
509
|
}),
|
|
510
|
+
...(effectiveAuth.accountUnlock && {
|
|
511
|
+
accountUnlock: {
|
|
512
|
+
requestHandler: AuthHandlers.requestAccountUnlock,
|
|
513
|
+
confirmHandler: AuthHandlers.confirmAccountUnlock,
|
|
514
|
+
},
|
|
515
|
+
}),
|
|
512
516
|
...(effectiveAuth.signup && {
|
|
513
517
|
signup: {
|
|
514
518
|
requestHandler: AuthHandlers.signupRequest,
|
|
@@ -558,7 +562,8 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
558
562
|
}
|
|
559
563
|
}
|
|
560
564
|
if (patFeature) {
|
|
561
|
-
|
|
565
|
+
tokenVerifier = (rawToken) =>
|
|
566
|
+
resolveTokenVerifier({ db: stack.db, registry: stack.registry }, rawToken);
|
|
562
567
|
}
|
|
563
568
|
if (effectiveAuth) {
|
|
564
569
|
await seedAdmin(stack.db, effectiveAuth.admin);
|
package/src/scaffold-app.ts
CHANGED
|
@@ -42,6 +42,8 @@ export type ScaffoldFeatureEntry = {
|
|
|
42
42
|
readonly importPath: string;
|
|
43
43
|
readonly exportName: string;
|
|
44
44
|
readonly callExpression: string;
|
|
45
|
+
/** Runtime args for factory-style exports that need config the codegen text can't parse back out (e.g. `{ scopes: {} }`). Omit for zero-arg factories and object-style exports. */
|
|
46
|
+
readonly callArgs?: readonly unknown[];
|
|
45
47
|
};
|
|
46
48
|
|
|
47
49
|
export type ScaffoldAppOptions = {
|
|
@@ -765,7 +767,7 @@ deploys. Build context = app repo root; migrations ship in \`kumiko/migrations/\
|
|
|
765
767
|
- \`kumiko/schema.ts\` — same feature set → \`ENTITY_METAS\` for \`kumiko schema\`.
|
|
766
768
|
- \`bin/dev.ts\` — dev-server entry (\`bun dev\`).
|
|
767
769
|
- \`bin/main.ts\` — production-bootstrap (\`bun run start\`).
|
|
768
|
-
- \`bin/kumiko.ts\` — schema-CLI bundled into \`dist-server/kumiko.js\`.
|
|
770
|
+
- \`bin/kumiko.ts\` — schema + consumer-ops CLI bundled into \`dist-server/kumiko.js\`.
|
|
769
771
|
- \`docker-compose.yml\` — local Postgres + Redis for \`bun dev\`.
|
|
770
772
|
|
|
771
773
|
For full docs see https://docs.kumiko.rocks.
|
|
@@ -806,25 +808,32 @@ function renderBinKumiko(): string {
|
|
|
806
808
|
return [
|
|
807
809
|
"#!/usr/bin/env bun",
|
|
808
810
|
"",
|
|
809
|
-
"// Standalone kumiko
|
|
810
|
-
"//
|
|
811
|
-
"//
|
|
811
|
+
"// Standalone kumiko CLI for the production bundle. The deploy migrate-step",
|
|
812
|
+
"// runs `bun /app/kumiko.js schema apply`; ops runs `bun /app/kumiko.js",
|
|
813
|
+
"// consumer status|restart <name>` to recover a dead event consumer without",
|
|
814
|
+
"// ad-hoc SQL. kumiko-build bundles this file to dist-server/kumiko.js.",
|
|
812
815
|
"",
|
|
813
816
|
'import { composeFeatures } from "@cosmicdrift/kumiko-server-runtime/compose-features";',
|
|
817
|
+
'import { runConsumerCli } from "@cosmicdrift/kumiko-framework/consumer-cli";',
|
|
814
818
|
'import { runSchemaCli } from "@cosmicdrift/kumiko-framework/schema-cli";',
|
|
815
819
|
'import { APP_FEATURES, HAS_AUTH } from "../src/run-config";',
|
|
816
820
|
"",
|
|
817
821
|
"const [, , cmd, ...rest] = Bun.argv;",
|
|
818
|
-
|
|
819
|
-
"
|
|
820
|
-
|
|
821
|
-
"
|
|
822
|
+
"// biome-ignore lint/suspicious/noConsole: CLI output is the feature.",
|
|
823
|
+
"const out = { log: (l: string) => console.log(l), err: (l: string) => console.error(l) };",
|
|
824
|
+
"",
|
|
825
|
+
'if (cmd === "schema") {',
|
|
826
|
+
" const features = composeFeatures([...APP_FEATURES], { includeBundled: HAS_AUTH });",
|
|
827
|
+
" process.exit(await runSchemaCli(rest, process.env.INIT_CWD ?? process.cwd(), out, { features }));",
|
|
828
|
+
"}",
|
|
829
|
+
"",
|
|
830
|
+
'if (cmd === "consumer") {',
|
|
831
|
+
" process.exit(await runConsumerCli(rest, out));",
|
|
822
832
|
"}",
|
|
823
833
|
"",
|
|
824
|
-
"const features = composeFeatures([...APP_FEATURES], { includeBundled: HAS_AUTH });",
|
|
825
834
|
"// biome-ignore lint/suspicious/noConsole: CLI output is the feature.",
|
|
826
|
-
"
|
|
827
|
-
"process.exit(
|
|
835
|
+
'console.error("\\n Unknown: kumiko " + (cmd ?? "") + " — only \'kumiko schema <sub>\' or \'kumiko consumer <sub>\' in the standalone bundle.\\n");',
|
|
836
|
+
"process.exit(1);",
|
|
828
837
|
"",
|
|
829
838
|
].join("\n");
|
|
830
839
|
}
|
|
@@ -843,11 +852,8 @@ async function instantiateScaffoldFeatures(
|
|
|
843
852
|
`scaffoldApp: ${entry.importPath} missing export ${entry.exportName} for ${entry.callExpression}`,
|
|
844
853
|
);
|
|
845
854
|
}
|
|
846
|
-
if (
|
|
847
|
-
|
|
848
|
-
throw new Error(`scaffoldApp: ${entry.exportName} is not callable (${entry.importPath})`);
|
|
849
|
-
}
|
|
850
|
-
instances.push((exp as () => FeatureDefinition)());
|
|
855
|
+
if (typeof exp === "function") {
|
|
856
|
+
instances.push((exp as (...args: unknown[]) => FeatureDefinition)(...(entry.callArgs ?? [])));
|
|
851
857
|
} else {
|
|
852
858
|
instances.push(exp as FeatureDefinition);
|
|
853
859
|
}
|