@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
@@ -4,3 +4,15 @@ export declare function validateInteractionRequestOrigin(value: string | undefin
4
4
  publicBaseUrl?: string | undefined;
5
5
  webBaseUrl?: string | undefined;
6
6
  }): string | null;
7
+ type BrowserOriginSettings = {
8
+ corsAllowOriginRegex: string;
9
+ publicBaseUrl?: string | undefined;
10
+ webBaseUrl?: string | undefined;
11
+ };
12
+ /**
13
+ * Whether a present `Origin` header names a browser origin this deployment
14
+ * serves its web app from: the CORS allowlist, the public base URL, or the
15
+ * separate web base URL. `null` and malformed values are never allowed.
16
+ */
17
+ export declare function isAllowedBrowserOrigin(value: string, input: BrowserOriginSettings): boolean;
18
+ export {};
@@ -0,0 +1,72 @@
1
+ import { type Settings } from "@opengeni/config";
2
+ /**
3
+ * Browser boundary for the unauthenticated local product mode.
4
+ *
5
+ * In `local` access mode every request without credentials acts as the local
6
+ * `dev` user, and the default local sandbox runs agent commands on the host.
7
+ * Binding the API to loopback keeps other devices out, but a web page open in
8
+ * the developer's browser can still reach `http://127.0.0.1:<port>`:
9
+ *
10
+ * - any site can send cross-origin requests to it (CORS only hides the
11
+ * response; a simple POST still runs), and
12
+ * - a DNS-rebinding site can make its own hostname resolve to 127.0.0.1 and
13
+ * read responses as a same-origin page.
14
+ *
15
+ * The boundary therefore sorts the `Host` a request is addressed to into two
16
+ * classes:
17
+ *
18
+ * - Browser hosts: loopback, the hosts of the configured web, public, and
19
+ * extra origins, the GitHub App callback tunnel, and a specific API bind
20
+ * address. A present `Origin` must be this stack's web app, an explicitly
21
+ * configured origin, or the API's own address on one of these hosts.
22
+ * - Sandbox hosts: names only sandboxes and the worker use to reach the API
23
+ * (`host.docker.internal` with the Docker sandbox, and the hosts of
24
+ * `OPENGENI_MCP_URL` and `OPENGENI_MCP_INTERNAL_URL`). They serve only the
25
+ * sandbox routes (Codemode, first-party MCP, and the Git broker) and refuse
26
+ * any request that carries browser metadata. A browser cannot tell a
27
+ * sandbox name from an attacker's, so a rebinding page on one of these
28
+ * names can neither send an accepted `Origin` nor reach the rest of the API.
29
+ *
30
+ * Every other `Host` is refused. Non-browser clients (the SDK, curl, sandbox
31
+ * callbacks, host-app servers) send no `Origin` and are unaffected. The `Host`
32
+ * port is not compared: a rebinding page controls only the hostname, while
33
+ * local proxies and forwarders legitimately present their own port.
34
+ *
35
+ * It applies only to `local` access mode in the `local` environment (the
36
+ * `bun run dev` stack and manual local runs). Managed and configured access
37
+ * modes, and deployments that set another `OPENGENI_ENVIRONMENT`, keep their
38
+ * existing CORS policy.
39
+ */
40
+ export type LocalBrowserBoundarySettings = Pick<Settings, "productAccessMode" | "environment" | "apiHost" | "webBaseUrl" | "publicBaseUrl" | "opengeniMcpUrl" | "opengeniMcpInternalUrl" | "githubAppManifestBaseUrl" | "localAllowedOrigins" | "sandboxBackend">;
41
+ export type LocalBrowserBoundaryRejectionCode = "LOCAL_HOST_NOT_ALLOWED" | "LOCAL_ORIGIN_NOT_ALLOWED" | "LOCAL_SANDBOX_ROUTE_ONLY";
42
+ export type LocalBrowserBoundaryRejection = {
43
+ status: 403;
44
+ code: LocalBrowserBoundaryRejectionCode;
45
+ message: string;
46
+ /** The refused `Host` or `Origin`, for the API log only; never sent back. */
47
+ refused: string;
48
+ };
49
+ /** Called once per distinct refused value, so an operator can see why. */
50
+ export type LocalBrowserBoundaryWarn = (message: string, attributes: Record<string, string>) => void;
51
+ export type LocalBrowserBoundary = {
52
+ readonly allowedOrigins: ReadonlySet<string>;
53
+ /** Hostnames a browser may address. */
54
+ readonly browserHostnames: ReadonlySet<string>;
55
+ /** Hostnames only sandboxes and the worker use; sandbox routes only, no browsers. */
56
+ readonly sandboxHostnames: ReadonlySet<string>;
57
+ /** Whether a present `Origin` may call the API for a request with this `Host`. */
58
+ originAllowed(origin: string, host: string | null): boolean;
59
+ /** The rejection for this request, or null when the boundary admits it. */
60
+ rejection(request: Request): LocalBrowserBoundaryRejection | null;
61
+ };
62
+ /** Mark a request the API dispatches to itself so the boundary admits it. */
63
+ export declare function markLocalInternalDispatch(request: Request): Request;
64
+ export declare function localBrowserBoundaryApplies(settings: Pick<Settings, "productAccessMode" | "environment">): boolean;
65
+ /** Build the boundary, or null when the deployment is not the local dev product. */
66
+ export declare function createLocalBrowserBoundary(settings: LocalBrowserBoundarySettings, options?: {
67
+ warn?: LocalBrowserBoundaryWarn;
68
+ }): LocalBrowserBoundary | null;
69
+ /** The standard API error envelope for a boundary rejection outside Hono. */
70
+ export declare function localBrowserBoundaryResponse(rejection: LocalBrowserBoundaryRejection): Response;
71
+ /** The `Host` the boundary checks: the header, else the request URL's authority. */
72
+ export declare function localBrowserRequestHost(request: Request): string | null;
@@ -0,0 +1,22 @@
1
+ import type { Settings } from "@opengeni/config";
2
+ import { Hono } from "hono";
3
+ type PrometheusSource = {
4
+ prometheusMetrics(): Promise<string>;
5
+ };
6
+ /** Register the Prometheus exposition route on one Hono app. */
7
+ export declare function registerPrometheusMetricsRoute(app: Hono, observability: PrometheusSource): void;
8
+ /**
9
+ * Whether the public API listener serves `GET /metrics`. A deployment that sets
10
+ * `OPENGENI_API_METRICS_PORT` moves the exposition to a dedicated internal
11
+ * listener, so an ingress that forwards every path to the API cannot publish it.
12
+ */
13
+ export declare function publicListenerServesMetrics(settings: Pick<Settings, "apiMetricsPort">): boolean;
14
+ /**
15
+ * The dedicated metrics listener: only `GET /metrics`, behind the same
16
+ * deployment-key perimeter rules as the public listener. Nothing else is
17
+ * routed here, so exposing this port to a scraper exposes nothing else.
18
+ */
19
+ export declare function createMetricsListenerApp(settings: Settings, observability: PrometheusSource): Hono;
20
+ /** Start the dedicated listener when configured; returns undefined otherwise. */
21
+ export declare function startApiMetricsListener(settings: Settings, observability: PrometheusSource): ReturnType<typeof Bun.serve> | undefined;
22
+ export {};
@@ -0,0 +1,16 @@
1
+ import type { Context } from "hono";
2
+ /**
3
+ * The registered Hono path template of the handler that answers this request.
4
+ *
5
+ * Hono resolves every route matching a request before the first middleware
6
+ * runs, so the metrics middleware can read the exact code-owned template (for
7
+ * example `/v1/workspaces/:workspaceId/rigs/:rigId`) without re-implementing
8
+ * routing. Templates come only from route registrations, never from request
9
+ * data, so they are a closed and bounded metric/log label set.
10
+ *
11
+ * Middleware (arity two: `(context, next)`) and global catch-alls never name a
12
+ * route; the first matched terminal handler is the one Hono dispatches to.
13
+ */
14
+ export declare function registeredHandlerRoutePath(context: Context): string | null;
15
+ /** Normalize a registered template; drop catch-alls that would name nothing. */
16
+ export declare function boundedRegisteredRouteLabel(path: string | null | undefined): string | null;
@@ -1,14 +1,52 @@
1
+ import { type Settings } from "@opengeni/config";
1
2
  import type { Context } from "hono";
2
3
  declare const TRANSPORT_PEER_ADDRESS_BINDING = "opengeniTransportPeerAddress";
4
+ /** Returned when the runtime supplied no transport peer (for example an
5
+ * in-process test request). Callers treat it as one shared bucket. */
6
+ export declare const UNKNOWN_REQUEST_SOURCE_ADDRESS = "unknown";
7
+ /**
8
+ * App-owned request header carrying the server-resolved client address into
9
+ * Better Auth, which reads client addresses only from request headers. It is
10
+ * removed from every inbound request and re-stamped on managed-auth routes, so
11
+ * a caller-supplied value never reaches the auth rate limiter or session IP.
12
+ */
13
+ export declare const TRUSTED_CLIENT_ADDRESS_HEADER = "x-opengeni-trusted-client-address";
3
14
  type ApiRequestBindings = {
4
15
  [TRANSPORT_PEER_ADDRESS_BINDING]?: string | null;
5
16
  };
17
+ /** The deployment's declared proxy chain (`OPENGENI_API_TRUSTED_PROXY_*`). */
18
+ export type RequestSourceTrust = Pick<Settings, "apiTrustedProxyHops" | "apiTrustedProxyCidrs">;
6
19
  export declare function apiRequestBindingsForTransportPeer(address: string | null | undefined): ApiRequestBindings;
7
20
  /**
8
21
  * Resolve a quota/audit source from the server-owned transport peer. Forwarded
9
22
  * values are considered only when the operator declares an exact trusted proxy
10
- * hop count; the chain is then walked from the server side so caller-prepended
11
- * values cannot replace the address inserted by the trusted edge.
23
+ * hop count (`OPENGENI_API_TRUSTED_PROXY_HOPS`) and, when
24
+ * `OPENGENI_API_TRUSTED_PROXY_CIDRS` is set, only for a transport peer inside
25
+ * those ranges. Each trusted proxy must append the address of the peer that
26
+ * connected to it, or overwrite the header with the original client address;
27
+ * the chain is then walked from the server side so caller-prepended values
28
+ * cannot replace the address observed by the trusted edge. A missing, short,
29
+ * or malformed chain falls back to the transport peer.
30
+ */
31
+ export declare function trustedRequestSourceAddress(c: Context, trust: RequestSourceTrust): string;
32
+ /**
33
+ * Rate-limit key for a trusted source address. An IPv6 address keys on its
34
+ * /64, the block one subscriber normally holds and the granularity Better
35
+ * Auth applies to the same stamped address, so rotating addresses inside one
36
+ * /64 cannot mint fresh buckets. An IPv4-mapped IPv6 address (how a
37
+ * dual-stack listener reports an IPv4 peer) keys on the embedded IPv4 address
38
+ * instead of collapsing every IPv4 client into one /64. Anything else,
39
+ * including {@link UNKNOWN_REQUEST_SOURCE_ADDRESS}, is returned unchanged.
40
+ */
41
+ export declare function requestSourceRateLimitKey(address: string): string;
42
+ /** {@link trustedRequestSourceAddress} as an application rate-limit key. */
43
+ export declare function trustedRequestSourceRateLimitKey(c: Context, trust: RequestSourceTrust): string;
44
+ /**
45
+ * Replace any caller-supplied {@link TRUSTED_CLIENT_ADDRESS_HEADER}. When
46
+ * `stamp` is true the header is set to the trusted source address, so every
47
+ * Better Auth request derived from this request (direct handler calls,
48
+ * isolated provider requests, and `auth.api.*` calls built from its headers)
49
+ * keys rate limits and session records on the real client.
12
50
  */
13
- export declare function trustedRequestSourceAddress(c: Context, trustedProxyHops: number): string;
51
+ export declare function replaceTrustedClientAddressHeader(c: Context, trust: RequestSourceTrust, stamp: boolean): void;
14
52
  export {};
@@ -0,0 +1,36 @@
1
+ export declare const USER_CONTENT_SECURITY_POLICY = "default-src 'none'; img-src 'self' data: blob:; media-src 'self' data: blob:; style-src 'unsafe-inline'; sandbox";
2
+ /** Media players only; scripts stay disabled (see the module comment). */
3
+ export declare const USER_MEDIA_CONTENT_SECURITY_POLICY = "default-src 'none'; img-src 'self' data: blob:; media-src 'self' data: blob:; style-src 'unsafe-inline'; sandbox allow-same-origin";
4
+ export declare const USER_CONTENT_SECURITY_HEADERS: Readonly<{
5
+ readonly "Content-Security-Policy": "default-src 'none'; img-src 'self' data: blob:; media-src 'self' data: blob:; style-src 'unsafe-inline'; sandbox";
6
+ readonly "Cross-Origin-Resource-Policy": "same-origin";
7
+ readonly "X-Content-Type-Options": "nosniff";
8
+ }>;
9
+ /**
10
+ * True when a browser would render this type as an active document (HTML,
11
+ * XHTML, SVG, any XML dialect, or multipart replacement streams). Anything that
12
+ * is not a clean `type/subtype` token is treated as active too.
13
+ */
14
+ export declare function isActiveUserContentType(contentType: string): boolean;
15
+ /** `Content-Disposition: attachment` for active markup; inline types are unchanged. */
16
+ export declare function userContentDispositionHeaders(contentType: string, filename?: string | null): {
17
+ "Content-Disposition": string;
18
+ } | Record<string, never>;
19
+ /**
20
+ * Signed object-storage GET options for one stored object: active markup gets
21
+ * a signed `attachment` response override, so a browser that opens the URL
22
+ * downloads the bytes instead of rendering them with their stored type. The
23
+ * storage endpoint is not always a separate site (local development serves it
24
+ * from loopback, and a preview may route the bucket path on the app origin),
25
+ * so a signed URL follows the same rule as the API routes. Spread into
26
+ * `createGetUrl`; inline types add nothing.
27
+ */
28
+ export declare function userContentSignedGetUrlOptions(contentType: string, filename?: string | null): {
29
+ responseContentDisposition: string;
30
+ } | Record<string, never>;
31
+ /** True for audio and video types the browser plays in a synthesized media document. */
32
+ export declare function isPlayableMediaUserContentType(contentType: string): boolean;
33
+ /** Sandbox policy for one response; only playable media keeps its origin. */
34
+ export declare function userContentSecurityPolicy(contentType: string): string;
35
+ /** Full header set for one user-content response with the given content type. */
36
+ export declare function userContentResponseHeaders(contentType: string, filename?: string | null): Record<string, string>;
@@ -0,0 +1,18 @@
1
+ import { type LocalBrowserBoundary } from "./local-browser-boundary.js";
2
+ export type ApiWebSocketUpgradeRoute<Server> = {
3
+ handles(request: Request): boolean;
4
+ upgrade(request: Request, server: Server): Response | undefined;
5
+ };
6
+ export type ApiWebSocketUpgradeDispatch = {
7
+ handled: false;
8
+ } | {
9
+ handled: true;
10
+ response: Response | undefined;
11
+ };
12
+ /**
13
+ * WebSocket upgrades bypass the Hono app, so the local-mode browser boundary
14
+ * (./local-browser-boundary.ts) is applied here before a transport upgrades.
15
+ * Returns `{ handled: false }` when no transport claims the request, so the
16
+ * caller dispatches it to the app, where the boundary runs as middleware.
17
+ */
18
+ export declare function dispatchApiWebSocketUpgrade<Server>(request: Request, server: Server, routes: ReadonlyArray<ApiWebSocketUpgradeRoute<Server>>, localBrowserBoundary: LocalBrowserBoundary | null): ApiWebSocketUpgradeDispatch;
package/dist/index.js CHANGED
@@ -4,10 +4,13 @@ import {
4
4
  apiRequestBindingsForTransportPeer,
5
5
  cleanupScheduledTaskConnectorAuthorization,
6
6
  createAppComposition,
7
+ createLocalBrowserBoundary,
7
8
  createOpenGeniSlackBotInteractionClient,
9
+ localBrowserBoundaryResponse,
10
+ startApiMetricsListener,
8
11
  startSlackInteractionPump,
9
12
  startTemporalScheduleCleanupPump
10
- } from "./chunk-24C5LPNE.js";
13
+ } from "./chunk-QDIBJSM4.js";
11
14
 
12
15
  // src/index.ts
13
16
  import { scheduledTaskKnowledgeSource } from "@opengeni/contracts";
@@ -48,6 +51,15 @@ import {
48
51
  } from "@temporalio/client";
49
52
 
50
53
  // src/observability.ts
54
+ import {
55
+ natsSubscriptionTerminationCounter
56
+ } from "@opengeni/events";
57
+ function observabilityEventBusOptions(observability) {
58
+ return {
59
+ logger: observabilityEventLogger(observability),
60
+ onSubscriptionTerminated: natsSubscriptionTerminationCounter(observability)
61
+ };
62
+ }
51
63
  function observabilityEventLogger(observability) {
52
64
  return {
53
65
  debug: (message, attributes) => observability.debug(message, eventAttributes(attributes)),
@@ -76,6 +88,8 @@ function eventAttributeValue(value) {
76
88
  }
77
89
 
78
90
  // src/sandbox/auth-callout.ts
91
+ import { createHash } from "crypto";
92
+ import { createLogThrottle } from "@opengeni/observability";
79
93
  import {
80
94
  resolveEnrollmentSigningSecret
81
95
  } from "@opengeni/config";
@@ -96,6 +110,23 @@ var AGENT_CONNECTION_LEASE_MS = 2e4;
96
110
  // src/sandbox/auth-callout.ts
97
111
  var AUTH_CALLOUT_SUBJECT = "$SYS.REQ.USER.AUTH";
98
112
  var NATS_USER_JWT_TTL_SECONDS = 5 * 60;
113
+ var AUTH_CALLOUT_DENIAL_WARNING_INTERVAL_MS = 6e4;
114
+ var processDenialWarningThrottle = createLogThrottle({
115
+ intervalMs: AUTH_CALLOUT_DENIAL_WARNING_INTERVAL_MS,
116
+ maxKeys: 1024
117
+ });
118
+ function warnDenial(deps, key, reason, message, attributes = {}) {
119
+ const admission = (deps.denialWarningThrottle ?? processDenialWarningThrottle).admit(key);
120
+ if (!admission) return;
121
+ deps.observability?.warn?.(message, {
122
+ ...attributes,
123
+ reason,
124
+ ...admission.suppressedCount > 0 ? { suppressedCount: admission.suppressedCount } : {}
125
+ });
126
+ }
127
+ function rejectedBearerKey(bearer) {
128
+ return `invalid-bearer:${createHash("sha256").update(bearer).digest("hex").slice(0, 32)}`;
129
+ }
99
130
  async function handleAuthorizationRequest(deps, requestBytes) {
100
131
  const requestJwt = Buffer.from(requestBytes).toString("utf8");
101
132
  const decoded = decodeAuthRequest(requestJwt);
@@ -122,15 +153,23 @@ async function handleAuthorizationRequest(deps, requestBytes) {
122
153
  }
123
154
  const claims = await verifyEnrollmentBearer(secret, bearer);
124
155
  if (!claims) {
125
- deps.observability?.warn?.("auth-callout: rejected an invalid enrollment bearer", {});
156
+ warnDenial(
157
+ deps,
158
+ rejectedBearerKey(bearer),
159
+ "invalid_bearer",
160
+ "auth-callout: rejected an invalid enrollment bearer"
161
+ );
126
162
  return deny("invalid or expired enrollment bearer");
127
163
  }
128
164
  const enrollment = await getEnrollment(deps.db, claims.workspaceId, claims.enrollmentId);
129
165
  if (!enrollment || enrollment.status !== "active") {
130
- deps.observability?.warn?.("auth-callout: denied a revoked or unknown enrollment", {
131
- workspaceId: claims.workspaceId,
132
- agentId: claims.agentId
133
- });
166
+ warnDenial(
167
+ deps,
168
+ `inactive:${claims.workspaceId}:${claims.agentId}`,
169
+ "inactive_enrollment",
170
+ "auth-callout: denied a revoked or unknown enrollment",
171
+ { workspaceId: claims.workspaceId, agentId: claims.agentId }
172
+ );
134
173
  return deny("enrollment is not active");
135
174
  }
136
175
  if (enrollment.workspaceId !== claims.workspaceId || enrollment.id !== claims.enrollmentId || enrollment.id !== claims.agentId || claims.agentId !== claims.enrollmentId || claims.subjectPrefix !== `agent.${claims.workspaceId}.${claims.agentId}`) {
@@ -151,10 +190,13 @@ async function handleAuthorizationRequest(deps, requestBytes) {
151
190
  leaseMs: AGENT_CONNECTION_LEASE_MS
152
191
  });
153
192
  if (!claim.claimed) {
154
- deps.observability?.warn?.("auth-callout: denied a duplicate live runner", {
155
- workspaceId: claims.workspaceId,
156
- agentId: claims.agentId
157
- });
193
+ warnDenial(
194
+ deps,
195
+ `duplicate:${claims.workspaceId}:${claims.agentId}`,
196
+ "duplicate_runner",
197
+ "auth-callout: denied a duplicate live runner",
198
+ { workspaceId: claims.workspaceId, agentId: claims.agentId }
199
+ );
158
200
  return deny("another runner instance currently owns this enrollment");
159
201
  }
160
202
  const permissions = workspaceAgentPermissions(
@@ -198,7 +240,7 @@ async function startAuthCalloutResponder(deps, natsUrl) {
198
240
  (bytes) => handleAuthorizationRequest(deps, bytes),
199
241
  {
200
242
  name: "opengeni-auth-callout",
201
- ...deps.observability ? { logger: observabilityEventLogger(deps.observability) } : {}
243
+ ...deps.observability ? observabilityEventBusOptions(deps.observability) : {}
202
244
  }
203
245
  );
204
246
  deps.observability?.info?.("OpenGeni NATS auth-callout responder started", {
@@ -1249,8 +1291,17 @@ function closeCode(reason) {
1249
1291
  return 4400;
1250
1292
  }
1251
1293
 
1294
+ // src/http/websocket-upgrade-dispatch.ts
1295
+ function dispatchApiWebSocketUpgrade(request, server, routes, localBrowserBoundary) {
1296
+ const route = routes.find((candidate) => candidate.handles(request));
1297
+ if (!route) return { handled: false };
1298
+ const rejection = localBrowserBoundary?.rejection(request);
1299
+ if (rejection) return { handled: true, response: localBrowserBoundaryResponse(rejection) };
1300
+ return { handled: true, response: route.upgrade(request, server) };
1301
+ }
1302
+
1252
1303
  // src/editable-artifact-production.ts
1253
- import { createHash as createHash4, randomUUID } from "crypto";
1304
+ import { createHash as createHash5, randomUUID } from "crypto";
1254
1305
  import {
1255
1306
  editableArtifactMaterializerCapabilitiesForRuntime
1256
1307
  } from "@opengeni/artifact-tool/runtime/materializer";
@@ -1352,7 +1403,7 @@ async function requireFlushBarrier(connection) {
1352
1403
  }
1353
1404
 
1354
1405
  // src/editable-artifact-native-kernel.ts
1355
- import { createHash } from "crypto";
1406
+ import { createHash as createHash2 } from "crypto";
1356
1407
  import { pathToFileURL } from "url";
1357
1408
  import {
1358
1409
  createNativeArtifactSession,
@@ -1476,7 +1527,7 @@ var NativeEditableArtifactKernelAdapter = class {
1476
1527
  canonicalChunks: fixedChunks(snapshot, input.signal),
1477
1528
  canonicalByteSize: snapshot.byteLength,
1478
1529
  canonicalContentHash: editableArtifactContentHash(
1479
- `sha256:${createHash("sha256").update(snapshot).digest("hex")}`
1530
+ `sha256:${createHash2("sha256").update(snapshot).digest("hex")}`
1480
1531
  ),
1481
1532
  stateHash: editableArtifactStateHash(session.stateHash()),
1482
1533
  coveredHeadSequence: 0,
@@ -1523,7 +1574,7 @@ var NativeEditableArtifactKernelAdapter = class {
1523
1574
  canonicalChunks: fixedChunks(canonical, input.signal),
1524
1575
  canonicalByteSize: canonical.byteLength,
1525
1576
  canonicalContentHash: editableArtifactContentHash(
1526
- `sha256:${createHash("sha256").update(canonical).digest("hex")}`
1577
+ `sha256:${createHash2("sha256").update(canonical).digest("hex")}`
1527
1578
  ),
1528
1579
  stateHash: editableArtifactStateHash(session.stateHash()),
1529
1580
  coveredHeadSequence: input.state.artifact.headSequence,
@@ -1570,7 +1621,7 @@ var NativeEditableArtifactKernelAdapter = class {
1570
1621
  const stateHash = editableArtifactStateHash(session.stateHash());
1571
1622
  if (stateHash !== expected.stateHash) throw new Error("Snapshot state hash mismatch");
1572
1623
  const canonicalContentHash = editableArtifactContentHash(
1573
- `sha256:${createHash("sha256").update(canonical).digest("hex")}`
1624
+ `sha256:${createHash2("sha256").update(canonical).digest("hex")}`
1574
1625
  );
1575
1626
  if (session.modality === "spreadsheet") {
1576
1627
  const coveredCausalFrontier = domainFrontier(session.frontier());
@@ -1786,7 +1837,7 @@ async function readVerifiedSnapshot(objects, snapshot, signal) {
1786
1837
  snapshot.byteSize,
1787
1838
  signal
1788
1839
  );
1789
- const contentHash = `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
1840
+ const contentHash = `sha256:${createHash2("sha256").update(bytes).digest("hex")}`;
1790
1841
  if (contentHash !== snapshot.contentHash) throw new Error("Snapshot content hash mismatch");
1791
1842
  await object.assertUnchanged(signal);
1792
1843
  return bytes;
@@ -1822,7 +1873,7 @@ async function* fixedChunks(bytes, signal) {
1822
1873
  }
1823
1874
  }
1824
1875
  function namespaceForArtifact(artifactId) {
1825
- const digest = createHash("sha256").update(artifactId).digest();
1876
+ const digest = createHash2("sha256").update(artifactId).digest();
1826
1877
  let value = 0n;
1827
1878
  for (let index = 0; index < 8; index += 1) value = value << 8n | BigInt(digest[index]);
1828
1879
  return value === 0n ? 1n : value;
@@ -1846,7 +1897,7 @@ function throwIfAborted(signal) {
1846
1897
  }
1847
1898
 
1848
1899
  // src/editable-artifact-office-import.ts
1849
- import { createHash as createHash2 } from "crypto";
1900
+ import { createHash as createHash3 } from "crypto";
1850
1901
  import {
1851
1902
  ArtifactOfficeSourceUnsupportedError,
1852
1903
  prepareArtifactOfficeImport
@@ -2012,7 +2063,7 @@ async function readVerifiedWorkspaceFile(storage, file, signal) {
2012
2063
  throw new EditableArtifactOfficeImportError("source_changed");
2013
2064
  }
2014
2065
  const bytes = new Uint8Array(file.sizeBytes);
2015
- const digest = createHash2("sha256");
2066
+ const digest = createHash3("sha256");
2016
2067
  for (let start = 0; start < file.sizeBytes; start += MAX_BOUNDED_OBJECT_CHUNK_BYTES) {
2017
2068
  throwIfAborted2(signal);
2018
2069
  const end = Math.min(file.sizeBytes, start + MAX_BOUNDED_OBJECT_CHUNK_BYTES) - 1;
@@ -2073,7 +2124,7 @@ function throwIfAborted2(signal) {
2073
2124
  }
2074
2125
 
2075
2126
  // src/editable-artifact-workspace-files.ts
2076
- import { createHash as createHash3 } from "crypto";
2127
+ import { createHash as createHash4 } from "crypto";
2077
2128
  import { completeFileUpload, prepareGeneratedWorkspaceFile } from "@opengeni/db";
2078
2129
  import { retryWhileMissing as retryWhileMissing2 } from "@opengeni/storage";
2079
2130
  var UPLOAD_INTENT_TTL_MS = 60 * 6e4;
@@ -2209,7 +2260,7 @@ function plainSha256(value) {
2209
2260
  return match[1];
2210
2261
  }
2211
2262
  function deterministicUuid(seed) {
2212
- const bytes = createHash3("sha256").update(seed, "utf8").digest().subarray(0, 16);
2263
+ const bytes = createHash4("sha256").update(seed, "utf8").digest().subarray(0, 16);
2213
2264
  bytes[6] = (bytes[6] ?? 0) & 15 | 80;
2214
2265
  bytes[8] = (bytes[8] ?? 0) & 63 | 128;
2215
2266
  const hex = bytes.toString("hex");
@@ -2484,7 +2535,7 @@ function snapshotBytesPort(objects) {
2484
2535
  throw new Error("Editable artifact snapshot content type mismatch");
2485
2536
  }
2486
2537
  const bytes = new Uint8Array(snapshot.byteSize);
2487
- const digest = createHash4("sha256");
2538
+ const digest = createHash5("sha256");
2488
2539
  let offset = 0;
2489
2540
  for await (const chunk of object.chunks({
2490
2541
  chunkBytes: MAX_BOUNDED_OBJECT_CHUNK_BYTES2
@@ -2947,7 +2998,7 @@ async function startApi(options = {}) {
2947
2998
  () => createNatsEventBus(
2948
2999
  settings.natsUrl,
2949
3000
  controlPlaneAuth ? { user: controlPlaneAuth.user, pass: controlPlaneAuth.password } : void 0,
2950
- { logger: observabilityEventLogger(observability) }
3001
+ observabilityEventBusOptions(observability)
2951
3002
  ),
2952
3003
  {
2953
3004
  ...retryOptions,
@@ -3012,17 +3063,22 @@ async function startApi(options = {}) {
3012
3063
  const interactionFrameProxies = new InteractionFrameProxyTransport(
3013
3064
  resolveFirstPartyDelegationSecret(settings)
3014
3065
  );
3066
+ const localBrowserBoundary = createLocalBrowserBoundary(settings, {
3067
+ warn: (message, attributes) => observability.warn(message, attributes)
3068
+ });
3069
+ const webSocketUpgrades = [interactionFrameProxies, artifactWebSockets];
3015
3070
  const server = Bun.serve({
3016
3071
  hostname: settings.apiHost,
3017
3072
  port: settings.apiPort,
3018
3073
  idleTimeout: 255,
3019
3074
  fetch: (request, bunServer) => {
3020
- if (interactionFrameProxies.handles(request)) {
3021
- return interactionFrameProxies.upgrade(request, bunServer);
3022
- }
3023
- if (artifactWebSockets.handles(request)) {
3024
- return artifactWebSockets.upgrade(request, bunServer);
3025
- }
3075
+ const upgrade = dispatchApiWebSocketUpgrade(
3076
+ request,
3077
+ bunServer,
3078
+ webSocketUpgrades,
3079
+ localBrowserBoundary
3080
+ );
3081
+ if (upgrade.handled) return upgrade.response;
3026
3082
  return app.fetch(
3027
3083
  request,
3028
3084
  apiRequestBindingsForTransportPeer(bunServer.requestIP(request)?.address)
@@ -3037,6 +3093,7 @@ async function startApi(options = {}) {
3037
3093
  close: (socket) => socket.data.transportClosed()
3038
3094
  }
3039
3095
  });
3096
+ const metricsServer = startApiMetricsListener(settings, observability);
3040
3097
  const stopSlackInteractionPump = settings.slackSigningSecret ? startSlackInteractionPump(routeDeps) : void 0;
3041
3098
  const stopMemorySlackPublicationPump = startMemorySlackPublicationPump(routeDeps);
3042
3099
  const stopTemporalScheduleCleanupPump = startTemporalScheduleCleanupPump({
@@ -3085,12 +3142,14 @@ async function startApi(options = {}) {
3085
3142
  }
3086
3143
  observability.info("OpenGeni API listening", {
3087
3144
  host: settings.apiHost,
3088
- port: settings.apiPort
3145
+ port: settings.apiPort,
3146
+ ...metricsServer ? { metricsPort: settings.apiMetricsPort } : {}
3089
3147
  });
3090
3148
  return {
3091
3149
  server,
3092
3150
  close: async () => {
3093
3151
  server.stop(true);
3152
+ metricsServer?.stop(true);
3094
3153
  stopSlackInteractionPump?.();
3095
3154
  await stopMemorySlackPublicationPump();
3096
3155
  stopMetricsIngestion?.();