@opengeni/api-router 0.23.1 → 0.26.1

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 (107) hide show
  1. package/dist/app.d.ts +2 -2
  2. package/dist/app.js +1 -1
  3. package/dist/browser-controller-authority.d.ts +43 -0
  4. package/dist/browser-state-authority.d.ts +27 -0
  5. package/dist/{chunk-T4T2PGU4.js → chunk-JIKNR5YL.js} +26181 -14408
  6. package/dist/chunk-JIKNR5YL.js.map +1 -0
  7. package/dist/codemode.d.ts +23 -0
  8. package/dist/editable-artifact-live-hints.d.ts +11 -0
  9. package/dist/editable-artifact-native-kernel.d.ts +37 -0
  10. package/dist/editable-artifact-office-import.d.ts +22 -0
  11. package/dist/editable-artifact-production.d.ts +29 -0
  12. package/dist/editable-artifact-websocket.d.ts +49 -0
  13. package/dist/editable-artifact-workspace-files.d.ts +23 -0
  14. package/dist/github-browser-flow.d.ts +6 -0
  15. package/dist/http/cors.d.ts +1 -0
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.js +1796 -22
  18. package/dist/index.js.map +1 -1
  19. package/dist/integrations/api-integrations.d.ts +24 -0
  20. package/dist/integrations/atlassian.d.ts +176 -0
  21. package/dist/integrations/github-skill-source.d.ts +5 -0
  22. package/dist/integrations/google-drive.d.ts +85 -0
  23. package/dist/integrations/oauth-client.d.ts +22 -1
  24. package/dist/integrations/provider-oauth.d.ts +19 -0
  25. package/dist/integrations/slack-bot.d.ts +4 -0
  26. package/dist/integrations/slack-interactions.d.ts +8 -2
  27. package/dist/integrations/social-api.d.ts +2 -1
  28. package/dist/mcp/editable-artifact-query-schema.d.ts +4 -0
  29. package/dist/mcp/editable-artifacts.d.ts +13 -0
  30. package/dist/mcp/scheduled-task-view.d.ts +168 -0
  31. package/dist/mcp/server.d.ts +14 -3
  32. package/dist/memory-slack-delivery.d.ts +9 -0
  33. package/dist/routes/api-integrations.d.ts +8 -0
  34. package/dist/routes/browser-identities.d.ts +5 -0
  35. package/dist/routes/browser-sessions.d.ts +6 -0
  36. package/dist/routes/company-profile.d.ts +3 -0
  37. package/dist/routes/computer-sessions.d.ts +6 -0
  38. package/dist/routes/editable-artifacts.d.ts +44 -0
  39. package/dist/routes/integration-features.d.ts +3 -0
  40. package/dist/routes/memory-slack-publications.d.ts +6 -0
  41. package/dist/routes/plugins.d.ts +8 -0
  42. package/dist/routes/sessions.d.ts +17 -2
  43. package/dist/routes/skills.d.ts +6 -0
  44. package/dist/routes/video-generation.d.ts +3 -0
  45. package/dist/sandbox/channel-a.d.ts +55 -2
  46. package/dist/sandbox/metrics-ingestion.d.ts +6 -1
  47. package/dist/sandbox/viewer.d.ts +4 -2
  48. package/dist/temporal-schedule-cleanup.d.ts +26 -0
  49. package/package.json +19 -14
  50. package/src/app.ts +274 -37
  51. package/src/browser-controller-authority.ts +137 -0
  52. package/src/browser-state-authority.ts +236 -0
  53. package/src/codemode.ts +186 -0
  54. package/src/editable-artifact-live-hints.ts +64 -0
  55. package/src/editable-artifact-native-kernel.ts +659 -0
  56. package/src/editable-artifact-office-import.ts +230 -0
  57. package/src/editable-artifact-production.ts +419 -0
  58. package/src/editable-artifact-websocket.ts +311 -0
  59. package/src/editable-artifact-workspace-files.ts +186 -0
  60. package/src/github-browser-flow.ts +35 -6
  61. package/src/http/auth.ts +2 -0
  62. package/src/http/cors.ts +3 -0
  63. package/src/index.ts +119 -20
  64. package/src/integrations/api-integrations.ts +350 -0
  65. package/src/integrations/atlassian.ts +1621 -0
  66. package/src/integrations/github-skill-source.ts +142 -0
  67. package/src/integrations/google-drive.ts +1000 -64
  68. package/src/integrations/oauth-client.ts +123 -33
  69. package/src/integrations/provider-oauth.ts +777 -0
  70. package/src/integrations/slack-bot.ts +31 -2
  71. package/src/integrations/slack-interactions.ts +487 -27
  72. package/src/integrations/social-api.ts +11 -0
  73. package/src/mcp/documents.ts +32 -1
  74. package/src/mcp/editable-artifact-query-schema.ts +236 -0
  75. package/src/mcp/editable-artifacts.ts +448 -0
  76. package/src/mcp/scheduled-task-view.ts +34 -0
  77. package/src/mcp/server.ts +923 -145
  78. package/src/memory-slack-delivery.ts +209 -0
  79. package/src/routes/api-integrations.ts +407 -0
  80. package/src/routes/browser-identities.ts +136 -0
  81. package/src/routes/browser-sessions.ts +2543 -0
  82. package/src/routes/company-profile.ts +255 -0
  83. package/src/routes/computer-sessions.ts +1247 -0
  84. package/src/routes/connections.ts +284 -99
  85. package/src/routes/documents.ts +22 -3
  86. package/src/routes/editable-artifacts.ts +1159 -0
  87. package/src/routes/files.ts +106 -4
  88. package/src/routes/github.ts +18 -2
  89. package/src/routes/install.ts +7 -1
  90. package/src/routes/integration-features.ts +258 -0
  91. package/src/routes/memory-slack-publications.ts +216 -0
  92. package/src/routes/packs.ts +437 -7
  93. package/src/routes/plugins.ts +751 -0
  94. package/src/routes/rigs.ts +77 -20
  95. package/src/routes/scheduled-tasks.ts +67 -40
  96. package/src/routes/sessions.ts +421 -245
  97. package/src/routes/skills.ts +174 -0
  98. package/src/routes/video-generation.ts +132 -0
  99. package/src/routes/workspaces.ts +46 -24
  100. package/src/sandbox/channel-a.ts +723 -116
  101. package/src/sandbox/metrics-ingestion.ts +121 -3
  102. package/src/sandbox/rematerialize.ts +35 -47
  103. package/src/sandbox/viewer.ts +29 -9
  104. package/src/temporal-schedule-cleanup.ts +135 -0
  105. package/dist/chunk-T4T2PGU4.js.map +0 -1
  106. package/dist/mcp/toolspace.d.ts +0 -71
  107. package/src/mcp/toolspace.ts +0 -1190
@@ -0,0 +1,777 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+
4
+ import {
5
+ providerDomainForPreset,
6
+ providerPresetById,
7
+ type OpenApiProviderPreset,
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
+ presetId: string;
64
+ presetFingerprint: 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 preset = requiredPreset(input.payload.presetId);
125
+ const providerDomain = providerDomainForPreset(preset);
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: preset.family,
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
+ ...preset.oauth.scopes,
162
+ ]);
163
+ const client = providerClientForPreset(deps.settings, preset);
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}${providerOAuthCallbackPath(preset)}`;
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
+ presetId: preset.id,
177
+ presetFingerprint: providerPresetFingerprint(preset),
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(preset.oauth.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 (preset.family === "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 preset = providerPresetById(state.presetId);
244
+ if (!preset) throw new ProviderOAuthCallbackError("state_invalid");
245
+ if (
246
+ state.presetFingerprint !== providerPresetFingerprint(preset) ||
247
+ state.providerDomain !== providerDomainForPreset(preset) ||
248
+ preset.oauth.scopes.some((scope) => !state!.authorizeScopes.includes(scope))
249
+ ) {
250
+ throw new ProviderOAuthCallbackError("state_invalid");
251
+ }
252
+ const client = providerClientForPreset(deps.settings, preset);
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}${providerOAuthCallbackPath(preset)}`;
262
+ const token = await exchangeProviderAuthorizationCode(deps, preset, client, {
263
+ code: input.code,
264
+ verifier,
265
+ redirectUri,
266
+ });
267
+ if (!providerScopesInclude(preset, 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, preset, 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: preset.family,
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: preset.oauth.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: preset.family,
333
+ providerPrincipalId: identity.principalId,
334
+ providerEmail: identity.email,
335
+ providerDisplayName: identity.displayName,
336
+ authorizedPresetIds: uniqueStrings([
337
+ ...(existingMetadata?.authorizedPresetIds ?? []),
338
+ preset.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: preset.family,
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
+ presetId: preset.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?.presetId ? { presetId: state.presetId } : {}),
383
+ reason: providerOAuthFailureReason(error),
384
+ },
385
+ ),
386
+ };
387
+ }
388
+ }
389
+
390
+ function requiredPreset(id: string): OpenApiProviderPreset {
391
+ const preset = providerPresetById(id);
392
+ if (!preset) throw new HTTPException(404, { message: "Unknown Integration preset" });
393
+ return preset;
394
+ }
395
+
396
+ export function isApiIntegrationProviderOAuthState(
397
+ value: string | undefined,
398
+ settings: Settings,
399
+ ): boolean {
400
+ try {
401
+ readProviderOAuthState(value, settings);
402
+ return true;
403
+ } catch {
404
+ return false;
405
+ }
406
+ }
407
+
408
+ function providerOAuthCallbackPath(preset: OpenApiProviderPreset): string {
409
+ // The existing Google OAuth client is already registered against this
410
+ // loopback callback. Keep that stable while the handler below distinguishes
411
+ // legacy Drive state from the protocol-neutral provider state.
412
+ return preset.family === "google"
413
+ ? "/v1/integrations/google-drive/callback"
414
+ : PROVIDER_OAUTH_CALLBACK_PATH;
415
+ }
416
+
417
+ function providerClientForPreset(
418
+ settings: Settings,
419
+ preset: OpenApiProviderPreset,
420
+ ): ProviderOAuthClient {
421
+ const configured = parseIntegrationsOauthClientsJson(settings.integrationsOauthClientsJson);
422
+ const candidates = providerClientKeys(preset);
423
+ let entry: IntegrationOAuthClientConfig | undefined;
424
+ for (const candidate of candidates) {
425
+ entry = configured[candidate] ?? configured[candidate.replace(/\/+$/, "")];
426
+ if (entry) break;
427
+ }
428
+ if (!entry) {
429
+ const normalized = new Set(candidates.map((value) => value.replace(/\/+$/, "")));
430
+ entry = Object.entries(configured).find(([key]) =>
431
+ normalized.has(key.replace(/\/+$/, "")),
432
+ )?.[1];
433
+ }
434
+ if (
435
+ !entry &&
436
+ preset.family === "google" &&
437
+ settings.googleDriveClientId?.trim() &&
438
+ settings.googleDriveClientSecret?.trim()
439
+ ) {
440
+ return {
441
+ clientId: settings.googleDriveClientId.trim(),
442
+ clientSecret: settings.googleDriveClientSecret.trim(),
443
+ tokenEndpointAuthMethod: "client_secret_post",
444
+ };
445
+ }
446
+ if (!entry) {
447
+ throw new HTTPException(503, {
448
+ message: `${preset.name} requires an operator OAuth client configuration`,
449
+ });
450
+ }
451
+ return {
452
+ clientId: entry.clientId,
453
+ ...(entry.clientSecret ? { clientSecret: entry.clientSecret } : {}),
454
+ tokenEndpointAuthMethod: entry.tokenEndpointAuthMethod,
455
+ };
456
+ }
457
+
458
+ function providerClientKeys(preset: OpenApiProviderPreset): string[] {
459
+ const authorization = new URL(preset.oauth.authorizationUrl);
460
+ const token = new URL(preset.oauth.tokenUrl);
461
+ return uniqueStrings([
462
+ preset.oauth.authorizationUrl,
463
+ authorization.origin,
464
+ preset.oauth.tokenUrl,
465
+ token.origin,
466
+ ...(preset.family === "google"
467
+ ? ["https://accounts.google.com"]
468
+ : [
469
+ "https://login.microsoftonline.com/common/v2.0",
470
+ "https://login.microsoftonline.com/common",
471
+ "https://login.microsoftonline.com",
472
+ ]),
473
+ ]);
474
+ }
475
+
476
+ function providerPresetFingerprint(preset: OpenApiProviderPreset): string {
477
+ return createHash("sha256")
478
+ .update(
479
+ JSON.stringify({
480
+ id: preset.id,
481
+ family: preset.family,
482
+ baseUrl: preset.baseUrl,
483
+ authorizationUrl: preset.oauth.authorizationUrl,
484
+ tokenUrl: preset.oauth.tokenUrl,
485
+ scopes: [...preset.oauth.scopes],
486
+ }),
487
+ )
488
+ .digest("hex");
489
+ }
490
+
491
+ function readProviderOAuthState(raw: string | undefined, settings: Settings): ProviderOAuthState {
492
+ if (!raw) throw new ProviderOAuthCallbackError("state_invalid");
493
+ const payload = readSignedState(raw, requireIntegrationsStateSecret(settings)) as Record<
494
+ string,
495
+ unknown
496
+ > | null;
497
+ if (!payload) throw new ProviderOAuthCallbackError("state_invalid");
498
+ const iat = numberValue(payload.iat);
499
+ const now = Math.floor(Date.now() / 1000);
500
+ if (iat === undefined || now < iat || now - iat > oauthStateTtlMs / 1000) {
501
+ throw new ProviderOAuthCallbackError("state_invalid");
502
+ }
503
+ const ownership = payload.ownership;
504
+ const tokenEndpointAuthMethod = payload.tokenEndpointAuthMethod;
505
+ if (
506
+ (ownership !== "workspace" && ownership !== "personal") ||
507
+ (tokenEndpointAuthMethod !== "none" &&
508
+ tokenEndpointAuthMethod !== "client_secret_post" &&
509
+ tokenEndpointAuthMethod !== "client_secret_basic")
510
+ ) {
511
+ throw new ProviderOAuthCallbackError("state_invalid");
512
+ }
513
+ const connectionId = optionalString(payload.connectionId);
514
+ const connectionVersion = numberValue(payload.connectionVersion);
515
+ if (Boolean(connectionId) !== Boolean(connectionVersion)) {
516
+ throw new ProviderOAuthCallbackError("state_invalid");
517
+ }
518
+ const authorizeScopes = stringArray(payload.authorizeScopes);
519
+ if (authorizeScopes.length === 0 || authorizeScopes.length > 256) {
520
+ throw new ProviderOAuthCallbackError("state_invalid");
521
+ }
522
+ return {
523
+ accountId: requiredStateString(payload.accountId),
524
+ workspaceId: requiredStateString(payload.workspaceId),
525
+ subjectId: requiredStateString(payload.subjectId),
526
+ ownership,
527
+ presetId: requiredStateString(payload.presetId),
528
+ presetFingerprint: requiredStateString(payload.presetFingerprint),
529
+ providerDomain: requiredStateString(payload.providerDomain),
530
+ authorizeScopes,
531
+ encryptedPkceVerifier: requiredStateString(payload.encryptedPkceVerifier),
532
+ clientId: requiredStateString(payload.clientId),
533
+ tokenEndpointAuthMethod,
534
+ returnPath: safeReturnPath(requiredStateString(payload.returnPath)),
535
+ ...(connectionId ? { connectionId, connectionVersion: connectionVersion! } : {}),
536
+ ...(optionalString(payload.expectedProviderPrincipalId)
537
+ ? { expectedProviderPrincipalId: optionalString(payload.expectedProviderPrincipalId)! }
538
+ : {}),
539
+ nonce: requiredStateString(payload.nonce),
540
+ iat,
541
+ };
542
+ }
543
+
544
+ async function exchangeProviderAuthorizationCode(
545
+ deps: ApiRouteDeps,
546
+ preset: OpenApiProviderPreset,
547
+ client: ProviderOAuthClient,
548
+ input: { code: string; verifier: string; redirectUri: string },
549
+ ): Promise<ProviderToken> {
550
+ const body = new URLSearchParams({
551
+ grant_type: "authorization_code",
552
+ code: input.code,
553
+ redirect_uri: input.redirectUri,
554
+ code_verifier: input.verifier,
555
+ });
556
+ const headers: Record<string, string> = {
557
+ accept: "application/json",
558
+ "content-type": "application/x-www-form-urlencoded",
559
+ };
560
+ if (client.clientSecret && client.tokenEndpointAuthMethod === "client_secret_post") {
561
+ body.set("client_id", client.clientId);
562
+ body.set("client_secret", client.clientSecret);
563
+ } else if (client.clientSecret && client.tokenEndpointAuthMethod === "client_secret_basic") {
564
+ headers.authorization = `Basic ${Buffer.from(`${client.clientId}:${client.clientSecret}`).toString("base64")}`;
565
+ } else {
566
+ body.set("client_id", client.clientId);
567
+ }
568
+ const response = await providerFetch(deps, preset.oauth.tokenUrl, {
569
+ method: "POST",
570
+ headers,
571
+ body,
572
+ });
573
+ if (!response.ok || response.status >= 300) {
574
+ await response.body?.cancel().catch(() => undefined);
575
+ throw new ProviderOAuthCallbackError("token_exchange_failed");
576
+ }
577
+ const payload = await readResponseJsonBounded<Record<string, unknown>>(
578
+ response,
579
+ OAUTH_MAX_RESPONSE_BYTES,
580
+ `${preset.name} OAuth token response`,
581
+ ).catch(() => {
582
+ throw new ProviderOAuthCallbackError("token_exchange_failed");
583
+ });
584
+ const accessToken = optionalString(payload.access_token);
585
+ if (!accessToken) throw new ProviderOAuthCallbackError("token_exchange_failed");
586
+ const expiresIn = numberValue(payload.expires_in);
587
+ return {
588
+ accessToken,
589
+ ...(optionalString(payload.refresh_token)
590
+ ? { refreshToken: optionalString(payload.refresh_token)! }
591
+ : {}),
592
+ tokenType: optionalString(payload.token_type) ?? "Bearer",
593
+ expiresAt: expiresIn && expiresIn > 0 ? new Date(Date.now() + expiresIn * 1000) : null,
594
+ scopes: stringArrayFromText(optionalString(payload.scope)),
595
+ };
596
+ }
597
+
598
+ async function verifyProviderIdentity(
599
+ deps: ApiRouteDeps,
600
+ preset: OpenApiProviderPreset,
601
+ token: ProviderToken,
602
+ ): Promise<ProviderIdentity> {
603
+ const url = preset.family === "google" ? GOOGLE_USERINFO_URL : MICROSOFT_USERINFO_URL;
604
+ const response = await providerFetch(deps, url, {
605
+ headers: {
606
+ accept: "application/json",
607
+ authorization: `${normalizeBearerScheme(token.tokenType)} ${token.accessToken}`,
608
+ },
609
+ });
610
+ if (!response.ok || response.status >= 300) {
611
+ await response.body?.cancel().catch(() => undefined);
612
+ throw new ProviderOAuthCallbackError("identity_verification_failed");
613
+ }
614
+ const payload = await readResponseJsonBounded<Record<string, unknown>>(
615
+ response,
616
+ OAUTH_MAX_RESPONSE_BYTES,
617
+ `${preset.name} identity response`,
618
+ ).catch(() => {
619
+ throw new ProviderOAuthCallbackError("identity_verification_failed");
620
+ });
621
+ if (preset.family === "google") {
622
+ const principalId = optionalString(payload.sub);
623
+ if (!principalId) throw new ProviderOAuthCallbackError("identity_verification_failed");
624
+ return {
625
+ principalId,
626
+ email: optionalString(payload.email) ?? null,
627
+ displayName: optionalString(payload.name) ?? null,
628
+ };
629
+ }
630
+ const principalId = optionalString(payload.id);
631
+ if (!principalId) throw new ProviderOAuthCallbackError("identity_verification_failed");
632
+ return {
633
+ principalId,
634
+ email: optionalString(payload.mail) ?? optionalString(payload.userPrincipalName) ?? null,
635
+ displayName: optionalString(payload.displayName) ?? null,
636
+ };
637
+ }
638
+
639
+ async function providerFetch(
640
+ deps: ApiRouteDeps,
641
+ url: string | URL,
642
+ init: RequestInit,
643
+ ): Promise<Response> {
644
+ try {
645
+ return await pinnedFetch(
646
+ url,
647
+ {
648
+ ...init,
649
+ redirect: "manual",
650
+ signal: AbortSignal.timeout(PROVIDER_OAUTH_TIMEOUT_MS),
651
+ },
652
+ deps.settings,
653
+ {
654
+ ...(deps.apiIntegrationOAuthFetch
655
+ ? { fetchImpl: deps.apiIntegrationOAuthFetch as FetchLike }
656
+ : {}),
657
+ label: "API Integration provider OAuth request",
658
+ requireHttpsOutsideLocalTest: true,
659
+ },
660
+ );
661
+ } catch (error) {
662
+ if (error instanceof ProviderOAuthCallbackError) throw error;
663
+ throw new ProviderOAuthCallbackError("provider_unavailable");
664
+ }
665
+ }
666
+
667
+ async function requireProviderOAuthGrant(
668
+ deps: ApiRouteDeps,
669
+ state: Pick<ProviderOAuthState, "accountId" | "workspaceId" | "subjectId">,
670
+ ): Promise<void> {
671
+ const grant = await getWorkspaceGrant(deps.db, state.subjectId, state.workspaceId);
672
+ if (
673
+ !grant ||
674
+ grant.accountId !== state.accountId ||
675
+ !hasPermission(grant.permissions, "connections:write")
676
+ ) {
677
+ throw new ProviderOAuthCallbackError("connection_conflict");
678
+ }
679
+ }
680
+
681
+ function requireProviderOAuthConnection(
682
+ connection: NonNullable<Awaited<ReturnType<typeof getConnectionMetadata>>>,
683
+ input: {
684
+ subjectId: string;
685
+ providerDomain: string;
686
+ providerFamily: OpenApiProviderPreset["family"];
687
+ },
688
+ ) {
689
+ const metadata = ApiIntegrationOAuthConnectionMetadata.safeParse(connection.metadata);
690
+ if (
691
+ connection.kind !== "oauth2" ||
692
+ connection.providerDomain !== input.providerDomain ||
693
+ (connection.subjectId !== null && connection.subjectId !== input.subjectId) ||
694
+ !metadata.success ||
695
+ metadata.data.providerFamily !== input.providerFamily
696
+ ) {
697
+ throw new HTTPException(422, {
698
+ message: "Connection is not compatible with this Integration preset",
699
+ });
700
+ }
701
+ return metadata.data;
702
+ }
703
+
704
+ function providerScopesInclude(
705
+ preset: OpenApiProviderPreset,
706
+ returnedScopes: string[],
707
+ fallbackScopes: string[],
708
+ ): boolean {
709
+ const effective = returnedScopes.length > 0 ? returnedScopes : fallbackScopes;
710
+ const normalize = (value: string) =>
711
+ preset.family === "microsoft" ? value.toLowerCase() : value;
712
+ const granted = new Set(effective.map(normalize));
713
+ return preset.oauth.scopes.every((scope) => granted.has(normalize(scope)));
714
+ }
715
+
716
+ function providerOAuthReturnUrl(
717
+ baseUrl: string,
718
+ returnPath: string,
719
+ status: "success" | "error",
720
+ params: Record<string, string>,
721
+ ): string {
722
+ const url = new URL(safeReturnPath(returnPath), `${baseUrl.replace(/\/+$/, "")}/`);
723
+ url.searchParams.set("integration_oauth", status);
724
+ for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value.slice(0, 512));
725
+ return url.toString();
726
+ }
727
+
728
+ function providerOAuthFailureReason(error: unknown): ProviderOAuthFailureReason {
729
+ if (error instanceof ProviderOAuthCallbackError) return error.reason;
730
+ if (error instanceof HTTPException && error.status === 503) return "client_unavailable";
731
+ if (error instanceof HTTPException) return "connection_conflict";
732
+ return "persistence_failed";
733
+ }
734
+
735
+ function safeReturnPath(value: string): string {
736
+ if (!value.startsWith("/") || value.startsWith("//")) {
737
+ throw new HTTPException(400, { message: "OAuth returnPath must be a relative path" });
738
+ }
739
+ const parsed = new URL(value, "https://opengeni.local");
740
+ if (parsed.origin !== "https://opengeni.local" || parsed.pathname.startsWith("//")) {
741
+ throw new HTTPException(400, { message: "OAuth returnPath must be a relative path" });
742
+ }
743
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
744
+ }
745
+
746
+ function normalizeBearerScheme(value: string): string {
747
+ return /^bearer$/i.test(value) ? "Bearer" : value;
748
+ }
749
+
750
+ function uniqueStrings(values: readonly string[]): string[] {
751
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
752
+ }
753
+
754
+ function stringArray(value: unknown): string[] {
755
+ return Array.isArray(value)
756
+ ? uniqueStrings(value.filter((entry): entry is string => typeof entry === "string"))
757
+ : [];
758
+ }
759
+
760
+ function stringArrayFromText(value: string | undefined): string[] {
761
+ return value ? uniqueStrings(value.split(/\s+/)) : [];
762
+ }
763
+
764
+ function optionalString(value: unknown): string | undefined {
765
+ return typeof value === "string" && value.length > 0 ? value : undefined;
766
+ }
767
+
768
+ function requiredStateString(value: unknown): string {
769
+ const result = optionalString(value);
770
+ if (!result) throw new ProviderOAuthCallbackError("state_invalid");
771
+ return result;
772
+ }
773
+
774
+ function numberValue(value: unknown): number | undefined {
775
+ const number = typeof value === "number" ? value : Number(value);
776
+ return Number.isFinite(number) ? number : undefined;
777
+ }