@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.
@@ -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({}),
@@ -347,6 +359,27 @@ describe("runProdApp", () => {
347
359
  expect(body).toContain('<probe ok="true" />');
348
360
  });
349
361
 
362
+ test("handle.fetch threads the socket address into deps.clientIp, ignoring a spoofed X-Forwarded-For at the default trustedProxyHops (0)", async () => {
363
+ const handle = await boot(undefined, {
364
+ extraRoutes: [
365
+ {
366
+ method: "GET",
367
+ path: "/client-ip-probe",
368
+ entry: "anonymous",
369
+ handler: (c, deps) => c.body(deps.clientIp, 200),
370
+ },
371
+ ],
372
+ });
373
+ const req = () =>
374
+ new Request("http://test/client-ip-probe", { headers: { "x-forwarded-for": "1.2.3.4" } });
375
+
376
+ const withSocket = await handle.fetch(req(), "198.51.100.7");
377
+ expect(await withSocket.text()).toBe("198.51.100.7");
378
+
379
+ const withoutSocket = await handle.fetch(req());
380
+ expect(await withoutSocket.text()).toBe("unknown");
381
+ });
382
+
350
383
  test("extraRoutes: entry:signature dispatchSystemWrite schreibt als SystemAdmin des Ziel-Tenants, registry verfügbar", async () => {
351
384
  // Wiring for provider webhook routes (billing-foundation
352
385
  // createSubscriptionWebhookRoute): the route authenticates via
@@ -691,9 +724,10 @@ describe("runProdApp", () => {
691
724
  });
692
725
 
693
726
  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).
727
+ // Multi-app deployment: two HTML files for different hosts. Neither
728
+ // ever carries __KUMIKO_SCHEMA__ (kumiko-framework#3314) —
729
+ // createKumikoApp fetches the schema itself from GET /api/schema
730
+ // after the auth gates let it through, regardless of host.
697
731
  const tmpStaticDir = await createTempStaticDir({
698
732
  "index.html": "<html><body>PUBLIC</body><script src=/client.js></script></html>",
699
733
  "admin.html": "<html><body>ADMIN</body><script src=/client.js></script></html>",
@@ -703,13 +737,12 @@ describe("runProdApp", () => {
703
737
  staticDir: tmpStaticDir,
704
738
  hostDispatch: ({ host }) => {
705
739
  if (host.startsWith("admin.")) {
706
- return { kind: "html", file: "admin.html", injectSchema: true };
740
+ return { kind: "html", file: "admin.html" };
707
741
  }
708
- return { kind: "html", file: "index.html", injectSchema: false };
742
+ return { kind: "html", file: "index.html" };
709
743
  },
710
744
  });
711
745
 
712
- // Public host: index.html, KEIN schema-Tag.
713
746
  const pubRes = await handle.fetch(
714
747
  new Request("http://demo.example.test/", { headers: { host: "demo.example.test" } }),
715
748
  );
@@ -718,57 +751,107 @@ describe("runProdApp", () => {
718
751
  expect(pubBody).toContain("PUBLIC");
719
752
  expect(pubBody).not.toContain("__KUMIKO_SCHEMA__");
720
753
 
721
- // Admin host: admin.html MIT schema-Tag.
722
754
  const adminRes = await handle.fetch(
723
755
  new Request("http://admin.example.test/", { headers: { host: "admin.example.test" } }),
724
756
  );
725
757
  expect(adminRes.status).toBe(200);
726
758
  const adminBody = await adminRes.text();
727
759
  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);
760
+ expect(adminBody).not.toContain("__KUMIKO_SCHEMA__");
744
761
  });
745
762
 
746
- test("extraContext.searchAdapter wired → injected schema omits searchAdapterMissing (#2062)", async () => {
763
+ test("staticDir without hostDispatch: HTML never carries the schema; GET /api/schema needs a signed-in user (kumiko-framework#3314)", async () => {
764
+ // A real POST /api/auth/login round-trip needs the tenant/user bundled
765
+ // features' own projection tables (e.g. read_tenants), which this
766
+ // suite's boot() doesn't migrate (migrations: false, only the app's own
767
+ // entity tables are auto-created — see unsafeEnsureEntityTable above).
768
+ // Signing a token straight through the entrypoint's JwtHelper exercises
769
+ // the exact same cookie transport auth-middleware reads, without
770
+ // pulling in that migration surface just for this schema-fetch test.
747
771
  const tmpStaticDir = await createTempStaticDir({
748
- "index.html": "<html><body>ADMIN</body><script src=/client.js></script></html>",
772
+ "index.html": "<html><body>SHELL</body><script src=/client.js></script></html>",
773
+ });
774
+
775
+ const handle = await boot(undefined, { staticDir: tmpStaticDir });
776
+
777
+ const rootRes = await handle.fetch(new Request("http://test/"));
778
+ expect(rootRes.status).toBe(200);
779
+ expect(await rootRes.text()).not.toContain("__KUMIKO_SCHEMA__");
780
+
781
+ const spaRes = await handle.fetch(new Request("http://test/some/spa/route"));
782
+ expect(spaRes.status).toBe(200);
783
+ expect(await spaRes.text()).not.toContain("__KUMIKO_SCHEMA__");
784
+
785
+ const anonSchemaRes = await handle.fetch(new Request("http://test/api/schema"));
786
+ expect(anonSchemaRes.status).toBe(401);
787
+
788
+ const token = await handle.entrypoint.jwt.sign({
789
+ id: "schema-fetch-user",
790
+ tenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
791
+ roles: ["User"],
749
792
  });
793
+ const schemaRes = await handle.fetch(
794
+ new Request("http://test/api/schema", { headers: { Cookie: `kumiko_auth=${token}` } }),
795
+ );
796
+ expect(schemaRes.status).toBe(200);
797
+ const schema = (await schemaRes.json()) as { features: readonly unknown[] };
798
+ expect(schema.features.length).toBeGreaterThan(0);
799
+ });
750
800
 
801
+ test("anonymousAccess wired → GET /api/schema still 401s for the synthesised anonymous user", async () => {
802
+ const handle = await boot(undefined, {
803
+ anonymousAccess: {
804
+ defaultTenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
805
+ },
806
+ });
807
+
808
+ const res = await handle.entrypoint.app.fetch(new Request("http://test/api/schema"));
809
+ expect(res.status).toBe(401);
810
+ });
811
+
812
+ test("extraContext.searchAdapter absent → GET /api/schema marks every feature searchAdapterMissing (#2062)", async () => {
813
+ const handle = await boot();
814
+ const token = await handle.entrypoint.jwt.sign({
815
+ id: "schema-probe-user",
816
+ tenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
817
+ roles: ["User"],
818
+ });
819
+
820
+ const res = await handle.entrypoint.app.fetch(
821
+ new Request("http://test/api/schema", { headers: { Authorization: `Bearer ${token}` } }),
822
+ );
823
+ expect(res.status).toBe(200);
824
+ const schema = (await res.json()) as {
825
+ features: ReadonlyArray<{ searchAdapterMissing?: boolean }>;
826
+ };
827
+ // Guards against a vacuous pass — an empty features array would make
828
+ // `.every(...)` below vacuously true too.
829
+ expect(schema.features.length).toBeGreaterThan(0);
830
+ expect(schema.features.every((f) => f.searchAdapterMissing === true)).toBe(true);
831
+ });
832
+
833
+ test("extraContext.searchAdapter wired → GET /api/schema omits searchAdapterMissing (#2062)", async () => {
751
834
  const handle = await boot(undefined, {
752
- staticDir: tmpStaticDir,
753
835
  // Presence-only probe — run-prod-app's cast-boundary read only checks
754
836
  // truthiness, so a minimal stand-in is enough without pulling in the
755
837
  // real SearchAdapter shape.
756
838
  extraContext: { searchAdapter: { configure: async () => {}, search: async () => [] } },
757
- hostDispatch: () => ({ kind: "html", file: "index.html", injectSchema: true }),
839
+ });
840
+ const token = await handle.entrypoint.jwt.sign({
841
+ id: "schema-probe-user",
842
+ tenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
843
+ roles: ["User"],
758
844
  });
759
845
 
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,
846
+ const res = await handle.entrypoint.app.fetch(
847
+ new Request("http://test/api/schema", { headers: { Authorization: `Bearer ${token}` } }),
770
848
  );
771
- expect(anyFeatureMissingAdapter).toBe(false);
849
+ expect(res.status).toBe(200);
850
+ const schema = (await res.json()) as {
851
+ features: ReadonlyArray<{ searchAdapterMissing?: boolean }>;
852
+ };
853
+ expect(schema.features.length).toBeGreaterThan(0);
854
+ expect(schema.features.some((f) => f.searchAdapterMissing === true)).toBe(false);
772
855
  });
773
856
 
774
857
  test("hostDispatch: redirect-Modus", async () => {
@@ -983,6 +1066,37 @@ describe("runProdApp", () => {
983
1066
  });
984
1067
  });
985
1068
 
1069
+ describe("runProdApp — real Bun.serve socket resolves the client IP (fw#3260 addendum)", () => {
1070
+ // Every other test in this file drives handle.fetch()/entrypoint.app.fetch()
1071
+ // directly, which never goes through Bun.serve's real TCP socket — so none
1072
+ // of them can catch a regression where the socket address gets lost before
1073
+ // reaching requestContext.ip (see client-ip.ts: falls back to "unknown"
1074
+ // rather than throwing, so a wiring bug here stays silent otherwise).
1075
+ test("a real loopback connection resolves to the actual socket address, not 'unknown'", async () => {
1076
+ const handle = await boot(undefined, {
1077
+ autoListen: true,
1078
+ anonymousAccess: { defaultTenantId: TENANT_ID },
1079
+ });
1080
+ const port = handle.server?.port;
1081
+ if (port === undefined) throw new Error("expected handle.server to be listening");
1082
+
1083
+ const res = await fetch(`http://127.0.0.1:${port}/api/query`, {
1084
+ method: "POST",
1085
+ headers: { "content-type": "application/json" },
1086
+ body: JSON.stringify({ type: "prod-probe:query:ip-probe", payload: {} }),
1087
+ });
1088
+ expect(res.status).toBe(200);
1089
+ const body = (await res.json()) as { data?: { ip?: string } };
1090
+ // Exact loopback notation (bare "127.0.0.1" vs. the IPv4-mapped IPv6
1091
+ // form "::ffff:127.0.0.1") depends on the platform's dual-stack
1092
+ // listener behavior, not on the code under test — matching the
1093
+ // family instead of a fixed literal keeps the assertion about what
1094
+ // this test actually proves: a real socket address reached
1095
+ // requestContext.ip instead of the "unknown" fallback.
1096
+ expect(body.data?.ip).toMatch(/^(::ffff:)?127\.0\.0\.1$/);
1097
+ });
1098
+ });
1099
+
986
1100
  describe("runProdApp: lokaler Event-Dispatcher (MSP-Anwendung im Single-Container)", () => {
987
1101
  // Regression für den 2026-06-11-Incident: runProdApp baute den
988
1102
  // Event-Dispatcher nie ({disabled:true} im API-Entrypoint) — jede
@@ -1238,6 +1352,13 @@ describe("runProdApp job-lane wiring (runSingleInstance)", () => {
1238
1352
  const handle = await boot(undefined, {
1239
1353
  rateLimit: { global: { limit: 1, windowSeconds: 60 } },
1240
1354
  anonymousAccess: { defaultTenantId: TENANT_ID },
1355
+ // handle.entrypoint.app.fetch has no real socket, and the client-ip
1356
+ // resolver ignores X-Forwarded-For without a trusted hop — without
1357
+ // this, every call below (and every other untrusted-XFF call this
1358
+ // suite makes) resolves to the same shared "unknown" bucket, so the
1359
+ // probeIp uniqueness below is silently defeated by whichever test
1360
+ // happens to run first.
1361
+ trustedProxyHops: 1,
1241
1362
  });
1242
1363
 
1243
1364
  // 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,43 @@
1
1
  [
2
+ {
3
+ "version": "0.321.0",
4
+ "type": "improvement",
5
+ "title": "runProdApp's KUMIKO_TRUSTED_PROXY_HOPS parsing now shares framework's parseTrustedProxyHopsEnv",
6
+ "detail": "The inline digits-only + non-negative-integer validation moved out of\n`run-prod-app.ts` into `parseTrustedProxyHopsEnv`\n(`@cosmicdrift/kumiko-framework/api`); same validation and error message\nas before, no behavioral change.",
7
+ "migration": "No action needed."
8
+ },
9
+ {
10
+ "version": "0.321.0",
11
+ "type": "improvement",
12
+ "title": "ProdAppHandle.fetch's type now declares its optional socket-address parameter",
13
+ "detail": "`fetch: (req: Request, socketAddress?: string) => Promise<Response> |\nResponse`. The doc comment now explains that an app with `autoListen:\nfalse` running its own `Bun.serve` must pass\n`server.requestIP(req)?.address`, extracted at the outermost Bun fetch\ncallback — `requestIP()` only resolves for Bun's original Request\ninstance, not a cloned/rebuilt one. `RunProdAppAuthOptions\n.trustedProxyHops`'s doc comment also dropped the stale \"pre-#1539\nspoofable default\" wording; 0 hops is the safe socket-only default.",
14
+ "migration": "No action needed for the default `autoListen: true` boot. Apps with\n`autoListen: false` can now type-check passing the socket address to\n`handle.fetch`; passing nothing keeps the previous (unresolved) behavior."
15
+ },
16
+ {
17
+ "version": "0.320.0",
18
+ "type": "breaking",
19
+ "title": "runProdApp threads the Bun socket address and a trustedProxyHops option through to the client-IP resolver",
20
+ "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.",
21
+ "migration": "Same as the framework entry — set `trustedProxyHops` on `runProdApp` when\ndeployed behind a reverse-proxy/ingress."
22
+ },
23
+ {
24
+ "version": "0.320.0",
25
+ "type": "breaking",
26
+ "title": "Static fallback no longer injects __KUMIKO_SCHEMA__ into HTML",
27
+ "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."
28
+ },
29
+ {
30
+ "version": "0.320.0",
31
+ "type": "improvement",
32
+ "title": "requireEnv is now part of the public package export",
33
+ "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."
34
+ },
35
+ {
36
+ "version": "0.320.0",
37
+ "type": "improvement",
38
+ "title": "resolveAuthMail builds locale-aware appUrls and adds a dedicated auth.mail.hmacSecret",
39
+ "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."
40
+ },
2
41
  {
3
42
  "version": "0.298.0",
4
43
  "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
  };