@zoowork-ai/sdk 0.7.0 → 0.9.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 -0
- package/README.md +114 -3
- package/dist/client.d.ts +22 -0
- package/dist/client.js +6 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/sse.js +39 -29
- package/dist/webhooks.d.ts +433 -0
- package/dist/webhooks.js +413 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,60 @@
|
|
|
3
3
|
All notable changes to `@zoowork-ai/sdk` (formerly `@zooclaw-agents/sdk`). Dates are the
|
|
4
4
|
day the behaviour was verified, not the day it was written.
|
|
5
5
|
|
|
6
|
+
## Unreleased
|
|
7
|
+
|
|
8
|
+
## 0.9.0 — 2026-10-01
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Webhook receiving.** `verifyWebhookSignature` and `unwrapWebhook` check a delivery's Standard
|
|
13
|
+
Webhooks signature over the raw body and hand back the envelope; `signWebhook` is exported so a
|
|
14
|
+
receiver can build a delivery for its own tests. The HMAC is WebCrypto, so the zero-runtime-
|
|
15
|
+
dependency guarantee holds and the functions are `async`. Headers can come from a fetch
|
|
16
|
+
`Headers`, a record in any casing, or Node's `IncomingHttpHeaders`. Failures throw
|
|
17
|
+
`ZooworkWebhookError` with a machine-readable `code` and a message that carries no secret,
|
|
18
|
+
signature or body.
|
|
19
|
+
- **Rotation without a deploy.** `ZOOWORK_WEBHOOK_SECRET` accepts several secrets separated by
|
|
20
|
+
whitespace or commas, and a delivery signed under any of them verifies — the sender double-signs
|
|
21
|
+
for the length of the window. The Python SDK reads the variable identically, so one deployment's
|
|
22
|
+
configuration serves both. A `secret` passed explicitly is never split.
|
|
23
|
+
- **The webhook event vocabulary.** `WEBHOOK_EVENT_TYPES` lists the sixteen types Engine
|
|
24
|
+
delivers today, with a `data` shape per type behind `WebhookEventFor<T>` and
|
|
25
|
+
`knownWebhookEvent()`. `WEBHOOK_SCHEDULE_CONFIG_EVENT_TYPES` declares the five schedule
|
|
26
|
+
configuration events whose server side is not deployed yet. A type the release does not know
|
|
27
|
+
passes through with its raw `type` and `data` instead of throwing, so a receiver stays
|
|
28
|
+
forward compatible.
|
|
29
|
+
- Fixed cross-language signing vectors in `src/__vectors__/webhook-vectors.json`, copied verbatim
|
|
30
|
+
from Engine's `packages/webhook-signing` and asserted three ways (an independent WebCrypto
|
|
31
|
+
computation, the recorded JSON, and this SDK). They are the compatibility contract with the
|
|
32
|
+
official `standardwebhooks` libraries; adding a vector is fine, editing one is not.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- `streamEvents()` cancels and releases its response reader when a consumer exits early,
|
|
37
|
+
including a loop that stops at `run.finished`. This prevents an open SSE connection from
|
|
38
|
+
keeping a Node.js consumer alive after its turn has completed.
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- Local E2E checks accept Platform `zwp_live_` keys as well as legacy `zct_` keys. Production
|
|
43
|
+
checks require an explicit URL and confirmation, and record their scope separately from
|
|
44
|
+
staging checks. The SDK already accepts Platform keys without a client API change.
|
|
45
|
+
- Live smoke tests select only selectable catalog models.
|
|
46
|
+
|
|
47
|
+
## 0.8.0 — 2026-09-20
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
|
|
51
|
+
- Model catalog lifecycle metadata, including `selectable` and replacement hints.
|
|
52
|
+
- Deleted Session tombstones in filtered cursor pages through `includeDeleted`.
|
|
53
|
+
- Agent declaration fields for global-Skill opt-out and named user timezones.
|
|
54
|
+
|
|
55
|
+
### Changed
|
|
56
|
+
|
|
57
|
+
- Model-selection examples now reject non-selectable catalog rows; selecting one can return
|
|
58
|
+
`409 model_not_selectable`.
|
|
59
|
+
|
|
6
60
|
## 0.7.0 — 2026-09-14
|
|
7
61
|
|
|
8
62
|
### Added
|
package/README.md
CHANGED
|
@@ -10,7 +10,9 @@ npm install @zoowork-ai/sdk
|
|
|
10
10
|
|
|
11
11
|
## Quickstart
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
Platform Project keys (`zwp_live_...`) and existing Work tokens (`zct_...`) both work with `apiKey`. Platform scopes Agent and Session access to the key’s Project and requires initialized Org billing and bound owner credentials. Rebind the key after signing in when the API requests it.
|
|
14
|
+
|
|
15
|
+
Create a Project key in Platform, or a Work token under **Settings → API Keys** in the ZooWork App. Secrets are shown once at creation. Keep them server-side: Platform keys carry Project scope, while Work tokens use the existing organization authorization contract.
|
|
14
16
|
|
|
15
17
|
```ts
|
|
16
18
|
import { createZooworkClient } from '@zoowork-ai/sdk'
|
|
@@ -29,8 +31,10 @@ The base URL has a working default, so you do not configure an endpoint. Overrid
|
|
|
29
31
|
// send. Select a model returned by this deployment instead of relying on a
|
|
30
32
|
// remembered id or on a server default that can rotate.
|
|
31
33
|
const models = await zc.listModels()
|
|
32
|
-
const primary = models.find(
|
|
33
|
-
|
|
34
|
+
const primary = models.find(
|
|
35
|
+
(model) => model.selectable !== false && model.model === 'litellm/gpt-5.6-terra',
|
|
36
|
+
)?.model
|
|
37
|
+
if (!primary) throw new Error('Choose a selectable model returned by listModels()')
|
|
34
38
|
|
|
35
39
|
const agent = await zc.createAgent({
|
|
36
40
|
resource: { name: 'research-agent', model: { primary } },
|
|
@@ -45,6 +49,15 @@ const session = await zc.createSession(agent.agent_id, {
|
|
|
45
49
|
})
|
|
46
50
|
```
|
|
47
51
|
|
|
52
|
+
`listModels()` can include a model whose retirement has started. Check `selectable !== false`
|
|
53
|
+
before using a catalog row in a new Agent or config. A non-selectable choice returns
|
|
54
|
+
`409 model_not_selectable`; `expired_fallback_to` names the reviewed replacement when present.
|
|
55
|
+
|
|
56
|
+
Agent resources also accept `userTimezone`, a named IANA timezone used for prompt and message
|
|
57
|
+
time context, and `include_global_skills: false` to disable automatic global Skills while keeping
|
|
58
|
+
explicitly listed Skills. An explicit `skills: []` also opts out. Schedule timezones are configured
|
|
59
|
+
separately.
|
|
60
|
+
|
|
48
61
|
## Configuration
|
|
49
62
|
|
|
50
63
|
| Option | Environment variable | Default |
|
|
@@ -204,6 +217,9 @@ const { exit_code, stdout } = await zc.exec(agent.agent_id, ['bash', '-lc', 'pwd
|
|
|
204
217
|
`listSessions` keeps the legacy numeric-page contract. Use `listSessionPage` for the filtered
|
|
205
218
|
cursor lane: it starts with `sls1:0`, accepts channel/surface/runtime/archive filters, and returns
|
|
206
219
|
`next_cursor` plus a `list_cursor` on each row. Cursors are opaque and bound to the same filters.
|
|
220
|
+
Pass `{ includeDeleted: true }` to include deletion tombstones for reconciliation; returned rows
|
|
221
|
+
then carry `deleted` and the page carries `includes_deleted: true`. This flag is part of the cursor
|
|
222
|
+
scope, so do not reuse a cursor created without it.
|
|
207
223
|
`archiveSession` and `deleteSession` round out the session surface. There is no
|
|
208
224
|
`patchSession`: the gateway does not proxy `PATCH` at all (405), so session `metadata` is fixed at
|
|
209
225
|
creation time.
|
|
@@ -271,6 +287,101 @@ activation on its own — moving it is one explicit call, `upgradeSystemPrompt`,
|
|
|
271
287
|
the agent's current `config_version` as a CAS (`409 config_version_changed` on a stale one)
|
|
272
288
|
and answers the new pin plus the version bump it cost.
|
|
273
289
|
|
|
290
|
+
## Receiving webhooks
|
|
291
|
+
|
|
292
|
+
Verification needs the **raw request bytes**. `express.json()` and most framework body parsers
|
|
293
|
+
consume them, and a `JSON.parse` → `JSON.stringify` round trip does not reproduce them byte for
|
|
294
|
+
byte, so read the body before anything else touches it:
|
|
295
|
+
|
|
296
|
+
```ts
|
|
297
|
+
import { createServer } from 'node:http'
|
|
298
|
+
import { knownWebhookEvent, unwrapWebhook, ZooworkWebhookError } from '@zoowork-ai/sdk'
|
|
299
|
+
|
|
300
|
+
createServer(async (req, res) => {
|
|
301
|
+
const chunks: Buffer[] = []
|
|
302
|
+
for await (const chunk of req) chunks.push(chunk as Buffer)
|
|
303
|
+
const rawBody = Buffer.concat(chunks)
|
|
304
|
+
|
|
305
|
+
let event
|
|
306
|
+
try {
|
|
307
|
+
// Defaults to ZOOWORK_WEBHOOK_SECRET, or pass `secret: [newest, previous]` explicitly.
|
|
308
|
+
event = await unwrapWebhook({ headers: req.headers, rawBody })
|
|
309
|
+
} catch (error) {
|
|
310
|
+
if (error instanceof ZooworkWebhookError) {
|
|
311
|
+
console.warn('rejected webhook', error.code) // never carries the secret, signature or body
|
|
312
|
+
res.writeHead(400).end()
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
throw error
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Acknowledge first, then do the work: the sender retries a slow or failed response, and
|
|
319
|
+
// `event.id` is stable across retries, so it is also your deduplication key.
|
|
320
|
+
res.writeHead(204).end()
|
|
321
|
+
|
|
322
|
+
const known = knownWebhookEvent(event)
|
|
323
|
+
if (!known) return // a type this release does not know: already acknowledged, ignore it
|
|
324
|
+
switch (known.type) {
|
|
325
|
+
case 'run.finished':
|
|
326
|
+
console.log(known.data.run_id, known.data.status)
|
|
327
|
+
break
|
|
328
|
+
case 'approval.requested':
|
|
329
|
+
console.log(known.data.approval_id, known.data.tool_name)
|
|
330
|
+
break
|
|
331
|
+
}
|
|
332
|
+
}).listen(3000)
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
`verifyWebhookSignature` is the same check without the parse, for a handler that wants the bytes.
|
|
336
|
+
Both are `async`: the HMAC is WebCrypto (`crypto.subtle`), which is how this SDK verifies
|
|
337
|
+
signatures with no runtime dependency and still runs in Workers, Deno and the browser.
|
|
338
|
+
|
|
339
|
+
### Rotating the secret without a deploy
|
|
340
|
+
|
|
341
|
+
`ZOOWORK_WEBHOOK_SECRET` accepts **several secrets**, separated by whitespace or commas. During a
|
|
342
|
+
rotation window the sender signs under every active key, so a receiver that lists both accepts
|
|
343
|
+
whichever one signed a given delivery:
|
|
344
|
+
|
|
345
|
+
```sh
|
|
346
|
+
ZOOWORK_WEBHOOK_SECRET="whsec_<new> whsec_<previous>" # commas work too
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Splitting is unambiguous because a secret is `whsec_` plus standard base64 of 32 bytes — no comma,
|
|
350
|
+
no whitespace. The Python SDK reads the variable the same way, so one deployment's configuration
|
|
351
|
+
serves both. A `secret` you pass explicitly is never split: a string is one secret, and several go
|
|
352
|
+
in an array.
|
|
353
|
+
|
|
354
|
+
Four more things worth knowing:
|
|
355
|
+
|
|
356
|
+
- **The clock window is checked against the `webhook-timestamp` header, not the envelope's
|
|
357
|
+
`created_at`.** A retry of an old event carries a fresh timestamp and verifies; `created_at`
|
|
358
|
+
still says when the fact happened. The default tolerance is ±300s.
|
|
359
|
+
- **A body over 16 KiB is refused before it is hashed** — the sender's own envelope ceiling.
|
|
360
|
+
- **Unknown event types are normal.** New types ship within a schema version, and a receiver that
|
|
361
|
+
fails one only makes the sender retry it and then dead-letter it. Acknowledge and ignore.
|
|
362
|
+
- **A repeated `webhook-id`, `webhook-timestamp` or `webhook-signature` header is rejected**, not
|
|
363
|
+
resolved to one of its values: choosing would be a guess about which send arrived. The Python
|
|
364
|
+
SDK rejects it too.
|
|
365
|
+
- **`unwrapWebhook` checks six fields and no more** — `object`, `id`, `type`, `schema_version`,
|
|
366
|
+
`created_at`, `data` — so a field or event type a later Engine release adds is not a reason to
|
|
367
|
+
drop a delivery. `schema_version` must be a number with an **integer value**: `1` and `1.0` both
|
|
368
|
+
pass, since JSON has one numeric type and they are the same number, while `1.5` is rejected with
|
|
369
|
+
`invalid_payload`. The Python SDK reaches the same verdict on the same envelope.
|
|
370
|
+
|
|
371
|
+
`ZooworkWebhookError.code` is the contract to match on — `invalid_secret`, `body_too_large`,
|
|
372
|
+
`missing_header`, `invalid_header`, `timestamp_out_of_window`, `signature_mismatch`,
|
|
373
|
+
`invalid_payload` — and those strings are identical in the Python SDK. The class shape is not: here
|
|
374
|
+
it extends `Error`, while the Python SDK makes it a subclass of its own `ZooworkError` carrying a
|
|
375
|
+
400. Match on `code`, and do not port `instanceof` checks between the two.
|
|
376
|
+
|
|
377
|
+
You do not have to use this SDK to verify. Engine emits plain
|
|
378
|
+
[Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks), so the official
|
|
379
|
+
library for any language works with the same `whsec_` secret and the same raw body — npm
|
|
380
|
+
[`standardwebhooks`](https://www.npmjs.com/package/standardwebhooks), PyPI
|
|
381
|
+
[`standardwebhooks`](https://pypi.org/project/standardwebhooks/), or another of the ports. The
|
|
382
|
+
fixed vectors in [`src/__vectors__/webhook-vectors.json`](src/__vectors__/webhook-vectors.json)
|
|
383
|
+
are copied verbatim from Engine and are byte-compatible with all of them.
|
|
384
|
+
|
|
274
385
|
## Two helpers
|
|
275
386
|
|
|
276
387
|
```ts
|
package/dist/client.d.ts
CHANGED
|
@@ -121,6 +121,15 @@ export interface ModelInfo {
|
|
|
121
121
|
display_name?: string;
|
|
122
122
|
family?: string;
|
|
123
123
|
api?: string;
|
|
124
|
+
expired_at?: string | null;
|
|
125
|
+
expired_fallback_to?: string | null;
|
|
126
|
+
retired_at?: string | null;
|
|
127
|
+
revision?: number;
|
|
128
|
+
lifecycle_status?: 'active' | 'scheduled' | 'draining' | 'retired' | string;
|
|
129
|
+
/** `false` means this catalog row cannot be selected for a new Agent or config. */
|
|
130
|
+
selectable?: boolean;
|
|
131
|
+
retire_not_before?: string | null;
|
|
132
|
+
default_for?: string[];
|
|
124
133
|
[k: string]: unknown;
|
|
125
134
|
}
|
|
126
135
|
/** Approval behavior for one MCP server or one of its tools. */
|
|
@@ -269,6 +278,8 @@ export interface OutcomeConfig {
|
|
|
269
278
|
}
|
|
270
279
|
export interface AgentResource {
|
|
271
280
|
name: string;
|
|
281
|
+
/** Named IANA timezone used in prompt context and message timestamps, e.g. `Asia/Shanghai`. */
|
|
282
|
+
userTimezone?: string;
|
|
272
283
|
/**
|
|
273
284
|
* Omit this section to pin the platform's current model defaults at create time. Those
|
|
274
285
|
* defaults can rotate; call `listModels()` and set `primary` explicitly for deterministic
|
|
@@ -291,6 +302,11 @@ export interface AgentResource {
|
|
|
291
302
|
skill_id: string;
|
|
292
303
|
version?: number | 'latest';
|
|
293
304
|
}[];
|
|
305
|
+
/**
|
|
306
|
+
* Defaults to true. False disables automatic global Skills without removing explicitly
|
|
307
|
+
* listed Skills. An explicit empty `skills` array also opts out.
|
|
308
|
+
*/
|
|
309
|
+
include_global_skills?: boolean;
|
|
294
310
|
labels?: Record<string, string>;
|
|
295
311
|
/**
|
|
296
312
|
* Tool-surface and approval policy. Name selectors in `allow`, `deny`, `rules[].match.tool`,
|
|
@@ -717,6 +733,8 @@ export interface SessionRecord {
|
|
|
717
733
|
status?: string | null;
|
|
718
734
|
metadata?: Record<string, unknown>;
|
|
719
735
|
archived?: boolean;
|
|
736
|
+
/** Present on filtered pages only when `includeDeleted` was requested. */
|
|
737
|
+
deleted?: boolean;
|
|
720
738
|
runtime_mode?: 'active' | 'preview' | 'authoring' | 'evaluation' | string;
|
|
721
739
|
config_version?: number;
|
|
722
740
|
updated_at?: string;
|
|
@@ -789,11 +807,15 @@ export interface SessionListPageOptions {
|
|
|
789
807
|
includeSurfaces?: string[];
|
|
790
808
|
runtimeModes?: Array<'active' | 'preview' | 'authoring' | 'evaluation'>;
|
|
791
809
|
includeArchived?: boolean;
|
|
810
|
+
/** Include deleted Session tombstones for history reconciliation. Changes the cursor scope. */
|
|
811
|
+
includeDeleted?: boolean;
|
|
792
812
|
}
|
|
793
813
|
/** One filtered session page. `next_cursor` is null at the end. */
|
|
794
814
|
export interface SessionListPage {
|
|
795
815
|
sessions: SessionRecord[];
|
|
796
816
|
next_cursor: string | null;
|
|
817
|
+
/** Present and true only when deleted tombstones were requested. */
|
|
818
|
+
includes_deleted?: true;
|
|
797
819
|
}
|
|
798
820
|
/** One `postEvents` receipt. An accepted event carries the full event object's fields too. */
|
|
799
821
|
export interface PostEventReceipt {
|
package/dist/client.js
CHANGED
|
@@ -551,8 +551,13 @@ export function createZooworkClient(cfg = {}) {
|
|
|
551
551
|
include_surfaces: opts.includeSurfaces?.join(','),
|
|
552
552
|
runtime_modes: opts.runtimeModes?.join(','),
|
|
553
553
|
include_archived: opts.includeArchived === undefined ? undefined : String(opts.includeArchived),
|
|
554
|
+
include_deleted: opts.includeDeleted === undefined ? undefined : String(opts.includeDeleted),
|
|
554
555
|
})}`);
|
|
555
|
-
return {
|
|
556
|
+
return {
|
|
557
|
+
sessions: data.sessions ?? [],
|
|
558
|
+
next_cursor: data.next_cursor ?? null,
|
|
559
|
+
...(data.includes_deleted === true ? { includes_deleted: true } : {}),
|
|
560
|
+
};
|
|
556
561
|
},
|
|
557
562
|
archiveSession: async (agentId, sessionId) => {
|
|
558
563
|
const data = await json(`${sessions(agentId)}/${encodeURIComponent(sessionId)}/archive`, { method: 'POST' });
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { createZooworkClient, DEFAULT_BASE_URL, ZooworkError, type ZooworkClient, type ZooworkConfig, type ZooworkAuth, type Ownership, type ModelInfo, type AgentResource, type AgentRecord, type AgentListParams, type AgentPage, type AgentPagePromise, type AgentStatus, type AgentSkill, type AgentChannel, type AgentChannelCapabilitySync, type AgentChannelCapabilities, type FeishuChannelProviderStatus, type FeishuDocumentsCapability, type ChannelPlatform, type AddChannelPlatform, type GuidedSetupPlatform, type AddChannelInput, type UpdateChannelInput, type ChannelSetupInput, type ChannelSetupSession, type ChannelPollResult, type FeishuSetupInput, type FeishuSetupSession, type FeishuPollResult, type McpContextConfig, type McpServerDeclaration, type McpToolPermission, type McpToolPermissionOverride, type CustomToolDeclaration, type CustomToolResultImageMimeType, type CustomToolResultContent, type CustomToolResultEvent, type SkillRecord, type SkillVersionRecord, type SessionRecord, type SessionListPageOptions, type SessionListPage, type SessionHistoryEntry, type SessionEvent, type SessionEventPage, type OutboundEvent, type PostEventReceipt, type ApprovalDecision, type ApprovalRecord, type CustomToolCallStatus, type CustomToolCallRecord, type ArtifactPage, type ArtifactRecord, type ArtifactStatus, type OutcomeConfig, type OutcomeEvaluator, type SystemPromptDeclaration, type SystemPromptInfo, type SystemPromptPreview, type SystemPromptPreviewInput, type SystemPromptUpgrade, type ScheduleSpec, type SchedulePayload, type ScheduleInput, type ScheduleUpdate, type ScheduleRecord, type ScheduleRun, type WakeResult, type ExecResult, type EnvironmentConfig, type EnvironmentResource, type EnvironmentRecord, type EnvironmentVersionRecord, } from './client.js';
|
|
2
2
|
export { SESSION_EVENT_TYPES, type SessionEventType, PUBLIC_INPUT_EVENT_TYPES, type PublicInputEventType, normalizeEvent, isRunFinished, runOutcome, messageText, assistantText, thinkingText, customToolUse, type CustomToolUse, toolCall, type ToolCall, } from './events.js';
|
|
3
3
|
export { parseSSE, type SSEMessage } from './sse.js';
|
|
4
|
+
export { WEBHOOK_EVENT_TYPES, WEBHOOK_SCHEDULE_CONFIG_EVENT_TYPES, type WebhookEventType, isKnownWebhookEventType, knownWebhookEvent, verifyWebhookSignature, unwrapWebhook, signWebhook, ZooworkWebhookError, type WebhookErrorCode, WEBHOOK_ID_HEADER, WEBHOOK_TIMESTAMP_HEADER, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_SECRET_ENV, WEBHOOK_TOLERANCE_SECONDS, WEBHOOK_DEFAULT_MAX_BODY_BYTES, type WebhookEvent, type WebhookEventFor, type KnownWebhookEvent, type WebhookEventData, type WebhookEventDataByType, type WebhookEventAttribution, type WebhookScheduleRunRef, type WebhookWaitingOnRef, type WebhookRunEventData, type WebhookRunStartedData, type WebhookRunFinishedData, type WebhookRunYieldedData, type WebhookApprovalEventData, type WebhookApprovalRequestedData, type WebhookApprovalResolvedData, type WebhookCustomToolEventData, type WebhookCustomToolRequestedData, type WebhookCustomToolResolvedData, type WebhookOutcomeEvaluatedData, type WebhookSessionCreatedData, type WebhookSessionArchivedData, type WebhookSessionDeletedData, type WebhookScheduleFireData, type WebhookScheduleDispatchedData, type WebhookScheduleDispatchFailedData, type WebhookScheduleSkippedData, type WebhookScheduleFinishedData, type WebhookScheduleConfigData, type WebhookTestData, type WebhookHeaders, type WebhookHeaderSource, type WebhookSignatureHeaders, type VerifyWebhookInput, type VerifiedWebhook, type SignWebhookInput, } from './webhooks.js';
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { createZooworkClient, DEFAULT_BASE_URL, ZooworkError, } from './client.js';
|
|
2
2
|
export { SESSION_EVENT_TYPES, PUBLIC_INPUT_EVENT_TYPES, normalizeEvent, isRunFinished, runOutcome, messageText, assistantText, thinkingText, customToolUse, toolCall, } from './events.js';
|
|
3
3
|
export { parseSSE } from './sse.js';
|
|
4
|
+
export { WEBHOOK_EVENT_TYPES, WEBHOOK_SCHEDULE_CONFIG_EVENT_TYPES, isKnownWebhookEventType, knownWebhookEvent, verifyWebhookSignature, unwrapWebhook, signWebhook, ZooworkWebhookError, WEBHOOK_ID_HEADER, WEBHOOK_TIMESTAMP_HEADER, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_SECRET_ENV, WEBHOOK_TOLERANCE_SECONDS, WEBHOOK_DEFAULT_MAX_BODY_BYTES, } from './webhooks.js';
|
package/dist/sse.js
CHANGED
|
@@ -30,36 +30,46 @@ export async function* parseSSE(body) {
|
|
|
30
30
|
dataLines = [];
|
|
31
31
|
return msg;
|
|
32
32
|
};
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
33
|
+
try {
|
|
34
|
+
for (;;) {
|
|
35
|
+
const { done, value } = await reader.read();
|
|
36
|
+
if (done)
|
|
37
|
+
break;
|
|
38
|
+
buf += dec.decode(value, { stream: true });
|
|
39
|
+
let i;
|
|
40
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
41
|
+
const raw = buf.slice(0, i);
|
|
42
|
+
buf = buf.slice(i + 1);
|
|
43
|
+
const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
|
|
44
|
+
if (line === '') {
|
|
45
|
+
const m = flush();
|
|
46
|
+
if (m)
|
|
47
|
+
yield m;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (line.startsWith(':'))
|
|
51
|
+
continue;
|
|
52
|
+
const c = line.indexOf(':');
|
|
53
|
+
const field = c === -1 ? line : line.slice(0, c);
|
|
54
|
+
const val = c === -1 ? '' : line.slice(c + 1).replace(/^ /, '');
|
|
55
|
+
if (field === 'event')
|
|
56
|
+
event = val;
|
|
57
|
+
else if (field === 'data')
|
|
58
|
+
dataLines.push(val);
|
|
59
|
+
else if (field === 'id')
|
|
60
|
+
id = val;
|
|
48
61
|
}
|
|
49
|
-
if (line.startsWith(':'))
|
|
50
|
-
continue;
|
|
51
|
-
const c = line.indexOf(':');
|
|
52
|
-
const field = c === -1 ? line : line.slice(0, c);
|
|
53
|
-
const val = c === -1 ? '' : line.slice(c + 1).replace(/^ /, '');
|
|
54
|
-
if (field === 'event')
|
|
55
|
-
event = val;
|
|
56
|
-
else if (field === 'data')
|
|
57
|
-
dataLines.push(val);
|
|
58
|
-
else if (field === 'id')
|
|
59
|
-
id = val;
|
|
60
62
|
}
|
|
63
|
+
const m = flush();
|
|
64
|
+
if (m)
|
|
65
|
+
yield m;
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
// A session stream outlives each turn. Closing the generator must close its HTTP body.
|
|
69
|
+
try {
|
|
70
|
+
await reader.cancel();
|
|
71
|
+
}
|
|
72
|
+
catch { /* Preserve the original parse/transport failure. */ }
|
|
73
|
+
reader.releaseLock();
|
|
61
74
|
}
|
|
62
|
-
const m = flush();
|
|
63
|
-
if (m)
|
|
64
|
-
yield m;
|
|
65
75
|
}
|
|
@@ -0,0 +1,433 @@
|
|
|
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
|
+
/**
|
|
41
|
+
* Event types the deployed Engine delivers, mirrored from its public projection.
|
|
42
|
+
*
|
|
43
|
+
* A type not in this list is NOT an error — see {@link isKnownWebhookEventType}.
|
|
44
|
+
*/
|
|
45
|
+
export declare const WEBHOOK_EVENT_TYPES: readonly ["run.started", "run.finished", "run.yielded", "approval.requested", "approval.resolved", "custom_tool.requested", "custom_tool.resolved", "outcome.evaluated", "session.created", "session.archived", "session.deleted", "schedule.dispatched", "schedule.dispatch_failed", "schedule.skipped", "schedule.finished", "webhook.test"];
|
|
46
|
+
/**
|
|
47
|
+
* Schedule CONFIGURATION changes, as distinct from the fire events above.
|
|
48
|
+
*
|
|
49
|
+
* Declared here so a receiver can write the subscription and the handler now, but the server
|
|
50
|
+
* side ships with Engine E5a and is NOT emitted yet — the projector currently retires such a
|
|
51
|
+
* receipt as `unknown_source`. Do not read a delivered one of these as evidence the feature is
|
|
52
|
+
* live; check the Engine release notes.
|
|
53
|
+
*/
|
|
54
|
+
export declare const WEBHOOK_SCHEDULE_CONFIG_EVENT_TYPES: readonly ["schedule.created", "schedule.updated", "schedule.paused", "schedule.resumed", "schedule.deleted"];
|
|
55
|
+
export type WebhookEventType = (typeof WEBHOOK_EVENT_TYPES)[number] | (typeof WEBHOOK_SCHEDULE_CONFIG_EVENT_TYPES)[number];
|
|
56
|
+
/**
|
|
57
|
+
* Whether this SDK release knows a type.
|
|
58
|
+
*
|
|
59
|
+
* Engine adds event types within a schema version, so a receiver MUST tolerate one it has never
|
|
60
|
+
* seen: acknowledge it with 2xx and ignore it. Failing an unknown type only makes the sender
|
|
61
|
+
* retry it, and after that, dead-letter it.
|
|
62
|
+
*/
|
|
63
|
+
export declare function isKnownWebhookEventType(type: string): type is WebhookEventType;
|
|
64
|
+
/**
|
|
65
|
+
* Attribution and origin, prepended to every event's `data` by the sender.
|
|
66
|
+
*
|
|
67
|
+
* `resource_access` is `internal_only` when the underlying Session is Engine's own rather than
|
|
68
|
+
* one you created through the public API; such a Session is not readable through the public API,
|
|
69
|
+
* so a lookup after the event would 404. `org_id`/`owner_uid` are the credential the endpoint
|
|
70
|
+
* belongs to — compare them if one process serves several tenants.
|
|
71
|
+
*/
|
|
72
|
+
export interface WebhookEventAttribution {
|
|
73
|
+
org_id: string;
|
|
74
|
+
owner_uid: string;
|
|
75
|
+
/** Absent only on an owner-scope `webhook.test`, which belongs to no agent. */
|
|
76
|
+
agent_id?: string;
|
|
77
|
+
session_origin?: 'api' | 'schedule' | 'channel' | 'internal';
|
|
78
|
+
run_origin?: 'api' | 'channel' | 'none';
|
|
79
|
+
resource_access?: 'public' | 'internal_only';
|
|
80
|
+
}
|
|
81
|
+
/** One schedule fire folded into a run event's summary. */
|
|
82
|
+
export interface WebhookScheduleRunRef {
|
|
83
|
+
/**
|
|
84
|
+
* The public schedule id — the same id you pass to the schedules API routes
|
|
85
|
+
* (`/schedules/{schedule_id}`). A heartbeat fire uses `heartbeat`.
|
|
86
|
+
*/
|
|
87
|
+
schedule_id: string;
|
|
88
|
+
fired_at: string;
|
|
89
|
+
}
|
|
90
|
+
/** What a yielded run is waiting for. */
|
|
91
|
+
export interface WebhookWaitingOnRef {
|
|
92
|
+
kind: 'child_session' | 'async_tool';
|
|
93
|
+
id: string;
|
|
94
|
+
}
|
|
95
|
+
/** Fields the three run events share. */
|
|
96
|
+
export interface WebhookRunEventData extends WebhookEventAttribution {
|
|
97
|
+
session_id: string;
|
|
98
|
+
run_id: string;
|
|
99
|
+
turn?: number;
|
|
100
|
+
/** `public_session_events.id` of the input that started this run — an inbound event id, not a
|
|
101
|
+
* public event id, and absent when nothing inbound triggered the run (a schedule fire). */
|
|
102
|
+
origin_event_id?: string;
|
|
103
|
+
/** `pse1:<seq>` cursor of this fact's session-event projection; feed it to `streamEvents`. */
|
|
104
|
+
event_cursor?: string;
|
|
105
|
+
/** The fires this run served. `schedule_runs_complete: false` means the list was truncated to
|
|
106
|
+
* keep the envelope under its 16 KiB ceiling — read the schedule's runs for the rest. */
|
|
107
|
+
schedule_runs?: WebhookScheduleRunRef[];
|
|
108
|
+
schedule_runs_complete?: false;
|
|
109
|
+
}
|
|
110
|
+
export interface WebhookRunStartedData extends WebhookRunEventData {
|
|
111
|
+
trigger?: string;
|
|
112
|
+
parent_run_id?: string;
|
|
113
|
+
}
|
|
114
|
+
export interface WebhookRunFinishedData extends WebhookRunEventData {
|
|
115
|
+
status?: 'succeeded' | 'failed' | 'aborted';
|
|
116
|
+
terminal_outcome?: string;
|
|
117
|
+
output_type?: string;
|
|
118
|
+
error_class?: string;
|
|
119
|
+
failure_stage?: string;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* A run that stopped to wait, not a run that ended.
|
|
123
|
+
*
|
|
124
|
+
* Only a SUCCESSFUL yield becomes `run.yielded`; a failed or aborted turn stays `run.finished`
|
|
125
|
+
* whatever it was waiting for. So the two are exclusive, and neither implies the other follows.
|
|
126
|
+
*/
|
|
127
|
+
export interface WebhookRunYieldedData extends WebhookRunEventData {
|
|
128
|
+
waiting_on?: WebhookWaitingOnRef[];
|
|
129
|
+
waiting_on_complete?: false;
|
|
130
|
+
}
|
|
131
|
+
export interface WebhookApprovalEventData extends WebhookEventAttribution {
|
|
132
|
+
approval_id: string;
|
|
133
|
+
session_id: string;
|
|
134
|
+
run_id: string;
|
|
135
|
+
event_cursor?: string;
|
|
136
|
+
}
|
|
137
|
+
export interface WebhookApprovalRequestedData extends WebhookApprovalEventData {
|
|
138
|
+
tool_call_id?: string;
|
|
139
|
+
tool_name?: string;
|
|
140
|
+
timeout_at?: string;
|
|
141
|
+
}
|
|
142
|
+
export interface WebhookApprovalResolvedData extends WebhookApprovalEventData {
|
|
143
|
+
/** `expired` is a timeout; a cancellation reads as `denied`. */
|
|
144
|
+
status?: 'approved' | 'denied' | 'expired';
|
|
145
|
+
}
|
|
146
|
+
export interface WebhookCustomToolEventData extends WebhookEventAttribution {
|
|
147
|
+
/** The custom-tool call id — `custom_tool_use_id` when you post the result back. */
|
|
148
|
+
call_id: string;
|
|
149
|
+
session_id: string;
|
|
150
|
+
run_id: string;
|
|
151
|
+
event_cursor?: string;
|
|
152
|
+
}
|
|
153
|
+
export interface WebhookCustomToolRequestedData extends WebhookCustomToolEventData {
|
|
154
|
+
tool_call_id?: string;
|
|
155
|
+
tool_name?: string;
|
|
156
|
+
timeout_at?: string;
|
|
157
|
+
}
|
|
158
|
+
export interface WebhookCustomToolResolvedData extends WebhookCustomToolEventData {
|
|
159
|
+
status?: 'completed' | 'timeout' | 'cancelled';
|
|
160
|
+
}
|
|
161
|
+
export interface WebhookOutcomeEvaluatedData extends WebhookEventAttribution {
|
|
162
|
+
session_id: string;
|
|
163
|
+
run_id: string;
|
|
164
|
+
event_cursor?: string;
|
|
165
|
+
/** 1-based evaluation round of this run. */
|
|
166
|
+
iteration: number;
|
|
167
|
+
verdict: 'satisfied' | 'failed' | 'needs_revision' | 'evaluator_error';
|
|
168
|
+
/** Present only when the verdict was assumed rather than graded (always with
|
|
169
|
+
* `verdict: 'needs_revision'`). */
|
|
170
|
+
skipped?: true;
|
|
171
|
+
grader_type?: string;
|
|
172
|
+
/**
|
|
173
|
+
* The public schedule id of the run's triggering fire, when one triggered it — the same id
|
|
174
|
+
* you pass to the schedules API routes (`/schedules/{schedule_id}`). A heartbeat fire uses
|
|
175
|
+
* `heartbeat`.
|
|
176
|
+
*/
|
|
177
|
+
schedule_id?: string;
|
|
178
|
+
}
|
|
179
|
+
export interface WebhookSessionCreatedData extends WebhookEventAttribution {
|
|
180
|
+
session_id: string;
|
|
181
|
+
parent_session_id?: string;
|
|
182
|
+
}
|
|
183
|
+
export interface WebhookSessionArchivedData extends WebhookEventAttribution {
|
|
184
|
+
session_id: string;
|
|
185
|
+
archived_at?: string;
|
|
186
|
+
}
|
|
187
|
+
export interface WebhookSessionDeletedData extends WebhookEventAttribution {
|
|
188
|
+
session_id: string;
|
|
189
|
+
deleted_at?: string;
|
|
190
|
+
}
|
|
191
|
+
/** Fields every schedule FIRE event shares. `fired_at` identifies the fire. */
|
|
192
|
+
export interface WebhookScheduleFireData extends WebhookEventAttribution {
|
|
193
|
+
/**
|
|
194
|
+
* The public schedule id — the same id you pass to the schedules API routes
|
|
195
|
+
* (`/schedules/{schedule_id}`). A heartbeat fire uses `heartbeat`.
|
|
196
|
+
*/
|
|
197
|
+
schedule_id: string;
|
|
198
|
+
fired_at: string;
|
|
199
|
+
job_kind?: string;
|
|
200
|
+
}
|
|
201
|
+
export interface WebhookScheduleDispatchedData extends WebhookScheduleFireData {
|
|
202
|
+
dispatch_mode?: string;
|
|
203
|
+
session_id?: string;
|
|
204
|
+
}
|
|
205
|
+
export interface WebhookScheduleDispatchFailedData extends WebhookScheduleFireData {
|
|
206
|
+
error_class?: string;
|
|
207
|
+
}
|
|
208
|
+
export interface WebhookScheduleSkippedData extends WebhookScheduleFireData {
|
|
209
|
+
reason?: string;
|
|
210
|
+
}
|
|
211
|
+
export interface WebhookScheduleFinishedData extends WebhookScheduleFireData {
|
|
212
|
+
result?: string;
|
|
213
|
+
exit_code?: number;
|
|
214
|
+
reason?: string;
|
|
215
|
+
session_id?: string;
|
|
216
|
+
run_id?: string;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* A schedule's configuration changed. See {@link WEBHOOK_SCHEDULE_CONFIG_EVENT_TYPES}: declared,
|
|
220
|
+
* not yet delivered.
|
|
221
|
+
*/
|
|
222
|
+
export interface WebhookScheduleConfigData extends WebhookEventAttribution {
|
|
223
|
+
/**
|
|
224
|
+
* The public schedule id — the same id you pass to the schedules API routes
|
|
225
|
+
* (`/schedules/{schedule_id}`). A heartbeat fire uses `heartbeat`.
|
|
226
|
+
*/
|
|
227
|
+
schedule_id: string;
|
|
228
|
+
/** Monotonic per schedule; the ordering to trust, since deliveries can overtake each other. */
|
|
229
|
+
resource_version?: number;
|
|
230
|
+
enabled?: boolean;
|
|
231
|
+
changed_fields?: string[];
|
|
232
|
+
}
|
|
233
|
+
export interface WebhookTestData extends WebhookEventAttribution {
|
|
234
|
+
endpoint_id: string;
|
|
235
|
+
}
|
|
236
|
+
/** Each known type's `data` shape. */
|
|
237
|
+
export interface WebhookEventDataByType {
|
|
238
|
+
'run.started': WebhookRunStartedData;
|
|
239
|
+
'run.finished': WebhookRunFinishedData;
|
|
240
|
+
'run.yielded': WebhookRunYieldedData;
|
|
241
|
+
'approval.requested': WebhookApprovalRequestedData;
|
|
242
|
+
'approval.resolved': WebhookApprovalResolvedData;
|
|
243
|
+
'custom_tool.requested': WebhookCustomToolRequestedData;
|
|
244
|
+
'custom_tool.resolved': WebhookCustomToolResolvedData;
|
|
245
|
+
'outcome.evaluated': WebhookOutcomeEvaluatedData;
|
|
246
|
+
'session.created': WebhookSessionCreatedData;
|
|
247
|
+
'session.archived': WebhookSessionArchivedData;
|
|
248
|
+
'session.deleted': WebhookSessionDeletedData;
|
|
249
|
+
'schedule.dispatched': WebhookScheduleDispatchedData;
|
|
250
|
+
'schedule.dispatch_failed': WebhookScheduleDispatchFailedData;
|
|
251
|
+
'schedule.skipped': WebhookScheduleSkippedData;
|
|
252
|
+
'schedule.finished': WebhookScheduleFinishedData;
|
|
253
|
+
'webhook.test': WebhookTestData;
|
|
254
|
+
'schedule.created': WebhookScheduleConfigData;
|
|
255
|
+
'schedule.updated': WebhookScheduleConfigData;
|
|
256
|
+
'schedule.paused': WebhookScheduleConfigData;
|
|
257
|
+
'schedule.resumed': WebhookScheduleConfigData;
|
|
258
|
+
'schedule.deleted': WebhookScheduleConfigData;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* A known type's `data`, left open at the edges.
|
|
262
|
+
*
|
|
263
|
+
* The named fields are what the server sends today; a field a later Engine release adds reads
|
|
264
|
+
* back as `unknown` instead of failing to compile. Nothing is ever removed or repurposed within
|
|
265
|
+
* a `schema_version`, so a field that IS named stays what it says.
|
|
266
|
+
*/
|
|
267
|
+
export type WebhookEventData<T extends WebhookEventType> = WebhookEventDataByType[T] & Record<string, unknown>;
|
|
268
|
+
/**
|
|
269
|
+
* The delivered envelope, as received.
|
|
270
|
+
*
|
|
271
|
+
* `type` is typed loosely on purpose: a type this release does not know arrives as its raw
|
|
272
|
+
* string rather than as an error. Narrow with {@link knownWebhookEvent} before switching.
|
|
273
|
+
*/
|
|
274
|
+
export interface WebhookEvent {
|
|
275
|
+
object: 'event';
|
|
276
|
+
/** `whe_...`; also the `webhook-id` header, and the idempotency key for your own handler —
|
|
277
|
+
* a retry redelivers the SAME id. */
|
|
278
|
+
id: string;
|
|
279
|
+
type: WebhookEventType | (string & {});
|
|
280
|
+
/**
|
|
281
|
+
* Envelope contract version, `1` today. A bump is announced; additive fields are not.
|
|
282
|
+
*
|
|
283
|
+
* Always an INTEGER value — {@link unwrapWebhook} rejects anything else, so a receiver may
|
|
284
|
+
* compare it or switch on it without rounding first.
|
|
285
|
+
*/
|
|
286
|
+
schema_version: number;
|
|
287
|
+
/** When the FACT happened, ISO-8601. Not when this attempt was sent — that is the
|
|
288
|
+
* `webhook-timestamp` header, which {@link verifyWebhookSignature} returns. */
|
|
289
|
+
created_at: string;
|
|
290
|
+
data: Record<string, unknown>;
|
|
291
|
+
}
|
|
292
|
+
/** One envelope narrowed to a single known type. */
|
|
293
|
+
export type WebhookEventFor<T extends WebhookEventType> = Omit<WebhookEvent, 'type' | 'data'> & {
|
|
294
|
+
type: T;
|
|
295
|
+
data: WebhookEventData<T>;
|
|
296
|
+
};
|
|
297
|
+
/** Every known type as one discriminated union — `switch (event.type)` narrows `event.data`. */
|
|
298
|
+
export type KnownWebhookEvent = {
|
|
299
|
+
[T in WebhookEventType]: WebhookEventFor<T>;
|
|
300
|
+
}[WebhookEventType];
|
|
301
|
+
/**
|
|
302
|
+
* The envelope as a discriminated union, or `undefined` when this release does not know its type.
|
|
303
|
+
*
|
|
304
|
+
* `undefined` is the forward-compatible path, not a failure: acknowledge the delivery anyway.
|
|
305
|
+
*
|
|
306
|
+
* The value is the same object, re-typed — nothing is copied, normalized or validated. The
|
|
307
|
+
* narrowing is on `type` alone; the `data` shape is the contract's, asserted rather than checked,
|
|
308
|
+
* exactly as the rest of this SDK types API responses. That is sound because the body arrived
|
|
309
|
+
* signed: verification already established that Engine wrote it.
|
|
310
|
+
*/
|
|
311
|
+
export declare function knownWebhookEvent(event: WebhookEvent): KnownWebhookEvent | undefined;
|
|
312
|
+
/** Default clock tolerance, in seconds, in EITHER direction and inclusive. */
|
|
313
|
+
export declare const WEBHOOK_TOLERANCE_SECONDS = 300;
|
|
314
|
+
/** The sender's envelope ceiling. A receiver refuses a larger body without hashing it. */
|
|
315
|
+
export declare const WEBHOOK_DEFAULT_MAX_BODY_BYTES: number;
|
|
316
|
+
/**
|
|
317
|
+
* Environment variable read when `secret` is omitted.
|
|
318
|
+
*
|
|
319
|
+
* Holds one secret, or several separated by whitespace or commas for a rotation window. The
|
|
320
|
+
* Python SDK reads it identically, so one deployment's configuration serves both.
|
|
321
|
+
*/
|
|
322
|
+
export declare const WEBHOOK_SECRET_ENV = "ZOOWORK_WEBHOOK_SECRET";
|
|
323
|
+
export declare const WEBHOOK_ID_HEADER = "webhook-id";
|
|
324
|
+
export declare const WEBHOOK_TIMESTAMP_HEADER = "webhook-timestamp";
|
|
325
|
+
export declare const WEBHOOK_SIGNATURE_HEADER = "webhook-signature";
|
|
326
|
+
export type WebhookErrorCode = 'invalid_secret' | 'body_too_large' | 'missing_header' | 'invalid_header' | 'timestamp_out_of_window' | 'signature_mismatch' | 'invalid_payload';
|
|
327
|
+
/**
|
|
328
|
+
* Every verification and unwrap failure.
|
|
329
|
+
*
|
|
330
|
+
* Match on {@link ZooworkWebhookError.code}, never on the message. The message names the failing
|
|
331
|
+
* check and at most a header name — never a secret, a signature value, or body bytes — so it is
|
|
332
|
+
* safe to log and to forward to your own error reporting.
|
|
333
|
+
*
|
|
334
|
+
* Separate from `ZooworkError`, which carries the HTTP `status` of an API call the SDK made.
|
|
335
|
+
* Nothing here is an HTTP response; a receiver decides its own status, and the right one is
|
|
336
|
+
* usually 400 for a bad signature and 2xx for anything it means to ignore.
|
|
337
|
+
*
|
|
338
|
+
* Cross-language note: the `code` strings are the contract and are identical in the Python SDK.
|
|
339
|
+
* The class shape is not — there this is a subclass of that SDK's `ZooworkError`, carrying a 400
|
|
340
|
+
* and `retryable=False`, which suits an exception hierarchy built around one base error. Match on
|
|
341
|
+
* `code`, and do not port `instanceof` checks between the two.
|
|
342
|
+
*/
|
|
343
|
+
export declare class ZooworkWebhookError extends Error {
|
|
344
|
+
code: WebhookErrorCode;
|
|
345
|
+
constructor(code: WebhookErrorCode, message: string);
|
|
346
|
+
}
|
|
347
|
+
/** fetch `Headers`, or anything else with a case-insensitive `get`. */
|
|
348
|
+
export interface WebhookHeaderSource {
|
|
349
|
+
get(name: string): string | null | undefined;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Node `IncomingHttpHeaders` (lower-cased keys, arrays for repeated headers), a plain object in
|
|
353
|
+
* any casing, or a `Headers`-like source.
|
|
354
|
+
*/
|
|
355
|
+
export type WebhookHeaders = Readonly<Record<string, string | readonly string[] | undefined>> | WebhookHeaderSource;
|
|
356
|
+
export interface VerifyWebhookInput {
|
|
357
|
+
headers: WebhookHeaders;
|
|
358
|
+
/** The bytes exactly as received. A string is UTF-8 encoded; never pass re-serialized JSON. */
|
|
359
|
+
rawBody: Uint8Array | string;
|
|
360
|
+
/**
|
|
361
|
+
* The endpoint's `whsec_` secret, or every active secret during a rotation window (order does
|
|
362
|
+
* not matter). A string here is always ONE secret; it is never split.
|
|
363
|
+
*
|
|
364
|
+
* Omitted, it defaults to {@link WEBHOOK_SECRET_ENV} — which MAY list several secrets separated
|
|
365
|
+
* by whitespace or commas, so a rotation needs a configuration change rather than a code
|
|
366
|
+
* change. That variable exists only where `process.env` does, so in a browser, Workers or Deno
|
|
367
|
+
* pass the secret explicitly.
|
|
368
|
+
*/
|
|
369
|
+
secret?: string | readonly string[];
|
|
370
|
+
/** Receiver clock in MILLISECONDS, for tests and for replaying a captured delivery. */
|
|
371
|
+
now?: number;
|
|
372
|
+
/** Defaults to {@link WEBHOOK_TOLERANCE_SECONDS}. Widen it only for a known-skewed clock. */
|
|
373
|
+
toleranceSeconds?: number;
|
|
374
|
+
/** Defaults to {@link WEBHOOK_DEFAULT_MAX_BODY_BYTES}. */
|
|
375
|
+
maxBodyBytes?: number;
|
|
376
|
+
}
|
|
377
|
+
export interface VerifiedWebhook {
|
|
378
|
+
/** `webhook-id`, which equals the envelope's `id`. Deduplicate your handler on it. */
|
|
379
|
+
eventId: string;
|
|
380
|
+
/** `webhook-timestamp`, unix SECONDS of this send attempt. */
|
|
381
|
+
timestamp: number;
|
|
382
|
+
}
|
|
383
|
+
export interface SignWebhookInput {
|
|
384
|
+
eventId: string;
|
|
385
|
+
/** Unix SECONDS. */
|
|
386
|
+
timestamp: number;
|
|
387
|
+
body: Uint8Array | string;
|
|
388
|
+
/** One entry per secret; a rotation window double-signs. */
|
|
389
|
+
secret: string | readonly string[];
|
|
390
|
+
}
|
|
391
|
+
/** The three headers a sender sets. */
|
|
392
|
+
export type WebhookSignatureHeaders = {
|
|
393
|
+
[WEBHOOK_ID_HEADER]: string;
|
|
394
|
+
[WEBHOOK_TIMESTAMP_HEADER]: string;
|
|
395
|
+
[WEBHOOK_SIGNATURE_HEADER]: string;
|
|
396
|
+
};
|
|
397
|
+
/**
|
|
398
|
+
* Verify a delivery and return what its headers asserted.
|
|
399
|
+
*
|
|
400
|
+
* Checks run cheapest-first and fail closed: the secrets decode, the body is within its ceiling,
|
|
401
|
+
* the three headers are present and well-formed, the timestamp is inside the tolerance, and only
|
|
402
|
+
* then is every `v1` entry compared against every secret with `crypto.subtle.verify` (a
|
|
403
|
+
* constant-time comparison). Any one match accepts. The body is never parsed here — an
|
|
404
|
+
* unverified body is not yet data. Throws {@link ZooworkWebhookError} on every failure.
|
|
405
|
+
*/
|
|
406
|
+
export declare function verifyWebhookSignature(input: VerifyWebhookInput): Promise<VerifiedWebhook>;
|
|
407
|
+
/**
|
|
408
|
+
* Verify a delivery and hand back its envelope.
|
|
409
|
+
*
|
|
410
|
+
* Verification runs FIRST; the body is parsed only once it is authenticated, and it is never
|
|
411
|
+
* re-serialized. The structural check is deliberately minimal — `object`, `id`, `type`,
|
|
412
|
+
* `schema_version`, `created_at`, `data` — because Engine adds fields and event types within a
|
|
413
|
+
* schema version, and rejecting one of those would drop a valid delivery. An unrecognized
|
|
414
|
+
* `type` passes straight through; narrow it with {@link knownWebhookEvent}.
|
|
415
|
+
*
|
|
416
|
+
* `schema_version` IS required, although it is the one field a receiver rarely reads: Engine's
|
|
417
|
+
* projector emits it unconditionally, so an envelope without it is not an envelope, and admitting
|
|
418
|
+
* one would leave {@link WebhookEvent} lying about its own type. It must be a number with an
|
|
419
|
+
* INTEGER VALUE — `1` and `1.0` both pass, since JSON has a single numeric type and they are the
|
|
420
|
+
* same number, while `1.5` is rejected because no release could mean it. The Python SDK checks the
|
|
421
|
+
* same six fields and reaches the same verdict on the same envelope.
|
|
422
|
+
*
|
|
423
|
+
* Throws {@link ZooworkWebhookError} with `invalid_payload` when the body is not an envelope,
|
|
424
|
+
* and with the verification codes before that.
|
|
425
|
+
*/
|
|
426
|
+
export declare function unwrapWebhook(input: VerifyWebhookInput): Promise<WebhookEvent>;
|
|
427
|
+
/**
|
|
428
|
+
* Sign a body the way Engine does — the sender's side of the contract.
|
|
429
|
+
*
|
|
430
|
+
* Exported for your own tests: a receiver can build a delivery it controls, rather than pointing
|
|
431
|
+
* its handler at staging to get one. Engine signs real deliveries; nothing here needs to.
|
|
432
|
+
*/
|
|
433
|
+
export declare function signWebhook(input: SignWebhookInput): Promise<WebhookSignatureHeaders>;
|
package/dist/webhooks.js
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
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
|
+
* Declared here so a receiver can write the subscription and the handler now, but the server
|
|
68
|
+
* side ships with Engine E5a and is NOT emitted yet — the projector currently retires such a
|
|
69
|
+
* receipt as `unknown_source`. Do not read a delivered one of these as evidence the feature is
|
|
70
|
+
* live; check the Engine release notes.
|
|
71
|
+
*/
|
|
72
|
+
export const WEBHOOK_SCHEDULE_CONFIG_EVENT_TYPES = [
|
|
73
|
+
'schedule.created',
|
|
74
|
+
'schedule.updated',
|
|
75
|
+
'schedule.paused',
|
|
76
|
+
'schedule.resumed',
|
|
77
|
+
'schedule.deleted',
|
|
78
|
+
];
|
|
79
|
+
const KNOWN_EVENT_TYPES = new Set([
|
|
80
|
+
...WEBHOOK_EVENT_TYPES,
|
|
81
|
+
...WEBHOOK_SCHEDULE_CONFIG_EVENT_TYPES,
|
|
82
|
+
]);
|
|
83
|
+
/**
|
|
84
|
+
* Whether this SDK release knows a type.
|
|
85
|
+
*
|
|
86
|
+
* Engine adds event types within a schema version, so a receiver MUST tolerate one it has never
|
|
87
|
+
* seen: acknowledge it with 2xx and ignore it. Failing an unknown type only makes the sender
|
|
88
|
+
* retry it, and after that, dead-letter it.
|
|
89
|
+
*/
|
|
90
|
+
export function isKnownWebhookEventType(type) {
|
|
91
|
+
return KNOWN_EVENT_TYPES.has(type);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The envelope as a discriminated union, or `undefined` when this release does not know its type.
|
|
95
|
+
*
|
|
96
|
+
* `undefined` is the forward-compatible path, not a failure: acknowledge the delivery anyway.
|
|
97
|
+
*
|
|
98
|
+
* The value is the same object, re-typed — nothing is copied, normalized or validated. The
|
|
99
|
+
* narrowing is on `type` alone; the `data` shape is the contract's, asserted rather than checked,
|
|
100
|
+
* exactly as the rest of this SDK types API responses. That is sound because the body arrived
|
|
101
|
+
* signed: verification already established that Engine wrote it.
|
|
102
|
+
*/
|
|
103
|
+
export function knownWebhookEvent(event) {
|
|
104
|
+
return isKnownWebhookEventType(event.type) ? event : undefined;
|
|
105
|
+
}
|
|
106
|
+
// ── signature verification ─────────────────────────────────────────────────
|
|
107
|
+
const WEBHOOK_SECRET_PREFIX = 'whsec_';
|
|
108
|
+
const WEBHOOK_SECRET_BYTES = 32;
|
|
109
|
+
const WEBHOOK_SIGNATURE_VERSION = 'v1';
|
|
110
|
+
/** Default clock tolerance, in seconds, in EITHER direction and inclusive. */
|
|
111
|
+
export const WEBHOOK_TOLERANCE_SECONDS = 300;
|
|
112
|
+
/** The sender's envelope ceiling. A receiver refuses a larger body without hashing it. */
|
|
113
|
+
export const WEBHOOK_DEFAULT_MAX_BODY_BYTES = 16 * 1024;
|
|
114
|
+
/**
|
|
115
|
+
* Environment variable read when `secret` is omitted.
|
|
116
|
+
*
|
|
117
|
+
* Holds one secret, or several separated by whitespace or commas for a rotation window. The
|
|
118
|
+
* Python SDK reads it identically, so one deployment's configuration serves both.
|
|
119
|
+
*/
|
|
120
|
+
export const WEBHOOK_SECRET_ENV = 'ZOOWORK_WEBHOOK_SECRET';
|
|
121
|
+
export const WEBHOOK_ID_HEADER = 'webhook-id';
|
|
122
|
+
export const WEBHOOK_TIMESTAMP_HEADER = 'webhook-timestamp';
|
|
123
|
+
export const WEBHOOK_SIGNATURE_HEADER = 'webhook-signature';
|
|
124
|
+
const SIGNATURE_BYTES = 32;
|
|
125
|
+
const BASE64_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
126
|
+
const TIMESTAMP_PATTERN = /^(?:0|[1-9][0-9]*)$/;
|
|
127
|
+
/**
|
|
128
|
+
* Every verification and unwrap failure.
|
|
129
|
+
*
|
|
130
|
+
* Match on {@link ZooworkWebhookError.code}, never on the message. The message names the failing
|
|
131
|
+
* check and at most a header name — never a secret, a signature value, or body bytes — so it is
|
|
132
|
+
* safe to log and to forward to your own error reporting.
|
|
133
|
+
*
|
|
134
|
+
* Separate from `ZooworkError`, which carries the HTTP `status` of an API call the SDK made.
|
|
135
|
+
* Nothing here is an HTTP response; a receiver decides its own status, and the right one is
|
|
136
|
+
* usually 400 for a bad signature and 2xx for anything it means to ignore.
|
|
137
|
+
*
|
|
138
|
+
* Cross-language note: the `code` strings are the contract and are identical in the Python SDK.
|
|
139
|
+
* The class shape is not — there this is a subclass of that SDK's `ZooworkError`, carrying a 400
|
|
140
|
+
* and `retryable=False`, which suits an exception hierarchy built around one base error. Match on
|
|
141
|
+
* `code`, and do not port `instanceof` checks between the two.
|
|
142
|
+
*/
|
|
143
|
+
export class ZooworkWebhookError extends Error {
|
|
144
|
+
code;
|
|
145
|
+
constructor(code, message) {
|
|
146
|
+
super(message);
|
|
147
|
+
this.name = 'ZooworkWebhookError';
|
|
148
|
+
this.code = code;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const utf8 = new TextEncoder();
|
|
152
|
+
/**
|
|
153
|
+
* Read an environment variable without assuming a Node runtime — `process` is a ReferenceError,
|
|
154
|
+
* not `undefined`, in a browser.
|
|
155
|
+
*/
|
|
156
|
+
function readEnv(name) {
|
|
157
|
+
const proc = globalThis.process;
|
|
158
|
+
const value = proc?.env?.[name];
|
|
159
|
+
return value === undefined || value === '' ? undefined : value;
|
|
160
|
+
}
|
|
161
|
+
function decodeBase64(encoded) {
|
|
162
|
+
if (!BASE64_PATTERN.test(encoded))
|
|
163
|
+
return undefined;
|
|
164
|
+
let binary;
|
|
165
|
+
try {
|
|
166
|
+
binary = atob(encoded);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return undefined; // wrong length for base64; atob is the only length check there is
|
|
170
|
+
}
|
|
171
|
+
const bytes = new Uint8Array(binary.length);
|
|
172
|
+
for (let i = 0; i < binary.length; i++)
|
|
173
|
+
bytes[i] = binary.charCodeAt(i);
|
|
174
|
+
return bytes;
|
|
175
|
+
}
|
|
176
|
+
function encodeBase64(bytes) {
|
|
177
|
+
let binary = '';
|
|
178
|
+
for (const byte of bytes)
|
|
179
|
+
binary += String.fromCharCode(byte);
|
|
180
|
+
return btoa(binary);
|
|
181
|
+
}
|
|
182
|
+
/** The raw HMAC key behind a `whsec_` secret; rejects anything but the exact format. */
|
|
183
|
+
function decodeSecret(secret) {
|
|
184
|
+
if (typeof secret !== 'string' || !secret.startsWith(WEBHOOK_SECRET_PREFIX)) {
|
|
185
|
+
throw new ZooworkWebhookError('invalid_secret', `webhook secret must start with ${WEBHOOK_SECRET_PREFIX}`);
|
|
186
|
+
}
|
|
187
|
+
const key = decodeBase64(secret.slice(WEBHOOK_SECRET_PREFIX.length));
|
|
188
|
+
if (!key || key.byteLength !== WEBHOOK_SECRET_BYTES) {
|
|
189
|
+
throw new ZooworkWebhookError('invalid_secret', `webhook secret must decode to ${WEBHOOK_SECRET_BYTES} bytes`);
|
|
190
|
+
}
|
|
191
|
+
return key;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Every secret to try, from the argument or from the environment.
|
|
195
|
+
*
|
|
196
|
+
* The ENVIRONMENT variable may hold several secrets separated by whitespace or commas, so a
|
|
197
|
+
* receiver can sit through a rotation window by changing its configuration rather than its code.
|
|
198
|
+
* Splitting is unambiguous because a secret is `whsec_` plus standard base64 of 32 bytes, an
|
|
199
|
+
* alphabet that contains neither a comma nor whitespace. The Python SDK reads the variable the
|
|
200
|
+
* same way, so one deployment's env works for both.
|
|
201
|
+
*
|
|
202
|
+
* An explicitly passed `secret` is taken exactly as given and is never split — a string is one
|
|
203
|
+
* secret, and several go in an array.
|
|
204
|
+
*/
|
|
205
|
+
function resolveSecrets(secret) {
|
|
206
|
+
if (secret === undefined) {
|
|
207
|
+
const configured = readEnv(WEBHOOK_SECRET_ENV);
|
|
208
|
+
const fromEnv = configured === undefined ? [] : configured.split(/[\s,]+/).filter((entry) => entry.length > 0);
|
|
209
|
+
if (fromEnv.length === 0) {
|
|
210
|
+
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)`);
|
|
211
|
+
}
|
|
212
|
+
return fromEnv.map(decodeSecret);
|
|
213
|
+
}
|
|
214
|
+
const secrets = typeof secret === 'string' ? [secret] : secret;
|
|
215
|
+
if (secrets.length === 0) {
|
|
216
|
+
throw new ZooworkWebhookError('invalid_secret', 'at least one webhook secret is required');
|
|
217
|
+
}
|
|
218
|
+
return secrets.map(decodeSecret);
|
|
219
|
+
}
|
|
220
|
+
function toBytes(value) {
|
|
221
|
+
return typeof value === 'string' ? utf8.encode(value) : value;
|
|
222
|
+
}
|
|
223
|
+
/** `UTF8(eventId + "." + timestamp + ".")` followed by the body bytes, unmodified. */
|
|
224
|
+
function signedPayload(eventId, timestamp, body) {
|
|
225
|
+
const prefix = utf8.encode(`${eventId}.${timestamp}.`);
|
|
226
|
+
const payload = new Uint8Array(prefix.byteLength + body.byteLength);
|
|
227
|
+
payload.set(prefix, 0);
|
|
228
|
+
payload.set(body, prefix.byteLength);
|
|
229
|
+
return payload;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Re-state a byte array as something `crypto.subtle` accepts.
|
|
233
|
+
*
|
|
234
|
+
* TypeScript 5.7 made `Uint8Array` generic over its buffer, and `BufferSource` admits only the
|
|
235
|
+
* `ArrayBuffer` instantiation — a `Uint8Array` whose buffer type is still open (which is what a
|
|
236
|
+
* caller's `rawBody` is) does not fit, although WebCrypto reads it perfectly well. Every array
|
|
237
|
+
* that reaches `crypto.subtle` below was allocated in this module, so this asserts nothing that
|
|
238
|
+
* is not already true; it exists so the assertion is written down once rather than four times.
|
|
239
|
+
*/
|
|
240
|
+
function bufferSource(bytes) {
|
|
241
|
+
return bytes;
|
|
242
|
+
}
|
|
243
|
+
function hmacKey(raw, usage) {
|
|
244
|
+
return crypto.subtle.importKey('raw', bufferSource(raw), { name: 'HMAC', hash: 'SHA-256' }, false, [usage]);
|
|
245
|
+
}
|
|
246
|
+
function readHeader(headers, name) {
|
|
247
|
+
if (typeof headers.get === 'function') {
|
|
248
|
+
return headers.get(name) ?? undefined;
|
|
249
|
+
}
|
|
250
|
+
const record = headers;
|
|
251
|
+
const key = Object.keys(record).find((candidate) => candidate.toLowerCase() === name);
|
|
252
|
+
return key === undefined ? undefined : record[key];
|
|
253
|
+
}
|
|
254
|
+
function requireHeader(headers, name) {
|
|
255
|
+
const value = readHeader(headers, name);
|
|
256
|
+
if (value === undefined) {
|
|
257
|
+
throw new ZooworkWebhookError('missing_header', `${name} header is missing`);
|
|
258
|
+
}
|
|
259
|
+
// A repeated header arrives as an array. Picking one would be a guess about which send this
|
|
260
|
+
// is, so a duplicate is refused rather than resolved. The Python SDK refuses it too, on the
|
|
261
|
+
// same reasoning — this is deliberate shared behavior, not an implementation accident.
|
|
262
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
263
|
+
throw new ZooworkWebhookError('invalid_header', `${name} header must be a single non-empty value`);
|
|
264
|
+
}
|
|
265
|
+
return value;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* The `v1` signatures in the header.
|
|
269
|
+
*
|
|
270
|
+
* Every entry must be `<version>,<value>`; a `v1` value must be base64 of 32 bytes. Another
|
|
271
|
+
* version is skipped, not rejected — a future `v2` alongside `v1` must not break a `v1`
|
|
272
|
+
* receiver. A header carrying ONLY non-`v1` entries therefore yields nothing to compare, and
|
|
273
|
+
* verification ends as `signature_mismatch`.
|
|
274
|
+
*/
|
|
275
|
+
function parseSignatureHeader(header) {
|
|
276
|
+
const signatures = [];
|
|
277
|
+
for (const entry of header.split(' ')) {
|
|
278
|
+
if (entry.length === 0)
|
|
279
|
+
continue;
|
|
280
|
+
const separator = entry.indexOf(',');
|
|
281
|
+
if (separator <= 0 || separator === entry.length - 1) {
|
|
282
|
+
throw new ZooworkWebhookError('invalid_header', `${WEBHOOK_SIGNATURE_HEADER} entry must be <version>,<base64>`);
|
|
283
|
+
}
|
|
284
|
+
if (entry.slice(0, separator) !== WEBHOOK_SIGNATURE_VERSION)
|
|
285
|
+
continue;
|
|
286
|
+
const decoded = decodeBase64(entry.slice(separator + 1));
|
|
287
|
+
if (!decoded || decoded.byteLength !== SIGNATURE_BYTES) {
|
|
288
|
+
throw new ZooworkWebhookError('invalid_header', `${WEBHOOK_SIGNATURE_HEADER} ${WEBHOOK_SIGNATURE_VERSION} entry must decode to ${SIGNATURE_BYTES} bytes`);
|
|
289
|
+
}
|
|
290
|
+
signatures.push(decoded);
|
|
291
|
+
}
|
|
292
|
+
return signatures;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Verify a delivery and return what its headers asserted.
|
|
296
|
+
*
|
|
297
|
+
* Checks run cheapest-first and fail closed: the secrets decode, the body is within its ceiling,
|
|
298
|
+
* the three headers are present and well-formed, the timestamp is inside the tolerance, and only
|
|
299
|
+
* then is every `v1` entry compared against every secret with `crypto.subtle.verify` (a
|
|
300
|
+
* constant-time comparison). Any one match accepts. The body is never parsed here — an
|
|
301
|
+
* unverified body is not yet data. Throws {@link ZooworkWebhookError} on every failure.
|
|
302
|
+
*/
|
|
303
|
+
export async function verifyWebhookSignature(input) {
|
|
304
|
+
const secrets = resolveSecrets(input.secret);
|
|
305
|
+
const maxBodyBytes = input.maxBodyBytes ?? WEBHOOK_DEFAULT_MAX_BODY_BYTES;
|
|
306
|
+
const body = toBytes(input.rawBody);
|
|
307
|
+
if (body.byteLength > maxBodyBytes) {
|
|
308
|
+
throw new ZooworkWebhookError('body_too_large', `webhook body exceeds ${maxBodyBytes} bytes`);
|
|
309
|
+
}
|
|
310
|
+
const eventId = requireHeader(input.headers, WEBHOOK_ID_HEADER);
|
|
311
|
+
const rawTimestamp = requireHeader(input.headers, WEBHOOK_TIMESTAMP_HEADER);
|
|
312
|
+
const signatureHeader = requireHeader(input.headers, WEBHOOK_SIGNATURE_HEADER);
|
|
313
|
+
if (!TIMESTAMP_PATTERN.test(rawTimestamp) || !Number.isSafeInteger(Number(rawTimestamp))) {
|
|
314
|
+
throw new ZooworkWebhookError('invalid_header', `${WEBHOOK_TIMESTAMP_HEADER} must be a non-negative integer`);
|
|
315
|
+
}
|
|
316
|
+
const timestamp = Number(rawTimestamp);
|
|
317
|
+
const signatures = parseSignatureHeader(signatureHeader);
|
|
318
|
+
const tolerance = input.toleranceSeconds ?? WEBHOOK_TOLERANCE_SECONDS;
|
|
319
|
+
if (!Number.isSafeInteger(tolerance) || tolerance < 0) {
|
|
320
|
+
throw new TypeError('toleranceSeconds must be a non-negative integer of seconds');
|
|
321
|
+
}
|
|
322
|
+
const nowMs = input.now ?? Date.now();
|
|
323
|
+
if (!Number.isFinite(nowMs)) {
|
|
324
|
+
throw new TypeError('now must be a finite number of milliseconds');
|
|
325
|
+
}
|
|
326
|
+
if (Math.abs(Math.floor(nowMs / 1000) - timestamp) > tolerance) {
|
|
327
|
+
throw new ZooworkWebhookError('timestamp_out_of_window', `${WEBHOOK_TIMESTAMP_HEADER} is more than ${tolerance}s from this clock`);
|
|
328
|
+
}
|
|
329
|
+
const payload = signedPayload(eventId, timestamp, body);
|
|
330
|
+
for (const secret of secrets) {
|
|
331
|
+
const key = await hmacKey(secret, 'verify');
|
|
332
|
+
for (const signature of signatures) {
|
|
333
|
+
if (await crypto.subtle.verify({ name: 'HMAC' }, key, bufferSource(signature), bufferSource(payload))) {
|
|
334
|
+
return { eventId, timestamp };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
throw new ZooworkWebhookError('signature_mismatch', 'no webhook signature matched the configured secrets');
|
|
339
|
+
}
|
|
340
|
+
function isRecord(value) {
|
|
341
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Verify a delivery and hand back its envelope.
|
|
345
|
+
*
|
|
346
|
+
* Verification runs FIRST; the body is parsed only once it is authenticated, and it is never
|
|
347
|
+
* re-serialized. The structural check is deliberately minimal — `object`, `id`, `type`,
|
|
348
|
+
* `schema_version`, `created_at`, `data` — because Engine adds fields and event types within a
|
|
349
|
+
* schema version, and rejecting one of those would drop a valid delivery. An unrecognized
|
|
350
|
+
* `type` passes straight through; narrow it with {@link knownWebhookEvent}.
|
|
351
|
+
*
|
|
352
|
+
* `schema_version` IS required, although it is the one field a receiver rarely reads: Engine's
|
|
353
|
+
* projector emits it unconditionally, so an envelope without it is not an envelope, and admitting
|
|
354
|
+
* one would leave {@link WebhookEvent} lying about its own type. It must be a number with an
|
|
355
|
+
* INTEGER VALUE — `1` and `1.0` both pass, since JSON has a single numeric type and they are the
|
|
356
|
+
* same number, while `1.5` is rejected because no release could mean it. The Python SDK checks the
|
|
357
|
+
* same six fields and reaches the same verdict on the same envelope.
|
|
358
|
+
*
|
|
359
|
+
* Throws {@link ZooworkWebhookError} with `invalid_payload` when the body is not an envelope,
|
|
360
|
+
* and with the verification codes before that.
|
|
361
|
+
*/
|
|
362
|
+
export async function unwrapWebhook(input) {
|
|
363
|
+
await verifyWebhookSignature(input);
|
|
364
|
+
const text = typeof input.rawBody === 'string' ? input.rawBody : new TextDecoder().decode(input.rawBody);
|
|
365
|
+
let parsed;
|
|
366
|
+
try {
|
|
367
|
+
parsed = JSON.parse(text);
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
throw new ZooworkWebhookError('invalid_payload', 'webhook body is not JSON');
|
|
371
|
+
}
|
|
372
|
+
if (!isRecord(parsed)) {
|
|
373
|
+
throw new ZooworkWebhookError('invalid_payload', 'webhook body is not a JSON object');
|
|
374
|
+
}
|
|
375
|
+
const { object, id, type, schema_version: schemaVersion, created_at: createdAt, data } = parsed;
|
|
376
|
+
if (object !== 'event' ||
|
|
377
|
+
typeof id !== 'string' ||
|
|
378
|
+
typeof type !== 'string' ||
|
|
379
|
+
// An integer VALUE, not merely a number. JSON has one numeric type, so `1` and `1.0` are the
|
|
380
|
+
// same number and both pass, while `1.5` is not a version this or any release could mean.
|
|
381
|
+
// `Number.isInteger` also rejects a string `"1"` and the Infinity a JSON overflow literal
|
|
382
|
+
// parses to; the `typeof` is kept for the reader, not for the check.
|
|
383
|
+
!(typeof schemaVersion === 'number' && Number.isInteger(schemaVersion)) ||
|
|
384
|
+
typeof createdAt !== 'string' ||
|
|
385
|
+
!isRecord(data)) {
|
|
386
|
+
throw new ZooworkWebhookError('invalid_payload', 'webhook body is not a webhook event envelope');
|
|
387
|
+
}
|
|
388
|
+
return parsed;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Sign a body the way Engine does — the sender's side of the contract.
|
|
392
|
+
*
|
|
393
|
+
* Exported for your own tests: a receiver can build a delivery it controls, rather than pointing
|
|
394
|
+
* its handler at staging to get one. Engine signs real deliveries; nothing here needs to.
|
|
395
|
+
*/
|
|
396
|
+
export async function signWebhook(input) {
|
|
397
|
+
if (!Number.isSafeInteger(input.timestamp) || input.timestamp < 0) {
|
|
398
|
+
throw new TypeError('webhook timestamp must be a non-negative integer of unix seconds');
|
|
399
|
+
}
|
|
400
|
+
const secrets = resolveSecrets(input.secret);
|
|
401
|
+
const payload = signedPayload(input.eventId, input.timestamp, toBytes(input.body));
|
|
402
|
+
const entries = [];
|
|
403
|
+
for (const secret of secrets) {
|
|
404
|
+
const key = await hmacKey(secret, 'sign');
|
|
405
|
+
const signature = await crypto.subtle.sign({ name: 'HMAC' }, key, bufferSource(payload));
|
|
406
|
+
entries.push(`${WEBHOOK_SIGNATURE_VERSION},${encodeBase64(new Uint8Array(signature))}`);
|
|
407
|
+
}
|
|
408
|
+
return {
|
|
409
|
+
[WEBHOOK_ID_HEADER]: input.eventId,
|
|
410
|
+
[WEBHOOK_TIMESTAMP_HEADER]: String(input.timestamp),
|
|
411
|
+
[WEBHOOK_SIGNATURE_HEADER]: entries.join(' '),
|
|
412
|
+
};
|
|
413
|
+
}
|