@opengeni/api-router 4.1.0 → 4.1.2-canary.36199476632001

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 (93) hide show
  1. package/dist/app.d.ts +7 -1
  2. package/dist/app.js +1 -1
  3. package/dist/auth/managed-auth-rate-limits.d.ts +167 -0
  4. package/dist/auth/managed-auth.d.ts +37 -1
  5. package/dist/auth/signup-funnel-metrics.d.ts +44 -0
  6. package/dist/{chunk-24C5LPNE.js → chunk-QDIBJSM4.js} +3277 -1509
  7. package/dist/{chunk-24C5LPNE.js.map → chunk-QDIBJSM4.js.map} +1 -1
  8. package/dist/http/cors.d.ts +12 -0
  9. package/dist/http/local-browser-boundary.d.ts +72 -0
  10. package/dist/http/metrics-listener.d.ts +22 -0
  11. package/dist/http/registered-route-label.d.ts +16 -0
  12. package/dist/http/request-source.d.ts +41 -3
  13. package/dist/http/user-content.d.ts +36 -0
  14. package/dist/http/websocket-upgrade-dispatch.d.ts +18 -0
  15. package/dist/index.js +90 -31
  16. package/dist/index.js.map +1 -1
  17. package/dist/integrations/github-lens-connect.d.ts +1 -1
  18. package/dist/integrations/oauth-client.d.ts +30 -0
  19. package/dist/integrations/slack-bot.d.ts +48 -2
  20. package/dist/integrations/slack-interactions.d.ts +1 -1
  21. package/dist/mcp/documents.d.ts +2 -0
  22. package/dist/mcp/first-party-tool-permissions.d.ts +12 -0
  23. package/dist/mcp/server.d.ts +35 -0
  24. package/dist/model-catalog.d.ts +9 -3
  25. package/dist/observability.d.ts +3 -1
  26. package/dist/routes/billing.d.ts +27 -0
  27. package/dist/routes/browser-sessions.d.ts +3 -0
  28. package/dist/routes/client-errors.d.ts +42 -0
  29. package/dist/routes/computer-sessions.d.ts +2 -0
  30. package/dist/routes/github-browser-pages.d.ts +12 -0
  31. package/dist/routes/insights.d.ts +26 -0
  32. package/dist/sandbox/auth-callout.d.ts +8 -0
  33. package/dist/sandbox-file-artifacts.d.ts +17 -1
  34. package/package.json +19 -19
  35. package/src/app.ts +124 -12
  36. package/src/auth/managed-auth-rate-limits.ts +252 -0
  37. package/src/auth/managed-auth.ts +137 -17
  38. package/src/auth/signup-funnel-metrics.ts +164 -0
  39. package/src/http/auth.ts +6 -0
  40. package/src/http/cors.ts +37 -5
  41. package/src/http/local-browser-boundary.ts +343 -0
  42. package/src/http/metrics-listener.ts +57 -0
  43. package/src/http/registered-route-label.ts +34 -0
  44. package/src/http/request-source.ts +154 -13
  45. package/src/http/sse.ts +36 -16
  46. package/src/http/user-content.ts +158 -0
  47. package/src/http/websocket-upgrade-dispatch.ts +29 -0
  48. package/src/index.ts +22 -8
  49. package/src/integrations/atlassian.ts +7 -3
  50. package/src/integrations/fiken.ts +9 -3
  51. package/src/integrations/github-app-connect.ts +12 -2
  52. package/src/integrations/google-drive.ts +7 -3
  53. package/src/integrations/oauth-client.ts +99 -12
  54. package/src/integrations/personal-github.ts +4 -2
  55. package/src/integrations/provider-oauth.ts +10 -3
  56. package/src/integrations/slack-bot.ts +43 -1
  57. package/src/integrations/social-oauth.ts +7 -2
  58. package/src/mcp/documents.ts +9 -2
  59. package/src/mcp/files.ts +5 -1
  60. package/src/mcp/first-party-tool-permissions.ts +6 -0
  61. package/src/mcp/knowledge-entries.ts +15 -2
  62. package/src/mcp/server.ts +191 -17
  63. package/src/mcp-oauth.ts +4 -6
  64. package/src/model-catalog.ts +30 -3
  65. package/src/observability.ts +15 -1
  66. package/src/routes/billing.ts +70 -9
  67. package/src/routes/browser-sessions.ts +169 -0
  68. package/src/routes/client-errors.ts +182 -0
  69. package/src/routes/company-brain.ts +4 -0
  70. package/src/routes/computer-sessions.ts +37 -9
  71. package/src/routes/connections.ts +7 -1
  72. package/src/routes/documents.ts +6 -1
  73. package/src/routes/editable-artifacts.ts +2 -1
  74. package/src/routes/enrollments.ts +2 -13
  75. package/src/routes/files.ts +17 -3
  76. package/src/routes/github-browser-pages.ts +162 -0
  77. package/src/routes/github.ts +122 -49
  78. package/src/routes/insights.ts +70 -6
  79. package/src/routes/knowledge.ts +24 -1
  80. package/src/routes/managed-auth-session-sets.ts +26 -6
  81. package/src/routes/managed-onboarding.ts +23 -19
  82. package/src/routes/organization-memberships.ts +3 -0
  83. package/src/routes/scheduled-tasks.ts +9 -2
  84. package/src/routes/sessions.ts +89 -0
  85. package/src/routes/workspace-artifacts.ts +9 -0
  86. package/src/routes/workspace-state.ts +4 -0
  87. package/src/routes/workspaces.ts +38 -15
  88. package/src/sandbox/auth-callout.ts +62 -11
  89. package/src/sandbox/channel-a.ts +8 -0
  90. package/src/sandbox/viewer.ts +16 -3
  91. package/src/sandbox-file-artifacts.ts +37 -26
  92. package/src/site-uploads.ts +8 -1
  93. package/src/workspace-tool-gateway.ts +1 -0
@@ -0,0 +1,343 @@
1
+ import {
2
+ localAllowedOriginEntries,
3
+ localSandboxApiRouteAllowed,
4
+ type Settings,
5
+ } from "@opengeni/config";
6
+
7
+ /**
8
+ * Browser boundary for the unauthenticated local product mode.
9
+ *
10
+ * In `local` access mode every request without credentials acts as the local
11
+ * `dev` user, and the default local sandbox runs agent commands on the host.
12
+ * Binding the API to loopback keeps other devices out, but a web page open in
13
+ * the developer's browser can still reach `http://127.0.0.1:<port>`:
14
+ *
15
+ * - any site can send cross-origin requests to it (CORS only hides the
16
+ * response; a simple POST still runs), and
17
+ * - a DNS-rebinding site can make its own hostname resolve to 127.0.0.1 and
18
+ * read responses as a same-origin page.
19
+ *
20
+ * The boundary therefore sorts the `Host` a request is addressed to into two
21
+ * classes:
22
+ *
23
+ * - Browser hosts: loopback, the hosts of the configured web, public, and
24
+ * extra origins, the GitHub App callback tunnel, and a specific API bind
25
+ * address. A present `Origin` must be this stack's web app, an explicitly
26
+ * configured origin, or the API's own address on one of these hosts.
27
+ * - Sandbox hosts: names only sandboxes and the worker use to reach the API
28
+ * (`host.docker.internal` with the Docker sandbox, and the hosts of
29
+ * `OPENGENI_MCP_URL` and `OPENGENI_MCP_INTERNAL_URL`). They serve only the
30
+ * sandbox routes (Codemode, first-party MCP, and the Git broker) and refuse
31
+ * any request that carries browser metadata. A browser cannot tell a
32
+ * sandbox name from an attacker's, so a rebinding page on one of these
33
+ * names can neither send an accepted `Origin` nor reach the rest of the API.
34
+ *
35
+ * Every other `Host` is refused. Non-browser clients (the SDK, curl, sandbox
36
+ * callbacks, host-app servers) send no `Origin` and are unaffected. The `Host`
37
+ * port is not compared: a rebinding page controls only the hostname, while
38
+ * local proxies and forwarders legitimately present their own port.
39
+ *
40
+ * It applies only to `local` access mode in the `local` environment (the
41
+ * `bun run dev` stack and manual local runs). Managed and configured access
42
+ * modes, and deployments that set another `OPENGENI_ENVIRONMENT`, keep their
43
+ * existing CORS policy.
44
+ */
45
+ export type LocalBrowserBoundarySettings = Pick<
46
+ Settings,
47
+ | "productAccessMode"
48
+ | "environment"
49
+ | "apiHost"
50
+ | "webBaseUrl"
51
+ | "publicBaseUrl"
52
+ | "opengeniMcpUrl"
53
+ | "opengeniMcpInternalUrl"
54
+ | "githubAppManifestBaseUrl"
55
+ | "localAllowedOrigins"
56
+ | "sandboxBackend"
57
+ >;
58
+
59
+ export type LocalBrowserBoundaryRejectionCode =
60
+ | "LOCAL_HOST_NOT_ALLOWED"
61
+ | "LOCAL_ORIGIN_NOT_ALLOWED"
62
+ | "LOCAL_SANDBOX_ROUTE_ONLY";
63
+
64
+ export type LocalBrowserBoundaryRejection = {
65
+ status: 403;
66
+ code: LocalBrowserBoundaryRejectionCode;
67
+ message: string;
68
+ /** The refused `Host` or `Origin`, for the API log only; never sent back. */
69
+ refused: string;
70
+ };
71
+
72
+ /** Called once per distinct refused value, so an operator can see why. */
73
+ export type LocalBrowserBoundaryWarn = (
74
+ message: string,
75
+ attributes: Record<string, string>,
76
+ ) => void;
77
+
78
+ export type LocalBrowserBoundary = {
79
+ readonly allowedOrigins: ReadonlySet<string>;
80
+ /** Hostnames a browser may address. */
81
+ readonly browserHostnames: ReadonlySet<string>;
82
+ /** Hostnames only sandboxes and the worker use; sandbox routes only, no browsers. */
83
+ readonly sandboxHostnames: ReadonlySet<string>;
84
+ /** Whether a present `Origin` may call the API for a request with this `Host`. */
85
+ originAllowed(origin: string, host: string | null): boolean;
86
+ /** The rejection for this request, or null when the boundary admits it. */
87
+ rejection(request: Request): LocalBrowserBoundaryRejection | null;
88
+ };
89
+
90
+ const LOOPBACK_HOSTNAMES = ["127.0.0.1", "localhost", "[::1]"] as const;
91
+ /** Docker Desktop's name for the host; Linux sandboxes use the configured bridge route. */
92
+ const DOCKER_HOST_ALIAS = "host.docker.internal";
93
+ /** `apps/web` dev server default when no web base URL is configured. */
94
+ const DEFAULT_LOCAL_WEB_ORIGIN = "http://127.0.0.1:3000";
95
+ const WILDCARD_BIND_HOSTS = new Set(["", "0.0.0.0", "::", "[::]"]);
96
+ /**
97
+ * Only browsers send these. (Node's fetch sends `Sec-Fetch-Mode: cors`, so that
98
+ * header does not identify a browser.)
99
+ */
100
+ const BROWSER_REQUEST_HEADERS = ["origin", "sec-fetch-site"];
101
+ /** Distinct refused values logged per boundary, so a rebinding page cannot flood the log. */
102
+ const MAX_REPORTED_REJECTIONS = 32;
103
+ const MAX_REPORTED_VALUE_LENGTH = 200;
104
+
105
+ const HOST_NOT_ALLOWED_MESSAGE =
106
+ "The local OpenGeni API only answers requests addressed to this computer. " +
107
+ "To use another address, set it in OPENGENI_WEB_BASE_URL, OPENGENI_PUBLIC_BASE_URL, " +
108
+ "or OPENGENI_LOCAL_ALLOWED_ORIGINS.";
109
+ const ORIGIN_NOT_ALLOWED_MESSAGE =
110
+ "The local OpenGeni API has no authentication, so browser requests are accepted only " +
111
+ "from this stack's web app. Add other trusted origins to OPENGENI_LOCAL_ALLOWED_ORIGINS.";
112
+ const SANDBOX_ROUTE_ONLY_MESSAGE =
113
+ "This address is the local OpenGeni API's sandbox route. It serves only sandbox calls " +
114
+ "(Codemode, first-party MCP, and the Git broker), never browsers. Open the web app at " +
115
+ "its own address, or list this address in OPENGENI_LOCAL_ALLOWED_ORIGINS.";
116
+
117
+ const WARNINGS: Record<LocalBrowserBoundaryRejectionCode, { message: string; setting: string }> = {
118
+ LOCAL_HOST_NOT_ALLOWED: {
119
+ message: "Local API refused a request addressed to a name that is not this computer's",
120
+ setting: "OPENGENI_WEB_BASE_URL, OPENGENI_PUBLIC_BASE_URL, or OPENGENI_LOCAL_ALLOWED_ORIGINS",
121
+ },
122
+ LOCAL_ORIGIN_NOT_ALLOWED: {
123
+ message: "Local API refused a browser request from an origin that is not this stack's web app",
124
+ setting: "OPENGENI_WEB_BASE_URL or OPENGENI_LOCAL_ALLOWED_ORIGINS",
125
+ },
126
+ LOCAL_SANDBOX_ROUTE_ONLY: {
127
+ message: "Local API refused a browser or non-sandbox request on an address only sandboxes use",
128
+ setting: "OPENGENI_WEB_BASE_URL or OPENGENI_LOCAL_ALLOWED_ORIGINS",
129
+ },
130
+ };
131
+
132
+ /**
133
+ * Requests the API builds and dispatches to itself, such as the Codemode SDK
134
+ * proxy's re-dispatch. They never come from the network, and the request they
135
+ * were built from already passed the boundary.
136
+ */
137
+ const internalDispatches = new WeakSet<Request>();
138
+
139
+ /** Mark a request the API dispatches to itself so the boundary admits it. */
140
+ export function markLocalInternalDispatch(request: Request): Request {
141
+ internalDispatches.add(request);
142
+ return request;
143
+ }
144
+
145
+ export function localBrowserBoundaryApplies(
146
+ settings: Pick<Settings, "productAccessMode" | "environment">,
147
+ ): boolean {
148
+ return settings.productAccessMode === "local" && settings.environment === "local";
149
+ }
150
+
151
+ /** Build the boundary, or null when the deployment is not the local dev product. */
152
+ export function createLocalBrowserBoundary(
153
+ settings: LocalBrowserBoundarySettings,
154
+ options: { warn?: LocalBrowserBoundaryWarn } = {},
155
+ ): LocalBrowserBoundary | null {
156
+ if (!localBrowserBoundaryApplies(settings)) return null;
157
+
158
+ const webOrigins = [settings.webBaseUrl ?? DEFAULT_LOCAL_WEB_ORIGIN, settings.publicBaseUrl]
159
+ .map((value) => parseHttpUrl(value))
160
+ .filter((url): url is URL => url !== null);
161
+ const extraOrigins = localAllowedOriginEntries(settings.localAllowedOrigins).map(
162
+ (origin) => new URL(origin),
163
+ );
164
+
165
+ const allowedOrigins = new Set<string>();
166
+ for (const url of [...webOrigins, ...extraOrigins]) {
167
+ allowedOrigins.add(url.origin);
168
+ // 127.0.0.1, localhost, and [::1] are the same machine and port; the
169
+ // developer may open the printed URL under any of those names.
170
+ if (isLoopbackHostname(url.hostname)) {
171
+ for (const hostname of LOOPBACK_HOSTNAMES) {
172
+ const alias = new URL(url.origin);
173
+ alias.hostname = hostname;
174
+ allowedOrigins.add(alias.origin);
175
+ }
176
+ }
177
+ }
178
+
179
+ const browserHostnames = new Set<string>(LOOPBACK_HOSTNAMES);
180
+ for (const url of [...webOrigins, ...extraOrigins]) browserHostnames.add(url.hostname);
181
+ // A tunnel configured for GitHub App callbacks: GitHub redirects the browser there.
182
+ const githubAppTunnel = parseHttpUrl(settings.githubAppManifestBaseUrl);
183
+ if (githubAppTunnel) browserHostnames.add(githubAppTunnel.hostname);
184
+ const apiHost = settings.apiHost.trim().toLowerCase();
185
+ if (!WILDCARD_BIND_HOSTS.has(apiHost)) {
186
+ const url = parseHostHeader(
187
+ apiHost.includes(":") && !apiHost.startsWith("[") ? `[${apiHost}]` : apiHost,
188
+ );
189
+ if (url) browserHostnames.add(url.hostname);
190
+ }
191
+
192
+ // The sandbox-visible API route (Docker Desktop's host alias, the Linux
193
+ // Docker bridge address, or a tunnel for a remote sandbox) and the worker's
194
+ // internal route arrive with their own Host.
195
+ const sandboxHostnames = new Set<string>();
196
+ if (settings.sandboxBackend === "docker") sandboxHostnames.add(DOCKER_HOST_ALIAS);
197
+ for (const value of [settings.opengeniMcpUrl, settings.opengeniMcpInternalUrl]) {
198
+ const url = parseHttpUrl(value?.replaceAll("{workspaceId}", "workspace"));
199
+ if (url) sandboxHostnames.add(url.hostname);
200
+ }
201
+ // A name the developer also uses in the browser stays a browser host.
202
+ for (const hostname of browserHostnames) sandboxHostnames.delete(hostname);
203
+
204
+ const browserHost = (host: string | null): URL | null => {
205
+ const url = host === null ? null : parseHostHeader(host);
206
+ return url !== null && browserHostnames.has(url.hostname) ? url : null;
207
+ };
208
+
209
+ const originAllowed = (origin: string, host: string | null): boolean => {
210
+ const url = parseHttpUrl(origin);
211
+ if (!url || url.origin !== origin.toLowerCase().replace(/\/$/u, "")) return false;
212
+ if (allowedOrigins.has(url.origin)) return true;
213
+ // Pages served by the API itself (for example the MCP OAuth consent form)
214
+ // and same-origin dev proxies present the API's own address. Only a
215
+ // browser host qualifies: sandbox names and unknown names never do.
216
+ const requestHost = browserHost(host);
217
+ return requestHost !== null && requestHost.host === url.host;
218
+ };
219
+
220
+ const reported = new Set<string>();
221
+ const report = (rejection: LocalBrowserBoundaryRejection, host: string | null) => {
222
+ if (!options.warn) return;
223
+ const key = `${rejection.code}\u0000${rejection.refused}`;
224
+ if (reported.has(key) || reported.size >= MAX_REPORTED_REJECTIONS) return;
225
+ reported.add(key);
226
+ const warning = WARNINGS[rejection.code];
227
+ options.warn(warning.message, {
228
+ code: rejection.code,
229
+ refused: rejection.refused.slice(0, MAX_REPORTED_VALUE_LENGTH),
230
+ host: (host ?? "").slice(0, MAX_REPORTED_VALUE_LENGTH),
231
+ setting: warning.setting,
232
+ });
233
+ };
234
+
235
+ const refuse = (
236
+ code: LocalBrowserBoundaryRejectionCode,
237
+ refused: string,
238
+ host: string | null,
239
+ ): LocalBrowserBoundaryRejection => {
240
+ const message =
241
+ code === "LOCAL_HOST_NOT_ALLOWED"
242
+ ? HOST_NOT_ALLOWED_MESSAGE
243
+ : code === "LOCAL_ORIGIN_NOT_ALLOWED"
244
+ ? ORIGIN_NOT_ALLOWED_MESSAGE
245
+ : SANDBOX_ROUTE_ONLY_MESSAGE;
246
+ const rejection: LocalBrowserBoundaryRejection = { status: 403, code, message, refused };
247
+ report(rejection, host);
248
+ return rejection;
249
+ };
250
+
251
+ return {
252
+ allowedOrigins,
253
+ browserHostnames,
254
+ sandboxHostnames,
255
+ originAllowed,
256
+ rejection(request) {
257
+ if (internalDispatches.has(request)) return null;
258
+ const host = localBrowserRequestHost(request);
259
+ const origin = request.headers.get("origin");
260
+ if (browserHost(host)) {
261
+ if (origin !== null && !originAllowed(origin, host)) {
262
+ return refuse("LOCAL_ORIGIN_NOT_ALLOWED", origin, host);
263
+ }
264
+ return null;
265
+ }
266
+ const parsedHost = host === null ? null : parseHostHeader(host);
267
+ if (parsedHost === null || !sandboxHostnames.has(parsedHost.hostname)) {
268
+ return refuse("LOCAL_HOST_NOT_ALLOWED", host ?? "", host);
269
+ }
270
+ if (
271
+ BROWSER_REQUEST_HEADERS.some((name) => request.headers.has(name)) ||
272
+ !localSandboxApiRouteAllowed(requestPathname(request))
273
+ ) {
274
+ return refuse("LOCAL_SANDBOX_ROUTE_ONLY", host ?? "", host);
275
+ }
276
+ return null;
277
+ },
278
+ };
279
+ }
280
+
281
+ /** The standard API error envelope for a boundary rejection outside Hono. */
282
+ export function localBrowserBoundaryResponse(rejection: LocalBrowserBoundaryRejection): Response {
283
+ return Response.json(
284
+ {
285
+ error: {
286
+ status: rejection.status,
287
+ code: "forbidden",
288
+ message: rejection.message,
289
+ retryable: false,
290
+ details: { code: rejection.code },
291
+ },
292
+ },
293
+ { status: rejection.status },
294
+ );
295
+ }
296
+
297
+ /** The `Host` the boundary checks: the header, else the request URL's authority. */
298
+ export function localBrowserRequestHost(request: Request): string | null {
299
+ const header = request.headers.get("host");
300
+ if (header !== null) return header;
301
+ try {
302
+ return new URL(request.url).host || null;
303
+ } catch {
304
+ return null;
305
+ }
306
+ }
307
+
308
+ /** The normalized path the router sees (URL parsing resolves dot segments). */
309
+ function requestPathname(request: Request): string {
310
+ try {
311
+ return new URL(request.url).pathname;
312
+ } catch {
313
+ return "";
314
+ }
315
+ }
316
+
317
+ function parseHostHeader(value: string): URL | null {
318
+ const host = value.trim();
319
+ // A Host is exactly `hostname[:port]`; anything else is not an address.
320
+ if (!host || /[\s/?#@\\,]/u.test(host)) return null;
321
+ try {
322
+ const url = new URL(`http://${host}`);
323
+ return url.host === host.toLowerCase() || url.host === host.toLowerCase().replace(/:80$/u, "")
324
+ ? url
325
+ : null;
326
+ } catch {
327
+ return null;
328
+ }
329
+ }
330
+
331
+ function parseHttpUrl(value: string | undefined): URL | null {
332
+ if (!value) return null;
333
+ try {
334
+ const url = new URL(value);
335
+ return url.protocol === "http:" || url.protocol === "https:" ? url : null;
336
+ } catch {
337
+ return null;
338
+ }
339
+ }
340
+
341
+ function isLoopbackHostname(hostname: string): boolean {
342
+ return (LOOPBACK_HOSTNAMES as readonly string[]).includes(hostname);
343
+ }
@@ -0,0 +1,57 @@
1
+ import type { Settings } from "@opengeni/config";
2
+ import { Hono } from "hono";
3
+
4
+ import { requireAccessKey } from "./auth";
5
+
6
+ type PrometheusSource = { prometheusMetrics(): Promise<string> };
7
+
8
+ /** Register the Prometheus exposition route on one Hono app. */
9
+ export function registerPrometheusMetricsRoute(app: Hono, observability: PrometheusSource): void {
10
+ app.get("/metrics", async (c) =>
11
+ c.text(await observability.prometheusMetrics(), 200, {
12
+ "content-type": "text/plain; version=0.0.4; charset=utf-8",
13
+ }),
14
+ );
15
+ }
16
+
17
+ /**
18
+ * Whether the public API listener serves `GET /metrics`. A deployment that sets
19
+ * `OPENGENI_API_METRICS_PORT` moves the exposition to a dedicated internal
20
+ * listener, so an ingress that forwards every path to the API cannot publish it.
21
+ */
22
+ export function publicListenerServesMetrics(settings: Pick<Settings, "apiMetricsPort">): boolean {
23
+ return settings.apiMetricsPort === undefined;
24
+ }
25
+
26
+ /**
27
+ * The dedicated metrics listener: only `GET /metrics`, behind the same
28
+ * deployment-key perimeter rules as the public listener. Nothing else is
29
+ * routed here, so exposing this port to a scraper exposes nothing else.
30
+ */
31
+ export function createMetricsListenerApp(
32
+ settings: Settings,
33
+ observability: PrometheusSource,
34
+ ): Hono {
35
+ const app = new Hono();
36
+ app.use("*", requireAccessKey(settings));
37
+ registerPrometheusMetricsRoute(app, observability);
38
+ return app;
39
+ }
40
+
41
+ /** Start the dedicated listener when configured; returns undefined otherwise. */
42
+ export function startApiMetricsListener(
43
+ settings: Settings,
44
+ observability: PrometheusSource,
45
+ ): ReturnType<typeof Bun.serve> | undefined {
46
+ const port = settings.apiMetricsPort;
47
+ if (port === undefined) return undefined;
48
+ if (port === settings.apiPort) {
49
+ throw new Error("OPENGENI_API_METRICS_PORT must differ from OPENGENI_API_PORT");
50
+ }
51
+ const app = createMetricsListenerApp(settings, observability);
52
+ return Bun.serve({
53
+ hostname: settings.apiHost,
54
+ port,
55
+ fetch: (request) => app.fetch(request),
56
+ });
57
+ }
@@ -0,0 +1,34 @@
1
+ import type { Context } from "hono";
2
+ import { matchedRoutes } from "hono/route";
3
+
4
+ /**
5
+ * The registered Hono path template of the handler that answers this request.
6
+ *
7
+ * Hono resolves every route matching a request before the first middleware
8
+ * runs, so the metrics middleware can read the exact code-owned template (for
9
+ * example `/v1/workspaces/:workspaceId/rigs/:rigId`) without re-implementing
10
+ * routing. Templates come only from route registrations, never from request
11
+ * data, so they are a closed and bounded metric/log label set.
12
+ *
13
+ * Middleware (arity two: `(context, next)`) and global catch-alls never name a
14
+ * route; the first matched terminal handler is the one Hono dispatches to.
15
+ */
16
+ export function registeredHandlerRoutePath(context: Context): string | null {
17
+ try {
18
+ for (const route of matchedRoutes(context)) {
19
+ if (typeof route.handler !== "function" || route.handler.length >= 2) continue;
20
+ const label = boundedRegisteredRouteLabel(route.path);
21
+ if (label) return label;
22
+ }
23
+ } catch {
24
+ // Route labels are observational and must never affect request handling.
25
+ }
26
+ return null;
27
+ }
28
+
29
+ /** Normalize a registered template; drop catch-alls that would name nothing. */
30
+ export function boundedRegisteredRouteLabel(path: string | null | undefined): string | null {
31
+ if (!path || !path.startsWith("/") || path === "/*") return null;
32
+ // `:param{regex}` constraints are routing detail, not part of the label.
33
+ return path.replace(/(:[A-Za-z0-9_]+)\{[^/]*\}/g, "$1");
34
+ }
@@ -1,11 +1,28 @@
1
+ import { BlockList, isIP } from "node:net";
2
+ import { trustedProxyCidrEntries, type Settings } from "@opengeni/config";
1
3
  import type { Context } from "hono";
2
4
 
3
5
  const TRANSPORT_PEER_ADDRESS_BINDING = "opengeniTransportPeerAddress";
4
6
 
7
+ /** Returned when the runtime supplied no transport peer (for example an
8
+ * in-process test request). Callers treat it as one shared bucket. */
9
+ export const UNKNOWN_REQUEST_SOURCE_ADDRESS = "unknown";
10
+
11
+ /**
12
+ * App-owned request header carrying the server-resolved client address into
13
+ * Better Auth, which reads client addresses only from request headers. It is
14
+ * removed from every inbound request and re-stamped on managed-auth routes, so
15
+ * a caller-supplied value never reaches the auth rate limiter or session IP.
16
+ */
17
+ export const TRUSTED_CLIENT_ADDRESS_HEADER = "x-opengeni-trusted-client-address";
18
+
5
19
  type ApiRequestBindings = {
6
20
  [TRANSPORT_PEER_ADDRESS_BINDING]?: string | null;
7
21
  };
8
22
 
23
+ /** The deployment's declared proxy chain (`OPENGENI_API_TRUSTED_PROXY_*`). */
24
+ export type RequestSourceTrust = Pick<Settings, "apiTrustedProxyHops" | "apiTrustedProxyCidrs">;
25
+
9
26
  export function apiRequestBindingsForTransportPeer(
10
27
  address: string | null | undefined,
11
28
  ): ApiRequestBindings {
@@ -15,23 +32,147 @@ export function apiRequestBindingsForTransportPeer(
15
32
  /**
16
33
  * Resolve a quota/audit source from the server-owned transport peer. Forwarded
17
34
  * values are considered only when the operator declares an exact trusted proxy
18
- * hop count; the chain is then walked from the server side so caller-prepended
19
- * values cannot replace the address inserted by the trusted edge.
35
+ * hop count (`OPENGENI_API_TRUSTED_PROXY_HOPS`) and, when
36
+ * `OPENGENI_API_TRUSTED_PROXY_CIDRS` is set, only for a transport peer inside
37
+ * those ranges. Each trusted proxy must append the address of the peer that
38
+ * connected to it, or overwrite the header with the original client address;
39
+ * the chain is then walked from the server side so caller-prepended values
40
+ * cannot replace the address observed by the trusted edge. A missing, short,
41
+ * or malformed chain falls back to the transport peer.
20
42
  */
21
- export function trustedRequestSourceAddress(c: Context, trustedProxyHops: number): string {
43
+ export function trustedRequestSourceAddress(c: Context, trust: RequestSourceTrust): string {
22
44
  const bindings = c.env as ApiRequestBindings | undefined;
23
- const peer = normalizedAddress(bindings?.[TRANSPORT_PEER_ADDRESS_BINDING]) ?? "unknown";
24
- if (trustedProxyHops <= 0 || peer === "unknown") return peer;
25
-
26
- const forwarded = (c.req.header("x-forwarded-for") ?? "")
27
- .split(",")
28
- .map(normalizedAddress)
29
- .filter((value): value is string => value !== null);
45
+ const peer =
46
+ normalizedAddress(bindings?.[TRANSPORT_PEER_ADDRESS_BINDING]) ?? UNKNOWN_REQUEST_SOURCE_ADDRESS;
47
+ const trustedProxyHops = trust.apiTrustedProxyHops;
48
+ if (
49
+ !Number.isInteger(trustedProxyHops) ||
50
+ trustedProxyHops <= 0 ||
51
+ peer === UNKNOWN_REQUEST_SOURCE_ADDRESS ||
52
+ !peerIsTrustedProxy(peer, trust.apiTrustedProxyCidrs)
53
+ ) {
54
+ return peer;
55
+ }
56
+ const header = c.req.header("x-forwarded-for");
57
+ if (!header) return peer;
58
+ // Entries are never filtered: dropping a malformed value would shift the
59
+ // server-side position and could promote a caller-prepended value.
60
+ const forwarded = header.split(",");
30
61
  const sourceIndex = forwarded.length - trustedProxyHops;
31
- return sourceIndex >= 0 ? forwarded[sourceIndex]! : peer;
62
+ if (sourceIndex < 0) return peer;
63
+ return normalizedAddress(forwarded[sourceIndex]) ?? peer;
64
+ }
65
+
66
+ /**
67
+ * Rate-limit key for a trusted source address. An IPv6 address keys on its
68
+ * /64, the block one subscriber normally holds and the granularity Better
69
+ * Auth applies to the same stamped address, so rotating addresses inside one
70
+ * /64 cannot mint fresh buckets. An IPv4-mapped IPv6 address (how a
71
+ * dual-stack listener reports an IPv4 peer) keys on the embedded IPv4 address
72
+ * instead of collapsing every IPv4 client into one /64. Anything else,
73
+ * including {@link UNKNOWN_REQUEST_SOURCE_ADDRESS}, is returned unchanged.
74
+ */
75
+ export function requestSourceRateLimitKey(address: string): string {
76
+ if (isIP(address) !== 6) return address;
77
+ const groups = ipv6Groups(address);
78
+ if (!groups) return address;
79
+ if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) {
80
+ const high = groups[6]!;
81
+ const low = groups[7]!;
82
+ return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`;
83
+ }
84
+ return `${groups
85
+ .slice(0, 4)
86
+ .map((group) => group.toString(16))
87
+ .join(":")}::/64`;
88
+ }
89
+
90
+ /** {@link trustedRequestSourceAddress} as an application rate-limit key. */
91
+ export function trustedRequestSourceRateLimitKey(c: Context, trust: RequestSourceTrust): string {
92
+ return requestSourceRateLimitKey(trustedRequestSourceAddress(c, trust));
32
93
  }
33
94
 
95
+ /**
96
+ * Replace any caller-supplied {@link TRUSTED_CLIENT_ADDRESS_HEADER}. When
97
+ * `stamp` is true the header is set to the trusted source address, so every
98
+ * Better Auth request derived from this request (direct handler calls,
99
+ * isolated provider requests, and `auth.api.*` calls built from its headers)
100
+ * keys rate limits and session records on the real client.
101
+ */
102
+ export function replaceTrustedClientAddressHeader(
103
+ c: Context,
104
+ trust: RequestSourceTrust,
105
+ stamp: boolean,
106
+ ): void {
107
+ const headers = c.req.raw.headers;
108
+ headers.delete(TRUSTED_CLIENT_ADDRESS_HEADER);
109
+ if (!stamp) return;
110
+ const address = trustedRequestSourceAddress(c, trust);
111
+ if (address !== UNKNOWN_REQUEST_SOURCE_ADDRESS) {
112
+ headers.set(TRUSTED_CLIENT_ADDRESS_HEADER, address);
113
+ }
114
+ }
115
+
116
+ const trustedProxyRanges = new Map<string, BlockList | null>();
117
+
118
+ function peerIsTrustedProxy(peer: string, cidrs: string): boolean {
119
+ let ranges = trustedProxyRanges.get(cidrs);
120
+ if (ranges === undefined) {
121
+ // Settings validation already rejected malformed entries at boot.
122
+ const entries = trustedProxyCidrEntries(cidrs);
123
+ ranges = entries.length === 0 ? null : new BlockList();
124
+ for (const entry of entries) ranges?.addSubnet(entry.address, entry.prefix, entry.family);
125
+ trustedProxyRanges.set(cidrs, ranges);
126
+ }
127
+ if (!ranges) return true;
128
+ return ranges.check(peer, isIP(peer) === 6 ? "ipv6" : "ipv4");
129
+ }
130
+
131
+ /**
132
+ * Canonical IP text, or null. Accepts the `ipv4:port` and `[ipv6]:port` forms
133
+ * some load balancers write into X-Forwarded-For, and drops IPv6 zone ids.
134
+ */
34
135
  function normalizedAddress(value: string | null | undefined): string | null {
35
- const normalized = value?.trim();
36
- return normalized ? normalized.slice(0, 128) : null;
136
+ let candidate = value?.trim();
137
+ if (!candidate || candidate.length > 64) return null;
138
+ const bracketed = /^\[([^\]]+)\](?::\d{1,5})?$/u.exec(candidate);
139
+ if (bracketed) {
140
+ candidate = bracketed[1]!;
141
+ } else {
142
+ const ipv4WithPort = /^(\d{1,3}(?:\.\d{1,3}){3}):\d{1,5}$/u.exec(candidate);
143
+ if (ipv4WithPort) candidate = ipv4WithPort[1]!;
144
+ }
145
+ const zone = candidate.indexOf("%");
146
+ if (zone >= 0) candidate = candidate.slice(0, zone);
147
+ const family = isIP(candidate);
148
+ if (family === 4) return candidate;
149
+ if (family === 6) return candidate.toLowerCase();
150
+ return null;
151
+ }
152
+
153
+ /** The eight 16-bit groups of a valid IPv6 address, or null. */
154
+ function ipv6Groups(address: string): number[] | null {
155
+ let text = address;
156
+ const tail: number[] = [];
157
+ const lastColon = text.lastIndexOf(":");
158
+ const last = text.slice(lastColon + 1);
159
+ if (last.includes(".")) {
160
+ const octets = last.split(".").map(Number);
161
+ if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet))) return null;
162
+ tail.push((octets[0]! << 8) | octets[1]!, (octets[2]! << 8) | octets[3]!);
163
+ text = text.slice(0, lastColon + 1);
164
+ if (!text.endsWith("::")) text = text.slice(0, -1);
165
+ }
166
+ const halves = text.split("::");
167
+ if (halves.length > 2) return null;
168
+ const parse = (part: string): number[] =>
169
+ part ? part.split(":").map((group) => Number.parseInt(group, 16)) : [];
170
+ const head = parse(halves[0]!);
171
+ const rest = halves.length === 2 ? parse(halves[1]!) : [];
172
+ const fill = halves.length === 2 ? 8 - head.length - rest.length - tail.length : 0;
173
+ if (fill < 0) return null;
174
+ const groups = [...head, ...Array.from({ length: fill }, () => 0), ...rest, ...tail];
175
+ if (groups.length !== 8) return null;
176
+ if (groups.some((group) => !Number.isInteger(group) || group < 0 || group > 0xffff)) return null;
177
+ return groups;
37
178
  }