@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,836 @@
|
|
|
1
|
+
import { getIntegrationDefinition, getIntegrationToolName } from '../../shared/catalog'
|
|
2
|
+
|
|
3
|
+
type Input = Record<string, unknown>
|
|
4
|
+
export type Credentials = Record<string, string>
|
|
5
|
+
type Endpoint = {
|
|
6
|
+
method?: string
|
|
7
|
+
path: string
|
|
8
|
+
form?: boolean
|
|
9
|
+
graphql?: string
|
|
10
|
+
approval?: boolean
|
|
11
|
+
}
|
|
12
|
+
export type IntegrationAction = Endpoint & {
|
|
13
|
+
id: string
|
|
14
|
+
name: string
|
|
15
|
+
label: string
|
|
16
|
+
parameters: Record<string, unknown>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Paths and methods are owned by adapters. Agents never supply an API URL or headers.
|
|
20
|
+
const endpoints: Record<string, Endpoint[]> = {
|
|
21
|
+
github: [
|
|
22
|
+
{ path: '/user/repos' },
|
|
23
|
+
{ method: 'POST', path: '/repos/{owner}/{repo}/issues/{number}/comments' },
|
|
24
|
+
{ method: 'POST', path: '/repos/{owner}/{repo}/issues' },
|
|
25
|
+
],
|
|
26
|
+
ga: [
|
|
27
|
+
{ method: 'POST', path: '/v1beta/properties/{propertyId}:runReport' },
|
|
28
|
+
{ method: 'POST', path: '/v1beta/properties/{propertyId}:runReport' },
|
|
29
|
+
{ method: 'POST', path: '/v1alpha/properties/{propertyId}/audiences' },
|
|
30
|
+
],
|
|
31
|
+
gsc: [
|
|
32
|
+
{ method: 'POST', path: '/webmasters/v3/sites/{siteUrl}/searchAnalytics/query' },
|
|
33
|
+
{ method: 'POST', path: '/v1/urlInspection/index:inspect' },
|
|
34
|
+
{ method: 'POST', path: '/v3/urlNotifications:publish' },
|
|
35
|
+
],
|
|
36
|
+
gads: [
|
|
37
|
+
{ method: 'POST', path: '/v25/customers/{customerId}/googleAds:search' },
|
|
38
|
+
{ method: 'POST', path: '/v25/customers/{customerId}/campaignBudgets:mutate' },
|
|
39
|
+
{ method: 'POST', path: '/v25/customers/{customerId}/adGroupCriteria:mutate' },
|
|
40
|
+
],
|
|
41
|
+
meta: [
|
|
42
|
+
{ path: '/{accountId}/insights' },
|
|
43
|
+
{ method: 'POST', path: '/{accountId}/campaigns' },
|
|
44
|
+
{ method: 'POST', path: '/{campaignId}' },
|
|
45
|
+
{ method: 'POST', path: '/{adId}' },
|
|
46
|
+
],
|
|
47
|
+
x: [
|
|
48
|
+
{ path: '/12/stats/accounts/{accountId}' },
|
|
49
|
+
{ method: 'POST', path: '/12/accounts/{accountId}/promoted_tweets', form: true },
|
|
50
|
+
{ method: 'PUT', path: '/12/accounts/{accountId}/line_items/{lineItemId}', form: true },
|
|
51
|
+
],
|
|
52
|
+
tiktok: [
|
|
53
|
+
{ path: '/open_api/v1.3/report/integrated/get/' },
|
|
54
|
+
{ method: 'POST', path: '/open_api/v1.3/campaign/update/' },
|
|
55
|
+
{ method: 'POST', path: '/open_api/v1.3/adgroup/status/update/' },
|
|
56
|
+
],
|
|
57
|
+
reddit: [
|
|
58
|
+
{ method: 'POST', path: '/api/v3/ad_accounts/{accountId}/reports' },
|
|
59
|
+
{ method: 'PATCH', path: '/api/v3/campaigns/{campaignId}' },
|
|
60
|
+
],
|
|
61
|
+
stripe: [
|
|
62
|
+
{ path: '/v1/payment_intents' },
|
|
63
|
+
{ method: 'POST', path: '/v1/invoices', form: true },
|
|
64
|
+
{ method: 'POST', path: '/v1/refunds', form: true, approval: true },
|
|
65
|
+
],
|
|
66
|
+
shopify: [
|
|
67
|
+
{
|
|
68
|
+
path: '',
|
|
69
|
+
graphql:
|
|
70
|
+
'query Orders($first: Int!, $after: String) { orders(first: $first, after: $after) { nodes { id name createdAt displayFinancialStatus totalPriceSet { shopMoney { amount currencyCode } } } pageInfo { hasNextPage endCursor } } }',
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
path: '',
|
|
74
|
+
graphql:
|
|
75
|
+
'mutation UpdateProduct($product: ProductUpdateInput!) { productUpdate(product: $product) { product { id title } userErrors { field message } } }',
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
path: '',
|
|
79
|
+
graphql:
|
|
80
|
+
'mutation TagCustomer($id: ID!, $tags: [String!]!) { tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } } }',
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
hubspot: [
|
|
84
|
+
{ path: '/crm/v3/objects/contacts' },
|
|
85
|
+
{ method: 'PATCH', path: '/crm/v3/objects/deals/{dealId}' },
|
|
86
|
+
{ method: 'POST', path: '/crm/v3/objects/notes' },
|
|
87
|
+
],
|
|
88
|
+
linear: [
|
|
89
|
+
{
|
|
90
|
+
path: '',
|
|
91
|
+
graphql:
|
|
92
|
+
'query Issues($first: Int) { issues(first: $first) { nodes { id identifier title url state { name } } } }',
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
path: '',
|
|
96
|
+
graphql:
|
|
97
|
+
'mutation CreateIssue($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { id identifier title url } } }',
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
path: '',
|
|
101
|
+
graphql:
|
|
102
|
+
'mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success issue { id identifier title url } } }',
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
vercel: [
|
|
106
|
+
{ path: '/v6/deployments' },
|
|
107
|
+
{ path: '/v3/deployments/{deploymentId}/events' },
|
|
108
|
+
{ method: 'POST', path: '/v10/projects/{projectId}/promote/{deploymentId}' },
|
|
109
|
+
],
|
|
110
|
+
gmail: [
|
|
111
|
+
{ path: '/gmail/v1/users/me/messages' },
|
|
112
|
+
{ method: 'POST', path: '/gmail/v1/users/me/drafts' },
|
|
113
|
+
{ method: 'POST', path: '/gmail/v1/users/me/messages/send' },
|
|
114
|
+
],
|
|
115
|
+
discord: [
|
|
116
|
+
{ path: '/guilds/{guildId}/channels' },
|
|
117
|
+
{ method: 'POST', path: '/channels/{channelId}/messages' },
|
|
118
|
+
],
|
|
119
|
+
intercom: [
|
|
120
|
+
{ path: '/conversations' },
|
|
121
|
+
{ method: 'POST', path: '/conversations/{conversationId}/reply', approval: true },
|
|
122
|
+
{ method: 'POST', path: '/conversations/{conversationId}/tags' },
|
|
123
|
+
],
|
|
124
|
+
notion: [
|
|
125
|
+
{ path: '/v1/pages/{pageId}' },
|
|
126
|
+
{ method: 'POST', path: '/v1/pages' },
|
|
127
|
+
{ method: 'PATCH', path: '/v1/databases/{databaseId}' },
|
|
128
|
+
],
|
|
129
|
+
gcal: [
|
|
130
|
+
{ path: '/calendar/v3/calendars/{calendarId}/events' },
|
|
131
|
+
{ method: 'POST', path: '/calendar/v3/calendars/{calendarId}/events' },
|
|
132
|
+
],
|
|
133
|
+
gdrive: [
|
|
134
|
+
{ path: '/drive/v3/files' },
|
|
135
|
+
{ method: 'POST', path: '/drive/v3/files' },
|
|
136
|
+
{ method: 'PATCH', path: '/drive/v3/files/{fileId}' },
|
|
137
|
+
],
|
|
138
|
+
slack: [{ path: '/conversations.list' }, { method: 'POST', path: '/chat.postMessage' }],
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const bases: Record<string, string> = {
|
|
142
|
+
github: 'https://api.github.com',
|
|
143
|
+
ga: 'https://analyticsdata.googleapis.com',
|
|
144
|
+
gsc: 'https://www.googleapis.com',
|
|
145
|
+
gads: 'https://googleads.googleapis.com',
|
|
146
|
+
meta: 'https://graph.facebook.com',
|
|
147
|
+
x: 'https://ads-api.x.com',
|
|
148
|
+
tiktok: 'https://business-api.tiktok.com',
|
|
149
|
+
reddit: 'https://ads-api.reddit.com',
|
|
150
|
+
stripe: 'https://api.stripe.com',
|
|
151
|
+
hubspot: 'https://api.hubapi.com',
|
|
152
|
+
linear: 'https://api.linear.app/graphql',
|
|
153
|
+
vercel: 'https://api.vercel.com',
|
|
154
|
+
gmail: 'https://gmail.googleapis.com',
|
|
155
|
+
discord: 'https://discord.com/api/v10',
|
|
156
|
+
intercom: 'https://api.intercom.io',
|
|
157
|
+
notion: 'https://api.notion.com',
|
|
158
|
+
gcal: 'https://www.googleapis.com',
|
|
159
|
+
gdrive: 'https://www.googleapis.com',
|
|
160
|
+
slack: 'https://slack.com/api',
|
|
161
|
+
}
|
|
162
|
+
const bodyHints: Record<string, string> = {
|
|
163
|
+
ga_run_reports:
|
|
164
|
+
'dateRanges: [{startDate, endDate}], metrics: [{name}], optional dimensions: [{name}].',
|
|
165
|
+
ga_compare_date_ranges:
|
|
166
|
+
'Two dateRanges: [{startDate, endDate}, {startDate, endDate}], metrics: [{name}], optional dimensions: [{name}].',
|
|
167
|
+
ga_create_audiences:
|
|
168
|
+
'displayName, description, membershipDurationDays, filterClauses with audience filters.',
|
|
169
|
+
gsc_read_search_performance:
|
|
170
|
+
'startDate and endDate (YYYY-MM-DD), optional dimensions and rowLimit.',
|
|
171
|
+
gsc_inspect_urls: 'inspectionUrl and siteUrl.',
|
|
172
|
+
gsc_request_indexing:
|
|
173
|
+
'url and type: URL_UPDATED or URL_DELETED. Google restricts eligible indexing content.',
|
|
174
|
+
gads_read_campaign_stats: 'query: a Google Ads Query Language SELECT query.',
|
|
175
|
+
gads_adjust_bids_and_budgets:
|
|
176
|
+
'operations: [{update:{resourceName, amountMicros}}] for a budget, or operations: [{update:{resourceName, cpcBidMicros}}] with resource: bid.',
|
|
177
|
+
gads_pause_keywords: 'operations: [{update:{resourceName}}]. The server enforces PAUSED status.',
|
|
178
|
+
meta_create_campaigns:
|
|
179
|
+
'name, objective, special_ad_categories and optional status. Use PAUSED to create a draft campaign.',
|
|
180
|
+
meta_adjust_budgets: 'daily_budget or lifetime_budget in the account currency’s minor units.',
|
|
181
|
+
meta_pause_ads: 'An empty object. The server enforces PAUSED status.',
|
|
182
|
+
x_create_promoted_posts: 'line_item_id and tweet_ids.',
|
|
183
|
+
x_adjust_bids: 'bid_amount_local_micro and optional bid_type.',
|
|
184
|
+
tiktok_adjust_budgets: 'campaign_id and budget.',
|
|
185
|
+
tiktok_pause_ad_groups: 'adgroup_ids. The server enforces DISABLE status.',
|
|
186
|
+
reddit_read_campaign_stats: 'data: {starts_at, ends_at, fields, breakdowns}.',
|
|
187
|
+
reddit_adjust_budgets:
|
|
188
|
+
'data: {goal_value, spend_cap}, in microcurrency. goal_value requires a campaign with budget optimization enabled.',
|
|
189
|
+
stripe_create_invoices:
|
|
190
|
+
'customer and optional collection_method, days_until_due, description. Returns a draft invoice.',
|
|
191
|
+
stripe_issue_refunds:
|
|
192
|
+
'payment_intent or charge, optional amount and reason. Requires owner approval before execution.',
|
|
193
|
+
hubspot_update_deal_stages: 'properties: {dealstage}.',
|
|
194
|
+
hubspot_log_activity: 'properties: {hs_timestamp, hs_note_body}, optional associations.',
|
|
195
|
+
discord_post_messages:
|
|
196
|
+
'content, optional allowed_mentions. The bot needs permission to send in the selected channel.',
|
|
197
|
+
intercom_draft_replies:
|
|
198
|
+
'body: the reply text, admin_id: the sender ID. Creates an approval draft; approval sends the reply.',
|
|
199
|
+
intercom_tag_conversations: 'id: tag ID, admin_id: sender ID.',
|
|
200
|
+
gcal_create_events:
|
|
201
|
+
'summary, start: {dateTime, timeZone}, end: {dateTime, timeZone}, optional attendees.',
|
|
202
|
+
gdrive_create_files: 'name, mimeType and optional parents. Creates a file or folder.',
|
|
203
|
+
gdrive_move_files:
|
|
204
|
+
'An empty object. Supply query.addParents and query.removeParents with folder IDs.',
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const providerBodyHints: Record<string, string> = {
|
|
208
|
+
gmail: 'Email fields: to, subject, text, optional threadId.',
|
|
209
|
+
slack: 'channel and text.',
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const verificationPaths: Record<string, string> = {
|
|
213
|
+
github: '/user',
|
|
214
|
+
ga: '/v1beta/accountSummaries',
|
|
215
|
+
gsc: '/webmasters/v3/sites',
|
|
216
|
+
gads: '/v25/customers:listAccessibleCustomers',
|
|
217
|
+
meta: '/me',
|
|
218
|
+
x: '/12/accounts',
|
|
219
|
+
tiktok: '/open_api/v1.3/advertiser/info/',
|
|
220
|
+
reddit: '/api/v3/me',
|
|
221
|
+
stripe: '/v1/account',
|
|
222
|
+
hubspot: '/account-info/v3/details',
|
|
223
|
+
vercel: '/v2/user',
|
|
224
|
+
gmail: '/gmail/v1/users/me/profile',
|
|
225
|
+
discord: '/users/@me',
|
|
226
|
+
intercom: '/me',
|
|
227
|
+
notion: '/v1/users/me',
|
|
228
|
+
gcal: '/calendar/v3/users/me/calendarList',
|
|
229
|
+
gdrive: '/drive/v3/about',
|
|
230
|
+
slack: '/auth.test',
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const verificationQueries: Record<string, string> = {
|
|
234
|
+
linear: 'query { viewer { id name email } }',
|
|
235
|
+
shopify: 'query { shop { name myshopifyDomain } }',
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Builds the agent-facing tool definitions for every action an integration exposes. */
|
|
239
|
+
export function listIntegrationActions(provider: string): IntegrationAction[] {
|
|
240
|
+
const definition = getIntegrationDefinition(provider)
|
|
241
|
+
return (endpoints[provider] ?? []).map((endpoint, index) => {
|
|
242
|
+
const action = definition!.actions[index]!
|
|
243
|
+
const toolName = getIntegrationToolName(provider, action.id)
|
|
244
|
+
const pathFields = [...endpoint.path.matchAll(/\{(\w+)\}/g)].map((match) => match[1]!)
|
|
245
|
+
const properties: Record<string, unknown> = Object.fromEntries(
|
|
246
|
+
pathFields.map((field) => [
|
|
247
|
+
field,
|
|
248
|
+
{ type: 'string', description: `The provider's ${field}.` },
|
|
249
|
+
]),
|
|
250
|
+
)
|
|
251
|
+
// Google Ads falls back to the connected customer and calendars default to "primary".
|
|
252
|
+
const required = pathFields.filter(
|
|
253
|
+
(field) => !(provider === 'gads' && field === 'customerId') && field !== 'calendarId',
|
|
254
|
+
)
|
|
255
|
+
if (endpoint.graphql) {
|
|
256
|
+
properties.body = {
|
|
257
|
+
type: 'object',
|
|
258
|
+
description:
|
|
259
|
+
'GraphQL variables. Shopify Orders: first, after; UpdateProduct: product; TagCustomer: id, tags. Linear: first, input, or id and input.',
|
|
260
|
+
}
|
|
261
|
+
required.push('body')
|
|
262
|
+
} else if (endpoint.method && endpoint.method !== 'GET') {
|
|
263
|
+
properties.body = {
|
|
264
|
+
type: 'object',
|
|
265
|
+
description:
|
|
266
|
+
bodyHints[toolName] ??
|
|
267
|
+
providerBodyHints[provider] ??
|
|
268
|
+
`JSON fields accepted by ${definition!.name} for ${action.label}.`,
|
|
269
|
+
}
|
|
270
|
+
required.push('body')
|
|
271
|
+
}
|
|
272
|
+
properties.query = {
|
|
273
|
+
type: 'object',
|
|
274
|
+
description: 'Optional provider query parameters, including filters and pagination.',
|
|
275
|
+
}
|
|
276
|
+
if (provider === 'gmail' && index === 0) {
|
|
277
|
+
properties.messageId = {
|
|
278
|
+
type: 'string',
|
|
279
|
+
description:
|
|
280
|
+
'Read a specific message with full headers and content. Omit to list messages.',
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (provider === 'gads' && index === 1) {
|
|
284
|
+
properties.resource = {
|
|
285
|
+
type: 'string',
|
|
286
|
+
enum: ['budget', 'bid'],
|
|
287
|
+
description: 'Choose campaign budget (default) or ad-group CPC bid updates.',
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
...endpoint,
|
|
292
|
+
...action,
|
|
293
|
+
name: toolName,
|
|
294
|
+
parameters: { type: 'object', properties, required, additionalProperties: false },
|
|
295
|
+
}
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Calls a cheap read-only endpoint to prove the credentials work.
|
|
301
|
+
*
|
|
302
|
+
* @returns The account name to show on the connection card.
|
|
303
|
+
*/
|
|
304
|
+
export async function verifyIntegration(provider: string, credentials: Credentials) {
|
|
305
|
+
const graphql = verificationQueries[provider]
|
|
306
|
+
const response = graphql
|
|
307
|
+
? await sendProviderRequest({
|
|
308
|
+
provider,
|
|
309
|
+
credentials,
|
|
310
|
+
endpoint: { graphql, path: '' },
|
|
311
|
+
input: { body: {} },
|
|
312
|
+
})
|
|
313
|
+
: await sendProviderRequest({
|
|
314
|
+
provider,
|
|
315
|
+
credentials,
|
|
316
|
+
endpoint: { path: verificationPaths[provider] ?? '/', method: 'GET' },
|
|
317
|
+
input: { query: getVerificationQuery(provider, credentials) },
|
|
318
|
+
})
|
|
319
|
+
const value = toRecord(response)
|
|
320
|
+
const data = toRecord(value.data)
|
|
321
|
+
return String(
|
|
322
|
+
value.login ??
|
|
323
|
+
value.emailAddress ??
|
|
324
|
+
value.name ??
|
|
325
|
+
value.account_name ??
|
|
326
|
+
toRecord(value.user).name ??
|
|
327
|
+
toRecord(data.shop).name ??
|
|
328
|
+
toRecord(data.viewer).name ??
|
|
329
|
+
toRecord(data.user).display_name ??
|
|
330
|
+
value.team ??
|
|
331
|
+
getIntegrationDefinition(provider)!.name,
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function getVerificationQuery(provider: string, credentials: Credentials): Input {
|
|
336
|
+
if (provider === 'gdrive') {
|
|
337
|
+
return { fields: 'user' }
|
|
338
|
+
}
|
|
339
|
+
if (provider === 'tiktok') {
|
|
340
|
+
return { advertiser_ids: [credentials.advertiserId] }
|
|
341
|
+
}
|
|
342
|
+
return {}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export interface ProviderRequestOptions {
|
|
346
|
+
signal?: AbortSignal
|
|
347
|
+
/** Lets the provider deduplicate retried writes. Only Stripe honours it today. */
|
|
348
|
+
idempotencyKey?: string
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Runs one catalog action against the provider API with the stored credentials. */
|
|
352
|
+
export async function executeIntegrationAction(
|
|
353
|
+
provider: string,
|
|
354
|
+
actionId: string,
|
|
355
|
+
{ credentials, input }: { credentials: Credentials; input: Input },
|
|
356
|
+
options: ProviderRequestOptions = {},
|
|
357
|
+
) {
|
|
358
|
+
const action = listIntegrationActions(provider).find((entry) => entry.id === actionId)
|
|
359
|
+
if (!action) {
|
|
360
|
+
throw new Error('Choose an action supported by this integration.')
|
|
361
|
+
}
|
|
362
|
+
const query = getActionQuery(provider, action, toRecord(input.query))
|
|
363
|
+
const parameters = {
|
|
364
|
+
...input,
|
|
365
|
+
body: getActionBody(provider, actionId, toRecord(input.body)),
|
|
366
|
+
query:
|
|
367
|
+
provider === 'vercel' && actionId === 'read_build_logs' ? { ...query, follow: 0 } : query,
|
|
368
|
+
}
|
|
369
|
+
const endpoint = getActionEndpoint(provider, action, input, credentials)
|
|
370
|
+
const result = await sendProviderRequest(
|
|
371
|
+
{ provider, credentials, endpoint, input: parameters },
|
|
372
|
+
options,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
// These read actions combine two listings into a single tool result.
|
|
376
|
+
if (provider === 'stripe' && actionId === 'read_payments_and_customers') {
|
|
377
|
+
const customers = await sendProviderRequest(
|
|
378
|
+
{ provider, credentials, endpoint: { path: '/v1/customers' }, input },
|
|
379
|
+
{ signal: options.signal },
|
|
380
|
+
)
|
|
381
|
+
return { payments: result, customers }
|
|
382
|
+
}
|
|
383
|
+
if (provider === 'hubspot' && actionId === 'read_contacts_and_deals') {
|
|
384
|
+
const deals = await sendProviderRequest(
|
|
385
|
+
{ provider, credentials, endpoint: { path: '/crm/v3/objects/deals' }, input },
|
|
386
|
+
{ signal: options.signal },
|
|
387
|
+
)
|
|
388
|
+
return { contacts: result, deals }
|
|
389
|
+
}
|
|
390
|
+
return result
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Write actions only forward the query parameters their provider documents. */
|
|
394
|
+
function getActionQuery(provider: string, action: IntegrationAction, query: Input) {
|
|
395
|
+
const isWrite = (action.method && action.method !== 'GET') || action.graphql
|
|
396
|
+
if (!isWrite) {
|
|
397
|
+
return query
|
|
398
|
+
}
|
|
399
|
+
const allowedKeys =
|
|
400
|
+
provider === 'gdrive' && action.id === 'move_files'
|
|
401
|
+
? ['addParents', 'removeParents', 'fields', 'supportsAllDrives']
|
|
402
|
+
: ['teamId']
|
|
403
|
+
return pick(query, allowedKeys)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function getActionEndpoint(
|
|
407
|
+
provider: string,
|
|
408
|
+
action: IntegrationAction,
|
|
409
|
+
input: Input,
|
|
410
|
+
credentials: Credentials,
|
|
411
|
+
): Endpoint {
|
|
412
|
+
// Installation tokens list the installation's repositories, not a user's.
|
|
413
|
+
if (
|
|
414
|
+
provider === 'github' &&
|
|
415
|
+
action.id === 'read_repositories' &&
|
|
416
|
+
credentials.githubInstallation
|
|
417
|
+
) {
|
|
418
|
+
return { path: '/installation/repositories' }
|
|
419
|
+
}
|
|
420
|
+
if (provider === 'gmail' && action.id === 'read_email' && input.messageId) {
|
|
421
|
+
return { path: '/gmail/v1/users/me/messages/{messageId}' }
|
|
422
|
+
}
|
|
423
|
+
if (provider === 'gads' && action.id === 'adjust_bids_and_budgets' && input.resource === 'bid') {
|
|
424
|
+
return { ...action, path: '/v25/customers/{customerId}/adGroups:mutate' }
|
|
425
|
+
}
|
|
426
|
+
return action
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
interface ProviderRequest {
|
|
430
|
+
provider: string
|
|
431
|
+
credentials: Credentials
|
|
432
|
+
endpoint: Endpoint
|
|
433
|
+
input: Input
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function sendProviderRequest(
|
|
437
|
+
{ provider, credentials, endpoint, input }: ProviderRequest,
|
|
438
|
+
{ signal, idempotencyKey }: ProviderRequestOptions = {},
|
|
439
|
+
) {
|
|
440
|
+
const url = buildRequestUrl(provider, credentials, endpoint, input)
|
|
441
|
+
const method = endpoint.graphql ? 'POST' : (endpoint.method ?? 'GET')
|
|
442
|
+
const body = buildRequestBody(provider, credentials, endpoint, input)
|
|
443
|
+
const headers = buildRequestHeaders(provider, credentials)
|
|
444
|
+
if (provider === 'x') {
|
|
445
|
+
headers.Authorization = await signOAuth1Request(
|
|
446
|
+
method,
|
|
447
|
+
url,
|
|
448
|
+
credentials,
|
|
449
|
+
endpoint.form ? encodeFormData(body) : undefined,
|
|
450
|
+
)
|
|
451
|
+
}
|
|
452
|
+
let encodedBody: string | URLSearchParams | undefined
|
|
453
|
+
if (method !== 'GET') {
|
|
454
|
+
headers['Content-Type'] = endpoint.form
|
|
455
|
+
? 'application/x-www-form-urlencoded'
|
|
456
|
+
: 'application/json'
|
|
457
|
+
encodedBody = endpoint.form
|
|
458
|
+
? encodeFormData(body)
|
|
459
|
+
: JSON.stringify(endpoint.graphql ? { query: endpoint.graphql, variables: body } : body)
|
|
460
|
+
}
|
|
461
|
+
if (provider === 'stripe' && idempotencyKey && method === 'POST') {
|
|
462
|
+
headers['Idempotency-Key'] = idempotencyKey
|
|
463
|
+
}
|
|
464
|
+
const response = await fetch(url, {
|
|
465
|
+
method,
|
|
466
|
+
headers,
|
|
467
|
+
body: encodedBody,
|
|
468
|
+
signal: signal ?? AbortSignal.timeout(30000),
|
|
469
|
+
redirect: 'error',
|
|
470
|
+
})
|
|
471
|
+
const providerName = getIntegrationDefinition(provider)!.name
|
|
472
|
+
if (!response.ok) {
|
|
473
|
+
throw new Error(
|
|
474
|
+
`${providerName} returned ${response.status}. Check the account, token permissions, and action parameters.`,
|
|
475
|
+
)
|
|
476
|
+
}
|
|
477
|
+
const result = await readJson(response)
|
|
478
|
+
assertProviderAccepted(provider, toRecord(result))
|
|
479
|
+
return result
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function getBaseUrl(provider: string, credentials: Credentials, path: string) {
|
|
483
|
+
if (provider === 'shopify') {
|
|
484
|
+
const shop = credentials.shop?.replace(/^https:\/\//, '').replace(/\/$/, '')
|
|
485
|
+
if (!shop || !/^[a-z0-9][a-z0-9-]*\.myshopify\.com$/.test(shop)) {
|
|
486
|
+
throw new Error('Enter your store.myshopify.com domain.')
|
|
487
|
+
}
|
|
488
|
+
return `https://${shop}/admin/api/2026-07/graphql.json`
|
|
489
|
+
}
|
|
490
|
+
const base = bases[provider]
|
|
491
|
+
if (!base) {
|
|
492
|
+
throw new Error('This integration has no adapter.')
|
|
493
|
+
}
|
|
494
|
+
// Some Google products split their API across hosts by version.
|
|
495
|
+
if (provider === 'ga' && (path.includes('/audiences') || path === '/v1beta/accountSummaries')) {
|
|
496
|
+
return 'https://analyticsadmin.googleapis.com'
|
|
497
|
+
}
|
|
498
|
+
if (provider === 'gsc' && path.startsWith('/v1/')) {
|
|
499
|
+
return 'https://searchconsole.googleapis.com'
|
|
500
|
+
}
|
|
501
|
+
if (provider === 'gsc' && path.startsWith('/v3/')) {
|
|
502
|
+
return 'https://indexing.googleapis.com'
|
|
503
|
+
}
|
|
504
|
+
return base
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function buildRequestUrl(
|
|
508
|
+
provider: string,
|
|
509
|
+
credentials: Credentials,
|
|
510
|
+
endpoint: Endpoint,
|
|
511
|
+
input: Input,
|
|
512
|
+
) {
|
|
513
|
+
const path = endpoint.path.replace(/\{(\w+)\}/g, (_match, name: string) => {
|
|
514
|
+
const value =
|
|
515
|
+
input[name] ?? credentials[name] ?? (name === 'calendarId' ? 'primary' : undefined)
|
|
516
|
+
if (typeof value !== 'string' && typeof value !== 'number') {
|
|
517
|
+
throw new Error(`Provide ${name} for this action.`)
|
|
518
|
+
}
|
|
519
|
+
return encodeURIComponent(String(value))
|
|
520
|
+
})
|
|
521
|
+
const url = new URL(getBaseUrl(provider, credentials, endpoint.path) + path)
|
|
522
|
+
for (const [key, value] of Object.entries(toRecord(input.query))) {
|
|
523
|
+
if (value != null) {
|
|
524
|
+
url.searchParams.set(key, typeof value === 'object' ? JSON.stringify(value) : String(value))
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
const isTiktokRead = provider === 'tiktok' && !endpoint.method
|
|
528
|
+
if (isTiktokRead && credentials.advertiserId && !url.searchParams.has('advertiser_id')) {
|
|
529
|
+
url.searchParams.set('advertiser_id', credentials.advertiserId)
|
|
530
|
+
}
|
|
531
|
+
return url
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function buildRequestHeaders(provider: string, credentials: Credentials) {
|
|
535
|
+
const headers: Record<string, string> = {
|
|
536
|
+
Accept: 'application/json',
|
|
537
|
+
Authorization: `Bearer ${credentials.token}`,
|
|
538
|
+
'User-Agent': 'nuxt-agentic-integrations',
|
|
539
|
+
}
|
|
540
|
+
switch (provider) {
|
|
541
|
+
case 'github':
|
|
542
|
+
headers['X-GitHub-Api-Version'] = '2022-11-28'
|
|
543
|
+
break
|
|
544
|
+
case 'gads':
|
|
545
|
+
headers['developer-token'] = credentials.developerToken ?? ''
|
|
546
|
+
if (credentials.loginCustomerId) {
|
|
547
|
+
headers['login-customer-id'] = credentials.loginCustomerId
|
|
548
|
+
}
|
|
549
|
+
break
|
|
550
|
+
case 'tiktok':
|
|
551
|
+
headers['Access-Token'] = credentials.token ?? ''
|
|
552
|
+
delete headers.Authorization
|
|
553
|
+
break
|
|
554
|
+
case 'shopify':
|
|
555
|
+
headers['X-Shopify-Access-Token'] = credentials.token ?? ''
|
|
556
|
+
delete headers.Authorization
|
|
557
|
+
break
|
|
558
|
+
case 'discord':
|
|
559
|
+
headers.Authorization = `Bot ${credentials.token}`
|
|
560
|
+
break
|
|
561
|
+
case 'intercom':
|
|
562
|
+
headers['Intercom-Version'] = '2.14'
|
|
563
|
+
break
|
|
564
|
+
case 'notion':
|
|
565
|
+
headers['Notion-Version'] = '2022-06-28'
|
|
566
|
+
break
|
|
567
|
+
}
|
|
568
|
+
return headers
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function buildRequestBody(
|
|
572
|
+
provider: string,
|
|
573
|
+
credentials: Credentials,
|
|
574
|
+
endpoint: Endpoint,
|
|
575
|
+
input: Input,
|
|
576
|
+
): Input {
|
|
577
|
+
const body = toRecord(input.body)
|
|
578
|
+
const method = endpoint.graphql ? 'POST' : (endpoint.method ?? 'GET')
|
|
579
|
+
if (provider === 'gmail' && method === 'POST') {
|
|
580
|
+
const message = {
|
|
581
|
+
raw: encodeRawEmail(body),
|
|
582
|
+
...(body.threadId ? { threadId: body.threadId } : {}),
|
|
583
|
+
}
|
|
584
|
+
return endpoint.path.endsWith('/drafts') ? { message } : message
|
|
585
|
+
}
|
|
586
|
+
if (provider === 'intercom' && endpoint.path.endsWith('/reply')) {
|
|
587
|
+
return { ...body, message_type: 'comment', type: 'admin' }
|
|
588
|
+
}
|
|
589
|
+
if (provider === 'tiktok' && credentials.advertiserId) {
|
|
590
|
+
return { ...body, advertiser_id: credentials.advertiserId }
|
|
591
|
+
}
|
|
592
|
+
return body
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Some providers report failures inside a 200 response. */
|
|
596
|
+
function assertProviderAccepted(provider: string, value: Input) {
|
|
597
|
+
const providerName = getIntegrationDefinition(provider)!.name
|
|
598
|
+
const hasTiktokError = provider === 'tiktok' && value.code !== 0 && value.code !== undefined
|
|
599
|
+
if (value.ok === false || hasTiktokError || Array.isArray(value.errors)) {
|
|
600
|
+
throw new Error(
|
|
601
|
+
`${providerName} could not complete this action. Check its permissions and parameters.`,
|
|
602
|
+
)
|
|
603
|
+
}
|
|
604
|
+
// GraphQL mutations return user errors next to a successful status.
|
|
605
|
+
for (const payload of Object.values(toRecord(value.data))) {
|
|
606
|
+
const userErrors = toRecord(payload).userErrors
|
|
607
|
+
if (Array.isArray(userErrors) && userErrors.length) {
|
|
608
|
+
throw new Error(`${providerName} rejected these action parameters.`)
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function toRecord(value: unknown): Input {
|
|
614
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Input) : {}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function pick(value: Input, keys: string[]) {
|
|
618
|
+
return Object.fromEntries(
|
|
619
|
+
keys.filter((key) => value[key] !== undefined).map((key) => [key, value[key]]),
|
|
620
|
+
)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** Narrows agent input to the fields each write action may change. */
|
|
624
|
+
function getActionBody(provider: string, action: string, body: Input): Input {
|
|
625
|
+
if (provider === 'meta' && action === 'pause_ads') {
|
|
626
|
+
return { status: 'PAUSED' }
|
|
627
|
+
}
|
|
628
|
+
if (provider === 'meta' && action === 'adjust_budgets') {
|
|
629
|
+
return pick(body, ['daily_budget', 'lifetime_budget'])
|
|
630
|
+
}
|
|
631
|
+
if (provider === 'x' && action === 'adjust_bids') {
|
|
632
|
+
return pick(body, ['bid_amount_local_micro', 'bid_type'])
|
|
633
|
+
}
|
|
634
|
+
if (provider === 'tiktok' && action === 'pause_ad_groups') {
|
|
635
|
+
return { adgroup_ids: body.adgroup_ids, operation_status: 'DISABLE' }
|
|
636
|
+
}
|
|
637
|
+
if (provider === 'tiktok' && action === 'adjust_budgets') {
|
|
638
|
+
return pick(body, ['campaign_id', 'budget'])
|
|
639
|
+
}
|
|
640
|
+
if (provider === 'hubspot' && action === 'update_deal_stages') {
|
|
641
|
+
return { properties: { dealstage: toRecord(body.properties).dealstage } }
|
|
642
|
+
}
|
|
643
|
+
if (provider === 'linear' && action === 'update_status') {
|
|
644
|
+
return { id: body.id, input: { stateId: toRecord(body.input).stateId } }
|
|
645
|
+
}
|
|
646
|
+
if (provider === 'gdrive' && action === 'move_files') {
|
|
647
|
+
return {}
|
|
648
|
+
}
|
|
649
|
+
if (provider === 'gads' && action === 'pause_keywords') {
|
|
650
|
+
return {
|
|
651
|
+
operations: toArray(body.operations).map((operation) => ({
|
|
652
|
+
update: {
|
|
653
|
+
resourceName: toRecord(toRecord(operation).update).resourceName,
|
|
654
|
+
status: 'PAUSED',
|
|
655
|
+
},
|
|
656
|
+
updateMask: 'status',
|
|
657
|
+
})),
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
if (provider === 'gads' && action === 'adjust_bids_and_budgets') {
|
|
661
|
+
return {
|
|
662
|
+
operations: toArray(body.operations).map((operation) => {
|
|
663
|
+
const update = pick(toRecord(toRecord(operation).update), [
|
|
664
|
+
'resourceName',
|
|
665
|
+
'amountMicros',
|
|
666
|
+
'cpcBidMicros',
|
|
667
|
+
])
|
|
668
|
+
return {
|
|
669
|
+
update,
|
|
670
|
+
updateMask: update.cpcBidMicros !== undefined ? 'cpc_bid_micros' : 'amount_micros',
|
|
671
|
+
}
|
|
672
|
+
}),
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
if (provider === 'reddit' && action === 'adjust_budgets') {
|
|
676
|
+
return { data: pick(toRecord(body.data), ['goal_value', 'spend_cap']) }
|
|
677
|
+
}
|
|
678
|
+
return body
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function toArray(value: unknown): unknown[] {
|
|
682
|
+
return Array.isArray(value) ? value : []
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** Encodes nested objects with bracket keys (`a[b]=c`), as Stripe and X expect. */
|
|
686
|
+
function encodeFormData(value: Input, prefix = '', form = new URLSearchParams()) {
|
|
687
|
+
for (const [key, item] of Object.entries(value)) {
|
|
688
|
+
const name = prefix ? `${prefix}[${key}]` : key
|
|
689
|
+
if (item !== null && typeof item === 'object') {
|
|
690
|
+
encodeFormData(item as Input, name, form)
|
|
691
|
+
} else if (item !== undefined) {
|
|
692
|
+
form.set(name, String(item))
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return form
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** Builds the base64url RFC 2822 message Gmail's send and draft endpoints require. */
|
|
699
|
+
function encodeRawEmail(body: Input) {
|
|
700
|
+
const to = String(body.to ?? '')
|
|
701
|
+
const subject = String(body.subject ?? '')
|
|
702
|
+
// Line breaks would let input inject extra headers.
|
|
703
|
+
if (!to || /[\r\n]/.test(to + subject)) {
|
|
704
|
+
throw new Error('Provide a recipient and a subject without line breaks.')
|
|
705
|
+
}
|
|
706
|
+
const encodedSubject = encodeBase64(new TextEncoder().encode(subject))
|
|
707
|
+
const message = [
|
|
708
|
+
`To: ${to}`,
|
|
709
|
+
`Subject: =?UTF-8?B?${encodedSubject}?=`,
|
|
710
|
+
'MIME-Version: 1.0',
|
|
711
|
+
'Content-Type: text/plain; charset=UTF-8',
|
|
712
|
+
'',
|
|
713
|
+
String(body.text ?? ''),
|
|
714
|
+
].join('\r\n')
|
|
715
|
+
return encodeBase64(new TextEncoder().encode(message))
|
|
716
|
+
.replace(/\+/g, '-')
|
|
717
|
+
.replace(/\//g, '_')
|
|
718
|
+
.replace(/=+$/, '')
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function encodeBase64(bytes: Uint8Array) {
|
|
722
|
+
return btoa(String.fromCharCode(...bytes))
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const maxResponseBytes = 2_000_000
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Reads a JSON or NDJSON response, refusing bodies large enough to flood an agent's context.
|
|
729
|
+
*/
|
|
730
|
+
export async function readJson(response: Response) {
|
|
731
|
+
if (!response.body) {
|
|
732
|
+
return null
|
|
733
|
+
}
|
|
734
|
+
const text = new TextDecoder().decode(await readLimitedBody(response.body))
|
|
735
|
+
if (!text) {
|
|
736
|
+
return null
|
|
737
|
+
}
|
|
738
|
+
if (response.headers.get('content-type')?.includes('ndjson')) {
|
|
739
|
+
return text
|
|
740
|
+
.trim()
|
|
741
|
+
.split('\n')
|
|
742
|
+
.filter(Boolean)
|
|
743
|
+
.map((line) => JSON.parse(line) as unknown)
|
|
744
|
+
}
|
|
745
|
+
return JSON.parse(text) as unknown
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
async function readLimitedBody(stream: ReadableStream<Uint8Array>) {
|
|
749
|
+
const reader = stream.getReader()
|
|
750
|
+
const chunks: Uint8Array[] = []
|
|
751
|
+
let length = 0
|
|
752
|
+
while (true) {
|
|
753
|
+
const { value, done } = await reader.read()
|
|
754
|
+
if (done) {
|
|
755
|
+
break
|
|
756
|
+
}
|
|
757
|
+
length += value.length
|
|
758
|
+
if (length > maxResponseBytes) {
|
|
759
|
+
await reader.cancel()
|
|
760
|
+
throw new Error('The result is too large. Use a smaller date range or page size.')
|
|
761
|
+
}
|
|
762
|
+
chunks.push(value)
|
|
763
|
+
}
|
|
764
|
+
const bytes = new Uint8Array(length)
|
|
765
|
+
let offset = 0
|
|
766
|
+
for (const chunk of chunks) {
|
|
767
|
+
bytes.set(chunk, offset)
|
|
768
|
+
offset += chunk.length
|
|
769
|
+
}
|
|
770
|
+
return bytes
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** RFC 3986 encoding, which OAuth 1.0a requires instead of `encodeURIComponent`'s. */
|
|
774
|
+
function encodeOAuthValue(value: string) {
|
|
775
|
+
return encodeURIComponent(value).replace(
|
|
776
|
+
/[!'()*]/g,
|
|
777
|
+
(character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
|
|
778
|
+
)
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function compareOAuthPairs([leftKey, leftValue]: string[], [rightKey, rightValue]: string[]) {
|
|
782
|
+
if (leftKey !== rightKey) {
|
|
783
|
+
return leftKey! < rightKey! ? -1 : 1
|
|
784
|
+
}
|
|
785
|
+
if (leftValue === rightValue) {
|
|
786
|
+
return 0
|
|
787
|
+
}
|
|
788
|
+
return leftValue! < rightValue! ? -1 : 1
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** Signs a request with OAuth 1.0a HMAC-SHA1, which the X Ads API still requires. */
|
|
792
|
+
async function signOAuth1Request(
|
|
793
|
+
method: string,
|
|
794
|
+
url: URL,
|
|
795
|
+
credentials: Credentials,
|
|
796
|
+
form?: URLSearchParams,
|
|
797
|
+
) {
|
|
798
|
+
const parameters: Record<string, string> = {
|
|
799
|
+
oauth_consumer_key: credentials.consumerKey ?? '',
|
|
800
|
+
oauth_token: credentials.token ?? '',
|
|
801
|
+
oauth_nonce: crypto.randomUUID(),
|
|
802
|
+
oauth_timestamp: String(Math.floor(Date.now() / 1000)),
|
|
803
|
+
oauth_signature_method: 'HMAC-SHA1',
|
|
804
|
+
oauth_version: '1.0',
|
|
805
|
+
}
|
|
806
|
+
const normalizedParameters = [
|
|
807
|
+
...Object.entries(parameters),
|
|
808
|
+
...url.searchParams.entries(),
|
|
809
|
+
...(form?.entries() ?? []),
|
|
810
|
+
]
|
|
811
|
+
.map(([key, value]) => [encodeOAuthValue(key), encodeOAuthValue(value)])
|
|
812
|
+
.sort(compareOAuthPairs)
|
|
813
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
814
|
+
.join('&')
|
|
815
|
+
const signatureBase = [
|
|
816
|
+
method,
|
|
817
|
+
encodeOAuthValue(url.origin + url.pathname),
|
|
818
|
+
encodeOAuthValue(normalizedParameters),
|
|
819
|
+
].join('&')
|
|
820
|
+
const signingKey = [credentials.consumerSecret ?? '', credentials.tokenSecret ?? '']
|
|
821
|
+
.map(encodeOAuthValue)
|
|
822
|
+
.join('&')
|
|
823
|
+
const key = await crypto.subtle.importKey(
|
|
824
|
+
'raw',
|
|
825
|
+
new TextEncoder().encode(signingKey),
|
|
826
|
+
{ name: 'HMAC', hash: 'SHA-1' },
|
|
827
|
+
false,
|
|
828
|
+
['sign'],
|
|
829
|
+
)
|
|
830
|
+
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signatureBase))
|
|
831
|
+
parameters.oauth_signature = encodeBase64(new Uint8Array(signature))
|
|
832
|
+
const header = Object.entries(parameters)
|
|
833
|
+
.map(([key, value]) => `${encodeOAuthValue(key)}="${encodeOAuthValue(value)}"`)
|
|
834
|
+
.join(', ')
|
|
835
|
+
return `OAuth ${header}`
|
|
836
|
+
}
|