@opengeni/api-router 2.11.3 → 2.12.3-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 (66) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/{chunk-5X3XM6CE.js → chunk-4AQMKVQM.js} +17727 -14427
  3. package/dist/chunk-4AQMKVQM.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/sandbox/rematerialize.d.ts +1 -1
  27. package/dist/workspace-tool-gateway.d.ts +1 -0
  28. package/package.json +19 -19
  29. package/src/app.ts +22 -2
  30. package/src/connection-ownership.ts +14 -1
  31. package/src/index.ts +2 -0
  32. package/src/integrations/atlassian.ts +143 -39
  33. package/src/integrations/connect-authority.ts +2 -0
  34. package/src/integrations/connect-callback-return.ts +86 -0
  35. package/src/integrations/fiken.ts +222 -40
  36. package/src/integrations/github-app-connect.ts +389 -0
  37. package/src/integrations/github-installation-proof.ts +60 -0
  38. package/src/integrations/github-lens-connect.ts +97 -0
  39. package/src/integrations/google-drive.ts +152 -47
  40. package/src/integrations/oauth-client.ts +180 -84
  41. package/src/integrations/oauth-return-path.ts +18 -0
  42. package/src/integrations/personal-github.ts +146 -52
  43. package/src/integrations/provider-oauth.ts +132 -21
  44. package/src/integrations/slack-install.ts +83 -0
  45. package/src/integrations/social-oauth.ts +142 -1
  46. package/src/mcp/documents.ts +1 -1
  47. package/src/mcp/server.ts +64 -17
  48. package/src/routes/api-integrations.ts +74 -64
  49. package/src/routes/connect.ts +1869 -0
  50. package/src/routes/connections.ts +263 -221
  51. package/src/routes/documents.ts +2 -2
  52. package/src/routes/external-identity-links.ts +200 -0
  53. package/src/routes/github.ts +39 -64
  54. package/src/routes/host-mcp-bindings.ts +122 -0
  55. package/src/routes/organization-memberships.ts +23 -0
  56. package/src/routes/organization-sessions.ts +2 -2
  57. package/src/routes/personal-github.ts +8 -1
  58. package/src/routes/pr-review-github.ts +30 -3
  59. package/src/routes/scheduled-tasks.ts +29 -4
  60. package/src/routes/sessions.ts +61 -32
  61. package/src/routes/social.ts +5 -1
  62. package/src/routes/supergrok.ts +90 -3
  63. package/src/routes/workspaces.ts +16 -0
  64. package/src/sandbox/rematerialize.ts +33 -1
  65. package/src/workspace-tool-gateway.ts +136 -24
  66. 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
@@ -15,6 +15,8 @@ import {
15
15
  } from "../site-uploads";
16
16
  import {
17
17
  CreateScheduledTaskRequest,
18
+ CreateSessionRequest,
19
+ GoalSpec,
18
20
  boundSessionMcpText as capSessionDiscoveryText,
19
21
  compactSessionMcpListRow,
20
22
  sessionMcpIncludesRelatedWork,
@@ -376,6 +378,45 @@ function orchestrationFailureCode(tool: OrchestrationToolName, error: HTTPExcept
376
378
  return `${tool}_${suffix}`.slice(0, ORCHESTRATION_FAILURE_CODE_MAX_LENGTH);
377
379
  }
378
380
 
381
+ function sessionCreateValidationFailureResult(error: z4.ZodError) {
382
+ // Only call with issues from the raw request preflight, never downstream errors.
383
+ // Paths may contain caller-controlled record keys, and custom issue messages
384
+ // may contain values. Only publish canonical field names and fixed type labels.
385
+ const details = error.issues.slice(0, 5).map((issue) => {
386
+ const [field, goalField] = issue.path;
387
+ let path =
388
+ typeof field === "string" && Object.hasOwn(CreateSessionRequest.out.shape, field)
389
+ ? field
390
+ : "request";
391
+ if (
392
+ field === "goal" &&
393
+ typeof goalField === "string" &&
394
+ Object.hasOwn(GoalSpec.shape, goalField)
395
+ ) {
396
+ path += `.${goalField}`;
397
+ }
398
+ const expected =
399
+ issue.code === "invalid_type" &&
400
+ ["string", "number", "boolean", "object", "array", "int"].includes(issue.expected)
401
+ ? `expected ${issue.expected === "int" ? "integer" : issue.expected}`
402
+ : "failed schema validation";
403
+ return `${path} ${expected}`;
404
+ });
405
+ const envelope = {
406
+ error: {
407
+ code: "session_create_invalid_request",
408
+ message: boundedOrchestrationFailureMessage(
409
+ `Invalid session create request: ${details.join("; ") || "request failed schema validation"}${error.issues.length > 5 ? "; additional fields failed validation" : ""}.`,
410
+ ),
411
+ },
412
+ };
413
+ return {
414
+ content: [{ type: "text" as const, text: JSON.stringify(envelope, null, 2) }],
415
+ structuredContent: envelope,
416
+ isError: true as const,
417
+ };
418
+ }
419
+
379
420
  function orchestrationFailureEnvelope(tool: OrchestrationToolName, error: unknown) {
380
421
  if (error instanceof SessionSpawnDeniedError) {
381
422
  const denial = sessionSpawnDenialEnvelope(error);
@@ -3613,7 +3654,7 @@ function registerMemoryTools(
3613
3654
  // session's narrowest layer. A null scope is the pre-0426 workspace path.
3614
3655
  const agentScope: MemoryAgentScope = sessionMemory ?? {
3615
3656
  mode: "workspace",
3616
- endUserSubjectId: null,
3657
+ userSubjectId: null,
3617
3658
  rootSessionId: null,
3618
3659
  };
3619
3660
  const writeScope = memoryWriteScopeForAgentScope(agentScope);
@@ -5102,7 +5143,7 @@ function registerWorkspaceOrchestrationTools(
5102
5143
  "Concise semantic title for the child session. Omit only when the delegated goal or initial message already provides a suitable title; OpenGeni derives a sensitive-safe bounded fallback from that text.",
5103
5144
  ),
5104
5145
  instructions: z4.string().min(1).max(SESSION_INSTRUCTIONS_MAX_CHARACTERS).optional(),
5105
- goal: z4.unknown().optional(),
5146
+ goal: GoalSpec.optional(),
5106
5147
  resources: z4
5107
5148
  .array(z4.unknown())
5108
5149
  .optional()
@@ -5157,12 +5198,12 @@ function registerWorkspaceOrchestrationTools(
5157
5198
  ),
5158
5199
  // The child's agent-access scope and end-user label are never model
5159
5200
  // choices: it inherits this session's exactly. Only the Memory
5160
- // selector may be narrowed here (workspace > user > session > off).
5201
+ // selector may be narrowed here (workspace > user > off).
5161
5202
  memoryScope: z4
5162
- .enum(["workspace", "user", "session", "off"])
5203
+ .enum(["workspace", "user", "off"])
5163
5204
  .optional()
5164
5205
  .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.",
5206
+ "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
5207
  ),
5167
5208
  // Omission is the ordinary safe sharing path. Literal "shared" remains
5168
5209
  // available to advanced REST/SDK callers but is intentionally absent
@@ -5211,22 +5252,28 @@ function registerWorkspaceOrchestrationTools(
5211
5252
  await authorizeFirstPartySession(deps, grant, callerSessionId, "session.child.create");
5212
5253
  }
5213
5254
  const { machineTarget, title, projectId, ...request } = args;
5255
+ const rawRequest = {
5256
+ ...request,
5257
+ ...(projectId !== undefined ? { channelId: projectId } : {}),
5258
+ ...(machineTarget
5259
+ ? {
5260
+ targetSandboxId: machineTarget.targetSandboxId,
5261
+ ...(machineTarget.workingDir !== undefined
5262
+ ? { workingDir: machineTarget.workingDir }
5263
+ : {}),
5264
+ }
5265
+ : {}),
5266
+ };
5267
+ // Keep authorization above validation and let core parse the original
5268
+ // request. Only this preflight's failures are attributable to input;
5269
+ // arbitrary downstream schema failures retain the private fallback.
5270
+ const validation = CreateSessionRequest.safeParse(rawRequest);
5271
+ if (!validation.success) return sessionCreateValidationFailureResult(validation.error);
5214
5272
  const result = await createSessionForRequestWithOutcome(
5215
5273
  deps,
5216
5274
  grant,
5217
5275
  grant.workspaceId,
5218
- {
5219
- ...request,
5220
- ...(projectId !== undefined ? { channelId: projectId } : {}),
5221
- ...(machineTarget
5222
- ? {
5223
- targetSandboxId: machineTarget.targetSandboxId,
5224
- ...(machineTarget.workingDir !== undefined
5225
- ? { workingDir: machineTarget.workingDir }
5226
- : {}),
5227
- }
5228
- : {}),
5229
- },
5276
+ rawRequest,
5230
5277
  undefined,
5231
5278
  title === undefined ? {} : { automaticTitleCandidate: title },
5232
5279
  );
@@ -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;