@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
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(workspaceId, sessionId, (events) => {
452
- if (bootstrapping) {
453
- for (const event of events) {
454
- if (!newestBuffered || event.sequence > newestBuffered.sequence) {
455
- newestBuffered = event;
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
- } else {
459
- delivery?.publish(events);
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(workspaceId, (event) => {
636
- if (bootstrapping) {
637
- if (!newestBuffered || event.sequence > newestBuffered.sequence) newestBuffered = event;
638
- } else {
639
- delivery?.publish([event]);
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 { observabilityEventLogger } from "./observability";
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
- { logger: observabilityEventLogger(observability) },
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
- if (interactionFrameProxies.handles(request)) {
458
- return interactionFrameProxies.upgrade(request, bunServer);
459
- }
460
- if (artifactWebSockets.handles(request)) {
461
- return artifactWebSockets.upgrade(request, bunServer);
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("state_reused");
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 ?? "/integrations",
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 ?? `/workspaces/${input.workspaceId}/capabilities`,
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 ?? "/integrations",
1374
+ state?.returnPath ?? failure!.returnPath,
1370
1375
  "error",
1371
- error instanceof FikenOAuthCallbackError ? error.reason : "callback_failed",
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
- if (!destination)
422
- return Response.json({ error: "Connection callback is invalid or expired" }, { status: 400 });
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 HTTPException(400, { message: "Google Drive OAuth state has already been used" });
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 ?? "/integrations",
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(context.payload.returnPath ?? "/integrations");
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("/integrations", "error", {
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(state, "error", {
799
- stage: "state_verify",
800
- reason: "missing_code",
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 { redirectTo: callbackReturnPath(state?.returnPath ?? "/integrations", status, details) };
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("/integrations", "https://opengeni.local");
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
  }