@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.
- package/package.json +8 -4
- package/src/__tests__/schema-cli.integration.test.ts +11 -0
- package/src/__tests__/upgrade-cli.test.ts +50 -3
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +11 -0
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +8 -4
- package/src/api/__tests__/client-ip.test.ts +164 -0
- package/src/api/__tests__/extra-routes.integration.test.ts +50 -1
- package/src/api/__tests__/http-route-rate-limit.integration.test.ts +73 -2
- package/src/api/__tests__/pat-route-guard.integration.test.ts +126 -0
- package/src/api/__tests__/post-auth-landing.test.ts +100 -0
- package/src/api/__tests__/request-id-middleware.test.ts +35 -2
- package/src/api/__tests__/schema-route.integration.test.ts +157 -0
- package/src/api/api-constants.ts +2 -0
- package/src/api/auth-routes.ts +92 -95
- package/src/api/client-ip.ts +118 -0
- package/src/api/extra-route.ts +3 -0
- package/src/api/index.ts +17 -0
- package/src/api/pat-route-guard.ts +51 -0
- package/src/api/post-auth-landing.ts +105 -0
- package/src/api/request-context.ts +4 -2
- package/src/api/request-id-middleware.ts +44 -14
- package/src/api/server.ts +130 -40
- package/src/changes.json +121 -0
- package/src/db/queries/event-store-transfer.ts +73 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +47 -0
- package/src/engine/__tests__/metric-navigate-tab.test.ts +167 -0
- package/src/engine/__tests__/project-app-schema-for-roles.test.ts +500 -0
- package/src/engine/__tests__/steps-webhook-send.test.ts +2 -2
- package/src/engine/__tests__/toolbar-navigate-tab.test.ts +233 -0
- package/src/engine/boot-validator/pii-retention.ts +18 -2
- package/src/engine/boot-validator/screens.ts +75 -18
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/alias-reexport.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-a.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-b.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/entry.ts +3 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/local-rebind.ts +3 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/origin.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/star-barrel.ts +1 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +4 -4
- package/src/engine/feature-ast/__tests__/resolve-import.test.ts +138 -0
- package/src/engine/feature-ast/extractors/index.ts +4 -0
- package/src/engine/feature-ast/extractors/resolve-import.ts +187 -0
- package/src/engine/feature-ast/extractors/shared.ts +26 -19
- package/src/engine/feature-ast/parse.ts +13 -20
- package/src/engine/index.ts +1 -0
- package/src/engine/project-app-schema-for-roles.ts +950 -0
- package/src/engine/steps/webhook-send.ts +4 -2
- package/src/engine/types/handlers.ts +1 -1
- package/src/engine/types/index.ts +5 -1
- package/src/entrypoint/index.ts +14 -1
- package/src/event-store/__tests__/transfer.integration.test.ts +236 -0
- package/src/event-store/index.ts +6 -0
- package/src/event-store/transfer.ts +84 -0
- package/src/files/__tests__/files.integration.test.ts +105 -1
- package/src/files/file-routes.ts +54 -2
- package/src/files/index.ts +1 -1
- package/src/http/index.ts +10 -2
- package/src/jobs/__tests__/jobs.integration.test.ts +105 -3
- package/src/jobs/job-runner.ts +6 -1
- package/src/pipeline/multi-stream-apply-context.ts +4 -0
- package/src/rate-limit/__tests__/middleware.integration.test.ts +24 -7
- package/src/rate-limit/middleware.ts +32 -8
- package/src/schema-cli.ts +5 -1
- package/src/stack/__tests__/push-entity-projection-tables-column-sync.integration.test.ts +107 -0
- package/src/stack/push-entity-projection-tables.ts +52 -7
- package/src/stack/table-helpers.ts +17 -9
- package/src/stack/test-stack.ts +28 -1
- package/src/testing/real-providers.ts +2 -0
- package/src/ui-types/index.ts +1 -1
- 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
|
+
}
|
package/src/api/extra-route.ts
CHANGED
|
@@ -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
|
|
33
|
-
//
|
|
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(
|
|
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
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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(
|
|
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);
|