@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.
- package/dist/app.d.ts +7 -1
- package/dist/app.js +1 -1
- package/dist/auth/managed-auth-rate-limits.d.ts +167 -0
- package/dist/auth/managed-auth.d.ts +37 -1
- package/dist/auth/signup-funnel-metrics.d.ts +44 -0
- package/dist/{chunk-24C5LPNE.js → chunk-QDIBJSM4.js} +3277 -1509
- package/dist/{chunk-24C5LPNE.js.map → chunk-QDIBJSM4.js.map} +1 -1
- package/dist/http/cors.d.ts +12 -0
- package/dist/http/local-browser-boundary.d.ts +72 -0
- package/dist/http/metrics-listener.d.ts +22 -0
- package/dist/http/registered-route-label.d.ts +16 -0
- package/dist/http/request-source.d.ts +41 -3
- package/dist/http/user-content.d.ts +36 -0
- package/dist/http/websocket-upgrade-dispatch.d.ts +18 -0
- package/dist/index.js +90 -31
- package/dist/index.js.map +1 -1
- package/dist/integrations/github-lens-connect.d.ts +1 -1
- package/dist/integrations/oauth-client.d.ts +30 -0
- package/dist/integrations/slack-bot.d.ts +48 -2
- package/dist/integrations/slack-interactions.d.ts +1 -1
- package/dist/mcp/documents.d.ts +2 -0
- package/dist/mcp/first-party-tool-permissions.d.ts +12 -0
- package/dist/mcp/server.d.ts +35 -0
- package/dist/model-catalog.d.ts +9 -3
- package/dist/observability.d.ts +3 -1
- package/dist/routes/billing.d.ts +27 -0
- package/dist/routes/browser-sessions.d.ts +3 -0
- package/dist/routes/client-errors.d.ts +42 -0
- package/dist/routes/computer-sessions.d.ts +2 -0
- package/dist/routes/github-browser-pages.d.ts +12 -0
- package/dist/routes/insights.d.ts +26 -0
- package/dist/sandbox/auth-callout.d.ts +8 -0
- package/dist/sandbox-file-artifacts.d.ts +17 -1
- package/package.json +19 -19
- package/src/app.ts +124 -12
- package/src/auth/managed-auth-rate-limits.ts +252 -0
- package/src/auth/managed-auth.ts +137 -17
- package/src/auth/signup-funnel-metrics.ts +164 -0
- package/src/http/auth.ts +6 -0
- package/src/http/cors.ts +37 -5
- package/src/http/local-browser-boundary.ts +343 -0
- package/src/http/metrics-listener.ts +57 -0
- package/src/http/registered-route-label.ts +34 -0
- package/src/http/request-source.ts +154 -13
- package/src/http/sse.ts +36 -16
- package/src/http/user-content.ts +158 -0
- package/src/http/websocket-upgrade-dispatch.ts +29 -0
- package/src/index.ts +22 -8
- package/src/integrations/atlassian.ts +7 -3
- package/src/integrations/fiken.ts +9 -3
- package/src/integrations/github-app-connect.ts +12 -2
- package/src/integrations/google-drive.ts +7 -3
- package/src/integrations/oauth-client.ts +99 -12
- package/src/integrations/personal-github.ts +4 -2
- package/src/integrations/provider-oauth.ts +10 -3
- package/src/integrations/slack-bot.ts +43 -1
- package/src/integrations/social-oauth.ts +7 -2
- package/src/mcp/documents.ts +9 -2
- package/src/mcp/files.ts +5 -1
- package/src/mcp/first-party-tool-permissions.ts +6 -0
- package/src/mcp/knowledge-entries.ts +15 -2
- package/src/mcp/server.ts +191 -17
- package/src/mcp-oauth.ts +4 -6
- package/src/model-catalog.ts +30 -3
- package/src/observability.ts +15 -1
- package/src/routes/billing.ts +70 -9
- package/src/routes/browser-sessions.ts +169 -0
- package/src/routes/client-errors.ts +182 -0
- package/src/routes/company-brain.ts +4 -0
- package/src/routes/computer-sessions.ts +37 -9
- package/src/routes/connections.ts +7 -1
- package/src/routes/documents.ts +6 -1
- package/src/routes/editable-artifacts.ts +2 -1
- package/src/routes/enrollments.ts +2 -13
- package/src/routes/files.ts +17 -3
- package/src/routes/github-browser-pages.ts +162 -0
- package/src/routes/github.ts +122 -49
- package/src/routes/insights.ts +70 -6
- package/src/routes/knowledge.ts +24 -1
- package/src/routes/managed-auth-session-sets.ts +26 -6
- package/src/routes/managed-onboarding.ts +23 -19
- package/src/routes/organization-memberships.ts +3 -0
- package/src/routes/scheduled-tasks.ts +9 -2
- package/src/routes/sessions.ts +89 -0
- package/src/routes/workspace-artifacts.ts +9 -0
- package/src/routes/workspace-state.ts +4 -0
- package/src/routes/workspaces.ts +38 -15
- package/src/sandbox/auth-callout.ts +62 -11
- package/src/sandbox/channel-a.ts +8 -0
- package/src/sandbox/viewer.ts +16 -3
- package/src/sandbox-file-artifacts.ts +37 -26
- package/src/site-uploads.ts +8 -1
- package/src/workspace-tool-gateway.ts +1 -0
package/src/observability.ts
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
|
-
import
|
|
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)),
|
package/src/routes/billing.ts
CHANGED
|
@@ -342,7 +342,12 @@ async function handleStripeWebhookEvent(
|
|
|
342
342
|
): Promise<void> {
|
|
343
343
|
switch (event.type) {
|
|
344
344
|
case "checkout.session.completed":
|
|
345
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|