@zoowork-ai/sdk 0.8.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 CHANGED
@@ -3,6 +3,47 @@
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
+
6
47
  ## 0.8.0 — 2026-09-20
7
48
 
8
49
  ### Added
package/README.md CHANGED
@@ -10,7 +10,9 @@ npm install @zoowork-ai/sdk
10
10
 
11
11
  ## Quickstart
12
12
 
13
- You need an API key (`zct_...`) issued for your organization — create one in the ZooWork App under **Settings → API Keys** (any personal org; enterprise orgs need the admin role), or ask your org admin for one. The secret is shown exactly once at creation. Keep it server-side: it authenticates as your whole organization, not as one end user.
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'
@@ -285,6 +287,101 @@ activation on its own — moving it is one explicit call, `upgradeSystemPrompt`,
285
287
  the agent's current `config_version` as a CAS (`409 config_version_changed` on a stale one)
286
288
  and answers the new pin plus the version bump it cost.
287
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
+
288
385
  ## Two helpers
289
386
 
290
387
  ```ts
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
- for (;;) {
34
- const { done, value } = await reader.read();
35
- if (done)
36
- break;
37
- buf += dec.decode(value, { stream: true });
38
- let i;
39
- while ((i = buf.indexOf('\n')) >= 0) {
40
- const raw = buf.slice(0, i);
41
- buf = buf.slice(i + 1);
42
- const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
43
- if (line === '') {
44
- const m = flush();
45
- if (m)
46
- yield m;
47
- continue;
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>;
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zoowork-ai/sdk",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "TypeScript SDK for the ZooWork Managed Agents API (Developer Preview)",
5
5
  "keywords": [
6
6
  "zoowork",