@cosmicdrift/kumiko-dev-server 0.159.1 → 0.161.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 +4 -5
- 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 +0 -2
- package/src/compose-stacks.ts +14 -3
- package/src/create-kumiko-server.ts +2 -0
- package/src/run-dev-app.ts +31 -43
- package/src/scaffold-app.ts +4 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dev-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.161.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.161.0",
|
|
58
|
+
"@cosmicdrift/kumiko-framework": "0.161.0",
|
|
59
|
+
"@cosmicdrift/kumiko-server-runtime": "0.161.0",
|
|
60
60
|
"ts-morph": "^28.0.0"
|
|
61
61
|
},
|
|
62
62
|
"publishConfig": {
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
3
2
|
import { createPersonalAccessTokensFeature } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
|
|
4
3
|
import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
4
|
import { composeFeatures } from "@cosmicdrift/kumiko-server-runtime/compose-features";
|
|
@@ -56,6 +55,7 @@ describe("composeStacks", () => {
|
|
|
56
55
|
"data-retention",
|
|
57
56
|
"compliance-profiles",
|
|
58
57
|
"tenant-lifecycle",
|
|
58
|
+
"auth-foundation",
|
|
59
59
|
"sessions",
|
|
60
60
|
]);
|
|
61
61
|
});
|
|
@@ -87,12 +87,13 @@ describe("composeStacks", () => {
|
|
|
87
87
|
]);
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
-
test("composeIdentityStack defaults sessions
|
|
91
|
-
expect(stackFeatureNames(composeIdentityStack())).toEqual(["sessions"]);
|
|
90
|
+
test("composeIdentityStack defaults auth-foundation + sessions", () => {
|
|
91
|
+
expect(stackFeatureNames(composeIdentityStack())).toEqual(["auth-foundation", "sessions"]);
|
|
92
92
|
});
|
|
93
93
|
|
|
94
94
|
test("composeIdentityStack mounts auth-mfa when mfa options given", () => {
|
|
95
95
|
expect(stackFeatureNames(composeIdentityStack({ mfa: TEST_MFA }))).toEqual([
|
|
96
|
+
"auth-foundation",
|
|
96
97
|
"sessions",
|
|
97
98
|
"auth-mfa",
|
|
98
99
|
]);
|
|
@@ -114,7 +115,6 @@ describe("composeStacks", () => {
|
|
|
114
115
|
validateBoot(
|
|
115
116
|
composeFeatures(
|
|
116
117
|
[
|
|
117
|
-
authFoundationFeature,
|
|
118
118
|
createPersonalAccessTokensFeature({ scopes: {} }),
|
|
119
119
|
...composeIdentityStack(),
|
|
120
120
|
...composeGdprStack({ sessions: true }),
|
|
@@ -131,7 +131,6 @@ describe("composeStacks boots for real", () => {
|
|
|
131
131
|
test("studio-shaped combined stack passes validateBoot (not just name-list comparison)", () => {
|
|
132
132
|
const features = composeFeatures(
|
|
133
133
|
[
|
|
134
|
-
authFoundationFeature,
|
|
135
134
|
createPersonalAccessTokensFeature({ scopes: {} }),
|
|
136
135
|
...composeOpsStack({ rateLimiting: true }),
|
|
137
136
|
...composePagesStack(),
|
|
@@ -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,7 +12,6 @@ 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";
|
|
16
15
|
import {
|
|
17
16
|
AuthMfaHandlers,
|
|
18
17
|
base32Decode,
|
|
@@ -118,7 +117,6 @@ beforeAll(async () => {
|
|
|
118
117
|
|
|
119
118
|
const features = composeFeatures(
|
|
120
119
|
[
|
|
121
|
-
authFoundationFeature,
|
|
122
120
|
...identity,
|
|
123
121
|
...composeOpsStack({ delivery: true, audit: false, jobs: false }),
|
|
124
122
|
...composeRendererStack(),
|
package/src/compose-stacks.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// or tier maps (those stay in bin/server.ts / app run-config).
|
|
4
4
|
|
|
5
5
|
import { createAuditFeature } from "@cosmicdrift/kumiko-bundled-features/audit";
|
|
6
|
+
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
6
7
|
import {
|
|
7
8
|
type AuthMfaFeatureOptions,
|
|
8
9
|
createAuthMfaFeature,
|
|
@@ -76,10 +77,17 @@ export type OpsStackOptions = {
|
|
|
76
77
|
};
|
|
77
78
|
|
|
78
79
|
/** sessions (+ optional auth-mfa). config/user/tenant/auth-email-password stay
|
|
79
|
-
* on composeFeatures(includeBundled). Pass `mfa` options to mount TOTP.
|
|
80
|
+
* on composeFeatures(includeBundled). Pass `mfa` options to mount TOTP.
|
|
81
|
+
* When `sessions` is on (the default), auth-foundation is mounted alongside it —
|
|
82
|
+
* the framework's registry now hard-requires it (sessions.requires("auth-foundation")).
|
|
83
|
+
* Pass `providers` for the tokenVerifier(s) auth-foundation itself requires at least
|
|
84
|
+
* one of (e.g. createPersonalAccessTokensFeature({ scopes })) — PAT scopes are
|
|
85
|
+
* app-specific (which write-handlers an API token may call), so they stay caller-owned
|
|
86
|
+
* instead of a framework default. */
|
|
80
87
|
export type IdentityStackOptions = {
|
|
81
88
|
readonly sessions?: boolean;
|
|
82
89
|
readonly mfa?: AuthMfaFeatureOptions;
|
|
90
|
+
readonly providers?: readonly FeatureDefinition[];
|
|
83
91
|
};
|
|
84
92
|
|
|
85
93
|
export function stackFeatureNames(features: readonly FeatureDefinition[]): string[] {
|
|
@@ -131,7 +139,7 @@ export function composeGdprStack(options: GdprStackOptions = {}): FeatureDefinit
|
|
|
131
139
|
const out: FeatureDefinition[] =
|
|
132
140
|
order === "compliance-first" ? [compliance, retention] : [retention, compliance];
|
|
133
141
|
if (options.tenantLifecycle) out.push(createTenantLifecycleFeature());
|
|
134
|
-
if (options.sessions) out.push(createSessionsFeature());
|
|
142
|
+
if (options.sessions) out.push(authFoundationFeature, createSessionsFeature());
|
|
135
143
|
return out;
|
|
136
144
|
}
|
|
137
145
|
|
|
@@ -162,7 +170,10 @@ export function composeOpsStack(options: OpsStackOptions = {}): FeatureDefinitio
|
|
|
162
170
|
export function composeIdentityStack(options: IdentityStackOptions = {}): FeatureDefinition[] {
|
|
163
171
|
const sessions = options.sessions ?? true;
|
|
164
172
|
const out: FeatureDefinition[] = [];
|
|
165
|
-
if (sessions)
|
|
173
|
+
if (sessions) {
|
|
174
|
+
out.push(authFoundationFeature, createSessionsFeature());
|
|
175
|
+
if (options.providers) out.push(...options.providers);
|
|
176
|
+
}
|
|
166
177
|
if (options.mfa !== undefined) out.push(createAuthMfaFeature(options.mfa));
|
|
167
178
|
return out;
|
|
168
179
|
}
|
|
@@ -19,6 +19,7 @@ import { existsSync, mkdtempSync, statSync } from "node:fs";
|
|
|
19
19
|
import { readFile, watch } from "node:fs/promises";
|
|
20
20
|
import { tmpdir } from "node:os";
|
|
21
21
|
import { join, resolve } from "node:path";
|
|
22
|
+
import { resolveAnonymousAccessFromRegistry } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
22
23
|
import { type AuthRoutesConfig, generateToken } from "@cosmicdrift/kumiko-framework/api";
|
|
23
24
|
import { buildAppSchema, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
24
25
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
@@ -673,6 +674,7 @@ export async function createKumikoServer(
|
|
|
673
674
|
...(options.auth !== undefined && { authConfig: options.auth }),
|
|
674
675
|
...(options.extraContext !== undefined && { extraContext: options.extraContext }),
|
|
675
676
|
...(options.anonymousAccess !== undefined && { anonymousAccess: options.anonymousAccess }),
|
|
677
|
+
enrichAnonymousAccess: (base, deps) => resolveAnonymousAccessFromRegistry(base, deps),
|
|
676
678
|
...(options.files !== undefined && { files: options.files }),
|
|
677
679
|
...(options.effectiveFeatures !== undefined && {
|
|
678
680
|
effectiveFeatures: options.effectiveFeatures,
|
package/src/run-dev-app.ts
CHANGED
|
@@ -18,7 +18,12 @@ import {
|
|
|
18
18
|
type SeedAdminOptions,
|
|
19
19
|
seedAdmin,
|
|
20
20
|
} from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
EXT_SESSION_STORE,
|
|
23
|
+
resolveSessionStore,
|
|
24
|
+
resolveTokenVerifier,
|
|
25
|
+
type SessionStore,
|
|
26
|
+
} from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
22
27
|
import {
|
|
23
28
|
AUTH_MFA_FEATURE,
|
|
24
29
|
AuthMfaHandlers,
|
|
@@ -34,9 +39,7 @@ import {
|
|
|
34
39
|
} from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
|
|
35
40
|
import {
|
|
36
41
|
bindAutoRevokeFromFeature,
|
|
37
|
-
createSessionCallbacks,
|
|
38
42
|
SESSIONS_FEATURE,
|
|
39
|
-
type SessionCallbacks,
|
|
40
43
|
} from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
41
44
|
import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
42
45
|
import {
|
|
@@ -118,17 +121,6 @@ export type RunDevAppAuthOptions = {
|
|
|
118
121
|
/** Optional override of the login error → HTTP status map. Default
|
|
119
122
|
* maps invalidCredentials → 401, noMembership → 403. */
|
|
120
123
|
readonly loginErrorStatusMap?: Readonly<Record<string, number>>;
|
|
121
|
-
/** Opt-in: revocable server-side sessions. Caller MUSS
|
|
122
|
-
* `createSessionsFeature()` zu `features` adden — runDevApp wired
|
|
123
|
-
* hier nur die Auth-Callbacks (creator/revoker/checker) gegen
|
|
124
|
-
* stack.db (sidless JWTs werden dann abgelehnt).
|
|
125
|
-
*
|
|
126
|
-
* Standardverhalten ohne diese Option: stateless JWTs ohne sid,
|
|
127
|
-
* Logout ist client-side cookie-clear, Kartenhaus existing-Apps
|
|
128
|
-
* bleibt unangefasst. */
|
|
129
|
-
readonly sessions?: {
|
|
130
|
-
readonly expiresInMs?: number;
|
|
131
|
-
};
|
|
132
124
|
/** Auth-Mail-Convenience — symmetrisch zu RunProdAppAuthOptions.mail.
|
|
133
125
|
* Verdrahtet alle 4 Mail-Flows aus env-SMTP + Standard-Templates;
|
|
134
126
|
* hmacSecret = `JWT_SECRET`-env (Dev-Fallback wenn ungesetzt). Ohne
|
|
@@ -395,19 +387,15 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
395
387
|
return { ...boot, ...base };
|
|
396
388
|
};
|
|
397
389
|
|
|
398
|
-
// Sessions
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
let sessionCallbacks: SessionCallbacks | undefined;
|
|
406
|
-
const requireSessions = (): SessionCallbacks => {
|
|
407
|
-
if (!sessionCallbacks) {
|
|
408
|
-
throw new Error("[runDevApp] session-callbacks accessed before onAfterSetup");
|
|
390
|
+
// Sessions: late-bound holder — resolveSessionStore needs concrete db+
|
|
391
|
+
// registry (only after setupTestStack). Wired when sessions feature is
|
|
392
|
+
// mounted (#1372); no auth.sessions opt-in anymore.
|
|
393
|
+
let sessionStore: SessionStore | undefined;
|
|
394
|
+
const requireSessionStore = (): SessionStore => {
|
|
395
|
+
if (!sessionStore) {
|
|
396
|
+
throw new Error("[runDevApp] sessionStore accessed before onAfterSetup");
|
|
409
397
|
}
|
|
410
|
-
return
|
|
398
|
+
return sessionStore;
|
|
411
399
|
};
|
|
412
400
|
// Token-verifier opt-in: same late-bound holder pattern — resolveTokenVerifier
|
|
413
401
|
// needs the real db+registry (only concrete after setupTestStack). Wired
|
|
@@ -444,14 +432,19 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
444
432
|
}
|
|
445
433
|
: {};
|
|
446
434
|
|
|
435
|
+
// Parity with runProdApp (#1372 review): gate on sessionStore provider, not
|
|
436
|
+
// the sessions feature name — a custom provider must also wire auth callbacks.
|
|
437
|
+
const sessionStoreProviderMounted = features.some((f) =>
|
|
438
|
+
f.extensionUsages.some((u) => u.extensionName === EXT_SESSION_STORE),
|
|
439
|
+
);
|
|
447
440
|
const sessionAuthFragment =
|
|
448
|
-
effectiveAuth
|
|
441
|
+
effectiveAuth && sessionStoreProviderMounted
|
|
449
442
|
? {
|
|
450
443
|
sessionCreator: (user: SessionUser, meta: SessionMetadata) =>
|
|
451
|
-
|
|
452
|
-
sessionRevoker: (sid: string) =>
|
|
444
|
+
requireSessionStore().creator(user, meta),
|
|
445
|
+
sessionRevoker: (sid: string) => requireSessionStore().revoker(sid),
|
|
453
446
|
sessionChecker: (sid: string, userId: string) =>
|
|
454
|
-
|
|
447
|
+
requireSessionStore().checker(sid, userId),
|
|
455
448
|
}
|
|
456
449
|
: {};
|
|
457
450
|
|
|
@@ -540,26 +533,21 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
540
533
|
registry: stack.registry,
|
|
541
534
|
});
|
|
542
535
|
}
|
|
543
|
-
if (effectiveAuth
|
|
544
|
-
|
|
545
|
-
|
|
536
|
+
if (effectiveAuth && stack.registry.getExtensionUsages(EXT_SESSION_STORE).length > 0) {
|
|
537
|
+
// Secure-by-default (#1372): resolve sessionStore provider; bind
|
|
538
|
+
// password-change mass-revoke + MFA revokeAllOthers.
|
|
539
|
+
const store = await resolveSessionStore({
|
|
546
540
|
db: stack.db,
|
|
547
|
-
|
|
541
|
+
registry: stack.registry,
|
|
548
542
|
});
|
|
549
|
-
// Secure-by-default (symmetrisch zu runProdApp): Password-Change/
|
|
550
|
-
// -Reset mass-revoked die Sessions des Users ohne App-Opt-in.
|
|
551
543
|
const sessionsFeature = features.find((f) => f.name === SESSIONS_FEATURE);
|
|
552
544
|
if (sessionsFeature) {
|
|
553
|
-
bindAutoRevokeFromFeature(sessionsFeature)?.(
|
|
545
|
+
bindAutoRevokeFromFeature(sessionsFeature)?.(store.massRevoker);
|
|
554
546
|
}
|
|
555
|
-
// MFA enable/disable/regenerate mass-revokes every OTHER live
|
|
556
|
-
// session (stolen-session defense) — only wired when auth-mfa is
|
|
557
|
-
// mounted.
|
|
558
547
|
if (mfaFeature) {
|
|
559
|
-
bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(
|
|
560
|
-
sessionCallbacks.sessionRevokeAllOthers,
|
|
561
|
-
);
|
|
548
|
+
bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(store.revokeAllOthers);
|
|
562
549
|
}
|
|
550
|
+
sessionStore = store;
|
|
563
551
|
}
|
|
564
552
|
if (patFeature) {
|
|
565
553
|
tokenVerifier = (rawToken) =>
|
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 = {
|
|
@@ -850,11 +852,8 @@ async function instantiateScaffoldFeatures(
|
|
|
850
852
|
`scaffoldApp: ${entry.importPath} missing export ${entry.exportName} for ${entry.callExpression}`,
|
|
851
853
|
);
|
|
852
854
|
}
|
|
853
|
-
if (
|
|
854
|
-
|
|
855
|
-
throw new Error(`scaffoldApp: ${entry.exportName} is not callable (${entry.importPath})`);
|
|
856
|
-
}
|
|
857
|
-
instances.push((exp as () => FeatureDefinition)());
|
|
855
|
+
if (typeof exp === "function") {
|
|
856
|
+
instances.push((exp as (...args: unknown[]) => FeatureDefinition)(...(entry.callArgs ?? [])));
|
|
858
857
|
} else {
|
|
859
858
|
instances.push(exp as FeatureDefinition);
|
|
860
859
|
}
|