@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
@@ -1,6 +1,20 @@
1
- import type { EventLogger } from "@opengeni/events";
1
+ import {
2
+ natsSubscriptionTerminationCounter,
3
+ type EventBusOptions,
4
+ type EventLogger,
5
+ } from "@opengeni/events";
2
6
  import type { Attributes, AttributeValue, Observability } from "@opengeni/observability";
3
7
 
8
+ /** Logger plus the closed-label subscription-termination counter for NATS connections. */
9
+ export function observabilityEventBusOptions(
10
+ observability: Observability,
11
+ ): Pick<EventBusOptions, "logger" | "onSubscriptionTerminated"> {
12
+ return {
13
+ logger: observabilityEventLogger(observability),
14
+ onSubscriptionTerminated: natsSubscriptionTerminationCounter(observability),
15
+ };
16
+ }
17
+
4
18
  export function observabilityEventLogger(observability: Observability): EventLogger {
5
19
  return {
6
20
  debug: (message, attributes) => observability.debug(message, eventAttributes(attributes)),
@@ -342,7 +342,12 @@ async function handleStripeWebhookEvent(
342
342
  ): Promise<void> {
343
343
  switch (event.type) {
344
344
  case "checkout.session.completed":
345
- await handleCheckoutSessionCompleted(deps, event);
345
+ case "checkout.session.async_payment_succeeded":
346
+ await handleCheckoutSessionPayment(deps, event);
347
+ return;
348
+ case "checkout.session.async_payment_failed":
349
+ // A delayed payment method failed. Completion reported it `unpaid`, so
350
+ // no credit was granted and there is nothing to reverse.
346
351
  return;
347
352
  case "checkout.session.expired":
348
353
  case "payment_intent.succeeded":
@@ -378,15 +383,69 @@ async function handleStripeWebhookEvent(
378
383
  }
379
384
  }
380
385
 
381
- async function handleCheckoutSessionCompleted(
386
+ export type StripeCheckoutCreditDecision =
387
+ | { action: "grant"; credit: CheckoutCreditMetadata }
388
+ | { action: "ignore"; reason: "foreign_checkout" | "not_payment_mode" | "payment_not_paid" };
389
+
390
+ /**
391
+ * Decides whether a Checkout Session webhook grants OpenGeni credits.
392
+ *
393
+ * Sessions created outside OpenGeni on the same Stripe account carry no
394
+ * `opengeni_` metadata and are acknowledged without effect. An OpenGeni
395
+ * session with malformed credit metadata still throws so the failure stays
396
+ * visible. Credits are granted only once Stripe reports the payment `paid`:
397
+ * on `checkout.session.completed` for immediate methods, or on
398
+ * `checkout.session.async_payment_succeeded` for delayed methods, whose
399
+ * completion event arrives `unpaid`. Both events carry the same session
400
+ * metadata, so the ledger idempotency key grants at most once.
401
+ */
402
+ export function stripeCheckoutCreditDecision(
403
+ session: Stripe.Checkout.Session,
404
+ ): StripeCheckoutCreditDecision {
405
+ if (!Object.keys(session.metadata ?? {}).some((key) => key.startsWith("opengeni_"))) {
406
+ return { action: "ignore", reason: "foreign_checkout" };
407
+ }
408
+ if (session.mode !== "payment") {
409
+ return { action: "ignore", reason: "not_payment_mode" };
410
+ }
411
+ if (session.payment_status !== "paid") {
412
+ return { action: "ignore", reason: "payment_not_paid" };
413
+ }
414
+ return {
415
+ action: "grant",
416
+ credit: creditMetadata(session.metadata, `Stripe checkout session ${session.id}`),
417
+ };
418
+ }
419
+
420
+ async function handleCheckoutSessionPayment(
382
421
  deps: ApiRouteDeps,
383
422
  event: Stripe.Event,
384
423
  ): Promise<void> {
385
424
  const session = event.data.object as Stripe.Checkout.Session;
386
- if (session.mode !== "payment" || session.payment_status !== "paid") {
425
+ const decision = stripeCheckoutCreditDecision(session);
426
+ if (decision.action === "ignore") {
427
+ if (decision.reason === "foreign_checkout") {
428
+ console.info("[api] stripe webhook ignored a checkout session not created by OpenGeni", {
429
+ stripeEventType: event.type,
430
+ livemode: event.livemode,
431
+ });
432
+ }
433
+ return;
434
+ }
435
+ const credit = decision.credit;
436
+ if (!(await getManagedAccount(deps.db, credit.accountId))) {
437
+ // Another OpenGeni deployment sharing the Stripe account (or an account
438
+ // removed before a delayed payment settled). Retrying cannot succeed, so
439
+ // acknowledge instead of failing the delivery for days.
440
+ console.info(
441
+ "[api] stripe webhook ignored a checkout session for an account not in this deployment",
442
+ {
443
+ stripeEventType: event.type,
444
+ livemode: event.livemode,
445
+ },
446
+ );
387
447
  return;
388
448
  }
389
- const credit = creditMetadata(session.metadata, `Stripe checkout session ${session.id}`);
390
449
  const customerId = typeof session.customer === "string" ? session.customer : session.customer?.id;
391
450
  if (customerId) {
392
451
  await upsertBillingCustomer(deps.db, {
@@ -601,16 +660,18 @@ async function metadataForDispute(
601
660
  : charge.metadata;
602
661
  }
603
662
 
604
- function creditMetadata(
605
- metadata: Stripe.Metadata | null | undefined,
606
- label: string,
607
- ): {
663
+ export type CheckoutCreditMetadata = {
608
664
  accountId: string;
609
665
  amountMicros: number;
610
666
  idempotencyKey: string;
611
667
  amountUsd?: string;
612
668
  packageId?: string;
613
- } {
669
+ };
670
+
671
+ function creditMetadata(
672
+ metadata: Stripe.Metadata | null | undefined,
673
+ label: string,
674
+ ): CheckoutCreditMetadata {
614
675
  const accountId = metadata?.opengeni_account_id;
615
676
  const amountMicros = Number(metadata?.opengeni_credit_micros);
616
677
  const idempotencyKey = metadata?.opengeni_credit_idempotency_key;
@@ -3,6 +3,7 @@ import {
3
3
  environmentsEncryptionKeyBytes,
4
4
  resolveFirstPartyDelegationSecret,
5
5
  resolveStreamTokenSecret,
6
+ sandboxWarmRateMicrosPerSecond,
6
7
  } from "@opengeni/config";
7
8
  import {
8
9
  BROWSER_CONTROL_WEBSOCKET_BEARER_PREFIX,
@@ -20,6 +21,8 @@ import {
20
21
  BrowserDownloadSaveRequest,
21
22
  BrowserDownloadSaveResponse,
22
23
  BrowserDownloadListResponse,
24
+ BrowserDomReadRequest,
25
+ BrowserDomReadResponse,
23
26
  BrowserExternalAuthCommand,
24
27
  BrowserOpenTargetRequest,
25
28
  BrowserObservation,
@@ -31,6 +34,7 @@ import {
31
34
  BrowserSessionListResponse,
32
35
  BrowserSessionMutationResponse,
33
36
  BrowserTargetListResponse,
37
+ BrowserTargetState,
34
38
  CreateBrowserSessionRequest,
35
39
  ExternalAuthInteractiveRequest,
36
40
  ExternalAuthInteractiveResponse,
@@ -75,6 +79,7 @@ import {
75
79
  BrowserSessionNotFoundError,
76
80
  BrowserSessionOperationConflictError,
77
81
  BrowserSessionStateError,
82
+ SandboxPaidComputeAdmissionError,
78
83
  completeBrowserSessionEnd,
79
84
  completeExternalAuth,
80
85
  completeBrowserDownloadSave,
@@ -88,6 +93,8 @@ import {
88
93
  dispatchExternalAuth,
89
94
  dispatchProtectedAuthFill,
90
95
  failBrowserSessionOperation,
96
+ failPreparedBrowserSessionEnd,
97
+ failPreparedBrowserSessionSuspend,
91
98
  failBrowserSessionResume,
92
99
  failBrowserSessionResumePreparation,
93
100
  failBrowserSessionSuspension,
@@ -169,6 +176,8 @@ import {
169
176
  provisionBrowserControlClient,
170
177
  renewSandboxProviderExpiration,
171
178
  type BrowserControlPlacementSession,
179
+ type BrowserControlScreenshotOptions,
180
+ type BrowserControlFrame,
172
181
  type PlacementBrowserStateCaptureReceipt,
173
182
  type PlacementBrowserNetworkRoute,
174
183
  type PlacementBrowserTransport,
@@ -221,6 +230,7 @@ import {
221
230
  } from "../interaction-metrics";
222
231
  import { withChannelA, withChannelARead, type ChannelAOperation } from "../sandbox/channel-a";
223
232
  import { sanitizeFilename } from "./files";
233
+ import { USER_CONTENT_SECURITY_HEADERS } from "../http/user-content";
224
234
 
225
235
  const BROWSER_DRIVER_ID = "opengeni.cdp.v1";
226
236
  const LIGHTPANDA_DRIVER_ID = "opengeni.lightpanda.cdp.v1";
@@ -952,6 +962,71 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
952
962
  },
953
963
  );
954
964
 
965
+ app.get(
966
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/targets/:targetId/state",
967
+ async (context) => {
968
+ const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
969
+ context,
970
+ "sessions:read",
971
+ );
972
+ const targetId = requireOpaqueParam(context, "targetId");
973
+ const result = await withActiveBrowserController(
974
+ context,
975
+ grant,
976
+ workspaceId,
977
+ browserSessionId,
978
+ "session.read",
979
+ "browser.read",
980
+ async ({ sessionClient }) => await sessionClient.targetState(targetId),
981
+ );
982
+ return context.json(BrowserTargetState.parse(result));
983
+ },
984
+ );
985
+
986
+ app.post(
987
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/targets/:targetId/dom-read",
988
+ async (context) => {
989
+ const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
990
+ context,
991
+ "sessions:read",
992
+ );
993
+ const targetId = requireOpaqueParam(context, "targetId");
994
+ const request = await parseJsonBody(context, BrowserDomReadRequest);
995
+ const result = await withActiveBrowserController(
996
+ context,
997
+ grant,
998
+ workspaceId,
999
+ browserSessionId,
1000
+ "session.read",
1001
+ "browser.read",
1002
+ async ({ sessionClient }) => await sessionClient.readDom(targetId, request),
1003
+ );
1004
+ return context.json(BrowserDomReadResponse.parse(result));
1005
+ },
1006
+ );
1007
+
1008
+ app.get(
1009
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/targets/:targetId/screenshot",
1010
+ async (context) => {
1011
+ const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
1012
+ context,
1013
+ "sessions:read",
1014
+ );
1015
+ const targetId = requireOpaqueParam(context, "targetId");
1016
+ const captureOptions = parseBrowserScreenshotOptions(new URL(context.req.url).searchParams);
1017
+ const frame = await withActiveBrowserController(
1018
+ context,
1019
+ grant,
1020
+ workspaceId,
1021
+ browserSessionId,
1022
+ "session.read",
1023
+ "browser.read",
1024
+ async ({ sessionClient }) => await sessionClient.capture(targetId, captureOptions),
1025
+ );
1026
+ return browserScreenshotResponse(frame);
1027
+ },
1028
+ );
1029
+
955
1030
  app.post(
956
1031
  "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/actions",
957
1032
  async (context) => {
@@ -2047,6 +2122,7 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
2047
2122
  const request = await parseJsonBody(context, BrowserSessionLifecycleRequest);
2048
2123
  const startedAtMs = performance.now();
2049
2124
  const origin = requestOrigin(context, deps.settings);
2125
+ let preparedForDispatch = false;
2050
2126
  try {
2051
2127
  const before = await getBrowserSessionControlRecord(deps.db, {
2052
2128
  accountId: grant.accountId,
@@ -2098,6 +2174,8 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
2098
2174
  return context.json(parsed, 200);
2099
2175
  }
2100
2176
 
2177
+ preparedForDispatch = true;
2178
+
2101
2179
  const record = await getBrowserSessionControlRecord(deps.db, {
2102
2180
  accountId: grant.accountId,
2103
2181
  workspaceId,
@@ -2259,6 +2337,15 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
2259
2337
  observeLifecycleResult(deps.observability, startedAtMs, parsed);
2260
2338
  return context.json(parsed, 200);
2261
2339
  } catch (error) {
2340
+ if (preparedForDispatch) {
2341
+ await failPreparedBrowserSessionSuspend(deps.db, {
2342
+ accountId: grant.accountId,
2343
+ workspaceId,
2344
+ browserSessionId,
2345
+ operationId: request.operationId,
2346
+ error: interactionFailure(error),
2347
+ });
2348
+ }
2262
2349
  throw browserRouteError(error);
2263
2350
  }
2264
2351
  },
@@ -2522,6 +2609,11 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
2522
2609
  const request = await parseJsonBody(context, BrowserSessionLifecycleRequest);
2523
2610
  const startedAtMs = performance.now();
2524
2611
  const origin = requestOrigin(context, deps.settings);
2612
+ let endPreparation: {
2613
+ restoreLifecycle: BrowserSessionValue["lifecycle"];
2614
+ restoreFailureCode: string | null;
2615
+ expectedControllerGeneration: string | null;
2616
+ } | null = null;
2525
2617
  try {
2526
2618
  const before = await getBrowserSessionControlRecord(deps.db, {
2527
2619
  accountId: grant.accountId,
@@ -2536,6 +2628,9 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
2536
2628
  browserSessionId,
2537
2629
  operationId: request.operationId,
2538
2630
  actorSubjectId: grant.subjectId,
2631
+ expectedLifecycle: before.session.lifecycle,
2632
+ expectedFailureCode: before.session.failureCode,
2633
+ expectedControllerGeneration: before.session.controller?.controllerGeneration ?? null,
2539
2634
  });
2540
2635
  if (isTerminalOperation(prepared.operation.state)) {
2541
2636
  if (prepared.operation.state === "completed") {
@@ -2551,6 +2646,13 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
2551
2646
  observeLifecycleResult(deps.observability, startedAtMs, parsed);
2552
2647
  return context.json(parsed, 200);
2553
2648
  }
2649
+ if (before.session.lifecycle !== "ending") {
2650
+ endPreparation = {
2651
+ restoreLifecycle: before.session.lifecycle,
2652
+ restoreFailureCode: before.session.failureCode,
2653
+ expectedControllerGeneration: before.session.controller?.controllerGeneration ?? null,
2654
+ };
2655
+ }
2554
2656
 
2555
2657
  const record = await getBrowserSessionControlRecord(deps.db, {
2556
2658
  accountId: grant.accountId,
@@ -2644,6 +2746,16 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
2644
2746
  observeLifecycleResult(deps.observability, startedAtMs, parsed);
2645
2747
  return context.json(parsed, 200);
2646
2748
  } catch (error) {
2749
+ if (endPreparation) {
2750
+ await failPreparedBrowserSessionEnd(deps.db, {
2751
+ accountId: grant.accountId,
2752
+ workspaceId,
2753
+ browserSessionId,
2754
+ operationId: request.operationId,
2755
+ ...endPreparation,
2756
+ error: interactionFailure(error),
2757
+ });
2758
+ }
2647
2759
  throw browserRouteError(error);
2648
2760
  }
2649
2761
  },
@@ -2792,6 +2904,12 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
2792
2904
  resolved.kind !== "selfhosted" ||
2793
2905
  resolved.sandboxId !== expectedPlacement.sandboxId
2794
2906
  ) {
2907
+ if (operation === "browser.create") {
2908
+ throw new HTTPException(422, {
2909
+ message:
2910
+ "The requested Connected Machine is not this session's current placement. Move the session to that machine before creating an interaction resource.",
2911
+ });
2912
+ }
2795
2913
  return await throwBrowserSourcePlacementChanged(
2796
2914
  grant,
2797
2915
  sourceSession.id,
@@ -3748,6 +3866,10 @@ async function ensureInteractionHolder(
3748
3866
  holderId: interactionHolderId(browserSessionId),
3749
3867
  subjectId: sourceSession.id,
3750
3868
  backend: placement.lease.backend,
3869
+ warmBilling: {
3870
+ mode: deps.settings.sandboxWarmBillingMode,
3871
+ rateMicrosPerSecond: sandboxWarmRateMicrosPerSecond(deps.settings, placement.lease.backend),
3872
+ },
3751
3873
  os: placement.lease.os,
3752
3874
  image: sandboxRuntime.image,
3753
3875
  rigVersionId: sourceSession.rigVersionId,
@@ -4023,6 +4145,7 @@ async function settleBrowserSessionSuspensionCaptureFailure(
4023
4145
  return await failBrowserSessionSuspension(deps.db, {
4024
4146
  ...input,
4025
4147
  ...(outcomeUnknown ? { state: "outcome_unknown" as const } : {}),
4148
+ missingControllerSession: error.status === 404 && isMissingBrowserControllerSession(error),
4026
4149
  error: error.error,
4027
4150
  });
4028
4151
  }
@@ -4160,6 +4283,50 @@ function optionalBoundedInteger(
4160
4283
  return parsed;
4161
4284
  }
4162
4285
 
4286
+ export function parseBrowserScreenshotOptions(
4287
+ query: URLSearchParams,
4288
+ ): BrowserControlScreenshotOptions {
4289
+ for (const key of ["fullPage", "format", "quality"]) {
4290
+ if (query.getAll(key).length > 1) {
4291
+ throw new HTTPException(400, { message: `duplicate browser screenshot ${key}` });
4292
+ }
4293
+ }
4294
+ const fullPage = query.get("fullPage");
4295
+ if (fullPage !== null && fullPage !== "true" && fullPage !== "false") {
4296
+ throw new HTTPException(400, { message: "invalid browser screenshot fullPage" });
4297
+ }
4298
+ const format = query.get("format");
4299
+ if (format !== null && format !== "jpeg" && format !== "png") {
4300
+ throw new HTTPException(400, { message: "invalid browser screenshot format" });
4301
+ }
4302
+ const quality = query.get("quality");
4303
+ const parsedQuality = quality === null ? null : optionalBoundedInteger(quality, 1, 100);
4304
+ if (quality !== null && parsedQuality === null) {
4305
+ throw new HTTPException(400, { message: "invalid browser screenshot quality" });
4306
+ }
4307
+ return {
4308
+ ...(fullPage === null ? {} : { fullPage: fullPage === "true" }),
4309
+ ...(format === null ? {} : { format }),
4310
+ ...(parsedQuality === null ? {} : { quality: parsedQuality }),
4311
+ };
4312
+ }
4313
+
4314
+ export function browserScreenshotResponse(
4315
+ frame: Pick<BrowserControlFrame, "data" | "mediaType" | "metadataHeader">,
4316
+ ): Response {
4317
+ // Node Buffers can be views into a larger backing allocation. Copy exactly
4318
+ // the image bytes before constructing the Response.
4319
+ return new Response(Uint8Array.from(frame.data).buffer, {
4320
+ status: 200,
4321
+ headers: {
4322
+ "cache-control": "no-store",
4323
+ "content-type": frame.mediaType,
4324
+ "x-opengeni-browser-frame": frame.metadataHeader,
4325
+ ...USER_CONTENT_SECURITY_HEADERS,
4326
+ },
4327
+ });
4328
+ }
4329
+
4163
4330
  function isUuid(value: unknown): value is string {
4164
4331
  return (
4165
4332
  typeof value === "string" &&
@@ -4716,6 +4883,8 @@ function browserRouteError(error: unknown): HTTPException {
4716
4883
  const connectedMachineError = interactionControlApiError(error, "browser");
4717
4884
  if (connectedMachineError) return connectedMachineError;
4718
4885
  if (error instanceof HTTPException) return error;
4886
+ if (error instanceof SandboxPaidComputeAdmissionError)
4887
+ return new HTTPException(402, { message: error.message, cause: error });
4719
4888
  if (error instanceof BrowserSessionNotFoundError) {
4720
4889
  return new HTTPException(404, { message: error.message, cause: error });
4721
4890
  }
@@ -0,0 +1,182 @@
1
+ // Minimal first-party web-client error beacon.
2
+ //
3
+ // The browser reports only a closed error kind, the matched route PATTERN
4
+ // (never a concrete URL), and its bundle revision. No message, stack, URL,
5
+ // identifier, user content, or credential is accepted, and the route admits
6
+ // anonymous callers so a failure before sign-in is still counted. Each
7
+ // accepted report increments one closed-label counter and writes one bounded
8
+ // structured log line. This is an operational lower bound, not exception
9
+ // capture: blocked requests, closed tabs, and the per-process admission bound
10
+ // all drop reports. Because the route is anonymous, any HTTP client that omits
11
+ // or forges `Origin` can still send reports up to the admission ceiling, so
12
+ // alert on rates and ratios, not on absolute counts.
13
+ import type { Settings } from "@opengeni/config";
14
+ import {
15
+ CLIENT_ERROR_KINDS,
16
+ CLIENT_ERROR_REPORT_MAX_BYTES,
17
+ CLIENT_ERROR_REVISION_PATTERN,
18
+ CLIENT_ERROR_ROUTE_PATTERN,
19
+ CLIENT_ERRORS_PATH,
20
+ type ClientErrorKind,
21
+ } from "@opengeni/contracts/client-error-report";
22
+ import type { Observability } from "@opengeni/observability";
23
+ import type { Context, Hono } from "hono";
24
+ import { bodyLimit } from "hono/body-limit";
25
+ import { z } from "zod";
26
+
27
+ import { isAllowedBrowserOrigin } from "../http/cors";
28
+
29
+ export {
30
+ CLIENT_ERROR_KINDS,
31
+ CLIENT_ERROR_REPORT_MAX_BYTES,
32
+ CLIENT_ERRORS_PATH,
33
+ type ClientErrorKind,
34
+ };
35
+
36
+ const CLIENT_ERROR_REJECTION_REASONS = ["invalid", "too_large", "origin", "rate_limited"] as const;
37
+ type ClientErrorRejectionReason = (typeof CLIENT_ERROR_REJECTION_REASONS)[number];
38
+
39
+ export const ClientErrorReport = z
40
+ .object({
41
+ kind: z.enum(CLIENT_ERROR_KINDS),
42
+ route: z.string().regex(CLIENT_ERROR_ROUTE_PATTERN),
43
+ revision: z.string().regex(CLIENT_ERROR_REVISION_PATTERN),
44
+ })
45
+ .strict();
46
+ export type ClientErrorReport = z.infer<typeof ClientErrorReport>;
47
+
48
+ const CLIENT_ERRORS_METRIC = {
49
+ name: "opengeni_client_errors_total",
50
+ help: "Web client errors reported by browsers, by closed kind. Admission is bounded per API process, so this is a lower bound.",
51
+ } as const;
52
+ const CLIENT_ERROR_REJECTIONS_METRIC = {
53
+ name: "opengeni_client_error_reports_rejected_total",
54
+ help: 'Web client error reports the API refused, by closed reason and kind ("unknown" when the body was not read or not valid).',
55
+ } as const;
56
+
57
+ export type ClientErrorAdmission = { admit(kind: ClientErrorKind): boolean };
58
+
59
+ /**
60
+ * One token bucket per closed kind bounds counter inflation and log volume
61
+ * from any caller, including an anonymous one, without per-client state.
62
+ */
63
+ export function createClientErrorAdmission(
64
+ options: { capacity?: number; refillPerSecond?: number; now?: () => number } = {},
65
+ ): ClientErrorAdmission {
66
+ const capacity = options.capacity ?? 30;
67
+ const refillPerMs = (options.refillPerSecond ?? 0.5) / 1_000;
68
+ const now = options.now ?? Date.now;
69
+ const buckets = new Map<ClientErrorKind, { tokens: number; updatedAt: number }>();
70
+ return {
71
+ admit(kind) {
72
+ const at = now();
73
+ const bucket = buckets.get(kind) ?? { tokens: capacity, updatedAt: at };
74
+ bucket.tokens = Math.min(
75
+ capacity,
76
+ bucket.tokens + Math.max(0, at - bucket.updatedAt) * refillPerMs,
77
+ );
78
+ bucket.updatedAt = at;
79
+ buckets.set(kind, bucket);
80
+ if (bucket.tokens < 1) return false;
81
+ bucket.tokens -= 1;
82
+ return true;
83
+ },
84
+ };
85
+ }
86
+
87
+ export function parseClientErrorReport(body: string | null): ClientErrorReport | null {
88
+ if (body === null || new TextEncoder().encode(body).byteLength > CLIENT_ERROR_REPORT_MAX_BYTES) {
89
+ return null;
90
+ }
91
+ let value: unknown;
92
+ try {
93
+ value = JSON.parse(body);
94
+ } catch {
95
+ return null;
96
+ }
97
+ const parsed = ClientErrorReport.safeParse(value);
98
+ return parsed.success ? parsed.data : null;
99
+ }
100
+
101
+ /**
102
+ * The exact beacon request. The app-wide request-body limit skips it because
103
+ * the route enforces its own 512-byte limit on the streamed body; the generic
104
+ * ceiling would otherwise buffer a chunked body of many megabytes on an
105
+ * anonymous route before this one could refuse it.
106
+ */
107
+ export function isClientErrorReportRequest(method: string, pathname: string): boolean {
108
+ return method === "POST" && pathname === CLIENT_ERRORS_PATH;
109
+ }
110
+
111
+ export function registerClientErrorRoutes(
112
+ app: Hono,
113
+ deps: {
114
+ observability: Observability;
115
+ settings: Pick<Settings, "corsAllowOriginRegex" | "publicBaseUrl" | "webBaseUrl">;
116
+ admission?: ClientErrorAdmission;
117
+ },
118
+ ): void {
119
+ const { observability, settings } = deps;
120
+ const admission = deps.admission ?? createClientErrorAdmission();
121
+ // Publish the finite series at zero so the first failure after a deploy is
122
+ // an increase from a baseline rather than a series appearing from nothing.
123
+ for (const kind of CLIENT_ERROR_KINDS) {
124
+ observability.incrementCounter({ ...CLIENT_ERRORS_METRIC, labels: { kind }, amount: 0 });
125
+ observability.incrementCounter({
126
+ ...CLIENT_ERROR_REJECTIONS_METRIC,
127
+ labels: { reason: "rate_limited", kind },
128
+ amount: 0,
129
+ });
130
+ }
131
+ for (const reason of CLIENT_ERROR_REJECTION_REASONS) {
132
+ if (reason === "rate_limited") continue;
133
+ observability.incrementCounter({
134
+ ...CLIENT_ERROR_REJECTIONS_METRIC,
135
+ labels: { reason, kind: "unknown" },
136
+ amount: 0,
137
+ });
138
+ }
139
+ // A rate-limited refusal keeps its kind, so accepted plus rejected is the
140
+ // true per-kind arrival rate even while a bucket is empty.
141
+ const reject = (
142
+ c: Context,
143
+ reason: ClientErrorRejectionReason,
144
+ status: 400 | 403 | 413 | 429,
145
+ kind: ClientErrorKind | "unknown" = "unknown",
146
+ ) => {
147
+ observability.incrementCounter({ ...CLIENT_ERROR_REJECTIONS_METRIC, labels: { reason, kind } });
148
+ c.header("cache-control", "no-store");
149
+ return c.body(null, status);
150
+ };
151
+
152
+ app.post(
153
+ CLIENT_ERRORS_PATH,
154
+ // Enforced on the streamed body, so a chunked request without a
155
+ // Content-Length is refused after 512 bytes rather than buffered.
156
+ bodyLimit({
157
+ maxSize: CLIENT_ERROR_REPORT_MAX_BYTES,
158
+ onError: (c) => reject(c, "too_large", 413),
159
+ }),
160
+ async (c) => {
161
+ c.header("cache-control", "no-store");
162
+ // Browsers always send Origin on this cross- or same-origin POST. A
163
+ // foreign page therefore cannot spend the admission budget or inflate
164
+ // the counter through its visitors' browsers.
165
+ const origin = c.req.header("origin");
166
+ if (origin !== undefined && !isAllowedBrowserOrigin(origin, settings)) {
167
+ return reject(c, "origin", 403);
168
+ }
169
+ const report = parseClientErrorReport(await c.req.text().catch(() => null));
170
+ if (!report) return reject(c, "invalid", 400);
171
+ if (!admission.admit(report.kind)) return reject(c, "rate_limited", 429, report.kind);
172
+ observability.incrementCounter({ ...CLIENT_ERRORS_METRIC, labels: { kind: report.kind } });
173
+ observability.warn("Web client error reported", {
174
+ surface: "web",
175
+ reason: report.kind,
176
+ clientRoute: report.route,
177
+ clientRevision: report.revision,
178
+ });
179
+ return c.body(null, 204);
180
+ },
181
+ );
182
+ }
@@ -36,6 +36,7 @@ import { z } from "zod";
36
36
 
37
37
  import { createCompanyBrainOkfPackage, serializeCompanyBrainOkf } from "../company-brain-okf";
38
38
  import { projectWorkspaceState } from "../workspace-state-projection";
39
+ import { USER_CONTENT_SECURITY_HEADERS } from "../http/user-content";
39
40
 
40
41
  const inspectorQuery = z
41
42
  .object({
@@ -235,6 +236,9 @@ export function registerCompanyBrainRoutes(app: Hono, deps: ApiRouteDeps): void
235
236
  "content-disposition",
236
237
  `attachment; filename="company-brain-${workspaceId}.okf.md"`,
237
238
  );
239
+ for (const [name, value] of Object.entries(USER_CONTENT_SECURITY_HEADERS)) {
240
+ context.header(name, value);
241
+ }
238
242
  return context.body(serializeCompanyBrainOkf(result));
239
243
  });
240
244