@andesine/sdk 0.1.0 → 0.5.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/WEBHOOKS.md ADDED
@@ -0,0 +1,317 @@
1
+ # Webhooks
2
+
3
+ Use `client.webhooks` to manage outgoing Andesine webhooks. Use
4
+ `@andesine/sdk/webhooks` in your receiving server to verify incoming requests.
5
+ These are separate tasks: an Andesine API credential manages the endpoint; its
6
+ `whsec_` signing secret authenticates deliveries. Keep both in server-side storage.
7
+
8
+ ## Configure an endpoint
9
+
10
+ The API key needs `webhooks` and the read permission for each resource type its
11
+ events send (for example `read:entries` for entry events). OAuth also requires an
12
+ explicit workspace and the member's current permissions. The stored scope belongs
13
+ to the workspace, independently of the creator's account.
14
+
15
+ ```ts
16
+ import { createClient } from "@andesine/sdk";
17
+
18
+ const client = createClient({ apiKey: process.env.ANDESINE_API_KEY });
19
+ const { endpoint, secret } = await client.webhooks.create({
20
+ name: "Search index",
21
+ url: "https://receiver.example.com/andesine",
22
+ enabled: false,
23
+ eventTypes: ["entry.content_saved", "entry.deleted"],
24
+ schemaVersion: 1,
25
+ collections: { mode: "all" },
26
+ channels: { mode: "all" },
27
+ restrictedContent: false
28
+ });
29
+ ```
30
+
31
+ Save `secret` in your receiver's secret store. It is shown once. Do not print it
32
+ or place it in frontend code. Webhooks are enabled on creation unless `enabled` is
33
+ `false`; this example enables it after a test. Select specific collection roots
34
+ instead of `all` to receive events only for those subtrees. A resource moved out
35
+ of them is reported without its new location. Restricted content needs
36
+ workspace-wide restricted-content read authority and explicit approval.
37
+
38
+ After deploying the receiver with that secret, send a synthetic test:
39
+
40
+ ```ts
41
+ const { deliveryID } = await client.webhooks.sendTest({
42
+ id: endpoint.id,
43
+ expectedRevision: endpoint.revision,
44
+ type: "entry.content_saved"
45
+ });
46
+ const details = await client.webhooks.getDelivery({ id: endpoint.id, deliveryID });
47
+ // Inspect details again later if details.delivery.state is still pending/in_flight.
48
+ ```
49
+
50
+ Tests contain example resource IDs and `test: true`. They get one attempt, can
51
+ run while disabled, and have a ten-minute queue/completion deadline. Record the
52
+ result, but do not process a test as a real content change. A successful test
53
+ does not enable the endpoint. Enable it explicitly with `update`, using the latest
54
+ `revision`. Configuration conflicts require a fresh `get` and review.
55
+
56
+ The resource provides `list`, `get`, `create`, `update`, `delete`,
57
+ `rotateSecret`, `sendTest`, `listDeliveries`, `getDelivery`, `listRuns`,
58
+ `listAttempts`, and `redeliver`. Lists use the normal cursor/limit pattern. Read
59
+ access permits metadata; payload previews also require content read authority.
60
+
61
+ ## Verify before parsing
62
+
63
+ ```ts
64
+ import { verifyWebhook, WebhookVerificationError, type WebhookEvent } from "@andesine/sdk/webhooks";
65
+
66
+ const event: WebhookEvent = await verifyWebhook({
67
+ body: rawBodyBytes,
68
+ headers: incomingHeaders,
69
+ secret: signingSecret
70
+ });
71
+
72
+ if (!event.test && event.type === "entry.content_saved") {
73
+ // The discriminated union narrows data to content_saved fields.
74
+ console.log(event.data.contentHash);
75
+ }
76
+ ```
77
+
78
+ `body` is a `Uint8Array` (including Node `Buffer`) or unchanged UTF-8 text.
79
+ Never pass parsed JSON, call `JSON.stringify` before verification, or let JSON
80
+ middleware change the body. Byte input is preferred. `headers` accepts standard
81
+ `Headers` or a case-insensitive record, including Node's incoming headers.
82
+ Ambiguous duplicate signature headers are rejected.
83
+
84
+ Both verification helpers return a promise. They authenticate the exact
85
+ `webhook-id.webhook-timestamp.body` bytes with HMAC-SHA256, then parse UTF-8 JSON
86
+ and validate the complete public event schema. The authenticated header ID must
87
+ match `event.id`. Signature comparisons use native Web Crypto. Timestamps more
88
+ than five minutes old or in the future are rejected; keep the receiver clock
89
+ synchronized. There is no option to disable verification or the time check.
90
+ The time check uses the attempt timestamp, not `occurredAt`.
91
+
92
+ The verifier accepts at most 256 KiB and never fetches Andesine or another
93
+ service. `verifyWebhookRequest` consumes the body once, checks its size while
94
+ reading, and passes the bytes to the same verifier. It does not clone the request.
95
+ The receiving server must set its own body-read deadline and request/header limits.
96
+
97
+ `WebhookVerificationError.code` is one of:
98
+
99
+ | Code | Meaning |
100
+ | ------------------------------------------ | ----------------------------------------------------------------------------- |
101
+ | `INVALID_HEADERS` | Required signature headers are missing, malformed, or ambiguous |
102
+ | `INVALID_SIGNATURE` | No supplied v1 signature matches a configured key |
103
+ | `TIMESTAMP_TOO_OLD` / `TIMESTAMP_TOO_NEW` | Attempt timestamp is outside the five-minute window |
104
+ | `INVALID_PAYLOAD` | Authenticated body is not valid UTF-8 JSON or does not match the event schema |
105
+ | `PAYLOAD_TOO_LARGE` | Body exceeds 256 KiB |
106
+ | `UNSUPPORTED_EVENT` / `UNSUPPORTED_SCHEMA` | Authenticated event needs a newer SDK |
107
+ | `EVENT_ID_MISMATCH` | Signed header and payload event IDs differ |
108
+
109
+ Errors contain fixed messages, not the body, secret, or signature. Invalid local
110
+ secret configuration throws `TypeError`; unavailable cryptography or stream
111
+ failures propagate as runtime errors. Do not turn these failures into success.
112
+ Unsupported events need an SDK update before replay. Inspect only error codes in
113
+ logs; do not log the request headers, signing secret, or sensitive payload data.
114
+
115
+ ## Standard Request receiver
116
+
117
+ `accept` below must durably and idempotently store the event before it resolves.
118
+ The PostgreSQL inbox example below shows that contract. Mount this handler only
119
+ at the configured receiver path. Load its expected workspace and secret from
120
+ trusted configuration, not from the incoming event.
121
+
122
+ ```ts
123
+ import {
124
+ verifyWebhookRequest,
125
+ WebhookVerificationError,
126
+ type WebhookEvent,
127
+ type WebhookVerificationOptions
128
+ } from "@andesine/sdk/webhooks";
129
+
130
+ interface ReceiverOptions extends WebhookVerificationOptions {
131
+ workspaceID: string;
132
+ integrationID: string;
133
+ accept: (integrationID: string, event: WebhookEvent) => Promise<void>;
134
+ }
135
+
136
+ export async function receive(request: Request, options: ReceiverOptions): Promise<Response> {
137
+ if (request.method !== "POST") return new Response(null, { status: 405 });
138
+
139
+ try {
140
+ const event = await verifyWebhookRequest(request, options);
141
+
142
+ if (event.workspaceID !== options.workspaceID) return new Response(null, { status: 403 });
143
+
144
+ await options.accept(options.integrationID, event);
145
+ return new Response(null, { status: 204 });
146
+ } catch (error) {
147
+ const status =
148
+ error instanceof WebhookVerificationError
149
+ ? error.code === "PAYLOAD_TOO_LARGE"
150
+ ? 413
151
+ : 400
152
+ : 503;
153
+
154
+ return new Response(null, { status });
155
+ }
156
+ }
157
+ ```
158
+
159
+ ## Node HTTP receiver
160
+
161
+ Use the raw stream before middleware calls `setEncoding` or parses JSON. Configure
162
+ an HTTP request timeout and mount the handler at your receiver route. `options`
163
+ has the same contract as the Request example above.
164
+
165
+ ```ts
166
+ import type { IncomingMessage, ServerResponse } from "node:http";
167
+ import { verifyWebhook, WebhookVerificationError } from "@andesine/sdk/webhooks";
168
+
169
+ async function readRawBody(request: IncomingMessage): Promise<Buffer> {
170
+ const chunks: Buffer[] = [];
171
+
172
+ let size = 0;
173
+
174
+ for await (const chunk of request) {
175
+ if (!Buffer.isBuffer(chunk)) throw new TypeError("Read the request before text middleware");
176
+
177
+ size += chunk.byteLength;
178
+
179
+ if (size > 262_144) throw new WebhookVerificationError("PAYLOAD_TOO_LARGE");
180
+
181
+ chunks.push(chunk);
182
+ }
183
+
184
+ return Buffer.concat(chunks, size);
185
+ }
186
+
187
+ export async function receiveNode(
188
+ request: IncomingMessage,
189
+ response: ServerResponse,
190
+ options: ReceiverOptions
191
+ ): Promise<void> {
192
+ if (request.method !== "POST") {
193
+ response.writeHead(405, { Connection: "close" }).end();
194
+ return;
195
+ }
196
+
197
+ try {
198
+ const body = await readRawBody(request);
199
+ const event = await verifyWebhook({ body, headers: request.headers, secret: options.secret });
200
+
201
+ if (event.workspaceID !== options.workspaceID) {
202
+ response.writeHead(403).end();
203
+ return;
204
+ }
205
+
206
+ await options.accept(options.integrationID, event);
207
+ response.writeHead(204).end();
208
+ } catch (error) {
209
+ const status =
210
+ error instanceof WebhookVerificationError
211
+ ? error.code === "PAYLOAD_TOO_LARGE"
212
+ ? 413
213
+ : 400
214
+ : 503;
215
+
216
+ if (!response.destroyed) response.writeHead(status, { Connection: "close" }).end();
217
+ }
218
+ }
219
+ ```
220
+
221
+ ## Durable acceptance and duplicate events
222
+
223
+ Delivery is at least once. A receiver can commit a change and lose its response;
224
+ Andesine then retries the same event. Manual replay also keeps the event ID.
225
+ Signatures and timestamps change on each attempt and are not deduplication keys.
226
+ There is no ordering guarantee.
227
+
228
+ For a PostgreSQL receiver, use a durable inbox like this in **your** database:
229
+
230
+ ```sql
231
+ CREATE TABLE andesine_inbox (
232
+ integration_id text NOT NULL,
233
+ event_id text NOT NULL,
234
+ event jsonb NOT NULL,
235
+ received_at timestamptz NOT NULL DEFAULT now(),
236
+ processed_at timestamptz,
237
+ PRIMARY KEY (integration_id, event_id)
238
+ );
239
+ ```
240
+
241
+ ```ts
242
+ import type { Pool } from "pg";
243
+ import type { WebhookEvent } from "@andesine/sdk/webhooks";
244
+
245
+ export function createInbox(pool: Pool) {
246
+ return async (integrationID: string, event: WebhookEvent): Promise<void> => {
247
+ await pool.query(
248
+ `INSERT INTO andesine_inbox (integration_id, event_id, event)
249
+ VALUES ($1, $2, $3::jsonb)
250
+ ON CONFLICT (integration_id, event_id) DO NOTHING`,
251
+ [integrationID, event.id, JSON.stringify(event)]
252
+ );
253
+ };
254
+ }
255
+ ```
256
+
257
+ That one statement atomically records acceptance and its deduplication key.
258
+ Return 2xx only after it commits; duplicates also return 2xx. Process the inbox
259
+ with a background worker. Apply local business changes and mark the inbox row
260
+ processed in the **same database transaction**. Skip business changes for `test`
261
+ events. External side effects need their own idempotency key or transactional
262
+ outbox. A signature check alone does not prevent repeated processing.
263
+
264
+ Keep deduplication keys for at least the sender's maximum supported replay period:
265
+ workspace retention defaults to seven days Free or 30 days Pro and can be longer
266
+ on self-hosted systems. Keeping keys indefinitely is also valid. Payloads can be
267
+ removed earlier after processing if their deduplication keys remain. Use the
268
+ configured integration/endpoint and event ID together: two endpoints can receive
269
+ different authorized projections of the same event. Do not deduplicate by
270
+ `operationID`, which can group several distinct events.
271
+
272
+ Events report committed changes, but an API read can already return a newer
273
+ state. Treat events as notifications to refresh state when appropriate. Deleted
274
+ or restricted resources can become unavailable. Publication snapshot references
275
+ can expire under the workspace policy; webhooks do not pin snapshots. Handle
276
+ missing references explicitly.
277
+
278
+ ## Rotation, replay, and recovery
279
+
280
+ Normal rotation returns a new secret once and signs with both old and new keys
281
+ for 24 hours. Deploy the new secret during that window. `secret` may also be an
282
+ array of one or two `whsec_` values during receiver rollout. Remove the old value
283
+ after rollout; an old configured key remains trusted by your receiver until you
284
+ remove it. Immediate rotation and URL changes retire previous signing keys.
285
+ Retire them at the receiver too.
286
+
287
+ To replay, first get the endpoint and show its current URL to the manager. Pass
288
+ its reviewed `destinationRevision` to `redeliver` with the delivery ID. Replay
289
+ requires an enabled endpoint and current authority over its full scope and the
290
+ retained payload. An active run returns a conflict. Replay preserves event ID,
291
+ payload, and original expiry; its retry deadline is at most 72 hours from replay
292
+ and never exceeds that expiry. An expired delivery returns not found.
293
+
294
+ Return 2xx as soon as durable acceptance is complete. Andesine uses a 15-second
295
+ HTTP deadline and retries normal failures within a bounded run. A disabled
296
+ endpoint needs explicit re-enabling. Enabling does not replay past work; inspect
297
+ history and request each needed replay. SDK mutation calls are not automatically
298
+ retried. If a test/replay response is lost, inspect history before repeating it.
299
+
300
+ ## Runtime and packaging
301
+
302
+ The verifier is an isolated ESM entry point. It uses Web Crypto (`crypto.subtle`
303
+ HMAC-SHA256), `TextEncoder`, fatal UTF-8 `TextDecoder`, `atob`/`btoa`, and `Headers`.
304
+ The Request helper also needs the Fetch/Streams APIs. Node.js 22+ supplies these
305
+ APIs. Worker runtimes must supply the same APIs; no Node `Buffer`, backend,
306
+ database, network access, runtime schema compilation, or `eval` is required by
307
+ the verifier. This is a runtime requirement, not a claim that every worker
308
+ platform has been tested. Browsers are not a place to store signing secrets.
309
+
310
+ The Rolldown build generates the payload validator from the checked-in OpenAPI
311
+ event schema through a virtual module and bundles it directly into `dist/webhooks.js`.
312
+ No generated validator JavaScript or declarations are stored in `src/generated`.
313
+ Ajv and ajv-formats are build-time dependencies; their required helpers are
314
+ included in the verifier bundle. The build copies their installed license notices
315
+ into `dist/THIRD_PARTY_LICENSES.txt`, which is included in the published package.
316
+ The root SDK entry point does not import the verifier. Regenerate OpenAPI, the
317
+ SDK, and the CLI manifest together when public contracts change.
@@ -0,0 +1,49 @@
1
+ ajv
2
+
3
+ The MIT License (MIT)
4
+
5
+ Copyright (c) 2015-2021 Evgeny Poberezkin
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
24
+
25
+
26
+
27
+ ajv-formats
28
+
29
+ MIT License
30
+
31
+ Copyright (c) 2020 Evgeny Poberezkin
32
+
33
+ Permission is hereby granted, free of charge, to any person obtaining a copy
34
+ of this software and associated documentation files (the "Software"), to deal
35
+ in the Software without restriction, including without limitation the rights
36
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
37
+ copies of the Software, and to permit persons to whom the Software is
38
+ furnished to do so, subject to the following conditions:
39
+
40
+ The above copyright notice and this permission notice shall be included in all
41
+ copies or substantial portions of the Software.
42
+
43
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
44
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
45
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
46
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
47
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
48
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
49
+ SOFTWARE.