@samva/webhooks 0.13.0 → 0.15.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@samva/webhooks",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Runtime-neutral verification for Samva webhook deliveries",
5
5
  "keywords": [
6
6
  "samva",
@@ -46,6 +46,10 @@
46
46
  "./protocol": {
47
47
  "types": "./dist/protocol.d.ts",
48
48
  "import": "./dist/protocol.js"
49
+ },
50
+ "./schemas": {
51
+ "types": "./dist/schemas.gen.d.ts",
52
+ "import": "./dist/schemas.gen.js"
49
53
  }
50
54
  },
51
55
  "publishConfig": {
@@ -54,11 +58,17 @@
54
58
  "scripts": {
55
59
  "build": "tsdown && tsc --project tsconfig.build.json",
56
60
  "prepack": "bun run build",
57
- "test": "vitest run --root ../.. --project packages packages/webhooks/tests",
58
- "typecheck": "tsc --noEmit"
61
+ "test": "bun run test:generation && vitest run --root ../.. --project packages packages/webhooks/tests",
62
+ "typecheck": "tsc --noEmit",
63
+ "generate": "bun run scripts/generate.ts",
64
+ "test:generation": "bun run scripts/generate.ts --check",
65
+ "test:bundle": "bun run build && bun run scripts/check-bundle.ts"
59
66
  },
60
67
  "devDependencies": {
61
68
  "@effect/vitest": "4.0.0",
69
+ "@samva/core": "0.0.0",
70
+ "ajv": "^8.20.0",
71
+ "ajv-formats": "^3.0.1",
62
72
  "effect": "4.0.0",
63
73
  "tsdown": "^0.22.13",
64
74
  "typescript": "^7.0.2",
@@ -1,333 +0,0 @@
1
- //#region src/errors.ts
2
- /**
3
- * Base class for every failure this package reports while verifying a webhook delivery.
4
- *
5
- * Each subclass carries a literal `_tag` (also used as `name`), so you can branch on the failure
6
- * with a `switch` or Effect's `catchTag`. `detail` is a human-readable reason for your logs; do not
7
- * echo it to the sender, and do not match on its wording.
8
- */
9
- var WebhookVerificationError = class extends Error {
10
- /** Discriminant naming the concrete failure; equal to the subclass name. */
11
- _tag;
12
- /** Human-readable reason for logs. The wording is not a stable contract. */
13
- detail;
14
- constructor(tag, options) {
15
- super(options.detail);
16
- this.name = tag;
17
- this._tag = tag;
18
- this.detail = options.detail;
19
- }
20
- };
21
- /**
22
- * The signing secret is unusable: it lacks the `whsec_` prefix, the remainder is not Base64, or
23
- * the decoded key length is outside the accepted range (`detail` names the range). This is a
24
- * configuration error on the receiving side, not a bad delivery; copy the endpoint's signing
25
- * secret from Samva again.
26
- */
27
- var InvalidWebhookSecretError = class extends WebhookVerificationError {
28
- constructor(options) {
29
- super("InvalidWebhookSecretError", options);
30
- }
31
- };
32
- /**
33
- * One of the `webhook-id`, `webhook-timestamp`, or `webhook-signature` headers is missing or
34
- * empty; `header` names it. The request is not a Samva delivery, or something between Samva and
35
- * your handler stripped the headers.
36
- */
37
- var InvalidWebhookHeadersError = class extends WebhookVerificationError {
38
- /** The first signing header, in id, timestamp, signature order, that is missing or empty. */
39
- header;
40
- constructor(options) {
41
- super("InvalidWebhookHeadersError", options);
42
- this.header = options.header;
43
- }
44
- };
45
- /**
46
- * The `webhook-timestamp` header is not an integer count of Unix seconds, it lies further from the
47
- * current time than the replay tolerance, or the tolerance you passed is negative or not finite.
48
- * A delivery outside the tolerance is rejected even when its signature is valid; that bounds how
49
- * long a captured request can be replayed. Repeated failures on genuine deliveries usually mean
50
- * the receiving server's clock is wrong.
51
- */
52
- var WebhookTimestampError = class extends WebhookVerificationError {
53
- constructor(options) {
54
- super("WebhookTimestampError", options);
55
- }
56
- };
57
- /**
58
- * No signature in the `webhook-signature` header matches the delivery, or no verification key was
59
- * supplied. Treat the request as forged and reject it. When genuine deliveries fail, the usual
60
- * causes are the wrong endpoint secret or a body that was parsed and re-serialized before
61
- * verification instead of passed through as the exact bytes received.
62
- */
63
- var WebhookSignatureError = class extends WebhookVerificationError {
64
- constructor(options) {
65
- super("WebhookSignatureError", options);
66
- }
67
- };
68
- /**
69
- * The signature is valid, but the body is not UTF-8 JSON holding exactly the `type`,
70
- * `timestamp`, and `data` fields of a Samva event envelope.
71
- */
72
- var MalformedWebhookPayloadError = class extends WebhookVerificationError {
73
- constructor(options) {
74
- super("MalformedWebhookPayloadError", options);
75
- }
76
- };
77
- /**
78
- * The runtime's Web Crypto implementation could not import a key or compute a signature, none of
79
- * the supplied public keys could be imported, or the Effect entrypoint could not read the request
80
- * body. Points at the runtime or its configuration rather than at the delivery.
81
- */
82
- var WebhookCryptoError = class extends WebhookVerificationError {
83
- constructor(options) {
84
- super("WebhookCryptoError", options);
85
- }
86
- };
87
- //#endregion
88
- //#region src/protocol.ts
89
- /**
90
- * Header carrying the delivery's unique message ID. Retries of the same delivery reuse it, so it
91
- * is the key for deduplicating deliveries you have already handled.
92
- */
93
- const WEBHOOK_ID_HEADER = "webhook-id";
94
- /** Header carrying the time Samva signed the delivery, as integer Unix seconds. */
95
- const WEBHOOK_TIMESTAMP_HEADER = "webhook-timestamp";
96
- /**
97
- * Header carrying one or more space-separated `<version>,<base64 signature>` entries. More than one
98
- * entry appears while a signing secret is being rotated; a delivery verifies when any entry of the
99
- * expected version matches.
100
- */
101
- const WEBHOOK_SIGNATURE_HEADER = "webhook-signature";
102
- /**
103
- * Replay tolerance, in seconds, used when a caller passes no `toleranceSeconds`. A delivery whose
104
- * `webhook-timestamp` differs from the current time by more than this, in either direction, is
105
- * rejected with {@link WebhookTimestampError}.
106
- */
107
- const DEFAULT_TOLERANCE_SECONDS = 300;
108
- const SECRET_PREFIX = "whsec_";
109
- const ASYMMETRIC_ALGORITHM = {
110
- name: "RSASSA-PKCS1-v1_5",
111
- hash: "SHA-256"
112
- };
113
- const MINIMUM_SECRET_BYTES = 24;
114
- const MAXIMUM_SECRET_BYTES = 64;
115
- const GENERATED_SECRET_BYTES = 32;
116
- const encoder = new TextEncoder();
117
- const toBytes = (body) => typeof body === "string" ? encoder.encode(body) : body;
118
- const toCryptoBytes = (bytes) => new Uint8Array(bytes);
119
- const bytesToBase64 = (bytes) => {
120
- let binary = "";
121
- for (const byte of bytes) binary += String.fromCharCode(byte);
122
- return btoa(binary);
123
- };
124
- const base64ToBytes = (encoded) => {
125
- const binary = atob(encoded);
126
- return Uint8Array.from(binary, (character) => character.charCodeAt(0));
127
- };
128
- const decodeSecret = (secret) => {
129
- if (!secret.startsWith(SECRET_PREFIX)) throw new InvalidWebhookSecretError({ detail: "Webhook secret must start with whsec_" });
130
- const decoded = (() => {
131
- try {
132
- return base64ToBytes(secret.slice(6));
133
- } catch {
134
- throw new InvalidWebhookSecretError({ detail: "Webhook secret is not valid Base64" });
135
- }
136
- })();
137
- if (decoded.byteLength < MINIMUM_SECRET_BYTES || decoded.byteLength > MAXIMUM_SECRET_BYTES) throw new InvalidWebhookSecretError({ detail: `Webhook secret must decode to ${MINIMUM_SECRET_BYTES}-${MAXIMUM_SECRET_BYTES} bytes` });
138
- return decoded;
139
- };
140
- const importHmacKey = async (secret) => {
141
- const secretBytes = decodeSecret(secret);
142
- try {
143
- return await crypto.subtle.importKey("raw", toCryptoBytes(secretBytes), {
144
- name: "HMAC",
145
- hash: "SHA-256"
146
- }, false, ["sign"]);
147
- } catch (cause) {
148
- throw new WebhookCryptoError({ detail: `Unable to import webhook signing key: ${cause instanceof Error ? cause.message : String(cause)}` });
149
- }
150
- };
151
- const importVerificationKey = async (publicKey) => {
152
- try {
153
- return await crypto.subtle.importKey("spki", toCryptoBytes(base64ToBytes(publicKey.trim())), ASYMMETRIC_ALGORITHM, false, ["verify"]);
154
- } catch {
155
- return;
156
- }
157
- };
158
- const signedBytes = (id, timestamp, body) => {
159
- const prefix = encoder.encode(`${id}.${timestamp}.`);
160
- const material = new Uint8Array(prefix.byteLength + body.byteLength);
161
- material.set(prefix);
162
- material.set(body, prefix.byteLength);
163
- return material;
164
- };
165
- const hmac = async (secret, id, timestamp, body) => {
166
- const key = await importHmacKey(secret);
167
- try {
168
- return new Uint8Array(await crypto.subtle.sign("HMAC", key, toCryptoBytes(signedBytes(id, timestamp, body))));
169
- } catch (cause) {
170
- throw new WebhookCryptoError({ detail: `Unable to sign webhook: ${cause instanceof Error ? cause.message : String(cause)}` });
171
- }
172
- };
173
- const asymmetricMatch = async (key, candidate, content) => {
174
- try {
175
- return await crypto.subtle.verify(ASYMMETRIC_ALGORITHM, key, toCryptoBytes(candidate), content);
176
- } catch {
177
- return false;
178
- }
179
- };
180
- const timingSafeEqual = (left, right) => {
181
- if (left.byteLength !== right.byteLength) return false;
182
- let difference = 0;
183
- for (let index = 0; index < left.byteLength; index += 1) difference |= (left[index] ?? 0) ^ (right[index] ?? 0);
184
- return difference === 0;
185
- };
186
- const signatureCandidates = (header, expected) => header.split(" ").map((candidate) => candidate.split(",", 2)).flatMap(([version, encoded]) => {
187
- if (version !== expected || encoded === void 0) return [];
188
- try {
189
- return [base64ToBytes(encoded)];
190
- } catch {
191
- return [];
192
- }
193
- });
194
- const parseEvent = (body) => {
195
- try {
196
- const parsed = JSON.parse(new TextDecoder("utf-8", {
197
- fatal: true,
198
- ignoreBOM: false
199
- }).decode(body));
200
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new MalformedWebhookPayloadError({ detail: "Webhook event must be an object" });
201
- const event = parsed;
202
- const keys = Object.keys(event);
203
- if (keys.length !== 3 || !keys.every((key) => key === "type" || key === "timestamp" || key === "data") || typeof event.type !== "string" || typeof event.timestamp !== "string" || !("data" in event)) throw new MalformedWebhookPayloadError({ detail: "Webhook event has an invalid envelope" });
204
- return {
205
- type: event.type,
206
- timestamp: event.timestamp,
207
- data: event.data
208
- };
209
- } catch {
210
- throw new MalformedWebhookPayloadError({ detail: "Webhook body must be valid UTF-8 JSON containing type, timestamp, and data" });
211
- }
212
- };
213
- /**
214
- * Creates a random `whsec_`-prefixed signing secret from the runtime's secure random source.
215
- * Samva issues each endpoint's secret for you; use this to sign test deliveries with
216
- * {@link signWebhook}.
217
- */
218
- const generateWebhookSecret = () => {
219
- const bytes = crypto.getRandomValues(new Uint8Array(GENERATED_SECRET_BYTES));
220
- return `${SECRET_PREFIX}${bytesToBase64(bytes)}`;
221
- };
222
- /**
223
- * Builds the bytes every signature version covers: `<id>.<timestamp>.<body>`. Use it to verify a
224
- * signature with your own crypto code; {@link verifyWebhookAt} and
225
- * {@link verifyWebhookAtWithPublicKeys} build it for you.
226
- */
227
- const webhookSignedContent = (input) => signedBytes(input.id, input.timestamp, toBytes(input.body));
228
- /** Formats raw signature bytes as one `<version>,<base64>` entry of the `webhook-signature` header. */
229
- const encodeWebhookSignature = (version, signature) => `${version},${bytesToBase64(signature)}`;
230
- /**
231
- * Computes the `v1` HMAC-SHA256 signature Samva sends, formatted as one `webhook-signature`
232
- * entry. Use it to produce signed test deliveries for your handler.
233
- *
234
- * `timestamp` is Unix seconds; a number is stringified, and the verifier requires an integer.
235
- * Rejects with {@link InvalidWebhookSecretError} for an unusable secret and
236
- * {@link WebhookCryptoError} when Web Crypto fails.
237
- */
238
- const signWebhook = async (input) => {
239
- const timestamp = String(input.timestamp);
240
- const signature = await hmac(input.secret, input.id, timestamp, toBytes(input.body));
241
- return encodeWebhookSignature("v1", signature);
242
- };
243
- const requireHeader = (header, value) => {
244
- if (value === null || value === void 0) throw new InvalidWebhookHeadersError({
245
- header,
246
- detail: `${header} header is missing`
247
- });
248
- if (value.length === 0) throw new InvalidWebhookHeadersError({
249
- header,
250
- detail: `${header} header is empty`
251
- });
252
- return value;
253
- };
254
- const requireHeaders = (values) => ({
255
- id: requireHeader(WEBHOOK_ID_HEADER, values.id),
256
- timestamp: requireHeader(WEBHOOK_TIMESTAMP_HEADER, values.timestamp),
257
- signature: requireHeader(WEBHOOK_SIGNATURE_HEADER, values.signature)
258
- });
259
- const freshHeaders = (input) => {
260
- const { id, timestamp, signature } = requireHeaders(input.headers);
261
- const timestampSeconds = Number(timestamp);
262
- if (!Number.isInteger(timestampSeconds)) throw new WebhookTimestampError({ detail: "webhook-timestamp must be Unix seconds" });
263
- const toleranceSeconds = input.toleranceSeconds ?? 300;
264
- if (!Number.isFinite(toleranceSeconds) || toleranceSeconds < 0) throw new WebhookTimestampError({ detail: "Webhook tolerance must be non-negative" });
265
- if (Math.abs(input.nowMilliseconds / 1e3 - timestampSeconds) > toleranceSeconds) throw new WebhookTimestampError({ detail: "Webhook timestamp is outside the replay tolerance" });
266
- return {
267
- id,
268
- timestamp,
269
- signature
270
- };
271
- };
272
- /**
273
- * Verifies a `v1` (HMAC-SHA256) delivery against an explicit current time. The `verify` and
274
- * `verifyRequest` entry points call this with the current clock; call it directly when your
275
- * framework owns the clock or a test needs a fixed time.
276
- *
277
- * Checks, in order: all three headers are present and non-empty; the timestamp is integer Unix seconds within
278
- * `toleranceSeconds` of `nowMilliseconds`; some `v1` entry in the signature header matches, using
279
- * a constant-time comparison; the body is a Samva event envelope. The replay window rejects stale
280
- * deliveries but does not detect a repeat inside it; deduplicate on the returned `id`.
281
- *
282
- * Rejects with {@link InvalidWebhookHeadersError}, {@link WebhookTimestampError},
283
- * {@link InvalidWebhookSecretError}, {@link WebhookSignatureError},
284
- * {@link MalformedWebhookPayloadError}, or {@link WebhookCryptoError}.
285
- */
286
- const verifyWebhookAt = async (input) => {
287
- const { id, timestamp, signature } = freshHeaders(input);
288
- const body = toBytes(input.body);
289
- const expected = await hmac(input.secret, id, timestamp, body);
290
- if (!signatureCandidates(signature, "v1").some((candidate) => timingSafeEqual(expected, candidate))) throw new WebhookSignatureError({ detail: "Webhook signature verification failed" });
291
- return {
292
- id,
293
- event: parseEvent(body)
294
- };
295
- };
296
- /**
297
- * Verifies a `v2` (RSASSA-PKCS1-v1_5, SHA-256) delivery against one or more public keys, each a
298
- * Base64-encoded SPKI DER key. Pass several keys during a key rotation; the delivery verifies when
299
- * any `v2` signature entry matches any key.
300
- *
301
- * Header, timestamp, and envelope checks match {@link verifyWebhookAt}. Keys that fail to import
302
- * are skipped. Rejects with {@link WebhookSignatureError} when `publicKeys` is empty or nothing
303
- * matches, and {@link WebhookCryptoError} when none of the keys imports.
304
- */
305
- const verifyWebhookAtWithPublicKeys = async (input) => {
306
- const { id, timestamp, signature } = freshHeaders(input);
307
- if (input.publicKeys.length === 0) throw new WebhookSignatureError({ detail: "No webhook verification key is available" });
308
- const keys = (await Promise.all(input.publicKeys.map(importVerificationKey))).filter((key) => key !== void 0);
309
- if (keys.length === 0) throw new WebhookCryptoError({ detail: "Unable to import any webhook verification key: keys must be Base64 SPKI DER" });
310
- const body = toBytes(input.body);
311
- const content = toCryptoBytes(signedBytes(id, timestamp, body));
312
- const candidates = signatureCandidates(signature, "v2");
313
- if (!(await Promise.all(keys.flatMap((key) => candidates.map((candidate) => asymmetricMatch(key, candidate, content))))).includes(true)) throw new WebhookSignatureError({ detail: "Webhook signature verification failed" });
314
- return {
315
- id,
316
- event: parseEvent(body)
317
- };
318
- };
319
- /**
320
- * Reads the three signing headers from a Fetch API `Headers` object, for code that needs a header
321
- * value, such as the timestamp, before or beside verification. Throws
322
- * {@link InvalidWebhookHeadersError}, naming the header, when one is missing or empty; call it
323
- * inside the same error handling as the verification that follows.
324
- */
325
- const readWebhookHeaders = (headers) => requireHeaders({
326
- id: headers.get(WEBHOOK_ID_HEADER),
327
- timestamp: headers.get(WEBHOOK_TIMESTAMP_HEADER),
328
- signature: headers.get(WEBHOOK_SIGNATURE_HEADER)
329
- });
330
- //#endregion
331
- export { WebhookTimestampError as _, encodeWebhookSignature as a, signWebhook as c, webhookSignedContent as d, InvalidWebhookHeadersError as f, WebhookSignatureError as g, WebhookCryptoError as h, WEBHOOK_TIMESTAMP_HEADER as i, verifyWebhookAt as l, MalformedWebhookPayloadError as m, WEBHOOK_ID_HEADER as n, generateWebhookSecret as o, InvalidWebhookSecretError as p, WEBHOOK_SIGNATURE_HEADER as r, readWebhookHeaders as s, DEFAULT_TOLERANCE_SECONDS as t, verifyWebhookAtWithPublicKeys as u, WebhookVerificationError as v };
332
-
333
- //# sourceMappingURL=protocol-BGKUmvlI.js.map