@zoowork-ai/sdk 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -2
- package/README.md +140 -44
- package/dist/client.d.ts +46 -20
- package/dist/client.js +14 -7
- package/dist/developer-api.d.ts +200 -0
- package/dist/developer-api.js +42 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/secret-redaction.d.ts +2 -0
- package/dist/secret-redaction.js +10 -0
- package/dist/sse.js +39 -29
- package/dist/webhooks.d.ts +430 -0
- package/dist/webhooks.js +410 -0
- package/package.json +1 -1
package/dist/webhooks.js
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Receiving webhooks: the event vocabulary, and Standard Webhooks verification of a raw body.
|
|
3
|
+
*
|
|
4
|
+
* Engine emits plain [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks),
|
|
5
|
+
* so nothing here is invented. Three headers arrive with every POST:
|
|
6
|
+
*
|
|
7
|
+
* ```text
|
|
8
|
+
* webhook-id: <event id>
|
|
9
|
+
* webhook-timestamp: <unix seconds of THIS send attempt, not of the event>
|
|
10
|
+
* webhook-signature: v1,<base64 HMAC> [v1,<base64 HMAC under the other active secret>]
|
|
11
|
+
*
|
|
12
|
+
* signed_bytes = UTF8(event_id + "." + timestamp + ".") || raw_body
|
|
13
|
+
* signature = HMAC-SHA256(decoded_secret, signed_bytes)
|
|
14
|
+
* secret = "whsec_" + base64(32 bytes)
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* Two things the shape above decides for you:
|
|
18
|
+
*
|
|
19
|
+
* - **You need the RAW bytes.** `JSON.parse` then `JSON.stringify` does not round-trip a body
|
|
20
|
+
* byte for byte (key order survives, but whitespace and non-ASCII escaping do not), and the
|
|
21
|
+
* signature covers bytes. Read the body before any JSON middleware touches it — Express
|
|
22
|
+
* `express.json()` and most framework body parsers consume it. {@link unwrapWebhook} parses
|
|
23
|
+
* only after the signature has verified, and never re-serializes.
|
|
24
|
+
* - **The window is checked against `webhook-timestamp`, never against the envelope's
|
|
25
|
+
* `created_at`.** A retry of a two-hour-old event carries a fresh timestamp and verifies; the
|
|
26
|
+
* envelope still says when the fact happened.
|
|
27
|
+
*
|
|
28
|
+
* Two `v1,` entries mean a rotation window is open: the sender signs under every active secret,
|
|
29
|
+
* so pass them all (`secret: [newest, previous]`) and any one match accepts.
|
|
30
|
+
*
|
|
31
|
+
* Zero runtime dependencies, so this is WebCrypto (`crypto.subtle`) rather than `node:crypto` —
|
|
32
|
+
* it runs in Node, Deno, Bun, Workers and the browser, and it is why the API is `async`. The
|
|
33
|
+
* comparison is `crypto.subtle.verify`, which is constant-time; this module never compares
|
|
34
|
+
* signatures with `===`.
|
|
35
|
+
*
|
|
36
|
+
* A receiver that would rather not use this SDK can verify the same bytes with the official
|
|
37
|
+
* Standard Webhooks library for its language (npm `standardwebhooks`, PyPI `standardwebhooks`,
|
|
38
|
+
* and the other ports). `src/__vectors__/webhook-vectors.json` is byte-compatible with them.
|
|
39
|
+
*/
|
|
40
|
+
// ── event vocabulary ───────────────────────────────────────────────────────
|
|
41
|
+
/**
|
|
42
|
+
* Event types the deployed Engine delivers, mirrored from its public projection.
|
|
43
|
+
*
|
|
44
|
+
* A type not in this list is NOT an error — see {@link isKnownWebhookEventType}.
|
|
45
|
+
*/
|
|
46
|
+
export const WEBHOOK_EVENT_TYPES = [
|
|
47
|
+
'run.started',
|
|
48
|
+
'run.finished',
|
|
49
|
+
'run.yielded',
|
|
50
|
+
'approval.requested',
|
|
51
|
+
'approval.resolved',
|
|
52
|
+
'custom_tool.requested',
|
|
53
|
+
'custom_tool.resolved',
|
|
54
|
+
'outcome.evaluated',
|
|
55
|
+
'session.created',
|
|
56
|
+
'session.archived',
|
|
57
|
+
'session.deleted',
|
|
58
|
+
'schedule.dispatched',
|
|
59
|
+
'schedule.dispatch_failed',
|
|
60
|
+
'schedule.skipped',
|
|
61
|
+
'schedule.finished',
|
|
62
|
+
'webhook.test',
|
|
63
|
+
];
|
|
64
|
+
/**
|
|
65
|
+
* Schedule CONFIGURATION changes, as distinct from the fire events above.
|
|
66
|
+
*
|
|
67
|
+
* Subscribe to these types for schedule configuration changes. Deliveries may be reordered.
|
|
68
|
+
*/
|
|
69
|
+
export const WEBHOOK_SCHEDULE_CONFIG_EVENT_TYPES = [
|
|
70
|
+
'schedule.created',
|
|
71
|
+
'schedule.updated',
|
|
72
|
+
'schedule.paused',
|
|
73
|
+
'schedule.resumed',
|
|
74
|
+
'schedule.deleted',
|
|
75
|
+
];
|
|
76
|
+
const KNOWN_EVENT_TYPES = new Set([
|
|
77
|
+
...WEBHOOK_EVENT_TYPES,
|
|
78
|
+
...WEBHOOK_SCHEDULE_CONFIG_EVENT_TYPES,
|
|
79
|
+
]);
|
|
80
|
+
/**
|
|
81
|
+
* Whether this SDK release knows a type.
|
|
82
|
+
*
|
|
83
|
+
* Engine adds event types within a schema version, so a receiver MUST tolerate one it has never
|
|
84
|
+
* seen: acknowledge it with 2xx and ignore it. Failing an unknown type only makes the sender
|
|
85
|
+
* retry it, and after that, dead-letter it.
|
|
86
|
+
*/
|
|
87
|
+
export function isKnownWebhookEventType(type) {
|
|
88
|
+
return KNOWN_EVENT_TYPES.has(type);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The envelope as a discriminated union, or `undefined` when this release does not know its type.
|
|
92
|
+
*
|
|
93
|
+
* `undefined` is the forward-compatible path, not a failure: acknowledge the delivery anyway.
|
|
94
|
+
*
|
|
95
|
+
* The value is the same object, re-typed — nothing is copied, normalized or validated. The
|
|
96
|
+
* narrowing is on `type` alone; the `data` shape is the contract's, asserted rather than checked,
|
|
97
|
+
* exactly as the rest of this SDK types API responses. That is sound because the body arrived
|
|
98
|
+
* signed: verification already established that Engine wrote it.
|
|
99
|
+
*/
|
|
100
|
+
export function knownWebhookEvent(event) {
|
|
101
|
+
return isKnownWebhookEventType(event.type) ? event : undefined;
|
|
102
|
+
}
|
|
103
|
+
// ── signature verification ─────────────────────────────────────────────────
|
|
104
|
+
const WEBHOOK_SECRET_PREFIX = 'whsec_';
|
|
105
|
+
const WEBHOOK_SECRET_BYTES = 32;
|
|
106
|
+
const WEBHOOK_SIGNATURE_VERSION = 'v1';
|
|
107
|
+
/** Default clock tolerance, in seconds, in EITHER direction and inclusive. */
|
|
108
|
+
export const WEBHOOK_TOLERANCE_SECONDS = 300;
|
|
109
|
+
/** The sender's envelope ceiling. A receiver refuses a larger body without hashing it. */
|
|
110
|
+
export const WEBHOOK_DEFAULT_MAX_BODY_BYTES = 16 * 1024;
|
|
111
|
+
/**
|
|
112
|
+
* Environment variable read when `secret` is omitted.
|
|
113
|
+
*
|
|
114
|
+
* Holds one secret, or several separated by whitespace or commas for a rotation window. The
|
|
115
|
+
* Python SDK reads it identically, so one deployment's configuration serves both.
|
|
116
|
+
*/
|
|
117
|
+
export const WEBHOOK_SECRET_ENV = 'ZOOWORK_WEBHOOK_SECRET';
|
|
118
|
+
export const WEBHOOK_ID_HEADER = 'webhook-id';
|
|
119
|
+
export const WEBHOOK_TIMESTAMP_HEADER = 'webhook-timestamp';
|
|
120
|
+
export const WEBHOOK_SIGNATURE_HEADER = 'webhook-signature';
|
|
121
|
+
const SIGNATURE_BYTES = 32;
|
|
122
|
+
const BASE64_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
123
|
+
const TIMESTAMP_PATTERN = /^(?:0|[1-9][0-9]*)$/;
|
|
124
|
+
/**
|
|
125
|
+
* Every verification and unwrap failure.
|
|
126
|
+
*
|
|
127
|
+
* Match on {@link ZooworkWebhookError.code}, never on the message. The message names the failing
|
|
128
|
+
* check and at most a header name — never a secret, a signature value, or body bytes — so it is
|
|
129
|
+
* safe to log and to forward to your own error reporting.
|
|
130
|
+
*
|
|
131
|
+
* Separate from `ZooworkError`, which carries the HTTP `status` of an API call the SDK made.
|
|
132
|
+
* Nothing here is an HTTP response; a receiver decides its own status, and the right one is
|
|
133
|
+
* usually 400 for a bad signature and 2xx for anything it means to ignore.
|
|
134
|
+
*
|
|
135
|
+
* Cross-language note: the `code` strings are the contract and are identical in the Python SDK.
|
|
136
|
+
* The class shape is not — there this is a subclass of that SDK's `ZooworkError`, carrying a 400
|
|
137
|
+
* and `retryable=False`, which suits an exception hierarchy built around one base error. Match on
|
|
138
|
+
* `code`, and do not port `instanceof` checks between the two.
|
|
139
|
+
*/
|
|
140
|
+
export class ZooworkWebhookError extends Error {
|
|
141
|
+
code;
|
|
142
|
+
constructor(code, message) {
|
|
143
|
+
super(message);
|
|
144
|
+
this.name = 'ZooworkWebhookError';
|
|
145
|
+
this.code = code;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const utf8 = new TextEncoder();
|
|
149
|
+
/**
|
|
150
|
+
* Read an environment variable without assuming a Node runtime — `process` is a ReferenceError,
|
|
151
|
+
* not `undefined`, in a browser.
|
|
152
|
+
*/
|
|
153
|
+
function readEnv(name) {
|
|
154
|
+
const proc = globalThis.process;
|
|
155
|
+
const value = proc?.env?.[name];
|
|
156
|
+
return value === undefined || value === '' ? undefined : value;
|
|
157
|
+
}
|
|
158
|
+
function decodeBase64(encoded) {
|
|
159
|
+
if (!BASE64_PATTERN.test(encoded))
|
|
160
|
+
return undefined;
|
|
161
|
+
let binary;
|
|
162
|
+
try {
|
|
163
|
+
binary = atob(encoded);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return undefined; // wrong length for base64; atob is the only length check there is
|
|
167
|
+
}
|
|
168
|
+
const bytes = new Uint8Array(binary.length);
|
|
169
|
+
for (let i = 0; i < binary.length; i++)
|
|
170
|
+
bytes[i] = binary.charCodeAt(i);
|
|
171
|
+
return bytes;
|
|
172
|
+
}
|
|
173
|
+
function encodeBase64(bytes) {
|
|
174
|
+
let binary = '';
|
|
175
|
+
for (const byte of bytes)
|
|
176
|
+
binary += String.fromCharCode(byte);
|
|
177
|
+
return btoa(binary);
|
|
178
|
+
}
|
|
179
|
+
/** The raw HMAC key behind a `whsec_` secret; rejects anything but the exact format. */
|
|
180
|
+
function decodeSecret(secret) {
|
|
181
|
+
if (typeof secret !== 'string' || !secret.startsWith(WEBHOOK_SECRET_PREFIX)) {
|
|
182
|
+
throw new ZooworkWebhookError('invalid_secret', `webhook secret must start with ${WEBHOOK_SECRET_PREFIX}`);
|
|
183
|
+
}
|
|
184
|
+
const key = decodeBase64(secret.slice(WEBHOOK_SECRET_PREFIX.length));
|
|
185
|
+
if (!key || key.byteLength !== WEBHOOK_SECRET_BYTES) {
|
|
186
|
+
throw new ZooworkWebhookError('invalid_secret', `webhook secret must decode to ${WEBHOOK_SECRET_BYTES} bytes`);
|
|
187
|
+
}
|
|
188
|
+
return key;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Every secret to try, from the argument or from the environment.
|
|
192
|
+
*
|
|
193
|
+
* The ENVIRONMENT variable may hold several secrets separated by whitespace or commas, so a
|
|
194
|
+
* receiver can sit through a rotation window by changing its configuration rather than its code.
|
|
195
|
+
* Splitting is unambiguous because a secret is `whsec_` plus standard base64 of 32 bytes, an
|
|
196
|
+
* alphabet that contains neither a comma nor whitespace. The Python SDK reads the variable the
|
|
197
|
+
* same way, so one deployment's env works for both.
|
|
198
|
+
*
|
|
199
|
+
* An explicitly passed `secret` is taken exactly as given and is never split — a string is one
|
|
200
|
+
* secret, and several go in an array.
|
|
201
|
+
*/
|
|
202
|
+
function resolveSecrets(secret) {
|
|
203
|
+
if (secret === undefined) {
|
|
204
|
+
const configured = readEnv(WEBHOOK_SECRET_ENV);
|
|
205
|
+
const fromEnv = configured === undefined ? [] : configured.split(/[\s,]+/).filter((entry) => entry.length > 0);
|
|
206
|
+
if (fromEnv.length === 0) {
|
|
207
|
+
throw new ZooworkWebhookError('invalid_secret', `no webhook secret: pass secret, or set ${WEBHOOK_SECRET_ENV} to one secret, or to several separated by whitespace or commas (it needs a runtime with process.env)`);
|
|
208
|
+
}
|
|
209
|
+
return fromEnv.map(decodeSecret);
|
|
210
|
+
}
|
|
211
|
+
const secrets = typeof secret === 'string' ? [secret] : secret;
|
|
212
|
+
if (secrets.length === 0) {
|
|
213
|
+
throw new ZooworkWebhookError('invalid_secret', 'at least one webhook secret is required');
|
|
214
|
+
}
|
|
215
|
+
return secrets.map(decodeSecret);
|
|
216
|
+
}
|
|
217
|
+
function toBytes(value) {
|
|
218
|
+
return typeof value === 'string' ? utf8.encode(value) : value;
|
|
219
|
+
}
|
|
220
|
+
/** `UTF8(eventId + "." + timestamp + ".")` followed by the body bytes, unmodified. */
|
|
221
|
+
function signedPayload(eventId, timestamp, body) {
|
|
222
|
+
const prefix = utf8.encode(`${eventId}.${timestamp}.`);
|
|
223
|
+
const payload = new Uint8Array(prefix.byteLength + body.byteLength);
|
|
224
|
+
payload.set(prefix, 0);
|
|
225
|
+
payload.set(body, prefix.byteLength);
|
|
226
|
+
return payload;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Re-state a byte array as something `crypto.subtle` accepts.
|
|
230
|
+
*
|
|
231
|
+
* TypeScript 5.7 made `Uint8Array` generic over its buffer, and `BufferSource` admits only the
|
|
232
|
+
* `ArrayBuffer` instantiation — a `Uint8Array` whose buffer type is still open (which is what a
|
|
233
|
+
* caller's `rawBody` is) does not fit, although WebCrypto reads it perfectly well. Every array
|
|
234
|
+
* that reaches `crypto.subtle` below was allocated in this module, so this asserts nothing that
|
|
235
|
+
* is not already true; it exists so the assertion is written down once rather than four times.
|
|
236
|
+
*/
|
|
237
|
+
function bufferSource(bytes) {
|
|
238
|
+
return bytes;
|
|
239
|
+
}
|
|
240
|
+
function hmacKey(raw, usage) {
|
|
241
|
+
return crypto.subtle.importKey('raw', bufferSource(raw), { name: 'HMAC', hash: 'SHA-256' }, false, [usage]);
|
|
242
|
+
}
|
|
243
|
+
function readHeader(headers, name) {
|
|
244
|
+
if (typeof headers.get === 'function') {
|
|
245
|
+
return headers.get(name) ?? undefined;
|
|
246
|
+
}
|
|
247
|
+
const record = headers;
|
|
248
|
+
const key = Object.keys(record).find((candidate) => candidate.toLowerCase() === name);
|
|
249
|
+
return key === undefined ? undefined : record[key];
|
|
250
|
+
}
|
|
251
|
+
function requireHeader(headers, name) {
|
|
252
|
+
const value = readHeader(headers, name);
|
|
253
|
+
if (value === undefined) {
|
|
254
|
+
throw new ZooworkWebhookError('missing_header', `${name} header is missing`);
|
|
255
|
+
}
|
|
256
|
+
// A repeated header arrives as an array. Picking one would be a guess about which send this
|
|
257
|
+
// is, so a duplicate is refused rather than resolved. The Python SDK refuses it too, on the
|
|
258
|
+
// same reasoning — this is deliberate shared behavior, not an implementation accident.
|
|
259
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
260
|
+
throw new ZooworkWebhookError('invalid_header', `${name} header must be a single non-empty value`);
|
|
261
|
+
}
|
|
262
|
+
return value;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* The `v1` signatures in the header.
|
|
266
|
+
*
|
|
267
|
+
* Every entry must be `<version>,<value>`; a `v1` value must be base64 of 32 bytes. Another
|
|
268
|
+
* version is skipped, not rejected — a future `v2` alongside `v1` must not break a `v1`
|
|
269
|
+
* receiver. A header carrying ONLY non-`v1` entries therefore yields nothing to compare, and
|
|
270
|
+
* verification ends as `signature_mismatch`.
|
|
271
|
+
*/
|
|
272
|
+
function parseSignatureHeader(header) {
|
|
273
|
+
const signatures = [];
|
|
274
|
+
for (const entry of header.split(' ')) {
|
|
275
|
+
if (entry.length === 0)
|
|
276
|
+
continue;
|
|
277
|
+
const separator = entry.indexOf(',');
|
|
278
|
+
if (separator <= 0 || separator === entry.length - 1) {
|
|
279
|
+
throw new ZooworkWebhookError('invalid_header', `${WEBHOOK_SIGNATURE_HEADER} entry must be <version>,<base64>`);
|
|
280
|
+
}
|
|
281
|
+
if (entry.slice(0, separator) !== WEBHOOK_SIGNATURE_VERSION)
|
|
282
|
+
continue;
|
|
283
|
+
const decoded = decodeBase64(entry.slice(separator + 1));
|
|
284
|
+
if (!decoded || decoded.byteLength !== SIGNATURE_BYTES) {
|
|
285
|
+
throw new ZooworkWebhookError('invalid_header', `${WEBHOOK_SIGNATURE_HEADER} ${WEBHOOK_SIGNATURE_VERSION} entry must decode to ${SIGNATURE_BYTES} bytes`);
|
|
286
|
+
}
|
|
287
|
+
signatures.push(decoded);
|
|
288
|
+
}
|
|
289
|
+
return signatures;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Verify a delivery and return what its headers asserted.
|
|
293
|
+
*
|
|
294
|
+
* Checks run cheapest-first and fail closed: the secrets decode, the body is within its ceiling,
|
|
295
|
+
* the three headers are present and well-formed, the timestamp is inside the tolerance, and only
|
|
296
|
+
* then is every `v1` entry compared against every secret with `crypto.subtle.verify` (a
|
|
297
|
+
* constant-time comparison). Any one match accepts. The body is never parsed here — an
|
|
298
|
+
* unverified body is not yet data. Throws {@link ZooworkWebhookError} on every failure.
|
|
299
|
+
*/
|
|
300
|
+
export async function verifyWebhookSignature(input) {
|
|
301
|
+
const secrets = resolveSecrets(input.secret);
|
|
302
|
+
const maxBodyBytes = input.maxBodyBytes ?? WEBHOOK_DEFAULT_MAX_BODY_BYTES;
|
|
303
|
+
const body = toBytes(input.rawBody);
|
|
304
|
+
if (body.byteLength > maxBodyBytes) {
|
|
305
|
+
throw new ZooworkWebhookError('body_too_large', `webhook body exceeds ${maxBodyBytes} bytes`);
|
|
306
|
+
}
|
|
307
|
+
const eventId = requireHeader(input.headers, WEBHOOK_ID_HEADER);
|
|
308
|
+
const rawTimestamp = requireHeader(input.headers, WEBHOOK_TIMESTAMP_HEADER);
|
|
309
|
+
const signatureHeader = requireHeader(input.headers, WEBHOOK_SIGNATURE_HEADER);
|
|
310
|
+
if (!TIMESTAMP_PATTERN.test(rawTimestamp) || !Number.isSafeInteger(Number(rawTimestamp))) {
|
|
311
|
+
throw new ZooworkWebhookError('invalid_header', `${WEBHOOK_TIMESTAMP_HEADER} must be a non-negative integer`);
|
|
312
|
+
}
|
|
313
|
+
const timestamp = Number(rawTimestamp);
|
|
314
|
+
const signatures = parseSignatureHeader(signatureHeader);
|
|
315
|
+
const tolerance = input.toleranceSeconds ?? WEBHOOK_TOLERANCE_SECONDS;
|
|
316
|
+
if (!Number.isSafeInteger(tolerance) || tolerance < 0) {
|
|
317
|
+
throw new TypeError('toleranceSeconds must be a non-negative integer of seconds');
|
|
318
|
+
}
|
|
319
|
+
const nowMs = input.now ?? Date.now();
|
|
320
|
+
if (!Number.isFinite(nowMs)) {
|
|
321
|
+
throw new TypeError('now must be a finite number of milliseconds');
|
|
322
|
+
}
|
|
323
|
+
if (Math.abs(Math.floor(nowMs / 1000) - timestamp) > tolerance) {
|
|
324
|
+
throw new ZooworkWebhookError('timestamp_out_of_window', `${WEBHOOK_TIMESTAMP_HEADER} is more than ${tolerance}s from this clock`);
|
|
325
|
+
}
|
|
326
|
+
const payload = signedPayload(eventId, timestamp, body);
|
|
327
|
+
for (const secret of secrets) {
|
|
328
|
+
const key = await hmacKey(secret, 'verify');
|
|
329
|
+
for (const signature of signatures) {
|
|
330
|
+
if (await crypto.subtle.verify({ name: 'HMAC' }, key, bufferSource(signature), bufferSource(payload))) {
|
|
331
|
+
return { eventId, timestamp };
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
throw new ZooworkWebhookError('signature_mismatch', 'no webhook signature matched the configured secrets');
|
|
336
|
+
}
|
|
337
|
+
function isRecord(value) {
|
|
338
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Verify a delivery and hand back its envelope.
|
|
342
|
+
*
|
|
343
|
+
* Verification runs FIRST; the body is parsed only once it is authenticated, and it is never
|
|
344
|
+
* re-serialized. The structural check is deliberately minimal — `object`, `id`, `type`,
|
|
345
|
+
* `schema_version`, `created_at`, `data` — because Engine adds fields and event types within a
|
|
346
|
+
* schema version, and rejecting one of those would drop a valid delivery. An unrecognized
|
|
347
|
+
* `type` passes straight through; narrow it with {@link knownWebhookEvent}.
|
|
348
|
+
*
|
|
349
|
+
* `schema_version` IS required, although it is the one field a receiver rarely reads: Engine's
|
|
350
|
+
* projector emits it unconditionally, so an envelope without it is not an envelope, and admitting
|
|
351
|
+
* one would leave {@link WebhookEvent} lying about its own type. It must be a number with an
|
|
352
|
+
* INTEGER VALUE — `1` and `1.0` both pass, since JSON has a single numeric type and they are the
|
|
353
|
+
* same number, while `1.5` is rejected because no release could mean it. The Python SDK checks the
|
|
354
|
+
* same six fields and reaches the same verdict on the same envelope.
|
|
355
|
+
*
|
|
356
|
+
* Throws {@link ZooworkWebhookError} with `invalid_payload` when the body is not an envelope,
|
|
357
|
+
* and with the verification codes before that.
|
|
358
|
+
*/
|
|
359
|
+
export async function unwrapWebhook(input) {
|
|
360
|
+
await verifyWebhookSignature(input);
|
|
361
|
+
const text = typeof input.rawBody === 'string' ? input.rawBody : new TextDecoder().decode(input.rawBody);
|
|
362
|
+
let parsed;
|
|
363
|
+
try {
|
|
364
|
+
parsed = JSON.parse(text);
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
throw new ZooworkWebhookError('invalid_payload', 'webhook body is not JSON');
|
|
368
|
+
}
|
|
369
|
+
if (!isRecord(parsed)) {
|
|
370
|
+
throw new ZooworkWebhookError('invalid_payload', 'webhook body is not a JSON object');
|
|
371
|
+
}
|
|
372
|
+
const { object, id, type, schema_version: schemaVersion, created_at: createdAt, data } = parsed;
|
|
373
|
+
if (object !== 'event' ||
|
|
374
|
+
typeof id !== 'string' ||
|
|
375
|
+
typeof type !== 'string' ||
|
|
376
|
+
// An integer VALUE, not merely a number. JSON has one numeric type, so `1` and `1.0` are the
|
|
377
|
+
// same number and both pass, while `1.5` is not a version this or any release could mean.
|
|
378
|
+
// `Number.isInteger` also rejects a string `"1"` and the Infinity a JSON overflow literal
|
|
379
|
+
// parses to; the `typeof` is kept for the reader, not for the check.
|
|
380
|
+
!(typeof schemaVersion === 'number' && Number.isInteger(schemaVersion)) ||
|
|
381
|
+
typeof createdAt !== 'string' ||
|
|
382
|
+
!isRecord(data)) {
|
|
383
|
+
throw new ZooworkWebhookError('invalid_payload', 'webhook body is not a webhook event envelope');
|
|
384
|
+
}
|
|
385
|
+
return parsed;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Sign a body the way Engine does — the sender's side of the contract.
|
|
389
|
+
*
|
|
390
|
+
* Exported for your own tests: a receiver can build a delivery it controls, rather than pointing
|
|
391
|
+
* its handler at staging to get one. Engine signs real deliveries; nothing here needs to.
|
|
392
|
+
*/
|
|
393
|
+
export async function signWebhook(input) {
|
|
394
|
+
if (!Number.isSafeInteger(input.timestamp) || input.timestamp < 0) {
|
|
395
|
+
throw new TypeError('webhook timestamp must be a non-negative integer of unix seconds');
|
|
396
|
+
}
|
|
397
|
+
const secrets = resolveSecrets(input.secret);
|
|
398
|
+
const payload = signedPayload(input.eventId, input.timestamp, toBytes(input.body));
|
|
399
|
+
const entries = [];
|
|
400
|
+
for (const secret of secrets) {
|
|
401
|
+
const key = await hmacKey(secret, 'sign');
|
|
402
|
+
const signature = await crypto.subtle.sign({ name: 'HMAC' }, key, bufferSource(payload));
|
|
403
|
+
entries.push(`${WEBHOOK_SIGNATURE_VERSION},${encodeBase64(new Uint8Array(signature))}`);
|
|
404
|
+
}
|
|
405
|
+
return {
|
|
406
|
+
[WEBHOOK_ID_HEADER]: input.eventId,
|
|
407
|
+
[WEBHOOK_TIMESTAMP_HEADER]: String(input.timestamp),
|
|
408
|
+
[WEBHOOK_SIGNATURE_HEADER]: entries.join(' '),
|
|
409
|
+
};
|
|
410
|
+
}
|