@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
@@ -1006,9 +1006,9 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
1006
1006
  // (migration 0427); a missing row resolves to no Memory tools.
1007
1007
  const memory =
1008
1008
  sessionId !== undefined
1009
- ? ((await resolveSessionMemoryAgentScope(db, workspaceId, sessionId)) ?? {
1009
+ ? ((await resolveSessionMemoryAgentScope(db, workspaceId, sessionId, grant.metadata)) ?? {
1010
1010
  mode: "off" as const,
1011
- endUserSubjectId: null,
1011
+ userSubjectId: null,
1012
1012
  rootSessionId: null,
1013
1013
  })
1014
1014
  : null;
@@ -0,0 +1,200 @@
1
+ import type { Hono } from "hono";
2
+ import { HTTPException } from "hono/http-exception";
3
+ import { z } from "zod";
4
+ import {
5
+ BeginExternalIdentityLinkRequest,
6
+ ConfirmExternalIdentityLinkRequest,
7
+ } from "@opengeni/contracts/external-identities";
8
+ import {
9
+ beginExternalIdentityLink,
10
+ confirmExternalIdentityLink,
11
+ getExternalIdentityLink,
12
+ listExternalIdentityLinks,
13
+ previewExternalIdentityLink,
14
+ revokeExternalIdentityLink,
15
+ ensureExternalIdentity,
16
+ ExternalIdentityLinkConflictError,
17
+ withWorkspaceSubjectRls,
18
+ lockExternalWorkspaceMembershipLifecycle,
19
+ getWorkspaceGrant,
20
+ namedSubjectPersonalWorkspaceId,
21
+ managedPersonalWorkspacePermissions,
22
+ } from "@opengeni/db";
23
+ import {
24
+ requireAccessGrantAuthorization,
25
+ externalActorContinuationForAuthorization,
26
+ externalContinuationCommitAuthorizer,
27
+ hasPermission,
28
+ type AccessGrantAuthorization,
29
+ type ApiRouteDeps,
30
+ } from "@opengeni/core";
31
+
32
+ function nativeConfirmation(authorization: AccessGrantAuthorization) {
33
+ if (
34
+ !authorization.contextIntegrity ||
35
+ !authorization.canonicalManagedHumanSession ||
36
+ authorization.authenticatedSubjectId !== authorization.grant.subjectId ||
37
+ !authorization.grant.subjectId.startsWith("user:")
38
+ )
39
+ throw new HTTPException(403, { message: "Confirm using your authenticated OpenGeni account" });
40
+ }
41
+
42
+ /** Both products address the link through a workspace they already own. The
43
+ * link itself is organization-local; the host's Personal workspace need not be
44
+ * exposed to the native human, and neither side names the other's subject. */
45
+ export function registerExternalIdentityLinkRoutes(app: Hono, deps: ApiRouteDeps) {
46
+ const base = "/v1/workspaces/:workspaceId/identity-links";
47
+ app.get(base, async (c) => {
48
+ const authorization = await requireAccessGrantAuthorization(
49
+ c,
50
+ deps,
51
+ c.req.param("workspaceId"),
52
+ "workspace:read",
53
+ );
54
+ if (!externalActorContinuationForAuthorization(authorization))
55
+ nativeConfirmation(authorization);
56
+ const parsedCursor = z.string().uuid().optional().safeParse(c.req.query("cursor"));
57
+ if (!parsedCursor.success) throw new HTTPException(400, { message: "Invalid link cursor" });
58
+ const cursor = parsedCursor.data;
59
+ const grant = authorization.grant;
60
+ return c.json(
61
+ await withWorkspaceSubjectRls(deps.db, grant.workspaceId, grant.subjectId, async (tx) => {
62
+ await externalContinuationCommitAuthorizer(authorization)?.(tx);
63
+ return listExternalIdentityLinks(tx, {
64
+ accountId: grant.accountId,
65
+ subjectId: grant.subjectId,
66
+ ...(cursor ? { cursor } : {}),
67
+ });
68
+ }),
69
+ );
70
+ });
71
+ app.post(base, async (c) => {
72
+ const authorization = await requireAccessGrantAuthorization(
73
+ c,
74
+ deps,
75
+ c.req.param("workspaceId"),
76
+ "workspace:read",
77
+ );
78
+ const continuation = externalActorContinuationForAuthorization(authorization);
79
+ if (!continuation || continuation.actor.actingMode !== "external")
80
+ throw new HTTPException(403, { message: "Start linking as the external product user" });
81
+ const input = BeginExternalIdentityLinkRequest.parse(await c.req.json());
82
+ if (input.expiresAt !== null && Date.parse(input.expiresAt) <= Date.now())
83
+ throw new HTTPException(422, { message: "Link expiry must be in the future" });
84
+ const commit = externalContinuationCommitAuthorizer(authorization)!;
85
+ try {
86
+ const result = await withWorkspaceSubjectRls(
87
+ deps.db,
88
+ authorization.grant.workspaceId,
89
+ authorization.grant.subjectId,
90
+ async (tx) => {
91
+ await commit(tx);
92
+ const identity = await ensureExternalIdentity(tx, {
93
+ accountId: authorization.grant.accountId,
94
+ ...continuation.identity,
95
+ });
96
+ return beginExternalIdentityLink(tx, identity, input);
97
+ },
98
+ );
99
+ return c.json(result, 201);
100
+ } catch (error) {
101
+ if (error instanceof ExternalIdentityLinkConflictError)
102
+ throw new HTTPException(409, { message: error.message });
103
+ throw error;
104
+ }
105
+ });
106
+ app.on(["GET", "POST"], [base + "/:linkId", base + "/:linkId/:operation"], async (c) => {
107
+ const workspaceId = c.req.param("workspaceId")!;
108
+ const linkId = z.string().uuid().parse(c.req.param("linkId"));
109
+ const operation = c.req.param("operation");
110
+ if (
111
+ !(
112
+ (c.req.method === "GET" && !operation) ||
113
+ (c.req.method === "POST" && ["preview", "confirm", "revoke"].includes(operation ?? ""))
114
+ )
115
+ )
116
+ throw new HTTPException(404, { message: "Not found" });
117
+ const authorization = await requireAccessGrantAuthorization(
118
+ c,
119
+ deps,
120
+ workspaceId,
121
+ "workspace:read",
122
+ );
123
+ const external = externalActorContinuationForAuthorization(authorization);
124
+ if (operation === "preview" || operation === "confirm" || !external)
125
+ nativeConfirmation(authorization);
126
+ const grant = authorization.grant;
127
+ const input = c.req.method === "POST" ? await c.req.json() : null;
128
+ try {
129
+ const result = await withWorkspaceSubjectRls(
130
+ deps.db,
131
+ workspaceId,
132
+ grant.subjectId,
133
+ async (tx) => {
134
+ await externalContinuationCommitAuthorizer(authorization)?.(tx);
135
+ if (operation === "preview") {
136
+ const request = z
137
+ .object({ challenge: ConfirmExternalIdentityLinkRequest.shape.challenge })
138
+ .strict()
139
+ .parse(input);
140
+ const preview = await previewExternalIdentityLink(tx, {
141
+ accountId: grant.accountId,
142
+ linkId,
143
+ ...request,
144
+ });
145
+ return preview
146
+ ? { ...preview, nativeSubjectId: grant.subjectId, organizationId: grant.accountId }
147
+ : null;
148
+ }
149
+ if (operation === "confirm") {
150
+ const request = ConfirmExternalIdentityLinkRequest.parse(input);
151
+ await lockExternalWorkspaceMembershipLifecycle(tx, grant.accountId);
152
+ const personalWorkspaceId = await namedSubjectPersonalWorkspaceId(tx, {
153
+ accountId: grant.accountId,
154
+ subjectId: grant.subjectId,
155
+ });
156
+ const current =
157
+ personalWorkspaceId === workspaceId
158
+ ? { accountId: grant.accountId, permissions: managedPersonalWorkspacePermissions }
159
+ : await getWorkspaceGrant(tx, grant.subjectId, workspaceId, {
160
+ principalKind: "human_session",
161
+ });
162
+ if (
163
+ !current ||
164
+ current.accountId !== grant.accountId ||
165
+ request.permissions.some(
166
+ (permission) =>
167
+ !hasPermission(grant.permissions, permission) ||
168
+ !hasPermission(current.permissions, permission),
169
+ )
170
+ )
171
+ throw new HTTPException(403, {
172
+ message: "Link permissions exceed your authority in this workspace",
173
+ });
174
+ return confirmExternalIdentityLink(tx, {
175
+ accountId: grant.accountId,
176
+ linkId,
177
+ nativeSubjectId: grant.subjectId,
178
+ request,
179
+ });
180
+ }
181
+ const scope = { accountId: grant.accountId, linkId, subjectId: grant.subjectId };
182
+ if (operation === "revoke") {
183
+ const request = z
184
+ .object({ expectedRevision: z.number().int().positive().safe() })
185
+ .strict()
186
+ .parse(input);
187
+ return revokeExternalIdentityLink(tx, { ...scope, ...request });
188
+ }
189
+ return getExternalIdentityLink(tx, scope);
190
+ },
191
+ );
192
+ if (!result) throw new HTTPException(404, { message: "Identity link not found or expired" });
193
+ return c.json(result);
194
+ } catch (error) {
195
+ if (error instanceof ExternalIdentityLinkConflictError)
196
+ throw new HTTPException(409, { message: error.message });
197
+ throw error;
198
+ }
199
+ });
200
+ }
@@ -5,7 +5,6 @@ import {
5
5
  UpdateGitHubActionPolicyRequest,
6
6
  type AccessGrant,
7
7
  type GitHubInstallationBindingCandidate,
8
- type GitHubInstallationBindingProof,
9
8
  } from "@opengeni/contracts";
10
9
  import { PersonalGitHubConnectionMetadata } from "@opengeni/contracts/personal-github";
11
10
  import {
@@ -50,6 +49,7 @@ import {
50
49
  personalGitHubActionPolicyActor,
51
50
  requireAccessGrant,
52
51
  requireAccessGrantAuthorization,
52
+ externalActorContinuationForAuthorization,
53
53
  updateGitHubActionPolicyGroup,
54
54
  } from "@opengeni/core";
55
55
  import type { ApiRouteDeps } from "@opengeni/core";
@@ -67,8 +67,19 @@ import {
67
67
  listWorkspaceGitHubRepositoryBranches,
68
68
  listWorkspaceGitHubRepositories,
69
69
  } from "../github-access";
70
- import { assertPersonalConnectionOwnerPrincipal } from "../connection-ownership";
70
+ import {
71
+ assertPersonalConnectionOwnerPrincipal,
72
+ requireLegacyOAuthActor,
73
+ } from "../connection-ownership";
71
74
  import { listPersonalGitHubConnections } from "../integrations/personal-github";
75
+ import {
76
+ isConsistentGitHubBindingCandidates,
77
+ isConsistentGitHubBindingProof,
78
+ } from "../integrations/github-installation-proof";
79
+ import {
80
+ completeGitHubAppConnect,
81
+ isGitHubAppConnectState,
82
+ } from "../integrations/github-app-connect";
72
83
 
73
84
  const githubStateCookie = "opengeni_github_state";
74
85
  const githubBindingStateMaxAgeSeconds = 10 * 60;
@@ -79,7 +90,8 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
79
90
 
80
91
  app.get("/v1/workspaces/:workspaceId/github/app", async (c) => {
81
92
  const workspaceId = c.req.param("workspaceId");
82
- const grant = await requireAccessGrant(c, deps, workspaceId, "github:use");
93
+ const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "github:use");
94
+ const grant = access.grant;
83
95
  const missing = githubAppMissingSettings(settings);
84
96
  const slug = settings.githubAppSlug?.trim() || null;
85
97
  const installations =
@@ -88,7 +100,9 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
88
100
  : [];
89
101
  const status = githubBindingStatus(missing.length === 0, installations);
90
102
  const setupMode = settings.productAccessMode === "managed" ? "platform" : "operator";
91
- const canManage = hasPermission(grant.permissions, "github:manage");
103
+ const canManage =
104
+ !externalActorContinuationForAuthorization(access) &&
105
+ hasPermission(grant.permissions, "github:manage");
92
106
  const returnPath = githubSessionReturnPath(c.req.query("returnPath"), grant.workspaceId);
93
107
  const connectState =
94
108
  missing.length === 0 && slug && canManage
@@ -414,7 +428,9 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
414
428
  app.post("/v1/workspaces/:workspaceId/github/app-manifest", async (c) => {
415
429
  assertOperatorGitHubAppSetup(settings);
416
430
  const workspaceId = c.req.param("workspaceId");
417
- const grant = await requireAccessGrant(c, deps, workspaceId, "github:manage");
431
+ const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "github:manage");
432
+ requireLegacyOAuthActor(access);
433
+ const grant = access.grant;
418
434
  const payload = GitHubAppManifestCreate.parse(await c.req.json());
419
435
  const baseUrl = (settings.githubAppManifestBaseUrl ?? new URL(c.req.url).origin).replace(
420
436
  /\/+$/,
@@ -465,6 +481,14 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
465
481
  });
466
482
 
467
483
  const handleGitHubInstallCallback = async (c: Context) => {
484
+ if (isGitHubAppConnectState(deps, c.req.query("state")))
485
+ return completeGitHubAppConnect(deps, {
486
+ state: c.req.query("state"),
487
+ installationId: c.req.query("installation_id"),
488
+ setupAction: c.req.query("setup_action"),
489
+ error: c.req.query("error"),
490
+ requestUrl: c.req.url,
491
+ });
468
492
  const state =
469
493
  c.req.query("state") ??
470
494
  allCookieValues(c, githubStateCookie).find((candidate) => {
@@ -542,6 +566,13 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
542
566
  app.get("/v1/github/install/callback", handleGitHubInstallCallback);
543
567
 
544
568
  app.get("/v1/github/oauth/callback", async (c) => {
569
+ if (isGitHubAppConnectState(deps, c.req.query("state")))
570
+ return completeGitHubAppConnect(deps, {
571
+ state: c.req.query("state"),
572
+ code: c.req.query("code"),
573
+ error: c.req.query("error"),
574
+ requestUrl: c.req.url,
575
+ });
545
576
  const code = c.req.query("code");
546
577
  const state = c.req.query("state");
547
578
  if (!code) {
@@ -884,7 +915,9 @@ async function requireGitHubManageGrant(
884
915
  expectedState: GitHubSignedStatePayload,
885
916
  ): Promise<AccessGrant> {
886
917
  try {
887
- return await requireAccessGrant(c, deps, workspaceId, "github:manage");
918
+ const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "github:manage");
919
+ requireLegacyOAuthActor(access);
920
+ return access.grant;
888
921
  } catch (error) {
889
922
  if (!(error instanceof HTTPException) || error.status !== 401) {
890
923
  throw error;
@@ -1022,29 +1055,6 @@ function githubSetupPendingHtml(): string {
1022
1055
  return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>GitHub App Requested</title><style>body{font-family:system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0b0d;color:#f4f4f5}main{width:min(640px,calc(100vw - 32px));border:1px solid #27272a;border-radius:8px;padding:28px;background:#111114}h1{margin:0 0 10px;font-size:24px;line-height:1.2}p{margin:0;color:#d4d4d8}</style></head><body><main><h1>GitHub App request sent</h1><p>A GitHub organization owner must approve the installation. OpenGeni has not created a workspace binding.</p></main></body></html>`;
1023
1056
  }
1024
1057
 
1025
- function isConsistentGitHubBindingCandidates(
1026
- candidates: GitHubInstallationBindingCandidate[],
1027
- ): boolean {
1028
- const ids = new Set<number>();
1029
- return candidates.every(({ installation, authorityKind }) => {
1030
- if (
1031
- !Number.isSafeInteger(installation.installationId) ||
1032
- installation.installationId <= 0 ||
1033
- !Number.isSafeInteger(installation.accountId) ||
1034
- installation.accountId <= 0 ||
1035
- !installation.accountLogin?.trim() ||
1036
- installation.suspended ||
1037
- ids.has(installation.installationId)
1038
- ) {
1039
- return false;
1040
- }
1041
- ids.add(installation.installationId);
1042
- return authorityKind === "personal_owner"
1043
- ? installation.accountType === "User"
1044
- : installation.accountType === "Organization";
1045
- });
1046
- }
1047
-
1048
1058
  function parsePositiveInteger(value: string | undefined | null): number | null {
1049
1059
  if (!value || !/^\d+$/.test(value)) {
1050
1060
  return null;
@@ -1058,41 +1068,6 @@ function isFreshGitHubBindingState(payload: GitHubSignedStatePayload): boolean {
1058
1068
  return age >= 0 && age < githubBindingStateMaxAgeSeconds;
1059
1069
  }
1060
1070
 
1061
- function isConsistentGitHubBindingProof(
1062
- proof: GitHubInstallationBindingProof,
1063
- installationId: number,
1064
- ): boolean {
1065
- const installation = proof.installation;
1066
- if (
1067
- installation.installationId !== installationId ||
1068
- !Number.isSafeInteger(installation.accountId) ||
1069
- installation.accountId <= 0 ||
1070
- !installation.accountLogin?.trim() ||
1071
- installation.suspended ||
1072
- !Number.isSafeInteger(proof.actorId) ||
1073
- proof.actorId <= 0 ||
1074
- !proof.actorLogin.trim() ||
1075
- proof.repositories.length === 0
1076
- ) {
1077
- return false;
1078
- }
1079
- if (
1080
- proof.authorityKind === "personal_owner"
1081
- ? installation.accountType !== "User" || proof.actorId !== installation.accountId
1082
- : installation.accountType !== "Organization"
1083
- ) {
1084
- return false;
1085
- }
1086
- return proof.repositories.every(
1087
- (repository) =>
1088
- Number.isSafeInteger(repository.id) &&
1089
- repository.id > 0 &&
1090
- repository.installationId === installationId &&
1091
- repository.accountLogin === installation.accountLogin &&
1092
- repository.accountType === installation.accountType,
1093
- );
1094
- }
1095
-
1096
1071
  function escapeHtml(value: string): string {
1097
1072
  return value.replace(
1098
1073
  /[&<>"']/g,
@@ -0,0 +1,122 @@
1
+ import type { Hono } from "hono";
2
+ import { HTTPException } from "hono/http-exception";
3
+ import { z } from "zod";
4
+ import {
5
+ CreateHostMcpBindingRequest,
6
+ IssueHostMcpDelegationRequest,
7
+ } from "@opengeni/contracts/host-mcp-bindings";
8
+ import {
9
+ createHostMcpBinding,
10
+ getHostMcpBinding,
11
+ revokeHostMcpBinding,
12
+ issueHostMcpDelegation,
13
+ getHostMcpDelegation,
14
+ revokeHostMcpDelegation,
15
+ HostMcpDelegationAuthorityError,
16
+ HostMcpBindingConflictError,
17
+ withWorkspaceSubjectRls,
18
+ } from "@opengeni/db";
19
+ import {
20
+ requireAccessGrantAuthorization,
21
+ prepareHostMcpOwnerAuthorization,
22
+ type ApiRouteDeps,
23
+ } from "@opengeni/core";
24
+
25
+ /** Registration is not an execution grant. The durable runtime must separately
26
+ * validate the exact binding generation and accepted execution context. */
27
+ export function registerHostMcpBindingRoutes(app: Hono, deps: ApiRouteDeps): void {
28
+ for (const entity of ["bindings", "delegations"] as const) {
29
+ app.on(
30
+ ["GET", "POST"],
31
+ [
32
+ `/v1/workspaces/:workspaceId/host-mcp-${entity}`,
33
+ `/v1/workspaces/:workspaceId/host-mcp-${entity}/:bindingId`,
34
+ `/v1/workspaces/:workspaceId/host-mcp-${entity}/:bindingId/revoke`,
35
+ ],
36
+ async (c) => {
37
+ const workspaceId = c.req.param("workspaceId")!;
38
+ const bindingId = c.req.param("bindingId");
39
+ const method = c.req.method;
40
+ const revoke = c.req.path.endsWith("/revoke");
41
+ if (
42
+ !(
43
+ (method === "POST" && !bindingId) ||
44
+ (method === "GET" && bindingId && !revoke) ||
45
+ (method === "POST" && bindingId && revoke)
46
+ )
47
+ )
48
+ throw new HTTPException(404, { message: "not found" });
49
+ const permission = method === "GET" ? "connections:read" : "connections:write";
50
+ const authorization = await requireAccessGrantAuthorization(
51
+ c,
52
+ deps,
53
+ workspaceId,
54
+ permission,
55
+ );
56
+ const authorizeCommit = prepareHostMcpOwnerAuthorization(
57
+ authorization,
58
+ workspaceId,
59
+ permission,
60
+ );
61
+ if (bindingId) z.string().uuid().parse(bindingId);
62
+ const createInput =
63
+ method === "POST" && !bindingId
64
+ ? (entity === "bindings"
65
+ ? CreateHostMcpBindingRequest
66
+ : IssueHostMcpDelegationRequest
67
+ ).parse(await c.req.json())
68
+ : null;
69
+ const revokeInput = revoke
70
+ ? z
71
+ .object({ expectedGeneration: z.number().int().positive().safe() })
72
+ .strict()
73
+ .parse(await c.req.json())
74
+ : null;
75
+ const owner = {
76
+ accountId: authorization.grant.accountId,
77
+ workspaceId,
78
+ subjectId: authorization.grant.subjectId,
79
+ };
80
+ try {
81
+ const result = await withWorkspaceSubjectRls(
82
+ deps.db,
83
+ workspaceId,
84
+ owner.subjectId,
85
+ async (tx) => {
86
+ const effectiveOwner = await authorizeCommit(tx);
87
+ if (createInput)
88
+ return entity === "bindings"
89
+ ? createHostMcpBinding(tx, effectiveOwner, createInput)
90
+ : issueHostMcpDelegation(tx, effectiveOwner, createInput);
91
+ const existing = await (
92
+ entity === "bindings" ? getHostMcpBinding : getHostMcpDelegation
93
+ )(tx, effectiveOwner, bindingId!);
94
+ if (!existing)
95
+ throw new HTTPException(404, {
96
+ message:
97
+ entity === "bindings" ? "Host binding not found" : "Host delegation not found",
98
+ });
99
+ return revokeInput
100
+ ? (entity === "bindings" ? revokeHostMcpBinding : revokeHostMcpDelegation)(
101
+ tx,
102
+ effectiveOwner,
103
+ bindingId!,
104
+ revokeInput.expectedGeneration,
105
+ )
106
+ : existing;
107
+ },
108
+ );
109
+ return c.json(result, createInput ? 201 : 200);
110
+ } catch (error) {
111
+ if (error instanceof HostMcpDelegationAuthorityError)
112
+ throw new HTTPException(403, { message: "Host delegation authority unavailable" });
113
+ if (error instanceof HostMcpBindingConflictError)
114
+ throw new HTTPException(409, {
115
+ message: "Host binding changed; reload before retrying",
116
+ });
117
+ throw error;
118
+ }
119
+ },
120
+ );
121
+ }
122
+ }
@@ -36,6 +36,7 @@ import {
36
36
  getManagedSession,
37
37
  organizationMembershipHttpStatus,
38
38
  requireCanonicalLocalAccountAdministrator,
39
+ updateExternalIdentityMembershipForRequest,
39
40
  type ApiRouteDeps,
40
41
  } from "@opengeni/core";
41
42
  import {
@@ -164,6 +165,28 @@ function rethrowMembershipError(error: unknown, resourceLimitMessage?: string):
164
165
  }
165
166
 
166
167
  export function registerOrganizationMembershipRoutes(app: Hono, deps: ApiRouteDeps): void {
168
+ app.patch("/v1/organizations/:organizationId/external-members/:membershipId", async (context) => {
169
+ const organizationId = parseId(
170
+ OrganizationId,
171
+ context.req.param("organizationId"),
172
+ "organization id",
173
+ );
174
+ const membershipId = parseId(MembershipId, context.req.param("membershipId"), "membership id");
175
+ try {
176
+ return context.json(
177
+ await updateExternalIdentityMembershipForRequest(
178
+ context,
179
+ deps,
180
+ organizationId,
181
+ membershipId,
182
+ await context.req.json().catch(() => null),
183
+ ),
184
+ );
185
+ } catch (error) {
186
+ if (error instanceof HTTPException) throw error;
187
+ rethrowMembershipError(error);
188
+ }
189
+ });
167
190
  app.post("/v1/organizations", async (context) => {
168
191
  const { session, subjectId } = await requireManagedHuman(context, deps);
169
192
  const payload = await parseBody(context, CreateOrganizationRequest);
@@ -294,8 +294,8 @@ async function listWorkspaceSessionPage(
294
294
  limit,
295
295
  materializeSnapshot: true,
296
296
  ...(cursor ? { cursor } : {}),
297
- ...(input.query.endUserSource !== undefined && input.query.endUserId !== undefined
298
- ? { endUser: { source: input.query.endUserSource, id: input.query.endUserId } }
297
+ ...(input.query.scopeSubjectId !== undefined
298
+ ? { scopeSubjectId: input.query.scopeSubjectId }
299
299
  : {}),
300
300
  ...(authorizationScope ? { authorizationScope } : {}),
301
301
  personalWorkspaceOwnerException: authorization.canonicalManagedHumanSession,
@@ -28,7 +28,10 @@ import {
28
28
  type PersonalGitHubRepositorySelectionState as DbPersonalGitHubRepositorySelectionState,
29
29
  } from "@opengeni/db";
30
30
  import { HTTPException } from "hono/http-exception";
31
- import { assertPersonalConnectionOwnerPrincipal } from "../connection-ownership";
31
+ import {
32
+ assertPersonalConnectionOwnerPrincipal,
33
+ requireLegacyOAuthActor,
34
+ } from "../connection-ownership";
32
35
  import {
33
36
  completePersonalGitHubOAuthCallback,
34
37
  listPersonalGitHubConnections,
@@ -64,6 +67,7 @@ export function registerPersonalGitHubRoutes(app: Hono, deps: ApiRouteDeps): voi
64
67
  app.post("/v1/workspaces/:workspaceId/connections/github/oauth/start", async (c) => {
65
68
  const workspaceId = c.req.param("workspaceId");
66
69
  const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "connections:write");
70
+ requireLegacyOAuthActor(access);
67
71
  assertPersonalConnectionOwnerPrincipal(access, "My GitHub account");
68
72
  const payload = PersonalGitHubOAuthStartRequest.parse(await c.req.json().catch(() => ({})));
69
73
  return c.json(
@@ -79,6 +83,7 @@ export function registerPersonalGitHubRoutes(app: Hono, deps: ApiRouteDeps): voi
79
83
  app.post("/v1/workspaces/:workspaceId/connections/:connectionId/github/reconnect", async (c) => {
80
84
  const workspaceId = c.req.param("workspaceId");
81
85
  const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "connections:write");
86
+ requireLegacyOAuthActor(access);
82
87
  assertPersonalConnectionOwnerPrincipal(access, "My GitHub account");
83
88
  const payload = PersonalGitHubOAuthStartRequest.omit({ connectionId: true }).parse(
84
89
  await c.req.json().catch(() => ({})),
@@ -288,6 +293,8 @@ export function registerPersonalGitHubRoutes(app: Hono, deps: ApiRouteDeps): voi
288
293
  ...(state ? { state } : {}),
289
294
  ...(error ? { error } : {}),
290
295
  });
296
+ if (result.exactReturn)
297
+ return new Response(null, { status: 302, headers: { Location: result.redirectTo } });
291
298
  return c.redirect(result.redirectTo, 302);
292
299
  });
293
300
  }
@@ -14,7 +14,8 @@ import {
14
14
  PR_REVIEW_AUTOMATION_TEMPLATE_ID,
15
15
  prReviewPackConnectorId,
16
16
  requireAutomationAdapter,
17
- requireAccessGrant,
17
+ requireAccessGrantAuthorization,
18
+ externalActorContinuationForAuthorization,
18
19
  requirePermission,
19
20
  verifyPrReviewWebhook,
20
21
  type ApiRouteDeps,
@@ -47,10 +48,15 @@ import {
47
48
  type GitHubSignedStatePayload,
48
49
  } from "@opengeni/github";
49
50
  import type { Context, Hono } from "hono";
51
+ import { requireLegacyOAuthActor } from "../connection-ownership";
50
52
  import { deleteCookie, setCookie } from "hono/cookie";
51
53
  import { HTTPException } from "hono/http-exception";
52
54
  import { githubBrowserBaseUrl } from "../github-browser-flow";
53
55
  import { acceptAutomationEvent, readAutomationWebhookBody } from "./automations";
56
+ import {
57
+ completeGitHubAppConnect,
58
+ isGitHubAppConnectState,
59
+ } from "../integrations/github-app-connect";
54
60
 
55
61
  const stateCookie = "opengeni_pr_review_github_state";
56
62
  const bindingStateMaxAgeSeconds = 10 * 60;
@@ -123,7 +129,8 @@ export function registerPrReviewGitHubRoutes(app: Hono, deps: ApiRouteDeps): voi
123
129
 
124
130
  app.get("/v1/workspaces/:workspaceId/pr-review/github", async (c) => {
125
131
  const workspaceId = c.req.param("workspaceId");
126
- const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:read");
132
+ const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "workspace:read");
133
+ const grant = access.grant;
127
134
  await requireActivePack(deps, workspaceId);
128
135
  const missing = prReviewGitHubAppMissingSettings(deps.settings);
129
136
  const configured = missing.length === 0;
@@ -136,6 +143,7 @@ export function registerPrReviewGitHubRoutes(app: Hono, deps: ApiRouteDeps): voi
136
143
  workspaceId,
137
144
  );
138
145
  const canManage =
146
+ !externalActorContinuationForAuthorization(access) &&
139
147
  hasPermission(grant.permissions, "workspace:admin") &&
140
148
  hasPermission(grant.permissions, "secrets:write");
141
149
  const connectState =
@@ -252,6 +260,15 @@ export function registerPrReviewGitHubRoutes(app: Hono, deps: ApiRouteDeps): voi
252
260
  );
253
261
 
254
262
  const handleInstallCallback = async (c: Context) => {
263
+ if (isGitHubAppConnectState(deps, c.req.query("state")))
264
+ return completeGitHubAppConnect(deps, {
265
+ expectedProvider: "github-lens",
266
+ state: c.req.query("state"),
267
+ installationId: c.req.query("installation_id"),
268
+ setupAction: c.req.query("setup_action"),
269
+ error: c.req.query("error"),
270
+ requestUrl: c.req.url,
271
+ });
255
272
  const state =
256
273
  c.req.query("state") ??
257
274
  allCookieValues(c, stateCookie).find((candidate) => {
@@ -303,6 +320,14 @@ export function registerPrReviewGitHubRoutes(app: Hono, deps: ApiRouteDeps): voi
303
320
  app.get("/v1/pr-review/github/install/callback", handleInstallCallback);
304
321
 
305
322
  app.get("/v1/pr-review/github/oauth/callback", async (c) => {
323
+ if (isGitHubAppConnectState(deps, c.req.query("state")))
324
+ return completeGitHubAppConnect(deps, {
325
+ expectedProvider: "github-lens",
326
+ state: c.req.query("state"),
327
+ code: c.req.query("code"),
328
+ error: c.req.query("error"),
329
+ requestUrl: c.req.url,
330
+ });
306
331
  const code = c.req.query("code");
307
332
  const state = c.req.query("state");
308
333
  if (!code || !state) {
@@ -517,7 +542,9 @@ async function requirePrReviewManageGrant(
517
542
  ): Promise<AccessGrant> {
518
543
  let grant: AccessGrant;
519
544
  try {
520
- grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
545
+ const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "workspace:admin");
546
+ requireLegacyOAuthActor(access);
547
+ grant = access.grant;
521
548
  } catch (error) {
522
549
  if (!(error instanceof HTTPException) || error.status !== 401) throw error;
523
550
  const handedOff = prReviewBrowserGrantFromState(deps, state, workspaceId);