hono-webhooks 0.1.1 → 0.2.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/src/index.ts CHANGED
@@ -1,7 +1,11 @@
1
- import type { StandardSchemaV1 } from '@standard-schema/spec'
1
+ import type {
2
+ StandardJSONSchemaV1,
3
+ StandardSchemaV1
4
+ } from '@standard-schema/spec'
2
5
  import { type Context, type Env, Hono } from 'hono'
3
6
  import { HTTPException } from 'hono/http-exception'
4
- import type { ParamKeys, ParamKeyToRecord } from 'hono/types'
7
+ import { basePath } from 'hono/route'
8
+ import type { ParamKeys } from 'hono/types'
5
9
 
6
10
  export type { StandardSchemaV1 }
7
11
 
@@ -27,6 +31,7 @@ export type ValidationTarget =
27
31
  | 'params'
28
32
  | 'query'
29
33
  | 'headers'
34
+ | 'signature'
30
35
  | 'body'
31
36
  | 'response'
32
37
 
@@ -43,13 +48,31 @@ type UnionToIntersection<U> = (
43
48
  ? I
44
49
  : never
45
50
 
51
+ type ParamRecord<K extends string> = K extends `${infer Name}?`
52
+ ? { [N in Name]?: string }
53
+ : { [N in K]: string }
54
+
46
55
  /**
47
56
  * Params typed from the path string: `/users/:id` gives `{ id: string }`,
48
- * `/files/:name?` gives `{ name: string | undefined }`.
57
+ * `/files/:name?` gives `{ name?: string }`.
49
58
  */
50
59
  export type PathParams<P extends string> = [ParamKeys<P>] extends [never]
51
60
  ? Record<string, never>
52
- : UnionToIntersection<ParamKeyToRecord<ParamKeys<P>>>
61
+ : UnionToIntersection<ParamRecord<ParamKeys<P>>>
62
+
63
+ /** What a `verify` function gets: the request before its body is parsed. */
64
+ export interface VerifyInput<E extends Env = any> {
65
+ c: Context<E>
66
+ /** The body exactly as received. */
67
+ rawBody: string
68
+ /** All request headers, lower-case names, not schema-validated. */
69
+ headers: Record<string, string>
70
+ }
71
+
72
+ /** Checks a request's signature. `hono-webhooks/signatures` has ready-made ones. */
73
+ export type Verify<E extends Env = any> = (
74
+ input: VerifyInput<E>
75
+ ) => MaybePromise<boolean>
53
76
 
54
77
  export interface WebhookConfig<
55
78
  P extends string = string,
@@ -57,7 +80,8 @@ export interface WebhookConfig<
57
80
  TQuery extends Schema | undefined = Schema | undefined,
58
81
  THeaders extends Schema | undefined = Schema | undefined,
59
82
  TBody extends Schema | undefined = Schema | undefined,
60
- TResponse extends Schema | undefined = Schema | undefined
83
+ TResponse extends Schema | undefined = Schema | undefined,
84
+ E extends Env = any
61
85
  > {
62
86
  method: WebhookMethod
63
87
  /** Hono path pattern, relative to where the webhooks are mounted. */
@@ -67,6 +91,13 @@ export interface WebhookConfig<
67
91
  /** Header names are lower-case. */
68
92
  headers?: THeaders
69
93
  body?: TBody
94
+ /**
95
+ * Checks the signature before the body is parsed or validated. Returning
96
+ * `false` answers `401 { error: 'Invalid signature' }`.
97
+ *
98
+ * @example verify: shopify.verify((c) => c.env.SHOPIFY_SECRET)
99
+ */
100
+ verify?: Verify<E>
70
101
  /**
71
102
  * Checks what the handler returns. A mismatch is reported on
72
103
  * `WebhookEvent.validation` and the response is still sent.
@@ -114,7 +145,7 @@ export interface Webhook<
114
145
  TBody extends Schema | undefined = any,
115
146
  TResponse extends Schema | undefined = any
116
147
  > {
117
- config: WebhookConfig<P, TParams, TQuery, THeaders, TBody, TResponse>
148
+ config: WebhookConfig<P, TParams, TQuery, THeaders, TBody, TResponse, E>
118
149
  // Method syntax keeps the parameter bivariant, so any webhook fits Webhook<E>[].
119
150
  handler(
120
151
  input: WebhookInput<E, P, TParams, TQuery, THeaders, TBody>
@@ -127,14 +158,21 @@ export interface WebhookEvent<E extends Env = any> {
127
158
  /** The matched path pattern, e.g. `/users/:id`. `undefined` for a 404. */
128
159
  route: string | undefined
129
160
  method: string
130
- /** The requested path. */
161
+ /**
162
+ * The requested path relative to the mount point, e.g. `/orders/42`. The
163
+ * full path is `c.req.path`.
164
+ */
131
165
  path: string
132
166
  status: number
167
+ /** Fractional milliseconds from `performance.now()`. */
133
168
  durationMs: number
134
- /** Each part is set once it has been validated. */
135
- params?: unknown
136
- query?: unknown
137
- headers?: unknown
169
+ /**
170
+ * Each part is set once it has been validated: the schema's output, or
171
+ * the raw string record when there is no schema.
172
+ */
173
+ params?: Record<string, unknown>
174
+ query?: Record<string, unknown>
175
+ headers?: Record<string, unknown>
138
176
  body?: unknown
139
177
  rawBody?: string
140
178
  /** What the handler returned, unless it returned a `Response`. */
@@ -148,11 +186,31 @@ export interface WebhookEvent<E extends Env = any> {
148
186
  }
149
187
  }
150
188
 
189
+ /** What `around` knows about a request before it is handled. */
190
+ export interface WebhookRequestInfo<E extends Env = any> {
191
+ c: Context<E>
192
+ /** The matched path pattern, e.g. `/users/:id`. `undefined` for a 404. */
193
+ route: string | undefined
194
+ method: string
195
+ }
196
+
151
197
  export interface WebhooksOptions<E extends Env = any> {
152
198
  /**
153
- * Called after every request: log it, store it, record metrics. Not
154
- * awaited; a returned promise is handed to `executionCtx.waitUntil` when
155
- * the runtime has one, and its rejection is logged.
199
+ * Wraps each request, 404s and 405s included, with the route already
200
+ * known: start a span, set up `AsyncLocalStorage`, time it. Call `next()`
201
+ * to handle the request (which also runs `onEvent`) and return its
202
+ * response.
203
+ */
204
+ around?: (
205
+ info: WebhookRequestInfo<E>,
206
+ next: () => Promise<Response>
207
+ ) => Promise<Response>
208
+ /**
209
+ * Called after every request: log it, store it, record metrics. Runs
210
+ * inside the request's async context (and inside `around`), so the active
211
+ * span and `AsyncLocalStorage` values are available. Not awaited; a
212
+ * returned promise is handed to `executionCtx.waitUntil` when the runtime
213
+ * has one, and its rejection is logged.
156
214
  */
157
215
  onEvent?: (event: WebhookEvent<E>) => MaybePromise<void>
158
216
  /**
@@ -161,6 +219,12 @@ export interface WebhooksOptions<E extends Env = any> {
161
219
  * and `500 { error: 'Internal Server Error' }` for anything else.
162
220
  */
163
221
  onError?: (error: unknown, c: Context<E>) => MaybePromise<Response | void>
222
+ /**
223
+ * Log unexpected errors with `console.error` when the default 500
224
+ * response is used. Turn off when `onEvent` already logs them.
225
+ * @default true
226
+ */
227
+ logErrors?: boolean
164
228
  }
165
229
 
166
230
  export type WebhooksApp<E extends Env = any> = Hono<E> & {
@@ -168,13 +232,18 @@ export type WebhooksApp<E extends Env = any> = Hono<E> & {
168
232
  readonly webhooks: readonly Webhook<E>[]
169
233
  }
170
234
 
171
- /** Thrown when a request part fails its schema. Answers 400 by default. */
235
+ /**
236
+ * Thrown when a request part fails its schema (400) or the signature check
237
+ * fails (401).
238
+ */
172
239
  export class WebhookValidationError extends HTTPException {
173
240
  constructor(
174
241
  readonly target: Exclude<ValidationTarget, 'response'>,
175
242
  readonly issues: readonly StandardSchemaV1.Issue[]
176
243
  ) {
177
- super(400, { message: `Invalid ${target}` })
244
+ super(target === 'signature' ? 401 : 400, {
245
+ message: `Invalid ${target}`
246
+ })
178
247
  this.name = 'WebhookValidationError'
179
248
  }
180
249
  }
@@ -188,7 +257,7 @@ function makeDefineWebhook<E extends Env>() {
188
257
  TBody extends Schema | undefined = undefined,
189
258
  TResponse extends Schema | undefined = undefined
190
259
  >(
191
- config: WebhookConfig<P, TParams, TQuery, THeaders, TBody, TResponse>,
260
+ config: WebhookConfig<P, TParams, TQuery, THeaders, TBody, TResponse, E>,
192
261
  handler: (
193
262
  input: WebhookInput<E, P, TParams, TQuery, THeaders, TBody>
194
263
  ) => WebhookResult<TResponse>
@@ -229,7 +298,7 @@ async function parse(
229
298
 
230
299
  function defaultErrorResponse(error: unknown, c: Context, log: boolean) {
231
300
  if (error instanceof WebhookValidationError) {
232
- return c.json({ error: error.message, issues: error.issues }, 400)
301
+ return c.json({ error: error.message, issues: error.issues }, error.status)
233
302
  }
234
303
  if (error instanceof HTTPException) {
235
304
  return error.res ?? c.json({ error: error.message }, error.status)
@@ -275,13 +344,15 @@ function makeCreateWebhooks<E extends Env>() {
275
344
 
276
345
  type Run = (c: Context<E>, event: WebhookEvent<E>) => Promise<Response>
277
346
  const handle = (route: string | undefined, run: Run) => {
278
- return async (c: Context<E>) => {
347
+ const execute = async (c: Context<E>) => {
279
348
  const start = performance.now()
349
+ const base = basePath(c)
280
350
  const event: WebhookEvent<E> = {
281
351
  c,
282
352
  route,
283
353
  method: c.req.method,
284
- path: c.req.path,
354
+ path:
355
+ base === '/' ? c.req.path : c.req.path.slice(base.length) || '/',
285
356
  status: 0,
286
357
  durationMs: 0
287
358
  }
@@ -295,13 +366,18 @@ function makeCreateWebhooks<E extends Env>() {
295
366
  }
296
367
  res =
297
368
  (await options.onError?.(error, c)) ??
298
- defaultErrorResponse(error, c as Context, !options.onError)
369
+ defaultErrorResponse(error, c as Context, options.logErrors ?? true)
299
370
  }
300
371
  event.status = res.status
301
372
  event.durationMs = performance.now() - start
302
373
  if (options.onEvent) report(c as Context, options.onEvent(event))
303
374
  return res
304
375
  }
376
+ const { around } = options
377
+ return (c: Context<E>) =>
378
+ around
379
+ ? around({ c, route, method: c.req.method }, () => execute(c))
380
+ : execute(c)
305
381
  }
306
382
 
307
383
  // Hono tries handlers in registration order, so static paths go first
@@ -316,10 +392,35 @@ function makeCreateWebhooks<E extends Env>() {
316
392
  config.method,
317
393
  config.path,
318
394
  handle(config.path, async (c, event) => {
319
- event.params = await parse(config.params, c.req.param(), 'params')
320
- event.query = await parse(config.query, c.req.query(), 'query')
321
- event.headers = await parse(config.headers, c.req.header(), 'headers')
395
+ type Parts = Record<string, unknown>
396
+ event.params = (await parse(
397
+ config.params,
398
+ c.req.param(),
399
+ 'params'
400
+ )) as Parts
401
+ event.query = (await parse(
402
+ config.query,
403
+ c.req.query(),
404
+ 'query'
405
+ )) as Parts
406
+ event.headers = (await parse(
407
+ config.headers,
408
+ c.req.header(),
409
+ 'headers'
410
+ )) as Parts
322
411
  event.rawBody = await c.req.text()
412
+ if (
413
+ config.verify &&
414
+ !(await config.verify({
415
+ c,
416
+ rawBody: event.rawBody,
417
+ headers: c.req.header()
418
+ }))
419
+ ) {
420
+ throw new WebhookValidationError('signature', [
421
+ { message: 'Signature does not match' }
422
+ ])
423
+ }
323
424
  event.body = await parse(
324
425
  config.body,
325
426
  parseBody(
@@ -416,3 +517,239 @@ export function createWebhookFactory<E extends Env = any>() {
416
517
  createWebhooks: makeCreateWebhooks<E>()
417
518
  }
418
519
  }
520
+
521
+ /**
522
+ * Validation issues as one line: `total: Expected number; items.0.id: Required`.
523
+ */
524
+ export function formatIssues(issues: readonly StandardSchemaV1.Issue[]) {
525
+ return issues
526
+ .map((issue) => {
527
+ const path = issuePath(issue)
528
+ return path ? `${path}: ${issue.message}` : issue.message
529
+ })
530
+ .join('; ')
531
+ }
532
+
533
+ function issuePath(issue: StandardSchemaV1.Issue) {
534
+ const path = issue.path
535
+ ?.map((segment) =>
536
+ String(typeof segment === 'object' ? segment.key : segment)
537
+ )
538
+ .join('.')
539
+ return path || null
540
+ }
541
+
542
+ /** A flat, JSON-safe record of a `WebhookEvent`, ready to log or store. */
543
+ export interface WebhookEventSummary {
544
+ route: string | null
545
+ method: string
546
+ /** Relative to the mount point. */
547
+ path: string
548
+ status: number
549
+ /** Rounded to two decimals. */
550
+ durationMs: number
551
+ /** From `x-forwarded-for`, `x-real-ip` or `cf-connecting-ip`; clients can spoof these. */
552
+ ip: string | null
553
+ userAgent: string | null
554
+ params: Record<string, unknown> | null
555
+ query: Record<string, unknown> | null
556
+ body: unknown
557
+ response: unknown
558
+ error: { name: string; message: string; stack: string | null } | null
559
+ validation: {
560
+ target: ValidationTarget
561
+ /** `formatIssues(issues)` */
562
+ message: string
563
+ issues: { message: string; path: string | null }[]
564
+ } | null
565
+ }
566
+
567
+ /**
568
+ * Flattens an event for logging or storage. Request headers are left out on
569
+ * purpose: they carry signatures and credentials.
570
+ *
571
+ * @example
572
+ * onEvent: (e) => db.insert(webhookCalls).values(summarizeEvent(e))
573
+ */
574
+ export function summarizeEvent(event: WebhookEvent): WebhookEventSummary {
575
+ const { c, error, validation } = event
576
+ return {
577
+ route: event.route ?? null,
578
+ method: event.method,
579
+ path: event.path,
580
+ status: event.status,
581
+ durationMs: Math.round(event.durationMs * 100) / 100,
582
+ ip:
583
+ c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ||
584
+ c.req.header('x-real-ip') ||
585
+ c.req.header('cf-connecting-ip') ||
586
+ null,
587
+ userAgent: c.req.header('user-agent') ?? null,
588
+ params: event.params ?? null,
589
+ query: event.query ?? null,
590
+ body: event.body ?? null,
591
+ response: event.response ?? null,
592
+ error:
593
+ error === undefined
594
+ ? null
595
+ : error instanceof Error
596
+ ? {
597
+ name: error.name,
598
+ message: error.message,
599
+ stack: error.stack ?? null
600
+ }
601
+ : { name: 'Error', message: String(error), stack: null },
602
+ validation: validation
603
+ ? {
604
+ target: validation.target,
605
+ message: formatIssues(validation.issues),
606
+ issues: validation.issues.map((issue) => ({
607
+ message: issue.message,
608
+ path: issuePath(issue)
609
+ }))
610
+ }
611
+ : null
612
+ }
613
+ }
614
+
615
+ export interface WebhookDescription {
616
+ method: WebhookMethod
617
+ path: string
618
+ description: string | null
619
+ /** Whether the webhook has a `verify` function. */
620
+ verified: boolean
621
+ bodyType: BodyType
622
+ /**
623
+ * JSON Schema per part: the input side for request parts, the output side
624
+ * for `response`. `null` when the part has no schema or its library
625
+ * doesn't implement Standard JSON Schema.
626
+ */
627
+ jsonSchema: Record<
628
+ 'params' | 'query' | 'headers' | 'body' | 'response',
629
+ Record<string, unknown> | null
630
+ >
631
+ }
632
+
633
+ function toJsonSchema(
634
+ schema: Schema | undefined,
635
+ side: 'input' | 'output',
636
+ target: StandardJSONSchemaV1.Target
637
+ ) {
638
+ const props = (schema as Partial<StandardJSONSchemaV1> | undefined)?.[
639
+ '~standard'
640
+ ]
641
+ try {
642
+ return props?.jsonSchema?.[side]({ target }) ?? null
643
+ } catch {
644
+ return null
645
+ }
646
+ }
647
+
648
+ /**
649
+ * Describes webhooks for docs, admin pages or OpenAPI, with JSON Schemas from
650
+ * any library that implements Standard JSON Schema (Zod 4.2+, for example).
651
+ */
652
+ export function describeWebhooks(
653
+ source: WebhooksApp | readonly Webhook[],
654
+ { target = 'draft-2020-12' }: { target?: StandardJSONSchemaV1.Target } = {}
655
+ ): WebhookDescription[] {
656
+ const webhooks = 'webhooks' in source ? source.webhooks : source
657
+ return webhooks.map(({ config }) => ({
658
+ method: config.method,
659
+ path: config.path,
660
+ description: config.description ?? null,
661
+ verified: Boolean(config.verify),
662
+ bodyType: config.bodyType ?? 'auto',
663
+ jsonSchema: {
664
+ params: toJsonSchema(config.params, 'input', target),
665
+ query: toJsonSchema(config.query, 'input', target),
666
+ headers: toJsonSchema(config.headers, 'input', target),
667
+ body: toJsonSchema(config.body, 'input', target),
668
+ response: toJsonSchema(config.response, 'output', target)
669
+ }
670
+ }))
671
+ }
672
+
673
+ type In<S, Fallback> = S extends Schema
674
+ ? StandardSchemaV1.InferInput<S>
675
+ : Fallback
676
+
677
+ /** What `testWebhook` sends, typed from the webhook's schemas. */
678
+ export type TestWebhookInput<W> =
679
+ W extends Webhook<infer E, infer P, infer TParams, any, any, infer TBody>
680
+ ? {
681
+ /** Filled into the path pattern. */
682
+ params?: In<TParams, PathParams<P>>
683
+ /** Overrides the path, e.g. for wildcard patterns. */
684
+ path?: string
685
+ query?: Record<string, string>
686
+ headers?: Record<string, string>
687
+ /**
688
+ * A string is sent as is (`text/plain` unless you set a content
689
+ * type). Anything else is sent as JSON, or url-encoded when the
690
+ * webhook's `bodyType` is `'form'`.
691
+ */
692
+ body?: In<TBody, unknown> | string
693
+ /** Headers computed from the raw body, e.g. `(raw) => shopify.sign(raw, secret)`. */
694
+ sign?: (rawBody: string) => MaybePromise<Record<string, string>>
695
+ /** Bindings, available as `c.env`. */
696
+ env?: E extends { Bindings: infer B } ? B : unknown
697
+ /** `onEvent`, `onError` and so on, as for `createWebhooks`. */
698
+ options?: WebhooksOptions<E>
699
+ }
700
+ : never
701
+
702
+ function fillPath(pattern: string, params: Record<string, unknown>) {
703
+ return pattern.replace(/\/:(\w+)(?:\{[^}]*\})?\??/g, (_, name: string) => {
704
+ const value = params[name]
705
+ return value === undefined ? '' : `/${encodeURIComponent(String(value))}`
706
+ })
707
+ }
708
+
709
+ /**
710
+ * Sends one request through a single webhook, with routing, signature check,
711
+ * validation and response handling, and returns the `Response`.
712
+ *
713
+ * @example
714
+ * const res = await testWebhook(orderPaid, {
715
+ * params: { orderId: '42' },
716
+ * body: { amount: 10, currency: 'DKK' }
717
+ * })
718
+ */
719
+ export async function testWebhook<W extends Webhook>(
720
+ webhook: W,
721
+ input: TestWebhookInput<W> = {} as TestWebhookInput<W>
722
+ ): Promise<Response> {
723
+ const { config } = webhook
724
+ const { body, sign, env, options } = input as TestWebhookInput<Webhook>
725
+ const headers = new Headers(input.headers)
726
+ let rawBody: string | undefined
727
+ if (typeof body === 'string') {
728
+ rawBody = body
729
+ if (!headers.has('content-type')) headers.set('content-type', 'text/plain')
730
+ } else if (body !== undefined && config.bodyType === 'form') {
731
+ rawBody = new URLSearchParams(body as Record<string, string>).toString()
732
+ if (!headers.has('content-type')) {
733
+ headers.set('content-type', 'application/x-www-form-urlencoded')
734
+ }
735
+ } else if (body !== undefined) {
736
+ rawBody = JSON.stringify(body)
737
+ if (!headers.has('content-type')) {
738
+ headers.set('content-type', 'application/json')
739
+ }
740
+ }
741
+ for (const [name, value] of Object.entries(
742
+ (await sign?.(rawBody ?? '')) ?? {}
743
+ )) {
744
+ headers.set(name, value)
745
+ }
746
+ const path =
747
+ input.path ??
748
+ fillPath(config.path, (input.params ?? {}) as Record<string, unknown>)
749
+ const query = new URLSearchParams(input.query).toString()
750
+ return createWebhooks([webhook], options).request(
751
+ `${path || '/'}${query ? `?${query}` : ''}`,
752
+ { method: config.method, headers, body: rawBody },
753
+ env as object | undefined
754
+ )
755
+ }
@@ -0,0 +1,171 @@
1
+ import type { Context } from 'hono'
2
+ import type { Verify } from './index'
3
+
4
+ /** A signing secret, or a function reading it per request (e.g. from `c.env`). */
5
+ export type Secret = string | ((c: Context<any>) => string | Promise<string>)
6
+
7
+ export interface SignatureScheme<VerifyOptions = void, SignOptions = void> {
8
+ /** A `verify` function for `defineWebhook`. */
9
+ verify(secret: Secret, options?: VerifyOptions): Verify
10
+ /** The headers a provider would send with `rawBody`. For tests and the CLI. */
11
+ sign(
12
+ rawBody: string,
13
+ secret: string,
14
+ options?: SignOptions
15
+ ): Promise<Record<string, string>>
16
+ }
17
+
18
+ export interface HmacOptions {
19
+ /** Header carrying the signature, e.g. `x-hub-signature-256`. */
20
+ header: string
21
+ encoding: 'hex' | 'base64'
22
+ /** Text before the signature in the header, e.g. `sha256=`. */
23
+ prefix?: string
24
+ /** @default 'SHA-256' */
25
+ hash?: 'SHA-1' | 'SHA-256' | 'SHA-512'
26
+ }
27
+
28
+ const encoder = new TextEncoder()
29
+
30
+ function hmacKey(secret: string, hash: string) {
31
+ if (!secret) throw new Error('hono-webhooks: the signing secret is empty')
32
+ return crypto.subtle.importKey(
33
+ 'raw',
34
+ encoder.encode(secret),
35
+ { name: 'HMAC', hash },
36
+ false,
37
+ ['sign', 'verify']
38
+ )
39
+ }
40
+
41
+ async function hmacSign(secret: string, payload: string, hash = 'SHA-256') {
42
+ const key = await hmacKey(secret, hash)
43
+ return new Uint8Array(
44
+ await crypto.subtle.sign('HMAC', key, encoder.encode(payload))
45
+ )
46
+ }
47
+
48
+ /** `crypto.subtle.verify` compares in constant time. */
49
+ async function hmacVerify(
50
+ secret: string,
51
+ payload: string,
52
+ signature: Uint8Array,
53
+ hash = 'SHA-256'
54
+ ) {
55
+ const key = await hmacKey(secret, hash)
56
+ return crypto.subtle.verify(
57
+ 'HMAC',
58
+ key,
59
+ signature as Uint8Array<ArrayBuffer>,
60
+ encoder.encode(payload)
61
+ )
62
+ }
63
+
64
+ const toHex = (bytes: Uint8Array) =>
65
+ Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
66
+
67
+ const toBase64 = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes))
68
+
69
+ function fromHex(hex: string) {
70
+ if (hex.length % 2 || /[^0-9a-f]/i.test(hex)) return undefined
71
+ return Uint8Array.from(hex.match(/../g) ?? [], (h) => Number.parseInt(h, 16))
72
+ }
73
+
74
+ function fromBase64(base64: string) {
75
+ try {
76
+ return Uint8Array.from(atob(base64), (ch) => ch.charCodeAt(0))
77
+ } catch {
78
+ return undefined
79
+ }
80
+ }
81
+
82
+ const resolveSecret = async (secret: Secret, c: Context) =>
83
+ typeof secret === 'function' ? secret(c) : secret
84
+
85
+ /**
86
+ * A scheme where one header carries an HMAC of the raw body. Covers most
87
+ * providers; `github` and `shopify` are built with it.
88
+ *
89
+ * @example
90
+ * const linear = hmac({ header: 'linear-signature', encoding: 'hex' })
91
+ */
92
+ export function hmac(options: HmacOptions): SignatureScheme {
93
+ const { header, encoding, prefix = '', hash = 'SHA-256' } = options
94
+ const name = header.toLowerCase()
95
+ return {
96
+ verify:
97
+ (secret) =>
98
+ async ({ c, rawBody, headers }) => {
99
+ const value = headers[name]
100
+ if (!value?.startsWith(prefix)) return false
101
+ const encoded = value.slice(prefix.length)
102
+ const signature =
103
+ encoding === 'hex' ? fromHex(encoded) : fromBase64(encoded)
104
+ if (!signature) return false
105
+ return hmacVerify(
106
+ await resolveSecret(secret, c),
107
+ rawBody,
108
+ signature,
109
+ hash
110
+ )
111
+ },
112
+ async sign(rawBody, secret) {
113
+ const signature = await hmacSign(secret, rawBody, hash)
114
+ return {
115
+ [name]: `${prefix}${encoding === 'hex' ? toHex(signature) : toBase64(signature)}`
116
+ }
117
+ }
118
+ }
119
+ }
120
+
121
+ /** GitHub: `x-hub-signature-256: sha256=<hex>`. */
122
+ export const github = hmac({
123
+ header: 'x-hub-signature-256',
124
+ encoding: 'hex',
125
+ prefix: 'sha256='
126
+ })
127
+
128
+ /** Shopify: `x-shopify-hmac-sha256: <base64>`. */
129
+ export const shopify = hmac({
130
+ header: 'x-shopify-hmac-sha256',
131
+ encoding: 'base64'
132
+ })
133
+
134
+ /**
135
+ * Stripe: `stripe-signature: t=<unix>,v1=<hex>`, signed over `<t>.<body>`.
136
+ * Rejects timestamps more than `toleranceSeconds` (default 300) away.
137
+ */
138
+ export const stripe: SignatureScheme<
139
+ { toleranceSeconds?: number },
140
+ { timestamp?: number }
141
+ > = {
142
+ verify:
143
+ (secret, { toleranceSeconds = 300 } = {}) =>
144
+ async ({ c, rawBody, headers }) => {
145
+ const parts = (headers['stripe-signature'] ?? '')
146
+ .split(',')
147
+ .map((part) => part.split('='))
148
+ // Signed over the timestamp exactly as sent.
149
+ const timestamp = parts.find(([k]) => k === 't')?.[1] ?? ''
150
+ if (!/^\d+$/.test(timestamp)) return false
151
+ if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
152
+ return false
153
+ }
154
+ const key = await resolveSecret(secret, c)
155
+ for (const [k, v] of parts) {
156
+ const signature = k === 'v1' && v ? fromHex(v) : undefined
157
+ if (
158
+ signature &&
159
+ (await hmacVerify(key, `${timestamp}.${rawBody}`, signature))
160
+ ) {
161
+ return true
162
+ }
163
+ }
164
+ return false
165
+ },
166
+ async sign(rawBody, secret, { timestamp } = {}) {
167
+ const t = timestamp ?? Math.floor(Date.now() / 1000)
168
+ const signature = await hmacSign(secret, `${t}.${rawBody}`)
169
+ return { 'stripe-signature': `t=${t},v1=${toHex(signature)}` }
170
+ }
171
+ }