@opengeni/api-router 2.6.4-canary.0 → 2.8.1-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-UOI7KAP3.js → chunk-HHKQMVY7.js} +2056 -1346
- package/dist/chunk-HHKQMVY7.js.map +1 -0
- package/dist/github-access.d.ts +25 -2
- package/dist/index.js +1 -1
- package/dist/integrations/personal-github-repositories.d.ts +41 -2
- package/dist/routes/codex.d.ts +8 -1
- package/dist/routes/github.d.ts +2 -0
- package/dist/routes/organization-model-providers.d.ts +3 -0
- package/dist/workspace-deletion.d.ts +6 -0
- package/package.json +18 -18
- package/src/app.ts +21 -0
- package/src/auth/organization-user-setup.ts +3 -3
- package/src/github-access.ts +117 -1
- package/src/http/sse.ts +15 -7
- package/src/integrations/personal-github-repositories.ts +238 -9
- package/src/integrations/slack-app-home.ts +2 -2
- package/src/mcp/company-brain-governed-writes.ts +2 -2
- package/src/mcp/company-profile-agent-admin.ts +1 -1
- package/src/mcp/remember.ts +1 -1
- package/src/routes/automations.ts +6 -14
- package/src/routes/codex.ts +136 -32
- package/src/routes/github.ts +116 -7
- package/src/routes/organization-memberships.ts +50 -3
- package/src/routes/organization-model-providers.ts +231 -0
- package/src/routes/packs.ts +1 -0
- package/src/routes/personal-github.ts +39 -0
- package/src/routes/pr-review.ts +6 -14
- package/src/routes/sessions.ts +16 -5
- package/src/routes/workspaces.ts +32 -59
- package/src/workspace-deletion.ts +64 -0
- package/dist/chunk-UOI7KAP3.js.map +0 -1
package/src/routes/codex.ts
CHANGED
|
@@ -223,7 +223,7 @@ async function managedCookieHuman(
|
|
|
223
223
|
};
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
-
async function requireOrganizationCodexHuman(
|
|
226
|
+
export async function requireOrganizationCodexHuman(
|
|
227
227
|
c: Context,
|
|
228
228
|
deps: ApiRouteDeps,
|
|
229
229
|
organizationId: string,
|
|
@@ -239,7 +239,9 @@ async function requireOrganizationCodexHuman(
|
|
|
239
239
|
};
|
|
240
240
|
}
|
|
241
241
|
if (!human) {
|
|
242
|
-
throw new HTTPException(401, {
|
|
242
|
+
throw new HTTPException(401, {
|
|
243
|
+
message: "organization administrator session required",
|
|
244
|
+
});
|
|
243
245
|
}
|
|
244
246
|
try {
|
|
245
247
|
await getOrganizationCodexRotationSettings(deps.db, {
|
|
@@ -249,7 +251,9 @@ async function requireOrganizationCodexHuman(
|
|
|
249
251
|
} catch (error) {
|
|
250
252
|
const state = nestedPostgresSqlState(error);
|
|
251
253
|
if (state === "42501") {
|
|
252
|
-
throw new HTTPException(403, {
|
|
254
|
+
throw new HTTPException(403, {
|
|
255
|
+
message: "organization administration is not authorized",
|
|
256
|
+
});
|
|
253
257
|
}
|
|
254
258
|
if (state === "P0002") {
|
|
255
259
|
throw new HTTPException(404, { message: "organization not found" });
|
|
@@ -270,11 +274,13 @@ async function requireWorkspaceCodexManagementSource(
|
|
|
270
274
|
});
|
|
271
275
|
}
|
|
272
276
|
if (source.effectiveSource === "disabled") {
|
|
273
|
-
throw new HTTPException(409, {
|
|
277
|
+
throw new HTTPException(409, {
|
|
278
|
+
message: "Codex is disabled for this workspace",
|
|
279
|
+
});
|
|
274
280
|
}
|
|
275
281
|
}
|
|
276
282
|
|
|
277
|
-
function requireSameOriginBrowserMutation(c: Context, deps: ApiRouteDeps): void {
|
|
283
|
+
export function requireSameOriginBrowserMutation(c: Context, deps: ApiRouteDeps): void {
|
|
278
284
|
const contentType = c.req.header("content-type")?.split(";", 1)[0]?.trim().toLowerCase();
|
|
279
285
|
if (contentType !== "application/json") {
|
|
280
286
|
throw new HTTPException(403, {
|
|
@@ -287,16 +293,28 @@ function requireSameOriginBrowserMutation(c: Context, deps: ApiRouteDeps): void
|
|
|
287
293
|
});
|
|
288
294
|
}
|
|
289
295
|
const origin = c.req.header("origin");
|
|
296
|
+
const localOriginMatches =
|
|
297
|
+
deps.settings.productAccessMode === "local" && localBrowserOriginMatchesRequest(c, origin);
|
|
290
298
|
if (
|
|
291
299
|
deps.settings.productAccessMode === "local"
|
|
292
|
-
? !
|
|
300
|
+
? !localOriginMatches
|
|
293
301
|
: origin !== new URL(deps.settings.publicBaseUrl!).origin
|
|
294
302
|
) {
|
|
295
303
|
throw new HTTPException(403, {
|
|
296
304
|
message: "same-origin browser request required",
|
|
297
305
|
});
|
|
298
306
|
}
|
|
299
|
-
|
|
307
|
+
const fetchSite = c.req.header("sec-fetch-site")?.toLowerCase();
|
|
308
|
+
const localFetchSiteMatches =
|
|
309
|
+
localOriginMatches &&
|
|
310
|
+
(fetchSite === "same-origin" ||
|
|
311
|
+
fetchSite === "same-site" ||
|
|
312
|
+
(fetchSite === "cross-site" && localLoopbackOriginMatchesRequest(c, origin)));
|
|
313
|
+
if (
|
|
314
|
+
deps.settings.productAccessMode === "local"
|
|
315
|
+
? !localFetchSiteMatches
|
|
316
|
+
: fetchSite !== "same-origin"
|
|
317
|
+
) {
|
|
300
318
|
throw new HTTPException(403, {
|
|
301
319
|
message: "same-origin fetch metadata required",
|
|
302
320
|
});
|
|
@@ -333,11 +351,35 @@ function localBrowserOriginMatchesRequest(c: Context, value: string | undefined)
|
|
|
333
351
|
}
|
|
334
352
|
return (
|
|
335
353
|
origin.protocol === request.protocol &&
|
|
336
|
-
origin.hostname === request.hostname
|
|
337
|
-
|
|
354
|
+
(origin.hostname === request.hostname ||
|
|
355
|
+
(isLoopbackHostname(origin.hostname) && isLoopbackHostname(request.hostname)))
|
|
338
356
|
);
|
|
339
357
|
}
|
|
340
358
|
|
|
359
|
+
function localLoopbackOriginMatchesRequest(c: Context, value: string | undefined): boolean {
|
|
360
|
+
if (!value) return false;
|
|
361
|
+
try {
|
|
362
|
+
const origin = new URL(value);
|
|
363
|
+
const forwardedProtocol = c.req.header("x-forwarded-proto")?.trim().toLowerCase();
|
|
364
|
+
const protocol = forwardedProtocol ? `${forwardedProtocol}:` : new URL(c.req.url).protocol;
|
|
365
|
+
const forwardedHost = c.req.header("x-forwarded-host") ?? c.req.header("host");
|
|
366
|
+
if (!forwardedHost || /[\s,/?#@\\]/u.test(forwardedHost)) return false;
|
|
367
|
+
const request = new URL(`${protocol}//${forwardedHost}`);
|
|
368
|
+
return (
|
|
369
|
+
origin.origin === value &&
|
|
370
|
+
origin.protocol === request.protocol &&
|
|
371
|
+
isLoopbackHostname(origin.hostname) &&
|
|
372
|
+
isLoopbackHostname(request.hostname)
|
|
373
|
+
);
|
|
374
|
+
} catch {
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function isLoopbackHostname(value: string): boolean {
|
|
380
|
+
return value === "localhost" || value === "[::1]" || /^127(?:\.[0-9]{1,3}){3}$/u.test(value);
|
|
381
|
+
}
|
|
382
|
+
|
|
341
383
|
async function requireRedemptionHuman(
|
|
342
384
|
c: Context,
|
|
343
385
|
deps: ApiRouteDeps,
|
|
@@ -386,11 +428,15 @@ async function requireCodexAppsHuman(
|
|
|
386
428
|
requireSameOriginBrowserMutation(c, deps);
|
|
387
429
|
const human = await managedCookieHuman(c, deps);
|
|
388
430
|
if (!human) {
|
|
389
|
-
throw new HTTPException(401, {
|
|
431
|
+
throw new HTTPException(401, {
|
|
432
|
+
message: "managed browser session required",
|
|
433
|
+
});
|
|
390
434
|
}
|
|
391
435
|
const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
|
|
392
436
|
if (grant.subjectId !== human.subjectId) {
|
|
393
|
-
throw new HTTPException(403, {
|
|
437
|
+
throw new HTTPException(403, {
|
|
438
|
+
message: "managed browser identity mismatch",
|
|
439
|
+
});
|
|
394
440
|
}
|
|
395
441
|
return { human, accountId: grant.accountId };
|
|
396
442
|
}
|
|
@@ -715,10 +761,14 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
715
761
|
const workspaceId = c.req.param("workspaceId");
|
|
716
762
|
const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
|
|
717
763
|
const parsed = z
|
|
718
|
-
.object({
|
|
764
|
+
.object({
|
|
765
|
+
mode: z.enum(["automatic", "workspace", "organization", "disabled"]),
|
|
766
|
+
})
|
|
719
767
|
.safeParse(await c.req.json().catch(() => null));
|
|
720
768
|
if (!parsed.success) {
|
|
721
|
-
throw new HTTPException(400, {
|
|
769
|
+
throw new HTTPException(400, {
|
|
770
|
+
message: "a valid Codex source mode is required",
|
|
771
|
+
});
|
|
722
772
|
}
|
|
723
773
|
try {
|
|
724
774
|
return c.json(
|
|
@@ -795,7 +845,9 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
795
845
|
const organizationId = c.req.param("organizationId");
|
|
796
846
|
requireSameOriginBrowserMutation(c, deps);
|
|
797
847
|
const human = await requireOrganizationCodexHuman(c, deps, organizationId);
|
|
798
|
-
const { state } = (await c.req.json().catch(() => null)) as {
|
|
848
|
+
const { state } = (await c.req.json().catch(() => null)) as {
|
|
849
|
+
state?: string;
|
|
850
|
+
};
|
|
799
851
|
const payload = (state
|
|
800
852
|
? readSignedState(state, githubStateSecret)
|
|
801
853
|
: null) as unknown as CodexConnectState | null;
|
|
@@ -806,7 +858,9 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
806
858
|
!payload.deviceAuthId ||
|
|
807
859
|
!payload.userCode
|
|
808
860
|
) {
|
|
809
|
-
throw new HTTPException(400, {
|
|
861
|
+
throw new HTTPException(400, {
|
|
862
|
+
message: "codex connect state is invalid or expired",
|
|
863
|
+
});
|
|
810
864
|
}
|
|
811
865
|
if (
|
|
812
866
|
typeof payload.iat === "number" &&
|
|
@@ -927,7 +981,9 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
927
981
|
const organizationId = c.req.param("organizationId");
|
|
928
982
|
requireSameOriginBrowserMutation(c, deps);
|
|
929
983
|
const human = await requireOrganizationCodexHuman(c, deps, organizationId);
|
|
930
|
-
const body = (await c.req.json().catch(() => null)) as {
|
|
984
|
+
const body = (await c.req.json().catch(() => null)) as {
|
|
985
|
+
label?: unknown;
|
|
986
|
+
} | null;
|
|
931
987
|
const renamed = await renameOrganizationCodexAccount(db, {
|
|
932
988
|
organizationId,
|
|
933
989
|
actorSubjectId: human.subjectId,
|
|
@@ -967,7 +1023,10 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
967
1023
|
throw error;
|
|
968
1024
|
}
|
|
969
1025
|
await signalCodexCapacityTargets(deps, result.wakeTargets);
|
|
970
|
-
return c.json({
|
|
1026
|
+
return c.json({
|
|
1027
|
+
disconnected: result.removed,
|
|
1028
|
+
newActiveId: result.newActiveCredentialId,
|
|
1029
|
+
});
|
|
971
1030
|
});
|
|
972
1031
|
|
|
973
1032
|
// Begin device-code login: returns the user code + verification URL and a
|
|
@@ -1065,6 +1124,8 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1065
1124
|
upserted: Awaited<ReturnType<typeof upsertCodexSubscriptionCredential>>;
|
|
1066
1125
|
isActive: boolean;
|
|
1067
1126
|
}>(db, { workspaceId, reason: "codex_credential_connected" }, async (tx) => {
|
|
1127
|
+
// The capacity mutation holds the source lock before entering this callback.
|
|
1128
|
+
const sourceBeforeConnect = await getWorkspaceCodexSubscriptionSource(tx, workspaceId);
|
|
1068
1129
|
const upserted = await upsertCodexSubscriptionCredential(tx, {
|
|
1069
1130
|
accountId: grant.accountId,
|
|
1070
1131
|
workspaceId,
|
|
@@ -1092,12 +1153,12 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1092
1153
|
}
|
|
1093
1154
|
await ensureCodexRotationSettings(tx, grant.accountId, workspaceId);
|
|
1094
1155
|
await setInitialActiveCodexCredential(tx, workspaceId, upserted.id);
|
|
1095
|
-
const source = await getWorkspaceCodexSubscriptionSource(tx, workspaceId);
|
|
1096
1156
|
await setWorkspaceCodexSubscriptionModeInTransaction(tx, {
|
|
1097
1157
|
accountId: grant.accountId,
|
|
1098
1158
|
workspaceId,
|
|
1099
1159
|
subjectId: grant.subjectId,
|
|
1100
|
-
mode:
|
|
1160
|
+
mode: sourceBeforeConnect.workspaceKind === "personal" ? "automatic" : "workspace",
|
|
1161
|
+
effectiveSourceBeforeMutation: sourceBeforeConnect.effectiveSource,
|
|
1101
1162
|
});
|
|
1102
1163
|
const rotation = await getCodexRotationSettings(tx, workspaceId);
|
|
1103
1164
|
return {
|
|
@@ -1107,6 +1168,14 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1107
1168
|
},
|
|
1108
1169
|
changed: true,
|
|
1109
1170
|
};
|
|
1171
|
+
}).catch((error: unknown) => {
|
|
1172
|
+
const cause = (error as { cause?: unknown } | null)?.cause;
|
|
1173
|
+
const message =
|
|
1174
|
+
cause instanceof Error ? cause.message : error instanceof Error ? error.message : "";
|
|
1175
|
+
if (message.includes("active turns are using it")) {
|
|
1176
|
+
throw new HTTPException(409, { message });
|
|
1177
|
+
}
|
|
1178
|
+
throw error;
|
|
1110
1179
|
});
|
|
1111
1180
|
const { upserted, isActive } = mutation.result;
|
|
1112
1181
|
if (upserted.kind === "unresolved_redemption") {
|
|
@@ -1116,7 +1185,12 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1116
1185
|
});
|
|
1117
1186
|
}
|
|
1118
1187
|
await signalCodexCapacityTargets(deps, mutation.wakeTargets);
|
|
1119
|
-
return c.json({
|
|
1188
|
+
return c.json({
|
|
1189
|
+
status: "connected",
|
|
1190
|
+
plan: id.planType,
|
|
1191
|
+
accountId: upserted.id,
|
|
1192
|
+
isActive,
|
|
1193
|
+
});
|
|
1120
1194
|
});
|
|
1121
1195
|
|
|
1122
1196
|
// Connection health: the cheapest real call is GET /codex/models (a 200 proves
|
|
@@ -1229,7 +1303,9 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1229
1303
|
const workspaceId = c.req.param("workspaceId");
|
|
1230
1304
|
await requireWorkspaceCodexManagementSource(deps, workspaceId);
|
|
1231
1305
|
if (!settings.codexConnectedAppsEnabled) {
|
|
1232
|
-
throw new HTTPException(409, {
|
|
1306
|
+
throw new HTTPException(409, {
|
|
1307
|
+
message: "Codex Apps is disabled for this deployment",
|
|
1308
|
+
});
|
|
1233
1309
|
}
|
|
1234
1310
|
const { human, accountId } = await requireCodexAppsHuman(c, deps, workspaceId);
|
|
1235
1311
|
const parsed = z
|
|
@@ -1239,7 +1315,9 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1239
1315
|
})
|
|
1240
1316
|
.safeParse(await c.req.json().catch(() => null));
|
|
1241
1317
|
if (!parsed.success) {
|
|
1242
|
-
throw new HTTPException(400, {
|
|
1318
|
+
throw new HTTPException(400, {
|
|
1319
|
+
message: "accountId and expectedVersion are required",
|
|
1320
|
+
});
|
|
1243
1321
|
}
|
|
1244
1322
|
const result = await designateCodexAppsCredential(db, {
|
|
1245
1323
|
accountId,
|
|
@@ -1257,10 +1335,14 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1257
1335
|
});
|
|
1258
1336
|
}
|
|
1259
1337
|
if (result.kind === "forbidden") {
|
|
1260
|
-
throw new HTTPException(403, {
|
|
1338
|
+
throw new HTTPException(403, {
|
|
1339
|
+
message: "missing permission: connections:write",
|
|
1340
|
+
});
|
|
1261
1341
|
}
|
|
1262
1342
|
if (result.kind === "unavailable") {
|
|
1263
|
-
throw new HTTPException(409, {
|
|
1343
|
+
throw new HTTPException(409, {
|
|
1344
|
+
message: "codex account requires relogin",
|
|
1345
|
+
});
|
|
1264
1346
|
}
|
|
1265
1347
|
const response = {
|
|
1266
1348
|
credentialId: result.credentialId,
|
|
@@ -1288,7 +1370,9 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1288
1370
|
expectedVersion: parsed.data.expectedVersion,
|
|
1289
1371
|
});
|
|
1290
1372
|
if (result.kind === "forbidden") {
|
|
1291
|
-
throw new HTTPException(403, {
|
|
1373
|
+
throw new HTTPException(403, {
|
|
1374
|
+
message: "missing permission: connections:write",
|
|
1375
|
+
});
|
|
1292
1376
|
}
|
|
1293
1377
|
const response = {
|
|
1294
1378
|
credentialId: result.credentialId,
|
|
@@ -1345,7 +1429,10 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1345
1429
|
if (patch.rotationEnabled === undefined) {
|
|
1346
1430
|
// Strategy-only writes are a deprecated no-op (no db touch): report the
|
|
1347
1431
|
// (only) truth. Callers that also flip rotationEnabled fall through.
|
|
1348
|
-
return c.json({
|
|
1432
|
+
return c.json({
|
|
1433
|
+
rotationStrategy: "sharded",
|
|
1434
|
+
rotationStrategyDeprecated: true,
|
|
1435
|
+
});
|
|
1349
1436
|
}
|
|
1350
1437
|
await ensureCodexRotationSettings(db, grant.accountId, workspaceId);
|
|
1351
1438
|
const mutation = await withCodexCapacityMutation(
|
|
@@ -1454,7 +1541,10 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1454
1541
|
});
|
|
1455
1542
|
}
|
|
1456
1543
|
await signalCodexCapacityTargets(deps, mutation.wakeTargets);
|
|
1457
|
-
return c.json({
|
|
1544
|
+
return c.json({
|
|
1545
|
+
disconnected: result.removed,
|
|
1546
|
+
newActiveId: result.newActiveCredentialId,
|
|
1547
|
+
});
|
|
1458
1548
|
});
|
|
1459
1549
|
|
|
1460
1550
|
// Legacy "disconnect all" (old workspace-wide behavior), deprecated in favor of
|
|
@@ -1752,10 +1842,14 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1752
1842
|
});
|
|
1753
1843
|
}
|
|
1754
1844
|
if (adoption.kind === "not_found") {
|
|
1755
|
-
throw new HTTPException(409, {
|
|
1845
|
+
throw new HTTPException(409, {
|
|
1846
|
+
message: "redemption recovery state changed",
|
|
1847
|
+
});
|
|
1756
1848
|
}
|
|
1757
1849
|
if (adoption.kind === "forbidden") {
|
|
1758
|
-
throw new HTTPException(403, {
|
|
1850
|
+
throw new HTTPException(403, {
|
|
1851
|
+
message: "redemption owner is unavailable",
|
|
1852
|
+
});
|
|
1759
1853
|
}
|
|
1760
1854
|
if (adoption.kind === "conflict") {
|
|
1761
1855
|
throw new HTTPException(409, {
|
|
@@ -1769,7 +1863,9 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1769
1863
|
// and a lost HTTP response must remain replayable after a later health
|
|
1770
1864
|
// transition without another consume call.
|
|
1771
1865
|
if (account.status !== "active" && existing?.status !== "completed") {
|
|
1772
|
-
throw new HTTPException(403, {
|
|
1866
|
+
throw new HTTPException(403, {
|
|
1867
|
+
message: "redemption credential is unavailable",
|
|
1868
|
+
});
|
|
1773
1869
|
}
|
|
1774
1870
|
const secret = settings.betterAuthSecret;
|
|
1775
1871
|
if (!secret) {
|
|
@@ -1968,13 +2064,21 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1968
2064
|
if (fenced.kind !== "ready") {
|
|
1969
2065
|
if (fenced.reason === "confirmation_expired") {
|
|
1970
2066
|
return c.json(
|
|
1971
|
-
{
|
|
2067
|
+
{
|
|
2068
|
+
status: "confirmation_expired",
|
|
2069
|
+
attemptId: attempt.id,
|
|
2070
|
+
retryable: true,
|
|
2071
|
+
},
|
|
1972
2072
|
403,
|
|
1973
2073
|
);
|
|
1974
2074
|
}
|
|
1975
2075
|
if (fenced.reason === "credential_unavailable") {
|
|
1976
2076
|
return c.json(
|
|
1977
|
-
{
|
|
2077
|
+
{
|
|
2078
|
+
status: "provider_unavailable",
|
|
2079
|
+
attemptId: attempt.id,
|
|
2080
|
+
retryable: true,
|
|
2081
|
+
},
|
|
1978
2082
|
503,
|
|
1979
2083
|
);
|
|
1980
2084
|
}
|
package/src/routes/github.ts
CHANGED
|
@@ -4,6 +4,11 @@ import {
|
|
|
4
4
|
type GitHubInstallationBindingCandidate,
|
|
5
5
|
type GitHubInstallationBindingProof,
|
|
6
6
|
} from "@opengeni/contracts";
|
|
7
|
+
import {
|
|
8
|
+
ListGitHubRepositoryBranchesQuery,
|
|
9
|
+
VerifyPublicGitHubRepositoryRefRequest,
|
|
10
|
+
} from "@opengeni/contracts/github-repository-contracts";
|
|
11
|
+
import { parseCanonicalGitHubRepositoryUrl } from "@opengeni/contracts/github-repository";
|
|
7
12
|
import {
|
|
8
13
|
bindAuthorizedGitHubInstallationRepositories,
|
|
9
14
|
deleteGitHubInstallationBinding,
|
|
@@ -19,12 +24,14 @@ import {
|
|
|
19
24
|
GitHubAppApiError,
|
|
20
25
|
GitHubAppConfigurationError,
|
|
21
26
|
GitHubInstallationAuthorityError,
|
|
27
|
+
GitHubPublicRepositoryVerificationError,
|
|
22
28
|
githubAppMissingSettings,
|
|
23
29
|
githubOAuthAuthorizeUrl,
|
|
24
30
|
organizationAppManifestUrl,
|
|
25
31
|
personalAppManifestUrl,
|
|
26
32
|
readSignedState,
|
|
27
33
|
stateMaxAgeSeconds,
|
|
34
|
+
verifyPublicGitHubRepositoryRef,
|
|
28
35
|
type GitHubSignedStatePayload,
|
|
29
36
|
verifySignedState,
|
|
30
37
|
} from "@opengeni/github";
|
|
@@ -42,7 +49,9 @@ import {
|
|
|
42
49
|
} from "../github-browser-flow";
|
|
43
50
|
import {
|
|
44
51
|
githubBindingStatus,
|
|
52
|
+
GitHubRepositoryBranchAuthorityError,
|
|
45
53
|
listWorkspaceGitHubInstallationBindings,
|
|
54
|
+
listWorkspaceGitHubRepositoryBranches,
|
|
46
55
|
listWorkspaceGitHubRepositories,
|
|
47
56
|
} from "../github-access";
|
|
48
57
|
|
|
@@ -177,6 +186,65 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
177
186
|
}
|
|
178
187
|
});
|
|
179
188
|
|
|
189
|
+
app.post("/v1/workspaces/:workspaceId/github/public-repositories/verify", async (c) => {
|
|
190
|
+
const workspaceId = c.req.param("workspaceId");
|
|
191
|
+
const grant = await requireAccessGrant(c, deps, workspaceId);
|
|
192
|
+
requirePublicGitHubRepositoryVerificationPermission(grant);
|
|
193
|
+
const request = VerifyPublicGitHubRepositoryRefRequest.parse(await c.req.json());
|
|
194
|
+
let repository: ReturnType<typeof parseCanonicalGitHubRepositoryUrl>;
|
|
195
|
+
try {
|
|
196
|
+
repository = parseCanonicalGitHubRepositoryUrl(request.url);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
throw new HTTPException(422, {
|
|
199
|
+
message: error instanceof Error ? error.message : "GitHub repository URL is invalid.",
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
return c.json(
|
|
204
|
+
await verifyPublicGitHubRepositoryRef(
|
|
205
|
+
{ repository, ref: request.ref },
|
|
206
|
+
deps.githubAnonymousFetch ?? fetch,
|
|
207
|
+
),
|
|
208
|
+
);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (error instanceof GitHubPublicRepositoryVerificationError) {
|
|
211
|
+
throw new HTTPException(error.code === "provider_unavailable" ? 503 : 422, {
|
|
212
|
+
message: error.message,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
throw new HTTPException(503, {
|
|
216
|
+
message: "GitHub public repository verification is temporarily unavailable.",
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
app.get(
|
|
222
|
+
"/v1/workspaces/:workspaceId/github/installations/:installationId/repositories/:repositoryId/branches",
|
|
223
|
+
async (c) => {
|
|
224
|
+
const workspaceId = c.req.param("workspaceId");
|
|
225
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "github:use");
|
|
226
|
+
const installationId = parsePositiveInteger(c.req.param("installationId"));
|
|
227
|
+
const repositoryId = parsePositiveInteger(c.req.param("repositoryId"));
|
|
228
|
+
if (installationId === null || repositoryId === null) {
|
|
229
|
+
throw new HTTPException(400, { message: "invalid GitHub repository identity" });
|
|
230
|
+
}
|
|
231
|
+
const query = ListGitHubRepositoryBranchesQuery.parse(c.req.query());
|
|
232
|
+
try {
|
|
233
|
+
return c.json(
|
|
234
|
+
await listWorkspaceGitHubRepositoryBranches(deps, {
|
|
235
|
+
accountId: grant.accountId,
|
|
236
|
+
workspaceId: grant.workspaceId,
|
|
237
|
+
installationId,
|
|
238
|
+
repositoryId,
|
|
239
|
+
query,
|
|
240
|
+
}),
|
|
241
|
+
);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
throw githubRepositoryBranchesRouteError(error);
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
);
|
|
247
|
+
|
|
180
248
|
app.delete("/v1/workspaces/:workspaceId/github/installations/:installationId", async (c) => {
|
|
181
249
|
const workspaceId = c.req.param("workspaceId");
|
|
182
250
|
const grant = await requireAccessGrant(c, deps, workspaceId, "github:manage");
|
|
@@ -184,14 +252,11 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
184
252
|
if (installationId === null) {
|
|
185
253
|
throw new HTTPException(400, { message: "invalid GitHub installation id" });
|
|
186
254
|
}
|
|
187
|
-
|
|
255
|
+
await deleteGitHubInstallationBinding(db, {
|
|
188
256
|
accountId: grant.accountId,
|
|
189
257
|
workspaceId: grant.workspaceId,
|
|
190
258
|
installationId,
|
|
191
259
|
});
|
|
192
|
-
if (!deleted) {
|
|
193
|
-
throw new HTTPException(404, { message: "GitHub installation binding not found" });
|
|
194
|
-
}
|
|
195
260
|
return c.body(null, 204);
|
|
196
261
|
});
|
|
197
262
|
|
|
@@ -749,6 +814,50 @@ function githubAuthorityHttpError(error: unknown): HTTPException {
|
|
|
749
814
|
return new HTTPException(502, { message: "GitHub authority verification failed" });
|
|
750
815
|
}
|
|
751
816
|
|
|
817
|
+
function githubRepositoryBranchesRouteError(error: unknown): Error {
|
|
818
|
+
if (error instanceof HTTPException) {
|
|
819
|
+
return error;
|
|
820
|
+
}
|
|
821
|
+
if (error instanceof GitHubRepositoryBranchAuthorityError) {
|
|
822
|
+
return error.code === "not_authorized"
|
|
823
|
+
? new HTTPException(404, {
|
|
824
|
+
message: "GitHub repository is not authorized for this workspace",
|
|
825
|
+
})
|
|
826
|
+
: new HTTPException(409, {
|
|
827
|
+
message: "GitHub repository authorization changed; refresh and try again",
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
if (error instanceof GitHubAppConfigurationError) {
|
|
831
|
+
return new HTTPException(409, { message: "GitHub App is not configured" });
|
|
832
|
+
}
|
|
833
|
+
if (error instanceof GitHubAppApiError) {
|
|
834
|
+
if (error.status === 429) {
|
|
835
|
+
return new HTTPException(503, { message: "GitHub is temporarily rate limited" });
|
|
836
|
+
}
|
|
837
|
+
if (error.status === 401 || error.status === 403 || error.status === 404) {
|
|
838
|
+
return new HTTPException(409, {
|
|
839
|
+
message: "GitHub repository access changed; refresh and try again",
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
return new HTTPException(502, { message: "GitHub branch discovery is unavailable" });
|
|
843
|
+
}
|
|
844
|
+
return new HTTPException(502, { message: "GitHub branch discovery is unavailable" });
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
export function requirePublicGitHubRepositoryVerificationPermission(
|
|
848
|
+
grant: Pick<AccessGrant, "permissions">,
|
|
849
|
+
): void {
|
|
850
|
+
if (
|
|
851
|
+
hasPermission(grant.permissions, "sessions:create") ||
|
|
852
|
+
hasPermission(grant.permissions, "sessions:control")
|
|
853
|
+
) {
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
throw new HTTPException(403, {
|
|
857
|
+
message: "missing permission: sessions:create or sessions:control",
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
|
|
752
861
|
function isSecureRequest(c: Context, deps: ApiRouteDeps): boolean {
|
|
753
862
|
return (
|
|
754
863
|
deps.settings.publicBaseUrl?.startsWith("https://") ||
|
|
@@ -775,15 +884,15 @@ function githubInstallationChooserHtml(
|
|
|
775
884
|
): string {
|
|
776
885
|
const action = `${baseUrl}/v1/workspaces/${encodeURIComponent(workspaceId)}/github/installations/select`;
|
|
777
886
|
const options = candidates
|
|
778
|
-
.map(({ installation, authorityKind }) => {
|
|
887
|
+
.map(({ installation, authorityKind }, index) => {
|
|
779
888
|
const account = escapeHtml(
|
|
780
889
|
installation.accountLogin ?? `installation ${installation.installationId}`,
|
|
781
890
|
);
|
|
782
891
|
const label = authorityKind === "personal_owner" ? "Personal account" : "Organization owner";
|
|
783
|
-
return `<label class="option"><input type="radio" name="installation_id" value="${installation.installationId}" required><span><strong>${account}</strong><small>${label}</small></span></label>`;
|
|
892
|
+
return `<label class="option"><input type="radio" name="installation_id" value="${installation.installationId}" required${candidates.length === 1 && index === 0 ? " checked" : ""}><span><strong>${account}</strong><small>${label}</small></span></label>`;
|
|
784
893
|
})
|
|
785
894
|
.join("");
|
|
786
|
-
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>
|
|
895
|
+
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>These accounts already have the OpenGeni GitHub App installed and GitHub proved you are the personal owner or an active organization owner.</p><form method="get" 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>`;
|
|
787
896
|
}
|
|
788
897
|
|
|
789
898
|
function githubSetupPendingHtml(): string {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
AcceptOrganizationInvitationRequest,
|
|
3
|
+
CreateAdditionalOrganizationRequest,
|
|
4
|
+
CreateAdditionalOrganizationResponse,
|
|
3
5
|
CreateOrganizationRequest,
|
|
4
6
|
CreateOrganizationResponse,
|
|
5
7
|
CreateOrganizationWorkspaceRequest,
|
|
@@ -40,6 +42,7 @@ import {
|
|
|
40
42
|
acceptOrganizationInvitation,
|
|
41
43
|
bindPendingOrganizationInvitationsForVerifiedEmail,
|
|
42
44
|
claimOrganizationUserSetupDelivery,
|
|
45
|
+
createAdditionalManagedOrganization,
|
|
43
46
|
createManagedOrganization,
|
|
44
47
|
createOrganizationWorkspace,
|
|
45
48
|
createOrganizationInvitation,
|
|
@@ -68,6 +71,7 @@ import {
|
|
|
68
71
|
import type { Context, Hono } from "hono";
|
|
69
72
|
import { HTTPException } from "hono/http-exception";
|
|
70
73
|
import { z } from "zod";
|
|
74
|
+
import { deleteWorkspaceForRequest } from "../workspace-deletion";
|
|
71
75
|
|
|
72
76
|
import {
|
|
73
77
|
assertOrganizationUserSetupDeliveryConfigured,
|
|
@@ -117,7 +121,9 @@ async function requireOrganizationAdministrator(
|
|
|
117
121
|
);
|
|
118
122
|
return { subjectId };
|
|
119
123
|
}
|
|
120
|
-
throw new HTTPException(401, {
|
|
124
|
+
throw new HTTPException(401, {
|
|
125
|
+
message: "organization administrator session required",
|
|
126
|
+
});
|
|
121
127
|
}
|
|
122
128
|
|
|
123
129
|
async function parseBody<S extends z.ZodType>(context: Context, schema: S): Promise<z.infer<S>> {
|
|
@@ -136,8 +142,14 @@ function parseId(schema: z.ZodString, value: string, label: string): string {
|
|
|
136
142
|
return parsed.data;
|
|
137
143
|
}
|
|
138
144
|
|
|
139
|
-
function rethrowMembershipError(error: unknown): never {
|
|
140
|
-
const
|
|
145
|
+
function rethrowMembershipError(error: unknown, resourceLimitMessage?: string): never {
|
|
146
|
+
const sqlState = nestedPostgresSqlState(error);
|
|
147
|
+
if (sqlState === "54000" && resourceLimitMessage) {
|
|
148
|
+
throw new HTTPException(409, {
|
|
149
|
+
message: resourceLimitMessage,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
const status = organizationMembershipHttpStatus(sqlState);
|
|
141
153
|
if (status !== null) {
|
|
142
154
|
throw new HTTPException(status, {
|
|
143
155
|
message:
|
|
@@ -173,6 +185,25 @@ export function registerOrganizationMembershipRoutes(app: Hono, deps: ApiRouteDe
|
|
|
173
185
|
}
|
|
174
186
|
});
|
|
175
187
|
|
|
188
|
+
app.post("/v1/organizations/additional", async (context) => {
|
|
189
|
+
const { session, subjectId } = await requireManagedHuman(context, deps);
|
|
190
|
+
const payload = await parseBody(context, CreateAdditionalOrganizationRequest);
|
|
191
|
+
try {
|
|
192
|
+
return context.json(
|
|
193
|
+
CreateAdditionalOrganizationResponse.parse(
|
|
194
|
+
await createAdditionalManagedOrganization(deps.db, {
|
|
195
|
+
subjectId,
|
|
196
|
+
subjectLabel: session.user.email || session.user.name,
|
|
197
|
+
...payload,
|
|
198
|
+
}),
|
|
199
|
+
),
|
|
200
|
+
201,
|
|
201
|
+
);
|
|
202
|
+
} catch (error) {
|
|
203
|
+
rethrowMembershipError(error, "additional organization limit reached");
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
176
207
|
app.get("/v1/organization-memberships", async (context) => {
|
|
177
208
|
const { session } = await requireManagedHuman(context, deps);
|
|
178
209
|
try {
|
|
@@ -328,6 +359,22 @@ export function registerOrganizationMembershipRoutes(app: Hono, deps: ApiRouteDe
|
|
|
328
359
|
}
|
|
329
360
|
});
|
|
330
361
|
|
|
362
|
+
app.delete("/v1/organizations/:organizationId/workspaces/:workspaceId", async (context) => {
|
|
363
|
+
const organizationId = parseId(
|
|
364
|
+
OrganizationId,
|
|
365
|
+
context.req.param("organizationId"),
|
|
366
|
+
"organization id",
|
|
367
|
+
);
|
|
368
|
+
const { subjectId } = await requireOrganizationAdministrator(context, deps, organizationId);
|
|
369
|
+
const workspaceId = parseId(WorkspaceId, context.req.param("workspaceId"), "workspace id");
|
|
370
|
+
await deleteWorkspaceForRequest(deps, {
|
|
371
|
+
accountId: organizationId,
|
|
372
|
+
workspaceId,
|
|
373
|
+
organizationAdministratorSubjectId: subjectId,
|
|
374
|
+
});
|
|
375
|
+
return context.body(null, 204);
|
|
376
|
+
});
|
|
377
|
+
|
|
331
378
|
app.patch(
|
|
332
379
|
"/v1/organizations/:organizationId/workspaces/:workspaceId/settings",
|
|
333
380
|
async (context) => {
|