@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.
- package/dist/app.js +1 -1
- package/dist/{chunk-5X3XM6CE.js → chunk-4AQMKVQM.js} +17727 -14427
- package/dist/chunk-4AQMKVQM.js.map +1 -0
- package/dist/connection-ownership.d.ts +5 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/integrations/atlassian.d.ts +4 -0
- package/dist/integrations/connect-authority.d.ts +1 -0
- package/dist/integrations/connect-callback-return.d.ts +6 -0
- package/dist/integrations/fiken.d.ts +7 -0
- package/dist/integrations/github-app-connect.d.ts +47 -0
- package/dist/integrations/github-installation-proof.d.ts +3 -0
- package/dist/integrations/github-lens-connect.d.ts +153 -0
- package/dist/integrations/google-drive.d.ts +4 -0
- package/dist/integrations/oauth-client.d.ts +4 -0
- package/dist/integrations/oauth-return-path.d.ts +1 -0
- package/dist/integrations/personal-github.d.ts +2 -0
- package/dist/integrations/provider-oauth.d.ts +11 -1
- package/dist/integrations/slack-install.d.ts +20 -0
- package/dist/integrations/social-oauth.d.ts +2 -0
- package/dist/mcp/scheduled-task-view.d.ts +3 -3
- package/dist/routes/api-integrations.d.ts +14 -0
- package/dist/routes/connect.d.ts +5 -0
- package/dist/routes/external-identity-links.d.ts +6 -0
- package/dist/routes/host-mcp-bindings.d.ts +5 -0
- package/dist/sandbox/rematerialize.d.ts +1 -1
- package/dist/workspace-tool-gateway.d.ts +1 -0
- package/package.json +19 -19
- package/src/app.ts +22 -2
- package/src/connection-ownership.ts +14 -1
- package/src/index.ts +2 -0
- package/src/integrations/atlassian.ts +143 -39
- package/src/integrations/connect-authority.ts +2 -0
- package/src/integrations/connect-callback-return.ts +86 -0
- package/src/integrations/fiken.ts +222 -40
- package/src/integrations/github-app-connect.ts +389 -0
- package/src/integrations/github-installation-proof.ts +60 -0
- package/src/integrations/github-lens-connect.ts +97 -0
- package/src/integrations/google-drive.ts +152 -47
- package/src/integrations/oauth-client.ts +180 -84
- package/src/integrations/oauth-return-path.ts +18 -0
- package/src/integrations/personal-github.ts +146 -52
- package/src/integrations/provider-oauth.ts +132 -21
- package/src/integrations/slack-install.ts +83 -0
- package/src/integrations/social-oauth.ts +142 -1
- package/src/mcp/documents.ts +1 -1
- package/src/mcp/server.ts +64 -17
- package/src/routes/api-integrations.ts +74 -64
- package/src/routes/connect.ts +1869 -0
- package/src/routes/connections.ts +263 -221
- package/src/routes/documents.ts +2 -2
- package/src/routes/external-identity-links.ts +200 -0
- package/src/routes/github.ts +39 -64
- package/src/routes/host-mcp-bindings.ts +122 -0
- package/src/routes/organization-memberships.ts +23 -0
- package/src/routes/organization-sessions.ts +2 -2
- package/src/routes/personal-github.ts +8 -1
- package/src/routes/pr-review-github.ts +30 -3
- package/src/routes/scheduled-tasks.ts +29 -4
- package/src/routes/sessions.ts +61 -32
- package/src/routes/social.ts +5 -1
- package/src/routes/supergrok.ts +90 -3
- package/src/routes/workspaces.ts +16 -0
- package/src/sandbox/rematerialize.ts +33 -1
- package/src/workspace-tool-gateway.ts +136 -24
- package/dist/chunk-5X3XM6CE.js.map +0 -1
package/src/routes/documents.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/routes/github.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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.
|
|
298
|
-
? {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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);
|