@pipelex/sdk 0.57.0 → 0.59.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/dist/cli/script.d.ts +1 -1
- package/dist/client.d.ts +101 -5
- package/dist/client.js +145 -12
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +3 -2
- package/dist/errors.js +3 -2
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/models.d.ts +8 -0
- package/dist/product-models.d.ts +24 -4
- 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 +286 -0
- package/dist/webhooks.js +318 -0
- package/dist/webhooks.js.map +1 -0
- package/docs/architecture.md +8 -2
- package/docs/webhooks.md +119 -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)
|
|
@@ -157,11 +162,12 @@ The hosted catalog/account routes the webapp drives. All ride the same `{base}/v
|
|
|
157
162
|
- **Method copies:** `copyMethod`, `listMethodCopies`, on `/v1/methods/{id}/copy` and `/v1/methods/{id}/copies`. `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. `listMethodCopies` pages the original's `MethodCopyMarker` rows through `requestPage`, as `listMethodVersions` does, with no iterator. 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`. Both take 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; `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`.
|
|
163
169
|
- **Storage:** `resolveStorageUrl`, `resolveStorageUrls` (`POST /v1/resolve-storage-url/bulk` — the single route applied to a list of at most 100 references, one verdict per reference in request order, a refused reference a value on its item; a deployment without the route answers a `404` `ApiResponseError`), `upload` (JSON base64 — the multipart hop is browser→BFF only — answering the file's `uri`, its `filename` and the `file_id` of its record), `requestUploadGrant` (`POST /v1/upload/grant` — for a file described by its name, size and, from `describeUpload`, its SHA-256: either `stored: true`, the organization already holding those bytes, or a presigned, create-only `PUT` for the object, which the holder of the bytes sends with the standalone `uploadWithGrant`; both helpers are also exported from the browser-safe `@pipelex/sdk/upload` entry; a declared size over the cap is a `413` `ApiResponseError`). The higher-level `uploadFile` / `prepareInputs` preparation surface is built on top of `upload`; `prepareInputs` additionally composes one `pipeIo` call, which selects the pipe and answers with the input-form descriptor it walks, and takes the method as its tree, a `method_ref` or a `method_id` like every other method-taking operation — see [input-preparation.md](./input-preparation.md). The download twin — `locateArtifacts`, `collectArtifacts`, `resolveArtifacts`, `fetchArtifact`, `downloadArtifacts` — is built on top of `resolveStorageUrls` and `getRunResult`, never on the embedded `public_url`, and is documented on [artifact-download.md](./artifact-download.md); downloading is a separate explicit gesture, and no run call takes a `download` option.
|
|
164
|
-
- **Runs list/detail/update:** `listRuns(query?)` returns **one page**, not the whole history — a `RunPage` (`{items, nextCursor}`) served from a time-ordered index, so its cost is the page size rather than the size of the history. The method is one filter among the others: `methodId`, sent as `method_id`, lists that method's runs, and without it the page holds the organization's runs of every method, newest first, the other filters applying exactly as with a method. A cursor is bound to the list that issued it, so the cursor of one method's runs is refused on the organization's and the other way round; pass it back with the same `methodId`. A query that is not an object, such as the method id a call written for the former `listRuns(methodId, query)` passes first, is a `RequestArgumentError` before any request, and `iterateRuns` throws it at the call, since spread into a page's query a string would become an object of its characters naming no filter. Its rows are `RunHistoryItem`, **not** `PipelineRun`: the id, `method_id`, the bare catalog id of the method the run ran, `created_by_user_id`, the canonical user id of the member who started it, both always present so that a list across methods can name each row's method from the catalog and its author from the organization's members, `status`, `created_at`, `finished_at`, `pipe_code`, for a failed run `error`, and `method_version` and `source_digest`, which version of the method the run ran (a number, `"draft"`, or `null` for an inline source) and the SHA-256 of the sources it sent as text, 64 hex digits with no prefix and `null` for a run whose `files` held bytes as Base64 or by hash — what a history row shows. `getRunDetail` also carries the run's `tree_digest` and `crate_fingerprint`, each `null` for a run that recorded none. The history lists the runs of every version and of the draft together, by the bare id. `method_version` and `source_digest` are optional on every run record (`RunPublic`, `RunRead`, `PipelineRun`, `RunDetail`, `RunHistoryItem`), since a run recorded before the platform recorded them carries neither. The run's organization, workflow id and result location are not on the list; `getRunDetail` returns them. `iterateRuns` is the async iterator that follows the cursor for callers who genuinely want everything (`for await (const run of client.iterateRuns({ methodId }))`, or `client.iterateRuns()` for every method), sending its query with every page; it is deliberately not a `listAllRuns(): Promise<RunHistoryItem[]>`, because an all-at-once helper needs a page cap and a cap means silently returning a truncated list. Both accept `createdFrom` / `createdTo`, applied server-side as index key conditions — these are **instants, not days** (ISO-8601 with a UTC offset; a naive timestamp is refused as `request.invalid`), because only the caller knows which timezone's day it means. Both also accept `createdBy: "me"`, sent as `created_by=me`, which keeps the runs the caller started (the platform resolves "me" from the authenticated identity and takes no other value), and `status`, a list of `RunStatus` sent as one repeated `status=` parameter per status, deduplicated into the platform's order, which keeps the runs in any of them: the caller's own latest unfinished run is `{ createdBy: "me", status: ["PENDING", "STARTED", "RUNNING"], limit: 1 }`. An empty `status` list would be sent as no parameter at all, which lists every status, so it is a `RequestArgumentError` before any request, as is a name that is no `RunStatus`. The platform may apply these two after its index read, so **a filtered page can hold fewer runs than `limit`, or none, and still carry a `nextCursor`**: an empty page is not the end of the history, a `null` cursor is, and `iterateRuns` keeps following the cursor through one. It runs the same loop as `iterateMethods`, so it also stops on a cursor the server failed to advance and throws `PagingNotTerminatingError` past the same page ceiling. `getRunDetail` (`GET /v1/runs/{id}`) is the only call returning `mthds_contents` — what the run actually executed — and `inputs`; it is kept off the status read that pollers hit every few seconds and off the list, where the bundle would be multiplied by the page size. `updateRun` is the admin/manual status patch. The status/results/start lifecycle routes live in the run-lifecycle section above.
|
|
170
|
+
- **Runs list/detail/update:** `listRuns(query?)` returns **one page**, not the whole history — a `RunPage` (`{items, nextCursor}`) served from a time-ordered index, so its cost is the page size rather than the size of the history. The method is one filter among the others: `methodId`, sent as `method_id`, lists that method's runs, and without it the page holds the organization's runs of every method, newest first, the other filters applying exactly as with a method. A cursor is bound to the list that issued it, so the cursor of one method's runs is refused on the organization's and the other way round; pass it back with the same `methodId`. A query that is not an object, such as the method id a call written for the former `listRuns(methodId, query)` passes first, is a `RequestArgumentError` before any request, and `iterateRuns` throws it at the call, since spread into a page's query a string would become an object of its characters naming no filter. Its rows are `RunHistoryItem`, **not** `PipelineRun`: the id, `method_id`, the bare catalog id of the method the run ran, `created_by_user_id`, the canonical user id of the member who started it, both always present so that a list across methods can name each row's method from the catalog and its author from the organization's members, `status`, `created_at`, `finished_at`, `pipe_code`, for a failed run `error`, and `method_version` and `source_digest`, which version of the method the run ran (a number, `"draft"`, or `null` for an inline source) and the SHA-256 of the sources it sent as text, 64 hex digits with no prefix and `null` for a run whose `files` held bytes as Base64 or by hash — what a history row shows. A row also carries `results_stored`, which says whether `getRunResult` can read the run's results: `true` exactly when the run is `COMPLETED` with its results stored, and `false` on every other row. Only while the run is still queued or running (`PENDING`, `STARTED`, `RUNNING`) does `false` mean "not yet". On a run that ended any other way than `COMPLETED` it is `false` for good, there being no results to read, and on a `COMPLETED` run it is `false` for good when the result went inline to the caller that started it, as a blocking `execute` run's does, and the results read of such a run answers `404 run.results_not_stored`, so a history asks for no results on that row. The field is optional, since a platform that predates it leaves it out, which says nothing either way: an absent field does not mean `false`. `getRunDetail` also carries the run's `tree_digest` and `crate_fingerprint`, each `null` for a run that recorded none. The history lists the runs of every version and of the draft together, by the bare id. `method_version` and `source_digest` are optional on every run record (`RunPublic`, `RunRead`, `PipelineRun`, `RunDetail`, `RunHistoryItem`), since a run recorded before the platform recorded them carries neither. The run's organization, workflow id and result location are not on the list; `getRunDetail` returns them. `iterateRuns` is the async iterator that follows the cursor for callers who genuinely want everything (`for await (const run of client.iterateRuns({ methodId }))`, or `client.iterateRuns()` for every method), sending its query with every page; it is deliberately not a `listAllRuns(): Promise<RunHistoryItem[]>`, because an all-at-once helper needs a page cap and a cap means silently returning a truncated list. Both accept `createdFrom` / `createdTo`, applied server-side as index key conditions — these are **instants, not days** (ISO-8601 with a UTC offset; a naive timestamp is refused as `request.invalid`), because only the caller knows which timezone's day it means. Both also accept `createdBy: "me"`, sent as `created_by=me`, which keeps the runs the caller started (the platform resolves "me" from the authenticated identity and takes no other value), and `status`, a list of `RunStatus` sent as one repeated `status=` parameter per status, deduplicated into the platform's order, which keeps the runs in any of them: the caller's own latest unfinished run is `{ createdBy: "me", status: ["PENDING", "STARTED", "RUNNING"], limit: 1 }`. An empty `status` list would be sent as no parameter at all, which lists every status, so it is a `RequestArgumentError` before any request, as is a name that is no `RunStatus`. The platform may apply these two after its index read, so **a filtered page can hold fewer runs than `limit`, or none, and still carry a `nextCursor`**: an empty page is not the end of the history, a `null` cursor is, and `iterateRuns` keeps following the cursor through one. It runs the same loop as `iterateMethods`, so it also stops on a cursor the server failed to advance and throws `PagingNotTerminatingError` past the same page ceiling. `getRunDetail` (`GET /v1/runs/{id}`) is the only call returning `mthds_contents` — what the run actually executed — and `inputs`; it is kept off the status read that pollers hit every few seconds and off the list, where the bundle would be multiplied by the page size. `updateRun` is the admin/manual status patch. The status/results/start lifecycle routes live in the run-lifecycle section above.
|
|
165
171
|
- **An organization's files:** `listFiles` (`GET /v1/files`) returns **one page** of the organization's listed files, newest first, a `FilePage` (`{items, nextCursor}`) of `FileSummary` rows, each with the `uri` a run's inputs take as any address, its name, type, size, `kind` (`uploaded`, `generated` or `test`) and `origin`. Its `ListFilesQuery` follows `ListRunsQuery`'s camelCase and is sent under the API's names: `methodId`, `runId` and `createdBy` choose the method's, the run's (in the order of its addresses), one member's or the organization's list, and `q`, `kind`, `format`, `mediaType`, `createdFrom` and `createdTo` filter it, `format` and `mediaType` as one repeated parameter per value, an empty list of either refused with `RequestArgumentError` before any request since it would be sent as no filter at all. The filters apply after the index read, so a filtered page can be short or empty and still carry a `nextCursor`, which is bound to the list and filters that issued it; `iterateFiles` follows it through the loop the other iterators share. An upload is recorded as a method's test file by `kind: "test"` with its `method_id`, the pair `UploadInput`, `uploadFile`'s options and `prepareInputs`' nested `upload` all take, checked before any file is read; the upload route's refusals of a test file, for the caller's role or for its method, are a `TestFileRefusedError`. The rest of the files surface reads and acts on one file or a selection: `getFile` answers a `FileDetail`, the whole `FileRecord` with its `method_ids` and one page of its uses, whose cursor stays inside the answer as `uses.next_cursor`; `getFilesByUri` the records holding one address, the one a surface shows first; `renameFile` the record under the name the platform's one rule saved; and `linkFiles` one `FileLink` per requested file, in order, each a link or an `error`, a `200` either way, read through `withLinks` so an answer the SDK cannot read hides every link. A zip is two calls, `requestFileArchive` (answered `202`, `queued`) and `getFileArchive`, read until `ready` or `failed`, with no waiting helper; a selection past a cap is a `422` refusal whose problem carries a `limit` member. `listRunFiles` (`GET /v1/runs/{id}/files`) answers a `RunFilesPage` of a run's inputs, outputs, intermediate files and web links, its `next_cursor` read as `nextCursor` and its `state` `listing` until every record is written. `copyFileIntoDraft` copies a file into a method's draft and answers a `200` discriminated on `blob`, `copied` or `already_held`, with the new draft token on its `method`. Each answer is checked on the member a caller branches on before it is handed back. See [files.md](./files.md).
|
|
166
172
|
|
|
167
173
|
## Hook bundle (`dist-hooks/check.mjs`)
|
package/docs/webhooks.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Caller webhooks
|
|
2
|
+
|
|
3
|
+
A run can call webhooks of its caller once it has ended, so the caller learns of the end without polling. The run registers the URLs when it starts, in `callback_urls`; when it ends, the runtime sends each URL one signed `POST` carrying a `run.finished` event; the caller's receiver verifies the call, then fetches the results by the run's id. The contract is the spec `conformance/specs/pipelex-caller-webhooks.md`, in the `conformance` repository, which both the hosted plane and a self-hosted runner honour. This page covers the four parts the SDK takes: registering the URLs, verifying a call (`verifyWebhook`), reading the hosted delivery log, and reading or rotating the organization's signing secret. The Python SDK's twin is [`python/docs/webhooks.md`](../../python/docs/webhooks.md).
|
|
4
|
+
|
|
5
|
+
## Registering the webhooks — `callback_urls`
|
|
6
|
+
|
|
7
|
+
`execute`, `start` and `startAndWaitForResult` take `callback_urls`, an array of URLs:
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
const ack = await client.start({
|
|
11
|
+
method_id: "mt_abc123",
|
|
12
|
+
inputs,
|
|
13
|
+
callback_urls: ["https://hooks.example.com/pipelex"],
|
|
14
|
+
});
|
|
15
|
+
ack.callback_urls; // ["https://hooks.example.com/***"]
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
It is a Pipelex API extension (`PipelexApiRunExtensions`), beside `method_ref`: the hosted API and a self-hosted runner both take it, and `startAndWaitForResult` keeps it on its blocking fallback, so a bare runner calls the same webhooks once its blocking run has ended. Each server holds the URLs to its own rules, which the client does not restate. The hosted API takes at most 5 HTTPS URLs of at most 2,048 characters, none repeated, and refuses a URL whose host resolves to a private, loopback, link-local or metadata address with a `422` `request.invalid` whose `field` names it as `callback_urls.<index>`; a deployment that does not deliver caller webhooks refuses the list with a `422` `request.unsupported`, and a self-hosted runner whose operator configured no signing secret with a `422` `pipelex.webhook.secret_not_configured`.
|
|
19
|
+
|
|
20
|
+
The client refuses, before anything is sent, a value that is not an array (`client.argument_invalid`, reason `not_a_list`) or an array holding anything but strings (`not_a_string`), and does not send an empty array, which the platform treats as absent. `callback_urls` is no run source: a run naming its webhooks and nothing to run is refused as any run with nothing to run is (`nothing_to_run`). It is reserved on `extra`, as `method_ref` is, so it reaches the body by its named option alone.
|
|
21
|
+
|
|
22
|
+
A URL may carry a credential of the receiver's in its path or query string. Every read of the run lists the URLs redacted to their scheme, host and a port other than the scheme's default, with `/***` in place of the rest: the start's acknowledgement (`PipelexRunResultStart.callback_urls`), the status read (`RunRead.callback_urls`) and the run record (`RunDetail.callback_urls`). For a run that registered none the acknowledgement leaves the member out, and the two reads answer `null`.
|
|
23
|
+
|
|
24
|
+
## Receiving a call — `verifyWebhook`
|
|
25
|
+
|
|
26
|
+
A call is a Standard Webhooks v1 `POST`. Its `webhook-id` header names the delivery, the same on every attempt and replay; `webhook-timestamp` is when the attempt was sent, in Unix seconds; and `webhook-signature` lists `v1,` and the base64 HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{body}` under each secret live when it was sent. `verifyWebhook(body, headers, secret, options?)` checks all three and returns the event:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import {
|
|
30
|
+
WebhookEventUnreadableError,
|
|
31
|
+
WebhookVerificationError,
|
|
32
|
+
isRunFinishedEvent,
|
|
33
|
+
verifyWebhook,
|
|
34
|
+
} from "@pipelex/sdk";
|
|
35
|
+
|
|
36
|
+
export async function POST(request: Request): Promise<Response> {
|
|
37
|
+
const body = await request.text(); // the raw body, never a parsed and re-serialized one
|
|
38
|
+
let event;
|
|
39
|
+
try {
|
|
40
|
+
event = await verifyWebhook(body, request.headers, process.env.PIPELEX_WEBHOOK_SECRET!);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
if (err instanceof WebhookVerificationError) return new Response(null, { status: 401 });
|
|
43
|
+
if (err instanceof WebhookEventUnreadableError) {
|
|
44
|
+
// Verified, so sent by the runtime, but no event this SDK reads: every retry carries the same body.
|
|
45
|
+
console.warn("Unreadable webhook", request.headers.get("webhook-id"), err.problem);
|
|
46
|
+
return new Response(null, { status: 204 });
|
|
47
|
+
}
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
if (!isRunFinishedEvent(event)) return new Response(null, { status: 204 }); // a type this receiver does not read
|
|
51
|
+
if (await alreadyHandled(request.headers.get("webhook-id")!)) return new Response(null, { status: 204 });
|
|
52
|
+
const results = await client.getRunResult(event.data.pipeline_run_id);
|
|
53
|
+
// ...
|
|
54
|
+
return new Response(null, { status: 204 });
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
It follows the contract's verification procedure, in its order:
|
|
59
|
+
|
|
60
|
+
1. **The headers.** A call missing `webhook-id`, `webhook-timestamp` or `webhook-signature`, or carrying one empty, is refused with the refusal `missing_header`. Header names are matched whatever their case; `headers` is a fetch `Headers` or a plain record such as Node's `IncomingHttpHeaders`, a header a record gives as several values, or names under several cases, being read as those values joined by spaces.
|
|
61
|
+
2. **The timestamp.** A `webhook-timestamp` that is not a whole number of Unix seconds, is past `Number.MAX_SAFE_INTEGER`, or is more than five minutes (`WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS`, 300) from the receiver's clock either way, is refused with `timestamp`; exactly five minutes is accepted. `options.now` sets the clock, for a test or a call received earlier.
|
|
62
|
+
3. **The signature.** The signature is computed from the raw body under each secret given and compared in constant time with every `v1` entry of `webhook-signature`, an entry of another version being ignored. Entries are separated by spaces, and a comma ending one is dropped, since a header sent more than once reaches a receiver joined by a comma and a space, as fetch's `Headers` and Node join it. No match is the refusal `signature`.
|
|
63
|
+
|
|
64
|
+
Only then is the body read. The body must be the bytes received, as a `string`, `Uint8Array` or `ArrayBuffer`: the same JSON written with other bytes, re-serialized or with its non-ASCII characters escaped, signs differently and is refused. In a framework that parses JSON bodies by itself, read the raw body instead, such as `express.raw({ type: "application/json" })` in Express.
|
|
65
|
+
|
|
66
|
+
The secret is the `whsec_` value the runtime signs with, given whole: the organization's on the hosted plane ([below](#the-organizations-signing-secret)), or the one a self-hosted runner's operator configured. During a rotation, give both the new and the previous secret as an array: the call verifies under either. A secret that is not `whsec_` and a key in standard base64 with its padding is refused with a `RequestArgumentError` (`client.argument_invalid` naming `secret`) before the call is looked at, and no refusal quotes it.
|
|
67
|
+
|
|
68
|
+
`verifyWebhook` uses Web Crypto (`globalThis.crypto.subtle`), so it runs in Node, Deno, Bun, an edge runtime or a worker, and stays out of the Node host like the rest of the barrel.
|
|
69
|
+
|
|
70
|
+
### What a refusal carries
|
|
71
|
+
|
|
72
|
+
| Error | When | `code` |
|
|
73
|
+
| --- | --- | --- |
|
|
74
|
+
| `WebhookVerificationError`, `refusal` `missing_header` | a header is absent or empty | `auth.missing_credential` |
|
|
75
|
+
| `WebhookVerificationError`, `refusal` `timestamp` | the timestamp is malformed or out of tolerance | `auth.invalid_credential` |
|
|
76
|
+
| `WebhookVerificationError`, `refusal` `signature` | no signature matches under any secret given | `auth.invalid_credential` |
|
|
77
|
+
| `WebhookEventUnreadableError` | a verified body that is not UTF-8 or not JSON | `request.malformed`, `reason` `not_utf8` or `not_json` |
|
|
78
|
+
| `WebhookEventUnreadableError` | a verified body that is no event this SDK reads | `request.invalid`, `field` the member at fault (`type`, `timestamp`, `attempt`, `data`, `data.pipeline_run_id`, `data.status`) |
|
|
79
|
+
| `RequestArgumentError` | a secret, a body or a clock of the wrong kind | `client.argument_invalid` naming `secret`, `body` or `now` |
|
|
80
|
+
|
|
81
|
+
Answer a `WebhookVerificationError` with a status outside `2xx`, such as `401`: the sender counts it as a failed attempt and retries, and a replay later sends the delivery again. Answer a `WebhookEventUnreadableError` with a `2xx` instead, and log it, as the example does: the call was verified, so the runtime sent it, and every retry and replay of the delivery carries the same body, so failing it would only spend the delivery's attempts on a body this SDK will never read. Both errors are `ProblemError`s, never a `PipelexRequestError`, since nothing was requested.
|
|
82
|
+
|
|
83
|
+
### The event
|
|
84
|
+
|
|
85
|
+
`verifyWebhook` returns a `RunFinishedEvent` (`isRunFinishedEvent`), or an `UnknownWebhookEvent` for a `type` this SDK does not read, which a receiver acknowledges with a `2xx` and ignores. A `run.finished` event holds:
|
|
86
|
+
|
|
87
|
+
- `type`, `run.finished` for every ending, the outcome being in `data.status`;
|
|
88
|
+
- `timestamp`, when the run ended (its `finished_at`), not when the call was sent;
|
|
89
|
+
- `attempt`, which attempt of the delivery this call is, from 1 to `Number.MAX_SAFE_INTEGER`. It is informational: it increases with each retry and a replay continues it, but a runtime that runs its delivery step again may skip numbers or start again at 1 under the same `webhook-id`. Deduplicate on the `webhook-id` header, never on `attempt` or the body;
|
|
90
|
+
- `data`, the run as its caller's status read served it when the delivery was created, the same on every attempt and replay.
|
|
91
|
+
|
|
92
|
+
The event never carries the run's results: fetch them with `getRunResult(event.data.pipeline_run_id)`.
|
|
93
|
+
|
|
94
|
+
**Two shapes of `data` reach a receiver today, and `RunFinishedEventData` accepts both.** A self-hosted runner sends the MTHDS protocol's status read: `pipeline_run_id`, a lowercase `status` (`completed` or `failed`, a cancelled or timed-out run reading `failed` with a code that says why), and for a failed run its problem as `error`, a member with no value left out. The hosted plane sends the body of its own `GET /v1/runs/{id}/status`, this SDK's `RunRead`, without `callback_urls`: an uppercase `status` (`COMPLETED`, `FAILED`, `CANCELLED`, `TERMINATED` or `TIMED_OUT`), a member with no value written as `null`, and the platform's own members, such as `finished_at`, `method_id` and `request_id`. That hosted shape does not validate against the contract's JSON Schema, which describes the protocol's read; the hosted event follows its status read onto the protocol's shape when that read moves, with no change of its own. Until then, tell a success from a failure under both spellings with `event.data.status.toLowerCase() === "completed"`. A failed run's `error` is kept when it is a problem document and read as `null` otherwise, as the status read keeps it.
|
|
95
|
+
|
|
96
|
+
## The hosted delivery log
|
|
97
|
+
|
|
98
|
+
The hosted plane records each delivery, one per URL a run registered, with every attempt. Any member of the organization reads them, by web session or API key:
|
|
99
|
+
|
|
100
|
+
- `listRunWebhookDeliveries(runId)` — `GET /v1/runs/{id}/webhook-deliveries`, a `WebhookDeliveryList` whose `deliveries` are `WebhookDeliverySummary` rows in the order of the run's URLs, none for a run that registered none or has not ended. Each has its `delivery_id` (the receiver's `webhook-id`), its `url_index` and redacted `url`, its `state` (`pending` on its retry schedule, `delivered` once a receiver answered `2xx`, `failed` past its last attempt) and its `attempts`, each with when it was sent, the HTTP `status_code` the receiver answered, or the `failure` that kept an answer from coming (`timeout`, `connection`, `tls`, `refused_address`), and its `duration_ms`. No response body is kept.
|
|
101
|
+
- `getRunWebhookDelivery(runId, deliveryId)` — one `WebhookDeliveryDetail`, the summary and the `event` its latest attempt sent.
|
|
102
|
+
- `replayRunWebhookDelivery(runId, deliveryId)` — sends a settled delivery again: one new attempt under the same `webhook-id`, with the same `data`, the next `attempt`, a fresh timestamp and the signatures of the secrets live then. It answers `202` with a `WebhookReplayAccepted` naming the attempt; a replay asked again before it is sent is that same attempt. A delivery still `pending` is refused `409` `pipelex.org.not_manageable` with the reason `webhook_delivery_pending`, and one holding the most attempts a delivery holds with `webhook_delivery_attempts_exhausted`; past the organization's replay rate the platform answers `429` `account.rate_limited` with its `retry_after`.
|
|
103
|
+
|
|
104
|
+
The states and failures are read open: one this SDK does not name arrives as its raw string.
|
|
105
|
+
|
|
106
|
+
## The organization's signing secret
|
|
107
|
+
|
|
108
|
+
On the hosted plane every caller webhook of an organization is signed with the organization's secret. Its admins read it, from a web session only, since the hosted gateway refuses an API key on these routes:
|
|
109
|
+
|
|
110
|
+
- `getWebhookSecret(orgId)` — `GET /v1/organizations/{org_id}/webhook-secret`, a `WebhookSecret`: the `secret`, when it was created, when it was last rotated, and until when the secret the last rotation replaced still signs (`previous_secret_expires_at`). The platform generates the first secret on this first read. Treat the answer as a credential: never log or cache it.
|
|
111
|
+
- `rotateWebhookSecret(orgId, { idempotencyKey? })` — `POST /v1/organizations/{org_id}/webhook-secret/rotate`. The new secret is current at once and the previous one keeps signing beside it for 24 hours, so a receiver configured with both, as `verifyWebhook` takes them, misses nothing. The answer carries a secret, so the platform never replays it: the same `Idempotency-Key` sent again is refused `409` `request.already_executed` and rotates nothing more, and the secret is then read with `getWebhookSecret`.
|
|
112
|
+
|
|
113
|
+
Both refuse another role with `403` `auth.forbidden` whose `params.requirement` is `admin`, an organization other than the caller's active one with `403` `pipelex.org.mismatch`, and one with no record with `404` `pipelex.org.not_found`.
|
|
114
|
+
|
|
115
|
+
## How the verification is held to the contract
|
|
116
|
+
|
|
117
|
+
The contract's known-answer vectors, `tests/caller_webhooks/fixtures/signature-vectors.json` in the `conformance` repository, are vendored byte for byte at `tests/fixtures/caller-webhooks/signature-vectors.json`, with `tests/fixtures/caller-webhooks/vendored.json` recording the commit it was copied at and its SHA-256. `tests/webhooks.test.ts` fails when the copy no longer has that digest, verifies every verification case at its `receiver_clock` and compares its verdict and refusal, and verifies every signing case under each secret that signed it. The shared case file `tests/fixtures/webhook-events.json` adds what the vectors leave to each SDK: the problem of each refusal, what each verified body reads as (the hosted and self-hosted shapes, an unknown type, the unreadable bodies) and the secrets refused.
|
|
118
|
+
|
|
119
|
+
All three files are shared with the Python SDK the way every case file is ([`docs/layout.md`](../../docs/layout.md)): the source is here, `scripts/shared-files.mjs` declares the copies under `python/tests/fixtures/`, and `make check-shared-files` fails on a stale copy. To refresh the vectors, copy the file from the conformance repository at the commit that last changed it, update the commit and the digest in `vendored.json`, and run `make shared-files` at the repository's root.
|