@nuxtagentic/integrations 0.0.0-stage → 0.1.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.
- package/LICENSE.md +9 -0
- package/README.md +175 -2
- package/client.ts +124 -0
- package/module.ts +44 -0
- package/package.json +52 -3
- package/runtime/assets/logos/discord.svg +1 -0
- package/runtime/assets/logos/github.svg +1 -0
- package/runtime/assets/logos/gmail.svg +1 -0
- package/runtime/assets/logos/googleads.svg +1 -0
- package/runtime/assets/logos/googleanalytics.svg +1 -0
- package/runtime/assets/logos/googlecalendar.svg +1 -0
- package/runtime/assets/logos/googledrive.svg +1 -0
- package/runtime/assets/logos/googlesearchconsole.svg +1 -0
- package/runtime/assets/logos/hubspot.svg +1 -0
- package/runtime/assets/logos/intercom.svg +1 -0
- package/runtime/assets/logos/linear.svg +1 -0
- package/runtime/assets/logos/mcp.svg +1 -0
- package/runtime/assets/logos/meta.svg +1 -0
- package/runtime/assets/logos/notion.svg +1 -0
- package/runtime/assets/logos/reddit.svg +1 -0
- package/runtime/assets/logos/sentry.svg +1 -0
- package/runtime/assets/logos/shopify.svg +1 -0
- package/runtime/assets/logos/slack.svg +1 -0
- package/runtime/assets/logos/stripe.svg +1 -0
- package/runtime/assets/logos/tiktok-dark.svg +1 -0
- package/runtime/assets/logos/tiktok.svg +1 -0
- package/runtime/assets/logos/vercel.svg +1 -0
- package/runtime/assets/logos/x.svg +1 -0
- package/runtime/components/IntegrationCard.vue +82 -0
- package/runtime/components/IntegrationCatalog.vue +130 -0
- package/runtime/components/IntegrationConnectionPanel.vue +589 -0
- package/runtime/components/IntegrationLogo.vue +41 -0
- package/runtime/pages/integrations.vue +481 -0
- package/runtime/server/api/integrations/approvals/[id].post.ts +69 -0
- package/runtime/server/api/integrations/catalog/[provider]/action.post.ts +35 -0
- package/runtime/server/api/integrations/catalog/[provider]/connection.post.ts +242 -0
- package/runtime/server/api/integrations/catalog/[provider]/tools.get.ts +40 -0
- package/runtime/server/api/integrations/catalog/index.get.ts +48 -0
- package/runtime/server/api/integrations/catalog/tools.get.ts +27 -0
- package/runtime/server/plugins/connect-maintenance.ts +30 -0
- package/runtime/server/utils/approval-continuation.ts +37 -0
- package/runtime/server/utils/google-mcp.ts +296 -0
- package/runtime/server/utils/integration-adapters.ts +836 -0
- package/runtime/server/utils/integration-context.ts +510 -0
- package/runtime/server/utils/integration-crypto.ts +13 -0
- package/runtime/server/utils/request-body.ts +38 -0
- package/runtime/shared/agent.ts +14 -0
- package/runtime/shared/catalog.ts +566 -0
- package/runtime/shared/google-mcp.ts +8 -0
- package/runtime/workspace.ts +15 -0
- package/schema.ts +179 -0
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
import { createError, setHeader, type H3Event } from 'h3'
|
|
2
|
+
import { useRuntimeConfig } from '#imports'
|
|
3
|
+
import { useCloudConfigServer } from '#cloudconfig/server'
|
|
4
|
+
import { resolveConfigExport, type InferServerSelect } from '@cloudconfig/core'
|
|
5
|
+
import { compileConfig, createServerClient, type DbProvider } from '@cloudconfig/core/server'
|
|
6
|
+
import {
|
|
7
|
+
ConnectError,
|
|
8
|
+
createConnect,
|
|
9
|
+
resolveActor,
|
|
10
|
+
type Connect,
|
|
11
|
+
type ConnectActor,
|
|
12
|
+
} from '@cloudconfig/core/connect'
|
|
13
|
+
import * as configModule from '#cloudconfig/config'
|
|
14
|
+
import type { IntegrationsConfig } from '../../../schema'
|
|
15
|
+
import {
|
|
16
|
+
getIntegrationDefinition,
|
|
17
|
+
getIntegrationToolName,
|
|
18
|
+
isMcpWrite,
|
|
19
|
+
type IntegrationScope,
|
|
20
|
+
} from '../../shared/catalog'
|
|
21
|
+
import {
|
|
22
|
+
executeIntegrationAction,
|
|
23
|
+
listIntegrationActions,
|
|
24
|
+
type Credentials,
|
|
25
|
+
} from './integration-adapters'
|
|
26
|
+
import {
|
|
27
|
+
executeGoogleMcpAction,
|
|
28
|
+
isGoogleMcpEnabled,
|
|
29
|
+
isGoogleMcpWriteAction,
|
|
30
|
+
planGoogleMcpAction,
|
|
31
|
+
} from './google-mcp'
|
|
32
|
+
|
|
33
|
+
export type Binding = InferServerSelect<IntegrationsConfig, 'integrationConnection'>
|
|
34
|
+
export const backendConfig = resolveConfigExport(configModule, '#cloudconfig/config')
|
|
35
|
+
|
|
36
|
+
/** Who a run acts as. `workspace` runs may only use shared connections. */
|
|
37
|
+
export type RunAs = 'owner' | 'workspace'
|
|
38
|
+
|
|
39
|
+
export interface IntegrationServiceContext {
|
|
40
|
+
ownerId: number
|
|
41
|
+
/** The active organization id as text, or `personal`. */
|
|
42
|
+
workspace: string
|
|
43
|
+
env: Record<string, unknown> | undefined
|
|
44
|
+
database: DbProvider
|
|
45
|
+
request: Request
|
|
46
|
+
runAs?: RunAs
|
|
47
|
+
/** Resolved membership; cached per context. */
|
|
48
|
+
actor?: ConnectActor | null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function organizationOf(workspace: string): number | null {
|
|
52
|
+
const id = Number(workspace)
|
|
53
|
+
return Number.isSafeInteger(id) && id > 0 ? id : null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The actor runs act as: the owner, inside the workspace, with their current
|
|
58
|
+
* role. Throws once the owner has left the workspace, so their agents and
|
|
59
|
+
* automations stop using its connections immediately.
|
|
60
|
+
*/
|
|
61
|
+
export async function actorOf(context: IntegrationServiceContext): Promise<ConnectActor> {
|
|
62
|
+
if (context.actor === undefined) {
|
|
63
|
+
const db = await context.database.connect({ env: context.env, request: context.request })
|
|
64
|
+
context.actor = await resolveActor(compileConfig(backendConfig), db, {
|
|
65
|
+
userId: context.ownerId,
|
|
66
|
+
organizationId: organizationOf(context.workspace),
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
if (!context.actor) {
|
|
70
|
+
throw new OwnerLeftError()
|
|
71
|
+
}
|
|
72
|
+
return context.actor
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The run's owner is no longer a member of its workspace. */
|
|
76
|
+
export class OwnerLeftError extends Error {
|
|
77
|
+
constructor() {
|
|
78
|
+
super('The owner of this run is no longer a member of the workspace.')
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A server client whose access rules see the actor's organization and role. */
|
|
83
|
+
export async function integrationApi(context: IntegrationServiceContext) {
|
|
84
|
+
const actor = await actorOf(context)
|
|
85
|
+
return createServerClient(
|
|
86
|
+
backendConfig as IntegrationsConfig,
|
|
87
|
+
{ env: context.env, request: context.request },
|
|
88
|
+
{
|
|
89
|
+
db: context.database,
|
|
90
|
+
getUser: () => ({
|
|
91
|
+
id: actor.userId,
|
|
92
|
+
organization:
|
|
93
|
+
actor.organizationId === null ? null : { id: actor.organizationId, role: actor.role },
|
|
94
|
+
}),
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function connectOf(
|
|
100
|
+
context: Pick<IntegrationServiceContext, 'database' | 'env' | 'request'>,
|
|
101
|
+
): Promise<Connect> {
|
|
102
|
+
const db = await context.database.connect({ env: context.env, request: context.request })
|
|
103
|
+
return createConnect(compileConfig(backendConfig), { db, env: context.env })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function getIntegrationContext(event: H3Event) {
|
|
107
|
+
setHeader(event, 'Cache-Control', 'private, no-store')
|
|
108
|
+
const runtime = useRuntimeConfig(event)
|
|
109
|
+
const authBase =
|
|
110
|
+
(runtime.public.cloudconfig as { authBasePath?: string }).authBasePath ?? '/api/auth'
|
|
111
|
+
const session = await event.$fetch<{
|
|
112
|
+
user: { id: string; email: string }
|
|
113
|
+
session: { activeOrganizationId?: string }
|
|
114
|
+
} | null>(`${authBase}/get-session`, { query: { disableCookieCache: true } })
|
|
115
|
+
if (!session?.user) {
|
|
116
|
+
throw createError({ statusCode: 401, statusMessage: 'Sign in to manage integrations.' })
|
|
117
|
+
}
|
|
118
|
+
const env = event.context.cloudflare?.env as Record<string, unknown> | undefined
|
|
119
|
+
const settings = runtime.cloudconfig as { d1Binding?: string; localDb?: string }
|
|
120
|
+
const database = await getDatabaseProvider(env, settings)
|
|
121
|
+
const context: IntegrationServiceContext = {
|
|
122
|
+
ownerId: Number(session.user.id),
|
|
123
|
+
workspace: String(session.session.activeOrganizationId ?? 'personal'),
|
|
124
|
+
env,
|
|
125
|
+
database,
|
|
126
|
+
request: new Request('https://integrations.internal'),
|
|
127
|
+
}
|
|
128
|
+
const actor = await actorOf(context).catch(() => {
|
|
129
|
+
throw createError({ statusCode: 403, statusMessage: 'You are not a member of this workspace.' })
|
|
130
|
+
})
|
|
131
|
+
return {
|
|
132
|
+
...context,
|
|
133
|
+
actor,
|
|
134
|
+
api: await useCloudConfigServer<IntegrationsConfig>(event),
|
|
135
|
+
email: session.user.email,
|
|
136
|
+
/** Owners and admins manage shared connections. */
|
|
137
|
+
manager:
|
|
138
|
+
actor.organizationId !== null &&
|
|
139
|
+
actor.role.split(',').some((role) => ['owner', 'admin'].includes(role.trim())),
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function getDatabaseProvider(
|
|
144
|
+
env: Record<string, unknown> | undefined,
|
|
145
|
+
settings: { d1Binding?: string; localDb?: string } = {},
|
|
146
|
+
): Promise<DbProvider> {
|
|
147
|
+
if (env) {
|
|
148
|
+
return (await import('@cloudconfig/core/drivers/d1')).d1(settings.d1Binding ?? 'DB')
|
|
149
|
+
}
|
|
150
|
+
return (await import('@cloudconfig/core/drivers/better-sqlite3')).sqliteFile(
|
|
151
|
+
settings.localDb ?? backendConfig.db?.local ?? '.cloudconfig/dev.db',
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Connections a run may use in this workspace: the owner's own, and the
|
|
157
|
+
* workspace's shared ones — or only the shared ones when it runs as the workspace.
|
|
158
|
+
*/
|
|
159
|
+
export async function listBindings(
|
|
160
|
+
context: IntegrationServiceContext,
|
|
161
|
+
filter: { provider?: string; enabledOnly?: boolean } = {},
|
|
162
|
+
): Promise<Binding[]> {
|
|
163
|
+
const api = await integrationApi(context)
|
|
164
|
+
const result = await api.integrationConnection.list({
|
|
165
|
+
pageSize: 100,
|
|
166
|
+
sortBy: 'id',
|
|
167
|
+
filters: {
|
|
168
|
+
type: 'group',
|
|
169
|
+
logic: 'and',
|
|
170
|
+
children: [
|
|
171
|
+
{ type: 'condition', field: 'workspace', operator: 'eq', value: context.workspace },
|
|
172
|
+
...(filter.provider
|
|
173
|
+
? [
|
|
174
|
+
{
|
|
175
|
+
type: 'condition' as const,
|
|
176
|
+
field: 'provider',
|
|
177
|
+
operator: 'eq' as const,
|
|
178
|
+
value: filter.provider,
|
|
179
|
+
},
|
|
180
|
+
]
|
|
181
|
+
: []),
|
|
182
|
+
],
|
|
183
|
+
},
|
|
184
|
+
})
|
|
185
|
+
return result.data.filter(
|
|
186
|
+
(row) =>
|
|
187
|
+
(!filter.enabledOnly || row.enabled) &&
|
|
188
|
+
(context.runAs !== 'workspace' || row.scope === 'workspace') &&
|
|
189
|
+
(row.scope === 'workspace' || row.ownerId === context.ownerId),
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface GrantedTool {
|
|
194
|
+
name: string
|
|
195
|
+
label: string
|
|
196
|
+
description: string
|
|
197
|
+
parameters: Record<string, unknown>
|
|
198
|
+
provider: string
|
|
199
|
+
action: string
|
|
200
|
+
bindingId: number
|
|
201
|
+
approvalRequired: boolean
|
|
202
|
+
account: string
|
|
203
|
+
scope: IntegrationScope
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The tools agents may call: each enabled connection's permitted actions, and
|
|
208
|
+
* for MCP connections the permitted tools the server offers right now.
|
|
209
|
+
*/
|
|
210
|
+
export async function listGrantedTools(context: IntegrationServiceContext): Promise<GrantedTool[]> {
|
|
211
|
+
const bindings = await listBindings(context, { enabledOnly: true })
|
|
212
|
+
const seen = new Set<string>()
|
|
213
|
+
const tools: GrantedTool[] = []
|
|
214
|
+
for (const binding of bindings) {
|
|
215
|
+
const definition = getIntegrationDefinition(binding.provider)
|
|
216
|
+
if (!definition) {
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
// The first connection of an integration keeps the plain tool names.
|
|
220
|
+
const suffix = seen.has(binding.provider) ? binding.id : undefined
|
|
221
|
+
seen.add(binding.provider)
|
|
222
|
+
const permissions = binding.permissions as string[]
|
|
223
|
+
if (definition.auth.mcp) {
|
|
224
|
+
if (!binding.connectionId) {
|
|
225
|
+
continue
|
|
226
|
+
}
|
|
227
|
+
const connect = await connectOf(context)
|
|
228
|
+
const available = await connect
|
|
229
|
+
.mcp(await actorOf(context), binding.connectionId)
|
|
230
|
+
.listTools()
|
|
231
|
+
.catch(() => [])
|
|
232
|
+
const approvals = binding.approvals as string[]
|
|
233
|
+
for (const tool of available.filter((entry) => permissions.includes(entry.name))) {
|
|
234
|
+
tools.push({
|
|
235
|
+
name: getIntegrationToolName(binding.provider, tool.name, suffix),
|
|
236
|
+
label: tool.title ?? tool.annotations?.title ?? tool.name,
|
|
237
|
+
description: tool.description ?? tool.name,
|
|
238
|
+
parameters: Object.keys(tool.inputSchema).length
|
|
239
|
+
? tool.inputSchema
|
|
240
|
+
: { type: 'object', properties: {} },
|
|
241
|
+
provider: binding.provider,
|
|
242
|
+
action: tool.name,
|
|
243
|
+
bindingId: binding.id,
|
|
244
|
+
approvalRequired: approvals.includes(tool.name),
|
|
245
|
+
account: binding.account,
|
|
246
|
+
scope: binding.scope as IntegrationScope,
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
continue
|
|
250
|
+
}
|
|
251
|
+
for (const action of listIntegrationActions(binding.provider)) {
|
|
252
|
+
if (!permissions.includes(action.id)) {
|
|
253
|
+
continue
|
|
254
|
+
}
|
|
255
|
+
tools.push({
|
|
256
|
+
name: getIntegrationToolName(binding.provider, action.id, suffix),
|
|
257
|
+
label: action.label,
|
|
258
|
+
description: suffix ? `${action.label} (${binding.account})` : action.label,
|
|
259
|
+
parameters: action.parameters,
|
|
260
|
+
provider: binding.provider,
|
|
261
|
+
action: action.id,
|
|
262
|
+
bindingId: binding.id,
|
|
263
|
+
approvalRequired:
|
|
264
|
+
!!action.approval ||
|
|
265
|
+
(isGoogleMcpEnabled(context.env) && isGoogleMcpWriteAction(binding.provider, action.id)),
|
|
266
|
+
account: binding.account,
|
|
267
|
+
scope: binding.scope as IntegrationScope,
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return tools
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** What the adapters need from a connection, read through Connect on every call. */
|
|
275
|
+
export async function credentialsFor(
|
|
276
|
+
context: IntegrationServiceContext,
|
|
277
|
+
binding: Binding,
|
|
278
|
+
): Promise<Credentials> {
|
|
279
|
+
const definition = getIntegrationDefinition(binding.provider)!
|
|
280
|
+
const settings = Object.fromEntries(
|
|
281
|
+
Object.entries((binding.settings ?? {}) as Record<string, unknown>).filter(
|
|
282
|
+
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
|
283
|
+
),
|
|
284
|
+
)
|
|
285
|
+
let access
|
|
286
|
+
try {
|
|
287
|
+
access = await (await connectOf(context)).access(await actorOf(context), binding.connectionId)
|
|
288
|
+
} catch (cause) {
|
|
289
|
+
if (cause instanceof ConnectError && cause.code === 'reauthorize') {
|
|
290
|
+
throw new Error(`Reconnect ${definition.name} in Integrations: ${cause.message}`)
|
|
291
|
+
}
|
|
292
|
+
if (cause instanceof ConnectError && cause.status === 404) {
|
|
293
|
+
throw new Error(
|
|
294
|
+
`The ${definition.name} account behind this connection was removed. Connect it again.`,
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
throw cause
|
|
298
|
+
}
|
|
299
|
+
const credentials: Credentials = { ...settings, ...access.fields }
|
|
300
|
+
if (access.connection.kind !== 'api_key') {
|
|
301
|
+
credentials.token = access.accessToken
|
|
302
|
+
}
|
|
303
|
+
if (access.tokenType.toLowerCase() === 'bot') {
|
|
304
|
+
credentials.tokenType = 'Bot'
|
|
305
|
+
}
|
|
306
|
+
if (typeof access.metadata.shop === 'string') {
|
|
307
|
+
credentials.shop = access.metadata.shop
|
|
308
|
+
}
|
|
309
|
+
if (
|
|
310
|
+
binding.provider === 'github' &&
|
|
311
|
+
access.connection.scope === 'workspace' &&
|
|
312
|
+
access.metadata.installationId
|
|
313
|
+
) {
|
|
314
|
+
credentials.githubInstallation = '1'
|
|
315
|
+
}
|
|
316
|
+
if (binding.provider === 'gads') {
|
|
317
|
+
const developerToken = String(
|
|
318
|
+
context.env?.NUXT_GOOGLE_ADS_DEVELOPER_TOKEN ??
|
|
319
|
+
process.env.NUXT_GOOGLE_ADS_DEVELOPER_TOKEN ??
|
|
320
|
+
'',
|
|
321
|
+
)
|
|
322
|
+
if (developerToken) {
|
|
323
|
+
credentials.developerToken = developerToken
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return credentials
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export interface IntegrationActionRequest {
|
|
330
|
+
/** Catalog integration id. */
|
|
331
|
+
provider: string
|
|
332
|
+
/** Action id, or the MCP tool name. */
|
|
333
|
+
action: string
|
|
334
|
+
input: Record<string, unknown>
|
|
335
|
+
/** A specific connection; defaults to the first one that allows the action. */
|
|
336
|
+
bindingId?: number
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export interface IntegrationActionOptions {
|
|
340
|
+
/** Set once the owner approved the action, so approval-gated actions run instead of queueing. */
|
|
341
|
+
approved?: boolean
|
|
342
|
+
signal?: AbortSignal
|
|
343
|
+
idempotencyKey?: string
|
|
344
|
+
/** Stored with a pending approval so the waiting agent or automation can resume. */
|
|
345
|
+
continuation?: Record<string, unknown>
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Runs an integration action as the context's actor, enforcing the
|
|
350
|
+
* connection's permissions. Actions that need approval are queued and return
|
|
351
|
+
* `approvalRequired` instead of running.
|
|
352
|
+
*/
|
|
353
|
+
export async function performIntegrationAction(
|
|
354
|
+
context: IntegrationServiceContext,
|
|
355
|
+
{ provider, action, input, bindingId }: IntegrationActionRequest,
|
|
356
|
+
options: IntegrationActionOptions = {},
|
|
357
|
+
) {
|
|
358
|
+
const definition = getIntegrationDefinition(provider)
|
|
359
|
+
if (!definition) {
|
|
360
|
+
throw new Error('Choose a supported integration.')
|
|
361
|
+
}
|
|
362
|
+
const bindings = await listBindings(context, { provider, enabledOnly: true })
|
|
363
|
+
const binding = bindingId
|
|
364
|
+
? bindings.find((row) => row.id === bindingId)
|
|
365
|
+
: bindings.find((row) => (row.permissions as string[]).includes(action))
|
|
366
|
+
if (!binding) {
|
|
367
|
+
throw new Error(`Connect ${definition.name} in Integrations first.`)
|
|
368
|
+
}
|
|
369
|
+
if (!(binding.permissions as string[]).includes(action)) {
|
|
370
|
+
throw new Error(
|
|
371
|
+
'This connection does not grant that action. Update its permissions in Integrations.',
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
if (definition.auth.mcp) {
|
|
375
|
+
const needsApproval = (binding.approvals as string[]).includes(action)
|
|
376
|
+
if (needsApproval && !options.approved) {
|
|
377
|
+
return queueApproval(context, binding, { action, input, continuation: options.continuation })
|
|
378
|
+
}
|
|
379
|
+
const result = await (
|
|
380
|
+
await connectOf(context)
|
|
381
|
+
)
|
|
382
|
+
.mcp(await actorOf(context), binding.connectionId)
|
|
383
|
+
.callTool(action, input, 60_000)
|
|
384
|
+
if (result.isError) {
|
|
385
|
+
throw new Error(
|
|
386
|
+
`${definition.name} reported an error: ${JSON.stringify(result.content).slice(0, 500)}`,
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
return result.structuredContent ?? result.content
|
|
390
|
+
}
|
|
391
|
+
const entry = listIntegrationActions(provider).find((item) => item.id === action)
|
|
392
|
+
if (!entry) {
|
|
393
|
+
throw new Error('This integration does not support that action.')
|
|
394
|
+
}
|
|
395
|
+
const needsApproval =
|
|
396
|
+
entry.approval || (isGoogleMcpEnabled(context.env) && isGoogleMcpWriteAction(provider, action))
|
|
397
|
+
if (needsApproval && !options.approved) {
|
|
398
|
+
return queueApproval(context, binding, { action, input, continuation: options.continuation })
|
|
399
|
+
}
|
|
400
|
+
if (isGoogleMcpEnabled(context.env)) {
|
|
401
|
+
const request = planGoogleMcpAction({ provider, action, input })
|
|
402
|
+
if (request) {
|
|
403
|
+
const connect = await connectOf(context)
|
|
404
|
+
const actor = await actorOf(context)
|
|
405
|
+
const connection = await connect.get(actor, binding.connectionId)
|
|
406
|
+
if (connection.kind === 'oauth' && connection.provider === 'google') {
|
|
407
|
+
return executeGoogleMcpAction({
|
|
408
|
+
connect,
|
|
409
|
+
actor,
|
|
410
|
+
connectionId: binding.connectionId,
|
|
411
|
+
request,
|
|
412
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
413
|
+
})
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const credentials = await credentialsFor(context, binding)
|
|
418
|
+
return executeIntegrationAction(
|
|
419
|
+
provider,
|
|
420
|
+
action,
|
|
421
|
+
{ credentials, input },
|
|
422
|
+
{ signal: options.signal, idempotencyKey: options.idempotencyKey },
|
|
423
|
+
)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function queueApproval(
|
|
427
|
+
context: IntegrationServiceContext,
|
|
428
|
+
binding: Binding,
|
|
429
|
+
{
|
|
430
|
+
action,
|
|
431
|
+
input,
|
|
432
|
+
continuation,
|
|
433
|
+
}: {
|
|
434
|
+
action: string
|
|
435
|
+
input: Record<string, unknown>
|
|
436
|
+
continuation?: IntegrationActionOptions['continuation']
|
|
437
|
+
},
|
|
438
|
+
) {
|
|
439
|
+
const approval = await (
|
|
440
|
+
await integrationApi(context)
|
|
441
|
+
).integrationApproval.create({
|
|
442
|
+
connectionId: binding.id,
|
|
443
|
+
action,
|
|
444
|
+
input,
|
|
445
|
+
ownerId: context.ownerId,
|
|
446
|
+
continuation: continuation ?? null,
|
|
447
|
+
})
|
|
448
|
+
return {
|
|
449
|
+
approvalRequired: true,
|
|
450
|
+
approvalId: approval.id,
|
|
451
|
+
message: 'The owner must approve this action in Integrations.',
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export function toPublicConnection(row: Binding, viewer?: { ownerId: number; manager: boolean }) {
|
|
456
|
+
const {
|
|
457
|
+
id,
|
|
458
|
+
provider,
|
|
459
|
+
account,
|
|
460
|
+
label,
|
|
461
|
+
scope,
|
|
462
|
+
connectionId,
|
|
463
|
+
enabled,
|
|
464
|
+
permissions,
|
|
465
|
+
approvals,
|
|
466
|
+
settings,
|
|
467
|
+
ownerId,
|
|
468
|
+
} = row
|
|
469
|
+
const isMine = viewer ? ownerId === viewer.ownerId : false
|
|
470
|
+
const canManage = !!viewer && (scope === 'user' ? isMine : viewer.manager)
|
|
471
|
+
|
|
472
|
+
return {
|
|
473
|
+
id,
|
|
474
|
+
provider,
|
|
475
|
+
account,
|
|
476
|
+
label,
|
|
477
|
+
scope: scope as IntegrationScope,
|
|
478
|
+
connectionId,
|
|
479
|
+
enabled,
|
|
480
|
+
permissions: permissions as string[],
|
|
481
|
+
approvals: approvals as string[],
|
|
482
|
+
settings: settings as Record<string, string>,
|
|
483
|
+
mine: isMine,
|
|
484
|
+
canManage,
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export function isMcpIntegration(provider: string) {
|
|
489
|
+
return !!getIntegrationDefinition(provider)?.auth.mcp
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** MCP tools that ask for approval by default: anything not marked read-only. */
|
|
493
|
+
export function defaultMcpApprovals(
|
|
494
|
+
tools: { name: string; annotations?: { readOnlyHint?: boolean } }[],
|
|
495
|
+
) {
|
|
496
|
+
return tools.filter((tool) => isMcpWrite(tool.annotations)).map((tool) => tool.name)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export function toActionError(cause: unknown) {
|
|
500
|
+
if (cause && typeof cause === 'object' && 'statusCode' in cause) {
|
|
501
|
+
return cause
|
|
502
|
+
}
|
|
503
|
+
if (cause instanceof ConnectError) {
|
|
504
|
+
return createError({ statusCode: cause.status, statusMessage: cause.message })
|
|
505
|
+
}
|
|
506
|
+
return createError({
|
|
507
|
+
statusCode: 400,
|
|
508
|
+
statusMessage: cause instanceof Error ? cause.message : 'Could not complete this action.',
|
|
509
|
+
})
|
|
510
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Connection credentials are sealed by CloudConfig Connect; only token comparison lives here. */
|
|
2
|
+
export async function tokenMatches(value: string, expected: string) {
|
|
3
|
+
const digest = (text: string) => crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
|
|
4
|
+
const [valueDigest, expectedDigest] = await Promise.all([digest(value), digest(expected)])
|
|
5
|
+
const actualBytes = new Uint8Array(valueDigest)
|
|
6
|
+
const expectedBytes = new Uint8Array(expectedDigest)
|
|
7
|
+
// Compare every byte so timing does not reveal how much of the token matched.
|
|
8
|
+
let difference = 0
|
|
9
|
+
for (let index = 0; index < actualBytes.length; index++) {
|
|
10
|
+
difference |= actualBytes[index]! ^ expectedBytes[index]!
|
|
11
|
+
}
|
|
12
|
+
return difference === 0
|
|
13
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createError, getRequestWebStream, type H3Event } from 'h3'
|
|
2
|
+
export async function readCommandBody<T>(event: H3Event, limit = 100000): Promise<T> {
|
|
3
|
+
const reader = getRequestWebStream(event)?.getReader()
|
|
4
|
+
if (!reader) {
|
|
5
|
+
throw createError({ statusCode: 400, statusMessage: 'Send a JSON object as the request body.' })
|
|
6
|
+
}
|
|
7
|
+
const decoder = new TextDecoder()
|
|
8
|
+
const chunks: string[] = []
|
|
9
|
+
let size = 0
|
|
10
|
+
while (true) {
|
|
11
|
+
const { value, done } = await reader.read()
|
|
12
|
+
if (done) {
|
|
13
|
+
break
|
|
14
|
+
}
|
|
15
|
+
size += value.length
|
|
16
|
+
if (size > limit) {
|
|
17
|
+
await reader.cancel()
|
|
18
|
+
throw createError({
|
|
19
|
+
statusCode: 413,
|
|
20
|
+
statusMessage: `Send a request body up to ${Math.round(limit / 1000)} KB.`,
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
chunks.push(decoder.decode(value, { stream: true }))
|
|
24
|
+
}
|
|
25
|
+
chunks.push(decoder.decode())
|
|
26
|
+
try {
|
|
27
|
+
const value = JSON.parse(chunks.join(''))
|
|
28
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
29
|
+
return value as T
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
/* Format error below. */
|
|
33
|
+
}
|
|
34
|
+
throw createError({
|
|
35
|
+
statusCode: 400,
|
|
36
|
+
statusMessage: 'Send a valid JSON object as the request body.',
|
|
37
|
+
})
|
|
38
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
excludedModels: ['integrationConnection', 'integrationApproval'],
|
|
3
|
+
googleServices: {
|
|
4
|
+
drive: { label: 'Google Drive', scopes: ['https://www.googleapis.com/auth/drive.file'] },
|
|
5
|
+
calendar: {
|
|
6
|
+
label: 'Google Calendar',
|
|
7
|
+
scopes: [
|
|
8
|
+
'https://www.googleapis.com/auth/calendar.events',
|
|
9
|
+
'https://www.googleapis.com/auth/calendar.calendarlist.readonly',
|
|
10
|
+
],
|
|
11
|
+
},
|
|
12
|
+
gmail: { label: 'Gmail', scopes: ['https://www.googleapis.com/auth/gmail.modify'] },
|
|
13
|
+
},
|
|
14
|
+
}
|