@opengeni/api-router 2.11.3 → 2.12.1-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 (64) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/{chunk-5X3XM6CE.js → chunk-Y3EPAIEQ.js} +17643 -14398
  3. package/dist/chunk-Y3EPAIEQ.js.map +1 -0
  4. package/dist/connection-ownership.d.ts +5 -1
  5. package/dist/index.js +3 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/integrations/atlassian.d.ts +4 -0
  8. package/dist/integrations/connect-authority.d.ts +1 -0
  9. package/dist/integrations/connect-callback-return.d.ts +6 -0
  10. package/dist/integrations/fiken.d.ts +7 -0
  11. package/dist/integrations/github-app-connect.d.ts +47 -0
  12. package/dist/integrations/github-installation-proof.d.ts +3 -0
  13. package/dist/integrations/github-lens-connect.d.ts +153 -0
  14. package/dist/integrations/google-drive.d.ts +4 -0
  15. package/dist/integrations/oauth-client.d.ts +4 -0
  16. package/dist/integrations/oauth-return-path.d.ts +1 -0
  17. package/dist/integrations/personal-github.d.ts +2 -0
  18. package/dist/integrations/provider-oauth.d.ts +11 -1
  19. package/dist/integrations/slack-install.d.ts +20 -0
  20. package/dist/integrations/social-oauth.d.ts +2 -0
  21. package/dist/mcp/scheduled-task-view.d.ts +3 -3
  22. package/dist/routes/api-integrations.d.ts +14 -0
  23. package/dist/routes/connect.d.ts +5 -0
  24. package/dist/routes/external-identity-links.d.ts +6 -0
  25. package/dist/routes/host-mcp-bindings.d.ts +5 -0
  26. package/dist/workspace-tool-gateway.d.ts +1 -0
  27. package/package.json +19 -19
  28. package/src/app.ts +22 -2
  29. package/src/connection-ownership.ts +14 -1
  30. package/src/index.ts +2 -0
  31. package/src/integrations/atlassian.ts +143 -39
  32. package/src/integrations/connect-authority.ts +2 -0
  33. package/src/integrations/connect-callback-return.ts +86 -0
  34. package/src/integrations/fiken.ts +222 -40
  35. package/src/integrations/github-app-connect.ts +389 -0
  36. package/src/integrations/github-installation-proof.ts +60 -0
  37. package/src/integrations/github-lens-connect.ts +97 -0
  38. package/src/integrations/google-drive.ts +152 -47
  39. package/src/integrations/oauth-client.ts +180 -84
  40. package/src/integrations/oauth-return-path.ts +18 -0
  41. package/src/integrations/personal-github.ts +146 -52
  42. package/src/integrations/provider-oauth.ts +132 -21
  43. package/src/integrations/slack-install.ts +83 -0
  44. package/src/integrations/social-oauth.ts +142 -1
  45. package/src/mcp/documents.ts +1 -1
  46. package/src/mcp/server.ts +4 -4
  47. package/src/routes/api-integrations.ts +74 -64
  48. package/src/routes/connect.ts +1869 -0
  49. package/src/routes/connections.ts +263 -221
  50. package/src/routes/documents.ts +2 -2
  51. package/src/routes/external-identity-links.ts +200 -0
  52. package/src/routes/github.ts +39 -64
  53. package/src/routes/host-mcp-bindings.ts +122 -0
  54. package/src/routes/organization-memberships.ts +23 -0
  55. package/src/routes/organization-sessions.ts +2 -2
  56. package/src/routes/personal-github.ts +8 -1
  57. package/src/routes/pr-review-github.ts +30 -3
  58. package/src/routes/scheduled-tasks.ts +29 -4
  59. package/src/routes/sessions.ts +61 -32
  60. package/src/routes/social.ts +5 -1
  61. package/src/routes/supergrok.ts +90 -3
  62. package/src/routes/workspaces.ts +16 -0
  63. package/src/workspace-tool-gateway.ts +136 -24
  64. package/dist/chunk-5X3XM6CE.js.map +0 -1
@@ -15,6 +15,9 @@ import {
15
15
  loadSocialConnectionCredential,
16
16
  updateSocialConnectionCredential,
17
17
  upsertSocialOAuthConnection,
18
+ claimConnectOperation,
19
+ finishConnectOperation,
20
+ getConnectAttempt,
18
21
  type Database,
19
22
  } from "@opengeni/db";
20
23
  import { createSignedState, readSignedState } from "@opengeni/github";
@@ -29,6 +32,7 @@ import {
29
32
  PERSONAL_OWNER_VERIFIED_STATE_CLAIM,
30
33
  } from "../connection-ownership";
31
34
  import { ApiHttpError } from "../http/api-error";
35
+ import { requireConnectOwnerAuthority } from "./connect-authority";
32
36
  import {
33
37
  integrationBaseUrl,
34
38
  oauthStateTtlMs,
@@ -135,6 +139,7 @@ function socialProviderFetch(
135
139
  }
136
140
 
137
141
  export type SocialOAuthStartContext = {
142
+ connectAttemptId?: string;
138
143
  accountId: string;
139
144
  workspaceId: string;
140
145
  subjectId: string;
@@ -148,6 +153,7 @@ export type SocialOAuthStartContext = {
148
153
  };
149
154
 
150
155
  type SocialOAuthStatePayload = {
156
+ connectAttemptId?: string;
151
157
  kind: "social_oauth";
152
158
  accountId: string;
153
159
  workspaceId: string;
@@ -201,6 +207,7 @@ export async function startSocialOAuth(
201
207
  const key = requireEnvironmentEncryption(settings);
202
208
  const state = createSignedState(requireIntegrationsStateSecret(settings), {
203
209
  kind: "social_oauth",
210
+ ...(context.connectAttemptId ? { connectAttemptId: context.connectAttemptId } : {}),
204
211
  accountId: context.accountId,
205
212
  workspaceId: context.workspaceId,
206
213
  subjectId: context.subjectId,
@@ -244,7 +251,7 @@ export async function completeSocialOAuthCallback(
244
251
  error?: string | undefined;
245
252
  requestUrl: string;
246
253
  },
247
- ): Promise<{ redirectTo: string }> {
254
+ ): Promise<{ redirectTo: string; exactReturn?: boolean }> {
248
255
  const { db, settings, observability } = deps;
249
256
  let state: SocialOAuthStatePayload | null = null;
250
257
  try {
@@ -255,6 +262,7 @@ export async function completeSocialOAuthCallback(
255
262
  redirectTo: callbackReturnPath("/integrations", "error", { reason: "state_invalid" }),
256
263
  };
257
264
  }
265
+ if (state.connectAttemptId) return completeSocialConnect(deps, state, input);
258
266
  // Provider denial / missing code: report before burning the single-use
259
267
  // nonce so a user who cancelled at the provider can retry the same flow
260
268
  // within the state TTL.
@@ -365,6 +373,136 @@ export async function completeSocialOAuthCallback(
365
373
  }
366
374
  }
367
375
 
376
+ /** Durable host setup keeps social credentials in their original domain. The
377
+ * stored origin, not the unauthenticated callback, supplies current authority. */
378
+ async function completeSocialConnect(
379
+ deps: SocialOAuthDeps,
380
+ state: SocialOAuthStatePayload,
381
+ input: {
382
+ state?: string | undefined;
383
+ code?: string | undefined;
384
+ error?: string | undefined;
385
+ requestUrl: string;
386
+ },
387
+ ): Promise<{ redirectTo: string; exactReturn: true }> {
388
+ const stored = await getConnectAttempt(deps.db, state, state.connectAttemptId!);
389
+ if (stored.attempt.providerId !== state.provider || stored.attempt.ownership !== state.ownership)
390
+ throw new HTTPException(400, { message: "Social connection attempt mismatch" });
391
+ const authorize: import("@opengeni/db").ConnectOperationAuthorization = async (
392
+ tx,
393
+ _attempt,
394
+ origin,
395
+ ) => {
396
+ await requireConnectOwnerAuthority(
397
+ tx,
398
+ { ...state, ...(origin ? { externalContinuation: origin } : {}) },
399
+ state.ownership === "workspace" ? "workspace:admin" : "workspace:read",
400
+ origin,
401
+ );
402
+ };
403
+ const operation = {
404
+ attemptId: state.connectAttemptId!,
405
+ operationId: `social:${state.nonce}`,
406
+ inputDigest: createHash("sha256").update(input.state!).digest("hex"),
407
+ authorize,
408
+ };
409
+ try {
410
+ const claim = await claimConnectOperation(deps.db, state, {
411
+ ...operation,
412
+ expectedRevision: stored.attempt.revision,
413
+ });
414
+ if (claim.status === "replayed") return { redirectTo: stored.returnUrl, exactReturn: true };
415
+ if (!personalOwnerStateAccepted(state))
416
+ throw new HTTPException(403, { message: PERSONAL_CONNECTION_PRINCIPAL_MESSAGE });
417
+ if (input.error || !input.code) {
418
+ await finishConnectOperation(deps.db, state, {
419
+ ...operation,
420
+ commit: async (_tx, current) => ({
421
+ ...current,
422
+ revision: current.revision + 1,
423
+ state: input.error === "access_denied" ? "cancelled" : "failed",
424
+ nextAction: { type: "none" },
425
+ error: {
426
+ code: "authorization_not_completed",
427
+ message: "Authorization was not completed. Start a new attempt.",
428
+ retryable: false,
429
+ },
430
+ }),
431
+ });
432
+ } else {
433
+ const provider = SOCIAL_OAUTH_PROVIDERS[state.provider];
434
+ const key = requireEnvironmentEncryption(deps.settings);
435
+ const verifier = state.encryptedPkceVerifier
436
+ ? decryptEnvironmentValue(key, state.encryptedPkceVerifier)
437
+ : null;
438
+ const token = await socialTokenRequest(
439
+ deps,
440
+ provider,
441
+ socialOAuthClientFor(deps.settings, provider.id),
442
+ {
443
+ grant_type: "authorization_code",
444
+ code: input.code,
445
+ redirect_uri: socialOAuthRedirectUri(deps.settings, input.requestUrl),
446
+ ...(verifier ? { code_verifier: verifier } : {}),
447
+ },
448
+ );
449
+ const identity = await fetchSocialIdentity(deps, provider.id, token.accessToken);
450
+ const bundle: SocialCredentialBundle = {
451
+ provider: provider.id,
452
+ accessToken: token.accessToken,
453
+ tokenType: token.tokenType,
454
+ ...(token.refreshToken ? { refreshToken: token.refreshToken } : {}),
455
+ ...(token.expiresAt ? { expiresAt: token.expiresAt } : {}),
456
+ ...(token.scope ? { scope: token.scope } : {}),
457
+ };
458
+ await finishConnectOperation(deps.db, state, {
459
+ ...operation,
460
+ commit: async (tx, current) => {
461
+ const connection = await upsertSocialOAuthConnection(tx, {
462
+ accountId: state.accountId,
463
+ workspaceId: state.workspaceId,
464
+ ...(current.account
465
+ ? {
466
+ expectedConnection: {
467
+ id: current.account.id.slice("social:".length),
468
+ version: current.account.version!,
469
+ },
470
+ }
471
+ : {}),
472
+ subjectId: state.ownership === "personal" ? state.subjectId : null,
473
+ provider: provider.id,
474
+ accountHandle: identity.handle,
475
+ accountName: identity.name ?? null,
476
+ externalAccountId: identity.externalAccountId,
477
+ scopes: token.scope ? token.scope.split(/[\s,]+/).filter(Boolean) : state.scopes,
478
+ credentialEncrypted: encryptEnvironmentValue(key, JSON.stringify(bundle)),
479
+ tokenMetadata: publicTokenMetadata(bundle),
480
+ });
481
+ return {
482
+ ...current,
483
+ revision: current.revision + 1,
484
+ state: "complete",
485
+ credentialsCommitted: true,
486
+ nextAction: { type: "none" },
487
+ account: {
488
+ id: `social:${connection.id}`,
489
+ providerId: provider.id,
490
+ version: connection.version!,
491
+ label: identity.handle,
492
+ ownership: state.ownership,
493
+ status: "connected",
494
+ },
495
+ };
496
+ },
497
+ });
498
+ }
499
+ } catch (error) {
500
+ // An uncertain exchange stays occupied: do not replay a consumed OAuth code.
501
+ logSocialOAuthFailure(deps.observability, "connect_callback", state, error);
502
+ }
503
+ return { redirectTo: stored.returnUrl, exactReturn: true };
504
+ }
505
+
368
506
  /**
369
507
  * Resolves a usable access token for a social connection, refreshing (and
370
508
  * persisting the rotated bundle) when the stored token is near expiry. Marks
@@ -706,6 +844,9 @@ function readSocialOAuthState(
706
844
  }
707
845
  return {
708
846
  kind: "social_oauth",
847
+ ...(typeof payload.connectAttemptId === "string"
848
+ ? { connectAttemptId: payload.connectAttemptId }
849
+ : {}),
709
850
  accountId: requiredStateString(payload.accountId, "accountId"),
710
851
  workspaceId: requiredStateString(payload.workspaceId, "workspaceId"),
711
852
  subjectId: requiredStateString(payload.subjectId, "subjectId"),
@@ -297,7 +297,7 @@ export function buildDocumentsMcpServer(
297
297
  if (memoryScope?.mode === "off") return server;
298
298
  const memoryReadScope: MemoryAgentScope = memoryScope ?? {
299
299
  mode: "workspace",
300
- endUserSubjectId: null,
300
+ userSubjectId: null,
301
301
  rootSessionId: null,
302
302
  };
303
303
 
package/src/mcp/server.ts CHANGED
@@ -3613,7 +3613,7 @@ function registerMemoryTools(
3613
3613
  // session's narrowest layer. A null scope is the pre-0426 workspace path.
3614
3614
  const agentScope: MemoryAgentScope = sessionMemory ?? {
3615
3615
  mode: "workspace",
3616
- endUserSubjectId: null,
3616
+ userSubjectId: null,
3617
3617
  rootSessionId: null,
3618
3618
  };
3619
3619
  const writeScope = memoryWriteScopeForAgentScope(agentScope);
@@ -5157,12 +5157,12 @@ function registerWorkspaceOrchestrationTools(
5157
5157
  ),
5158
5158
  // The child's agent-access scope and end-user label are never model
5159
5159
  // choices: it inherits this session's exactly. Only the Memory
5160
- // selector may be narrowed here (workspace > user > session > off).
5160
+ // selector may be narrowed here (workspace > user > off).
5161
5161
  memoryScope: z4
5162
- .enum(["workspace", "user", "session", "off"])
5162
+ .enum(["workspace", "user", "off"])
5163
5163
  .optional()
5164
5164
  .describe(
5165
- "Optional Memory selector for the child. Omit to inherit this session's selector. An explicit value may only narrow it (workspace > user > session > off); off gives the child no Memory tools.",
5165
+ "Optional Memory selector for the child. Omit to inherit this session's selector. An explicit value may only narrow it (workspace > user > off); off gives the child no Memory tools. Use task notes for task-local findings.",
5166
5166
  ),
5167
5167
  // Omission is the ordinary safe sharing path. Literal "shared" remains
5168
5168
  // available to advanced REST/SDK callers but is intentionally absent
@@ -15,6 +15,7 @@ import {
15
15
  PreviewApiIntegrationRequest,
16
16
  UninstallApiIntegrationRequest,
17
17
  UninstallApiIntegrationResult,
18
+ type AccessGrant,
18
19
  } from "@opengeni/contracts";
19
20
  import { requireAccessGrant, type ApiRouteDeps } from "@opengeni/core";
20
21
  import {
@@ -37,6 +38,7 @@ import { HTTPException } from "hono/http-exception";
37
38
  import {
38
39
  resolveApiIntegrationPreview,
39
40
  type ApiIntegrationConnectionDescriptor,
41
+ type ResolvedApiIntegrationPreview,
40
42
  } from "../integrations/api-integrations";
41
43
 
42
44
  export type ApiIntegrationRouteOverrides = Readonly<{ fetchImpl?: FetchLike }>;
@@ -46,6 +48,72 @@ export function apiIntegrationRequiresConnection(authScheme: Record<string, unkn
46
48
  return typeof authKind === "string" && authKind !== "none";
47
49
  }
48
50
 
51
+ /** Shared native/embedded install validation. The caller must still authorize
52
+ * capabilities:manage and commit under its own live-authority boundary. */
53
+ export function validatedIntegrationInstallInput(
54
+ grant: AccessGrant,
55
+ workspaceId: string,
56
+ payload: InstallApiIntegrationRequest,
57
+ resolved: ResolvedApiIntegrationPreview,
58
+ ): Parameters<typeof installApiIntegration>[1] {
59
+ if (
60
+ resolved.preview.revisionId !== payload.expectedRevisionId ||
61
+ resolved.preview.contentSha256 !== payload.expectedContentSha256
62
+ ) {
63
+ throw new HTTPException(409, {
64
+ message:
65
+ "The Integration source changed after preview. Review the new tools and permissions before installing.",
66
+ });
67
+ }
68
+ if (resolved.preview.auth.kind !== "none" && !payload.connectionId)
69
+ throw new HTTPException(422, {
70
+ message: "Connect an account before installing this Integration.",
71
+ });
72
+ if (
73
+ payload.ownership &&
74
+ resolved.preview.connectionOwnership &&
75
+ payload.ownership !== resolved.preview.connectionOwnership
76
+ )
77
+ throw new HTTPException(422, {
78
+ message: "The selected Connection ownership does not match this install request.",
79
+ });
80
+ if (payload.ownership === "personal" && !resolved.preview.connectionOwnership)
81
+ throw new HTTPException(422, {
82
+ message: "Choose a Personal Connection before installing for yourself.",
83
+ });
84
+ return {
85
+ accountId: grant.accountId,
86
+ workspaceId,
87
+ subjectId: grant.subjectId,
88
+ capabilityId: resolved.preview.capabilityId,
89
+ pluginKey: resolved.preview.pluginKey,
90
+ serverId: resolved.preview.serverId,
91
+ name: resolved.preview.name,
92
+ description: resolved.preview.description,
93
+ category: "integrations",
94
+ tags: [resolved.preview.protocol, resolved.preview.provider ?? "custom"],
95
+ definitionId: resolved.preview.definitionId,
96
+ definitionProvenance: resolved.preview.definitionProvenance,
97
+ ...(resolved.provider ? { provider: resolved.provider } : {}),
98
+ providerDomain: resolved.preview.providerDomain,
99
+ protocol: resolved.preview.protocol,
100
+ baseUrl: resolved.preview.baseUrl,
101
+ sourceUrl: resolved.preview.sourceUrl,
102
+ authScheme: resolved.authScheme,
103
+ ...(payload.connectionId ? { connectionId: payload.connectionId } : {}),
104
+ ...(payload.instanceKey ? { instanceKey: payload.instanceKey } : {}),
105
+ ...(payload.displayName ? { displayName: payload.displayName } : {}),
106
+ ...(payload.expectedInstanceVersion !== undefined
107
+ ? { expectedInstanceVersion: payload.expectedInstanceVersion }
108
+ : {}),
109
+ requiredScopes: resolved.requiredScopes,
110
+ ownership: resolved.preview.connectionOwnership === "personal" ? "subject" : "workspace",
111
+ ...(payload.allowedTools ? { allowedTools: payload.allowedTools } : {}),
112
+ facetDefinitions: integrationFacetDefinitions(resolved.preview.definitionId),
113
+ revision: resolved.revision,
114
+ };
115
+ }
116
+
49
117
  export function registerApiIntegrationRoutes(
50
118
  app: Hono,
51
119
  deps: ApiRouteDeps,
@@ -53,7 +121,9 @@ export function registerApiIntegrationRoutes(
53
121
  ): void {
54
122
  const transport = createPinnedIntegrationTransport({
55
123
  network: deps.settings,
56
- ...(overrides.fetchImpl ? { fetchImpl: overrides.fetchImpl } : {}),
124
+ ...((overrides.fetchImpl ?? deps.apiIntegrationSourceFetch)
125
+ ? { fetchImpl: overrides.fetchImpl ?? deps.apiIntegrationSourceFetch! }
126
+ : {}),
57
127
  });
58
128
 
59
129
  app.get("/v1/workspaces/:workspaceId/integrations/definitions", async (c) => {
@@ -157,70 +227,10 @@ export function registerApiIntegrationRoutes(
157
227
  subjectId: grant.subjectId,
158
228
  payload,
159
229
  });
160
- if (
161
- resolved.preview.revisionId !== payload.expectedRevisionId ||
162
- resolved.preview.contentSha256 !== payload.expectedContentSha256
163
- ) {
164
- throw new HTTPException(409, {
165
- message:
166
- "The Integration source changed after preview. Review the new tools and permissions before installing.",
167
- });
168
- }
169
- if (resolved.preview.auth.kind !== "none" && !payload.connectionId) {
170
- throw new HTTPException(422, {
171
- message: "Connect an account before installing this Integration.",
172
- });
173
- }
174
- if (
175
- payload.ownership &&
176
- resolved.preview.connectionOwnership &&
177
- payload.ownership !== resolved.preview.connectionOwnership
178
- ) {
179
- throw new HTTPException(422, {
180
- message: "The selected Connection ownership does not match this install request.",
181
- });
182
- }
183
- if (payload.ownership === "personal" && !resolved.preview.connectionOwnership) {
184
- throw new HTTPException(422, {
185
- message: "Choose a Personal Connection before installing for yourself.",
186
- });
187
- }
230
+ const installation = validatedIntegrationInstallInput(grant, workspaceId, payload, resolved);
188
231
  try {
189
232
  return c.json(
190
- InstalledApiIntegration.parse(
191
- await installApiIntegration(deps.db, {
192
- accountId: grant.accountId,
193
- workspaceId,
194
- subjectId: grant.subjectId,
195
- capabilityId: resolved.preview.capabilityId,
196
- pluginKey: resolved.preview.pluginKey,
197
- serverId: resolved.preview.serverId,
198
- name: resolved.preview.name,
199
- description: resolved.preview.description,
200
- category: "integrations",
201
- tags: [resolved.preview.protocol, resolved.preview.provider ?? "custom"],
202
- definitionId: resolved.preview.definitionId,
203
- definitionProvenance: resolved.preview.definitionProvenance,
204
- ...(resolved.provider ? { provider: resolved.provider } : {}),
205
- providerDomain: resolved.preview.providerDomain,
206
- protocol: resolved.preview.protocol,
207
- baseUrl: resolved.preview.baseUrl,
208
- sourceUrl: resolved.preview.sourceUrl,
209
- authScheme: resolved.authScheme,
210
- ...(payload.connectionId ? { connectionId: payload.connectionId } : {}),
211
- ...(payload.instanceKey ? { instanceKey: payload.instanceKey } : {}),
212
- ...(payload.displayName ? { displayName: payload.displayName } : {}),
213
- ...(payload.expectedInstanceVersion !== undefined
214
- ? { expectedInstanceVersion: payload.expectedInstanceVersion }
215
- : {}),
216
- requiredScopes: resolved.requiredScopes,
217
- ownership:
218
- resolved.preview.connectionOwnership === "personal" ? "subject" : "workspace",
219
- ...(payload.allowedTools ? { allowedTools: payload.allowedTools } : {}),
220
- facetDefinitions: integrationFacetDefinitions(resolved.preview.definitionId),
221
- revision: resolved.revision,
222
- }),
223
- ),
233
+ InstalledApiIntegration.parse(await installApiIntegration(deps.db, installation)),
224
234
  payload.expectedInstanceVersion === undefined ? 201 : 200,
225
235
  );
226
236
  } catch (error) {
@@ -295,7 +305,7 @@ export function registerApiIntegrationRoutes(
295
305
  );
296
306
  }
297
307
 
298
- async function resolveForRoute(input: {
308
+ export async function resolveForRoute(input: {
299
309
  deps: ApiRouteDeps;
300
310
  transport: ReturnType<typeof createPinnedIntegrationTransport>;
301
311
  workspaceId: string;