@opengeni/api-router 0.30.1 → 2.1.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 (167) hide show
  1. package/dist/api-websocket.d.ts +18 -0
  2. package/dist/app.js +1 -1
  3. package/dist/auth/managed-auth.d.ts +6 -0
  4. package/dist/{chunk-2PQMSRCB.js → chunk-QKDFBBUE.js} +29110 -18137
  5. package/dist/chunk-QKDFBBUE.js.map +1 -0
  6. package/dist/codemode.d.ts +8 -1
  7. package/dist/company-brain-okf.d.ts +24 -0
  8. package/dist/connected-machine-computer-access.d.ts +11 -0
  9. package/dist/connection-authority-owner.d.ts +59 -0
  10. package/dist/connection-ownership.d.ts +47 -0
  11. package/dist/controller-data-plane.d.ts +16 -0
  12. package/dist/editable-artifact-office-import.d.ts +7 -4
  13. package/dist/editable-artifact-websocket.d.ts +5 -15
  14. package/dist/editable-artifact-workspace-files.d.ts +1 -1
  15. package/dist/http/api-error.d.ts +10 -0
  16. package/dist/http/cors.d.ts +5 -0
  17. package/dist/http/interaction-control-error.d.ts +13 -0
  18. package/dist/http/sse.d.ts +2 -3
  19. package/dist/index.d.ts +5 -2
  20. package/dist/index.js +364 -86
  21. package/dist/index.js.map +1 -1
  22. package/dist/integrations/atlassian.d.ts +17 -0
  23. package/dist/integrations/fiken.d.ts +7 -2
  24. package/dist/integrations/google-drive.d.ts +19 -0
  25. package/dist/integrations/oauth-client.d.ts +22 -3
  26. package/dist/integrations/oauth-profiles.d.ts +195 -0
  27. package/dist/integrations/personal-github-repositories.d.ts +45 -0
  28. package/dist/integrations/personal-github.d.ts +40 -0
  29. package/dist/integrations/pr-review-provider.d.ts +22 -0
  30. package/dist/integrations/provider-oauth.d.ts +8 -0
  31. package/dist/integrations/slack-app-home.d.ts +24 -0
  32. package/dist/integrations/slack-bot.d.ts +149 -9
  33. package/dist/integrations/slack-interactions.d.ts +10 -4
  34. package/dist/integrations/slack-routing.d.ts +120 -0
  35. package/dist/integrations/social-oauth.d.ts +5 -0
  36. package/dist/interaction-frame-proxy.d.ts +68 -0
  37. package/dist/mcp/company-brain-governed-writes.d.ts +26 -0
  38. package/dist/mcp/company-profile-agent-admin.d.ts +44 -0
  39. package/dist/mcp/documents.d.ts +1 -0
  40. package/dist/mcp/remember.d.ts +28 -0
  41. package/dist/mcp/request-abort.d.ts +19 -0
  42. package/dist/mcp/scheduled-task-view.d.ts +4 -4
  43. package/dist/mcp/server.d.ts +15 -2
  44. package/dist/mcp/session-view.d.ts +4 -0
  45. package/dist/mcp/session-wait.d.ts +137 -0
  46. package/dist/routes/automations.d.ts +4 -0
  47. package/dist/routes/billing.d.ts +5 -0
  48. package/dist/routes/browser-sessions.d.ts +17 -3
  49. package/dist/routes/company-brain.d.ts +3 -0
  50. package/dist/routes/company-profile.d.ts +2 -1
  51. package/dist/routes/connection-authorities.d.ts +3 -0
  52. package/dist/routes/files.d.ts +4 -0
  53. package/dist/routes/personal-github-git-broker.d.ts +29 -0
  54. package/dist/routes/personal-github.d.ts +3 -0
  55. package/dist/routes/pr-review.d.ts +3 -0
  56. package/dist/routes/sessions.d.ts +1 -0
  57. package/dist/routes/user-resource-authorities.d.ts +3 -0
  58. package/dist/routes/workspace-capture.d.ts +10 -2
  59. package/dist/routes/workspace-learning.d.ts +3 -0
  60. package/dist/routes/workspaces.d.ts +19 -0
  61. package/dist/sandbox/channel-a.d.ts +11 -1
  62. package/dist/sandbox/connection-authority.d.ts +3 -0
  63. package/dist/sandbox/enrollment.d.ts +2 -0
  64. package/dist/sandbox/machines.d.ts +2 -2
  65. package/dist/sandbox/metrics-ingestion.d.ts +10 -20
  66. package/dist/sandbox/viewer.d.ts +24 -1
  67. package/dist/sandbox-file-artifacts.d.ts +11 -0
  68. package/dist/scheduled-task-deletion.d.ts +14 -0
  69. package/dist/slack-reaction-files.d.ts +1 -1
  70. package/dist/temporal-schedule-cleanup.d.ts +1 -0
  71. package/package.json +20 -18
  72. package/src/api-websocket.ts +23 -0
  73. package/src/app.ts +160 -27
  74. package/src/auth/managed-auth.ts +25 -3
  75. package/src/codemode.ts +53 -23
  76. package/src/codex-realtime.ts +8 -2
  77. package/src/company-brain-okf.ts +340 -0
  78. package/src/connected-machine-computer-access.ts +33 -0
  79. package/src/connection-authority-owner.ts +61 -0
  80. package/src/connection-ownership.ts +180 -0
  81. package/src/controller-data-plane.ts +47 -0
  82. package/src/editable-artifact-native-kernel.ts +26 -15
  83. package/src/editable-artifact-office-import.ts +77 -9
  84. package/src/editable-artifact-production.ts +13 -7
  85. package/src/editable-artifact-websocket.ts +11 -18
  86. package/src/editable-artifact-workspace-files.ts +31 -6
  87. package/src/http/api-error.ts +28 -0
  88. package/src/http/auth.ts +4 -0
  89. package/src/http/cors.ts +35 -0
  90. package/src/http/interaction-control-error.ts +164 -0
  91. package/src/http/sse.ts +112 -66
  92. package/src/index.ts +46 -6
  93. package/src/integrations/atlassian.ts +146 -35
  94. package/src/integrations/fiken.ts +102 -22
  95. package/src/integrations/google-drive.ts +416 -101
  96. package/src/integrations/oauth-client.ts +272 -143
  97. package/src/integrations/oauth-profiles.ts +477 -0
  98. package/src/integrations/personal-github-repositories.ts +445 -0
  99. package/src/integrations/personal-github.ts +705 -0
  100. package/src/integrations/pr-review-provider.ts +246 -0
  101. package/src/integrations/provider-oauth.ts +121 -5
  102. package/src/integrations/slack-app-home.ts +300 -0
  103. package/src/integrations/slack-bot.ts +337 -57
  104. package/src/integrations/slack-interactions.ts +1274 -214
  105. package/src/integrations/slack-routing.ts +324 -0
  106. package/src/integrations/social-oauth.ts +25 -0
  107. package/src/interaction-frame-proxy.ts +409 -0
  108. package/src/mcp/company-brain-governed-writes.ts +262 -0
  109. package/src/mcp/company-profile-agent-admin.ts +205 -0
  110. package/src/mcp/documents.ts +37 -6
  111. package/src/mcp/files.ts +22 -2
  112. package/src/mcp/remember.ts +181 -0
  113. package/src/mcp/request-abort.ts +44 -0
  114. package/src/mcp/scheduled-task-view.ts +1 -0
  115. package/src/mcp/server.ts +719 -369
  116. package/src/mcp/session-view.ts +54 -0
  117. package/src/mcp/session-wait.ts +554 -0
  118. package/src/model-catalog.ts +20 -12
  119. package/src/routes/api-integrations.ts +4 -0
  120. package/src/routes/api-keys.ts +1 -0
  121. package/src/routes/automations.ts +534 -0
  122. package/src/routes/billing.ts +57 -1
  123. package/src/routes/browser-sessions.ts +388 -98
  124. package/src/routes/channels.ts +17 -1
  125. package/src/routes/codex.ts +50 -0
  126. package/src/routes/company-brain.ts +367 -0
  127. package/src/routes/company-profile.ts +1 -1
  128. package/src/routes/computer-sessions.ts +320 -93
  129. package/src/routes/connection-authorities.ts +139 -0
  130. package/src/routes/connections.ts +145 -34
  131. package/src/routes/documents.ts +221 -3
  132. package/src/routes/editable-artifacts.ts +11 -3
  133. package/src/routes/enrollments.ts +116 -26
  134. package/src/routes/environments.ts +127 -55
  135. package/src/routes/files.ts +152 -27
  136. package/src/routes/install.ts +82 -28
  137. package/src/routes/integration-facets.ts +29 -0
  138. package/src/routes/interaction-resources.ts +20 -2
  139. package/src/routes/machines.ts +275 -13
  140. package/src/routes/organization-memberships.ts +717 -28
  141. package/src/routes/packs.ts +4 -3
  142. package/src/routes/personal-github-git-broker.ts +785 -0
  143. package/src/routes/personal-github.ts +319 -0
  144. package/src/routes/pr-review.ts +531 -0
  145. package/src/routes/rigs.ts +104 -39
  146. package/src/routes/scheduled-tasks.ts +19 -21
  147. package/src/routes/sessions.ts +777 -40
  148. package/src/routes/social.ts +14 -2
  149. package/src/routes/supergrok.ts +23 -4
  150. package/src/routes/transcription-recordings.ts +8 -2
  151. package/src/routes/user-resource-authorities.ts +138 -0
  152. package/src/routes/workspace-artifacts.ts +4 -1
  153. package/src/routes/workspace-capture.ts +21 -0
  154. package/src/routes/workspace-learning.ts +228 -0
  155. package/src/routes/workspaces.ts +59 -6
  156. package/src/sandbox/auth-callout.ts +38 -12
  157. package/src/sandbox/channel-a.ts +242 -19
  158. package/src/sandbox/connection-authority.ts +3 -0
  159. package/src/sandbox/enrollment.ts +15 -3
  160. package/src/sandbox/machines.ts +186 -64
  161. package/src/sandbox/metrics-ingestion.ts +220 -58
  162. package/src/sandbox/viewer.ts +243 -19
  163. package/src/sandbox-file-artifacts.ts +329 -0
  164. package/src/scheduled-task-deletion.ts +127 -0
  165. package/src/slack-reaction-files.ts +16 -5
  166. package/src/temporal-schedule-cleanup.ts +13 -0
  167. package/dist/chunk-2PQMSRCB.js.map +0 -1
@@ -0,0 +1,139 @@
1
+ import {
2
+ ConnectionUseGrantMutationResponse,
3
+ ConnectionUseGrantRevocationResponse,
4
+ IssueConnectionUseGrantRequest,
5
+ ListConnectionAuthoritiesQuery,
6
+ RevokeConnectionUseGrantQuery,
7
+ } from "@opengeni/contracts/connection-authority";
8
+ import {
9
+ issueManagedHumanUserResourceGrant,
10
+ listManagedHumanUserResourceAuthorities,
11
+ requireAccessGrantAuthorization,
12
+ revokeManagedHumanUserResourceGrant,
13
+ SessionAuthorizationDeniedError,
14
+ SessionAuthorizationUnavailableError,
15
+ SessionTenancyManagedHumanRequiredError,
16
+ type ApiRouteDeps,
17
+ } from "@opengeni/core";
18
+ import { nestedPostgresSqlState, SessionTenancyNotActivatedError } from "@opengeni/db";
19
+ import type { Context, Hono } from "hono";
20
+ import { HTTPException } from "hono/http-exception";
21
+ import { z } from "zod";
22
+ import {
23
+ connectionUseGrantLifecycleInput,
24
+ projectSelfConnectionAuthorities,
25
+ } from "../connection-authority-owner";
26
+
27
+ async function body<S extends z.ZodType>(context: Context, schema: S): Promise<z.infer<S>> {
28
+ const parsed = schema.safeParse(await context.req.json().catch(() => null));
29
+ if (!parsed.success) throw new HTTPException(422, { message: "invalid connection authority" });
30
+ return parsed.data;
31
+ }
32
+
33
+ function lifecycleError(error: unknown): never {
34
+ if (
35
+ error instanceof SessionAuthorizationDeniedError ||
36
+ error instanceof SessionTenancyManagedHumanRequiredError ||
37
+ error instanceof SessionTenancyNotActivatedError
38
+ ) {
39
+ throw new HTTPException(403, { message: "connection authority denied" });
40
+ }
41
+ if (error instanceof SessionAuthorizationUnavailableError) {
42
+ throw new HTTPException(503, { message: "session authorization unavailable" });
43
+ }
44
+ if (nestedPostgresSqlState(error) === "42501") {
45
+ throw new HTTPException(403, { message: "connection authority denied" });
46
+ }
47
+ if (nestedPostgresSqlState(error) === "22023") {
48
+ throw new HTTPException(422, { message: "invalid connection authority" });
49
+ }
50
+ throw error;
51
+ }
52
+
53
+ export function registerConnectionAuthorityRoutes(app: Hono, deps: ApiRouteDeps): void {
54
+ const base = "/v1/workspaces/:workspaceId/connection-authorities";
55
+
56
+ app.get(base, async (context) => {
57
+ const query = ListConnectionAuthoritiesQuery.safeParse({
58
+ scope: context.req.query("scope"),
59
+ cursor: context.req.query("cursor"),
60
+ limit: context.req.query("limit"),
61
+ });
62
+ if (!query.success) throw new HTTPException(422, { message: "explicit scope=user required" });
63
+ const workspaceId = context.req.param("workspaceId");
64
+ const access = await requireAccessGrantAuthorization(
65
+ context,
66
+ deps,
67
+ workspaceId,
68
+ "connections:read",
69
+ );
70
+ try {
71
+ return context.json(
72
+ projectSelfConnectionAuthorities(
73
+ await listManagedHumanUserResourceAuthorities(deps, access, workspaceId, {
74
+ resourceKind: "connection",
75
+ cursor: query.data.cursor,
76
+ limit: query.data.limit,
77
+ }),
78
+ ),
79
+ );
80
+ } catch (error) {
81
+ return lifecycleError(error);
82
+ }
83
+ });
84
+
85
+ app.post(`${base}/:authorityId/grants`, async (context) => {
86
+ const workspaceId = context.req.param("workspaceId");
87
+ const authorityId = z.string().uuid().safeParse(context.req.param("authorityId"));
88
+ if (!authorityId.success) throw new HTTPException(422, { message: "invalid authority id" });
89
+ const request = await body(context, IssueConnectionUseGrantRequest);
90
+ const access = await requireAccessGrantAuthorization(
91
+ context,
92
+ deps,
93
+ workspaceId,
94
+ "connections:read",
95
+ );
96
+ try {
97
+ const normalized = connectionUseGrantLifecycleInput(request);
98
+ return context.json(
99
+ ConnectionUseGrantMutationResponse.parse({
100
+ scope: "user",
101
+ grant: await issueManagedHumanUserResourceGrant(
102
+ deps,
103
+ access,
104
+ workspaceId,
105
+ authorityId.data,
106
+ { scope: "user", resourceKind: "connection", ...normalized },
107
+ "http",
108
+ ),
109
+ }),
110
+ );
111
+ } catch (error) {
112
+ return lifecycleError(error);
113
+ }
114
+ });
115
+
116
+ app.delete(`${base}/grants/:grantId`, async (context) => {
117
+ const query = RevokeConnectionUseGrantQuery.safeParse({ scope: context.req.query("scope") });
118
+ if (!query.success) throw new HTTPException(422, { message: "explicit scope=user required" });
119
+ const grantId = z.string().uuid().safeParse(context.req.param("grantId"));
120
+ if (!grantId.success) throw new HTTPException(422, { message: "invalid grant id" });
121
+ const workspaceId = context.req.param("workspaceId");
122
+ const access = await requireAccessGrantAuthorization(
123
+ context,
124
+ deps,
125
+ workspaceId,
126
+ "workspace:read",
127
+ );
128
+ try {
129
+ return context.json(
130
+ ConnectionUseGrantRevocationResponse.parse({
131
+ scope: "user",
132
+ grant: await revokeManagedHumanUserResourceGrant(deps, access, workspaceId, grantId.data),
133
+ }),
134
+ );
135
+ } catch (error) {
136
+ return lifecycleError(error);
137
+ }
138
+ });
139
+ }
@@ -39,6 +39,12 @@ import {
39
39
  GoogleDriveOAuthStartRequest,
40
40
  GoogleDriveOAuthStartResponse,
41
41
  } from "@opengeni/contracts/google-drive";
42
+ import {
43
+ PERSONAL_GITHUB_PROVIDER_DOMAIN,
44
+ PersonalGitHubDisconnectRequest,
45
+ hasReservedPersonalGitHubMetadata,
46
+ isPersonalGitHubConnection,
47
+ } from "@opengeni/contracts/personal-github";
42
48
  import {
43
49
  fikenConnectionMetadata,
44
50
  hasPermission,
@@ -84,8 +90,10 @@ import {
84
90
  } from "../integrations/google-drive";
85
91
  import {
86
92
  completeApiIntegrationProviderOAuth,
93
+ isApiIntegrationProviderOAuthState,
87
94
  startApiIntegrationProviderOAuth,
88
95
  } from "../integrations/provider-oauth";
96
+ import { disconnectPersonalGitHub } from "../integrations/personal-github";
89
97
  import {
90
98
  browseAtlassianSources,
91
99
  completeAtlassianOAuthCallback,
@@ -99,6 +107,10 @@ import {
99
107
  integrationBaseUrl,
100
108
  startMcpOAuth,
101
109
  } from "../integrations/oauth-client";
110
+ import {
111
+ assertPersonalConnectionOwnerPrincipal,
112
+ isPersonalConnectionOwnerPrincipal,
113
+ } from "../connection-ownership";
102
114
  import { canonicalProviderDomain } from "../integrations/provider-domain";
103
115
  import {
104
116
  exchangeOpenGeniSlackAuthorizationCode,
@@ -164,17 +176,23 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
164
176
 
165
177
  app.post("/v1/workspaces/:workspaceId/connections", async (c) => {
166
178
  const workspaceId = c.req.param("workspaceId");
167
- const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
179
+ const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "connections:write");
180
+ const grant = access.grant;
168
181
  const payload = CreateConnectionRequest.parse(await c.req.json());
169
182
  assertNotReservedSlackBotMetadata(payload.metadata);
170
183
  assertNotReservedFikenMetadata(payload.metadata);
171
184
  assertNotReservedApiIntegrationOAuthMetadata(payload.metadata);
185
+ assertNotReservedPersonalGitHubMetadata(payload.metadata);
172
186
  const key = requireEnvironmentEncryption(settings);
173
187
  const subjectId = createConnectionSubjectId(payload, grant.subjectId);
188
+ if (subjectId !== null) {
189
+ assertPersonalConnectionOwnerPrincipal(access);
190
+ }
174
191
  const providerDomain = canonicalProviderDomain(payload.providerDomain);
175
192
  assertNotDirectPersonalSlackOAuth(providerDomain, payload.kind);
176
193
  assertNotDirectGoogleDriveOAuth(providerDomain, payload.kind, payload.metadata);
177
194
  assertNotDirectAtlassianOAuth(providerDomain, payload.kind, payload.metadata);
195
+ assertNotDirectPersonalGitHubOAuth(providerDomain, payload.kind, payload.metadata);
178
196
  const connection = await createConnection(db, {
179
197
  accountId: grant.accountId,
180
198
  workspaceId,
@@ -271,7 +289,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
271
289
  }
272
290
  const slack = requireOpenGeniSlackOAuthSettings(settings);
273
291
  const redirectUri = `${baseUrl}/v1/integrations/slack/callback`;
274
- const token = await exchangeOpenGeniSlackAuthorizationCode(
292
+ const authorization = await exchangeOpenGeniSlackAuthorizationCode(
275
293
  {
276
294
  code,
277
295
  clientId: slack.clientId,
@@ -281,14 +299,22 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
281
299
  deps.slackFetch ?? fetch,
282
300
  );
283
301
  stage = "credential_verification";
284
- const verified = await verifyOpenGeniSlackBotCredential(token, deps.slackFetch ?? fetch);
302
+ const verified = await verifyOpenGeniSlackBotCredential(
303
+ authorization.accessToken,
304
+ {
305
+ appId: authorization.appId,
306
+ displayName: settings.slackBotDisplayName,
307
+ },
308
+ deps.slackFetch ?? fetch,
309
+ new Date(),
310
+ );
285
311
  stage = "permission_recheck";
286
312
  await requireSlackInstallCallbackGrant(db, state);
287
313
  stage = "persistence";
288
314
  const connection = await persistOpenGeniSlackBotConnection({
289
315
  deps,
290
316
  state,
291
- token,
317
+ token: authorization.accessToken,
292
318
  verified,
293
319
  });
294
320
  return c.redirect(
@@ -439,7 +465,11 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
439
465
  app.post("/v1/workspaces/:workspaceId/connections/google-drive/install", async (c) => {
440
466
  assertIntegrationsEnabled();
441
467
  const workspaceId = c.req.param("workspaceId");
442
- const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
468
+ // The Drive connector writes only a personal Connection, so only a managed
469
+ // human may start it.
470
+ const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "connections:write");
471
+ assertPersonalConnectionOwnerPrincipal(access, "Google Drive");
472
+ const grant = access.grant;
443
473
  const parsed = GoogleDriveOAuthStartRequest.safeParse(await c.req.json());
444
474
  if (!parsed.success) {
445
475
  throw new HTTPException(400, { message: "invalid Google Drive install request" });
@@ -460,7 +490,11 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
460
490
  app.post("/v1/workspaces/:workspaceId/connections/atlassian/install", async (c) => {
461
491
  assertIntegrationsEnabled();
462
492
  const workspaceId = c.req.param("workspaceId");
463
- const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
493
+ // The Atlassian connector writes only a personal Connection, so only a
494
+ // managed human may start it.
495
+ const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "connections:write");
496
+ assertPersonalConnectionOwnerPrincipal(access, "Atlassian");
497
+ const grant = access.grant;
464
498
  const parsed = AtlassianOAuthStartRequest.safeParse(await c.req.json());
465
499
  if (!parsed.success) {
466
500
  throw new HTTPException(400, { message: "invalid Atlassian install request" });
@@ -557,6 +591,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
557
591
  ...(c.req.query("code") ? { code: c.req.query("code") } : {}),
558
592
  ...(c.req.query("state") ? { state: c.req.query("state") } : {}),
559
593
  ...(c.req.query("error") ? { error: c.req.query("error") } : {}),
594
+ ...(c.req.query("picked_file_ids") ? { pickedFileIds: c.req.query("picked_file_ids") } : {}),
560
595
  requestUrl: c.req.url,
561
596
  };
562
597
  const result = await completeGoogleDriveOAuthCallback(deps, input);
@@ -662,6 +697,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
662
697
  assertNotReservedSlackBotMetadata(payload.metadata);
663
698
  assertNotReservedFikenMetadata(payload.metadata);
664
699
  assertNotReservedApiIntegrationOAuthMetadata(payload.metadata);
700
+ assertNotReservedPersonalGitHubMetadata(payload.metadata);
665
701
  const existing = await getConnectionMetadata(
666
702
  db,
667
703
  workspaceId,
@@ -748,6 +784,11 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
748
784
  message: "use the dedicated Atlassian reconnect or source-selection flow",
749
785
  });
750
786
  }
787
+ if (existing && isPersonalGitHubConnection(existing)) {
788
+ throw new HTTPException(422, {
789
+ message: "use the dedicated personal GitHub reconnect flow to update this connection",
790
+ });
791
+ }
751
792
  if (existing) {
752
793
  const providerDomain = canonicalProviderDomain(
753
794
  payload.providerDomain ?? existing.providerDomain,
@@ -756,6 +797,11 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
756
797
  assertNotDirectPersonalSlackOAuth(providerDomain, kind);
757
798
  assertNotDirectGoogleDriveOAuth(providerDomain, kind, payload.metadata ?? existing.metadata);
758
799
  assertNotDirectAtlassianOAuth(providerDomain, kind, payload.metadata ?? existing.metadata);
800
+ assertNotDirectPersonalGitHubOAuth(
801
+ providerDomain,
802
+ kind,
803
+ payload.metadata ?? existing.metadata,
804
+ );
759
805
  }
760
806
  // Status is not a free-form field: revocation goes through DELETE, and the
761
807
  // broker owns needs_reauth/error. Reactivating a connection is only
@@ -812,6 +858,17 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
812
858
  if (!existing) {
813
859
  throw new HTTPException(404, { message: "connection not found" });
814
860
  }
861
+ const isPersonalGitHub =
862
+ existing.subjectId === grant.subjectId && isPersonalGitHubConnection(existing);
863
+ if (isPersonalGitHub) {
864
+ const access = await requireAccessGrantAuthorization(
865
+ c,
866
+ deps,
867
+ workspaceId,
868
+ "connections:write",
869
+ );
870
+ assertPersonalConnectionOwnerPrincipal(access, "My GitHub account");
871
+ }
815
872
  const isGoogleDrive =
816
873
  existing.subjectId === grant.subjectId &&
817
874
  existing.providerDomain === GOOGLE_DRIVE_PROVIDER_DOMAIN &&
@@ -842,7 +899,17 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
842
899
  atlassianDisconnect.error.issues[0]?.message ?? "invalid Atlassian disconnect request",
843
900
  });
844
901
  }
845
- if (existing.status === "revoked" && !isGoogleDrive && !isAtlassian) {
902
+ const personalGitHubDisconnect = isPersonalGitHub
903
+ ? PersonalGitHubDisconnectRequest.safeParse(disconnectPayload)
904
+ : null;
905
+ if (personalGitHubDisconnect && !personalGitHubDisconnect.success) {
906
+ throw new HTTPException(400, {
907
+ message:
908
+ personalGitHubDisconnect.error.issues[0]?.message ??
909
+ "invalid personal GitHub disconnect request",
910
+ });
911
+ }
912
+ if (existing.status === "revoked" && !isGoogleDrive && !isAtlassian && !isPersonalGitHub) {
846
913
  return c.json(ConnectionResponse.parse({ connection: existing }));
847
914
  }
848
915
  const connection = isGoogleDrive
@@ -859,24 +926,31 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
859
926
  connection: existing,
860
927
  payload: atlassianDisconnect!.data,
861
928
  })
862
- : isOpenGeniSlackBotConnection(existing)
863
- ? await revokeConnectionWithSlackBotSuccessAudit(db, {
864
- accountId: grant.accountId,
929
+ : isPersonalGitHub
930
+ ? await disconnectPersonalGitHub(deps, {
865
931
  workspaceId,
866
932
  subjectId: grant.subjectId,
867
933
  connectionId,
868
- expectedVersion: existing.version,
869
- credentialRole: OPENGENI_SLACK_BOT_CREDENTIAL_ROLE,
870
- credentialLabel: OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
871
- slackTeamId: openGeniSlackBotMetadata(existing.metadata)!.slackTeamId,
934
+ payload: personalGitHubDisconnect!.data,
872
935
  })
873
- : await revokeConnection(
874
- db,
875
- workspaceId,
876
- connectionId,
877
- grant.subjectId,
878
- existing.version,
879
- );
936
+ : isOpenGeniSlackBotConnection(existing)
937
+ ? await revokeConnectionWithSlackBotSuccessAudit(db, {
938
+ accountId: grant.accountId,
939
+ workspaceId,
940
+ subjectId: grant.subjectId,
941
+ connectionId,
942
+ expectedVersion: existing.version,
943
+ credentialRole: OPENGENI_SLACK_BOT_CREDENTIAL_ROLE,
944
+ credentialLabel: OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
945
+ slackTeamId: openGeniSlackBotMetadata(existing.metadata)!.slackTeamId,
946
+ })
947
+ : await revokeConnection(
948
+ db,
949
+ workspaceId,
950
+ connectionId,
951
+ grant.subjectId,
952
+ existing.version,
953
+ );
880
954
  if (!connection) {
881
955
  throw new HTTPException(409, { message: "connection changed during disconnect; try again" });
882
956
  }
@@ -886,7 +960,8 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
886
960
  app.post("/v1/workspaces/:workspaceId/connections/oauth/start", async (c) => {
887
961
  assertIntegrationsEnabled();
888
962
  const workspaceId = c.req.param("workspaceId");
889
- const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
963
+ const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "connections:write");
964
+ const grant = access.grant;
890
965
  const parsed = OAuthStartRequest.safeParse(await c.req.json());
891
966
  if (!parsed.success) {
892
967
  throw new HTTPException(400, {
@@ -900,6 +975,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
900
975
  accountId: grant.accountId,
901
976
  workspaceId,
902
977
  subjectId: grant.subjectId,
978
+ personalOwnershipAllowed: isPersonalConnectionOwnerPrincipal(access),
903
979
  requestUrl: c.req.url,
904
980
  payload,
905
981
  },
@@ -910,7 +986,8 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
910
986
  app.post("/v1/workspaces/:workspaceId/integrations/oauth/start", async (c) => {
911
987
  assertIntegrationsEnabled();
912
988
  const workspaceId = c.req.param("workspaceId");
913
- const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
989
+ const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "connections:write");
990
+ const grant = access.grant;
914
991
  const parsed = ApiIntegrationOAuthStartRequest.safeParse(await c.req.json());
915
992
  if (!parsed.success) {
916
993
  throw new HTTPException(400, {
@@ -923,6 +1000,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
923
1000
  accountId: grant.accountId,
924
1001
  workspaceId,
925
1002
  subjectId: grant.subjectId,
1003
+ personalOwnershipAllowed: isPersonalConnectionOwnerPrincipal(access),
926
1004
  requestUrl: c.req.url,
927
1005
  payload: parsed.data,
928
1006
  }),
@@ -932,15 +1010,22 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
932
1010
 
933
1011
  app.get("/v1/integrations/oauth/callback", async (c) => {
934
1012
  assertIntegrationsEnabled();
935
- const result = await completeMcpOAuthCallback(
936
- { db, settings, observability, oauthCallbackDeadlineMs: deps.oauthCallbackDeadlineMs },
937
- {
938
- code: c.req.query("code"),
939
- state: c.req.query("state"),
940
- requestUrl: c.req.url,
941
- },
942
- );
943
- return c.redirect(result.redirectTo, 302);
1013
+ const input = {
1014
+ ...(c.req.query("code") ? { code: c.req.query("code") } : {}),
1015
+ ...(c.req.query("state") ? { state: c.req.query("state") } : {}),
1016
+ ...(c.req.query("error") ? { error: c.req.query("error") } : {}),
1017
+ requestUrl: c.req.url,
1018
+ };
1019
+ const result = isApiIntegrationProviderOAuthState(input.state, deps.settings)
1020
+ ? await completeApiIntegrationProviderOAuth(deps, input)
1021
+ : await completeMcpOAuthCallback(
1022
+ { db, settings, observability, oauthCallbackDeadlineMs: deps.oauthCallbackDeadlineMs },
1023
+ input,
1024
+ );
1025
+ const redirectTo = settings.webBaseUrl
1026
+ ? new URL(result.redirectTo, settings.webBaseUrl).toString()
1027
+ : result.redirectTo;
1028
+ return c.redirect(redirectTo, 302);
944
1029
  });
945
1030
 
946
1031
  app.get("/v1/integrations/provider-oauth/callback", async (c) => {
@@ -1073,10 +1158,11 @@ function requireOpenGeniSlackOAuthSettings(settings: ApiRouteDeps["settings"]):
1073
1158
  } {
1074
1159
  const clientId = settings.slackClientId?.trim();
1075
1160
  const clientSecret = settings.slackClientSecret?.trim();
1076
- if (!clientId || !clientSecret) {
1161
+ const signingSecret = settings.slackSigningSecret?.trim();
1162
+ if (!clientId || !clientSecret || !signingSecret) {
1077
1163
  throw new HTTPException(503, {
1078
1164
  message:
1079
- "OpenGeni Slack installation requires OPENGENI_SLACK_CLIENT_ID and OPENGENI_SLACK_CLIENT_SECRET",
1165
+ "OpenGeni Slack bot installation requires OPENGENI_SLACK_CLIENT_ID, OPENGENI_SLACK_CLIENT_SECRET, and OPENGENI_SLACK_SIGNING_SECRET",
1080
1166
  });
1081
1167
  }
1082
1168
  return { clientId, clientSecret };
@@ -1256,6 +1342,31 @@ function assertNotDirectAtlassianOAuth(
1256
1342
  }
1257
1343
  }
1258
1344
 
1345
+ function assertNotDirectPersonalGitHubOAuth(
1346
+ providerDomain: string,
1347
+ kind: string,
1348
+ metadata: Record<string, unknown> | undefined,
1349
+ ): void {
1350
+ if (
1351
+ (providerDomain === PERSONAL_GITHUB_PROVIDER_DOMAIN && kind === "oauth2") ||
1352
+ hasReservedPersonalGitHubMetadata(metadata)
1353
+ ) {
1354
+ throw new HTTPException(422, {
1355
+ message: "personal GitHub credentials must use the dedicated OAuth connection flow",
1356
+ });
1357
+ }
1358
+ }
1359
+
1360
+ function assertNotReservedPersonalGitHubMetadata(
1361
+ metadata: Record<string, unknown> | undefined,
1362
+ ): void {
1363
+ if (hasReservedPersonalGitHubMetadata(metadata)) {
1364
+ throw new HTTPException(422, {
1365
+ message: "personal GitHub metadata is reserved for the dedicated OAuth connection flow",
1366
+ });
1367
+ }
1368
+ }
1369
+
1259
1370
  function assertNotReservedSlackBotMetadata(metadata: Record<string, unknown> | undefined): void {
1260
1371
  if (hasReservedOpenGeniSlackBotMetadata(metadata)) {
1261
1372
  throw new HTTPException(422, {