hono-webhooks 0.5.1 → 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/dist/otel.js CHANGED
@@ -49,9 +49,10 @@ function otelWebhooks(options = {}) {
49
49
  });
50
50
  },
51
51
  onEvent: (event) => {
52
- const summary = summarizeEvent(event);
52
+ const summary = summarizeEvent(event, options.summary);
53
53
  const success = summary.status < 400;
54
54
  const call = {
55
+ ignored: summary.route !== null && ignored.has(summary.route),
55
56
  ...summary,
56
57
  level: summary.status >= 500 ? "error" : success ? "info" : "warn",
57
58
  outcome: summary.validation ? "validation_failed" : success ? "success" : "failed"
@@ -71,8 +72,7 @@ function otelWebhooks(options = {}) {
71
72
  span?.setStatus({
72
73
  code: success ? SpanStatusCode.OK : SpanStatusCode.ERROR
73
74
  });
74
- const isIgnored = call.route !== null && ignored.has(call.route);
75
- if (!isIgnored) {
75
+ if (!call.ignored) {
76
76
  instruments ??= createInstruments(options.meter ?? metrics.getMeter("webhooks"));
77
77
  const attributes = {
78
78
  route: call.route ?? UNMATCHED_ROUTE,
@@ -81,7 +81,7 @@ function otelWebhooks(options = {}) {
81
81
  instruments.total.add(1, attributes);
82
82
  instruments.duration.record(event.durationMs, attributes);
83
83
  }
84
- if (span && options.logger && call.route !== null && !isIgnored) {
84
+ if (span && options.logger && call.route !== null && !call.ignored) {
85
85
  options.logger(span)[call.level]("webhook.completed", {
86
86
  event: "webhook.completed",
87
87
  route: call.route,
@@ -89,7 +89,10 @@ function otelWebhooks(options = {}) {
89
89
  status_code: call.status,
90
90
  duration_ms: call.durationMs,
91
91
  status: call.outcome,
92
- ...call.error && { error_type: call.error.name }
92
+ ...call.error && {
93
+ error_type: call.error.name,
94
+ error_message: call.error.message
95
+ }
93
96
  });
94
97
  }
95
98
  return options.onWebhookCall?.(call, event);
@@ -17,6 +17,17 @@ export interface HmacOptions {
17
17
  /** @default 'SHA-256' */
18
18
  hash?: 'SHA-1' | 'SHA-256' | 'SHA-512';
19
19
  }
20
+ /**
21
+ * The URL a provider signed. Behind a proxy `c.req.url` is the internal URL,
22
+ * so return the public one; return several and any match passes.
23
+ * @default (c) => c.req.url
24
+ */
25
+ export type SignedUrl = (c: Context<any>) => string | readonly string[] | Promise<string | readonly string[]>;
26
+ /**
27
+ * Why a built-in scheme rejected a request, passed to `fail` and so to
28
+ * `onEvent` as `validation.issues[0].message`.
29
+ */
30
+ export type VerifyFailureReason = 'missing-header' | 'malformed' | 'timestamp-out-of-tolerance' | 'mismatch';
20
31
  /**
21
32
  * A scheme where one header carries an HMAC of the raw body. Covers most
22
33
  * providers; `github` and `shopify` are built with it.
@@ -27,6 +38,8 @@ export interface HmacOptions {
27
38
  export declare function hmac(options: HmacOptions): SignatureScheme;
28
39
  /** GitHub: `x-hub-signature-256: sha256=<hex>`. */
29
40
  export declare const github: SignatureScheme<void, void>;
41
+ /** Meta (WhatsApp, Messenger, Instagram): signs like GitHub, with the app secret. */
42
+ export declare const meta: SignatureScheme<void, void>;
30
43
  /** Shopify: `x-shopify-hmac-sha256: <base64>`. */
31
44
  export declare const shopify: SignatureScheme<void, void>;
32
45
  /**
@@ -51,3 +64,54 @@ export declare const svix: SignatureScheme<{
51
64
  id?: string;
52
65
  timestamp?: number;
53
66
  }>;
67
+ export interface SharedSecretOptions {
68
+ /** Header carrying the secret, e.g. `x-gitlab-token` or `authorization`. */
69
+ header: string;
70
+ /** Text before the secret in the header, e.g. `Bearer `. */
71
+ prefix?: string;
72
+ }
73
+ /**
74
+ * A secret sent as is in a header (GitLab's `x-gitlab-token`, Telegram's
75
+ * `x-telegram-bot-api-secret-token`, `authorization: Bearer <token>`),
76
+ * compared in constant time. An empty secret throws (a 500).
77
+ *
78
+ * @example
79
+ * verify: sharedSecret({ header: 'x-gitlab-token' }).verify((c) => c.env.GITLAB_TOKEN)
80
+ */
81
+ export declare function sharedSecret(options: SharedSecretOptions): SignatureScheme;
82
+ /**
83
+ * Infisical: `x-infisical-signature: t=<ms>;<hex>`, an HMAC-SHA256 of the
84
+ * JSON body. The body's own `timestamp` (milliseconds) is what is signed, so
85
+ * that is what must be within `toleranceSeconds` (default 300).
86
+ */
87
+ export declare const infisical: SignatureScheme<{
88
+ toleranceSeconds?: number;
89
+ }>;
90
+ /**
91
+ * Twilio: `x-twilio-signature: <base64>`, an HMAC-SHA1 with the auth token
92
+ * over the full public URL and the sorted form fields (`bodyType: 'form'`).
93
+ * Pass `url` when `c.req.url` is not the URL Twilio called.
94
+ *
95
+ * @example
96
+ * verify: twilio.verify((c) => c.env.TWILIO_AUTH_TOKEN, { url: (c) => publicUrl(c) })
97
+ */
98
+ export declare const twilio: SignatureScheme<{
99
+ url?: SignedUrl;
100
+ }, {
101
+ url: string;
102
+ }>;
103
+ /**
104
+ * HubSpot v3: `x-hubspot-signature-v3: <base64>`, an HMAC-SHA256 with the
105
+ * app's client secret over `<METHOD><url><body><timestamp>`, and
106
+ * `x-hubspot-request-timestamp` in milliseconds, rejected when more than
107
+ * `toleranceSeconds` (default 300) away. Pass `url` when `c.req.url` is not
108
+ * the URL HubSpot called.
109
+ */
110
+ export declare const hubspot: SignatureScheme<{
111
+ url?: SignedUrl;
112
+ toleranceSeconds?: number;
113
+ }, {
114
+ url: string;
115
+ method?: string;
116
+ timestamp?: number;
117
+ }>;
@@ -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 ({ c, rawBody, headers }) => {
37
- const value = headers[name];
38
- if (!value?.startsWith(prefix))
39
- return false;
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 false;
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 ({ c, rawBody, headers }) => {
65
- const parts = (headers["stripe-signature"] ?? "").split(",").map((part) => part.split("="));
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 false;
69
- if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
70
- return false;
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 false;
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 ({ c, rawBody, headers }) => {
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 || !/^\d+$/.test(timestamp))
101
- return false;
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
- const key = svixKey(await resolveSecret(secret, c));
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 false;
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.5.1",
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.3.0",
91
+ "hanzio": "^3.4.0",
92
92
  "hono": "^4.13.10",
93
93
  "husky": "^9.1.7",
94
94
  "typescript": "^7.0.2",
@@ -18,19 +18,30 @@ import {
18
18
  } from './index'
19
19
  import {
20
20
  github,
21
+ hubspot,
22
+ infisical,
23
+ meta,
21
24
  type SignatureScheme,
22
25
  shopify,
23
26
  stripe,
24
- svix
27
+ svix,
28
+ twilio
25
29
  } from './signatures'
26
30
 
27
31
  const schemes: Record<string, SignatureScheme<any, any>> = {
28
32
  github,
33
+ hubspot,
34
+ infisical,
35
+ meta,
29
36
  shopify,
30
37
  stripe,
31
- svix
38
+ svix,
39
+ twilio
32
40
  }
33
41
 
42
+ /** `request()` with a bare path runs at this origin, so URL-signing schemes sign it. */
43
+ const ORIGIN = 'http://localhost'
44
+
34
45
  /** A file path relative to the cwd, or a package name. */
35
46
  const importModule = (specifier: string) =>
36
47
  import(
@@ -119,6 +130,7 @@ const list = defineCommand({
119
130
  `${config.method.padEnd(7)} ${config.path}${config.description ? ` ${config.description}` : ''}`
120
131
  )
121
132
  if (validates) print(ctx, ` validates ${validates}`)
133
+ if (config.verify === false) print(ctx, ' public (verify: false)')
122
134
  }
123
135
  }
124
136
  })
@@ -213,6 +225,7 @@ const testCommand = defineCommand({
213
225
  headers.set('content-type', 'text/plain')
214
226
  }
215
227
  }
228
+ const url = `${path.startsWith('/') ? path : `/${path}`}${input.query ? `?${input.query}` : ''}`
216
229
  if (input.sign) {
217
230
  const scheme = schemes[input.sign]
218
231
  if (!scheme) {
@@ -225,13 +238,15 @@ const testCommand = defineCommand({
225
238
  const secret = ctx.env[name]
226
239
  if (secret === undefined) throw new CliError(`${name} is not set`)
227
240
  for (const [key, value] of Object.entries(
228
- await scheme.sign(input.body ?? '', secret)
241
+ await scheme.sign(input.body ?? '', secret, {
242
+ url: ORIGIN + url,
243
+ method: method.toUpperCase()
244
+ })
229
245
  )) {
230
246
  headers.set(key, value)
231
247
  }
232
248
  }
233
249
 
234
- const url = `${path.startsWith('/') ? path : `/${path}`}${input.query ? `?${input.query}` : ''}`
235
250
  const start = performance.now()
236
251
  const res = await ctx.context.request(url, {
237
252
  method: method.toUpperCase(),