@opengeni/api-router 2.11.3 → 2.12.3-canary.0

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 (66) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/{chunk-5X3XM6CE.js → chunk-4AQMKVQM.js} +17727 -14427
  3. package/dist/chunk-4AQMKVQM.js.map +1 -0
  4. package/dist/connection-ownership.d.ts +5 -1
  5. package/dist/index.js +3 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/integrations/atlassian.d.ts +4 -0
  8. package/dist/integrations/connect-authority.d.ts +1 -0
  9. package/dist/integrations/connect-callback-return.d.ts +6 -0
  10. package/dist/integrations/fiken.d.ts +7 -0
  11. package/dist/integrations/github-app-connect.d.ts +47 -0
  12. package/dist/integrations/github-installation-proof.d.ts +3 -0
  13. package/dist/integrations/github-lens-connect.d.ts +153 -0
  14. package/dist/integrations/google-drive.d.ts +4 -0
  15. package/dist/integrations/oauth-client.d.ts +4 -0
  16. package/dist/integrations/oauth-return-path.d.ts +1 -0
  17. package/dist/integrations/personal-github.d.ts +2 -0
  18. package/dist/integrations/provider-oauth.d.ts +11 -1
  19. package/dist/integrations/slack-install.d.ts +20 -0
  20. package/dist/integrations/social-oauth.d.ts +2 -0
  21. package/dist/mcp/scheduled-task-view.d.ts +3 -3
  22. package/dist/routes/api-integrations.d.ts +14 -0
  23. package/dist/routes/connect.d.ts +5 -0
  24. package/dist/routes/external-identity-links.d.ts +6 -0
  25. package/dist/routes/host-mcp-bindings.d.ts +5 -0
  26. package/dist/sandbox/rematerialize.d.ts +1 -1
  27. package/dist/workspace-tool-gateway.d.ts +1 -0
  28. package/package.json +19 -19
  29. package/src/app.ts +22 -2
  30. package/src/connection-ownership.ts +14 -1
  31. package/src/index.ts +2 -0
  32. package/src/integrations/atlassian.ts +143 -39
  33. package/src/integrations/connect-authority.ts +2 -0
  34. package/src/integrations/connect-callback-return.ts +86 -0
  35. package/src/integrations/fiken.ts +222 -40
  36. package/src/integrations/github-app-connect.ts +389 -0
  37. package/src/integrations/github-installation-proof.ts +60 -0
  38. package/src/integrations/github-lens-connect.ts +97 -0
  39. package/src/integrations/google-drive.ts +152 -47
  40. package/src/integrations/oauth-client.ts +180 -84
  41. package/src/integrations/oauth-return-path.ts +18 -0
  42. package/src/integrations/personal-github.ts +146 -52
  43. package/src/integrations/provider-oauth.ts +132 -21
  44. package/src/integrations/slack-install.ts +83 -0
  45. package/src/integrations/social-oauth.ts +142 -1
  46. package/src/mcp/documents.ts +1 -1
  47. package/src/mcp/server.ts +64 -17
  48. package/src/routes/api-integrations.ts +74 -64
  49. package/src/routes/connect.ts +1869 -0
  50. package/src/routes/connections.ts +263 -221
  51. package/src/routes/documents.ts +2 -2
  52. package/src/routes/external-identity-links.ts +200 -0
  53. package/src/routes/github.ts +39 -64
  54. package/src/routes/host-mcp-bindings.ts +122 -0
  55. package/src/routes/organization-memberships.ts +23 -0
  56. package/src/routes/organization-sessions.ts +2 -2
  57. package/src/routes/personal-github.ts +8 -1
  58. package/src/routes/pr-review-github.ts +30 -3
  59. package/src/routes/scheduled-tasks.ts +29 -4
  60. package/src/routes/sessions.ts +61 -32
  61. package/src/routes/social.ts +5 -1
  62. package/src/routes/supergrok.ts +90 -3
  63. package/src/routes/workspaces.ts +16 -0
  64. package/src/sandbox/rematerialize.ts +33 -1
  65. package/src/workspace-tool-gateway.ts +136 -24
  66. package/dist/chunk-5X3XM6CE.js.map +0 -1
@@ -1,3 +1,4 @@
1
+ import { safeReturnPath } from "./oauth-return-path";
1
2
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
3
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3
4
  import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
@@ -8,22 +9,23 @@ import {
8
9
  type ConnectionOwnership,
9
10
  type OAuthStartRequest,
10
11
  } from "@opengeni/contracts";
11
- import { hasPermission, requireEnvironmentEncryption } from "@opengeni/core";
12
+ import { requireEnvironmentEncryption } from "@opengeni/core";
13
+ import { ExternalActorContinuation } from "@opengeni/contracts/external-identities";
12
14
  import type { Observability } from "@opengeni/observability";
13
15
  import {
14
16
  consumeIntegrationOAuthStateNonce,
17
+ claimConnectOperation,
18
+ finishConnectOperation,
19
+ getConnectAttempt,
15
20
  createConnection,
16
21
  decryptEnvironmentValue,
17
22
  encryptEnvironmentValue,
18
- findActiveWorkspaceApiKeyById,
19
23
  getConnectionMetadata,
20
24
  getGlobalCatalogOAuthProfile,
21
- getWorkspaceGrant,
22
25
  listConnectionsMetadata,
23
26
  loadIntegrationOAuthClient,
24
27
  normalizeBearerScheme,
25
28
  replaceIntegrationOAuthClientIfCurrent,
26
- resolveNamedManagedPersonalWorkspaceGrant,
27
29
  storeIntegrationOAuthClient,
28
30
  updateConnection,
29
31
  withDatabaseStatementTimeout,
@@ -58,6 +60,7 @@ import {
58
60
  PERSONAL_OWNER_VERIFIED_STATE_CLAIM,
59
61
  } from "../connection-ownership";
60
62
  import { ApiHttpError } from "../http/api-error";
63
+ import { requireConnectOwnerAuthority } from "./connect-authority";
61
64
  import {
62
65
  DEFAULT_OAUTH_PROFILE,
63
66
  DEPLOYMENT_MANAGED_CLIENTS,
@@ -92,6 +95,8 @@ type OAuthClientDeps = {
92
95
  };
93
96
 
94
97
  export type OAuthStartContext = {
98
+ connectAttemptId?: string;
99
+ externalContinuation?: ExternalActorContinuation;
95
100
  accountId: string;
96
101
  workspaceId: string;
97
102
  subjectId: string;
@@ -107,6 +112,7 @@ export type OAuthStartContext = {
107
112
 
108
113
  export type OAuthCallbackResult = {
109
114
  redirectTo: string;
115
+ exactReturn?: boolean;
110
116
  };
111
117
 
112
118
  type WwwAuthenticateChallenge = McpOAuthChallenge;
@@ -123,6 +129,9 @@ type OAuthClientRegistration = {
123
129
  };
124
130
 
125
131
  type OAuthStatePayload = {
132
+ connectAttemptId?: string;
133
+ externalContinuation?: ExternalActorContinuation;
134
+ returnUrl?: string;
126
135
  accountId: string;
127
136
  workspaceId: string;
128
137
  subjectId: string;
@@ -328,6 +337,26 @@ async function startMcpOAuthWithinDeadline(
328
337
  deadline: OAuthStartDeadline,
329
338
  ): Promise<OAuthStartResponse> {
330
339
  const { db, settings } = deps;
340
+ const externalContinuation = context.externalContinuation
341
+ ? ExternalActorContinuation.parse(context.externalContinuation)
342
+ : undefined;
343
+ const returnUrl =
344
+ context.payload.returnUrl !== undefined
345
+ ? exactExternalReturnUrl(context.payload.returnUrl)
346
+ : undefined;
347
+ if (
348
+ (externalContinuation && !returnUrl) ||
349
+ (returnUrl && !externalContinuation && !context.connectAttemptId)
350
+ )
351
+ throw new HTTPException(422, {
352
+ message: "external MCP OAuth requires verified actor authority and a host returnUrl",
353
+ });
354
+ if (
355
+ externalContinuation &&
356
+ (externalContinuation.actor.accountId !== context.accountId ||
357
+ externalContinuation.actor.effectiveSubjectId !== context.subjectId)
358
+ )
359
+ throw new HTTPException(403, { message: "external OAuth actor mismatch" });
331
360
  const mcpUrl = canonicalMcpResource(context.payload.mcpUrl ?? context.payload.resource);
332
361
  const urlProfile = builtInOAuthProfileFor({ mcpUrl });
333
362
  const providerDomain =
@@ -414,6 +443,7 @@ async function startMcpOAuthWithinDeadline(
414
443
  );
415
444
  const key = requireEnvironmentEncryption(settings);
416
445
  const state = createSignedState(requireIntegrationsStateSecret(settings), {
446
+ ...(context.connectAttemptId ? { connectAttemptId: context.connectAttemptId } : {}),
417
447
  accountId: context.accountId,
418
448
  workspaceId: context.workspaceId,
419
449
  subjectId: context.subjectId,
@@ -421,6 +451,15 @@ async function startMcpOAuthWithinDeadline(
421
451
  // Signed record that a live principal was checked; the callback has no
422
452
  // principal of its own and enforces exactly this decision.
423
453
  [PERSONAL_OWNER_VERIFIED_STATE_CLAIM]: context.personalOwnershipAllowed,
454
+ ...(externalContinuation
455
+ ? {
456
+ encryptedExternalContinuation: encryptEnvironmentValue(
457
+ requireEnvironmentEncryption(settings),
458
+ JSON.stringify(externalContinuation),
459
+ ),
460
+ }
461
+ : {}),
462
+ ...(returnUrl ? { returnUrl } : {}),
424
463
  providerDomain,
425
464
  mcpUrl,
426
465
  resource,
@@ -588,6 +627,7 @@ async function completeMcpOAuthCallbackWithinDeadline(
588
627
  ): Promise<OAuthCallbackResult> {
589
628
  const { db, settings, observability } = deps;
590
629
  let state: OAuthStatePayload | null = null;
630
+ let connectOperation: { attemptId: string; operationId: string; inputDigest: string } | undefined;
591
631
  if (!input.state) {
592
632
  const error = new OAuthCallbackStageError(
593
633
  "state_verify",
@@ -604,6 +644,27 @@ async function completeMcpOAuthCallbackWithinDeadline(
604
644
  }
605
645
  try {
606
646
  state = readOAuthState(input.state, settings);
647
+ if (state.connectAttemptId) {
648
+ const stored = await getConnectAttempt(db, state, state.connectAttemptId);
649
+ if (
650
+ !["mcp-oauth", "slack-personal"].includes(stored.attempt.providerId) ||
651
+ stored.attempt.ownership !== state.ownership ||
652
+ stored.returnUrl !== state.returnUrl
653
+ )
654
+ throw new HTTPException(403, { message: "OAuth attempt mismatch" });
655
+ connectOperation = {
656
+ attemptId: state.connectAttemptId,
657
+ operationId: `oauth:${state.nonce}`,
658
+ inputDigest: createHash("sha256").update(input.state).digest("hex"),
659
+ };
660
+ const claim = await claimConnectOperation(db, state, {
661
+ ...connectOperation,
662
+ expectedRevision: stored.attempt.revision,
663
+ authorize: (tx, _attempt, origin) =>
664
+ requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
665
+ });
666
+ if (claim.status === "replayed") return { redirectTo: stored.returnUrl, exactReturn: true };
667
+ }
607
668
  // Fence state minted by an older deployment too: a rolling update must not
608
669
  // let a still-valid callback persist an ownership the target's profile no
609
670
  // longer allows (workspace-owned Gmail or hosted Slack, for example).
@@ -627,12 +688,28 @@ async function completeMcpOAuthCallbackWithinDeadline(
627
688
  );
628
689
  }
629
690
  if (!input.code) {
630
- return {
631
- redirectTo: callbackReturnPath(state.returnPath, "error", {
632
- stage: "state_verify",
633
- reason: "missing_code",
634
- }),
635
- };
691
+ if (connectOperation) {
692
+ await finishConnectOperation(db, state, {
693
+ ...connectOperation,
694
+ authorize: (tx, _attempt, origin) =>
695
+ requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
696
+ commit: async (_tx, current) => ({
697
+ ...current,
698
+ revision: current.revision + 1,
699
+ state: "failed",
700
+ nextAction: { type: "none" },
701
+ error: {
702
+ code: "missing_code",
703
+ message: "Authorization was not completed. Start a new connection attempt.",
704
+ retryable: false,
705
+ },
706
+ }),
707
+ });
708
+ }
709
+ return callbackStateResult(state, "error", {
710
+ stage: "state_verify",
711
+ reason: "missing_code",
712
+ });
636
713
  }
637
714
  const consumed = await runCallbackDatabaseStage(
638
715
  deadline,
@@ -673,12 +750,10 @@ async function completeMcpOAuthCallbackWithinDeadline(
673
750
  ? error
674
751
  : new OAuthCallbackStageError("state_verify", "state_invalid", error);
675
752
  logOAuthCallbackFailure(observability, staged, state);
676
- return {
677
- redirectTo: callbackReturnPath(state?.returnPath ?? "/integrations", "error", {
678
- stage: staged.stage,
679
- reason: staged.reason,
680
- }),
681
- };
753
+ return callbackStateResult(state, "error", {
754
+ stage: staged.stage,
755
+ reason: staged.reason,
756
+ });
682
757
  }
683
758
 
684
759
  const ownerSubjectId = state.ownership === "personal" ? state.subjectId : null;
@@ -735,7 +810,7 @@ async function completeMcpOAuthCallbackWithinDeadline(
735
810
  ...(verification.tools ? { mcpTools: verification.tools } : {}),
736
811
  };
737
812
  const credentialEncrypted = encryptEnvironmentValue(key, JSON.stringify(credential));
738
- const connection = await runCallbackDatabaseStage(deadline, "persist", db, async (scopedDb) => {
813
+ const persist = async (scopedDb: Database) => {
739
814
  await requireOAuthCallbackGrant(scopedDb, state!);
740
815
  return state!.connectionId
741
816
  ? await updateConnection(scopedDb, {
@@ -765,7 +840,41 @@ async function completeMcpOAuthCallbackWithinDeadline(
765
840
  metadata,
766
841
  createdBySubjectId: state.subjectId,
767
842
  });
768
- });
843
+ };
844
+ if (connectOperation) {
845
+ await runCallbackDatabaseStage(deadline, "persist", db, (scopedDb) =>
846
+ finishConnectOperation(scopedDb, state!, {
847
+ ...connectOperation!,
848
+ authorize: (tx, _attempt, origin) =>
849
+ requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
850
+ commit: async (tx, current) => {
851
+ const connection = await persist(tx);
852
+ if (!connection)
853
+ throw new HTTPException(409, {
854
+ message: "connection changed during OAuth reconnect",
855
+ });
856
+ return {
857
+ ...current,
858
+ revision: current.revision + 1,
859
+ state: "complete",
860
+ completionRequirement: "connection",
861
+ credentialsCommitted: true,
862
+ nextAction: { type: "none" },
863
+ account: {
864
+ id: connection.id,
865
+ version: connection.version,
866
+ providerId: current.providerId,
867
+ label: state!.providerDomain,
868
+ ownership: current.ownership,
869
+ status: "connected",
870
+ },
871
+ };
872
+ },
873
+ }),
874
+ );
875
+ return { redirectTo: state.returnUrl!, exactReturn: true };
876
+ }
877
+ const connection = await runCallbackDatabaseStage(deadline, "persist", db, persist);
769
878
  if (!connection) {
770
879
  throw new HTTPException(409, {
771
880
  message: "connection changed during OAuth reconnect; start again",
@@ -775,29 +884,49 @@ async function completeMcpOAuthCallbackWithinDeadline(
775
884
  // the enable connectionRef straight from the redirect, without a listConnections
776
885
  // round-trip that could fail (transient, or a grant lacking connections:read)
777
886
  // and leave the connection created but the capability un-enabled.
778
- return {
779
- redirectTo: callbackReturnPath(state.returnPath, "success", {
780
- connectionId: connection.id,
781
- providerDomain: connection.providerDomain,
782
- ownership: state.ownership,
783
- ...(verification.metadata.status === "failed" ? { verification: "failed" } : {}),
784
- }),
785
- };
887
+ return callbackStateResult(state, "success", {
888
+ connectionId: connection.id,
889
+ providerDomain: connection.providerDomain,
890
+ ownership: state.ownership,
891
+ ...(verification.metadata.status === "failed" ? { verification: "failed" } : {}),
892
+ });
786
893
  } catch (error) {
787
894
  const staged =
788
895
  error instanceof OAuthCallbackStageError
789
896
  ? error
790
897
  : new OAuthCallbackStageError("persist", "persist_failed", error);
791
898
  logOAuthCallbackFailure(observability, staged, state);
792
- return {
793
- redirectTo: callbackReturnPath(state.returnPath, "error", {
794
- stage: staged.stage,
795
- reason: staged.reason,
796
- }),
797
- };
899
+ return callbackStateResult(state, "error", {
900
+ stage: staged.stage,
901
+ reason: staged.reason,
902
+ });
798
903
  }
799
904
  }
800
905
 
906
+ function exactExternalReturnUrl(raw: string): string {
907
+ if (!raw || raw.length > 4096 || /[\u0000-\u0020\u007f]/.test(raw))
908
+ throw new HTTPException(422, { message: "invalid external OAuth returnUrl" });
909
+ let url: URL;
910
+ try {
911
+ url = new URL(raw);
912
+ } catch {
913
+ throw new HTTPException(422, { message: "invalid external OAuth returnUrl" });
914
+ }
915
+ if (!["https:", "http:"].includes(url.protocol) || url.username || url.password)
916
+ throw new HTTPException(422, { message: "invalid external OAuth returnUrl" });
917
+ return raw;
918
+ }
919
+
920
+ function callbackStateResult(
921
+ state: OAuthStatePayload | null,
922
+ status: Parameters<typeof callbackReturnPath>[1],
923
+ details: Parameters<typeof callbackReturnPath>[2],
924
+ ): OAuthCallbackResult {
925
+ if ((state?.externalContinuation || state?.connectAttemptId) && state.returnUrl)
926
+ return { redirectTo: state.returnUrl, exactReturn: true };
927
+ return { redirectTo: callbackReturnPath(state?.returnPath ?? "/integrations", status, details) };
928
+ }
929
+
801
930
  export function integrationBaseUrl(publicBaseUrl: string | undefined, requestUrl: string): string {
802
931
  return (publicBaseUrl ?? new URL(requestUrl).origin).replace(/\/+$/, "");
803
932
  }
@@ -813,41 +942,7 @@ export function requireIntegrationsStateSecret(settings: Settings): string {
813
942
  }
814
943
 
815
944
  async function requireOAuthCallbackGrant(db: Database, state: OAuthStatePayload): Promise<void> {
816
- const membershipGrant = await getWorkspaceGrant(db, state.subjectId, state.workspaceId);
817
- const grant =
818
- membershipGrant?.accountId === state.accountId
819
- ? membershipGrant
820
- : state.personalOwnerVerified
821
- ? await resolveNamedManagedPersonalWorkspaceGrant(db, state)
822
- : await apiKeyCallbackGrant(db, state);
823
- if (
824
- !grant ||
825
- grant.accountId !== state.accountId ||
826
- !hasPermission(grant.permissions, "connections:write")
827
- ) {
828
- throw new HTTPException(403, {
829
- message: "OAuth subject no longer has permission to write this workspace connection",
830
- });
831
- }
832
- }
833
-
834
- async function apiKeyCallbackGrant(
835
- db: Database,
836
- state: OAuthStatePayload,
837
- ): Promise<{ accountId: string; permissions: Parameters<typeof hasPermission>[0] } | null> {
838
- const prefix = "api_key:";
839
- if (!state.subjectId.startsWith(prefix)) {
840
- return null;
841
- }
842
- const apiKey = await findActiveWorkspaceApiKeyById(db, {
843
- accountId: state.accountId,
844
- workspaceId: state.workspaceId,
845
- apiKeyId: state.subjectId.slice(prefix.length),
846
- });
847
- if (!apiKey) {
848
- return null;
849
- }
850
- return { accountId: apiKey.accountId, permissions: apiKey.permissions };
945
+ await requireConnectOwnerAuthority(db, state);
851
946
  }
852
947
 
853
948
  /** The hosted-Slack profile's origin pins, kept exported for its tests. */
@@ -1451,6 +1546,24 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
1451
1546
  accountId: requiredString(payload.accountId, "state.accountId"),
1452
1547
  workspaceId: requiredString(payload.workspaceId, "state.workspaceId"),
1453
1548
  subjectId: requiredString(payload.subjectId, "state.subjectId"),
1549
+ ...(stringValue(payload.encryptedExternalContinuation)
1550
+ ? {
1551
+ externalContinuation: ExternalActorContinuation.parse(
1552
+ JSON.parse(
1553
+ decryptEnvironmentValue(
1554
+ requireEnvironmentEncryption(settings),
1555
+ requiredString(payload.encryptedExternalContinuation, "state.externalContinuation"),
1556
+ ),
1557
+ ),
1558
+ ),
1559
+ }
1560
+ : {}),
1561
+ ...(payload.returnUrl !== undefined
1562
+ ? { returnUrl: exactExternalReturnUrl(requiredString(payload.returnUrl, "state.returnUrl")) }
1563
+ : {}),
1564
+ ...(stringValue(payload.connectAttemptId)
1565
+ ? { connectAttemptId: requiredString(payload.connectAttemptId, "state.connectAttemptId") }
1566
+ : {}),
1454
1567
  // OAuth states minted before ownership was explicit were always personal.
1455
1568
  // Preserve that meaning for in-flight reconnects during a rolling deploy.
1456
1569
  ownership: connectionOwnership(payload.ownership) ?? "personal",
@@ -2085,23 +2198,6 @@ function oauthEndpointUrl(rawUrl: string, settings: Settings, label: string): st
2085
2198
  }
2086
2199
  }
2087
2200
 
2088
- function safeReturnPath(value: string): string {
2089
- if (!value.startsWith("/") || value.startsWith("//")) {
2090
- throw new HTTPException(400, {
2091
- message: "OAuth returnPath must be a relative path",
2092
- });
2093
- }
2094
- const parsed = new URL(value, "https://opengeni.local");
2095
- // `..` segments can normalize back into a `//host` prefix, which browsers
2096
- // resolve as a protocol-relative absolute URL. Reject the NORMALIZED path.
2097
- if (parsed.origin !== "https://opengeni.local" || parsed.pathname.startsWith("//")) {
2098
- throw new HTTPException(400, {
2099
- message: "OAuth returnPath must be a relative path",
2100
- });
2101
- }
2102
- return `${parsed.pathname}${parsed.search}${parsed.hash}`;
2103
- }
2104
-
2105
2201
  async function fetchOAuth(
2106
2202
  rawUrl: string,
2107
2203
  settings: Settings,
@@ -0,0 +1,18 @@
1
+ import { HTTPException } from "hono/http-exception";
2
+
3
+ export function safeReturnPath(value: string): string {
4
+ if (!value.startsWith("/") || value.startsWith("//")) {
5
+ throw new HTTPException(400, {
6
+ message: "OAuth returnPath must be a relative path",
7
+ });
8
+ }
9
+ const parsed = new URL(value, "https://opengeni.local");
10
+ // `..` segments can normalize back into a `//host` prefix, which browsers
11
+ // resolve as a protocol-relative absolute URL. Reject the NORMALIZED path.
12
+ if (parsed.origin !== "https://opengeni.local" || parsed.pathname.startsWith("//")) {
13
+ throw new HTTPException(400, {
14
+ message: "OAuth returnPath must be a relative path",
15
+ });
16
+ }
17
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
18
+ }