@cosmicdrift/kumiko-server-runtime 0.319.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.
@@ -25,6 +25,7 @@ import {
25
25
  ExtraRouteRejection,
26
26
  isRedisSseBroker,
27
27
  NO_ROUTE_MATCH_HEADER_NAME,
28
+ requestContext,
28
29
  type SseEvent,
29
30
  signatureRoute,
30
31
  } from "@cosmicdrift/kumiko-framework/api";
@@ -92,6 +93,17 @@ const widgetFeature = defineFeature("prod-probe", (r) => {
92
93
  rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
93
94
  handler: async () => ({ pong: true }),
94
95
  });
96
+ // Echoes the resolved client IP — the only way to prove a real Bun.serve
97
+ // socket address reaches requestContext.ip end-to-end (fw#3260 addendum:
98
+ // every other test in this file drives fetchHandler directly, which never
99
+ // exercises server.requestIP()).
100
+ r.queryHandler({
101
+ name: "ip-probe",
102
+ schema: z.object({}),
103
+ access: { roles: ["anonymous"] },
104
+ rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
105
+ handler: async () => ({ ip: requestContext.get()?.ip }),
106
+ });
95
107
  r.queryHandler({
96
108
  name: "kms-probe",
97
109
  schema: z.object({}),
@@ -691,9 +703,10 @@ describe("runProdApp", () => {
691
703
  });
692
704
 
693
705
  test("hostDispatch: per-host html-Datei + Schema-Gating", async () => {
694
- // Multi-App-Deployment: zwei HTML-Dateien für unterschiedliche
695
- // Hosts. Schema wird NUR für admin-Host injected — Public-Host
696
- // bekommt das pure HTML ohne __KUMIKO_SCHEMA__ Tag (Sicherheit).
706
+ // Multi-app deployment: two HTML files for different hosts. Neither
707
+ // ever carries __KUMIKO_SCHEMA__ (kumiko-framework#3314) —
708
+ // createKumikoApp fetches the schema itself from GET /api/schema
709
+ // after the auth gates let it through, regardless of host.
697
710
  const tmpStaticDir = await createTempStaticDir({
698
711
  "index.html": "<html><body>PUBLIC</body><script src=/client.js></script></html>",
699
712
  "admin.html": "<html><body>ADMIN</body><script src=/client.js></script></html>",
@@ -703,13 +716,12 @@ describe("runProdApp", () => {
703
716
  staticDir: tmpStaticDir,
704
717
  hostDispatch: ({ host }) => {
705
718
  if (host.startsWith("admin.")) {
706
- return { kind: "html", file: "admin.html", injectSchema: true };
719
+ return { kind: "html", file: "admin.html" };
707
720
  }
708
- return { kind: "html", file: "index.html", injectSchema: false };
721
+ return { kind: "html", file: "index.html" };
709
722
  },
710
723
  });
711
724
 
712
- // Public host: index.html, KEIN schema-Tag.
713
725
  const pubRes = await handle.fetch(
714
726
  new Request("http://demo.example.test/", { headers: { host: "demo.example.test" } }),
715
727
  );
@@ -718,57 +730,107 @@ describe("runProdApp", () => {
718
730
  expect(pubBody).toContain("PUBLIC");
719
731
  expect(pubBody).not.toContain("__KUMIKO_SCHEMA__");
720
732
 
721
- // Admin host: admin.html MIT schema-Tag.
722
733
  const adminRes = await handle.fetch(
723
734
  new Request("http://admin.example.test/", { headers: { host: "admin.example.test" } }),
724
735
  );
725
736
  expect(adminRes.status).toBe(200);
726
737
  const adminBody = await adminRes.text();
727
738
  expect(adminBody).toContain("ADMIN");
728
- expect(adminBody).toContain("__KUMIKO_SCHEMA__");
729
-
730
- // #2062: this boot()'s widgetFeature never wires extraContext.searchAdapter,
731
- // so run-prod-app's `searchAdapterMissing = !(extraContext).searchAdapter`
732
- // must actually flip true and flow into the injected schema — proving
733
- // the cast-boundary read isn't silently always-false.
734
- const schemaMatch = adminBody.match(/window\.__KUMIKO_SCHEMA__=(\{.*?\});<\/script>/s);
735
- expect(schemaMatch).not.toBeNull();
736
- const injectedSchema = JSON.parse(schemaMatch?.[1] ?? "{}");
737
- // Guards against a regex miss silently degrading to `?? "{}"` — an empty
738
- // features array would make `.every(...)` below vacuously true too.
739
- expect(injectedSchema.features.length).toBeGreaterThan(0);
740
- const allFeaturesMissingAdapter = injectedSchema.features.every(
741
- (f: { searchAdapterMissing?: boolean }) => f.searchAdapterMissing === true,
742
- );
743
- expect(allFeaturesMissingAdapter).toBe(true);
739
+ expect(adminBody).not.toContain("__KUMIKO_SCHEMA__");
744
740
  });
745
741
 
746
- test("extraContext.searchAdapter wired → injected schema omits searchAdapterMissing (#2062)", async () => {
742
+ test("staticDir without hostDispatch: HTML never carries the schema; GET /api/schema needs a signed-in user (kumiko-framework#3314)", async () => {
743
+ // A real POST /api/auth/login round-trip needs the tenant/user bundled
744
+ // features' own projection tables (e.g. read_tenants), which this
745
+ // suite's boot() doesn't migrate (migrations: false, only the app's own
746
+ // entity tables are auto-created — see unsafeEnsureEntityTable above).
747
+ // Signing a token straight through the entrypoint's JwtHelper exercises
748
+ // the exact same cookie transport auth-middleware reads, without
749
+ // pulling in that migration surface just for this schema-fetch test.
747
750
  const tmpStaticDir = await createTempStaticDir({
748
- "index.html": "<html><body>ADMIN</body><script src=/client.js></script></html>",
751
+ "index.html": "<html><body>SHELL</body><script src=/client.js></script></html>",
749
752
  });
750
753
 
754
+ const handle = await boot(undefined, { staticDir: tmpStaticDir });
755
+
756
+ const rootRes = await handle.fetch(new Request("http://test/"));
757
+ expect(rootRes.status).toBe(200);
758
+ expect(await rootRes.text()).not.toContain("__KUMIKO_SCHEMA__");
759
+
760
+ const spaRes = await handle.fetch(new Request("http://test/some/spa/route"));
761
+ expect(spaRes.status).toBe(200);
762
+ expect(await spaRes.text()).not.toContain("__KUMIKO_SCHEMA__");
763
+
764
+ const anonSchemaRes = await handle.fetch(new Request("http://test/api/schema"));
765
+ expect(anonSchemaRes.status).toBe(401);
766
+
767
+ const token = await handle.entrypoint.jwt.sign({
768
+ id: "schema-fetch-user",
769
+ tenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
770
+ roles: ["User"],
771
+ });
772
+ const schemaRes = await handle.fetch(
773
+ new Request("http://test/api/schema", { headers: { Cookie: `kumiko_auth=${token}` } }),
774
+ );
775
+ expect(schemaRes.status).toBe(200);
776
+ const schema = (await schemaRes.json()) as { features: readonly unknown[] };
777
+ expect(schema.features.length).toBeGreaterThan(0);
778
+ });
779
+
780
+ test("anonymousAccess wired → GET /api/schema still 401s for the synthesised anonymous user", async () => {
781
+ const handle = await boot(undefined, {
782
+ anonymousAccess: {
783
+ defaultTenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
784
+ },
785
+ });
786
+
787
+ const res = await handle.entrypoint.app.fetch(new Request("http://test/api/schema"));
788
+ expect(res.status).toBe(401);
789
+ });
790
+
791
+ test("extraContext.searchAdapter absent → GET /api/schema marks every feature searchAdapterMissing (#2062)", async () => {
792
+ const handle = await boot();
793
+ const token = await handle.entrypoint.jwt.sign({
794
+ id: "schema-probe-user",
795
+ tenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
796
+ roles: ["User"],
797
+ });
798
+
799
+ const res = await handle.entrypoint.app.fetch(
800
+ new Request("http://test/api/schema", { headers: { Authorization: `Bearer ${token}` } }),
801
+ );
802
+ expect(res.status).toBe(200);
803
+ const schema = (await res.json()) as {
804
+ features: ReadonlyArray<{ searchAdapterMissing?: boolean }>;
805
+ };
806
+ // Guards against a vacuous pass — an empty features array would make
807
+ // `.every(...)` below vacuously true too.
808
+ expect(schema.features.length).toBeGreaterThan(0);
809
+ expect(schema.features.every((f) => f.searchAdapterMissing === true)).toBe(true);
810
+ });
811
+
812
+ test("extraContext.searchAdapter wired → GET /api/schema omits searchAdapterMissing (#2062)", async () => {
751
813
  const handle = await boot(undefined, {
752
- staticDir: tmpStaticDir,
753
814
  // Presence-only probe — run-prod-app's cast-boundary read only checks
754
815
  // truthiness, so a minimal stand-in is enough without pulling in the
755
816
  // real SearchAdapter shape.
756
817
  extraContext: { searchAdapter: { configure: async () => {}, search: async () => [] } },
757
- hostDispatch: () => ({ kind: "html", file: "index.html", injectSchema: true }),
818
+ });
819
+ const token = await handle.entrypoint.jwt.sign({
820
+ id: "schema-probe-user",
821
+ tenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
822
+ roles: ["User"],
758
823
  });
759
824
 
760
- const res = await handle.fetch(new Request("http://demo.example.test/"));
761
- const body = await res.text();
762
- const schemaMatch = body.match(/window\.__KUMIKO_SCHEMA__=(\{.*?\});<\/script>/s);
763
- expect(schemaMatch).not.toBeNull();
764
- const injectedSchema = JSON.parse(schemaMatch?.[1] ?? "{}");
765
- // Guards against a regex miss silently degrading to `?? "{}"` — an empty
766
- // features array would make `.some(...)` below vacuously false too.
767
- expect(injectedSchema.features.length).toBeGreaterThan(0);
768
- const anyFeatureMissingAdapter = injectedSchema.features.some(
769
- (f: { searchAdapterMissing?: boolean }) => f.searchAdapterMissing === true,
825
+ const res = await handle.entrypoint.app.fetch(
826
+ new Request("http://test/api/schema", { headers: { Authorization: `Bearer ${token}` } }),
770
827
  );
771
- expect(anyFeatureMissingAdapter).toBe(false);
828
+ expect(res.status).toBe(200);
829
+ const schema = (await res.json()) as {
830
+ features: ReadonlyArray<{ searchAdapterMissing?: boolean }>;
831
+ };
832
+ expect(schema.features.length).toBeGreaterThan(0);
833
+ expect(schema.features.some((f) => f.searchAdapterMissing === true)).toBe(false);
772
834
  });
773
835
 
774
836
  test("hostDispatch: redirect-Modus", async () => {
@@ -983,6 +1045,37 @@ describe("runProdApp", () => {
983
1045
  });
984
1046
  });
985
1047
 
1048
+ describe("runProdApp — real Bun.serve socket resolves the client IP (fw#3260 addendum)", () => {
1049
+ // Every other test in this file drives handle.fetch()/entrypoint.app.fetch()
1050
+ // directly, which never goes through Bun.serve's real TCP socket — so none
1051
+ // of them can catch a regression where the socket address gets lost before
1052
+ // reaching requestContext.ip (see client-ip.ts: falls back to "unknown"
1053
+ // rather than throwing, so a wiring bug here stays silent otherwise).
1054
+ test("a real loopback connection resolves to the actual socket address, not 'unknown'", async () => {
1055
+ const handle = await boot(undefined, {
1056
+ autoListen: true,
1057
+ anonymousAccess: { defaultTenantId: TENANT_ID },
1058
+ });
1059
+ const port = handle.server?.port;
1060
+ if (port === undefined) throw new Error("expected handle.server to be listening");
1061
+
1062
+ const res = await fetch(`http://127.0.0.1:${port}/api/query`, {
1063
+ method: "POST",
1064
+ headers: { "content-type": "application/json" },
1065
+ body: JSON.stringify({ type: "prod-probe:query:ip-probe", payload: {} }),
1066
+ });
1067
+ expect(res.status).toBe(200);
1068
+ const body = (await res.json()) as { data?: { ip?: string } };
1069
+ // Exact loopback notation (bare "127.0.0.1" vs. the IPv4-mapped IPv6
1070
+ // form "::ffff:127.0.0.1") depends on the platform's dual-stack
1071
+ // listener behavior, not on the code under test — matching the
1072
+ // family instead of a fixed literal keeps the assertion about what
1073
+ // this test actually proves: a real socket address reached
1074
+ // requestContext.ip instead of the "unknown" fallback.
1075
+ expect(body.data?.ip).toMatch(/^(::ffff:)?127\.0\.0\.1$/);
1076
+ });
1077
+ });
1078
+
986
1079
  describe("runProdApp: lokaler Event-Dispatcher (MSP-Anwendung im Single-Container)", () => {
987
1080
  // Regression für den 2026-06-11-Incident: runProdApp baute den
988
1081
  // Event-Dispatcher nie ({disabled:true} im API-Entrypoint) — jede
@@ -1238,6 +1331,13 @@ describe("runProdApp job-lane wiring (runSingleInstance)", () => {
1238
1331
  const handle = await boot(undefined, {
1239
1332
  rateLimit: { global: { limit: 1, windowSeconds: 60 } },
1240
1333
  anonymousAccess: { defaultTenantId: TENANT_ID },
1334
+ // handle.entrypoint.app.fetch has no real socket, and the client-ip
1335
+ // resolver ignores X-Forwarded-For without a trusted hop — without
1336
+ // this, every call below (and every other untrusted-XFF call this
1337
+ // suite makes) resolves to the same shared "unknown" bucket, so the
1338
+ // probeIp uniqueness below is silently defeated by whichever test
1339
+ // happens to run first.
1340
+ trustedProxyHops: 1,
1241
1341
  });
1242
1342
 
1243
1343
  // Unique per test run: the L1 limiter's Redis bucket is keyed only on
@@ -1,3 +1,31 @@
1
+ import { DEFAULT_MAX_REQUEST_BYTES } from "@cosmicdrift/kumiko-framework/api";
2
+ import type { Registry } from "@cosmicdrift/kumiko-framework/engine/types";
3
+ import {
4
+ readFilesRouteOptions,
5
+ resolveMaxUploadBodyBytes,
6
+ } from "@cosmicdrift/kumiko-framework/files";
7
+
8
+ // Fallback for callers that skip resolveDerivedMaxRequestBodySize below —
9
+ // Bun's own default is 128 MiB, buffered per request before any app-level
10
+ // body-limit middleware runs.
11
+ export const DEFAULT_MAX_REQUEST_BODY_SIZE_BYTES = 32 * 1024 * 1024;
12
+
13
+ // Keeps Bun's cap above the files route's own derived bodyLimit, so that
14
+ // route's JSON 413 fires before Bun's bare rejection would.
15
+ const BUN_BODY_LIMIT_SAFETY_MARGIN_BYTES = 8 * 1024;
16
+
17
+ // Derives Bun's request-body ceiling from the same registry-configured
18
+ // maxUploadSize/field maxSize the upload route itself enforces.
19
+ export function resolveDerivedMaxRequestBodySize(registry: Registry): number {
20
+ const filesRouteOptions = readFilesRouteOptions(registry);
21
+ return (
22
+ Math.max(
23
+ DEFAULT_MAX_REQUEST_BYTES,
24
+ resolveMaxUploadBodyBytes({ registry, maxUploadSize: filesRouteOptions.maxUploadSize }),
25
+ ) + BUN_BODY_LIMIT_SAFETY_MARGIN_BYTES
26
+ );
27
+ }
28
+
1
29
  /**
2
30
  * Bun.serve-Options für Production.
3
31
  *
@@ -12,11 +40,21 @@
12
40
  */
13
41
  export function buildBunServeOptions(
14
42
  port: number,
15
- fetchHandler: (req: Request) => Response | Promise<Response>,
43
+ fetchHandler: (req: Request, socketAddress?: string) => Response | Promise<Response>,
44
+ maxRequestBodySize: number = DEFAULT_MAX_REQUEST_BODY_SIZE_BYTES,
16
45
  ): {
17
46
  readonly port: number;
18
- readonly fetch: (req: Request) => Response | Promise<Response>;
47
+ readonly fetch: (req: Request, server: Bun.Server<unknown>) => Response | Promise<Response>;
19
48
  readonly idleTimeout: number;
49
+ readonly maxRequestBodySize: number;
20
50
  } {
21
- return { port, fetch: fetchHandler, idleTimeout: 0 };
51
+ // `server.requestIP(req)` only resolves for the exact Request instance
52
+ // Bun created — extracted here, once, before any downstream req.clone()
53
+ // (tryHonoFirst et al.) can invalidate it.
54
+ return {
55
+ port,
56
+ fetch: (req, server) => fetchHandler(req, server.requestIP(req)?.address),
57
+ idleTimeout: 0,
58
+ maxRequestBodySize,
59
+ };
22
60
  }
package/src/changes.json CHANGED
@@ -1,4 +1,29 @@
1
1
  [
2
+ {
3
+ "version": "0.320.0",
4
+ "type": "breaking",
5
+ "title": "runProdApp threads the Bun socket address and a trustedProxyHops option through to the client-IP resolver",
6
+ "detail": "`buildBunServeOptions` now extracts the client socket address once via\n`server.requestIP(req)` at the outermost Bun.serve fetch callback (a\ncloned/rebuilt Request loses that ability) and threads it through\n`tryHonoFirst`/`withSecurityHeaders`/`buildStaticFallback` as Hono's\n`env`. `RunProdAppOptions` gained a top-level `trustedProxyHops` (falls\nback to the deprecated `auth.trustedProxyHops`, then\n`KUMIKO_TRUSTED_PROXY_HOPS`), forwarded into `buildServer` and the\nstatic-fallback's own client-IP resolver.",
7
+ "migration": "Same as the framework entry — set `trustedProxyHops` on `runProdApp` when\ndeployed behind a reverse-proxy/ingress."
8
+ },
9
+ {
10
+ "version": "0.320.0",
11
+ "type": "breaking",
12
+ "title": "Static fallback no longer injects __KUMIKO_SCHEMA__ into HTML",
13
+ "migration": "`HostDispatchResult.injectSchema` is deprecated and ignored — HTML never\ncarries the schema anymore, in prod or dev. `createKumikoApp` now fetches\nthe schema itself from the authenticated `GET /api/schema` after its\nclientFeature gates (e.g. an auth gate) let rendering through, so a\nsigned-in admin app keeps working without changes. Anything that read\n`window.__KUMIKO_SCHEMA__` directly (custom clients, e2e fixtures) must\nswitch to fetching `/api/schema` instead. An anonymously reachable page\nthat used to render schema-based screens needs `createPublicSurface`,\nwhich never carries a schema. The `@cosmicdrift/kumiko-server-runtime/inject-schema`\nsubpath export is removed."
14
+ },
15
+ {
16
+ "version": "0.320.0",
17
+ "type": "improvement",
18
+ "title": "requireEnv is now part of the public package export",
19
+ "detail": "`packages/server-runtime/src/index.ts` now re-exports `requireEnv`\nalongside `runProdApp`, so `import { requireEnv } from\n\"@cosmicdrift/kumiko-server-runtime\"` resolves without reaching into the\ninternal `run-prod-app.ts` module."
20
+ },
21
+ {
22
+ "version": "0.320.0",
23
+ "type": "improvement",
24
+ "title": "resolveAuthMail builds locale-aware appUrls and adds a dedicated auth.mail.hmacSecret",
25
+ "detail": "resolveAuthMail (shared by runProdApp and runDevApp) now builds the passwordReset/emailVerification/signup/invite appUrl from a locale-aware AuthPath, so a function-valued auth.mail.paths entry produces a locale-aware appUrl for every flow, symmetric to the appUrl-as-function support each flow's options already had.\nAuthMailOptions also gains an optional hmacSecret: when set, passwordReset/emailVerification tokens sign with it instead of the hmacSecret resolveAuthMail is called with (JWT_SECRET), so JWT_SECRET can rotate without invalidating in-flight reset/verify tokens. hmacSecret is now optional on the passwordReset/emailVerification wrapper options too — an app overriding just appUrl no longer has to also thread its own secret; resolveAuthMail backfills the resolved secret onto that explicit block. An explicitly supplied hmacSecret still wins."
26
+ },
2
27
  {
3
28
  "version": "0.298.0",
4
29
  "type": "breaking",
@@ -110,8 +110,8 @@ export function composeFeatures(
110
110
  }
111
111
 
112
112
  /** Shape of any run{Prod,Dev}App auth block that can carry a
113
- * passwordReset/emailVerification config. The wrapper API
114
- * (PasswordResetSetup) extends the feature API (PasswordResetOptions), so
113
+ * passwordReset/emailVerification config, once resolveAuthMail has resolved
114
+ * the wrapper's optional hmacSecret into the feature API's required one —
115
115
  * a structural-typed lookup on the auth-only subset is enough. Lets
116
116
  * buildComposeAuthOptions be called with both RunProd- and
117
117
  * RunDev-AuthOptions without building the helper twice. */
package/src/index.ts CHANGED
@@ -17,7 +17,7 @@ export type {
17
17
  RunProdAppOptions,
18
18
  SignupSetup,
19
19
  } from "./run-prod-app";
20
- export { runProdApp } from "./run-prod-app";
20
+ export { requireEnv, runProdApp } from "./run-prod-app";
21
21
  export type {
22
22
  RunWorkerAppOptions,
23
23
  WorkerAppHandle,
@@ -2,7 +2,12 @@ import {
2
2
  AUDIT_FEATURE,
3
3
  createEscapeHatchAuditSink,
4
4
  } from "@cosmicdrift/kumiko-bundled-features/audit";
5
- import { makeAuthPaths } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
5
+ import {
6
+ type AuthPath,
7
+ type EmailVerificationOptions,
8
+ makeAuthPaths,
9
+ type PasswordResetOptions,
10
+ } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
6
11
  import { resolveSessionStore } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
7
12
  import {
8
13
  bindMfaRevokeAllOtherSessionsFromFeature,
@@ -182,18 +187,53 @@ type AuthMailNormalizable = {
182
187
  readonly invite?: InviteSetup;
183
188
  };
184
189
 
190
+ function buildAuthPathAppUrl(
191
+ baseUrl: string,
192
+ path: AuthPath,
193
+ ): string | ((locale: string) => string) {
194
+ return typeof path === "function"
195
+ ? (locale: string) => `${baseUrl}${path(locale)}`
196
+ : `${baseUrl}${path}`;
197
+ }
198
+
185
199
  // accountUnlock (#1266) deliberately does NOT join this convenience block —
186
200
  // unlike reset/verify/signup/invite it's only meaningful paired with
187
201
  // `accountLockout` (kumiko-framework#1627), and apps set both explicitly
188
202
  // alongside each other (same shape as `passwordReset`), so `mail` alone
189
203
  // can't silently expose a new public endpoint an app didn't ask for.
190
204
 
205
+ function backfillTokenSecrets<T extends AuthMailNormalizable>(auth: T, tokenSecret: string) {
206
+ return {
207
+ ...auth,
208
+ ...(auth.passwordReset && {
209
+ passwordReset: {
210
+ ...auth.passwordReset,
211
+ hmacSecret: auth.passwordReset.hmacSecret ?? tokenSecret,
212
+ },
213
+ }),
214
+ ...(auth.emailVerification && {
215
+ emailVerification: {
216
+ ...auth.emailVerification,
217
+ hmacSecret: auth.emailVerification.hmacSecret ?? tokenSecret,
218
+ },
219
+ }),
220
+ };
221
+ }
222
+
191
223
  export function resolveAuthMail<T extends AuthMailNormalizable>(
192
224
  auth: T,
193
225
  hmacSecret: string,
194
226
  envSource: Record<string, string | undefined>,
195
- ): T {
196
- if (!auth.mail) return auth;
227
+ ): T & {
228
+ readonly passwordReset?: PasswordResetOptions;
229
+ readonly emailVerification?: EmailVerificationOptions;
230
+ } {
231
+ // Runs before the mail/SMTP guards below so an app-supplied block without
232
+ // hmacSecret is backfilled even without a `mail` block; an explicit secret still wins.
233
+ const tokenSecret = auth.mail?.hmacSecret ?? hmacSecret;
234
+ const withResolvedSecrets = backfillTokenSecrets(auth, tokenSecret);
235
+
236
+ if (!auth.mail) return withResolvedSecrets;
197
237
  // SMTP-presence gate: ohne SMTP_HOST-env wird KEIN Flow verdrahtet (Routes
198
238
  // blieben sonst 500). Der eigentliche Mail-Versand läuft über delivery
199
239
  // (channel-email), nicht über diesen Transport — er ist nur der Detektor
@@ -201,7 +241,7 @@ export function resolveAuthMail<T extends AuthMailNormalizable>(
201
241
  if (
202
242
  !createSmtpTransportFromEnv(envSource, { fallbackFrom: auth.mail.from ?? "noreply@localhost" })
203
243
  ) {
204
- return auth;
244
+ return withResolvedSecrets;
205
245
  }
206
246
  const paths = makeAuthPaths(auth.mail.paths);
207
247
  // appName/locale fließen in alle vier Flow-Options (alle mailen via delivery).
@@ -210,26 +250,26 @@ export function resolveAuthMail<T extends AuthMailNormalizable>(
210
250
  ...(auth.mail.locale !== undefined && { locale: auth.mail.locale }),
211
251
  };
212
252
  return {
213
- ...auth,
214
- passwordReset: auth.passwordReset ?? {
215
- hmacSecret,
216
- appUrl: `${auth.mail.baseUrl}${paths.resetPassword}`,
253
+ ...withResolvedSecrets,
254
+ passwordReset: withResolvedSecrets.passwordReset ?? {
255
+ hmacSecret: tokenSecret,
256
+ appUrl: buildAuthPathAppUrl(auth.mail.baseUrl, paths.resetPassword),
217
257
  ...mailPresentation,
218
258
  },
219
- emailVerification: auth.emailVerification ?? {
220
- hmacSecret,
221
- appUrl: `${auth.mail.baseUrl}${paths.verifyEmail}`,
259
+ emailVerification: withResolvedSecrets.emailVerification ?? {
260
+ hmacSecret: tokenSecret,
261
+ appUrl: buildAuthPathAppUrl(auth.mail.baseUrl, paths.verifyEmail),
222
262
  ...(auth.mail.emailVerificationMode !== undefined && {
223
263
  mode: auth.mail.emailVerificationMode,
224
264
  }),
225
265
  ...mailPresentation,
226
266
  },
227
- signup: auth.signup ?? {
228
- appUrl: `${auth.mail.baseUrl}${paths.signupComplete}`,
267
+ signup: withResolvedSecrets.signup ?? {
268
+ appUrl: buildAuthPathAppUrl(auth.mail.baseUrl, paths.signupComplete),
229
269
  ...mailPresentation,
230
270
  },
231
- invite: auth.invite ?? {
232
- appUrl: `${auth.mail.baseUrl}${paths.inviteAccept}`,
271
+ invite: withResolvedSecrets.invite ?? {
272
+ appUrl: buildAuthPathAppUrl(auth.mail.baseUrl, paths.inviteAccept),
233
273
  ...mailPresentation,
234
274
  },
235
275
  };
@@ -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