@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,209 @@
1
+ import {
2
+ claimMemorySlackPublication,
3
+ completeMemorySlackPublication,
4
+ currentMemorySlackConfigurationMatches,
5
+ deferMemorySlackPublication,
6
+ failMemorySlackPublication,
7
+ type ClaimedMemorySlackPublication,
8
+ } from "@opengeni/db";
9
+ import { sanitizeSlackPublicationText, type ApiRouteDeps } from "@opengeni/core";
10
+ import {
11
+ createOpenGeniSlackBotInteractionClient,
12
+ SlackBotProviderError,
13
+ } from "./integrations/slack-bot";
14
+
15
+ const DELIVERY_LEASE_MS = 30_000;
16
+ const MAX_DELIVERY_ATTEMPTS = 8;
17
+ const MAX_DELIVERY_RETRY_MS = 5 * 60_000;
18
+ const MAX_SLACK_MESSAGE_CHARS = 3_500;
19
+
20
+ export async function drainMemorySlackPublicationsOnce(deps: ApiRouteDeps): Promise<boolean> {
21
+ const claimHolderId = crypto.randomUUID();
22
+ const publication = await claimMemorySlackPublication(deps.db, claimHolderId, DELIVERY_LEASE_MS);
23
+ if (!publication) return false;
24
+
25
+ try {
26
+ if (!(await currentMemorySlackConfigurationMatches(deps.db, publication))) {
27
+ await failMemorySlackPublication(deps.db, {
28
+ publication,
29
+ claimHolderId,
30
+ errorCode: "configuration_changed",
31
+ cancelled: true,
32
+ });
33
+ return true;
34
+ }
35
+ const client = await createOpenGeniSlackBotInteractionClient(deps, {
36
+ accountId: publication.accountId,
37
+ workspaceId: publication.workspaceId,
38
+ connectionId: publication.connectionId,
39
+ subjectId: publication.initiatorSubjectId,
40
+ sessionId: publication.sessionId,
41
+ });
42
+ const result = await client.postMessage({
43
+ operationId: publication.operationId,
44
+ channelId: publication.slackChannelId,
45
+ text: formatMemorySlackPublicationMessage(deps, publication),
46
+ requireActiveNonSharedChannel: true,
47
+ });
48
+ const completed = await completeMemorySlackPublication(deps.db, {
49
+ publication,
50
+ claimHolderId,
51
+ slackChannelId: result.channelId,
52
+ slackMessageTimestamp: result.timestamp,
53
+ });
54
+ if (!completed) throw new Error("Memory Slack publication lost its durable claim");
55
+ } catch (error) {
56
+ const errorCode = deliveryErrorCode(error);
57
+ if (memorySlackDestinationDrift(error)) {
58
+ await failMemorySlackPublication(deps.db, {
59
+ publication,
60
+ claimHolderId,
61
+ errorCode,
62
+ cancelled: true,
63
+ }).catch(() => undefined);
64
+ } else if (publication.attemptCount >= MAX_DELIVERY_ATTEMPTS || permanentDeliveryError(error)) {
65
+ await failMemorySlackPublication(deps.db, {
66
+ publication,
67
+ claimHolderId,
68
+ errorCode,
69
+ }).catch(() => undefined);
70
+ } else {
71
+ await deferMemorySlackPublication(deps.db, {
72
+ publication,
73
+ claimHolderId,
74
+ errorCode,
75
+ retryAt: new Date(Date.now() + deliveryRetryMs(error, publication.attemptCount)),
76
+ }).catch(() => undefined);
77
+ }
78
+ }
79
+ return true;
80
+ }
81
+
82
+ export function startMemorySlackPublicationPump(
83
+ deps: ApiRouteDeps,
84
+ options: { intervalMs?: number; maxPerTick?: number } = {},
85
+ ): () => Promise<void> {
86
+ let stopped = false;
87
+ let running: Promise<void> | undefined;
88
+ const intervalMs = Math.max(250, options.intervalMs ?? 1_000);
89
+ const maxPerTick = Math.max(1, Math.min(50, options.maxPerTick ?? 10));
90
+ const tick = () => {
91
+ if (stopped || running) return;
92
+ running = (async () => {
93
+ for (let index = 0; index < maxPerTick; index += 1) {
94
+ if (!(await drainMemorySlackPublicationsOnce(deps))) break;
95
+ }
96
+ })()
97
+ .catch((error) => {
98
+ deps.observability?.error("Memory Slack publication claim failed", {
99
+ errorClass: error instanceof Error ? error.name : "MemorySlackPublicationPumpError",
100
+ errorCode: deliveryErrorCode(error),
101
+ origin: "api",
102
+ });
103
+ })
104
+ .finally(() => {
105
+ running = undefined;
106
+ });
107
+ };
108
+ const timer = setInterval(tick, intervalMs);
109
+ queueMicrotask(tick);
110
+ return async () => {
111
+ stopped = true;
112
+ clearInterval(timer);
113
+ await running;
114
+ };
115
+ }
116
+
117
+ export function formatMemorySlackPublicationMessage(
118
+ deps: Pick<ApiRouteDeps, "settings">,
119
+ publication: ClaimedMemorySlackPublication,
120
+ ): string {
121
+ const projection = publication.projection;
122
+ const summary = boundedProjectionString(projection["summary"], 512) || "Important change";
123
+ const owner = boundedProjectionString(projection["ownerLabel"], 96);
124
+ const occurredAt = boundedProjectionString(projection["occurredAt"], 64);
125
+ const changeKind = boundedProjectionString(projection["changeKind"], 32);
126
+ const destination = boundedProjectionString(projection["destination"], 64);
127
+ const outcome = boundedProjectionString(projection["outcome"], 64);
128
+ const heading =
129
+ publication.sourceType === "workspace_memory"
130
+ ? `Workspace Memory ${changeKind || "change"}`
131
+ : `Governed learning ${outcome || "change"}`;
132
+ const details = [
133
+ `Importance: ${publication.importance}`,
134
+ ...(destination ? [`Surface: ${destination}`] : []),
135
+ ...(owner ? [`Owner: ${owner}`] : []),
136
+ ...(occurredAt ? [`Recorded: ${occurredAt}`] : []),
137
+ ];
138
+ const path =
139
+ publication.sourceType === "workspace_memory"
140
+ ? `/workspaces/${publication.workspaceId}/memory?memoryId=${encodeURIComponent(publication.sourceId)}`
141
+ : `/workspaces/${publication.workspaceId}/workspace-state`;
142
+ const base = deps.settings.webBaseUrl ?? deps.settings.publicBaseUrl;
143
+ const link = base ? new URL(path, base).toString() : null;
144
+ const text = `*${heading}*\n${summary}\n\n${details.join(" · ")}${link ? `\n${link}` : ""}`;
145
+ return text.length <= MAX_SLACK_MESSAGE_CHARS
146
+ ? text
147
+ : `${text.slice(0, MAX_SLACK_MESSAGE_CHARS - 20)}\n… message truncated`;
148
+ }
149
+
150
+ const PERMANENT_DELIVERY_CODES = new Set([
151
+ "account_inactive",
152
+ "channel_not_found",
153
+ "invalid_auth",
154
+ "is_archived",
155
+ "not_authed",
156
+ "not_in_channel",
157
+ "token_expired",
158
+ "token_revoked",
159
+ ]);
160
+
161
+ const MEMORY_SLACK_DESTINATION_DRIFT_CODES = new Set([
162
+ "channel_not_found",
163
+ "is_archived",
164
+ "not_in_channel",
165
+ "slack_connect_unsupported",
166
+ ]);
167
+
168
+ export function memorySlackDestinationDrift(error: unknown) {
169
+ return (
170
+ error instanceof SlackBotProviderError && MEMORY_SLACK_DESTINATION_DRIFT_CODES.has(error.code)
171
+ );
172
+ }
173
+
174
+ function permanentDeliveryError(error: unknown) {
175
+ if (!(error instanceof SlackBotProviderError)) return false;
176
+ if (PERMANENT_DELIVERY_CODES.has(error.code)) return true;
177
+ const status = /^http_(\d{3})$/.exec(error.code)?.[1];
178
+ return status
179
+ ? Number(status) >= 400 && Number(status) < 500 && status !== "408" && status !== "429"
180
+ : false;
181
+ }
182
+
183
+ function deliveryRetryMs(error: unknown, attemptCount: number) {
184
+ if (error instanceof SlackBotProviderError && error.retryAfterMs) {
185
+ return Math.min(Math.max(error.retryAfterMs, 1_000), MAX_DELIVERY_RETRY_MS);
186
+ }
187
+ return Math.min(1_000 * 2 ** Math.max(0, attemptCount - 1), MAX_DELIVERY_RETRY_MS);
188
+ }
189
+
190
+ function deliveryErrorCode(error: unknown) {
191
+ if (error instanceof SlackBotProviderError) return error.code.slice(0, 128);
192
+ const raw = error instanceof Error ? error.name : "memory_slack_delivery_error";
193
+ return (
194
+ raw
195
+ .toLowerCase()
196
+ .replace(/[^a-z0-9_-]/g, "_")
197
+ .slice(0, 128) || "error"
198
+ );
199
+ }
200
+
201
+ function boundedProjectionString(value: unknown, maxChars: number) {
202
+ return typeof value === "string"
203
+ ? escapeSlackText(sanitizeSlackPublicationText(value).slice(0, maxChars))
204
+ : "";
205
+ }
206
+
207
+ function escapeSlackText(value: string) {
208
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
209
+ }
@@ -2,6 +2,7 @@ import {
2
2
  configuredModels,
3
3
  configuredProviders,
4
4
  withCodexCatalogProvider,
5
+ withXaiSubscriptionCatalogProvider,
5
6
  withWorkspaceGatewayCatalogProvider,
6
7
  type ConfiguredModel,
7
8
  type Settings,
@@ -37,16 +38,20 @@ export const MODEL_CREDENTIAL_READINESS_OBSERVATION_MAX_AGE_MS = 5 * 60_000;
37
38
  export function projectClientModel(model: ConfiguredModel): ClientModel {
38
39
  const source =
39
40
  model.credentialSource.kind === "connected_subscription"
40
- ? "codex"
41
+ ? model.credentialSource.provider === "xai"
42
+ ? "supergrok"
43
+ : "codex"
41
44
  : model.credentialSource.kind === "workspace_connection"
42
45
  ? "workspace_gateway"
43
46
  : "opengeni";
44
47
  const publicProvider =
45
48
  source === "codex"
46
49
  ? { provider: "codex", providerLabel: "Codex" }
47
- : source === "workspace_gateway"
48
- ? { provider: "workspace-gateway", providerLabel: "Your Gateway" }
49
- : { provider: "opengeni", providerLabel: "OpenGeni" };
50
+ : source === "supergrok"
51
+ ? { provider: "supergrok", providerLabel: "SuperGrok" }
52
+ : source === "workspace_gateway"
53
+ ? { provider: "workspace-gateway", providerLabel: "Your Gateway" }
54
+ : { provider: "opengeni", providerLabel: "OpenGeni" };
50
55
  return ClientModel.parse({
51
56
  id: model.id,
52
57
  label: model.label,
@@ -131,6 +136,7 @@ function credentialReadinessFor(input: {
131
136
  model: ConfiguredModel;
132
137
  provider: ReturnType<typeof configuredProviders>[number] | undefined;
133
138
  codexSubscriptionActive: boolean;
139
+ xaiSubscriptionActive: boolean;
134
140
  workspaceGatewayConnectionActive: boolean;
135
141
  observation: ModelCredentialReadinessObservation | undefined;
136
142
  nowMs: number;
@@ -138,7 +144,9 @@ function credentialReadinessFor(input: {
138
144
  }): ModelCredentialReadinessV1 {
139
145
  const source = input.model.credentialSource;
140
146
  if (source.kind === "connected_subscription") {
141
- return input.codexSubscriptionActive
147
+ const active =
148
+ source.provider === "xai" ? input.xaiSubscriptionActive : input.codexSubscriptionActive;
149
+ return active
142
150
  ? { status: "ready", reason: null, basis: "connection", checkedAt: null }
143
151
  : {
144
152
  status: "not_ready",
@@ -309,6 +317,7 @@ export function buildWorkspaceModelCatalog(input: {
309
317
  settings: Settings;
310
318
  policy: WorkspaceModelPolicyContract | null;
311
319
  codexSubscriptionActive: boolean;
320
+ xaiSubscriptionActive?: boolean;
312
321
  workspaceGatewayConnectionActive?: boolean;
313
322
  credentialReadinessObservations?:
314
323
  | Readonly<Record<string, ModelCredentialReadinessObservation>>
@@ -320,7 +329,10 @@ export function buildWorkspaceModelCatalog(input: {
320
329
  const codexSettings = input.settings.codexSubscriptionEnabled
321
330
  ? withCodexCatalogProvider(input.settings)
322
331
  : input.settings;
323
- const catalogSettings = withWorkspaceGatewayCatalogProvider(codexSettings);
332
+ const xaiSettings = input.settings.supergrokSubscriptionEnabled
333
+ ? withXaiSubscriptionCatalogProvider(codexSettings)
334
+ : codexSettings;
335
+ const catalogSettings = withWorkspaceGatewayCatalogProvider(xaiSettings);
324
336
  const providers = new Map(
325
337
  configuredProviders(catalogSettings).map((provider) => [provider.id, provider]),
326
338
  );
@@ -341,6 +353,7 @@ export function buildWorkspaceModelCatalog(input: {
341
353
  model,
342
354
  provider,
343
355
  codexSubscriptionActive: input.codexSubscriptionActive,
356
+ xaiSubscriptionActive: input.xaiSubscriptionActive === true,
344
357
  workspaceGatewayConnectionActive: input.workspaceGatewayConnectionActive === true,
345
358
  observation: input.credentialReadinessObservations?.[model.definitionVersion],
346
359
  nowMs,
@@ -0,0 +1,410 @@
1
+ import {
2
+ CORE_INTEGRATION_DEFINITIONS,
3
+ createPinnedIntegrationTransport,
4
+ integrationFacetDefinitions,
5
+ type IntegrationCredentialResolver,
6
+ } from "@opengeni/capabilities";
7
+ import {
8
+ ApiIntegrationPreview,
9
+ ListIntegrationDefinitionsResponse,
10
+ ApiIntegrationUninstallPreview,
11
+ InstallApiIntegrationRequest,
12
+ InstalledApiIntegration,
13
+ ListApiIntegrationsResponse,
14
+ PreviewApiIntegrationRequest,
15
+ UninstallApiIntegrationRequest,
16
+ UninstallApiIntegrationResult,
17
+ } from "@opengeni/contracts";
18
+ import { requireAccessGrant, type ApiRouteDeps } from "@opengeni/core";
19
+ import {
20
+ ApiIntegrationInstallationVersionConflictError,
21
+ buildConnectionTokenResolver,
22
+ getApiIntegrationUninstallPreview,
23
+ getConnectionMetadata,
24
+ installApiIntegration,
25
+ IntegrationFacetBindingOwnershipConflictError,
26
+ IntegrationFacetBindingVersionConflictError,
27
+ IntegrationFacetBindingVersionRequiredError,
28
+ listInstalledApiIntegrations,
29
+ uninstallApiIntegration,
30
+ type ConnectionMetadataWithVerification,
31
+ } from "@opengeni/db";
32
+ import type { FetchLike } from "@opengeni/network";
33
+ import type { Hono } from "hono";
34
+ import { HTTPException } from "hono/http-exception";
35
+
36
+ import {
37
+ resolveApiIntegrationPreview,
38
+ type ApiIntegrationConnectionDescriptor,
39
+ } from "../integrations/api-integrations";
40
+
41
+ export type ApiIntegrationRouteOverrides = Readonly<{ fetchImpl?: FetchLike }>;
42
+
43
+ export function apiIntegrationRequiresConnection(authScheme: Record<string, unknown>): boolean {
44
+ const authKind = authScheme.kind;
45
+ return typeof authKind === "string" && authKind !== "none";
46
+ }
47
+
48
+ export function registerApiIntegrationRoutes(
49
+ app: Hono,
50
+ deps: ApiRouteDeps,
51
+ overrides: ApiIntegrationRouteOverrides = {},
52
+ ): void {
53
+ const transport = createPinnedIntegrationTransport({
54
+ network: deps.settings,
55
+ ...(overrides.fetchImpl ? { fetchImpl: overrides.fetchImpl } : {}),
56
+ });
57
+
58
+ app.get("/v1/workspaces/:workspaceId/integrations/definitions", async (c) => {
59
+ const workspaceId = c.req.param("workspaceId");
60
+ await requireAccessGrant(c, deps, workspaceId, "workspace:read");
61
+ return c.json(
62
+ ListIntegrationDefinitionsResponse.parse({
63
+ definitions: CORE_INTEGRATION_DEFINITIONS.map((definition) => ({
64
+ id: definition.id,
65
+ name: definition.name,
66
+ summary: definition.summary,
67
+ protocol: definition.protocol,
68
+ provider: { ...definition.provider },
69
+ authentication: {
70
+ kind: definition.authentication.kind,
71
+ scopes: [...definition.authentication.scopes],
72
+ },
73
+ facets: definition.facets.map((facet) => ({
74
+ facetKey: facet.facetKey,
75
+ kind: facet.kind,
76
+ configSchema: { ...facet.configSchema },
77
+ capabilities: { ...facet.capabilities },
78
+ })),
79
+ })),
80
+ }),
81
+ );
82
+ });
83
+
84
+ app.get("/v1/workspaces/:workspaceId/integrations", async (c) => {
85
+ const workspaceId = c.req.param("workspaceId");
86
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:read");
87
+ const integrations = await listInstalledApiIntegrations(deps.db, workspaceId, grant.subjectId);
88
+ return c.json(
89
+ ListApiIntegrationsResponse.parse({
90
+ integrations: integrations.map((integration) => {
91
+ return {
92
+ capabilityId: integration.capabilityId,
93
+ pluginKey: integration.pluginKey,
94
+ installationVersion: integration.installationVersion,
95
+ instanceId: integration.instanceId,
96
+ instanceKey: integration.instanceKey,
97
+ displayName: integration.displayName,
98
+ instanceVersion: integration.instanceVersion,
99
+ serverId: integration.serverId,
100
+ name: integration.name,
101
+ description: integration.description,
102
+ protocol: integration.protocol,
103
+ definitionId: integration.definitionId,
104
+ definitionProvenance: integration.definitionProvenance,
105
+ providerDomain: integration.providerDomain,
106
+ baseUrl: integration.baseUrl,
107
+ sourceUrl: integration.sourceUrl,
108
+ connected: integration.connectionRef !== null,
109
+ requiresConnection: apiIntegrationRequiresConnection(integration.authScheme),
110
+ connectionId: integration.connectionRef?.connectionId ?? null,
111
+ ownership:
112
+ integration.connectionRef?.subjectScope === "subject"
113
+ ? "personal"
114
+ : integration.connectionRef
115
+ ? "workspace"
116
+ : "none",
117
+ allowedTools: integration.allowedTools,
118
+ toolCount: integration.allowedTools.length,
119
+ approvalRequiredToolCount:
120
+ integration.requireApproval === true
121
+ ? integration.allowedTools.length
122
+ : integration.requireApproval.length,
123
+ revisionId: integration.revision.id,
124
+ contentSha256: integration.revision.contentSha256,
125
+ };
126
+ }),
127
+ }),
128
+ );
129
+ });
130
+
131
+ app.post("/v1/workspaces/:workspaceId/integrations/preview", async (c) => {
132
+ const workspaceId = c.req.param("workspaceId");
133
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:read");
134
+ const payload = PreviewApiIntegrationRequest.parse(await c.req.json());
135
+ const resolved = await resolveForRoute({
136
+ deps,
137
+ transport,
138
+ workspaceId,
139
+ subjectId: grant.subjectId,
140
+ payload,
141
+ });
142
+ return c.json(ApiIntegrationPreview.parse(resolved.preview));
143
+ });
144
+
145
+ app.post("/v1/workspaces/:workspaceId/integrations/install", async (c) => {
146
+ const workspaceId = c.req.param("workspaceId");
147
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
148
+ const payload = InstallApiIntegrationRequest.parse(await c.req.json());
149
+ const resolved = await resolveForRoute({
150
+ deps,
151
+ transport,
152
+ workspaceId,
153
+ subjectId: grant.subjectId,
154
+ payload,
155
+ });
156
+ if (
157
+ resolved.preview.revisionId !== payload.expectedRevisionId ||
158
+ resolved.preview.contentSha256 !== payload.expectedContentSha256
159
+ ) {
160
+ throw new HTTPException(409, {
161
+ message:
162
+ "The Integration source changed after preview. Review the new tools and permissions before installing.",
163
+ });
164
+ }
165
+ if (resolved.preview.auth.kind !== "none" && !payload.connectionId) {
166
+ throw new HTTPException(422, {
167
+ message: "Connect an account before installing this Integration.",
168
+ });
169
+ }
170
+ if (
171
+ payload.ownership &&
172
+ resolved.preview.connectionOwnership &&
173
+ payload.ownership !== resolved.preview.connectionOwnership
174
+ ) {
175
+ throw new HTTPException(422, {
176
+ message: "The selected Connection ownership does not match this install request.",
177
+ });
178
+ }
179
+ if (payload.ownership === "personal" && !resolved.preview.connectionOwnership) {
180
+ throw new HTTPException(422, {
181
+ message: "Choose a Personal Connection before installing for yourself.",
182
+ });
183
+ }
184
+ try {
185
+ return c.json(
186
+ InstalledApiIntegration.parse(
187
+ await installApiIntegration(deps.db, {
188
+ accountId: grant.accountId,
189
+ workspaceId,
190
+ subjectId: grant.subjectId,
191
+ capabilityId: resolved.preview.capabilityId,
192
+ pluginKey: resolved.preview.pluginKey,
193
+ serverId: resolved.preview.serverId,
194
+ name: resolved.preview.name,
195
+ description: resolved.preview.description,
196
+ category: "integrations",
197
+ tags: [resolved.preview.protocol, resolved.preview.provider ?? "custom"],
198
+ definitionId: resolved.preview.definitionId,
199
+ definitionProvenance: resolved.preview.definitionProvenance,
200
+ ...(resolved.provider ? { provider: resolved.provider } : {}),
201
+ providerDomain: resolved.preview.providerDomain,
202
+ protocol: resolved.preview.protocol,
203
+ baseUrl: resolved.preview.baseUrl,
204
+ sourceUrl: resolved.preview.sourceUrl,
205
+ authScheme: resolved.authScheme,
206
+ ...(payload.connectionId ? { connectionId: payload.connectionId } : {}),
207
+ ...(payload.instanceKey ? { instanceKey: payload.instanceKey } : {}),
208
+ ...(payload.displayName ? { displayName: payload.displayName } : {}),
209
+ ...(payload.expectedInstanceVersion !== undefined
210
+ ? { expectedInstanceVersion: payload.expectedInstanceVersion }
211
+ : {}),
212
+ requiredScopes: resolved.requiredScopes,
213
+ ownership:
214
+ resolved.preview.connectionOwnership === "personal" ? "subject" : "workspace",
215
+ ...(payload.allowedTools ? { allowedTools: payload.allowedTools } : {}),
216
+ facetDefinitions: integrationFacetDefinitions(resolved.preview.definitionId),
217
+ revision: resolved.revision,
218
+ }),
219
+ ),
220
+ payload.expectedInstanceVersion === undefined ? 201 : 200,
221
+ );
222
+ } catch (error) {
223
+ if (
224
+ error instanceof IntegrationFacetBindingVersionConflictError ||
225
+ error instanceof IntegrationFacetBindingVersionRequiredError ||
226
+ error instanceof IntegrationFacetBindingOwnershipConflictError
227
+ ) {
228
+ throw new HTTPException(409, {
229
+ message:
230
+ "The Integration instance changed or is shared by another owner. Refresh its details before updating it.",
231
+ });
232
+ }
233
+ throw error;
234
+ }
235
+ });
236
+
237
+ app.get(
238
+ "/v1/workspaces/:workspaceId/integrations/:capabilityId/instances/:instanceKey/uninstall-preview",
239
+ async (c) => {
240
+ const workspaceId = c.req.param("workspaceId");
241
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:read");
242
+ return c.json(
243
+ ApiIntegrationUninstallPreview.parse(
244
+ await getApiIntegrationUninstallPreview(
245
+ deps.db,
246
+ workspaceId,
247
+ grant.subjectId,
248
+ decodeURIComponent(c.req.param("capabilityId")),
249
+ decodeURIComponent(c.req.param("instanceKey")),
250
+ ),
251
+ ),
252
+ );
253
+ },
254
+ );
255
+
256
+ app.delete(
257
+ "/v1/workspaces/:workspaceId/integrations/:capabilityId/instances/:instanceKey",
258
+ async (c) => {
259
+ const workspaceId = c.req.param("workspaceId");
260
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
261
+ const capabilityId = decodeURIComponent(c.req.param("capabilityId"));
262
+ const instanceKey = decodeURIComponent(c.req.param("instanceKey"));
263
+ const payload = UninstallApiIntegrationRequest.parse(await c.req.json());
264
+ try {
265
+ return c.json(
266
+ UninstallApiIntegrationResult.parse(
267
+ await uninstallApiIntegration(deps.db, {
268
+ accountId: grant.accountId,
269
+ workspaceId,
270
+ subjectId: grant.subjectId,
271
+ capabilityId,
272
+ instanceKey,
273
+ expectedInstallationVersion: payload.expectedInstallationVersion,
274
+ expectedInstanceVersion: payload.expectedInstanceVersion,
275
+ }),
276
+ ),
277
+ );
278
+ } catch (error) {
279
+ if (
280
+ error instanceof ApiIntegrationInstallationVersionConflictError ||
281
+ error instanceof IntegrationFacetBindingVersionConflictError
282
+ ) {
283
+ throw new HTTPException(409, {
284
+ message:
285
+ "The Integration instance changed after preview. Review uninstall impact again.",
286
+ });
287
+ }
288
+ throw error;
289
+ }
290
+ },
291
+ );
292
+ }
293
+
294
+ async function resolveForRoute(input: {
295
+ deps: ApiRouteDeps;
296
+ transport: ReturnType<typeof createPinnedIntegrationTransport>;
297
+ workspaceId: string;
298
+ subjectId: string;
299
+ payload: PreviewApiIntegrationRequest | InstallApiIntegrationRequest;
300
+ }): ReturnType<typeof resolveApiIntegrationPreview> {
301
+ try {
302
+ const connection = input.payload.connectionId
303
+ ? await requireVisibleConnection(
304
+ input.deps,
305
+ input.workspaceId,
306
+ input.subjectId,
307
+ input.payload.connectionId,
308
+ )
309
+ : null;
310
+ return await resolveApiIntegrationPreview({
311
+ source: input.payload.source,
312
+ connection: connectionDescriptor(connection),
313
+ transport: input.transport,
314
+ authority: {
315
+ accountId: connection?.accountId ?? "preview",
316
+ workspaceId: input.workspaceId,
317
+ initiatingSubjectId: input.subjectId,
318
+ ...(connection ? { connectionRef: connection.id } : {}),
319
+ },
320
+ ...(connection
321
+ ? {
322
+ credentialResolver: previewCredentialResolver(
323
+ input.deps,
324
+ input.workspaceId,
325
+ input.subjectId,
326
+ connection,
327
+ ),
328
+ }
329
+ : {}),
330
+ });
331
+ } catch (error) {
332
+ if (error instanceof HTTPException) throw error;
333
+ throw new HTTPException(422, {
334
+ message:
335
+ error instanceof Error
336
+ ? error.message
337
+ : "The Integration source could not be detected safely",
338
+ });
339
+ }
340
+ }
341
+
342
+ async function requireVisibleConnection(
343
+ deps: ApiRouteDeps,
344
+ workspaceId: string,
345
+ subjectId: string,
346
+ connectionId: string,
347
+ ): Promise<ConnectionMetadataWithVerification> {
348
+ const connection = await getConnectionMetadata(deps.db, workspaceId, connectionId, subjectId);
349
+ if (!connection) throw new HTTPException(404, { message: "connection not found" });
350
+ if (connection.status !== "active") {
351
+ throw new HTTPException(422, { message: "connection is not active" });
352
+ }
353
+ return connection;
354
+ }
355
+
356
+ function connectionDescriptor(
357
+ connection: ConnectionMetadataWithVerification | null,
358
+ ): ApiIntegrationConnectionDescriptor | null {
359
+ return connection
360
+ ? {
361
+ id: connection.id,
362
+ kind: connection.kind,
363
+ providerDomain: connection.providerDomain,
364
+ scopes: [...connection.grantedScopes],
365
+ ownership: connection.subjectId ? "personal" : "workspace",
366
+ }
367
+ : null;
368
+ }
369
+
370
+ function previewCredentialResolver(
371
+ deps: ApiRouteDeps,
372
+ workspaceId: string,
373
+ subjectId: string,
374
+ connection: ConnectionMetadataWithVerification,
375
+ ): IntegrationCredentialResolver {
376
+ const resolve = buildConnectionTokenResolver(deps.db, deps.settings);
377
+ return {
378
+ resolve: async (request) => {
379
+ const result = await resolve({
380
+ workspaceId,
381
+ ...(connection.subjectId ? { subjectId } : {}),
382
+ serverId: `preview_${connection.id}`,
383
+ toolName: request.operationKey,
384
+ connectionRef: {
385
+ connectionId: connection.id,
386
+ providerDomain: connection.providerDomain,
387
+ kind: connection.kind,
388
+ ...(connection.grantedScopes.length > 0 ? { scopes: [...connection.grantedScopes] } : {}),
389
+ subjectScope: connection.subjectId ? "subject" : "workspace",
390
+ },
391
+ destinationUrl: request.destinationUrl,
392
+ credentialTarget: "http_api",
393
+ forceRefresh: request.forceRefresh === true,
394
+ });
395
+ if (result.status === "auth_needed") return null;
396
+ const destination = new URL(request.destinationUrl);
397
+ return {
398
+ audience: { origin: destination.origin, pathPrefix: "/" },
399
+ placements:
400
+ result.placements ??
401
+ Object.entries(result.headers).map(([name, value]) => ({
402
+ carrier: "header" as const,
403
+ name,
404
+ value,
405
+ })),
406
+ ...(result.expiresAt ? { expiresAt: result.expiresAt.toISOString() } : {}),
407
+ };
408
+ },
409
+ };
410
+ }