@opengeni/api-router 2.6.4 → 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.
Files changed (43) hide show
  1. package/dist/app.d.ts +2 -1
  2. package/dist/app.js +3 -1
  3. package/dist/{chunk-XTLI3CBH.js → chunk-HHKQMVY7.js} +3009 -1699
  4. package/dist/chunk-HHKQMVY7.js.map +1 -0
  5. package/dist/github-access.d.ts +25 -2
  6. package/dist/index.js +14 -2
  7. package/dist/index.js.map +1 -1
  8. package/dist/integrations/personal-github-repositories.d.ts +41 -2
  9. package/dist/mcp/scheduled-task-view.d.ts +3 -3
  10. package/dist/model-catalog.d.ts +5 -31
  11. package/dist/routes/codex.d.ts +8 -1
  12. package/dist/routes/github.d.ts +2 -0
  13. package/dist/routes/organization-model-providers.d.ts +3 -0
  14. package/dist/workspace-deletion.d.ts +6 -0
  15. package/package.json +18 -18
  16. package/src/app.ts +55 -9
  17. package/src/auth/organization-user-setup.ts +3 -3
  18. package/src/github-access.ts +117 -1
  19. package/src/http/sse.ts +15 -7
  20. package/src/index.ts +13 -1
  21. package/src/integrations/personal-github-repositories.ts +238 -9
  22. package/src/integrations/slack-app-home.ts +2 -2
  23. package/src/integrations/slack-interactions.ts +77 -37
  24. package/src/mcp/company-brain-governed-writes.ts +2 -2
  25. package/src/mcp/company-profile-agent-admin.ts +1 -1
  26. package/src/mcp/remember.ts +1 -1
  27. package/src/mcp/server.ts +11 -2
  28. package/src/model-catalog.ts +45 -337
  29. package/src/routes/automations.ts +178 -19
  30. package/src/routes/codex.ts +136 -32
  31. package/src/routes/connections.ts +271 -16
  32. package/src/routes/github.ts +116 -15
  33. package/src/routes/organization-memberships.ts +50 -3
  34. package/src/routes/organization-model-providers.ts +231 -0
  35. package/src/routes/packs.ts +1 -0
  36. package/src/routes/personal-github.ts +39 -0
  37. package/src/routes/pr-review.ts +72 -4
  38. package/src/routes/scheduled-tasks.ts +40 -13
  39. package/src/routes/sessions.ts +88 -52
  40. package/src/routes/supergrok.ts +3 -2
  41. package/src/routes/workspaces.ts +405 -62
  42. package/src/workspace-deletion.ts +64 -0
  43. package/dist/chunk-XTLI3CBH.js.map +0 -1
@@ -1,4 +1,10 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, createHmac } from "node:crypto";
2
+ import {
3
+ WORKSPACE_OPENROUTER_CONNECTION_DOMAIN,
4
+ WORKSPACE_OPENROUTER_CONNECTION_ROLE,
5
+ VERCEL_AI_GATEWAY_CONNECTION_DOMAIN,
6
+ VERCEL_AI_GATEWAY_CONNECTION_ROLE,
7
+ } from "@opengeni/config";
2
8
  import {
3
9
  ATLASSIAN_PROVIDER_DOMAIN,
4
10
  AtlassianConnectionMetadata,
@@ -21,11 +27,16 @@ import {
21
27
  IntegrationClientMetadata,
22
28
  ListSlackInstallationBindingsResponse,
23
29
  ListConnectionsResponse,
30
+ OPENROUTER_CREDENTIAL_OPERATION_DIGEST_METADATA_KEY,
31
+ OPENROUTER_CREDENTIAL_OPERATION_ID_METADATA_KEY,
24
32
  OpenGeniSlackBotInstallRequest,
25
33
  OpenGeniSlackBotInstallStart,
26
34
  OAuthStartRequest,
27
35
  OAuthStartResponse,
28
36
  UpdateConnectionRequest,
37
+ VERCEL_AI_GATEWAY_CREDENTIAL_OPERATION_DIGEST_METADATA_KEY,
38
+ VERCEL_AI_GATEWAY_CREDENTIAL_OPERATION_ID_METADATA_KEY,
39
+ stableJson,
29
40
  } from "@opengeni/contracts";
30
41
  import {
31
42
  bindConnectorDocumentDestination,
@@ -69,11 +80,17 @@ import {
69
80
  persistSlackBotInstallationWithSuccessAudit,
70
81
  recordSlackBotInstallCallbackFailure,
71
82
  revokeConnection,
83
+ revokeWorkspaceOpenRouterConnections,
84
+ revokeWorkspaceVercelAiGatewayConnections,
72
85
  revokeConnectionWithSlackBotSuccessAudit,
86
+ rotateWorkspaceVercelAiGatewayConnection,
87
+ rotateWorkspaceOpenRouterConnection,
73
88
  SlackBotLifecycleSuccessAuditError,
74
89
  SlackInstallationBindingConflictError,
75
90
  updateConnection,
76
91
  updateSlackBotDocumentDestination,
92
+ upsertWorkspaceVercelAiGatewayConnection,
93
+ upsertWorkspaceOpenRouterConnection,
77
94
  type SlackBotInstallCallbackFailureReason,
78
95
  type SlackBotInstallCallbackFailureStage,
79
96
  } from "@opengeni/db";
@@ -193,18 +210,69 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
193
210
  assertNotDirectGoogleDriveOAuth(providerDomain, payload.kind, payload.metadata);
194
211
  assertNotDirectAtlassianOAuth(providerDomain, payload.kind, payload.metadata);
195
212
  assertNotDirectPersonalGitHubOAuth(providerDomain, payload.kind, payload.metadata);
196
- const connection = await createConnection(db, {
197
- accountId: grant.accountId,
198
- workspaceId,
213
+ const credentialEncrypted = encryptCredentialBundle(key, payload.credential);
214
+ const workspaceProviderKind = workspaceProviderApiKeyConnectionKind({
199
215
  subjectId,
200
216
  providerDomain,
201
217
  kind: payload.kind,
202
- credentialEncrypted: encryptCredentialBundle(key, payload.credential),
203
- grantedScopes: payload.grantedScopes,
204
- expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null,
205
218
  metadata: payload.metadata,
206
- createdBySubjectId: grant.subjectId,
207
219
  });
220
+ const connection = workspaceProviderKind
221
+ ? await (async () => {
222
+ const provider = workspaceProviderApiKeyConnectionSpec(workspaceProviderKind);
223
+ if (!payload.operationId) {
224
+ throw new HTTPException(400, {
225
+ message: `connecting ${provider.label} requires an operationId`,
226
+ });
227
+ }
228
+ const expiresAt = payload.expiresAt ? new Date(payload.expiresAt) : null;
229
+ const metadata = workspaceProviderCredentialMetadata(
230
+ workspaceProviderKind,
231
+ payload.metadata,
232
+ );
233
+ const input = {
234
+ accountId: grant.accountId,
235
+ workspaceId,
236
+ operationId: payload.operationId,
237
+ requestDigest: workspaceProviderCredentialRequestDigest(key, {
238
+ action: "create",
239
+ providerDomain,
240
+ kind: payload.kind,
241
+ subjectId,
242
+ credential: payload.credential,
243
+ grantedScopes: payload.grantedScopes,
244
+ expiresAt: expiresAt?.toISOString() ?? null,
245
+ metadata,
246
+ }),
247
+ credentialEncrypted,
248
+ grantedScopes: payload.grantedScopes,
249
+ expiresAt,
250
+ metadata,
251
+ updatedBySubjectId: grant.subjectId,
252
+ };
253
+ const created =
254
+ workspaceProviderKind === "vercel_gateway"
255
+ ? await upsertWorkspaceVercelAiGatewayConnection(db, input)
256
+ : await upsertWorkspaceOpenRouterConnection(db, input);
257
+ if (!created || created.status === "revoked") {
258
+ throw new HTTPException(409, {
259
+ message: `${provider.label} is already connected; reload before replacing its key`,
260
+ });
261
+ }
262
+ return created;
263
+ })()
264
+ : await createConnection(db, {
265
+ accountId: grant.accountId,
266
+ workspaceId,
267
+ subjectId,
268
+ providerDomain,
269
+ kind: payload.kind,
270
+ credentialEncrypted,
271
+ grantedScopes: payload.grantedScopes,
272
+ expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null,
273
+ metadata: payload.metadata,
274
+ createdBySubjectId: grant.subjectId,
275
+ });
208
276
  return c.json(ConnectionResponse.parse({ connection }), 201);
209
277
  });
210
278
 
@@ -794,6 +862,24 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
794
862
  payload.providerDomain ?? existing.providerDomain,
795
863
  );
796
864
  const kind = payload.kind ?? existing.kind;
865
+ const subjectId =
866
+ payload.subjectId === undefined
867
+ ? existing.subjectId
868
+ : writableSubjectId(payload.subjectId, grant.subjectId);
869
+ const targetWorkspaceProviderKind = workspaceProviderApiKeyConnectionKind({
870
+ subjectId,
871
+ providerDomain,
872
+ kind,
873
+ metadata: payload.metadata ?? existing.metadata,
874
+ });
875
+ if (!workspaceProviderApiKeyConnectionKind(existing) && targetWorkspaceProviderKind) {
876
+ throw new HTTPException(422, {
877
+ message:
878
+ targetWorkspaceProviderKind === "vercel_gateway"
879
+ ? "use the Vercel AI Gateway connect flow to create this connection"
880
+ : "use the OpenRouter connect flow to create this connection",
881
+ });
882
+ }
797
883
  assertNotDirectPersonalSlackOAuth(providerDomain, kind);
798
884
  assertNotDirectGoogleDriveOAuth(providerDomain, kind, payload.metadata ?? existing.metadata);
799
885
  assertNotDirectAtlassianOAuth(providerDomain, kind, payload.metadata ?? existing.metadata);
@@ -819,6 +905,86 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
819
905
  });
820
906
  }
821
907
  }
908
+ const existingWorkspaceProviderKind = existing
909
+ ? workspaceProviderApiKeyConnectionKind(existing)
910
+ : null;
911
+ if (existing && existingWorkspaceProviderKind) {
912
+ const provider = workspaceProviderApiKeyConnectionSpec(existingWorkspaceProviderKind);
913
+ const providerDomain = canonicalProviderDomain(
914
+ payload.providerDomain ?? existing.providerDomain,
915
+ );
916
+ const subjectId =
917
+ payload.subjectId === undefined
918
+ ? existing.subjectId
919
+ : writableSubjectId(payload.subjectId, grant.subjectId);
920
+ const kind = payload.kind ?? existing.kind;
921
+ if (
922
+ subjectId !== null ||
923
+ providerDomain !== provider.providerDomain ||
924
+ kind !== "api_key" ||
925
+ (payload.metadata?.credentialRole !== undefined &&
926
+ payload.metadata.credentialRole !== provider.credentialRole)
927
+ ) {
928
+ throw new HTTPException(422, {
929
+ message: `${provider.label} connection identity cannot be changed`,
930
+ });
931
+ }
932
+ if (payload.credential === undefined) {
933
+ throw new HTTPException(400, {
934
+ message: `updating a ${provider.label} connection requires a new credential`,
935
+ });
936
+ }
937
+ if (payload.expectedVersion === undefined || payload.operationId === undefined) {
938
+ throw new HTTPException(400, {
939
+ message: `updating a ${provider.label} connection requires expectedVersion and operationId`,
940
+ });
941
+ }
942
+ const key = requireEnvironmentEncryption(settings);
943
+ const grantedScopes = payload.grantedScopes ?? existing.grantedScopes;
944
+ const expiresAt =
945
+ payload.expiresAt !== undefined
946
+ ? payload.expiresAt
947
+ ? new Date(payload.expiresAt)
948
+ : null
949
+ : existing.expiresAt
950
+ ? new Date(existing.expiresAt)
951
+ : null;
952
+ const metadata = workspaceProviderCredentialMetadata(existingWorkspaceProviderKind, {
953
+ ...existing.metadata,
954
+ ...(payload.metadata ?? {}),
955
+ });
956
+ const input = {
957
+ accountId: grant.accountId,
958
+ workspaceId,
959
+ connectionId: existing.id,
960
+ expectedVersion: payload.expectedVersion,
961
+ operationId: payload.operationId,
962
+ requestDigest: workspaceProviderCredentialRequestDigest(key, {
963
+ action: "rotate",
964
+ connectionId: existing.id,
965
+ expectedVersion: payload.expectedVersion,
966
+ credential: payload.credential,
967
+ grantedScopes,
968
+ expiresAt: expiresAt?.toISOString() ?? null,
969
+ metadata,
970
+ }),
971
+ credentialEncrypted: encryptCredentialBundle(key, payload.credential),
972
+ grantedScopes,
973
+ expiresAt,
974
+ metadata,
975
+ updatedBySubjectId: grant.subjectId,
976
+ };
977
+ const connection =
978
+ existingWorkspaceProviderKind === "vercel_gateway"
979
+ ? await rotateWorkspaceVercelAiGatewayConnection(db, input)
980
+ : await rotateWorkspaceOpenRouterConnection(db, input);
981
+ if (!connection) {
982
+ throw new HTTPException(409, {
983
+ message: `${provider.label} connection changed; reload before replacing its key`,
984
+ });
985
+ }
986
+ return c.json(ConnectionResponse.parse({ connection }));
987
+ }
822
988
  const key = payload.credential === undefined ? null : requireEnvironmentEncryption(settings);
823
989
  const subjectId =
824
990
  payload.subjectId === undefined
@@ -879,6 +1045,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
879
1045
  existing.providerDomain === ATLASSIAN_PROVIDER_DOMAIN &&
880
1046
  existing.kind === "oauth2" &&
881
1047
  AtlassianConnectionMetadata.safeParse(existing.metadata).success;
1048
+ const workspaceProviderKind = workspaceProviderApiKeyConnectionKind(existing);
882
1049
  const disconnectPayload = await c.req.json().catch(() => null);
883
1050
  const googleDriveDisconnect = isGoogleDrive
884
1051
  ? GoogleDriveDisconnectRequest.safeParse(disconnectPayload)
@@ -909,7 +1076,13 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
909
1076
  "invalid personal GitHub disconnect request",
910
1077
  });
911
1078
  }
912
- if (existing.status === "revoked" && !isGoogleDrive && !isAtlassian && !isPersonalGitHub) {
1079
+ if (
1080
+ existing.status === "revoked" &&
1081
+ !isGoogleDrive &&
1082
+ !isAtlassian &&
1083
+ !isPersonalGitHub &&
1084
+ !workspaceProviderKind
1085
+ ) {
913
1086
  return c.json(ConnectionResponse.parse({ connection: existing }));
914
1087
  }
915
1088
  const connection = isGoogleDrive
@@ -944,13 +1117,29 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
944
1117
  credentialLabel: OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
945
1118
  slackTeamId: openGeniSlackBotMetadata(existing.metadata)!.slackTeamId,
946
1119
  })
947
- : await revokeConnection(
948
- db,
949
- workspaceId,
950
- connectionId,
951
- grant.subjectId,
952
- existing.version,
953
- );
1120
+ : workspaceProviderKind
1121
+ ? workspaceProviderKind === "vercel_gateway"
1122
+ ? await revokeWorkspaceVercelAiGatewayConnections(db, {
1123
+ accountId: grant.accountId,
1124
+ workspaceId,
1125
+ connectionId,
1126
+ expectedVersion: existing.version,
1127
+ updatedBySubjectId: grant.subjectId,
1128
+ })
1129
+ : await revokeWorkspaceOpenRouterConnections(db, {
1130
+ accountId: grant.accountId,
1131
+ workspaceId,
1132
+ connectionId,
1133
+ expectedVersion: existing.version,
1134
+ updatedBySubjectId: grant.subjectId,
1135
+ })
1136
+ : await revokeConnection(
1137
+ db,
1138
+ workspaceId,
1139
+ connectionId,
1140
+ grant.subjectId,
1141
+ existing.version,
1142
+ );
954
1143
  if (!connection) {
955
1144
  throw new HTTPException(409, { message: "connection changed during disconnect; try again" });
956
1145
  }
@@ -1367,6 +1556,51 @@ function assertNotReservedPersonalGitHubMetadata(
1367
1556
  }
1368
1557
  }
1369
1558
 
1559
+ type WorkspaceProviderApiKeyConnectionKind = "vercel_gateway" | "openrouter";
1560
+
1561
+ function workspaceProviderApiKeyConnectionSpec(
1562
+ providerKind: WorkspaceProviderApiKeyConnectionKind,
1563
+ ): {
1564
+ providerDomain: string;
1565
+ credentialRole: string;
1566
+ label: string;
1567
+ } {
1568
+ return providerKind === "vercel_gateway"
1569
+ ? {
1570
+ providerDomain: VERCEL_AI_GATEWAY_CONNECTION_DOMAIN,
1571
+ credentialRole: VERCEL_AI_GATEWAY_CONNECTION_ROLE,
1572
+ label: "Vercel AI Gateway",
1573
+ }
1574
+ : {
1575
+ providerDomain: WORKSPACE_OPENROUTER_CONNECTION_DOMAIN,
1576
+ credentialRole: WORKSPACE_OPENROUTER_CONNECTION_ROLE,
1577
+ label: "OpenRouter",
1578
+ };
1579
+ }
1580
+
1581
+ function workspaceProviderApiKeyConnectionKind(input: {
1582
+ subjectId?: string | null;
1583
+ providerDomain: string;
1584
+ kind: string;
1585
+ metadata?: Record<string, unknown>;
1586
+ }): WorkspaceProviderApiKeyConnectionKind | null {
1587
+ if (input.subjectId != null || input.kind !== "api_key") return null;
1588
+ const providerDomain = input.providerDomain.toLowerCase();
1589
+ if (
1590
+ providerDomain === VERCEL_AI_GATEWAY_CONNECTION_DOMAIN &&
1591
+ input.metadata?.credentialRole === VERCEL_AI_GATEWAY_CONNECTION_ROLE
1592
+ ) {
1593
+ return "vercel_gateway";
1594
+ }
1595
+ if (
1596
+ providerDomain === WORKSPACE_OPENROUTER_CONNECTION_DOMAIN &&
1597
+ input.metadata?.credentialRole === WORKSPACE_OPENROUTER_CONNECTION_ROLE
1598
+ ) {
1599
+ return "openrouter";
1600
+ }
1601
+ return null;
1602
+ }
1603
+
1370
1604
  function assertNotReservedSlackBotMetadata(metadata: Record<string, unknown> | undefined): void {
1371
1605
  if (hasReservedOpenGeniSlackBotMetadata(metadata)) {
1372
1606
  throw new HTTPException(422, {
@@ -1426,6 +1660,27 @@ function encryptCredentialBundle(key: Uint8Array, credential: Record<string, unk
1426
1660
  return encryptEnvironmentValue(key, JSON.stringify(credential));
1427
1661
  }
1428
1662
 
1663
+ function workspaceProviderCredentialMetadata(
1664
+ providerKind: WorkspaceProviderApiKeyConnectionKind,
1665
+ metadata: Record<string, unknown> | undefined,
1666
+ ): Record<string, unknown> {
1667
+ const {
1668
+ [OPENROUTER_CREDENTIAL_OPERATION_ID_METADATA_KEY]: _openRouterOperationId,
1669
+ [OPENROUTER_CREDENTIAL_OPERATION_DIGEST_METADATA_KEY]: _openRouterOperationDigest,
1670
+ [VERCEL_AI_GATEWAY_CREDENTIAL_OPERATION_ID_METADATA_KEY]: _operationId,
1671
+ [VERCEL_AI_GATEWAY_CREDENTIAL_OPERATION_DIGEST_METADATA_KEY]: _operationDigest,
1672
+ ...effectiveMetadata
1673
+ } = metadata ?? {};
1674
+ return {
1675
+ ...effectiveMetadata,
1676
+ credentialRole: workspaceProviderApiKeyConnectionSpec(providerKind).credentialRole,
1677
+ };
1678
+ }
1679
+
1680
+ function workspaceProviderCredentialRequestDigest(key: Uint8Array, value: unknown): string {
1681
+ return createHmac("sha256", key).update(stableJson(value), "utf8").digest("hex");
1682
+ }
1683
+
1429
1684
  function slackBotCredentialBundle(token: string): Record<string, unknown> {
1430
1685
  const headerName = ["author", "ization"].join("");
1431
1686
  const scheme = ["Bear", "er"].join("");
@@ -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
 
@@ -428,14 +493,6 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
428
493
  if (candidates.length === 0) {
429
494
  return redirectToGitHubInstallation(c, deps, selectionState);
430
495
  }
431
- if (candidates.length === 1) {
432
- return redirectToExactGitHubAuthorization(
433
- c,
434
- deps,
435
- selectionState,
436
- candidates[0]!.installation.installationId,
437
- );
438
- }
439
496
  setGitHubStateCookie(c, deps, selectionState);
440
497
  return c.html(
441
498
  githubInstallationChooserHtml(
@@ -757,6 +814,50 @@ function githubAuthorityHttpError(error: unknown): HTTPException {
757
814
  return new HTTPException(502, { message: "GitHub authority verification failed" });
758
815
  }
759
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
+
760
861
  function isSecureRequest(c: Context, deps: ApiRouteDeps): boolean {
761
862
  return (
762
863
  deps.settings.publicBaseUrl?.startsWith("https://") ||
@@ -783,15 +884,15 @@ function githubInstallationChooserHtml(
783
884
  ): string {
784
885
  const action = `${baseUrl}/v1/workspaces/${encodeURIComponent(workspaceId)}/github/installations/select`;
785
886
  const options = candidates
786
- .map(({ installation, authorityKind }) => {
887
+ .map(({ installation, authorityKind }, index) => {
787
888
  const account = escapeHtml(
788
889
  installation.accountLogin ?? `installation ${installation.installationId}`,
789
890
  );
790
891
  const label = authorityKind === "personal_owner" ? "Personal account" : "Organization owner";
791
- 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>`;
792
893
  })
793
894
  .join("");
794
- 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>`;
795
896
  }
796
897
 
797
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) => {