@cosmicdrift/kumiko-dev-server 0.164.0 → 0.165.1

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.
@@ -24,6 +24,7 @@ import {
24
24
  discoverServerEntry,
25
25
  formatBuildResult,
26
26
  formatServerBuildResult,
27
+ readExtraRuntimeExternals,
27
28
  } from "../src/build";
28
29
  import { runCodegen } from "../src/codegen";
29
30
 
@@ -73,7 +74,11 @@ try {
73
74
  }
74
75
  if (hasServer) {
75
76
  const t0 = performance.now();
76
- const result = await buildServerBundle({ cwd });
77
+ const extraRuntimeExternals = readExtraRuntimeExternals(cwd);
78
+ const result = await buildServerBundle({
79
+ cwd,
80
+ ...(extraRuntimeExternals.length > 0 && { extraRuntimeExternals }),
81
+ });
77
82
  const ms = Math.round(performance.now() - t0);
78
83
  // biome-ignore lint/suspicious/noConsole: CLI-Output, einziger Weg
79
84
  console.log(formatServerBuildResult(result, ms));
@@ -68,6 +68,7 @@ async function readMountedFeatures(runConfigPath: string): Promise<Set<string>>
68
68
  const mod = (await import(runConfigPath)) as {
69
69
  APP_FEATURES?: ReadonlyArray<{ name: string }>;
70
70
  HAS_AUTH?: boolean;
71
+ HAS_SIGNUP?: boolean;
71
72
  };
72
73
  if (!mod.APP_FEATURES) {
73
74
  throw new Error(
@@ -85,6 +86,12 @@ async function readMountedFeatures(runConfigPath: string): Promise<Set<string>>
85
86
  if (mod.HAS_AUTH ?? true) {
86
87
  for (const name of implicitAuthModeFeatureNames()) set.add(name);
87
88
  }
89
+ // auth-self-registration is only prepended when authOptions.signup is set
90
+ // (composeFeatures) — opt-in via HAS_SIGNUP so apps without signup don't
91
+ // get a stale-registry false positive (#1521).
92
+ if (mod.HAS_SIGNUP === true) {
93
+ set.add("auth-self-registration");
94
+ }
88
95
  return set;
89
96
  }
90
97
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-dev-server",
3
- "version": "0.164.0",
3
+ "version": "0.165.1",
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.164.0",
58
- "@cosmicdrift/kumiko-framework": "0.164.0",
59
- "@cosmicdrift/kumiko-server-runtime": "0.164.0",
57
+ "@cosmicdrift/kumiko-bundled-features": "0.165.1",
58
+ "@cosmicdrift/kumiko-framework": "0.165.1",
59
+ "@cosmicdrift/kumiko-server-runtime": "0.165.1",
60
60
  "ts-morph": "^28.0.0"
61
61
  },
62
62
  "publishConfig": {
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { buildServerBundle } from "../build-server-bundle";
5
+ import { buildServerBundle, readExtraRuntimeExternals } from "../build-server-bundle";
6
6
 
7
7
  // Baut ein Mini-App-Fixture (bin/main.ts + bin/kumiko.ts teilen ein Modul) und
8
8
  // prüft das Variante-B-Verhalten: ein Bun.build-Call → server.js + kumiko.js als
@@ -95,4 +95,134 @@ describe("buildServerBundle (multi-entry + splitting)", () => {
95
95
  rmSync(dir, { recursive: true, force: true });
96
96
  }
97
97
  });
98
+
99
+ test("prefers node_modules/@cosmicdrift/* over packages/* when both declare a version", async () => {
100
+ // Framework-monorepo checkout: both the installed node_modules package
101
+ // (symlinked in real life, diverging here) and packages/framework exist.
102
+ // node_modules must win — it reflects what actually gets shipped/installed
103
+ // for the consumer, packages/framework is only a repo-root fallback.
104
+ const dir = makeFixture();
105
+ try {
106
+ const frameworkPkgDir = join(dir, "node_modules/@cosmicdrift/kumiko-framework");
107
+ mkdirSync(frameworkPkgDir, { recursive: true });
108
+ writeFileSync(
109
+ join(frameworkPkgDir, "package.json"),
110
+ `${JSON.stringify({ name: "@cosmicdrift/kumiko-framework", dependencies: { meilisearch: "^0.58.0" } })}\n`,
111
+ );
112
+
113
+ mkdirSync(join(dir, "packages/framework"), { recursive: true });
114
+ writeFileSync(
115
+ join(dir, "packages/framework/package.json"),
116
+ `${JSON.stringify({ name: "@cosmicdrift/kumiko-framework", dependencies: { meilisearch: "^0.30.0" } })}\n`,
117
+ );
118
+
119
+ const result = await buildServerBundle({ cwd: dir, outDir: join(dir, "dist-server") });
120
+
121
+ expect(result.runtimeDeps["meilisearch"]).toBe("^0.58.0");
122
+ } finally {
123
+ rmSync(dir, { recursive: true, force: true });
124
+ }
125
+ });
126
+ });
127
+
128
+ describe("readExtraRuntimeExternals (package.json kumiko field, #1484)", () => {
129
+ test("reads string list from kumiko.extraRuntimeExternals", () => {
130
+ const dir = mkdtempSync(join(tmpdir(), "extra-ext-"));
131
+ try {
132
+ writeFileSync(
133
+ join(dir, "package.json"),
134
+ `${JSON.stringify({
135
+ name: "app",
136
+ kumiko: { extraRuntimeExternals: ["@napi-rs/canvas", "pdf-parse"] },
137
+ })}\n`,
138
+ );
139
+ expect(readExtraRuntimeExternals(dir)).toEqual(["@napi-rs/canvas", "pdf-parse"]);
140
+ } finally {
141
+ rmSync(dir, { recursive: true, force: true });
142
+ }
143
+ });
144
+
145
+ test("missing / invalid shapes yield empty list", () => {
146
+ const dir = mkdtempSync(join(tmpdir(), "extra-ext-bad-"));
147
+ try {
148
+ writeFileSync(join(dir, "package.json"), `${JSON.stringify({ name: "app" })}\n`);
149
+ expect(readExtraRuntimeExternals(dir)).toEqual([]);
150
+ writeFileSync(
151
+ join(dir, "package.json"),
152
+ `${JSON.stringify({ name: "app", kumiko: { extraRuntimeExternals: "nope" } })}\n`,
153
+ );
154
+ expect(readExtraRuntimeExternals(dir)).toEqual([]);
155
+ writeFileSync(
156
+ join(dir, "package.json"),
157
+ `${JSON.stringify({
158
+ name: "app",
159
+ kumiko: { extraRuntimeExternals: ["ok", 1, "", " "] },
160
+ })}\n`,
161
+ );
162
+ expect(readExtraRuntimeExternals(dir)).toEqual(["ok"]);
163
+ } finally {
164
+ rmSync(dir, { recursive: true, force: true });
165
+ }
166
+ });
167
+ });
168
+
169
+ describe("buildServerBundle + package.json extras (#1484)", () => {
170
+ test("extraRuntimeExternals from options land in runtimeDeps with app pin", async () => {
171
+ const dir = makeFixture();
172
+ try {
173
+ writeFileSync(
174
+ join(dir, "package.json"),
175
+ `${JSON.stringify({
176
+ name: "fixture-app",
177
+ dependencies: { "@napi-rs/canvas": "0.1.65" },
178
+ kumiko: { extraRuntimeExternals: ["@napi-rs/canvas"] },
179
+ })}\n`,
180
+ );
181
+ const extras = readExtraRuntimeExternals(dir);
182
+ const result = await buildServerBundle({
183
+ cwd: dir,
184
+ outDir: join(dir, "dist-server"),
185
+ extraRuntimeExternals: extras,
186
+ });
187
+ expect(result.runtimeDeps["@napi-rs/canvas"]).toBe("0.1.65");
188
+ const distPkg = JSON.parse(readFileSync(join(dir, "dist-server/package.json"), "utf-8")) as {
189
+ dependencies: Record<string, string>;
190
+ };
191
+ expect(distPkg.dependencies["@napi-rs/canvas"]).toBe("0.1.65");
192
+ } finally {
193
+ rmSync(dir, { recursive: true, force: true });
194
+ }
195
+ });
196
+
197
+ test("app package.json does not override framework runtime pins", async () => {
198
+ const dir = makeFixture();
199
+ try {
200
+ const frameworkPkgDir = join(dir, "node_modules/@cosmicdrift/kumiko-framework");
201
+ mkdirSync(frameworkPkgDir, { recursive: true });
202
+ writeFileSync(
203
+ join(frameworkPkgDir, "package.json"),
204
+ `${JSON.stringify({
205
+ name: "@cosmicdrift/kumiko-framework",
206
+ dependencies: { meilisearch: "^0.58.0" },
207
+ })}\n`,
208
+ );
209
+ writeFileSync(
210
+ join(dir, "package.json"),
211
+ `${JSON.stringify({
212
+ name: "fixture-app",
213
+ dependencies: { meilisearch: "^0.1.0", "@napi-rs/canvas": "0.1.65" },
214
+ kumiko: { extraRuntimeExternals: ["@napi-rs/canvas"] },
215
+ })}\n`,
216
+ );
217
+ const result = await buildServerBundle({
218
+ cwd: dir,
219
+ outDir: join(dir, "dist-server"),
220
+ extraRuntimeExternals: readExtraRuntimeExternals(dir),
221
+ });
222
+ expect(result.runtimeDeps["meilisearch"]).toBe("^0.58.0");
223
+ expect(result.runtimeDeps["@napi-rs/canvas"]).toBe("0.1.65");
224
+ } finally {
225
+ rmSync(dir, { recursive: true, force: true });
226
+ }
227
+ });
98
228
  });
@@ -144,6 +144,29 @@ describe("composeStacks boots for real", () => {
144
144
  expect(() => validateBoot(features)).not.toThrow();
145
145
  });
146
146
 
147
+ test("money-horse-shaped composeGdprStack({sessions:true}) without a tokenVerifier provider passes boot", () => {
148
+ // Session-only stacks (#1570): composeGdprStack({sessions:true}) mounts
149
+ // auth-foundation + sessions (sessionStore). Cookie auth does not need a
150
+ // tokenVerifier; money-horse/solon run-configs pass none and must boot.
151
+ const features = composeFeatures([...composeGdprStack({ sessions: true })], {
152
+ includeBundled: true,
153
+ });
154
+ expect(() => validateBoot(features)).not.toThrow();
155
+ });
156
+
157
+ test("composeGdprStack({sessions:true, providers:[...]}) passes boot with a tokenVerifier mounted", () => {
158
+ const features = composeFeatures(
159
+ [
160
+ ...composeGdprStack({
161
+ sessions: true,
162
+ providers: [createPersonalAccessTokensFeature({ scopes: {} })],
163
+ }),
164
+ ],
165
+ { includeBundled: true },
166
+ );
167
+ expect(() => validateBoot(features)).not.toThrow();
168
+ });
169
+
147
170
  // auth-mfa's encrypted fields need a runtime KEK (env or configureEntityFieldEncryption
148
171
  // + secrets). Name-list + saas-identity-wire.integration.test.ts cover identity boot.
149
172
  test("identity stack names compose with ops + renderer under includeBundled", () => {
@@ -1,24 +1,15 @@
1
1
  // createKumikoServer error / graceful-degradation paths — boot rejects,
2
2
  // stylesheet pipeline failures, missing CSS route.
3
3
 
4
- import { afterEach, describe, expect, test } from "bun:test";
4
+ import { describe, expect, test } from "bun:test";
5
5
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { tmpdir } from "node:os";
7
7
  import { join } from "node:path";
8
8
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
9
- import { createKumikoServer, type KumikoServerHandle } from "../create-kumiko-server";
9
+ import { createKumikoServer } from "../create-kumiko-server";
10
10
 
11
11
  const emptyFeature = defineFeature("dev-server-errors-probe", () => {});
12
12
 
13
- let handle: KumikoServerHandle | undefined;
14
-
15
- afterEach(async () => {
16
- if (handle) {
17
- await handle.stop();
18
- handle = undefined;
19
- }
20
- });
21
-
22
13
  describe("createKumikoServer — client bundle failure", () => {
23
14
  test("broken clientEntry rejects at boot with client bundle failed", async () => {
24
15
  const tmpDir = mkdtempSync(join(tmpdir(), "kumiko-bundle-fail-"));
@@ -385,6 +385,13 @@ describe("createKumikoServer — real Bun.build (buildClient)", () => {
385
385
  const body = await res.text();
386
386
  expect(body.length).toBeGreaterThan(0);
387
387
  expect(body).toMatch(/ping/);
388
+
389
+ // Stop the watcher before teardown rmSync deletes clientEntry out from
390
+ // under it — a bare "client.tsx" delete event classifies as "restart"
391
+ // (classifyChange only special-cases endsWith("/client.tsx")) and
392
+ // process.exit(75) kills the whole bun test runner mid-suite.
393
+ await handle.stop();
394
+ handle = undefined;
388
395
  } finally {
389
396
  rmSync(tmpDir, { recursive: true, force: true });
390
397
  }
@@ -186,6 +186,27 @@ describe("scaffoldApp", () => {
186
186
  expect(readme).toContain("- `delivery`");
187
187
  });
188
188
 
189
+ test("callExpression/export-callability mismatch throws instead of silently mis-instantiating", async () => {
190
+ const dest = join(tmp, "my-shop");
191
+ await expect(
192
+ scaffoldApp({
193
+ name: "my-shop",
194
+ destination: dest,
195
+ features: [
196
+ {
197
+ name: "delivery",
198
+ importPath: "@cosmicdrift/kumiko-bundled-features/delivery",
199
+ exportName: "createDeliveryFeature",
200
+ // createDeliveryFeature is a factory function, but this claims it's
201
+ // a plain object export (no trailing "()") — must not silently push
202
+ // the function itself as a FeatureDefinition.
203
+ callExpression: "createDeliveryFeature",
204
+ },
205
+ ],
206
+ }),
207
+ ).rejects.toThrow(/is.*callable but callExpression/);
208
+ });
209
+
189
210
  test("bin/main.ts contains runProdApp + auth.admin stub + staticDir", async () => {
190
211
  const dest = join(tmp, "my-shop");
191
212
  await scaffoldApp({ name: "my-shop", destination: dest });
@@ -224,6 +224,25 @@ function findRepoRoot(start: string): string | undefined {
224
224
  return undefined;
225
225
  }
226
226
 
227
+ /** Read `package.json#kumiko.extraRuntimeExternals` for kumiko-build (#1484).
228
+ * Invalid / missing shapes → empty list (CLI stays convention-driven). */
229
+ export function readExtraRuntimeExternals(cwd: string): readonly string[] {
230
+ const pkgJson = join(cwd, "package.json");
231
+ if (!existsSync(pkgJson)) return [];
232
+ let raw: string;
233
+ try {
234
+ raw = readFileSync(pkgJson, "utf-8");
235
+ } catch {
236
+ return [];
237
+ }
238
+ const parsed = parseJsonSafe<{
239
+ kumiko?: { extraRuntimeExternals?: unknown };
240
+ }>(raw, {});
241
+ const list = parsed.kumiko?.extraRuntimeExternals;
242
+ if (!Array.isArray(list)) return [];
243
+ return list.filter((x): x is string => typeof x === "string" && x.trim().length > 0);
244
+ }
245
+
227
246
  // Versionen für RUNTIME_EXTERNALS auflösen: erst aus den installierten
228
247
  // node_modules/@cosmicdrift/*-Packages (relativ zu cwd — funktioniert für
229
248
  // jede Consumer-App), dann als Zusatz aus packages/framework + bundled-
@@ -237,21 +256,42 @@ async function resolveRuntimeDepsVersions(
237
256
  const out: Record<string, string> = {};
238
257
 
239
258
  const pinSources = [
240
- join(cwd, "node_modules/@cosmicdrift/kumiko-framework/package.json"),
241
- join(cwd, "node_modules/@cosmicdrift/kumiko-bundled-features/package.json"),
242
259
  ...(repoRoot
243
260
  ? [
244
261
  join(repoRoot, "packages/framework/package.json"),
245
262
  join(repoRoot, "packages/bundled-features/package.json"),
246
263
  ]
247
264
  : []),
265
+ // node_modules applied last among framework sources so installed versions
266
+ // win over the repo-root fallback (#1217).
267
+ join(cwd, "node_modules/@cosmicdrift/kumiko-framework/package.json"),
268
+ join(cwd, "node_modules/@cosmicdrift/kumiko-bundled-features/package.json"),
248
269
  ];
249
270
  const allDeps: Record<string, string> = {};
250
271
  for (const path of pinSources) {
251
272
  if (!existsSync(path)) continue;
252
273
  const raw = await readFile(path, "utf-8");
253
- const parsed = parseJsonOrThrow<{ dependencies?: Record<string, string> }>(raw, path);
254
- Object.assign(allDeps, parsed.dependencies ?? {});
274
+ const parsed = parseJsonOrThrow<{
275
+ dependencies?: Record<string, string>;
276
+ optionalDependencies?: Record<string, string>;
277
+ }>(raw, path);
278
+ Object.assign(allDeps, parsed.dependencies ?? {}, parsed.optionalDependencies ?? {});
279
+ }
280
+ // App package.json fills gaps only — pins extraRuntimeExternals without
281
+ // overriding framework-native pins the app may also list (#1484 / review).
282
+ const appPkg = join(cwd, "package.json");
283
+ if (existsSync(appPkg)) {
284
+ const raw = await readFile(appPkg, "utf-8");
285
+ const parsed = parseJsonOrThrow<{
286
+ dependencies?: Record<string, string>;
287
+ optionalDependencies?: Record<string, string>;
288
+ }>(raw, appPkg);
289
+ for (const [name, ver] of Object.entries({
290
+ ...(parsed.dependencies ?? {}),
291
+ ...(parsed.optionalDependencies ?? {}),
292
+ })) {
293
+ if (allDeps[name] === undefined) allDeps[name] = ver;
294
+ }
255
295
  }
256
296
  for (const pkg of packages) {
257
297
  out[pkg] = allDeps[pkg] ?? "*";
package/src/build.ts CHANGED
@@ -22,4 +22,5 @@ export {
22
22
  buildServerBundle,
23
23
  discoverServerEntry,
24
24
  formatServerBuildResult,
25
+ readExtraRuntimeExternals,
25
26
  } from "./build-server-bundle";
@@ -59,6 +59,11 @@ export type GdprStackOptions = {
59
59
  readonly order?: GdprStackOrder;
60
60
  readonly sessions?: boolean;
61
61
  readonly tenantLifecycle?: boolean;
62
+ /** Optional tokenVerifier provider(s) (e.g. PAT) when `sessions` is on.
63
+ * Session-only stacks need none — sessions registers sessionStore, which
64
+ * satisfies auth-foundation's boot check (#1570). Pass providers when the
65
+ * app also wants Bearer/PAT auth. */
66
+ readonly providers?: readonly FeatureDefinition[];
62
67
  };
63
68
 
64
69
  export type UserDataRightsStackOptions = {
@@ -80,14 +85,12 @@ export type OpsStackOptions = {
80
85
  * on composeFeatures(includeBundled). Pass `mfa` options to mount TOTP.
81
86
  * When `sessions` is on (the default), auth-foundation is mounted alongside it —
82
87
  * 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. */
88
+ * Session-only is enough for boot (#1570); optional Bearer/PAT still needs a
89
+ * tokenVerifier provider from the caller (e.g. createPersonalAccessTokensFeature
90
+ * ({ scopes })) PAT scopes are app-specific, so they stay caller-owned. */
87
91
  export type IdentityStackOptions = {
88
92
  readonly sessions?: boolean;
89
93
  readonly mfa?: AuthMfaFeatureOptions;
90
- readonly providers?: readonly FeatureDefinition[];
91
94
  };
92
95
 
93
96
  export function stackFeatureNames(features: readonly FeatureDefinition[]): string[] {
@@ -139,7 +142,10 @@ export function composeGdprStack(options: GdprStackOptions = {}): FeatureDefinit
139
142
  const out: FeatureDefinition[] =
140
143
  order === "compliance-first" ? [compliance, retention] : [retention, compliance];
141
144
  if (options.tenantLifecycle) out.push(createTenantLifecycleFeature());
142
- if (options.sessions) out.push(authFoundationFeature, createSessionsFeature());
145
+ if (options.sessions) {
146
+ out.push(authFoundationFeature, createSessionsFeature());
147
+ if (options.providers) out.push(...options.providers);
148
+ }
143
149
  return out;
144
150
  }
145
151
 
@@ -172,7 +178,6 @@ export function composeIdentityStack(options: IdentityStackOptions = {}): Featur
172
178
  const out: FeatureDefinition[] = [];
173
179
  if (sessions) {
174
180
  out.push(authFoundationFeature, createSessionsFeature());
175
- if (options.providers) out.push(...options.providers);
176
181
  }
177
182
  if (options.mfa !== undefined) out.push(createAuthMfaFeature(options.mfa));
178
183
  return out;
@@ -132,23 +132,28 @@ export type RunDevAppAuthOptions = {
132
132
  * der request/confirm-Handler im auth-email-password-Feature wird
133
133
  * registriert. Symmetrisch zu RunProdAppAuthOptions.passwordReset. */
134
134
  readonly passwordReset?: PasswordResetSetup;
135
- /** Email-verification flow. Symmetric zu passwordReset. */
135
+ /** Email-verification flow. Symmetric to passwordReset. */
136
136
  readonly emailVerification?: EmailVerificationSetup;
137
- /** Self-Signup flow (Magic-Link). Symmetric zu RunProdAppAuthOptions. */
137
+ /** Self-signup flow (magic link). Symmetric to RunProdAppAuthOptions. */
138
138
  readonly signup?: SignupSetup;
139
- /** Tenant-Invite flow (Magic-Link). Symmetric. */
139
+ /** Tenant-invite flow (magic link). Symmetric. */
140
140
  readonly invite?: InviteSetup;
141
- /** Account-unlock flow (#1266). Symmetric zu RunProdAppAuthOptions. */
141
+ /** Account-unlock flow (#1266). Symmetric to RunProdAppAuthOptions. */
142
142
  readonly accountUnlock?: AccountUnlockSetup;
143
143
  /** Domain attribute for both auth cookies (see
144
- * AuthRoutesConfig.cookieDomain). Symmetric zu RunProdAppAuthOptions. */
144
+ * AuthRoutesConfig.cookieDomain). Symmetric to RunProdAppAuthOptions. */
145
145
  readonly cookieDomain?: string;
146
146
  /** Server-side Origin allowlist for the CSRF guard (see
147
- * AuthRoutesConfig.allowedOrigins). Symmetric zu RunProdAppAuthOptions —
147
+ * AuthRoutesConfig.allowedOrigins). Symmetric to RunProdAppAuthOptions —
148
148
  * required once `cookieDomain` is set. */
149
149
  readonly allowedOrigins?: readonly string[];
150
- /** Opt out of the Origin guard. Symmetric zu RunProdAppAuthOptions. */
150
+ /** Opt out of the Origin guard. Symmetric to RunProdAppAuthOptions. */
151
151
  readonly unsafeSkipOriginCheck?: boolean;
152
+ /** Number of trusted reverse-proxy hops for client-IP derivation (see
153
+ * AuthRoutesConfig.trustedProxyHops, kumiko-framework#1539). Symmetric to
154
+ * RunProdAppAuthOptions — dev usually runs unproxied, so this is normally
155
+ * left unset (default 0). */
156
+ readonly trustedProxyHops?: number;
152
157
  };
153
158
 
154
159
  /** Hook for app-specific seeding (demo data, fixtures). Runs after the
@@ -484,6 +489,9 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
484
489
  ...(effectiveAuth.unsafeSkipOriginCheck !== undefined && {
485
490
  unsafeSkipOriginCheck: effectiveAuth.unsafeSkipOriginCheck,
486
491
  }),
492
+ ...(effectiveAuth.trustedProxyHops !== undefined && {
493
+ trustedProxyHops: effectiveAuth.trustedProxyHops,
494
+ }),
487
495
  ...sessionAuthFragment,
488
496
  ...patAuthFragment,
489
497
  ...tenantLifecycleAuthFragment,
@@ -852,6 +852,14 @@ async function instantiateScaffoldFeatures(
852
852
  `scaffoldApp: ${entry.importPath} missing export ${entry.exportName} for ${entry.callExpression}`,
853
853
  );
854
854
  }
855
+ const looksCallable = entry.callExpression.endsWith(")");
856
+ if (looksCallable !== (typeof exp === "function")) {
857
+ throw new Error(
858
+ `scaffoldApp: ${entry.importPath} export ${entry.exportName} is ${
859
+ typeof exp === "function" ? "" : "not "
860
+ }callable but callExpression "${entry.callExpression}" says otherwise`,
861
+ );
862
+ }
855
863
  if (typeof exp === "function") {
856
864
  instances.push((exp as (...args: unknown[]) => FeatureDefinition)(...(entry.callArgs ?? [])));
857
865
  } else {