@opengeni/api-router 0.23.1 → 0.30.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 (152) hide show
  1. package/dist/app.d.ts +3 -2
  2. package/dist/app.js +5 -3
  3. package/dist/auth/canonical-human-session-admission.d.ts +27 -0
  4. package/dist/browser-auth-broker.d.ts +20 -0
  5. package/dist/browser-controller-authority.d.ts +61 -0
  6. package/dist/browser-network-route.d.ts +8 -0
  7. package/dist/browser-state-authority.d.ts +27 -0
  8. package/dist/{chunk-T4T2PGU4.js → chunk-2PQMSRCB.js} +37891 -17463
  9. package/dist/chunk-2PQMSRCB.js.map +1 -0
  10. package/dist/codemode.d.ts +23 -0
  11. package/dist/controller-data-plane.d.ts +12 -0
  12. package/dist/editable-artifact-live-hints.d.ts +11 -0
  13. package/dist/editable-artifact-native-kernel.d.ts +37 -0
  14. package/dist/editable-artifact-office-import.d.ts +22 -0
  15. package/dist/editable-artifact-production.d.ts +29 -0
  16. package/dist/editable-artifact-websocket.d.ts +49 -0
  17. package/dist/editable-artifact-workspace-files.d.ts +23 -0
  18. package/dist/github-browser-flow.d.ts +6 -0
  19. package/dist/http/auth.d.ts +1 -1
  20. package/dist/http/cors.d.ts +1 -0
  21. package/dist/http/sse.d.ts +18 -1
  22. package/dist/index.d.ts +3 -2
  23. package/dist/index.js +1796 -22
  24. package/dist/index.js.map +1 -1
  25. package/dist/integrations/api-integrations.d.ts +24 -0
  26. package/dist/integrations/atlassian.d.ts +176 -0
  27. package/dist/integrations/fiken.d.ts +233 -0
  28. package/dist/integrations/github-skill-source.d.ts +5 -0
  29. package/dist/integrations/google-drive.d.ts +85 -0
  30. package/dist/integrations/oauth-client.d.ts +22 -1
  31. package/dist/integrations/provider-oauth.d.ts +17 -0
  32. package/dist/integrations/slack-bot.d.ts +175 -1
  33. package/dist/integrations/slack-interactions.d.ts +24 -6
  34. package/dist/integrations/social-api.d.ts +2 -1
  35. package/dist/interaction-holder-heartbeat.d.ts +17 -0
  36. package/dist/interaction-metrics.d.ts +11 -0
  37. package/dist/mcp/editable-artifact-query-schema.d.ts +4 -0
  38. package/dist/mcp/editable-artifacts.d.ts +13 -0
  39. package/dist/mcp/scheduled-task-view.d.ts +168 -0
  40. package/dist/mcp/server.d.ts +16 -4
  41. package/dist/memory-slack-delivery.d.ts +9 -0
  42. package/dist/model-catalog.d.ts +1 -0
  43. package/dist/routes/api-integrations.d.ts +8 -0
  44. package/dist/routes/browser-identities.d.ts +5 -0
  45. package/dist/routes/browser-sessions.d.ts +6 -0
  46. package/dist/routes/canonical-human-identities.d.ts +3 -0
  47. package/dist/routes/channels.d.ts +3 -0
  48. package/dist/routes/company-profile.d.ts +3 -0
  49. package/dist/routes/computer-sessions.d.ts +6 -0
  50. package/dist/routes/editable-artifacts.d.ts +44 -0
  51. package/dist/routes/integration-facets.d.ts +3 -0
  52. package/dist/routes/interaction-resources.d.ts +5 -0
  53. package/dist/routes/memory-slack-publications.d.ts +6 -0
  54. package/dist/routes/organization-memberships.d.ts +3 -0
  55. package/dist/routes/plugins.d.ts +8 -0
  56. package/dist/routes/sessions.d.ts +18 -2
  57. package/dist/routes/skills.d.ts +6 -0
  58. package/dist/routes/slack-task-policy.d.ts +3 -0
  59. package/dist/routes/supergrok.d.ts +3 -0
  60. package/dist/routes/video-generation.d.ts +3 -0
  61. package/dist/sandbox/channel-a.d.ts +62 -2
  62. package/dist/sandbox/machines.d.ts +2 -2
  63. package/dist/sandbox/metrics-ingestion.d.ts +6 -1
  64. package/dist/sandbox/viewer.d.ts +7 -3
  65. package/dist/slack-reaction-files.d.ts +27 -0
  66. package/dist/temporal-schedule-cleanup.d.ts +26 -0
  67. package/dist/transcription/providers/xai-subscription.d.ts +8 -0
  68. package/dist/xai-realtime.d.ts +26 -0
  69. package/dist/xai-subscription-auth.d.ts +23 -0
  70. package/package.json +20 -14
  71. package/src/app.ts +604 -64
  72. package/src/auth/canonical-human-session-admission.ts +116 -0
  73. package/src/auth/managed-auth.ts +101 -0
  74. package/src/browser-auth-broker.ts +155 -0
  75. package/src/browser-controller-authority.ts +186 -0
  76. package/src/browser-network-route.ts +34 -0
  77. package/src/browser-state-authority.ts +236 -0
  78. package/src/codemode.ts +186 -0
  79. package/src/controller-data-plane.ts +35 -0
  80. package/src/editable-artifact-live-hints.ts +64 -0
  81. package/src/editable-artifact-native-kernel.ts +659 -0
  82. package/src/editable-artifact-office-import.ts +230 -0
  83. package/src/editable-artifact-production.ts +419 -0
  84. package/src/editable-artifact-websocket.ts +311 -0
  85. package/src/editable-artifact-workspace-files.ts +186 -0
  86. package/src/github-browser-flow.ts +35 -6
  87. package/src/http/auth.ts +29 -9
  88. package/src/http/cors.ts +3 -0
  89. package/src/http/sse.ts +229 -5
  90. package/src/index.ts +119 -20
  91. package/src/integrations/api-integrations.ts +365 -0
  92. package/src/integrations/atlassian.ts +1621 -0
  93. package/src/integrations/fiken.ts +1230 -0
  94. package/src/integrations/github-skill-source.ts +142 -0
  95. package/src/integrations/google-drive.ts +1005 -64
  96. package/src/integrations/oauth-client.ts +123 -33
  97. package/src/integrations/provider-oauth.ts +756 -0
  98. package/src/integrations/slack-bot.ts +669 -11
  99. package/src/integrations/slack-interactions.ts +2063 -93
  100. package/src/integrations/social-api.ts +11 -0
  101. package/src/interaction-holder-heartbeat.ts +95 -0
  102. package/src/interaction-metrics.ts +159 -0
  103. package/src/mcp/documents.ts +146 -6
  104. package/src/mcp/editable-artifact-query-schema.ts +236 -0
  105. package/src/mcp/editable-artifacts.ts +448 -0
  106. package/src/mcp/scheduled-task-view.ts +34 -0
  107. package/src/mcp/server.ts +1756 -355
  108. package/src/memory-slack-delivery.ts +209 -0
  109. package/src/model-catalog.ts +19 -6
  110. package/src/routes/api-integrations.ts +410 -0
  111. package/src/routes/browser-identities.ts +160 -0
  112. package/src/routes/browser-sessions.ts +4482 -0
  113. package/src/routes/canonical-human-identities.ts +156 -0
  114. package/src/routes/channels.ts +90 -0
  115. package/src/routes/company-profile.ts +255 -0
  116. package/src/routes/computer-sessions.ts +1602 -0
  117. package/src/routes/connections.ts +421 -99
  118. package/src/routes/documents.ts +22 -3
  119. package/src/routes/editable-artifacts.ts +1159 -0
  120. package/src/routes/files.ts +106 -4
  121. package/src/routes/github.ts +18 -2
  122. package/src/routes/install.ts +7 -1
  123. package/src/routes/integration-facets.ts +258 -0
  124. package/src/routes/interaction-resources.ts +595 -0
  125. package/src/routes/memory-slack-publications.ts +216 -0
  126. package/src/routes/organization-memberships.ts +53 -0
  127. package/src/routes/packs.ts +437 -7
  128. package/src/routes/plugins.ts +752 -0
  129. package/src/routes/rigs.ts +77 -20
  130. package/src/routes/scheduled-tasks.ts +67 -40
  131. package/src/routes/sessions.ts +830 -278
  132. package/src/routes/skills.ts +256 -0
  133. package/src/routes/slack-task-policy.ts +115 -0
  134. package/src/routes/supergrok.ts +717 -0
  135. package/src/routes/transcription-recordings.ts +9 -2
  136. package/src/routes/transcriptions.ts +2 -1
  137. package/src/routes/video-generation.ts +162 -0
  138. package/src/routes/workspaces.ts +68 -28
  139. package/src/sandbox/channel-a.ts +750 -116
  140. package/src/sandbox/machines.ts +5 -5
  141. package/src/sandbox/metrics-ingestion.ts +121 -3
  142. package/src/sandbox/rematerialize.ts +35 -47
  143. package/src/sandbox/viewer.ts +39 -12
  144. package/src/slack-reaction-files.ts +181 -0
  145. package/src/temporal-schedule-cleanup.ts +135 -0
  146. package/src/transcription/providers/xai-subscription.ts +110 -0
  147. package/src/transcription/service.ts +17 -2
  148. package/src/xai-realtime.ts +216 -0
  149. package/src/xai-subscription-auth.ts +132 -0
  150. package/dist/chunk-T4T2PGU4.js.map +0 -1
  151. package/dist/mcp/toolspace.d.ts +0 -71
  152. package/src/mcp/toolspace.ts +0 -1190
@@ -0,0 +1,756 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+
4
+ import {
5
+ integrationDefinitionProviderDomain,
6
+ integrationDefinitionById,
7
+ type IntegrationDefinition,
8
+ } from "@opengeni/capabilities";
9
+ import {
10
+ parseIntegrationsOauthClientsJson,
11
+ type IntegrationOAuthClientConfig,
12
+ type Settings,
13
+ } from "@opengeni/config";
14
+ import {
15
+ API_INTEGRATION_OAUTH_CREDENTIAL_ROLE,
16
+ ApiIntegrationOAuthConnectionMetadata,
17
+ OAuthStartResponse,
18
+ type ApiIntegrationOAuthStartRequest,
19
+ type ConnectionOwnership,
20
+ } from "@opengeni/contracts";
21
+ import { hasPermission, requireEnvironmentEncryption, type ApiRouteDeps } from "@opengeni/core";
22
+ import {
23
+ consumeIntegrationOAuthStateNonce,
24
+ decryptEnvironmentValue,
25
+ encryptEnvironmentValue,
26
+ getConnectionMetadata,
27
+ getWorkspaceGrant,
28
+ loadConnectionCredentialForBroker,
29
+ persistProviderOAuthConnection,
30
+ } from "@opengeni/db";
31
+ import { createSignedState, readSignedState } from "@opengeni/github";
32
+ import {
33
+ OAUTH_MAX_RESPONSE_BYTES,
34
+ pinnedFetch,
35
+ readResponseJsonBounded,
36
+ type FetchLike,
37
+ } from "@opengeni/network";
38
+ import { HTTPException } from "hono/http-exception";
39
+
40
+ import {
41
+ integrationBaseUrl,
42
+ oauthStateTtlMs,
43
+ requireIntegrationsStateSecret,
44
+ } from "./oauth-client";
45
+
46
+ const PROVIDER_OAUTH_CALLBACK_PATH = "/v1/integrations/provider-oauth/callback";
47
+ const PROVIDER_OAUTH_TIMEOUT_MS = 15_000;
48
+ const GOOGLE_USERINFO_URL = "https://openidconnect.googleapis.com/v1/userinfo";
49
+ const MICROSOFT_USERINFO_URL =
50
+ "https://graph.microsoft.com/v1.0/me?$select=id,displayName,userPrincipalName,mail";
51
+
52
+ type ProviderOAuthClient = {
53
+ clientId: string;
54
+ clientSecret?: string;
55
+ tokenEndpointAuthMethod: "none" | "client_secret_post" | "client_secret_basic";
56
+ };
57
+
58
+ type ProviderOAuthState = {
59
+ accountId: string;
60
+ workspaceId: string;
61
+ subjectId: string;
62
+ ownership: ConnectionOwnership;
63
+ definitionId: string;
64
+ definitionFingerprint: string;
65
+ providerDomain: string;
66
+ authorizeScopes: string[];
67
+ encryptedPkceVerifier: string;
68
+ clientId: string;
69
+ tokenEndpointAuthMethod: ProviderOAuthClient["tokenEndpointAuthMethod"];
70
+ returnPath: string;
71
+ connectionId?: string;
72
+ connectionVersion?: number;
73
+ expectedProviderPrincipalId?: string;
74
+ nonce: string;
75
+ iat: number;
76
+ };
77
+
78
+ type ProviderToken = {
79
+ accessToken: string;
80
+ refreshToken?: string;
81
+ tokenType: string;
82
+ expiresAt: Date | null;
83
+ scopes: string[];
84
+ };
85
+
86
+ type ProviderIdentity = {
87
+ principalId: string;
88
+ email: string | null;
89
+ displayName: string | null;
90
+ };
91
+
92
+ type ProviderOAuthFailureReason =
93
+ | "state_invalid"
94
+ | "state_replayed"
95
+ | "provider_denied"
96
+ | "missing_code"
97
+ | "client_unavailable"
98
+ | "token_exchange_failed"
99
+ | "scope_not_granted"
100
+ | "identity_verification_failed"
101
+ | "account_mismatch"
102
+ | "refresh_token_missing"
103
+ | "connection_conflict"
104
+ | "provider_unavailable"
105
+ | "persistence_failed";
106
+
107
+ class ProviderOAuthCallbackError extends Error {
108
+ constructor(readonly reason: ProviderOAuthFailureReason) {
109
+ super(reason);
110
+ this.name = "ProviderOAuthCallbackError";
111
+ }
112
+ }
113
+
114
+ export async function startApiIntegrationProviderOAuth(
115
+ deps: ApiRouteDeps,
116
+ input: {
117
+ accountId: string;
118
+ workspaceId: string;
119
+ subjectId: string;
120
+ requestUrl: string;
121
+ payload: ApiIntegrationOAuthStartRequest;
122
+ },
123
+ ): Promise<OAuthStartResponse> {
124
+ const definition = requiredDefinition(input.payload.definitionId);
125
+ const providerDomain = integrationDefinitionProviderDomain(definition);
126
+ const existing = input.payload.connectionId
127
+ ? await getConnectionMetadata(
128
+ deps.db,
129
+ input.workspaceId,
130
+ input.payload.connectionId,
131
+ input.subjectId,
132
+ )
133
+ : null;
134
+ if (input.payload.connectionId && !existing) {
135
+ throw new HTTPException(404, { message: "Connection not found" });
136
+ }
137
+ const existingMetadata = existing
138
+ ? requireProviderOAuthConnection(existing, {
139
+ subjectId: input.subjectId,
140
+ providerDomain,
141
+ providerFamily: definition.provider.id,
142
+ })
143
+ : null;
144
+ const existingOwnership: ConnectionOwnership | null = existing
145
+ ? existing.subjectId === null
146
+ ? "workspace"
147
+ : "personal"
148
+ : null;
149
+ if (
150
+ input.payload.ownership &&
151
+ existingOwnership &&
152
+ input.payload.ownership !== existingOwnership
153
+ ) {
154
+ throw new HTTPException(422, {
155
+ message: "The selected Connection ownership does not match this OAuth request",
156
+ });
157
+ }
158
+ const ownership = existingOwnership ?? input.payload.ownership ?? "personal";
159
+ const authorizeScopes = uniqueStrings([
160
+ ...(existing?.grantedScopes ?? []),
161
+ ...definition.authentication.scopes,
162
+ ]);
163
+ const client = providerClientForDefinition(deps.settings, definition);
164
+ const verifier = randomBytes(48).toString("base64url");
165
+ const key = requireEnvironmentEncryption(deps.settings);
166
+ const baseUrl = integrationBaseUrl(deps.settings.publicBaseUrl, input.requestUrl);
167
+ const redirectUri = `${baseUrl}${PROVIDER_OAUTH_CALLBACK_PATH}`;
168
+ const returnPath = safeReturnPath(
169
+ input.payload.returnPath ?? `/workspaces/${input.workspaceId}/capabilities`,
170
+ );
171
+ const state = createSignedState(requireIntegrationsStateSecret(deps.settings), {
172
+ accountId: input.accountId,
173
+ workspaceId: input.workspaceId,
174
+ subjectId: input.subjectId,
175
+ ownership,
176
+ definitionId: definition.id,
177
+ definitionFingerprint: providerDefinitionFingerprint(definition),
178
+ providerDomain,
179
+ authorizeScopes,
180
+ encryptedPkceVerifier: encryptEnvironmentValue(key, verifier),
181
+ clientId: client.clientId,
182
+ tokenEndpointAuthMethod: client.tokenEndpointAuthMethod,
183
+ returnPath,
184
+ ...(existing
185
+ ? {
186
+ connectionId: existing.id,
187
+ connectionVersion: existing.version,
188
+ expectedProviderPrincipalId: existingMetadata!.providerPrincipalId,
189
+ }
190
+ : {}),
191
+ });
192
+ const authorizationUrl = new URL(definition.authentication.authorizationUrl);
193
+ authorizationUrl.searchParams.set("response_type", "code");
194
+ authorizationUrl.searchParams.set("client_id", client.clientId);
195
+ authorizationUrl.searchParams.set("redirect_uri", redirectUri);
196
+ authorizationUrl.searchParams.set("scope", authorizeScopes.join(" "));
197
+ authorizationUrl.searchParams.set("state", state);
198
+ authorizationUrl.searchParams.set("code_challenge_method", "S256");
199
+ authorizationUrl.searchParams.set(
200
+ "code_challenge",
201
+ createHash("sha256").update(verifier).digest("base64url"),
202
+ );
203
+ authorizationUrl.searchParams.set("prompt", "select_account");
204
+ if (definition.provider.id === "google") {
205
+ authorizationUrl.searchParams.set("access_type", "offline");
206
+ authorizationUrl.searchParams.set("include_granted_scopes", "true");
207
+ authorizationUrl.searchParams.set("prompt", "consent select_account");
208
+ }
209
+ return OAuthStartResponse.parse({
210
+ state,
211
+ authorizationUrl: authorizationUrl.toString(),
212
+ expiresAt: new Date(Date.now() + oauthStateTtlMs).toISOString(),
213
+ });
214
+ }
215
+
216
+ export async function completeApiIntegrationProviderOAuth(
217
+ deps: ApiRouteDeps,
218
+ input: {
219
+ code?: string | undefined;
220
+ state?: string | undefined;
221
+ error?: string | undefined;
222
+ requestUrl: string;
223
+ },
224
+ ): Promise<{ redirectTo: string }> {
225
+ const apiBaseUrl = integrationBaseUrl(deps.settings.publicBaseUrl, input.requestUrl);
226
+ const returnBaseUrl = deps.settings.webBaseUrl?.replace(/\/+$/, "") ?? apiBaseUrl;
227
+ let state: ProviderOAuthState | null = null;
228
+ try {
229
+ state = readProviderOAuthState(input.state, deps.settings);
230
+ await requireProviderOAuthGrant(deps, state);
231
+ const consumed = await consumeIntegrationOAuthStateNonce(deps.db, {
232
+ accountId: state.accountId,
233
+ workspaceId: state.workspaceId,
234
+ subjectId: state.subjectId,
235
+ nonce: state.nonce,
236
+ expiresAt: new Date(state.iat * 1000 + oauthStateTtlMs),
237
+ now: new Date(),
238
+ });
239
+ if (!consumed) throw new ProviderOAuthCallbackError("state_replayed");
240
+ if (input.error) throw new ProviderOAuthCallbackError("provider_denied");
241
+ if (!input.code) throw new ProviderOAuthCallbackError("missing_code");
242
+
243
+ const definition = integrationDefinitionById(state.definitionId);
244
+ if (!definition) throw new ProviderOAuthCallbackError("state_invalid");
245
+ if (
246
+ state.definitionFingerprint !== providerDefinitionFingerprint(definition) ||
247
+ state.providerDomain !== integrationDefinitionProviderDomain(definition) ||
248
+ definition.authentication.scopes.some((scope) => !state!.authorizeScopes.includes(scope))
249
+ ) {
250
+ throw new ProviderOAuthCallbackError("state_invalid");
251
+ }
252
+ const client = providerClientForDefinition(deps.settings, definition);
253
+ if (
254
+ client.clientId !== state.clientId ||
255
+ client.tokenEndpointAuthMethod !== state.tokenEndpointAuthMethod
256
+ ) {
257
+ throw new ProviderOAuthCallbackError("client_unavailable");
258
+ }
259
+ const key = requireEnvironmentEncryption(deps.settings);
260
+ const verifier = decryptEnvironmentValue(key, state.encryptedPkceVerifier);
261
+ const redirectUri = `${apiBaseUrl}${PROVIDER_OAUTH_CALLBACK_PATH}`;
262
+ const token = await exchangeProviderAuthorizationCode(deps, definition, client, {
263
+ code: input.code,
264
+ verifier,
265
+ redirectUri,
266
+ });
267
+ if (!providerScopesInclude(definition, token.scopes, state.authorizeScopes)) {
268
+ throw new ProviderOAuthCallbackError("scope_not_granted");
269
+ }
270
+ const grantedScopes = token.scopes.length > 0 ? token.scopes : state.authorizeScopes;
271
+ const identity = await verifyProviderIdentity(deps, definition, token);
272
+ if (
273
+ state.expectedProviderPrincipalId &&
274
+ state.expectedProviderPrincipalId !== identity.principalId
275
+ ) {
276
+ throw new ProviderOAuthCallbackError("account_mismatch");
277
+ }
278
+ await requireProviderOAuthGrant(deps, state);
279
+
280
+ const existing = state.connectionId
281
+ ? await getConnectionMetadata(deps.db, state.workspaceId, state.connectionId, state.subjectId)
282
+ : null;
283
+ if (state.connectionId && (!existing || existing.version !== state.connectionVersion)) {
284
+ throw new ProviderOAuthCallbackError("connection_conflict");
285
+ }
286
+ const existingMetadata = existing
287
+ ? requireProviderOAuthConnection(existing, {
288
+ subjectId: state.subjectId,
289
+ providerDomain: state.providerDomain,
290
+ providerFamily: definition.provider.id,
291
+ })
292
+ : null;
293
+ if (existingMetadata && existingMetadata.providerPrincipalId !== identity.principalId) {
294
+ throw new ProviderOAuthCallbackError("account_mismatch");
295
+ }
296
+ let refreshToken = token.refreshToken;
297
+ if (!refreshToken && existing) {
298
+ const previous = await loadConnectionCredentialForBroker(deps.db, deps.settings, {
299
+ workspaceId: state.workspaceId,
300
+ connectionId: existing.id,
301
+ providerDomain: state.providerDomain,
302
+ kind: "oauth2",
303
+ ...(existing.subjectId ? { subjectId: state.subjectId, allowSubjectOwned: true } : {}),
304
+ });
305
+ refreshToken = optionalString(previous?.credential.refresh_token);
306
+ }
307
+ if (!refreshToken) {
308
+ throw new ProviderOAuthCallbackError("refresh_token_missing");
309
+ }
310
+ const credentialEncrypted = encryptEnvironmentValue(
311
+ key,
312
+ JSON.stringify({
313
+ access_token: token.accessToken,
314
+ refresh_token: refreshToken,
315
+ token_type: token.tokenType,
316
+ ...(token.expiresAt ? { expires_at: token.expiresAt.toISOString() } : {}),
317
+ scope: grantedScopes.join(" "),
318
+ token_endpoint: definition.authentication.tokenUrl,
319
+ client_id: client.clientId,
320
+ ...(client.clientSecret
321
+ ? {
322
+ client_secret: client.clientSecret,
323
+ token_endpoint_auth_method: client.tokenEndpointAuthMethod,
324
+ }
325
+ : { token_endpoint_auth_method: "none" }),
326
+ }),
327
+ );
328
+ const ownerSubjectId = state.ownership === "personal" ? state.subjectId : null;
329
+ const metadata = ApiIntegrationOAuthConnectionMetadata.parse({
330
+ ...(existing?.metadata ?? {}),
331
+ credentialRole: API_INTEGRATION_OAUTH_CREDENTIAL_ROLE,
332
+ providerFamily: definition.provider.id,
333
+ providerPrincipalId: identity.principalId,
334
+ providerEmail: identity.email,
335
+ providerDisplayName: identity.displayName,
336
+ authorizedDefinitionIds: uniqueStrings([
337
+ ...(existingMetadata?.authorizedDefinitionIds ?? []),
338
+ definition.id,
339
+ ]),
340
+ verifiedAt: new Date().toISOString(),
341
+ });
342
+ const connection = await persistProviderOAuthConnection(deps.db, {
343
+ accountId: state.accountId,
344
+ workspaceId: state.workspaceId,
345
+ subjectId: ownerSubjectId,
346
+ visibleToSubjectId: state.subjectId,
347
+ providerDomain: state.providerDomain,
348
+ kind: "oauth2",
349
+ status: "active",
350
+ credentialEncrypted,
351
+ grantedScopes,
352
+ expiresAt: token.expiresAt,
353
+ metadata,
354
+ createdBySubjectId: state.subjectId,
355
+ updatedBySubjectId: state.subjectId,
356
+ credentialRole: API_INTEGRATION_OAUTH_CREDENTIAL_ROLE,
357
+ providerFamily: definition.provider.id,
358
+ providerPrincipalId: identity.principalId,
359
+ ...(state.connectionId
360
+ ? {
361
+ requestedConnectionId: state.connectionId,
362
+ requestedConnectionVersion: state.connectionVersion,
363
+ }
364
+ : {}),
365
+ });
366
+ if (!connection) throw new ProviderOAuthCallbackError("connection_conflict");
367
+ return {
368
+ redirectTo: providerOAuthReturnUrl(returnBaseUrl, state.returnPath, "success", {
369
+ definitionId: definition.id,
370
+ connectionId: connection.id,
371
+ providerDomain: connection.providerDomain,
372
+ ownership: state.ownership,
373
+ }),
374
+ };
375
+ } catch (error) {
376
+ return {
377
+ redirectTo: providerOAuthReturnUrl(
378
+ returnBaseUrl,
379
+ state?.returnPath ?? "/integrations",
380
+ "error",
381
+ {
382
+ ...(state?.definitionId ? { definitionId: state.definitionId } : {}),
383
+ reason: providerOAuthFailureReason(error),
384
+ },
385
+ ),
386
+ };
387
+ }
388
+ }
389
+
390
+ function requiredDefinition(id: string): IntegrationDefinition {
391
+ const definition = integrationDefinitionById(id);
392
+ if (!definition) throw new HTTPException(404, { message: "Unknown Integration definition" });
393
+ return definition;
394
+ }
395
+
396
+ function providerClientForDefinition(
397
+ settings: Settings,
398
+ definition: IntegrationDefinition,
399
+ ): ProviderOAuthClient {
400
+ const configured = parseIntegrationsOauthClientsJson(settings.integrationsOauthClientsJson);
401
+ const candidates = providerClientKeys(definition);
402
+ let entry: IntegrationOAuthClientConfig | undefined;
403
+ for (const candidate of candidates) {
404
+ entry = configured[candidate] ?? configured[candidate.replace(/\/+$/, "")];
405
+ if (entry) break;
406
+ }
407
+ if (!entry) {
408
+ const normalized = new Set(candidates.map((value) => value.replace(/\/+$/, "")));
409
+ entry = Object.entries(configured).find(([key]) =>
410
+ normalized.has(key.replace(/\/+$/, "")),
411
+ )?.[1];
412
+ }
413
+ if (
414
+ !entry &&
415
+ definition.provider.id === "google" &&
416
+ settings.googleDriveClientId?.trim() &&
417
+ settings.googleDriveClientSecret?.trim()
418
+ ) {
419
+ return {
420
+ clientId: settings.googleDriveClientId.trim(),
421
+ clientSecret: settings.googleDriveClientSecret.trim(),
422
+ tokenEndpointAuthMethod: "client_secret_post",
423
+ };
424
+ }
425
+ if (!entry) {
426
+ throw new HTTPException(503, {
427
+ message: `${definition.name} requires an operator OAuth client configuration`,
428
+ });
429
+ }
430
+ return {
431
+ clientId: entry.clientId,
432
+ ...(entry.clientSecret ? { clientSecret: entry.clientSecret } : {}),
433
+ tokenEndpointAuthMethod: entry.tokenEndpointAuthMethod,
434
+ };
435
+ }
436
+
437
+ function providerClientKeys(definition: IntegrationDefinition): string[] {
438
+ const authorization = new URL(definition.authentication.authorizationUrl);
439
+ const token = new URL(definition.authentication.tokenUrl);
440
+ return uniqueStrings([
441
+ definition.authentication.authorizationUrl,
442
+ authorization.origin,
443
+ definition.authentication.tokenUrl,
444
+ token.origin,
445
+ ...(definition.provider.id === "google"
446
+ ? ["https://accounts.google.com"]
447
+ : [
448
+ "https://login.microsoftonline.com/common/v2.0",
449
+ "https://login.microsoftonline.com/common",
450
+ "https://login.microsoftonline.com",
451
+ ]),
452
+ ]);
453
+ }
454
+
455
+ function providerDefinitionFingerprint(definition: IntegrationDefinition): string {
456
+ return createHash("sha256")
457
+ .update(
458
+ JSON.stringify({
459
+ id: definition.id,
460
+ provider: definition.provider,
461
+ baseUrl: definition.baseUrl,
462
+ authorizationUrl: definition.authentication.authorizationUrl,
463
+ tokenUrl: definition.authentication.tokenUrl,
464
+ scopes: [...definition.authentication.scopes],
465
+ }),
466
+ )
467
+ .digest("hex");
468
+ }
469
+
470
+ function readProviderOAuthState(raw: string | undefined, settings: Settings): ProviderOAuthState {
471
+ if (!raw) throw new ProviderOAuthCallbackError("state_invalid");
472
+ const payload = readSignedState(raw, requireIntegrationsStateSecret(settings)) as Record<
473
+ string,
474
+ unknown
475
+ > | null;
476
+ if (!payload) throw new ProviderOAuthCallbackError("state_invalid");
477
+ const iat = numberValue(payload.iat);
478
+ const now = Math.floor(Date.now() / 1000);
479
+ if (iat === undefined || now < iat || now - iat > oauthStateTtlMs / 1000) {
480
+ throw new ProviderOAuthCallbackError("state_invalid");
481
+ }
482
+ const ownership = payload.ownership;
483
+ const tokenEndpointAuthMethod = payload.tokenEndpointAuthMethod;
484
+ if (
485
+ (ownership !== "workspace" && ownership !== "personal") ||
486
+ (tokenEndpointAuthMethod !== "none" &&
487
+ tokenEndpointAuthMethod !== "client_secret_post" &&
488
+ tokenEndpointAuthMethod !== "client_secret_basic")
489
+ ) {
490
+ throw new ProviderOAuthCallbackError("state_invalid");
491
+ }
492
+ const connectionId = optionalString(payload.connectionId);
493
+ const connectionVersion = numberValue(payload.connectionVersion);
494
+ if (Boolean(connectionId) !== Boolean(connectionVersion)) {
495
+ throw new ProviderOAuthCallbackError("state_invalid");
496
+ }
497
+ const authorizeScopes = stringArray(payload.authorizeScopes);
498
+ if (authorizeScopes.length === 0 || authorizeScopes.length > 256) {
499
+ throw new ProviderOAuthCallbackError("state_invalid");
500
+ }
501
+ return {
502
+ accountId: requiredStateString(payload.accountId),
503
+ workspaceId: requiredStateString(payload.workspaceId),
504
+ subjectId: requiredStateString(payload.subjectId),
505
+ ownership,
506
+ definitionId: requiredStateString(payload.definitionId),
507
+ definitionFingerprint: requiredStateString(payload.definitionFingerprint),
508
+ providerDomain: requiredStateString(payload.providerDomain),
509
+ authorizeScopes,
510
+ encryptedPkceVerifier: requiredStateString(payload.encryptedPkceVerifier),
511
+ clientId: requiredStateString(payload.clientId),
512
+ tokenEndpointAuthMethod,
513
+ returnPath: safeReturnPath(requiredStateString(payload.returnPath)),
514
+ ...(connectionId ? { connectionId, connectionVersion: connectionVersion! } : {}),
515
+ ...(optionalString(payload.expectedProviderPrincipalId)
516
+ ? { expectedProviderPrincipalId: optionalString(payload.expectedProviderPrincipalId)! }
517
+ : {}),
518
+ nonce: requiredStateString(payload.nonce),
519
+ iat,
520
+ };
521
+ }
522
+
523
+ async function exchangeProviderAuthorizationCode(
524
+ deps: ApiRouteDeps,
525
+ definition: IntegrationDefinition,
526
+ client: ProviderOAuthClient,
527
+ input: { code: string; verifier: string; redirectUri: string },
528
+ ): Promise<ProviderToken> {
529
+ const body = new URLSearchParams({
530
+ grant_type: "authorization_code",
531
+ code: input.code,
532
+ redirect_uri: input.redirectUri,
533
+ code_verifier: input.verifier,
534
+ });
535
+ const headers: Record<string, string> = {
536
+ accept: "application/json",
537
+ "content-type": "application/x-www-form-urlencoded",
538
+ };
539
+ if (client.clientSecret && client.tokenEndpointAuthMethod === "client_secret_post") {
540
+ body.set("client_id", client.clientId);
541
+ body.set("client_secret", client.clientSecret);
542
+ } else if (client.clientSecret && client.tokenEndpointAuthMethod === "client_secret_basic") {
543
+ headers.authorization = `Basic ${Buffer.from(`${client.clientId}:${client.clientSecret}`).toString("base64")}`;
544
+ } else {
545
+ body.set("client_id", client.clientId);
546
+ }
547
+ const response = await providerFetch(deps, definition.authentication.tokenUrl, {
548
+ method: "POST",
549
+ headers,
550
+ body,
551
+ });
552
+ if (!response.ok || response.status >= 300) {
553
+ await response.body?.cancel().catch(() => undefined);
554
+ throw new ProviderOAuthCallbackError("token_exchange_failed");
555
+ }
556
+ const payload = await readResponseJsonBounded<Record<string, unknown>>(
557
+ response,
558
+ OAUTH_MAX_RESPONSE_BYTES,
559
+ `${definition.name} OAuth token response`,
560
+ ).catch(() => {
561
+ throw new ProviderOAuthCallbackError("token_exchange_failed");
562
+ });
563
+ const accessToken = optionalString(payload.access_token);
564
+ if (!accessToken) throw new ProviderOAuthCallbackError("token_exchange_failed");
565
+ const expiresIn = numberValue(payload.expires_in);
566
+ return {
567
+ accessToken,
568
+ ...(optionalString(payload.refresh_token)
569
+ ? { refreshToken: optionalString(payload.refresh_token)! }
570
+ : {}),
571
+ tokenType: optionalString(payload.token_type) ?? "Bearer",
572
+ expiresAt: expiresIn && expiresIn > 0 ? new Date(Date.now() + expiresIn * 1000) : null,
573
+ scopes: stringArrayFromText(optionalString(payload.scope)),
574
+ };
575
+ }
576
+
577
+ async function verifyProviderIdentity(
578
+ deps: ApiRouteDeps,
579
+ definition: IntegrationDefinition,
580
+ token: ProviderToken,
581
+ ): Promise<ProviderIdentity> {
582
+ const url = definition.provider.id === "google" ? GOOGLE_USERINFO_URL : MICROSOFT_USERINFO_URL;
583
+ const response = await providerFetch(deps, url, {
584
+ headers: {
585
+ accept: "application/json",
586
+ authorization: `${normalizeBearerScheme(token.tokenType)} ${token.accessToken}`,
587
+ },
588
+ });
589
+ if (!response.ok || response.status >= 300) {
590
+ await response.body?.cancel().catch(() => undefined);
591
+ throw new ProviderOAuthCallbackError("identity_verification_failed");
592
+ }
593
+ const payload = await readResponseJsonBounded<Record<string, unknown>>(
594
+ response,
595
+ OAUTH_MAX_RESPONSE_BYTES,
596
+ `${definition.name} identity response`,
597
+ ).catch(() => {
598
+ throw new ProviderOAuthCallbackError("identity_verification_failed");
599
+ });
600
+ if (definition.provider.id === "google") {
601
+ const principalId = optionalString(payload.sub);
602
+ if (!principalId) throw new ProviderOAuthCallbackError("identity_verification_failed");
603
+ return {
604
+ principalId,
605
+ email: optionalString(payload.email) ?? null,
606
+ displayName: optionalString(payload.name) ?? null,
607
+ };
608
+ }
609
+ const principalId = optionalString(payload.id);
610
+ if (!principalId) throw new ProviderOAuthCallbackError("identity_verification_failed");
611
+ return {
612
+ principalId,
613
+ email: optionalString(payload.mail) ?? optionalString(payload.userPrincipalName) ?? null,
614
+ displayName: optionalString(payload.displayName) ?? null,
615
+ };
616
+ }
617
+
618
+ async function providerFetch(
619
+ deps: ApiRouteDeps,
620
+ url: string | URL,
621
+ init: RequestInit,
622
+ ): Promise<Response> {
623
+ try {
624
+ return await pinnedFetch(
625
+ url,
626
+ {
627
+ ...init,
628
+ redirect: "manual",
629
+ signal: AbortSignal.timeout(PROVIDER_OAUTH_TIMEOUT_MS),
630
+ },
631
+ deps.settings,
632
+ {
633
+ ...(deps.apiIntegrationOAuthFetch
634
+ ? { fetchImpl: deps.apiIntegrationOAuthFetch as FetchLike }
635
+ : {}),
636
+ label: "API Integration provider OAuth request",
637
+ requireHttpsOutsideLocalTest: true,
638
+ },
639
+ );
640
+ } catch (error) {
641
+ if (error instanceof ProviderOAuthCallbackError) throw error;
642
+ throw new ProviderOAuthCallbackError("provider_unavailable");
643
+ }
644
+ }
645
+
646
+ async function requireProviderOAuthGrant(
647
+ deps: ApiRouteDeps,
648
+ state: Pick<ProviderOAuthState, "accountId" | "workspaceId" | "subjectId">,
649
+ ): Promise<void> {
650
+ const grant = await getWorkspaceGrant(deps.db, state.subjectId, state.workspaceId);
651
+ if (
652
+ !grant ||
653
+ grant.accountId !== state.accountId ||
654
+ !hasPermission(grant.permissions, "connections:write")
655
+ ) {
656
+ throw new ProviderOAuthCallbackError("connection_conflict");
657
+ }
658
+ }
659
+
660
+ function requireProviderOAuthConnection(
661
+ connection: NonNullable<Awaited<ReturnType<typeof getConnectionMetadata>>>,
662
+ input: {
663
+ subjectId: string;
664
+ providerDomain: string;
665
+ providerFamily: IntegrationDefinition["provider"]["id"];
666
+ },
667
+ ) {
668
+ const metadata = ApiIntegrationOAuthConnectionMetadata.safeParse(connection.metadata);
669
+ if (
670
+ connection.kind !== "oauth2" ||
671
+ connection.providerDomain !== input.providerDomain ||
672
+ (connection.subjectId !== null && connection.subjectId !== input.subjectId) ||
673
+ !metadata.success ||
674
+ metadata.data.providerFamily !== input.providerFamily
675
+ ) {
676
+ throw new HTTPException(422, {
677
+ message: "Connection is not compatible with this Integration definition",
678
+ });
679
+ }
680
+ return metadata.data;
681
+ }
682
+
683
+ function providerScopesInclude(
684
+ definition: IntegrationDefinition,
685
+ returnedScopes: string[],
686
+ fallbackScopes: string[],
687
+ ): boolean {
688
+ const effective = returnedScopes.length > 0 ? returnedScopes : fallbackScopes;
689
+ const normalize = (value: string) =>
690
+ definition.provider.id === "microsoft" ? value.toLowerCase() : value;
691
+ const granted = new Set(effective.map(normalize));
692
+ return definition.authentication.scopes.every((scope) => granted.has(normalize(scope)));
693
+ }
694
+
695
+ function providerOAuthReturnUrl(
696
+ baseUrl: string,
697
+ returnPath: string,
698
+ status: "success" | "error",
699
+ params: Record<string, string>,
700
+ ): string {
701
+ const url = new URL(safeReturnPath(returnPath), `${baseUrl.replace(/\/+$/, "")}/`);
702
+ url.searchParams.set("integration_oauth", status);
703
+ for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value.slice(0, 512));
704
+ return url.toString();
705
+ }
706
+
707
+ function providerOAuthFailureReason(error: unknown): ProviderOAuthFailureReason {
708
+ if (error instanceof ProviderOAuthCallbackError) return error.reason;
709
+ if (error instanceof HTTPException && error.status === 503) return "client_unavailable";
710
+ if (error instanceof HTTPException) return "connection_conflict";
711
+ return "persistence_failed";
712
+ }
713
+
714
+ function safeReturnPath(value: string): string {
715
+ if (!value.startsWith("/") || value.startsWith("//")) {
716
+ throw new HTTPException(400, { message: "OAuth returnPath must be a relative path" });
717
+ }
718
+ const parsed = new URL(value, "https://opengeni.local");
719
+ if (parsed.origin !== "https://opengeni.local" || parsed.pathname.startsWith("//")) {
720
+ throw new HTTPException(400, { message: "OAuth returnPath must be a relative path" });
721
+ }
722
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
723
+ }
724
+
725
+ function normalizeBearerScheme(value: string): string {
726
+ return /^bearer$/i.test(value) ? "Bearer" : value;
727
+ }
728
+
729
+ function uniqueStrings(values: readonly string[]): string[] {
730
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
731
+ }
732
+
733
+ function stringArray(value: unknown): string[] {
734
+ return Array.isArray(value)
735
+ ? uniqueStrings(value.filter((entry): entry is string => typeof entry === "string"))
736
+ : [];
737
+ }
738
+
739
+ function stringArrayFromText(value: string | undefined): string[] {
740
+ return value ? uniqueStrings(value.split(/\s+/)) : [];
741
+ }
742
+
743
+ function optionalString(value: unknown): string | undefined {
744
+ return typeof value === "string" && value.length > 0 ? value : undefined;
745
+ }
746
+
747
+ function requiredStateString(value: unknown): string {
748
+ const result = optionalString(value);
749
+ if (!result) throw new ProviderOAuthCallbackError("state_invalid");
750
+ return result;
751
+ }
752
+
753
+ function numberValue(value: unknown): number | undefined {
754
+ const number = typeof value === "number" ? value : Number(value);
755
+ return Number.isFinite(number) ? number : undefined;
756
+ }