@zackbart/connecta 0.30.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -0
- package/README.md +21 -16
- package/bin/connecta.mjs +1 -0
- package/bin/version.mjs +1 -1
- package/dist/auth-elicitation.js +1 -1
- package/dist/call-recovery.js +25 -0
- package/dist/catalog-service.js +7 -2
- package/dist/config-schema.d.ts +14 -7
- package/dist/config-schema.js +29 -10
- package/dist/config-value-sources.js +6 -3
- package/dist/config.d.ts +26 -8
- package/dist/config.js +38 -16
- package/dist/connector-access.d.ts +2 -1
- package/dist/connectors/guarded-fetch.d.ts +18 -1
- package/dist/connectors/guarded-fetch.js +21 -2
- package/dist/describe-config.d.ts +6 -3
- package/dist/describe-config.js +3 -2
- package/dist/errors.d.ts +3 -1
- package/dist/errors.js +1 -0
- package/dist/execute.js +16 -14
- package/dist/index.js +4 -3
- package/dist/invocation.js +39 -4
- package/dist/meta-tools.js +17 -19
- package/dist/operator-record.d.ts +2 -2
- package/dist/operator-record.js +2 -0
- package/dist/operator-ui/contract.d.ts +5 -4
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +1 -1
- package/dist/operator-ui/view.d.ts +9 -0
- package/dist/operator-ui/view.js +12 -0
- package/dist/program-writes.js +4 -49
- package/dist/provider.d.ts +6 -5
- package/dist/provider.js +3 -4
- package/dist/providers/_shared/google/workspace.js +1 -1
- package/dist/providers/_shared/rest/dispatch.js +41 -0
- package/dist/providers/_shared/rest/operation-index.d.ts +133 -0
- package/dist/providers/_shared/rest/operation-index.js +527 -0
- package/dist/providers/_shared/rest/tools.d.ts +141 -0
- package/dist/providers/_shared/rest/tools.js +569 -0
- package/dist/providers/_shared/rest/value-safety.d.ts +151 -0
- package/dist/providers/_shared/rest/value-safety.js +580 -0
- package/dist/providers/breeze/skill.generated.js +2 -2
- package/dist/providers/cloudflare/graphql.js +385 -0
- package/dist/providers/cloudflare/index.d.ts +47 -51
- package/dist/providers/cloudflare/index.js +188 -1997
- package/dist/providers/cloudflare/openapi.generated.js +3347 -0
- package/dist/providers/cloudflare/rest.d.ts +37 -0
- package/dist/providers/cloudflare/rest.js +1056 -0
- package/dist/providers/cloudflare/skill.generated.js +7 -4
- package/dist/providers/cloudflare/value-safety.js +1247 -0
- package/dist/providers/docs/index.d.ts +1 -1
- package/dist/providers/docs/index.js +2 -2
- package/dist/providers/drive/index.d.ts +1 -1
- package/dist/providers/drive/index.js +1 -1
- package/dist/providers/forms/index.d.ts +1 -1
- package/dist/providers/forms/index.js +2 -2
- package/dist/providers/github/skill.generated.js +2 -2
- package/dist/providers/gmail/index.d.ts +1 -1
- package/dist/providers/gmail/index.js +274 -23
- package/dist/providers/gmail/skill.generated.js +2 -2
- package/dist/providers/linear/index.js +1 -1
- package/dist/providers/mixpanel/index.d.ts +27 -12
- package/dist/providers/mixpanel/index.js +58 -6
- package/dist/providers/mixpanel/operations.js +20 -0
- package/dist/providers/mixpanel/rest.js +603 -0
- package/dist/providers/mixpanel/skill.generated.js +3 -2
- package/dist/providers/mixpanel/value-safety.js +16 -0
- package/dist/providers/notion/index.d.ts +35 -31
- package/dist/providers/notion/index.js +76 -1499
- package/dist/providers/notion/openapi.generated.js +75 -0
- package/dist/providers/notion/rest.d.ts +24 -0
- package/dist/providers/notion/rest.js +1186 -0
- package/dist/providers/notion/skill.generated.js +5 -4
- package/dist/providers/notion/value-safety.js +31 -0
- package/dist/providers/planning-center/index.d.ts +27 -0
- package/dist/providers/planning-center/index.js +286 -24
- package/dist/providers/planning-center/skill.generated.js +24 -4
- package/dist/providers/sheets/index.d.ts +1 -1
- package/dist/providers/sheets/index.js +3 -3
- package/dist/providers/slides/index.d.ts +1 -1
- package/dist/providers/slides/index.js +3 -3
- package/dist/providers/stripe/index.d.ts +26 -20
- package/dist/providers/stripe/index.js +103 -130
- package/dist/providers/stripe/openapi.generated.js +649 -0
- package/dist/providers/stripe/rest.d.ts +14 -0
- package/dist/providers/stripe/rest.js +395 -0
- package/dist/providers/stripe/skill.generated.js +7 -5
- package/dist/providers/stripe/value-safety.js +705 -0
- package/dist/providers/vercel/index.d.ts +31 -22
- package/dist/providers/vercel/index.js +96 -853
- package/dist/providers/vercel/openapi.generated.js +456 -0
- package/dist/providers/vercel/rest.d.ts +20 -0
- package/dist/providers/vercel/rest.js +1002 -0
- package/dist/providers/vercel/skill.generated.js +4 -5
- package/dist/providers/vercel/value-safety.js +328 -0
- package/dist/providers/wispr-flow/index.d.ts +14 -0
- package/dist/providers/wispr-flow/index.js +117 -0
- package/dist/providers/wispr-flow/skill.generated.js +8 -0
- package/dist/routes/mcp.js +30 -10
- package/dist/routes/operator.js +14 -7
- package/dist/routes/ui-config.js +2 -2
- package/dist/server.js +3 -0
- package/dist/skills.js +5 -4
- package/dist/tool-safety.js +13 -5
- package/dist/types.d.ts +1 -1
- package/dist/ui.d.ts +1 -1
- package/dist/ui.js +1 -1
- package/dist/usage-guide.js +3 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/write-outcome.js +47 -0
- package/documentation/README.md +1 -0
- package/documentation/architecture.md +155 -28
- package/documentation/auth.md +86 -27
- package/documentation/code-mode.md +81 -72
- package/documentation/deploying.md +10 -5
- package/documentation/integrating.md +72 -9
- package/documentation/meta-tools.md +52 -38
- package/documentation/operating.md +11 -8
- package/documentation/operator-tests.md +2 -3
- package/documentation/operator-ui.md +9 -3
- package/documentation/provider-auth-migration.md +308 -0
- package/documentation/provider-migration-0.29.md +18 -6
- package/examples/worker/AGENTS.md +5 -0
- package/examples/worker/README.md +2 -2
- package/examples/worker/src/connecta.config.ts +5 -0
- package/package.json +7 -2
- package/templates/node/AGENTS.md +6 -4
- package/templates/node/README.md +6 -4
- package/templates/node/package.json +1 -1
- package/templates/node/src/connecta.config.ts +5 -0
- package/dist/providers/vercel/mcp-ownership.js +0 -212
- package/dist/tool-safety.d.ts +0 -9
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
// The pinned operation index a REST connector validates against before it
|
|
2
|
+
// sends anything. `scripts/generate-openapi.mjs` compiles a vendor's OpenAPI
|
|
3
|
+
// document into `OpenApiData` (search rows plus request-side details as one
|
|
4
|
+
// lazily parsed JSON string); this module reads it. Nothing here does I/O.
|
|
5
|
+
//
|
|
6
|
+
// The index is the contract: a path no operation matches is refused with the
|
|
7
|
+
// nearest operations, and query or body names the operation does not declare
|
|
8
|
+
// are refused with the accepted names, because a guessed argument is the most
|
|
9
|
+
// common way an agent wastes a call. Drift between the pin and the vendor is
|
|
10
|
+
// caught by `providers:check` and refreshed by `providers:spec`.
|
|
11
|
+
import { ConnectorCallError } from "../../../errors.js";
|
|
12
|
+
const METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]);
|
|
13
|
+
const MAX_ISSUES = 20;
|
|
14
|
+
function words(text) {
|
|
15
|
+
return text
|
|
16
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
17
|
+
.toLowerCase()
|
|
18
|
+
.split(/[^a-z0-9]+/)
|
|
19
|
+
.filter(Boolean);
|
|
20
|
+
}
|
|
21
|
+
function isRecord(value) {
|
|
22
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
23
|
+
}
|
|
24
|
+
function invalid(message, issues, repair) {
|
|
25
|
+
throw new ConnectorCallError("invalid_args", message, {
|
|
26
|
+
...(issues ? { validation: { issues } } : {}),
|
|
27
|
+
...(repair ? { repair } : {}),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
function typesOf(node) {
|
|
31
|
+
return node.t === undefined ? [] : typeof node.t === "string" ? [node.t] : node.t;
|
|
32
|
+
}
|
|
33
|
+
/** Edit distance, for naming the parameter a misspelling meant. */
|
|
34
|
+
function distance(a, b) {
|
|
35
|
+
let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
36
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
37
|
+
const current = [i];
|
|
38
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
39
|
+
current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
40
|
+
}
|
|
41
|
+
previous = current;
|
|
42
|
+
}
|
|
43
|
+
return previous[b.length];
|
|
44
|
+
}
|
|
45
|
+
/** The accepted names, bounded and led by the likeliest intended one; repair details carry the full list. */
|
|
46
|
+
function oneOf(names, given) {
|
|
47
|
+
const close = given === undefined
|
|
48
|
+
? undefined
|
|
49
|
+
: names
|
|
50
|
+
.map((name) => ({ name, cost: distance(given.toLowerCase(), name.toLowerCase()) }))
|
|
51
|
+
.filter(({ cost }) => cost <= Math.max(2, Math.floor(given.length / 3)))
|
|
52
|
+
.sort((a, b) => a.cost - b.cost)[0]?.name;
|
|
53
|
+
const shown = names.slice(0, 12).join(", ");
|
|
54
|
+
const list = names.length > 12 ? `one of ${shown}, … (${names.length} in all)` : `one of ${shown}`;
|
|
55
|
+
return close ? `${close}? ${list}` : list;
|
|
56
|
+
}
|
|
57
|
+
function valueType(value) {
|
|
58
|
+
return value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Split a caller's path into segments, refusing anything that could resolve
|
|
62
|
+
* somewhere other than the operation it was validated as: dot segments in any
|
|
63
|
+
* spelling (the URL parser treats `%2e%2e` as `..`), encoded separators,
|
|
64
|
+
* empty segments, control characters, and unfilled `{placeholders}`.
|
|
65
|
+
*/
|
|
66
|
+
function segmentsOf(path, title, search, slashes) {
|
|
67
|
+
if (typeof path !== "string" || !path.startsWith("/")) {
|
|
68
|
+
invalid(`A ${title} path begins with "/" and names one API operation, such as one ${search} returned.`);
|
|
69
|
+
}
|
|
70
|
+
if (/[?#]/.test(path))
|
|
71
|
+
invalid(`A ${title} path carries no query or fragment; put query parameters in query.`);
|
|
72
|
+
const segments = path.slice(1).split("/");
|
|
73
|
+
for (const [index, segment] of segments.entries()) {
|
|
74
|
+
if (segment === "")
|
|
75
|
+
invalid(`A ${title} path has no empty segments or trailing slash.`);
|
|
76
|
+
if (/[{}]/.test(segment)) {
|
|
77
|
+
invalid(`The ${title} path still contains a {placeholder}; substitute the real id from an earlier result.`);
|
|
78
|
+
}
|
|
79
|
+
let decoded;
|
|
80
|
+
try {
|
|
81
|
+
decoded = decodeURIComponent(segment);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return invalid(`A ${title} path segment is not valid percent-encoding.`);
|
|
85
|
+
}
|
|
86
|
+
const control = [...decoded].some((character) => character.charCodeAt(0) < 0x20 || character === "\u007f");
|
|
87
|
+
// An encoded "/" is held for the match to decide (see `resolve`): only a
|
|
88
|
+
// last segment filling a reviewed slash parameter may carry one, and none
|
|
89
|
+
// of its pieces may be empty or a dot segment.
|
|
90
|
+
const slashed = slashes !== undefined &&
|
|
91
|
+
decoded.includes("/") &&
|
|
92
|
+
decoded.split("/").every((piece) => piece !== "" && piece !== "." && piece !== "..");
|
|
93
|
+
if (slashed)
|
|
94
|
+
slashes.add(index);
|
|
95
|
+
if (decoded === "." ||
|
|
96
|
+
decoded === ".." ||
|
|
97
|
+
decoded.includes("\\") ||
|
|
98
|
+
(decoded.includes("/") && !slashed) ||
|
|
99
|
+
control) {
|
|
100
|
+
invalid(`A ${title} path segment may not be a dot segment, an encoded separator, or a control character.`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return segments;
|
|
104
|
+
}
|
|
105
|
+
/** The read side of one vendor's pinned index. */
|
|
106
|
+
export class OperationIndex {
|
|
107
|
+
version;
|
|
108
|
+
revision;
|
|
109
|
+
ops;
|
|
110
|
+
/** The methods any operation uses, in a stable order. */
|
|
111
|
+
methods;
|
|
112
|
+
#data;
|
|
113
|
+
#title;
|
|
114
|
+
#vendor;
|
|
115
|
+
#segments;
|
|
116
|
+
#slashParams;
|
|
117
|
+
#details;
|
|
118
|
+
#words;
|
|
119
|
+
/**
|
|
120
|
+
* `vendor` is the tool-name prefix; `title` the display name in refusals.
|
|
121
|
+
* `slashParams` names path parameters whose values may carry an encoded
|
|
122
|
+
* "/" (`%2F`), such as an object key, and only as the path's last segment.
|
|
123
|
+
*/
|
|
124
|
+
constructor(data, names) {
|
|
125
|
+
const { title } = names;
|
|
126
|
+
this.#slashParams = new Set(names.slashParams ?? []);
|
|
127
|
+
this.#data = data;
|
|
128
|
+
this.#title = title;
|
|
129
|
+
this.#vendor = names.vendor;
|
|
130
|
+
this.version = data.version;
|
|
131
|
+
this.revision = data.revision;
|
|
132
|
+
this.ops = data.ops.map(([method, path, operationId, summary, tag, server], row) => {
|
|
133
|
+
if (!METHODS.has(method))
|
|
134
|
+
throw new Error(`${title} operation index row ${row} has unsupported method ${method}.`);
|
|
135
|
+
return Object.freeze({
|
|
136
|
+
method: method,
|
|
137
|
+
path,
|
|
138
|
+
operationId,
|
|
139
|
+
summary,
|
|
140
|
+
tag: data.tags[tag] ?? "",
|
|
141
|
+
server: server === undefined ? undefined : data.servers[server],
|
|
142
|
+
row,
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
this.#segments = this.ops.map((op) => op.path.slice(1).split("/"));
|
|
146
|
+
this.methods = [...METHODS].filter((method) => this.ops.some((op) => op.method === method));
|
|
147
|
+
}
|
|
148
|
+
/** The operation with this exact method and path template, if any. */
|
|
149
|
+
operation(method, template) {
|
|
150
|
+
return this.ops.find((op) => op.method === method && op.path === template);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Match a concrete call to its operation. Literal segments outrank
|
|
154
|
+
* parameters, as OpenAPI requires (`/v1/customers/search` before
|
|
155
|
+
* `/v1/customers/{customer}`); the most specific template that fits decides,
|
|
156
|
+
* and a method it does not define is refused rather than retried against a
|
|
157
|
+
* looser template.
|
|
158
|
+
*/
|
|
159
|
+
resolve(method, path) {
|
|
160
|
+
const slashes = this.#slashParams.size > 0 ? new Set() : undefined;
|
|
161
|
+
const segments = segmentsOf(path, this.#title, this.#vendorTool("search"), slashes);
|
|
162
|
+
let best = { score: -1, rows: [] };
|
|
163
|
+
this.#segments.forEach((template, row) => {
|
|
164
|
+
if (template.length !== segments.length)
|
|
165
|
+
return;
|
|
166
|
+
let score = 0;
|
|
167
|
+
for (const [index, part] of template.entries()) {
|
|
168
|
+
if (part.startsWith("{"))
|
|
169
|
+
continue;
|
|
170
|
+
if (part !== segments[index])
|
|
171
|
+
return;
|
|
172
|
+
score += 1;
|
|
173
|
+
}
|
|
174
|
+
if (score > best.score)
|
|
175
|
+
best = { score, rows: [row] };
|
|
176
|
+
else if (score === best.score)
|
|
177
|
+
best.rows.push(row);
|
|
178
|
+
});
|
|
179
|
+
if (best.rows.length === 0) {
|
|
180
|
+
invalid(`${this.#title} has no ${path} operation in the pinned API index (${this.revision}). ` +
|
|
181
|
+
`Nearest: ${this.nearest(method, segments).join("; ")}. Find operations with ${this.#vendorTool("search")}.`);
|
|
182
|
+
}
|
|
183
|
+
const templates = new Set(best.rows.map((row) => this.ops[row].path));
|
|
184
|
+
const op = best.rows.map((row) => this.ops[row]).find((candidate) => candidate.method === method);
|
|
185
|
+
if (!op) {
|
|
186
|
+
const allowed = [...new Set(best.rows.map((row) => this.ops[row].method))].join(", ");
|
|
187
|
+
invalid(`${this.#title} ${[...templates].join(" or ")} accepts ${allowed}, not ${method}.`);
|
|
188
|
+
}
|
|
189
|
+
const template = this.#segments[op.row];
|
|
190
|
+
for (const index of slashes ?? []) {
|
|
191
|
+
const part = template[index];
|
|
192
|
+
if (index !== template.length - 1 || !part.startsWith("{") || !this.#slashParams.has(part.slice(1, -1))) {
|
|
193
|
+
invalid(`A ${this.#title} path segment may not be a dot segment, an encoded separator, or a control character.`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const params = {};
|
|
197
|
+
template.forEach((part, index) => {
|
|
198
|
+
if (part.startsWith("{"))
|
|
199
|
+
params[part.slice(1, -1)] = segments[index];
|
|
200
|
+
});
|
|
201
|
+
return { op, params };
|
|
202
|
+
}
|
|
203
|
+
/** Up to five operations whose paths most resemble a missed one. */
|
|
204
|
+
nearest(method, segments) {
|
|
205
|
+
// Shared leading characters, so `customer` sits next to `customers`.
|
|
206
|
+
const likeness = (a, b) => {
|
|
207
|
+
let same = 0;
|
|
208
|
+
while (same < a.length && same < b.length && a[same] === b[same])
|
|
209
|
+
same += 1;
|
|
210
|
+
return same / Math.max(a.length, b.length, 1);
|
|
211
|
+
};
|
|
212
|
+
const scored = this.ops.map((op) => {
|
|
213
|
+
const template = this.#segments[op.row];
|
|
214
|
+
let score = 0;
|
|
215
|
+
for (let index = 0; index < Math.min(template.length, segments.length); index += 1) {
|
|
216
|
+
const part = template[index];
|
|
217
|
+
score += part.startsWith("{") ? 0.6 : likeness(part, segments[index]) * 2;
|
|
218
|
+
}
|
|
219
|
+
score -= Math.abs(template.length - segments.length) * 0.75;
|
|
220
|
+
if (op.method === method)
|
|
221
|
+
score += 0.25;
|
|
222
|
+
return { op, score };
|
|
223
|
+
});
|
|
224
|
+
return scored
|
|
225
|
+
.sort((a, b) => b.score - a.score || a.op.path.length - b.op.path.length)
|
|
226
|
+
.slice(0, 5)
|
|
227
|
+
.map(({ op }) => `${op.method} ${op.path}`);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Rank operations for a free-text query: every query word must appear in
|
|
231
|
+
* the path, summary, operationId, or tag (plural-insensitive, prefixes of
|
|
232
|
+
* three letters count), and path matches weigh most. A query that starts
|
|
233
|
+
* with "/" also matches the templates that fit it.
|
|
234
|
+
*/
|
|
235
|
+
search(query, options) {
|
|
236
|
+
this.#words ??= this.ops.map((op) => ({
|
|
237
|
+
path: new Set(op.path
|
|
238
|
+
.split("/")
|
|
239
|
+
.filter((part) => !part.startsWith("{"))
|
|
240
|
+
.flatMap(words)
|
|
241
|
+
.concat(words(op.tag))),
|
|
242
|
+
summary: new Set(words(op.summary)),
|
|
243
|
+
id: new Set(words(op.operationId)),
|
|
244
|
+
}));
|
|
245
|
+
const terms = [...new Set(words(query))];
|
|
246
|
+
const candidates = this.ops.filter((op) => options.method === undefined || op.method === options.method);
|
|
247
|
+
const fits = new Set();
|
|
248
|
+
if (query.trim().startsWith("/")) {
|
|
249
|
+
const path = query.trim().split(/\s/)[0];
|
|
250
|
+
for (const op of candidates) {
|
|
251
|
+
try {
|
|
252
|
+
if (this.resolve(op.method, path).op.row === op.row)
|
|
253
|
+
fits.add(op.row);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
// Not this template.
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const match = (set, term) => {
|
|
261
|
+
if (set.has(term))
|
|
262
|
+
return 2;
|
|
263
|
+
for (const word of set) {
|
|
264
|
+
if (word === `${term}s` || term === `${word}s` || word === `${term}es` || term === `${word}es`)
|
|
265
|
+
return 2;
|
|
266
|
+
if (term.length >= 3 && word.startsWith(term))
|
|
267
|
+
return 1;
|
|
268
|
+
}
|
|
269
|
+
return 0;
|
|
270
|
+
};
|
|
271
|
+
const ranked = candidates.flatMap((op) => {
|
|
272
|
+
const indexed = this.#words[op.row];
|
|
273
|
+
let score = fits.has(op.row) ? 100 : 0;
|
|
274
|
+
let matched = 0;
|
|
275
|
+
for (const term of terms) {
|
|
276
|
+
const path = match(indexed.path, term);
|
|
277
|
+
const summary = match(indexed.summary, term);
|
|
278
|
+
const id = match(indexed.id, term);
|
|
279
|
+
if (path + summary + id > 0)
|
|
280
|
+
matched += 1;
|
|
281
|
+
score += path * 3 + summary * 1.5 + id;
|
|
282
|
+
}
|
|
283
|
+
if (!fits.has(op.row) && (terms.length === 0 || matched < terms.length))
|
|
284
|
+
return [];
|
|
285
|
+
return [{ op, score }];
|
|
286
|
+
});
|
|
287
|
+
return ranked
|
|
288
|
+
.sort((a, b) => b.score - a.score ||
|
|
289
|
+
a.op.path.split("/").length - b.op.path.split("/").length ||
|
|
290
|
+
a.op.path.localeCompare(b.op.path))
|
|
291
|
+
.slice(0, options.limit)
|
|
292
|
+
.map(({ op }) => op);
|
|
293
|
+
}
|
|
294
|
+
/** Whether the operation takes a body, and in which framing. */
|
|
295
|
+
bodyType(op) {
|
|
296
|
+
const row = this.#row(op);
|
|
297
|
+
return row && row[1] !== 0 ? row[1][0] : undefined;
|
|
298
|
+
}
|
|
299
|
+
/** The operation's request contract as JSON Schema, optionally one parameter only. */
|
|
300
|
+
contract(op, only) {
|
|
301
|
+
const row = this.#row(op);
|
|
302
|
+
const declared = row ? row[0] : [];
|
|
303
|
+
// A generator may omit plain-string path parameters (`pathParams:
|
|
304
|
+
// "typed"`); the template still names them, so the contract does too.
|
|
305
|
+
const implied = this.#segments[op.row].filter((part) => part.startsWith("{") && !declared.some(([name, at]) => at === "path" && name === part.slice(1, -1)))
|
|
306
|
+
.map((part) => [part.slice(1, -1), "path", 1, { t: "string" }]);
|
|
307
|
+
const parameters = [...implied, ...declared]
|
|
308
|
+
.filter(([name]) => only === undefined || name === only)
|
|
309
|
+
.map(([name, at, required, schema, description]) => ({
|
|
310
|
+
name,
|
|
311
|
+
in: at,
|
|
312
|
+
required: required === 1,
|
|
313
|
+
...(description ? { description } : {}),
|
|
314
|
+
schema: this.#schema(schema),
|
|
315
|
+
}));
|
|
316
|
+
let body;
|
|
317
|
+
if (row && row[1] !== 0) {
|
|
318
|
+
const [contentType, schema] = row[1];
|
|
319
|
+
const node = this.#deref(schema);
|
|
320
|
+
if (only !== undefined && node.p?.[only] !== undefined) {
|
|
321
|
+
body = {
|
|
322
|
+
contentType,
|
|
323
|
+
schema: {
|
|
324
|
+
type: "object",
|
|
325
|
+
properties: { [only]: this.#schema(node.p[only]) },
|
|
326
|
+
...(node.r?.includes(only) ? { required: [only] } : {}),
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
else if (only === undefined) {
|
|
331
|
+
body = { contentType, ...(row[1][2] === 1 ? { required: true } : {}), schema: this.#schema(schema) };
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (only !== undefined && parameters.length === 0 && body === undefined) {
|
|
335
|
+
invalid(`${op.method} ${op.path} has no parameter named ${only}.`);
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
method: op.method,
|
|
339
|
+
path: op.path,
|
|
340
|
+
operationId: op.operationId,
|
|
341
|
+
summary: op.summary,
|
|
342
|
+
...(op.server ? { server: op.server } : {}),
|
|
343
|
+
parameters,
|
|
344
|
+
...(body ? { body } : {}),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Check query and body names, required members, enums, and object/array
|
|
349
|
+
* shapes against the operation, to the depth the index carries. Scalars are
|
|
350
|
+
* not type-policed beyond that: vendors coerce form strings, and a wrong
|
|
351
|
+
* number is the vendor's to explain. `null` and `""` pass anywhere, since
|
|
352
|
+
* they are how form APIs unset a field.
|
|
353
|
+
*/
|
|
354
|
+
check(op, query, body) {
|
|
355
|
+
const row = this.#row(op);
|
|
356
|
+
const issues = [];
|
|
357
|
+
const repair = [];
|
|
358
|
+
const declared = (row ? row[0] : []).filter(([, at]) => at === "query");
|
|
359
|
+
const accepted = declared.map(([name]) => name);
|
|
360
|
+
for (const [name, value] of Object.entries(query)) {
|
|
361
|
+
if (value === undefined)
|
|
362
|
+
continue;
|
|
363
|
+
const param = declared.find(([key]) => key === name);
|
|
364
|
+
if (!param) {
|
|
365
|
+
issues.push({
|
|
366
|
+
path: `/query/${name}`,
|
|
367
|
+
code: "additionalProperties",
|
|
368
|
+
expected: accepted.length ? oneOf(accepted, name) : "no query parameters",
|
|
369
|
+
});
|
|
370
|
+
repair.push({ path: `/query/${name}`, receivedType: valueType(value), acceptedKeys: accepted });
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
this.#check(param[3], value, `/query/${name}`, issues, repair);
|
|
374
|
+
}
|
|
375
|
+
for (const [name, , required] of declared) {
|
|
376
|
+
if (required === 1 && query[name] === undefined) {
|
|
377
|
+
issues.push({ path: `/query/${name}`, code: "required", expected: "a value" });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const bodyRow = row && row[1] !== 0 ? row[1] : undefined;
|
|
381
|
+
if (body !== undefined) {
|
|
382
|
+
if (!bodyRow) {
|
|
383
|
+
issues.push({ path: "/body", code: "false", expected: "no body for this operation" });
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
this.#check(bodyRow[1], body, "/body", issues, repair);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
else if (bodyRow?.[2] === 1) {
|
|
390
|
+
// An optional body may be omitted whole; a required one may not.
|
|
391
|
+
issues.push({ path: "/body", code: "required", expected: `a ${bodyRow[0]} body` });
|
|
392
|
+
}
|
|
393
|
+
if (issues.length > 0) {
|
|
394
|
+
const shown = issues
|
|
395
|
+
.slice(0, 5)
|
|
396
|
+
.map((issue) => `${issue.path} (${issue.code}: ${issue.expected})`)
|
|
397
|
+
.join("; ");
|
|
398
|
+
invalid(`Not sent: the arguments do not match ${op.method} ${op.path} in the pinned API index: ${shown}` +
|
|
399
|
+
`${issues.length > 5 ? `; ${issues.length - 5} more` : ""}. Read ${this.#vendorTool("details")} for the contract.`, issues.slice(0, MAX_ISSUES), repair.length ? { issues: repair.slice(0, 5) } : undefined);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
#vendorTool(kind) {
|
|
403
|
+
return `${this.#vendor}_api_${kind}`;
|
|
404
|
+
}
|
|
405
|
+
#row(op) {
|
|
406
|
+
this.#details ??= JSON.parse(this.#data.details);
|
|
407
|
+
const row = this.#details.o[op.row];
|
|
408
|
+
return row === 0 ? undefined : row;
|
|
409
|
+
}
|
|
410
|
+
#deref(node) {
|
|
411
|
+
let current = node;
|
|
412
|
+
for (let hops = 0; current.$ !== undefined && hops < 32; hops += 1) {
|
|
413
|
+
current = this.#details.d[current.$] ?? {};
|
|
414
|
+
}
|
|
415
|
+
return current;
|
|
416
|
+
}
|
|
417
|
+
#schema(node) {
|
|
418
|
+
const resolved = this.#deref(node);
|
|
419
|
+
const out = {};
|
|
420
|
+
if (resolved.t !== undefined)
|
|
421
|
+
out["type"] = resolved.t;
|
|
422
|
+
if (resolved.d !== undefined)
|
|
423
|
+
out["description"] = resolved.d;
|
|
424
|
+
if (resolved.e !== undefined)
|
|
425
|
+
out["enum"] = resolved.e;
|
|
426
|
+
if (resolved.f !== undefined)
|
|
427
|
+
out["format"] = resolved.f;
|
|
428
|
+
if (resolved.n === 1)
|
|
429
|
+
out["nullable"] = true;
|
|
430
|
+
if (resolved.a)
|
|
431
|
+
out["anyOf"] = resolved.a.map((branch) => this.#schema(branch));
|
|
432
|
+
if (resolved.p) {
|
|
433
|
+
out["properties"] = Object.fromEntries(Object.entries(resolved.p).map(([key, value]) => [key, this.#schema(value)]));
|
|
434
|
+
}
|
|
435
|
+
if (resolved.r)
|
|
436
|
+
out["required"] = resolved.r;
|
|
437
|
+
if (resolved.i)
|
|
438
|
+
out["items"] = this.#schema(resolved.i);
|
|
439
|
+
if (resolved.m)
|
|
440
|
+
out["additionalProperties"] = this.#schema(resolved.m);
|
|
441
|
+
if (resolved.x === 1)
|
|
442
|
+
out["truncated"] = true;
|
|
443
|
+
return out;
|
|
444
|
+
}
|
|
445
|
+
#check(node, value, at, issues, repair) {
|
|
446
|
+
if (issues.length >= MAX_ISSUES)
|
|
447
|
+
return;
|
|
448
|
+
const schema = this.#deref(node);
|
|
449
|
+
if (schema.x === 1)
|
|
450
|
+
return;
|
|
451
|
+
// Conflicting composed constraints (disjoint enums or types) admit nothing,
|
|
452
|
+
// not even the null or "" that otherwise unsets a field.
|
|
453
|
+
if (schema.e?.length === 0) {
|
|
454
|
+
issues.push({ path: at, code: "enum", expected: "no value: this field's combined constraints admit none" });
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (value === null || value === "")
|
|
458
|
+
return;
|
|
459
|
+
if (schema.a) {
|
|
460
|
+
let fewest;
|
|
461
|
+
for (const branch of schema.a) {
|
|
462
|
+
const attempt = { issues: [], repair: [] };
|
|
463
|
+
this.#check(branch, value, at, attempt.issues, attempt.repair);
|
|
464
|
+
if (attempt.issues.length === 0)
|
|
465
|
+
return;
|
|
466
|
+
if (!fewest || attempt.issues.length < fewest.issues.length)
|
|
467
|
+
fewest = attempt;
|
|
468
|
+
}
|
|
469
|
+
if (fewest) {
|
|
470
|
+
issues.push(...fewest.issues);
|
|
471
|
+
repair.push(...fewest.repair);
|
|
472
|
+
}
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const types = typesOf(schema);
|
|
476
|
+
if (schema.e && (typeof value === "string" || typeof value === "number" || typeof value === "boolean")) {
|
|
477
|
+
if (!schema.e.some((option) => option === value || String(option) === String(value))) {
|
|
478
|
+
issues.push({ path: at, code: "enum", expected: oneOf(schema.e.map(String)) });
|
|
479
|
+
}
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (schema.p || types.includes("object")) {
|
|
483
|
+
if (!isRecord(value)) {
|
|
484
|
+
if (types.length === 0 || types.includes("object")) {
|
|
485
|
+
issues.push({ path: at, code: "type", expected: "object" });
|
|
486
|
+
}
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const properties = schema.p ?? {};
|
|
490
|
+
const accepted = Object.keys(properties);
|
|
491
|
+
for (const [key, item] of Object.entries(value)) {
|
|
492
|
+
if (item === undefined)
|
|
493
|
+
continue;
|
|
494
|
+
const child = properties[key];
|
|
495
|
+
if (child)
|
|
496
|
+
this.#check(child, item, `${at}/${key}`, issues, repair);
|
|
497
|
+
else if (schema.m)
|
|
498
|
+
this.#check(schema.m, item, `${at}/${key}`, issues, repair);
|
|
499
|
+
else if (schema.p) {
|
|
500
|
+
issues.push({
|
|
501
|
+
path: `${at}/${key}`,
|
|
502
|
+
code: "additionalProperties",
|
|
503
|
+
expected: accepted.length ? oneOf(accepted, key) : "no properties",
|
|
504
|
+
});
|
|
505
|
+
repair.push({ path: `${at}/${key}`, receivedType: valueType(item), acceptedKeys: accepted });
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
for (const key of schema.r ?? []) {
|
|
509
|
+
if (value[key] === undefined)
|
|
510
|
+
issues.push({ path: `${at}/${key}`, code: "required", expected: "a value" });
|
|
511
|
+
}
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
if (schema.i || types.includes("array")) {
|
|
515
|
+
if (!Array.isArray(value)) {
|
|
516
|
+
issues.push({ path: at, code: "type", expected: "array" });
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (schema.i)
|
|
520
|
+
value.forEach((item, index) => this.#check(schema.i, item, `${at}/${index}`, issues, repair));
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (types.length > 0 && (isRecord(value) || Array.isArray(value))) {
|
|
524
|
+
issues.push({ path: at, code: "type", expected: types.join(" or ") });
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { ApiTool } from "../../../connectors/api-connector.js";
|
|
2
|
+
import { type GuardedFetchOptions, type GuardedRequest, type GuardedTransport } from "../../../connectors/guarded-fetch.js";
|
|
3
|
+
import { ConnectorCallError } from "../../../errors.js";
|
|
4
|
+
import type { ConnectorContext } from "../../../types.js";
|
|
5
|
+
import type { Operation, OperationIndex, RestMethod } from "./operation-index.js";
|
|
6
|
+
import type { ValueSafety } from "./value-safety.js";
|
|
7
|
+
/** One call matched to its operation, after the vendor's defaults and pins. */
|
|
8
|
+
export interface RestCall {
|
|
9
|
+
readonly method: RestMethod;
|
|
10
|
+
/** The concrete path the caller named. */
|
|
11
|
+
readonly path: string;
|
|
12
|
+
readonly op: Operation;
|
|
13
|
+
/** Path parameter values by template name. */
|
|
14
|
+
readonly params: Readonly<Record<string, string>>;
|
|
15
|
+
readonly query: Readonly<Record<string, unknown>>;
|
|
16
|
+
readonly body: unknown;
|
|
17
|
+
/** Request headers from the vendor's reviewed `headers` allowlist. */
|
|
18
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
19
|
+
}
|
|
20
|
+
/** How the vendor frames one call on the wire. */
|
|
21
|
+
export interface RestFraming {
|
|
22
|
+
query?: GuardedRequest["query"];
|
|
23
|
+
/** A JSON body; exclusive with `rawBody`. */
|
|
24
|
+
body?: unknown;
|
|
25
|
+
/** A pre-framed body, such as a form-encoded string, with its own `Content-Type` header. */
|
|
26
|
+
rawBody?: BodyInit;
|
|
27
|
+
headers?: Record<string, string | undefined>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The cursor for the next page: pass `next` back as `param`, a query
|
|
31
|
+
* parameter unless `in` says the operation takes it in its body (Notion's
|
|
32
|
+
* read-only POST queries do). `path` names a different GET that continues
|
|
33
|
+
* the list, when the first page came from a call that created it (Notion's
|
|
34
|
+
* view queries). `hasMore` without `next` means the operation has no cursor.
|
|
35
|
+
*/
|
|
36
|
+
export interface RestPage {
|
|
37
|
+
hasMore: boolean;
|
|
38
|
+
next?: string;
|
|
39
|
+
param?: string;
|
|
40
|
+
in?: "query" | "body";
|
|
41
|
+
path?: string;
|
|
42
|
+
}
|
|
43
|
+
export interface RestResult {
|
|
44
|
+
status: number;
|
|
45
|
+
data: unknown;
|
|
46
|
+
page?: RestPage;
|
|
47
|
+
}
|
|
48
|
+
/** A reviewed POST that only reads: `[method, path template, reason]`. */
|
|
49
|
+
export type RestReadPost = readonly [method: RestMethod, path: string, reason: string];
|
|
50
|
+
export interface RestVendor {
|
|
51
|
+
/** Lowercase tool-name prefix: `${vendor}_api_search` and so on. */
|
|
52
|
+
readonly vendor: string;
|
|
53
|
+
/** Display name in descriptions and refusals. */
|
|
54
|
+
readonly title: string;
|
|
55
|
+
readonly index: OperationIndex;
|
|
56
|
+
/**
|
|
57
|
+
* The transport for an operation's server (`undefined` is the default
|
|
58
|
+
* origin), or a refusal that says where to go instead.
|
|
59
|
+
*/
|
|
60
|
+
transport(server: string | undefined): GuardedTransport | string;
|
|
61
|
+
/** Map a failed response to what the caller does next (H11); `call` lets a vendor withhold text for secret families. */
|
|
62
|
+
failure(status: number, headers: Headers, body: unknown, call?: RestCall): ConnectorCallError;
|
|
63
|
+
/** Reviewed POSTs that only read; `_api_read` admits exactly these. */
|
|
64
|
+
readonly readPosts?: readonly RestReadPost[];
|
|
65
|
+
/**
|
|
66
|
+
* The vendor's reviewed value-safety table: `refuse` verdicts are refused
|
|
67
|
+
* by the generic tools before any request, and every successful body is
|
|
68
|
+
* redacted (decision 0005, "Value safety"). Failure mappers ask it which
|
|
69
|
+
* operations withhold vendor error text.
|
|
70
|
+
*/
|
|
71
|
+
readonly valueSafety: ValueSafety;
|
|
72
|
+
/** Reviewed refusals that depend on arguments: return the reason to refuse, before any request. */
|
|
73
|
+
refuse?(call: RestCall): string | undefined;
|
|
74
|
+
/** Rewrite a caller's path before it is matched, such as filling configured default ids. */
|
|
75
|
+
path?(path: string): string;
|
|
76
|
+
/** Apply configured defaults and enforce pins; throw to refuse. */
|
|
77
|
+
scope?(call: RestCall): RestCall;
|
|
78
|
+
/**
|
|
79
|
+
* Admit one prepared call before it is sent; throw to refuse. Runs in
|
|
80
|
+
* `callRest`, so generic and named tools alike pass through it, and may
|
|
81
|
+
* await, as a pin check that resolves which account owns a zone does.
|
|
82
|
+
*/
|
|
83
|
+
admit?(call: RestCall, ctx: ConnectorContext): void | Promise<void>;
|
|
84
|
+
/**
|
|
85
|
+
* Map a successful body into the envelope's `data`, such as unwrapping a
|
|
86
|
+
* vendor envelope; throw to fail a success-status body that reports an
|
|
87
|
+
* error. `page` still reads the body as it arrived.
|
|
88
|
+
*/
|
|
89
|
+
result?(body: unknown, call: RestCall, response: {
|
|
90
|
+
status: number;
|
|
91
|
+
headers: Headers;
|
|
92
|
+
}): unknown;
|
|
93
|
+
/** Request headers the generic tools accept, by name, with what each does; the vendor validates values. */
|
|
94
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
95
|
+
/** Whether the generic tools accept a string body, for operations framed as text. */
|
|
96
|
+
readonly textBodies?: boolean;
|
|
97
|
+
/** A concrete path in this vendor's API, for the path argument's description. */
|
|
98
|
+
readonly pathExample?: string;
|
|
99
|
+
/** Frame the request. Defaults to scalar query values and a JSON body. */
|
|
100
|
+
encode?(call: RestCall): RestFraming;
|
|
101
|
+
/** Read the next-page cursor from a successful body. */
|
|
102
|
+
page?(data: unknown, call: RestCall): RestPage | undefined;
|
|
103
|
+
/** A header write tools fill with a caller key or a generated one, and return. */
|
|
104
|
+
readonly idempotencyHeader?: string;
|
|
105
|
+
/** Where multipart or binary uploads go instead of `_api_write`. */
|
|
106
|
+
readonly upload?: string;
|
|
107
|
+
/** One clause on how `_api_write` frames bodies, for its schema. */
|
|
108
|
+
readonly bodyHint?: string;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* A guarded transport whose requests, response bodies included, end after
|
|
112
|
+
* `timeoutMs`: a stream the vendor never closes cannot hold a call open past
|
|
113
|
+
* it. The request's own signal still cancels first.
|
|
114
|
+
*/
|
|
115
|
+
export declare function restTransport(options: Omit<GuardedFetchOptions, "fetch"> & {
|
|
116
|
+
timeoutMs: number;
|
|
117
|
+
}): GuardedTransport;
|
|
118
|
+
/** Match a call to its operation and apply the vendor's defaults and pins. */
|
|
119
|
+
export declare function restCall(vendor: RestVendor, method: RestMethod, path: string, input?: {
|
|
120
|
+
query?: Readonly<Record<string, unknown>>;
|
|
121
|
+
body?: unknown;
|
|
122
|
+
headers?: Readonly<Record<string, string>>;
|
|
123
|
+
}): RestCall;
|
|
124
|
+
/**
|
|
125
|
+
* A request that is not safe to repeat (a write without an idempotency key)
|
|
126
|
+
* must never be advertised as retryable after it may have reached the vendor
|
|
127
|
+
* (INV-9). Definite answers pass through: refusals and 4xx verdicts, a failure
|
|
128
|
+
* proven to precede any connection, and timeouts, which the invocation layer
|
|
129
|
+
* already reports as `write_outcome_unknown`. Exported so a named write tool
|
|
130
|
+
* with its own transport (an upload) applies the same rule.
|
|
131
|
+
*/
|
|
132
|
+
export declare function unknownOutcome(vendor: RestVendor, error: unknown, responded: boolean): unknown;
|
|
133
|
+
/**
|
|
134
|
+
* Send one prepared call and read it into the envelope. No validation and no
|
|
135
|
+
* refusal tables run here: the generic tools do both first, and named tools
|
|
136
|
+
* build their calls from fixed operations. `visibleHeaders` carry values the
|
|
137
|
+
* result returns to the caller, such as an idempotency key.
|
|
138
|
+
*/
|
|
139
|
+
export declare function callRest(vendor: RestVendor, call: RestCall, ctx: ConnectorContext, visibleHeaders?: Record<string, string>): Promise<RestResult>;
|
|
140
|
+
/** The four generic tools over one vendor's index. */
|
|
141
|
+
export declare function restTools(vendor: RestVendor): ApiTool[];
|