@opengeni/api-router 0.30.3 → 2.2.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 (165) 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-WCXHI3FF.js → chunk-3TP54PPX.js} +26692 -15389
  5. package/dist/chunk-3TP54PPX.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-websocket.d.ts +5 -15
  13. package/dist/editable-artifact-workspace-files.d.ts +1 -1
  14. package/dist/http/api-error.d.ts +10 -0
  15. package/dist/http/cors.d.ts +5 -0
  16. package/dist/http/interaction-control-error.d.ts +13 -0
  17. package/dist/http/sse.d.ts +2 -3
  18. package/dist/index.d.ts +5 -2
  19. package/dist/index.js +326 -82
  20. package/dist/index.js.map +1 -1
  21. package/dist/integrations/atlassian.d.ts +17 -0
  22. package/dist/integrations/fiken.d.ts +7 -2
  23. package/dist/integrations/google-drive.d.ts +18 -0
  24. package/dist/integrations/oauth-client.d.ts +22 -3
  25. package/dist/integrations/oauth-profiles.d.ts +195 -0
  26. package/dist/integrations/personal-github-repositories.d.ts +45 -0
  27. package/dist/integrations/personal-github.d.ts +40 -0
  28. package/dist/integrations/pr-review-provider.d.ts +22 -0
  29. package/dist/integrations/provider-oauth.d.ts +8 -0
  30. package/dist/integrations/slack-app-home.d.ts +24 -0
  31. package/dist/integrations/slack-bot.d.ts +157 -9
  32. package/dist/integrations/slack-interactions.d.ts +39 -5
  33. package/dist/integrations/slack-routing.d.ts +120 -0
  34. package/dist/integrations/social-oauth.d.ts +5 -0
  35. package/dist/interaction-frame-proxy.d.ts +68 -0
  36. package/dist/mcp/company-brain-governed-writes.d.ts +26 -0
  37. package/dist/mcp/company-profile-agent-admin.d.ts +44 -0
  38. package/dist/mcp/documents.d.ts +1 -0
  39. package/dist/mcp/remember.d.ts +28 -0
  40. package/dist/mcp/request-abort.d.ts +19 -0
  41. package/dist/mcp/scheduled-task-view.d.ts +4 -4
  42. package/dist/mcp/server.d.ts +15 -2
  43. package/dist/mcp/session-view.d.ts +4 -0
  44. package/dist/mcp/session-wait.d.ts +137 -0
  45. package/dist/routes/automations.d.ts +4 -0
  46. package/dist/routes/billing.d.ts +5 -0
  47. package/dist/routes/browser-sessions.d.ts +4 -1
  48. package/dist/routes/company-brain.d.ts +3 -0
  49. package/dist/routes/company-profile.d.ts +2 -1
  50. package/dist/routes/connection-authorities.d.ts +3 -0
  51. package/dist/routes/personal-github-git-broker.d.ts +29 -0
  52. package/dist/routes/personal-github.d.ts +3 -0
  53. package/dist/routes/pr-review.d.ts +3 -0
  54. package/dist/routes/sessions.d.ts +1 -0
  55. package/dist/routes/user-resource-authorities.d.ts +3 -0
  56. package/dist/routes/workspace-capture.d.ts +10 -2
  57. package/dist/routes/workspace-learning.d.ts +3 -0
  58. package/dist/routes/workspaces.d.ts +19 -0
  59. package/dist/sandbox/channel-a.d.ts +11 -1
  60. package/dist/sandbox/connection-authority.d.ts +3 -0
  61. package/dist/sandbox/enrollment.d.ts +2 -0
  62. package/dist/sandbox/machines.d.ts +2 -2
  63. package/dist/sandbox/metrics-ingestion.d.ts +10 -20
  64. package/dist/sandbox/viewer.d.ts +24 -1
  65. package/dist/sandbox-file-artifacts.d.ts +11 -0
  66. package/dist/scheduled-task-deletion.d.ts +14 -0
  67. package/dist/slack-reaction-files.d.ts +1 -1
  68. package/dist/temporal-schedule-cleanup.d.ts +1 -0
  69. package/package.json +20 -18
  70. package/src/api-websocket.ts +23 -0
  71. package/src/app.ts +161 -23
  72. package/src/auth/managed-auth.ts +25 -3
  73. package/src/codemode.ts +53 -23
  74. package/src/codex-realtime.ts +8 -2
  75. package/src/company-brain-okf.ts +340 -0
  76. package/src/connected-machine-computer-access.ts +33 -0
  77. package/src/connection-authority-owner.ts +61 -0
  78. package/src/connection-ownership.ts +180 -0
  79. package/src/controller-data-plane.ts +47 -0
  80. package/src/editable-artifact-native-kernel.ts +26 -15
  81. package/src/editable-artifact-office-import.ts +28 -3
  82. package/src/editable-artifact-production.ts +13 -7
  83. package/src/editable-artifact-websocket.ts +11 -18
  84. package/src/editable-artifact-workspace-files.ts +31 -6
  85. package/src/http/api-error.ts +28 -0
  86. package/src/http/auth.ts +4 -0
  87. package/src/http/cors.ts +35 -0
  88. package/src/http/interaction-control-error.ts +164 -0
  89. package/src/http/sse.ts +112 -66
  90. package/src/index.ts +46 -6
  91. package/src/integrations/atlassian.ts +146 -35
  92. package/src/integrations/fiken.ts +102 -22
  93. package/src/integrations/google-drive.ts +301 -92
  94. package/src/integrations/oauth-client.ts +272 -143
  95. package/src/integrations/oauth-profiles.ts +477 -0
  96. package/src/integrations/personal-github-repositories.ts +445 -0
  97. package/src/integrations/personal-github.ts +705 -0
  98. package/src/integrations/pr-review-provider.ts +246 -0
  99. package/src/integrations/provider-oauth.ts +121 -5
  100. package/src/integrations/slack-app-home.ts +300 -0
  101. package/src/integrations/slack-bot.ts +342 -57
  102. package/src/integrations/slack-interactions.ts +1898 -259
  103. package/src/integrations/slack-routing.ts +337 -0
  104. package/src/integrations/social-oauth.ts +25 -0
  105. package/src/interaction-frame-proxy.ts +409 -0
  106. package/src/mcp/company-brain-governed-writes.ts +262 -0
  107. package/src/mcp/company-profile-agent-admin.ts +205 -0
  108. package/src/mcp/documents.ts +37 -6
  109. package/src/mcp/files.ts +16 -1
  110. package/src/mcp/remember.ts +181 -0
  111. package/src/mcp/request-abort.ts +44 -0
  112. package/src/mcp/scheduled-task-view.ts +1 -0
  113. package/src/mcp/server.ts +719 -369
  114. package/src/mcp/session-view.ts +54 -0
  115. package/src/mcp/session-wait.ts +554 -0
  116. package/src/model-catalog.ts +20 -12
  117. package/src/routes/api-integrations.ts +4 -0
  118. package/src/routes/api-keys.ts +1 -0
  119. package/src/routes/automations.ts +534 -0
  120. package/src/routes/billing.ts +57 -1
  121. package/src/routes/browser-sessions.ts +329 -79
  122. package/src/routes/channels.ts +17 -1
  123. package/src/routes/codex.ts +50 -0
  124. package/src/routes/company-brain.ts +367 -0
  125. package/src/routes/company-profile.ts +1 -1
  126. package/src/routes/computer-sessions.ts +320 -93
  127. package/src/routes/connection-authorities.ts +139 -0
  128. package/src/routes/connections.ts +144 -34
  129. package/src/routes/documents.ts +353 -4
  130. package/src/routes/editable-artifacts.ts +11 -3
  131. package/src/routes/enrollments.ts +116 -26
  132. package/src/routes/environments.ts +127 -55
  133. package/src/routes/files.ts +72 -11
  134. package/src/routes/install.ts +82 -28
  135. package/src/routes/integration-facets.ts +29 -0
  136. package/src/routes/interaction-resources.ts +20 -2
  137. package/src/routes/machines.ts +275 -13
  138. package/src/routes/organization-memberships.ts +717 -28
  139. package/src/routes/packs.ts +4 -3
  140. package/src/routes/personal-github-git-broker.ts +785 -0
  141. package/src/routes/personal-github.ts +319 -0
  142. package/src/routes/pr-review.ts +531 -0
  143. package/src/routes/rigs.ts +104 -39
  144. package/src/routes/scheduled-tasks.ts +19 -21
  145. package/src/routes/sessions.ts +775 -38
  146. package/src/routes/social.ts +14 -2
  147. package/src/routes/supergrok.ts +23 -4
  148. package/src/routes/transcription-recordings.ts +8 -2
  149. package/src/routes/user-resource-authorities.ts +138 -0
  150. package/src/routes/workspace-artifacts.ts +4 -1
  151. package/src/routes/workspace-capture.ts +21 -0
  152. package/src/routes/workspace-learning.ts +228 -0
  153. package/src/routes/workspaces.ts +59 -6
  154. package/src/sandbox/auth-callout.ts +38 -12
  155. package/src/sandbox/channel-a.ts +242 -19
  156. package/src/sandbox/connection-authority.ts +3 -0
  157. package/src/sandbox/enrollment.ts +15 -3
  158. package/src/sandbox/machines.ts +186 -64
  159. package/src/sandbox/metrics-ingestion.ts +220 -58
  160. package/src/sandbox/viewer.ts +243 -19
  161. package/src/sandbox-file-artifacts.ts +329 -0
  162. package/src/scheduled-task-deletion.ts +127 -0
  163. package/src/slack-reaction-files.ts +16 -5
  164. package/src/temporal-schedule-cleanup.ts +13 -0
  165. package/dist/chunk-WCXHI3FF.js.map +0 -1
@@ -0,0 +1,705 @@
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
+ import { personalGitHubOAuthCallbackUrl, type Settings } from "@opengeni/config";
3
+ import {
4
+ PERSONAL_GITHUB_AUTHORIZATION_URL,
5
+ PERSONAL_GITHUB_CREDENTIAL_ROLE,
6
+ PERSONAL_GITHUB_PROVIDER_DOMAIN,
7
+ PERSONAL_GITHUB_PROVIDER_FAMILY,
8
+ PERSONAL_GITHUB_REQUESTED_SCOPES,
9
+ PERSONAL_GITHUB_TOKEN_URL,
10
+ PERSONAL_GITHUB_USER_URL,
11
+ PersonalGitHubConnectionMetadata,
12
+ type PersonalGitHubDisconnectRequest,
13
+ PersonalGitHubOAuthStartResponse,
14
+ isPersonalGitHubConnection,
15
+ } from "@opengeni/contracts/personal-github";
16
+ import {
17
+ hasPermission,
18
+ requireEnvironmentEncryption,
19
+ type AccessGrantAuthorization,
20
+ type ApiRouteDeps,
21
+ } from "@opengeni/core";
22
+ import {
23
+ consumeIntegrationOAuthStateNonce,
24
+ ConnectionDisconnectGenerationError,
25
+ ConnectionDisconnectIdempotencyError,
26
+ decryptEnvironmentValue,
27
+ disconnectConnectionIdempotently,
28
+ encryptEnvironmentValue,
29
+ getConnectionMetadata,
30
+ getWorkspaceGrant,
31
+ listConnectionsMetadata,
32
+ namedSubjectHasLiveWorkspaceAuthority,
33
+ persistProviderOAuthConnection,
34
+ } from "@opengeni/db";
35
+ import { createSignedState, readSignedState } from "@opengeni/github";
36
+ import { readResponseJsonBounded, type FetchLike } from "@opengeni/network";
37
+ import { HTTPException } from "hono/http-exception";
38
+ import {
39
+ PERSONAL_OWNER_VERIFIED_STATE_CLAIM,
40
+ personalOnlyConnectionPrincipalMessage,
41
+ personalOwnerStateAccepted,
42
+ personalOwnerVerifiedInState,
43
+ } from "../connection-ownership";
44
+ import {
45
+ integrationBaseUrl,
46
+ oauthStateTtlMs,
47
+ requireIntegrationsStateSecret,
48
+ } from "./oauth-client";
49
+
50
+ const PERSONAL_GITHUB_OAUTH_STATE_KIND = "personal_github_oauth";
51
+ const GITHUB_RESPONSE_MAX_BYTES = 256 * 1024;
52
+ const GITHUB_REQUEST_TIMEOUT_MS = 10_000;
53
+ const GITHUB_API_VERSION = "2022-11-28";
54
+
55
+ type PersonalGitHubOAuthState = {
56
+ accountId: string;
57
+ workspaceId: string;
58
+ subjectId: string;
59
+ personalOwnerVerified: boolean;
60
+ canonicalManagedHumanSession: boolean;
61
+ returnPath: string;
62
+ encryptedPkceVerifier: string;
63
+ oauthEnvironment: string;
64
+ oauthClientMarker: string;
65
+ connectionId?: string;
66
+ connectionVersion?: number;
67
+ nonce: string;
68
+ iat: number;
69
+ };
70
+
71
+ type GitHubToken = {
72
+ accessToken: string;
73
+ refreshToken: string | null;
74
+ tokenType: "Bearer";
75
+ expiresAt: Date | null;
76
+ refreshTokenExpiresAt: Date | null;
77
+ scopes: string[];
78
+ };
79
+
80
+ type GitHubIdentity = {
81
+ id: string;
82
+ login: string;
83
+ };
84
+
85
+ type PersonalGitHubFailureReason =
86
+ | "account_mismatch"
87
+ | "client_changed"
88
+ | "connection_conflict"
89
+ | "disabled"
90
+ | "identity_failed"
91
+ | "invalid_state"
92
+ | "missing_code"
93
+ | "not_authorized"
94
+ | "provider_denied"
95
+ | "scope_not_granted"
96
+ | "state_replayed"
97
+ | "token_exchange_failed";
98
+
99
+ class PersonalGitHubCallbackError extends Error {
100
+ constructor(readonly reason: PersonalGitHubFailureReason) {
101
+ super(reason);
102
+ this.name = "PersonalGitHubCallbackError";
103
+ }
104
+ }
105
+
106
+ export function personalGitHubClientMarker(clientId: string): string {
107
+ return createHash("sha256").update(clientId).digest("hex").slice(0, 32);
108
+ }
109
+
110
+ export function requirePersonalGitHubOAuthSettings(settings: Settings): {
111
+ clientId: string;
112
+ clientSecret: string;
113
+ callbackUrl: string;
114
+ oauthEnvironment: string;
115
+ oauthClientMarker: string;
116
+ } {
117
+ if (!settings.githubPersonalOauthEnabled) {
118
+ throw new HTTPException(404, {
119
+ message: "personal GitHub OAuth is not enabled for this deployment",
120
+ });
121
+ }
122
+ const clientId = settings.githubPersonalOauthClientId?.trim();
123
+ const clientSecret = settings.githubPersonalOauthClientSecret?.trim();
124
+ const callbackUrl = personalGitHubOAuthCallbackUrl(settings.publicBaseUrl);
125
+ if (!clientId || !clientSecret || !callbackUrl) {
126
+ throw new HTTPException(503, { message: "personal GitHub OAuth is not configured" });
127
+ }
128
+ return {
129
+ clientId,
130
+ clientSecret,
131
+ callbackUrl,
132
+ oauthEnvironment: settings.environment,
133
+ oauthClientMarker: personalGitHubClientMarker(clientId),
134
+ };
135
+ }
136
+
137
+ export async function listPersonalGitHubConnections(
138
+ deps: ApiRouteDeps,
139
+ input: { workspaceId: string; subjectId: string },
140
+ ) {
141
+ const visible = await listConnectionsMetadata(deps.db, input.workspaceId, input.subjectId);
142
+ return visible.filter(
143
+ (connection) =>
144
+ connection.subjectId === input.subjectId && isPersonalGitHubConnection(connection),
145
+ );
146
+ }
147
+
148
+ export async function disconnectPersonalGitHub(
149
+ deps: ApiRouteDeps,
150
+ input: {
151
+ workspaceId: string;
152
+ subjectId: string;
153
+ connectionId: string;
154
+ payload: PersonalGitHubDisconnectRequest;
155
+ },
156
+ ) {
157
+ const existing = await getConnectionMetadata(
158
+ deps.db,
159
+ input.workspaceId,
160
+ input.connectionId,
161
+ input.subjectId,
162
+ );
163
+ if (
164
+ !existing ||
165
+ existing.subjectId !== input.subjectId ||
166
+ !isPersonalGitHubConnection(existing)
167
+ ) {
168
+ throw new HTTPException(404, { message: "personal GitHub connection not found" });
169
+ }
170
+ const metadata = PersonalGitHubConnectionMetadata.parse(existing.metadata);
171
+ try {
172
+ return await disconnectConnectionIdempotently(deps.db, {
173
+ accountId: existing.accountId,
174
+ workspaceId: input.workspaceId,
175
+ subjectId: input.subjectId,
176
+ connectionId: existing.id,
177
+ expectedVersion: input.payload.expectedVersion,
178
+ idempotencyKey: input.payload.idempotencyKey,
179
+ metadata: PersonalGitHubConnectionMetadata.parse({
180
+ ...metadata,
181
+ disconnectedAt: new Date().toISOString(),
182
+ }),
183
+ lastError: null,
184
+ updatedBySubjectId: input.subjectId,
185
+ });
186
+ } catch (error) {
187
+ if (error instanceof ConnectionDisconnectIdempotencyError) {
188
+ throw new HTTPException(409, {
189
+ message: "personal GitHub disconnect key was already used for another operation",
190
+ });
191
+ }
192
+ if (error instanceof ConnectionDisconnectGenerationError) {
193
+ throw new HTTPException(409, {
194
+ message: "personal GitHub connection changed; refresh before disconnecting",
195
+ });
196
+ }
197
+ throw error;
198
+ }
199
+ }
200
+
201
+ export async function startPersonalGitHubOAuth(
202
+ deps: ApiRouteDeps,
203
+ input: {
204
+ access: AccessGrantAuthorization;
205
+ workspaceId: string;
206
+ connectionId?: string;
207
+ returnPath?: string;
208
+ },
209
+ ) {
210
+ const oauth = requirePersonalGitHubOAuthSettings(deps.settings);
211
+ const { grant } = input.access;
212
+ const existing = input.connectionId
213
+ ? await getConnectionMetadata(deps.db, input.workspaceId, input.connectionId, grant.subjectId)
214
+ : null;
215
+ if (input.connectionId && !existing) {
216
+ throw new HTTPException(404, { message: "personal GitHub connection not found" });
217
+ }
218
+ if (
219
+ existing &&
220
+ (existing.subjectId !== grant.subjectId || !isPersonalGitHubConnection(existing))
221
+ ) {
222
+ throw new HTTPException(422, {
223
+ message: "connectionId is not the caller's personal GitHub connection",
224
+ });
225
+ }
226
+
227
+ const key = requireEnvironmentEncryption(deps.settings);
228
+ const verifier = randomBytes(48).toString("base64url");
229
+ const returnPath = personalGitHubReturnPath(input.workspaceId, input.returnPath);
230
+ const state = createSignedState(requireIntegrationsStateSecret(deps.settings), {
231
+ accountId: grant.accountId,
232
+ workspaceId: input.workspaceId,
233
+ subjectId: grant.subjectId,
234
+ kind: PERSONAL_GITHUB_OAUTH_STATE_KIND,
235
+ [PERSONAL_OWNER_VERIFIED_STATE_CLAIM]: true,
236
+ canonicalManagedHumanSession: input.access.canonicalManagedHumanSession,
237
+ returnPath,
238
+ encryptedPkceVerifier: encryptEnvironmentValue(key, verifier),
239
+ oauthEnvironment: oauth.oauthEnvironment,
240
+ oauthClientMarker: oauth.oauthClientMarker,
241
+ ...(existing ? { connectionId: existing.id, connectionVersion: existing.version } : {}),
242
+ });
243
+ const authorizationUrl = new URL(PERSONAL_GITHUB_AUTHORIZATION_URL);
244
+ authorizationUrl.searchParams.set("client_id", oauth.clientId);
245
+ authorizationUrl.searchParams.set("redirect_uri", oauth.callbackUrl);
246
+ authorizationUrl.searchParams.set("scope", PERSONAL_GITHUB_REQUESTED_SCOPES.join(" "));
247
+ authorizationUrl.searchParams.set("state", state);
248
+ authorizationUrl.searchParams.set(
249
+ "code_challenge",
250
+ createHash("sha256").update(verifier).digest("base64url"),
251
+ );
252
+ authorizationUrl.searchParams.set("code_challenge_method", "S256");
253
+ return PersonalGitHubOAuthStartResponse.parse({
254
+ authorizationUrl: authorizationUrl.toString(),
255
+ expiresAt: new Date(Date.now() + oauthStateTtlMs).toISOString(),
256
+ });
257
+ }
258
+
259
+ export async function completePersonalGitHubOAuthCallback(
260
+ deps: ApiRouteDeps,
261
+ input: {
262
+ code?: string;
263
+ state?: string;
264
+ error?: string;
265
+ requestUrl: string;
266
+ },
267
+ ): Promise<{ redirectTo: string }> {
268
+ const apiBaseUrl = integrationBaseUrl(deps.settings.publicBaseUrl, input.requestUrl);
269
+ const returnBaseUrl = deps.settings.webBaseUrl?.replace(/\/+$/u, "") ?? apiBaseUrl;
270
+ let state: PersonalGitHubOAuthState | null = null;
271
+ try {
272
+ state = readPersonalGitHubOAuthState(input.state, deps.settings);
273
+ await requirePersonalGitHubCallbackGrant(deps, state);
274
+ const consumed = await consumeIntegrationOAuthStateNonce(deps.db, {
275
+ accountId: state.accountId,
276
+ workspaceId: state.workspaceId,
277
+ subjectId: state.subjectId,
278
+ nonce: state.nonce,
279
+ expiresAt: new Date(state.iat * 1000 + oauthStateTtlMs),
280
+ now: new Date(),
281
+ });
282
+ if (!consumed) throw new PersonalGitHubCallbackError("state_replayed");
283
+ if (input.error) throw new PersonalGitHubCallbackError("provider_denied");
284
+ if (!input.code) throw new PersonalGitHubCallbackError("missing_code");
285
+
286
+ const oauth = requirePersonalGitHubOAuthSettings(deps.settings);
287
+ if (
288
+ oauth.oauthEnvironment !== state.oauthEnvironment ||
289
+ oauth.oauthClientMarker !== state.oauthClientMarker
290
+ ) {
291
+ throw new PersonalGitHubCallbackError("client_changed");
292
+ }
293
+ const key = requireEnvironmentEncryption(deps.settings);
294
+ let verifier: string;
295
+ try {
296
+ verifier = decryptEnvironmentValue(key, state.encryptedPkceVerifier);
297
+ } catch {
298
+ throw new PersonalGitHubCallbackError("invalid_state");
299
+ }
300
+ const fetchImpl = deps.githubPersonalFetch ?? fetch;
301
+ const token = await exchangePersonalGitHubAuthorizationCode(
302
+ fetchImpl,
303
+ oauth,
304
+ input.code,
305
+ verifier,
306
+ );
307
+ assertExactPersonalGitHubScopes(token.scopes);
308
+ const identity = await fetchPersonalGitHubIdentity(fetchImpl, token.accessToken);
309
+ await requirePersonalGitHubCallbackGrant(deps, state);
310
+
311
+ const requested = state.connectionId
312
+ ? await getConnectionMetadata(deps.db, state.workspaceId, state.connectionId, state.subjectId)
313
+ : null;
314
+ if (
315
+ state.connectionId &&
316
+ (!requested ||
317
+ requested.version !== state.connectionVersion ||
318
+ !isPersonalGitHubConnection(requested))
319
+ ) {
320
+ throw new PersonalGitHubCallbackError("connection_conflict");
321
+ }
322
+ const requestedMetadata = requested
323
+ ? PersonalGitHubConnectionMetadata.parse(requested.metadata)
324
+ : null;
325
+ if (requestedMetadata && requestedMetadata.providerPrincipalId !== identity.id) {
326
+ throw new PersonalGitHubCallbackError("account_mismatch");
327
+ }
328
+ if (!requested) {
329
+ const active = (
330
+ await listPersonalGitHubConnections(deps, {
331
+ workspaceId: state.workspaceId,
332
+ subjectId: state.subjectId,
333
+ })
334
+ ).filter((connection) => connection.status !== "revoked");
335
+ if (
336
+ active.some(
337
+ (connection) =>
338
+ PersonalGitHubConnectionMetadata.parse(connection.metadata).providerPrincipalId !==
339
+ identity.id,
340
+ )
341
+ ) {
342
+ throw new PersonalGitHubCallbackError("account_mismatch");
343
+ }
344
+ }
345
+
346
+ const now = new Date().toISOString();
347
+ const metadata = PersonalGitHubConnectionMetadata.parse({
348
+ ...(requested?.metadata ?? {}),
349
+ credentialRole: PERSONAL_GITHUB_CREDENTIAL_ROLE,
350
+ providerFamily: PERSONAL_GITHUB_PROVIDER_FAMILY,
351
+ providerPrincipalId: identity.id,
352
+ githubUserId: identity.id,
353
+ githubLogin: identity.login,
354
+ oauthEnvironment: oauth.oauthEnvironment,
355
+ oauthClientMarker: oauth.oauthClientMarker,
356
+ credentialBindingId: requestedMetadata?.credentialBindingId ?? randomUUID(),
357
+ connectedAt: requestedMetadata?.connectedAt ?? now,
358
+ lastVerifiedAt: now,
359
+ disconnectedAt: null,
360
+ refreshTokenExpiresAt: token.refreshTokenExpiresAt?.toISOString() ?? null,
361
+ });
362
+ const credentialEncrypted = encryptEnvironmentValue(
363
+ key,
364
+ JSON.stringify({
365
+ access_token: token.accessToken,
366
+ ...(token.refreshToken ? { refresh_token: token.refreshToken } : {}),
367
+ token_type: token.tokenType,
368
+ ...(token.expiresAt ? { expires_at: token.expiresAt.toISOString() } : {}),
369
+ ...(token.refreshTokenExpiresAt
370
+ ? { refresh_token_expires_at: token.refreshTokenExpiresAt.toISOString() }
371
+ : {}),
372
+ scope: token.scopes.join(" "),
373
+ token_endpoint: PERSONAL_GITHUB_TOKEN_URL,
374
+ client_id: oauth.clientId,
375
+ token_endpoint_auth_method: "client_secret_post",
376
+ scope_parameter_supported: false,
377
+ }),
378
+ );
379
+ const connection = await persistProviderOAuthConnection(deps.db, {
380
+ accountId: state.accountId,
381
+ workspaceId: state.workspaceId,
382
+ subjectId: state.subjectId,
383
+ visibleToSubjectId: state.subjectId,
384
+ providerDomain: PERSONAL_GITHUB_PROVIDER_DOMAIN,
385
+ kind: "oauth2",
386
+ status: "active",
387
+ credentialEncrypted,
388
+ grantedScopes: token.scopes,
389
+ expiresAt: token.expiresAt,
390
+ metadata,
391
+ createdBySubjectId: state.subjectId,
392
+ updatedBySubjectId: state.subjectId,
393
+ credentialRole: PERSONAL_GITHUB_CREDENTIAL_ROLE,
394
+ providerFamily: PERSONAL_GITHUB_PROVIDER_FAMILY,
395
+ providerPrincipalId: identity.id,
396
+ requireLiveUserAuthority: true,
397
+ requiredLiveUserPermission: "connections:write",
398
+ allowCanonicalPersonalWorkspaceOwner: state.canonicalManagedHumanSession,
399
+ exclusiveProviderPrincipalPerOwner: true,
400
+ preserveExistingMetadataKeys: ["credentialBindingId", "connectedAt"],
401
+ ...(state.connectionId
402
+ ? {
403
+ requestedConnectionId: state.connectionId,
404
+ requestedConnectionVersion: state.connectionVersion,
405
+ }
406
+ : {}),
407
+ });
408
+ if (!connection) throw new PersonalGitHubCallbackError("connection_conflict");
409
+ return {
410
+ redirectTo: personalGitHubCallbackReturnUrl(returnBaseUrl, state.returnPath, "success", {
411
+ connectionId: connection.id,
412
+ }),
413
+ };
414
+ } catch (error) {
415
+ return {
416
+ redirectTo: personalGitHubCallbackReturnUrl(
417
+ returnBaseUrl,
418
+ state?.returnPath ?? "/integrations",
419
+ "error",
420
+ { reason: personalGitHubFailureReason(error) },
421
+ ),
422
+ };
423
+ }
424
+ }
425
+
426
+ export function personalGitHubReviewUrl(settings: Settings): string | null {
427
+ const clientId = settings.githubPersonalOauthClientId?.trim();
428
+ return clientId
429
+ ? `https://github.com/settings/connections/applications/${encodeURIComponent(clientId)}`
430
+ : null;
431
+ }
432
+
433
+ function readPersonalGitHubOAuthState(
434
+ raw: string | undefined,
435
+ settings: Settings,
436
+ ): PersonalGitHubOAuthState {
437
+ if (!raw) throw new PersonalGitHubCallbackError("invalid_state");
438
+ let secret: string;
439
+ try {
440
+ secret = requireIntegrationsStateSecret(settings);
441
+ } catch {
442
+ throw new PersonalGitHubCallbackError("disabled");
443
+ }
444
+ const payload = readSignedState(raw, secret) as Record<string, unknown> | null;
445
+ if (!payload || payload.kind !== PERSONAL_GITHUB_OAUTH_STATE_KIND) {
446
+ throw new PersonalGitHubCallbackError("invalid_state");
447
+ }
448
+ const iat = numberValue(payload.iat);
449
+ const connectionId = optionalString(payload.connectionId);
450
+ const connectionVersion = numberValue(payload.connectionVersion);
451
+ const issuedAtMs = iat === null ? null : iat * 1000;
452
+ const nowMs = Date.now();
453
+ if (
454
+ iat === null ||
455
+ !Number.isInteger(iat) ||
456
+ issuedAtMs === null ||
457
+ issuedAtMs > nowMs + 60_000 ||
458
+ nowMs >= issuedAtMs + oauthStateTtlMs ||
459
+ (connectionVersion !== null && !Number.isInteger(connectionVersion)) ||
460
+ Boolean(connectionId) !== Boolean(connectionVersion)
461
+ ) {
462
+ throw new PersonalGitHubCallbackError("invalid_state");
463
+ }
464
+ const workspaceId = requiredString(payload.workspaceId);
465
+ return {
466
+ accountId: requiredString(payload.accountId),
467
+ workspaceId,
468
+ subjectId: requiredString(payload.subjectId),
469
+ personalOwnerVerified: personalOwnerVerifiedInState(payload),
470
+ canonicalManagedHumanSession: payload.canonicalManagedHumanSession === true,
471
+ returnPath: personalGitHubReturnPath(workspaceId, requiredString(payload.returnPath)),
472
+ encryptedPkceVerifier: requiredString(payload.encryptedPkceVerifier),
473
+ oauthEnvironment: requiredString(payload.oauthEnvironment),
474
+ oauthClientMarker: requiredString(payload.oauthClientMarker),
475
+ ...(connectionId ? { connectionId, connectionVersion: connectionVersion! } : {}),
476
+ nonce: requiredString(payload.nonce),
477
+ iat,
478
+ };
479
+ }
480
+
481
+ async function requirePersonalGitHubCallbackGrant(
482
+ deps: ApiRouteDeps,
483
+ state: PersonalGitHubOAuthState,
484
+ ): Promise<void> {
485
+ if (
486
+ !personalOwnerStateAccepted({
487
+ ownership: "personal",
488
+ subjectId: state.subjectId,
489
+ personalOwnerVerified: state.personalOwnerVerified,
490
+ })
491
+ ) {
492
+ throw new PersonalGitHubCallbackError("not_authorized");
493
+ }
494
+ const grant = await getWorkspaceGrant(deps.db, state.subjectId, state.workspaceId);
495
+ if (
496
+ grant &&
497
+ grant.accountId === state.accountId &&
498
+ hasPermission(grant.permissions, "connections:write")
499
+ ) {
500
+ return;
501
+ }
502
+ if (
503
+ state.canonicalManagedHumanSession &&
504
+ (await namedSubjectHasLiveWorkspaceAuthority(deps.db, {
505
+ accountId: state.accountId,
506
+ workspaceId: state.workspaceId,
507
+ subjectId: state.subjectId,
508
+ }))
509
+ ) {
510
+ return;
511
+ }
512
+ throw new PersonalGitHubCallbackError("not_authorized");
513
+ }
514
+
515
+ async function exchangePersonalGitHubAuthorizationCode(
516
+ fetchImpl: FetchLike,
517
+ oauth: ReturnType<typeof requirePersonalGitHubOAuthSettings>,
518
+ code: string,
519
+ verifier: string,
520
+ ): Promise<GitHubToken> {
521
+ let response: Response;
522
+ try {
523
+ response = await fetchImpl(PERSONAL_GITHUB_TOKEN_URL, {
524
+ method: "POST",
525
+ redirect: "manual",
526
+ headers: {
527
+ accept: "application/json",
528
+ "content-type": "application/x-www-form-urlencoded",
529
+ "user-agent": "OpenGeni",
530
+ },
531
+ body: new URLSearchParams({
532
+ client_id: oauth.clientId,
533
+ client_secret: oauth.clientSecret,
534
+ code,
535
+ redirect_uri: oauth.callbackUrl,
536
+ code_verifier: verifier,
537
+ }),
538
+ signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS),
539
+ });
540
+ } catch {
541
+ throw new PersonalGitHubCallbackError("token_exchange_failed");
542
+ }
543
+ if (!response.ok || (response.status >= 300 && response.status < 400)) {
544
+ await consumeBoundedJson(response, "GitHub OAuth error response");
545
+ throw new PersonalGitHubCallbackError("token_exchange_failed");
546
+ }
547
+ const payload = await readResponseJsonBounded<Record<string, unknown>>(
548
+ response,
549
+ GITHUB_RESPONSE_MAX_BYTES,
550
+ "GitHub OAuth token response",
551
+ ).catch(() => {
552
+ throw new PersonalGitHubCallbackError("token_exchange_failed");
553
+ });
554
+ const accessToken = optionalString(payload.access_token);
555
+ const tokenType = optionalString(payload.token_type)?.toLowerCase();
556
+ if (!accessToken || tokenType !== "bearer") {
557
+ throw new PersonalGitHubCallbackError("token_exchange_failed");
558
+ }
559
+ const expiresIn = positiveInteger(payload.expires_in);
560
+ const refreshToken = optionalString(payload.refresh_token);
561
+ if (expiresIn !== null && !refreshToken) {
562
+ throw new PersonalGitHubCallbackError("token_exchange_failed");
563
+ }
564
+ const refreshExpiresIn = positiveInteger(payload.refresh_token_expires_in);
565
+ return {
566
+ accessToken,
567
+ refreshToken,
568
+ tokenType: "Bearer",
569
+ expiresAt: expiresIn === null ? null : new Date(Date.now() + expiresIn * 1000),
570
+ refreshTokenExpiresAt:
571
+ refreshExpiresIn === null ? null : new Date(Date.now() + refreshExpiresIn * 1000),
572
+ scopes: parseGitHubScopes(optionalString(payload.scope)),
573
+ };
574
+ }
575
+
576
+ async function fetchPersonalGitHubIdentity(
577
+ fetchImpl: FetchLike,
578
+ accessToken: string,
579
+ ): Promise<GitHubIdentity> {
580
+ let response: Response;
581
+ try {
582
+ response = await fetchImpl(PERSONAL_GITHUB_USER_URL, {
583
+ method: "GET",
584
+ redirect: "manual",
585
+ headers: {
586
+ accept: "application/vnd.github+json",
587
+ authorization: `Bearer ${accessToken}`,
588
+ "user-agent": "OpenGeni",
589
+ "x-github-api-version": GITHUB_API_VERSION,
590
+ },
591
+ signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS),
592
+ });
593
+ } catch {
594
+ throw new PersonalGitHubCallbackError("identity_failed");
595
+ }
596
+ if (!response.ok || (response.status >= 300 && response.status < 400)) {
597
+ await consumeBoundedJson(response, "GitHub identity error response");
598
+ throw new PersonalGitHubCallbackError("identity_failed");
599
+ }
600
+ const headerScopes = response.headers.get("x-oauth-scopes");
601
+ if (headerScopes) assertExactPersonalGitHubScopes(parseGitHubScopes(headerScopes));
602
+ const payload = await readResponseJsonBounded<Record<string, unknown>>(
603
+ response,
604
+ GITHUB_RESPONSE_MAX_BYTES,
605
+ "GitHub identity response",
606
+ ).catch(() => {
607
+ throw new PersonalGitHubCallbackError("identity_failed");
608
+ });
609
+ const id = positiveInteger(payload.id);
610
+ const login = optionalString(payload.login);
611
+ if (id === null || !Number.isSafeInteger(id) || !login || login.length > 100) {
612
+ throw new PersonalGitHubCallbackError("identity_failed");
613
+ }
614
+ return { id: String(id), login };
615
+ }
616
+
617
+ function assertExactPersonalGitHubScopes(scopes: string[]): void {
618
+ const exact = [...new Set(scopes)].sort();
619
+ if (exact.length !== 1 || exact[0] !== "repo") {
620
+ throw new PersonalGitHubCallbackError("scope_not_granted");
621
+ }
622
+ }
623
+
624
+ function parseGitHubScopes(raw: string | null): string[] {
625
+ if (!raw) return [];
626
+ return [
627
+ ...new Set(
628
+ raw
629
+ .split(/[\s,]+/u)
630
+ .map((scope) => scope.trim())
631
+ .filter(Boolean),
632
+ ),
633
+ ].sort();
634
+ }
635
+
636
+ function personalGitHubReturnPath(workspaceId: string, raw?: string): string {
637
+ const fallback = `/workspaces/${workspaceId}/capabilities`;
638
+ if (!raw) return fallback;
639
+ let parsed: URL;
640
+ try {
641
+ parsed = new URL(raw, "https://opengeni.invalid");
642
+ } catch {
643
+ throw new HTTPException(400, { message: "invalid personal GitHub return path" });
644
+ }
645
+ const workspacePrefix = `/workspaces/${workspaceId}`;
646
+ if (
647
+ parsed.origin !== "https://opengeni.invalid" ||
648
+ (parsed.pathname !== workspacePrefix && !parsed.pathname.startsWith(`${workspacePrefix}/`))
649
+ ) {
650
+ throw new HTTPException(400, { message: "invalid personal GitHub return path" });
651
+ }
652
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
653
+ }
654
+
655
+ function personalGitHubCallbackReturnUrl(
656
+ baseUrl: string,
657
+ returnPath: string,
658
+ status: "success" | "error",
659
+ params: Record<string, string>,
660
+ ): string {
661
+ let safePath: string;
662
+ try {
663
+ const match = returnPath.match(/^\/workspaces\/([^/]+)/u);
664
+ safePath = match ? personalGitHubReturnPath(match[1]!, returnPath) : "/integrations";
665
+ } catch {
666
+ safePath = "/integrations";
667
+ }
668
+ const url = new URL(safePath, `${baseUrl.replace(/\/+$/u, "")}/`);
669
+ url.searchParams.set("github_personal_oauth", status);
670
+ for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value.slice(0, 256));
671
+ return url.toString();
672
+ }
673
+
674
+ function personalGitHubFailureReason(error: unknown): PersonalGitHubFailureReason {
675
+ if (error instanceof PersonalGitHubCallbackError) return error.reason;
676
+ if (error instanceof HTTPException && error.status === 404) return "disabled";
677
+ return "connection_conflict";
678
+ }
679
+
680
+ async function consumeBoundedJson(response: Response, label: string): Promise<void> {
681
+ await readResponseJsonBounded(response, GITHUB_RESPONSE_MAX_BYTES, label).catch(async () => {
682
+ await response.body?.cancel().catch(() => undefined);
683
+ });
684
+ }
685
+
686
+ function requiredString(value: unknown): string {
687
+ const parsed = optionalString(value);
688
+ if (!parsed) throw new PersonalGitHubCallbackError("invalid_state");
689
+ return parsed;
690
+ }
691
+
692
+ function optionalString(value: unknown): string | null {
693
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
694
+ }
695
+
696
+ function numberValue(value: unknown): number | null {
697
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
698
+ }
699
+
700
+ function positiveInteger(value: unknown): number | null {
701
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
702
+ }
703
+
704
+ export const PERSONAL_GITHUB_CONNECTION_PRINCIPAL_MESSAGE =
705
+ personalOnlyConnectionPrincipalMessage("My GitHub account");