@cosmicdrift/kumiko-framework 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.
Files changed (71) hide show
  1. package/package.json +8 -4
  2. package/src/__tests__/schema-cli.integration.test.ts +11 -0
  3. package/src/__tests__/upgrade-cli.test.ts +50 -3
  4. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
  5. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +11 -0
  6. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +8 -4
  7. package/src/api/__tests__/client-ip.test.ts +164 -0
  8. package/src/api/__tests__/extra-routes.integration.test.ts +50 -1
  9. package/src/api/__tests__/http-route-rate-limit.integration.test.ts +73 -2
  10. package/src/api/__tests__/pat-route-guard.integration.test.ts +126 -0
  11. package/src/api/__tests__/post-auth-landing.test.ts +100 -0
  12. package/src/api/__tests__/request-id-middleware.test.ts +35 -2
  13. package/src/api/__tests__/schema-route.integration.test.ts +157 -0
  14. package/src/api/api-constants.ts +2 -0
  15. package/src/api/auth-routes.ts +92 -95
  16. package/src/api/client-ip.ts +118 -0
  17. package/src/api/extra-route.ts +3 -0
  18. package/src/api/index.ts +17 -0
  19. package/src/api/pat-route-guard.ts +51 -0
  20. package/src/api/post-auth-landing.ts +105 -0
  21. package/src/api/request-context.ts +4 -2
  22. package/src/api/request-id-middleware.ts +44 -14
  23. package/src/api/server.ts +130 -40
  24. package/src/changes.json +121 -0
  25. package/src/db/queries/event-store-transfer.ts +73 -0
  26. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +47 -0
  27. package/src/engine/__tests__/metric-navigate-tab.test.ts +167 -0
  28. package/src/engine/__tests__/project-app-schema-for-roles.test.ts +500 -0
  29. package/src/engine/__tests__/steps-webhook-send.test.ts +2 -2
  30. package/src/engine/__tests__/toolbar-navigate-tab.test.ts +233 -0
  31. package/src/engine/boot-validator/pii-retention.ts +18 -2
  32. package/src/engine/boot-validator/screens.ts +75 -18
  33. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/alias-reexport.ts +1 -0
  34. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-a.ts +1 -0
  35. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-b.ts +1 -0
  36. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/entry.ts +3 -0
  37. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/local-rebind.ts +3 -0
  38. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/origin.ts +1 -0
  39. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/star-barrel.ts +1 -0
  40. package/src/engine/feature-ast/__tests__/parse.test.ts +4 -4
  41. package/src/engine/feature-ast/__tests__/resolve-import.test.ts +138 -0
  42. package/src/engine/feature-ast/extractors/index.ts +4 -0
  43. package/src/engine/feature-ast/extractors/resolve-import.ts +187 -0
  44. package/src/engine/feature-ast/extractors/shared.ts +26 -19
  45. package/src/engine/feature-ast/parse.ts +13 -20
  46. package/src/engine/index.ts +1 -0
  47. package/src/engine/project-app-schema-for-roles.ts +950 -0
  48. package/src/engine/steps/webhook-send.ts +4 -2
  49. package/src/engine/types/handlers.ts +1 -1
  50. package/src/engine/types/index.ts +5 -1
  51. package/src/entrypoint/index.ts +14 -1
  52. package/src/event-store/__tests__/transfer.integration.test.ts +236 -0
  53. package/src/event-store/index.ts +6 -0
  54. package/src/event-store/transfer.ts +84 -0
  55. package/src/files/__tests__/files.integration.test.ts +105 -1
  56. package/src/files/file-routes.ts +54 -2
  57. package/src/files/index.ts +1 -1
  58. package/src/http/index.ts +10 -2
  59. package/src/jobs/__tests__/jobs.integration.test.ts +105 -3
  60. package/src/jobs/job-runner.ts +6 -1
  61. package/src/pipeline/multi-stream-apply-context.ts +4 -0
  62. package/src/rate-limit/__tests__/middleware.integration.test.ts +24 -7
  63. package/src/rate-limit/middleware.ts +32 -8
  64. package/src/schema-cli.ts +5 -1
  65. package/src/stack/__tests__/push-entity-projection-tables-column-sync.integration.test.ts +107 -0
  66. package/src/stack/push-entity-projection-tables.ts +52 -7
  67. package/src/stack/table-helpers.ts +17 -9
  68. package/src/stack/test-stack.ts +28 -1
  69. package/src/testing/real-providers.ts +2 -0
  70. package/src/ui-types/index.ts +1 -1
  71. package/src/upgrade-cli.ts +58 -18
@@ -0,0 +1,118 @@
1
+ import type { Context } from "hono";
2
+
3
+ // Single source for client-IP extraction, shared by every IP-keyed rate
4
+ // limit and audit field. Deriving the caller IP independently per call site
5
+ // makes it easy for one to silently trust the first XFF entry, which is
6
+ // attacker-controlled unless the exact number of trusted reverse proxies is
7
+ // known.
8
+ //
9
+ // Algorithm for `trustedProxyHops`:
10
+ // 0 (default): don't trust any proxy header — only the socket address
11
+ // counts. A spoofed XFF is ignored outright.
12
+ // >=1: each trusted proxy hop APPENDS (not overwrites) its peer address to
13
+ // XFF (e.g. nginx `$proxy_add_x_forwarded_for`), so the real client sits
14
+ // at `entries[length - hops]`. Anything a client itself prepended lands
15
+ // further left and is ignored. A chain shorter than `hops` falls back to
16
+ // `x-real-ip` (no hop-count semantics of its own), then the socket address.
17
+ // A bucket is never skipped: without any usable value the fallback is a
18
+ // fixed "unknown" string, which collapses affected callers into one shared
19
+ // bucket rather than letting them bypass the limit entirely.
20
+ export const UNKNOWN_CLIENT_IP = "unknown";
21
+
22
+ export type ClientIpHeaderSource = {
23
+ readonly header: (name: string) => string | undefined;
24
+ readonly socketAddress?: string;
25
+ };
26
+
27
+ export type ClientIpResolver = {
28
+ readonly resolve: (source: ClientIpHeaderSource) => string;
29
+ };
30
+
31
+ // The socket address rides in as Hono's `Env` (2nd `app.fetch` arg, read
32
+ // back via `c.env`) as a bare string, never the raw Bun `Server` — a
33
+ // `Server.requestIP()` only resolves for the exact Request instance Bun
34
+ // created, and a cloned/rebuilt Request (tryHonoFirst's req.clone(), etc.)
35
+ // would silently return null. The address is extracted once at the
36
+ // outermost Bun.serve fetch handler and threaded down as a plain value.
37
+ export function extractSocketAddress(env: unknown): string | undefined {
38
+ return typeof env === "string" && env.length > 0 ? env : undefined;
39
+ }
40
+
41
+ export function assertValidTrustedProxyHops(value: number | undefined, context: string): void {
42
+ if (value !== undefined && (!Number.isInteger(value) || value < 0)) {
43
+ throw new Error(`${context}: trustedProxyHops must be a non-negative integer, got ${value}.`);
44
+ }
45
+ }
46
+
47
+ export const TRUSTED_PROXY_HOPS_ENV = "KUMIKO_TRUSTED_PROXY_HOPS";
48
+
49
+ // Fail loud on a garbage env value rather than silently coercing to NaN:
50
+ // a resolver built from NaN treats it like "always short chain" and returns
51
+ // "unknown" for every request, which collapses an IP-keyed rate limiter
52
+ // into one shared bucket for the whole deployment — a self-inflicted DoS,
53
+ // worse than staying on the default. Digits-only (not parseInt) —
54
+ // parseInt("0x10")/("1e3")/("2x") would silently coerce instead of failing.
55
+ export function parseTrustedProxyHopsEnv(
56
+ raw: string | undefined,
57
+ context: string,
58
+ ): number | undefined {
59
+ if (raw === undefined) return undefined;
60
+ if (!/^\d+$/.test(raw)) {
61
+ throw new Error(
62
+ `${context}: ${TRUSTED_PROXY_HOPS_ENV} must be a non-negative integer, got "${raw}".`,
63
+ );
64
+ }
65
+ const parsed = Number.parseInt(raw, 10);
66
+ if (!Number.isInteger(parsed) || parsed < 0) {
67
+ throw new Error(
68
+ `${context}: ${TRUSTED_PROXY_HOPS_ENV} must be a non-negative integer, got "${raw}".`,
69
+ );
70
+ }
71
+ return parsed;
72
+ }
73
+
74
+ // Instance-scoped, NOT a module singleton: the warn-once flag must reset
75
+ // per server boot (and per test), otherwise the first test to trigger it
76
+ // consumes the warning for the whole process and later tests can't observe
77
+ // it.
78
+ export function createClientIpResolver(
79
+ trustedProxyHops: number,
80
+ context = "resolveClientIp",
81
+ ): ClientIpResolver {
82
+ assertValidTrustedProxyHops(trustedProxyHops, context);
83
+ let warnedUnexpectedForwardedFor = false;
84
+
85
+ function resolve(source: ClientIpHeaderSource): string {
86
+ const xff = source.header("x-forwarded-for");
87
+ if (trustedProxyHops === 0) {
88
+ if (xff !== undefined && xff.trim().length > 0 && !warnedUnexpectedForwardedFor) {
89
+ warnedUnexpectedForwardedFor = true;
90
+ console.warn(
91
+ "[kumiko] received X-Forwarded-For but trustedProxyHops is 0 (no proxy trusted) — " +
92
+ "ignoring it. Running behind a proxy? Set trustedProxyHops, otherwise all clients " +
93
+ "share one rate-limit bucket.",
94
+ );
95
+ }
96
+ return source.socketAddress ?? UNKNOWN_CLIENT_IP;
97
+ }
98
+ const entries = (xff ?? "")
99
+ .split(",")
100
+ .map((entry) => entry.trim())
101
+ .filter((entry) => entry.length > 0);
102
+ if (entries.length >= trustedProxyHops) {
103
+ return entries[entries.length - trustedProxyHops] ?? UNKNOWN_CLIENT_IP;
104
+ }
105
+ const realIp = source.header("x-real-ip")?.trim();
106
+ if (realIp) return realIp;
107
+ return source.socketAddress ?? UNKNOWN_CLIENT_IP;
108
+ }
109
+
110
+ return { resolve };
111
+ }
112
+
113
+ export function clientIpSourceFromHonoContext(c: Context): ClientIpHeaderSource {
114
+ return {
115
+ header: (name) => c.req.header(name),
116
+ socketAddress: extractSocketAddress(c.env),
117
+ };
118
+ }
@@ -32,6 +32,9 @@ export type AnonymousExtraRouteDeps = {
32
32
  * user, same request-resolved tenant (never overridable), only under
33
33
  * "/api/" (the only path that populates a session user). */
34
34
  readonly write: (type: string, payload: unknown) => Promise<WriteResult>;
35
+ /** Resolved by the server's single trustedProxyHops-aware resolver —
36
+ * never read X-Forwarded-For yourself. */
37
+ readonly clientIp: string;
35
38
  };
36
39
 
37
40
  export type AnonymousExtraRoute = {
package/src/api/index.ts CHANGED
@@ -28,6 +28,16 @@ export {
28
28
  createInMemoryLoginRateLimiter,
29
29
  createRedisLoginRateLimiter,
30
30
  } from "./auth-routes";
31
+ export type { ClientIpHeaderSource, ClientIpResolver } from "./client-ip";
32
+ export {
33
+ assertValidTrustedProxyHops,
34
+ clientIpSourceFromHonoContext,
35
+ createClientIpResolver,
36
+ extractSocketAddress,
37
+ parseTrustedProxyHopsEnv,
38
+ TRUSTED_PROXY_HOPS_ENV,
39
+ UNKNOWN_CLIENT_IP,
40
+ } from "./client-ip";
31
41
  export type {
32
42
  AnonymousExtraRoute,
33
43
  AnonymousExtraRouteDeps,
@@ -57,6 +67,12 @@ export {
57
67
  export type { JwtHelper, JwtKeyring, JwtPayload } from "./jwt";
58
68
  export { createJwtHelper, loadJwtSecretOrKeyring } from "./jwt";
59
69
  export { patAllows, qnMatches } from "./pat-scope";
70
+ export type {
71
+ PostAuthLandingArgs,
72
+ PostAuthLandingFlow,
73
+ PostAuthLandingResolver,
74
+ } from "./post-auth-landing";
75
+ export { isSafeLandingPath } from "./post-auth-landing";
60
76
  export type { RedisSseBroker, RedisSseBrokerOptions } from "./redis-sse-broker";
61
77
  export { createDefaultSseBroker, createRedisSseBroker, isRedisSseBroker } from "./redis-sse-broker";
62
78
  export { type RequestContextData, requestContext } from "./request-context";
@@ -64,6 +80,7 @@ export {
64
80
  buildRequestContextDataFromRequest,
65
81
  requestIdMiddleware,
66
82
  } from "./request-id-middleware";
83
+ export { DEFAULT_MAX_REQUEST_BYTES } from "./route-registrars";
67
84
  export { createApiRoutes } from "./routes";
68
85
  export type { KumikoServer, ServerOptions } from "./server";
69
86
  export { buildServer, makeDispatchSystemQuery, makeDispatchSystemWrite } from "./server";
@@ -0,0 +1,51 @@
1
+ import type { Context, MiddlewareHandler, Next } from "hono";
2
+ import { AccessDeniedError } from "../errors";
3
+ import { Routes } from "./api-constants";
4
+ import { getUser } from "./auth-middleware";
5
+
6
+ // Personal Access Tokens are minted for headless dispatcher access only.
7
+ // Every other /api/* surface (SSE, files, auth/*, feature httpRoutes,
8
+ // entry:"user" extraRoutes) must stay off-limits to a PAT regardless of its
9
+ // granted scopes — those routes never call assertPatAllowed, so without this
10
+ // guard a PAT with any scope (even an empty one) authenticates everywhere a
11
+ // cookie/JWT session would.
12
+ const PAT_ALLOWED_API_PATHS: ReadonlySet<string> = new Set([
13
+ `/api${Routes.write}`,
14
+ `/api${Routes.batch}`,
15
+ `/api${Routes.query}`,
16
+ `/api${Routes.command}`,
17
+ `/api${Routes.stream}`,
18
+ ]);
19
+
20
+ function isDispatcherPath(path: string): boolean {
21
+ return PAT_ALLOWED_API_PATHS.has(path);
22
+ }
23
+
24
+ // Must run after an auth guard has resolved getUser(c) — same wiring point
25
+ // as buildPatRateLimitGuard. Cookie/JWT users (user.pat undefined) pass
26
+ // through untouched.
27
+ export function patRouteGuard(): MiddlewareHandler {
28
+ return async (c: Context, next: Next) => {
29
+ const user = getUser(c);
30
+ if (user?.pat && !isDispatcherPath(c.req.path)) {
31
+ const denied = new AccessDeniedError({
32
+ message:
33
+ "personal access tokens are only valid for the dispatcher API (write, batch, query, command, stream)",
34
+ details: { reason: "pat_dispatcher_routes_only" },
35
+ });
36
+ return c.json(
37
+ {
38
+ error: {
39
+ code: denied.code,
40
+ httpStatus: denied.httpStatus,
41
+ message: denied.message,
42
+ i18nKey: denied.i18nKey,
43
+ details: denied.details,
44
+ },
45
+ },
46
+ 403,
47
+ );
48
+ }
49
+ return next();
50
+ };
51
+ }
@@ -0,0 +1,105 @@
1
+ // Server-computed post-auth redirect target. AuthRoutesConfig.postAuthLanding
2
+ // lets an app decide, in ONE place, where a user lands after login/signup/
3
+ // invite — instead of every frontend screen re-deriving it from roles/
4
+ // tenantId via loggedInHref. The result travels in the JSON response
5
+ // (`landingPath`) and is validated here before it ever reaches the wire: an
6
+ // app-supplied resolver is untrusted input from the framework's perspective,
7
+ // and a bad value would otherwise become a same-response open redirect.
8
+
9
+ import { createFallbackLogger } from "../logging";
10
+
11
+ export type PostAuthLandingFlow = "login" | "signup" | "invite";
12
+
13
+ export type PostAuthLandingArgs = {
14
+ readonly flow: PostAuthLandingFlow;
15
+ readonly roles: readonly string[];
16
+ readonly tenantId: string;
17
+ // signup only.
18
+ readonly tenantKey?: string;
19
+ // signup only, present when signup-confirm claimed a bound handover grant.
20
+ readonly handover?: { readonly entityType: string; readonly id: string };
21
+ };
22
+
23
+ export type PostAuthLandingResolver = (args: PostAuthLandingArgs) => string | undefined;
24
+
25
+ function hasControlOrDelCharacter(candidate: string): boolean {
26
+ for (let i = 0; i < candidate.length; i++) {
27
+ const code = candidate.charCodeAt(i);
28
+ if (code <= 0x1f || code === 0x7f) return true;
29
+ }
30
+ return false;
31
+ }
32
+
33
+ // Root-relative-only allowlist, stricter than the framework's existing
34
+ // billing isRootRelativePath: rejects protocol-relative ("//host") and
35
+ // backslash-based ("/\host") paths a browser would still resolve to a
36
+ // different origin, and control chars/whitespace a URL parser silently
37
+ // strips (so "/\t/evil.com" would otherwise normalize to "//evil.com" past
38
+ // the leading-slash check). The new URL(...) parse is a backstop for
39
+ // anything the explicit checks above missed.
40
+ export function isSafeLandingPath(candidate: unknown): candidate is string {
41
+ if (typeof candidate !== "string" || candidate.length === 0) return false;
42
+ if (!candidate.startsWith("/")) return false;
43
+ const second = candidate[1];
44
+ if (second === "/" || second === "\\") return false;
45
+ if (candidate.includes("\\")) return false;
46
+ if (hasControlOrDelCharacter(candidate)) return false;
47
+ if (/\s/.test(candidate)) return false;
48
+ try {
49
+ const parsed = new URL(candidate, "https://landing.invalid");
50
+ if (parsed.origin !== "https://landing.invalid") return false;
51
+ // "/.//evil.com" clears every check above (no leading "//", no
52
+ // backslash) but normalizes to pathname "//evil.com" — still same-
53
+ // origin on first navigation, but protocol-relative if a caller ever
54
+ // re-resolves the bare pathname as a fresh href.
55
+ return !parsed.pathname.startsWith("//");
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ function isThenable(value: unknown): value is PromiseLike<unknown> {
62
+ return (
63
+ typeof value === "object" &&
64
+ value !== null &&
65
+ "then" in value &&
66
+ typeof value.then === "function"
67
+ );
68
+ }
69
+
70
+ const landingLog = createFallbackLogger("auth");
71
+
72
+ export function resolvePostAuthLandingPath(
73
+ resolver: PostAuthLandingResolver | undefined,
74
+ args: PostAuthLandingArgs,
75
+ ): string | undefined {
76
+ if (!resolver) return undefined;
77
+ let candidate: unknown;
78
+ try {
79
+ candidate = resolver(args);
80
+ } catch (error) {
81
+ // Consumer error messages can carry PII or secrets; log only the error class.
82
+ landingLog.warn("postAuthLanding threw — landingPath omitted", {
83
+ flow: args.flow,
84
+ errorName: error instanceof Error ? error.name : typeof error,
85
+ });
86
+ return undefined;
87
+ }
88
+ if (isThenable(candidate)) {
89
+ // A JS consumer (or a cast) can bypass the sync signature; an unobserved
90
+ // rejection would terminate the Bun process on every login.
91
+ Promise.resolve(candidate).catch(() => undefined);
92
+ landingLog.warn("postAuthLanding async resolver not supported — landingPath omitted", {
93
+ flow: args.flow,
94
+ });
95
+ return undefined;
96
+ }
97
+ if (candidate === undefined) return undefined;
98
+ if (!isSafeLandingPath(candidate)) {
99
+ landingLog.warn("postAuthLanding returned an unsafe path — landingPath omitted", {
100
+ flow: args.flow,
101
+ });
102
+ return undefined;
103
+ }
104
+ return candidate;
105
+ }
@@ -29,8 +29,10 @@ export type RequestContextData = {
29
29
  readonly causationId?: string;
30
30
  readonly signal?: AbortSignal;
31
31
  // Client IP for per-IP rate limiting (L1, L2, L3 with per: "ip*").
32
- // Populated by requestIdMiddleware from x-forwarded-for or the
33
- // socket address. Undefined for non-HTTP entry points (jobs, MSP).
32
+ // Populated by requestIdMiddleware via the shared trustedProxyHops-aware
33
+ // resolver (see api/client-ip.ts) — never skipped
34
+ // for an HTTP request, falls back to "unknown" rather than leaving this
35
+ // undefined. Undefined only for non-HTTP entry points (jobs, MSP).
34
36
  readonly ip?: string;
35
37
  // Raw User-Agent header — audit trails (download tokens, GDPR export
36
38
  // access) want it alongside `ip`. Undefined for non-HTTP entry points.
@@ -1,8 +1,16 @@
1
1
  import type { Context, Next } from "hono";
2
2
  import { resolveHeaderLocale } from "../i18n/request-locale";
3
3
  import { LOCALE_HEADER_NAME } from "./api-constants";
4
+ import { type ClientIpResolver, createClientIpResolver, extractSocketAddress } from "./client-ip";
4
5
  import { type RequestContextData, requestContext } from "./request-context";
5
6
 
7
+ // Fallback for the few call-sites that build a RequestContextData without a
8
+ // caller-configured resolver (e.g. an internal system-query mount reached
9
+ // before requestIdMiddleware ran). trustedProxyHops 0 matches the framework
10
+ // default; callers that care about the real hop count pass their own
11
+ // resolver instead — see requestIdMiddleware(options).
12
+ const FALLBACK_CLIENT_IP_RESOLVER = createClientIpResolver(0, "requestIdMiddleware");
13
+
6
14
  const REQUEST_ID_HEADER = "X-Request-ID";
7
15
  const CORRELATION_ID_HEADER = "X-Correlation-ID";
8
16
 
@@ -34,7 +42,10 @@ function header(req: Request, name: string): string | undefined {
34
42
  * Hono's router entirely — can still populate the same AsyncLocalStorage
35
43
  * record via `requestContext.run(...)`.
36
44
  */
37
- export function buildRequestContextDataFromRequest(req: Request): RequestContextData {
45
+ export function buildRequestContextDataFromRequest(
46
+ req: Request,
47
+ options?: { readonly resolver?: ClientIpResolver; readonly socketAddress?: string },
48
+ ): RequestContextData {
38
49
  const requestId = sanitizeClientId(header(req, REQUEST_ID_HEADER)) ?? requestContext.generateId();
39
50
  const correlationId = sanitizeClientId(header(req, CORRELATION_ID_HEADER)) ?? requestId;
40
51
 
@@ -42,14 +53,15 @@ export function buildRequestContextDataFromRequest(req: Request): RequestContext
42
53
  // back-press, tab close). We propagate it through requestContext so
43
54
  // framework internals can honour cancellation at long-running checkpoints.
44
55
  const signal = req.signal;
45
- // Client IP for per-IP rate limiting. Trust `x-forwarded-for` when
46
- // present (proxy/CDN) — first hop is the originating client. Adapter-
47
- // specific socket-address fallback (bun, node) is not standardized
48
- // in Hono; deployments behind a proxy should always set xff. Without
49
- // either we leave `ip` undefined and skip ip-bucketed checks rather
50
- // than fabricate one.
51
- const xff = header(req, "x-forwarded-for");
52
- const ip = xff?.split(",")[0]?.trim();
56
+ // Client IP for per-IP rate limiting — resolved via the shared
57
+ // trustedProxyHops-aware resolver. Always set for
58
+ // an HTTP request; falls back to "unknown" rather than skipping the
59
+ // ip-bucket (see createClientIpResolver).
60
+ const resolver = options?.resolver ?? FALLBACK_CLIENT_IP_RESOLVER;
61
+ const ip = resolver.resolve({
62
+ header: (name) => header(req, name),
63
+ socketAddress: options?.socketAddress,
64
+ });
53
65
  const userAgent = header(req, "user-agent");
54
66
  // Runs before auth-middleware, so this reaches public routes too (e.g.
55
67
  // signup-request) — that's the whole point: the active UI locale must
@@ -64,7 +76,7 @@ export function buildRequestContextDataFromRequest(req: Request): RequestContext
64
76
  correlationId,
65
77
  startedAt: performance.now(),
66
78
  ...(signal ? { signal } : {}),
67
- ...(ip && ip.length > 0 ? { ip } : {}),
79
+ ip,
68
80
  ...(userAgent !== undefined ? { userAgent } : {}),
69
81
  ...(locale !== undefined ? { locale } : {}),
70
82
  };
@@ -76,7 +88,10 @@ export function buildRequestContextDataFromRequest(req: Request): RequestContext
76
88
  * export because most call-sites (server.ts's httpRoute→systemQuery mount,
77
89
  * `requestIdMiddleware` below) already hold a Hono `Context`.
78
90
  */
79
- export function buildRequestContextData(c: Context): RequestContextData {
91
+ export function buildRequestContextData(
92
+ c: Context,
93
+ resolver?: ClientIpResolver,
94
+ ): RequestContextData {
80
95
  // Older Hono / adapter combos may leave c.req.raw unset even though it's
81
96
  // typed as Request — degrade to a bare id pair (no signal/ip/ua/locale)
82
97
  // instead of letting req.headers.get() throw on every request.
@@ -84,7 +99,10 @@ export function buildRequestContextData(c: Context): RequestContextData {
84
99
  const requestId = requestContext.generateId();
85
100
  return { requestId, correlationId: requestId, startedAt: performance.now() };
86
101
  }
87
- return buildRequestContextDataFromRequest(c.req.raw);
102
+ return buildRequestContextDataFromRequest(c.req.raw, {
103
+ resolver,
104
+ socketAddress: extractSocketAddress(c.env),
105
+ });
88
106
  }
89
107
 
90
108
  /**
@@ -96,9 +114,21 @@ export function buildRequestContextData(c: Context): RequestContextData {
96
114
  * `x-correlation-id` — clients that don't care about cross-service tracing
97
115
  * still get sensible single-request correlation for free.
98
116
  */
99
- export function requestIdMiddleware() {
117
+ export function requestIdMiddleware(options?: {
118
+ // Shared resolver instance — pass buildServer's single clientIpResolver
119
+ // here so the whole server shares one warn-once flag.
120
+ // Takes precedence over `trustedProxyHops`, which only exists for
121
+ // standalone callers (no buildServer).
122
+ readonly resolver?: ClientIpResolver;
123
+ readonly trustedProxyHops?: number;
124
+ }) {
125
+ // Created once per middleware mount (not per request) so the warn-once
126
+ // flag in the resolver actually fires only once per server boot.
127
+ const resolver =
128
+ options?.resolver ??
129
+ createClientIpResolver(options?.trustedProxyHops ?? 0, "requestIdMiddleware");
100
130
  return async (c: Context, next: Next) => {
101
- const data = buildRequestContextData(c);
131
+ const data = buildRequestContextData(c, resolver);
102
132
  c.header(REQUEST_ID_HEADER, data.requestId);
103
133
  c.header(CORRELATION_ID_HEADER, data.correlationId);
104
134
  c.set("requestId", data.requestId);