@pipelex/sdk 0.58.0 → 0.60.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/README.md +1 -2
- package/dist/cli/script.d.ts +1 -1
- package/dist/client.d.ts +121 -33
- package/dist/client.js +179 -55
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +5 -4
- package/dist/errors.js +5 -4
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/models.d.ts +15 -3
- package/dist/product-models.d.ts +7 -36
- package/dist/runs.d.ts +7 -0
- package/dist/runs.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/webhooks.d.ts +304 -0
- package/dist/webhooks.js +318 -0
- package/dist/webhooks.js.map +1 -0
- package/docs/architecture.md +10 -4
- package/docs/crate-routes.md +5 -5
- package/docs/errors.md +2 -0
- package/docs/input-preparation.md +1 -1
- package/docs/method-copy.md +5 -14
- package/docs/webhooks.md +130 -0
- package/package.json +1 -1
package/dist/webhooks.js
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caller webhooks: the `run.finished` event a Pipelex runtime sends each URL a run registered in
|
|
3
|
+
* `callback_urls` once the run has ended, the receiver's verification of a call, and the shapes of
|
|
4
|
+
* the hosted delivery log and of an organization's signing secret.
|
|
5
|
+
*
|
|
6
|
+
* The contract is the spec `conformance/specs/pipelex-caller-webhooks.md`, in the `conformance`
|
|
7
|
+
* repository: a call is a Standard Webhooks v1 `POST`, its `webhook-id`, `webhook-timestamp` and
|
|
8
|
+
* `webhook-signature` headers signing `{webhook-id}.{webhook-timestamp}.{body}` with HMAC-SHA256
|
|
9
|
+
* under a `whsec_` secret, and its body a `run.finished` event. `verifyWebhook` follows the spec's
|
|
10
|
+
* verification procedure step by step and is held to its known-answer vectors, a byte-identical copy
|
|
11
|
+
* of which this package's tests run (`tests/fixtures/caller-webhooks/`).
|
|
12
|
+
*
|
|
13
|
+
* Browser-safe, as the whole barrel is: the signature is computed with Web Crypto
|
|
14
|
+
* (`globalThis.crypto.subtle`), which Node, Deno, Bun, edge runtimes and browsers carry, never with
|
|
15
|
+
* `node:crypto`. See `docs/webhooks.md`.
|
|
16
|
+
*/
|
|
17
|
+
import { ProblemError, buildProblem, isProblem } from "mthds/protocol";
|
|
18
|
+
import { RequestArgumentError, argumentInvalidProblem } from "./errors.js";
|
|
19
|
+
import { isPlainObject } from "./records.js";
|
|
20
|
+
// ── The event ─────────────────────────────────────────────────────────
|
|
21
|
+
/** The `type` of the event a run's end sends: one type for every ending, the outcome in `data.status`. */
|
|
22
|
+
export const RUN_FINISHED_EVENT_TYPE = "run.finished";
|
|
23
|
+
/**
|
|
24
|
+
* How far a call's `webhook-timestamp` may be from the receiver's clock, either way, in seconds:
|
|
25
|
+
* five minutes, as the contract sets it. A call further away is refused, which bounds how long a
|
|
26
|
+
* captured call can be replayed.
|
|
27
|
+
*/
|
|
28
|
+
export const WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS = 300;
|
|
29
|
+
/** Whether `event` is a `run.finished` event, narrowing it to `RunFinishedEvent`. */
|
|
30
|
+
export function isRunFinishedEvent(event) {
|
|
31
|
+
return event.type === RUN_FINISHED_EVENT_TYPE;
|
|
32
|
+
}
|
|
33
|
+
/** The problem a refusal carries: the call's credential absent, or refused. */
|
|
34
|
+
function webhookRefusalProblem(refusal) {
|
|
35
|
+
switch (refusal) {
|
|
36
|
+
case "missing_header":
|
|
37
|
+
return buildProblem("auth.missing_credential", {});
|
|
38
|
+
case "timestamp":
|
|
39
|
+
case "signature":
|
|
40
|
+
return buildProblem("auth.invalid_credential", {});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* A call `verifyWebhook` refused: it does not come from the runtime holding the secret, or not
|
|
45
|
+
* within the last five minutes, so its body must not be acted on. Answer it with a status outside
|
|
46
|
+
* `2xx`, such as `401`, which the sender counts as a failed attempt and retries.
|
|
47
|
+
*
|
|
48
|
+
* Branch on `refusal` (see `WebhookRefusal`). Its problem treats the signature as the call's
|
|
49
|
+
* credential: `auth.missing_credential` for a missing header, `auth.invalid_credential` for a
|
|
50
|
+
* timestamp or a signature refused. Nothing was requested, so it is no `PipelexRequestError`; it
|
|
51
|
+
* is a `ProblemError`, as every error this SDK raises is.
|
|
52
|
+
*/
|
|
53
|
+
export class WebhookVerificationError extends ProblemError {
|
|
54
|
+
/** Which step of the verification refused the call. */
|
|
55
|
+
refusal;
|
|
56
|
+
constructor(message, refusal) {
|
|
57
|
+
super(webhookRefusalProblem(refusal));
|
|
58
|
+
this.name = "WebhookVerificationError";
|
|
59
|
+
this.message = message;
|
|
60
|
+
this.refusal = refusal;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* A verified call whose body is not a webhook event this SDK can read: bytes that are not UTF-8,
|
|
65
|
+
* text that is not JSON, JSON that is not an object with a string `type`, or a `run.finished` event
|
|
66
|
+
* missing a member the contract gives it or carrying one of the wrong type. The call does come from
|
|
67
|
+
* the runtime holding the secret, so this is that runtime's fault, never the receiver's.
|
|
68
|
+
*
|
|
69
|
+
* Its problem is `request.malformed`, whose `reason` is `not_utf8` or `not_json`, for a body that
|
|
70
|
+
* does not parse, and `request.invalid`, whose `field` names the member as a dotted path (`type`,
|
|
71
|
+
* `attempt`, `data.status`), for one that parses but is no event.
|
|
72
|
+
*/
|
|
73
|
+
export class WebhookEventUnreadableError extends ProblemError {
|
|
74
|
+
constructor(message, problem, options) {
|
|
75
|
+
super(problem, options);
|
|
76
|
+
this.name = "WebhookEventUnreadableError";
|
|
77
|
+
this.message = message;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const SECRET_PREFIX = "whsec_";
|
|
81
|
+
const SIGNATURE_VERSION = "v1";
|
|
82
|
+
const ID_HEADER = "webhook-id";
|
|
83
|
+
const TIMESTAMP_HEADER = "webhook-timestamp";
|
|
84
|
+
const SIGNATURE_HEADER = "webhook-signature";
|
|
85
|
+
const UNIX_SECONDS = /^[0-9]+$/;
|
|
86
|
+
// What separates the entries of a `webhook-signature`: the spaces HTTP allows in a field, a space and a tab.
|
|
87
|
+
const SIGNATURE_ENTRY_SEPARATOR = /[ \t]+/;
|
|
88
|
+
// Standard base64 (RFC 4648 §4) with its padding: whole quanta, then at most one padded one.
|
|
89
|
+
const STANDARD_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
90
|
+
const TEXT_ENCODER = new TextEncoder();
|
|
91
|
+
const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
92
|
+
/**
|
|
93
|
+
* Verify a call to a caller webhook and read its event.
|
|
94
|
+
*
|
|
95
|
+
* It follows the contract's procedure: it refuses a call missing one of the three headers, then
|
|
96
|
+
* one whose `webhook-timestamp` is more than five minutes from `options.now`, then one whose
|
|
97
|
+
* `webhook-signature` lists no `v1` signature matching its own, computed from the raw body under
|
|
98
|
+
* each secret given and compared in constant time. Only then does it parse the body.
|
|
99
|
+
*
|
|
100
|
+
* Give `body` exactly as received, the raw bytes or their text, never a value parsed and written
|
|
101
|
+
* again: the same JSON written with other bytes signs differently. In a framework that parses JSON
|
|
102
|
+
* bodies by itself, read the raw body instead (`await request.text()` in a fetch handler,
|
|
103
|
+
* `express.raw({ type: "application/json" })` in Express).
|
|
104
|
+
*
|
|
105
|
+
* `secret` is the `whsec_` secret the runtime signs with: the organization's, which its admins read
|
|
106
|
+
* with `getWebhookSecret`, or the one a self-hosted runner's operator configured. During a rotation
|
|
107
|
+
* give both, the call then verifying under either: the hosted plane signs every call with the new
|
|
108
|
+
* secret and the previous one for 24 hours.
|
|
109
|
+
*
|
|
110
|
+
* Returns the event: a `RunFinishedEvent` (`isRunFinishedEvent`), or an `UnknownWebhookEvent` for a
|
|
111
|
+
* `type` this SDK does not read, to acknowledge and ignore. Deduplicate on the `webhook-id` header,
|
|
112
|
+
* since a delivery can arrive more than once.
|
|
113
|
+
*
|
|
114
|
+
* Throws `WebhookVerificationError` for a call refused, whose `refusal` says why;
|
|
115
|
+
* `WebhookEventUnreadableError` for a verified call that holds no event this SDK reads; and
|
|
116
|
+
* `RequestArgumentError` for a `secret` that is not a `whsec_` secret written in standard base64
|
|
117
|
+
* with its padding, a `body` that is neither text nor bytes, or an `options.now` that is no valid
|
|
118
|
+
* `Date`, before the call is looked at.
|
|
119
|
+
*/
|
|
120
|
+
export async function verifyWebhook(body, headers, secret, options = {}) {
|
|
121
|
+
const keys = signingKeys(secret);
|
|
122
|
+
const bodyBytes = bytesOf(body);
|
|
123
|
+
const clock = options.now ?? new Date();
|
|
124
|
+
// An invalid date would read every timestamp as within tolerance, so it is refused rather than used.
|
|
125
|
+
if (!(clock instanceof Date) || Number.isNaN(clock.getTime())) {
|
|
126
|
+
throw new RequestArgumentError("verifyWebhook() takes options.now as a valid Date.", {
|
|
127
|
+
problem: argumentInvalidProblem("now", "not_a_date"),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
const webhookId = headerValue(headers, ID_HEADER);
|
|
131
|
+
const webhookTimestamp = headerValue(headers, TIMESTAMP_HEADER);
|
|
132
|
+
const webhookSignature = headerValue(headers, SIGNATURE_HEADER);
|
|
133
|
+
if (webhookId === undefined || webhookTimestamp === undefined || webhookSignature === undefined) {
|
|
134
|
+
const missing = webhookId === undefined
|
|
135
|
+
? ID_HEADER
|
|
136
|
+
: webhookTimestamp === undefined
|
|
137
|
+
? TIMESTAMP_HEADER
|
|
138
|
+
: SIGNATURE_HEADER;
|
|
139
|
+
throw new WebhookVerificationError(`The call carries no ${missing} header, so it cannot be verified.`, "missing_header");
|
|
140
|
+
}
|
|
141
|
+
const sentAt = UNIX_SECONDS.test(webhookTimestamp) ? Number(webhookTimestamp) : Number.NaN;
|
|
142
|
+
if (!Number.isSafeInteger(sentAt)) {
|
|
143
|
+
throw new WebhookVerificationError("The call's webhook-timestamp is not a whole number of Unix seconds.", "timestamp");
|
|
144
|
+
}
|
|
145
|
+
const now = Math.floor(clock.getTime() / 1000);
|
|
146
|
+
if (Math.abs(now - sentAt) > WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS) {
|
|
147
|
+
throw new WebhookVerificationError(`The call's webhook-timestamp is ${Math.abs(now - sentAt)} seconds from this clock, more than the ${WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS} the contract accepts.`, "timestamp");
|
|
148
|
+
}
|
|
149
|
+
const signed = concatBytes(TEXT_ENCODER.encode(`${webhookId}.${webhookTimestamp}.`), bodyBytes);
|
|
150
|
+
const given = givenSignatures(webhookSignature);
|
|
151
|
+
let verified = false;
|
|
152
|
+
for (const key of keys) {
|
|
153
|
+
const expected = await signatureOf(key, signed);
|
|
154
|
+
// Every given signature is compared, whatever an earlier comparison found.
|
|
155
|
+
for (const candidate of given) {
|
|
156
|
+
if (equalInConstantTime(expected, candidate))
|
|
157
|
+
verified = true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (!verified) {
|
|
161
|
+
throw new WebhookVerificationError("No v1 signature of the call matches its body under the secret given.", "signature");
|
|
162
|
+
}
|
|
163
|
+
return readWebhookEvent(bodyBytes);
|
|
164
|
+
}
|
|
165
|
+
/** Each secret's key, the bytes after its `whsec_` prefix decoded from standard base64. */
|
|
166
|
+
function signingKeys(secret) {
|
|
167
|
+
const secrets = Array.isArray(secret) ? [...secret] : [secret];
|
|
168
|
+
if (secrets.length === 0) {
|
|
169
|
+
throw new RequestArgumentError("verifyWebhook() needs at least one secret to verify a call with.", { problem: argumentInvalidProblem("secret", "empty") });
|
|
170
|
+
}
|
|
171
|
+
return secrets.map((value) => {
|
|
172
|
+
if (typeof value !== "string") {
|
|
173
|
+
throw new RequestArgumentError(`verifyWebhook() takes each secret as a string; got ${value === null ? "null" : typeof value}.`, { problem: argumentInvalidProblem("secret", "not_a_string") });
|
|
174
|
+
}
|
|
175
|
+
const key = value.startsWith(SECRET_PREFIX)
|
|
176
|
+
? decodeStandardBase64(value.slice(SECRET_PREFIX.length))
|
|
177
|
+
: undefined;
|
|
178
|
+
if (key === undefined) {
|
|
179
|
+
// The secret itself is never quoted: it is the receiver's credential.
|
|
180
|
+
throw new RequestArgumentError("verifyWebhook() takes a secret written `whsec_` and its key in standard base64 with its " +
|
|
181
|
+
"padding, as the runtime gives it; give the whole value.", { problem: argumentInvalidProblem("secret", "malformed") });
|
|
182
|
+
}
|
|
183
|
+
return key;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
/** `text` decoded from standard base64 with its padding, or `undefined` for anything else. */
|
|
187
|
+
function decodeStandardBase64(text) {
|
|
188
|
+
if (text.length === 0 || !STANDARD_BASE64.test(text))
|
|
189
|
+
return undefined;
|
|
190
|
+
const bytes = Uint8Array.from(atob(text), (character) => character.charCodeAt(0));
|
|
191
|
+
// A last quantum whose unused bits are not zero decodes, but is not how the key is written.
|
|
192
|
+
return encodeStandardBase64(bytes) === text ? bytes : undefined;
|
|
193
|
+
}
|
|
194
|
+
function encodeStandardBase64(bytes) {
|
|
195
|
+
let binary = "";
|
|
196
|
+
for (const byte of bytes)
|
|
197
|
+
binary += String.fromCharCode(byte);
|
|
198
|
+
return btoa(binary);
|
|
199
|
+
}
|
|
200
|
+
/** A call's body as the bytes it was signed over: text in UTF-8, bytes as given. */
|
|
201
|
+
function bytesOf(body) {
|
|
202
|
+
if (typeof body === "string")
|
|
203
|
+
return TEXT_ENCODER.encode(body);
|
|
204
|
+
if (body instanceof Uint8Array)
|
|
205
|
+
return body;
|
|
206
|
+
if (body instanceof ArrayBuffer)
|
|
207
|
+
return new Uint8Array(body);
|
|
208
|
+
throw new RequestArgumentError("verifyWebhook() takes the call's raw body as a string, a Uint8Array or an ArrayBuffer.", { problem: argumentInvalidProblem("body", "not_text_or_bytes") });
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* A header's value whatever the case of its name, or `undefined` when it is absent or empty. A
|
|
212
|
+
* record naming it under several cases, or giving it as a list, is read as its values joined by a
|
|
213
|
+
* space, as `pipelex-sdk` reads a header listed more than once.
|
|
214
|
+
*/
|
|
215
|
+
function headerValue(headers, name) {
|
|
216
|
+
let text;
|
|
217
|
+
if (typeof headers.get === "function") {
|
|
218
|
+
text = headers.get(name);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
const record = headers;
|
|
222
|
+
text = Object.keys(record)
|
|
223
|
+
.filter((candidate) => candidate.toLowerCase() === name)
|
|
224
|
+
.flatMap((key) => record[key] ?? [])
|
|
225
|
+
.filter((part) => part.length > 0)
|
|
226
|
+
.join(" ");
|
|
227
|
+
}
|
|
228
|
+
return typeof text === "string" && text.length > 0 ? text : undefined;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* The `v1` signatures a `webhook-signature` lists, an entry of any other version ignored.
|
|
232
|
+
*
|
|
233
|
+
* Entries are separated by spaces. A comma ending an entry is dropped: a header sent more than
|
|
234
|
+
* once reaches a receiver joined by a comma and a space, as fetch's `Headers`, Node and httpx
|
|
235
|
+
* join a repeated field, and a signature, written in base64, never ends with one.
|
|
236
|
+
*/
|
|
237
|
+
function givenSignatures(header) {
|
|
238
|
+
const signatures = [];
|
|
239
|
+
for (const given of header.split(SIGNATURE_ENTRY_SEPARATOR)) {
|
|
240
|
+
const entry = given.endsWith(",") ? given.slice(0, -1) : given;
|
|
241
|
+
const comma = entry.indexOf(",");
|
|
242
|
+
if (comma > 0 && entry.slice(0, comma) === SIGNATURE_VERSION) {
|
|
243
|
+
signatures.push(entry.slice(comma + 1));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return signatures;
|
|
247
|
+
}
|
|
248
|
+
/** The base64 HMAC-SHA256 of `content` under `key`. */
|
|
249
|
+
async function signatureOf(key, content) {
|
|
250
|
+
const subtle = globalThis.crypto.subtle;
|
|
251
|
+
const hmacKey = await subtle.importKey("raw", key, { name: "HMAC", hash: "SHA-256" }, false, [
|
|
252
|
+
"sign",
|
|
253
|
+
]);
|
|
254
|
+
const signature = await subtle.sign("HMAC", hmacKey, content);
|
|
255
|
+
return encodeStandardBase64(new Uint8Array(signature));
|
|
256
|
+
}
|
|
257
|
+
function concatBytes(head, tail) {
|
|
258
|
+
const joined = new Uint8Array(head.length + tail.length);
|
|
259
|
+
joined.set(head, 0);
|
|
260
|
+
joined.set(tail, head.length);
|
|
261
|
+
return joined;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Whether two signatures are equal, compared in a time that depends on their length alone. A
|
|
265
|
+
* signature of another length is no match, and the expected one's length is public.
|
|
266
|
+
*/
|
|
267
|
+
function equalInConstantTime(expected, given) {
|
|
268
|
+
if (expected.length !== given.length)
|
|
269
|
+
return false;
|
|
270
|
+
let difference = 0;
|
|
271
|
+
for (let index = 0; index < expected.length; index += 1) {
|
|
272
|
+
difference |= expected.charCodeAt(index) ^ given.charCodeAt(index);
|
|
273
|
+
}
|
|
274
|
+
return difference === 0;
|
|
275
|
+
}
|
|
276
|
+
/** The refusal of a verified body that is no event, naming the member at fault. */
|
|
277
|
+
function invalidMember(field) {
|
|
278
|
+
return new WebhookEventUnreadableError(`The verified call's body is not a webhook event this SDK reads: its member \`${field}\` is missing or of the wrong type.`, buildProblem("request.invalid", { field }));
|
|
279
|
+
}
|
|
280
|
+
/** The event a verified body holds; see `WebhookEventUnreadableError` for what it refuses. */
|
|
281
|
+
function readWebhookEvent(bodyBytes) {
|
|
282
|
+
let text;
|
|
283
|
+
try {
|
|
284
|
+
text = UTF8_DECODER.decode(bodyBytes);
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
throw new WebhookEventUnreadableError("The verified call's body is not UTF-8 text.", buildProblem("request.malformed", { reason: "not_utf8" }), { cause: err });
|
|
288
|
+
}
|
|
289
|
+
let parsed;
|
|
290
|
+
try {
|
|
291
|
+
parsed = JSON.parse(text);
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
throw new WebhookEventUnreadableError("The verified call's body is not JSON.", buildProblem("request.malformed", { reason: "not_json" }), { cause: err });
|
|
295
|
+
}
|
|
296
|
+
if (!isPlainObject(parsed) || typeof parsed.type !== "string")
|
|
297
|
+
throw invalidMember("type");
|
|
298
|
+
if (parsed.type !== RUN_FINISHED_EVENT_TYPE)
|
|
299
|
+
return parsed;
|
|
300
|
+
if (typeof parsed.timestamp !== "string")
|
|
301
|
+
throw invalidMember("timestamp");
|
|
302
|
+
const attempt = parsed.attempt;
|
|
303
|
+
if (typeof attempt !== "number" || !Number.isSafeInteger(attempt) || attempt < 1) {
|
|
304
|
+
throw invalidMember("attempt");
|
|
305
|
+
}
|
|
306
|
+
const data = parsed.data;
|
|
307
|
+
if (!isPlainObject(data))
|
|
308
|
+
throw invalidMember("data");
|
|
309
|
+
if (typeof data.pipeline_run_id !== "string")
|
|
310
|
+
throw invalidMember("data.pipeline_run_id");
|
|
311
|
+
if (typeof data.status !== "string")
|
|
312
|
+
throw invalidMember("data.status");
|
|
313
|
+
const readData = { ...data };
|
|
314
|
+
if (Object.hasOwn(readData, "error") && !isProblem(readData.error))
|
|
315
|
+
readData.error = null;
|
|
316
|
+
return { ...parsed, data: readData };
|
|
317
|
+
}
|
|
318
|
+
//# sourceMappingURL=webhooks.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhooks.js","sourceRoot":"","sources":["../src/webhooks.ts"],"names":[],"mappings":"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"}
|
package/docs/architecture.md
CHANGED
|
@@ -82,7 +82,7 @@ A run request carries its method as its **tree**, the MTHDS protocol's one metho
|
|
|
82
82
|
|
|
83
83
|
- **The tree's own rule is the standard's, not restated here.** `assertOneMethodSource` is imported from `mthds/protocol` and applied to `files` and `tree` on every run and on `validate`, so this client and the MTHDS runners cannot drift on which combinations they reject: both at once, or either holding no file, is refused. A second local copy would be free to disagree about what is legal. The client adds the refusals of its own two forms (two forms at once, `contents` holding no file), the `method_ref` pairings (`assertMethodRefPairsWithNothing` in `client.ts`) and the linkage form's bare `method_id` (`assertLinkageMethodIdIsBare`), because the protocol knows none of them — the wording of the `method_ref` errors mirrors the server's own validator.
|
|
84
84
|
- **An empty `directory` and a `null` member are absent; an empty map is not.** `directory: ""` names no directory and counts as no form, while `files: {}` or `contents: {}` is refused, as the standard refuses a tree holding no file, rather than read as "nothing to run".
|
|
85
|
-
- **`files` / `tree` / `directory` / `contents` / `bundleMain` / `method_ref` / `method_id` are reserved keys on `extra`.** `extra` merges last into the body, so smuggling a run source through it would overwrite the validated fields and bypass the exclusivity check; `buildExtensions` rejects any reserved key — the protocol's own fields, the client's tree forms, the two method selectors, _and_ the client-only `bundleMain` hint. A field this client no longer names, such as a runner's legacy `mthds_contents`, is not reserved, and passes through `extra` to a runner that still reads it. `buildExtensions` also strips prototype-pollution keys (`__proto__` / `constructor` / `prototype`), which `JSON.parse`-sourced `extra` can carry, so the client never emits a pollution gadget on the wire.
|
|
85
|
+
- **`files` / `tree` / `directory` / `contents` / `bundleMain` / `method_ref` / `method_id` / `callback_urls` are reserved keys on `extra`.** `extra` merges last into the body, so smuggling a run source through it would overwrite the validated fields and bypass the exclusivity check; `buildExtensions` rejects any reserved key — the protocol's own fields, the client's tree forms, the two method selectors, the run's webhooks, which travel in their own `webhooks` run part and never count as something to run ([Caller webhooks](#caller-webhooks-callback_urls) below), _and_ the client-only `bundleMain` hint. A field this client no longer names, such as a runner's legacy `mthds_contents`, is not reserved, and passes through `extra` to a runner that still reads it. `buildExtensions` also strips prototype-pollution keys (`__proto__` / `constructor` / `prototype`), which `JSON.parse`-sourced `extra` can carry, so the client never emits a pollution gadget on the wire.
|
|
86
86
|
- **`bundleMain`, `directory` and `contents` are client-only and never serialized as such.** `bundleMain` is an entrypoint hint for a _local_ runner, and the two tree forms become the wire's `files`; the request builders name wire fields explicitly, and each is reserved on `extra`, so none can leak onto the wire by either path.
|
|
87
87
|
|
|
88
88
|
### Pipelex API run extensions (`method_ref`)
|
|
@@ -94,6 +94,10 @@ A run request carries its method as its **tree**, the MTHDS protocol's one metho
|
|
|
94
94
|
- **It rides the blocking fallback too**, like `method_id` — a bare runner reached through `startAndWaitForResult`'s degrade runs the same fetched package.
|
|
95
95
|
- **An empty string is treated as absent**, same as `method_id`.
|
|
96
96
|
|
|
97
|
+
### Caller webhooks (`callback_urls`)
|
|
98
|
+
|
|
99
|
+
`callback_urls` is the second field of `PipelexApiRunExtensions`, beside `method_ref`: the webhook URLs a run calls once it has ended, each with a signed `run.finished` event (the spec `conformance/specs/pipelex-caller-webhooks.md`). It is a layer-2 extension because the hosted API and a self-hosted runner both take it, so it rides the blocking fallback of `startAndWaitForResult` too, and it is reserved on `extra` with `method_ref`. It is no run source, so `runParts` keeps it in its own `webhooks` part rather than in `api`, whose size the "something to run" precondition reads. The client checks only that it is an array of strings and leaves the URLs themselves to the server, whose rules differ between the hosted plane and a runner. The receiver's side lives in `webhooks.ts`. See [webhooks.md](./webhooks.md).
|
|
100
|
+
|
|
97
101
|
### Hosted run extensions (`method_id`)
|
|
98
102
|
|
|
99
103
|
`RunOptions` / `StartOptions` come from `mthds/protocol` and stay pure. This SDK's run methods take `PipelexRunOptions` / `PipelexStartOptions` — those types intersected with `PipelexApiRunExtensions` (above) and `PipelexHostedRunExtensions`, which is where the hosted API's own run arguments live. Today the hosted interface is one field, `method_id`, and the reasoning generalizes to every hosted-only argument that follows:
|
|
@@ -130,6 +134,7 @@ Flat `src/` (mirrors `mthds-js`'s `runners/api` flatness — the SDK has one cli
|
|
|
130
134
|
- `node-host.ts` and `node-host/` — the Node host ([above](#the-node-host)): `loadNodeHost()` and `isNodeRuntime()` in `node-host.ts`, the Node variant `node-host/node.ts`, the only module of the barrel's graph that imports a Node built-in, `undici` or the `mthds` root entry, and `node-host/unavailable.ts`, the variant every other runtime resolves.
|
|
131
135
|
- `version.ts` — `SDK_VERSION`, in its own module so the client stamps it on the `User-Agent` without importing the barrel, which re-exports it.
|
|
132
136
|
- `cli.ts` and `cli/` — the `pipelex-sdk` command, `run` and `script` ([`cli.md`](cli.md)): `cli.ts` is the executable `package.json` declares as the package's one `bin`, handing the process to `cli/main.ts`'s `runCommand`, and `cli/` holds the flags, the bundle reader, the inputs, the environment, the two subcommands and what they print. It sits at the TOP of the graph, the other way round from every other module: nothing imports it, the barrel does not export it, and it reaches the SDK only through `index.ts`, which `dependency-cruiser` enforces in both directions.
|
|
137
|
+
- `webhooks.ts` — the caller-webhook surface that needs no client: the `run.finished` event's types (`RunFinishedEvent`, whose `data` accepts both the protocol's status read and the hosted plane's own, `UnknownWebhookEvent`, `isRunFinishedEvent`), `verifyWebhook`, the receiver's verification of a call by the contract's procedure with Web Crypto, its two errors (`WebhookVerificationError`, `WebhookEventUnreadableError`), and the shapes the delivery-log and secret routes answer. Like `codegen-check.ts`, it imports no Node built-in, so the barrel stays browser-bundleable. See [webhooks.md](./webhooks.md).
|
|
133
138
|
- `index.ts` — the public barrel: `export * from "mthds/protocol"` (single import source for the standard surface) plus the client, models, run types, the standalone pure helpers (`runCodegenCheck`, `summarizeUsage`, `locateArtifacts`, `collectArtifacts`, `artifactFilename`, `parseExactJson`, `stringifyExactJson`), and errors.
|
|
134
139
|
|
|
135
140
|
## Client surface (current)
|
|
@@ -141,7 +146,7 @@ Flat `src/` (mirrors `mthds-js`'s `runners/api` flatness — the SDK has one cli
|
|
|
141
146
|
- **An idempotency key on a run start (hosted):** `start` and `startAndWaitForResult` take `idempotencyKey` (`PipelexStartRequestOptions`, intersected into `PipelexStartOptions`), sent as the `Idempotency-Key` header of `POST /v1/start` and never in the body; with no key there is no header. The hosted API binds the key to the run, answers a repeat of the same start with that run, and refuses the key with other options; a bare runner ignores the header. The client refuses a key that is not 1 to 255 visible ASCII characters with a `RequestArgumentError` before any request, the blocking `execute` fallback of `startAndWaitForResult` sends none, and the key leaves the error's problem as it is: a start that may have reached the server stays `client.outcome_unknown`, and the caller who sent a key recovers the run by sending the same start again under it. [errors.md](./errors.md#a-starts-idempotency-key--idempotencykey) holds the contract.
|
|
142
147
|
- **Method selectors on `validate`:** the first argument is a `PipelexValidateSource` — the method's tree in one of its four forms, `{ method_ref }` (runner-resolved by address) or `{ method_id }` (hosted, platform-resolved) — under the strict tooling XOR: exactly one, no linkage exception, refused with a `RequestArgumentError` before any request. Its options (`ValidateOptions`: `render`, `views`, `timeoutMs`, `signal`) ride the third argument. A selector-resolution failure is a non-2xx `ApiResponseError`, never an `is_valid: false` verdict.
|
|
143
148
|
- **The files a step reads ride the tree.** A `PipeDocGen` step's `template_file` is a file of the method's tree like any other, at the tree path the step names; the separate `resources` field is gone from every route. The valid verdict's `warnings` carry the template check's findings as problem items, located by the template file they name, and its optional `tree_files_read` and `tree_file_readers` name the files of the method's tree each step reads, whatever form the method was sent in ([which step reads which file](./method-trees.md#which-step-reads-which-file)). See [method-trees.md](./method-trees.md#the-files-a-step-reads).
|
|
144
|
-
- **Crate routes (`/v1/resolve`, `/v1/codegen`, `/v1/pipe-io`):** `resolve` emits the **normalized library crate** (the MTHDS standard's Library Crate Format, fingerprint riding inside the payload); `codegen` projects that crate through two explicit axes — `kind` (`types`) × `target` (`ts-zod` / `python-pydantic` / `python-structures`) — into stamped artifacts plus their `codegen.lock`. All three share one closure envelope (the method's tree, in one of the four forms a run takes, XOR a `method_ref`, the address form server-resolved and the registry form a `
|
|
149
|
+
- **Crate routes (`/v1/resolve`, `/v1/codegen`, `/v1/pipe-io`):** `resolve` emits the **normalized library crate** (the MTHDS standard's Library Crate Format, fingerprint riding inside the payload); `codegen` projects that crate through two explicit axes — `kind` (`types`) × `target` (`ts-zod` / `python-pydantic` / `python-structures`) — into stamped artifacts plus their `codegen.lock`. All three share one closure envelope (the method's tree, in one of the four forms a run takes, XOR a `method_ref`, the address form server-resolved and the registry form a `422` `request.unsupported`) and return a **discriminated 200 verdict** (`is_valid`, with `CrateInvalidReport` as the shared invalid arm) rather than a bare payload, the same discipline as `validate`. They also take the hosted `method_id` (`PipelexHostedToolingExtensions`) as a third closure form under the strict three-way XOR — a pass-through the platform resolves, meaningless against a bare runner. Writing the artifacts and lock verbatim reproduces a local `pipelex codegen types` run byte-for-byte, so the offline `pipelex codegen check` passes — the SDK stays transport-only and does not write files. Verifying such a tree afterwards is `runCodegenCheck`, a standalone pure helper rather than a client method, because it needs no server at all. `pipeIo` derives a method's three I/O artifacts — the standard's `PipeIOContracts`, `InputForm` and `OutputForm`, imported from `mthds/protocol` — for the pipe the route selects (the request's qualified `pipe_ref`, else the method's own entry pipe) or for every pipe under `all_pipes`, with the runnability facts beside them and the closure's files echoed on `include_files`, and it runs no dry run; a selection it refuses is a `422` problem coded `request.pipe_not_found`, `request.pipe_unspecified` or `request.pipe_ambiguous`. See [crate-routes.md](./crate-routes.md).
|
|
145
150
|
- **Model reference check (`/v1/models/check`):** `checkModelReference(reference, category?)`, a Pipelex API extension served by any `pipelex-api` runner from pipelex 0.78.0 and on the hosted API, whose platform relays it byte-identically. It is a **diagnostic endpoint** like `validate`: a reference that resolves nowhere is a `200` verdict with `resolution: "not_found"` and the names the caller may have meant (`suggestions`, `other_kinds`, `other_categories`), never a thrown error. Only a request that cannot produce a verdict throws `ApiResponseError`, a `422` `request.invalid` whose `location.field` names the parameter it refused: `reference` for a reference that is missing, repeated, blank, a sigil or a namespace alone, or past the runner's length limit, and `type` for a category that is repeated, empty or unknown. The SDK validates none of it, so the runner's rule is the only one. `ModelReferenceVerdict` is a union of one arm per reference kind, discriminated on the wire's own `kind` (`PresetReferenceVerdict`, `AliasReferenceVerdict`, `WaterfallReferenceVerdict`, `HandleReferenceVerdict`): a `matches` entry carries no kind of its own, but every entry of one verdict shares the verdict's, so narrowing on `kind` types `matches` as `PresetMatch[]`, `AliasMatch[]`, `WaterfallMatch[]` or `HandleMatch[]` without a field the wire does not carry. `ModelCheckCategory` is the protocol's model categories plus `doc_gen`, the family of `PipeDocGen`, which the protocol and so `models` leave out but a method's `model` field can name. `resolution` is typed open, `ModelReferenceResolution | (string & {})`, under the spec's reader rule that a client reading a value it does not know treats the reference as unresolved, and so is every category a verdict names, `ModelCheckCategory | (string & {})` as `mthds` types `ModelInfo.type`, since a runner of a later version may add one; the `category` a request names stays closed. A match's `resolves_to` is `null` when the reference resolves by name but a run through it would reach no model the runner can call, which a consumer shows as a warning rather than as an unknown name.
|
|
146
151
|
- **Tools (`/v1/lint`, `/v1/format`):** `lint`, `format` — single-file static diagnostics and canonical formatting, served by any pipelex-api runner (no inference, no bundle load). Both are **diagnostic endpoints** like `validate`: a malformed `.mthds` file is a produced verdict on a **200** carrying `diagnostics[]`, never a thrown error — `format` additionally echoes the content back unchanged with `changed: false`. Only a no-verdict condition is non-2xx: a malformed body, **malformed formatter `options`** (e.g. a non-numeric `column_width` ⇒ 422), auth, or a server fault, all mapped to the typed `ApiResponseError`. `lint` is the cheap static check (syntax / schema / semantic); it does not replace `validate`, which loads the bundle, resolves across files, and dry-runs the pipes.
|
|
147
152
|
- **Durable run lifecycle (hosted extension — NOT protocol):** `getRunStatus`, `getRunProgress` (`GET /v1/runs/{id}/progress`, the run's progress snapshot, see [run-results.md](./run-results.md#following-a-run-while-it-runs--onprogress-and-getrunprogress)), `getRunResult` (with an optional `artifacts` selection sent as `?artifacts=`, see [run-results.md](./run-results.md#reading-only-some-artifacts--artifacts)), `waitForResult` (whose `onProgress` reads the progress route on each poll that finds the run running and delivers each fresher snapshot by `isFresherRunProgress`, a failed progress read never failing the wait), `startAndWaitForResult` (handshakes `/v1/version`, takes the durable start+poll path on a hosted deployment, and self-heals to the blocking `execute` against a bare runner). The handshake's outcome is cached for the client's lifetime, under the rule the Python SDK follows too: an answer that is not a usable version assumes hosted and lets the start surface the real error, while no answer at all throws its `ApiUnreachableError` at once, caches nothing and sends no start. An answer is anything the server sent back: an error status, a body that is not JSON, not UTF-8 or no version, and a body that arrived but could not be decoded, such as a broken gzip stream, which fetch fails with the decompressor's code (`Z_DATA_ERROR` and the other zlib codes, brotli's `ERR__ERROR_…`, zstd's `ZSTD_error_…`). A run that ended without completing answers the results read with a `409` `run.failed` problem document, naming the terminal status as its `run_status` parameter and nesting the run's own problem as `run_error`; `getRunResult` turns it into the `failed` arm (`status`, `message` from `detail`, `problem`, `error`) and the waiting calls on the durable path into a `RunFailedError` carrying the same (the blocking fallback's failure is the runner's `ApiResponseError`). The status read serves the run's own problem as `RunRead.error`. See [errors.md](./errors.md#a-failed-run--runfailederror). The status read also carries `degraded` and the `degradation` that explains it, whose `reason` tells a status the platform could not refresh (`status_unavailable`) from a run whose workflow task keeps failing until the platform ends it (`workflow_task_failing`), checked field by field so it never fails the poll, and the run's `request_id`, the id to quote to support. See [run-results.md](./run-results.md#a-degraded-status--degraded-and-degradation). Reading what a completed run consumed is `summarizeUsage(results)`, a standalone pure helper rather than a client method, because it folds the usage pair the results already carry and needs no server. See [run-usage.md](./run-usage.md#summarizing-a-run--summarizeusage).
|
|
@@ -154,9 +159,10 @@ The hosted catalog/account routes the webapp drives. All ride the same `{base}/v
|
|
|
154
159
|
- **User profile:** `getMe` (`GET /v1/me`).
|
|
155
160
|
- **Methods catalog:** `listMethods`, `getMethod`, `createMethod`, `writeDraft`, `renameMethod`, `publishMethod`, `listMethodVersions`, `getMethodVersion`, `deleteMethod`. **A saved method has a draft and immutable published versions.** Its draft is a method tree with its `input_data`, which `writeDraft` (`PUT /v1/methods/{id}/draft`) replaces whole, without validating it; `updated_at` is the draft's token, which moves on every draft write and nothing else, and `draft_digest` the draft's tree digest (`sha256:` and 64 hex digits), `null` for an empty draft. A save takes the tree as `files`, sent as given, or as `directory`, `tree` or `contents`, sent inline or by hash after uploading the blobs the organization lacks, and a read lists the tree's files, each with a short-lived link to its bytes, which `writeMethodTree` writes to disk ([method-trees.md](./method-trees.md)). A draft write may carry `expected_updated_at`, the token the caller last saw, and is then a compare-and-swap: a draft that moved since is a `409` `pipelex.catalog.update_conflict` and nothing is written. `renameMethod` (`PATCH /v1/methods/{id}`) changes the name alone and moves no token. `publishMethod` (`POST /v1/methods/{id}/publish`) takes the required `expected_draft_updated_at` and answers a `MethodPublishResult` discriminated on `outcome` — `published` with the new version, `unchanged` with the existing latest version when the draft's digest equals it, `refused` with a `reason` (`invalid` or `not_runnable`), a `message` and the runner's `validation` verdict, its problem items read member by member as `validate` reads them — every arm carrying the `method`; the SDK reads the nested method as `getMethod` does, and an answer whose `outcome` is none of these, or a refusal whose `validation` is not a verdict object, is an answer it cannot read. A publish that produced no verdict is thrown rather than answered: only a draft that differs from the latest version reaches the runner, and a runner that cannot be reached, answers unusably or does not finish within the platform's deadline is a `502` or `503` with nothing written, so a retry is safe, and a retry of a publish that landed while its answer was lost answers `unchanged` with the version it wrote. A method is stored as one item that keeps room for the summary a publish adds, so a draft that saves can always be published: a creation or a draft write is a `413` `request.too_large` when it would grow the method past that room, below the store's item limit, and a rename is the same `413` for a name so long it would leave the method too large to publish. A version is numbered from 1, never reused, and holds the draft's tree as it stood, its `tree_digest` (`null` for a version stored before methods were trees), its `crate_fingerprint` (`null` when the runner could not resolve the bundle in memory), the runner's version, and when and by whom it was published; `listMethodVersions` pages the summaries newest first (`MethodVersionPage`, `{items, nextCursor}`, a `limit` from 1 to 100, 20 by default, and a `422` outside that range), and `getMethodVersion` reads one with its `files` listed as a method's are, or answers a `500` `runtime.unexpected` for a version stored before methods were trees and never rewritten as one. A method read carries `latest_version` and `latest_published`, both `null` until the first publish, so the publish state is derived: never published, draft unchanged (`draft_digest` not `null` and equal to `latest_published.tree_digest`), or draft ahead. The method routes take a bare id and address the method itself, never one of its versions; the platform does not parse a suffix there, so `mt_x@3` would answer a `404` that reads as a method that does not exist, and each route refuses a suffixed id with `RequestArgumentError` before anything is sent, naming `parseMethodSelector` and `getMethodVersion`. The whole-method `updateMethod` is gone, replaced by the draft write and the rename. **`deleteMethod` is asynchronous and its return type says so:** `DELETE /v1/methods/{id}` answers `202` with a `MethodDeletionAccepted` (`method_id`, `deletion_state`, `deletion_job_id`) the moment the platform has claimed the method and terminated its in-flight workflows — the rest of the cascade (runs, events, S3 objects) is enqueued. A resolved promise means "accepted", never "gone"; completion is the row disappearing from `listMethods`, not any field of that body. The claim is a conditional write, so a double-clicked delete is a `409` `pipelex.catalog.being_deleted` rather than a second cascade over the same runs, and an unknown or foreign-org id is a `404`. The erasure deletes the method's versions with the rest. (This doc previously stated the SDK had no `delete` because the platform had no such route — the route has always existed; the claim was simply false.) `listMethods` returns **one page**, not the whole catalog — a `MethodPage` (`{items, nextCursor}`) served from a narrow index projection, so its cost is the page size rather than the size of the catalog. It used to return every method with its full `.mthds` bundle attached, which hit DynamoDB's 1 MB page cap at a couple hundred methods and came back truncated with no error and no flag, so methods silently vanished from the UI. Rows are therefore `MethodSummary`, **not** `MethodDetail`: no `files`, no `updated_at`, because neither is in the projection and putting the source back is what restored the truncation. Reach for `getMethod(id)` when you need a method's files. `query.q` is applied server-side across the whole catalog, not over the page — filtering one page client-side would be searching 50 rows of 10,000 and calling it a search. `query.published` filters the same way, sent as `published=true` (the methods with a published version) or `published=false` (those never published), and combines with `q`. Each row carries the method's publish state, so a list tells its methods apart without reading each one: `latest_version` and `latest_published` (a `MethodVersionSummary`), both `null` when the method was never published, and `draft_digest`, `null` for an empty draft; all three are optional, absent from a platform that does not serve them. Never published is `latest_version === null`, the draft unchanged is a `draft_digest` that is not `null` and equals `latest_published.tree_digest`, and the draft ahead is two digests that differ. Both filters run after the index read, so **a filtered page can come back empty and still carry a `nextCursor`**: an empty page is not the end of the list, and only a `null` cursor is. `iterateMethods` is the async iterator that follows the cursor for callers who genuinely want everything (`for await (const m of client.iterateMethods())`); it keeps going **past empty pages**, since `{items: [], nextCursor: "…"}` means "nothing matched in the slice I just read, keep going". A server that keeps minting fresh cursors forever stops it at a page ceiling far past any real catalog with a `PagingNotTerminatingError` carrying `pageLimit`, never a silently truncated stream; `iterateRuns` shares the same loop. It gives up only when the server stops advancing its cursor, and **throws** rather than returning a partial answer; like `iterateRuns` it is deliberately not a `listAllMethods(): Promise<MethodSummary[]>`, because an all-at-once helper needs a page cap and a cap means silently returning a truncated list. `MethodSummary.deletion_state` rides the list rows: a method mid-erasure stays listed so the UI can render it as "Deleting…", while `getMethod` refuses it with a 409. Full read responses (`MethodDetail`, from `getMethod`) carry `org_id`, `created_by_user_id`, a server-derived `description` (recomputed from the draft's top-level `description` on every draft write), the draft token and digest, the latest version's summary and the draft's `files` on top of the write contract's `name` / `input_data`; the writes and the rename answer `MethodData`, the same method without `files`.
|
|
156
161
|
- **Method examples:** `createMethodExample`, `listMethodExamples`, `iterateMethodExamples`, `getMethodExample`, `updateMethodExample`, `recordMethodExampleLatestRun`, `saveAssistantMethodExample`, `deleteMethodExample`, on `/v1/methods/{id}/examples`. An example is a named set of inputs saved on a method, shared across the organization, with an `input_revision` that rises each time its inputs change and the `latest_run_id` of those inputs, which an inputs edit clears. `recordMethodExampleLatestRun` records a run against the revision whose inputs it used and answers a `MethodExampleLatestRunResult` discriminated on `outcome`, `recorded` or `stale`, each arm carrying the `example`: a run that outlasted an edit is a verdict, never thrown, and an answer off the union is one the SDK cannot read; `saveAssistantMethodExample` is the build assistant's save, which writes into the example it names only while that example is still the assistant's (`kept_by_assistant`) at the revision it read, and otherwise creates one, answering a `MethodExampleAssistantSaveResult` discriminated on `outcome`, `kept` or `created`; one helper, `readVerdict`, reads these verdicts and `publishMethod`'s, the example checked once, by the reader every example goes through. Every answer carrying an example is read with `parseExactJson` and every request body written with `stringifyExactJson`, so an integer past `Number.MAX_SAFE_INTEGER` in the inputs reads as a `bigint` and goes back digit for digit, to an edit or as a run's inputs; `3.0` still reads as `3`, and inputs holding one sent back unchanged are an edit for the platform, which compares inputs as canonical JSON. Every example the client hands back, the page rows and the verdict's `example` included, is checked by `readMethodExample` against the fields the Python SDK's model requires, with the strict types it holds them to, an example breaking one being an answer the SDK cannot read. The list is paged oldest first by an opaque cursor, and a page may be short while `nextCursor` is set; `iterateMethodExamples` follows it through the loop `iterateMethods` and `iterateRuns` share, and refuses a method id at the call rather than at the first pull. Every method takes a bare catalog id, and the client refuses an example id that is empty, `.` or `..` or holds a lone surrogate, a create without its name or inputs or whose `run_id` is given but empty, an edit naming neither `name` nor `inputs`, and a latest-run write without a run id or a positive integer revision before sending. See [method-examples.md](./method-examples.md).
|
|
157
|
-
- **Method copies:** `copyMethod`,
|
|
162
|
+
- **Method copies:** `copyMethod`, on `/v1/methods/{id}/copy`. `copyMethod` makes a new, unpublished method in an organization the caller may change methods in, the method's own for a duplicate, holding the original's draft tree, its form inputs and its examples, the stored files they name copied into the destination, and answers a `MethodCopyResult` discriminated on `outcome`: `copied` with the copy as `method` and what was copied, or `refused` with the `references` of a bundle naming storage the destination cannot read or, in a duplicate, a file under the original's run files, which erasing the original would remove. The two arms carry different members, so the verdict is read by the copy itself rather than by `readVerdict`, which reads verdicts whose arms share one: an `outcome` off the union, a `copied` answer whose `method` is not an object and a `refused` one whose `references` is not an array are each an answer the SDK cannot read. Its body is closed, `destination_org_id` with `name` and `expected_draft_updated_at` when given, and its last argument takes an `idempotencyKey`, sent as the `Idempotency-Key` header through the same `requestRaw` option and the same `idempotencyKeyOf` check a start uses. Every method read carries `copied_from`, the original a copy names, `null` on a method created afresh. `MethodErrorCode` names the copy's own codes, `pipelex.org.not_found` and `pipelex.catalog.copy_too_large`, whose `limit` member is typed `MethodCopyLimit`. It takes a bare catalog id. See [method-copy.md](./method-copy.md).
|
|
158
163
|
- **Organizations:** `listMemberships`, `createOrganization`, `renameOrganization` (`PATCH`). Each membership carries the caller's `role_in_org`, an `OrgRole` (`admin`, `member` or `runner`) read open: a role a later platform adds arrives as its raw string, so a `switch` over it says what an unknown role does. The role is for display; what it may do is the platform's to decide, and a call refused to the caller's role is an `ApiResponseError`. The org "switch" is a WorkOS-session op (`/api/active-org`), not a `/v1` route — out of SDK scope.
|
|
159
164
|
- **Organization branding:** `getBranding` (`GET /v1/branding`, any role, web session or API key) and `updateOrganizationBranding` (`PATCH /v1/organizations/{org_id}/branding`, an admin's write) answer the same `OrganizationBranding`: the organization's name, its light and dark colour sets (`BrandingColors`) and its square and wide logos (`LogoDescriptor`). The write's `OrganizationBrandingUpdate` sets a key given a value, clears a key given `null` and keeps a key left out; a refused logo or colour is named in `ApiResponseError.errors`, by slot or by dotted key, a logo the platform measured with a `LogoRefusalCode`. See [organization-branding.md](./organization-branding.md).
|
|
165
|
+
- **Caller webhooks:** `listRunWebhookDeliveries`, `getRunWebhookDelivery` and `replayRunWebhookDelivery`, on `/v1/runs/{id}/webhook-deliveries`, `/v1/runs/{id}/webhook-deliveries/{delivery_id}` and its `/replay`, read a run's delivery log and send a settled delivery again, any member by web session or API key, the replay taking an `idempotencyKey` under which the platform stores its answer; `getWebhookSecret`, on `GET /v1/organizations/{org_id}/webhook-secret`, and `rotateWebhookSecret`, on `POST /v1/organizations/{org_id}/webhook-secret/rotate`, read and rotate the organization's signing secret, an admin's routes from a web session only, the rotation taking an `idempotencyKey` the platform never replays. See [webhooks.md](./webhooks.md).
|
|
160
166
|
- **Billing:** `getSubscription`, `listPlans`, `listInvoices`, `createCheckout`, `changePlan` (409 `conflict` ⇒ no subscription), `getBillingPortal` (409 `conflict` ⇒ no subscription).
|
|
161
167
|
- **Pipelex API keys (`plx_sk_…`):** `listPipelexApiKeys`, `createPipelexApiKey` (409 `pipelex_api_key_limit_reached`), `revokePipelexApiKey`, `rotatePipelexApiKey`. The plaintext `api_key` is returned once, on create/rotate.
|
|
162
168
|
- **Onboarding:** `submitOnboarding`.
|
|
@@ -177,4 +183,4 @@ Two layers, split by whether a server is in the loop:
|
|
|
177
183
|
- **Unit suites (`tests/*.test.ts`, `make test`):** mock the fetch boundary (`vi.spyOn(globalThis, "fetch")`) and pin the client's wire behavior — URLs, request bodies, status→error mapping, poll semantics. These run everywhere, including CI. The codegen-check suite is the exception that proves the rule: it has no server to mock, so it runs against **vendored real codegen output** under `tests/fixtures/codegen/` — artifacts and lock emitted by an actual `pipelex codegen types` run, committed beside the `.mthds` they came from. A hand-written stamp would only prove the port agrees with itself. The artifacts are vendored with a trailing `.txt` and their real names restored at load time, because `format:check`, `lint`, and `typecheck:test` all glob `tests/**/*.ts` — and a single reformatted byte would invalidate the very hash the fixture exists to pin.
|
|
178
184
|
- **The command's case table (`tests/cli.test.ts`):** runs every case of `tests/fixtures/cli-cases.json` through the `pipelex-sdk` command in-process, in a fresh working directory, with `fetch` answering from the case's recorded routes, so the command runs on the real client. The table is written for the Python SDK's command of the same name to run too; [`cli.md`](cli.md) describes the format.
|
|
179
185
|
- **The packed command (`make test-pack`):** packs the package with `npm pack` and runs `pipelex-sdk --help` and `--version` from the tarball through `npx`, from a directory outside the package. It needs the registry for the dependencies, so CI's `js (make all)` job runs it and `make test` does not.
|
|
180
|
-
- **E2E suites (`tests/e2e/*.e2e.ts`, `make test-e2e`):** no mocks — they drive a **live pipelex-api server** (`PIPELEX_E2E_BASE_URL`, default `http://localhost:8081`) and verify the diagnostic-endpoint contract end-to-end with correct and malformed MTHDS bundles: clean content lints with zero diagnostics and formats to itself (idempotently), malformed content comes back as a 200 verdict (`diagnostics[]` on lint; `diagnostics[]` plus the content echoed back unchanged with `changed: false` on format; `is_valid: false` + `errors[]` on validate), and malformed formatter options surface as a 422 `ApiResponseError`. The crate suite adds the case only a live server can settle: the `kind` / `target` vocabulary is a mirror of a Python `StrEnum` in another repo, so nothing but a real call proves the two still agree — and it cross-checks that `codegen`'s `crate_fingerprint` equals the `fingerprint` `resolve` returns for the same closure. It then runs `runCodegenCheck` over the artifacts and lock the server just emitted — verbatim first (current, with the lock header's fingerprint matching the response's), then mutated in memory once per drift category, for a `.ts` target and a `.py` one. The unit suite pins the port against vendored bytes; only this pins it against the stamp and lock bytes the server writes **today**, which is the pairing that matters when a consumer commits the server's artifacts and gates CI on the SDK's verdict about them. The same suite drives `pipeIo` across all three selectors: the artifacts under the resolved ref, their equality with `validate`'s views for the same closure, the `include_files` echo, `all_pipes` on a method with no entry pipe, a `method_ref` package's manifest entry pipe, the invalid arm, and the
|
|
186
|
+
- **E2E suites (`tests/e2e/*.e2e.ts`, `make test-e2e`):** no mocks — they drive a **live pipelex-api server** (`PIPELEX_E2E_BASE_URL`, default `http://localhost:8081`) and verify the diagnostic-endpoint contract end-to-end with correct and malformed MTHDS bundles: clean content lints with zero diagnostics and formats to itself (idempotently), malformed content comes back as a 200 verdict (`diagnostics[]` on lint; `diagnostics[]` plus the content echoed back unchanged with `changed: false` on format; `is_valid: false` + `errors[]` on validate), and malformed formatter options surface as a 422 `ApiResponseError`. The crate suite adds the case only a live server can settle: the `kind` / `target` vocabulary is a mirror of a Python `StrEnum` in another repo, so nothing but a real call proves the two still agree — and it cross-checks that `codegen`'s `crate_fingerprint` equals the `fingerprint` `resolve` returns for the same closure. It then runs `runCodegenCheck` over the artifacts and lock the server just emitted — verbatim first (current, with the lock header's fingerprint matching the response's), then mutated in memory once per drift category, for a `.ts` target and a `.py` one. The unit suite pins the port against vendored bytes; only this pins it against the stamp and lock bytes the server writes **today**, which is the pairing that matters when a consumer commits the server's artifacts and gates CI on the SDK's verdict about them. The same suite drives `pipeIo` across all three selectors: the artifacts under the resolved ref, their equality with `validate`'s views for the same closure, the `include_files` echo, `all_pipes` on a method with no entry pipe, a `method_ref` package's manifest entry pipe, the invalid arm, the selection `422` coded `request.pipe_not_found` and naming the pipe in its `params`, and a registry-form `method_ref`'s `422 request.unsupported`, which `resolve` answers too. Its `method_id` case, like the `prepareInputs` suite's, runs only when `PIPELEX_E2E_METHOD_ID` names a stored method the key's organization owns, since only a hosted origin can resolve one, and is skipped otherwise. The distinct `.e2e.ts` suffix + `vitest.e2e.config.ts` keep them out of the default `vitest run`, so `make test` / CI never require a server. An unreachable server is an error, never a silent skip, and it is reported once: the suite's global setup (`tests/e2e/global-setup.ts`) probes the target a single time and stops the run with a one-line message, instead of letting every suite fail in `beforeAll` and print a socket-error dump each. It probes only after the account check below went ahead, so a run the check refuses sends no request at all, and `make test-e2e` sends none before it. The per-suite `beforeAll` guard stays as the backstop. Both probe **`/v1/version`**, deliberately: a bare runner serves the origin-level `/health` and a hosted origin does not (it answers `/v1/health` instead), so `/v1/version` is the one unauthenticated route both answer — probing anything else reports a perfectly reachable hosted origin as down. The target itself is resolved once, in the Makefile, and exported, so the URL the global setup checks and probes is always the URL the suites call: the shell environment (or a `make test-e2e PIPELEX_E2E_BASE_URL=...` override) wins, then `.env`, then `http://localhost:8081`. The global setup prints the resolved target before the first case, since `.env` can supply it invisibly, as its scheme and host alone. The key is never read from `.env`: against a hosted origin the suites act for the registered test account, QA Builder in QA SDK (dev), whose key the workspace's `test-account run` hands over beside what the registry expects of it, and the suite's global setup, `tests/e2e/live-account.ts`, compares `GET /v1/me` with those expectations before the first case, refuses the production API unless `PIPELEX_E2E_ALLOW_PRODUCTION=1` asks for it, and says nothing is checked against a loopback runner ([`docs/live-accounts.md`](https://github.com/Pipelex/pipelex-sdk/blob/main/docs/live-accounts.md) at the repository's root, which the package does not ship). A hosted origin serves every route the suite calls but `lint` and `format`, which it does not expose (gateway `403`), so their cases need a bare runner and skip on a hosted origin, as the `method_id` cases skip without a stored method: `tests/e2e/origin.ts` holds the one predicate every file asks, `isHostedOrigin`, which reads any origin whose host is not one of this machine's loopback spellings (`localhost`, a subdomain of it, `127.x.x.x` or `::1`) as a hosted plane. A local runner runs them. The upload-grant suite needs the same hosted platform: it requests a grant, sends a small PDF with `uploadWithGrant`, reads the bytes back equal through `fetchArtifact`, and proves the refusals the error mapping reads into a `RejectedAssetError`'s `reason` — `grant_used` for the `412` of a used grant, `signature_mismatch` for the `403 SignatureDoesNotMatch` of a file one byte longer than declared, `store_refused` for the `400 BadDigest` of other bytes than the hash named — and the platform's `413 request.too_large` for a declared size over the cap, its `params` naming the cap and the size. It runs in Node, whose `fetch` sends no CORS preflight, so the bucket's CORS rule is proven by a page, not by it. The artifact suite is the mirror image: it uploads a file with `prepareInputs`, runs a pass-through and brings the file back down with `downloadArtifacts` over `working_memory`, bytes equal, so it needs a hosted platform carrying `POST /v1/pipe-io` (which `prepareInputs` reads), the upload, run-lifecycle and bulk resolve routes, and a key for it, and it fails honestly rather than skipping against a bare runner. The problems suite proves that the problem on a platform refusal is the one the server sent: an `ApiResponseError` reads a problem document as received, and builds one from the status by the edge's rules when the body is none, so each case asserts which it was against `responseBody`. A run of a method id the catalog does not hold and a request body the platform's own validation refuses are the platform's problems, the second a `request.invalid` whose `location` and `params` name the field, with no `errors`, since a problem with a single fault carries none. A rejected API key is refused before the platform sees it, by the gateway's authorizer, with a bare `403 {"message":"Forbidden"}`, so its problem is the SDK's reading of the status, `auth.invalid_credential`. It needs a hosted platform and a key for it, and fails honestly against a bare runner.
|
package/docs/crate-routes.md
CHANGED
|
@@ -6,7 +6,7 @@ Three routes project a **closure** of MTHDS files into the artifacts downstream
|
|
|
6
6
|
>
|
|
7
7
|
> Measured 2026-08-23 against `api.pipelex.com` (`pipelex-hosted@0.10.1`) with a real API key: an empty body to either route comes back as an RFC 7807 `422` naming the missing fields — a request-shape verdict only a route that reached the service can produce — and a real closure comes back as a `200` carrying the artifacts. `api-dev.pipelex.com` has served them since 2026-08-13. Use a key when re-measuring: unauthenticated, every path answers `401` whether or not it is allowlisted, so a keyless probe cannot tell the two states apart.
|
|
8
8
|
>
|
|
9
|
-
> **`pipe-io` is newer.** A runner serves it from `pipelex-api` v0.33.0, and codes its pipe-selection refusals `request.pipe_not_found`, `request.pipe_unspecified` and `request.pipe_ambiguous` from v0.90.0, which is what `prepareInputs` branches on (see [What throws](#what-throws)).
|
|
9
|
+
> **`pipe-io` is newer.** A runner serves it from `pipelex-api` v0.33.0, and codes its pipe-selection refusals `request.pipe_not_found`, `request.pipe_unspecified` and `request.pipe_ambiguous` from v0.90.0, which is what `prepareInputs` branches on (see [What throws](#what-throws)). The platform's tooling proxy and the gateway's allowlist list it too, and the dev plane serves it (measured on `api-dev.pipelex.com` on 2026-10-31). `prepareInputs` reads this route, so it needs an origin that serves it.
|
|
10
10
|
>
|
|
11
11
|
> **`lint` / `format` are the exception and answer `403` on both origins.** That blocks nothing, because linting and formatting `.mthds` are toolchain capabilities rather than hosted ones: `plxt` carries both, and the post-edit hook this repo builds (`npm run build:hook`, vendored into `pipelex-plugins`) runs them offline through `@pipelex/tools-wasm` with no credentials — from the published package, `client.lint` / `client.format` are the documented fallback, against a runner. Exposing them on the hosted origins is a known, non-critical item on the platform's list, tracked in the workspace ledger as L-260929-b58f26.
|
|
12
12
|
|
|
@@ -21,7 +21,7 @@ interface CrateRequestBase {
|
|
|
21
21
|
tree?: MethodTreeSource; // or a listed tree, sent inline or by hash
|
|
22
22
|
contents?: Readonly<Record<string, Uint8Array | string>>; // or each file's bytes or text by tree path
|
|
23
23
|
dependencies?: Record<string, MethodFiles>; // beside the tree: the packages it calls by address
|
|
24
|
-
method_ref?: string; // address form server-resolved; registry form reserved →
|
|
24
|
+
method_ref?: string; // address form server-resolved; registry form reserved → 422 request.unsupported
|
|
25
25
|
read_scope?: string; // Pipelex-API extension; the hosted platform stamps its own
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -49,7 +49,7 @@ When the engine can attribute a fault to a file, the `errors[]` problem item's `
|
|
|
49
49
|
|
|
50
50
|
`dependencies` carries the method packages the tree calls by address, each a `files` map keyed by its address, posted as given. They are loaded beside the method, never merged into it, so a cross-package call resolves without the server fetching anything. A call into a package nothing shipped makes the closure invalid, a `200` verdict whose `unresolved_package_dependency` item names the package; `dependencies` without the tree is the server's `422`.
|
|
51
51
|
|
|
52
|
-
An **address-form** `method_ref` (`github.com/<owner>/<repo>[/<selector>][@<tag>]`) is resolved by the server through the same fetch path as a `method_ref` run: the package's whole tree is read from the commit at the tag and loaded as a sent tree is, with the packages it ships under its own `.mthds/methods/` beside it. Any non-address reference stays reserved and
|
|
52
|
+
An **address-form** `method_ref` (`github.com/<owner>/<repo>[/<selector>][@<tag>]`) is resolved by the server through the same fetch path as a `method_ref` run: the package's whole tree is read from the commit at the tag and loaded as a sent tree is, with the packages it ships under its own `.mthds/methods/` beside it. Any non-address reference stays reserved until a method registry exists: the server refuses it with a `422` `request.unsupported` whose `params.reason` is `registry_form_method_ref` and whose `location.field` is `method_ref`, on a runner from `pipelex-api` v0.90.0, which answered `501` before, and on the hosted plane, which relays the runner's refusal.
|
|
53
53
|
|
|
54
54
|
`method_id` is the hosted platform's selector, and it is a **pass-through**: nothing is expanded client-side — the platform resolves the id against the org's catalog and injects the stored tree as `files` before the runner sees the request. A bare `mt_…` names the method's latest published version, `mt_…@<n>` the fixed version `n` and `mt_…@draft` its draft, and the suffix rides the string untouched. It is meaningless against a bare runner (no catalog). An unknown or foreign-org id is a `404` `pipelex.catalog.not_found` (indistinguishable by design), as is a version the method never published; a bare id of a method never published is a `409` `pipelex.catalog.not_published`, whose detail names `mt_…@draft`; a malformed suffix is a `422`; a stored method with no MTHDS source is a `422`.
|
|
55
55
|
|
|
@@ -158,10 +158,10 @@ Only a **no-verdict** condition, as the typed `ApiResponseError` — branch on i
|
|
|
158
158
|
| ------------- | --------------------------------------------------------------------------------------------------------- |
|
|
159
159
|
| `422` | Request shape, `request.invalid` with its `location.field`: no closure selector or more than one, `dependencies` without the tree, an unknown `kind`/`target`, a `pipe_ref` on `kind: "types"`, a `method_id` whose version suffix is malformed; and a fetched package with no `.mthds` file or a stored method with no MTHDS source. |
|
|
160
160
|
| `422` | The tree's refusals, as on `validate`: `method.package_invalid` for a key, an entry or a size the tree's rule refuses (a `files` that is not a map among them), `request.blobs_missing` for a hash the server does not hold, and a tree holding no `.mthds` bundle. |
|
|
161
|
-
| `422` | On `pipeIo`, a pipe selection the route refuses: `request.pipe_not_found` for a `pipe_ref` that names no pipe, `request.pipe_unspecified` for a method with no entry pipe, `request.pipe_ambiguous` for a code that matches pipes in several domains
|
|
161
|
+
| `422` | On `pipeIo`, a pipe selection the route refuses: `request.pipe_not_found` for a `pipe_ref` that names no pipe, `request.pipe_unspecified` for a method with no entry pipe or, with no manifest, several bundles' `main_pipe` declarations, `request.pipe_ambiguous` for a bare code that matches pipes in several domains. Its `params` name the pipe as `pipe` and the candidates as `candidates` where there are some; its `detail` is the registry's sentence for the code, which names no candidate for `request.pipe_unspecified`. A manifest `main_pipe` that no bundle or several define is the method's own fault instead, `method.pipe_unresolved` or `method.ref_ambiguous`. |
|
|
162
162
|
| `404` | `pipelex.catalog.not_found`: an unknown or foreign-org `method_id` (indistinguishable by design), or a `mt_…@<n>` naming a version the method never published; no package at a `method_ref` address. |
|
|
163
163
|
| `409` | `pipelex.catalog.not_published`: a bare `method_id` of a method never published; `mt_…@draft` reaches its draft. |
|
|
164
|
-
| `
|
|
164
|
+
| `422` | `request.unsupported`: a registry-form `method_ref`, reserved until a method registry exists (the address form resolves). A runner older than `pipelex-api` v0.90.0 answers it `501`. |
|
|
165
165
|
| `401` / `403` | Auth, or on a hosted origin a route the gateway does not list yet (`403 {"message":"Forbidden"}`). |
|
|
166
166
|
| `403` | A tree holding a `.py` file on a deployment that is not sandbox-hosted, one whose Python declares a structure class on one that is, or a `read_scope` a `jwt` caller may not read under. |
|
|
167
167
|
| `502` | On a hosted origin, a cold `method_ref` clone that outran the gateway's 30-second cap; a retry clears it. |
|
package/docs/errors.md
CHANGED
|
@@ -96,6 +96,8 @@ What the hosted API answers a start sent under a key:
|
|
|
96
96
|
|
|
97
97
|
**A copy takes a key too.** `copyMethod` sends an `idempotencyKey` the same way, held to the same rule, and the hosted API keeps it for the same 24 hours, scoped to the caller and the method's organization. A copy binds no run, so its answers under a key differ from a start's: [method-copy.md](./method-copy.md#retrying-a-copy--idempotencykey) gives them.
|
|
98
98
|
|
|
99
|
+
**So do a secret rotation and a delivery replay.** `rotateWebhookSecret` and `replayRunWebhookDelivery` send an `idempotencyKey` the same way, held to the same rule. A rotation's answer carries a secret, so the platform never replays it, while a replay's stored answer is given again: [webhooks.md](./webhooks.md) gives both.
|
|
100
|
+
|
|
99
101
|
## Each class's problem
|
|
100
102
|
|
|
101
103
|
The classes remain, so a `catch` can still tell an argument refusal from a refused request or a failed run, but every one carries its failure as its problem, and the code is what a program branches on.
|
|
@@ -166,7 +166,7 @@ The route selects the pipe, and the ref it answers with is the one prepared —
|
|
|
166
166
|
|
|
167
167
|
1. **`pipe_ref` when given.** Qualified-only: `domain.pipe_code`, normalized (trimmed, and blank is absent) and checked **before any request**. A bare code is refused with an `InputPreparationError` asking for the qualified form, since a request names a pipe by its qualified ref and search is a run-route affordance this helper never grows. An `alias->domain.pipe_code` ref is refused for a different reason: the alias names a dependency package's pipe, and preparation covers the method's *own* pipes, whose input form the pipe I/O route describes. The run route takes such a ref; preparation cannot.
|
|
168
168
|
2. **Otherwise the method's own entry pipe**, chosen by the route: the `main_pipe` of the `METHODS.toml` at the tree's root, whether the tree was sent or fetched with a package, else the closure's `main_pipe` when exactly one domain declares one. A method that names its entry pipe in `METHODS.toml` alone — the published `github.com/Pipelex/methods/documents` does — therefore prepares with no `pipe_ref`.
|
|
169
|
-
3. **A selection the route refuses** — a `pipe_ref` that names no pipe of the method, a method with no entry pipe, one whose
|
|
169
|
+
3. **A selection the route refuses** — a `pipe_ref` that names no pipe of the method, a method with no entry pipe, one whose bundles declare several `main_pipe`s — comes back as a `422` whose code is `request.pipe_not_found` or `request.pipe_unspecified`. The route's third selection code, `request.pipe_ambiguous`, for a bare code several domains define, does not arise from here, since a bare `pipe_ref` is refused before any request, but the helper reads it as a refused selection too. The helper turns exactly those three codes into an `InputPreparationError` carrying the server's problem, whose `params` name the unknown ref as `pipe` and the refs the request could select as `candidates`, with the `ApiResponseError` as its `cause`; its message ends with the problem's `detail`, the registry's sentence for the code, which names no candidate for `request.pipe_unspecified`. A manifest `main_pipe` the closure does not define, or defines in several domains, is the method's own fault, a `422` `method.pipe_unresolved` or `method.ref_ambiguous`, which stays an `ApiResponseError`. The run route refuses a selector-less run in the same cases, so preparation never succeeds for a pipe the run would not take.
|
|
170
170
|
|
|
171
171
|
There is no single-pipe fallback: a method that declares one pipe and no `main_pipe` is refused a selector-less run, and a selector-less preparation with it.
|
|
172
172
|
|