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/README.md +315 -91
- package/dist/cli.js +426 -20
- package/dist/index.d.ts +171 -18
- package/dist/index.js +122 -6
- package/dist/signatures.d.ts +40 -0
- package/dist/signatures.js +91 -0
- package/package.json +9 -3
- package/src/cli.ts +90 -25
- package/src/index.ts +361 -24
- package/src/signatures.ts +171 -0
package/src/index.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import type {
|
|
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
|
|
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
|
|
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<
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
*
|
|
154
|
-
*
|
|
155
|
-
* the
|
|
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
|
-
/**
|
|
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(
|
|
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 },
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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
|
-
|
|
320
|
-
event.
|
|
321
|
-
|
|
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
|
+
}
|