@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.
@@ -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, { message: "organization administrator session required" });
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, { message: "organization administration is not authorized" });
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, { message: "Codex is disabled for this workspace" });
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
- ? !localBrowserOriginMatchesRequest(c, origin)
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
- if (c.req.header("sec-fetch-site")?.toLowerCase() !== "same-origin") {
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
- (request.port === "" || origin.port === request.port)
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, { message: "managed browser session required" });
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, { message: "managed browser identity mismatch" });
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({ mode: z.enum(["automatic", "workspace", "organization", "disabled"]) })
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, { message: "a valid Codex source mode is required" });
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 { state?: string };
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, { message: "codex connect state is invalid or expired" });
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 { label?: unknown } | null;
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({ disconnected: result.removed, newActiveId: result.newActiveCredentialId });
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: source.workspaceKind === "personal" ? "automatic" : "workspace",
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({ status: "connected", plan: id.planType, accountId: upserted.id, isActive });
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, { message: "Codex Apps is disabled for this deployment" });
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, { message: "accountId and expectedVersion are required" });
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, { message: "missing permission: connections:write" });
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, { message: "codex account requires relogin" });
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, { message: "missing permission: connections:write" });
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({ rotationStrategy: "sharded", rotationStrategyDeprecated: true });
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({ disconnected: result.removed, newActiveId: result.newActiveCredentialId });
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, { message: "redemption recovery state changed" });
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, { message: "redemption owner is unavailable" });
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, { message: "redemption credential is unavailable" });
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
- { status: "confirmation_expired", attemptId: attempt.id, retryable: true },
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
- { status: "provider_unavailable", attemptId: attempt.id, retryable: true },
2077
+ {
2078
+ status: "provider_unavailable",
2079
+ attemptId: attempt.id,
2080
+ retryable: true,
2081
+ },
1978
2082
  503,
1979
2083
  );
1980
2084
  }
@@ -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
- const deleted = await deleteGitHubInstallationBinding(db, {
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>Only installations where GitHub proved you are the personal owner or an active organization owner are shown.</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>`;
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, { message: "organization administrator session required" });
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 status = organizationMembershipHttpStatus(nestedPostgresSqlState(error));
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) => {