@cosmicdrift/kumiko-dev-server 0.318.0 → 0.320.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.318.0",
3
+ "version": "0.320.0",
4
4
  "description": "Dev-tooling for Kumiko apps: local dev-server bootstrap (runDevApp), scaffolding, codegen. Its compose-stacks/env-schema subpaths are consumed at prod boot too — see @cosmicdrift/kumiko-server-runtime for the prod runner itself.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -59,10 +59,10 @@
59
59
  "kumiko-upgrade": "./bin/kumiko-upgrade.ts"
60
60
  },
61
61
  "dependencies": {
62
- "@cosmicdrift/kumiko-bundled-features": "0.318.0",
63
- "@cosmicdrift/kumiko-framework": "0.318.0",
64
- "@cosmicdrift/kumiko-headless": "0.318.0",
65
- "@cosmicdrift/kumiko-server-runtime": "0.318.0",
62
+ "@cosmicdrift/kumiko-bundled-features": "0.320.0",
63
+ "@cosmicdrift/kumiko-framework": "0.320.0",
64
+ "@cosmicdrift/kumiko-headless": "0.320.0",
65
+ "@cosmicdrift/kumiko-server-runtime": "0.320.0",
66
66
  "ts-morph": "^28.0.0"
67
67
  },
68
68
  "publishConfig": {
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
  import {
6
6
  ExtraRouteRejection,
7
7
  NO_ROUTE_MATCH_HEADER_NAME,
8
+ requestContext,
8
9
  signatureRoute,
9
10
  } from "@cosmicdrift/kumiko-framework/api";
10
11
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
@@ -16,6 +17,7 @@ import {
16
17
  type TenantId,
17
18
  } from "@cosmicdrift/kumiko-framework/engine";
18
19
  import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
20
+ import { getSetCookieValue } from "@cosmicdrift/kumiko-framework/testing";
19
21
  import * as z from "zod";
20
22
  import {
21
23
  createKumikoServer,
@@ -50,6 +52,16 @@ const probeFeature = defineFeature("dev-server-probe", (r) => {
50
52
  access: { roles: ["anonymous"] },
51
53
  handler: async () => ({ pong: true }),
52
54
  });
55
+ // Echoes the resolved client IP — proves a real Bun.serve socket address
56
+ // reaches requestContext.ip through handleFetch (fw#3260 addendum: every
57
+ // other test drives h.fetch()/handle.fetch() directly, never Bun.serve's
58
+ // real socket).
59
+ r.queryHandler({
60
+ name: "whoami",
61
+ schema: z.object({}),
62
+ access: { roles: ["anonymous"] },
63
+ handler: async () => ({ ip: requestContext.get()?.ip }),
64
+ });
53
65
  // SystemAdmin-gated write — Ziel der entry:"signature" / `wire`-Tests
54
66
  // (252/2): Echo von user.tenantId + roles beweist Dispatch durch
55
67
  // den echten Dispatcher (Zod + Access-Check) mit Ziel-Tenant-SystemUser.
@@ -245,9 +257,9 @@ describe("createKumikoServer (Multi-Entry)", () => {
245
257
  if (host === "apex.test") return { kind: "not-found" };
246
258
  if (host === "old.test") return { kind: "redirect", to: "https://new.test/" };
247
259
  if (host.startsWith("admin.")) {
248
- return { kind: "html", entryName: "admin", injectSchema: true };
260
+ return { kind: "html", entryName: "admin" };
249
261
  }
250
- return { kind: "html", entryName: "public", injectSchema: false };
262
+ return { kind: "html", entryName: "public" };
251
263
  },
252
264
  });
253
265
  }
@@ -288,35 +300,42 @@ describe("createKumikoServer (Multi-Entry)", () => {
288
300
  expect(noFallback.status).toBe(404);
289
301
  });
290
302
 
291
- test("Schema-Inject: admin → injected, public → NICHT injected", async () => {
303
+ test("HTML never carries the schema; GET /api/schema needs the auto-minted cookie (kumiko-framework#3314)", async () => {
292
304
  handle = await bootMultiEntry();
293
305
 
294
- const publicHtml = await (
295
- await handle.fetch(
296
- new Request("http://status.localhost/", { headers: { host: "status.localhost" } }),
297
- )
298
- ).text();
299
- expect(publicHtml).not.toMatch(/__KUMIKO_SCHEMA__/);
306
+ const publicRes = await handle.fetch(
307
+ new Request("http://status.localhost/", { headers: { host: "status.localhost" } }),
308
+ );
309
+ expect(await publicRes.text()).not.toMatch(/__KUMIKO_SCHEMA__/);
310
+
311
+ const adminRes = await handle.fetch(
312
+ new Request("http://admin.localhost/", { headers: { host: "admin.localhost" } }),
313
+ );
314
+ expect(await adminRes.text()).not.toMatch(/__KUMIKO_SCHEMA__/);
315
+
316
+ const authCookie = getSetCookieValue(adminRes, "kumiko_auth");
317
+ expect(authCookie).toBeDefined();
318
+
319
+ const unauthenticated = await handle.fetch(new Request("http://admin.localhost/api/schema"));
320
+ expect(unauthenticated.status).toBe(401);
300
321
 
301
- const adminHtml = await (
302
- await handle.fetch(
303
- new Request("http://admin.localhost/", { headers: { host: "admin.localhost" } }),
304
- )
305
- ).text();
306
- expect(adminHtml).toMatch(/__KUMIKO_SCHEMA__/);
322
+ const authenticatedRes = await handle.fetch(
323
+ new Request("http://admin.localhost/api/schema", {
324
+ headers: { cookie: `kumiko_auth=${authCookie}` },
325
+ }),
326
+ );
327
+ expect(authenticatedRes.status).toBe(200);
328
+ const schema = await authenticatedRes.json();
329
+ // Guards against an empty features array making the #2062 `.some(...)`
330
+ // assertion below vacuously false.
331
+ expect(schema.features.length).toBeGreaterThan(0);
307
332
 
308
333
  // #2062: setupTestStack always wires an in-memory SearchAdapter
309
334
  // (test-stack.ts), so the dev-server call site's
310
335
  // `searchAdapterMissing: !stack.context.searchAdapter` must read false —
311
336
  // proving the flag actually flows from stack.context through to the
312
- // injected schema instead of silently staying at its own default.
313
- const schemaMatch = adminHtml.match(/window\.__KUMIKO_SCHEMA__=(\{.*?\});<\/script>/s);
314
- expect(schemaMatch).not.toBeNull();
315
- const injectedSchema = JSON.parse(schemaMatch?.[1] ?? "{}");
316
- // Guards against a regex miss silently degrading to `?? "{}"` — an empty
317
- // features array would make `.some(...)` below vacuously false too.
318
- expect(injectedSchema.features.length).toBeGreaterThan(0);
319
- const anyFeatureMissingAdapter = injectedSchema.features.some(
337
+ // schema served by GET /api/schema.
338
+ const anyFeatureMissingAdapter = schema.features.some(
320
339
  (f: { searchAdapterMissing?: boolean }) => f.searchAdapterMissing === true,
321
340
  );
322
341
  expect(anyFeatureMissingAdapter).toBe(false);
@@ -907,3 +926,33 @@ describe("createKumikoServer — public/ static files", () => {
907
926
  }
908
927
  });
909
928
  });
929
+
930
+ describe("createKumikoServer — real Bun.serve socket resolves the client IP (fw#3260 addendum)", () => {
931
+ // Every other test in this file drives h.fetch()/handle.fetch() directly,
932
+ // bypassing Bun.serve's real TCP socket entirely — none of them can catch
933
+ // a regression where handleFetch loses the socket address before it
934
+ // reaches requestContext.ip (see client-ip.ts: falls back to "unknown"
935
+ // rather than throwing, so a wiring bug here stays silent otherwise).
936
+ test("a real loopback connection resolves to the actual socket address, not 'unknown'", async () => {
937
+ handle = await createKumikoServer({
938
+ features: [probeFeature],
939
+ port: 0,
940
+ installSignalHandlers: false,
941
+ anonymousAccess: { defaultTenantId: TENANT_ID },
942
+ });
943
+ const port = handle.server?.port;
944
+ if (port === undefined) throw new Error("expected handle.server to be listening");
945
+
946
+ const res = await fetch(`http://127.0.0.1:${port}/api/query`, {
947
+ method: "POST",
948
+ headers: { "content-type": "application/json" },
949
+ body: JSON.stringify({ type: "dev-server-probe:query:whoami", payload: {} }),
950
+ });
951
+ expect(res.status).toBe(200);
952
+ const body = (await res.json()) as { data?: { ip?: string } };
953
+ // See the matching prod-side test (run-prod-app.integration.test.ts)
954
+ // for why the assertion matches the loopback family instead of a
955
+ // fixed literal (IPv4 vs. IPv4-mapped IPv6 is a platform detail).
956
+ expect(body.data?.ip).toMatch(/^(::ffff:)?127\.0\.0\.1$/);
957
+ });
958
+ });
@@ -26,19 +26,30 @@ describe("mergeExtraContext", () => {
26
26
  expect(merged).toBe(base);
27
27
  });
28
28
 
29
- test("config preset merges configResolver, keeping base-object fields", () => {
29
+ test("config preset merges configResolver + _configAccessorFactory, keeping base-object fields", () => {
30
30
  const merged = mergeExtraContext({ foo: "bar" }, ["config"]);
31
31
  expect(typeof merged).toBe("function");
32
32
  const result = (merged as (deps: typeof fakeDeps) => Record<string, unknown>)(fakeDeps);
33
33
  expect(result["foo"]).toBe("bar");
34
34
  expect(result["configResolver"]).toBeDefined();
35
+ // buildHandlerContext derives ctx.config from _configAccessorFactory, not configResolver (fw#3313).
36
+ expect(result["_configAccessorFactory"]).toBeDefined();
35
37
  });
36
38
 
37
- test("config preset merges configResolver, keeping base-fn fields", () => {
39
+ test("config preset merges configResolver + _configAccessorFactory, keeping base-fn fields", () => {
38
40
  const merged = mergeExtraContext(() => ({ fromFn: 1 }), ["config"]);
39
41
  const result = (merged as (deps: typeof fakeDeps) => Record<string, unknown>)(fakeDeps);
40
42
  expect(result["fromFn"]).toBe(1);
41
43
  expect(result["configResolver"]).toBeDefined();
44
+ expect(result["_configAccessorFactory"]).toBeDefined();
45
+ });
46
+
47
+ test("config preset keeps a base-supplied configResolver and derives the factory from it", () => {
48
+ const ownResolver = { get: async () => undefined } as never;
49
+ const merged = mergeExtraContext({ configResolver: ownResolver }, ["config"]);
50
+ const result = (merged as (deps: typeof fakeDeps) => Record<string, unknown>)(fakeDeps);
51
+ expect(result["configResolver"]).toBe(ownResolver);
52
+ expect(result["_configAccessorFactory"]).toBeDefined();
42
53
  });
43
54
 
44
55
  test("template-resolver preset merges templateResolver, built from deps.db", () => {
package/src/changes.json CHANGED
@@ -1,4 +1,23 @@
1
1
  [
2
+ {
3
+ "version": "0.320.0",
4
+ "type": "breaking",
5
+ "title": "createKumikoServer/runDevApp gained a trustedProxyHops option",
6
+ "detail": "`CreateKumikoServerOptions`/`RunDevAppOptions` gained a top-level\n`trustedProxyHops`, forwarded into `setupTestStack`'s `buildServer` call.\nDev normally runs unproxied, so this is usually left unset (default 0).",
7
+ "migration": "No action needed for the default (unproxied) dev setup. Set\n`trustedProxyHops` only if you run the dev server behind a reverse proxy."
8
+ },
9
+ {
10
+ "version": "0.320.0",
11
+ "type": "fix",
12
+ "title": "setupTestStackFromFeatures' config preset now also derives _configAccessorFactory",
13
+ "detail": "buildHandlerContext derives ctx.config from _configAccessorFactory, not configResolver directly, so a write handler under presets: [\"config\"] saw ctx.config as undefined even with a configResolver present. mergeExtraContext now also runs addConfigAccessorFactory (the same helper runProdApp/runDevApp use), keeping a base-supplied configResolver winning over the preset default. Closes #3313."
14
+ },
15
+ {
16
+ "version": "0.320.0",
17
+ "type": "breaking",
18
+ "title": "Dev-server HTML no longer injects __KUMIKO_SCHEMA__ either",
19
+ "migration": "Same semantics as the prod change: `DevHostDispatchResult.injectSchema`\nis deprecated and ignored. The dev-server's auto-mint mode still sets\nthe `kumiko_auth`/`kumiko_csrf` cookies on the HTML response, so a\nclient-side fetch to `/api/schema` is authenticated immediately without\na real login round-trip."
20
+ },
2
21
  {
3
22
  "version": "0.308.0",
4
23
  "type": "improvement",
@@ -23,6 +23,7 @@ import { resolveAnonymousAccessFromRegistry } from "@cosmicdrift/kumiko-bundled-
23
23
  import {
24
24
  type AuthRoutesConfig,
25
25
  buildRequestContextDataFromRequest,
26
+ createClientIpResolver,
26
27
  type ExtraRouteDefinition,
27
28
  generateToken,
28
29
  requestContext,
@@ -46,12 +47,14 @@ import {
46
47
  resolveAndInjectPageHead,
47
48
  } from "@cosmicdrift/kumiko-headless/apex";
48
49
  import { startDevJobRunners } from "@cosmicdrift/kumiko-server-runtime/boot/job-run-logger";
49
- import { buildBunServeOptions } from "@cosmicdrift/kumiko-server-runtime/bun-serve-options";
50
+ import {
51
+ buildBunServeOptions,
52
+ resolveDerivedMaxRequestBodySize,
53
+ } from "@cosmicdrift/kumiko-server-runtime/bun-serve-options";
50
54
  import {
51
55
  makeDispatchSystemWrite,
52
56
  type SystemWireDeps,
53
57
  } from "@cosmicdrift/kumiko-server-runtime/extra-routes-deps";
54
- import { injectSchema } from "@cosmicdrift/kumiko-server-runtime/inject-schema";
55
58
  import {
56
59
  canResolveTailwindStylesheet,
57
60
  resolveTailwindCli,
@@ -102,24 +105,23 @@ export type DevClientEntry = {
102
105
  readonly htmlPath?: string;
103
106
  };
104
107
 
105
- /** Discriminated-Union, identisch zur Form von `runProdApp.hostDispatch`.
106
- * Damit kann Dev/Prod-Routing 1:1 gespiegelt werden — ein Apex-404 in
107
- * Prod ist ein Apex-404 in Dev (mit `/etc/hosts`-Eintrag für die
108
- * betroffene Domain). Schema-Inject ist pro Response steuerbar — ein
109
- * Public-Bundle leakt das Admin-Schema nicht, auch nicht in Dev. */
108
+ /** Discriminated union, identical in shape to `runProdApp.hostDispatch`, so
109
+ * dev/prod routing mirrors 1:1 — an apex 404 in prod is an apex 404 in dev
110
+ * (with an `/etc/hosts` entry for the affected domain). HTML never carries
111
+ * a schema, in dev or prod — createKumikoApp fetches it itself from the
112
+ * authenticated GET /api/schema. */
110
113
  export type DevHostDispatchResult =
111
114
  | {
112
115
  readonly kind: "html";
113
116
  readonly entryName: string;
114
- /** Default: true. Setze `false` für Public-Routes — analog zu
115
- * prod-`injectSchema:false` für Anonymous-Visitors. */
117
+ /** @deprecated Ignored — HTML never carries the schema; createKumikoApp
118
+ * fetches it from the authenticated GET /api/schema. */
116
119
  readonly injectSchema?: boolean;
117
120
  }
118
121
  | {
119
- /** Static-HTML: liefert eine Datei wortwörtlich, kein Bundle-Inject,
120
- * kein Schema-Inject. Pendant zu prod's `{ kind: "html", file: ...,
121
- * injectSchema: false }` für Marketing-/Apex-Pages die kein React
122
- * brauchen. Pfad relativ zum Server-CWD. */
122
+ /** Static HTML: serves a file verbatim, no bundle injection. Mirrors
123
+ * prod's `{ kind: "html", file: ... }` for marketing/apex pages that
124
+ * don't need React. Path relative to the server CWD. */
123
125
  readonly kind: "static-html";
124
126
  readonly file: string;
125
127
  }
@@ -232,6 +234,10 @@ export type CreateKumikoServerOptions = {
232
234
  * inside buildServer (setupTestStack), before the Static/HTML-fallback,
233
235
  * so an own GET (/feed.xml, /og-image, …) wins over the dev-asset path. */
234
236
  readonly extraRoutes?: readonly ExtraRouteDefinition[];
237
+ /** Forwarded to setupTestStack → buildServer's top-level
238
+ * `ServerOptions.trustedProxyHops`. Dev usually runs unproxied, so this
239
+ * is normally left unset (default 0). */
240
+ readonly trustedProxyHops?: number;
235
241
  /** Hook for app-wired co-running components that need the system-write
236
242
  * dispatcher — runs after buildServer, before onAfterSetup (seeds), with
237
243
  * NO `app` (routes are declared via `extraRoutes`, not wired here). */
@@ -495,9 +501,10 @@ async function tryServePublicAsset(
495
501
  pathname: string,
496
502
  app: HonoLikeApp,
497
503
  publicDir: string,
504
+ socketAddress?: string,
498
505
  ): Promise<Response | undefined> {
499
506
  if (!isRoutableGetOrHead(req, pathname) || !pathname.includes(".")) return undefined;
500
- const honoTry = await tryHonoFirst(app, req);
507
+ const honoTry = await tryHonoFirst(app, req, socketAddress);
501
508
  if (honoTry.matched) {
502
509
  return honoTry.response;
503
510
  }
@@ -512,9 +519,6 @@ async function tryServePublicAsset(
512
519
  return honoTry.response;
513
520
  }
514
521
 
515
- // injectSchema lebt in `./inject-schema.ts` damit dev-server + prod-
516
- // server denselben Inject-Pfad nutzen.
517
-
518
522
  async function watchDir(
519
523
  dir: string,
520
524
  onChange: (filename: string) => void,
@@ -898,6 +902,7 @@ export async function createKumikoServer(
898
902
  effectiveFeatures: options.effectiveFeatures,
899
903
  }),
900
904
  ...(options.extraRoutes !== undefined && { extraRoutes: options.extraRoutes }),
905
+ ...(options.trustedProxyHops !== undefined && { trustedProxyHops: options.trustedProxyHops }),
901
906
  // jobs.consumerLane unset = enqueuer-only; startDevJobRunners below is
902
907
  // the sole consumer/cron-scheduler per lane, so runOnBoot/cron jobs
903
908
  // don't double-fire. queueNamePrefix keeps this boot's queues isolated
@@ -968,17 +973,14 @@ export async function createKumikoServer(
968
973
  const autoMintJwt = options.auth === undefined;
969
974
  const devUser = TestUsers.admin;
970
975
 
971
- // AppSchema einmal beim Boot bauen. Sample-clients ohne explizites
972
- // schema-Argument lesen das via window.__KUMIKO_SCHEMA__ aus — der
973
- // dev-server injiziert das in jede HTML-Response. Re-build NICHT
974
- // bei Hot-Reload weil sich Feature-Defs nur über einen restart
975
- // ändern.
976
- const appSchemaJson = JSON.stringify(
977
- buildAppSchema(stack.registry, {
978
- authoringWarnings: true,
979
- searchAdapterMissing: !stack.context.searchAdapter,
980
- }),
981
- );
976
+ // Build once at boot purely to surface authoring warnings (settings-hub
977
+ // placement etc.) at the same point prod would — clients fetch the actual
978
+ // schema themselves from GET /api/schema, so the result itself is
979
+ // discarded here.
980
+ buildAppSchema(stack.registry, {
981
+ authoringWarnings: true,
982
+ searchAdapterMissing: !stack.context.searchAdapter,
983
+ });
982
984
 
983
985
  // --- SSE reload ---
984
986
  // bootId identifiziert diese spezifische Server-Process-Instanz. Wird
@@ -1000,24 +1002,39 @@ export async function createKumikoServer(
1000
1002
  }
1001
1003
  };
1002
1004
 
1003
- // Build a fresh HTML response. Im Auto-Mint-Modus (keine auth-Config)
1004
- // packen wir direkt ein gültiges JWT + CSRF-Cookie rein — Deep-Links
1005
- // funktionieren sofort ohne Login. Im Auth-Modus serven wir nur die
1006
- // nackte HTML; der Client geht dann durch /auth/login und bekommt die
1007
- // Cookies von dort.
1005
+ // Build a fresh HTML response. In auto-mint mode (no auth config) we
1006
+ // pack a valid JWT + CSRF cookie right in — deep-links work immediately
1007
+ // without login (and that auth cookie is what a client-side fetch to
1008
+ // GET /api/schema authenticates with). In auth mode we serve the bare
1009
+ // HTML; the client then goes through /auth/login and gets its cookies
1010
+ // from there.
1008
1011
  //
1009
- // entryName + injectSchemaForEntry werden vom Caller (handleFetch)
1010
- // bestimmt nachdem er hostDispatch evaluiert hat. Ohne hostDispatch
1011
- // ist es immer "client" mit Schema-Inject true (Single-Entry-Default
1012
- // damit der Client TypeScript-Schemas findet).
1012
+ // entryName is decided by the caller (handleFetch) after evaluating
1013
+ // hostDispatch. Without hostDispatch it's always "client" (single-entry
1014
+ // default).
1013
1015
  //
1014
1016
  // Anonymous-role systemQuery, shared by resolvePageHead and hostDispatch —
1015
1017
  // mirrors runProdApp's HostDispatchFn deps so dev/prod stay symmetric.
1018
+ // Own resolver mirroring the same effective hops as setupTestStack's
1019
+ // internal buildServer — this path runs for
1020
+ // page-head/hostDispatch resolution, ahead of requestIdMiddleware's
1021
+ // /api/* mount, so it can't reuse that instance. Two independent
1022
+ // warn-once flags in dev is an accepted, low-risk gap (dev normally
1023
+ // runs unproxied — see CreateKumikoServerOptions.trustedProxyHops).
1024
+ const pageHeadClientIpResolver = createClientIpResolver(
1025
+ options.trustedProxyHops ?? 0,
1026
+ "createKumikoServer(pageHead)",
1027
+ );
1016
1028
  const buildDevSystemQuery =
1017
- (req: Request): PageHeadSystemQuery =>
1029
+ (req: Request, socketAddress?: string): PageHeadSystemQuery =>
1018
1030
  (type, payload, tenantId) =>
1019
- requestContext.run(requestContext.get() ?? buildRequestContextDataFromRequest(req), () =>
1020
- stack.dispatcher.query(type, payload, createAnonymousUser(tenantId)),
1031
+ requestContext.run(
1032
+ requestContext.get() ??
1033
+ buildRequestContextDataFromRequest(req, {
1034
+ resolver: pageHeadClientIpResolver,
1035
+ socketAddress,
1036
+ }),
1037
+ () => stack.dispatcher.query(type, payload, createAnonymousUser(tenantId)),
1021
1038
  );
1022
1039
 
1023
1040
  // resolvePageHead goes through the same headless resolveAndInjectPageHead
@@ -1025,8 +1042,8 @@ export async function createKumikoServer(
1025
1042
  // fallback path instead of a second copy of it (#3026).
1026
1043
  const htmlResponse = async (
1027
1044
  entryName: string,
1028
- doInjectSchema: boolean,
1029
1045
  req: Request,
1046
+ socketAddress?: string,
1030
1047
  ): Promise<Response> => {
1031
1048
  const template = htmlTemplates.get(entryName) ?? defaultTemplate;
1032
1049
  const headers = new Headers();
@@ -1039,14 +1056,13 @@ export async function createKumikoServer(
1039
1056
  }
1040
1057
  let html = injectReload(template);
1041
1058
  if (stylesheetPath !== undefined) html = injectStylesheet(html);
1042
- if (doInjectSchema) html = injectSchema(html, appSchemaJson);
1043
1059
  if (options.resolvePageHead !== undefined) {
1044
1060
  const url = new URL(req.url);
1045
1061
  const host = req.headers.get("host") ?? url.host;
1046
1062
  html = await resolveAndInjectPageHead(html, options.resolvePageHead, {
1047
1063
  path: url.pathname,
1048
1064
  host,
1049
- systemQuery: buildDevSystemQuery(req),
1065
+ systemQuery: buildDevSystemQuery(req, socketAddress),
1050
1066
  });
1051
1067
  }
1052
1068
  return new Response(html, { headers });
@@ -1064,7 +1080,7 @@ export async function createKumikoServer(
1064
1080
  // process.cwd() is the app workspace, so public/ is its static asset dir.
1065
1081
  const publicDir = resolve(process.cwd(), "public");
1066
1082
 
1067
- const handleFetch = async (req: Request): Promise<Response> => {
1083
+ const handleFetch = async (req: Request, socketAddress?: string): Promise<Response> => {
1068
1084
  const url = new URL(req.url);
1069
1085
 
1070
1086
  // Specific routes first — assets, reload-SSE, API.
@@ -1162,14 +1178,16 @@ export async function createKumikoServer(
1162
1178
  isRoutableGetOrHead(req, url.pathname) &&
1163
1179
  !url.pathname.includes(".")
1164
1180
  ) {
1165
- const honoTry = await tryHonoFirst(stack.app, req);
1181
+ const honoTry = await tryHonoFirst(stack.app, req, socketAddress);
1166
1182
  if (honoTry.matched) {
1167
1183
  return honoTry.response;
1168
1184
  }
1169
1185
  // Discriminated-Dispatch — symmetric zu prod. Ohne hostDispatch
1170
1186
  // landet das im Single-Entry-Default ("client" + Schema-Inject).
1171
1187
  if (options.hostDispatch !== undefined) {
1172
- const dispatch = await options.hostDispatch(req, { systemQuery: buildDevSystemQuery(req) });
1188
+ const dispatch = await options.hostDispatch(req, {
1189
+ systemQuery: buildDevSystemQuery(req, socketAddress),
1190
+ });
1173
1191
  if (dispatch.kind === "redirect") {
1174
1192
  return new Response(null, {
1175
1193
  status: dispatch.status ?? 302,
@@ -1180,28 +1198,34 @@ export async function createKumikoServer(
1180
1198
  return new Response("Not Found", { status: 404 });
1181
1199
  }
1182
1200
  if (dispatch.kind === "static-html") {
1183
- // Raw-File-Serve, kein Bundle-Inject, kein Schema-Inject.
1184
- // Pendant zu prod's `{ kind: "html", file: ..., injectSchema: false }`.
1201
+ // Raw file serve, no bundle injection — mirrors prod's
1202
+ // `{ kind: "html", file: ... }` static-html branch.
1185
1203
  const file = await readFile(dispatch.file, "utf-8");
1186
1204
  return new Response(file, {
1187
1205
  headers: { "Content-Type": "text/html; charset=utf-8" },
1188
1206
  });
1189
1207
  }
1190
- return htmlResponse(dispatch.entryName, dispatch.injectSchema ?? true, req);
1208
+ return htmlResponse(dispatch.entryName, req, socketAddress);
1191
1209
  }
1192
- return htmlResponse("client", true, req);
1210
+ return htmlResponse("client", req, socketAddress);
1193
1211
  }
1194
1212
 
1195
1213
  // Static assets under public/ — see tryServePublicAsset's own comment
1196
1214
  // for the Hono → file → router-miss ordering.
1197
- const staticAsset = await tryServePublicAsset(req, url.pathname, stack.app, publicDir);
1215
+ const staticAsset = await tryServePublicAsset(
1216
+ req,
1217
+ url.pathname,
1218
+ stack.app,
1219
+ publicDir,
1220
+ socketAddress,
1221
+ );
1198
1222
  if (staticAsset !== undefined) return staticAsset;
1199
1223
 
1200
1224
  // Bypasses tryHonoFirst entirely (API paths, /sse, non-GET/HEAD), so the
1201
1225
  // router-miss marker must be stripped here too — otherwise an unmatched
1202
1226
  // path would leak it straight to the client (see try-hono-first.ts's
1203
1227
  // header-hygiene note).
1204
- return stripNoRouteMatchHeader(await stack.app.fetch(req));
1228
+ return stripNoRouteMatchHeader(await stack.app.fetch(req, socketAddress));
1205
1229
  };
1206
1230
 
1207
1231
  // --- HTTP server (Bun only) ---
@@ -1213,7 +1237,7 @@ export async function createKumikoServer(
1213
1237
  // (idleTimeout: 0). Spec-Test in run-prod-app-spec.test.ts pinst das.
1214
1238
  const server = hasBun
1215
1239
  ? (globalThis as { Bun: { serve: (opts: unknown) => BunServer } }).Bun.serve(
1216
- buildBunServeOptions(port, handleFetch),
1240
+ buildBunServeOptions(port, handleFetch, resolveDerivedMaxRequestBodySize(stack.registry)),
1217
1241
  )
1218
1242
  : undefined;
1219
1243
 
@@ -1247,7 +1271,10 @@ export async function createKumikoServer(
1247
1271
  if (action === "ignore") return;
1248
1272
  if (action === "restart") {
1249
1273
  logInfo(
1250
- `[kumiko-server] schema change in ${filename} — restarting (Bun caches imports, hot-reload reicht hier nicht)`,
1274
+ `[kumiko-server] schema change in ${filename} — restarting (Bun caches imports, hot-reload reicht hier nicht). ` +
1275
+ "The kumiko-dev wrapper (package.json's `dev` script) respawns automatically " +
1276
+ "and re-syncs new r.entity() columns on the new boot; running this entry " +
1277
+ "directly (not via kumiko-dev) exits here without restarting.",
1251
1278
  );
1252
1279
  await stop();
1253
1280
  process.exit(75);
@@ -44,7 +44,11 @@ import {
44
44
  SESSIONS_FEATURE,
45
45
  } from "@cosmicdrift/kumiko-bundled-features/sessions";
46
46
  import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
47
- import type { SessionMetadata, TokenVerifier } from "@cosmicdrift/kumiko-framework/api";
47
+ import type {
48
+ PostAuthLandingResolver,
49
+ SessionMetadata,
50
+ TokenVerifier,
51
+ } from "@cosmicdrift/kumiko-framework/api";
48
52
  import { createInMemoryLoginRateLimiter } from "@cosmicdrift/kumiko-framework/api";
49
53
  import {
50
54
  configureBlindIndexKey,
@@ -162,6 +166,10 @@ export type RunDevAppAuthOptions = {
162
166
  * RunProdAppAuthOptions — dev usually runs unproxied, so this is normally
163
167
  * left unset (default 0). */
164
168
  readonly trustedProxyHops?: number;
169
+ /** Server-computed post-auth redirect (see AuthRoutesConfig.postAuthLanding).
170
+ * Result lands in the login/signup/invite response as `landingPath`; an
171
+ * invalid path or a throwing resolver just omits the field. */
172
+ readonly postAuthLanding?: PostAuthLandingResolver;
165
173
  };
166
174
 
167
175
  /** Hook for app-specific seeding (demo data, fixtures). Runs after the
@@ -268,6 +276,12 @@ export type RunDevAppOptions = {
268
276
  * something the user can click. Pass an object to override the
269
277
  * features-dir hint or the docs URL. */
270
278
  readonly welcomeBanner?: boolean | { readonly featuresDir?: string; readonly docsUrl?: string };
279
+ /** Forwarded to createKumikoServer → setupTestStack's top-level
280
+ * `trustedProxyHops` — symmetric to
281
+ * RunProdAppOptions.trustedProxyHops. Wins over the deprecated
282
+ * `auth.trustedProxyHops`. Dev usually runs unproxied, so this is
283
+ * normally left unset (default 0). */
284
+ readonly trustedProxyHops?: number;
271
285
  };
272
286
 
273
287
  export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServerHandle> {
@@ -491,6 +505,9 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
491
505
  ...(options.files !== undefined && { files: options.files }),
492
506
  ...(options.extraRoutes !== undefined && { extraRoutes: options.extraRoutes }),
493
507
  ...(options.wire !== undefined && { wire: options.wire }),
508
+ ...((options.trustedProxyHops ?? effectiveAuth?.trustedProxyHops) !== undefined && {
509
+ trustedProxyHops: options.trustedProxyHops ?? effectiveAuth?.trustedProxyHops,
510
+ }),
494
511
  ...(finalEffectiveFeatures !== undefined && {
495
512
  effectiveFeatures: finalEffectiveFeatures,
496
513
  }),
@@ -514,6 +531,9 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
514
531
  ...(effectiveAuth.trustedProxyHops !== undefined && {
515
532
  trustedProxyHops: effectiveAuth.trustedProxyHops,
516
533
  }),
534
+ ...(effectiveAuth.postAuthLanding !== undefined && {
535
+ postAuthLanding: effectiveAuth.postAuthLanding,
536
+ }),
517
537
  ...sessionAuthFragment,
518
538
  ...patAuthFragment,
519
539
  ...(mfaFeature && {
@@ -705,9 +705,9 @@ function renderDev(appName: string): string {
705
705
  function renderClient(appName: string): string {
706
706
  return [
707
707
  "// Browser entry. runDevApp's clientEntry option bundles this file to",
708
- "// /client.js and the default HTML loads it. createKumikoApp reads the",
709
- "// schema from the window global (injectSchema in the dev-server sets it)",
710
- "// and mounts the routes.",
708
+ "// /client.js and the default HTML loads it. createKumikoApp fetches the",
709
+ "// AppSchema itself from the authenticated GET /api/schema and mounts",
710
+ "// the routes.",
711
711
  "//",
712
712
  "// AppShell wraps DefaultAppShell to supply `brand` — createKumikoApp's",
713
713
  "// shell option only injects schema + children. Without `shell` the",
@@ -1,7 +1,10 @@
1
1
  // Test helper: composeFeatures + setupTestStack in one call.
2
2
  // Apps pass the same feature list as run-config (APP_FEATURES / buildAppFeatures).
3
3
 
4
- import { createConfigResolver } from "@cosmicdrift/kumiko-bundled-features/config";
4
+ import {
5
+ type ConfigResolver,
6
+ createConfigResolver,
7
+ } from "@cosmicdrift/kumiko-bundled-features/config";
5
8
  import { createTemplateResolverApi } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
6
9
  import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
7
10
  import {
@@ -13,6 +16,7 @@ import {
13
16
  type ComposeFeaturesOptions,
14
17
  composeFeatures,
15
18
  } from "@cosmicdrift/kumiko-server-runtime/compose-features";
19
+ import { addConfigAccessorFactory } from "@cosmicdrift/kumiko-server-runtime/run-prod-app";
16
20
 
17
21
  export type TestStackPreset = "config" | "template-resolver";
18
22
 
@@ -34,8 +38,10 @@ export function mergeExtraContext(
34
38
  const merged: Record<string, unknown> = { ...fromBase };
35
39
 
36
40
  if (presets.includes("config")) {
37
- const configResolver = createConfigResolver();
38
- merged["configResolver"] = configResolver;
41
+ // Same precedence as mergeConfigResolverDefault (run-dev-app.ts) — a base-supplied resolver wins, factory always derives from it (fw#3313).
42
+ const configResolver =
43
+ (fromBase["configResolver"] as ConfigResolver | undefined) ?? createConfigResolver();
44
+ Object.assign(merged, addConfigAccessorFactory({ configResolver }, deps.registry));
39
45
  }
40
46
  if (presets.includes("template-resolver")) {
41
47
  merged["templateResolver"] = createTemplateResolverApi(deps.db);