@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.
Files changed (51) hide show
  1. package/LICENSE.md +9 -0
  2. package/README.md +175 -2
  3. package/client.ts +124 -0
  4. package/module.ts +44 -0
  5. package/package.json +52 -3
  6. package/runtime/assets/logos/discord.svg +1 -0
  7. package/runtime/assets/logos/github.svg +1 -0
  8. package/runtime/assets/logos/gmail.svg +1 -0
  9. package/runtime/assets/logos/googleads.svg +1 -0
  10. package/runtime/assets/logos/googleanalytics.svg +1 -0
  11. package/runtime/assets/logos/googlecalendar.svg +1 -0
  12. package/runtime/assets/logos/googledrive.svg +1 -0
  13. package/runtime/assets/logos/googlesearchconsole.svg +1 -0
  14. package/runtime/assets/logos/hubspot.svg +1 -0
  15. package/runtime/assets/logos/intercom.svg +1 -0
  16. package/runtime/assets/logos/linear.svg +1 -0
  17. package/runtime/assets/logos/mcp.svg +1 -0
  18. package/runtime/assets/logos/meta.svg +1 -0
  19. package/runtime/assets/logos/notion.svg +1 -0
  20. package/runtime/assets/logos/reddit.svg +1 -0
  21. package/runtime/assets/logos/sentry.svg +1 -0
  22. package/runtime/assets/logos/shopify.svg +1 -0
  23. package/runtime/assets/logos/slack.svg +1 -0
  24. package/runtime/assets/logos/stripe.svg +1 -0
  25. package/runtime/assets/logos/tiktok-dark.svg +1 -0
  26. package/runtime/assets/logos/tiktok.svg +1 -0
  27. package/runtime/assets/logos/vercel.svg +1 -0
  28. package/runtime/assets/logos/x.svg +1 -0
  29. package/runtime/components/IntegrationCard.vue +82 -0
  30. package/runtime/components/IntegrationCatalog.vue +130 -0
  31. package/runtime/components/IntegrationConnectionPanel.vue +589 -0
  32. package/runtime/components/IntegrationLogo.vue +41 -0
  33. package/runtime/pages/integrations.vue +481 -0
  34. package/runtime/server/api/integrations/approvals/[id].post.ts +69 -0
  35. package/runtime/server/api/integrations/catalog/[provider]/action.post.ts +35 -0
  36. package/runtime/server/api/integrations/catalog/[provider]/connection.post.ts +242 -0
  37. package/runtime/server/api/integrations/catalog/[provider]/tools.get.ts +40 -0
  38. package/runtime/server/api/integrations/catalog/index.get.ts +48 -0
  39. package/runtime/server/api/integrations/catalog/tools.get.ts +27 -0
  40. package/runtime/server/plugins/connect-maintenance.ts +30 -0
  41. package/runtime/server/utils/approval-continuation.ts +37 -0
  42. package/runtime/server/utils/google-mcp.ts +296 -0
  43. package/runtime/server/utils/integration-adapters.ts +836 -0
  44. package/runtime/server/utils/integration-context.ts +510 -0
  45. package/runtime/server/utils/integration-crypto.ts +13 -0
  46. package/runtime/server/utils/request-body.ts +38 -0
  47. package/runtime/shared/agent.ts +14 -0
  48. package/runtime/shared/catalog.ts +566 -0
  49. package/runtime/shared/google-mcp.ts +8 -0
  50. package/runtime/workspace.ts +15 -0
  51. package/schema.ts +179 -0
@@ -0,0 +1,242 @@
1
+ import { readCommandBody } from '../../../../utils/request-body'
2
+ import { defineEventHandler, getRequestURL, getRouterParam } from 'h3'
3
+ import { assertGoogleMutation } from '@nuxtagentic/google/runtime/server/utils/google'
4
+ import type { ConnectionSummary } from '@cloudconfig/core/connect'
5
+ import {
6
+ connectOf,
7
+ defaultMcpApprovals,
8
+ getIntegrationContext,
9
+ listBindings,
10
+ toActionError,
11
+ toPublicConnection,
12
+ type Binding,
13
+ } from '../../../../utils/integration-context'
14
+ import { verifyIntegration, type Credentials } from '../../../../utils/integration-adapters'
15
+ import { getIntegrationDefinition, type IntegrationScope } from '../../../../../shared/catalog'
16
+
17
+ type Operation = 'start' | 'bind' | 'apiKey' | 'permissions' | 'disconnect' | 'target' | 'webhook'
18
+
19
+ interface Body {
20
+ operation: Operation
21
+ scope?: IntegrationScope
22
+ /** MCP server URL for the custom MCP integration. */
23
+ server?: string
24
+ /** Values the provider needs before authorizing (Shopify store). */
25
+ fields?: Record<string, string>
26
+ credentials?: Credentials
27
+ label?: string
28
+ /** A Connect connection id (bind, target). */
29
+ connection?: number
30
+ /** An integration connection id (permissions, disconnect, webhook). */
31
+ binding?: number
32
+ target?: string
33
+ permissions?: string[]
34
+ approvals?: string[]
35
+ settings?: Record<string, string>
36
+ enabled?: boolean
37
+ rotate?: boolean
38
+ }
39
+
40
+ export default defineEventHandler(async (event) => {
41
+ assertGoogleMutation(event)
42
+ try {
43
+ const context = await getIntegrationContext(event)
44
+ const provider = getRouterParam(event, 'provider')!
45
+ const definition = getIntegrationDefinition(provider)
46
+ if (!definition) {
47
+ throw new Error('Choose a supported integration.')
48
+ }
49
+ const {
50
+ ownership,
51
+ name: integrationName,
52
+ auth,
53
+ fields: integrationFields,
54
+ actions: integrationActions,
55
+ } = definition
56
+ const { ownerId, manager, actor, api, workspace } = context
57
+ const body = await readCommandBody<Body>(event, 20000)
58
+ const {
59
+ operation,
60
+ scope: requestedScope,
61
+ settings: connectionSettings,
62
+ label: connectionLabel,
63
+ connection: connectConnectionId,
64
+ binding: bindingId,
65
+ } = body
66
+ const connect = await connectOf(context)
67
+ const request = new Request(getRequestURL(event))
68
+ const viewer = { ownerId, manager }
69
+ const scope: IntegrationScope = requestedScope ?? ownership[0]!
70
+ if (!ownership.includes(scope)) {
71
+ throw new Error(
72
+ scope === 'workspace'
73
+ ? `${integrationName} connects for one person only.`
74
+ : `${integrationName} is connected for the whole workspace by an admin.`,
75
+ )
76
+ }
77
+ const ownBinding = async (id: number | undefined): Promise<Binding> => {
78
+ const row = (await listBindings(context, { provider })).find((entry) => entry.id === id)
79
+ if (!row) {
80
+ throw new Error('Connection not found.')
81
+ }
82
+ return row
83
+ }
84
+
85
+ /** Attach a Connect connection to this integration, with every action allowed. */
86
+ const bind = async (summary: ConnectionSummary) => {
87
+ if (summary.status !== 'active') {
88
+ throw new Error('Finish setting up this connection first.')
89
+ }
90
+ const existing = (await listBindings(context, { provider })).find(
91
+ (row) => row.connectionId === summary.id,
92
+ )
93
+ if (existing) {
94
+ return toPublicConnection(existing, viewer)
95
+ }
96
+ let permissions = integrationActions.map((action) => action.id)
97
+ let approvals: string[] = []
98
+ if (auth.mcp) {
99
+ const tools = await connect.mcp(actor, summary.id).listTools()
100
+ permissions = tools.map((tool) => tool.name)
101
+ approvals = defaultMcpApprovals(tools)
102
+ }
103
+ const row = await api.integrationConnection.create({
104
+ provider,
105
+ workspace,
106
+ scope: summary.scope,
107
+ ...(summary.organizationId !== null ? { organizationId: summary.organizationId } : {}),
108
+ connectionId: summary.id,
109
+ connectionUid: summary.uid,
110
+ account: summary.label || summary.account.name,
111
+ enabled: true,
112
+ permissions,
113
+ approvals,
114
+ settings: connectionSettings ?? {},
115
+ ownerId,
116
+ })
117
+ return toPublicConnection(row, viewer)
118
+ }
119
+
120
+ switch (operation) {
121
+ case 'start': {
122
+ const { fields } = body
123
+ const returnTo = `/dashboard/integrations?integration=${encodeURIComponent(provider)}`
124
+ if (auth.mcp) {
125
+ const server = auth.mcp.server ?? body.server ?? ''
126
+ return connect.start(actor, request, {
127
+ provider: 'mcp',
128
+ scope,
129
+ returnTo,
130
+ fields: { server },
131
+ ...(connectionLabel ? { label: connectionLabel } : {}),
132
+ })
133
+ }
134
+ if (!auth.oauth) {
135
+ throw new Error(`${integrationName} connects with an API token.`)
136
+ }
137
+ return connect.start(actor, request, {
138
+ provider: auth.oauth.provider,
139
+ scope,
140
+ returnTo,
141
+ ...(auth.oauth.scopes ? { scopes: auth.oauth.scopes } : {}),
142
+ ...(fields ? { fields } : {}),
143
+ ...(connectionLabel ? { label: connectionLabel } : {}),
144
+ ...(connectConnectionId ? { connection: connectConnectionId } : {}),
145
+ })
146
+ }
147
+ case 'bind': {
148
+ const summary = await connect.get(actor, Number(connectConnectionId))
149
+ const expected = auth.mcp ? 'mcp' : auth.oauth?.provider
150
+ if (summary.provider !== expected) {
151
+ throw new Error(`That account is not a ${integrationName} connection.`)
152
+ }
153
+ const fixed = auth.mcp?.server
154
+ if (fixed && summary.server && new URL(summary.server).origin !== new URL(fixed).origin) {
155
+ throw new Error(`That MCP server is not ${integrationName}.`)
156
+ }
157
+ return bind(summary)
158
+ }
159
+ case 'target': {
160
+ const summary = await connect.selectTarget(
161
+ actor,
162
+ Number(connectConnectionId),
163
+ String(body.target ?? ''),
164
+ )
165
+ return bind(summary)
166
+ }
167
+ case 'apiKey': {
168
+ if (!auth.apiKey) {
169
+ throw new Error(
170
+ `${integrationName} connects with ${auth.mcp ? 'its MCP server' : 'OAuth'}.`,
171
+ )
172
+ }
173
+ const credentials = body.credentials ?? {}
174
+ if (
175
+ JSON.stringify(credentials).length > 16000 ||
176
+ Object.values(credentials).some((value) => typeof value !== 'string')
177
+ ) {
178
+ throw new Error('Provide valid connection credentials.')
179
+ }
180
+ if (!credentials.token) {
181
+ throw new Error('Enter the API access token for this connection.')
182
+ }
183
+ for (const field of integrationFields) {
184
+ if (!credentials[field.key]) {
185
+ throw new Error(`Enter ${field.label.toLowerCase()}.`)
186
+ }
187
+ }
188
+ // Prove the token works before storing it.
189
+ const account = await verifyIntegration(provider, credentials)
190
+ const summary = await connect.saveApiKey(actor, {
191
+ provider: `key-${provider}`,
192
+ scope,
193
+ fields: credentials,
194
+ account: { id: account, name: account },
195
+ ...(connectionLabel ? { label: connectionLabel } : {}),
196
+ })
197
+ return bind(summary)
198
+ }
199
+ case 'permissions': {
200
+ const { permissions, approvals, enabled } = body
201
+ const row = await ownBinding(bindingId)
202
+ return toPublicConnection(
203
+ await api.integrationConnection.update(row.id, {
204
+ ownerId: row.ownerId,
205
+ ...(permissions ? { permissions } : {}),
206
+ ...(approvals ? { approvals } : {}),
207
+ ...(connectionSettings ? { settings: connectionSettings } : {}),
208
+ ...(enabled !== undefined ? { enabled } : {}),
209
+ ...(connectionLabel !== undefined ? { label: connectionLabel.slice(0, 120) } : {}),
210
+ }),
211
+ viewer,
212
+ )
213
+ }
214
+ case 'disconnect': {
215
+ const row = await ownBinding(bindingId)
216
+ await api.integrationConnection.delete(row.id)
217
+ // Revoke the account at the vendor once no integration in this workspace uses it.
218
+ const stillUsed = (await listBindings(context)).some(
219
+ (entry) => entry.connectionId === row.connectionId,
220
+ )
221
+ if (row.connectionId && !stillUsed) {
222
+ await connect.remove(actor, row.connectionId).catch(() => {})
223
+ }
224
+ return { removed: row.id }
225
+ }
226
+ case 'webhook': {
227
+ const row = await ownBinding(bindingId)
228
+ const secret = body.rotate
229
+ ? await connect.rotateWebhookSecret(actor, row.connectionId)
230
+ : await connect.webhookSecret(actor, row.connectionId)
231
+ return {
232
+ url: `${getRequestURL(event).origin}/api/integration-hooks/${row.connectionUid}`,
233
+ secret,
234
+ }
235
+ }
236
+ default:
237
+ throw new Error('Choose a connection operation.')
238
+ }
239
+ } catch (cause) {
240
+ throw toActionError(cause)
241
+ }
242
+ })
@@ -0,0 +1,40 @@
1
+ import { defineEventHandler, getQuery, getRouterParam } from 'h3'
2
+ import {
3
+ connectOf,
4
+ getIntegrationContext,
5
+ isMcpIntegration,
6
+ listBindings,
7
+ toActionError,
8
+ } from '../../../../utils/integration-context'
9
+ import { isMcpWrite } from '../../../../../shared/catalog'
10
+
11
+ /** Tools an MCP connection's server offers, for choosing what agents may use. */
12
+ export default defineEventHandler(async (event) => {
13
+ try {
14
+ const context = await getIntegrationContext(event)
15
+ const provider = getRouterParam(event, 'provider')!
16
+ if (!isMcpIntegration(provider)) {
17
+ throw new Error('Only MCP servers list their tools.')
18
+ }
19
+ const binding = (await listBindings(context, { provider })).find(
20
+ (row) => row.id === Number(getQuery(event).binding),
21
+ )
22
+ if (!binding) {
23
+ throw new Error('Connection not found.')
24
+ }
25
+ const tools = await (
26
+ await connectOf(context)
27
+ )
28
+ .mcp(context.actor, binding.connectionId)
29
+ .listTools()
30
+ return tools.map((tool) => ({
31
+ name: tool.name,
32
+ title: tool.title ?? tool.annotations?.title ?? tool.name,
33
+ description: tool.description ?? '',
34
+ write: isMcpWrite(tool.annotations),
35
+ destructive: tool.annotations?.destructiveHint === true,
36
+ }))
37
+ } catch (cause) {
38
+ throw toActionError(cause)
39
+ }
40
+ })
@@ -0,0 +1,48 @@
1
+ import { defineEventHandler } from 'h3'
2
+ import type { ConnectionSummary } from '@cloudconfig/core/connect'
3
+ import {
4
+ connectOf,
5
+ getIntegrationContext,
6
+ listBindings,
7
+ toPublicConnection,
8
+ } from '../../../utils/integration-context'
9
+ import { INTEGRATION_CATALOG } from '../../../../shared/catalog'
10
+
11
+ export default defineEventHandler(async (event) => {
12
+ const context = await getIntegrationContext(event)
13
+ const connect = await connectOf(context)
14
+ const bindings = await listBindings(context)
15
+ // Connection health (expired, pending a choice) lives in Connect; sealed tokens never leave it.
16
+ let accounts: ConnectionSummary[] = []
17
+ try {
18
+ accounts = await connect.list(context.actor)
19
+ } catch {
20
+ // Without a sealing key there is nothing Connect can report yet.
21
+ }
22
+ const byId = new Map(accounts.map((account) => [account.id, account]))
23
+ return {
24
+ catalog: INTEGRATION_CATALOG,
25
+ connections: bindings.map((row) => {
26
+ const account = byId.get(row.connectionId)
27
+ return {
28
+ ...toPublicConnection(row, context),
29
+ status: account?.status ?? (row.connectionId ? 'error' : 'active'),
30
+ statusMessage: account?.statusMessage ?? '',
31
+ }
32
+ }),
33
+ /** Connect connections of this workspace that no integration uses yet, e.g. awaiting a choice. */
34
+ pending: accounts.filter((account) => account.status === 'pending'),
35
+ providers: connect
36
+ .providers()
37
+ .map(({ id, label, configured, missing, allow, startFields }) => ({
38
+ id,
39
+ label,
40
+ configured,
41
+ missing,
42
+ allow,
43
+ startFields,
44
+ })),
45
+ manager: context.manager,
46
+ organization: context.actor.organizationId !== null,
47
+ }
48
+ })
@@ -0,0 +1,27 @@
1
+ import { defineEventHandler } from 'h3'
2
+ import {
3
+ getIntegrationContext,
4
+ listGrantedTools,
5
+ toActionError,
6
+ } from '../../../utils/integration-context'
7
+
8
+ /** The tools a saved agent may be given here: connected, enabled and permitted. */
9
+ export default defineEventHandler(async (event) => {
10
+ try {
11
+ const context = await getIntegrationContext(event)
12
+ return (await listGrantedTools(context)).map(
13
+ ({ provider, action, name, description, parameters, approvalRequired, account, scope }) => ({
14
+ provider,
15
+ action,
16
+ name,
17
+ description,
18
+ parameters,
19
+ approvalRequired,
20
+ account,
21
+ scope,
22
+ }),
23
+ )
24
+ } catch (cause) {
25
+ throw toActionError(cause)
26
+ }
27
+ })
@@ -0,0 +1,30 @@
1
+ import { defineNitroPlugin, useRuntimeConfig } from 'nitropack/runtime'
2
+ import { connectOf, getDatabaseProvider } from '../utils/integration-context'
3
+
4
+ /**
5
+ * On the Worker's cron trigger: refresh tokens before they lapse, re-seal after
6
+ * a key rotation, and revoke connections whose owner is gone.
7
+ */
8
+ export default defineNitroPlugin((nitro) => {
9
+ nitro.hooks.hook('cloudflare:scheduled', async ({ env }) => {
10
+ const bindings = env as Record<string, unknown>
11
+ try {
12
+ const settings = useRuntimeConfig().cloudconfig as { d1Binding?: string; localDb?: string }
13
+ const database = await getDatabaseProvider(bindings, settings)
14
+ const connect = await connectOf({
15
+ database,
16
+ env: bindings,
17
+ request: new Request('https://maintenance.internal'),
18
+ })
19
+ const report = await connect.maintain()
20
+ if (Object.values(report).some(Boolean)) {
21
+ console.log('[integrations] Connection maintenance', JSON.stringify(report))
22
+ }
23
+ } catch (cause) {
24
+ console.error(
25
+ '[integrations] Connection maintenance failed:',
26
+ cause instanceof Error ? cause.message : cause,
27
+ )
28
+ }
29
+ })
30
+ })
@@ -0,0 +1,37 @@
1
+ import { getAgentPlatform, type AgentScope } from '@cloudconfig/core/agents/platform'
2
+ import type { H3Event } from 'h3'
3
+
4
+ /** The continuation is server-only and immutable, and callers already checked the owner and workspace. */
5
+ export async function notifyApproval(
6
+ event: H3Event,
7
+ id: number,
8
+ continuation: unknown,
9
+ env?: Record<string, unknown>,
10
+ ) {
11
+ if (!continuation || typeof continuation !== 'object') {
12
+ return
13
+ }
14
+ const { workflowId, automationRunId, agentScope, agentRunId } = continuation as {
15
+ workflowId?: string
16
+ automationRunId?: number
17
+ agentScope?: AgentScope
18
+ agentRunId?: string
19
+ }
20
+ if (workflowId && env?.AUTOMATION_RUNNER) {
21
+ const binding = env.AUTOMATION_RUNNER as {
22
+ get(
23
+ id: string,
24
+ ): Promise<{ sendEvent(event: { type: string; payload: unknown }): Promise<void> }>
25
+ }
26
+ await (
27
+ await binding.get(workflowId)
28
+ ).sendEvent({ type: `integration-approval-${id}`, payload: { approvalId: id } })
29
+ } else if (automationRunId && !env) {
30
+ await event.$fetch(`/api/automations/runs/${automationRunId}/resume`, { method: 'POST' })
31
+ }
32
+ // Queues the saved agent run on whichever platform holds it.
33
+ const agents = getAgentPlatform(env)
34
+ if (agentScope && agentRunId && agents) {
35
+ await (await agents.runs(agentScope)).resume(agentRunId)
36
+ }
37
+ }