@zap-studio/webhooks 0.4.0 → 1.1.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/types.d.ts CHANGED
@@ -1,10 +1,18 @@
1
1
  import { StandardSchemaV1 } from "@zap-studio/validation";
2
+ import { Logger } from "@zap-studio/logger";
2
3
  //#region src/types.d.ts
3
4
  /**
4
5
  * Context shared by hooks, verifiers, and handlers for a single webhook request.
5
6
  *
6
7
  * The router consumes the request body exactly once, so `request.body` is
7
8
  * already used by the time hooks or handlers run — read `rawBody` instead.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * const before: BeforeHook = (ctx: WebhookContext) => {
13
+ * console.log("received", ctx.path);
14
+ * };
15
+ * ```
8
16
  */
9
17
  interface WebhookContext {
10
18
  /** The matched route key registered on the router (e.g. "stripe") */
@@ -18,12 +26,75 @@ interface WebhookContext {
18
26
  * Handler context extending the shared webhook context with the validated payload.
19
27
  *
20
28
  * @template TPayload - Validated payload type for the matched route.
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * const handler: WebhookHandler<{ type: string }> = ({ payload }: HandlerContext<{ type: string }>) => {
33
+ * console.log(payload.type);
34
+ * };
35
+ * ```
21
36
  */
22
37
  interface HandlerContext<TPayload = unknown> extends WebhookContext {
23
38
  /** The validated webhook payload */
24
39
  payload: TPayload;
25
40
  }
26
- /** Route registration options for a webhook handler. */
41
+ /** Internal handler entry stored per registered route. */
42
+ interface HandlerEntry<TPayload = unknown> {
43
+ /** Route-level hooks that run after successful processing. */
44
+ after?: AfterHook[];
45
+ /** Route-level hooks that run before request processing. */
46
+ before?: BeforeHook[];
47
+ /** The handler function to process the webhook. */
48
+ handler: WebhookHandler<TPayload>;
49
+ /** Optional Standard Schema validator to validate the webhook payload. */
50
+ schema?: StandardSchemaV1<unknown, TPayload>;
51
+ }
52
+ /**
53
+ * Configuration options for creating a `WebhookRouter`.
54
+ *
55
+ * @example
56
+ * ```ts
57
+ * const options: WebhookRouterOptions = {
58
+ * prefix: "/webhooks",
59
+ * verify: createHmacVerifier({ headerName: "x-signature", secret }),
60
+ * };
61
+ * ```
62
+ */
63
+ interface WebhookRouterOptions {
64
+ /** Global hooks executed after successful route handler completion. */
65
+ after?: AfterHook | AfterHook[];
66
+ /** Global hooks executed before route-level hooks and verification. */
67
+ before?: BeforeHook | BeforeHook[];
68
+ /** Global error hook used to override the default `500` response. */
69
+ onError?: ErrorHook;
70
+ /**
71
+ * Optional logger for router internals. When omitted, nothing is logged.
72
+ *
73
+ * Logs each delivery attempt and handler dispatch at `debug`, and
74
+ * verification failures and unmatched routes at `warn`.
75
+ */
76
+ logger?: Logger;
77
+ /**
78
+ * Required path prefix for all webhook routes. Defaults to `"/webhooks"`.
79
+ *
80
+ * Normalized internally: leading slash added, trailing slash stripped,
81
+ * duplicate slashes collapsed. Use `""` or `"/"` to mount at the root.
82
+ */
83
+ prefix?: string;
84
+ /** Optional request verification function (for signature checks, auth, etc.). */
85
+ verify?: VerifyFn;
86
+ }
87
+ /**
88
+ * Route registration options for a webhook handler.
89
+ *
90
+ * @example
91
+ * ```ts
92
+ * const options: RegisterOptions<{ type: string }> = {
93
+ * schema: stripeEventSchema,
94
+ * handler: ({ payload }) => console.log(payload.type),
95
+ * };
96
+ * ```
97
+ */
27
98
  interface RegisterOptions<T> {
28
99
  /** Hooks that run after successful processing (before global after hooks) */
29
100
  after?: AfterHook | AfterHook[];
@@ -44,20 +115,40 @@ type InferSchemaOutput<TSchema> = TSchema extends StandardSchemaV1<unknown, infe
44
115
  * Route options where schema is required and handler payload is inferred.
45
116
  *
46
117
  * @template TSchema - Schema used to infer handler payload type.
118
+ *
119
+ * @example
120
+ * ```ts
121
+ * const stripeRoute: SchemaRouteOptions<typeof stripeEventSchema> = {
122
+ * schema: stripeEventSchema,
123
+ * handler: ({ payload }) => console.log(payload.type),
124
+ * };
125
+ * ```
47
126
  */
48
127
  type SchemaRouteOptions<TSchema extends StandardSchemaV1<unknown, unknown>> = Omit<RegisterOptions<InferSchemaOutput<TSchema>>, "schema"> & {
49
128
  schema: TSchema;
50
129
  };
130
+ /** A single route's registration shape, as used by schema-driven route dictionaries. */
51
131
  interface RouteLike {
132
+ /** Hooks that run after successful processing. */
52
133
  after?: AfterHook | AfterHook[];
134
+ /** Hooks that run before request processing. */
53
135
  before?: BeforeHook | BeforeHook[];
136
+ /** The handler function to process the webhook. */
54
137
  handler: WebhookHandler;
138
+ /** Standard Schema validator the route's payload type is inferred from. */
55
139
  schema: StandardSchemaV1<unknown, unknown>;
56
140
  }
57
141
  /**
58
142
  * Applies schema-driven payload inference to each route entry.
59
143
  *
60
144
  * @template TRoutes - Route dictionary keyed by webhook path.
145
+ *
146
+ * @example
147
+ * ```ts
148
+ * const routes: SchemaRoutes<{ "/stripe": { handler: WebhookHandler; schema: typeof stripeEventSchema } }> = {
149
+ * "/stripe": { schema: stripeEventSchema, handler: ({ payload }) => console.log(payload.type) },
150
+ * };
151
+ * ```
61
152
  */
62
153
  type SchemaRoutes<TRoutes extends Record<string, RouteLike>> = { [P in keyof TRoutes]: SchemaRouteOptions<TRoutes[P]["schema"]>; };
63
154
  /**
@@ -65,29 +156,78 @@ type SchemaRoutes<TRoutes extends Record<string, RouteLike>> = { [P in keyof TRo
65
156
  *
66
157
  * Return a `Response` to control the reply, or `undefined` to let the router
67
158
  * respond with its default `200` acknowledgement.
159
+ *
160
+ * @example
161
+ * ```ts
162
+ * const handler: WebhookHandler<{ type: string }> = ({ payload }) => {
163
+ * console.log(payload.type);
164
+ * };
165
+ * ```
68
166
  */
69
167
  type WebhookHandler<TPayload = unknown> = (ctx: HandlerContext<TPayload>) => Promise<Response | undefined> | Response | undefined;
70
- /** Maps route keys to their payload-specific webhook handlers. */
168
+ /**
169
+ * Maps route keys to their payload-specific webhook handlers.
170
+ *
171
+ * @example
172
+ * ```ts
173
+ * const handlers: HandlerMap<{ "/stripe": { type: string } }> = {
174
+ * "/stripe": ({ payload }) => console.log(payload.type),
175
+ * };
176
+ * ```
177
+ */
71
178
  type HandlerMap<TMap extends Record<string, unknown>> = { [P in keyof TMap]: WebhookHandler<TMap[P]>; };
72
179
  /**
73
180
  * Builds a webhook payload map from a schema-based route dictionary.
74
181
  *
75
182
  * @template TRoutes - Route dictionary keyed by webhook path.
183
+ *
184
+ * @example
185
+ * ```ts
186
+ * type Payloads = InferWebhookMapFromRoutes<{
187
+ * "/stripe": { handler: WebhookHandler; schema: typeof stripeEventSchema };
188
+ * }>;
189
+ * ```
76
190
  */
77
191
  type InferWebhookMapFromRoutes<TRoutes extends Record<string, RouteLike>> = { [P in keyof TRoutes]: InferSchemaOutput<TRoutes[P]["schema"]>; };
78
- /** Verification function for incoming requests. Throws to reject the request. */
192
+ /**
193
+ * Verification function for incoming requests. Throws to reject the request.
194
+ *
195
+ * @example
196
+ * ```ts
197
+ * const verify: VerifyFn = createHmacVerifier({ headerName: "x-signature", secret });
198
+ * ```
199
+ */
79
200
  type VerifyFn = (ctx: WebhookContext) => Promise<void> | void;
80
- /** Hook function that runs before request processing */
201
+ /**
202
+ * Hook function that runs before request processing
203
+ *
204
+ * @example
205
+ * ```ts
206
+ * const before: BeforeHook = (ctx) => console.log("received", ctx.path);
207
+ * ```
208
+ */
81
209
  type BeforeHook = (ctx: WebhookContext) => Promise<void> | void;
82
210
  /**
83
211
  * Hook function that runs after successful request processing.
84
212
  *
85
213
  * The hook receives the outgoing response as-is; call `response.clone()`
86
214
  * before reading its body to avoid consuming the stream sent to the client.
215
+ *
216
+ * @example
217
+ * ```ts
218
+ * const after: AfterHook = (ctx, response) => console.log(response.status);
219
+ * ```
87
220
  */
88
221
  type AfterHook = (ctx: WebhookContext, response: Response) => Promise<void> | void;
89
- /** Hook function that runs when an error occurs */
222
+ /**
223
+ * Hook function that runs when an error occurs
224
+ *
225
+ * @example
226
+ * ```ts
227
+ * const onError: ErrorHook = (error) => Response.json({ error: error.message }, { status: 500 });
228
+ * ```
229
+ */
90
230
  type ErrorHook = (error: Error, ctx: WebhookContext) => Promise<Response | undefined> | Response | undefined;
91
231
  //#endregion
92
- export { AfterHook, BeforeHook, ErrorHook, HandlerContext, HandlerMap, InferSchemaOutput, InferWebhookMapFromRoutes, RegisterOptions, SchemaRouteOptions, SchemaRoutes, VerifyFn, WebhookContext, WebhookHandler };
232
+ export { AfterHook, BeforeHook, ErrorHook, HandlerContext, HandlerEntry, HandlerMap, InferSchemaOutput, InferWebhookMapFromRoutes, RegisterOptions, RouteLike, SchemaRouteOptions, SchemaRoutes, VerifyFn, WebhookContext, WebhookHandler, WebhookRouterOptions };
93
233
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","names":[],"sources":["../src/types.ts"],"mappings":";;;;;;;;UAciB;;EAEf;;EAEA,SAAS;;EAET,SAAS;;;;;;;UAQM,eAAe,4BAA4B;;EAE1D,SAAS;;;UAIM,gBAAgB;;EAE/B,QAAQ,YAAY;;EAEpB,SAAS,aAAa;;EAEtB,SAAS,eAAe;;EAExB,SAAS,0BAA0B;;;;;;;KAQzB,kBAAkB,WAC5B,gBAAgB,gCAAgC,WAAW;;;;;;KAOjD,mBACV,gBAAgB,sCACd,KAAK,gBAAgB,kBAAkB;EACzC,QAAQ;;UAGA;EACR,QAAQ,YAAY;EACpB,SAAS,aAAa;EACtB,SAAS;EACT,QAAQ;;;;;;;KAQE,aAAa,gBAAgB,eAAe,iBACrD,WAAW,UAAU,mBAAmB,QAAQ;;;;;;;KASvC,eAAe,uBACzB,KAAK,eAAe,cACjB,QAAQ,wBAAwB;;KAGzB,WAAW,aAAa,8BACjC,WAAW,OAAO,eAAe,KAAK;;;;;;KAQ7B,0BACV,gBAAgB,eAAe,iBAE9B,WAAW,UAAU,kBAAkB,QAAQ;;KAItC,YAAY,KAAK,mBAAmB;;KAGpC,cAAc,KAAK,mBAAmB;;;;;;;KAQtC,aACV,KAAK,gBACL,UAAU,aACP;;KAGO,aACV,OAAO,OACP,KAAK,mBACF,QAAQ,wBAAwB"}
1
+ {"version":3,"file":"types.d.ts","names":[],"sources":["../src/types.ts"],"mappings":";;;;;;;;;;;;;;;;UAsBiB;;EAEf;;EAEA,SAAS;;EAET,SAAS;;;;;;;;;;;;;;UAeM,eAAe,4BAA4B;;EAE1D,SAAS;;;UAIM,aAAa;;EAE5B,QAAQ;;EAER,SAAS;;EAET,SAAS,eAAe;;EAExB,SAAS,0BAA0B;;;;;;;;;;;;;UAcpB;;EAEf,QAAQ,YAAY;;EAEpB,SAAS,aAAa;;EAEtB,UAAU;;;;;;;EAOV,SAAS;;;;;;;EAOT;;EAEA,SAAS;;;;;;;;;;;;;UAcM,gBAAgB;;EAE/B,QAAQ,YAAY;;EAEpB,SAAS,aAAa;;EAEtB,SAAS,eAAe;;EAExB,SAAS,0BAA0B;;;;;;;KAQzB,kBAAkB,WAC5B,gBAAgB,gCAAgC,WAAW;;;;;;;;;;;;;;KAejD,mBACV,gBAAgB,sCACd,KAAK,gBAAgB,kBAAkB;EACzC,QAAQ;;;UAIO;;EAEf,QAAQ,YAAY;;EAEpB,SAAS,aAAa;;EAEtB,SAAS;;EAET,QAAQ;;;;;;;;;;;;;;KAeE,aAAa,gBAAgB,eAAe,iBACrD,WAAW,UAAU,mBAAmB,QAAQ;;;;;;;;;;;;;;KAgBvC,eAAe,uBACzB,KAAK,eAAe,cACjB,QAAQ,wBAAwB;;;;;;;;;;;KAYzB,WAAW,aAAa,8BACjC,WAAW,OAAO,eAAe,KAAK;;;;;;;;;;;;;KAe7B,0BACV,gBAAgB,eAAe,iBAE9B,WAAW,UAAU,kBAAkB,QAAQ;;;;;;;;;KAWtC,YAAY,KAAK,mBAAmB;;;;;;;;;KAUpC,cAAc,KAAK,mBAAmB;;;;;;;;;;;;KAatC,aACV,KAAK,gBACL,UAAU,aACP;;;;;;;;;KAUO,aACV,OAAO,OACP,KAAK,mBACF,QAAQ,wBAAwB"}
package/dist/verify.d.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import { VerifyFn } from "./types.js";
2
2
  //#region src/verify.d.ts
3
+ /**
4
+ * Compares two byte arrays in constant time to prevent timing attacks.
5
+ */
6
+ declare const constantTimeEquals: (a: Uint8Array, b: Uint8Array) => boolean;
3
7
  declare const HMAC_HASH: {
4
8
  readonly sha1: "SHA-1";
5
9
  readonly sha256: "SHA-256";
@@ -45,5 +49,5 @@ declare const createHmacVerifier: ({ headerName, secret, algo }: {
45
49
  algo?: HmacAlgorithm;
46
50
  }) => VerifyFn;
47
51
  //#endregion
48
- export { createHmacVerifier };
52
+ export { constantTimeEquals, createHmacVerifier };
49
53
  //# sourceMappingURL=verify.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"verify.d.ts","names":[],"sources":["../src/verify.ts"],"mappings":";;cAUM;WACJ;WACA;WACA;WACA;;KAGG,6BAA6B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cA2CrB,uBACX,YACA,QACA;EAEA;EACA;EACA,OAAO;MACL"}
1
+ {"version":3,"file":"verify.d.ts","names":[],"sources":["../src/verify.ts"],"mappings":";;;;;cAYa,qBAAsB,GAAG,YAAY,GAAG;cAc/C;WACJ;WACA;WACA;WACA;;KAGG,6BAA6B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAuDrB,uBACX,YACA,QACA;EAEA;EACA;EACA,OAAO;MACL"}
package/dist/verify.js CHANGED
@@ -1,19 +1,38 @@
1
1
  import { VerificationError } from "./errors.js";
2
- import { constantTimeEquals } from "./utils.js";
3
2
  //#region src/verify.ts
4
3
  /**
5
4
  * Signature verification helpers for webhook requests.
6
5
  *
7
6
  * @module @zap-studio/webhooks/verify
8
7
  */
8
+ /**
9
+ * Compares two byte arrays in constant time to prevent timing attacks.
10
+ */
11
+ const constantTimeEquals = (a, b) => {
12
+ if (a.length !== b.length) return false;
13
+ let result = 0;
14
+ for (let i = 0; i < a.length; i += 1)
15
+ // v8 ignore next -- `?? 0` fallback is unreachable: a Uint8Array never holds `undefined` at an in-bounds index, this exists only to satisfy noUncheckedIndexedAccess.
16
+ result |= (a[i] ?? 0) ^ (b[i] ?? 0);
17
+ return result === 0;
18
+ };
9
19
  const HMAC_HASH = {
10
20
  sha1: "SHA-1",
11
21
  sha256: "SHA-256",
12
22
  sha384: "SHA-384",
13
23
  sha512: "SHA-512"
14
24
  };
15
- const toHex = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
16
- const normalizeSignature = (signature) => signature.replace(/^[a-z0-9-]+=/iu, "").trim().toLowerCase();
25
+ const HEX_PATTERN = /^[0-9a-f]*$/iu;
26
+ /**
27
+ * Decodes a hex string into bytes, or `undefined` when it is not valid hex.
28
+ */
29
+ const hexToBytes = (hex) => {
30
+ if (hex.length % 2 !== 0 || !HEX_PATTERN.test(hex)) return;
31
+ const bytes = new Uint8Array(hex.length / 2);
32
+ for (let i = 0; i < bytes.length; i += 1) bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
33
+ return bytes;
34
+ };
35
+ const normalizeSignature = (signature) => signature.replace(/^[a-z0-9-]+=/iu, "").trim();
17
36
  /**
18
37
  * Creates a webhook verifier that validates an HMAC signature from a request header.
19
38
  *
@@ -60,11 +79,12 @@ const createHmacVerifier = ({ headerName, secret, algo = "sha256" }) => {
60
79
  if (actual === null || actual.length === 0) throw new VerificationError(`Missing signature header: ${headerName}`);
61
80
  const key = await keyPromise;
62
81
  const signature = await subtle.sign("HMAC", key, new Uint8Array(ctx.rawBody));
63
- const expected = toHex(new Uint8Array(signature));
64
- if (!constantTimeEquals(expected, normalizeSignature(actual))) throw new VerificationError(`Invalid signature for header: ${headerName}`);
82
+ const expected = new Uint8Array(signature);
83
+ const provided = hexToBytes(normalizeSignature(actual));
84
+ if (provided === void 0 || !constantTimeEquals(expected, provided)) throw new VerificationError(`Invalid signature for header: ${headerName}`);
65
85
  };
66
86
  };
67
87
  //#endregion
68
- export { createHmacVerifier };
88
+ export { constantTimeEquals, createHmacVerifier };
69
89
 
70
90
  //# sourceMappingURL=verify.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"verify.js","names":[],"sources":["../src/verify.ts"],"sourcesContent":["/**\n * Signature verification helpers for webhook requests.\n *\n * @module @zap-studio/webhooks/verify\n */\n\nimport { VerificationError } from \"./errors.js\";\nimport type { VerifyFn } from \"./types.js\";\nimport { constantTimeEquals } from \"./utils.js\";\n\nconst HMAC_HASH = {\n sha1: \"SHA-1\",\n sha256: \"SHA-256\",\n sha384: \"SHA-384\",\n sha512: \"SHA-512\",\n} as const;\n\ntype HmacAlgorithm = keyof typeof HMAC_HASH;\n\nconst toHex = (bytes: Uint8Array): string =>\n Array.from(bytes, (byte) => byte.toString(16).padStart(2, \"0\")).join(\"\");\n\nconst normalizeSignature = (signature: string): string =>\n signature\n .replace(/^[a-z0-9-]+=/iu, \"\")\n .trim()\n .toLowerCase();\n\n/**\n * Creates a webhook verifier that validates an HMAC signature from a request header.\n *\n * The verifier imports the provided string secret once, computes an HMAC from\n * `ctx.rawBody`, normalizes the incoming header value, and compares both\n * signatures in constant time.\n *\n * Header values like `sha256=<hex>` are supported so common provider formats\n * such as GitHub work without extra parsing.\n *\n * @example\n * ```ts\n * import { createWebhookRouter } from \"@zap-studio/webhooks\";\n * import { createHmacVerifier } from \"@zap-studio/webhooks/verify\";\n *\n * const router = createWebhookRouter({\n * verify: createHmacVerifier({\n * headerName: \"x-hub-signature-256\",\n * secret: process.env.GITHUB_WEBHOOK_SECRET!,\n * }),\n * });\n * ```\n *\n * @param options - Verifier configuration.\n * @param options.headerName - Header containing the provider signature.\n * @param options.secret - Shared HMAC secret as a string.\n * @param options.algo - HMAC hash algorithm. Defaults to `\"sha256\"`.\n * @returns A router-compatible request verifier.\n *\n * @throws {VerificationError}\n * Thrown when verifier setup fails or request verification does not pass.\n */\nexport const createHmacVerifier = ({\n headerName,\n secret,\n algo = \"sha256\",\n}: {\n headerName: string;\n secret: string;\n algo?: HmacAlgorithm;\n}): VerifyFn => {\n if (globalThis.crypto?.subtle === undefined) {\n throw new VerificationError(\n \"Web Crypto API is unavailable in this runtime\"\n );\n }\n\n const { subtle } = globalThis.crypto;\n\n const hash = HMAC_HASH[algo];\n if (!hash) {\n throw new VerificationError(`Unsupported HMAC algorithm: ${algo}`);\n }\n\n const keyPromise = subtle.importKey(\n \"raw\",\n new TextEncoder().encode(secret),\n { hash, name: \"HMAC\" },\n false,\n [\"sign\"]\n );\n\n return async (ctx) => {\n const actual = ctx.request.headers.get(headerName);\n if (actual === null || actual.length === 0) {\n throw new VerificationError(`Missing signature header: ${headerName}`);\n }\n\n const key = await keyPromise;\n const signature = await subtle.sign(\n \"HMAC\",\n key,\n new Uint8Array(ctx.rawBody)\n );\n const expected = toHex(new Uint8Array(signature));\n\n if (!constantTimeEquals(expected, normalizeSignature(actual))) {\n throw new VerificationError(\n `Invalid signature for header: ${headerName}`\n );\n }\n };\n};\n"],"mappings":";;;;;;;;AAUA,MAAM,YAAY;CAChB,MAAM;CACN,QAAQ;CACR,QAAQ;CACR,QAAQ;AACV;AAIA,MAAM,SAAS,UACb,MAAM,KAAK,QAAQ,SAAS,KAAK,SAAS,EAAE,CAAC,CAAC,SAAS,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,EAAE;AAEzE,MAAM,sBAAsB,cAC1B,UACG,QAAQ,kBAAkB,EAAE,CAAC,CAC7B,KAAK,CAAC,CACN,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCjB,MAAa,sBAAsB,EACjC,YACA,QACA,OAAO,eAKO;CACd,IAAI,WAAW,QAAQ,WAAW,KAAA,GAChC,MAAM,IAAI,kBACR,+CACF;CAGF,MAAM,EAAE,WAAW,WAAW;CAE9B,MAAM,OAAO,UAAU;CACvB,IAAI,CAAC,MACH,MAAM,IAAI,kBAAkB,+BAA+B,MAAM;CAGnE,MAAM,aAAa,OAAO,UACxB,OACA,IAAI,YAAY,CAAC,CAAC,OAAO,MAAM,GAC/B;EAAE;EAAM,MAAM;CAAO,GACrB,OACA,CAAC,MAAM,CACT;CAEA,OAAO,OAAO,QAAQ;EACpB,MAAM,SAAS,IAAI,QAAQ,QAAQ,IAAI,UAAU;EACjD,IAAI,WAAW,QAAQ,OAAO,WAAW,GACvC,MAAM,IAAI,kBAAkB,6BAA6B,YAAY;EAGvE,MAAM,MAAM,MAAM;EAClB,MAAM,YAAY,MAAM,OAAO,KAC7B,QACA,KACA,IAAI,WAAW,IAAI,OAAO,CAC5B;EACA,MAAM,WAAW,MAAM,IAAI,WAAW,SAAS,CAAC;EAEhD,IAAI,CAAC,mBAAmB,UAAU,mBAAmB,MAAM,CAAC,GAC1D,MAAM,IAAI,kBACR,iCAAiC,YACnC;CAEJ;AACF"}
1
+ {"version":3,"file":"verify.js","names":[],"sources":["../src/verify.ts"],"sourcesContent":["/**\n * Signature verification helpers for webhook requests.\n *\n * @module @zap-studio/webhooks/verify\n */\n\nimport { VerificationError } from \"./errors.js\";\nimport type { VerifyFn } from \"./types.js\";\n\n/**\n * Compares two byte arrays in constant time to prevent timing attacks.\n */\nexport const constantTimeEquals = (a: Uint8Array, b: Uint8Array): boolean => {\n if (a.length !== b.length) {\n return false;\n }\n\n let result = 0;\n for (let i = 0; i < a.length; i += 1) {\n // v8 ignore next -- `?? 0` fallback is unreachable: a Uint8Array never holds `undefined` at an in-bounds index, this exists only to satisfy noUncheckedIndexedAccess.\n result |= (a[i] ?? 0) ^ (b[i] ?? 0); // oxlint-disable-line no-bitwise -- XOR is the constant-time compare trick.\n }\n\n return result === 0;\n};\n\nconst HMAC_HASH = {\n sha1: \"SHA-1\",\n sha256: \"SHA-256\",\n sha384: \"SHA-384\",\n sha512: \"SHA-512\",\n} as const;\n\ntype HmacAlgorithm = keyof typeof HMAC_HASH;\n\nconst HEX_PATTERN = /^[0-9a-f]*$/iu;\n\n/**\n * Decodes a hex string into bytes, or `undefined` when it is not valid hex.\n */\nconst hexToBytes = (hex: string): Uint8Array | undefined => {\n if (hex.length % 2 !== 0 || !HEX_PATTERN.test(hex)) {\n return undefined;\n }\n\n const bytes = new Uint8Array(hex.length / 2);\n for (let i = 0; i < bytes.length; i += 1) {\n bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);\n }\n\n return bytes;\n};\n\nconst normalizeSignature = (signature: string): string =>\n signature.replace(/^[a-z0-9-]+=/iu, \"\").trim();\n\n/**\n * Creates a webhook verifier that validates an HMAC signature from a request header.\n *\n * The verifier imports the provided string secret once, computes an HMAC from\n * `ctx.rawBody`, normalizes the incoming header value, and compares both\n * signatures in constant time.\n *\n * Header values like `sha256=<hex>` are supported so common provider formats\n * such as GitHub work without extra parsing.\n *\n * @example\n * ```ts\n * import { createWebhookRouter } from \"@zap-studio/webhooks\";\n * import { createHmacVerifier } from \"@zap-studio/webhooks/verify\";\n *\n * const router = createWebhookRouter({\n * verify: createHmacVerifier({\n * headerName: \"x-hub-signature-256\",\n * secret: process.env.GITHUB_WEBHOOK_SECRET!,\n * }),\n * });\n * ```\n *\n * @param options - Verifier configuration.\n * @param options.headerName - Header containing the provider signature.\n * @param options.secret - Shared HMAC secret as a string.\n * @param options.algo - HMAC hash algorithm. Defaults to `\"sha256\"`.\n * @returns A router-compatible request verifier.\n *\n * @throws {VerificationError}\n * Thrown when verifier setup fails or request verification does not pass.\n */\nexport const createHmacVerifier = ({\n headerName,\n secret,\n algo = \"sha256\",\n}: {\n headerName: string;\n secret: string;\n algo?: HmacAlgorithm;\n}): VerifyFn => {\n if (globalThis.crypto?.subtle === undefined) {\n throw new VerificationError(\n \"Web Crypto API is unavailable in this runtime\"\n );\n }\n\n const { subtle } = globalThis.crypto;\n\n const hash = HMAC_HASH[algo];\n if (!hash) {\n throw new VerificationError(`Unsupported HMAC algorithm: ${algo}`);\n }\n\n const keyPromise = subtle.importKey(\n \"raw\",\n new TextEncoder().encode(secret),\n { hash, name: \"HMAC\" },\n false,\n [\"sign\"]\n );\n\n return async (ctx) => {\n const actual = ctx.request.headers.get(headerName);\n if (actual === null || actual.length === 0) {\n throw new VerificationError(`Missing signature header: ${headerName}`);\n }\n\n const key = await keyPromise;\n const signature = await subtle.sign(\n \"HMAC\",\n key,\n new Uint8Array(ctx.rawBody)\n );\n const expected = new Uint8Array(signature);\n const provided = hexToBytes(normalizeSignature(actual));\n\n if (provided === undefined || !constantTimeEquals(expected, provided)) {\n throw new VerificationError(\n `Invalid signature for header: ${headerName}`\n );\n }\n };\n};\n"],"mappings":";;;;;;;;;;AAYA,MAAa,sBAAsB,GAAe,MAA2B;CAC3E,IAAI,EAAE,WAAW,EAAE,QACjB,OAAO;CAGT,IAAI,SAAS;CACb,KAAK,IAAI,IAAI,GAAG,IAAI,EAAE,QAAQ,KAAK;;CAEjC,WAAW,EAAE,MAAM,MAAM,EAAE,MAAM;CAGnC,OAAO,WAAW;AACpB;AAEA,MAAM,YAAY;CAChB,MAAM;CACN,QAAQ;CACR,QAAQ;CACR,QAAQ;AACV;AAIA,MAAM,cAAc;;;;AAKpB,MAAM,cAAc,QAAwC;CAC1D,IAAI,IAAI,SAAS,MAAM,KAAK,CAAC,YAAY,KAAK,GAAG,GAC/C;CAGF,MAAM,QAAQ,IAAI,WAAW,IAAI,SAAS,CAAC;CAC3C,KAAK,IAAI,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK,GACrC,MAAM,KAAK,OAAO,SAAS,IAAI,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC,GAAG,EAAE;CAG5D,OAAO;AACT;AAEA,MAAM,sBAAsB,cAC1B,UAAU,QAAQ,kBAAkB,EAAE,CAAC,CAAC,KAAK;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkC/C,MAAa,sBAAsB,EACjC,YACA,QACA,OAAO,eAKO;CACd,IAAI,WAAW,QAAQ,WAAW,KAAA,GAChC,MAAM,IAAI,kBACR,+CACF;CAGF,MAAM,EAAE,WAAW,WAAW;CAE9B,MAAM,OAAO,UAAU;CACvB,IAAI,CAAC,MACH,MAAM,IAAI,kBAAkB,+BAA+B,MAAM;CAGnE,MAAM,aAAa,OAAO,UACxB,OACA,IAAI,YAAY,CAAC,CAAC,OAAO,MAAM,GAC/B;EAAE;EAAM,MAAM;CAAO,GACrB,OACA,CAAC,MAAM,CACT;CAEA,OAAO,OAAO,QAAQ;EACpB,MAAM,SAAS,IAAI,QAAQ,QAAQ,IAAI,UAAU;EACjD,IAAI,WAAW,QAAQ,OAAO,WAAW,GACvC,MAAM,IAAI,kBAAkB,6BAA6B,YAAY;EAGvE,MAAM,MAAM,MAAM;EAClB,MAAM,YAAY,MAAM,OAAO,KAC7B,QACA,KACA,IAAI,WAAW,IAAI,OAAO,CAC5B;EACA,MAAM,WAAW,IAAI,WAAW,SAAS;EACzC,MAAM,WAAW,WAAW,mBAAmB,MAAM,CAAC;EAEtD,IAAI,aAAa,KAAA,KAAa,CAAC,mBAAmB,UAAU,QAAQ,GAClE,MAAM,IAAI,kBACR,iCAAiC,YACnC;CAEJ;AACF"}
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@zap-studio/webhooks",
3
- "version": "0.4.0",
3
+ "version": "1.1.0",
4
4
  "private": false,
5
- "description": "A lightweight, type-safe webhook router with Standard Schema validation, signature verification, and lifecycle hooks.",
5
+ "description": "A lightweight, type-safe, tree-shakeable webhook router with Standard Schema validation, signature verification, and lifecycle hooks.",
6
6
  "keywords": [
7
7
  "arktype",
8
8
  "lifecycle hooks",
@@ -39,7 +39,6 @@
39
39
  "./errors": "./dist/errors.js",
40
40
  "./router": "./dist/router.js",
41
41
  "./types": "./dist/types.js",
42
- "./utils": "./dist/utils.js",
43
42
  "./verify": "./dist/verify.js",
44
43
  "./package.json": "./package.json"
45
44
  },
@@ -47,16 +46,17 @@
47
46
  "access": "public"
48
47
  },
49
48
  "dependencies": {
50
- "@zap-studio/validation": "workspace:*"
49
+ "@zap-studio/logger": "1.0.0",
50
+ "@zap-studio/validation": "1.0.0"
51
51
  },
52
52
  "devDependencies": {
53
- "@zap-studio/typescript": "workspace:*",
54
- "tsdown": "catalog:",
55
- "typescript": "catalog:",
56
- "vitest": "catalog:",
57
- "zod": "catalog:"
53
+ "tsdown": "^0.22.14",
54
+ "typescript": "^7.0.2",
55
+ "vitest": "^4.1.10",
56
+ "zod": "^4.4.3",
57
+ "@zap-studio/typescript": "0.0.0"
58
58
  },
59
59
  "engines": {
60
60
  "node": ">=18.0.0"
61
61
  }
62
- }
62
+ }
package/dist/utils.d.ts DELETED
@@ -1,18 +0,0 @@
1
- //#region src/utils.d.ts
2
- /**
3
- * Utility helpers for webhook internals.
4
- *
5
- * @module @zap-studio/webhooks/utils
6
- */
7
- /**
8
- * Compares two strings in constant time to prevent timing attacks.
9
- *
10
- * @example
11
- * ```ts
12
- * const isEqual = constantTimeEquals("string1", "string2"); // returns false
13
- * ```
14
- */
15
- declare const constantTimeEquals: (a: string, b: string) => boolean;
16
- //#endregion
17
- export { constantTimeEquals };
18
- //# sourceMappingURL=utils.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"utils.d.ts","names":[],"sources":["../src/utils.ts"],"mappings":";;;;;;;;;;;;;;cAca,qBAAsB,WAAW"}
package/dist/utils.js DELETED
@@ -1,24 +0,0 @@
1
- //#region src/utils.ts
2
- /**
3
- * Utility helpers for webhook internals.
4
- *
5
- * @module @zap-studio/webhooks/utils
6
- */
7
- /**
8
- * Compares two strings in constant time to prevent timing attacks.
9
- *
10
- * @example
11
- * ```ts
12
- * const isEqual = constantTimeEquals("string1", "string2"); // returns false
13
- * ```
14
- */
15
- const constantTimeEquals = (a, b) => {
16
- if (a.length !== b.length) return false;
17
- let result = 0;
18
- for (let i = 0; i < a.length; i += 1) result |= a.charCodeAt(i) ^ b.charCodeAt(i);
19
- return result === 0;
20
- };
21
- //#endregion
22
- export { constantTimeEquals };
23
-
24
- //# sourceMappingURL=utils.js.map
package/dist/utils.js.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"file":"utils.js","names":[],"sources":["../src/utils.ts"],"sourcesContent":["/**\n * Utility helpers for webhook internals.\n *\n * @module @zap-studio/webhooks/utils\n */\n\n/**\n * Compares two strings in constant time to prevent timing attacks.\n *\n * @example\n * ```ts\n * const isEqual = constantTimeEquals(\"string1\", \"string2\"); // returns false\n * ```\n */\nexport const constantTimeEquals = (a: string, b: string): boolean => {\n if (a.length !== b.length) {\n return false;\n }\n\n let result = 0;\n for (let i = 0; i < a.length; i += 1) {\n // oxlint-disable-next-line no-bitwise, unicorn/prefer-code-point -- XOR is the constant-time compare trick; charCodeAt reads each char with the same cost, and inputs are plain ASCII hex.\n result |= a.charCodeAt(i) ^ b.charCodeAt(i);\n }\n\n return result === 0;\n};\n"],"mappings":";;;;;;;;;;;;;;AAcA,MAAa,sBAAsB,GAAW,MAAuB;CACnE,IAAI,EAAE,WAAW,EAAE,QACjB,OAAO;CAGT,IAAI,SAAS;CACb,KAAK,IAAI,IAAI,GAAG,IAAI,EAAE,QAAQ,KAAK,GAEjC,UAAU,EAAE,WAAW,CAAC,IAAI,EAAE,WAAW,CAAC;CAG5C,OAAO,WAAW;AACpB"}