@tangle-network/agent-integrations 0.25.2 → 0.25.4
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/README.md +13 -1
- package/dist/bin/tangle-catalog-runtime.js +5 -1
- package/dist/bin/tangle-catalog-runtime.js.map +1 -1
- package/dist/catalog.d.ts +4 -0
- package/dist/catalog.js +15 -0
- package/dist/catalog.js.map +1 -0
- package/dist/chunk-376UBTNB.js +1 -0
- package/dist/chunk-376UBTNB.js.map +1 -0
- package/dist/chunk-6KWCC42J.js +120 -0
- package/dist/chunk-6KWCC42J.js.map +1 -0
- package/dist/chunk-FQAT4IEE.js +246 -0
- package/dist/chunk-FQAT4IEE.js.map +1 -0
- package/dist/chunk-IDX3KIPA.js +3233 -0
- package/dist/chunk-IDX3KIPA.js.map +1 -0
- package/dist/{chunk-VJ57GPYO.js → chunk-IOO75SJH.js} +3234 -6787
- package/dist/chunk-IOO75SJH.js.map +1 -0
- package/dist/connectors/adapters/index.d.ts +1 -0
- package/dist/connectors/adapters/index.js +39 -0
- package/dist/connectors/adapters/index.js.map +1 -0
- package/dist/connectors/index.d.ts +180 -0
- package/dist/connectors/index.js +74 -0
- package/dist/connectors/index.js.map +1 -0
- package/dist/index-BNb1A0Id.d.ts +810 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +51 -44
- package/dist/registry.d.ts +1982 -0
- package/dist/registry.js +20 -0
- package/dist/registry.js.map +1 -0
- package/dist/runtime.d.ts +4 -0
- package/dist/runtime.js +12 -0
- package/dist/runtime.js.map +1 -0
- package/dist/specs.d.ts +4 -2962
- package/dist/tangle-catalog-runtime.d.ts +4 -0
- package/dist/tangle-catalog-runtime.js +28 -0
- package/dist/tangle-catalog-runtime.js.map +1 -0
- package/docs/platform-control-plane.md +54 -0
- package/docs/production-completion-checklist.md +2 -0
- package/package.json +31 -1
- package/dist/chunk-VJ57GPYO.js.map +0 -1
|
@@ -0,0 +1,3233 @@
|
|
|
1
|
+
// src/connectors/types.ts
|
|
2
|
+
var ResourceContention = class extends Error {
|
|
3
|
+
constructor(message, alternatives = [], currentState) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.alternatives = alternatives;
|
|
6
|
+
this.currentState = currentState;
|
|
7
|
+
}
|
|
8
|
+
alternatives;
|
|
9
|
+
currentState;
|
|
10
|
+
name = "ResourceContention";
|
|
11
|
+
};
|
|
12
|
+
var CredentialsExpired = class extends Error {
|
|
13
|
+
constructor(message, dataSourceId) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.dataSourceId = dataSourceId;
|
|
16
|
+
}
|
|
17
|
+
dataSourceId;
|
|
18
|
+
name = "CredentialsExpired";
|
|
19
|
+
};
|
|
20
|
+
function validateConnectorManifest(manifest) {
|
|
21
|
+
const issues = [];
|
|
22
|
+
if (!manifest.kind.trim()) issues.push({ path: "kind", message: "kind is required" });
|
|
23
|
+
if (!manifest.displayName.trim()) issues.push({ path: "displayName", message: "displayName is required" });
|
|
24
|
+
const seen = /* @__PURE__ */ new Set();
|
|
25
|
+
for (const [index, capability] of manifest.capabilities.entries()) {
|
|
26
|
+
const path = `capabilities[${index}]`;
|
|
27
|
+
if (!capability.name.trim()) issues.push({ path: `${path}.name`, message: "capability name is required" });
|
|
28
|
+
if (seen.has(capability.name)) issues.push({ path: `${path}.name`, message: `duplicate capability name: ${capability.name}` });
|
|
29
|
+
seen.add(capability.name);
|
|
30
|
+
if (capability.class === "mutation") {
|
|
31
|
+
if (!capability.cas) issues.push({ path: `${path}.cas`, message: "mutation capability must declare a CAS strategy" });
|
|
32
|
+
if (manifest.defaultConsistencyModel === "authoritative" && capability.cas === "none") {
|
|
33
|
+
issues.push({ path: `${path}.cas`, message: 'authoritative mutations cannot use cas="none"' });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (manifest.rateLimit) {
|
|
38
|
+
if (!Number.isFinite(manifest.rateLimit.requests) || manifest.rateLimit.requests <= 0) {
|
|
39
|
+
issues.push({ path: "rateLimit.requests", message: "rateLimit.requests must be positive" });
|
|
40
|
+
}
|
|
41
|
+
if (!Number.isFinite(manifest.rateLimit.windowMs) || manifest.rateLimit.windowMs <= 0) {
|
|
42
|
+
issues.push({ path: "rateLimit.windowMs", message: "rateLimit.windowMs must be positive" });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { ok: issues.length === 0, issues };
|
|
46
|
+
}
|
|
47
|
+
function assertValidConnectorManifest(manifest) {
|
|
48
|
+
const result = validateConnectorManifest(manifest);
|
|
49
|
+
if (!result.ok) {
|
|
50
|
+
throw new Error(`Invalid connector manifest ${manifest.kind || "<unknown>"}: ${result.issues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// src/connectors/oauth.ts
|
|
55
|
+
import { createHash, randomBytes } from "crypto";
|
|
56
|
+
var PENDING_TTL_MS = 10 * 60 * 1e3;
|
|
57
|
+
var InMemoryOAuthFlowStore = class {
|
|
58
|
+
pendingFlows = /* @__PURE__ */ new Map();
|
|
59
|
+
put(state, flow) {
|
|
60
|
+
this.pendingFlows.set(state, flow);
|
|
61
|
+
}
|
|
62
|
+
consume(state) {
|
|
63
|
+
const flow = this.pendingFlows.get(state);
|
|
64
|
+
this.pendingFlows.delete(state);
|
|
65
|
+
if (!flow || flow.expiresAt <= Date.now()) return void 0;
|
|
66
|
+
return flow;
|
|
67
|
+
}
|
|
68
|
+
sweep(now) {
|
|
69
|
+
for (const [k, v] of this.pendingFlows) {
|
|
70
|
+
if (v.expiresAt <= now) this.pendingFlows.delete(k);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
clear() {
|
|
74
|
+
this.pendingFlows.clear();
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
var defaultFlowStore = new InMemoryOAuthFlowStore();
|
|
78
|
+
function startOAuthFlow(input) {
|
|
79
|
+
const store = input.store ?? defaultFlowStore;
|
|
80
|
+
const now = input.now ?? Date.now();
|
|
81
|
+
store.sweep?.(now);
|
|
82
|
+
const codeVerifier = base64Url(randomBytes(48));
|
|
83
|
+
const codeChallenge = base64Url(createHash("sha256").update(codeVerifier).digest());
|
|
84
|
+
const state = base64Url(randomBytes(24));
|
|
85
|
+
store.put(state, {
|
|
86
|
+
codeVerifier,
|
|
87
|
+
state,
|
|
88
|
+
projectId: input.projectId,
|
|
89
|
+
kind: input.kind,
|
|
90
|
+
label: input.label,
|
|
91
|
+
redirectUri: input.redirectUri,
|
|
92
|
+
expiresAt: now + PENDING_TTL_MS
|
|
93
|
+
});
|
|
94
|
+
const url = new URL(input.authorizationUrl);
|
|
95
|
+
url.searchParams.set("response_type", "code");
|
|
96
|
+
url.searchParams.set("client_id", input.clientId);
|
|
97
|
+
url.searchParams.set("redirect_uri", input.redirectUri);
|
|
98
|
+
url.searchParams.set("scope", input.scopes.join(" "));
|
|
99
|
+
url.searchParams.set("state", state);
|
|
100
|
+
url.searchParams.set("code_challenge", codeChallenge);
|
|
101
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
102
|
+
if (input.extraAuthParams) {
|
|
103
|
+
for (const [k, v] of Object.entries(input.extraAuthParams)) {
|
|
104
|
+
url.searchParams.set(k, v);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { authorizationUrl: url.toString(), state };
|
|
108
|
+
}
|
|
109
|
+
async function consumePendingFlow(state, store = defaultFlowStore) {
|
|
110
|
+
await store.sweep?.(Date.now());
|
|
111
|
+
const flow = await store.consume(state);
|
|
112
|
+
if (!flow) {
|
|
113
|
+
throw new Error("Unknown or expired OAuth state: possible CSRF, replay, or stale flow");
|
|
114
|
+
}
|
|
115
|
+
return flow;
|
|
116
|
+
}
|
|
117
|
+
async function exchangeAuthorizationCode(input) {
|
|
118
|
+
const body = new URLSearchParams({
|
|
119
|
+
grant_type: "authorization_code",
|
|
120
|
+
client_id: input.clientId,
|
|
121
|
+
client_secret: input.clientSecret,
|
|
122
|
+
code: input.code,
|
|
123
|
+
redirect_uri: input.redirectUri,
|
|
124
|
+
code_verifier: input.codeVerifier
|
|
125
|
+
});
|
|
126
|
+
const res = await (input.fetchImpl ?? fetch)(input.tokenUrl, {
|
|
127
|
+
method: "POST",
|
|
128
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
129
|
+
body,
|
|
130
|
+
signal: input.signal
|
|
131
|
+
});
|
|
132
|
+
if (!res.ok) {
|
|
133
|
+
const text = await res.text().catch(() => "");
|
|
134
|
+
throw new Error(`OAuth token exchange failed: ${res.status} ${res.statusText} \u2014 ${text.slice(0, 200)}`);
|
|
135
|
+
}
|
|
136
|
+
const json = await res.json();
|
|
137
|
+
return {
|
|
138
|
+
accessToken: json.access_token,
|
|
139
|
+
refreshToken: json.refresh_token,
|
|
140
|
+
expiresIn: json.expires_in,
|
|
141
|
+
scope: json.scope,
|
|
142
|
+
tokenType: json.token_type
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
async function refreshAccessToken(input) {
|
|
146
|
+
const body = new URLSearchParams({
|
|
147
|
+
grant_type: "refresh_token",
|
|
148
|
+
client_id: input.clientId,
|
|
149
|
+
client_secret: input.clientSecret,
|
|
150
|
+
refresh_token: input.refreshToken
|
|
151
|
+
});
|
|
152
|
+
const res = await (input.fetchImpl ?? fetch)(input.tokenUrl, {
|
|
153
|
+
method: "POST",
|
|
154
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
155
|
+
body,
|
|
156
|
+
signal: input.signal
|
|
157
|
+
});
|
|
158
|
+
if (!res.ok) {
|
|
159
|
+
const text = await res.text().catch(() => "");
|
|
160
|
+
throw new Error(`OAuth refresh failed: ${res.status} ${res.statusText} \u2014 ${text.slice(0, 200)}`);
|
|
161
|
+
}
|
|
162
|
+
const json = await res.json();
|
|
163
|
+
return {
|
|
164
|
+
accessToken: json.access_token,
|
|
165
|
+
// Some providers omit refresh_token on refresh — keep the previous one
|
|
166
|
+
// in that case (caller passes through if undefined).
|
|
167
|
+
refreshToken: json.refresh_token,
|
|
168
|
+
expiresIn: json.expires_in,
|
|
169
|
+
scope: json.scope,
|
|
170
|
+
tokenType: json.token_type
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function base64Url(buf) {
|
|
174
|
+
return buf.toString("base64").replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
|
|
175
|
+
}
|
|
176
|
+
function _resetPendingFlowsForTests() {
|
|
177
|
+
defaultFlowStore.clear?.();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// src/connectors/adapters/google-calendar.ts
|
|
181
|
+
var SCOPES = ["https://www.googleapis.com/auth/calendar"];
|
|
182
|
+
var AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
183
|
+
var TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
184
|
+
function googleCalendar(opts) {
|
|
185
|
+
const { clientId, clientSecret } = opts;
|
|
186
|
+
const adapter = {
|
|
187
|
+
manifest: {
|
|
188
|
+
kind: "google-calendar",
|
|
189
|
+
displayName: "Google Calendar",
|
|
190
|
+
description: "Let your agent check availability and book against a Google Calendar. Conflict-resolved: two callers can't grab the same slot \u2014 the second one is offered the next free time.",
|
|
191
|
+
auth: {
|
|
192
|
+
kind: "oauth2",
|
|
193
|
+
authorizationUrl: AUTH_URL,
|
|
194
|
+
tokenUrl: TOKEN_URL,
|
|
195
|
+
scopes: SCOPES,
|
|
196
|
+
clientIdEnv: "GOOGLE_OAUTH_CLIENT_ID",
|
|
197
|
+
clientSecretEnv: "GOOGLE_OAUTH_CLIENT_SECRET",
|
|
198
|
+
extraAuthParams: { access_type: "offline", prompt: "consent", include_granted_scopes: "true" }
|
|
199
|
+
},
|
|
200
|
+
category: "calendar",
|
|
201
|
+
defaultConsistencyModel: "authoritative",
|
|
202
|
+
// Google Calendar's per-project quota is ~600 req/min before
|
|
203
|
+
// throttling kicks in (Calendar API "Queries per minute per user",
|
|
204
|
+
// shared per OAuth client). We meter at that rate locally so the
|
|
205
|
+
// FIRST chatty agent doesn't push the shared client into Google's
|
|
206
|
+
// throttle pool and degrade everyone else's quota.
|
|
207
|
+
rateLimit: { requests: 600, windowMs: 6e4, scope: "oauth-client" },
|
|
208
|
+
capabilities: [
|
|
209
|
+
{
|
|
210
|
+
name: "list_availability",
|
|
211
|
+
class: "read",
|
|
212
|
+
description: "Look up busy/free times on the connected calendar between timeMin and timeMax (RFC3339 timestamps).",
|
|
213
|
+
parameters: {
|
|
214
|
+
type: "object",
|
|
215
|
+
properties: {
|
|
216
|
+
timeMin: { type: "string", description: "RFC3339 lower bound (inclusive)" },
|
|
217
|
+
timeMax: { type: "string", description: "RFC3339 upper bound (exclusive)" }
|
|
218
|
+
},
|
|
219
|
+
required: ["timeMin", "timeMax"]
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
name: "book_slot",
|
|
224
|
+
class: "mutation",
|
|
225
|
+
description: "Reserve a time window on the connected calendar. Returns conflict + alternatives if the slot is no longer free.",
|
|
226
|
+
cas: "native-idempotency",
|
|
227
|
+
externalEffect: true,
|
|
228
|
+
parameters: {
|
|
229
|
+
type: "object",
|
|
230
|
+
properties: {
|
|
231
|
+
start: { type: "string", description: "RFC3339 start time" },
|
|
232
|
+
end: { type: "string", description: "RFC3339 end time" },
|
|
233
|
+
summary: { type: "string", description: "Event title shown on the calendar" },
|
|
234
|
+
description: { type: "string", description: "Optional event description" },
|
|
235
|
+
attendees: {
|
|
236
|
+
type: "array",
|
|
237
|
+
items: { type: "string", description: "email" }
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
required: ["start", "end", "summary"]
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
]
|
|
244
|
+
},
|
|
245
|
+
async executeRead(inv) {
|
|
246
|
+
if (inv.capabilityName !== "list_availability") {
|
|
247
|
+
throw new Error(`google-calendar: unknown read capability ${inv.capabilityName}`);
|
|
248
|
+
}
|
|
249
|
+
const calendarId = readMetaString(inv.source.metadata, "calendarId");
|
|
250
|
+
const { timeMin, timeMax } = inv.args;
|
|
251
|
+
const accessToken = await ensureFreshAccessToken(inv.source.credentials, clientId, clientSecret);
|
|
252
|
+
const fb = await freebusyQuery({ accessToken, calendarId, timeMin, timeMax });
|
|
253
|
+
return {
|
|
254
|
+
data: { busy: fb.busy },
|
|
255
|
+
fetchedAt: Date.now()
|
|
256
|
+
};
|
|
257
|
+
},
|
|
258
|
+
async executeMutation(inv) {
|
|
259
|
+
if (inv.capabilityName !== "book_slot") {
|
|
260
|
+
throw new Error(`google-calendar: unknown mutation capability ${inv.capabilityName}`);
|
|
261
|
+
}
|
|
262
|
+
const calendarId = readMetaString(inv.source.metadata, "calendarId");
|
|
263
|
+
const { start, end, summary, description, attendees } = inv.args;
|
|
264
|
+
const accessToken = await ensureFreshAccessToken(inv.source.credentials, clientId, clientSecret);
|
|
265
|
+
const fb = await freebusyQuery({ accessToken, calendarId, timeMin: start, timeMax: end });
|
|
266
|
+
if (fb.busy.length > 0) {
|
|
267
|
+
const startMs = Date.parse(start);
|
|
268
|
+
const endMs = Date.parse(end);
|
|
269
|
+
const durMs = endMs - startMs;
|
|
270
|
+
const alternatives = await findNextFreeSlots({
|
|
271
|
+
accessToken,
|
|
272
|
+
calendarId,
|
|
273
|
+
searchFromMs: endMs,
|
|
274
|
+
durationMs: durMs,
|
|
275
|
+
wanted: 3
|
|
276
|
+
});
|
|
277
|
+
throw new ResourceContention(
|
|
278
|
+
`requested slot ${start}\u2013${end} is no longer free`,
|
|
279
|
+
alternatives,
|
|
280
|
+
{ busy: fb.busy }
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
const requestId = inv.idempotencyKey.replace(/[^a-zA-Z0-9_:.-]/g, "_").slice(0, 1024);
|
|
284
|
+
const event = {
|
|
285
|
+
summary,
|
|
286
|
+
description,
|
|
287
|
+
start: { dateTime: start },
|
|
288
|
+
end: { dateTime: end },
|
|
289
|
+
attendees: attendees?.map((email) => ({ email }))
|
|
290
|
+
};
|
|
291
|
+
const res = await fetch(
|
|
292
|
+
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?conferenceDataVersion=0&sendUpdates=none&requestId=${encodeURIComponent(requestId)}`,
|
|
293
|
+
{
|
|
294
|
+
method: "POST",
|
|
295
|
+
headers: {
|
|
296
|
+
authorization: `Bearer ${accessToken}`,
|
|
297
|
+
"content-type": "application/json"
|
|
298
|
+
},
|
|
299
|
+
body: JSON.stringify(event),
|
|
300
|
+
signal: AbortSignal.timeout(15e3)
|
|
301
|
+
}
|
|
302
|
+
);
|
|
303
|
+
if (res.status === 409) {
|
|
304
|
+
const dup = await res.json().catch(() => ({}));
|
|
305
|
+
return {
|
|
306
|
+
status: "committed",
|
|
307
|
+
data: dup,
|
|
308
|
+
etagAfter: dup.etag,
|
|
309
|
+
committedAt: Date.now(),
|
|
310
|
+
idempotentReplay: true
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
if (res.status === 401 || res.status === 403) {
|
|
314
|
+
throw new CredentialsExpired(`Google Calendar rejected token (${res.status})`, inv.source.id);
|
|
315
|
+
}
|
|
316
|
+
if (!res.ok) {
|
|
317
|
+
const text = await res.text().catch(() => "");
|
|
318
|
+
throw new Error(`google-calendar book_slot ${res.status}: ${text.slice(0, 200)}`);
|
|
319
|
+
}
|
|
320
|
+
const created = await res.json();
|
|
321
|
+
return {
|
|
322
|
+
status: "committed",
|
|
323
|
+
data: { eventId: created.id, htmlLink: created.htmlLink },
|
|
324
|
+
etagAfter: created.etag,
|
|
325
|
+
committedAt: Date.now(),
|
|
326
|
+
idempotentReplay: false
|
|
327
|
+
};
|
|
328
|
+
},
|
|
329
|
+
async exchangeOAuth(input) {
|
|
330
|
+
const tokens = await exchangeAuthorizationCode({
|
|
331
|
+
tokenUrl: TOKEN_URL,
|
|
332
|
+
clientId,
|
|
333
|
+
clientSecret,
|
|
334
|
+
code: input.code,
|
|
335
|
+
codeVerifier: input.codeVerifier,
|
|
336
|
+
redirectUri: input.redirectUri
|
|
337
|
+
});
|
|
338
|
+
return {
|
|
339
|
+
credentials: {
|
|
340
|
+
kind: "oauth2",
|
|
341
|
+
accessToken: tokens.accessToken,
|
|
342
|
+
refreshToken: tokens.refreshToken,
|
|
343
|
+
expiresAt: tokens.expiresIn ? Date.now() + tokens.expiresIn * 1e3 : void 0
|
|
344
|
+
},
|
|
345
|
+
scopes: tokens.scope?.split(/\s+/) ?? SCOPES,
|
|
346
|
+
metadata: { calendarId: "primary" }
|
|
347
|
+
};
|
|
348
|
+
},
|
|
349
|
+
async refreshToken(creds) {
|
|
350
|
+
if (creds.kind !== "oauth2" || !creds.refreshToken) {
|
|
351
|
+
throw new Error("google-calendar.refreshToken: missing refresh token");
|
|
352
|
+
}
|
|
353
|
+
const refreshed = await refreshAccessToken({
|
|
354
|
+
tokenUrl: TOKEN_URL,
|
|
355
|
+
clientId,
|
|
356
|
+
clientSecret,
|
|
357
|
+
refreshToken: creds.refreshToken
|
|
358
|
+
});
|
|
359
|
+
return {
|
|
360
|
+
kind: "oauth2",
|
|
361
|
+
accessToken: refreshed.accessToken,
|
|
362
|
+
refreshToken: refreshed.refreshToken ?? creds.refreshToken,
|
|
363
|
+
expiresAt: refreshed.expiresIn ? Date.now() + refreshed.expiresIn * 1e3 : void 0
|
|
364
|
+
};
|
|
365
|
+
},
|
|
366
|
+
async test(source) {
|
|
367
|
+
try {
|
|
368
|
+
const accessToken = await ensureFreshAccessToken(source.credentials, clientId, clientSecret);
|
|
369
|
+
const calendarId = readMetaString(source.metadata, "calendarId");
|
|
370
|
+
const url = `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}`;
|
|
371
|
+
const res = await fetch(url, {
|
|
372
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
373
|
+
signal: AbortSignal.timeout(8e3)
|
|
374
|
+
});
|
|
375
|
+
if (res.status === 401 || res.status === 403) {
|
|
376
|
+
return { ok: false, reason: `Google rejected token (${res.status}) \u2014 reconnect required` };
|
|
377
|
+
}
|
|
378
|
+
if (!res.ok) return { ok: false, reason: `Google returned ${res.status}` };
|
|
379
|
+
return { ok: true };
|
|
380
|
+
} catch (err) {
|
|
381
|
+
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
return adapter;
|
|
386
|
+
}
|
|
387
|
+
async function freebusyQuery(input) {
|
|
388
|
+
const res = await fetch("https://www.googleapis.com/calendar/v3/freeBusy", {
|
|
389
|
+
method: "POST",
|
|
390
|
+
headers: {
|
|
391
|
+
authorization: `Bearer ${input.accessToken}`,
|
|
392
|
+
"content-type": "application/json"
|
|
393
|
+
},
|
|
394
|
+
body: JSON.stringify({
|
|
395
|
+
timeMin: input.timeMin,
|
|
396
|
+
timeMax: input.timeMax,
|
|
397
|
+
items: [{ id: input.calendarId }]
|
|
398
|
+
}),
|
|
399
|
+
signal: AbortSignal.timeout(1e4)
|
|
400
|
+
});
|
|
401
|
+
if (!res.ok) {
|
|
402
|
+
const text = await res.text().catch(() => "");
|
|
403
|
+
throw new Error(`freebusy ${res.status}: ${text.slice(0, 200)}`);
|
|
404
|
+
}
|
|
405
|
+
const json = await res.json();
|
|
406
|
+
return { busy: json.calendars?.[input.calendarId]?.busy ?? [] };
|
|
407
|
+
}
|
|
408
|
+
async function findNextFreeSlots(input) {
|
|
409
|
+
const horizonMs = input.searchFromMs + 14 * 24 * 60 * 60 * 1e3;
|
|
410
|
+
const out = [];
|
|
411
|
+
let cursor = input.searchFromMs;
|
|
412
|
+
while (cursor < horizonMs && out.length < input.wanted) {
|
|
413
|
+
const windowEnd = Math.min(cursor + 24 * 60 * 60 * 1e3, horizonMs);
|
|
414
|
+
const fb = await freebusyQuery({
|
|
415
|
+
accessToken: input.accessToken,
|
|
416
|
+
calendarId: input.calendarId,
|
|
417
|
+
timeMin: new Date(cursor).toISOString(),
|
|
418
|
+
timeMax: new Date(windowEnd).toISOString()
|
|
419
|
+
});
|
|
420
|
+
const busy = fb.busy.map((b) => ({ s: Date.parse(b.start), e: Date.parse(b.end) })).filter((b) => Number.isFinite(b.s) && Number.isFinite(b.e)).sort((a, b) => a.s - b.s);
|
|
421
|
+
let pos = cursor;
|
|
422
|
+
for (const b of busy) {
|
|
423
|
+
if (out.length >= input.wanted) break;
|
|
424
|
+
if (b.s > pos && b.s - pos >= input.durationMs) {
|
|
425
|
+
out.push({ start: new Date(pos).toISOString(), end: new Date(pos + input.durationMs).toISOString() });
|
|
426
|
+
}
|
|
427
|
+
pos = Math.max(pos, b.e);
|
|
428
|
+
}
|
|
429
|
+
if (out.length < input.wanted && windowEnd - pos >= input.durationMs) {
|
|
430
|
+
out.push({ start: new Date(pos).toISOString(), end: new Date(pos + input.durationMs).toISOString() });
|
|
431
|
+
}
|
|
432
|
+
cursor = windowEnd;
|
|
433
|
+
}
|
|
434
|
+
return out.slice(0, input.wanted);
|
|
435
|
+
}
|
|
436
|
+
async function ensureFreshAccessToken(creds, clientId, clientSecret) {
|
|
437
|
+
if (creds.kind !== "oauth2") {
|
|
438
|
+
throw new Error("google-calendar: expected oauth2 credentials");
|
|
439
|
+
}
|
|
440
|
+
if (creds.accessToken && (!creds.expiresAt || creds.expiresAt > Date.now() + 6e4)) {
|
|
441
|
+
return creds.accessToken;
|
|
442
|
+
}
|
|
443
|
+
if (!creds.refreshToken) {
|
|
444
|
+
throw new CredentialsExpired("Google Calendar access token expired and no refresh token", "");
|
|
445
|
+
}
|
|
446
|
+
const refreshed = await refreshAccessToken({
|
|
447
|
+
tokenUrl: TOKEN_URL,
|
|
448
|
+
clientId,
|
|
449
|
+
clientSecret,
|
|
450
|
+
refreshToken: creds.refreshToken
|
|
451
|
+
});
|
|
452
|
+
creds.accessToken = refreshed.accessToken;
|
|
453
|
+
creds.expiresAt = refreshed.expiresIn ? Date.now() + refreshed.expiresIn * 1e3 : void 0;
|
|
454
|
+
if (refreshed.refreshToken) creds.refreshToken = refreshed.refreshToken;
|
|
455
|
+
return creds.accessToken;
|
|
456
|
+
}
|
|
457
|
+
function readMetaString(meta, key) {
|
|
458
|
+
const v = meta[key];
|
|
459
|
+
if (typeof v !== "string" || v.length === 0) {
|
|
460
|
+
throw new Error(`google-calendar DataSource.metadata.${key} is missing`);
|
|
461
|
+
}
|
|
462
|
+
return v;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// src/connectors/adapters/google-sheets.ts
|
|
466
|
+
import { createHash as createHash2 } from "crypto";
|
|
467
|
+
var SCOPES2 = ["https://www.googleapis.com/auth/spreadsheets"];
|
|
468
|
+
var AUTH_URL2 = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
469
|
+
var TOKEN_URL2 = "https://oauth2.googleapis.com/token";
|
|
470
|
+
function googleSheets(opts) {
|
|
471
|
+
const { clientId, clientSecret } = opts;
|
|
472
|
+
const adapter = {
|
|
473
|
+
manifest: {
|
|
474
|
+
kind: "google-sheets",
|
|
475
|
+
displayName: "Google Sheets",
|
|
476
|
+
description: "Bind your agent's knowledge base or pricing/availability lookup to a live Google Sheet. Edit the sheet, and the agent picks up changes \u2014 no redeploys.",
|
|
477
|
+
auth: {
|
|
478
|
+
kind: "oauth2",
|
|
479
|
+
authorizationUrl: AUTH_URL2,
|
|
480
|
+
tokenUrl: TOKEN_URL2,
|
|
481
|
+
scopes: SCOPES2,
|
|
482
|
+
clientIdEnv: "GOOGLE_OAUTH_CLIENT_ID",
|
|
483
|
+
clientSecretEnv: "GOOGLE_OAUTH_CLIENT_SECRET",
|
|
484
|
+
extraAuthParams: { access_type: "offline", prompt: "consent", include_granted_scopes: "true" }
|
|
485
|
+
},
|
|
486
|
+
category: "spreadsheet",
|
|
487
|
+
defaultConsistencyModel: "cache",
|
|
488
|
+
// Sheets API caps OAuth-client-wide reads + writes at 300 req/min
|
|
489
|
+
// each (Google's published quotas: "Read requests per minute per
|
|
490
|
+
// project" and the matching write bucket). We meter the tighter of
|
|
491
|
+
// the two so neither side exhausts before us.
|
|
492
|
+
rateLimit: { requests: 300, windowMs: 6e4, scope: "oauth-client" },
|
|
493
|
+
capabilities: [
|
|
494
|
+
{
|
|
495
|
+
name: "list_rows",
|
|
496
|
+
class: "read",
|
|
497
|
+
description: "Return rows from the connected sheet. Each row is keyed by the header cells declared at connect time.",
|
|
498
|
+
parameters: {
|
|
499
|
+
type: "object",
|
|
500
|
+
properties: {
|
|
501
|
+
limit: { type: "integer", minimum: 1, maximum: 500, default: 100 }
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
name: "query_rows",
|
|
507
|
+
class: "read",
|
|
508
|
+
description: "Return rows matching every key=value pair in `predicate` (string equality, case-insensitive).",
|
|
509
|
+
parameters: {
|
|
510
|
+
type: "object",
|
|
511
|
+
properties: {
|
|
512
|
+
predicate: {
|
|
513
|
+
type: "object",
|
|
514
|
+
additionalProperties: { type: "string" }
|
|
515
|
+
},
|
|
516
|
+
limit: { type: "integer", minimum: 1, maximum: 500, default: 100 }
|
|
517
|
+
},
|
|
518
|
+
required: ["predicate"]
|
|
519
|
+
}
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
name: "update_row",
|
|
523
|
+
class: "mutation",
|
|
524
|
+
description: "Update a row identified by `rowKey` (the value in the configured key column). Patch is a {column: newValue} map. Returns conflict if the row changed since the agent last read it.",
|
|
525
|
+
cas: "optimistic-read-verify",
|
|
526
|
+
externalEffect: true,
|
|
527
|
+
parameters: {
|
|
528
|
+
type: "object",
|
|
529
|
+
properties: {
|
|
530
|
+
rowKey: { type: "string", description: "Value in the key column identifying the row to update." },
|
|
531
|
+
patch: {
|
|
532
|
+
type: "object",
|
|
533
|
+
additionalProperties: { type: "string" }
|
|
534
|
+
},
|
|
535
|
+
expectedFingerprint: {
|
|
536
|
+
type: "string",
|
|
537
|
+
description: "Optional. The fingerprint the agent read on its last list_rows/query_rows call. If supplied and stale, the update is rejected with conflict."
|
|
538
|
+
}
|
|
539
|
+
},
|
|
540
|
+
required: ["rowKey", "patch"]
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
]
|
|
544
|
+
},
|
|
545
|
+
async executeRead(inv) {
|
|
546
|
+
const meta = readSheetMeta(inv.source.metadata);
|
|
547
|
+
const accessToken = await ensureFreshAccessToken2(inv.source.credentials, clientId, clientSecret);
|
|
548
|
+
const rows = await fetchAllRows(accessToken, meta);
|
|
549
|
+
const limit = clampLimit(inv.args.limit, 100);
|
|
550
|
+
let filtered = rows;
|
|
551
|
+
if (inv.capabilityName === "query_rows") {
|
|
552
|
+
const predicate = inv.args.predicate ?? {};
|
|
553
|
+
filtered = rows.filter((row) => matchesPredicate(row, predicate));
|
|
554
|
+
} else if (inv.capabilityName !== "list_rows") {
|
|
555
|
+
throw new Error(`google-sheets: unknown read ${inv.capabilityName}`);
|
|
556
|
+
}
|
|
557
|
+
const sliced = filtered.slice(0, limit);
|
|
558
|
+
return {
|
|
559
|
+
data: {
|
|
560
|
+
rows: sliced.map((r) => ({ ...r.values, _fingerprint: r.fingerprint, _rowIndex: r.rowIndex })),
|
|
561
|
+
total: filtered.length,
|
|
562
|
+
truncated: filtered.length > sliced.length
|
|
563
|
+
},
|
|
564
|
+
etag: meta.etag,
|
|
565
|
+
// currently undefined — Sheets values.get doesn't surface etag; row-level fingerprints are the conflict signal
|
|
566
|
+
fetchedAt: Date.now()
|
|
567
|
+
};
|
|
568
|
+
},
|
|
569
|
+
async executeMutation(inv) {
|
|
570
|
+
if (inv.capabilityName !== "update_row") {
|
|
571
|
+
throw new Error(`google-sheets: unknown mutation ${inv.capabilityName}`);
|
|
572
|
+
}
|
|
573
|
+
const meta = readSheetMeta(inv.source.metadata);
|
|
574
|
+
const accessToken = await ensureFreshAccessToken2(inv.source.credentials, clientId, clientSecret);
|
|
575
|
+
const { rowKey, patch, expectedFingerprint } = inv.args;
|
|
576
|
+
const rows = await fetchAllRows(accessToken, meta);
|
|
577
|
+
const target = rows.find((r) => normalizeKey(r.values[meta.keyColumn]) === normalizeKey(rowKey));
|
|
578
|
+
if (!target) {
|
|
579
|
+
throw new ResourceContention(
|
|
580
|
+
`row with key "${rowKey}" not found`,
|
|
581
|
+
[],
|
|
582
|
+
{ availableRows: rows.length }
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
if (expectedFingerprint && expectedFingerprint !== target.fingerprint) {
|
|
586
|
+
throw new ResourceContention(
|
|
587
|
+
`row "${rowKey}" was modified since the agent last read it; re-read and try again`,
|
|
588
|
+
[],
|
|
589
|
+
{ current: target.values, currentFingerprint: target.fingerprint }
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
const updatedValues = meta.headers.map(
|
|
593
|
+
(h) => h in patch ? String(patch[h]) : target.values[h] ?? ""
|
|
594
|
+
);
|
|
595
|
+
const range = `${meta.sheetName}!A${target.rowIndex + 1}:${columnIndexToLetter(meta.headers.length - 1)}${target.rowIndex + 1}`;
|
|
596
|
+
const url = `https://sheets.googleapis.com/v4/spreadsheets/${encodeURIComponent(meta.spreadsheetId)}/values/${encodeURIComponent(range)}?valueInputOption=USER_ENTERED`;
|
|
597
|
+
const res = await fetch(url, {
|
|
598
|
+
method: "PUT",
|
|
599
|
+
headers: {
|
|
600
|
+
authorization: `Bearer ${accessToken}`,
|
|
601
|
+
"content-type": "application/json"
|
|
602
|
+
},
|
|
603
|
+
body: JSON.stringify({ values: [updatedValues] }),
|
|
604
|
+
signal: AbortSignal.timeout(15e3)
|
|
605
|
+
});
|
|
606
|
+
if (res.status === 401 || res.status === 403) {
|
|
607
|
+
throw new CredentialsExpired(`Google Sheets rejected token (${res.status})`, inv.source.id);
|
|
608
|
+
}
|
|
609
|
+
if (!res.ok) {
|
|
610
|
+
const text = await res.text().catch(() => "");
|
|
611
|
+
throw new Error(`google-sheets update_row ${res.status}: ${text.slice(0, 200)}`);
|
|
612
|
+
}
|
|
613
|
+
const updatedValuesByHeader = Object.fromEntries(
|
|
614
|
+
meta.headers.map((h, i) => [h, updatedValues[i] ?? ""])
|
|
615
|
+
);
|
|
616
|
+
return {
|
|
617
|
+
status: "committed",
|
|
618
|
+
data: {
|
|
619
|
+
row: updatedValuesByHeader,
|
|
620
|
+
fingerprint: rowFingerprint(updatedValuesByHeader),
|
|
621
|
+
updatedRange: range
|
|
622
|
+
},
|
|
623
|
+
etagAfter: rowFingerprint(updatedValuesByHeader),
|
|
624
|
+
committedAt: Date.now(),
|
|
625
|
+
idempotentReplay: false
|
|
626
|
+
};
|
|
627
|
+
},
|
|
628
|
+
async exchangeOAuth(input) {
|
|
629
|
+
const tokens = await exchangeAuthorizationCode({
|
|
630
|
+
tokenUrl: TOKEN_URL2,
|
|
631
|
+
clientId,
|
|
632
|
+
clientSecret,
|
|
633
|
+
code: input.code,
|
|
634
|
+
codeVerifier: input.codeVerifier,
|
|
635
|
+
redirectUri: input.redirectUri
|
|
636
|
+
});
|
|
637
|
+
return {
|
|
638
|
+
credentials: {
|
|
639
|
+
kind: "oauth2",
|
|
640
|
+
accessToken: tokens.accessToken,
|
|
641
|
+
refreshToken: tokens.refreshToken,
|
|
642
|
+
expiresAt: tokens.expiresIn ? Date.now() + tokens.expiresIn * 1e3 : void 0
|
|
643
|
+
},
|
|
644
|
+
scopes: tokens.scope?.split(/\s+/) ?? SCOPES2,
|
|
645
|
+
// Operator must select the spreadsheet + range post-connect; we
|
|
646
|
+
// can't infer it during the OAuth handshake.
|
|
647
|
+
metadata: { spreadsheetId: "", sheetName: "Sheet1", headerRow: 1, keyColumn: "" }
|
|
648
|
+
};
|
|
649
|
+
},
|
|
650
|
+
async refreshToken(creds) {
|
|
651
|
+
if (creds.kind !== "oauth2" || !creds.refreshToken) {
|
|
652
|
+
throw new Error("google-sheets.refreshToken: missing refresh token");
|
|
653
|
+
}
|
|
654
|
+
const refreshed = await refreshAccessToken({
|
|
655
|
+
tokenUrl: TOKEN_URL2,
|
|
656
|
+
clientId,
|
|
657
|
+
clientSecret,
|
|
658
|
+
refreshToken: creds.refreshToken
|
|
659
|
+
});
|
|
660
|
+
return {
|
|
661
|
+
kind: "oauth2",
|
|
662
|
+
accessToken: refreshed.accessToken,
|
|
663
|
+
refreshToken: refreshed.refreshToken ?? creds.refreshToken,
|
|
664
|
+
expiresAt: refreshed.expiresIn ? Date.now() + refreshed.expiresIn * 1e3 : void 0
|
|
665
|
+
};
|
|
666
|
+
},
|
|
667
|
+
async test(source) {
|
|
668
|
+
try {
|
|
669
|
+
const accessToken = await ensureFreshAccessToken2(source.credentials, clientId, clientSecret);
|
|
670
|
+
const meta = readSheetMeta(source.metadata);
|
|
671
|
+
if (!meta.spreadsheetId) {
|
|
672
|
+
return { ok: false, reason: "spreadsheetId not configured \u2014 pick a sheet in the connection settings" };
|
|
673
|
+
}
|
|
674
|
+
const url = `https://sheets.googleapis.com/v4/spreadsheets/${encodeURIComponent(meta.spreadsheetId)}?fields=spreadsheetId,properties.title`;
|
|
675
|
+
const res = await fetch(url, {
|
|
676
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
677
|
+
signal: AbortSignal.timeout(8e3)
|
|
678
|
+
});
|
|
679
|
+
if (res.status === 401 || res.status === 403) {
|
|
680
|
+
return { ok: false, reason: `Google rejected token (${res.status}) \u2014 reconnect required` };
|
|
681
|
+
}
|
|
682
|
+
if (!res.ok) return { ok: false, reason: `Google returned ${res.status}` };
|
|
683
|
+
return { ok: true };
|
|
684
|
+
} catch (err) {
|
|
685
|
+
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
return adapter;
|
|
690
|
+
}
|
|
691
|
+
function readSheetMeta(meta) {
|
|
692
|
+
const spreadsheetId = String(meta.spreadsheetId ?? "");
|
|
693
|
+
const sheetName = String(meta.sheetName ?? "Sheet1");
|
|
694
|
+
const headerRow = Number(meta.headerRow ?? 1);
|
|
695
|
+
const keyColumn = String(meta.keyColumn ?? "");
|
|
696
|
+
const headers = Array.isArray(meta.headers) ? meta.headers.map(String) : [];
|
|
697
|
+
if (!spreadsheetId || !keyColumn) {
|
|
698
|
+
throw new Error("google-sheets metadata missing spreadsheetId or keyColumn");
|
|
699
|
+
}
|
|
700
|
+
return { spreadsheetId, sheetName, headerRow, keyColumn, headers };
|
|
701
|
+
}
|
|
702
|
+
async function fetchAllRows(accessToken, meta) {
|
|
703
|
+
const range = `${meta.sheetName}`;
|
|
704
|
+
const url = `https://sheets.googleapis.com/v4/spreadsheets/${encodeURIComponent(meta.spreadsheetId)}/values/${encodeURIComponent(range)}`;
|
|
705
|
+
const res = await fetch(url, {
|
|
706
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
707
|
+
signal: AbortSignal.timeout(15e3)
|
|
708
|
+
});
|
|
709
|
+
if (res.status === 401 || res.status === 403) {
|
|
710
|
+
throw new CredentialsExpired(`Google Sheets rejected token (${res.status})`, "");
|
|
711
|
+
}
|
|
712
|
+
if (!res.ok) {
|
|
713
|
+
const text = await res.text().catch(() => "");
|
|
714
|
+
throw new Error(`google-sheets values.get ${res.status}: ${text.slice(0, 200)}`);
|
|
715
|
+
}
|
|
716
|
+
const json = await res.json();
|
|
717
|
+
const grid = json.values ?? [];
|
|
718
|
+
if (grid.length === 0) return [];
|
|
719
|
+
const headers = meta.headers.length > 0 ? meta.headers : grid[meta.headerRow - 1] ?? [];
|
|
720
|
+
if (headers.length === 0) return [];
|
|
721
|
+
const dataRows = grid.slice(meta.headerRow);
|
|
722
|
+
return dataRows.map((rowCells, i) => {
|
|
723
|
+
const values = {};
|
|
724
|
+
for (let c = 0; c < headers.length; c++) {
|
|
725
|
+
values[headers[c]] = (rowCells[c] ?? "").toString();
|
|
726
|
+
}
|
|
727
|
+
return {
|
|
728
|
+
// rowIndex is the absolute row in the sheet (1-indexed) where this
|
|
729
|
+
// row's data lives. Header is at meta.headerRow. So this row is at
|
|
730
|
+
// headerRow + i + 1 (1-indexed).
|
|
731
|
+
rowIndex: meta.headerRow + i,
|
|
732
|
+
values,
|
|
733
|
+
fingerprint: rowFingerprint(values)
|
|
734
|
+
};
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
function rowFingerprint(values) {
|
|
738
|
+
const keys = Object.keys(values).sort();
|
|
739
|
+
const blob = keys.map((k) => `${k}=${values[k]}`).join("");
|
|
740
|
+
return createHash2("sha256").update(blob).digest("hex").slice(0, 16);
|
|
741
|
+
}
|
|
742
|
+
function matchesPredicate(row, predicate) {
|
|
743
|
+
for (const [k, v] of Object.entries(predicate)) {
|
|
744
|
+
const cell = row.values[k];
|
|
745
|
+
if (cell === void 0) return false;
|
|
746
|
+
if (cell.toLowerCase().trim() !== String(v).toLowerCase().trim()) return false;
|
|
747
|
+
}
|
|
748
|
+
return true;
|
|
749
|
+
}
|
|
750
|
+
function normalizeKey(s) {
|
|
751
|
+
return (s ?? "").trim().toLowerCase();
|
|
752
|
+
}
|
|
753
|
+
function clampLimit(v, dflt) {
|
|
754
|
+
const n = typeof v === "number" ? v : Number(v);
|
|
755
|
+
if (!Number.isFinite(n) || n <= 0) return dflt;
|
|
756
|
+
return Math.min(Math.max(1, Math.floor(n)), 500);
|
|
757
|
+
}
|
|
758
|
+
function columnIndexToLetter(idx) {
|
|
759
|
+
let n = idx;
|
|
760
|
+
let s = "";
|
|
761
|
+
while (n >= 0) {
|
|
762
|
+
s = String.fromCharCode(n % 26 + 65) + s;
|
|
763
|
+
n = Math.floor(n / 26) - 1;
|
|
764
|
+
}
|
|
765
|
+
return s;
|
|
766
|
+
}
|
|
767
|
+
async function ensureFreshAccessToken2(creds, clientId, clientSecret) {
|
|
768
|
+
if (creds.kind !== "oauth2") {
|
|
769
|
+
throw new Error("google-sheets: expected oauth2 credentials");
|
|
770
|
+
}
|
|
771
|
+
if (creds.accessToken && (!creds.expiresAt || creds.expiresAt > Date.now() + 6e4)) {
|
|
772
|
+
return creds.accessToken;
|
|
773
|
+
}
|
|
774
|
+
if (!creds.refreshToken) {
|
|
775
|
+
throw new CredentialsExpired("Google Sheets access token expired and no refresh token", "");
|
|
776
|
+
}
|
|
777
|
+
const refreshed = await refreshAccessToken({
|
|
778
|
+
tokenUrl: TOKEN_URL2,
|
|
779
|
+
clientId,
|
|
780
|
+
clientSecret,
|
|
781
|
+
refreshToken: creds.refreshToken
|
|
782
|
+
});
|
|
783
|
+
creds.accessToken = refreshed.accessToken;
|
|
784
|
+
creds.expiresAt = refreshed.expiresIn ? Date.now() + refreshed.expiresIn * 1e3 : void 0;
|
|
785
|
+
if (refreshed.refreshToken) creds.refreshToken = refreshed.refreshToken;
|
|
786
|
+
return creds.accessToken;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// src/connectors/adapters/microsoft-calendar.ts
|
|
790
|
+
var SCOPES3 = [
|
|
791
|
+
"https://graph.microsoft.com/Calendars.ReadWrite",
|
|
792
|
+
// offline_access is required to receive a refresh_token from the v2.0
|
|
793
|
+
// endpoint; without it Graph hands back access tokens only and the
|
|
794
|
+
// connection silently dies after ~1 hour.
|
|
795
|
+
"offline_access"
|
|
796
|
+
];
|
|
797
|
+
var AUTH_URL3 = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize";
|
|
798
|
+
var TOKEN_URL3 = "https://login.microsoftonline.com/common/oauth2/v2.0/token";
|
|
799
|
+
function microsoftCalendar(opts) {
|
|
800
|
+
const { clientId, clientSecret } = opts;
|
|
801
|
+
const adapter = {
|
|
802
|
+
manifest: {
|
|
803
|
+
kind: "microsoft-calendar",
|
|
804
|
+
displayName: "Microsoft Calendar (Outlook 365)",
|
|
805
|
+
description: "Let your agent check availability and book against an Outlook / Microsoft 365 calendar. Conflict-resolved via Graph's getSchedule pre-flight; etag-guarded on event updates.",
|
|
806
|
+
auth: {
|
|
807
|
+
kind: "oauth2",
|
|
808
|
+
authorizationUrl: AUTH_URL3,
|
|
809
|
+
tokenUrl: TOKEN_URL3,
|
|
810
|
+
scopes: SCOPES3,
|
|
811
|
+
clientIdEnv: "MS_OAUTH_CLIENT_ID",
|
|
812
|
+
clientSecretEnv: "MS_OAUTH_CLIENT_SECRET"
|
|
813
|
+
// Microsoft v2.0 doesn't need extra params to issue a refresh_token
|
|
814
|
+
// as long as `offline_access` is in scopes (above).
|
|
815
|
+
},
|
|
816
|
+
category: "calendar",
|
|
817
|
+
defaultConsistencyModel: "authoritative",
|
|
818
|
+
capabilities: [
|
|
819
|
+
{
|
|
820
|
+
name: "list_availability",
|
|
821
|
+
class: "read",
|
|
822
|
+
description: "Look up busy windows on the connected Outlook calendar between timeMin and timeMax (RFC3339 timestamps).",
|
|
823
|
+
parameters: {
|
|
824
|
+
type: "object",
|
|
825
|
+
properties: {
|
|
826
|
+
timeMin: { type: "string", description: "ISO8601 lower bound (inclusive)" },
|
|
827
|
+
timeMax: { type: "string", description: "ISO8601 upper bound (exclusive)" }
|
|
828
|
+
},
|
|
829
|
+
required: ["timeMin", "timeMax"]
|
|
830
|
+
}
|
|
831
|
+
},
|
|
832
|
+
{
|
|
833
|
+
name: "book_slot",
|
|
834
|
+
class: "mutation",
|
|
835
|
+
description: "Reserve a time window on the connected Outlook calendar. Returns conflict + alternatives if the slot is no longer free.",
|
|
836
|
+
cas: "native-idempotency",
|
|
837
|
+
externalEffect: true,
|
|
838
|
+
parameters: {
|
|
839
|
+
type: "object",
|
|
840
|
+
properties: {
|
|
841
|
+
start: { type: "string", description: "ISO8601 start time" },
|
|
842
|
+
end: { type: "string", description: "ISO8601 end time" },
|
|
843
|
+
summary: { type: "string", description: "Event title (subject)" },
|
|
844
|
+
description: { type: "string", description: "Optional event body" },
|
|
845
|
+
attendees: {
|
|
846
|
+
type: "array",
|
|
847
|
+
items: { type: "string", description: "attendee email" }
|
|
848
|
+
}
|
|
849
|
+
},
|
|
850
|
+
required: ["start", "end", "summary"]
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
]
|
|
854
|
+
},
|
|
855
|
+
async executeRead(inv) {
|
|
856
|
+
if (inv.capabilityName !== "list_availability") {
|
|
857
|
+
throw new Error(`microsoft-calendar: unknown read capability ${inv.capabilityName}`);
|
|
858
|
+
}
|
|
859
|
+
const userPrincipal = readMetaString2(inv.source.metadata, "userPrincipal");
|
|
860
|
+
const { timeMin, timeMax } = inv.args;
|
|
861
|
+
const accessToken = await ensureFreshAccessToken3(inv.source.credentials, clientId, clientSecret);
|
|
862
|
+
const busy = await getScheduleBusy({ accessToken, userPrincipal, timeMin, timeMax });
|
|
863
|
+
return {
|
|
864
|
+
data: { busy },
|
|
865
|
+
fetchedAt: Date.now()
|
|
866
|
+
};
|
|
867
|
+
},
|
|
868
|
+
async executeMutation(inv) {
|
|
869
|
+
if (inv.capabilityName !== "book_slot") {
|
|
870
|
+
throw new Error(`microsoft-calendar: unknown mutation capability ${inv.capabilityName}`);
|
|
871
|
+
}
|
|
872
|
+
const userPrincipal = readMetaString2(inv.source.metadata, "userPrincipal");
|
|
873
|
+
const { start, end, summary, description, attendees } = inv.args;
|
|
874
|
+
const accessToken = await ensureFreshAccessToken3(inv.source.credentials, clientId, clientSecret);
|
|
875
|
+
const busy = await getScheduleBusy({ accessToken, userPrincipal, timeMin: start, timeMax: end });
|
|
876
|
+
if (busy.length > 0) {
|
|
877
|
+
const startMs = Date.parse(start);
|
|
878
|
+
const endMs = Date.parse(end);
|
|
879
|
+
const durMs = endMs - startMs;
|
|
880
|
+
const alternatives = await findNextFreeSlots2({
|
|
881
|
+
accessToken,
|
|
882
|
+
userPrincipal,
|
|
883
|
+
searchFromMs: endMs,
|
|
884
|
+
durationMs: durMs,
|
|
885
|
+
wanted: 3
|
|
886
|
+
});
|
|
887
|
+
throw new ResourceContention(
|
|
888
|
+
`requested slot ${start}\u2013${end} is no longer free`,
|
|
889
|
+
alternatives,
|
|
890
|
+
{ busy }
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
const event = {
|
|
894
|
+
subject: summary,
|
|
895
|
+
body: description ? { contentType: "text", content: description } : void 0,
|
|
896
|
+
start: { dateTime: start, timeZone: "UTC" },
|
|
897
|
+
end: { dateTime: end, timeZone: "UTC" },
|
|
898
|
+
attendees: attendees?.map((email) => ({
|
|
899
|
+
emailAddress: { address: email },
|
|
900
|
+
type: "required"
|
|
901
|
+
}))
|
|
902
|
+
};
|
|
903
|
+
const res = await fetch("https://graph.microsoft.com/v1.0/me/events", {
|
|
904
|
+
method: "POST",
|
|
905
|
+
headers: {
|
|
906
|
+
authorization: `Bearer ${accessToken}`,
|
|
907
|
+
"content-type": "application/json"
|
|
908
|
+
},
|
|
909
|
+
body: JSON.stringify(event),
|
|
910
|
+
signal: AbortSignal.timeout(15e3)
|
|
911
|
+
});
|
|
912
|
+
if (res.status === 401 || res.status === 403) {
|
|
913
|
+
throw new CredentialsExpired(`Microsoft Graph rejected token (${res.status})`, inv.source.id);
|
|
914
|
+
}
|
|
915
|
+
if (res.status === 412 || res.status === 409) {
|
|
916
|
+
throw new ResourceContention(
|
|
917
|
+
`Microsoft Graph reported conflict on book_slot (${res.status})`,
|
|
918
|
+
[]
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
if (!res.ok) {
|
|
922
|
+
const text = await res.text().catch(() => "");
|
|
923
|
+
throw new Error(`microsoft-calendar book_slot ${res.status}: ${text.slice(0, 200)}`);
|
|
924
|
+
}
|
|
925
|
+
const created = await res.json();
|
|
926
|
+
return {
|
|
927
|
+
status: "committed",
|
|
928
|
+
data: { eventId: created.id, webLink: created.webLink },
|
|
929
|
+
etagAfter: created["@odata.etag"],
|
|
930
|
+
committedAt: Date.now(),
|
|
931
|
+
idempotentReplay: false
|
|
932
|
+
};
|
|
933
|
+
},
|
|
934
|
+
async exchangeOAuth(input) {
|
|
935
|
+
if (!clientId || !clientSecret) {
|
|
936
|
+
throw new Error("Microsoft OAuth client not configured (MS_OAUTH_CLIENT_ID / _SECRET)");
|
|
937
|
+
}
|
|
938
|
+
const tokens = await exchangeAuthorizationCode({
|
|
939
|
+
tokenUrl: TOKEN_URL3,
|
|
940
|
+
clientId,
|
|
941
|
+
clientSecret,
|
|
942
|
+
code: input.code,
|
|
943
|
+
codeVerifier: input.codeVerifier,
|
|
944
|
+
redirectUri: input.redirectUri
|
|
945
|
+
});
|
|
946
|
+
return {
|
|
947
|
+
credentials: {
|
|
948
|
+
kind: "oauth2",
|
|
949
|
+
accessToken: tokens.accessToken,
|
|
950
|
+
refreshToken: tokens.refreshToken,
|
|
951
|
+
expiresAt: tokens.expiresIn ? Date.now() + tokens.expiresIn * 1e3 : void 0
|
|
952
|
+
},
|
|
953
|
+
scopes: tokens.scope?.split(/\s+/) ?? SCOPES3,
|
|
954
|
+
// Operator picks the shared mailbox / room calendar post-connect.
|
|
955
|
+
// Default to the authenticated user's own primary calendar via 'me'.
|
|
956
|
+
metadata: { userPrincipal: "me" }
|
|
957
|
+
};
|
|
958
|
+
},
|
|
959
|
+
async refreshToken(creds) {
|
|
960
|
+
if (creds.kind !== "oauth2" || !creds.refreshToken) {
|
|
961
|
+
throw new Error("microsoft-calendar.refreshToken: missing refresh token");
|
|
962
|
+
}
|
|
963
|
+
const refreshed = await refreshAccessToken({
|
|
964
|
+
tokenUrl: TOKEN_URL3,
|
|
965
|
+
clientId,
|
|
966
|
+
clientSecret,
|
|
967
|
+
refreshToken: creds.refreshToken
|
|
968
|
+
});
|
|
969
|
+
return {
|
|
970
|
+
kind: "oauth2",
|
|
971
|
+
accessToken: refreshed.accessToken,
|
|
972
|
+
refreshToken: refreshed.refreshToken ?? creds.refreshToken,
|
|
973
|
+
expiresAt: refreshed.expiresIn ? Date.now() + refreshed.expiresIn * 1e3 : void 0
|
|
974
|
+
};
|
|
975
|
+
},
|
|
976
|
+
async test(source) {
|
|
977
|
+
try {
|
|
978
|
+
const accessToken = await ensureFreshAccessToken3(source.credentials, clientId, clientSecret);
|
|
979
|
+
const res = await fetch("https://graph.microsoft.com/v1.0/me?$select=id", {
|
|
980
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
981
|
+
signal: AbortSignal.timeout(8e3)
|
|
982
|
+
});
|
|
983
|
+
if (res.status === 401 || res.status === 403) {
|
|
984
|
+
return { ok: false, reason: `Microsoft rejected token (${res.status}) \u2014 reconnect required` };
|
|
985
|
+
}
|
|
986
|
+
if (!res.ok) return { ok: false, reason: `Microsoft Graph returned ${res.status}` };
|
|
987
|
+
return { ok: true };
|
|
988
|
+
} catch (err) {
|
|
989
|
+
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
};
|
|
993
|
+
return adapter;
|
|
994
|
+
}
|
|
995
|
+
async function getScheduleBusy(input) {
|
|
996
|
+
const target = input.userPrincipal === "me" ? "me" : input.userPrincipal;
|
|
997
|
+
const url = "https://graph.microsoft.com/v1.0/me/calendar/getSchedule";
|
|
998
|
+
const res = await fetch(url, {
|
|
999
|
+
method: "POST",
|
|
1000
|
+
headers: {
|
|
1001
|
+
authorization: `Bearer ${input.accessToken}`,
|
|
1002
|
+
"content-type": "application/json"
|
|
1003
|
+
},
|
|
1004
|
+
body: JSON.stringify({
|
|
1005
|
+
schedules: [target],
|
|
1006
|
+
startTime: { dateTime: input.timeMin, timeZone: "UTC" },
|
|
1007
|
+
endTime: { dateTime: input.timeMax, timeZone: "UTC" },
|
|
1008
|
+
availabilityViewInterval: 30
|
|
1009
|
+
}),
|
|
1010
|
+
signal: AbortSignal.timeout(1e4)
|
|
1011
|
+
});
|
|
1012
|
+
if (res.status === 401 || res.status === 403) {
|
|
1013
|
+
throw new CredentialsExpired(`Microsoft Graph rejected token (${res.status})`, "");
|
|
1014
|
+
}
|
|
1015
|
+
if (!res.ok) {
|
|
1016
|
+
const text = await res.text().catch(() => "");
|
|
1017
|
+
throw new Error(`microsoft-calendar getSchedule ${res.status}: ${text.slice(0, 200)}`);
|
|
1018
|
+
}
|
|
1019
|
+
const json = await res.json();
|
|
1020
|
+
const items = json.value?.[0]?.scheduleItems ?? [];
|
|
1021
|
+
return items.filter((it) => it.status && it.status !== "free" && it.start && it.end).map((it) => ({ start: it.start.dateTime, end: it.end.dateTime }));
|
|
1022
|
+
}
|
|
1023
|
+
async function findNextFreeSlots2(input) {
|
|
1024
|
+
const horizonMs = input.searchFromMs + 14 * 24 * 60 * 60 * 1e3;
|
|
1025
|
+
const out = [];
|
|
1026
|
+
let cursor = input.searchFromMs;
|
|
1027
|
+
while (cursor < horizonMs && out.length < input.wanted) {
|
|
1028
|
+
const windowEnd = Math.min(cursor + 24 * 60 * 60 * 1e3, horizonMs);
|
|
1029
|
+
const busy = await getScheduleBusy({
|
|
1030
|
+
accessToken: input.accessToken,
|
|
1031
|
+
userPrincipal: input.userPrincipal,
|
|
1032
|
+
timeMin: new Date(cursor).toISOString(),
|
|
1033
|
+
timeMax: new Date(windowEnd).toISOString()
|
|
1034
|
+
});
|
|
1035
|
+
const norm = busy.map((b) => ({ s: Date.parse(b.start), e: Date.parse(b.end) })).filter((b) => Number.isFinite(b.s) && Number.isFinite(b.e)).sort((a, b) => a.s - b.s);
|
|
1036
|
+
let pos = cursor;
|
|
1037
|
+
for (const b of norm) {
|
|
1038
|
+
if (out.length >= input.wanted) break;
|
|
1039
|
+
if (b.s > pos && b.s - pos >= input.durationMs) {
|
|
1040
|
+
out.push({ start: new Date(pos).toISOString(), end: new Date(pos + input.durationMs).toISOString() });
|
|
1041
|
+
}
|
|
1042
|
+
pos = Math.max(pos, b.e);
|
|
1043
|
+
}
|
|
1044
|
+
if (out.length < input.wanted && windowEnd - pos >= input.durationMs) {
|
|
1045
|
+
out.push({ start: new Date(pos).toISOString(), end: new Date(pos + input.durationMs).toISOString() });
|
|
1046
|
+
}
|
|
1047
|
+
cursor = windowEnd;
|
|
1048
|
+
}
|
|
1049
|
+
return out.slice(0, input.wanted);
|
|
1050
|
+
}
|
|
1051
|
+
async function ensureFreshAccessToken3(creds, clientId, clientSecret) {
|
|
1052
|
+
if (creds.kind !== "oauth2") {
|
|
1053
|
+
throw new Error("microsoft-calendar: expected oauth2 credentials");
|
|
1054
|
+
}
|
|
1055
|
+
if (creds.accessToken && (!creds.expiresAt || creds.expiresAt > Date.now() + 6e4)) {
|
|
1056
|
+
return creds.accessToken;
|
|
1057
|
+
}
|
|
1058
|
+
if (!creds.refreshToken) {
|
|
1059
|
+
throw new CredentialsExpired("Microsoft Calendar access token expired and no refresh token", "");
|
|
1060
|
+
}
|
|
1061
|
+
const refreshed = await refreshAccessToken({
|
|
1062
|
+
tokenUrl: TOKEN_URL3,
|
|
1063
|
+
clientId,
|
|
1064
|
+
clientSecret,
|
|
1065
|
+
refreshToken: creds.refreshToken
|
|
1066
|
+
});
|
|
1067
|
+
creds.accessToken = refreshed.accessToken;
|
|
1068
|
+
creds.expiresAt = refreshed.expiresIn ? Date.now() + refreshed.expiresIn * 1e3 : void 0;
|
|
1069
|
+
if (refreshed.refreshToken) creds.refreshToken = refreshed.refreshToken;
|
|
1070
|
+
return creds.accessToken;
|
|
1071
|
+
}
|
|
1072
|
+
function readMetaString2(meta, key) {
|
|
1073
|
+
const v = meta[key];
|
|
1074
|
+
if (typeof v !== "string" || v.length === 0) {
|
|
1075
|
+
throw new Error(`microsoft-calendar DataSource.metadata.${key} is missing`);
|
|
1076
|
+
}
|
|
1077
|
+
return v;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// src/connectors/adapters/hubspot.ts
|
|
1081
|
+
var SCOPES4 = [
|
|
1082
|
+
"crm.objects.contacts.read",
|
|
1083
|
+
"crm.objects.contacts.write"
|
|
1084
|
+
];
|
|
1085
|
+
var AUTH_URL4 = "https://app.hubspot.com/oauth/authorize";
|
|
1086
|
+
var TOKEN_URL4 = "https://api.hubapi.com/oauth/v1/token";
|
|
1087
|
+
var API = "https://api.hubapi.com";
|
|
1088
|
+
function hubspot(opts) {
|
|
1089
|
+
const { clientId, clientSecret } = opts;
|
|
1090
|
+
const adapter = {
|
|
1091
|
+
manifest: {
|
|
1092
|
+
kind: "hubspot",
|
|
1093
|
+
displayName: "HubSpot CRM",
|
|
1094
|
+
description: "Look up callers in HubSpot, upsert contacts without duplicates, and log call notes as CRM activities. Three capabilities \u2014 the voice-agent's CRM hot path.",
|
|
1095
|
+
auth: {
|
|
1096
|
+
kind: "oauth2",
|
|
1097
|
+
authorizationUrl: AUTH_URL4,
|
|
1098
|
+
tokenUrl: TOKEN_URL4,
|
|
1099
|
+
scopes: SCOPES4,
|
|
1100
|
+
clientIdEnv: "HUBSPOT_OAUTH_CLIENT_ID",
|
|
1101
|
+
clientSecretEnv: "HUBSPOT_OAUTH_CLIENT_SECRET"
|
|
1102
|
+
},
|
|
1103
|
+
category: "crm",
|
|
1104
|
+
defaultConsistencyModel: "authoritative",
|
|
1105
|
+
capabilities: [
|
|
1106
|
+
{
|
|
1107
|
+
name: "find_contact",
|
|
1108
|
+
class: "read",
|
|
1109
|
+
description: "Search HubSpot contacts by email. Returns the first match or {found:false}.",
|
|
1110
|
+
parameters: {
|
|
1111
|
+
type: "object",
|
|
1112
|
+
properties: { email: { type: "string", description: "Email to search for (case-insensitive)." } },
|
|
1113
|
+
required: ["email"]
|
|
1114
|
+
}
|
|
1115
|
+
},
|
|
1116
|
+
{
|
|
1117
|
+
name: "upsert_contact",
|
|
1118
|
+
class: "mutation",
|
|
1119
|
+
description: "Create-or-update a contact identified by email. Returns the contact id and a `created` flag indicating whether the row was new.",
|
|
1120
|
+
cas: "native-idempotency",
|
|
1121
|
+
externalEffect: true,
|
|
1122
|
+
parameters: {
|
|
1123
|
+
type: "object",
|
|
1124
|
+
properties: {
|
|
1125
|
+
email: { type: "string" },
|
|
1126
|
+
properties: {
|
|
1127
|
+
type: "object",
|
|
1128
|
+
additionalProperties: { type: "string" },
|
|
1129
|
+
description: "Property map (firstname, lastname, phone, company, \u2026)."
|
|
1130
|
+
}
|
|
1131
|
+
},
|
|
1132
|
+
required: ["email"]
|
|
1133
|
+
}
|
|
1134
|
+
},
|
|
1135
|
+
{
|
|
1136
|
+
name: "create_note",
|
|
1137
|
+
class: "mutation",
|
|
1138
|
+
description: "Log a note engagement against a contact. Append-only \u2014 note bodies do not conflict.",
|
|
1139
|
+
cas: "native-idempotency",
|
|
1140
|
+
externalEffect: true,
|
|
1141
|
+
parameters: {
|
|
1142
|
+
type: "object",
|
|
1143
|
+
properties: {
|
|
1144
|
+
contactId: { type: "string" },
|
|
1145
|
+
body: { type: "string", description: "Note body (HTML or plain text)." }
|
|
1146
|
+
},
|
|
1147
|
+
required: ["contactId", "body"]
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
]
|
|
1151
|
+
},
|
|
1152
|
+
async executeRead(inv) {
|
|
1153
|
+
if (inv.capabilityName !== "find_contact") {
|
|
1154
|
+
throw new Error(`hubspot: unknown read capability ${inv.capabilityName}`);
|
|
1155
|
+
}
|
|
1156
|
+
const { email } = inv.args;
|
|
1157
|
+
const accessToken = await ensureFreshAccessToken4(inv.source.credentials, clientId, clientSecret);
|
|
1158
|
+
const res = await fetch(`${API}/crm/v3/objects/contacts/search`, {
|
|
1159
|
+
method: "POST",
|
|
1160
|
+
headers: {
|
|
1161
|
+
authorization: `Bearer ${accessToken}`,
|
|
1162
|
+
"content-type": "application/json"
|
|
1163
|
+
},
|
|
1164
|
+
body: JSON.stringify({
|
|
1165
|
+
filterGroups: [
|
|
1166
|
+
{
|
|
1167
|
+
filters: [{ propertyName: "email", operator: "EQ", value: email.toLowerCase() }]
|
|
1168
|
+
}
|
|
1169
|
+
],
|
|
1170
|
+
properties: ["email", "firstname", "lastname", "phone", "company"],
|
|
1171
|
+
limit: 1
|
|
1172
|
+
}),
|
|
1173
|
+
signal: AbortSignal.timeout(1e4)
|
|
1174
|
+
});
|
|
1175
|
+
if (res.status === 401) {
|
|
1176
|
+
throw new CredentialsExpired(`HubSpot rejected token (401)`, inv.source.id);
|
|
1177
|
+
}
|
|
1178
|
+
if (!res.ok) {
|
|
1179
|
+
const text = await res.text().catch(() => "");
|
|
1180
|
+
throw new Error(`hubspot find_contact ${res.status}: ${text.slice(0, 200)}`);
|
|
1181
|
+
}
|
|
1182
|
+
const json = await res.json();
|
|
1183
|
+
const first = json.results?.[0];
|
|
1184
|
+
return {
|
|
1185
|
+
data: first ? { found: true, contact: { id: first.id, properties: first.properties } } : { found: false },
|
|
1186
|
+
fetchedAt: Date.now()
|
|
1187
|
+
};
|
|
1188
|
+
},
|
|
1189
|
+
async executeMutation(inv) {
|
|
1190
|
+
const accessToken = await ensureFreshAccessToken4(inv.source.credentials, clientId, clientSecret);
|
|
1191
|
+
if (inv.capabilityName === "upsert_contact") {
|
|
1192
|
+
return upsertContact(inv, accessToken);
|
|
1193
|
+
}
|
|
1194
|
+
if (inv.capabilityName === "create_note") {
|
|
1195
|
+
return createNote(inv, accessToken);
|
|
1196
|
+
}
|
|
1197
|
+
throw new Error(`hubspot: unknown mutation capability ${inv.capabilityName}`);
|
|
1198
|
+
},
|
|
1199
|
+
async exchangeOAuth(input) {
|
|
1200
|
+
if (!clientId || !clientSecret) {
|
|
1201
|
+
throw new Error("HubSpot OAuth client not configured (HUBSPOT_OAUTH_CLIENT_ID / _SECRET)");
|
|
1202
|
+
}
|
|
1203
|
+
const tokens = await exchangeAuthorizationCode({
|
|
1204
|
+
tokenUrl: TOKEN_URL4,
|
|
1205
|
+
clientId,
|
|
1206
|
+
clientSecret,
|
|
1207
|
+
code: input.code,
|
|
1208
|
+
codeVerifier: input.codeVerifier,
|
|
1209
|
+
redirectUri: input.redirectUri
|
|
1210
|
+
});
|
|
1211
|
+
return {
|
|
1212
|
+
credentials: {
|
|
1213
|
+
kind: "oauth2",
|
|
1214
|
+
accessToken: tokens.accessToken,
|
|
1215
|
+
refreshToken: tokens.refreshToken,
|
|
1216
|
+
expiresAt: tokens.expiresIn ? Date.now() + tokens.expiresIn * 1e3 : void 0
|
|
1217
|
+
},
|
|
1218
|
+
scopes: tokens.scope?.split(/\s+/) ?? SCOPES4,
|
|
1219
|
+
metadata: {}
|
|
1220
|
+
};
|
|
1221
|
+
},
|
|
1222
|
+
async refreshToken(creds) {
|
|
1223
|
+
if (creds.kind !== "oauth2" || !creds.refreshToken) {
|
|
1224
|
+
throw new Error("hubspot.refreshToken: missing refresh token");
|
|
1225
|
+
}
|
|
1226
|
+
const refreshed = await refreshAccessToken({
|
|
1227
|
+
tokenUrl: TOKEN_URL4,
|
|
1228
|
+
clientId,
|
|
1229
|
+
clientSecret,
|
|
1230
|
+
refreshToken: creds.refreshToken
|
|
1231
|
+
});
|
|
1232
|
+
return {
|
|
1233
|
+
kind: "oauth2",
|
|
1234
|
+
accessToken: refreshed.accessToken,
|
|
1235
|
+
refreshToken: refreshed.refreshToken ?? creds.refreshToken,
|
|
1236
|
+
expiresAt: refreshed.expiresIn ? Date.now() + refreshed.expiresIn * 1e3 : void 0
|
|
1237
|
+
};
|
|
1238
|
+
},
|
|
1239
|
+
async test(source) {
|
|
1240
|
+
try {
|
|
1241
|
+
const accessToken = await ensureFreshAccessToken4(source.credentials, clientId, clientSecret);
|
|
1242
|
+
const res = await fetch(`${API}/oauth/v1/access-tokens/${encodeURIComponent(accessToken)}`, {
|
|
1243
|
+
signal: AbortSignal.timeout(8e3)
|
|
1244
|
+
});
|
|
1245
|
+
if (res.status === 401 || res.status === 403 || res.status === 404) {
|
|
1246
|
+
return { ok: false, reason: `HubSpot rejected token (${res.status}) \u2014 reconnect required` };
|
|
1247
|
+
}
|
|
1248
|
+
if (!res.ok) return { ok: false, reason: `HubSpot returned ${res.status}` };
|
|
1249
|
+
return { ok: true };
|
|
1250
|
+
} catch (err) {
|
|
1251
|
+
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
};
|
|
1255
|
+
return adapter;
|
|
1256
|
+
}
|
|
1257
|
+
async function upsertContact(inv, accessToken) {
|
|
1258
|
+
const { email, properties } = inv.args;
|
|
1259
|
+
const idemKey = sanitizeIdempotencyKey(inv.idempotencyKey);
|
|
1260
|
+
const url = `${API}/crm/v3/objects/contacts/batch/upsert?idempotencyKey=${encodeURIComponent(idemKey)}`;
|
|
1261
|
+
const body = {
|
|
1262
|
+
inputs: [
|
|
1263
|
+
{
|
|
1264
|
+
idProperty: "email",
|
|
1265
|
+
id: email.toLowerCase(),
|
|
1266
|
+
properties: { email: email.toLowerCase(), ...properties ?? {} }
|
|
1267
|
+
}
|
|
1268
|
+
]
|
|
1269
|
+
};
|
|
1270
|
+
const res = await fetch(url, {
|
|
1271
|
+
method: "POST",
|
|
1272
|
+
headers: {
|
|
1273
|
+
authorization: `Bearer ${accessToken}`,
|
|
1274
|
+
"content-type": "application/json"
|
|
1275
|
+
},
|
|
1276
|
+
body: JSON.stringify(body),
|
|
1277
|
+
signal: AbortSignal.timeout(15e3)
|
|
1278
|
+
});
|
|
1279
|
+
if (res.status === 401) {
|
|
1280
|
+
throw new CredentialsExpired(`HubSpot rejected token (401)`, inv.source.id);
|
|
1281
|
+
}
|
|
1282
|
+
if (res.status === 409) {
|
|
1283
|
+
const text = await res.text().catch(() => "");
|
|
1284
|
+
throw new ResourceContention(`hubspot upsert_contact conflict: ${text.slice(0, 200)}`);
|
|
1285
|
+
}
|
|
1286
|
+
if (!res.ok) {
|
|
1287
|
+
const text = await res.text().catch(() => "");
|
|
1288
|
+
throw new Error(`hubspot upsert_contact ${res.status}: ${text.slice(0, 200)}`);
|
|
1289
|
+
}
|
|
1290
|
+
const json = await res.json();
|
|
1291
|
+
const first = json.results?.[0];
|
|
1292
|
+
if (!first) {
|
|
1293
|
+
throw new Error("hubspot upsert_contact: empty results array");
|
|
1294
|
+
}
|
|
1295
|
+
const created = first.createdAt && first.updatedAt && first.createdAt === first.updatedAt;
|
|
1296
|
+
return {
|
|
1297
|
+
status: "committed",
|
|
1298
|
+
data: { contactId: first.id, created: Boolean(created) },
|
|
1299
|
+
committedAt: Date.now(),
|
|
1300
|
+
idempotentReplay: false
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
async function createNote(inv, accessToken) {
|
|
1304
|
+
const { contactId, body } = inv.args;
|
|
1305
|
+
const idemKey = sanitizeIdempotencyKey(inv.idempotencyKey);
|
|
1306
|
+
const url = `${API}/crm/v3/objects/notes/batch/create?idempotencyKey=${encodeURIComponent(idemKey)}`;
|
|
1307
|
+
const payload = {
|
|
1308
|
+
inputs: [
|
|
1309
|
+
{
|
|
1310
|
+
properties: {
|
|
1311
|
+
hs_note_body: body,
|
|
1312
|
+
hs_timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1313
|
+
},
|
|
1314
|
+
associations: [
|
|
1315
|
+
{
|
|
1316
|
+
to: { id: contactId },
|
|
1317
|
+
// 202 = note→contact association type id (standard HubSpot mapping)
|
|
1318
|
+
types: [{ associationCategory: "HUBSPOT_DEFINED", associationTypeId: 202 }]
|
|
1319
|
+
}
|
|
1320
|
+
]
|
|
1321
|
+
}
|
|
1322
|
+
]
|
|
1323
|
+
};
|
|
1324
|
+
const res = await fetch(url, {
|
|
1325
|
+
method: "POST",
|
|
1326
|
+
headers: {
|
|
1327
|
+
authorization: `Bearer ${accessToken}`,
|
|
1328
|
+
"content-type": "application/json"
|
|
1329
|
+
},
|
|
1330
|
+
body: JSON.stringify(payload),
|
|
1331
|
+
signal: AbortSignal.timeout(15e3)
|
|
1332
|
+
});
|
|
1333
|
+
if (res.status === 401) {
|
|
1334
|
+
throw new CredentialsExpired(`HubSpot rejected token (401)`, inv.source.id);
|
|
1335
|
+
}
|
|
1336
|
+
if (res.status === 409) {
|
|
1337
|
+
const text = await res.text().catch(() => "");
|
|
1338
|
+
throw new ResourceContention(`hubspot create_note conflict: ${text.slice(0, 200)}`);
|
|
1339
|
+
}
|
|
1340
|
+
if (!res.ok) {
|
|
1341
|
+
const text = await res.text().catch(() => "");
|
|
1342
|
+
throw new Error(`hubspot create_note ${res.status}: ${text.slice(0, 200)}`);
|
|
1343
|
+
}
|
|
1344
|
+
const json = await res.json();
|
|
1345
|
+
const first = json.results?.[0];
|
|
1346
|
+
if (!first) {
|
|
1347
|
+
throw new Error("hubspot create_note: empty results array");
|
|
1348
|
+
}
|
|
1349
|
+
return {
|
|
1350
|
+
status: "committed",
|
|
1351
|
+
data: { noteId: first.id },
|
|
1352
|
+
committedAt: Date.now(),
|
|
1353
|
+
idempotentReplay: false
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1356
|
+
function sanitizeIdempotencyKey(k) {
|
|
1357
|
+
return k.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64);
|
|
1358
|
+
}
|
|
1359
|
+
async function ensureFreshAccessToken4(creds, clientId, clientSecret) {
|
|
1360
|
+
if (creds.kind !== "oauth2") {
|
|
1361
|
+
throw new Error("hubspot: expected oauth2 credentials");
|
|
1362
|
+
}
|
|
1363
|
+
if (creds.accessToken && (!creds.expiresAt || creds.expiresAt > Date.now() + 6e4)) {
|
|
1364
|
+
return creds.accessToken;
|
|
1365
|
+
}
|
|
1366
|
+
if (!creds.refreshToken) {
|
|
1367
|
+
throw new CredentialsExpired("HubSpot access token expired and no refresh token", "");
|
|
1368
|
+
}
|
|
1369
|
+
const refreshed = await refreshAccessToken({
|
|
1370
|
+
tokenUrl: TOKEN_URL4,
|
|
1371
|
+
clientId,
|
|
1372
|
+
clientSecret,
|
|
1373
|
+
refreshToken: creds.refreshToken
|
|
1374
|
+
});
|
|
1375
|
+
creds.accessToken = refreshed.accessToken;
|
|
1376
|
+
creds.expiresAt = refreshed.expiresIn ? Date.now() + refreshed.expiresIn * 1e3 : void 0;
|
|
1377
|
+
if (refreshed.refreshToken) creds.refreshToken = refreshed.refreshToken;
|
|
1378
|
+
return creds.accessToken;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// src/connectors/adapters/slack.ts
|
|
1382
|
+
var SCOPES5 = ["chat:write", "users:read", "users:read.email", "channels:read"];
|
|
1383
|
+
var AUTH_URL5 = "https://slack.com/oauth/v2/authorize";
|
|
1384
|
+
var TOKEN_URL5 = "https://slack.com/api/oauth.v2.access";
|
|
1385
|
+
var API2 = "https://slack.com/api";
|
|
1386
|
+
function slack(opts) {
|
|
1387
|
+
const { clientId, clientSecret } = opts;
|
|
1388
|
+
const adapter = {
|
|
1389
|
+
manifest: {
|
|
1390
|
+
// The inbound Events API receiver registers kind `slack-inbound`
|
|
1391
|
+
// (hmac signing-secret auth). This connector — kind `slack` —
|
|
1392
|
+
// carries the OAuth bot-token outbound surface. Two kinds, one
|
|
1393
|
+
// logical product, deliberately split because the credential shapes
|
|
1394
|
+
// are different (HMAC secret vs bot OAuth) and operators commonly
|
|
1395
|
+
// wire one without the other.
|
|
1396
|
+
kind: "slack",
|
|
1397
|
+
displayName: "Slack",
|
|
1398
|
+
description: "Post messages from the agent into Slack, look up users by email, and list channels. Advisory surface \u2014 Slack posts are informational, not transactional.",
|
|
1399
|
+
auth: {
|
|
1400
|
+
kind: "oauth2",
|
|
1401
|
+
authorizationUrl: AUTH_URL5,
|
|
1402
|
+
tokenUrl: TOKEN_URL5,
|
|
1403
|
+
scopes: SCOPES5,
|
|
1404
|
+
clientIdEnv: "SLACK_OAUTH_CLIENT_ID",
|
|
1405
|
+
clientSecretEnv: "SLACK_OAUTH_CLIENT_SECRET"
|
|
1406
|
+
},
|
|
1407
|
+
category: "comms",
|
|
1408
|
+
defaultConsistencyModel: "advisory",
|
|
1409
|
+
capabilities: [
|
|
1410
|
+
{
|
|
1411
|
+
name: "post_message",
|
|
1412
|
+
class: "mutation",
|
|
1413
|
+
description: "Post a message from the bot to a channel or user DM. Append-only \u2014 no CAS.",
|
|
1414
|
+
cas: "none",
|
|
1415
|
+
externalEffect: true,
|
|
1416
|
+
parameters: {
|
|
1417
|
+
type: "object",
|
|
1418
|
+
properties: {
|
|
1419
|
+
channel: { type: "string", description: "Channel id (C\u2026) or user id (U\u2026) for DM." },
|
|
1420
|
+
text: { type: "string" },
|
|
1421
|
+
blocks: { type: "array", description: "Optional Slack Block Kit blocks." }
|
|
1422
|
+
},
|
|
1423
|
+
required: ["channel"]
|
|
1424
|
+
}
|
|
1425
|
+
},
|
|
1426
|
+
{
|
|
1427
|
+
name: "lookup_user",
|
|
1428
|
+
class: "read",
|
|
1429
|
+
description: "Look up a Slack workspace user by email.",
|
|
1430
|
+
parameters: {
|
|
1431
|
+
type: "object",
|
|
1432
|
+
properties: { email: { type: "string" } },
|
|
1433
|
+
required: ["email"]
|
|
1434
|
+
}
|
|
1435
|
+
},
|
|
1436
|
+
{
|
|
1437
|
+
name: "list_channels",
|
|
1438
|
+
class: "read",
|
|
1439
|
+
description: "List channels visible to the bot. `types` defaults to public_channel,private_channel.",
|
|
1440
|
+
parameters: {
|
|
1441
|
+
type: "object",
|
|
1442
|
+
properties: {
|
|
1443
|
+
types: { type: "string", description: "Comma-separated channel types." },
|
|
1444
|
+
limit: { type: "integer", minimum: 1, maximum: 1e3, default: 200 }
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
]
|
|
1449
|
+
},
|
|
1450
|
+
async executeRead(inv) {
|
|
1451
|
+
const accessToken = readBotToken(inv.source.credentials);
|
|
1452
|
+
if (inv.capabilityName === "lookup_user") {
|
|
1453
|
+
const { email } = inv.args;
|
|
1454
|
+
const url = `${API2}/users.lookupByEmail?email=${encodeURIComponent(email)}`;
|
|
1455
|
+
const json = await slackGet(url, accessToken, inv.source.id);
|
|
1456
|
+
if (!json.ok) {
|
|
1457
|
+
if (json.error === "users_not_found") {
|
|
1458
|
+
return { data: { found: false }, fetchedAt: Date.now() };
|
|
1459
|
+
}
|
|
1460
|
+
throw new Error(`slack lookup_user: ${json.error ?? "unknown"}`);
|
|
1461
|
+
}
|
|
1462
|
+
const u = json.user;
|
|
1463
|
+
return {
|
|
1464
|
+
data: { found: true, user: u ? { id: u.id, name: u.name, realName: u.real_name } : null },
|
|
1465
|
+
fetchedAt: Date.now()
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
if (inv.capabilityName === "list_channels") {
|
|
1469
|
+
const { types, limit } = inv.args;
|
|
1470
|
+
const params = new URLSearchParams({
|
|
1471
|
+
limit: String(Math.min(Math.max(1, limit ?? 200), 1e3)),
|
|
1472
|
+
types: types ?? "public_channel,private_channel"
|
|
1473
|
+
});
|
|
1474
|
+
const json = await slackGet(`${API2}/conversations.list?${params.toString()}`, accessToken, inv.source.id);
|
|
1475
|
+
if (!json.ok) {
|
|
1476
|
+
throw new Error(`slack list_channels: ${json.error ?? "unknown"}`);
|
|
1477
|
+
}
|
|
1478
|
+
const channels = json.channels ?? [];
|
|
1479
|
+
return {
|
|
1480
|
+
data: { channels: channels.map((c) => ({ id: c.id, name: c.name, isPrivate: c.is_private ?? false })) },
|
|
1481
|
+
fetchedAt: Date.now()
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1484
|
+
throw new Error(`slack: unknown read capability ${inv.capabilityName}`);
|
|
1485
|
+
},
|
|
1486
|
+
async executeMutation(inv) {
|
|
1487
|
+
if (inv.capabilityName !== "post_message") {
|
|
1488
|
+
throw new Error(`slack: unknown mutation capability ${inv.capabilityName}`);
|
|
1489
|
+
}
|
|
1490
|
+
const accessToken = readBotToken(inv.source.credentials);
|
|
1491
|
+
const { channel, text, blocks } = inv.args;
|
|
1492
|
+
const res = await fetch(`${API2}/chat.postMessage`, {
|
|
1493
|
+
method: "POST",
|
|
1494
|
+
headers: {
|
|
1495
|
+
authorization: `Bearer ${accessToken}`,
|
|
1496
|
+
"content-type": "application/json; charset=utf-8"
|
|
1497
|
+
},
|
|
1498
|
+
body: JSON.stringify({ channel, text, blocks }),
|
|
1499
|
+
signal: AbortSignal.timeout(15e3)
|
|
1500
|
+
});
|
|
1501
|
+
if (res.status === 401) {
|
|
1502
|
+
throw new CredentialsExpired("Slack rejected token (401)", inv.source.id);
|
|
1503
|
+
}
|
|
1504
|
+
if (!res.ok) {
|
|
1505
|
+
const t = await res.text().catch(() => "");
|
|
1506
|
+
throw new Error(`slack post_message HTTP ${res.status}: ${t.slice(0, 200)}`);
|
|
1507
|
+
}
|
|
1508
|
+
const json = await res.json();
|
|
1509
|
+
if (!json.ok) {
|
|
1510
|
+
if (json.error === "invalid_auth" || json.error === "token_expired" || json.error === "not_authed" || json.error === "token_revoked") {
|
|
1511
|
+
throw new CredentialsExpired(`Slack rejected token: ${json.error}`, inv.source.id);
|
|
1512
|
+
}
|
|
1513
|
+
throw new Error(`slack post_message: ${json.error ?? "unknown"}`);
|
|
1514
|
+
}
|
|
1515
|
+
return {
|
|
1516
|
+
status: "committed",
|
|
1517
|
+
data: { ts: json.ts, channel: json.channel },
|
|
1518
|
+
committedAt: Date.now(),
|
|
1519
|
+
idempotentReplay: false
|
|
1520
|
+
};
|
|
1521
|
+
},
|
|
1522
|
+
async exchangeOAuth(input) {
|
|
1523
|
+
if (!clientId || !clientSecret) {
|
|
1524
|
+
throw new Error("Slack OAuth client not configured (SLACK_OAUTH_CLIENT_ID / _SECRET)");
|
|
1525
|
+
}
|
|
1526
|
+
const tokens = await exchangeAuthorizationCode({
|
|
1527
|
+
tokenUrl: TOKEN_URL5,
|
|
1528
|
+
clientId,
|
|
1529
|
+
clientSecret,
|
|
1530
|
+
code: input.code,
|
|
1531
|
+
codeVerifier: input.codeVerifier,
|
|
1532
|
+
redirectUri: input.redirectUri
|
|
1533
|
+
});
|
|
1534
|
+
return {
|
|
1535
|
+
credentials: {
|
|
1536
|
+
kind: "oauth2",
|
|
1537
|
+
accessToken: tokens.accessToken,
|
|
1538
|
+
refreshToken: tokens.refreshToken,
|
|
1539
|
+
expiresAt: tokens.expiresIn ? Date.now() + tokens.expiresIn * 1e3 : void 0
|
|
1540
|
+
},
|
|
1541
|
+
scopes: tokens.scope?.split(/[,\s]+/) ?? SCOPES5,
|
|
1542
|
+
metadata: {}
|
|
1543
|
+
};
|
|
1544
|
+
},
|
|
1545
|
+
async refreshToken(creds) {
|
|
1546
|
+
if (creds.kind !== "oauth2" || !creds.refreshToken) {
|
|
1547
|
+
return creds;
|
|
1548
|
+
}
|
|
1549
|
+
const refreshed = await refreshAccessToken({
|
|
1550
|
+
tokenUrl: TOKEN_URL5,
|
|
1551
|
+
clientId,
|
|
1552
|
+
clientSecret,
|
|
1553
|
+
refreshToken: creds.refreshToken
|
|
1554
|
+
});
|
|
1555
|
+
return {
|
|
1556
|
+
kind: "oauth2",
|
|
1557
|
+
accessToken: refreshed.accessToken,
|
|
1558
|
+
refreshToken: refreshed.refreshToken ?? creds.refreshToken,
|
|
1559
|
+
expiresAt: refreshed.expiresIn ? Date.now() + refreshed.expiresIn * 1e3 : void 0
|
|
1560
|
+
};
|
|
1561
|
+
},
|
|
1562
|
+
async test(source) {
|
|
1563
|
+
try {
|
|
1564
|
+
const accessToken = readBotToken(source.credentials);
|
|
1565
|
+
const res = await fetch(`${API2}/auth.test`, {
|
|
1566
|
+
method: "POST",
|
|
1567
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
1568
|
+
signal: AbortSignal.timeout(8e3)
|
|
1569
|
+
});
|
|
1570
|
+
if (!res.ok) return { ok: false, reason: `Slack returned ${res.status}` };
|
|
1571
|
+
const json = await res.json();
|
|
1572
|
+
if (!json.ok) {
|
|
1573
|
+
return { ok: false, reason: `Slack auth.test: ${json.error ?? "unknown"} \u2014 reconnect required` };
|
|
1574
|
+
}
|
|
1575
|
+
return { ok: true };
|
|
1576
|
+
} catch (err) {
|
|
1577
|
+
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
};
|
|
1581
|
+
return adapter;
|
|
1582
|
+
}
|
|
1583
|
+
function readBotToken(creds) {
|
|
1584
|
+
if (creds.kind !== "oauth2" || typeof creds.accessToken !== "string") {
|
|
1585
|
+
throw new Error("slack: expected oauth2 credentials");
|
|
1586
|
+
}
|
|
1587
|
+
return creds.accessToken;
|
|
1588
|
+
}
|
|
1589
|
+
async function slackGet(url, accessToken, dataSourceId) {
|
|
1590
|
+
const res = await fetch(url, {
|
|
1591
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
1592
|
+
signal: AbortSignal.timeout(1e4)
|
|
1593
|
+
});
|
|
1594
|
+
if (res.status === 401) {
|
|
1595
|
+
throw new CredentialsExpired("Slack rejected token (401)", dataSourceId);
|
|
1596
|
+
}
|
|
1597
|
+
if (!res.ok) {
|
|
1598
|
+
const t = await res.text().catch(() => "");
|
|
1599
|
+
throw new Error(`slack HTTP ${res.status}: ${t.slice(0, 200)}`);
|
|
1600
|
+
}
|
|
1601
|
+
return await res.json();
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// src/connectors/adapters/notion-database.ts
|
|
1605
|
+
var AUTH_URL6 = "https://api.notion.com/v1/oauth/authorize";
|
|
1606
|
+
var TOKEN_URL6 = "https://api.notion.com/v1/oauth/token";
|
|
1607
|
+
var API3 = "https://api.notion.com/v1";
|
|
1608
|
+
var NOTION_VERSION = "2022-06-28";
|
|
1609
|
+
function notionDatabase(opts) {
|
|
1610
|
+
const { clientId, clientSecret } = opts;
|
|
1611
|
+
const adapter = {
|
|
1612
|
+
manifest: {
|
|
1613
|
+
kind: "notion-database",
|
|
1614
|
+
displayName: "Notion (database)",
|
|
1615
|
+
description: "Query a Notion database, create new pages, and update existing ones with optimistic concurrency via last_edited_time.",
|
|
1616
|
+
auth: {
|
|
1617
|
+
kind: "oauth2",
|
|
1618
|
+
authorizationUrl: AUTH_URL6,
|
|
1619
|
+
tokenUrl: TOKEN_URL6,
|
|
1620
|
+
// Notion does not use OAuth scopes — the workspace owner picks
|
|
1621
|
+
// which pages/databases the integration sees during install. We
|
|
1622
|
+
// declare an empty scope list so the consent screen renders cleanly.
|
|
1623
|
+
scopes: [],
|
|
1624
|
+
clientIdEnv: "NOTION_OAUTH_CLIENT_ID",
|
|
1625
|
+
clientSecretEnv: "NOTION_OAUTH_CLIENT_SECRET",
|
|
1626
|
+
extraAuthParams: { owner: "user" }
|
|
1627
|
+
},
|
|
1628
|
+
category: "doc",
|
|
1629
|
+
defaultConsistencyModel: "authoritative",
|
|
1630
|
+
capabilities: [
|
|
1631
|
+
{
|
|
1632
|
+
name: "query_database",
|
|
1633
|
+
class: "read",
|
|
1634
|
+
description: "Query the connected Notion database with an optional filter object (Notion query DSL).",
|
|
1635
|
+
parameters: {
|
|
1636
|
+
type: "object",
|
|
1637
|
+
properties: {
|
|
1638
|
+
filter: { type: "object", description: "Notion API filter object \u2014 passed through verbatim." },
|
|
1639
|
+
pageSize: { type: "integer", minimum: 1, maximum: 100, default: 50 },
|
|
1640
|
+
startCursor: { type: "string" }
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
},
|
|
1644
|
+
{
|
|
1645
|
+
name: "create_page",
|
|
1646
|
+
class: "mutation",
|
|
1647
|
+
description: "Create a new page inside the connected database.",
|
|
1648
|
+
cas: "native-idempotency",
|
|
1649
|
+
externalEffect: true,
|
|
1650
|
+
parameters: {
|
|
1651
|
+
type: "object",
|
|
1652
|
+
properties: {
|
|
1653
|
+
properties: {
|
|
1654
|
+
type: "object",
|
|
1655
|
+
description: "Notion property map keyed by property name."
|
|
1656
|
+
}
|
|
1657
|
+
},
|
|
1658
|
+
required: ["properties"]
|
|
1659
|
+
}
|
|
1660
|
+
},
|
|
1661
|
+
{
|
|
1662
|
+
name: "update_page",
|
|
1663
|
+
class: "mutation",
|
|
1664
|
+
description: "Update properties on an existing page. If `expectedLastEditedTime` is supplied and stale, the update is rejected with conflict.",
|
|
1665
|
+
cas: "etag-if-match",
|
|
1666
|
+
externalEffect: true,
|
|
1667
|
+
parameters: {
|
|
1668
|
+
type: "object",
|
|
1669
|
+
properties: {
|
|
1670
|
+
pageId: { type: "string" },
|
|
1671
|
+
properties: { type: "object" },
|
|
1672
|
+
expectedLastEditedTime: {
|
|
1673
|
+
type: "string",
|
|
1674
|
+
description: "RFC3339 timestamp the agent observed on its last read. Drift triggers ResourceContention."
|
|
1675
|
+
}
|
|
1676
|
+
},
|
|
1677
|
+
required: ["pageId", "properties"]
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
]
|
|
1681
|
+
},
|
|
1682
|
+
async executeRead(inv) {
|
|
1683
|
+
if (inv.capabilityName !== "query_database") {
|
|
1684
|
+
throw new Error(`notion-database: unknown read capability ${inv.capabilityName}`);
|
|
1685
|
+
}
|
|
1686
|
+
const accessToken = readToken(inv.source.credentials);
|
|
1687
|
+
const databaseId = readMetaString3(inv.source.metadata, "databaseId");
|
|
1688
|
+
const { filter, pageSize, startCursor } = inv.args;
|
|
1689
|
+
const body = {
|
|
1690
|
+
page_size: Math.min(Math.max(1, pageSize ?? 50), 100)
|
|
1691
|
+
};
|
|
1692
|
+
if (filter) body.filter = filter;
|
|
1693
|
+
if (startCursor) body.start_cursor = startCursor;
|
|
1694
|
+
const res = await fetch(`${API3}/databases/${encodeURIComponent(databaseId)}/query`, {
|
|
1695
|
+
method: "POST",
|
|
1696
|
+
headers: notionHeaders(accessToken),
|
|
1697
|
+
body: JSON.stringify(body),
|
|
1698
|
+
signal: AbortSignal.timeout(1e4)
|
|
1699
|
+
});
|
|
1700
|
+
if (res.status === 401) {
|
|
1701
|
+
throw new CredentialsExpired("Notion rejected token (401)", inv.source.id);
|
|
1702
|
+
}
|
|
1703
|
+
if (!res.ok) {
|
|
1704
|
+
const text = await res.text().catch(() => "");
|
|
1705
|
+
throw new Error(`notion-database query_database ${res.status}: ${text.slice(0, 200)}`);
|
|
1706
|
+
}
|
|
1707
|
+
const json = await res.json();
|
|
1708
|
+
return {
|
|
1709
|
+
data: {
|
|
1710
|
+
results: json.results ?? [],
|
|
1711
|
+
hasMore: json.has_more ?? false,
|
|
1712
|
+
nextCursor: json.next_cursor ?? null
|
|
1713
|
+
},
|
|
1714
|
+
fetchedAt: Date.now()
|
|
1715
|
+
};
|
|
1716
|
+
},
|
|
1717
|
+
async executeMutation(inv) {
|
|
1718
|
+
const accessToken = readToken(inv.source.credentials);
|
|
1719
|
+
if (inv.capabilityName === "create_page") return createPage(inv, accessToken);
|
|
1720
|
+
if (inv.capabilityName === "update_page") return updatePage(inv, accessToken);
|
|
1721
|
+
throw new Error(`notion-database: unknown mutation capability ${inv.capabilityName}`);
|
|
1722
|
+
},
|
|
1723
|
+
async exchangeOAuth(input) {
|
|
1724
|
+
if (!clientId || !clientSecret) {
|
|
1725
|
+
throw new Error("Notion OAuth client not configured (NOTION_OAUTH_CLIENT_ID / _SECRET)");
|
|
1726
|
+
}
|
|
1727
|
+
const body = new URLSearchParams({
|
|
1728
|
+
grant_type: "authorization_code",
|
|
1729
|
+
code: input.code,
|
|
1730
|
+
redirect_uri: input.redirectUri,
|
|
1731
|
+
code_verifier: input.codeVerifier
|
|
1732
|
+
});
|
|
1733
|
+
const res = await fetch(TOKEN_URL6, {
|
|
1734
|
+
method: "POST",
|
|
1735
|
+
headers: {
|
|
1736
|
+
authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`,
|
|
1737
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
1738
|
+
accept: "application/json",
|
|
1739
|
+
"Notion-Version": NOTION_VERSION
|
|
1740
|
+
},
|
|
1741
|
+
body
|
|
1742
|
+
});
|
|
1743
|
+
if (!res.ok) {
|
|
1744
|
+
const text = await res.text().catch(() => "");
|
|
1745
|
+
throw new Error(`Notion OAuth token exchange failed: ${res.status} \u2014 ${text.slice(0, 200)}`);
|
|
1746
|
+
}
|
|
1747
|
+
const json = await res.json();
|
|
1748
|
+
return {
|
|
1749
|
+
credentials: {
|
|
1750
|
+
kind: "oauth2",
|
|
1751
|
+
accessToken: json.access_token,
|
|
1752
|
+
refreshToken: json.refresh_token
|
|
1753
|
+
},
|
|
1754
|
+
scopes: [],
|
|
1755
|
+
metadata: {
|
|
1756
|
+
botId: json.bot_id,
|
|
1757
|
+
workspaceId: json.workspace_id,
|
|
1758
|
+
workspaceName: json.workspace_name,
|
|
1759
|
+
// Operator picks the database in a follow-up step; default empty.
|
|
1760
|
+
databaseId: ""
|
|
1761
|
+
}
|
|
1762
|
+
};
|
|
1763
|
+
},
|
|
1764
|
+
async refreshToken(creds) {
|
|
1765
|
+
if (creds.kind !== "oauth2" || !creds.refreshToken) {
|
|
1766
|
+
if (creds.kind === "oauth2" && creds.accessToken && !creds.expiresAt) {
|
|
1767
|
+
return creds;
|
|
1768
|
+
}
|
|
1769
|
+
throw new Error("notion-database.refreshToken: missing refresh token");
|
|
1770
|
+
}
|
|
1771
|
+
const refreshed = await refreshAccessToken({
|
|
1772
|
+
tokenUrl: TOKEN_URL6,
|
|
1773
|
+
clientId,
|
|
1774
|
+
clientSecret,
|
|
1775
|
+
refreshToken: creds.refreshToken
|
|
1776
|
+
});
|
|
1777
|
+
return {
|
|
1778
|
+
kind: "oauth2",
|
|
1779
|
+
accessToken: refreshed.accessToken,
|
|
1780
|
+
refreshToken: refreshed.refreshToken ?? creds.refreshToken,
|
|
1781
|
+
expiresAt: refreshed.expiresIn ? Date.now() + refreshed.expiresIn * 1e3 : void 0
|
|
1782
|
+
};
|
|
1783
|
+
},
|
|
1784
|
+
async test(source) {
|
|
1785
|
+
try {
|
|
1786
|
+
const accessToken = readToken(source.credentials);
|
|
1787
|
+
const res = await fetch(`${API3}/users/me`, {
|
|
1788
|
+
headers: notionHeaders(accessToken),
|
|
1789
|
+
signal: AbortSignal.timeout(8e3)
|
|
1790
|
+
});
|
|
1791
|
+
if (res.status === 401) return { ok: false, reason: "Notion rejected token (401) \u2014 reconnect required" };
|
|
1792
|
+
if (!res.ok) return { ok: false, reason: `Notion returned ${res.status}` };
|
|
1793
|
+
return { ok: true };
|
|
1794
|
+
} catch (err) {
|
|
1795
|
+
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
};
|
|
1799
|
+
return adapter;
|
|
1800
|
+
}
|
|
1801
|
+
async function createPage(inv, accessToken) {
|
|
1802
|
+
const databaseId = readMetaString3(inv.source.metadata, "databaseId");
|
|
1803
|
+
const { properties } = inv.args;
|
|
1804
|
+
const res = await fetch(`${API3}/pages`, {
|
|
1805
|
+
method: "POST",
|
|
1806
|
+
headers: {
|
|
1807
|
+
...notionHeaders(accessToken),
|
|
1808
|
+
"Idempotency-Key": inv.idempotencyKey
|
|
1809
|
+
},
|
|
1810
|
+
body: JSON.stringify({
|
|
1811
|
+
parent: { database_id: databaseId },
|
|
1812
|
+
properties
|
|
1813
|
+
}),
|
|
1814
|
+
signal: AbortSignal.timeout(15e3)
|
|
1815
|
+
});
|
|
1816
|
+
if (res.status === 401) {
|
|
1817
|
+
throw new CredentialsExpired("Notion rejected token (401)", inv.source.id);
|
|
1818
|
+
}
|
|
1819
|
+
if (res.status === 409) {
|
|
1820
|
+
throw new ResourceContention("Notion idempotency-key conflict \u2014 different args under same key");
|
|
1821
|
+
}
|
|
1822
|
+
if (!res.ok) {
|
|
1823
|
+
const text = await res.text().catch(() => "");
|
|
1824
|
+
throw new Error(`notion-database create_page ${res.status}: ${text.slice(0, 200)}`);
|
|
1825
|
+
}
|
|
1826
|
+
const created = await res.json();
|
|
1827
|
+
return {
|
|
1828
|
+
status: "committed",
|
|
1829
|
+
data: { pageId: created.id, url: created.url, lastEditedTime: created.last_edited_time },
|
|
1830
|
+
etagAfter: created.last_edited_time,
|
|
1831
|
+
committedAt: Date.now(),
|
|
1832
|
+
idempotentReplay: false
|
|
1833
|
+
};
|
|
1834
|
+
}
|
|
1835
|
+
async function updatePage(inv, accessToken) {
|
|
1836
|
+
const { pageId, properties, expectedLastEditedTime } = inv.args;
|
|
1837
|
+
if (expectedLastEditedTime) {
|
|
1838
|
+
const headRes = await fetch(`${API3}/pages/${encodeURIComponent(pageId)}`, {
|
|
1839
|
+
headers: notionHeaders(accessToken),
|
|
1840
|
+
signal: AbortSignal.timeout(1e4)
|
|
1841
|
+
});
|
|
1842
|
+
if (headRes.status === 401) {
|
|
1843
|
+
throw new CredentialsExpired("Notion rejected token (401)", inv.source.id);
|
|
1844
|
+
}
|
|
1845
|
+
if (!headRes.ok) {
|
|
1846
|
+
const text = await headRes.text().catch(() => "");
|
|
1847
|
+
throw new Error(`notion-database update_page (preflight) ${headRes.status}: ${text.slice(0, 200)}`);
|
|
1848
|
+
}
|
|
1849
|
+
const page = await headRes.json();
|
|
1850
|
+
if (page.last_edited_time && page.last_edited_time !== expectedLastEditedTime) {
|
|
1851
|
+
throw new ResourceContention(
|
|
1852
|
+
`Notion page ${pageId} was modified since the agent last read it`,
|
|
1853
|
+
[],
|
|
1854
|
+
{ last_edited_time: page.last_edited_time, properties: page.properties }
|
|
1855
|
+
);
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
const res = await fetch(`${API3}/pages/${encodeURIComponent(pageId)}`, {
|
|
1859
|
+
method: "PATCH",
|
|
1860
|
+
headers: notionHeaders(accessToken),
|
|
1861
|
+
body: JSON.stringify({ properties }),
|
|
1862
|
+
signal: AbortSignal.timeout(15e3)
|
|
1863
|
+
});
|
|
1864
|
+
if (res.status === 401) {
|
|
1865
|
+
throw new CredentialsExpired("Notion rejected token (401)", inv.source.id);
|
|
1866
|
+
}
|
|
1867
|
+
if (res.status === 409 || res.status === 412) {
|
|
1868
|
+
throw new ResourceContention(`Notion update_page conflict (${res.status})`);
|
|
1869
|
+
}
|
|
1870
|
+
if (!res.ok) {
|
|
1871
|
+
const text = await res.text().catch(() => "");
|
|
1872
|
+
throw new Error(`notion-database update_page ${res.status}: ${text.slice(0, 200)}`);
|
|
1873
|
+
}
|
|
1874
|
+
const updated = await res.json();
|
|
1875
|
+
return {
|
|
1876
|
+
status: "committed",
|
|
1877
|
+
data: { pageId: updated.id, url: updated.url, lastEditedTime: updated.last_edited_time },
|
|
1878
|
+
etagAfter: updated.last_edited_time,
|
|
1879
|
+
committedAt: Date.now(),
|
|
1880
|
+
idempotentReplay: false
|
|
1881
|
+
};
|
|
1882
|
+
}
|
|
1883
|
+
function notionHeaders(accessToken) {
|
|
1884
|
+
return {
|
|
1885
|
+
authorization: `Bearer ${accessToken}`,
|
|
1886
|
+
"Notion-Version": NOTION_VERSION,
|
|
1887
|
+
"content-type": "application/json"
|
|
1888
|
+
};
|
|
1889
|
+
}
|
|
1890
|
+
function readToken(creds) {
|
|
1891
|
+
if (creds.kind !== "oauth2" || typeof creds.accessToken !== "string") {
|
|
1892
|
+
throw new Error("notion-database: expected oauth2 credentials");
|
|
1893
|
+
}
|
|
1894
|
+
return creds.accessToken;
|
|
1895
|
+
}
|
|
1896
|
+
function readMetaString3(meta, key) {
|
|
1897
|
+
const v = meta[key];
|
|
1898
|
+
if (typeof v !== "string" || v.length === 0) {
|
|
1899
|
+
throw new Error(`notion-database DataSource.metadata.${key} is missing`);
|
|
1900
|
+
}
|
|
1901
|
+
return v;
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
// src/connectors/adapters/declarative-rest.ts
|
|
1905
|
+
function declarativeRestConnector(spec) {
|
|
1906
|
+
const capabilities = spec.capabilities.map(operationToCapability);
|
|
1907
|
+
const adapter = {
|
|
1908
|
+
manifest: {
|
|
1909
|
+
kind: spec.kind,
|
|
1910
|
+
displayName: spec.displayName,
|
|
1911
|
+
description: spec.description,
|
|
1912
|
+
auth: spec.auth,
|
|
1913
|
+
category: spec.category,
|
|
1914
|
+
defaultConsistencyModel: spec.defaultConsistencyModel,
|
|
1915
|
+
capabilities
|
|
1916
|
+
},
|
|
1917
|
+
async executeRead(inv) {
|
|
1918
|
+
const op = readOperation(spec, inv.capabilityName, "read");
|
|
1919
|
+
const response = await executeRestRequest(spec, op.request, inv);
|
|
1920
|
+
return {
|
|
1921
|
+
data: response.data,
|
|
1922
|
+
etag: response.etag,
|
|
1923
|
+
fetchedAt: Date.now()
|
|
1924
|
+
};
|
|
1925
|
+
},
|
|
1926
|
+
async executeMutation(inv) {
|
|
1927
|
+
const op = readOperation(spec, inv.capabilityName, "mutation");
|
|
1928
|
+
const response = await executeRestRequest(spec, op.request, inv);
|
|
1929
|
+
return {
|
|
1930
|
+
status: "committed",
|
|
1931
|
+
data: response.data,
|
|
1932
|
+
etagAfter: response.etag,
|
|
1933
|
+
committedAt: Date.now(),
|
|
1934
|
+
idempotentReplay: false
|
|
1935
|
+
};
|
|
1936
|
+
},
|
|
1937
|
+
async test(source) {
|
|
1938
|
+
if (!spec.test) return { ok: true };
|
|
1939
|
+
try {
|
|
1940
|
+
await executeRestRequest(spec, spec.test, {
|
|
1941
|
+
source,
|
|
1942
|
+
capabilityName: "__test__",
|
|
1943
|
+
args: {},
|
|
1944
|
+
idempotencyKey: "test"
|
|
1945
|
+
});
|
|
1946
|
+
return { ok: true };
|
|
1947
|
+
} catch (error) {
|
|
1948
|
+
return { ok: false, reason: error instanceof Error ? error.message : "unknown error" };
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
};
|
|
1952
|
+
return adapter;
|
|
1953
|
+
}
|
|
1954
|
+
function operationToCapability(op) {
|
|
1955
|
+
const base = {
|
|
1956
|
+
name: op.name,
|
|
1957
|
+
description: op.description,
|
|
1958
|
+
parameters: op.parameters,
|
|
1959
|
+
requiredScopes: op.requiredScopes
|
|
1960
|
+
};
|
|
1961
|
+
if (op.class === "read") {
|
|
1962
|
+
return { ...base, class: "read" };
|
|
1963
|
+
}
|
|
1964
|
+
return {
|
|
1965
|
+
...base,
|
|
1966
|
+
class: "mutation",
|
|
1967
|
+
cas: op.cas ?? "native-idempotency",
|
|
1968
|
+
externalEffect: op.externalEffect ?? true
|
|
1969
|
+
};
|
|
1970
|
+
}
|
|
1971
|
+
function readOperation(spec, name, expected) {
|
|
1972
|
+
const op = spec.capabilities.find((candidate) => candidate.name === name);
|
|
1973
|
+
if (!op || op.class !== expected) {
|
|
1974
|
+
throw new Error(`${spec.kind}: unknown ${expected} capability ${name}`);
|
|
1975
|
+
}
|
|
1976
|
+
return op;
|
|
1977
|
+
}
|
|
1978
|
+
async function executeRestRequest(spec, request, inv) {
|
|
1979
|
+
const baseUrl = resolveBaseUrl(spec.baseUrl, inv.source.metadata);
|
|
1980
|
+
const url = new URL(interpolate(request.path, inv.args), baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`);
|
|
1981
|
+
for (const [key, value] of Object.entries(request.query ?? {})) {
|
|
1982
|
+
const rendered = renderQueryValue(value, inv.args);
|
|
1983
|
+
if (rendered !== void 0 && rendered !== "") url.searchParams.set(key, String(rendered));
|
|
1984
|
+
}
|
|
1985
|
+
const headers = {
|
|
1986
|
+
accept: "application/json",
|
|
1987
|
+
...spec.defaultHeaders,
|
|
1988
|
+
...renderHeaders(request.headers ?? {}, inv.args)
|
|
1989
|
+
};
|
|
1990
|
+
applyCredentials(headers, url, spec.credentialPlacement ?? { kind: "bearer" }, inv.source.credentials);
|
|
1991
|
+
if (inv.expectedEtag) headers["if-match"] = inv.expectedEtag;
|
|
1992
|
+
if (request.method !== "GET" && request.method !== "DELETE") {
|
|
1993
|
+
headers["content-type"] = headers["content-type"] ?? "application/json";
|
|
1994
|
+
}
|
|
1995
|
+
const res = await fetch(url, {
|
|
1996
|
+
method: request.method,
|
|
1997
|
+
headers,
|
|
1998
|
+
body: request.method === "GET" || request.method === "DELETE" ? void 0 : JSON.stringify(resolveBody(request.body, inv.args)),
|
|
1999
|
+
signal: AbortSignal.timeout(2e4)
|
|
2000
|
+
});
|
|
2001
|
+
if (res.status === 401 || res.status === 403) {
|
|
2002
|
+
throw new CredentialsExpired(`${spec.displayName} rejected credentials (${res.status})`, inv.source.id);
|
|
2003
|
+
}
|
|
2004
|
+
if (res.status === 409 || res.status === 412) {
|
|
2005
|
+
return {
|
|
2006
|
+
data: {
|
|
2007
|
+
status: "conflict",
|
|
2008
|
+
message: await safeErrorText(res)
|
|
2009
|
+
},
|
|
2010
|
+
etag: res.headers.get("etag") ?? void 0
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
2013
|
+
if (res.status === 429) {
|
|
2014
|
+
return {
|
|
2015
|
+
data: {
|
|
2016
|
+
status: "rate-limited",
|
|
2017
|
+
retryAfter: res.headers.get("retry-after") ?? void 0,
|
|
2018
|
+
message: await safeErrorText(res)
|
|
2019
|
+
}
|
|
2020
|
+
};
|
|
2021
|
+
}
|
|
2022
|
+
if (!res.ok) {
|
|
2023
|
+
throw new Error(`${spec.kind} ${request.method} ${url.pathname} HTTP ${res.status}: ${(await safeErrorText(res)).slice(0, 300)}`);
|
|
2024
|
+
}
|
|
2025
|
+
const text = await res.text();
|
|
2026
|
+
const data = text ? JSON.parse(text) : null;
|
|
2027
|
+
return { data, etag: res.headers.get("etag") ?? void 0 };
|
|
2028
|
+
}
|
|
2029
|
+
function resolveBaseUrl(baseUrl, metadata) {
|
|
2030
|
+
if (typeof baseUrl === "string") return baseUrl;
|
|
2031
|
+
const value = metadata[baseUrl.metadataKey];
|
|
2032
|
+
if (typeof value === "string" && value.trim()) return value;
|
|
2033
|
+
if (baseUrl.fallback) return baseUrl.fallback;
|
|
2034
|
+
throw new Error(`missing metadata.${baseUrl.metadataKey} base URL`);
|
|
2035
|
+
}
|
|
2036
|
+
function applyCredentials(headers, url, placement, credentials) {
|
|
2037
|
+
const token = credentialToken(credentials);
|
|
2038
|
+
if (placement.kind === "bearer") headers.authorization = `Bearer ${token}`;
|
|
2039
|
+
if (placement.kind === "header") headers[placement.header] = `${placement.prefix ?? ""}${token}`;
|
|
2040
|
+
if (placement.kind === "query") url.searchParams.set(placement.parameter, token);
|
|
2041
|
+
}
|
|
2042
|
+
function credentialToken(credentials) {
|
|
2043
|
+
if (credentials.kind === "oauth2") return credentials.accessToken;
|
|
2044
|
+
if (credentials.kind === "api-key") return credentials.apiKey;
|
|
2045
|
+
throw new Error(`declarative REST connectors require oauth2 or api-key credentials, got ${credentials.kind}`);
|
|
2046
|
+
}
|
|
2047
|
+
function resolveBody(body, args) {
|
|
2048
|
+
if (!body || body === "args") return args;
|
|
2049
|
+
if (typeof body === "string") return renderValue(body, args);
|
|
2050
|
+
return renderObject(body, args);
|
|
2051
|
+
}
|
|
2052
|
+
function renderHeaders(headers, args) {
|
|
2053
|
+
return Object.fromEntries(Object.entries(headers).map(([key, value]) => [key, interpolate(value, args)]));
|
|
2054
|
+
}
|
|
2055
|
+
function renderObject(input, args) {
|
|
2056
|
+
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, renderValue(value, args)]));
|
|
2057
|
+
}
|
|
2058
|
+
function renderValue(value, args) {
|
|
2059
|
+
if (typeof value === "string") {
|
|
2060
|
+
const exact = value.match(/^\{([a-zA-Z0-9_.-]+)\}$/);
|
|
2061
|
+
if (exact) return readRequiredPath(args, exact[1]);
|
|
2062
|
+
return interpolate(value, args);
|
|
2063
|
+
}
|
|
2064
|
+
return value;
|
|
2065
|
+
}
|
|
2066
|
+
function renderQueryValue(value, args) {
|
|
2067
|
+
if (typeof value !== "string") return value;
|
|
2068
|
+
const exact = value.match(/^\{([a-zA-Z0-9_.-]+)\}$/);
|
|
2069
|
+
if (exact) return readPath(args, exact[1]);
|
|
2070
|
+
try {
|
|
2071
|
+
return interpolate(value, args);
|
|
2072
|
+
} catch {
|
|
2073
|
+
return void 0;
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
function interpolate(template, args) {
|
|
2077
|
+
return template.replace(/\{([a-zA-Z0-9_.-]+)\}/g, (_match, key) => {
|
|
2078
|
+
const value = readPath(args, key);
|
|
2079
|
+
if (value === void 0 || value === null) {
|
|
2080
|
+
throw new Error(`missing required argument: ${key}`);
|
|
2081
|
+
}
|
|
2082
|
+
return encodeURIComponent(String(value));
|
|
2083
|
+
});
|
|
2084
|
+
}
|
|
2085
|
+
function readRequiredPath(input, path) {
|
|
2086
|
+
const value = readPath(input, path);
|
|
2087
|
+
if (value === void 0 || value === null) throw new Error(`missing required argument: ${path}`);
|
|
2088
|
+
return value;
|
|
2089
|
+
}
|
|
2090
|
+
function readPath(input, path) {
|
|
2091
|
+
return path.split(".").reduce((value, part) => {
|
|
2092
|
+
if (value && typeof value === "object" && part in value) {
|
|
2093
|
+
return value[part];
|
|
2094
|
+
}
|
|
2095
|
+
return void 0;
|
|
2096
|
+
}, input);
|
|
2097
|
+
}
|
|
2098
|
+
async function safeErrorText(res) {
|
|
2099
|
+
return await res.text().catch(() => res.statusText) || res.statusText;
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
// src/connectors/adapters/twilio-sms.ts
|
|
2103
|
+
var API4 = "https://api.twilio.com/2010-04-01";
|
|
2104
|
+
var LOOKUP_API = "https://lookups.twilio.com/v1";
|
|
2105
|
+
var twilioSmsConnector = {
|
|
2106
|
+
manifest: {
|
|
2107
|
+
kind: "twilio-sms",
|
|
2108
|
+
displayName: "Twilio SMS",
|
|
2109
|
+
description: "Send outbound SMS, look up phone numbers, and audit recent messages. Twilio's native Idempotency-Key prevents duplicate sends on retry.",
|
|
2110
|
+
auth: {
|
|
2111
|
+
kind: "api-key",
|
|
2112
|
+
hint: 'Paste your Twilio credentials as "AccountSid:AuthToken" (e.g. "AC123\u2026:abc\u2026"). API-key style "AccountSid:KeySid:Secret" is also accepted.'
|
|
2113
|
+
},
|
|
2114
|
+
category: "comms",
|
|
2115
|
+
defaultConsistencyModel: "authoritative",
|
|
2116
|
+
capabilities: [
|
|
2117
|
+
{
|
|
2118
|
+
name: "send_sms",
|
|
2119
|
+
class: "mutation",
|
|
2120
|
+
description: "Send an SMS from the configured Twilio number to the supplied destination.",
|
|
2121
|
+
cas: "native-idempotency",
|
|
2122
|
+
externalEffect: true,
|
|
2123
|
+
parameters: {
|
|
2124
|
+
type: "object",
|
|
2125
|
+
properties: {
|
|
2126
|
+
to: { type: "string", description: "E.164 destination, e.g. +14155551212" },
|
|
2127
|
+
body: { type: "string", description: "Message body (\u22641600 chars after Twilio segments)." },
|
|
2128
|
+
from: { type: "string", description: "Optional E.164 sender; falls back to metadata.fromNumber." }
|
|
2129
|
+
},
|
|
2130
|
+
required: ["to", "body"]
|
|
2131
|
+
}
|
|
2132
|
+
},
|
|
2133
|
+
{
|
|
2134
|
+
name: "lookup_number",
|
|
2135
|
+
class: "read",
|
|
2136
|
+
description: "Validate a phone number and (if your account has Lookup) retrieve carrier metadata.",
|
|
2137
|
+
parameters: {
|
|
2138
|
+
type: "object",
|
|
2139
|
+
properties: {
|
|
2140
|
+
phoneNumber: { type: "string", description: "E.164 number to look up." },
|
|
2141
|
+
includeCarrier: { type: "boolean", default: false }
|
|
2142
|
+
},
|
|
2143
|
+
required: ["phoneNumber"]
|
|
2144
|
+
}
|
|
2145
|
+
},
|
|
2146
|
+
{
|
|
2147
|
+
name: "find_recent_messages",
|
|
2148
|
+
class: "read",
|
|
2149
|
+
description: "Return up to `limit` recent Messages on the account, optionally filtered by To or From.",
|
|
2150
|
+
parameters: {
|
|
2151
|
+
type: "object",
|
|
2152
|
+
properties: {
|
|
2153
|
+
to: { type: "string", description: "Optional E.164 filter on the To address." },
|
|
2154
|
+
from: { type: "string", description: "Optional E.164 filter on the From address." },
|
|
2155
|
+
limit: { type: "integer", minimum: 1, maximum: 100, default: 20 }
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
]
|
|
2160
|
+
},
|
|
2161
|
+
async executeRead(inv) {
|
|
2162
|
+
const auth = parseAuth(inv.source.credentials);
|
|
2163
|
+
if (inv.capabilityName === "lookup_number") {
|
|
2164
|
+
const { phoneNumber, includeCarrier } = inv.args;
|
|
2165
|
+
const url = `${LOOKUP_API}/PhoneNumbers/${encodeURIComponent(phoneNumber)}${includeCarrier ? "?Type=carrier" : ""}`;
|
|
2166
|
+
const res = await fetch(url, {
|
|
2167
|
+
headers: { authorization: basicAuth(auth) },
|
|
2168
|
+
signal: AbortSignal.timeout(1e4)
|
|
2169
|
+
});
|
|
2170
|
+
if (res.status === 401) throw new CredentialsExpired("Twilio rejected credentials (401)", inv.source.id);
|
|
2171
|
+
if (res.status === 404) {
|
|
2172
|
+
return { data: { valid: false }, fetchedAt: Date.now() };
|
|
2173
|
+
}
|
|
2174
|
+
if (!res.ok) {
|
|
2175
|
+
const text = await res.text().catch(() => "");
|
|
2176
|
+
throw new Error(`twilio-sms lookup_number ${res.status}: ${text.slice(0, 200)}`);
|
|
2177
|
+
}
|
|
2178
|
+
const json = await res.json();
|
|
2179
|
+
return {
|
|
2180
|
+
data: {
|
|
2181
|
+
valid: true,
|
|
2182
|
+
phoneNumber: json.phone_number,
|
|
2183
|
+
countryCode: json.country_code,
|
|
2184
|
+
carrier: json.carrier
|
|
2185
|
+
},
|
|
2186
|
+
fetchedAt: Date.now()
|
|
2187
|
+
};
|
|
2188
|
+
}
|
|
2189
|
+
if (inv.capabilityName === "find_recent_messages") {
|
|
2190
|
+
const { to, from, limit } = inv.args;
|
|
2191
|
+
const params = new URLSearchParams();
|
|
2192
|
+
params.set("PageSize", String(Math.min(Math.max(1, limit ?? 20), 100)));
|
|
2193
|
+
if (to) params.set("To", to);
|
|
2194
|
+
if (from) params.set("From", from);
|
|
2195
|
+
const url = `${API4}/Accounts/${encodeURIComponent(auth.accountSid)}/Messages.json?${params.toString()}`;
|
|
2196
|
+
const res = await fetch(url, {
|
|
2197
|
+
headers: { authorization: basicAuth(auth) },
|
|
2198
|
+
signal: AbortSignal.timeout(1e4)
|
|
2199
|
+
});
|
|
2200
|
+
if (res.status === 401) throw new CredentialsExpired("Twilio rejected credentials (401)", inv.source.id);
|
|
2201
|
+
if (!res.ok) {
|
|
2202
|
+
const text = await res.text().catch(() => "");
|
|
2203
|
+
throw new Error(`twilio-sms find_recent_messages ${res.status}: ${text.slice(0, 200)}`);
|
|
2204
|
+
}
|
|
2205
|
+
const json = await res.json();
|
|
2206
|
+
return {
|
|
2207
|
+
data: { messages: json.messages ?? [] },
|
|
2208
|
+
fetchedAt: Date.now()
|
|
2209
|
+
};
|
|
2210
|
+
}
|
|
2211
|
+
throw new Error(`twilio-sms: unknown read capability ${inv.capabilityName}`);
|
|
2212
|
+
},
|
|
2213
|
+
async executeMutation(inv) {
|
|
2214
|
+
if (inv.capabilityName !== "send_sms") {
|
|
2215
|
+
throw new Error(`twilio-sms: unknown mutation capability ${inv.capabilityName}`);
|
|
2216
|
+
}
|
|
2217
|
+
const auth = parseAuth(inv.source.credentials);
|
|
2218
|
+
const { to, body, from } = inv.args;
|
|
2219
|
+
const fromNumber = from ?? readMetaString4(inv.source.metadata, "fromNumber");
|
|
2220
|
+
const formBody = new URLSearchParams({ To: to, From: fromNumber, Body: body });
|
|
2221
|
+
const url = `${API4}/Accounts/${encodeURIComponent(auth.accountSid)}/Messages.json`;
|
|
2222
|
+
const res = await fetch(url, {
|
|
2223
|
+
method: "POST",
|
|
2224
|
+
headers: {
|
|
2225
|
+
authorization: basicAuth(auth),
|
|
2226
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
2227
|
+
"idempotency-key": inv.idempotencyKey
|
|
2228
|
+
},
|
|
2229
|
+
body: formBody,
|
|
2230
|
+
signal: AbortSignal.timeout(15e3)
|
|
2231
|
+
});
|
|
2232
|
+
if (res.status === 401) throw new CredentialsExpired("Twilio rejected credentials (401)", inv.source.id);
|
|
2233
|
+
if (res.status === 409) {
|
|
2234
|
+
throw new ResourceContention("Twilio idempotency-key conflict \u2014 different args under same key");
|
|
2235
|
+
}
|
|
2236
|
+
if (!res.ok) {
|
|
2237
|
+
const text = await res.text().catch(() => "");
|
|
2238
|
+
throw new Error(`twilio-sms send_sms ${res.status}: ${text.slice(0, 200)}`);
|
|
2239
|
+
}
|
|
2240
|
+
const created = await res.json();
|
|
2241
|
+
return {
|
|
2242
|
+
status: "committed",
|
|
2243
|
+
data: { messageSid: created.sid, deliveryStatus: created.status, to: created.to, from: created.from },
|
|
2244
|
+
committedAt: Date.now(),
|
|
2245
|
+
idempotentReplay: false
|
|
2246
|
+
};
|
|
2247
|
+
},
|
|
2248
|
+
async test(source) {
|
|
2249
|
+
try {
|
|
2250
|
+
const auth = parseAuth(source.credentials);
|
|
2251
|
+
const res = await fetch(`${API4}/Accounts/${encodeURIComponent(auth.accountSid)}.json`, {
|
|
2252
|
+
headers: { authorization: basicAuth(auth) },
|
|
2253
|
+
signal: AbortSignal.timeout(8e3)
|
|
2254
|
+
});
|
|
2255
|
+
if (res.status === 401) return { ok: false, reason: "Twilio rejected credentials (401) \u2014 reconnect required" };
|
|
2256
|
+
if (!res.ok) return { ok: false, reason: `Twilio returned ${res.status}` };
|
|
2257
|
+
return { ok: true };
|
|
2258
|
+
} catch (err) {
|
|
2259
|
+
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
};
|
|
2263
|
+
function parseAuth(creds) {
|
|
2264
|
+
if (creds.kind !== "api-key" || typeof creds.apiKey !== "string") {
|
|
2265
|
+
throw new Error("twilio-sms: expected api-key credentials");
|
|
2266
|
+
}
|
|
2267
|
+
const parts = creds.apiKey.split(":");
|
|
2268
|
+
if (parts.length === 2) {
|
|
2269
|
+
const [accountSid, authToken] = parts;
|
|
2270
|
+
if (!accountSid.startsWith("AC")) {
|
|
2271
|
+
throw new Error('twilio-sms: AccountSid must start with "AC"');
|
|
2272
|
+
}
|
|
2273
|
+
return { accountSid, username: accountSid, password: authToken };
|
|
2274
|
+
}
|
|
2275
|
+
if (parts.length === 3) {
|
|
2276
|
+
const [accountSid, keySid, secret] = parts;
|
|
2277
|
+
if (!accountSid.startsWith("AC")) {
|
|
2278
|
+
throw new Error('twilio-sms: AccountSid must start with "AC"');
|
|
2279
|
+
}
|
|
2280
|
+
return { accountSid, username: keySid, password: secret };
|
|
2281
|
+
}
|
|
2282
|
+
throw new Error('twilio-sms: apiKey must be "AccountSid:AuthToken" or "AccountSid:KeySid:Secret"');
|
|
2283
|
+
}
|
|
2284
|
+
function basicAuth(auth) {
|
|
2285
|
+
return `Basic ${Buffer.from(`${auth.username}:${auth.password}`).toString("base64")}`;
|
|
2286
|
+
}
|
|
2287
|
+
function readMetaString4(meta, key) {
|
|
2288
|
+
const v = meta[key];
|
|
2289
|
+
if (typeof v !== "string" || v.length === 0) {
|
|
2290
|
+
throw new Error(`twilio-sms DataSource.metadata.${key} is missing`);
|
|
2291
|
+
}
|
|
2292
|
+
return v;
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
// src/connectors/adapters/stripe-pack.ts
|
|
2296
|
+
var API5 = "https://api.stripe.com/v1";
|
|
2297
|
+
var stripePackConnector = {
|
|
2298
|
+
manifest: {
|
|
2299
|
+
kind: "stripe-pack",
|
|
2300
|
+
displayName: "Stripe (customers, invoices, checkout)",
|
|
2301
|
+
description: "Look up Stripe customers, draft invoices, and spin up hosted Checkout sessions from a single Stripe restricted key. Idempotency-Key forwarded on every mutation.",
|
|
2302
|
+
auth: {
|
|
2303
|
+
kind: "api-key",
|
|
2304
|
+
hint: "Paste a Stripe restricted key (rk_live_\u2026) with read access on customers and write access on invoices + checkout sessions."
|
|
2305
|
+
},
|
|
2306
|
+
category: "commerce",
|
|
2307
|
+
defaultConsistencyModel: "authoritative",
|
|
2308
|
+
capabilities: [
|
|
2309
|
+
{
|
|
2310
|
+
name: "find_customer",
|
|
2311
|
+
class: "read",
|
|
2312
|
+
description: "Search Stripe customers by email. Returns the first match or {found:false}.",
|
|
2313
|
+
parameters: {
|
|
2314
|
+
type: "object",
|
|
2315
|
+
properties: { email: { type: "string" } },
|
|
2316
|
+
required: ["email"]
|
|
2317
|
+
}
|
|
2318
|
+
},
|
|
2319
|
+
{
|
|
2320
|
+
name: "create_invoice",
|
|
2321
|
+
class: "mutation",
|
|
2322
|
+
description: "Draft + finalize a Stripe invoice for a customer with line items. Idempotency-Key guarantees at-most-once.",
|
|
2323
|
+
cas: "native-idempotency",
|
|
2324
|
+
externalEffect: true,
|
|
2325
|
+
parameters: {
|
|
2326
|
+
type: "object",
|
|
2327
|
+
properties: {
|
|
2328
|
+
customerId: { type: "string" },
|
|
2329
|
+
items: {
|
|
2330
|
+
type: "array",
|
|
2331
|
+
items: {
|
|
2332
|
+
type: "object",
|
|
2333
|
+
properties: {
|
|
2334
|
+
description: { type: "string" },
|
|
2335
|
+
amount: { type: "integer", description: "Amount in the smallest currency unit (cents)." },
|
|
2336
|
+
currency: { type: "string", description: "3-letter ISO currency code, lowercase." },
|
|
2337
|
+
quantity: { type: "integer", minimum: 1, default: 1 }
|
|
2338
|
+
},
|
|
2339
|
+
required: ["amount", "currency"]
|
|
2340
|
+
}
|
|
2341
|
+
},
|
|
2342
|
+
autoFinalize: { type: "boolean", default: true }
|
|
2343
|
+
},
|
|
2344
|
+
required: ["customerId", "items"]
|
|
2345
|
+
}
|
|
2346
|
+
},
|
|
2347
|
+
{
|
|
2348
|
+
name: "create_checkout_session",
|
|
2349
|
+
class: "mutation",
|
|
2350
|
+
description: "Create a Stripe Checkout session and return its hosted URL. Idempotency-Key guarantees at-most-once.",
|
|
2351
|
+
cas: "native-idempotency",
|
|
2352
|
+
externalEffect: true,
|
|
2353
|
+
parameters: {
|
|
2354
|
+
type: "object",
|
|
2355
|
+
properties: {
|
|
2356
|
+
customerId: { type: "string" },
|
|
2357
|
+
mode: { type: "string", enum: ["payment", "subscription"], default: "payment" },
|
|
2358
|
+
successUrl: { type: "string" },
|
|
2359
|
+
cancelUrl: { type: "string" },
|
|
2360
|
+
lineItems: {
|
|
2361
|
+
type: "array",
|
|
2362
|
+
items: {
|
|
2363
|
+
type: "object",
|
|
2364
|
+
properties: {
|
|
2365
|
+
price: { type: "string", description: "Stripe price id (price_...)" },
|
|
2366
|
+
quantity: { type: "integer", minimum: 1, default: 1 }
|
|
2367
|
+
},
|
|
2368
|
+
required: ["price"]
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
},
|
|
2372
|
+
required: ["successUrl", "cancelUrl", "lineItems"]
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
]
|
|
2376
|
+
},
|
|
2377
|
+
async executeRead(inv) {
|
|
2378
|
+
if (inv.capabilityName !== "find_customer") {
|
|
2379
|
+
throw new Error(`stripe-pack: unknown read capability ${inv.capabilityName}`);
|
|
2380
|
+
}
|
|
2381
|
+
const apiKey = readApiKey(inv.source.credentials);
|
|
2382
|
+
const { email } = inv.args;
|
|
2383
|
+
const url = `${API5}/customers/search?query=${encodeURIComponent(`email:'${email.toLowerCase()}'`)}&limit=1`;
|
|
2384
|
+
const res = await fetch(url, {
|
|
2385
|
+
headers: { authorization: `Bearer ${apiKey}` },
|
|
2386
|
+
signal: AbortSignal.timeout(1e4)
|
|
2387
|
+
});
|
|
2388
|
+
if (res.status === 401) {
|
|
2389
|
+
throw new CredentialsExpired("Stripe rejected API key (401)", inv.source.id);
|
|
2390
|
+
}
|
|
2391
|
+
if (!res.ok) {
|
|
2392
|
+
const text = await res.text().catch(() => "");
|
|
2393
|
+
throw new Error(`stripe-pack find_customer ${res.status}: ${text.slice(0, 200)}`);
|
|
2394
|
+
}
|
|
2395
|
+
const json = await res.json();
|
|
2396
|
+
const first = json.data?.[0];
|
|
2397
|
+
return {
|
|
2398
|
+
data: first ? { found: true, customer: { id: first.id, email: first.email, name: first.name, phone: first.phone } } : { found: false },
|
|
2399
|
+
fetchedAt: Date.now()
|
|
2400
|
+
};
|
|
2401
|
+
},
|
|
2402
|
+
async executeMutation(inv) {
|
|
2403
|
+
const apiKey = readApiKey(inv.source.credentials);
|
|
2404
|
+
if (inv.capabilityName === "create_invoice") return createInvoice(inv, apiKey);
|
|
2405
|
+
if (inv.capabilityName === "create_checkout_session") return createCheckoutSession(inv, apiKey);
|
|
2406
|
+
throw new Error(`stripe-pack: unknown mutation capability ${inv.capabilityName}`);
|
|
2407
|
+
},
|
|
2408
|
+
async test(source) {
|
|
2409
|
+
try {
|
|
2410
|
+
const apiKey = readApiKey(source.credentials);
|
|
2411
|
+
const res = await fetch(`${API5}/account`, {
|
|
2412
|
+
headers: { authorization: `Bearer ${apiKey}` },
|
|
2413
|
+
signal: AbortSignal.timeout(8e3)
|
|
2414
|
+
});
|
|
2415
|
+
if (res.status === 401) {
|
|
2416
|
+
return { ok: false, reason: "Stripe rejected API key (401) \u2014 reconnect required" };
|
|
2417
|
+
}
|
|
2418
|
+
if (!res.ok) return { ok: false, reason: `Stripe returned ${res.status}` };
|
|
2419
|
+
return { ok: true };
|
|
2420
|
+
} catch (err) {
|
|
2421
|
+
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
};
|
|
2425
|
+
async function createInvoice(inv, apiKey) {
|
|
2426
|
+
const { customerId, items, autoFinalize } = inv.args;
|
|
2427
|
+
const idemKey = inv.idempotencyKey;
|
|
2428
|
+
for (let i = 0; i < items.length; i++) {
|
|
2429
|
+
const it = items[i];
|
|
2430
|
+
const body = new URLSearchParams({
|
|
2431
|
+
customer: customerId,
|
|
2432
|
+
amount: String(it.amount),
|
|
2433
|
+
currency: it.currency.toLowerCase(),
|
|
2434
|
+
quantity: String(it.quantity ?? 1)
|
|
2435
|
+
});
|
|
2436
|
+
if (it.description) body.set("description", it.description);
|
|
2437
|
+
const res = await fetch(`${API5}/invoiceitems`, {
|
|
2438
|
+
method: "POST",
|
|
2439
|
+
headers: {
|
|
2440
|
+
authorization: `Bearer ${apiKey}`,
|
|
2441
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
2442
|
+
"idempotency-key": `${idemKey}-item-${i}`
|
|
2443
|
+
},
|
|
2444
|
+
body,
|
|
2445
|
+
signal: AbortSignal.timeout(15e3)
|
|
2446
|
+
});
|
|
2447
|
+
if (res.status === 401) throw new CredentialsExpired("Stripe rejected API key (401)", inv.source.id);
|
|
2448
|
+
if (res.status === 409) {
|
|
2449
|
+
throw new ResourceContention("Stripe invoiceitem conflict \u2014 retry rejected by idempotency check");
|
|
2450
|
+
}
|
|
2451
|
+
if (!res.ok) {
|
|
2452
|
+
const text = await res.text().catch(() => "");
|
|
2453
|
+
throw new Error(`stripe-pack create_invoice (item ${i}) ${res.status}: ${text.slice(0, 200)}`);
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
const invBody = new URLSearchParams({
|
|
2457
|
+
customer: customerId,
|
|
2458
|
+
auto_advance: autoFinalize === false ? "false" : "true",
|
|
2459
|
+
collection_method: "send_invoice",
|
|
2460
|
+
days_until_due: "14"
|
|
2461
|
+
});
|
|
2462
|
+
const invRes = await fetch(`${API5}/invoices`, {
|
|
2463
|
+
method: "POST",
|
|
2464
|
+
headers: {
|
|
2465
|
+
authorization: `Bearer ${apiKey}`,
|
|
2466
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
2467
|
+
"idempotency-key": `${idemKey}-invoice`
|
|
2468
|
+
},
|
|
2469
|
+
body: invBody,
|
|
2470
|
+
signal: AbortSignal.timeout(15e3)
|
|
2471
|
+
});
|
|
2472
|
+
if (invRes.status === 401) throw new CredentialsExpired("Stripe rejected API key (401)", inv.source.id);
|
|
2473
|
+
if (invRes.status === 409) {
|
|
2474
|
+
throw new ResourceContention("Stripe invoice conflict \u2014 retry rejected by idempotency check");
|
|
2475
|
+
}
|
|
2476
|
+
if (!invRes.ok) {
|
|
2477
|
+
const text = await invRes.text().catch(() => "");
|
|
2478
|
+
throw new Error(`stripe-pack create_invoice ${invRes.status}: ${text.slice(0, 200)}`);
|
|
2479
|
+
}
|
|
2480
|
+
const created = await invRes.json();
|
|
2481
|
+
return {
|
|
2482
|
+
status: "committed",
|
|
2483
|
+
data: { invoiceId: created.id, hostedInvoiceUrl: created.hosted_invoice_url, status: created.status },
|
|
2484
|
+
committedAt: Date.now(),
|
|
2485
|
+
idempotentReplay: false
|
|
2486
|
+
};
|
|
2487
|
+
}
|
|
2488
|
+
async function createCheckoutSession(inv, apiKey) {
|
|
2489
|
+
const { customerId, mode, successUrl, cancelUrl, lineItems } = inv.args;
|
|
2490
|
+
const body = new URLSearchParams({
|
|
2491
|
+
mode: mode ?? "payment",
|
|
2492
|
+
success_url: successUrl,
|
|
2493
|
+
cancel_url: cancelUrl
|
|
2494
|
+
});
|
|
2495
|
+
if (customerId) body.set("customer", customerId);
|
|
2496
|
+
lineItems.forEach((it, i) => {
|
|
2497
|
+
body.set(`line_items[${i}][price]`, it.price);
|
|
2498
|
+
body.set(`line_items[${i}][quantity]`, String(it.quantity ?? 1));
|
|
2499
|
+
});
|
|
2500
|
+
const res = await fetch(`${API5}/checkout/sessions`, {
|
|
2501
|
+
method: "POST",
|
|
2502
|
+
headers: {
|
|
2503
|
+
authorization: `Bearer ${apiKey}`,
|
|
2504
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
2505
|
+
"idempotency-key": inv.idempotencyKey
|
|
2506
|
+
},
|
|
2507
|
+
body,
|
|
2508
|
+
signal: AbortSignal.timeout(15e3)
|
|
2509
|
+
});
|
|
2510
|
+
if (res.status === 401) throw new CredentialsExpired("Stripe rejected API key (401)", inv.source.id);
|
|
2511
|
+
if (res.status === 409) {
|
|
2512
|
+
throw new ResourceContention("Stripe checkout session conflict \u2014 retry rejected by idempotency check");
|
|
2513
|
+
}
|
|
2514
|
+
if (!res.ok) {
|
|
2515
|
+
const text = await res.text().catch(() => "");
|
|
2516
|
+
throw new Error(`stripe-pack create_checkout_session ${res.status}: ${text.slice(0, 200)}`);
|
|
2517
|
+
}
|
|
2518
|
+
const created = await res.json();
|
|
2519
|
+
return {
|
|
2520
|
+
status: "committed",
|
|
2521
|
+
data: { sessionId: created.id, url: created.url, paymentStatus: created.payment_status },
|
|
2522
|
+
committedAt: Date.now(),
|
|
2523
|
+
idempotentReplay: false
|
|
2524
|
+
};
|
|
2525
|
+
}
|
|
2526
|
+
function readApiKey(creds) {
|
|
2527
|
+
if (creds.kind !== "api-key" || typeof creds.apiKey !== "string" || creds.apiKey.length === 0) {
|
|
2528
|
+
throw new Error("stripe-pack: expected api-key credentials");
|
|
2529
|
+
}
|
|
2530
|
+
return creds.apiKey;
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
// src/connectors/adapters/webhook.ts
|
|
2534
|
+
import { createHmac } from "crypto";
|
|
2535
|
+
var webhookConnector = {
|
|
2536
|
+
manifest: {
|
|
2537
|
+
kind: "webhook",
|
|
2538
|
+
displayName: "Webhook (custom URL)",
|
|
2539
|
+
description: "Fire signed HTTP POSTs from your agent to any URL you control. The escape hatch when there's no native connector \u2014 receive the agent's intent, run your own logic, return a result.",
|
|
2540
|
+
auth: { kind: "hmac" },
|
|
2541
|
+
category: "webhook",
|
|
2542
|
+
defaultConsistencyModel: "advisory",
|
|
2543
|
+
capabilities: [
|
|
2544
|
+
{
|
|
2545
|
+
name: "post_event",
|
|
2546
|
+
class: "mutation",
|
|
2547
|
+
description: "Send a JSON event to the configured webhook URL. The receiver SHOULD return 200 on accept and 409 on conflict (the agent will offer alternatives if you include them in the response).",
|
|
2548
|
+
cas: "native-idempotency",
|
|
2549
|
+
externalEffect: true,
|
|
2550
|
+
parameters: {
|
|
2551
|
+
type: "object",
|
|
2552
|
+
additionalProperties: true,
|
|
2553
|
+
description: "Whatever JSON the operator declared at connect time. The DataSource.metadata.requestSchema is the source of truth at runtime."
|
|
2554
|
+
}
|
|
2555
|
+
},
|
|
2556
|
+
{
|
|
2557
|
+
name: "fetch_state",
|
|
2558
|
+
class: "read",
|
|
2559
|
+
description: "GET the configured webhook URL with the agent-supplied query params. Returns whatever JSON the receiver responds with.",
|
|
2560
|
+
parameters: {
|
|
2561
|
+
type: "object",
|
|
2562
|
+
additionalProperties: true
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
]
|
|
2566
|
+
},
|
|
2567
|
+
async executeRead(inv) {
|
|
2568
|
+
const url = readMetaString5(inv.source.metadata, "url");
|
|
2569
|
+
const params = inv.args && typeof inv.args === "object" ? inv.args : {};
|
|
2570
|
+
const u = new URL(url);
|
|
2571
|
+
for (const [k, v] of Object.entries(params)) {
|
|
2572
|
+
u.searchParams.set(k, typeof v === "string" ? v : JSON.stringify(v));
|
|
2573
|
+
}
|
|
2574
|
+
const res = await fetch(u.toString(), {
|
|
2575
|
+
method: "GET",
|
|
2576
|
+
headers: signHeaders(inv.source.credentials, "", inv.idempotencyKey),
|
|
2577
|
+
signal: AbortSignal.timeout(15e3)
|
|
2578
|
+
});
|
|
2579
|
+
if (!res.ok) {
|
|
2580
|
+
throw new Error(`webhook fetch_state ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
2581
|
+
}
|
|
2582
|
+
const data = await res.json();
|
|
2583
|
+
return {
|
|
2584
|
+
data,
|
|
2585
|
+
etag: res.headers.get("etag") ?? void 0,
|
|
2586
|
+
fetchedAt: Date.now()
|
|
2587
|
+
};
|
|
2588
|
+
},
|
|
2589
|
+
async executeMutation(inv) {
|
|
2590
|
+
const url = readMetaString5(inv.source.metadata, "url");
|
|
2591
|
+
const body = JSON.stringify(inv.args ?? {});
|
|
2592
|
+
const res = await fetch(url, {
|
|
2593
|
+
method: "POST",
|
|
2594
|
+
headers: signHeaders(inv.source.credentials, body, inv.idempotencyKey),
|
|
2595
|
+
body,
|
|
2596
|
+
signal: AbortSignal.timeout(15e3)
|
|
2597
|
+
});
|
|
2598
|
+
if (res.status === 409) {
|
|
2599
|
+
const json = await res.json().catch(() => ({}));
|
|
2600
|
+
return {
|
|
2601
|
+
status: "conflict",
|
|
2602
|
+
alternatives: json.alternatives ?? [],
|
|
2603
|
+
message: json.message ?? "webhook receiver returned 409"
|
|
2604
|
+
};
|
|
2605
|
+
}
|
|
2606
|
+
if (!res.ok) {
|
|
2607
|
+
throw new Error(`webhook post_event ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
2608
|
+
}
|
|
2609
|
+
const data = await res.json().catch(() => ({}));
|
|
2610
|
+
return {
|
|
2611
|
+
status: "committed",
|
|
2612
|
+
data,
|
|
2613
|
+
etagAfter: res.headers.get("etag") ?? void 0,
|
|
2614
|
+
committedAt: Date.now(),
|
|
2615
|
+
idempotentReplay: false
|
|
2616
|
+
};
|
|
2617
|
+
},
|
|
2618
|
+
async test(source) {
|
|
2619
|
+
try {
|
|
2620
|
+
const url = readMetaString5(source.metadata, "url");
|
|
2621
|
+
const res = await fetch(url, {
|
|
2622
|
+
method: "HEAD",
|
|
2623
|
+
headers: signHeaders(source.credentials, "", `health-${Date.now()}`),
|
|
2624
|
+
signal: AbortSignal.timeout(8e3)
|
|
2625
|
+
});
|
|
2626
|
+
if (res.status >= 500) return { ok: false, reason: `webhook returned ${res.status}` };
|
|
2627
|
+
return { ok: true };
|
|
2628
|
+
} catch (err) {
|
|
2629
|
+
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
};
|
|
2633
|
+
function readMetaString5(meta, key) {
|
|
2634
|
+
const v = meta[key];
|
|
2635
|
+
if (typeof v !== "string" || v.length === 0) {
|
|
2636
|
+
throw new Error(`webhook DataSource.metadata.${key} is missing`);
|
|
2637
|
+
}
|
|
2638
|
+
return v;
|
|
2639
|
+
}
|
|
2640
|
+
function signHeaders(creds, body, idempotencyKey) {
|
|
2641
|
+
const ts = Math.floor(Date.now() / 1e3).toString();
|
|
2642
|
+
const headers = {
|
|
2643
|
+
"content-type": "application/json",
|
|
2644
|
+
"x-phony-timestamp": ts,
|
|
2645
|
+
"x-phony-idempotency-key": idempotencyKey
|
|
2646
|
+
};
|
|
2647
|
+
if (creds.kind === "hmac" && typeof creds.secret === "string" && creds.secret.length > 0) {
|
|
2648
|
+
const sig = createHmac("sha256", creds.secret).update(`${ts}.${body}`).digest("hex");
|
|
2649
|
+
headers["x-phony-signature"] = `sha256=${sig}`;
|
|
2650
|
+
}
|
|
2651
|
+
return headers;
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
// src/connectors/webhooks.ts
|
|
2655
|
+
import { createHmac as createHmac2, timingSafeEqual } from "crypto";
|
|
2656
|
+
var DEFAULT_SIGNATURE_TOLERANCE_SECONDS = 5 * 60;
|
|
2657
|
+
function parseStripeSignatureHeader(header) {
|
|
2658
|
+
const acc = { sigs: [] };
|
|
2659
|
+
for (const part of header.split(",")) {
|
|
2660
|
+
const idx = part.indexOf("=");
|
|
2661
|
+
if (idx < 0) continue;
|
|
2662
|
+
const key = part.slice(0, idx).trim();
|
|
2663
|
+
const val = part.slice(idx + 1).trim();
|
|
2664
|
+
if (key === "t") {
|
|
2665
|
+
const n = Number(val);
|
|
2666
|
+
if (Number.isFinite(n)) acc.ts = n;
|
|
2667
|
+
} else if (key === "v1") {
|
|
2668
|
+
acc.sigs.push(val);
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
if (acc.ts === void 0 || acc.sigs.length === 0) return null;
|
|
2672
|
+
return { t: acc.ts, sigs: acc.sigs };
|
|
2673
|
+
}
|
|
2674
|
+
function verifyStripeSignature(rawBody, signatureHeader, secret, options = {}) {
|
|
2675
|
+
const parsed = parseStripeSignatureHeader(signatureHeader);
|
|
2676
|
+
if (!parsed) return false;
|
|
2677
|
+
const tolerance = options.toleranceSeconds ?? DEFAULT_SIGNATURE_TOLERANCE_SECONDS;
|
|
2678
|
+
const now = options.now ?? Math.floor(Date.now() / 1e3);
|
|
2679
|
+
if (Math.abs(now - parsed.t) > tolerance) return false;
|
|
2680
|
+
const expected = createHmac2("sha256", secret).update(`${parsed.t}.${rawBody}`).digest("hex");
|
|
2681
|
+
const expectedBuf = Buffer.from(expected, "utf8");
|
|
2682
|
+
for (const sig of parsed.sigs) {
|
|
2683
|
+
const sigBuf = Buffer.from(sig, "utf8");
|
|
2684
|
+
if (sigBuf.length !== expectedBuf.length) continue;
|
|
2685
|
+
if (timingSafeEqual(sigBuf, expectedBuf)) return true;
|
|
2686
|
+
}
|
|
2687
|
+
return false;
|
|
2688
|
+
}
|
|
2689
|
+
function verifySlackSignature(rawBody, signatureHeader, timestampHeader, secret, options = {}) {
|
|
2690
|
+
if (!signatureHeader.startsWith("v0=")) return false;
|
|
2691
|
+
const ts = Number(timestampHeader);
|
|
2692
|
+
if (!Number.isFinite(ts)) return false;
|
|
2693
|
+
const tolerance = options.toleranceSeconds ?? DEFAULT_SIGNATURE_TOLERANCE_SECONDS;
|
|
2694
|
+
const now = options.now ?? Math.floor(Date.now() / 1e3);
|
|
2695
|
+
if (Math.abs(now - ts) > tolerance) return false;
|
|
2696
|
+
const expected = "v0=" + createHmac2("sha256", secret).update(`v0:${ts}:${rawBody}`).digest("hex");
|
|
2697
|
+
const expectedBuf = Buffer.from(expected, "utf8");
|
|
2698
|
+
const sigBuf = Buffer.from(signatureHeader, "utf8");
|
|
2699
|
+
if (sigBuf.length !== expectedBuf.length) return false;
|
|
2700
|
+
return timingSafeEqual(sigBuf, expectedBuf);
|
|
2701
|
+
}
|
|
2702
|
+
function verifyHmacSignature(rawBody, signatureHeader, secret, options = {}) {
|
|
2703
|
+
const algorithm = options.algorithm ?? "sha256";
|
|
2704
|
+
const prefix = options.signaturePrefix ?? "";
|
|
2705
|
+
const lower = options.lowercaseHex ?? true;
|
|
2706
|
+
let candidate = signatureHeader;
|
|
2707
|
+
if (prefix) {
|
|
2708
|
+
if (!candidate.startsWith(prefix)) return false;
|
|
2709
|
+
candidate = candidate.slice(prefix.length);
|
|
2710
|
+
}
|
|
2711
|
+
if (lower) candidate = candidate.toLowerCase();
|
|
2712
|
+
const expected = createHmac2(algorithm, secret).update(rawBody).digest("hex");
|
|
2713
|
+
const expectedBuf = Buffer.from(expected, "utf8");
|
|
2714
|
+
const sigBuf = Buffer.from(candidate, "utf8");
|
|
2715
|
+
if (sigBuf.length !== expectedBuf.length) return false;
|
|
2716
|
+
return timingSafeEqual(sigBuf, expectedBuf);
|
|
2717
|
+
}
|
|
2718
|
+
function verifyTwilioSignature(input, options = {}) {
|
|
2719
|
+
if (!input.authToken) {
|
|
2720
|
+
return options.skipWhenAuthTokenMissing === true;
|
|
2721
|
+
}
|
|
2722
|
+
const signature = input.signatureHeader;
|
|
2723
|
+
if (!signature || Array.isArray(signature)) return false;
|
|
2724
|
+
if (!input.fullUrl) return false;
|
|
2725
|
+
const data = options.bodyAsRaw === true ? input.fullUrl + (options.rawBody ?? "") : Object.keys(input.params ?? {}).sort().reduce((acc, key) => acc + key + (input.params[key] ?? ""), input.fullUrl);
|
|
2726
|
+
const expected = createHmac2("sha1", input.authToken).update(data).digest("base64");
|
|
2727
|
+
const expectedBuf = Buffer.from(expected);
|
|
2728
|
+
const sigBuf = Buffer.from(signature);
|
|
2729
|
+
if (expectedBuf.length !== sigBuf.length) return false;
|
|
2730
|
+
return timingSafeEqual(expectedBuf, sigBuf);
|
|
2731
|
+
}
|
|
2732
|
+
function firstHeader(headers, name) {
|
|
2733
|
+
const v = headers[name] ?? headers[name.toLowerCase()] ?? Object.entries(headers).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1];
|
|
2734
|
+
if (Array.isArray(v)) return v[0];
|
|
2735
|
+
return typeof v === "string" ? v : void 0;
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
// src/connectors/adapters/stripe-webhook-receiver.ts
|
|
2739
|
+
var stripeWebhookReceiverConnector = {
|
|
2740
|
+
manifest: {
|
|
2741
|
+
kind: "stripe",
|
|
2742
|
+
displayName: "Stripe (inbound events)",
|
|
2743
|
+
description: "Receive Stripe webhook events from your own Stripe account. Paste your endpoint signing secret (whsec_*) at connect time; we'll verify every push and feed events to your agent's runtime.",
|
|
2744
|
+
auth: { kind: "hmac" },
|
|
2745
|
+
category: "commerce",
|
|
2746
|
+
// Inbound-only. Stripe events are advisory in this incarnation — the
|
|
2747
|
+
// agent reacts to them but doesn't compete for writes against the same
|
|
2748
|
+
// resource.
|
|
2749
|
+
defaultConsistencyModel: "advisory",
|
|
2750
|
+
capabilities: []
|
|
2751
|
+
},
|
|
2752
|
+
verifySignature({ rawBody, headers, source }) {
|
|
2753
|
+
if (source.credentials.kind !== "hmac") return { valid: false, reason: "missing_hmac_secret" };
|
|
2754
|
+
const sig = firstHeader(headers, "stripe-signature");
|
|
2755
|
+
if (!sig) return { valid: false, reason: "missing_stripe_signature_header" };
|
|
2756
|
+
const ok = verifyStripeSignature(rawBody, sig, source.credentials.secret);
|
|
2757
|
+
return ok ? { valid: true } : { valid: false, reason: "invalid_signature" };
|
|
2758
|
+
},
|
|
2759
|
+
async handleInboundEvent({ rawBody }) {
|
|
2760
|
+
let parsed;
|
|
2761
|
+
try {
|
|
2762
|
+
parsed = JSON.parse(rawBody);
|
|
2763
|
+
} catch {
|
|
2764
|
+
return { events: [], response: { status: 400, body: { error: "invalid_json" } } };
|
|
2765
|
+
}
|
|
2766
|
+
if (!parsed || typeof parsed !== "object") {
|
|
2767
|
+
return { events: [], response: { status: 400, body: { error: "invalid_payload" } } };
|
|
2768
|
+
}
|
|
2769
|
+
const evt = parsed;
|
|
2770
|
+
const eventType = typeof evt.type === "string" ? evt.type : "stripe.unknown";
|
|
2771
|
+
const providerEventId = typeof evt.id === "string" ? evt.id : void 0;
|
|
2772
|
+
const events = [
|
|
2773
|
+
{
|
|
2774
|
+
eventType,
|
|
2775
|
+
providerEventId,
|
|
2776
|
+
payload: evt
|
|
2777
|
+
}
|
|
2778
|
+
];
|
|
2779
|
+
return { events };
|
|
2780
|
+
},
|
|
2781
|
+
async test(source) {
|
|
2782
|
+
if (source.credentials.kind !== "hmac" || !source.credentials.secret) {
|
|
2783
|
+
return { ok: false, reason: "webhook secret not configured" };
|
|
2784
|
+
}
|
|
2785
|
+
return { ok: true };
|
|
2786
|
+
}
|
|
2787
|
+
};
|
|
2788
|
+
|
|
2789
|
+
// src/connectors/adapters/slack-events.ts
|
|
2790
|
+
var slackEventsConnector = {
|
|
2791
|
+
manifest: {
|
|
2792
|
+
// NOTE: `slack` is owned by the OAuth bot connector in slack.ts (post_message,
|
|
2793
|
+
// lookup_user, list_channels). This adapter is the HMAC-only inbound-events
|
|
2794
|
+
// sibling — distinct kind so a customer can stand up the Events API receiver
|
|
2795
|
+
// without granting bot OAuth, and so the registry doesn't reject duplicate
|
|
2796
|
+
// kinds at boot.
|
|
2797
|
+
kind: "slack-inbound",
|
|
2798
|
+
displayName: "Slack (Events API)",
|
|
2799
|
+
description: "Receive workspace events (messages, reactions, app mentions, \u2026) from Slack's Events API. Outbound bot messaging will land in a follow-up.",
|
|
2800
|
+
auth: { kind: "hmac" },
|
|
2801
|
+
category: "comms",
|
|
2802
|
+
// Inbound-only. Events are advisory in this incarnation — agents observe
|
|
2803
|
+
// and react, no CAS.
|
|
2804
|
+
defaultConsistencyModel: "advisory",
|
|
2805
|
+
capabilities: []
|
|
2806
|
+
},
|
|
2807
|
+
verifySignature({ rawBody, headers, source }) {
|
|
2808
|
+
if (source.credentials.kind !== "hmac") return { valid: false, reason: "missing_hmac_secret" };
|
|
2809
|
+
const sig = firstHeader(headers, "x-slack-signature");
|
|
2810
|
+
const ts = firstHeader(headers, "x-slack-request-timestamp");
|
|
2811
|
+
if (!sig || !ts) return { valid: false, reason: "missing_slack_headers" };
|
|
2812
|
+
const ok = verifySlackSignature(rawBody, sig, ts, source.credentials.secret);
|
|
2813
|
+
return ok ? { valid: true } : { valid: false, reason: "invalid_signature" };
|
|
2814
|
+
},
|
|
2815
|
+
async handleInboundEvent({ rawBody }) {
|
|
2816
|
+
let parsed;
|
|
2817
|
+
try {
|
|
2818
|
+
parsed = JSON.parse(rawBody);
|
|
2819
|
+
} catch {
|
|
2820
|
+
return { events: [], response: { status: 400, body: { error: "invalid_json" } } };
|
|
2821
|
+
}
|
|
2822
|
+
if (!parsed || typeof parsed !== "object") {
|
|
2823
|
+
return { events: [], response: { status: 400, body: { error: "invalid_payload" } } };
|
|
2824
|
+
}
|
|
2825
|
+
const obj = parsed;
|
|
2826
|
+
if (obj.type === "url_verification") {
|
|
2827
|
+
const challenge = typeof obj.challenge === "string" ? obj.challenge : "";
|
|
2828
|
+
return {
|
|
2829
|
+
events: [],
|
|
2830
|
+
response: { status: 200, body: { challenge } }
|
|
2831
|
+
};
|
|
2832
|
+
}
|
|
2833
|
+
if (obj.type === "event_callback") {
|
|
2834
|
+
const inner = obj.event;
|
|
2835
|
+
const innerType = inner && typeof inner === "object" && "type" in inner && typeof inner.type === "string" ? inner.type : "slack.event";
|
|
2836
|
+
const providerEventId = typeof obj.event_id === "string" ? obj.event_id : void 0;
|
|
2837
|
+
const event = {
|
|
2838
|
+
eventType: `slack.${innerType}`,
|
|
2839
|
+
providerEventId,
|
|
2840
|
+
payload: obj
|
|
2841
|
+
};
|
|
2842
|
+
return { events: [event] };
|
|
2843
|
+
}
|
|
2844
|
+
return { events: [] };
|
|
2845
|
+
},
|
|
2846
|
+
async test(source) {
|
|
2847
|
+
if (source.credentials.kind !== "hmac" || !source.credentials.secret) {
|
|
2848
|
+
return { ok: false, reason: "signing secret not configured" };
|
|
2849
|
+
}
|
|
2850
|
+
return { ok: true };
|
|
2851
|
+
}
|
|
2852
|
+
};
|
|
2853
|
+
|
|
2854
|
+
// src/connectors/adapters/github.ts
|
|
2855
|
+
var repoParams = {
|
|
2856
|
+
type: "object",
|
|
2857
|
+
properties: {
|
|
2858
|
+
owner: { type: "string" },
|
|
2859
|
+
repo: { type: "string" }
|
|
2860
|
+
},
|
|
2861
|
+
required: ["owner", "repo"]
|
|
2862
|
+
};
|
|
2863
|
+
var githubConnector = declarativeRestConnector({
|
|
2864
|
+
kind: "github",
|
|
2865
|
+
displayName: "GitHub",
|
|
2866
|
+
description: "Search repositories/issues and create or update GitHub issues through a user-scoped token.",
|
|
2867
|
+
auth: { kind: "api-key", hint: "GitHub fine-grained personal access token or installation token." },
|
|
2868
|
+
category: "other",
|
|
2869
|
+
defaultConsistencyModel: "authoritative",
|
|
2870
|
+
baseUrl: "https://api.github.com",
|
|
2871
|
+
defaultHeaders: {
|
|
2872
|
+
"x-github-api-version": "2022-11-28"
|
|
2873
|
+
},
|
|
2874
|
+
test: { method: "GET", path: "/user" },
|
|
2875
|
+
capabilities: [
|
|
2876
|
+
{
|
|
2877
|
+
name: "repositories.get",
|
|
2878
|
+
class: "read",
|
|
2879
|
+
description: "Read repository metadata.",
|
|
2880
|
+
parameters: repoParams,
|
|
2881
|
+
request: { method: "GET", path: "/repos/{owner}/{repo}" }
|
|
2882
|
+
},
|
|
2883
|
+
{
|
|
2884
|
+
name: "issues.search",
|
|
2885
|
+
class: "read",
|
|
2886
|
+
description: "Search GitHub issues and pull requests.",
|
|
2887
|
+
parameters: {
|
|
2888
|
+
type: "object",
|
|
2889
|
+
properties: { q: { type: "string" }, per_page: { type: "integer", minimum: 1, maximum: 100 } },
|
|
2890
|
+
required: ["q"]
|
|
2891
|
+
},
|
|
2892
|
+
request: { method: "GET", path: "/search/issues", query: { q: "{q}", per_page: "{per_page}" } }
|
|
2893
|
+
},
|
|
2894
|
+
{
|
|
2895
|
+
name: "issues.create",
|
|
2896
|
+
class: "mutation",
|
|
2897
|
+
description: "Create an issue in a repository.",
|
|
2898
|
+
parameters: {
|
|
2899
|
+
type: "object",
|
|
2900
|
+
properties: {
|
|
2901
|
+
owner: { type: "string" },
|
|
2902
|
+
repo: { type: "string" },
|
|
2903
|
+
title: { type: "string" },
|
|
2904
|
+
body: { type: "string" },
|
|
2905
|
+
labels: { type: "array", items: { type: "string" } }
|
|
2906
|
+
},
|
|
2907
|
+
required: ["owner", "repo", "title"]
|
|
2908
|
+
},
|
|
2909
|
+
request: { method: "POST", path: "/repos/{owner}/{repo}/issues", body: "args" },
|
|
2910
|
+
cas: "native-idempotency"
|
|
2911
|
+
},
|
|
2912
|
+
{
|
|
2913
|
+
name: "issues.update",
|
|
2914
|
+
class: "mutation",
|
|
2915
|
+
description: "Update an issue by number.",
|
|
2916
|
+
parameters: {
|
|
2917
|
+
type: "object",
|
|
2918
|
+
properties: {
|
|
2919
|
+
owner: { type: "string" },
|
|
2920
|
+
repo: { type: "string" },
|
|
2921
|
+
issue_number: { type: "integer" },
|
|
2922
|
+
title: { type: "string" },
|
|
2923
|
+
body: { type: "string" },
|
|
2924
|
+
state: { type: "string", enum: ["open", "closed"] }
|
|
2925
|
+
},
|
|
2926
|
+
required: ["owner", "repo", "issue_number"]
|
|
2927
|
+
},
|
|
2928
|
+
request: { method: "PATCH", path: "/repos/{owner}/{repo}/issues/{issue_number}", body: "args" },
|
|
2929
|
+
cas: "etag-if-match"
|
|
2930
|
+
}
|
|
2931
|
+
]
|
|
2932
|
+
});
|
|
2933
|
+
|
|
2934
|
+
// src/connectors/adapters/gitlab.ts
|
|
2935
|
+
var gitlabConnector = declarativeRestConnector({
|
|
2936
|
+
kind: "gitlab",
|
|
2937
|
+
displayName: "GitLab",
|
|
2938
|
+
description: "Search GitLab projects/issues and create or update issues through a personal, project, or group token.",
|
|
2939
|
+
auth: { kind: "api-key", hint: "GitLab access token with api/read_api scope." },
|
|
2940
|
+
category: "other",
|
|
2941
|
+
defaultConsistencyModel: "authoritative",
|
|
2942
|
+
baseUrl: { metadataKey: "baseUrl", fallback: "https://gitlab.com/api/v4" },
|
|
2943
|
+
credentialPlacement: { kind: "header", header: "PRIVATE-TOKEN" },
|
|
2944
|
+
test: { method: "GET", path: "/user" },
|
|
2945
|
+
capabilities: [
|
|
2946
|
+
{
|
|
2947
|
+
name: "projects.search",
|
|
2948
|
+
class: "read",
|
|
2949
|
+
description: "Search projects visible to the token.",
|
|
2950
|
+
parameters: {
|
|
2951
|
+
type: "object",
|
|
2952
|
+
properties: { search: { type: "string" }, per_page: { type: "integer", minimum: 1, maximum: 100 } },
|
|
2953
|
+
required: ["search"]
|
|
2954
|
+
},
|
|
2955
|
+
request: { method: "GET", path: "/projects", query: { search: "{search}", per_page: "{per_page}" } }
|
|
2956
|
+
},
|
|
2957
|
+
{
|
|
2958
|
+
name: "issues.search",
|
|
2959
|
+
class: "read",
|
|
2960
|
+
description: "Search issues in a project.",
|
|
2961
|
+
parameters: {
|
|
2962
|
+
type: "object",
|
|
2963
|
+
properties: { projectId: { type: "string" }, search: { type: "string" }, per_page: { type: "integer" } },
|
|
2964
|
+
required: ["projectId", "search"]
|
|
2965
|
+
},
|
|
2966
|
+
request: { method: "GET", path: "/projects/{projectId}/issues", query: { search: "{search}", per_page: "{per_page}" } }
|
|
2967
|
+
},
|
|
2968
|
+
{
|
|
2969
|
+
name: "issues.create",
|
|
2970
|
+
class: "mutation",
|
|
2971
|
+
description: "Create a GitLab project issue.",
|
|
2972
|
+
parameters: {
|
|
2973
|
+
type: "object",
|
|
2974
|
+
properties: { projectId: { type: "string" }, title: { type: "string" }, description: { type: "string" } },
|
|
2975
|
+
required: ["projectId", "title"]
|
|
2976
|
+
},
|
|
2977
|
+
request: { method: "POST", path: "/projects/{projectId}/issues", body: "args" },
|
|
2978
|
+
cas: "native-idempotency"
|
|
2979
|
+
},
|
|
2980
|
+
{
|
|
2981
|
+
name: "issues.update",
|
|
2982
|
+
class: "mutation",
|
|
2983
|
+
description: "Update a GitLab issue.",
|
|
2984
|
+
parameters: {
|
|
2985
|
+
type: "object",
|
|
2986
|
+
properties: { projectId: { type: "string" }, issueIid: { type: "integer" }, title: { type: "string" }, description: { type: "string" }, state_event: { type: "string" } },
|
|
2987
|
+
required: ["projectId", "issueIid"]
|
|
2988
|
+
},
|
|
2989
|
+
request: { method: "PUT", path: "/projects/{projectId}/issues/{issueIid}", body: "args" },
|
|
2990
|
+
cas: "etag-if-match"
|
|
2991
|
+
}
|
|
2992
|
+
]
|
|
2993
|
+
});
|
|
2994
|
+
|
|
2995
|
+
// src/connectors/adapters/airtable.ts
|
|
2996
|
+
var baseTableParams = {
|
|
2997
|
+
type: "object",
|
|
2998
|
+
properties: {
|
|
2999
|
+
baseId: { type: "string" },
|
|
3000
|
+
tableName: { type: "string" }
|
|
3001
|
+
},
|
|
3002
|
+
required: ["baseId", "tableName"]
|
|
3003
|
+
};
|
|
3004
|
+
var airtableConnector = declarativeRestConnector({
|
|
3005
|
+
kind: "airtable",
|
|
3006
|
+
displayName: "Airtable",
|
|
3007
|
+
description: "Query and update Airtable records for lightweight operational databases.",
|
|
3008
|
+
auth: { kind: "api-key", hint: "Airtable personal access token." },
|
|
3009
|
+
category: "spreadsheet",
|
|
3010
|
+
defaultConsistencyModel: "authoritative",
|
|
3011
|
+
baseUrl: "https://api.airtable.com",
|
|
3012
|
+
test: { method: "GET", path: "/v0/meta/whoami" },
|
|
3013
|
+
capabilities: [
|
|
3014
|
+
{
|
|
3015
|
+
name: "records.list",
|
|
3016
|
+
class: "read",
|
|
3017
|
+
description: "List records in a table.",
|
|
3018
|
+
parameters: {
|
|
3019
|
+
...baseTableParams,
|
|
3020
|
+
properties: {
|
|
3021
|
+
...baseTableParams.properties,
|
|
3022
|
+
maxRecords: { type: "integer", minimum: 1, maximum: 100 },
|
|
3023
|
+
filterByFormula: { type: "string" }
|
|
3024
|
+
}
|
|
3025
|
+
},
|
|
3026
|
+
request: { method: "GET", path: "/v0/{baseId}/{tableName}", query: { maxRecords: "{maxRecords}", filterByFormula: "{filterByFormula}" } }
|
|
3027
|
+
},
|
|
3028
|
+
{
|
|
3029
|
+
name: "records.get",
|
|
3030
|
+
class: "read",
|
|
3031
|
+
description: "Read a single Airtable record.",
|
|
3032
|
+
parameters: {
|
|
3033
|
+
type: "object",
|
|
3034
|
+
properties: { baseId: { type: "string" }, tableName: { type: "string" }, recordId: { type: "string" } },
|
|
3035
|
+
required: ["baseId", "tableName", "recordId"]
|
|
3036
|
+
},
|
|
3037
|
+
request: { method: "GET", path: "/v0/{baseId}/{tableName}/{recordId}" }
|
|
3038
|
+
},
|
|
3039
|
+
{
|
|
3040
|
+
name: "records.create",
|
|
3041
|
+
class: "mutation",
|
|
3042
|
+
description: "Create an Airtable record.",
|
|
3043
|
+
parameters: {
|
|
3044
|
+
type: "object",
|
|
3045
|
+
properties: { baseId: { type: "string" }, tableName: { type: "string" }, fields: { type: "object" } },
|
|
3046
|
+
required: ["baseId", "tableName", "fields"]
|
|
3047
|
+
},
|
|
3048
|
+
request: { method: "POST", path: "/v0/{baseId}/{tableName}", body: { fields: "{fields}" } },
|
|
3049
|
+
cas: "native-idempotency"
|
|
3050
|
+
},
|
|
3051
|
+
{
|
|
3052
|
+
name: "records.update",
|
|
3053
|
+
class: "mutation",
|
|
3054
|
+
description: "Update an Airtable record.",
|
|
3055
|
+
parameters: {
|
|
3056
|
+
type: "object",
|
|
3057
|
+
properties: { baseId: { type: "string" }, tableName: { type: "string" }, recordId: { type: "string" }, fields: { type: "object" } },
|
|
3058
|
+
required: ["baseId", "tableName", "recordId", "fields"]
|
|
3059
|
+
},
|
|
3060
|
+
request: { method: "PATCH", path: "/v0/{baseId}/{tableName}/{recordId}", body: { fields: "{fields}" } },
|
|
3061
|
+
cas: "optimistic-read-verify"
|
|
3062
|
+
}
|
|
3063
|
+
]
|
|
3064
|
+
});
|
|
3065
|
+
|
|
3066
|
+
// src/connectors/adapters/asana.ts
|
|
3067
|
+
var asanaConnector = declarativeRestConnector({
|
|
3068
|
+
kind: "asana",
|
|
3069
|
+
displayName: "Asana",
|
|
3070
|
+
description: "Search projects/tasks and create or update Asana tasks.",
|
|
3071
|
+
auth: { kind: "api-key", hint: "Asana personal access token." },
|
|
3072
|
+
category: "other",
|
|
3073
|
+
defaultConsistencyModel: "authoritative",
|
|
3074
|
+
baseUrl: "https://app.asana.com/api/1.0",
|
|
3075
|
+
test: { method: "GET", path: "/users/me" },
|
|
3076
|
+
capabilities: [
|
|
3077
|
+
{
|
|
3078
|
+
name: "projects.search",
|
|
3079
|
+
class: "read",
|
|
3080
|
+
description: "List or search projects in a workspace.",
|
|
3081
|
+
parameters: {
|
|
3082
|
+
type: "object",
|
|
3083
|
+
properties: { workspace: { type: "string" }, archived: { type: "boolean" }, limit: { type: "integer" } },
|
|
3084
|
+
required: ["workspace"]
|
|
3085
|
+
},
|
|
3086
|
+
request: { method: "GET", path: "/projects", query: { workspace: "{workspace}", archived: "{archived}", limit: "{limit}" } }
|
|
3087
|
+
},
|
|
3088
|
+
{
|
|
3089
|
+
name: "tasks.search",
|
|
3090
|
+
class: "read",
|
|
3091
|
+
description: "Search tasks in a workspace.",
|
|
3092
|
+
parameters: {
|
|
3093
|
+
type: "object",
|
|
3094
|
+
properties: { workspace: { type: "string" }, text: { type: "string" }, limit: { type: "integer" } },
|
|
3095
|
+
required: ["workspace"]
|
|
3096
|
+
},
|
|
3097
|
+
request: { method: "GET", path: "/workspaces/{workspace}/tasks/search", query: { text: "{text}", limit: "{limit}" } }
|
|
3098
|
+
},
|
|
3099
|
+
{
|
|
3100
|
+
name: "tasks.create",
|
|
3101
|
+
class: "mutation",
|
|
3102
|
+
description: "Create an Asana task.",
|
|
3103
|
+
parameters: {
|
|
3104
|
+
type: "object",
|
|
3105
|
+
properties: { data: { type: "object" } },
|
|
3106
|
+
required: ["data"]
|
|
3107
|
+
},
|
|
3108
|
+
request: { method: "POST", path: "/tasks", body: { data: "{data}" } },
|
|
3109
|
+
cas: "native-idempotency"
|
|
3110
|
+
},
|
|
3111
|
+
{
|
|
3112
|
+
name: "tasks.update",
|
|
3113
|
+
class: "mutation",
|
|
3114
|
+
description: "Update an Asana task.",
|
|
3115
|
+
parameters: {
|
|
3116
|
+
type: "object",
|
|
3117
|
+
properties: { taskGid: { type: "string" }, data: { type: "object" } },
|
|
3118
|
+
required: ["taskGid", "data"]
|
|
3119
|
+
},
|
|
3120
|
+
request: { method: "PUT", path: "/tasks/{taskGid}", body: { data: "{data}" } },
|
|
3121
|
+
cas: "optimistic-read-verify"
|
|
3122
|
+
}
|
|
3123
|
+
]
|
|
3124
|
+
});
|
|
3125
|
+
|
|
3126
|
+
// src/connectors/adapters/salesforce.ts
|
|
3127
|
+
var salesforceConnector = declarativeRestConnector({
|
|
3128
|
+
kind: "salesforce",
|
|
3129
|
+
displayName: "Salesforce",
|
|
3130
|
+
description: "Query Salesforce records with SOQL and create or update sObjects.",
|
|
3131
|
+
auth: {
|
|
3132
|
+
kind: "oauth2",
|
|
3133
|
+
authorizationUrl: "https://login.salesforce.com/services/oauth2/authorize",
|
|
3134
|
+
tokenUrl: "https://login.salesforce.com/services/oauth2/token",
|
|
3135
|
+
scopes: ["api", "refresh_token"],
|
|
3136
|
+
clientIdEnv: "SALESFORCE_OAUTH_CLIENT_ID",
|
|
3137
|
+
clientSecretEnv: "SALESFORCE_OAUTH_CLIENT_SECRET"
|
|
3138
|
+
},
|
|
3139
|
+
category: "crm",
|
|
3140
|
+
defaultConsistencyModel: "authoritative",
|
|
3141
|
+
baseUrl: { metadataKey: "instanceUrl" },
|
|
3142
|
+
test: { method: "GET", path: "/services/data/v61.0/" },
|
|
3143
|
+
capabilities: [
|
|
3144
|
+
{
|
|
3145
|
+
name: "records.query",
|
|
3146
|
+
class: "read",
|
|
3147
|
+
description: "Run a SOQL query.",
|
|
3148
|
+
parameters: {
|
|
3149
|
+
type: "object",
|
|
3150
|
+
properties: { q: { type: "string" } },
|
|
3151
|
+
required: ["q"]
|
|
3152
|
+
},
|
|
3153
|
+
request: { method: "GET", path: "/services/data/v61.0/query", query: { q: "{q}" } },
|
|
3154
|
+
requiredScopes: ["api"]
|
|
3155
|
+
},
|
|
3156
|
+
{
|
|
3157
|
+
name: "records.get",
|
|
3158
|
+
class: "read",
|
|
3159
|
+
description: "Read a Salesforce sObject record.",
|
|
3160
|
+
parameters: {
|
|
3161
|
+
type: "object",
|
|
3162
|
+
properties: { objectName: { type: "string" }, recordId: { type: "string" } },
|
|
3163
|
+
required: ["objectName", "recordId"]
|
|
3164
|
+
},
|
|
3165
|
+
request: { method: "GET", path: "/services/data/v61.0/sobjects/{objectName}/{recordId}" },
|
|
3166
|
+
requiredScopes: ["api"]
|
|
3167
|
+
},
|
|
3168
|
+
{
|
|
3169
|
+
name: "records.create",
|
|
3170
|
+
class: "mutation",
|
|
3171
|
+
description: "Create a Salesforce sObject record.",
|
|
3172
|
+
parameters: {
|
|
3173
|
+
type: "object",
|
|
3174
|
+
properties: { objectName: { type: "string" }, fields: { type: "object" } },
|
|
3175
|
+
required: ["objectName", "fields"]
|
|
3176
|
+
},
|
|
3177
|
+
request: { method: "POST", path: "/services/data/v61.0/sobjects/{objectName}", body: "{fields}" },
|
|
3178
|
+
cas: "native-idempotency",
|
|
3179
|
+
requiredScopes: ["api"]
|
|
3180
|
+
},
|
|
3181
|
+
{
|
|
3182
|
+
name: "records.update",
|
|
3183
|
+
class: "mutation",
|
|
3184
|
+
description: "Update a Salesforce sObject record.",
|
|
3185
|
+
parameters: {
|
|
3186
|
+
type: "object",
|
|
3187
|
+
properties: { objectName: { type: "string" }, recordId: { type: "string" }, fields: { type: "object" } },
|
|
3188
|
+
required: ["objectName", "recordId", "fields"]
|
|
3189
|
+
},
|
|
3190
|
+
request: { method: "PATCH", path: "/services/data/v61.0/sobjects/{objectName}/{recordId}", body: "{fields}" },
|
|
3191
|
+
cas: "etag-if-match",
|
|
3192
|
+
requiredScopes: ["api"]
|
|
3193
|
+
}
|
|
3194
|
+
]
|
|
3195
|
+
});
|
|
3196
|
+
|
|
3197
|
+
export {
|
|
3198
|
+
ResourceContention,
|
|
3199
|
+
CredentialsExpired,
|
|
3200
|
+
validateConnectorManifest,
|
|
3201
|
+
assertValidConnectorManifest,
|
|
3202
|
+
InMemoryOAuthFlowStore,
|
|
3203
|
+
startOAuthFlow,
|
|
3204
|
+
consumePendingFlow,
|
|
3205
|
+
exchangeAuthorizationCode,
|
|
3206
|
+
refreshAccessToken,
|
|
3207
|
+
_resetPendingFlowsForTests,
|
|
3208
|
+
DEFAULT_SIGNATURE_TOLERANCE_SECONDS,
|
|
3209
|
+
parseStripeSignatureHeader,
|
|
3210
|
+
verifyStripeSignature,
|
|
3211
|
+
verifySlackSignature,
|
|
3212
|
+
verifyHmacSignature,
|
|
3213
|
+
verifyTwilioSignature,
|
|
3214
|
+
firstHeader,
|
|
3215
|
+
googleCalendar,
|
|
3216
|
+
googleSheets,
|
|
3217
|
+
microsoftCalendar,
|
|
3218
|
+
hubspot,
|
|
3219
|
+
slack,
|
|
3220
|
+
notionDatabase,
|
|
3221
|
+
declarativeRestConnector,
|
|
3222
|
+
twilioSmsConnector,
|
|
3223
|
+
stripePackConnector,
|
|
3224
|
+
webhookConnector,
|
|
3225
|
+
stripeWebhookReceiverConnector,
|
|
3226
|
+
slackEventsConnector,
|
|
3227
|
+
githubConnector,
|
|
3228
|
+
gitlabConnector,
|
|
3229
|
+
airtableConnector,
|
|
3230
|
+
asanaConnector,
|
|
3231
|
+
salesforceConnector
|
|
3232
|
+
};
|
|
3233
|
+
//# sourceMappingURL=chunk-IDX3KIPA.js.map
|