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/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
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
+ }>;