@cosmicdrift/kumiko-server-runtime 0.319.0 → 0.321.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.
@@ -1,15 +1,16 @@
1
1
  import {
2
2
  buildRequestContextDataFromRequest,
3
3
  type CachePolicy,
4
+ type ClientIpResolver,
4
5
  cachedResponse,
5
6
  computeStrongEtag,
6
7
  computeWeakEtag,
8
+ createClientIpResolver,
7
9
  requestContext,
8
10
  } from "@cosmicdrift/kumiko-framework/api";
9
11
  import { createAnonymousUser, type SessionUser } from "@cosmicdrift/kumiko-framework/engine";
10
12
  import { resolveAndInjectPageHead } from "@cosmicdrift/kumiko-headless/apex";
11
13
  import { ASSETS_DIR } from "./build-prod-bundle";
12
- import { injectSchema } from "./inject-schema";
13
14
  import type { HostDispatchFn, PageHeadResolver, PageHeadSystemQuery } from "./run-prod-app";
14
15
  import { stripNoRouteMatchHeader, tryHonoFirst } from "./try-hono-first";
15
16
 
@@ -127,9 +128,8 @@ export type PageHeadOptions = {
127
128
  };
128
129
 
129
130
  export function buildStaticFallback(
130
- apiHandler: (req: Request) => Response | Promise<Response>,
131
+ apiHandler: (req: Request, socketAddress?: string) => Response | Promise<Response>,
131
132
  staticDir: string,
132
- appSchemaJson: string,
133
133
  hostDispatch?: HostDispatchFn,
134
134
  pageHead?: PageHeadOptions,
135
135
  /** Backs hostDispatch's `systemQuery` — required whenever `hostDispatch`
@@ -137,38 +137,31 @@ export function buildStaticFallback(
137
137
  * throw). Separate from `pageHead.dispatcher` since resolvePageHead is
138
138
  * optional independently of hostDispatch. */
139
139
  hostDispatchDispatcher?: QueryDispatcher,
140
- ): (req: Request) => Promise<Response> {
140
+ /** trustedProxyHops for the systemQuery request-context builds below —
141
+ * 1:1 with runProdApp's effective value. */
142
+ trustedProxyHops = 0,
143
+ ): (req: Request, socketAddress?: string) => Promise<Response> {
141
144
  const indexHtml = `${staticDir}/index.html`;
145
+ const clientIpResolver: ClientIpResolver = createClientIpResolver(
146
+ trustedProxyHops,
147
+ "buildStaticFallback",
148
+ );
142
149
 
143
- // Helper: liest eine HTML-Datei von der Disk + (optional) injiziert
144
- // das pre-serialized AppSchema vor dem client.js-Tag. Schema-Injection
145
- // ist explicit-opt-in damit Public-Domain-Antworten die Admin-UI-
146
- // Topologie nicht leaken. injectSchema ist idempotent, doppelte Calls
147
- // produzieren keinen doppelten Tag.
150
+ // Reads an HTML file from disk. No schema injection — createKumikoApp
151
+ // fetches it itself from the authenticated GET /api/schema, so the HTML
152
+ // is identical for every host.
148
153
  async function readHtmlFile(
149
154
  path: string,
150
- injectSchemaInto: boolean,
151
155
  ): Promise<{ bytes: ArrayBuffer; mime: string; etag: string; mtimeMs: number } | null> {
152
156
  const file = await readStaticFile(path);
153
157
  if (!file) return null;
154
- if (!injectSchemaInto) {
155
- return {
156
- bytes: file.bytes.buffer.slice(
157
- file.bytes.byteOffset,
158
- file.bytes.byteOffset + file.bytes.byteLength,
159
- ) as ArrayBuffer,
160
- mime: file.mime,
161
- etag: computeWeakEtag(file.mtimeMs, file.bytes.byteLength),
162
- mtimeMs: file.mtimeMs,
163
- };
164
- }
165
- const text = new TextDecoder().decode(file.bytes);
166
- const injected = injectSchema(text, appSchemaJson);
167
- const bytes = new TextEncoder().encode(injected).buffer as ArrayBuffer;
168
158
  return {
169
- bytes,
159
+ bytes: file.bytes.buffer.slice(
160
+ file.bytes.byteOffset,
161
+ file.bytes.byteOffset + file.bytes.byteLength,
162
+ ) as ArrayBuffer,
170
163
  mime: file.mime,
171
- etag: computeStrongEtag(new Uint8Array(bytes)),
164
+ etag: computeWeakEtag(file.mtimeMs, file.bytes.byteLength),
172
165
  mtimeMs: file.mtimeMs,
173
166
  };
174
167
  }
@@ -203,13 +196,16 @@ export function buildStaticFallback(
203
196
  async function applyPageHead(
204
197
  req: Request,
205
198
  html: { bytes: ArrayBuffer; mime: string; etag: string; mtimeMs: number },
199
+ socketAddress?: string,
206
200
  ): Promise<{ bytes: ArrayBuffer; mime: string; etag: string; mtimeMs: number }> {
207
201
  if (!pageHead) return html;
208
202
  const url = new URL(req.url);
209
203
  const host = req.headers.get("host") ?? url.host;
210
204
  const systemQuery: PageHeadSystemQuery = (type, payload, tenantId) =>
211
- requestContext.run(requestContext.get() ?? buildRequestContextDataFromRequest(req), () =>
212
- pageHead.dispatcher.query(type, payload, createAnonymousUser(tenantId)),
205
+ requestContext.run(
206
+ requestContext.get() ??
207
+ buildRequestContextDataFromRequest(req, { resolver: clientIpResolver, socketAddress }),
208
+ () => pageHead.dispatcher.query(type, payload, createAnonymousUser(tenantId)),
213
209
  );
214
210
  const text = new TextDecoder().decode(html.bytes);
215
211
  const injected = await resolveAndInjectPageHead(text, pageHead.resolvePageHead, {
@@ -231,7 +227,7 @@ export function buildStaticFallback(
231
227
  // HTML-Fallback fällt (Root oder SPA-Route). Returnt entweder die
232
228
  // resolved Response (redirect/404/html) oder null wenn der Default-
233
229
  // Pfad weiterlaufen soll.
234
- async function tryHostDispatch(req: Request): Promise<Response | null> {
230
+ async function tryHostDispatch(req: Request, socketAddress?: string): Promise<Response | null> {
235
231
  if (!hostDispatch) return null;
236
232
  const url = new URL(req.url);
237
233
  const host = req.headers.get("host") ?? url.host;
@@ -242,7 +238,8 @@ export function buildStaticFallback(
242
238
  );
243
239
  }
244
240
  return requestContext.run(
245
- requestContext.get() ?? buildRequestContextDataFromRequest(req),
241
+ requestContext.get() ??
242
+ buildRequestContextDataFromRequest(req, { resolver: clientIpResolver, socketAddress }),
246
243
  () => hostDispatchDispatcher.query(type, payload, createAnonymousUser(tenantId)),
247
244
  );
248
245
  };
@@ -261,28 +258,28 @@ export function buildStaticFallback(
261
258
  }
262
259
  // result.kind === "html"
263
260
  const filePath = `${staticDir}/${result.file}`;
264
- const html = await readHtmlFile(filePath, result.injectSchema === true);
261
+ const html = await readHtmlFile(filePath);
265
262
  if (!html) {
266
263
  // Author-Fehler: hostDispatch verweist auf nicht-existente Datei.
267
264
  // Liefer 500 statt silent-404 damit der Bug schnell auffällt.
268
265
  return new Response(`hostDispatch: file not found: ${result.file}`, { status: 500 });
269
266
  }
270
- // Per-Host-Body (hostDispatch wählt die Datei nach Host) → Vary: Host,
271
- // sonst darf ein Shared-Cache Tenant-As Schema an Tenant B liefern.
267
+ // Per-host body (hostDispatch picks the file by host) → Vary: Host,
268
+ // otherwise a shared cache could serve Host A's HTML to Host B.
272
269
  const extraHeaders: Record<string, string> = { vary: "Host" };
273
270
  if (result.csp) extraHeaders["content-security-policy"] = result.csp;
274
- const withHead = await applyPageHead(req, html);
271
+ const withHead = await applyPageHead(req, html, socketAddress);
275
272
  return serveHtmlFile(req, "/index.html", withHead, extraHeaders);
276
273
  }
277
274
 
278
- return async (req: Request): Promise<Response> => {
275
+ return async (req: Request, socketAddress?: string): Promise<Response> => {
279
276
  const url = new URL(req.url);
280
277
  // /api/* and /health → always Hono (Dispatcher + Health-Probe). Bypasses
281
278
  // tryHonoFirst entirely, so the router-miss marker must be stripped
282
279
  // here too — otherwise an unmatched /api/* path would leak it straight
283
280
  // to the client (see try-hono-first.ts's header-hygiene note).
284
281
  if (url.pathname.startsWith("/api/") || url.pathname === "/health") {
285
- return stripNoRouteMatchHeader(await apiHandler(req));
282
+ return stripNoRouteMatchHeader(await apiHandler(req, socketAddress));
286
283
  }
287
284
 
288
285
  // Hono-First für andere Pfade: extraRoutes (z.B. /feed.xml,
@@ -290,7 +287,7 @@ export function buildStaticFallback(
290
287
  // dem Disk-Lookup greifen, sonst schluckt der SPA-Fallback unten
291
288
  // unbekannte Pfade als index.html. Shared mit dev-server's
292
289
  // createKumikoServer.handleFetch damit beide IDENTISCHE Semantik haben.
293
- const honoTry = await tryHonoFirst({ fetch: apiHandler }, req);
290
+ const honoTry = await tryHonoFirst({ fetch: apiHandler }, req, socketAddress);
294
291
  if (honoTry.matched) {
295
292
  return honoTry.response;
296
293
  }
@@ -317,15 +314,15 @@ export function buildStaticFallback(
317
314
  }
318
315
  }
319
316
 
320
- // Root oder SPA-Route — hier greift hostDispatch wenn gesetzt.
321
- // Ohne hostDispatch: alter Single-App-Pfad (index.html mit Schema).
322
- const dispatched = await tryHostDispatch(req);
317
+ // Root or SPA route — hostDispatch applies here when set.
318
+ // Without hostDispatch: the old single-app path (index.html).
319
+ const dispatched = await tryHostDispatch(req, socketAddress);
323
320
  if (dispatched) return dispatched;
324
321
 
325
- // Default Single-App-Pfad: index.html, schema injected.
326
- const index = await readHtmlFile(indexHtml, true);
322
+ // Default single-app path: index.html, no schema injection.
323
+ const index = await readHtmlFile(indexHtml);
327
324
  if (index) {
328
- const withHead = await applyPageHead(req, index);
325
+ const withHead = await applyPageHead(req, index, socketAddress);
329
326
  return serveHtmlFile(req, "/index.html", withHead);
330
327
  }
331
328
 
@@ -75,8 +75,11 @@ import {
75
75
  type ExtraRouteDefinition,
76
76
  type LoginRateLimiter,
77
77
  loadJwtSecretOrKeyring,
78
+ type PostAuthLandingResolver,
79
+ parseTrustedProxyHopsEnv,
78
80
  type SseBroker,
79
81
  type TokenVerifier,
82
+ TRUSTED_PROXY_HOPS_ENV,
80
83
  } from "@cosmicdrift/kumiko-framework/api";
81
84
  import {
82
85
  configureBlindIndexKey,
@@ -90,7 +93,6 @@ import {
90
93
  type DbRunner,
91
94
  } from "@cosmicdrift/kumiko-framework/db";
92
95
  import {
93
- buildAppSchema,
94
96
  collectWriteHandlerQns,
95
97
  createRegistry,
96
98
  type EffectiveFeaturesResolver,
@@ -145,7 +147,7 @@ import Redis from "ioredis";
145
147
  import { applyBootSeeds } from "./boot/apply-boot-seeds";
146
148
  import { resolveBootCrypto } from "./boot/boot-crypto";
147
149
  import { jobRunLoggerCallbacks } from "./boot/job-run-logger";
148
- import { buildBunServeOptions } from "./bun-serve-options";
150
+ import { buildBunServeOptions, resolveDerivedMaxRequestBodySize } from "./bun-serve-options";
149
151
  import { buildComposeAuthOptions, composeFeatures } from "./compose-features";
150
152
  import { makeDispatchSystemWrite, type SystemWireDeps } from "./extra-routes-deps";
151
153
  import { assertPiiBootInvariants } from "./pii-boot-gate";
@@ -248,17 +250,24 @@ function makeDryRunHandle(): ProdAppHandle {
248
250
  };
249
251
  }
250
252
 
251
- /** Wrapper-API für den Password-Reset-Flow.
253
+ /** Wrapper API for the password-reset flow.
252
254
  *
253
- * Seit der delivery-Migration trägt PasswordResetOptions selbst `appUrl`
254
- * (+ appName/locale) und der Handler mailt via ctx.notify — kein
255
- * sendResetEmail-Callback mehr. Apps geben `auth.mail` (Convenience,
256
- * resolveAuthMail baut die appUrl) ODER einen expliziten Block. */
257
- export type PasswordResetSetup = PasswordResetOptions;
255
+ * Since the delivery migration PasswordResetOptions carries `appUrl`
256
+ * (+ appName/locale) itself and the handler mails via ctx.notify — no
257
+ * sendResetEmail callback anymore. Apps pass `auth.mail` (convenience,
258
+ * resolveAuthMail builds the appUrl) OR an explicit block.
259
+ *
260
+ * hmacSecret is optional here (unlike PasswordResetOptions): resolveAuthMail
261
+ * backfills it, so overriding just appUrl needs no secret of its own. */
262
+ export type PasswordResetSetup = Omit<PasswordResetOptions, "hmacSecret"> & {
263
+ readonly hmacSecret?: string;
264
+ };
258
265
 
259
- /** Wrapper-API für den Email-Verification-Flow. Symmetrisch zu
260
- * PasswordResetSetup — = EmailVerificationOptions (appUrl via delivery). */
261
- export type EmailVerificationSetup = EmailVerificationOptions;
266
+ /** Wrapper API for the email-verification flow, symmetric to
267
+ * PasswordResetSetup (appUrl via delivery, optional hmacSecret). */
268
+ export type EmailVerificationSetup = Omit<EmailVerificationOptions, "hmacSecret"> & {
269
+ readonly hmacSecret?: string;
270
+ };
262
271
 
263
272
  /** Wrapper-API für Magic-Link Self-Signup. = SignupOptions (appUrl, die
264
273
  * Mail geht via delivery/ctx.notify wie reset/verify). Anders als reset/
@@ -303,6 +312,9 @@ export type AuthMailOptions = {
303
312
  readonly from?: string;
304
313
  /** Einzelne Auth-Pfade überschreiben (Default DEFAULT_AUTH_PATHS). */
305
314
  readonly paths?: Partial<AuthPaths>;
315
+ /** Reset/verify token secret, decoupled from JWT_SECRET rotation.
316
+ * Falls back to resolveAuthMail's call-site secret when unset. */
317
+ readonly hmacSecret?: string;
306
318
  };
307
319
 
308
320
  export type RunProdAppAuthOptions = {
@@ -370,9 +382,15 @@ export type RunProdAppAuthOptions = {
370
382
  * process for client-IP derivation (see AuthRoutesConfig.trustedProxyHops,
371
383
  * kumiko-framework#1539) — closes the X-Forwarded-For spoofing hole on
372
384
  * the auth rate-limiters. Falls back to the `KUMIKO_TRUSTED_PROXY_HOPS`
373
- * env var when unset; both unset means the pre-#1539 spoofable default
374
- * (0 hops). Set this to your real ingress hop count (typically 1). */
385
+ * env var when unset; both unset means 0 hops, the safe socket-only
386
+ * default (only the socket address counts, XFF ignored). Deprecated in
387
+ * favor of the top-level `trustedProxyHops` option, which wins over
388
+ * this and the env var. */
375
389
  readonly trustedProxyHops?: number;
390
+ /** Server-computed post-auth redirect (see AuthRoutesConfig.postAuthLanding).
391
+ * Result lands in the login/signup/invite response as `landingPath`; an
392
+ * invalid path or a throwing resolver just omits the field. */
393
+ readonly postAuthLanding?: PostAuthLandingResolver;
376
394
  };
377
395
 
378
396
  /** Hook for app-specific seeding — runs after the admin (when auth is
@@ -403,23 +421,25 @@ export type ExtraContextOption =
403
421
  | Record<string, unknown>
404
422
  | ((deps: RunProdAppDeps) => Record<string, unknown>);
405
423
 
406
- /** Per-Host Routing-Entscheidung für den staticDir-Fallback. Wird aus
407
- * hostDispatch returned. Drei Modi:
408
- * - "html": eine bestimmte HTML-Datei (relativ zu staticDir) servieren,
409
- * mit optionaler Schema-Injection und CSP. Schema-Injection MUSS
410
- * explizit eingeschaltet werden (default false) — Public-Domain-
411
- * Antworten leaken sonst die volle Admin-UI-Schema-Topologie.
412
- * - "redirect": 301/302 an die angegebene Location.
413
- * - "not-found": klar abweisen (z.B. unbekannte Subdomain).
424
+ /** Per-host routing decision for the staticDir fallback, returned from
425
+ * hostDispatch. Three modes:
426
+ * - "html": serve a specific HTML file (relative to staticDir), optionally
427
+ * with CSP. HTML never carries a schema — createKumikoApp fetches it
428
+ * itself from the authenticated GET /api/schema, after the
429
+ * clientFeature gates (auth) let it through.
430
+ * - "redirect": 301/302 to the given location.
431
+ * - "not-found": reject outright (e.g. unknown subdomain).
414
432
  *
415
- * Wird NUR konsultiert wenn der Pfad sonst auf den HTML-Fallback gehen
416
- * würde — also für "/", "/index.html", oder SPA-Routen die weder Hono
417
- * matched noch eine konkrete Disk-Datei treffen. Asset-Pfade (/assets/*)
418
- * und API-Pfade laufen unabhängig vom Host. */
433
+ * Only consulted when the path would otherwise fall through to the HTML
434
+ * fallback — i.e. for "/", "/index.html", or SPA routes that neither Hono
435
+ * matches nor a concrete disk file. Asset paths (/assets/*) and API paths
436
+ * run independent of the host. */
419
437
  export type HostDispatchResult =
420
438
  | {
421
439
  readonly kind: "html";
422
440
  readonly file: string;
441
+ /** @deprecated Ignored — HTML never carries the schema; createKumikoApp
442
+ * fetches it from the authenticated GET /api/schema. */
423
443
  readonly injectSchema?: boolean;
424
444
  readonly csp?: string;
425
445
  }
@@ -458,6 +478,12 @@ export type RunProdAppOptions = {
458
478
  readonly validateBootOptions?: ValidateBootOptions;
459
479
  /** Listen-Port. Default 3000 (or $PORT). */
460
480
  readonly port?: number;
481
+ /** Ceiling Bun.serve buffers a request body to before any app-level
482
+ * body-limit middleware runs. Default is derived from `createFilesFeature`'s
483
+ * configured `maxUploadSize`/field `maxSize` (see
484
+ * resolveDerivedMaxRequestBodySize in bun-serve-options.ts) — override
485
+ * only for a custom ceiling unrelated to that config. */
486
+ readonly maxRequestBodySize?: number;
461
487
  /** Auth-mode: standard features + routes wired, admin seeded. */
462
488
  readonly auth?: RunProdAppAuthOptions;
463
489
  /** Custom seed functions, run after the admin seed (when auth-mode). */
@@ -481,21 +507,19 @@ export type RunProdAppOptions = {
481
507
  * for any path that doesn't match an /api/ handler. Use this for the
482
508
  * public status page HTML, embed widget JS, etc. */
483
509
  readonly staticDir?: string;
484
- /** Host-aware Routing-Hook für Multi-Tenant + Multi-App-Deployments
485
- * (z.B. publicstatus's `<sub>.publicstatus.eu` (Public-Page) +
486
- * `admin.publicstatus.eu` (Admin-UI) + `publicstatus.eu` (Apex/
487
- * Marketing) im SELBEN Container).
510
+ /** Host-aware routing hook for multi-tenant + multi-app deployments
511
+ * (e.g. publicstatus's `<sub>.publicstatus.eu` (public page) +
512
+ * `admin.publicstatus.eu` (admin UI) + `publicstatus.eu` (apex/
513
+ * marketing) in the SAME container).
488
514
  *
489
- * Wird aufgerufen wenn der staticDir-Fallback einen HTML-Response
490
- * generieren würde (Root oder SPA-Route). Default-Verhalten ohne
491
- * hostDispatch: index.html mit Schema-Injection (Single-App).
515
+ * Called when the staticDir fallback would generate an HTML response
516
+ * (root or SPA route). Default behavior without hostDispatch: index.html
517
+ * (single-app), no schema.
492
518
  *
493
- * Sicherheitshinweis: Schema-Injection (`__KUMIKO_SCHEMA__`) leakt
494
- * die Admin-UI-Topologie (alle Screens, Felder, Layouts) ans HTML.
495
- * Public-Domain-Antworten sollen das NIEMALS — `injectSchema` ist
496
- * daher default false und MUSS pro Host explizit eingeschaltet
497
- * werden. CSP-Header pro Host können zusätzlich Asset-Pfade
498
- * einschränken. */
519
+ * Schema safety: HTML (per-host as well as single-app) never carries an
520
+ * AppSchema — createKumikoApp fetches it via GET /api/schema, which sits
521
+ * behind the auth middleware and only serves non-anonymous, signed-in
522
+ * users. Per-host CSP headers can additionally restrict asset paths. */
499
523
  readonly hostDispatch?: HostDispatchFn;
500
524
  /** Per-request head-metadata resolver (Open-Graph/title/description) for
501
525
  * the static-fallback HTML shell — see `PageHeadResolver`. Consulted on
@@ -664,6 +688,12 @@ export type RunProdAppOptions = {
664
688
  * overridden. `false` disables the whole block; per-header overrides
665
689
  * via the object form — see SecurityHeadersOption. */
666
690
  readonly securityHeaders?: SecurityHeadersOption;
691
+ /** Number of trusted reverse-proxy hops for client-IP resolution — 1:1
692
+ * durchgereicht an `buildServer`s
693
+ * top-level `ServerOptions.trustedProxyHops`. Wins over the deprecated
694
+ * `auth.trustedProxyHops` and `KUMIKO_TRUSTED_PROXY_HOPS`. Default 0 =
695
+ * trust no proxy header, only the socket address (or "unknown") counts. */
696
+ readonly trustedProxyHops?: number;
667
697
  };
668
698
 
669
699
  export type ProdAppHandle = {
@@ -674,8 +704,12 @@ export type ProdAppHandle = {
674
704
  * `process.exit(0)` before returning a handle. */
675
705
  readonly entrypoint: ApiEntrypoint | AllInOneEntrypoint;
676
706
  /** The fetch-handler — wired into Bun.serve in production, called
677
- * directly in tests. Composes Hono + static-fallback. */
678
- readonly fetch: (req: Request) => Promise<Response> | Response;
707
+ * directly in tests. Composes Hono + static-fallback. Apps with
708
+ * `autoListen: false` that call `Bun.serve` themselves must pass the
709
+ * 2nd arg from `server.requestIP(req)?.address`, extracted in their
710
+ * outermost Bun fetch callback — `requestIP` only resolves for Bun's
711
+ * original Request instance, never a cloned/rebuilt one. */
712
+ readonly fetch: (req: Request, socketAddress?: string) => Promise<Response> | Response;
679
713
  /** Active Bun-server (only set when listen() was called — tests skip
680
714
  * listen() because Bun.serve isn't available under vitest/node). */
681
715
  server?: ReturnType<typeof Bun.serve>;
@@ -792,30 +826,10 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
792
826
  // kumiko-framework#1539 — options.auth.trustedProxyHops wins; falls back
793
827
  // to the env var so ops can close the XFF-spoofing hole per-deployment
794
828
  // without a code change (mirrors instanceId's env-first pattern above).
795
- // Fail loud on a garbage env value rather than silently coercing to NaN:
796
- // clientIpOf treats NaN like "always short chain" and returns "unknown"
797
- // for every request, which collapses mfa-verify/preauth-confirm's
798
- // pure-IP-keyed rate limiter into one shared bucket for the whole
799
- // deployment — a self-inflicted DoS, worse than staying on the default.
800
- const trustedProxyHopsFromEnv = readEnv("KUMIKO_TRUSTED_PROXY_HOPS", envSource);
801
- const trustedProxyHops = ((): number | undefined => {
802
- if (options.auth?.trustedProxyHops !== undefined) return options.auth.trustedProxyHops;
803
- if (trustedProxyHopsFromEnv === undefined) return undefined;
804
- // Digits-only — parseInt("0x10")/("1e3")/("2x") would silently coerce
805
- // and land on the spoofable hops=0 path for values like "0x10".
806
- if (!/^\d+$/.test(trustedProxyHopsFromEnv)) {
807
- throw new Error(
808
- `runProdApp: KUMIKO_TRUSTED_PROXY_HOPS must be a non-negative integer, got "${trustedProxyHopsFromEnv}".`,
809
- );
810
- }
811
- const parsed = Number.parseInt(trustedProxyHopsFromEnv, 10);
812
- if (!Number.isInteger(parsed) || parsed < 0) {
813
- throw new Error(
814
- `runProdApp: KUMIKO_TRUSTED_PROXY_HOPS must be a non-negative integer, got "${trustedProxyHopsFromEnv}".`,
815
- );
816
- }
817
- return parsed;
818
- })();
829
+ const trustedProxyHops =
830
+ options.trustedProxyHops ??
831
+ options.auth?.trustedProxyHops ??
832
+ parseTrustedProxyHopsEnv(readEnv(TRUSTED_PROXY_HOPS_ENV, envSource), "runProdApp");
819
833
  const port = options.port ?? Number.parseInt(envSource["PORT"] ?? "3000", 10);
820
834
 
821
835
  // biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
@@ -989,11 +1003,6 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
989
1003
  ...addConfigAccessorFactory({ ...autoExtraContext, ...resolvedExtraContext }, registry),
990
1004
  ...(options.defaultLocale !== undefined && { defaultLocale: options.defaultLocale }),
991
1005
  };
992
- // @cast-boundary engine-bridge — searchAdapter is an optional context-extension
993
- // (SharedContextFields.searchAdapter), invisible to extraContext's loose
994
- // Record<string, unknown>-based typing. Presence check only, no need to
995
- // pull in the SearchAdapter type here.
996
- const searchAdapterMissing = !(extraContext as { searchAdapter?: unknown }).searchAdapter;
997
1006
  const baseAnonymousAccess =
998
1007
  typeof options.anonymousAccess === "function"
999
1008
  ? options.anonymousAccess(deps)
@@ -1075,6 +1084,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1075
1084
  ...(options.observabilityOptions && { observabilityOptions: options.observabilityOptions }),
1076
1085
  ...(options.metrics && { metrics: options.metrics }),
1077
1086
  ...(options.rateLimit && { rateLimit: options.rateLimit }),
1087
+ ...(trustedProxyHops !== undefined && { trustedProxyHops }),
1078
1088
  ...(options.extraRoutes && { extraRoutes: options.extraRoutes }),
1079
1089
  ...(effectiveAuth && {
1080
1090
  auth: {
@@ -1101,6 +1111,9 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1101
1111
  unsafeSkipOriginCheck: effectiveAuth.unsafeSkipOriginCheck,
1102
1112
  }),
1103
1113
  ...(trustedProxyHops !== undefined && { trustedProxyHops }),
1114
+ ...(effectiveAuth.postAuthLanding !== undefined && {
1115
+ postAuthLanding: effectiveAuth.postAuthLanding,
1116
+ }),
1104
1117
  ...sessionAuthFragment,
1105
1118
  ...patAuthFragment,
1106
1119
  ...(mfaFeature && {
@@ -1222,18 +1235,6 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1222
1235
  });
1223
1236
  }
1224
1237
 
1225
- // 8. Build the AppSchema once + serialize. Wird beim Static-Fallback
1226
- // in die index.html injiziert damit createKumikoApp() im Browser
1227
- // `window.__KUMIKO_SCHEMA__` synchron lesen kann — gleicher Pfad
1228
- // wie im dev-server, damit der Client-Code keine Sonderfall-
1229
- // Branch zwischen dev/prod braucht. Boot-once weil Features
1230
- // nach dem Start nicht mehr ändern.
1231
- // TODO: Sobald per-Tenant- oder per-User-Schema kommt (Feature-Toggles
1232
- // pro Tenant, Auth-Rolle gated Screens), muss die Injection pro
1233
- // Request rendern — staticDir-Fallback einen render(req)-Hook bekommen
1234
- // statt eines fixed JSON-Strings. Heute: registry-static, also OK.
1235
- const appSchemaJson = JSON.stringify(buildAppSchema(registry, { searchAdapterMissing }));
1236
-
1237
1238
  // `extraRoutes` itself is already mounted — buildServer wires it inside
1238
1239
  // createApiEntrypoint/createAllInOneEntrypoint, before this function ever
1239
1240
  // runs. `wire` is the remaining escape-hatch for app-wired co-running
@@ -1301,19 +1302,20 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1301
1302
  ? buildStaticFallback(
1302
1303
  entrypoint.app.fetch.bind(entrypoint.app),
1303
1304
  options.staticDir,
1304
- appSchemaJson,
1305
1305
  options.hostDispatch,
1306
1306
  options.resolvePageHead
1307
1307
  ? { resolvePageHead: options.resolvePageHead, dispatcher: entrypoint.dispatcher }
1308
1308
  : undefined,
1309
1309
  entrypoint.dispatcher,
1310
+ trustedProxyHops ?? 0,
1310
1311
  )
1311
1312
  : // No staticDir (split-deploy / API-only container) → app.fetch's
1312
1313
  // response goes straight to the client, bypassing buildStaticFallback
1313
1314
  // (and with it tryHonoFirst) entirely. Must strip the router-miss
1314
1315
  // marker here too, same reason as the /api/* passthrough in
1315
1316
  // run-prod-app-static-files.ts (see try-hono-first.ts).
1316
- async (req: Request) => stripNoRouteMatchHeader(await entrypoint.app.fetch(req)),
1317
+ async (req: Request, socketAddress?: string) =>
1318
+ stripNoRouteMatchHeader(await entrypoint.app.fetch(req, socketAddress)),
1317
1319
  options.securityHeaders,
1318
1320
  );
1319
1321
 
@@ -1338,7 +1340,13 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1338
1340
  "Under Node/vitest pass `autoListen: false` and call the returned `fetch()` directly.",
1339
1341
  );
1340
1342
  }
1341
- handle.server = Bun.serve(buildBunServeOptions(listenPort, fetchHandler));
1343
+ handle.server = Bun.serve(
1344
+ buildBunServeOptions(
1345
+ listenPort,
1346
+ fetchHandler,
1347
+ options.maxRequestBodySize ?? resolveDerivedMaxRequestBodySize(registry),
1348
+ ),
1349
+ );
1342
1350
 
1343
1351
  // SIGTERM/SIGINT — graceful shutdown. Only registered when we
1344
1352
  // actually own a Bun-server, otherwise the test process picks up
@@ -43,13 +43,13 @@ export function resolveSecurityHeaders(
43
43
  // Sets each header only when absent so per-response values (e.g. the
44
44
  // per-host CSP from hostDispatch) always win over the runtime default.
45
45
  export function withSecurityHeaders(
46
- handler: (req: Request) => Response | Promise<Response>,
46
+ handler: (req: Request, socketAddress?: string) => Response | Promise<Response>,
47
47
  option: SecurityHeadersOption | undefined,
48
- ): (req: Request) => Response | Promise<Response> {
48
+ ): (req: Request, socketAddress?: string) => Response | Promise<Response> {
49
49
  const defaults = resolveSecurityHeaders(option);
50
50
  if (defaults.length === 0) return handler;
51
- return async (req: Request): Promise<Response> => {
52
- const res = await handler(req);
51
+ return async (req: Request, socketAddress?: string): Promise<Response> => {
52
+ const res = await handler(req, socketAddress);
53
53
  try {
54
54
  for (const [name, value] of defaults) {
55
55
  if (!res.headers.has(name)) res.headers.set(name, value);
@@ -31,11 +31,13 @@
31
31
  import { NO_ROUTE_MATCH_HEADER_NAME } from "@cosmicdrift/kumiko-framework/api";
32
32
 
33
33
  export type HonoLikeApp = {
34
- // Hono.app.fetch is `(req) => Response | Promise<Response>` (sync if all
35
- // handlers are sync, otherwise a Promise). createApiEntrypoint's
34
+ // Hono.app.fetch is `(req, env?) => Response | Promise<Response>` (sync if
35
+ // all handlers are sync, otherwise a Promise). createApiEntrypoint's
36
36
  // apiHandler matches the same shape. The union accepts both — we await
37
- // below, which works for either case.
38
- readonly fetch: (req: Request) => Response | Promise<Response>;
37
+ // below, which works for either case. `socketAddress` rides in as `env`
38
+ // so requestIdMiddleware/rate-limit can read it via `c.env` without Hono
39
+ // ever seeing a raw Bun `Server`.
40
+ readonly fetch: (req: Request, socketAddress?: string) => Response | Promise<Response>;
39
41
  };
40
42
 
41
43
  export type HonoFirstResult = {
@@ -69,8 +71,12 @@ export function stripNoRouteMatchHeader(response: Response): Response {
69
71
  * req.clone() because downstream needs to read the request body again
70
72
  * (future-proofing for POST/PUT/PATCH — only GET routes today).
71
73
  */
72
- export async function tryHonoFirst(app: HonoLikeApp, req: Request): Promise<HonoFirstResult> {
73
- const response = await app.fetch(req.clone());
74
+ export async function tryHonoFirst(
75
+ app: HonoLikeApp,
76
+ req: Request,
77
+ socketAddress?: string,
78
+ ): Promise<HonoFirstResult> {
79
+ const response = await app.fetch(req.clone(), socketAddress);
74
80
  const isRouterMiss = response.status === 404 && response.headers.has(NO_ROUTE_MATCH_HEADER_NAME);
75
81
  stripNoRouteMatchHeader(response);
76
82
  return { matched: !isRouterMiss, response };