@opengeni/api-router 2.6.4 → 2.10.0-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 (75) hide show
  1. package/dist/access-grant-rls.d.ts +3 -0
  2. package/dist/app.d.ts +2 -1
  3. package/dist/app.js +3 -1
  4. package/dist/auth/organization-user-setup.d.ts +23 -0
  5. package/dist/{chunk-XTLI3CBH.js → chunk-XXH7DW34.js} +6866 -3554
  6. package/dist/chunk-XXH7DW34.js.map +1 -0
  7. package/dist/codemode.d.ts +6 -0
  8. package/dist/github-access.d.ts +25 -2
  9. package/dist/http/request-source.d.ts +14 -0
  10. package/dist/index.js +19 -3
  11. package/dist/index.js.map +1 -1
  12. package/dist/integrations/oauth-client.d.ts +2 -11
  13. package/dist/integrations/personal-github-repositories.d.ts +41 -2
  14. package/dist/integrations/slack-interactions.d.ts +1 -1
  15. package/dist/mcp/server.d.ts +158 -1
  16. package/dist/mcp/session-view.d.ts +56 -2
  17. package/dist/mcp/session-wait.d.ts +1 -1
  18. package/dist/mcp-oauth.d.ts +19 -0
  19. package/dist/model-catalog.d.ts +5 -31
  20. package/dist/routes/codex.d.ts +27 -2
  21. package/dist/routes/github.d.ts +2 -0
  22. package/dist/routes/organization-model-providers.d.ts +3 -0
  23. package/dist/routes/workspace-artifacts.d.ts +2 -1
  24. package/dist/work-discovery-observability.d.ts +1 -1
  25. package/dist/workspace-artifact-content.d.ts +28 -0
  26. package/dist/workspace-artifact-provenance.d.ts +7 -0
  27. package/dist/workspace-deletion.d.ts +6 -0
  28. package/dist/workspace-tool-gateway-observability.d.ts +17 -0
  29. package/dist/workspace-tool-gateway.d.ts +118 -0
  30. package/package.json +19 -18
  31. package/src/access-grant-rls.ts +40 -0
  32. package/src/app.ts +296 -53
  33. package/src/auth/organization-user-setup.ts +95 -5
  34. package/src/codemode.ts +33 -4
  35. package/src/github-access.ts +117 -1
  36. package/src/http/auth.ts +11 -0
  37. package/src/http/request-source.ts +37 -0
  38. package/src/http/sse.ts +15 -7
  39. package/src/index.ts +18 -2
  40. package/src/integrations/oauth-client.ts +168 -203
  41. package/src/integrations/personal-github-repositories.ts +238 -9
  42. package/src/integrations/slack-app-home.ts +2 -2
  43. package/src/integrations/slack-interactions.ts +77 -37
  44. package/src/mcp/company-brain-governed-writes.ts +2 -2
  45. package/src/mcp/company-profile-agent-admin.ts +1 -1
  46. package/src/mcp/remember.ts +1 -1
  47. package/src/mcp/server.ts +504 -133
  48. package/src/mcp/session-view.ts +20 -1
  49. package/src/mcp/session-wait.ts +3 -0
  50. package/src/mcp-oauth.ts +539 -0
  51. package/src/model-catalog.ts +45 -337
  52. package/src/routes/automations.ts +178 -19
  53. package/src/routes/codex.ts +242 -73
  54. package/src/routes/connections.ts +271 -16
  55. package/src/routes/documents.ts +67 -19
  56. package/src/routes/files.ts +54 -6
  57. package/src/routes/github.ts +116 -15
  58. package/src/routes/organization-memberships.ts +59 -16
  59. package/src/routes/organization-model-providers.ts +231 -0
  60. package/src/routes/packs.ts +1 -0
  61. package/src/routes/personal-github.ts +39 -0
  62. package/src/routes/pr-review.ts +72 -4
  63. package/src/routes/scheduled-tasks.ts +40 -13
  64. package/src/routes/sessions.ts +153 -65
  65. package/src/routes/supergrok.ts +3 -2
  66. package/src/routes/workspace-artifacts.ts +176 -67
  67. package/src/routes/workspaces.ts +405 -62
  68. package/src/sandbox/channel-a.ts +17 -4
  69. package/src/work-discovery-observability.ts +3 -3
  70. package/src/workspace-artifact-content.ts +163 -0
  71. package/src/workspace-artifact-provenance.ts +104 -0
  72. package/src/workspace-deletion.ts +64 -0
  73. package/src/workspace-tool-gateway-observability.ts +100 -0
  74. package/src/workspace-tool-gateway.ts +691 -0
  75. package/dist/chunk-XTLI3CBH.js.map +0 -1
@@ -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,12 +71,11 @@ 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,
74
- deriveOrganizationUserSetupToken,
75
- organizationUserSetupPayloadDigest,
76
- renderOrganizationUserSetupEmail,
78
+ resolveOrganizationUserSetupDeliveryEmail,
77
79
  } from "../auth/organization-user-setup";
78
80
 
79
81
  const OrganizationId = z.string().uuid();
@@ -117,7 +119,9 @@ async function requireOrganizationAdministrator(
117
119
  );
118
120
  return { subjectId };
119
121
  }
120
- throw new HTTPException(401, { message: "organization administrator session required" });
122
+ throw new HTTPException(401, {
123
+ message: "organization administrator session required",
124
+ });
121
125
  }
122
126
 
123
127
  async function parseBody<S extends z.ZodType>(context: Context, schema: S): Promise<z.infer<S>> {
@@ -136,8 +140,14 @@ function parseId(schema: z.ZodString, value: string, label: string): string {
136
140
  return parsed.data;
137
141
  }
138
142
 
139
- function rethrowMembershipError(error: unknown): never {
140
- const status = organizationMembershipHttpStatus(nestedPostgresSqlState(error));
143
+ function rethrowMembershipError(error: unknown, resourceLimitMessage?: string): never {
144
+ const sqlState = nestedPostgresSqlState(error);
145
+ if (sqlState === "54000" && resourceLimitMessage) {
146
+ throw new HTTPException(409, {
147
+ message: resourceLimitMessage,
148
+ });
149
+ }
150
+ const status = organizationMembershipHttpStatus(sqlState);
141
151
  if (status !== null) {
142
152
  throw new HTTPException(status, {
143
153
  message:
@@ -173,6 +183,25 @@ export function registerOrganizationMembershipRoutes(app: Hono, deps: ApiRouteDe
173
183
  }
174
184
  });
175
185
 
186
+ app.post("/v1/organizations/additional", async (context) => {
187
+ const { session, subjectId } = await requireManagedHuman(context, deps);
188
+ const payload = await parseBody(context, CreateAdditionalOrganizationRequest);
189
+ try {
190
+ return context.json(
191
+ CreateAdditionalOrganizationResponse.parse(
192
+ await createAdditionalManagedOrganization(deps.db, {
193
+ subjectId,
194
+ subjectLabel: session.user.email || session.user.name,
195
+ ...payload,
196
+ }),
197
+ ),
198
+ 201,
199
+ );
200
+ } catch (error) {
201
+ rethrowMembershipError(error, "additional organization limit reached");
202
+ }
203
+ });
204
+
176
205
  app.get("/v1/organization-memberships", async (context) => {
177
206
  const { session } = await requireManagedHuman(context, deps);
178
207
  try {
@@ -328,6 +357,22 @@ export function registerOrganizationMembershipRoutes(app: Hono, deps: ApiRouteDe
328
357
  }
329
358
  });
330
359
 
360
+ app.delete("/v1/organizations/:organizationId/workspaces/:workspaceId", async (context) => {
361
+ const organizationId = parseId(
362
+ OrganizationId,
363
+ context.req.param("organizationId"),
364
+ "organization id",
365
+ );
366
+ const { subjectId } = await requireOrganizationAdministrator(context, deps, organizationId);
367
+ const workspaceId = parseId(WorkspaceId, context.req.param("workspaceId"), "workspace id");
368
+ await deleteWorkspaceForRequest(deps, {
369
+ accountId: organizationId,
370
+ workspaceId,
371
+ organizationAdministratorSubjectId: subjectId,
372
+ });
373
+ return context.body(null, 204);
374
+ });
375
+
331
376
  app.patch(
332
377
  "/v1/organizations/:organizationId/workspaces/:workspaceId/settings",
333
378
  async (context) => {
@@ -768,18 +813,18 @@ async function deliverOrganizationUserSetup(
768
813
  ) {
769
814
  const claim = await claimOrganizationUserSetupDelivery(deps.db, input);
770
815
  if (!claim.claimed) return claim.delivery;
771
- const setup = await deriveOrganizationUserSetupToken(deps.settings, {
816
+ const prepared = await resolveOrganizationUserSetupDeliveryEmail(deps.settings, {
772
817
  invitationId: claim.invitationId,
773
818
  deliveryId: claim.delivery.id,
774
- });
775
- const message = renderOrganizationUserSetupEmail({
776
819
  senderEmail: deps.managedEmailTransport.sender,
777
820
  recipientEmail: claim.recipientEmail,
778
821
  recipientName: claim.recipientName,
779
822
  organizationName: claim.organizationName,
780
823
  organizationRole: claim.organizationRole,
781
824
  sharedWorkspaceAccess: claim.sharedWorkspaceAccess,
782
- setupUrl: setup.url,
825
+ providerIdempotencyScope: deps.managedEmailTransport.idempotency.scope,
826
+ frozenTransport: claim.setupTokenTransport,
827
+ frozenPayloadDigest: claim.payloadDigest,
783
828
  });
784
829
  await prepareOrganizationUserSetupDelivery(deps.db, {
785
830
  organizationId: input.organizationId,
@@ -787,11 +832,9 @@ async function deliverOrganizationUserSetup(
787
832
  deliveryId: claim.delivery.id,
788
833
  attemptId: claim.attemptId,
789
834
  claimHolderId: claim.claimHolderId,
790
- tokenDigest: setup.digest,
791
- payloadDigest: await organizationUserSetupPayloadDigest({
792
- ...message,
793
- providerIdempotencyScope: deps.managedEmailTransport.idempotency.scope,
794
- }),
835
+ tokenDigest: prepared.tokenDigest,
836
+ payloadDigest: prepared.payloadDigest,
837
+ setupTokenTransport: prepared.transport,
795
838
  providerIdempotencyScope: deps.managedEmailTransport.idempotency.scope,
796
839
  providerIdempotencyRetentionSeconds: deps.managedEmailTransport.idempotency.retentionSeconds,
797
840
  });
@@ -801,7 +844,7 @@ async function deliverOrganizationUserSetup(
801
844
  try {
802
845
  outcome = await deps.managedEmailTransport.send({
803
846
  kind: "organization_user_setup",
804
- ...message,
847
+ ...prepared.message,
805
848
  idempotencyKey: claim.providerKey,
806
849
  });
807
850
  } catch {
@@ -0,0 +1,231 @@
1
+ import {
2
+ CreateOrganizationProviderCustomModelRequest,
3
+ DeleteOrganizationProviderCustomModelRequest,
4
+ OrganizationModelProviderConnectionResponse,
5
+ OrganizationModelProviderKind,
6
+ OrganizationProviderCustomModel,
7
+ OrganizationProviderCustomModelsResponse,
8
+ RevokeOrganizationModelProviderConnectionRequest,
9
+ UpsertOrganizationModelProviderConnectionRequest,
10
+ } from "@opengeni/contracts";
11
+ import { requireEnvironmentEncryption, type ApiRouteDeps } from "@opengeni/core";
12
+ import {
13
+ createOrganizationModelProviderCustomModel,
14
+ encryptEnvironmentValue,
15
+ getOrganizationModelProviderConnection,
16
+ listOrganizationModelProviderCustomModels,
17
+ organizationModelProviderCredentialDigest,
18
+ OrganizationModelProviderConflictError,
19
+ OrganizationModelProviderLimitError,
20
+ retireOrganizationModelProviderCustomModel,
21
+ revokeOrganizationModelProviderConnection,
22
+ upsertOrganizationModelProviderConnection,
23
+ } from "@opengeni/db";
24
+ import type { Context, Hono } from "hono";
25
+ import { HTTPException } from "hono/http-exception";
26
+ import { z } from "zod";
27
+
28
+ import { requireOrganizationCodexHuman, requireSameOriginBrowserMutation } from "./codex";
29
+
30
+ const OrganizationId = z.string().uuid();
31
+
32
+ function parseOrganizationId(value: string): string {
33
+ const parsed = OrganizationId.safeParse(value);
34
+ if (!parsed.success) throw new HTTPException(404, { message: "organization not found" });
35
+ return parsed.data;
36
+ }
37
+
38
+ async function jsonBody<T>(c: Context, schema: z.ZodType<T>, message: string): Promise<T> {
39
+ const parsed = schema.safeParse(await c.req.json().catch(() => null));
40
+ if (!parsed.success) throw new HTTPException(422, { message });
41
+ return parsed.data;
42
+ }
43
+
44
+ function providerKind(value: string) {
45
+ const parsed = OrganizationModelProviderKind.safeParse(value);
46
+ if (!parsed.success) throw new HTTPException(404, { message: "model provider not found" });
47
+ return parsed.data;
48
+ }
49
+
50
+ function connectionJson(connection: {
51
+ providerKind: "vercel_gateway" | "openrouter";
52
+ status: "active" | "revoked";
53
+ version: number;
54
+ createdAt: Date;
55
+ updatedAt: Date;
56
+ }) {
57
+ return OrganizationModelProviderConnectionResponse.parse({
58
+ ...connection,
59
+ createdAt: connection.createdAt.toISOString(),
60
+ updatedAt: connection.updatedAt.toISOString(),
61
+ });
62
+ }
63
+
64
+ function modelJson(model: {
65
+ id: string;
66
+ upstreamModelId: string;
67
+ label: string | null;
68
+ version: number;
69
+ createdAt: Date;
70
+ updatedAt: Date;
71
+ }) {
72
+ return OrganizationProviderCustomModel.parse({
73
+ ...model,
74
+ createdAt: model.createdAt.toISOString(),
75
+ updatedAt: model.updatedAt.toISOString(),
76
+ });
77
+ }
78
+
79
+ function conflict(error: unknown): never {
80
+ if (error instanceof OrganizationModelProviderConflictError) {
81
+ throw new HTTPException(409, { message: "organization model provider version conflict" });
82
+ }
83
+ if (error instanceof OrganizationModelProviderLimitError) {
84
+ throw new HTTPException(409, { message: "organization custom model limit reached" });
85
+ }
86
+ throw error;
87
+ }
88
+
89
+ export function registerOrganizationModelProviderRoutes(app: Hono, deps: ApiRouteDeps): void {
90
+ app.get("/v1/organizations/:organizationId/model-providers/:providerKind", async (c) => {
91
+ c.header("cache-control", "private, no-store");
92
+ const organizationId = parseOrganizationId(c.req.param("organizationId"));
93
+ const human = await requireOrganizationCodexHuman(c, deps, organizationId);
94
+ const connection = await getOrganizationModelProviderConnection(deps.db, {
95
+ organizationId,
96
+ actorSubjectId: human.subjectId,
97
+ providerKind: providerKind(c.req.param("providerKind")),
98
+ });
99
+ return connection ? c.json(connectionJson(connection)) : c.json(null);
100
+ });
101
+
102
+ app.put("/v1/organizations/:organizationId/model-providers/:providerKind", async (c) => {
103
+ c.header("cache-control", "private, no-store");
104
+ requireSameOriginBrowserMutation(c, deps);
105
+ const organizationId = parseOrganizationId(c.req.param("organizationId"));
106
+ const human = await requireOrganizationCodexHuman(c, deps, organizationId);
107
+ const payload = await jsonBody(
108
+ c,
109
+ UpsertOrganizationModelProviderConnectionRequest,
110
+ "invalid organization model provider connection",
111
+ );
112
+ try {
113
+ const connection = await upsertOrganizationModelProviderConnection(deps.db, {
114
+ organizationId,
115
+ actorSubjectId: human.subjectId,
116
+ providerKind: providerKind(c.req.param("providerKind")),
117
+ credentialEncrypted: encryptEnvironmentValue(
118
+ requireEnvironmentEncryption(deps.settings),
119
+ payload.apiKey,
120
+ ),
121
+ credentialDigest: organizationModelProviderCredentialDigest(payload.apiKey),
122
+ operationId: payload.operationId,
123
+ ...(payload.expectedVersion === undefined
124
+ ? {}
125
+ : { expectedVersion: payload.expectedVersion }),
126
+ });
127
+ return c.json(connectionJson(connection));
128
+ } catch (error) {
129
+ conflict(error);
130
+ }
131
+ });
132
+
133
+ app.delete("/v1/organizations/:organizationId/model-providers/:providerKind", async (c) => {
134
+ c.header("cache-control", "private, no-store");
135
+ requireSameOriginBrowserMutation(c, deps);
136
+ const organizationId = parseOrganizationId(c.req.param("organizationId"));
137
+ const human = await requireOrganizationCodexHuman(c, deps, organizationId);
138
+ const payload = await jsonBody(
139
+ c,
140
+ RevokeOrganizationModelProviderConnectionRequest,
141
+ "invalid organization model provider disconnect",
142
+ );
143
+ try {
144
+ const connection = await revokeOrganizationModelProviderConnection(deps.db, {
145
+ organizationId,
146
+ actorSubjectId: human.subjectId,
147
+ providerKind: providerKind(c.req.param("providerKind")),
148
+ ...payload,
149
+ });
150
+ return c.json(connectionJson(connection));
151
+ } catch (error) {
152
+ conflict(error);
153
+ }
154
+ });
155
+
156
+ app.get(
157
+ "/v1/organizations/:organizationId/model-providers/:providerKind/custom-models",
158
+ async (c) => {
159
+ c.header("cache-control", "private, no-store");
160
+ const organizationId = parseOrganizationId(c.req.param("organizationId"));
161
+ const human = await requireOrganizationCodexHuman(c, deps, organizationId);
162
+ const models = await listOrganizationModelProviderCustomModels(deps.db, {
163
+ organizationId,
164
+ actorSubjectId: human.subjectId,
165
+ providerKind: providerKind(c.req.param("providerKind")),
166
+ });
167
+ return c.json(
168
+ OrganizationProviderCustomModelsResponse.parse({ models: models.map(modelJson) }),
169
+ );
170
+ },
171
+ );
172
+
173
+ app.post(
174
+ "/v1/organizations/:organizationId/model-providers/:providerKind/custom-models",
175
+ async (c) => {
176
+ c.header("cache-control", "private, no-store");
177
+ requireSameOriginBrowserMutation(c, deps);
178
+ const organizationId = parseOrganizationId(c.req.param("organizationId"));
179
+ const human = await requireOrganizationCodexHuman(c, deps, organizationId);
180
+ const payload = await jsonBody(
181
+ c,
182
+ CreateOrganizationProviderCustomModelRequest,
183
+ "invalid organization custom model",
184
+ );
185
+ try {
186
+ const model = await createOrganizationModelProviderCustomModel(deps.db, {
187
+ organizationId,
188
+ actorSubjectId: human.subjectId,
189
+ providerKind: providerKind(c.req.param("providerKind")),
190
+ operationId: payload.operationId,
191
+ upstreamModelId: payload.upstreamModelId,
192
+ ...(payload.label === undefined ? {} : { label: payload.label }),
193
+ });
194
+ return c.json(modelJson(model), 201);
195
+ } catch (error) {
196
+ conflict(error);
197
+ }
198
+ },
199
+ );
200
+
201
+ app.delete(
202
+ "/v1/organizations/:organizationId/model-providers/:providerKind/custom-models/:customModelId",
203
+ async (c) => {
204
+ c.header("cache-control", "private, no-store");
205
+ requireSameOriginBrowserMutation(c, deps);
206
+ const organizationId = parseOrganizationId(c.req.param("organizationId"));
207
+ const human = await requireOrganizationCodexHuman(c, deps, organizationId);
208
+ const payload = await jsonBody(
209
+ c,
210
+ DeleteOrganizationProviderCustomModelRequest,
211
+ "invalid organization custom model deletion",
212
+ );
213
+ const customModelId = z.string().uuid().safeParse(c.req.param("customModelId"));
214
+ if (!customModelId.success) {
215
+ throw new HTTPException(422, { message: "invalid organization custom model id" });
216
+ }
217
+ try {
218
+ const model = await retireOrganizationModelProviderCustomModel(deps.db, {
219
+ organizationId,
220
+ actorSubjectId: human.subjectId,
221
+ providerKind: providerKind(c.req.param("providerKind")),
222
+ customModelId: customModelId.data,
223
+ ...payload,
224
+ });
225
+ return c.json(modelJson(model));
226
+ } catch (error) {
227
+ conflict(error);
228
+ }
229
+ },
230
+ );
231
+ }
@@ -277,6 +277,7 @@ export function registerPackRoutes(app: Hono, deps: ApiRouteDeps): void {
277
277
  sourcePath: inline.sourcePath,
278
278
  name: inline.name,
279
279
  description: inline.description,
280
+ activationMode: inline.activationMode,
280
281
  contentSha256: inline.contentSha256,
281
282
  totalBytes: inline.totalBytes,
282
283
  files: inline.files,
@@ -3,10 +3,15 @@ import {
3
3
  ListPersonalGitHubRepositoriesResponse,
4
4
  PersonalGitHubConnectionStatusResponse,
5
5
  PersonalGitHubOAuthStartRequest,
6
+ PersonalGitHubRepositoryId,
6
7
  PersonalGitHubRepositorySelectionState,
7
8
  ReplacePersonalGitHubRepositorySelectionsRequest,
8
9
  VerifyPersonalGitHubRepositorySelectionsRequest,
9
10
  } from "@opengeni/contracts/personal-github";
11
+ import {
12
+ ListGitHubRepositoryBranchesQuery,
13
+ GitHubRepositoryBranchesResponse,
14
+ } from "@opengeni/contracts/github-repository-contracts";
10
15
  import {
11
16
  requireAccessGrant,
12
17
  requireAccessGrantAuthorization,
@@ -31,6 +36,7 @@ import {
31
36
  startPersonalGitHubOAuth,
32
37
  } from "../integrations/personal-github";
33
38
  import {
39
+ listLivePersonalGitHubRepositoryBranches,
34
40
  listLivePersonalGitHubRepositories,
35
41
  personalGitHubRepositoryProviderHttpError,
36
42
  PersonalGitHubRepositoryProviderError,
@@ -187,6 +193,39 @@ export function registerPersonalGitHubRoutes(app: Hono, deps: ApiRouteDeps): voi
187
193
  },
188
194
  );
189
195
 
196
+ app.get(
197
+ "/v1/workspaces/:workspaceId/connections/:connectionId/github/repositories/:repositoryId/branches",
198
+ async (c) => {
199
+ const workspaceId = c.req.param("workspaceId");
200
+ const connectionId = c.req.param("connectionId");
201
+ const access = await requireAccessGrantAuthorization(
202
+ c,
203
+ deps,
204
+ workspaceId,
205
+ "connections:read",
206
+ );
207
+ assertPersonalConnectionOwnerPrincipal(access, "My GitHub repositories");
208
+ const query = ListGitHubRepositoryBranchesQuery.parse(c.req.query());
209
+ const repositoryId = PersonalGitHubRepositoryId.parse(c.req.param("repositoryId"));
210
+ try {
211
+ return c.json(
212
+ GitHubRepositoryBranchesResponse.parse(
213
+ await listLivePersonalGitHubRepositoryBranches(deps, {
214
+ accountId: access.grant.accountId,
215
+ workspaceId,
216
+ subjectId: access.grant.subjectId,
217
+ connectionId,
218
+ repositoryId,
219
+ query,
220
+ }),
221
+ ),
222
+ );
223
+ } catch (error) {
224
+ throw personalGitHubRepositoryRouteError(error);
225
+ }
226
+ },
227
+ );
228
+
190
229
  app.post(
191
230
  "/v1/workspaces/:workspaceId/connections/:connectionId/github/repositories/verify",
192
231
  async (c) => {