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/cli.js CHANGED
@@ -5657,8 +5657,10 @@ function superRefine(fn, params) {
5657
5657
  }
5658
5658
  // src/index.ts
5659
5659
  import { Hono } from "hono";
5660
+ import { bodyLimit } from "hono/body-limit";
5660
5661
  import { HTTPException } from "hono/http-exception";
5661
5662
  import { basePath } from "hono/route";
5663
+ var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
5662
5664
 
5663
5665
  class WebhookValidationError extends HTTPException {
5664
5666
  target;
@@ -5709,7 +5711,7 @@ async function parse3(schema, value, target) {
5709
5711
  }
5710
5712
  function defaultErrorResponse(error, c, log) {
5711
5713
  if (error instanceof WebhookValidationError) {
5712
- return c.json({ error: error.message, issues: error.issues }, error.status);
5714
+ return error.target === "signature" ? c.json({ error: error.message }, error.status) : c.json({ error: error.message, issues: error.issues }, error.status);
5713
5715
  }
5714
5716
  if (error instanceof HTTPException) {
5715
5717
  return error.res ?? c.json({ error: error.message }, error.status);
@@ -5734,6 +5736,9 @@ function makeCreateWebhooks() {
5734
5736
  const app = new Hono;
5735
5737
  const methodsByPath = new Map;
5736
5738
  for (const { config } of webhooks) {
5739
+ if (config.method !== "GET" && config.verify === undefined) {
5740
+ throw new Error(`hono-webhooks: ${config.method} ${config.path} has no verify. Pass a verify function, or verify: false if it is public on purpose.`);
5741
+ }
5737
5742
  const methods = methodsByPath.get(config.path) ?? [];
5738
5743
  if (methods.includes(config.method)) {
5739
5744
  throw new Error(`hono-webhooks: ${config.method} ${config.path} is defined twice`);
@@ -5775,15 +5780,32 @@ function makeCreateWebhooks() {
5775
5780
  const ordered = [...webhooks].sort((a, b) => Number(isDynamic(a.config.path)) - Number(isDynamic(b.config.path)));
5776
5781
  for (const { config, handler } of ordered) {
5777
5782
  app.on(config.method, config.path, handle(config.path, async (c, event) => {
5783
+ const maxSize = config.maxBodyBytes ?? options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
5784
+ if (maxSize !== Infinity) {
5785
+ await bodyLimit({
5786
+ maxSize,
5787
+ onError: () => {
5788
+ throw new HTTPException(413, { message: "Payload too large" });
5789
+ }
5790
+ })(c, async () => {});
5791
+ }
5778
5792
  event.rawBody = await c.req.text();
5779
- if (config.verify && !await config.verify({
5780
- c,
5781
- rawBody: event.rawBody,
5782
- headers: c.req.header()
5783
- })) {
5784
- throw new WebhookValidationError("signature", [
5785
- { message: "Signature does not match" }
5786
- ]);
5793
+ if (config.verify) {
5794
+ let reason = "Signature does not match";
5795
+ const ok = await config.verify({
5796
+ c,
5797
+ rawBody: event.rawBody,
5798
+ headers: c.req.header(),
5799
+ fail: (why) => {
5800
+ reason = why;
5801
+ return false;
5802
+ }
5803
+ });
5804
+ if (ok !== true) {
5805
+ throw new WebhookValidationError("signature", [
5806
+ { message: reason }
5807
+ ]);
5808
+ }
5787
5809
  }
5788
5810
  event.params = await parse3(config.params, c.req.param(), "params");
5789
5811
  event.query = await parse3(config.query, c.req.query(), "query");
@@ -5860,6 +5882,7 @@ function describeWebhooks(source, { target = "draft-2020-12" } = {}) {
5860
5882
 
5861
5883
  // src/signatures.ts
5862
5884
  var encoder = new TextEncoder;
5885
+ var reject = (input, reason) => input.fail?.(reason) ?? false;
5863
5886
  function hmacKey(secret, hash) {
5864
5887
  if (!secret.length) {
5865
5888
  throw new Error("hono-webhooks: the signing secret is empty");
@@ -5889,19 +5912,23 @@ function fromBase64(base64) {
5889
5912
  }
5890
5913
  }
5891
5914
  var resolveSecret = async (secret, c) => typeof secret === "function" ? secret(c) : secret;
5915
+ var signedUrls = async (url, c) => [await url?.(c) ?? c.req.url].flat();
5916
+ var outsideTolerance = (seconds, toleranceSeconds) => Math.abs(Date.now() / 1000 - seconds) > toleranceSeconds;
5892
5917
  function hmac(options) {
5893
5918
  const { header, encoding, prefix = "", hash = "SHA-256" } = options;
5894
5919
  const name = header.toLowerCase();
5895
5920
  return {
5896
- verify: (secret) => async ({ c, rawBody, headers }) => {
5897
- const value = headers[name];
5898
- if (!value?.startsWith(prefix))
5899
- return false;
5921
+ verify: (secret) => async (input) => {
5922
+ const value = input.headers[name];
5923
+ if (!value)
5924
+ return reject(input, "missing-header");
5925
+ if (!value.startsWith(prefix))
5926
+ return reject(input, "malformed");
5900
5927
  const encoded = value.slice(prefix.length);
5901
5928
  const signature = encoding === "hex" ? fromHex(encoded) : fromBase64(encoded);
5902
5929
  if (!signature)
5903
- return false;
5904
- return hmacVerify(await resolveSecret(secret, c), rawBody, signature, hash);
5930
+ return reject(input, "malformed");
5931
+ return await hmacVerify(await resolveSecret(secret, input.c), input.rawBody, signature, hash) || reject(input, "mismatch");
5905
5932
  },
5906
5933
  async sign(rawBody, secret) {
5907
5934
  const signature = await hmacSign(secret, rawBody, hash);
@@ -5916,27 +5943,31 @@ var github = hmac({
5916
5943
  encoding: "hex",
5917
5944
  prefix: "sha256="
5918
5945
  });
5946
+ var meta2 = github;
5919
5947
  var shopify = hmac({
5920
5948
  header: "x-shopify-hmac-sha256",
5921
5949
  encoding: "base64"
5922
5950
  });
5923
5951
  var stripe = {
5924
- verify: (secret, { toleranceSeconds = 300 } = {}) => async ({ c, rawBody, headers }) => {
5925
- const parts = (headers["stripe-signature"] ?? "").split(",").map((part) => part.split("="));
5952
+ verify: (secret, { toleranceSeconds = 300 } = {}) => async (input) => {
5953
+ const header = input.headers["stripe-signature"];
5954
+ if (!header)
5955
+ return reject(input, "missing-header");
5956
+ const parts = header.split(",").map((part) => part.split("="));
5926
5957
  const timestamp = parts.find(([k]) => k === "t")?.[1] ?? "";
5927
5958
  if (!/^\d+$/.test(timestamp))
5928
- return false;
5929
- if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
5930
- return false;
5959
+ return reject(input, "malformed");
5960
+ if (outsideTolerance(Number(timestamp), toleranceSeconds)) {
5961
+ return reject(input, "timestamp-out-of-tolerance");
5931
5962
  }
5932
- const key = await resolveSecret(secret, c);
5963
+ const key = await resolveSecret(secret, input.c);
5933
5964
  for (const [k, v] of parts) {
5934
5965
  const signature = k === "v1" && v ? fromHex(v) : undefined;
5935
- if (signature && await hmacVerify(key, `${timestamp}.${rawBody}`, signature)) {
5966
+ if (signature && await hmacVerify(key, `${timestamp}.${input.rawBody}`, signature)) {
5936
5967
  return true;
5937
5968
  }
5938
5969
  }
5939
- return false;
5970
+ return reject(input, "mismatch");
5940
5971
  },
5941
5972
  async sign(rawBody, secret, { timestamp } = {}) {
5942
5973
  const t = timestamp ?? Math.floor(Date.now() / 1000);
@@ -5952,25 +5983,29 @@ function svixKey(secret) {
5952
5983
  return key;
5953
5984
  }
5954
5985
  var svix = {
5955
- verify: (secret, { toleranceSeconds = 300 } = {}) => async ({ c, rawBody, headers }) => {
5986
+ verify: (secret, { toleranceSeconds = 300 } = {}) => async (input) => {
5987
+ const { headers } = input;
5956
5988
  const prefix = headers["svix-id"] ? "svix" : "webhook";
5957
5989
  const id = headers[`${prefix}-id`];
5958
- const timestamp = headers[`${prefix}-timestamp`] ?? "";
5990
+ const timestamp = headers[`${prefix}-timestamp`];
5959
5991
  const signatures = headers[`${prefix}-signature`];
5960
- if (!id || !signatures || !/^\d+$/.test(timestamp))
5961
- return false;
5962
- if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
5963
- return false;
5992
+ if (!id || !signatures || !timestamp) {
5993
+ return reject(input, "missing-header");
5994
+ }
5995
+ if (!/^\d+$/.test(timestamp))
5996
+ return reject(input, "malformed");
5997
+ if (outsideTolerance(Number(timestamp), toleranceSeconds)) {
5998
+ return reject(input, "timestamp-out-of-tolerance");
5964
5999
  }
5965
- const key = svixKey(await resolveSecret(secret, c));
6000
+ const key = svixKey(await resolveSecret(secret, input.c));
5966
6001
  for (const entry of signatures.split(" ")) {
5967
6002
  const [version, encoded] = entry.split(",");
5968
6003
  const signature = version === "v1" && encoded ? fromBase64(encoded) : undefined;
5969
- if (signature && await hmacVerify(key, `${id}.${timestamp}.${rawBody}`, signature)) {
6004
+ if (signature && await hmacVerify(key, `${id}.${timestamp}.${input.rawBody}`, signature)) {
5970
6005
  return true;
5971
6006
  }
5972
6007
  }
5973
- return false;
6008
+ return reject(input, "mismatch");
5974
6009
  },
5975
6010
  async sign(rawBody, secret, { id, timestamp } = {}) {
5976
6011
  const msgId = id ?? `msg_${crypto.randomUUID().replaceAll("-", "")}`;
@@ -5983,14 +6018,122 @@ var svix = {
5983
6018
  };
5984
6019
  }
5985
6020
  };
6021
+ var infisical = {
6022
+ verify: (secret, { toleranceSeconds = 300 } = {}) => async (input) => {
6023
+ const header = input.headers["x-infisical-signature"];
6024
+ if (!header)
6025
+ return reject(input, "missing-header");
6026
+ const signature = fromHex(header.split(";")[1] ?? "");
6027
+ if (!signature?.length)
6028
+ return reject(input, "malformed");
6029
+ let timestamp;
6030
+ try {
6031
+ timestamp = JSON.parse(input.rawBody)?.timestamp;
6032
+ } catch {}
6033
+ if (typeof timestamp !== "number")
6034
+ return reject(input, "malformed");
6035
+ if (!await hmacVerify(await resolveSecret(secret, input.c), input.rawBody, signature)) {
6036
+ return reject(input, "mismatch");
6037
+ }
6038
+ return !outsideTolerance(timestamp / 1000, toleranceSeconds) || reject(input, "timestamp-out-of-tolerance");
6039
+ },
6040
+ async sign(rawBody, secret) {
6041
+ let t;
6042
+ try {
6043
+ t = JSON.parse(rawBody)?.timestamp;
6044
+ } catch {}
6045
+ const signature = toHex(await hmacSign(secret, rawBody));
6046
+ return { "x-infisical-signature": `t=${t ?? Date.now()};${signature}` };
6047
+ }
6048
+ };
6049
+ function twilioPayload(url, rawBody) {
6050
+ const fields = [...new URLSearchParams(rawBody)].sort(([a, x], [b, y]) => a === b ? x < y ? -1 : 1 : a < b ? -1 : 1);
6051
+ return url + fields.map(([k, v]) => k + v).join("");
6052
+ }
6053
+ var twilio = {
6054
+ verify: (secret, { url } = {}) => async (input) => {
6055
+ const signature = input.headers["x-twilio-signature"];
6056
+ if (!signature)
6057
+ return reject(input, "missing-header");
6058
+ const bytes = fromBase64(signature);
6059
+ if (!bytes)
6060
+ return reject(input, "malformed");
6061
+ const key = await resolveSecret(secret, input.c);
6062
+ for (const candidate of await signedUrls(url, input.c)) {
6063
+ const payload = twilioPayload(candidate, input.rawBody);
6064
+ if (await hmacVerify(key, payload, bytes, "SHA-1"))
6065
+ return true;
6066
+ }
6067
+ return reject(input, "mismatch");
6068
+ },
6069
+ async sign(rawBody, secret, options) {
6070
+ if (!options?.url)
6071
+ throw new Error("hono-webhooks: twilio.sign needs a url");
6072
+ const signature = await hmacSign(secret, twilioPayload(options.url, rawBody), "SHA-1");
6073
+ return { "x-twilio-signature": toBase64(signature) };
6074
+ }
6075
+ };
6076
+ var HUBSPOT_DECODE = {
6077
+ "%3A": ":",
6078
+ "%2F": "/",
6079
+ "%3F": "?",
6080
+ "%40": "@",
6081
+ "%21": "!",
6082
+ "%24": "$",
6083
+ "%27": "'",
6084
+ "%28": "(",
6085
+ "%29": ")",
6086
+ "%2A": "*",
6087
+ "%2C": ",",
6088
+ "%3B": ";"
6089
+ };
6090
+ var hubspotUrl = (url) => url.replace(/%(3A|2F|3F|40|21|24|27|28|29|2A|2C|3B)/gi, (match) => HUBSPOT_DECODE[match.toUpperCase()] ?? match);
6091
+ var hubspot = {
6092
+ verify: (secret, { url, toleranceSeconds = 300 } = {}) => async (input) => {
6093
+ const signature = input.headers["x-hubspot-signature-v3"];
6094
+ const timestamp = input.headers["x-hubspot-request-timestamp"];
6095
+ if (!signature || !timestamp)
6096
+ return reject(input, "missing-header");
6097
+ const bytes = fromBase64(signature);
6098
+ if (!bytes || !/^\d+$/.test(timestamp))
6099
+ return reject(input, "malformed");
6100
+ if (outsideTolerance(Number(timestamp) / 1000, toleranceSeconds)) {
6101
+ return reject(input, "timestamp-out-of-tolerance");
6102
+ }
6103
+ const key = await resolveSecret(secret, input.c);
6104
+ const method = input.c.req.method.toUpperCase();
6105
+ for (const candidate of await signedUrls(url, input.c)) {
6106
+ const payload = `${method}${hubspotUrl(candidate)}${input.rawBody}${timestamp}`;
6107
+ if (await hmacVerify(key, payload, bytes))
6108
+ return true;
6109
+ }
6110
+ return reject(input, "mismatch");
6111
+ },
6112
+ async sign(rawBody, secret, options) {
6113
+ if (!options?.url)
6114
+ throw new Error("hono-webhooks: hubspot.sign needs a url");
6115
+ const t = options.timestamp ?? Date.now();
6116
+ const method = (options.method ?? "POST").toUpperCase();
6117
+ const signature = await hmacSign(secret, `${method}${hubspotUrl(options.url)}${rawBody}${t}`);
6118
+ return {
6119
+ "x-hubspot-signature-v3": toBase64(signature),
6120
+ "x-hubspot-request-timestamp": String(t)
6121
+ };
6122
+ }
6123
+ };
5986
6124
 
5987
6125
  // src/cli-commands.ts
5988
6126
  var schemes = {
5989
6127
  github,
6128
+ hubspot,
6129
+ infisical,
6130
+ meta: meta2,
5990
6131
  shopify,
5991
6132
  stripe,
5992
- svix
6133
+ svix,
6134
+ twilio
5993
6135
  };
6136
+ var ORIGIN = "http://localhost";
5994
6137
  var importModule = (specifier) => existsSync(resolve(specifier)) ? import(pathToFileURL(resolve(specifier)).href) : import(specifier);
5995
6138
  function isWebhooksApp(value) {
5996
6139
  return typeof value === "object" && value !== null && Array.isArray(value.webhooks) && typeof value.request === "function";
@@ -6042,6 +6185,8 @@ var list = defineCommand2({
6042
6185
  print(ctx, `${config.method.padEnd(7)} ${config.path}${config.description ? ` ${config.description}` : ""}`);
6043
6186
  if (validates)
6044
6187
  print(ctx, ` validates ${validates}`);
6188
+ if (config.verify === false)
6189
+ print(ctx, " public (verify: false)");
6045
6190
  }
6046
6191
  }
6047
6192
  });
@@ -6126,6 +6271,7 @@ var testCommand = defineCommand2({
6126
6271
  headers.set("content-type", "text/plain");
6127
6272
  }
6128
6273
  }
6274
+ const url = `${path.startsWith("/") ? path : `/${path}`}${input.query ? `?${input.query}` : ""}`;
6129
6275
  if (input.sign) {
6130
6276
  const scheme = schemes[input.sign];
6131
6277
  if (!scheme) {
@@ -6137,11 +6283,13 @@ var testCommand = defineCommand2({
6137
6283
  const secret = ctx.env[name];
6138
6284
  if (secret === undefined)
6139
6285
  throw new CliError2(`${name} is not set`);
6140
- for (const [key, value] of Object.entries(await scheme.sign(input.body ?? "", secret))) {
6286
+ for (const [key, value] of Object.entries(await scheme.sign(input.body ?? "", secret, {
6287
+ url: ORIGIN + url,
6288
+ method: method.toUpperCase()
6289
+ }))) {
6141
6290
  headers.set(key, value);
6142
6291
  }
6143
6292
  }
6144
- const url = `${path.startsWith("/") ? path : `/${path}`}${input.query ? `?${input.query}` : ""}`;
6145
6293
  const start = performance.now();
6146
6294
  const res = await ctx.context.request(url, {
6147
6295
  method: method.toUpperCase(),
package/dist/index.d.ts CHANGED
@@ -35,8 +35,17 @@ export interface VerifyInput<E extends Env = any> {
35
35
  rawBody: string;
36
36
  /** All request headers, lower-case names, not schema-validated. */
37
37
  headers: Record<string, string>;
38
+ /**
39
+ * Records why the check failed and returns `false`:
40
+ * `return fail('timestamp-out-of-tolerance')`. The reason reaches
41
+ * `onEvent` (`validation.issues[0].message`), never the response.
42
+ */
43
+ fail(reason: string): false;
38
44
  }
39
- /** Checks a request's signature. `hono-webhooks/signatures` has ready-made ones. */
45
+ /**
46
+ * Checks a request's signature. `hono-webhooks/signatures` has ready-made
47
+ * ones. Only `true` lets the request through.
48
+ */
40
49
  export type Verify<E extends Env = any> = (input: VerifyInput<E>) => MaybePromise<boolean>;
41
50
  export interface WebhookConfig<P extends string = string, TParams extends Schema | undefined = Schema | undefined, TQuery extends Schema | undefined = Schema | undefined, THeaders extends Schema | undefined = Schema | undefined, TBody extends Schema | undefined = Schema | undefined, TResponse extends Schema | undefined = Schema | undefined, E extends Env = any> {
42
51
  method: WebhookMethod;
@@ -51,9 +60,14 @@ export interface WebhookConfig<P extends string = string, TParams extends Schema
51
60
  * Checks the signature right after routing, before anything is validated
52
61
  * or parsed. Returning `false` answers `401 { error: 'Invalid signature' }`.
53
62
  *
63
+ * Required for every method but `GET`: pass `false` for a webhook that is
64
+ * public on purpose, or `createWebhooks` throws.
65
+ *
54
66
  * @example verify: shopify.verify((c) => c.env.SHOPIFY_SECRET)
55
67
  */
56
- verify?: Verify<E>;
68
+ verify?: Verify<E> | false;
69
+ /** Overrides `WebhooksOptions.maxBodyBytes` for this webhook. */
70
+ maxBodyBytes?: number;
57
71
  /**
58
72
  * Checks what the handler returns. A mismatch is reported on
59
73
  * `WebhookEvent.validation` and the response is still sent.
@@ -152,6 +166,12 @@ export interface WebhooksOptions<E extends Env = any> {
152
166
  * @default true
153
167
  */
154
168
  logErrors?: boolean;
169
+ /**
170
+ * Larger bodies answer `413 { error: 'Payload too large' }` before they
171
+ * are read. `Infinity` turns the limit off. Each webhook can override it.
172
+ * @default 1_048_576 (1 MiB)
173
+ */
174
+ maxBodyBytes?: number;
155
175
  }
156
176
  export type WebhooksApp<E extends Env = any> = Hono<E> & {
157
177
  /** The definitions, in the order they were passed. */
@@ -172,7 +192,12 @@ export declare class WebhookValidationError extends HTTPException {
172
192
  *
173
193
  * @example
174
194
  * defineWebhook(
175
- * { method: 'POST', path: '/orders/:id', body: z.object({ total: z.number() }) },
195
+ * {
196
+ * method: 'POST',
197
+ * path: '/orders/:id',
198
+ * verify: shopify.verify((c) => c.env.SHOPIFY_SECRET),
199
+ * body: z.object({ total: z.number() })
200
+ * },
176
201
  * async ({ params, body }) => ({ id: params.id, total: body.total })
177
202
  * )
178
203
  */
@@ -180,7 +205,8 @@ export declare const defineWebhook: <const P extends string, TParams extends Sch
180
205
  /**
181
206
  * Builds a Hono app that serves the webhooks. Mount it under a prefix:
182
207
  * `app.route('/webhooks', createWebhooks([...]))`. Unknown paths below the
183
- * prefix answer 404, known paths with the wrong method answer 405.
208
+ * prefix answer 404, known paths with the wrong method answer 405. Throws
209
+ * for a duplicate route and for a non-GET webhook without `verify`.
184
210
  */
185
211
  export declare const createWebhooks: (webhooks: readonly Webhook<any, string, any, any, any, any, any>[], options?: WebhooksOptions<any>) => WebhooksApp<any>;
186
212
  /**
@@ -213,6 +239,8 @@ export interface WebhookEventSummary {
213
239
  userAgent: string | null;
214
240
  params: Record<string, unknown> | null;
215
241
  query: Record<string, unknown> | null;
242
+ /** Only the headers named in `SummaryOptions.headers`; absent without that option. */
243
+ headers?: Record<string, string>;
216
244
  body: unknown;
217
245
  response: unknown;
218
246
  /** `null` for validation failures, which `validation` describes. */
@@ -231,19 +259,31 @@ export interface WebhookEventSummary {
231
259
  }[];
232
260
  } | null;
233
261
  }
262
+ /** What `summarizeEvent` keeps. Defaults keep everything but headers. */
263
+ export interface SummaryOptions {
264
+ /** `false` sets `body` and `response` to `null`. @default true */
265
+ body?: boolean;
266
+ /** `false` sets `query` to `null`. @default true */
267
+ query?: boolean;
268
+ /**
269
+ * Request headers to keep, lower-case, e.g. a delivery id such as
270
+ * `x-github-delivery` or `svix-id`. @default []
271
+ */
272
+ headers?: readonly string[];
273
+ }
234
274
  /**
235
- * Flattens an event for logging or storage. Request headers are left out on
236
- * purpose: they carry signatures and credentials.
275
+ * Flattens an event for logging or storage. Request headers are left out
276
+ * unless named in `options.headers`: they carry signatures and credentials.
237
277
  *
238
278
  * @example
239
- * onEvent: (e) => db.insert(webhookCalls).values(summarizeEvent(e))
279
+ * onEvent: (e) => db.insert(webhookCalls).values(summarizeEvent(e, { body: false }))
240
280
  */
241
- export declare function summarizeEvent(event: WebhookEvent): WebhookEventSummary;
281
+ export declare function summarizeEvent(event: WebhookEvent, options?: SummaryOptions): WebhookEventSummary;
242
282
  export interface WebhookDescription {
243
283
  method: WebhookMethod;
244
284
  path: string;
245
285
  description: string | null;
246
- /** Whether the webhook has a `verify` function. */
286
+ /** Whether the webhook has a `verify` function; `false` for `verify: false`. */
247
287
  verified: boolean;
248
288
  bodyType: BodyType;
249
289
  /**
@@ -275,8 +315,20 @@ export type TestWebhookInput<W> = W extends Webhook<infer E, infer P, infer TPar
275
315
  * webhook's `bodyType` is `'form'`.
276
316
  */
277
317
  body?: In<TBody, unknown> | string;
278
- /** Headers computed from the raw body, e.g. `(raw) => shopify.sign(raw, secret)`. */
279
- sign?: (rawBody: string) => MaybePromise<Record<string, string>>;
318
+ /**
319
+ * Headers computed from the raw body and the request, e.g.
320
+ * `(raw) => shopify.sign(raw, secret)` or
321
+ * `(raw, { url }) => twilio.sign(raw, token, { url })`.
322
+ */
323
+ sign?: (rawBody: string, request: {
324
+ url: string;
325
+ method: string;
326
+ }) => MaybePromise<Record<string, string>>;
327
+ /**
328
+ * Origin the request is sent to, for schemes that sign the URL.
329
+ * @default 'http://localhost'
330
+ */
331
+ baseUrl?: string;
280
332
  /** Bindings, available as `c.env`. */
281
333
  env?: E extends {
282
334
  Bindings: infer B;
@@ -295,3 +347,9 @@ export type TestWebhookInput<W> = W extends Webhook<infer E, infer P, infer TPar
295
347
  * })
296
348
  */
297
349
  export declare function testWebhook<W extends Webhook>(webhook: W, input?: TestWebhookInput<W>): Promise<Response>;
350
+ /**
351
+ * The webhook's path with `params` filled in, relative to the mount point:
352
+ * `webhookPath(oauthCallback, { provider: 'gmail' })` gives
353
+ * `/channels/gmail/callback`. Prepend your origin and mount prefix for a URL.
354
+ */
355
+ export declare function webhookPath<P extends string>(webhook: Webhook<any, P>, ...[params]: [ParamKeys<P>] extends [never] ? [] : [PathParams<P>]): string;
package/dist/index.js CHANGED
@@ -1,7 +1,9 @@
1
1
  // src/index.ts
2
2
  import { Hono } from "hono";
3
+ import { bodyLimit } from "hono/body-limit";
3
4
  import { HTTPException } from "hono/http-exception";
4
5
  import { basePath } from "hono/route";
6
+ var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
5
7
 
6
8
  class WebhookValidationError extends HTTPException {
7
9
  target;
@@ -52,7 +54,7 @@ async function parse(schema, value, target) {
52
54
  }
53
55
  function defaultErrorResponse(error, c, log) {
54
56
  if (error instanceof WebhookValidationError) {
55
- return c.json({ error: error.message, issues: error.issues }, error.status);
57
+ return error.target === "signature" ? c.json({ error: error.message }, error.status) : c.json({ error: error.message, issues: error.issues }, error.status);
56
58
  }
57
59
  if (error instanceof HTTPException) {
58
60
  return error.res ?? c.json({ error: error.message }, error.status);
@@ -77,6 +79,9 @@ function makeCreateWebhooks() {
77
79
  const app = new Hono;
78
80
  const methodsByPath = new Map;
79
81
  for (const { config } of webhooks) {
82
+ if (config.method !== "GET" && config.verify === undefined) {
83
+ throw new Error(`hono-webhooks: ${config.method} ${config.path} has no verify. Pass a verify function, or verify: false if it is public on purpose.`);
84
+ }
80
85
  const methods = methodsByPath.get(config.path) ?? [];
81
86
  if (methods.includes(config.method)) {
82
87
  throw new Error(`hono-webhooks: ${config.method} ${config.path} is defined twice`);
@@ -118,15 +123,32 @@ function makeCreateWebhooks() {
118
123
  const ordered = [...webhooks].sort((a, b) => Number(isDynamic(a.config.path)) - Number(isDynamic(b.config.path)));
119
124
  for (const { config, handler } of ordered) {
120
125
  app.on(config.method, config.path, handle(config.path, async (c, event) => {
126
+ const maxSize = config.maxBodyBytes ?? options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
127
+ if (maxSize !== Infinity) {
128
+ await bodyLimit({
129
+ maxSize,
130
+ onError: () => {
131
+ throw new HTTPException(413, { message: "Payload too large" });
132
+ }
133
+ })(c, async () => {});
134
+ }
121
135
  event.rawBody = await c.req.text();
122
- if (config.verify && !await config.verify({
123
- c,
124
- rawBody: event.rawBody,
125
- headers: c.req.header()
126
- })) {
127
- throw new WebhookValidationError("signature", [
128
- { message: "Signature does not match" }
129
- ]);
136
+ if (config.verify) {
137
+ let reason = "Signature does not match";
138
+ const ok = await config.verify({
139
+ c,
140
+ rawBody: event.rawBody,
141
+ headers: c.req.header(),
142
+ fail: (why) => {
143
+ reason = why;
144
+ return false;
145
+ }
146
+ });
147
+ if (ok !== true) {
148
+ throw new WebhookValidationError("signature", [
149
+ { message: reason }
150
+ ]);
151
+ }
130
152
  }
131
153
  event.params = await parse(config.params, c.req.param(), "params");
132
154
  event.query = await parse(config.query, c.req.query(), "query");
@@ -191,8 +213,15 @@ function issuePath(issue) {
191
213
  const path = issue.path?.map((segment) => String(typeof segment === "object" ? segment.key : segment)).join(".");
192
214
  return path || null;
193
215
  }
194
- function summarizeEvent(event) {
216
+ function summarizeEvent(event, options = {}) {
195
217
  const { c, error, validation } = event;
218
+ const keepBody = options.body ?? true;
219
+ const headers = {};
220
+ for (const name of options.headers ?? []) {
221
+ const value = c.req.header(name);
222
+ if (value !== undefined)
223
+ headers[name] = value;
224
+ }
196
225
  return {
197
226
  route: event.route ?? null,
198
227
  method: event.method,
@@ -202,9 +231,10 @@ function summarizeEvent(event) {
202
231
  ip: c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || c.req.header("x-real-ip") || c.req.header("cf-connecting-ip") || null,
203
232
  userAgent: c.req.header("user-agent") ?? null,
204
233
  params: event.params ?? null,
205
- query: event.query ?? null,
206
- body: event.body ?? null,
207
- response: event.response ?? null,
234
+ query: options.query === false ? null : event.query ?? null,
235
+ ...options.headers && { headers },
236
+ body: keepBody ? event.body ?? null : null,
237
+ response: keepBody ? event.response ?? null : null,
208
238
  error: error === undefined || error instanceof WebhookValidationError ? null : error instanceof Error ? {
209
239
  name: error.name,
210
240
  message: error.message,
@@ -253,7 +283,13 @@ function fillPath(pattern, params) {
253
283
  }
254
284
  async function testWebhook(webhook, input = {}) {
255
285
  const { config } = webhook;
256
- const { body, sign, env, options } = input;
286
+ const {
287
+ body,
288
+ sign,
289
+ env,
290
+ options,
291
+ baseUrl = "http://localhost"
292
+ } = input;
257
293
  const headers = new Headers(input.headers);
258
294
  let rawBody;
259
295
  if (typeof body === "string") {
@@ -271,12 +307,16 @@ async function testWebhook(webhook, input = {}) {
271
307
  headers.set("content-type", "application/json");
272
308
  }
273
309
  }
274
- for (const [name, value] of Object.entries(await sign?.(rawBody ?? "") ?? {})) {
275
- headers.set(name, value);
276
- }
277
310
  const path = input.path ?? fillPath(config.path, input.params ?? {});
278
311
  const query = new URLSearchParams(input.query).toString();
279
- return createWebhooks([webhook], options).request(`${path || "/"}${query ? `?${query}` : ""}`, { method: config.method, headers, body: rawBody }, env);
312
+ const url = `${baseUrl.replace(/\/$/, "")}${path || "/"}${query ? `?${query}` : ""}`;
313
+ for (const [name, value] of Object.entries(await sign?.(rawBody ?? "", { url, method: config.method }) ?? {})) {
314
+ headers.set(name, value);
315
+ }
316
+ return createWebhooks([webhook], options).request(url, { method: config.method, headers, body: rawBody }, env);
317
+ }
318
+ function webhookPath(webhook, ...[params]) {
319
+ return fillPath(webhook.config.path, params ?? {}) || "/";
280
320
  }
281
321
  export {
282
322
  WebhookValidationError,
@@ -286,5 +326,6 @@ export {
286
326
  describeWebhooks,
287
327
  formatIssues,
288
328
  summarizeEvent,
289
- testWebhook
329
+ testWebhook,
330
+ webhookPath
290
331
  };
package/dist/otel.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type Meter, type Span, type Tracer } from '@opentelemetry/api';
2
2
  import type { Env } from 'hono';
3
- import { type WebhookEvent, type WebhookEventSummary, type WebhooksOptions } from 'hono-webhooks';
3
+ import { type SummaryOptions, type WebhookEvent, type WebhookEventSummary, type WebhooksOptions } from 'hono-webhooks';
4
4
  /** The `route` label for 404s, which match no webhook. */
5
5
  export declare const UNMATCHED_ROUTE = "__unmatched__";
6
6
  /** A metric `otelWebhooks` records, described for a metric catalog. */
@@ -39,6 +39,8 @@ export interface WebhookCall extends WebhookEventSummary {
39
39
  /** `error` for 5xx, `warn` for 4xx, else `info`. */
40
40
  level: 'info' | 'warn' | 'error';
41
41
  outcome: 'success' | 'failed' | 'validation_failed';
42
+ /** The route is in `ignoreRoutes`: no metrics or log were recorded. */
43
+ ignored: boolean;
42
44
  }
43
45
  /** `console` fits, as does any logger with these three methods. */
44
46
  export type WebhookLogger = Record<WebhookCall['level'], (message: string, fields: Record<string, string | number>) => void>;
@@ -49,16 +51,18 @@ export interface OtelWebhooksOptions<E extends Env = any> {
49
51
  meter?: Meter;
50
52
  /** Routes that get a span but no metrics, e.g. `['/health']`. */
51
53
  ignoreRoutes?: readonly string[];
54
+ /** What `call` keeps of the request, as for `summarizeEvent`. */
55
+ summary?: SummaryOptions;
52
56
  /**
53
- * Called after every request, ignored routes included, with the span
54
- * still active: log it, store it. A returned promise is handled like
55
- * `onEvent`'s.
57
+ * Called after every request, ignored routes included (`call.ignored`),
58
+ * with the span still active: log it, store it. A returned promise is
59
+ * handled like `onEvent`'s.
56
60
  */
57
61
  onWebhookCall?: (call: WebhookCall, event: WebhookEvent<E>) => void | Promise<void>;
58
62
  /**
59
63
  * Logs one `webhook.completed` event per request at `call.level`, with
60
64
  * `route`, `method`, `status_code`, `duration_ms`, `status` (the outcome)
61
- * and `error_type`. 404s and `ignoreRoutes` are skipped. Called with the
65
+ * `error_type` and `error_message`. 404s and `ignoreRoutes` are skipped. Called with the
62
66
  * request's span, so the logger can attach to it.
63
67
  *
64
68
  * @example