@zackbart/connecta 0.29.0 → 0.31.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 +99 -0
- package/README.md +126 -134
- package/bin/connecta.mjs +13 -2
- package/bin/version.mjs +1 -1
- package/dist/argument-redaction.js +230 -0
- package/dist/auth-elicitation.js +14 -9
- package/dist/authorization-handoff.js +111 -0
- package/dist/call-recovery.js +25 -0
- package/dist/catalog-cache.js +2 -2
- package/dist/catalog-service.js +103 -63
- package/dist/config-schema.d.ts +14 -7
- package/dist/config-schema.js +29 -10
- package/dist/config.d.ts +2 -0
- package/dist/config.js +12 -7
- package/dist/connectors/guarded-fetch.d.ts +9 -1
- package/dist/connectors/guarded-fetch.js +17 -5
- package/dist/connectors/negotiation-cache.js +1 -1
- package/dist/downstream-input-context.js +9 -0
- package/dist/downstream-input.js +13 -11
- package/dist/errors.d.ts +38 -12
- package/dist/errors.js +19 -9
- package/dist/execute.js +375 -43
- package/dist/executor-result.js +11 -9
- package/dist/executors/quickjs-child.js +12 -5
- package/dist/executors/quickjs-protocol.js +5 -6
- package/dist/executors/quickjs-runtime.js +6 -1
- package/dist/executors/quickjs.js +20 -4
- package/dist/guest-runtime.js +52 -2
- package/dist/guest-types.d.ts +47 -5
- package/dist/index.d.ts +1 -1
- package/dist/invocation.js +72 -14
- package/dist/meta-output.js +0 -15
- package/dist/meta-tools.js +166 -95
- package/dist/oauth-handoff.js +88 -11
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/view.d.ts +9 -0
- package/dist/operator-ui/view.js +11 -0
- package/dist/program-result.js +5 -0
- package/dist/program-writes.js +7 -48
- package/dist/provider.d.ts +8 -6
- package/dist/provider.js +3 -4
- package/dist/providers/_shared/google/workspace.d.ts +1 -1
- package/dist/providers/_shared/request-token.js +83 -0
- 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/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/infisical/index.d.ts +7 -0
- package/dist/providers/infisical/index.js +713 -0
- package/dist/providers/infisical/skill.generated.js +8 -0
- package/dist/providers/notion/index.d.ts +35 -31
- package/dist/providers/notion/index.js +60 -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 +1067 -0
- package/dist/providers/notion/skill.generated.js +5 -4
- package/dist/providers/notion/value-safety.js +31 -0
- 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/registry.d.ts +8 -6
- package/dist/registry.js +134 -61
- package/dist/resource-read.js +4 -1
- package/dist/routes/mcp.js +11 -2
- package/dist/sent-secrets.d.ts +54 -13
- package/dist/sent-secrets.js +770 -231
- package/dist/skills.js +26 -16
- package/dist/sqlite.js +21 -6
- package/dist/storage/keys.js +20 -5
- package/dist/storage/memory.js +9 -4
- package/dist/storage/sql.js +18 -9
- package/dist/types.d.ts +17 -3
- package/dist/usage-guide.js +49 -40
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker.js +2 -1
- package/dist/write-outcome.js +47 -0
- package/documentation/README.md +64 -0
- package/documentation/architecture.md +241 -97
- package/documentation/auth.md +138 -5
- package/documentation/code-mode.md +224 -107
- package/documentation/deploying.md +146 -0
- package/documentation/integrating.md +170 -0
- package/documentation/meta-tools.md +142 -30
- package/documentation/operating.md +135 -0
- package/documentation/operator-tests.md +4 -0
- package/documentation/operator-ui.md +132 -0
- package/documentation/provider-auth-migration.md +307 -0
- package/documentation/provider-migration-0.29.md +22 -5
- package/examples/worker/README.md +9 -2
- package/package.json +9 -2
- package/templates/node/README.md +9 -2
- package/templates/node/package.json +1 -1
- package/dist/providers/vercel/mcp-ownership.js +0 -212
|
@@ -0,0 +1,1002 @@
|
|
|
1
|
+
import { guardedFetch, retryAfterMs } from "../../connectors/guarded-fetch.js";
|
|
2
|
+
import { ConnectorCallError } from "../../errors.js";
|
|
3
|
+
import { OperationIndex } from "../_shared/rest/operation-index.js";
|
|
4
|
+
import { callRest, restCall, unknownOutcome, restTransport, } from "../_shared/rest/tools.js";
|
|
5
|
+
import { openapi } from "./openapi.generated.js";
|
|
6
|
+
import { valueSafety } from "../_shared/rest/value-safety.js";
|
|
7
|
+
import { VERCEL_VALUE_SAFETY } from "./value-safety.js";
|
|
8
|
+
/** Vercel's public REST origin. Override only for a proxy or test double. */
|
|
9
|
+
export const VERCEL_API_BASE_URL = "https://api.vercel.com";
|
|
10
|
+
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
11
|
+
const READ_CAP_MS = 60_000;
|
|
12
|
+
/** Most log bytes one bounded log read keeps. */
|
|
13
|
+
const MAX_LOG_BYTES = 1024 * 1024;
|
|
14
|
+
/** Characters kept from one log line; the rest is marked truncated. */
|
|
15
|
+
const MAX_LOG_TEXT = 4_000;
|
|
16
|
+
/**
|
|
17
|
+
* Uploads named by their content, so repeating one stores the same bytes:
|
|
18
|
+
* Remote Cache artifacts by the hash in the path, deployment files by the
|
|
19
|
+
* SHA-1 sent as `x-vercel-digest` (Vercel answers "already uploaded").
|
|
20
|
+
*/
|
|
21
|
+
const CONTENT_ADDRESSED = new Set(["PUT /v8/artifacts/{hash}", "POST /v2/files"]);
|
|
22
|
+
/** Most raw upload bytes, after base64 decoding. */
|
|
23
|
+
const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
|
|
24
|
+
/** POSTs that only read, admitted by `vercel_api_read`. */
|
|
25
|
+
const READ_POSTS = [
|
|
26
|
+
["POST", "/v2/observability/query", "Runs an Observability metrics query; Vercel persists nothing."],
|
|
27
|
+
["POST", "/v1/registrar/domains/availability", "Checks availability for a list of domains; buys nothing."],
|
|
28
|
+
["POST", "/v1/registrar/domains/price", "Quotes registrar prices for a list of domains; buys nothing."],
|
|
29
|
+
["POST", "/v1/registrar/domains/search", "Searches domain availability and pricing; buys nothing."],
|
|
30
|
+
["POST", "/v8/artifacts", "Reads Remote Cache metadata for a list of artifact hashes; uploads nothing."],
|
|
31
|
+
];
|
|
32
|
+
function isRecord(value) {
|
|
33
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
function record(value) {
|
|
36
|
+
return isRecord(value) ? value : {};
|
|
37
|
+
}
|
|
38
|
+
function text(value) {
|
|
39
|
+
return typeof value === "string" && value.trim() !== "" ? value : undefined;
|
|
40
|
+
}
|
|
41
|
+
function invalid(message) {
|
|
42
|
+
throw new ConnectorCallError("invalid_args", message);
|
|
43
|
+
}
|
|
44
|
+
function compact(value) {
|
|
45
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
46
|
+
}
|
|
47
|
+
function truthy(value) {
|
|
48
|
+
return value === true || value === 1 || (typeof value === "string" && /^(?:1|true)$/i.test(value.trim()));
|
|
49
|
+
}
|
|
50
|
+
function resetAfterMs(headers) {
|
|
51
|
+
const retryAfter = retryAfterMs(headers);
|
|
52
|
+
if (retryAfter !== undefined)
|
|
53
|
+
return retryAfter;
|
|
54
|
+
const raw = headers.get("x-ratelimit-reset");
|
|
55
|
+
if (!raw)
|
|
56
|
+
return undefined;
|
|
57
|
+
const seconds = Number(raw);
|
|
58
|
+
if (!Number.isFinite(seconds))
|
|
59
|
+
return undefined;
|
|
60
|
+
return Math.max(0, Math.trunc(seconds * 1_000 - Date.now()));
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Error codes a secret-family failure may name: Vercel's documented generic
|
|
64
|
+
* codes and its environment-variable conflicts. Anything else is withheld,
|
|
65
|
+
* since a code is vendor text too.
|
|
66
|
+
*/
|
|
67
|
+
const REVIEWED_CODES = new Set([
|
|
68
|
+
"bad_request",
|
|
69
|
+
"forbidden",
|
|
70
|
+
"not_found",
|
|
71
|
+
"rate_limited",
|
|
72
|
+
"conflict",
|
|
73
|
+
"unauthorized",
|
|
74
|
+
"invalid_token",
|
|
75
|
+
"internal_server_error",
|
|
76
|
+
"ENV_ALREADY_EXISTS",
|
|
77
|
+
"ENV_CONFLICT",
|
|
78
|
+
"ENV_KEY_RESERVED",
|
|
79
|
+
"ENV_NOT_FOUND",
|
|
80
|
+
"ENV_SIZE_LIMIT_EXCEEDED",
|
|
81
|
+
]);
|
|
82
|
+
/** A reviewed code, or nothing. */
|
|
83
|
+
function reviewedCode(value) {
|
|
84
|
+
return typeof value === "string" && REVIEWED_CODES.has(value) ? value : undefined;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Map a Vercel failure by the caller's next move (H11). A secret family's
|
|
88
|
+
* failure (`family`) never carries Vercel's message, which can quote a stored
|
|
89
|
+
* value: it says the status, a reviewed code, and what to do.
|
|
90
|
+
*/
|
|
91
|
+
function vercelFailure(status, headers, payload, family = false) {
|
|
92
|
+
const root = record(payload);
|
|
93
|
+
const error = record(root["error"]);
|
|
94
|
+
const code = text(error["code"]);
|
|
95
|
+
const message = text(error["message"])?.trim() ?? text(root["message"])?.trim() ?? `Vercel returned HTTP ${status}.`;
|
|
96
|
+
const reviewed = reviewedCode(code);
|
|
97
|
+
const detail = family
|
|
98
|
+
? `Vercel answered HTTP ${status}${reviewed ? ` (${reviewed})` : ""}; its message is withheld because this operation handles secret values.`
|
|
99
|
+
: (code ? `Vercel ${code}: ${message}` : message).slice(0, 1_000);
|
|
100
|
+
if (status === 429) {
|
|
101
|
+
const wait = resetAfterMs(headers);
|
|
102
|
+
return new ConnectorCallError("rate_limited", `${detail} Vercel meters endpoints separately; wait for the reported reset before retrying this operation.`, wait === undefined ? {} : { retryAfterMs: wait });
|
|
103
|
+
}
|
|
104
|
+
if (status === 401) {
|
|
105
|
+
return new ConnectorCallError("auth_required", `${detail} The configured access token is invalid, expired, outside this team, or lacks the required scope. An operator must replace it or widen its Vercel scope.`);
|
|
106
|
+
}
|
|
107
|
+
if (status === 403) {
|
|
108
|
+
return new ConnectorCallError("provider_permission_denied", `${detail} Vercel refused access to this team or resource. Ask the team administrator to grant access and an operator to widen the configured token's Vercel scope.`);
|
|
109
|
+
}
|
|
110
|
+
if (status === 404) {
|
|
111
|
+
return new ConnectorCallError("not_found", `${detail} Confirm the team, project, deployment, domain, or environment-variable id with a list read.`);
|
|
112
|
+
}
|
|
113
|
+
if (status === 400 || status === 409 || status === 422)
|
|
114
|
+
return new ConnectorCallError("invalid_args", detail);
|
|
115
|
+
if (status >= 500) {
|
|
116
|
+
const wait = resetAfterMs(headers);
|
|
117
|
+
return new ConnectorCallError("unavailable", `${detail} Vercel is failing upstream.`, wait === undefined ? {} : { retryAfterMs: wait });
|
|
118
|
+
}
|
|
119
|
+
return new ConnectorCallError("connector_call_failed", detail, { retryable: false });
|
|
120
|
+
}
|
|
121
|
+
/** The pinned index, shared by every Vercel REST connector in the deployment. */
|
|
122
|
+
let index;
|
|
123
|
+
function vercelIndex() {
|
|
124
|
+
index ??= new OperationIndex(openapi, { vendor: "vercel", title: "Vercel" });
|
|
125
|
+
return index;
|
|
126
|
+
}
|
|
127
|
+
/** The reviewed value-safety table over the pinned index. */
|
|
128
|
+
const SAFETY = valueSafety(VERCEL_VALUE_SAFETY, () => vercelIndex());
|
|
129
|
+
/** Query parameter names each operation declares, by index row. */
|
|
130
|
+
const queryNames = new Map();
|
|
131
|
+
function queryNamesOf(operations, op) {
|
|
132
|
+
let names = queryNames.get(op.row);
|
|
133
|
+
if (!names) {
|
|
134
|
+
names = new Set(operations
|
|
135
|
+
.contract(op)
|
|
136
|
+
.parameters.filter((parameter) => parameter.in === "query")
|
|
137
|
+
.map((parameter) => parameter.name));
|
|
138
|
+
queryNames.set(op.row, names);
|
|
139
|
+
}
|
|
140
|
+
return names;
|
|
141
|
+
}
|
|
142
|
+
const URL_SHAPE = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
143
|
+
/**
|
|
144
|
+
* Cursors: lists answer `pagination.next` (a millisecond timestamp or an
|
|
145
|
+
* opaque string) or a top-level `nextCursor`/`cursor`. The parameter to pass
|
|
146
|
+
* it back as is the one the operation declares in the index: `until` (or
|
|
147
|
+
* `from`) for timestamps, `cursor` or `next` for opaque strings. A cursor that
|
|
148
|
+
* is a URL is never echoed: it can carry credentials.
|
|
149
|
+
*/
|
|
150
|
+
function vercelPage(operations, data, call) {
|
|
151
|
+
const body = record(data);
|
|
152
|
+
const accepts = queryNamesOf(operations, call.op);
|
|
153
|
+
const paramFor = (next) => typeof next === "number"
|
|
154
|
+
? ["until", "next", "from", "since"].find((name) => accepts.has(name))
|
|
155
|
+
: ["cursor", "next", ...(/^\d+$/.test(next) ? ["until", "from"] : [])].find((name) => accepts.has(name));
|
|
156
|
+
const found = (next) => {
|
|
157
|
+
if (typeof next === "number" && Number.isFinite(next)) {
|
|
158
|
+
const param = paramFor(next);
|
|
159
|
+
return { hasMore: true, next: String(next), ...(param ? { param } : {}) };
|
|
160
|
+
}
|
|
161
|
+
if (typeof next === "string" && next !== "") {
|
|
162
|
+
if (URL_SHAPE.test(next))
|
|
163
|
+
return { hasMore: true };
|
|
164
|
+
const param = paramFor(next);
|
|
165
|
+
return { hasMore: true, next, ...(param ? { param } : {}) };
|
|
166
|
+
}
|
|
167
|
+
return undefined;
|
|
168
|
+
};
|
|
169
|
+
if (isRecord(body["pagination"])) {
|
|
170
|
+
const pagination = body["pagination"];
|
|
171
|
+
return found(pagination["next"] ?? pagination["nextCursor"]) ?? { hasMore: false };
|
|
172
|
+
}
|
|
173
|
+
for (const name of ["nextCursor", "cursor", "next"]) {
|
|
174
|
+
if (name in body)
|
|
175
|
+
return found(body[name]) ?? { hasMore: false };
|
|
176
|
+
}
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Items of a possibly cut-off JSON array, or complete NDJSON lines: a bounded
|
|
181
|
+
* log read stops mid-body, and the rows it finished are still the answer.
|
|
182
|
+
*/
|
|
183
|
+
export function completeRows(body) {
|
|
184
|
+
const trimmed = body.trimStart();
|
|
185
|
+
if (!trimmed.startsWith("[")) {
|
|
186
|
+
const rows = [];
|
|
187
|
+
for (const line of body.split("\n")) {
|
|
188
|
+
if (line.trim() === "")
|
|
189
|
+
continue;
|
|
190
|
+
try {
|
|
191
|
+
rows.push(JSON.parse(line));
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// A line the read cut off, or one Vercel did not finish.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return rows;
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
const whole = JSON.parse(trimmed);
|
|
201
|
+
if (Array.isArray(whole))
|
|
202
|
+
return whole;
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// Cut off: scan for the complete top-level items below.
|
|
206
|
+
}
|
|
207
|
+
const rows = [];
|
|
208
|
+
let depth = 0;
|
|
209
|
+
let start = -1;
|
|
210
|
+
let quoted = false;
|
|
211
|
+
let escaped = false;
|
|
212
|
+
for (let at = 1; at < trimmed.length; at += 1) {
|
|
213
|
+
const character = trimmed[at];
|
|
214
|
+
if (quoted) {
|
|
215
|
+
if (escaped)
|
|
216
|
+
escaped = false;
|
|
217
|
+
else if (character === "\\")
|
|
218
|
+
escaped = true;
|
|
219
|
+
else if (character === '"')
|
|
220
|
+
quoted = false;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (character === '"') {
|
|
224
|
+
quoted = true;
|
|
225
|
+
if (depth === 0 && start < 0)
|
|
226
|
+
start = at;
|
|
227
|
+
}
|
|
228
|
+
else if (character === "{" || character === "[") {
|
|
229
|
+
if (depth === 0)
|
|
230
|
+
start = at;
|
|
231
|
+
depth += 1;
|
|
232
|
+
}
|
|
233
|
+
else if (character === "}" || character === "]") {
|
|
234
|
+
depth -= 1;
|
|
235
|
+
if (depth < 0)
|
|
236
|
+
break;
|
|
237
|
+
if (depth === 0 && start >= 0) {
|
|
238
|
+
try {
|
|
239
|
+
rows.push(JSON.parse(trimmed.slice(start, at + 1)));
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
// Not a complete item.
|
|
243
|
+
}
|
|
244
|
+
start = -1;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return rows;
|
|
249
|
+
}
|
|
250
|
+
function clamp(value) {
|
|
251
|
+
if (typeof value !== "string")
|
|
252
|
+
return {};
|
|
253
|
+
return value.length > MAX_LOG_TEXT ? { text: value.slice(0, MAX_LOG_TEXT), textTruncated: true } : { text: value };
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* A body that ends cleanly, instead of erroring, `waitMs` after the response
|
|
257
|
+
* arrives or once `maxRows` accepted rows have arrived, whichever is first.
|
|
258
|
+
* Rows are counted as each chunk lands, so a stream that stays open stops as
|
|
259
|
+
* soon as it has answered.
|
|
260
|
+
*/
|
|
261
|
+
function boundedBody(body, limits, stop) {
|
|
262
|
+
const reader = body.getReader();
|
|
263
|
+
const decoder = new TextDecoder();
|
|
264
|
+
let pending = "";
|
|
265
|
+
let rows = 0;
|
|
266
|
+
let timer;
|
|
267
|
+
const deadline = new Promise((resolve) => {
|
|
268
|
+
timer = setTimeout(() => resolve("deadline"), limits.waitMs);
|
|
269
|
+
});
|
|
270
|
+
const finish = async (controller, reason) => {
|
|
271
|
+
clearTimeout(timer);
|
|
272
|
+
if (reason)
|
|
273
|
+
stop(reason);
|
|
274
|
+
controller.close();
|
|
275
|
+
if (reason)
|
|
276
|
+
await reader.cancel().catch(() => { });
|
|
277
|
+
};
|
|
278
|
+
return new ReadableStream({
|
|
279
|
+
async pull(controller) {
|
|
280
|
+
const next = await Promise.race([reader.read(), deadline]);
|
|
281
|
+
if (next === "deadline")
|
|
282
|
+
return await finish(controller, "time");
|
|
283
|
+
if (next.done)
|
|
284
|
+
return await finish(controller);
|
|
285
|
+
controller.enqueue(next.value);
|
|
286
|
+
if (limits.maxRows === undefined)
|
|
287
|
+
return;
|
|
288
|
+
pending += decoder.decode(next.value, { stream: true });
|
|
289
|
+
const lines = pending.split("\n");
|
|
290
|
+
pending = lines.pop() ?? "";
|
|
291
|
+
for (const line of lines) {
|
|
292
|
+
if (line.trim() === "")
|
|
293
|
+
continue;
|
|
294
|
+
let row;
|
|
295
|
+
try {
|
|
296
|
+
row = JSON.parse(line);
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (limits.accept && !limits.accept(row))
|
|
302
|
+
continue;
|
|
303
|
+
rows += 1;
|
|
304
|
+
if (rows >= limits.maxRows)
|
|
305
|
+
return await finish(controller, "rows");
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
async cancel(reason) {
|
|
309
|
+
clearTimeout(timer);
|
|
310
|
+
await reader.cancel(reason).catch(() => { });
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
async function failureBody(response) {
|
|
315
|
+
try {
|
|
316
|
+
const parsed = await response.jsonResult();
|
|
317
|
+
return "value" in parsed ? parsed.value : undefined;
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const TEAM_ID = {
|
|
324
|
+
type: ["string", "null"],
|
|
325
|
+
minLength: 1,
|
|
326
|
+
description: "Vercel team id. Omit for the connection's default; null for the token owner's personal account.",
|
|
327
|
+
};
|
|
328
|
+
const PROJECT_ID = { type: "string", minLength: 1, description: "Project id or name." };
|
|
329
|
+
const ENV_VAR_ID = {
|
|
330
|
+
type: "string",
|
|
331
|
+
minLength: 1,
|
|
332
|
+
description: "Environment-variable id from list_project_env_vars.",
|
|
333
|
+
};
|
|
334
|
+
const TARGETS = {
|
|
335
|
+
type: "array",
|
|
336
|
+
minItems: 1,
|
|
337
|
+
uniqueItems: true,
|
|
338
|
+
items: {
|
|
339
|
+
type: "string",
|
|
340
|
+
enum: ["production", "preview", "development"],
|
|
341
|
+
description: "A default Vercel environment.",
|
|
342
|
+
},
|
|
343
|
+
description: "Default Vercel environments that receive this value.",
|
|
344
|
+
};
|
|
345
|
+
const CUSTOM_ENVIRONMENT_IDS = {
|
|
346
|
+
type: "array",
|
|
347
|
+
uniqueItems: true,
|
|
348
|
+
items: { type: "string", minLength: 1, description: "A custom environment id." },
|
|
349
|
+
description: "Custom environment ids that receive this value.",
|
|
350
|
+
};
|
|
351
|
+
const ENV_SCHEMA = {
|
|
352
|
+
type: "object",
|
|
353
|
+
properties: {
|
|
354
|
+
id: { type: "string" },
|
|
355
|
+
key: { type: "string" },
|
|
356
|
+
type: { type: "string" },
|
|
357
|
+
target: { type: ["array", "string"], items: { type: "string" } },
|
|
358
|
+
gitBranch: { type: ["string", "null"] },
|
|
359
|
+
customEnvironmentIds: { type: "array", items: { type: "string" } },
|
|
360
|
+
comment: { type: "string" },
|
|
361
|
+
createdAt: { type: "number" },
|
|
362
|
+
updatedAt: { type: "number" },
|
|
363
|
+
},
|
|
364
|
+
required: ["id", "key", "type"],
|
|
365
|
+
};
|
|
366
|
+
/** Deliberately omits `value`, even though redaction already removed it. */
|
|
367
|
+
function envMetadata(value) {
|
|
368
|
+
const variable = record(value);
|
|
369
|
+
return compact({
|
|
370
|
+
id: variable["id"],
|
|
371
|
+
key: variable["key"],
|
|
372
|
+
type: variable["type"],
|
|
373
|
+
target: variable["target"],
|
|
374
|
+
gitBranch: variable["gitBranch"],
|
|
375
|
+
customEnvironmentIds: variable["customEnvironmentIds"],
|
|
376
|
+
comment: variable["comment"],
|
|
377
|
+
createdAt: variable["createdAt"],
|
|
378
|
+
updatedAt: variable["updatedAt"],
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
function closed(properties, required) {
|
|
382
|
+
return { type: "object", properties, required, additionalProperties: false };
|
|
383
|
+
}
|
|
384
|
+
function segment(value) {
|
|
385
|
+
return encodeURIComponent(String(value));
|
|
386
|
+
}
|
|
387
|
+
function decodeUpload(args) {
|
|
388
|
+
const hasText = typeof args["textBody"] === "string";
|
|
389
|
+
const hasBase64 = typeof args["base64Body"] === "string";
|
|
390
|
+
if (hasText === hasBase64)
|
|
391
|
+
invalid("Provide exactly one of textBody or base64Body for a Vercel upload.");
|
|
392
|
+
let bytes;
|
|
393
|
+
if (hasText)
|
|
394
|
+
bytes = new TextEncoder().encode(args["textBody"]);
|
|
395
|
+
else {
|
|
396
|
+
try {
|
|
397
|
+
bytes = Uint8Array.from(atob(args["base64Body"]), (character) => character.charCodeAt(0));
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
return invalid("base64Body is not valid base64.");
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (bytes.length > MAX_UPLOAD_BYTES)
|
|
404
|
+
invalid(`A Vercel upload through Connecta is at most ${MAX_UPLOAD_BYTES} bytes.`);
|
|
405
|
+
return bytes;
|
|
406
|
+
}
|
|
407
|
+
async function sha1(bytes) {
|
|
408
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-1", bytes));
|
|
409
|
+
return [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
410
|
+
}
|
|
411
|
+
/** Vercel's REST vendor, its named tools, and its credential test, for one connection. */
|
|
412
|
+
export function vercelRest(options) {
|
|
413
|
+
const operations = vercelIndex();
|
|
414
|
+
const { teamId } = options;
|
|
415
|
+
const authenticate = async (ctx) => {
|
|
416
|
+
const token = (await ctx.credential?.get())?.trim();
|
|
417
|
+
if (!token) {
|
|
418
|
+
throw new ConnectorCallError("auth_required", "No Vercel access token is configured for this connector. An operator must add one in this connection in the operator UI.");
|
|
419
|
+
}
|
|
420
|
+
return { Authorization: `Bearer ${token}` };
|
|
421
|
+
};
|
|
422
|
+
const transport = restTransport({
|
|
423
|
+
provider: "Vercel",
|
|
424
|
+
baseUrl: options.baseUrl,
|
|
425
|
+
headers: { Accept: "application/json" },
|
|
426
|
+
maxResponseBytes: MAX_RESPONSE_BYTES,
|
|
427
|
+
timeoutMs: READ_CAP_MS,
|
|
428
|
+
authenticate,
|
|
429
|
+
});
|
|
430
|
+
const refuse = (call) => {
|
|
431
|
+
if (call.method === "PATCH" &&
|
|
432
|
+
call.op.path === "/v3/domains/{domain}" &&
|
|
433
|
+
String(record(call.body)["op"]).trim().toLowerCase() === "move-out") {
|
|
434
|
+
return "Connecta refuses a domain move-out: Vercel answers it with a transfer token that lets the destination account claim the domain. Move domains in the Vercel dashboard.";
|
|
435
|
+
}
|
|
436
|
+
if (truthy(call.query["decrypt"])) {
|
|
437
|
+
return "Connecta never asks Vercel to decrypt environment values. Use list_project_env_vars for metadata.";
|
|
438
|
+
}
|
|
439
|
+
if (call.op.path === "/v1/projects/{projectId}/deployments/{deploymentId}/runtime-logs") {
|
|
440
|
+
return "Runtime logs are a live stream that never ends on its own. Read them with get_runtime_logs, which stops after waitMs or maxRows.";
|
|
441
|
+
}
|
|
442
|
+
if (truthy(call.query["follow"])) {
|
|
443
|
+
return "follow streams live events until the connection drops. Omit it (or pass 0); read build events with get_deployment_build_logs.";
|
|
444
|
+
}
|
|
445
|
+
return undefined;
|
|
446
|
+
};
|
|
447
|
+
const vendor = {
|
|
448
|
+
vendor: "vercel",
|
|
449
|
+
title: "Vercel",
|
|
450
|
+
index: operations,
|
|
451
|
+
transport(server) {
|
|
452
|
+
return server === undefined
|
|
453
|
+
? transport
|
|
454
|
+
: `This operation is served from ${server}, which this connector does not reach.`;
|
|
455
|
+
},
|
|
456
|
+
failure: (status, headers, body, call) => vercelFailure(status, headers, body, call ? SAFETY.withholdsErrors(call.op) : false),
|
|
457
|
+
readPosts: READ_POSTS,
|
|
458
|
+
valueSafety: SAFETY,
|
|
459
|
+
refuse,
|
|
460
|
+
scope(call) {
|
|
461
|
+
const query = { ...call.query };
|
|
462
|
+
// null selects the token owner's personal account: no team parameter at all.
|
|
463
|
+
if (query["teamId"] === null) {
|
|
464
|
+
delete query["teamId"];
|
|
465
|
+
return { ...call, query };
|
|
466
|
+
}
|
|
467
|
+
if (teamId && query["teamId"] === undefined && query["slug"] === undefined) {
|
|
468
|
+
if (queryNamesOf(operations, call.op).has("teamId"))
|
|
469
|
+
query["teamId"] = teamId;
|
|
470
|
+
}
|
|
471
|
+
return { ...call, query };
|
|
472
|
+
},
|
|
473
|
+
page: (data, call) => vercelPage(operations, data, call),
|
|
474
|
+
upload: "Send raw bytes with vercel_api_upload.",
|
|
475
|
+
};
|
|
476
|
+
const family = (call) => SAFETY.withholdsErrors(call.op);
|
|
477
|
+
const send = (method, path, ctx, input = {}) => callRest(vendor, restCall(vendor, method, path, input), ctx);
|
|
478
|
+
/**
|
|
479
|
+
* Read one bounded log body: at most `MAX_LOG_BYTES`, never past `waitMs`
|
|
480
|
+
* after Vercel starts answering, and no further than `maxRows` accepted
|
|
481
|
+
* rows, whichever comes first. A follow stream
|
|
482
|
+
* ends at the deadline instead of failing, so the rows read so far are the
|
|
483
|
+
* answer.
|
|
484
|
+
*/
|
|
485
|
+
const boundedRows = async (call, ctx, limits) => {
|
|
486
|
+
let stopped;
|
|
487
|
+
const logs = guardedFetch({
|
|
488
|
+
provider: "Vercel",
|
|
489
|
+
baseUrl: options.baseUrl,
|
|
490
|
+
headers: { Accept: "application/json" },
|
|
491
|
+
maxResponseBytes: MAX_RESPONSE_BYTES,
|
|
492
|
+
authenticate,
|
|
493
|
+
fetch: async (url, init) => {
|
|
494
|
+
const cap = AbortSignal.timeout(READ_CAP_MS);
|
|
495
|
+
const response = await fetch(url, { ...init, signal: init.signal ? AbortSignal.any([init.signal, cap]) : cap });
|
|
496
|
+
if (!response.ok || !response.body)
|
|
497
|
+
return response;
|
|
498
|
+
return new Response(boundedBody(response.body, limits, (reason) => (stopped = reason)), {
|
|
499
|
+
status: response.status,
|
|
500
|
+
statusText: response.statusText,
|
|
501
|
+
headers: response.headers,
|
|
502
|
+
});
|
|
503
|
+
},
|
|
504
|
+
});
|
|
505
|
+
const query = {};
|
|
506
|
+
for (const [name, value] of Object.entries(call.query)) {
|
|
507
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean")
|
|
508
|
+
query[name] = value;
|
|
509
|
+
}
|
|
510
|
+
return await logs({ method: "GET", path: call.path, query, prefixOnly: true }, ctx, async (response) => {
|
|
511
|
+
if (!response.ok) {
|
|
512
|
+
throw vercelFailure(response.status, response.headers, await failureBody(response), family(call));
|
|
513
|
+
}
|
|
514
|
+
const { bytes, truncated } = await response.prefix(MAX_LOG_BYTES);
|
|
515
|
+
const rows = completeRows(new TextDecoder().decode(bytes)).map((row) => SAFETY.redact(row, call.method, call.op.path));
|
|
516
|
+
return { rows, stopped: truncated ? "bytes" : (stopped ?? "end") };
|
|
517
|
+
});
|
|
518
|
+
};
|
|
519
|
+
const tools = [
|
|
520
|
+
{
|
|
521
|
+
name: "list_teams",
|
|
522
|
+
description: "List the Vercel teams this token can reach, with ids and slugs to pass as teamId. Also names the connection's default team.",
|
|
523
|
+
annotations: { readOnlyHint: true },
|
|
524
|
+
inputSchema: closed({
|
|
525
|
+
limit: { type: "integer", minimum: 1, maximum: 100, description: "Most teams per page; default 20." },
|
|
526
|
+
until: { type: "number", description: "page.next from the previous page: a millisecond timestamp." },
|
|
527
|
+
}, []),
|
|
528
|
+
outputSchema: {
|
|
529
|
+
type: "object",
|
|
530
|
+
properties: {
|
|
531
|
+
teams: {
|
|
532
|
+
type: "array",
|
|
533
|
+
items: {
|
|
534
|
+
type: "object",
|
|
535
|
+
properties: {
|
|
536
|
+
id: { type: "string" },
|
|
537
|
+
slug: { type: "string" },
|
|
538
|
+
name: { type: ["string", "null"] },
|
|
539
|
+
role: { type: "string" },
|
|
540
|
+
},
|
|
541
|
+
required: ["id", "slug"],
|
|
542
|
+
},
|
|
543
|
+
},
|
|
544
|
+
defaultTeamId: { type: ["string", "null"], description: "The team operations use when teamId is omitted." },
|
|
545
|
+
page: {
|
|
546
|
+
type: "object",
|
|
547
|
+
properties: { hasMore: { type: "boolean" }, next: { type: "string" }, param: { type: "string" } },
|
|
548
|
+
required: ["hasMore"],
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
required: ["teams", "defaultTeamId"],
|
|
552
|
+
},
|
|
553
|
+
handler: async (args, ctx) => {
|
|
554
|
+
const result = await send("GET", "/v2/teams", ctx, {
|
|
555
|
+
query: compact({ limit: args["limit"] ?? 20, until: args["until"] }),
|
|
556
|
+
});
|
|
557
|
+
const teams = Array.isArray(record(result.data)["teams"]) ? record(result.data)["teams"] : [];
|
|
558
|
+
return {
|
|
559
|
+
teams: teams.map((team) => {
|
|
560
|
+
const entry = record(team);
|
|
561
|
+
return compact({
|
|
562
|
+
id: entry["id"],
|
|
563
|
+
slug: entry["slug"],
|
|
564
|
+
name: entry["name"] ?? null,
|
|
565
|
+
role: record(entry["membership"])["role"],
|
|
566
|
+
});
|
|
567
|
+
}),
|
|
568
|
+
defaultTeamId: teamId ?? null,
|
|
569
|
+
...(result.page ? { page: result.page } : {}),
|
|
570
|
+
};
|
|
571
|
+
},
|
|
572
|
+
},
|
|
573
|
+
{
|
|
574
|
+
name: "list_project_env_vars",
|
|
575
|
+
description: "List a project's environment-variable metadata without decrypting or returning values: keys, types, targets, branches, and custom environments.",
|
|
576
|
+
annotations: { readOnlyHint: true },
|
|
577
|
+
inputSchema: closed({
|
|
578
|
+
projectId: PROJECT_ID,
|
|
579
|
+
teamId: TEAM_ID,
|
|
580
|
+
gitBranch: { type: "string", description: "Preview branch filter." },
|
|
581
|
+
customEnvironmentId: { type: "string", description: "Custom environment filter." },
|
|
582
|
+
}, ["projectId"]),
|
|
583
|
+
outputSchema: {
|
|
584
|
+
type: "object",
|
|
585
|
+
properties: { variables: { type: "array", items: ENV_SCHEMA } },
|
|
586
|
+
required: ["variables"],
|
|
587
|
+
},
|
|
588
|
+
handler: async (args, ctx) => {
|
|
589
|
+
const { data } = await send("GET", `/v10/projects/${segment(args["projectId"])}/env`, ctx, {
|
|
590
|
+
query: compact({
|
|
591
|
+
teamId: args["teamId"],
|
|
592
|
+
gitBranch: args["gitBranch"],
|
|
593
|
+
customEnvironmentId: args["customEnvironmentId"],
|
|
594
|
+
decrypt: "false",
|
|
595
|
+
}),
|
|
596
|
+
});
|
|
597
|
+
const envs = record(data)["envs"];
|
|
598
|
+
return { variables: (Array.isArray(envs) ? envs : []).map(envMetadata) };
|
|
599
|
+
},
|
|
600
|
+
},
|
|
601
|
+
{
|
|
602
|
+
name: "upsert_project_env_var",
|
|
603
|
+
description: "Create or replace one Vercel project environment variable. Changes affect only future deployments; returns metadata without the value.",
|
|
604
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
605
|
+
inputSchema: closed({
|
|
606
|
+
projectId: PROJECT_ID,
|
|
607
|
+
teamId: TEAM_ID,
|
|
608
|
+
key: { type: "string", minLength: 1, maxLength: 256, description: "Environment variable name." },
|
|
609
|
+
value: {
|
|
610
|
+
type: "string",
|
|
611
|
+
maxLength: 65536,
|
|
612
|
+
description: "New value; write input only. Vercel caps a project's environment at 64 KB.",
|
|
613
|
+
},
|
|
614
|
+
type: {
|
|
615
|
+
type: "string",
|
|
616
|
+
enum: ["plain", "encrypted", "sensitive"],
|
|
617
|
+
description: "Storage type. Sensitive values cannot be read back.",
|
|
618
|
+
},
|
|
619
|
+
targets: TARGETS,
|
|
620
|
+
gitBranch: { type: "string", description: "Optional preview-only Git branch." },
|
|
621
|
+
customEnvironmentIds: CUSTOM_ENVIRONMENT_IDS,
|
|
622
|
+
comment: { type: "string", maxLength: 500, description: "Operator-facing note explaining the variable." },
|
|
623
|
+
upsert: {
|
|
624
|
+
type: "boolean",
|
|
625
|
+
description: "Defaults to true. False refuses to overwrite an existing variable.",
|
|
626
|
+
},
|
|
627
|
+
}, ["projectId", "key", "value", "type", "targets"]),
|
|
628
|
+
outputSchema: ENV_SCHEMA,
|
|
629
|
+
handler: async (args, ctx) => {
|
|
630
|
+
const { data } = await send("POST", `/v10/projects/${segment(args["projectId"])}/env`, ctx, {
|
|
631
|
+
query: compact({ teamId: args["teamId"], upsert: args["upsert"] === false ? "false" : "true" }),
|
|
632
|
+
body: compact({
|
|
633
|
+
key: args["key"],
|
|
634
|
+
value: args["value"],
|
|
635
|
+
type: args["type"],
|
|
636
|
+
target: args["targets"],
|
|
637
|
+
gitBranch: args["gitBranch"],
|
|
638
|
+
customEnvironmentIds: args["customEnvironmentIds"],
|
|
639
|
+
comment: args["comment"],
|
|
640
|
+
}),
|
|
641
|
+
});
|
|
642
|
+
const payload = record(data);
|
|
643
|
+
const failed = Array.isArray(payload["failed"]) ? payload["failed"] : [];
|
|
644
|
+
if (failed.length > 0) {
|
|
645
|
+
// Vercel's message can quote the existing value: only a reviewed code is named.
|
|
646
|
+
const code = reviewedCode(record(record(failed[0])["error"])["code"]);
|
|
647
|
+
throw new ConnectorCallError("invalid_args", code === "ENV_ALREADY_EXISTS" || code === "ENV_CONFLICT"
|
|
648
|
+
? `Vercel ${code}: a variable with this key already targets one of these environments. Pass upsert: true to replace it, or update it by id with update_project_env_var.`
|
|
649
|
+
: `Vercel rejected the environment-variable write${code ? ` (${code})` : ""}; its message is withheld because it can quote stored values.`);
|
|
650
|
+
}
|
|
651
|
+
const created = Array.isArray(payload["created"]) ? payload["created"][0] : payload["created"];
|
|
652
|
+
const result = envMetadata(created ?? payload);
|
|
653
|
+
if (!result["id"] || !result["key"] || !result["type"]) {
|
|
654
|
+
throw new ConnectorCallError("connector_call_failed", "Vercel accepted the environment-variable write without returning the created variable.", { retryable: false });
|
|
655
|
+
}
|
|
656
|
+
return result;
|
|
657
|
+
},
|
|
658
|
+
},
|
|
659
|
+
{
|
|
660
|
+
name: "update_project_env_var",
|
|
661
|
+
description: "Update one Vercel project environment variable by id, sending only the fields that change. Deployments keep their previous values.",
|
|
662
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
663
|
+
inputSchema: closed({
|
|
664
|
+
projectId: PROJECT_ID,
|
|
665
|
+
teamId: TEAM_ID,
|
|
666
|
+
envVarId: ENV_VAR_ID,
|
|
667
|
+
key: { type: "string", minLength: 1, maxLength: 256, description: "Replacement variable name." },
|
|
668
|
+
value: { type: "string", maxLength: 65536, description: "Replacement value; write input only." },
|
|
669
|
+
type: { type: "string", enum: ["plain", "encrypted", "sensitive"], description: "Replacement storage type." },
|
|
670
|
+
targets: { ...TARGETS, description: "Replacement default environments." },
|
|
671
|
+
gitBranch: { type: ["string", "null"], description: "Replacement preview branch, or null to clear it." },
|
|
672
|
+
customEnvironmentIds: { ...CUSTOM_ENVIRONMENT_IDS, description: "Replacement custom environment ids." },
|
|
673
|
+
comment: { type: "string", maxLength: 500, description: "Replacement operator-facing note." },
|
|
674
|
+
}, ["projectId", "envVarId"]),
|
|
675
|
+
outputSchema: ENV_SCHEMA,
|
|
676
|
+
handler: async (args, ctx) => {
|
|
677
|
+
const body = compact({
|
|
678
|
+
key: args["key"],
|
|
679
|
+
value: args["value"],
|
|
680
|
+
type: args["type"],
|
|
681
|
+
target: args["targets"],
|
|
682
|
+
gitBranch: args["gitBranch"],
|
|
683
|
+
customEnvironmentIds: args["customEnvironmentIds"],
|
|
684
|
+
comment: args["comment"],
|
|
685
|
+
});
|
|
686
|
+
if (Object.keys(body).length === 0) {
|
|
687
|
+
invalid("Nothing to update: provide key, value, type, targets, gitBranch, customEnvironmentIds, or comment.");
|
|
688
|
+
}
|
|
689
|
+
const { data } = await send("PATCH", `/v9/projects/${segment(args["projectId"])}/env/${segment(args["envVarId"])}`, ctx, { query: compact({ teamId: args["teamId"] }), body });
|
|
690
|
+
return envMetadata(data);
|
|
691
|
+
},
|
|
692
|
+
},
|
|
693
|
+
{
|
|
694
|
+
name: "delete_project_env_var",
|
|
695
|
+
description: "Delete one environment variable from a Vercel project by id. Existing deployments keep their embedded value; future deployments do not.",
|
|
696
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
697
|
+
inputSchema: closed({ projectId: PROJECT_ID, teamId: TEAM_ID, envVarId: ENV_VAR_ID }, ["projectId", "envVarId"]),
|
|
698
|
+
outputSchema: {
|
|
699
|
+
type: "object",
|
|
700
|
+
properties: { deleted: { type: "boolean" }, envVarId: { type: "string" } },
|
|
701
|
+
required: ["deleted", "envVarId"],
|
|
702
|
+
},
|
|
703
|
+
handler: async (args, ctx) => {
|
|
704
|
+
await send("DELETE", `/v9/projects/${segment(args["projectId"])}/env/${segment(args["envVarId"])}`, ctx, {
|
|
705
|
+
query: compact({ teamId: args["teamId"] }),
|
|
706
|
+
});
|
|
707
|
+
return { deleted: true, envVarId: args["envVarId"] };
|
|
708
|
+
},
|
|
709
|
+
},
|
|
710
|
+
{
|
|
711
|
+
name: "get_deployment_build_logs",
|
|
712
|
+
description: "Read a deployment's build events and log lines, without following live output. Bounded by limit and 1 MB; filter by build, time, or direction.",
|
|
713
|
+
annotations: { readOnlyHint: true },
|
|
714
|
+
inputSchema: closed({
|
|
715
|
+
deploymentId: { type: "string", minLength: 1, description: "Deployment id (dpl_…) or hostname." },
|
|
716
|
+
teamId: TEAM_ID,
|
|
717
|
+
buildId: { type: "string", minLength: 1, description: "Only this build's events (bld_…)." },
|
|
718
|
+
direction: {
|
|
719
|
+
type: "string",
|
|
720
|
+
enum: ["forward", "backward"],
|
|
721
|
+
description: "forward (default) reads oldest first; backward reads the newest first.",
|
|
722
|
+
},
|
|
723
|
+
limit: { type: "integer", minimum: 1, maximum: 2000, description: "Most events; default 200." },
|
|
724
|
+
since: { type: "number", description: "Only events at or after this millisecond timestamp." },
|
|
725
|
+
until: { type: "number", description: "Only events at or before this millisecond timestamp." },
|
|
726
|
+
}, ["deploymentId"]),
|
|
727
|
+
outputSchema: {
|
|
728
|
+
type: "object",
|
|
729
|
+
properties: {
|
|
730
|
+
events: {
|
|
731
|
+
type: "array",
|
|
732
|
+
items: {
|
|
733
|
+
type: "object",
|
|
734
|
+
properties: {
|
|
735
|
+
created: { type: "number" },
|
|
736
|
+
type: { type: "string", description: "stdout, stderr, command, exit, deployment-state, and so on." },
|
|
737
|
+
text: { type: "string" },
|
|
738
|
+
textTruncated: { type: "boolean" },
|
|
739
|
+
info: { type: "object", description: "Build step, entrypoint, and path when Vercel sends them." },
|
|
740
|
+
},
|
|
741
|
+
},
|
|
742
|
+
},
|
|
743
|
+
truncated: { type: "boolean", description: "True when the 1 MB or time cap stopped the read early." },
|
|
744
|
+
},
|
|
745
|
+
required: ["events", "truncated"],
|
|
746
|
+
},
|
|
747
|
+
handler: async (args, ctx) => {
|
|
748
|
+
const call = restCall(vendor, "GET", `/v3/deployments/${segment(args["deploymentId"])}/events`, {
|
|
749
|
+
query: compact({
|
|
750
|
+
teamId: args["teamId"],
|
|
751
|
+
name: args["buildId"],
|
|
752
|
+
direction: args["direction"],
|
|
753
|
+
limit: args["limit"] ?? 200,
|
|
754
|
+
since: args["since"],
|
|
755
|
+
until: args["until"],
|
|
756
|
+
follow: 0,
|
|
757
|
+
}),
|
|
758
|
+
});
|
|
759
|
+
const { rows, stopped } = await boundedRows(call, ctx, { waitMs: 20_000 });
|
|
760
|
+
return {
|
|
761
|
+
events: rows.map((row) => {
|
|
762
|
+
const event = record(row);
|
|
763
|
+
const payload = record(event["payload"]);
|
|
764
|
+
const raw = isRecord(payload["info"])
|
|
765
|
+
? payload["info"]
|
|
766
|
+
: isRecord(event["info"])
|
|
767
|
+
? event["info"]
|
|
768
|
+
: undefined;
|
|
769
|
+
const info = raw
|
|
770
|
+
? compact({
|
|
771
|
+
type: text(raw["type"]),
|
|
772
|
+
name: text(raw["name"]),
|
|
773
|
+
step: text(raw["step"]),
|
|
774
|
+
entrypoint: text(raw["entrypoint"]),
|
|
775
|
+
path: text(raw["path"]),
|
|
776
|
+
readyState: text(raw["readyState"]),
|
|
777
|
+
})
|
|
778
|
+
: undefined;
|
|
779
|
+
return compact({
|
|
780
|
+
created: event["created"] ?? payload["created"] ?? payload["date"],
|
|
781
|
+
type: event["type"],
|
|
782
|
+
...clamp(payload["text"] ?? event["text"]),
|
|
783
|
+
info,
|
|
784
|
+
});
|
|
785
|
+
}),
|
|
786
|
+
truncated: stopped !== "end",
|
|
787
|
+
};
|
|
788
|
+
},
|
|
789
|
+
},
|
|
790
|
+
{
|
|
791
|
+
name: "get_runtime_logs",
|
|
792
|
+
description: "Collect a deployment's runtime log stream for at most waitMs or maxRows, then stop. Returns level, message, request path, and status per row.",
|
|
793
|
+
annotations: { readOnlyHint: true },
|
|
794
|
+
inputSchema: closed({
|
|
795
|
+
projectId: PROJECT_ID,
|
|
796
|
+
deploymentId: { type: "string", minLength: 1, description: "Deployment id (dpl_…)." },
|
|
797
|
+
teamId: TEAM_ID,
|
|
798
|
+
waitMs: {
|
|
799
|
+
type: "integer",
|
|
800
|
+
minimum: 1000,
|
|
801
|
+
maximum: 20000,
|
|
802
|
+
description: "How long to collect the stream; default 5000.",
|
|
803
|
+
},
|
|
804
|
+
maxRows: { type: "integer", minimum: 1, maximum: 1000, description: "Most rows returned; default 200." },
|
|
805
|
+
levels: {
|
|
806
|
+
type: "array",
|
|
807
|
+
uniqueItems: true,
|
|
808
|
+
items: {
|
|
809
|
+
type: "string",
|
|
810
|
+
enum: ["debug", "info", "warning", "error", "fatal", "trace"],
|
|
811
|
+
description: "A log level to keep.",
|
|
812
|
+
},
|
|
813
|
+
description: "Keep only these levels.",
|
|
814
|
+
},
|
|
815
|
+
}, ["projectId", "deploymentId"]),
|
|
816
|
+
outputSchema: {
|
|
817
|
+
type: "object",
|
|
818
|
+
properties: {
|
|
819
|
+
logs: {
|
|
820
|
+
type: "array",
|
|
821
|
+
items: {
|
|
822
|
+
type: "object",
|
|
823
|
+
properties: {
|
|
824
|
+
timestampInMs: { type: "number" },
|
|
825
|
+
level: { type: "string" },
|
|
826
|
+
source: { type: "string" },
|
|
827
|
+
text: { type: "string" },
|
|
828
|
+
textTruncated: { type: "boolean" },
|
|
829
|
+
requestMethod: { type: "string" },
|
|
830
|
+
requestPath: { type: "string" },
|
|
831
|
+
responseStatusCode: { type: "number" },
|
|
832
|
+
domain: { type: "string" },
|
|
833
|
+
},
|
|
834
|
+
},
|
|
835
|
+
},
|
|
836
|
+
stopped: {
|
|
837
|
+
type: "string",
|
|
838
|
+
enum: ["end", "time", "bytes", "rows"],
|
|
839
|
+
description: "Why collection stopped.",
|
|
840
|
+
},
|
|
841
|
+
},
|
|
842
|
+
required: ["logs", "stopped"],
|
|
843
|
+
},
|
|
844
|
+
handler: async (args, ctx) => {
|
|
845
|
+
const call = restCall(vendor, "GET", `/v1/projects/${segment(args["projectId"])}/deployments/${segment(args["deploymentId"])}/runtime-logs`, { query: compact({ teamId: args["teamId"] }) });
|
|
846
|
+
const waitMs = typeof args["waitMs"] === "number" ? args["waitMs"] : 5_000;
|
|
847
|
+
const maxRows = typeof args["maxRows"] === "number" ? args["maxRows"] : 200;
|
|
848
|
+
const levels = Array.isArray(args["levels"]) ? new Set(args["levels"]) : undefined;
|
|
849
|
+
const keep = (row) => !levels || levels.has(record(row)["level"]);
|
|
850
|
+
const { rows, stopped } = await boundedRows(call, ctx, { waitMs, maxRows, accept: keep });
|
|
851
|
+
const logs = rows
|
|
852
|
+
.map(record)
|
|
853
|
+
.filter(keep)
|
|
854
|
+
.map((row) => compact({
|
|
855
|
+
timestampInMs: row["timestampInMs"],
|
|
856
|
+
level: row["level"],
|
|
857
|
+
source: row["source"],
|
|
858
|
+
...clamp(row["message"]),
|
|
859
|
+
requestMethod: row["requestMethod"],
|
|
860
|
+
requestPath: row["requestPath"],
|
|
861
|
+
responseStatusCode: row["responseStatusCode"],
|
|
862
|
+
domain: row["domain"],
|
|
863
|
+
}));
|
|
864
|
+
return { logs: logs.slice(0, maxRows), stopped: logs.length > maxRows ? "rows" : stopped };
|
|
865
|
+
},
|
|
866
|
+
},
|
|
867
|
+
{
|
|
868
|
+
name: "vercel_api_upload",
|
|
869
|
+
description: "Upload explicit text or base64 bytes to a Vercel octet-stream operation, such as POST /v2/files for deployment files. Reads no local files.",
|
|
870
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
871
|
+
inputSchema: closed({
|
|
872
|
+
method: { type: "string", enum: ["POST", "PUT"], description: "The operation's HTTP method." },
|
|
873
|
+
path: {
|
|
874
|
+
type: "string",
|
|
875
|
+
minLength: 2,
|
|
876
|
+
maxLength: 2048,
|
|
877
|
+
description: "Concrete path of an octet-stream operation from vercel_api_search, e.g. /v2/files.",
|
|
878
|
+
},
|
|
879
|
+
query: { type: "object", description: "Query parameters as JSON, named as vercel_api_details lists them." },
|
|
880
|
+
contentType: {
|
|
881
|
+
type: "string",
|
|
882
|
+
minLength: 1,
|
|
883
|
+
maxLength: 200,
|
|
884
|
+
description: "Content-Type of the bytes; default application/octet-stream.",
|
|
885
|
+
},
|
|
886
|
+
textBody: { type: "string", description: "Raw UTF-8 body. Exclusive with base64Body." },
|
|
887
|
+
base64Body: { type: "string", description: "Base64-encoded bytes. Exclusive with textBody." },
|
|
888
|
+
}, ["method", "path"]),
|
|
889
|
+
outputSchema: {
|
|
890
|
+
type: "object",
|
|
891
|
+
properties: {
|
|
892
|
+
status: { type: "integer", description: "HTTP status." },
|
|
893
|
+
data: { description: "Vercel's response body." },
|
|
894
|
+
digest: { type: "string", description: "SHA-1 of the bytes, sent as x-vercel-digest for /v2/files." },
|
|
895
|
+
},
|
|
896
|
+
required: ["status", "data"],
|
|
897
|
+
},
|
|
898
|
+
handler: async (args, ctx) => {
|
|
899
|
+
const call = restCall(vendor, args["method"], String(args["path"]), {
|
|
900
|
+
query: isRecord(args["query"]) ? args["query"] : {},
|
|
901
|
+
});
|
|
902
|
+
const framing = operations.bodyType(call.op) ?? "";
|
|
903
|
+
if (!/octet-stream|^multipart\//.test(framing)) {
|
|
904
|
+
invalid(`${call.method} ${call.op.path} is not an upload; call it with vercel_api_write.`);
|
|
905
|
+
}
|
|
906
|
+
const refusal = refuse(call);
|
|
907
|
+
if (refusal)
|
|
908
|
+
invalid(refusal);
|
|
909
|
+
// Decode first, then validate what was supplied: a required binary body
|
|
910
|
+
// is present whenever bytes are (the index types it as a string).
|
|
911
|
+
const bytes = decodeUpload(args);
|
|
912
|
+
operations.check(call.op, call.query, String(args["base64Body"] ?? args["textBody"]));
|
|
913
|
+
const digest = call.op.path === "/v2/files" ? await sha1(bytes) : undefined;
|
|
914
|
+
const query = {};
|
|
915
|
+
for (const [name, value] of Object.entries(call.query)) {
|
|
916
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean")
|
|
917
|
+
query[name] = value;
|
|
918
|
+
}
|
|
919
|
+
// A keyless write whose request may have reached Vercel has an unknown
|
|
920
|
+
// outcome (INV-9): never advertised as retryable, except a
|
|
921
|
+
// content-addressed upload, where repeating it stores the same bytes.
|
|
922
|
+
const repeatable = CONTENT_ADDRESSED.has(`${call.method} ${call.op.path}`);
|
|
923
|
+
let responded = false;
|
|
924
|
+
try {
|
|
925
|
+
return await transport({
|
|
926
|
+
method: call.method,
|
|
927
|
+
path: call.path,
|
|
928
|
+
query,
|
|
929
|
+
headers: {
|
|
930
|
+
"Content-Type": typeof args["contentType"] === "string" ? args["contentType"] : "application/octet-stream",
|
|
931
|
+
...(digest ? { "x-vercel-digest": digest } : {}),
|
|
932
|
+
},
|
|
933
|
+
rawBody: bytes,
|
|
934
|
+
}, ctx, async (response) => {
|
|
935
|
+
responded = true;
|
|
936
|
+
if (!response.ok) {
|
|
937
|
+
throw vercelFailure(response.status, response.headers, await failureBody(response), family(call));
|
|
938
|
+
}
|
|
939
|
+
let body;
|
|
940
|
+
try {
|
|
941
|
+
body = await response.text();
|
|
942
|
+
}
|
|
943
|
+
catch (error) {
|
|
944
|
+
if (error instanceof ConnectorCallError)
|
|
945
|
+
throw error;
|
|
946
|
+
// Sent, answered, unread: whether the upload took effect is unknown (INV-9).
|
|
947
|
+
throw new ConnectorCallError("connector_call_failed", "Vercel answered the upload, but its response could not be read, so whether it took effect is unknown. Check the target before repeating it.", { retryable: false });
|
|
948
|
+
}
|
|
949
|
+
// Only JSON comes back, made value-safe; other bodies are described, never echoed.
|
|
950
|
+
let data = null;
|
|
951
|
+
if (body !== "") {
|
|
952
|
+
try {
|
|
953
|
+
data = SAFETY.redact(JSON.parse(body), call.method, call.op.path);
|
|
954
|
+
}
|
|
955
|
+
catch {
|
|
956
|
+
data = {
|
|
957
|
+
contentType: response.headers.get("content-type") ?? "",
|
|
958
|
+
bytes: new TextEncoder().encode(body).length,
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
return { status: response.status, data, ...(digest ? { digest } : {}) };
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
catch (error) {
|
|
966
|
+
throw repeatable ? error : unknownOutcome(vendor, error, responded);
|
|
967
|
+
}
|
|
968
|
+
},
|
|
969
|
+
},
|
|
970
|
+
];
|
|
971
|
+
return {
|
|
972
|
+
vendor,
|
|
973
|
+
tools,
|
|
974
|
+
async testCredential(value, ctx) {
|
|
975
|
+
const token = value.trim();
|
|
976
|
+
try {
|
|
977
|
+
const { data } = await callRest(vendor, restCall(vendor, "GET", "/v2/user"), {
|
|
978
|
+
...ctx,
|
|
979
|
+
credential: { get: async () => token, getAll: async () => ({ value: token }) },
|
|
980
|
+
});
|
|
981
|
+
const payload = record(data);
|
|
982
|
+
const user = record(payload["user"] ?? payload);
|
|
983
|
+
const identity = text(user["username"]) ?? text(user["email"]) ?? text(user["name"]) ?? text(user["id"]);
|
|
984
|
+
return { ok: true, message: `Authenticated as ${identity ?? "a Vercel user"}.` };
|
|
985
|
+
}
|
|
986
|
+
catch (error) {
|
|
987
|
+
// Fixed text: Vercel's message can quote the candidate token.
|
|
988
|
+
const code = error instanceof ConnectorCallError ? error.code : undefined;
|
|
989
|
+
return {
|
|
990
|
+
ok: false,
|
|
991
|
+
message: code === "auth_required"
|
|
992
|
+
? "Vercel rejected the token: it is invalid, expired, or revoked."
|
|
993
|
+
: code === "provider_permission_denied"
|
|
994
|
+
? "Vercel accepted the token but refused to read the current user; check the token's scope."
|
|
995
|
+
: code === "rate_limited" || code === "unavailable"
|
|
996
|
+
? "Vercel could not check the token right now; try again shortly."
|
|
997
|
+
: "Vercel did not accept the token.",
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
},
|
|
1001
|
+
};
|
|
1002
|
+
}
|