@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-dev-server",
3
- "version": "0.159.1",
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.159.1",
58
- "@cosmicdrift/kumiko-framework": "0.159.1",
59
- "@cosmicdrift/kumiko-server-runtime": "0.159.1",
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 only", () => {
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(),
@@ -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) out.push(createSessionsFeature());
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,
@@ -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 { resolveTokenVerifier } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
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, Karten­haus 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 opt-in: Holder lebt im closure, `createSessionCallbacks`
399
- // kennt erst nach setupTestStack die echte db-connection. Inline
400
- // statt @cosmicdrift/kumiko-framework/testing's createLateBoundHolder zu reusen,
401
- // weil dev-server (dev-runtime) keine Tooling aus framework/testing
402
- // (test-runtime) importieren darf — Runtime-Isolation Guard.
403
- // Server-Start passiert NACH onAfterSetup (siehe create-kumiko-server.ts),
404
- // daher ist `sessionCallbacks` zur ersten Login-Request konkret.
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 sessionCallbacks;
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?.sessions !== undefined
441
+ effectiveAuth && sessionStoreProviderMounted
449
442
  ? {
450
443
  sessionCreator: (user: SessionUser, meta: SessionMetadata) =>
451
- requireSessions().sessionCreator(user, meta),
452
- sessionRevoker: (sid: string) => requireSessions().sessionRevoker(sid),
444
+ requireSessionStore().creator(user, meta),
445
+ sessionRevoker: (sid: string) => requireSessionStore().revoker(sid),
453
446
  sessionChecker: (sid: string, userId: string) =>
454
- requireSessions().sessionChecker(sid, userId),
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?.sessions !== undefined) {
544
- const expiresInMs = effectiveAuth.sessions.expiresInMs;
545
- sessionCallbacks = createSessionCallbacks({
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
- ...(expiresInMs !== undefined && { expiresInMs }),
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)?.(sessionCallbacks.sessionMassRevoker);
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) =>
@@ -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 (entry.callExpression.endsWith("()")) {
854
- if (typeof exp !== "function") {
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
  }