@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/http/sse.ts
CHANGED
|
@@ -448,17 +448,28 @@ export async function sseSessionStream(
|
|
|
448
448
|
stopReconnectObservation = durableFanout.subscribeRecovery(scheduleReconnectReconciliation);
|
|
449
449
|
|
|
450
450
|
void (async () => {
|
|
451
|
-
const release = await bus.subscribe(
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
451
|
+
const release = await bus.subscribe(
|
|
452
|
+
workspaceId,
|
|
453
|
+
sessionId,
|
|
454
|
+
(events) => {
|
|
455
|
+
if (bootstrapping) {
|
|
456
|
+
for (const event of events) {
|
|
457
|
+
if (!newestBuffered || event.sequence > newestBuffered.sequence) {
|
|
458
|
+
newestBuffered = event;
|
|
459
|
+
}
|
|
456
460
|
}
|
|
461
|
+
} else {
|
|
462
|
+
delivery?.publish(events);
|
|
457
463
|
}
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
// Live fanout stopped while this stream still held it. Heartbeats alone
|
|
467
|
+
// would keep a silently stale timeline open; fail retryably so the
|
|
468
|
+
// client reconnects and replays from the durable Postgres cursor.
|
|
469
|
+
onTerminated: (error) =>
|
|
470
|
+
fail(retryableSseFailure("session live fanout subscription ended", error)),
|
|
471
|
+
},
|
|
472
|
+
);
|
|
462
473
|
if (channel.stopped()) {
|
|
463
474
|
release();
|
|
464
475
|
return;
|
|
@@ -632,13 +643,22 @@ export async function sseWorkspaceControlStream(
|
|
|
632
643
|
delivery = createLatestWinsDelivery(send, fail);
|
|
633
644
|
|
|
634
645
|
void (async () => {
|
|
635
|
-
const release = await bus.subscribeWorkspaceControl(
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
646
|
+
const release = await bus.subscribeWorkspaceControl(
|
|
647
|
+
workspaceId,
|
|
648
|
+
(event) => {
|
|
649
|
+
if (bootstrapping) {
|
|
650
|
+
if (!newestBuffered || event.sequence > newestBuffered.sequence) newestBuffered = event;
|
|
651
|
+
} else {
|
|
652
|
+
delivery?.publish([event]);
|
|
653
|
+
}
|
|
654
|
+
},
|
|
655
|
+
{
|
|
656
|
+
// Same contract as session SSE: a dead live subscription must reconnect
|
|
657
|
+
// and replay the durable control cursor, not idle on heartbeats.
|
|
658
|
+
onTerminated: (error) =>
|
|
659
|
+
fail(retryableSseFailure("workspace control live subscription ended", error)),
|
|
660
|
+
},
|
|
661
|
+
);
|
|
642
662
|
if (channel.stopped()) {
|
|
643
663
|
release();
|
|
644
664
|
return;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response headers for user-controlled bytes served from the API origin.
|
|
3
|
+
*
|
|
4
|
+
* Retained files, screenshots, exports, and live frames carry bytes and, for
|
|
5
|
+
* uploads, a content type chosen by a user or agent. The API shares its origin
|
|
6
|
+
* with the web console in the default deployment, so a stored `text/html` file
|
|
7
|
+
* opened by top-level navigation would otherwise run as that origin.
|
|
8
|
+
*
|
|
9
|
+
* Every such response therefore:
|
|
10
|
+
* - is a sandboxed document with no script, form, popup, plugin, or network
|
|
11
|
+
* capability (`sandbox` plus `default-src 'none'`). Raster images and text
|
|
12
|
+
* still render when a URL is opened directly;
|
|
13
|
+
* - cannot be embedded by another site as a no-cors subresource (CORP);
|
|
14
|
+
* - is never re-sniffed into a different type (`nosniff`).
|
|
15
|
+
*
|
|
16
|
+
* Audio and video keep their real origin (`sandbox allow-same-origin`, still
|
|
17
|
+
* without `allow-scripts`): the browser's synthesized media document re-fetches
|
|
18
|
+
* its own URL, and from an opaque origin that same-origin-only request would be
|
|
19
|
+
* refused. With scripts disabled and `nosniff`, such a response can only ever
|
|
20
|
+
* be a media player, never attacker markup.
|
|
21
|
+
*
|
|
22
|
+
* Markup types that a browser would render as an active document (HTML, XML,
|
|
23
|
+
* SVG, multipart) are additionally served as attachments, so a direct
|
|
24
|
+
* navigation downloads the bytes instead of showing attacker-authored markup on
|
|
25
|
+
* the app origin. The console never navigates to these routes: it reads bytes
|
|
26
|
+
* through the SDK and renders them in app-owned elements, so neither header
|
|
27
|
+
* changes an in-app preview. Signed object-storage GET URLs for active markup
|
|
28
|
+
* carry the same attachment as a signed response override
|
|
29
|
+
* (`userContentSignedGetUrlOptions`), because the storage endpoint is not
|
|
30
|
+
* always a separate site.
|
|
31
|
+
*/
|
|
32
|
+
const USER_CONTENT_FETCH_DIRECTIVES =
|
|
33
|
+
"default-src 'none'; img-src 'self' data: blob:; media-src 'self' data: blob:; style-src 'unsafe-inline'";
|
|
34
|
+
|
|
35
|
+
export const USER_CONTENT_SECURITY_POLICY = `${USER_CONTENT_FETCH_DIRECTIVES}; sandbox`;
|
|
36
|
+
|
|
37
|
+
/** Media players only; scripts stay disabled (see the module comment). */
|
|
38
|
+
export const USER_MEDIA_CONTENT_SECURITY_POLICY = `${USER_CONTENT_FETCH_DIRECTIVES}; sandbox allow-same-origin`;
|
|
39
|
+
|
|
40
|
+
export const USER_CONTENT_SECURITY_HEADERS = Object.freeze({
|
|
41
|
+
"Content-Security-Policy": USER_CONTENT_SECURITY_POLICY,
|
|
42
|
+
"Cross-Origin-Resource-Policy": "same-origin",
|
|
43
|
+
"X-Content-Type-Options": "nosniff",
|
|
44
|
+
} as const);
|
|
45
|
+
|
|
46
|
+
const ACTIVE_DOCUMENT_TYPES = new Set(["text/html", "text/xml", "text/xsl", "application/xml"]);
|
|
47
|
+
|
|
48
|
+
/** Media type essence (no parameters), lowercased. */
|
|
49
|
+
function mediaTypeEssence(contentType: string): string {
|
|
50
|
+
return (contentType.split(";", 1)[0] ?? "").trim().toLowerCase();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* True when a browser would render this type as an active document (HTML,
|
|
55
|
+
* XHTML, SVG, any XML dialect, or multipart replacement streams). Anything that
|
|
56
|
+
* is not a clean `type/subtype` token is treated as active too.
|
|
57
|
+
*/
|
|
58
|
+
export function isActiveUserContentType(contentType: string): boolean {
|
|
59
|
+
const essence = mediaTypeEssence(contentType);
|
|
60
|
+
if (!/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/.test(essence)) return true;
|
|
61
|
+
return (
|
|
62
|
+
ACTIVE_DOCUMENT_TYPES.has(essence) ||
|
|
63
|
+
essence.endsWith("+xml") ||
|
|
64
|
+
essence.startsWith("multipart/")
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const DISPOSITION_FILENAME_MAX_CHARS = 200;
|
|
69
|
+
|
|
70
|
+
/** RFC 8187 `attr-char` percent-encoding (stricter than `encodeURIComponent`). */
|
|
71
|
+
function encodeExtendedParameterValue(value: string): string {
|
|
72
|
+
return encodeURIComponent(value).replace(
|
|
73
|
+
/['()*]/g,
|
|
74
|
+
(character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* RFC 6266 filename parameters: an ASCII-only quoted `filename` that cannot
|
|
80
|
+
* break out of the header, plus an RFC 8187 `filename*` carrying the original
|
|
81
|
+
* name (for example non-Latin names) whenever the ASCII form had to change it.
|
|
82
|
+
*/
|
|
83
|
+
function dispositionFilenameParameters(filename: string): string {
|
|
84
|
+
const cleaned = Array.from(
|
|
85
|
+
filename
|
|
86
|
+
.replace(/[\u0000-\u001f\u007f/\\]+/g, "_")
|
|
87
|
+
// Lone surrogates cannot be percent-encoded as UTF-8.
|
|
88
|
+
.replace(/[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/g, "_")
|
|
89
|
+
.trim(),
|
|
90
|
+
)
|
|
91
|
+
.slice(0, DISPOSITION_FILENAME_MAX_CHARS)
|
|
92
|
+
.join("");
|
|
93
|
+
const ascii =
|
|
94
|
+
cleaned
|
|
95
|
+
.replace(/[^A-Za-z0-9._ -]+/g, "_")
|
|
96
|
+
.replace(/\s+/g, " ")
|
|
97
|
+
.trim()
|
|
98
|
+
.slice(0, DISPOSITION_FILENAME_MAX_CHARS) || "download";
|
|
99
|
+
if (!cleaned || ascii === cleaned) return `filename="${ascii}"`;
|
|
100
|
+
return `filename="${ascii}"; filename*=UTF-8''${encodeExtendedParameterValue(cleaned)}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** `Content-Disposition: attachment` for active markup; inline types are unchanged. */
|
|
104
|
+
export function userContentDispositionHeaders(
|
|
105
|
+
contentType: string,
|
|
106
|
+
filename?: string | null,
|
|
107
|
+
): { "Content-Disposition": string } | Record<string, never> {
|
|
108
|
+
if (!isActiveUserContentType(contentType)) return {};
|
|
109
|
+
return {
|
|
110
|
+
"Content-Disposition": filename
|
|
111
|
+
? `attachment; ${dispositionFilenameParameters(filename)}`
|
|
112
|
+
: "attachment",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Signed object-storage GET options for one stored object: active markup gets
|
|
118
|
+
* a signed `attachment` response override, so a browser that opens the URL
|
|
119
|
+
* downloads the bytes instead of rendering them with their stored type. The
|
|
120
|
+
* storage endpoint is not always a separate site (local development serves it
|
|
121
|
+
* from loopback, and a preview may route the bucket path on the app origin),
|
|
122
|
+
* so a signed URL follows the same rule as the API routes. Spread into
|
|
123
|
+
* `createGetUrl`; inline types add nothing.
|
|
124
|
+
*/
|
|
125
|
+
export function userContentSignedGetUrlOptions(
|
|
126
|
+
contentType: string,
|
|
127
|
+
filename?: string | null,
|
|
128
|
+
): { responseContentDisposition: string } | Record<string, never> {
|
|
129
|
+
const headers = userContentDispositionHeaders(contentType, filename);
|
|
130
|
+
return "Content-Disposition" in headers
|
|
131
|
+
? { responseContentDisposition: headers["Content-Disposition"] }
|
|
132
|
+
: {};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** True for audio and video types the browser plays in a synthesized media document. */
|
|
136
|
+
export function isPlayableMediaUserContentType(contentType: string): boolean {
|
|
137
|
+
const essence = mediaTypeEssence(contentType);
|
|
138
|
+
return !isActiveUserContentType(essence) && /^(audio|video)\//.test(essence);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Sandbox policy for one response; only playable media keeps its origin. */
|
|
142
|
+
export function userContentSecurityPolicy(contentType: string): string {
|
|
143
|
+
return isPlayableMediaUserContentType(contentType)
|
|
144
|
+
? USER_MEDIA_CONTENT_SECURITY_POLICY
|
|
145
|
+
: USER_CONTENT_SECURITY_POLICY;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Full header set for one user-content response with the given content type. */
|
|
149
|
+
export function userContentResponseHeaders(
|
|
150
|
+
contentType: string,
|
|
151
|
+
filename?: string | null,
|
|
152
|
+
): Record<string, string> {
|
|
153
|
+
return {
|
|
154
|
+
...USER_CONTENT_SECURITY_HEADERS,
|
|
155
|
+
"Content-Security-Policy": userContentSecurityPolicy(contentType),
|
|
156
|
+
...userContentDispositionHeaders(contentType, filename),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { localBrowserBoundaryResponse, type LocalBrowserBoundary } from "./local-browser-boundary";
|
|
2
|
+
|
|
3
|
+
export type ApiWebSocketUpgradeRoute<Server> = {
|
|
4
|
+
handles(request: Request): boolean;
|
|
5
|
+
upgrade(request: Request, server: Server): Response | undefined;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type ApiWebSocketUpgradeDispatch =
|
|
9
|
+
| { handled: false }
|
|
10
|
+
| { handled: true; 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 function dispatchApiWebSocketUpgrade<Server>(
|
|
19
|
+
request: Request,
|
|
20
|
+
server: Server,
|
|
21
|
+
routes: ReadonlyArray<ApiWebSocketUpgradeRoute<Server>>,
|
|
22
|
+
localBrowserBoundary: LocalBrowserBoundary | null,
|
|
23
|
+
): ApiWebSocketUpgradeDispatch {
|
|
24
|
+
const route = routes.find((candidate) => candidate.handles(request));
|
|
25
|
+
if (!route) return { handled: false };
|
|
26
|
+
const rejection = localBrowserBoundary?.rejection(request);
|
|
27
|
+
if (rejection) return { handled: true, response: localBrowserBoundaryResponse(rejection) };
|
|
28
|
+
return { handled: true, response: route.upgrade(request, server) };
|
|
29
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
} from "@temporalio/client";
|
|
44
44
|
import type { ScheduleOptions, ScheduleSpec, ScheduleUpdateOptions } from "@temporalio/client";
|
|
45
45
|
import { createAppComposition, type DocumentIndexClient, type SessionWorkflowClient } from "./app";
|
|
46
|
-
import {
|
|
46
|
+
import { observabilityEventBusOptions } from "./observability";
|
|
47
47
|
import { startAuthCalloutResponder } from "./sandbox/auth-callout";
|
|
48
48
|
import { startHelloIngestion, startMetricsIngestion } from "./sandbox/metrics-ingestion";
|
|
49
49
|
import { startSlackInteractionPump } from "./integrations/slack-interactions";
|
|
@@ -57,6 +57,9 @@ import {
|
|
|
57
57
|
import type { ApiWebSocketConnection } from "./api-websocket";
|
|
58
58
|
import { InteractionFrameProxyTransport } from "./interaction-frame-proxy";
|
|
59
59
|
import { apiRequestBindingsForTransportPeer } from "./http/request-source";
|
|
60
|
+
import { startApiMetricsListener } from "./http/metrics-listener";
|
|
61
|
+
import { createLocalBrowserBoundary } from "./http/local-browser-boundary";
|
|
62
|
+
import { dispatchApiWebSocketUpgrade } from "./http/websocket-upgrade-dispatch";
|
|
60
63
|
import {
|
|
61
64
|
createStandaloneEditableArtifactApplication,
|
|
62
65
|
type StandaloneEditableArtifactApplication,
|
|
@@ -382,7 +385,7 @@ export async function startApi(
|
|
|
382
385
|
controlPlaneAuth
|
|
383
386
|
? { user: controlPlaneAuth.user, pass: controlPlaneAuth.password }
|
|
384
387
|
: undefined,
|
|
385
|
-
|
|
388
|
+
observabilityEventBusOptions(observability),
|
|
386
389
|
),
|
|
387
390
|
{
|
|
388
391
|
...retryOptions,
|
|
@@ -449,17 +452,25 @@ export async function startApi(
|
|
|
449
452
|
const interactionFrameProxies = new InteractionFrameProxyTransport(
|
|
450
453
|
resolveFirstPartyDelegationSecret(settings),
|
|
451
454
|
);
|
|
455
|
+
// WebSocket upgrades bypass the Hono app, so the local-mode browser boundary
|
|
456
|
+
// (http/local-browser-boundary.ts) is applied to them in the dispatcher;
|
|
457
|
+
// every other request meets it in the Hono middleware.
|
|
458
|
+
const localBrowserBoundary = createLocalBrowserBoundary(settings, {
|
|
459
|
+
warn: (message, attributes) => observability.warn(message, attributes),
|
|
460
|
+
});
|
|
461
|
+
const webSocketUpgrades = [interactionFrameProxies, artifactWebSockets];
|
|
452
462
|
const server = Bun.serve<ApiWebSocketConnection>({
|
|
453
463
|
hostname: settings.apiHost,
|
|
454
464
|
port: settings.apiPort,
|
|
455
465
|
idleTimeout: 255,
|
|
456
466
|
fetch: (request, bunServer) => {
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
467
|
+
const upgrade = dispatchApiWebSocketUpgrade(
|
|
468
|
+
request,
|
|
469
|
+
bunServer,
|
|
470
|
+
webSocketUpgrades,
|
|
471
|
+
localBrowserBoundary,
|
|
472
|
+
);
|
|
473
|
+
if (upgrade.handled) return upgrade.response;
|
|
463
474
|
return app.fetch(
|
|
464
475
|
request,
|
|
465
476
|
apiRequestBindingsForTransportPeer(bunServer.requestIP(request)?.address),
|
|
@@ -474,6 +485,7 @@ export async function startApi(
|
|
|
474
485
|
close: (socket) => socket.data.transportClosed(),
|
|
475
486
|
},
|
|
476
487
|
});
|
|
488
|
+
const metricsServer = startApiMetricsListener(settings, observability);
|
|
477
489
|
const stopSlackInteractionPump = settings.slackSigningSecret
|
|
478
490
|
? startSlackInteractionPump(routeDeps)
|
|
479
491
|
: undefined;
|
|
@@ -540,11 +552,13 @@ export async function startApi(
|
|
|
540
552
|
observability.info("OpenGeni API listening", {
|
|
541
553
|
host: settings.apiHost,
|
|
542
554
|
port: settings.apiPort,
|
|
555
|
+
...(metricsServer ? { metricsPort: settings.apiMetricsPort } : {}),
|
|
543
556
|
});
|
|
544
557
|
return {
|
|
545
558
|
server,
|
|
546
559
|
close: async () => {
|
|
547
560
|
server.stop(true);
|
|
561
|
+
metricsServer?.stop(true);
|
|
548
562
|
stopSlackInteractionPump?.();
|
|
549
563
|
await stopMemorySlackPublicationPump();
|
|
550
564
|
stopMetricsIngestion?.();
|
|
@@ -92,6 +92,7 @@ import {
|
|
|
92
92
|
} from "../connection-ownership";
|
|
93
93
|
import {
|
|
94
94
|
integrationBaseUrl,
|
|
95
|
+
oauthStateFailureReturn,
|
|
95
96
|
oauthStateTtlMs,
|
|
96
97
|
requireIntegrationsStateSecret,
|
|
97
98
|
} from "./oauth-client";
|
|
@@ -246,7 +247,7 @@ export async function completeAtlassianOAuthCallback(
|
|
|
246
247
|
expiresAt: new Date(state.iat * 1000 + oauthStateTtlMs),
|
|
247
248
|
now: new Date(),
|
|
248
249
|
});
|
|
249
|
-
if (!consumed) throw new AtlassianCallbackError("
|
|
250
|
+
if (!consumed) throw new AtlassianCallbackError("state_replayed");
|
|
250
251
|
if (operation && (input.error || !input.code)) {
|
|
251
252
|
await finishConnectOperation(deps.db, state, {
|
|
252
253
|
...operation,
|
|
@@ -428,12 +429,15 @@ export async function completeAtlassianOAuthCallback(
|
|
|
428
429
|
};
|
|
429
430
|
} catch (error) {
|
|
430
431
|
if (exactReturnUrl) return { redirectTo: exactReturnUrl, exactReturn: true };
|
|
432
|
+
// Reading the state is the first step, so no state means the link itself
|
|
433
|
+
// was unusable: say whether it expired rather than blame configuration.
|
|
434
|
+
const failure = state ? null : oauthStateFailureReturn(deps.settings, input.state);
|
|
431
435
|
return {
|
|
432
436
|
redirectTo: returnUrl(
|
|
433
437
|
returnBaseUrl,
|
|
434
|
-
state?.returnPath ??
|
|
438
|
+
state?.returnPath ?? failure!.returnPath,
|
|
435
439
|
"error",
|
|
436
|
-
errorReason(error),
|
|
440
|
+
failure?.reason ?? errorReason(error),
|
|
437
441
|
),
|
|
438
442
|
};
|
|
439
443
|
}
|
|
@@ -46,8 +46,10 @@ import { readResponseJsonBounded, type FetchLike } from "@opengeni/network";
|
|
|
46
46
|
import { HTTPException } from "hono/http-exception";
|
|
47
47
|
import {
|
|
48
48
|
integrationBaseUrl,
|
|
49
|
+
oauthStateFailureReturn,
|
|
49
50
|
oauthStateTtlMs,
|
|
50
51
|
requireIntegrationsStateSecret,
|
|
52
|
+
workspaceIntegrationsPath,
|
|
51
53
|
} from "./oauth-client";
|
|
52
54
|
|
|
53
55
|
const FIKEN_API_BASE = "https://api.fiken.no/api/v2";
|
|
@@ -1123,7 +1125,7 @@ export async function startFikenOAuth(
|
|
|
1123
1125
|
workspaceId: input.workspaceId,
|
|
1124
1126
|
subjectId: input.subjectId,
|
|
1125
1127
|
returnPath: safeReturnPath(
|
|
1126
|
-
input.payload.returnPath ??
|
|
1128
|
+
input.payload.returnPath ?? workspaceIntegrationsPath(input.workspaceId),
|
|
1127
1129
|
),
|
|
1128
1130
|
...(input.connectAttemptId ? { connectAttemptId: input.connectAttemptId } : {}),
|
|
1129
1131
|
...(input.externalContinuation
|
|
@@ -1363,12 +1365,16 @@ export async function completeFikenOAuthCallback(
|
|
|
1363
1365
|
};
|
|
1364
1366
|
} catch (error) {
|
|
1365
1367
|
if (exactReturnUrl) return { redirectTo: exactReturnUrl, exactReturn: true };
|
|
1368
|
+
// Reading the state is the first step, so no state means the link itself
|
|
1369
|
+
// was unusable: say whether it expired instead of suggesting a token.
|
|
1370
|
+
const failure = state ? null : oauthStateFailureReturn(deps.settings, input.state);
|
|
1366
1371
|
return {
|
|
1367
1372
|
redirectTo: fikenReturnUrl(
|
|
1368
1373
|
returnBaseUrl,
|
|
1369
|
-
state?.returnPath ??
|
|
1374
|
+
state?.returnPath ?? failure!.returnPath,
|
|
1370
1375
|
"error",
|
|
1371
|
-
|
|
1376
|
+
failure?.reason ??
|
|
1377
|
+
(error instanceof FikenOAuthCallbackError ? error.reason : "callback_failed"),
|
|
1372
1378
|
),
|
|
1373
1379
|
};
|
|
1374
1380
|
}
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
isConsistentGitHubBindingCandidates,
|
|
28
28
|
isConsistentGitHubBindingProof,
|
|
29
29
|
} from "./github-installation-proof";
|
|
30
|
+
import { githubConnectFailureHtml } from "../routes/github-browser-pages";
|
|
30
31
|
|
|
31
32
|
const stateSchema = z.object({
|
|
32
33
|
kind: z.literal("github_app_connect"),
|
|
@@ -418,8 +419,17 @@ export async function completeGitHubAppConnect(
|
|
|
418
419
|
}
|
|
419
420
|
} catch {
|
|
420
421
|
// Preserve unknown outcomes; never replay provider authorization to recover.
|
|
421
|
-
|
|
422
|
-
|
|
422
|
+
// This is browser navigation (usually the Connect popup), so explain the
|
|
423
|
+
// stale or reused link on a page instead of returning a JSON body.
|
|
424
|
+
if (!destination) {
|
|
425
|
+
const home =
|
|
426
|
+
deps.settings.webBaseUrl ??
|
|
427
|
+
integrationBaseUrl(deps.settings.publicBaseUrl, input.requestUrl);
|
|
428
|
+
return new Response(githubConnectFailureHtml("expired", `${home.replace(/\/+$/u, "")}/`), {
|
|
429
|
+
status: 400,
|
|
430
|
+
headers: { "content-type": "text/html; charset=UTF-8" },
|
|
431
|
+
});
|
|
432
|
+
}
|
|
423
433
|
}
|
|
424
434
|
return new Response(null, { status: 302, headers: { Location: destination! } });
|
|
425
435
|
}
|
|
@@ -116,6 +116,7 @@ import {
|
|
|
116
116
|
} from "../connection-ownership";
|
|
117
117
|
import {
|
|
118
118
|
integrationBaseUrl,
|
|
119
|
+
oauthStateFailureReturn,
|
|
119
120
|
oauthStateTtlMs,
|
|
120
121
|
requireIntegrationsStateSecret,
|
|
121
122
|
} from "./oauth-client";
|
|
@@ -400,7 +401,7 @@ export async function completeGoogleDriveOAuthCallback(
|
|
|
400
401
|
now: new Date(),
|
|
401
402
|
});
|
|
402
403
|
if (!consumed) {
|
|
403
|
-
throw new
|
|
404
|
+
throw new GoogleDriveCallbackError("state_replayed");
|
|
404
405
|
}
|
|
405
406
|
if (operation && (input.error || !input.code)) {
|
|
406
407
|
await finishConnectOperation(deps.db, state, {
|
|
@@ -629,12 +630,15 @@ export async function completeGoogleDriveOAuthCallback(
|
|
|
629
630
|
};
|
|
630
631
|
} catch (error) {
|
|
631
632
|
if (exactReturnUrl) return { redirectTo: exactReturnUrl, exactReturn: true };
|
|
633
|
+
// Reading the state is the first step, so no state means the link itself
|
|
634
|
+
// was unusable: say whether it expired rather than blame configuration.
|
|
635
|
+
const failure = state ? null : oauthStateFailureReturn(deps.settings, input.state);
|
|
632
636
|
return {
|
|
633
637
|
redirectTo: googleDriveReturnUrl(
|
|
634
638
|
returnBaseUrl,
|
|
635
|
-
state?.returnPath ??
|
|
639
|
+
state?.returnPath ?? failure!.returnPath,
|
|
636
640
|
"error",
|
|
637
|
-
googleDriveErrorReason(error),
|
|
641
|
+
failure?.reason ?? googleDriveErrorReason(error),
|
|
638
642
|
),
|
|
639
643
|
};
|
|
640
644
|
}
|
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
withDatabaseStatementTimeout,
|
|
39
39
|
type Database,
|
|
40
40
|
} from "@opengeni/db";
|
|
41
|
-
import { createSignedState, readSignedState } from "@opengeni/github";
|
|
41
|
+
import { createSignedState, inspectSignedState, readSignedState } from "@opengeni/github";
|
|
42
42
|
import {
|
|
43
43
|
DestinationPolicyError,
|
|
44
44
|
McpOAuthDiscoveryError,
|
|
@@ -86,6 +86,64 @@ import {
|
|
|
86
86
|
import { canonicalProviderDomain } from "./provider-domain";
|
|
87
87
|
|
|
88
88
|
export const oauthStateTtlMs = 10 * 60 * 1000;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Workspace-free landing for a callback whose state names no trustworthy
|
|
92
|
+
* workspace. The web app resolves it to the viewer's current workspace
|
|
93
|
+
* integrations page and keeps the callback outcome parameters.
|
|
94
|
+
*/
|
|
95
|
+
export const INTEGRATIONS_FALLBACK_PATH = "/integrations";
|
|
96
|
+
|
|
97
|
+
const WORKSPACE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
|
|
98
|
+
|
|
99
|
+
/** The web integrations page for one workspace (the Plugins surface). */
|
|
100
|
+
export function workspaceIntegrationsPath(workspaceId: string): string {
|
|
101
|
+
return `/workspaces/${encodeURIComponent(workspaceId)}/plugins`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The integrations page for a workspace id read from untrusted input (a URL
|
|
106
|
+
* parameter or an unverified-age state), or null unless it is a workspace UUID.
|
|
107
|
+
* A link target only: it grants nothing.
|
|
108
|
+
*/
|
|
109
|
+
export function workspaceIntegrationsPathForUntrusted(candidate: unknown): string | null {
|
|
110
|
+
return typeof candidate === "string" && WORKSPACE_ID_PATTERN.test(candidate)
|
|
111
|
+
? workspaceIntegrationsPath(candidate)
|
|
112
|
+
: null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Where a callback returns, and why, when its own signed state is unusable.
|
|
117
|
+
*
|
|
118
|
+
* A correctly signed state still names its workspace, even when it is too old
|
|
119
|
+
* or its flow rejected it, so the browser returns to that workspace's
|
|
120
|
+
* integrations page; an aged one is reported as expired. Anything unsigned,
|
|
121
|
+
* tampered, or signed with another secret returns to
|
|
122
|
+
* {@link INTEGRATIONS_FALLBACK_PATH}. Display routing only: nothing here
|
|
123
|
+
* authorizes, resumes, or replays the callback.
|
|
124
|
+
*/
|
|
125
|
+
export function oauthStateFailureReturn(
|
|
126
|
+
settings: Settings,
|
|
127
|
+
rawState: string | undefined,
|
|
128
|
+
nowMs = Date.now(),
|
|
129
|
+
): { returnPath: string; reason: "state_expired" | "state_invalid" } {
|
|
130
|
+
let payload: ReturnType<typeof inspectSignedState> = null;
|
|
131
|
+
try {
|
|
132
|
+
payload = rawState
|
|
133
|
+
? inspectSignedState(rawState, requireIntegrationsStateSecret(settings))
|
|
134
|
+
: null;
|
|
135
|
+
} catch {
|
|
136
|
+
payload = null;
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
returnPath:
|
|
140
|
+
workspaceIntegrationsPathForUntrusted(payload?.workspaceId) ?? INTEGRATIONS_FALLBACK_PATH,
|
|
141
|
+
reason:
|
|
142
|
+
payload !== null && nowMs - payload.iat * 1000 > oauthStateTtlMs
|
|
143
|
+
? "state_expired"
|
|
144
|
+
: "state_invalid",
|
|
145
|
+
};
|
|
146
|
+
}
|
|
89
147
|
export {
|
|
90
148
|
OFFICIAL_GMAIL_MCP_SCOPES,
|
|
91
149
|
OFFICIAL_GMAIL_MCP_URL,
|
|
@@ -469,7 +527,9 @@ async function startMcpOAuthWithinDeadline(
|
|
|
469
527
|
// Catalog defaults are setup preferences; an explicit ownership always wins.
|
|
470
528
|
const requestedOwnership: ConnectionOwnership =
|
|
471
529
|
context.payload.ownership ?? defaultOwnershipFor(profile);
|
|
472
|
-
const returnPath = safeReturnPath(
|
|
530
|
+
const returnPath = safeReturnPath(
|
|
531
|
+
context.payload.returnPath ?? workspaceIntegrationsPath(context.workspaceId),
|
|
532
|
+
);
|
|
473
533
|
const baseUrl = integrationBaseUrl(settings.publicBaseUrl, context.requestUrl);
|
|
474
534
|
const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
|
|
475
535
|
const metadataUrl = `${baseUrl}/v1/integrations/oauth/client-metadata.json`;
|
|
@@ -686,6 +746,8 @@ export async function completeMcpOAuthCallback(
|
|
|
686
746
|
input: {
|
|
687
747
|
code?: string | undefined;
|
|
688
748
|
state?: string | undefined;
|
|
749
|
+
/** The provider's `error` parameter, for example `access_denied` on Cancel. */
|
|
750
|
+
error?: string | undefined;
|
|
689
751
|
requestUrl: string;
|
|
690
752
|
},
|
|
691
753
|
): Promise<OAuthCallbackResult> {
|
|
@@ -704,6 +766,7 @@ async function completeMcpOAuthCallbackWithinDeadline(
|
|
|
704
766
|
input: {
|
|
705
767
|
code?: string | undefined;
|
|
706
768
|
state?: string | undefined;
|
|
769
|
+
error?: string | undefined;
|
|
707
770
|
requestUrl: string;
|
|
708
771
|
},
|
|
709
772
|
deadline: OAuthCallbackDeadline,
|
|
@@ -720,7 +783,7 @@ async function completeMcpOAuthCallbackWithinDeadline(
|
|
|
720
783
|
);
|
|
721
784
|
logOAuthCallbackFailure(observability, error, state);
|
|
722
785
|
return {
|
|
723
|
-
redirectTo: callbackReturnPath(
|
|
786
|
+
redirectTo: callbackReturnPath(INTEGRATIONS_FALLBACK_PATH, "error", {
|
|
724
787
|
stage: error.stage,
|
|
725
788
|
reason: error.reason,
|
|
726
789
|
}),
|
|
@@ -776,7 +839,11 @@ async function completeMcpOAuthCallbackWithinDeadline(
|
|
|
776
839
|
new HTTPException(422, { message: PERSONAL_CONNECTION_PRINCIPAL_MESSAGE }),
|
|
777
840
|
);
|
|
778
841
|
}
|
|
779
|
-
if (!input.code) {
|
|
842
|
+
if (input.error || !input.code) {
|
|
843
|
+
// The provider sends `error` (for example `access_denied` when the user
|
|
844
|
+
// clicks Cancel) instead of a code. Report that as a refusal, not as an
|
|
845
|
+
// expired attempt, and never echo the provider's own text.
|
|
846
|
+
const cancelled = input.error === "access_denied";
|
|
780
847
|
if (connectOperation) {
|
|
781
848
|
await finishConnectOperation(db, state, {
|
|
782
849
|
...connectOperation,
|
|
@@ -785,20 +852,23 @@ async function completeMcpOAuthCallbackWithinDeadline(
|
|
|
785
852
|
commit: async (_tx, current) => ({
|
|
786
853
|
...current,
|
|
787
854
|
revision: current.revision + 1,
|
|
788
|
-
state: "failed",
|
|
855
|
+
state: cancelled ? "cancelled" : "failed",
|
|
789
856
|
nextAction: { type: "none" },
|
|
790
857
|
error: {
|
|
791
|
-
code: "missing_code",
|
|
858
|
+
code: input.error ? "provider_denied" : "missing_code",
|
|
792
859
|
message: "Authorization was not completed. Start a new connection attempt.",
|
|
793
860
|
retryable: false,
|
|
794
861
|
},
|
|
795
862
|
}),
|
|
796
863
|
});
|
|
797
864
|
}
|
|
798
|
-
return callbackStateResult(
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
865
|
+
return callbackStateResult(
|
|
866
|
+
state,
|
|
867
|
+
"error",
|
|
868
|
+
input.error
|
|
869
|
+
? { stage: "authorize", reason: cancelled ? "access_denied" : "provider_error" }
|
|
870
|
+
: { stage: "state_verify", reason: "missing_code" },
|
|
871
|
+
);
|
|
802
872
|
}
|
|
803
873
|
await withOrganizationIntegrationAcquisition(db, state, [integrationKey], async () => {});
|
|
804
874
|
const consumed = await runCallbackDatabaseStage(
|
|
@@ -828,6 +898,17 @@ async function completeMcpOAuthCallbackWithinDeadline(
|
|
|
828
898
|
? error
|
|
829
899
|
: new OAuthCallbackStageError("state_verify", "state_invalid", error);
|
|
830
900
|
logOAuthCallbackFailure(observability, staged, state);
|
|
901
|
+
if (!state) {
|
|
902
|
+
// The state itself could not be resolved, so it carries no return path.
|
|
903
|
+
// An authentic but aged state still names its workspace and "expired".
|
|
904
|
+
const failure = oauthStateFailureReturn(settings, input.state);
|
|
905
|
+
return {
|
|
906
|
+
redirectTo: callbackReturnPath(failure.returnPath, "error", {
|
|
907
|
+
stage: staged.stage,
|
|
908
|
+
reason: staged.reason === "state_invalid" ? failure.reason : staged.reason,
|
|
909
|
+
}),
|
|
910
|
+
};
|
|
911
|
+
}
|
|
831
912
|
return callbackStateResult(state, "error", {
|
|
832
913
|
stage: staged.stage,
|
|
833
914
|
reason: staged.reason,
|
|
@@ -1009,7 +1090,13 @@ function callbackStateResult(
|
|
|
1009
1090
|
): OAuthCallbackResult {
|
|
1010
1091
|
if ((state?.externalContinuation || state?.connectAttemptId) && state.returnUrl)
|
|
1011
1092
|
return { redirectTo: state.returnUrl, exactReturn: true };
|
|
1012
|
-
return {
|
|
1093
|
+
return {
|
|
1094
|
+
redirectTo: callbackReturnPath(
|
|
1095
|
+
state?.returnPath ?? INTEGRATIONS_FALLBACK_PATH,
|
|
1096
|
+
status,
|
|
1097
|
+
details,
|
|
1098
|
+
),
|
|
1099
|
+
};
|
|
1013
1100
|
}
|
|
1014
1101
|
|
|
1015
1102
|
export function integrationBaseUrl(publicBaseUrl: string | undefined, requestUrl: string): string {
|
|
@@ -2432,7 +2519,7 @@ function callbackReturnPath(
|
|
|
2432
2519
|
// Defense in depth: a `//host` pathname becomes a protocol-relative absolute
|
|
2433
2520
|
// Location — an open redirect from the unauthenticated callback.
|
|
2434
2521
|
if (url.pathname.startsWith("//")) {
|
|
2435
|
-
const fallback = new URL(
|
|
2522
|
+
const fallback = new URL(INTEGRATIONS_FALLBACK_PATH, "https://opengeni.local");
|
|
2436
2523
|
fallback.search = url.search;
|
|
2437
2524
|
return `${fallback.pathname}${fallback.search}`;
|
|
2438
2525
|
}
|