hono-webhooks 0.5.0 → 0.6.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/README.md +262 -37
- package/dist/cli.js +5632 -5338
- package/dist/index.d.ts +69 -11
- package/dist/index.js +60 -19
- package/dist/otel.d.ts +9 -5
- package/dist/otel.js +8 -5
- package/dist/signatures.d.ts +64 -0
- package/dist/signatures.js +171 -24
- package/package.json +2 -2
- package/src/cli-commands.ts +198 -169
- package/src/cli.ts +1 -3
- package/src/index.ts +141 -31
- package/src/otel.ts +17 -9
- package/src/signatures.ts +277 -33
package/dist/signatures.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// src/signatures.ts
|
|
2
2
|
var encoder = new TextEncoder;
|
|
3
|
+
var reject = (input, reason) => input.fail?.(reason) ?? false;
|
|
3
4
|
function hmacKey(secret, hash) {
|
|
4
5
|
if (!secret.length) {
|
|
5
6
|
throw new Error("hono-webhooks: the signing secret is empty");
|
|
@@ -29,19 +30,23 @@ function fromBase64(base64) {
|
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
32
|
var resolveSecret = async (secret, c) => typeof secret === "function" ? secret(c) : secret;
|
|
33
|
+
var signedUrls = async (url, c) => [await url?.(c) ?? c.req.url].flat();
|
|
34
|
+
var outsideTolerance = (seconds, toleranceSeconds) => Math.abs(Date.now() / 1000 - seconds) > toleranceSeconds;
|
|
32
35
|
function hmac(options) {
|
|
33
36
|
const { header, encoding, prefix = "", hash = "SHA-256" } = options;
|
|
34
37
|
const name = header.toLowerCase();
|
|
35
38
|
return {
|
|
36
|
-
verify: (secret) => async (
|
|
37
|
-
const value = headers[name];
|
|
38
|
-
if (!value
|
|
39
|
-
return
|
|
39
|
+
verify: (secret) => async (input) => {
|
|
40
|
+
const value = input.headers[name];
|
|
41
|
+
if (!value)
|
|
42
|
+
return reject(input, "missing-header");
|
|
43
|
+
if (!value.startsWith(prefix))
|
|
44
|
+
return reject(input, "malformed");
|
|
40
45
|
const encoded = value.slice(prefix.length);
|
|
41
46
|
const signature = encoding === "hex" ? fromHex(encoded) : fromBase64(encoded);
|
|
42
47
|
if (!signature)
|
|
43
|
-
return
|
|
44
|
-
return hmacVerify(await resolveSecret(secret, c), rawBody, signature, hash);
|
|
48
|
+
return reject(input, "malformed");
|
|
49
|
+
return await hmacVerify(await resolveSecret(secret, input.c), input.rawBody, signature, hash) || reject(input, "mismatch");
|
|
45
50
|
},
|
|
46
51
|
async sign(rawBody, secret) {
|
|
47
52
|
const signature = await hmacSign(secret, rawBody, hash);
|
|
@@ -56,27 +61,31 @@ var github = hmac({
|
|
|
56
61
|
encoding: "hex",
|
|
57
62
|
prefix: "sha256="
|
|
58
63
|
});
|
|
64
|
+
var meta = github;
|
|
59
65
|
var shopify = hmac({
|
|
60
66
|
header: "x-shopify-hmac-sha256",
|
|
61
67
|
encoding: "base64"
|
|
62
68
|
});
|
|
63
69
|
var stripe = {
|
|
64
|
-
verify: (secret, { toleranceSeconds = 300 } = {}) => async (
|
|
65
|
-
const
|
|
70
|
+
verify: (secret, { toleranceSeconds = 300 } = {}) => async (input) => {
|
|
71
|
+
const header = input.headers["stripe-signature"];
|
|
72
|
+
if (!header)
|
|
73
|
+
return reject(input, "missing-header");
|
|
74
|
+
const parts = header.split(",").map((part) => part.split("="));
|
|
66
75
|
const timestamp = parts.find(([k]) => k === "t")?.[1] ?? "";
|
|
67
76
|
if (!/^\d+$/.test(timestamp))
|
|
68
|
-
return
|
|
69
|
-
if (
|
|
70
|
-
return
|
|
77
|
+
return reject(input, "malformed");
|
|
78
|
+
if (outsideTolerance(Number(timestamp), toleranceSeconds)) {
|
|
79
|
+
return reject(input, "timestamp-out-of-tolerance");
|
|
71
80
|
}
|
|
72
|
-
const key = await resolveSecret(secret, c);
|
|
81
|
+
const key = await resolveSecret(secret, input.c);
|
|
73
82
|
for (const [k, v] of parts) {
|
|
74
83
|
const signature = k === "v1" && v ? fromHex(v) : undefined;
|
|
75
|
-
if (signature && await hmacVerify(key, `${timestamp}.${rawBody}`, signature)) {
|
|
84
|
+
if (signature && await hmacVerify(key, `${timestamp}.${input.rawBody}`, signature)) {
|
|
76
85
|
return true;
|
|
77
86
|
}
|
|
78
87
|
}
|
|
79
|
-
return
|
|
88
|
+
return reject(input, "mismatch");
|
|
80
89
|
},
|
|
81
90
|
async sign(rawBody, secret, { timestamp } = {}) {
|
|
82
91
|
const t = timestamp ?? Math.floor(Date.now() / 1000);
|
|
@@ -92,25 +101,29 @@ function svixKey(secret) {
|
|
|
92
101
|
return key;
|
|
93
102
|
}
|
|
94
103
|
var svix = {
|
|
95
|
-
verify: (secret, { toleranceSeconds = 300 } = {}) => async (
|
|
104
|
+
verify: (secret, { toleranceSeconds = 300 } = {}) => async (input) => {
|
|
105
|
+
const { headers } = input;
|
|
96
106
|
const prefix = headers["svix-id"] ? "svix" : "webhook";
|
|
97
107
|
const id = headers[`${prefix}-id`];
|
|
98
|
-
const timestamp = headers[`${prefix}-timestamp`]
|
|
108
|
+
const timestamp = headers[`${prefix}-timestamp`];
|
|
99
109
|
const signatures = headers[`${prefix}-signature`];
|
|
100
|
-
if (!id || !signatures ||
|
|
101
|
-
return
|
|
102
|
-
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
|
|
103
|
-
return false;
|
|
110
|
+
if (!id || !signatures || !timestamp) {
|
|
111
|
+
return reject(input, "missing-header");
|
|
104
112
|
}
|
|
105
|
-
|
|
113
|
+
if (!/^\d+$/.test(timestamp))
|
|
114
|
+
return reject(input, "malformed");
|
|
115
|
+
if (outsideTolerance(Number(timestamp), toleranceSeconds)) {
|
|
116
|
+
return reject(input, "timestamp-out-of-tolerance");
|
|
117
|
+
}
|
|
118
|
+
const key = svixKey(await resolveSecret(secret, input.c));
|
|
106
119
|
for (const entry of signatures.split(" ")) {
|
|
107
120
|
const [version, encoded] = entry.split(",");
|
|
108
121
|
const signature = version === "v1" && encoded ? fromBase64(encoded) : undefined;
|
|
109
|
-
if (signature && await hmacVerify(key, `${id}.${timestamp}.${rawBody}`, signature)) {
|
|
122
|
+
if (signature && await hmacVerify(key, `${id}.${timestamp}.${input.rawBody}`, signature)) {
|
|
110
123
|
return true;
|
|
111
124
|
}
|
|
112
125
|
}
|
|
113
|
-
return
|
|
126
|
+
return reject(input, "mismatch");
|
|
114
127
|
},
|
|
115
128
|
async sign(rawBody, secret, { id, timestamp } = {}) {
|
|
116
129
|
const msgId = id ?? `msg_${crypto.randomUUID().replaceAll("-", "")}`;
|
|
@@ -123,10 +136,144 @@ var svix = {
|
|
|
123
136
|
};
|
|
124
137
|
}
|
|
125
138
|
};
|
|
139
|
+
var compareKey;
|
|
140
|
+
async function secretsEqual(given, expected) {
|
|
141
|
+
if (!expected)
|
|
142
|
+
throw new Error("hono-webhooks: the shared secret is empty");
|
|
143
|
+
compareKey ??= crypto.subtle.generateKey({ name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
|
|
144
|
+
const key = await compareKey;
|
|
145
|
+
const mac = await crypto.subtle.sign("HMAC", key, encoder.encode(expected));
|
|
146
|
+
return crypto.subtle.verify("HMAC", key, mac, encoder.encode(given));
|
|
147
|
+
}
|
|
148
|
+
function sharedSecret(options) {
|
|
149
|
+
const { prefix = "" } = options;
|
|
150
|
+
const name = options.header.toLowerCase();
|
|
151
|
+
return {
|
|
152
|
+
verify: (secret) => async (input) => {
|
|
153
|
+
const value = input.headers[name];
|
|
154
|
+
if (!value)
|
|
155
|
+
return reject(input, "missing-header");
|
|
156
|
+
if (!value.startsWith(prefix))
|
|
157
|
+
return reject(input, "malformed");
|
|
158
|
+
return await secretsEqual(value.slice(prefix.length), await resolveSecret(secret, input.c)) || reject(input, "mismatch");
|
|
159
|
+
},
|
|
160
|
+
async sign(_rawBody, secret) {
|
|
161
|
+
return { [name]: `${prefix}${secret}` };
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
var infisical = {
|
|
166
|
+
verify: (secret, { toleranceSeconds = 300 } = {}) => async (input) => {
|
|
167
|
+
const header = input.headers["x-infisical-signature"];
|
|
168
|
+
if (!header)
|
|
169
|
+
return reject(input, "missing-header");
|
|
170
|
+
const signature = fromHex(header.split(";")[1] ?? "");
|
|
171
|
+
if (!signature?.length)
|
|
172
|
+
return reject(input, "malformed");
|
|
173
|
+
let timestamp;
|
|
174
|
+
try {
|
|
175
|
+
timestamp = JSON.parse(input.rawBody)?.timestamp;
|
|
176
|
+
} catch {}
|
|
177
|
+
if (typeof timestamp !== "number")
|
|
178
|
+
return reject(input, "malformed");
|
|
179
|
+
if (!await hmacVerify(await resolveSecret(secret, input.c), input.rawBody, signature)) {
|
|
180
|
+
return reject(input, "mismatch");
|
|
181
|
+
}
|
|
182
|
+
return !outsideTolerance(timestamp / 1000, toleranceSeconds) || reject(input, "timestamp-out-of-tolerance");
|
|
183
|
+
},
|
|
184
|
+
async sign(rawBody, secret) {
|
|
185
|
+
let t;
|
|
186
|
+
try {
|
|
187
|
+
t = JSON.parse(rawBody)?.timestamp;
|
|
188
|
+
} catch {}
|
|
189
|
+
const signature = toHex(await hmacSign(secret, rawBody));
|
|
190
|
+
return { "x-infisical-signature": `t=${t ?? Date.now()};${signature}` };
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
function twilioPayload(url, rawBody) {
|
|
194
|
+
const fields = [...new URLSearchParams(rawBody)].sort(([a, x], [b, y]) => a === b ? x < y ? -1 : 1 : a < b ? -1 : 1);
|
|
195
|
+
return url + fields.map(([k, v]) => k + v).join("");
|
|
196
|
+
}
|
|
197
|
+
var twilio = {
|
|
198
|
+
verify: (secret, { url } = {}) => async (input) => {
|
|
199
|
+
const signature = input.headers["x-twilio-signature"];
|
|
200
|
+
if (!signature)
|
|
201
|
+
return reject(input, "missing-header");
|
|
202
|
+
const bytes = fromBase64(signature);
|
|
203
|
+
if (!bytes)
|
|
204
|
+
return reject(input, "malformed");
|
|
205
|
+
const key = await resolveSecret(secret, input.c);
|
|
206
|
+
for (const candidate of await signedUrls(url, input.c)) {
|
|
207
|
+
const payload = twilioPayload(candidate, input.rawBody);
|
|
208
|
+
if (await hmacVerify(key, payload, bytes, "SHA-1"))
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
return reject(input, "mismatch");
|
|
212
|
+
},
|
|
213
|
+
async sign(rawBody, secret, options) {
|
|
214
|
+
if (!options?.url)
|
|
215
|
+
throw new Error("hono-webhooks: twilio.sign needs a url");
|
|
216
|
+
const signature = await hmacSign(secret, twilioPayload(options.url, rawBody), "SHA-1");
|
|
217
|
+
return { "x-twilio-signature": toBase64(signature) };
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
var HUBSPOT_DECODE = {
|
|
221
|
+
"%3A": ":",
|
|
222
|
+
"%2F": "/",
|
|
223
|
+
"%3F": "?",
|
|
224
|
+
"%40": "@",
|
|
225
|
+
"%21": "!",
|
|
226
|
+
"%24": "$",
|
|
227
|
+
"%27": "'",
|
|
228
|
+
"%28": "(",
|
|
229
|
+
"%29": ")",
|
|
230
|
+
"%2A": "*",
|
|
231
|
+
"%2C": ",",
|
|
232
|
+
"%3B": ";"
|
|
233
|
+
};
|
|
234
|
+
var hubspotUrl = (url) => url.replace(/%(3A|2F|3F|40|21|24|27|28|29|2A|2C|3B)/gi, (match) => HUBSPOT_DECODE[match.toUpperCase()] ?? match);
|
|
235
|
+
var hubspot = {
|
|
236
|
+
verify: (secret, { url, toleranceSeconds = 300 } = {}) => async (input) => {
|
|
237
|
+
const signature = input.headers["x-hubspot-signature-v3"];
|
|
238
|
+
const timestamp = input.headers["x-hubspot-request-timestamp"];
|
|
239
|
+
if (!signature || !timestamp)
|
|
240
|
+
return reject(input, "missing-header");
|
|
241
|
+
const bytes = fromBase64(signature);
|
|
242
|
+
if (!bytes || !/^\d+$/.test(timestamp))
|
|
243
|
+
return reject(input, "malformed");
|
|
244
|
+
if (outsideTolerance(Number(timestamp) / 1000, toleranceSeconds)) {
|
|
245
|
+
return reject(input, "timestamp-out-of-tolerance");
|
|
246
|
+
}
|
|
247
|
+
const key = await resolveSecret(secret, input.c);
|
|
248
|
+
const method = input.c.req.method.toUpperCase();
|
|
249
|
+
for (const candidate of await signedUrls(url, input.c)) {
|
|
250
|
+
const payload = `${method}${hubspotUrl(candidate)}${input.rawBody}${timestamp}`;
|
|
251
|
+
if (await hmacVerify(key, payload, bytes))
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
return reject(input, "mismatch");
|
|
255
|
+
},
|
|
256
|
+
async sign(rawBody, secret, options) {
|
|
257
|
+
if (!options?.url)
|
|
258
|
+
throw new Error("hono-webhooks: hubspot.sign needs a url");
|
|
259
|
+
const t = options.timestamp ?? Date.now();
|
|
260
|
+
const method = (options.method ?? "POST").toUpperCase();
|
|
261
|
+
const signature = await hmacSign(secret, `${method}${hubspotUrl(options.url)}${rawBody}${t}`);
|
|
262
|
+
return {
|
|
263
|
+
"x-hubspot-signature-v3": toBase64(signature),
|
|
264
|
+
"x-hubspot-request-timestamp": String(t)
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
};
|
|
126
268
|
export {
|
|
127
269
|
github,
|
|
128
270
|
hmac,
|
|
271
|
+
hubspot,
|
|
272
|
+
infisical,
|
|
273
|
+
meta,
|
|
274
|
+
sharedSecret,
|
|
129
275
|
shopify,
|
|
130
276
|
stripe,
|
|
131
|
-
svix
|
|
277
|
+
svix,
|
|
278
|
+
twilio
|
|
132
279
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hono-webhooks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Type-safe webhook routes for Hono, validated with any Standard Schema library (Zod, Valibot, ArkType)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
"@opentelemetry/sdk-trace-base": "^2.12.0",
|
|
89
89
|
"@types/bun": "^1.4.2",
|
|
90
90
|
"@typescript/native-preview": "^7.0.0-dev.20260707.2",
|
|
91
|
-
"hanzio": "^3.
|
|
91
|
+
"hanzio": "^3.4.0",
|
|
92
92
|
"hono": "^4.13.10",
|
|
93
93
|
"husky": "^9.1.7",
|
|
94
94
|
"typescript": "^7.0.2",
|