@zackbart/connecta 0.35.0 → 0.36.1
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/CHANGELOG.md +97 -0
- package/README.md +8 -4
- package/bin/version.mjs +1 -1
- package/dist/auth/downstream-oauth.js +71 -79
- package/dist/auth/static-oauth.js +104 -26
- package/dist/connectors/api-connector.d.ts +17 -0
- package/dist/connectors/api-connector.js +3 -1
- package/dist/http/client.js +1 -1
- package/dist/http/hatch-paging.js +2 -2
- package/dist/http/paging.js +3 -89
- package/dist/http/provider.js +1 -2
- package/dist/http/services.js +1 -1
- package/dist/http/tools.js +1 -27
- package/dist/operator-ui/contract.d.ts +1 -0
- package/dist/operator-ui/generated.js +4 -4
- package/dist/program-writes.js +0 -1
- package/dist/providers/_shared/google/kit.js +29 -139
- package/dist/providers/_shared/rest/kit.js +4 -404
- package/dist/providers/_shared/sensitive-fields.js +0 -16
- package/dist/providers/breeze/api.js +2 -2
- package/dist/providers/ccb/api.js +1 -1
- package/dist/providers/cloudflare/api.js +8 -8
- package/dist/providers/cloudflare/lib/config.d.ts +2 -1
- package/dist/providers/docs/api.js +1 -1
- package/dist/providers/docs/tools.js +1 -2
- package/dist/providers/drive/api.js +2 -2
- package/dist/providers/drive/lib/hatch.js +1 -1
- package/dist/providers/drive/tools.js +1 -1
- package/dist/providers/forms/api.js +1 -1
- package/dist/providers/forms/lib/hatch.js +1 -1
- package/dist/providers/forms/tools.js +2 -3
- package/dist/providers/github/api.js +1 -1
- package/dist/providers/github/lib/discovery.js +2 -2
- package/dist/providers/gmail/api.js +1 -1
- package/dist/providers/gmail/tools.js +2 -2
- package/dist/providers/granola/api.js +155 -0
- package/dist/providers/granola/index.d.ts +10 -0
- package/dist/providers/granola/index.js +68 -0
- package/dist/providers/granola/openapi.generated.js +30 -0
- package/dist/providers/granola/safety.js +37 -0
- package/dist/providers/granola/skill.generated.js +5 -0
- package/dist/providers/granola/tools.js +2 -0
- package/dist/providers/overflow/api.js +26 -26
- package/dist/providers/planning-center/api.js +4 -4
- package/dist/providers/planning-center/lib/constants.d.ts +1 -1
- package/dist/providers/planning-center/lib/json-api.js +2 -2
- package/dist/providers/quickbooks/api.js +299 -0
- package/dist/providers/quickbooks/index.d.ts +13 -0
- package/dist/providers/quickbooks/index.js +93 -0
- package/dist/providers/quickbooks/lib/query.js +118 -0
- package/dist/providers/quickbooks/lib/writes.js +168 -0
- package/dist/providers/quickbooks/safety.js +222 -0
- package/dist/providers/quickbooks/skill.generated.js +5 -0
- package/dist/providers/quickbooks/tools.js +117 -0
- package/dist/providers/sheets/api.js +1 -1
- package/dist/providers/sheets/lib/hatch.js +2 -2
- package/dist/providers/sheets/lib/requests.js +1 -1
- package/dist/providers/sheets/tools.js +1 -2
- package/dist/providers/slides/api.js +1 -1
- package/dist/providers/slides/tools.js +2 -2
- package/dist/providers/stripe/api.js +7 -7
- package/dist/providers/tithely/api.js +2 -2
- package/dist/providers/vercel/api.js +12 -12
- package/dist/providers/vercel/index.js +1 -2
- package/dist/providers/vercel/lib/pin.js +1 -1
- package/dist/routes/mcp.js +1 -1
- package/dist/routes/oauth.js +3 -0
- package/dist/runtime/admission.js +0 -12
- package/dist/runtime/call.js +0 -4
- package/dist/skills.js +0 -1
- package/dist/storage/keys.js +2 -3
- package/dist/types.d.ts +2 -0
- package/dist/ui.d.ts +1 -1
- package/dist/ui.js +0 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/README.md +1 -0
- package/documentation/architecture.md +249 -366
- package/documentation/auth.md +56 -19
- package/documentation/code-mode.md +6 -6
- package/documentation/integrating.md +1 -1
- package/documentation/meta-tools.md +6 -3
- package/documentation/operator-tests.md +11 -2
- package/documentation/operator-ui.md +17 -5
- package/documentation/providers.md +492 -0
- package/package.json +12 -9
- package/templates/node/package.json +1 -1
- package/dist/operator-ui/agents.d.ts +0 -75
- package/dist/operator-ui/agents.js +0 -114
- package/dist/operator-ui/config-summary.js +0 -566
- package/dist/operator-ui/connection-map.js +0 -378
- package/dist/operator-ui/endpoints.js +0 -212
- package/dist/operator-ui/fix-prompts.d.ts +0 -18
- package/dist/operator-ui/fix-prompts.js +0 -79
- package/dist/operator-ui/health.js +0 -52
- package/dist/operator-ui/member-page.js +0 -97
- package/dist/operator-ui/overview.js +0 -126
- package/dist/operator-ui/setup-commands.js +0 -47
- package/dist/operator-ui/view.d.ts +0 -378
- package/dist/operator-ui/view.js +0 -631
- package/dist/providers/_shared/rest/dispatch.js +0 -21
- package/dist/providers/_shared/rest/tools.js +0 -592
|
@@ -1,592 +0,0 @@
|
|
|
1
|
-
// Connecta's generic REST connector over one vendor's pinned operation index.
|
|
2
|
-
//
|
|
3
|
-
// `restTools(vendor)` returns four tools named `<vendor>_api_search`,
|
|
4
|
-
// `_api_details`, `_api_read`, and `_api_write`. The first two read the index
|
|
5
|
-
// and send nothing. `_api_read` sends GET, plus the vendor's reviewed
|
|
6
|
-
// read-only POSTs; classification is per tool name, so that rule is enforced
|
|
7
|
-
// here in the handler, not inferred. `_api_write` sends every other method and
|
|
8
|
-
// is always destructive, with `method` and `path` top-level arguments so a
|
|
9
|
-
// host's approval prompt shows them.
|
|
10
|
-
//
|
|
11
|
-
// Every call is matched to an operation and checked against its contract
|
|
12
|
-
// before transport (unknown path → nearest operations; unknown or missing
|
|
13
|
-
// parameters → `validation.issues`), then the vendor's reviewed refusals run,
|
|
14
|
-
// then the vendor frames the request. Every vendor answers in one envelope:
|
|
15
|
-
// `{ status, data, page?: { hasMore, next?, param? } }`, with an optional
|
|
16
|
-
// `select` projection and no default projection. A write is dispatched once
|
|
17
|
-
// (INV-9): nothing here retries it or replays it against another
|
|
18
|
-
// implementation.
|
|
19
|
-
//
|
|
20
|
-
// A vendor adopts this module with configuration only: its transport, its
|
|
21
|
-
// failure mapper, and optional `scope` (defaults and pins), `encode` (body
|
|
22
|
-
// and query framing), `page` (cursor extraction), `refuse` (argument-dependent refusals),
|
|
23
|
-
// `readPosts`, and an idempotency header. Every vendor ships a reviewed
|
|
24
|
-
// value-safety table (`valueSafety`, `value-safety.ts` here): its refusals run
|
|
25
|
-
// in the generic tools before any request, and its redaction runs on every
|
|
26
|
-
// successful body in `callRest`, after `result` and before cursors and
|
|
27
|
-
// `select`. Optional hooks cover the rest: `path` (fill default ids before
|
|
28
|
-
// matching), `admit` (an awaited check in `callRest`, which every tool passes,
|
|
29
|
-
// such as an account pin), `result` (unwrap a vendor envelope), a reviewed
|
|
30
|
-
// `headers` allowlist, and `textBodies`. Named tools reuse `restCall` and
|
|
31
|
-
// `callRest` to share the same validation-free request path.
|
|
32
|
-
import { callClassification } from "../../../call-classification.js";
|
|
33
|
-
import { providerFailureMessage } from "../../../write-outcome.js";
|
|
34
|
-
import { guardedFetch, } from "../../../connectors/guarded-fetch.js";
|
|
35
|
-
import { IDEMPOTENCY_KEY_MAX_LENGTH, recordRecovery } from "../../../call-recovery.js";
|
|
36
|
-
import { providerRequests } from "../../../provider-call.js";
|
|
37
|
-
import { ConnectorCallError, keyResendAdvice, unavailableCallError } from "../../../errors.js";
|
|
38
|
-
import { encodeForAgent, redactBeforeCut } from "../../../sent-secrets.js";
|
|
39
|
-
const READ = { readOnlyHint: true };
|
|
40
|
-
const DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true };
|
|
41
|
-
const MAX_SELECT = 50;
|
|
42
|
-
function isRecord(value) {
|
|
43
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
44
|
-
}
|
|
45
|
-
function invalid(message) {
|
|
46
|
-
throw new ConnectorCallError("invalid_args", message);
|
|
47
|
-
}
|
|
48
|
-
/** A header value as fetch sends it: leading and trailing HTTP whitespace removed. */
|
|
49
|
-
function sentValue(value) {
|
|
50
|
-
return value.replace(/^[\t\n\r ]+|[\t\n\r ]+$/g, "");
|
|
51
|
-
}
|
|
52
|
-
/**
|
|
53
|
-
* Why fetch would not send `value` as a header value the vendor receives:
|
|
54
|
-
* blank once trimmed, or a byte fetch rejects (CR, LF, NUL, any other control
|
|
55
|
-
* character but tab, DEL, or a code point past Latin-1). Undici refuses the
|
|
56
|
-
* whole request for any of them; workerd at least for CR, LF and NUL.
|
|
57
|
-
*/
|
|
58
|
-
function unsendableKey(value) {
|
|
59
|
-
const sent = sentValue(value);
|
|
60
|
-
if (sent === "")
|
|
61
|
-
return "is blank; pass a key with at least one non-whitespace character";
|
|
62
|
-
if (!/^[\t\x20-\x7e\x80-\xff]*$/.test(sent))
|
|
63
|
-
return "contains a character an HTTP header cannot carry (a control character, or one past Latin-1); pass a printable key";
|
|
64
|
-
return undefined;
|
|
65
|
-
}
|
|
66
|
-
/**
|
|
67
|
-
* A guarded transport whose requests, response bodies included, end after
|
|
68
|
-
* `timeoutMs`: a stream the vendor never closes cannot hold a call open past
|
|
69
|
-
* it. The request's own signal still cancels first.
|
|
70
|
-
*/
|
|
71
|
-
export function restTransport(options) {
|
|
72
|
-
const { timeoutMs, ...rest } = options;
|
|
73
|
-
return guardedFetch({
|
|
74
|
-
...rest,
|
|
75
|
-
fetch: (url, init) => {
|
|
76
|
-
const cap = AbortSignal.timeout(timeoutMs);
|
|
77
|
-
return fetch(url, { ...init, signal: init.signal ? AbortSignal.any([init.signal, cap]) : cap });
|
|
78
|
-
},
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
/** Match a call to its operation and apply the vendor's defaults and pins. */
|
|
82
|
-
export function restCall(vendor, method, path, input = {}) {
|
|
83
|
-
const concrete = vendor.path ? vendor.path(path) : path;
|
|
84
|
-
const { op, params } = vendor.index.resolve(method, concrete);
|
|
85
|
-
const call = {
|
|
86
|
-
method: op.method,
|
|
87
|
-
path: concrete,
|
|
88
|
-
op,
|
|
89
|
-
params,
|
|
90
|
-
query: input.query ?? {},
|
|
91
|
-
body: input.body,
|
|
92
|
-
...(input.headers ? { headers: input.headers } : {}),
|
|
93
|
-
};
|
|
94
|
-
return vendor.scope ? vendor.scope(call) : call;
|
|
95
|
-
}
|
|
96
|
-
function defaultFraming(vendor, call) {
|
|
97
|
-
const query = {};
|
|
98
|
-
for (const [name, value] of Object.entries(call.query)) {
|
|
99
|
-
if (value === undefined)
|
|
100
|
-
continue;
|
|
101
|
-
const scalar = (item) => typeof item === "string" || typeof item === "number" || typeof item === "boolean";
|
|
102
|
-
if (scalar(value) || (Array.isArray(value) && value.every(scalar)))
|
|
103
|
-
query[name] = value;
|
|
104
|
-
else
|
|
105
|
-
invalid(`${vendor.title} query parameter ${name} takes a string, number, boolean, or a list of them.`);
|
|
106
|
-
}
|
|
107
|
-
return {
|
|
108
|
-
query,
|
|
109
|
-
...(call.headers ? { headers: { ...call.headers } } : {}),
|
|
110
|
-
...(call.body !== undefined ? { body: call.body } : {}),
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
async function failureBody(response) {
|
|
114
|
-
try {
|
|
115
|
-
const parsed = await response.jsonResult();
|
|
116
|
-
return "value" in parsed ? parsed.value : undefined;
|
|
117
|
-
}
|
|
118
|
-
catch {
|
|
119
|
-
// An unreadable or oversized error body still has a status to map.
|
|
120
|
-
return undefined;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
async function successBody(vendor, response, ctx) {
|
|
124
|
-
const type = (response.headers.get("content-type") ?? "").toLowerCase();
|
|
125
|
-
try {
|
|
126
|
-
if (/\b(?:x-ndjson|jsonl|stream\+json)\b/.test(type)) {
|
|
127
|
-
return (await response.text())
|
|
128
|
-
.split("\n")
|
|
129
|
-
.filter((line) => line.trim() !== "")
|
|
130
|
-
.map((line) => {
|
|
131
|
-
try {
|
|
132
|
-
return JSON.parse(line);
|
|
133
|
-
}
|
|
134
|
-
catch {
|
|
135
|
-
return { line };
|
|
136
|
-
}
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
if (/\bjson\b|\+json\b/.test(type)) {
|
|
140
|
-
// Read, then parse: a read that times out must not pass for malformed JSON.
|
|
141
|
-
const body = await response.text();
|
|
142
|
-
if (body.trim() === "")
|
|
143
|
-
return null;
|
|
144
|
-
try {
|
|
145
|
-
return JSON.parse(body);
|
|
146
|
-
}
|
|
147
|
-
catch {
|
|
148
|
-
throw new ConnectorCallError("connector_call_failed", `${vendor.title} returned malformed JSON.`, {
|
|
149
|
-
retryable: false,
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
if (type === "" || /^text\/|\bxml\b|\bcsv\b/.test(type)) {
|
|
154
|
-
const text = await response.text();
|
|
155
|
-
return text === "" ? null : text;
|
|
156
|
-
}
|
|
157
|
-
const bytes = await response.bytes();
|
|
158
|
-
// Checked before encoding: no later pass reads through base64 a field does not declare.
|
|
159
|
-
return bytes.length === 0 ? null : { contentType: type, bytes: bytes.length, base64: encodeForAgent(ctx, bytes) };
|
|
160
|
-
}
|
|
161
|
-
catch (error) {
|
|
162
|
-
if (error instanceof ConnectorCallError)
|
|
163
|
-
throw error;
|
|
164
|
-
if (ctx.signal?.aborted === true && error === ctx.signal.reason)
|
|
165
|
-
throw error;
|
|
166
|
-
if (error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError")) {
|
|
167
|
-
throw new ConnectorCallError("unavailable", `${vendor.title} did not finish its response within the read cap.`, {
|
|
168
|
-
details: { code: "timeout" },
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
// The request was sent and the status arrived; only the body was lost. Told
|
|
172
|
-
// in Connecta's words (the runtime's can quote the transport), never raw.
|
|
173
|
-
// `callRest` decides whether that is safe to repeat.
|
|
174
|
-
throw unavailableCallError(error, undefined, `${vendor.title} answered, but its response could not be read.`);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
/**
|
|
178
|
-
* Send one prepared call and read it into the envelope. No validation and no
|
|
179
|
-
* refusal tables run here: the generic tools do both first, and named tools
|
|
180
|
-
* build their calls from fixed operations. `visibleHeaders` carry values the
|
|
181
|
-
* result returns to the caller, such as an idempotency key.
|
|
182
|
-
*/
|
|
183
|
-
export async function callRest(vendor, call, ctx, visibleHeaders = {}) {
|
|
184
|
-
const send = vendor.transport(call.op.server);
|
|
185
|
-
if (typeof send === "string")
|
|
186
|
-
invalid(send);
|
|
187
|
-
await vendor.admit?.(call, ctx);
|
|
188
|
-
const framing = vendor.encode ? vendor.encode(call) : defaultFraming(vendor, call);
|
|
189
|
-
const header = vendor.idempotencyHeader?.toLowerCase();
|
|
190
|
-
const idempotencyKey = header === undefined
|
|
191
|
-
? undefined
|
|
192
|
-
: Object.entries(visibleHeaders).find(([name, value]) => name.toLowerCase() === header && unsendableKey(value) === undefined)?.[1];
|
|
193
|
-
const request = {
|
|
194
|
-
method: call.method,
|
|
195
|
-
classification: call.method === "GET" ||
|
|
196
|
-
call.method === "HEAD" ||
|
|
197
|
-
(vendor.readPosts ?? []).some(([method, path]) => method === call.method && path === call.op.path)
|
|
198
|
-
? "read"
|
|
199
|
-
: "write",
|
|
200
|
-
// Only a key the vendor receives proves the write repeatable.
|
|
201
|
-
...(idempotencyKey === undefined ? {} : { idempotencyKey }),
|
|
202
|
-
path: call.path,
|
|
203
|
-
...(framing.query ? { query: framing.query } : {}),
|
|
204
|
-
...(framing.headers ? { headers: framing.headers } : {}),
|
|
205
|
-
visibleHeaders,
|
|
206
|
-
...(framing.rawBody !== undefined
|
|
207
|
-
? { rawBody: framing.rawBody }
|
|
208
|
-
: framing.body !== undefined
|
|
209
|
-
? { body: framing.body }
|
|
210
|
-
: {}),
|
|
211
|
-
};
|
|
212
|
-
const write = (callClassification(ctx) ?? request.classification) === "write";
|
|
213
|
-
return await send(request, ctx, async (response) => {
|
|
214
|
-
// Mappers cut the vendor's prose short, so it is redacted whole first.
|
|
215
|
-
if (!response.ok)
|
|
216
|
-
throw vendor.failure(response.status, response.headers, redactBeforeCut(ctx, await failureBody(response)), call, write);
|
|
217
|
-
// HEAD answers with headers alone; they are its data.
|
|
218
|
-
const body = call.method === "HEAD" ? headerData(response.headers) : await successBody(vendor, response, ctx);
|
|
219
|
-
// Order: unwrap the vendor's envelope, then redact (every success, HEAD
|
|
220
|
-
// header data and named tools included), then cursors (and, in the
|
|
221
|
-
// tools, `select`). Cursors read the envelope when the vendor unwraps
|
|
222
|
-
// one, and the redacted body otherwise.
|
|
223
|
-
const unwrapped = vendor.result && call.method !== "HEAD" ? vendor.result(body, call, response, ctx) : body;
|
|
224
|
-
const data = vendor.valueSafety.redact(unwrapped, call.method, call.op.path);
|
|
225
|
-
const page = vendor.page?.(vendor.result ? body : data, call);
|
|
226
|
-
return { status: response.status, data, ...(page ? { page } : {}) };
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
/** A HEAD response's headers as data, without cookies. */
|
|
230
|
-
function headerData(headers) {
|
|
231
|
-
const out = {};
|
|
232
|
-
headers.forEach((value, name) => {
|
|
233
|
-
if (name !== "set-cookie")
|
|
234
|
-
out[name] = value;
|
|
235
|
-
});
|
|
236
|
-
return out;
|
|
237
|
-
}
|
|
238
|
-
/** Keep only the named dot paths; a path through an array applies to each item. */
|
|
239
|
-
function project(value, paths) {
|
|
240
|
-
const root = new Map();
|
|
241
|
-
for (const path of paths) {
|
|
242
|
-
let node = root;
|
|
243
|
-
const parts = path.split(".");
|
|
244
|
-
for (const [index, part] of parts.entries()) {
|
|
245
|
-
const existing = node.get(part);
|
|
246
|
-
// A shorter path already keeps this whole subtree.
|
|
247
|
-
if (existing === true)
|
|
248
|
-
break;
|
|
249
|
-
if (index === parts.length - 1) {
|
|
250
|
-
node.set(part, true);
|
|
251
|
-
break;
|
|
252
|
-
}
|
|
253
|
-
const next = existing ?? new Map();
|
|
254
|
-
node.set(part, next);
|
|
255
|
-
node = next;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
const pick = (current, tree) => {
|
|
259
|
-
if (Array.isArray(current))
|
|
260
|
-
return current.map((item) => pick(item, tree));
|
|
261
|
-
if (!isRecord(current))
|
|
262
|
-
return current;
|
|
263
|
-
const out = {};
|
|
264
|
-
for (const [key, child] of tree) {
|
|
265
|
-
if (!Object.hasOwn(current, key))
|
|
266
|
-
continue;
|
|
267
|
-
out[key] = child === true ? current[key] : pick(current[key], child);
|
|
268
|
-
}
|
|
269
|
-
return out;
|
|
270
|
-
};
|
|
271
|
-
return pick(value, root);
|
|
272
|
-
}
|
|
273
|
-
function selected(args, result) {
|
|
274
|
-
const select = args["select"];
|
|
275
|
-
if (!Array.isArray(select) || select.length === 0)
|
|
276
|
-
return result;
|
|
277
|
-
return { ...result, data: project(result.data, select) };
|
|
278
|
-
}
|
|
279
|
-
function pathSchema(example = "/v1/customers/cus_123") {
|
|
280
|
-
return {
|
|
281
|
-
type: "string",
|
|
282
|
-
minLength: 2,
|
|
283
|
-
maxLength: 2048,
|
|
284
|
-
description: `Concrete path from search with ids filled in, e.g. ${example}. No query string.`,
|
|
285
|
-
};
|
|
286
|
-
}
|
|
287
|
-
const QUERY = {
|
|
288
|
-
type: "object",
|
|
289
|
-
description: "Query parameters as JSON, named as details lists them.",
|
|
290
|
-
};
|
|
291
|
-
const SELECT = {
|
|
292
|
-
type: "array",
|
|
293
|
-
maxItems: MAX_SELECT,
|
|
294
|
-
items: { type: "string", minLength: 1, maxLength: 200 },
|
|
295
|
-
description: "Dot paths to keep from data, e.g. data.id; a path through a list applies to each item.",
|
|
296
|
-
};
|
|
297
|
-
const PAGE = {
|
|
298
|
-
type: "object",
|
|
299
|
-
description: "Present on lists: pass next as the param query parameter (a body field when in is body) for the next page. hasMore without next: no cursor exists.",
|
|
300
|
-
properties: {
|
|
301
|
-
hasMore: { type: "boolean" },
|
|
302
|
-
next: { type: "string" },
|
|
303
|
-
param: { type: "string" },
|
|
304
|
-
in: { type: "string", enum: ["query", "body"] },
|
|
305
|
-
path: { type: "string", description: "GET this path for the next page, when it differs from this call's." },
|
|
306
|
-
},
|
|
307
|
-
required: ["hasMore"],
|
|
308
|
-
};
|
|
309
|
-
function envelope(extra = {}) {
|
|
310
|
-
return {
|
|
311
|
-
type: "object",
|
|
312
|
-
properties: {
|
|
313
|
-
status: { type: "integer", description: "HTTP status." },
|
|
314
|
-
data: {
|
|
315
|
-
description: "Response body: JSON, text, NDJSON rows, { contentType, bytes, base64 }, or HEAD's headers.",
|
|
316
|
-
},
|
|
317
|
-
page: PAGE,
|
|
318
|
-
...extra,
|
|
319
|
-
},
|
|
320
|
-
required: ["status", "data"],
|
|
321
|
-
};
|
|
322
|
-
}
|
|
323
|
-
function closed(properties, required) {
|
|
324
|
-
return { type: "object", properties, required, additionalProperties: false };
|
|
325
|
-
}
|
|
326
|
-
/** The four generic tools over one vendor's index. */
|
|
327
|
-
export function restTools(vendor) {
|
|
328
|
-
const { index, title } = vendor;
|
|
329
|
-
const tool = (kind) => `${vendor.vendor}_api_${kind}`;
|
|
330
|
-
const PATH = pathSchema(vendor.pathExample);
|
|
331
|
-
const headerNames = Object.keys(vendor.headers ?? {});
|
|
332
|
-
const HEADERS = headerNames.length
|
|
333
|
-
? {
|
|
334
|
-
headers: {
|
|
335
|
-
type: "object",
|
|
336
|
-
description: "Optional request headers this vendor's operations read; nothing else is accepted.",
|
|
337
|
-
properties: Object.fromEntries(Object.entries(vendor.headers).map(([name, description]) => [
|
|
338
|
-
name,
|
|
339
|
-
{ type: "string", minLength: 1, maxLength: 200, description },
|
|
340
|
-
])),
|
|
341
|
-
additionalProperties: false,
|
|
342
|
-
},
|
|
343
|
-
}
|
|
344
|
-
: {};
|
|
345
|
-
const headersOf = (args) => isRecord(args["headers"]) ? { headers: args["headers"] } : {};
|
|
346
|
-
const readPosts = vendor.readPosts ?? [];
|
|
347
|
-
for (const [method, path] of readPosts) {
|
|
348
|
-
// A reviewed exception the index no longer carries is a stale review.
|
|
349
|
-
if (!index.operation(method, path)) {
|
|
350
|
-
throw new Error(`${title} reviewed read-only ${method} ${path} is not in the pinned API index.`);
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
const reads = (op) => op.method === "GET" ||
|
|
354
|
-
op.method === "HEAD" ||
|
|
355
|
-
readPosts.some(([method, path]) => method === op.method && path === op.path);
|
|
356
|
-
const writeMethods = index.methods.filter((method) => method !== "GET" && method !== "HEAD");
|
|
357
|
-
// The methods an idempotency key is sent with: never DELETE (see the handler).
|
|
358
|
-
const keyed = vendor.idempotencyHeader ? writeMethods.filter((method) => method !== "DELETE") : [];
|
|
359
|
-
const keyedText = keyed.join(" or ");
|
|
360
|
-
const head = index.methods.includes("HEAD");
|
|
361
|
-
const readMethods = [...new Set(["GET", ...(head ? ["HEAD"] : []), ...readPosts.map(([method]) => method)])];
|
|
362
|
-
const routeOf = (op) => (reads(op) ? tool("read") : tool("write"));
|
|
363
|
-
// An unreachable host and a reviewed refusal are answered before argument
|
|
364
|
-
// checks: no corrected argument would make either call go through.
|
|
365
|
-
const guard = (call) => {
|
|
366
|
-
const send = vendor.transport(call.op.server);
|
|
367
|
-
if (typeof send === "string")
|
|
368
|
-
invalid(send);
|
|
369
|
-
const refusal = vendor.valueSafety.refusal(call.method, call.op.path) ?? vendor.refuse?.(call);
|
|
370
|
-
if (refusal)
|
|
371
|
-
invalid(refusal);
|
|
372
|
-
};
|
|
373
|
-
return [
|
|
374
|
-
{
|
|
375
|
-
name: tool("search"),
|
|
376
|
-
description: `Find ${title} API operations by keyword or path in the pinned API index. Returns method, path, and summary; reads no account data.`,
|
|
377
|
-
annotations: READ,
|
|
378
|
-
inputSchema: closed({
|
|
379
|
-
query: {
|
|
380
|
-
type: "string",
|
|
381
|
-
minLength: 1,
|
|
382
|
-
maxLength: 200,
|
|
383
|
-
description: "Keywords or a path, e.g. refund charge.",
|
|
384
|
-
},
|
|
385
|
-
method: { type: "string", enum: [...index.methods], description: "Only operations with this method." },
|
|
386
|
-
limit: { type: "integer", minimum: 1, maximum: 50, description: "Most operations to return; default 10." },
|
|
387
|
-
}, ["query"]),
|
|
388
|
-
outputSchema: {
|
|
389
|
-
type: "object",
|
|
390
|
-
properties: {
|
|
391
|
-
operations: {
|
|
392
|
-
type: "array",
|
|
393
|
-
items: {
|
|
394
|
-
type: "object",
|
|
395
|
-
properties: {
|
|
396
|
-
method: { type: "string" },
|
|
397
|
-
path: { type: "string" },
|
|
398
|
-
summary: { type: "string" },
|
|
399
|
-
tool: { type: "string", description: "The tool that calls it." },
|
|
400
|
-
},
|
|
401
|
-
required: ["method", "path", "tool"],
|
|
402
|
-
},
|
|
403
|
-
},
|
|
404
|
-
revision: { type: "string", description: "Pinned index revision." },
|
|
405
|
-
},
|
|
406
|
-
required: ["operations", "revision"],
|
|
407
|
-
},
|
|
408
|
-
handler: (args) => ({
|
|
409
|
-
operations: index
|
|
410
|
-
.search(String(args["query"]), {
|
|
411
|
-
...(typeof args["method"] === "string" ? { method: args["method"] } : {}),
|
|
412
|
-
limit: typeof args["limit"] === "number" ? args["limit"] : 10,
|
|
413
|
-
})
|
|
414
|
-
.map((op) => ({
|
|
415
|
-
method: op.method,
|
|
416
|
-
path: op.path,
|
|
417
|
-
operationId: op.operationId,
|
|
418
|
-
summary: op.summary,
|
|
419
|
-
tag: op.tag,
|
|
420
|
-
tool: routeOf(op),
|
|
421
|
-
})),
|
|
422
|
-
revision: index.revision,
|
|
423
|
-
}),
|
|
424
|
-
},
|
|
425
|
-
{
|
|
426
|
-
name: tool("details"),
|
|
427
|
-
description: `Read one ${title} API operation's request contract: path, query, and body parameters with types, enums, and required names. Reads no account data.`,
|
|
428
|
-
annotations: READ,
|
|
429
|
-
inputSchema: closed({
|
|
430
|
-
method: { type: "string", enum: [...index.methods], description: "The operation's HTTP method." },
|
|
431
|
-
path: { ...PATH, description: "The path template from search, or a concrete path." },
|
|
432
|
-
param: { type: "string", minLength: 1, maxLength: 200, description: "Return only this parameter." },
|
|
433
|
-
}, ["method", "path"]),
|
|
434
|
-
outputSchema: {
|
|
435
|
-
type: "object",
|
|
436
|
-
properties: {
|
|
437
|
-
method: { type: "string" },
|
|
438
|
-
path: { type: "string" },
|
|
439
|
-
tool: { type: "string", description: "The tool that calls it." },
|
|
440
|
-
parameters: { type: "array", description: "Path and query parameters." },
|
|
441
|
-
body: {
|
|
442
|
-
type: "object",
|
|
443
|
-
description: "Body content type and schema; truncated: true marks unexpanded levels.",
|
|
444
|
-
},
|
|
445
|
-
revision: { type: "string" },
|
|
446
|
-
},
|
|
447
|
-
required: ["method", "path", "tool", "parameters", "revision"],
|
|
448
|
-
},
|
|
449
|
-
handler: (args) => {
|
|
450
|
-
const method = String(args["method"]);
|
|
451
|
-
const path = String(args["path"]);
|
|
452
|
-
const op = index.operation(method, path) ?? index.resolve(method, path).op;
|
|
453
|
-
return {
|
|
454
|
-
...index.contract(op, typeof args["param"] === "string" ? args["param"] : undefined),
|
|
455
|
-
tool: routeOf(op),
|
|
456
|
-
revision: index.revision,
|
|
457
|
-
};
|
|
458
|
-
},
|
|
459
|
-
},
|
|
460
|
-
{
|
|
461
|
-
name: tool("read"),
|
|
462
|
-
description: `Call a ${title} API GET${head ? " or HEAD" : ""} operation${readPosts.length ? ", or a reviewed read-only POST" : ""}. ` +
|
|
463
|
-
"Arguments are checked against the pinned index before anything is sent.",
|
|
464
|
-
annotations: READ,
|
|
465
|
-
inputSchema: closed({
|
|
466
|
-
path: PATH,
|
|
467
|
-
...(readMethods.length > 1
|
|
468
|
-
? {
|
|
469
|
-
method: {
|
|
470
|
-
type: "string",
|
|
471
|
-
enum: readMethods,
|
|
472
|
-
description: "GET by default; another method only for an operation search routes here.",
|
|
473
|
-
},
|
|
474
|
-
}
|
|
475
|
-
: {}),
|
|
476
|
-
...(readPosts.length
|
|
477
|
-
? {
|
|
478
|
-
body: {
|
|
479
|
-
type: vendor.textBodies ? ["object", "string"] : "object",
|
|
480
|
-
description: `Body for a reviewed read-only POST${vendor.textBodies ? "; a string for a text-framed operation" : ""}.`,
|
|
481
|
-
},
|
|
482
|
-
}
|
|
483
|
-
: {}),
|
|
484
|
-
query: QUERY,
|
|
485
|
-
...HEADERS,
|
|
486
|
-
select: SELECT,
|
|
487
|
-
}, ["path"]),
|
|
488
|
-
outputSchema: envelope(),
|
|
489
|
-
handler: async (args, ctx) => {
|
|
490
|
-
const method = (typeof args["method"] === "string" ? args["method"] : "GET");
|
|
491
|
-
const call = restCall(vendor, method, String(args["path"]), {
|
|
492
|
-
query: isRecord(args["query"]) ? args["query"] : {},
|
|
493
|
-
body: args["body"],
|
|
494
|
-
...headersOf(args),
|
|
495
|
-
});
|
|
496
|
-
if (!reads(call.op)) {
|
|
497
|
-
invalid(`${call.method} ${call.op.path} is not a reviewed read; call it with ${tool("write")}.`);
|
|
498
|
-
}
|
|
499
|
-
if ((call.method === "GET" || call.method === "HEAD") && call.body !== undefined) {
|
|
500
|
-
invalid(`A ${call.method} operation takes query, not body.`);
|
|
501
|
-
}
|
|
502
|
-
guard(call);
|
|
503
|
-
index.check(call.op, call.query, call.body);
|
|
504
|
-
return selected(args, await callRest(vendor, call, ctx));
|
|
505
|
-
},
|
|
506
|
-
},
|
|
507
|
-
{
|
|
508
|
-
name: tool("write"),
|
|
509
|
-
description: `Call a ${title} API write (${writeMethods.join(", ")}); method and path are stated for approval. ` +
|
|
510
|
-
`Checked against the pinned index first.${keyed.length ? ` Pass idempotencyKey to retry a ${keyedText} safely${keyed.length < writeMethods.length ? "; a DELETE sends no key" : ""}.` : ""}`,
|
|
511
|
-
annotations: DESTRUCTIVE,
|
|
512
|
-
inputSchema: closed({
|
|
513
|
-
method: { type: "string", enum: writeMethods, description: "The operation's HTTP method." },
|
|
514
|
-
path: PATH,
|
|
515
|
-
query: QUERY,
|
|
516
|
-
body: {
|
|
517
|
-
type: vendor.textBodies ? ["object", "array", "string"] : ["object", "array"],
|
|
518
|
-
description: `Request body as JSON${vendor.bodyHint ? `; ${vendor.bodyHint}` : ""}.`,
|
|
519
|
-
},
|
|
520
|
-
...HEADERS,
|
|
521
|
-
...(vendor.idempotencyHeader
|
|
522
|
-
? {
|
|
523
|
-
idempotencyKey: {
|
|
524
|
-
type: "string",
|
|
525
|
-
minLength: 1,
|
|
526
|
-
maxLength: IDEMPOTENCY_KEY_MAX_LENGTH,
|
|
527
|
-
description: `Reuse to retry this exact ${keyedText}; one is generated and returned when omitted.`,
|
|
528
|
-
},
|
|
529
|
-
}
|
|
530
|
-
: {}),
|
|
531
|
-
select: SELECT,
|
|
532
|
-
}, ["method", "path"]),
|
|
533
|
-
outputSchema: envelope(vendor.idempotencyHeader
|
|
534
|
-
? {
|
|
535
|
-
idempotencyKey: {
|
|
536
|
-
type: "string",
|
|
537
|
-
description: "The key sent; reuse it, with the same arguments, while the vendor retains it.",
|
|
538
|
-
},
|
|
539
|
-
}
|
|
540
|
-
: {}),
|
|
541
|
-
handler: async (args, ctx) => {
|
|
542
|
-
const call = restCall(vendor, args["method"], String(args["path"]), {
|
|
543
|
-
query: isRecord(args["query"]) ? args["query"] : {},
|
|
544
|
-
body: args["body"],
|
|
545
|
-
...headersOf(args),
|
|
546
|
-
});
|
|
547
|
-
if (call.method === "GET" || call.method === "HEAD") {
|
|
548
|
-
invalid(`${call.method} operations are reads; call them with ${tool("read")}.`);
|
|
549
|
-
}
|
|
550
|
-
guard(call);
|
|
551
|
-
const framing = index.bodyType(call.op);
|
|
552
|
-
if (framing && /^multipart\/|octet-stream/.test(framing)) {
|
|
553
|
-
invalid(`${call.method} ${call.op.path} takes a ${framing} upload, which ${tool("write")} does not send.` +
|
|
554
|
-
(vendor.upload ? ` ${vendor.upload}` : ""));
|
|
555
|
-
}
|
|
556
|
-
index.check(call.op, call.query, call.body);
|
|
557
|
-
const header = vendor.idempotencyHeader && call.method !== "DELETE" ? vendor.idempotencyHeader : undefined;
|
|
558
|
-
const given = typeof args["idempotencyKey"] === "string" ? args["idempotencyKey"] : undefined;
|
|
559
|
-
// A blank key reaches the vendor empty, and one fetch cannot carry
|
|
560
|
-
// never leaves: neither proves that a retry cannot repeat the write.
|
|
561
|
-
const unsendable = given === undefined ? undefined : unsendableKey(given);
|
|
562
|
-
if (unsendable)
|
|
563
|
-
invalid(`idempotencyKey ${unsendable}, or omit it.`);
|
|
564
|
-
const key = header ? (given ?? crypto.randomUUID()) : undefined;
|
|
565
|
-
// Recorded before dispatch: if the invocation deadline interrupts this
|
|
566
|
-
// call, no failure of ours arrives, and the invocation returns the key
|
|
567
|
-
// from here with write_outcome_unknown.
|
|
568
|
-
if (key)
|
|
569
|
-
recordRecovery(ctx, { idempotencyKey: key });
|
|
570
|
-
try {
|
|
571
|
-
const result = await callRest(vendor, call, ctx, header && key ? { [header]: key } : {});
|
|
572
|
-
return { ...selected(args, result), ...(key ? { idempotencyKey: key } : {}) };
|
|
573
|
-
}
|
|
574
|
-
catch (error) {
|
|
575
|
-
// A generated key exists nowhere else, so a failure must carry it:
|
|
576
|
-
// only that key lets the caller check or retry this exact write.
|
|
577
|
-
// The key stays in the message as a diagnostic; the advice to
|
|
578
|
-
// reuse it is a segment the invocation composes only where the key
|
|
579
|
-
// protects the whole logical call (keyResendAdvice), and exists
|
|
580
|
-
// only once the ledger shows a dispatched request carrying it.
|
|
581
|
-
if (!key || given || !(error instanceof ConnectorCallError))
|
|
582
|
-
throw error;
|
|
583
|
-
const failure = providerFailureMessage(error, `${error.message} ${header}: ${key}.`);
|
|
584
|
-
if (!providerRequests(ctx)?.some((request) => request.dispatched && request.key === key))
|
|
585
|
-
throw failure;
|
|
586
|
-
throw keyResendAdvice(failure, key, " Reusing it with the exact original arguments retries this write without repeating it only while " +
|
|
587
|
-
"the vendor retains the key; after that, look the object up before retrying.");
|
|
588
|
-
}
|
|
589
|
-
},
|
|
590
|
-
},
|
|
591
|
-
];
|
|
592
|
-
}
|