@opengeni/api-router 0.12.5 → 0.12.12
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-3BHOMOSD.js → chunk-5TULDPWX.js} +589 -153
- package/dist/chunk-5TULDPWX.js.map +1 -0
- package/dist/index.js +10 -4
- package/dist/index.js.map +1 -1
- package/package.json +10 -10
- package/src/app.ts +12 -0
- package/src/github-access.ts +1 -0
- package/src/index.ts +8 -3
- package/src/mcp/files.ts +77 -0
- package/src/mcp/server.ts +196 -86
- package/src/routes/files.ts +11 -0
- package/src/routes/github.ts +325 -20
- package/src/routes/scheduled-tasks.ts +1 -0
- package/src/routes/sessions.ts +12 -23
- package/src/routes/workspace-capture.ts +108 -8
- package/dist/chunk-3BHOMOSD.js.map +0 -1
package/src/routes/github.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
GitHubAppManifestCreate,
|
|
3
3
|
type AccessGrant,
|
|
4
|
+
type GitHubInstallationBindingCandidate,
|
|
4
5
|
type GitHubInstallationBindingProof,
|
|
5
6
|
} from "@opengeni/contracts";
|
|
6
7
|
import {
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
buildGitHubAppManifest,
|
|
14
15
|
convertGitHubAppManifest,
|
|
15
16
|
createSignedState,
|
|
17
|
+
discoverGitHubInstallationBindingCandidates,
|
|
16
18
|
envLinesFromGitHubManifestConversion,
|
|
17
19
|
GitHubAppApiError,
|
|
18
20
|
GitHubAppConfigurationError,
|
|
@@ -47,7 +49,6 @@ const githubStateCookie = "opengeni_github_state";
|
|
|
47
49
|
const githubBindingStateMaxAgeSeconds = 10 * 60;
|
|
48
50
|
const legacyInstallationChooserDisabledMessage =
|
|
49
51
|
"The legacy repository-admin GitHub installation chooser is disabled; use the GitHub owner-consent connect flow";
|
|
50
|
-
|
|
51
52
|
export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
52
53
|
const { db, settings, githubStateSecret } = deps;
|
|
53
54
|
|
|
@@ -61,6 +62,7 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
61
62
|
? await listWorkspaceGitHubInstallationBindings(deps, grant.workspaceId)
|
|
62
63
|
: [];
|
|
63
64
|
const status = githubBindingStatus(missing.length === 0, installations);
|
|
65
|
+
const setupMode = settings.productAccessMode === "managed" ? "platform" : "operator";
|
|
64
66
|
const canManage = hasPermission(grant.permissions, "github:manage");
|
|
65
67
|
const connectState =
|
|
66
68
|
missing.length === 0 && slug && canManage
|
|
@@ -74,23 +76,29 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
74
76
|
const connectUrl = connectState
|
|
75
77
|
? `${openGeniBaseUrl(settings, c)}/v1/workspaces/${grant.workspaceId}/github/connect?state=${encodeURIComponent(connectState)}`
|
|
76
78
|
: null;
|
|
79
|
+
const installationViews = installations.map((installation) => ({
|
|
80
|
+
...installation,
|
|
81
|
+
configureUrl: connectState
|
|
82
|
+
? `${openGeniBaseUrl(settings, c)}/v1/workspaces/${grant.workspaceId}/github/installations/${installation.installationId}/configure?state=${encodeURIComponent(connectState)}`
|
|
83
|
+
: null,
|
|
84
|
+
}));
|
|
77
85
|
return c.json({
|
|
78
86
|
configured: missing.length === 0,
|
|
79
87
|
status,
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
88
|
+
setupMode,
|
|
89
|
+
appId: setupMode === "operator" ? (settings.githubAppId ?? null) : null,
|
|
90
|
+
clientId: setupMode === "operator" ? (settings.githubClientId ?? null) : null,
|
|
91
|
+
appSlug: setupMode === "operator" ? slug : null,
|
|
83
92
|
installUrl: connectUrl,
|
|
84
93
|
linkUrl: connectUrl,
|
|
85
|
-
installations,
|
|
86
|
-
missing,
|
|
94
|
+
installations: installationViews,
|
|
95
|
+
missing: setupMode === "operator" ? missing : [],
|
|
87
96
|
});
|
|
88
97
|
});
|
|
89
98
|
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
// authority before any workspace binding write.
|
|
99
|
+
// Start with user authorization, not GitHub's install/configure selector.
|
|
100
|
+
// This lets an owner link an already-installed App without relying on
|
|
101
|
+
// GitHub's Configure page to preserve or return OpenGeni state.
|
|
94
102
|
app.get("/v1/workspaces/:workspaceId/github/connect", async (c) => {
|
|
95
103
|
const workspaceId = c.req.param("workspaceId");
|
|
96
104
|
const state = c.req.query("state");
|
|
@@ -107,8 +115,8 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
107
115
|
) {
|
|
108
116
|
throw new HTTPException(400, { message: "invalid or expired GitHub installation state" });
|
|
109
117
|
}
|
|
110
|
-
const
|
|
111
|
-
if (!
|
|
118
|
+
const clientId = settings.githubClientId?.trim();
|
|
119
|
+
if (!clientId || githubAppMissingSettings(settings).length > 0) {
|
|
112
120
|
throw new HTTPException(409, {
|
|
113
121
|
message: JSON.stringify({
|
|
114
122
|
message: "GitHub App is not configured",
|
|
@@ -116,9 +124,19 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
116
124
|
}),
|
|
117
125
|
});
|
|
118
126
|
}
|
|
119
|
-
|
|
127
|
+
const discoveryState = createSignedState(githubStateSecret, {
|
|
128
|
+
accountId: statePayload.accountId,
|
|
129
|
+
workspaceId,
|
|
130
|
+
intent: "installation_authority_discovery",
|
|
131
|
+
...continuedGitHubBrowserGrantClaims(statePayload),
|
|
132
|
+
});
|
|
133
|
+
setGitHubStateCookie(c, deps, discoveryState);
|
|
120
134
|
return c.redirect(
|
|
121
|
-
|
|
135
|
+
githubOAuthAuthorizeUrl({
|
|
136
|
+
clientId,
|
|
137
|
+
state: discoveryState,
|
|
138
|
+
redirectUri: `${openGeniBaseUrl(settings, c)}/v1/github/oauth/callback`,
|
|
139
|
+
}),
|
|
122
140
|
);
|
|
123
141
|
});
|
|
124
142
|
|
|
@@ -174,7 +192,55 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
174
192
|
return c.body(null, 204);
|
|
175
193
|
});
|
|
176
194
|
|
|
195
|
+
app.get(
|
|
196
|
+
"/v1/workspaces/:workspaceId/github/installations/:installationId/configure",
|
|
197
|
+
async (c) => {
|
|
198
|
+
const workspaceId = c.req.param("workspaceId");
|
|
199
|
+
const installationId = parsePositiveInteger(c.req.param("installationId"));
|
|
200
|
+
const state = c.req.query("state");
|
|
201
|
+
if (installationId === null || !state) {
|
|
202
|
+
throw new HTTPException(400, { message: "invalid GitHub installation configuration" });
|
|
203
|
+
}
|
|
204
|
+
const statePayload = readSignedState(state, githubStateSecret);
|
|
205
|
+
if (
|
|
206
|
+
!statePayload ||
|
|
207
|
+
statePayload.intent !== "installation_authority" ||
|
|
208
|
+
statePayload.workspaceId !== workspaceId ||
|
|
209
|
+
typeof statePayload.accountId !== "string" ||
|
|
210
|
+
!isFreshGitHubBindingState(statePayload)
|
|
211
|
+
) {
|
|
212
|
+
throw new HTTPException(400, {
|
|
213
|
+
message: "invalid or expired GitHub installation configuration state",
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
const grant = await requireGitHubManageGrant(c, deps, workspaceId, statePayload);
|
|
217
|
+
if (grant.accountId !== statePayload.accountId) {
|
|
218
|
+
throw new HTTPException(403, {
|
|
219
|
+
message: "GitHub installation state does not match this workspace",
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
const installation = (
|
|
223
|
+
await listWorkspaceGitHubInstallationBindings(deps, grant.workspaceId)
|
|
224
|
+
).find((candidate) => candidate.installationId === installationId);
|
|
225
|
+
if (!installation) {
|
|
226
|
+
throw new HTTPException(404, { message: "GitHub installation binding not found" });
|
|
227
|
+
}
|
|
228
|
+
const configureState = createSignedState(githubStateSecret, {
|
|
229
|
+
accountId: grant.accountId,
|
|
230
|
+
workspaceId: grant.workspaceId,
|
|
231
|
+
expectedInstallationId: installationId,
|
|
232
|
+
intent: "installation_authority_install",
|
|
233
|
+
...continuedGitHubBrowserGrantClaims(statePayload),
|
|
234
|
+
});
|
|
235
|
+
setGitHubStateCookie(c, deps, configureState);
|
|
236
|
+
const configureUrl = githubInstallationSettingsUrl(installation);
|
|
237
|
+
configureUrl.searchParams.set("state", configureState);
|
|
238
|
+
return c.redirect(configureUrl.toString());
|
|
239
|
+
},
|
|
240
|
+
);
|
|
241
|
+
|
|
177
242
|
app.post("/v1/workspaces/:workspaceId/github/app-manifest", async (c) => {
|
|
243
|
+
assertOperatorGitHubAppSetup(settings);
|
|
178
244
|
const workspaceId = c.req.param("workspaceId");
|
|
179
245
|
const grant = await requireAccessGrant(c, deps, workspaceId, "github:manage");
|
|
180
246
|
const payload = GitHubAppManifestCreate.parse(await c.req.json());
|
|
@@ -206,6 +272,7 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
206
272
|
});
|
|
207
273
|
|
|
208
274
|
app.get("/v1/github/app-manifest/callback", async (c) => {
|
|
275
|
+
assertOperatorGitHubAppSetup(settings);
|
|
209
276
|
const code = c.req.query("code");
|
|
210
277
|
const state = c.req.query("state");
|
|
211
278
|
if (!code) {
|
|
@@ -226,14 +293,21 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
226
293
|
});
|
|
227
294
|
|
|
228
295
|
const handleGitHubInstallCallback = async (c: Context) => {
|
|
229
|
-
const state =
|
|
296
|
+
const state =
|
|
297
|
+
c.req.query("state") ??
|
|
298
|
+
allCookieValues(c, githubStateCookie).find((candidate) => {
|
|
299
|
+
const payload = readSignedState(candidate, githubStateSecret);
|
|
300
|
+
return (
|
|
301
|
+
payload?.intent === "installation_authority_install" && isFreshGitHubBindingState(payload)
|
|
302
|
+
);
|
|
303
|
+
});
|
|
230
304
|
if (!state) {
|
|
231
305
|
throw new HTTPException(400, { message: "missing GitHub installation state" });
|
|
232
306
|
}
|
|
233
307
|
const statePayload = readSignedState(state, githubStateSecret);
|
|
234
308
|
if (
|
|
235
309
|
!statePayload ||
|
|
236
|
-
statePayload.intent !== "
|
|
310
|
+
statePayload.intent !== "installation_authority_install" ||
|
|
237
311
|
typeof statePayload.accountId !== "string" ||
|
|
238
312
|
typeof statePayload.workspaceId !== "string" ||
|
|
239
313
|
!isFreshGitHubBindingState(statePayload)
|
|
@@ -258,6 +332,14 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
258
332
|
if (installationId === null) {
|
|
259
333
|
throw new HTTPException(400, { message: "missing or invalid GitHub installation_id" });
|
|
260
334
|
}
|
|
335
|
+
if (
|
|
336
|
+
statePayload.expectedInstallationId !== undefined &&
|
|
337
|
+
statePayload.expectedInstallationId !== installationId
|
|
338
|
+
) {
|
|
339
|
+
throw new HTTPException(409, {
|
|
340
|
+
message: "GitHub returned a different installation than the one being configured",
|
|
341
|
+
});
|
|
342
|
+
}
|
|
261
343
|
const clientId = settings.githubClientId?.trim();
|
|
262
344
|
if (!clientId) {
|
|
263
345
|
throw new HTTPException(409, {
|
|
@@ -299,13 +381,71 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
299
381
|
const statePayload = readSignedState(state, githubStateSecret);
|
|
300
382
|
if (
|
|
301
383
|
!statePayload ||
|
|
302
|
-
statePayload.intent !== "installation_authority_oauth" ||
|
|
303
384
|
typeof statePayload.accountId !== "string" ||
|
|
304
385
|
typeof statePayload.workspaceId !== "string" ||
|
|
305
386
|
!isFreshGitHubBindingState(statePayload)
|
|
306
387
|
) {
|
|
307
388
|
throw new HTTPException(400, { message: "invalid or expired GitHub OAuth state" });
|
|
308
389
|
}
|
|
390
|
+
if (statePayload.intent === "installation_authority_discovery") {
|
|
391
|
+
requireGitHubStateCookie(c, state);
|
|
392
|
+
const grant = await requireGitHubManageGrant(c, deps, statePayload.workspaceId, statePayload);
|
|
393
|
+
if (grant.accountId !== statePayload.accountId) {
|
|
394
|
+
throw new HTTPException(403, {
|
|
395
|
+
message: "GitHub OAuth state does not match this workspace",
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
let candidates: GitHubInstallationBindingCandidate[] | null;
|
|
399
|
+
try {
|
|
400
|
+
candidates = deps.githubAppApi?.discoverInstallationBindingCandidates
|
|
401
|
+
? await deps.githubAppApi.discoverInstallationBindingCandidates({ code })
|
|
402
|
+
: deps.githubAppApi
|
|
403
|
+
? null
|
|
404
|
+
: await discoverGitHubInstallationBindingCandidates(settings, { code });
|
|
405
|
+
} catch (error) {
|
|
406
|
+
throw githubAuthorityHttpError(error);
|
|
407
|
+
}
|
|
408
|
+
if (!candidates) {
|
|
409
|
+
throw new HTTPException(409, {
|
|
410
|
+
message: "The configured GitHub provider cannot discover owner-authorized installations",
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
if (!isConsistentGitHubBindingCandidates(candidates)) {
|
|
414
|
+
throw new HTTPException(409, {
|
|
415
|
+
message: "GitHub installation discovery proof is stale or invalid",
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
const selectionState = createSignedState(githubStateSecret, {
|
|
419
|
+
accountId: grant.accountId,
|
|
420
|
+
workspaceId: grant.workspaceId,
|
|
421
|
+
intent: "installation_authority_selection",
|
|
422
|
+
allowedInstallationIds: candidates.map(({ installation }) => installation.installationId),
|
|
423
|
+
...continuedGitHubBrowserGrantClaims(statePayload),
|
|
424
|
+
});
|
|
425
|
+
if (candidates.length === 0) {
|
|
426
|
+
return redirectToGitHubInstallation(c, deps, selectionState);
|
|
427
|
+
}
|
|
428
|
+
if (candidates.length === 1) {
|
|
429
|
+
return redirectToExactGitHubAuthorization(
|
|
430
|
+
c,
|
|
431
|
+
deps,
|
|
432
|
+
selectionState,
|
|
433
|
+
candidates[0]!.installation.installationId,
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
setGitHubStateCookie(c, deps, selectionState);
|
|
437
|
+
return c.html(
|
|
438
|
+
githubInstallationChooserHtml(
|
|
439
|
+
candidates,
|
|
440
|
+
selectionState,
|
|
441
|
+
grant.workspaceId,
|
|
442
|
+
openGeniBaseUrl(settings, c),
|
|
443
|
+
),
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
if (statePayload.intent !== "installation_authority_oauth") {
|
|
447
|
+
throw new HTTPException(400, { message: "invalid or expired GitHub OAuth state" });
|
|
448
|
+
}
|
|
309
449
|
const installationId = parsePositiveInteger(String(statePayload.installationId ?? ""));
|
|
310
450
|
if (installationId === null) {
|
|
311
451
|
throw new HTTPException(400, { message: "invalid GitHub installation id" });
|
|
@@ -382,6 +522,52 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
382
522
|
);
|
|
383
523
|
});
|
|
384
524
|
|
|
525
|
+
app.post("/v1/workspaces/:workspaceId/github/installations/select", async (c) => {
|
|
526
|
+
const workspaceId = c.req.param("workspaceId");
|
|
527
|
+
const form = new URLSearchParams(await c.req.text());
|
|
528
|
+
const state = form.get("state");
|
|
529
|
+
if (!state) {
|
|
530
|
+
throw new HTTPException(400, { message: "missing GitHub installation selection state" });
|
|
531
|
+
}
|
|
532
|
+
const statePayload = readSignedState(state, githubStateSecret);
|
|
533
|
+
if (
|
|
534
|
+
!statePayload ||
|
|
535
|
+
statePayload.intent !== "installation_authority_selection" ||
|
|
536
|
+
typeof statePayload.accountId !== "string" ||
|
|
537
|
+
statePayload.accountId.length === 0 ||
|
|
538
|
+
statePayload.workspaceId !== workspaceId ||
|
|
539
|
+
!isFreshGitHubBindingState(statePayload)
|
|
540
|
+
) {
|
|
541
|
+
throw new HTTPException(400, {
|
|
542
|
+
message: "invalid or expired GitHub installation selection state",
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
requireGitHubStateCookie(c, state);
|
|
546
|
+
const grant = await requireGitHubManageGrant(c, deps, workspaceId, statePayload);
|
|
547
|
+
if (grant.accountId !== statePayload.accountId) {
|
|
548
|
+
throw new HTTPException(403, {
|
|
549
|
+
message: "GitHub installation state does not match this workspace",
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
const selected = form.get("installation_id");
|
|
553
|
+
if (selected === "new") {
|
|
554
|
+
return redirectToGitHubInstallation(c, deps, state);
|
|
555
|
+
}
|
|
556
|
+
const installationId = parsePositiveInteger(selected);
|
|
557
|
+
if (installationId === null) {
|
|
558
|
+
throw new HTTPException(400, { message: "invalid GitHub installation selection" });
|
|
559
|
+
}
|
|
560
|
+
if (
|
|
561
|
+
!Array.isArray(statePayload.allowedInstallationIds) ||
|
|
562
|
+
!statePayload.allowedInstallationIds.includes(installationId)
|
|
563
|
+
) {
|
|
564
|
+
throw new HTTPException(403, {
|
|
565
|
+
message: "GitHub installation was not in the owner-authorized selection",
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
return redirectToExactGitHubAuthorization(c, deps, state, installationId);
|
|
569
|
+
});
|
|
570
|
+
|
|
385
571
|
app.post("/v1/workspaces/:workspaceId/github/installations", async (c) => {
|
|
386
572
|
const workspaceId = c.req.param("workspaceId");
|
|
387
573
|
const form = new URLSearchParams(await c.req.text());
|
|
@@ -398,12 +584,89 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
398
584
|
) {
|
|
399
585
|
throw new HTTPException(400, { message: "invalid or expired GitHub OAuth state" });
|
|
400
586
|
}
|
|
401
|
-
throw
|
|
587
|
+
throw new HTTPException(410, { message: legacyInstallationChooserDisabledMessage });
|
|
402
588
|
});
|
|
403
589
|
}
|
|
404
590
|
|
|
405
|
-
function
|
|
406
|
-
|
|
591
|
+
function assertOperatorGitHubAppSetup(settings: ApiRouteDeps["settings"]): void {
|
|
592
|
+
if (settings.productAccessMode === "managed") {
|
|
593
|
+
throw new HTTPException(404, {
|
|
594
|
+
message: "GitHub App creation is unavailable in platform-managed deployments",
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function redirectToGitHubInstallation(
|
|
600
|
+
c: Context,
|
|
601
|
+
deps: ApiRouteDeps,
|
|
602
|
+
sourceState: string,
|
|
603
|
+
): Response {
|
|
604
|
+
const payload = readSignedState(sourceState, deps.githubStateSecret);
|
|
605
|
+
const slug = deps.settings.githubAppSlug?.trim();
|
|
606
|
+
if (
|
|
607
|
+
!payload ||
|
|
608
|
+
typeof payload.accountId !== "string" ||
|
|
609
|
+
typeof payload.workspaceId !== "string" ||
|
|
610
|
+
!slug
|
|
611
|
+
) {
|
|
612
|
+
throw new HTTPException(409, { message: "GitHub App installation is unavailable" });
|
|
613
|
+
}
|
|
614
|
+
const installState = createSignedState(deps.githubStateSecret, {
|
|
615
|
+
accountId: payload.accountId,
|
|
616
|
+
workspaceId: payload.workspaceId,
|
|
617
|
+
intent: "installation_authority_install",
|
|
618
|
+
...continuedGitHubBrowserGrantClaims(payload),
|
|
619
|
+
});
|
|
620
|
+
setGitHubStateCookie(c, deps, installState);
|
|
621
|
+
return c.redirect(
|
|
622
|
+
`https://github.com/apps/${encodeURIComponent(slug)}/installations/new?state=${encodeURIComponent(installState)}`,
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function githubInstallationSettingsUrl(installation: {
|
|
627
|
+
installationId: number;
|
|
628
|
+
accountLogin: string | null;
|
|
629
|
+
accountType: string | null;
|
|
630
|
+
}): URL {
|
|
631
|
+
if (installation.accountType === "Organization" && installation.accountLogin) {
|
|
632
|
+
return new URL(
|
|
633
|
+
`https://github.com/organizations/${encodeURIComponent(installation.accountLogin)}/settings/installations/${installation.installationId}`,
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
return new URL(`https://github.com/settings/installations/${installation.installationId}`);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function redirectToExactGitHubAuthorization(
|
|
640
|
+
c: Context,
|
|
641
|
+
deps: ApiRouteDeps,
|
|
642
|
+
sourceState: string,
|
|
643
|
+
installationId: number,
|
|
644
|
+
): Response {
|
|
645
|
+
const payload = readSignedState(sourceState, deps.githubStateSecret);
|
|
646
|
+
const clientId = deps.settings.githubClientId?.trim();
|
|
647
|
+
if (
|
|
648
|
+
!payload ||
|
|
649
|
+
typeof payload.accountId !== "string" ||
|
|
650
|
+
typeof payload.workspaceId !== "string" ||
|
|
651
|
+
!clientId
|
|
652
|
+
) {
|
|
653
|
+
throw new HTTPException(409, { message: "GitHub user authorization is unavailable" });
|
|
654
|
+
}
|
|
655
|
+
const oauthState = createSignedState(deps.githubStateSecret, {
|
|
656
|
+
accountId: payload.accountId,
|
|
657
|
+
workspaceId: payload.workspaceId,
|
|
658
|
+
installationId,
|
|
659
|
+
intent: "installation_authority_oauth",
|
|
660
|
+
...continuedGitHubBrowserGrantClaims(payload),
|
|
661
|
+
});
|
|
662
|
+
setGitHubStateCookie(c, deps, oauthState);
|
|
663
|
+
return c.redirect(
|
|
664
|
+
githubOAuthAuthorizeUrl({
|
|
665
|
+
clientId,
|
|
666
|
+
state: oauthState,
|
|
667
|
+
redirectUri: `${openGeniBaseUrl(deps.settings, c)}/v1/github/oauth/callback`,
|
|
668
|
+
}),
|
|
669
|
+
);
|
|
407
670
|
}
|
|
408
671
|
|
|
409
672
|
function setGitHubStateCookie(c: Context, deps: ApiRouteDeps, state: string): void {
|
|
@@ -502,10 +765,52 @@ function githubSetupSuccessHtml(account: string, returnUrl: string): string {
|
|
|
502
765
|
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>GitHub App Connected</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 0 18px;color:#d4d4d8}.button{display:inline-flex;align-items:center;justify-content:center;min-height:36px;border-radius:6px;border:1px solid #3f3f46;padding:0 12px;background:#f4f4f5;color:#09090b;font:600 14px system-ui,sans-serif;text-decoration:none}</style></head><body><main><h1>GitHub App connected</h1><p>${escapeHtml(account)} is now available to this OpenGeni workspace through an explicit repository allowlist.</p><a class="button" href="${escapeHtml(returnUrl)}">Back to OpenGeni</a></main></body></html>`;
|
|
503
766
|
}
|
|
504
767
|
|
|
768
|
+
function githubInstallationChooserHtml(
|
|
769
|
+
candidates: GitHubInstallationBindingCandidate[],
|
|
770
|
+
state: string,
|
|
771
|
+
workspaceId: string,
|
|
772
|
+
baseUrl: string,
|
|
773
|
+
): string {
|
|
774
|
+
const action = `${baseUrl}/v1/workspaces/${encodeURIComponent(workspaceId)}/github/installations/select`;
|
|
775
|
+
const options = candidates
|
|
776
|
+
.map(({ installation, authorityKind }) => {
|
|
777
|
+
const account = escapeHtml(
|
|
778
|
+
installation.accountLogin ?? `installation ${installation.installationId}`,
|
|
779
|
+
);
|
|
780
|
+
const label = authorityKind === "personal_owner" ? "Personal account" : "Organization owner";
|
|
781
|
+
return `<label class="option"><input type="radio" name="installation_id" value="${installation.installationId}" required><span><strong>${account}</strong><small>${label}</small></span></label>`;
|
|
782
|
+
})
|
|
783
|
+
.join("");
|
|
784
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Choose GitHub installation</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:12px;padding:28px;background:#111114}h1{margin:0 0 10px;font-size:24px}p{margin:0 0 18px;color:#d4d4d8}.options{display:grid;gap:8px;margin-bottom:18px}.option{display:flex;align-items:center;gap:12px;border:1px solid #3f3f46;border-radius:8px;padding:12px;cursor:pointer}.option span{display:grid;gap:2px}.option small{color:#a1a1aa}button{min-height:38px;border-radius:7px;border:1px solid #3f3f46;padding:0 14px;background:#f4f4f5;color:#09090b;font:600 14px system-ui,sans-serif;cursor:pointer}.secondary{margin-left:8px;background:transparent;color:#f4f4f5}</style></head><body><main><h1>Choose a GitHub account</h1><p>Only installations where GitHub proved you are the personal owner or an active organization owner are shown.</p><form method="post" action="${escapeHtml(action)}"><input type="hidden" name="state" value="${escapeHtml(state)}"><div class="options">${options}</div><button type="submit">Connect selected</button><button class="secondary" type="submit" name="installation_id" value="new" formnovalidate>Install on another account</button></form></main></body></html>`;
|
|
785
|
+
}
|
|
786
|
+
|
|
505
787
|
function githubSetupPendingHtml(): string {
|
|
506
788
|
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>`;
|
|
507
789
|
}
|
|
508
790
|
|
|
791
|
+
function isConsistentGitHubBindingCandidates(
|
|
792
|
+
candidates: GitHubInstallationBindingCandidate[],
|
|
793
|
+
): boolean {
|
|
794
|
+
const ids = new Set<number>();
|
|
795
|
+
return candidates.every(({ installation, authorityKind }) => {
|
|
796
|
+
if (
|
|
797
|
+
!Number.isSafeInteger(installation.installationId) ||
|
|
798
|
+
installation.installationId <= 0 ||
|
|
799
|
+
!Number.isSafeInteger(installation.accountId) ||
|
|
800
|
+
installation.accountId <= 0 ||
|
|
801
|
+
!installation.accountLogin?.trim() ||
|
|
802
|
+
installation.suspended ||
|
|
803
|
+
ids.has(installation.installationId)
|
|
804
|
+
) {
|
|
805
|
+
return false;
|
|
806
|
+
}
|
|
807
|
+
ids.add(installation.installationId);
|
|
808
|
+
return authorityKind === "personal_owner"
|
|
809
|
+
? installation.accountType === "User"
|
|
810
|
+
: installation.accountType === "Organization";
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
|
|
509
814
|
function parsePositiveInteger(value: string | undefined | null): number | null {
|
|
510
815
|
if (!value || !/^\d+$/.test(value)) {
|
|
511
816
|
return null;
|
|
@@ -131,6 +131,7 @@ export function registerScheduledTaskRoutes(app: Hono, deps: ApiRouteDeps): void
|
|
|
131
131
|
task,
|
|
132
132
|
agentRunUsageIdempotencyKey,
|
|
133
133
|
triggerWorkflowId,
|
|
134
|
+
initiator: { kind: "subject", subjectId: grant.subjectId },
|
|
134
135
|
});
|
|
135
136
|
await recordWorkspaceUsage(deps, {
|
|
136
137
|
accountId: grant.accountId,
|
package/src/routes/sessions.ts
CHANGED
|
@@ -102,6 +102,7 @@ import {
|
|
|
102
102
|
SessionContextBusyError,
|
|
103
103
|
HumanInputResponseValidationError,
|
|
104
104
|
latestWorkspaceCapture,
|
|
105
|
+
sessionLatestWorkspaceCapture,
|
|
105
106
|
workspaceCaptureAtRevision,
|
|
106
107
|
type AppendEventInput,
|
|
107
108
|
type SandboxOpenPtySessionRow,
|
|
@@ -167,12 +168,17 @@ import {
|
|
|
167
168
|
} from "@opengeni/core";
|
|
168
169
|
import { assertSessionExists, boundedLimit } from "../http/common";
|
|
169
170
|
import { sseSessionStream } from "../http/sse";
|
|
170
|
-
import {
|
|
171
|
+
import {
|
|
172
|
+
serveWorkspaceCapture,
|
|
173
|
+
serveWorkspaceCaptureFile,
|
|
174
|
+
WorkspaceCaptureManifestCache,
|
|
175
|
+
} from "./workspace-capture";
|
|
171
176
|
|
|
172
177
|
type SessionRouteDeps = ApiRouteDeps & Pick<ViewerServices, "establishSandboxSession">;
|
|
173
178
|
|
|
174
179
|
export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
175
180
|
const { settings, db, bus, workflowClient, objectStorage } = deps;
|
|
181
|
+
const workspaceCaptureManifestCache = new WorkspaceCaptureManifestCache();
|
|
176
182
|
const ptyIdentity = (pty: SandboxOpenPtySessionRow): SandboxPtyProcessIdentity => ({
|
|
177
183
|
leaseId: pty.leaseId,
|
|
178
184
|
sandboxGroupId: pty.sandboxGroupId,
|
|
@@ -1276,8 +1282,6 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1276
1282
|
text: payload.text,
|
|
1277
1283
|
turnInstructions: payload.turnInstructions ?? null,
|
|
1278
1284
|
resources: payload.resources,
|
|
1279
|
-
tools: payload.tools,
|
|
1280
|
-
toolsProvided: userMessagePayloadHasOwnProperty({ payload: raw }, "tools"),
|
|
1281
1285
|
model: payload.model ?? null,
|
|
1282
1286
|
reasoningEffort: payload.reasoningEffort ?? null,
|
|
1283
1287
|
mcpCredentialUpdates: payload.mcpCredentialUpdates ?? [],
|
|
@@ -1320,8 +1324,6 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1320
1324
|
text: event.payload.text,
|
|
1321
1325
|
turnInstructions: event.payload.turnInstructions ?? null,
|
|
1322
1326
|
resources: event.payload.resources ?? [],
|
|
1323
|
-
tools: event.payload.tools ?? [],
|
|
1324
|
-
toolsProvided: userMessagePayloadHasOwnProperty(rawEvent, "tools"),
|
|
1325
1327
|
model: event.payload.model ?? null,
|
|
1326
1328
|
reasoningEffort: event.payload.reasoningEffort ?? null,
|
|
1327
1329
|
mcpCredentialUpdates: event.payload.mcpCredentialUpdates ?? [],
|
|
@@ -2120,16 +2122,17 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2120
2122
|
const workspaceId = c.req.param("workspaceId") ?? "";
|
|
2121
2123
|
await requireAccessGrant(c, deps, workspaceId, "files:read");
|
|
2122
2124
|
const sessionId = c.req.param("sessionId") ?? "";
|
|
2123
|
-
const
|
|
2124
|
-
if (!
|
|
2125
|
+
const lookup = await sessionLatestWorkspaceCapture(db, workspaceId, sessionId);
|
|
2126
|
+
if (!lookup.sessionExists) {
|
|
2125
2127
|
throw new HTTPException(404, { message: "session not found" });
|
|
2126
2128
|
}
|
|
2127
2129
|
if (!objectStorage) {
|
|
2128
2130
|
// No storage configured → no captures can exist. Cold-fallback, not an error.
|
|
2129
2131
|
return c.json({ available: false });
|
|
2130
2132
|
}
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
+
return c.json(
|
|
2134
|
+
await serveWorkspaceCapture(lookup.capture, objectStorage, workspaceCaptureManifestCache),
|
|
2135
|
+
);
|
|
2133
2136
|
});
|
|
2134
2137
|
|
|
2135
2138
|
app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/workspace/capture/file", async (c) => {
|
|
@@ -2650,20 +2653,6 @@ function optionalEventSequence(raw: string | undefined): number | undefined {
|
|
|
2650
2653
|
return Math.floor(sequence);
|
|
2651
2654
|
}
|
|
2652
2655
|
|
|
2653
|
-
function hasOwnProperty(value: unknown, key: string): boolean {
|
|
2654
|
-
return Boolean(
|
|
2655
|
-
value && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, key),
|
|
2656
|
-
);
|
|
2657
|
-
}
|
|
2658
|
-
|
|
2659
|
-
function userMessagePayloadHasOwnProperty(value: unknown, key: string): boolean {
|
|
2660
|
-
if (!value || typeof value !== "object") {
|
|
2661
|
-
return false;
|
|
2662
|
-
}
|
|
2663
|
-
const payload = (value as { payload?: unknown }).payload;
|
|
2664
|
-
return hasOwnProperty(payload, key);
|
|
2665
|
-
}
|
|
2666
|
-
|
|
2667
2656
|
/** Stable, value-free JSON errors for only the create-session boundary. */
|
|
2668
2657
|
export function sessionCreateErrorResponse(c: Context, error: unknown): Response {
|
|
2669
2658
|
if (error instanceof SessionSpawnDeniedError) {
|