@samva/webhooks 0.4.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/AGENTS.md +1 -1
- package/README.md +6 -2
- package/dist/effect.d.ts +37 -4
- package/dist/effect.js +60 -23
- package/dist/errors.d.ts +50 -1
- package/dist/promise.d.ts +96 -4
- package/dist/promise.js +82 -2
- package/dist/{protocol-M4mOvMtW.js → protocol-BGKUmvlI.js} +130 -7
- package/dist/protocol.d.ts +104 -2
- package/dist/protocol.js +1 -1
- package/package.json +4 -4
package/AGENTS.md
CHANGED
|
@@ -5,7 +5,7 @@ webhook. Verification requires the untouched request body and the endpoint signi
|
|
|
5
5
|
expose that secret to browser code.
|
|
6
6
|
|
|
7
7
|
Effect applications can import the typed adapter from `@samva/webhooks/effect` and install the
|
|
8
|
-
optional exact
|
|
8
|
+
optional peer `effect` at the exact version this package's `peerDependencies` names.
|
|
9
9
|
|
|
10
10
|
Import only package subpaths declared in `package.json`. The delivery, queue, persistence, retry,
|
|
11
11
|
and endpoint-management implementation is not part of this package.
|
package/README.md
CHANGED
|
@@ -17,9 +17,13 @@ request body and keep the signing secret on the server.
|
|
|
17
17
|
Effect applications can use the optional Effect entrypoint:
|
|
18
18
|
|
|
19
19
|
```ts
|
|
20
|
+
import { Effect } from "effect";
|
|
20
21
|
import * as Webhooks from "@samva/webhooks/effect";
|
|
21
22
|
|
|
22
|
-
const
|
|
23
|
+
const program = Effect.gen(function* () {
|
|
24
|
+
const verified = yield* Webhooks.verifyRequest(request, secret);
|
|
25
|
+
return verified.event;
|
|
26
|
+
});
|
|
23
27
|
```
|
|
24
28
|
|
|
25
|
-
Install the
|
|
29
|
+
Install the `effect` version this package lists in `peerDependencies` when using that entrypoint.
|
package/dist/effect.d.ts
CHANGED
|
@@ -1,10 +1,43 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
2
|
import type { VerifyOptions } from "./promise.js";
|
|
3
|
-
import { type AnyWebhookVerificationError } from "./protocol.js";
|
|
4
|
-
|
|
3
|
+
import { type AnyWebhookVerificationError, type VerifiedWebhook } from "./protocol.js";
|
|
4
|
+
/**
|
|
5
|
+
* Effect version of the Promise entrypoint's `verify`: verifies a delivery from its body and
|
|
6
|
+
* header values and succeeds with its ID and parsed event.
|
|
7
|
+
*
|
|
8
|
+
* Reads the current time from Effect's `Clock`, so `TestClock` controls the replay window in
|
|
9
|
+
* tests. Fails with {@link AnyWebhookVerificationError}; branch with `Effect.catchTag`. Input
|
|
10
|
+
* that violates the declared types, such as an undefined secret, dies instead.
|
|
11
|
+
*/
|
|
12
|
+
export declare const verify: (options: VerifyOptions) => Effect.Effect<VerifiedWebhook, AnyWebhookVerificationError, never>;
|
|
13
|
+
/**
|
|
14
|
+
* Effect version of the Promise entrypoint's `verifyRequest`: verifies a delivery received as a
|
|
15
|
+
* Fetch API `Request` and succeeds with its ID and parsed event. Reads the body from a clone, so
|
|
16
|
+
* the original request stays readable.
|
|
17
|
+
*
|
|
18
|
+
* Reads the current time from Effect's `Clock`. Fails with {@link AnyWebhookVerificationError};
|
|
19
|
+
* a body that cannot be read fails with {@link WebhookCryptoError}. Input that violates the
|
|
20
|
+
* declared types dies instead.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* import { Effect } from "effect";
|
|
25
|
+
* import * as Webhooks from "@samva/webhooks/effect";
|
|
26
|
+
*
|
|
27
|
+
* const receive = (request: Request, secret: string) =>
|
|
28
|
+
* Webhooks.verifyRequest(request, secret).pipe(
|
|
29
|
+
* Effect.flatMap(({ id, event }) => Effect.promise(() => handleOnce(id, event))),
|
|
30
|
+
* Effect.as(new Response(null, { status: 204 })),
|
|
31
|
+
* Effect.catchTags({
|
|
32
|
+
* WebhookSignatureError: () => Effect.succeed(new Response(null, { status: 400 })),
|
|
33
|
+
* WebhookTimestampError: () => Effect.succeed(new Response(null, { status: 400 })),
|
|
34
|
+
* }),
|
|
35
|
+
* );
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
5
38
|
export declare const verifyRequest: (request: Request, secret: string, options?: {
|
|
6
39
|
readonly toleranceSeconds?: number | undefined;
|
|
7
|
-
} | undefined) => Effect.Effect<
|
|
40
|
+
} | undefined) => Effect.Effect<VerifiedWebhook, AnyWebhookVerificationError, never>;
|
|
8
41
|
export { DEFAULT_TOLERANCE_SECONDS, WEBHOOK_ID_HEADER, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_TIMESTAMP_HEADER, generateWebhookSecret, signWebhook, } from "./protocol.js";
|
|
9
|
-
export type { AnyWebhookVerificationError, SamvaWebhookEvent, VerifiedWebhook, } from "./protocol.js";
|
|
42
|
+
export type { AnyWebhookVerificationError, SamvaWebhookEvent, VerifiedWebhook, WebhookHeaderName, } from "./protocol.js";
|
|
10
43
|
export { InvalidWebhookHeadersError, InvalidWebhookSecretError, MalformedWebhookPayloadError, WebhookCryptoError, WebhookSignatureError, WebhookTimestampError, WebhookVerificationError, } from "./protocol.js";
|
package/dist/effect.js
CHANGED
|
@@ -1,38 +1,75 @@
|
|
|
1
|
-
import { _ as WebhookTimestampError, c as signWebhook, f as InvalidWebhookHeadersError, g as WebhookSignatureError, h as WebhookCryptoError, i as WEBHOOK_TIMESTAMP_HEADER, l as verifyWebhookAt, m as MalformedWebhookPayloadError, n as WEBHOOK_ID_HEADER, o as generateWebhookSecret, p as InvalidWebhookSecretError, r as WEBHOOK_SIGNATURE_HEADER,
|
|
1
|
+
import { _ as WebhookTimestampError, c as signWebhook, f as InvalidWebhookHeadersError, g as WebhookSignatureError, h as WebhookCryptoError, i as WEBHOOK_TIMESTAMP_HEADER, l as verifyWebhookAt, m as MalformedWebhookPayloadError, n as WEBHOOK_ID_HEADER, o as generateWebhookSecret, p as InvalidWebhookSecretError, r as WEBHOOK_SIGNATURE_HEADER, t as DEFAULT_TOLERANCE_SECONDS, v as WebhookVerificationError } from "./protocol-BGKUmvlI.js";
|
|
2
2
|
import { Clock, Effect } from "effect";
|
|
3
3
|
//#region src/effect.ts
|
|
4
|
+
/**
|
|
5
|
+
* Fails with the verification errors `verifyWebhookAt` rejects with. Any other rejection, such as
|
|
6
|
+
* a `TypeError` from input that violates the declared types, is a defect rather than a failed
|
|
7
|
+
* delivery.
|
|
8
|
+
*/
|
|
9
|
+
const verifyAt = (input) => Effect.flatten(Effect.promise(() => verifyWebhookAt(input).then((verified) => Effect.succeed(verified), (error) => error instanceof WebhookVerificationError ? Effect.fail(error) : Effect.die(error))));
|
|
10
|
+
/**
|
|
11
|
+
* Effect version of the Promise entrypoint's `verify`: verifies a delivery from its body and
|
|
12
|
+
* header values and succeeds with its ID and parsed event.
|
|
13
|
+
*
|
|
14
|
+
* Reads the current time from Effect's `Clock`, so `TestClock` controls the replay window in
|
|
15
|
+
* tests. Fails with {@link AnyWebhookVerificationError}; branch with `Effect.catchTag`. Input
|
|
16
|
+
* that violates the declared types, such as an undefined secret, dies instead.
|
|
17
|
+
*/
|
|
4
18
|
const verify = Effect.fn("Webhooks.verify")(function* (options) {
|
|
5
19
|
const nowMilliseconds = yield* Clock.currentTimeMillis;
|
|
6
|
-
return yield*
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
nowMilliseconds
|
|
17
|
-
}),
|
|
18
|
-
catch: (error) => error
|
|
20
|
+
return yield* verifyAt({
|
|
21
|
+
body: options.body,
|
|
22
|
+
headers: {
|
|
23
|
+
id: options.id,
|
|
24
|
+
timestamp: options.timestamp,
|
|
25
|
+
signature: options.signature
|
|
26
|
+
},
|
|
27
|
+
secret: options.secret,
|
|
28
|
+
toleranceSeconds: options.toleranceSeconds,
|
|
29
|
+
nowMilliseconds
|
|
19
30
|
});
|
|
20
31
|
});
|
|
32
|
+
/**
|
|
33
|
+
* Effect version of the Promise entrypoint's `verifyRequest`: verifies a delivery received as a
|
|
34
|
+
* Fetch API `Request` and succeeds with its ID and parsed event. Reads the body from a clone, so
|
|
35
|
+
* the original request stays readable.
|
|
36
|
+
*
|
|
37
|
+
* Reads the current time from Effect's `Clock`. Fails with {@link AnyWebhookVerificationError};
|
|
38
|
+
* a body that cannot be read fails with {@link WebhookCryptoError}. Input that violates the
|
|
39
|
+
* declared types dies instead.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```ts
|
|
43
|
+
* import { Effect } from "effect";
|
|
44
|
+
* import * as Webhooks from "@samva/webhooks/effect";
|
|
45
|
+
*
|
|
46
|
+
* const receive = (request: Request, secret: string) =>
|
|
47
|
+
* Webhooks.verifyRequest(request, secret).pipe(
|
|
48
|
+
* Effect.flatMap(({ id, event }) => Effect.promise(() => handleOnce(id, event))),
|
|
49
|
+
* Effect.as(new Response(null, { status: 204 })),
|
|
50
|
+
* Effect.catchTags({
|
|
51
|
+
* WebhookSignatureError: () => Effect.succeed(new Response(null, { status: 400 })),
|
|
52
|
+
* WebhookTimestampError: () => Effect.succeed(new Response(null, { status: 400 })),
|
|
53
|
+
* }),
|
|
54
|
+
* );
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
21
57
|
const verifyRequest = Effect.fn("Webhooks.verifyRequest")(function* (request, secret, options) {
|
|
22
58
|
const nowMilliseconds = yield* Clock.currentTimeMillis;
|
|
23
59
|
const body = yield* Effect.tryPromise({
|
|
24
60
|
try: async () => new Uint8Array(await request.clone().arrayBuffer()),
|
|
25
61
|
catch: () => new WebhookCryptoError({ detail: "Unable to read webhook request body" })
|
|
26
62
|
});
|
|
27
|
-
return yield*
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
63
|
+
return yield* verifyAt({
|
|
64
|
+
body,
|
|
65
|
+
headers: {
|
|
66
|
+
id: request.headers.get(WEBHOOK_ID_HEADER),
|
|
67
|
+
timestamp: request.headers.get(WEBHOOK_TIMESTAMP_HEADER),
|
|
68
|
+
signature: request.headers.get(WEBHOOK_SIGNATURE_HEADER)
|
|
69
|
+
},
|
|
70
|
+
secret,
|
|
71
|
+
toleranceSeconds: options?.toleranceSeconds,
|
|
72
|
+
nowMilliseconds
|
|
36
73
|
});
|
|
37
74
|
});
|
|
38
75
|
//#endregion
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,28 +1,77 @@
|
|
|
1
1
|
interface WebhookVerificationFailureOptions {
|
|
2
2
|
readonly detail: string;
|
|
3
3
|
}
|
|
4
|
+
/** Name of one of the three Standard Webhooks signing headers a delivery carries. */
|
|
5
|
+
export type WebhookHeaderName = "webhook-id" | "webhook-timestamp" | "webhook-signature";
|
|
6
|
+
/**
|
|
7
|
+
* Base class for every failure this package reports while verifying a webhook delivery.
|
|
8
|
+
*
|
|
9
|
+
* Each subclass carries a literal `_tag` (also used as `name`), so you can branch on the failure
|
|
10
|
+
* with a `switch` or Effect's `catchTag`. `detail` is a human-readable reason for your logs; do not
|
|
11
|
+
* echo it to the sender, and do not match on its wording.
|
|
12
|
+
*/
|
|
4
13
|
export declare abstract class WebhookVerificationError<Tag extends string = string> extends Error {
|
|
14
|
+
/** Discriminant naming the concrete failure; equal to the subclass name. */
|
|
5
15
|
readonly _tag: Tag;
|
|
16
|
+
/** Human-readable reason for logs. The wording is not a stable contract. */
|
|
6
17
|
readonly detail: string;
|
|
7
18
|
protected constructor(tag: Tag, options: WebhookVerificationFailureOptions);
|
|
8
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* The signing secret is unusable: it lacks the `whsec_` prefix, the remainder is not Base64, or
|
|
22
|
+
* the decoded key length is outside the accepted range (`detail` names the range). This is a
|
|
23
|
+
* configuration error on the receiving side, not a bad delivery; copy the endpoint's signing
|
|
24
|
+
* secret from Samva again.
|
|
25
|
+
*/
|
|
9
26
|
export declare class InvalidWebhookSecretError extends WebhookVerificationError<"InvalidWebhookSecretError"> {
|
|
10
27
|
constructor(options: WebhookVerificationFailureOptions);
|
|
11
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* One of the `webhook-id`, `webhook-timestamp`, or `webhook-signature` headers is missing or
|
|
31
|
+
* empty; `header` names it. The request is not a Samva delivery, or something between Samva and
|
|
32
|
+
* your handler stripped the headers.
|
|
33
|
+
*/
|
|
12
34
|
export declare class InvalidWebhookHeadersError extends WebhookVerificationError<"InvalidWebhookHeadersError"> {
|
|
13
|
-
|
|
35
|
+
/** The first signing header, in id, timestamp, signature order, that is missing or empty. */
|
|
36
|
+
readonly header: WebhookHeaderName;
|
|
37
|
+
constructor(options: WebhookVerificationFailureOptions & {
|
|
38
|
+
readonly header: WebhookHeaderName;
|
|
39
|
+
});
|
|
14
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* The `webhook-timestamp` header is not an integer count of Unix seconds, it lies further from the
|
|
43
|
+
* current time than the replay tolerance, or the tolerance you passed is negative or not finite.
|
|
44
|
+
* A delivery outside the tolerance is rejected even when its signature is valid; that bounds how
|
|
45
|
+
* long a captured request can be replayed. Repeated failures on genuine deliveries usually mean
|
|
46
|
+
* the receiving server's clock is wrong.
|
|
47
|
+
*/
|
|
15
48
|
export declare class WebhookTimestampError extends WebhookVerificationError<"WebhookTimestampError"> {
|
|
16
49
|
constructor(options: WebhookVerificationFailureOptions);
|
|
17
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* No signature in the `webhook-signature` header matches the delivery, or no verification key was
|
|
53
|
+
* supplied. Treat the request as forged and reject it. When genuine deliveries fail, the usual
|
|
54
|
+
* causes are the wrong endpoint secret or a body that was parsed and re-serialized before
|
|
55
|
+
* verification instead of passed through as the exact bytes received.
|
|
56
|
+
*/
|
|
18
57
|
export declare class WebhookSignatureError extends WebhookVerificationError<"WebhookSignatureError"> {
|
|
19
58
|
constructor(options: WebhookVerificationFailureOptions);
|
|
20
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* The signature is valid, but the body is not UTF-8 JSON holding exactly the `type`,
|
|
62
|
+
* `timestamp`, and `data` fields of a Samva event envelope.
|
|
63
|
+
*/
|
|
21
64
|
export declare class MalformedWebhookPayloadError extends WebhookVerificationError<"MalformedWebhookPayloadError"> {
|
|
22
65
|
constructor(options: WebhookVerificationFailureOptions);
|
|
23
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* The runtime's Web Crypto implementation could not import a key or compute a signature, none of
|
|
69
|
+
* the supplied public keys could be imported, or the Effect entrypoint could not read the request
|
|
70
|
+
* body. Points at the runtime or its configuration rather than at the delivery.
|
|
71
|
+
*/
|
|
24
72
|
export declare class WebhookCryptoError extends WebhookVerificationError<"WebhookCryptoError"> {
|
|
25
73
|
constructor(options: WebhookVerificationFailureOptions);
|
|
26
74
|
}
|
|
75
|
+
/** Every concrete failure a verification call can report, discriminated by `_tag`. */
|
|
27
76
|
export type AnyWebhookVerificationError = InvalidWebhookSecretError | InvalidWebhookHeadersError | WebhookTimestampError | WebhookSignatureError | MalformedWebhookPayloadError | WebhookCryptoError;
|
|
28
77
|
export {};
|
package/dist/promise.d.ts
CHANGED
|
@@ -1,16 +1,108 @@
|
|
|
1
1
|
import { type AnyWebhookVerificationError, type VerifiedWebhook } from "./protocol.js";
|
|
2
|
+
/** A delivery's parts, for frameworks that do not expose a Fetch API `Request`. */
|
|
2
3
|
export interface VerifyOptions {
|
|
4
|
+
/**
|
|
5
|
+
* The request body exactly as received, as text or bytes. Read it before any JSON body parser
|
|
6
|
+
* runs: parsing and re-serializing changes the bytes and fails the signature check.
|
|
7
|
+
*/
|
|
3
8
|
readonly body: string | Uint8Array;
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Value of the `webhook-id` header (`WEBHOOK_ID_HEADER`). Pass an absent header through as
|
|
11
|
+
* `null` or `undefined`; verification rejects it with `InvalidWebhookHeadersError`.
|
|
12
|
+
*/
|
|
13
|
+
readonly id: string | null | undefined;
|
|
14
|
+
/** Value of the `webhook-timestamp` header (`WEBHOOK_TIMESTAMP_HEADER`), or absent. */
|
|
15
|
+
readonly timestamp: string | null | undefined;
|
|
16
|
+
/** Value of the `webhook-signature` header (`WEBHOOK_SIGNATURE_HEADER`), unmodified, or absent. */
|
|
17
|
+
readonly signature: string | null | undefined;
|
|
18
|
+
/** The endpoint's signing secret, including its `whsec_` prefix. Keep it on the server. */
|
|
7
19
|
readonly secret: string;
|
|
20
|
+
/** Replay tolerance in seconds; defaults to `DEFAULT_TOLERANCE_SECONDS`. */
|
|
8
21
|
readonly toleranceSeconds?: number | undefined;
|
|
9
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Verifies a Samva webhook delivery from its body and header values, then returns its ID and
|
|
25
|
+
* parsed event.
|
|
26
|
+
*
|
|
27
|
+
* Verification follows Standard Webhooks: the `webhook-timestamp` must be within the replay
|
|
28
|
+
* tolerance of the current time, and an HMAC-SHA256 signature over `<id>.<timestamp>.<body>` with
|
|
29
|
+
* the endpoint secret must match one entry in `webhook-signature`. Only then is the body parsed.
|
|
30
|
+
* A valid delivery can arrive more than once, so handle each returned `id` once.
|
|
31
|
+
*
|
|
32
|
+
* Rejects with a subclass of `WebhookVerificationError`; {@link AnyWebhookVerificationError}
|
|
33
|
+
* lists them. A missing or empty header rejects with `InvalidWebhookHeadersError`, whose `header`
|
|
34
|
+
* names it. Use {@link safeVerify} for a result instead of a rejection.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```ts
|
|
38
|
+
* import { verify } from "@samva/webhooks";
|
|
39
|
+
*
|
|
40
|
+
* // Express: mount express.raw({ type: "application/json" }) on this route so body is a Buffer.
|
|
41
|
+
* app.post("/webhooks/samva", async (req, res) => {
|
|
42
|
+
* const verified = await verify({
|
|
43
|
+
* body: req.body,
|
|
44
|
+
* id: req.header("webhook-id"),
|
|
45
|
+
* timestamp: req.header("webhook-timestamp"),
|
|
46
|
+
* signature: req.header("webhook-signature"),
|
|
47
|
+
* secret: process.env.SAMVA_WEBHOOK_SECRET!,
|
|
48
|
+
* });
|
|
49
|
+
* await handleOnce(verified.id, verified.event);
|
|
50
|
+
* res.sendStatus(204);
|
|
51
|
+
* });
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
10
54
|
export declare const verify: (options: VerifyOptions) => Promise<VerifiedWebhook>;
|
|
55
|
+
/**
|
|
56
|
+
* Verifies a Samva webhook delivery received as a Fetch API `Request` (Cloudflare Workers, Bun,
|
|
57
|
+
* Deno, Next.js route handlers, Hono, and similar), then returns its ID and parsed event.
|
|
58
|
+
*
|
|
59
|
+
* Reads the body from a clone, so the original request stays readable. Performs the same checks
|
|
60
|
+
* as {@link verify} and rejects with the same errors; a missing signing header rejects with
|
|
61
|
+
* `InvalidWebhookHeadersError`, whose `header` names it.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* ```ts
|
|
65
|
+
* import { verifyRequest, WebhookVerificationError } from "@samva/webhooks";
|
|
66
|
+
*
|
|
67
|
+
* export default {
|
|
68
|
+
* async fetch(request: Request, env: { SAMVA_WEBHOOK_SECRET: string }) {
|
|
69
|
+
* try {
|
|
70
|
+
* const { id, event } = await verifyRequest(request, env.SAMVA_WEBHOOK_SECRET);
|
|
71
|
+
* await handleOnce(id, event);
|
|
72
|
+
* return new Response(null, { status: 204 });
|
|
73
|
+
* } catch (error) {
|
|
74
|
+
* if (error instanceof WebhookVerificationError) {
|
|
75
|
+
* return new Response(null, { status: 400 });
|
|
76
|
+
* }
|
|
77
|
+
* throw error;
|
|
78
|
+
* }
|
|
79
|
+
* },
|
|
80
|
+
* };
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
11
83
|
export declare const verifyRequest: (request: Request, secret: string, options?: {
|
|
12
84
|
readonly toleranceSeconds?: number | undefined;
|
|
13
85
|
}) => Promise<VerifiedWebhook>;
|
|
86
|
+
/**
|
|
87
|
+
* Runs {@link verify} and resolves to a result instead of rejecting: `{ verified: true, value }`
|
|
88
|
+
* on success, or `{ verified: false, error }` carrying the verification failure. Any other error,
|
|
89
|
+
* such as a `TypeError` from input that violates the declared types, rejects.
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```ts
|
|
93
|
+
* import { safeVerify, type VerifyOptions } from "@samva/webhooks";
|
|
94
|
+
*
|
|
95
|
+
* async function receive(delivery: VerifyOptions): Promise<Response> {
|
|
96
|
+
* const result = await safeVerify(delivery);
|
|
97
|
+
* if (!result.verified) {
|
|
98
|
+
* console.warn("rejected webhook", result.error._tag);
|
|
99
|
+
* return new Response(null, { status: 400 });
|
|
100
|
+
* }
|
|
101
|
+
* await handleOnce(result.value.id, result.value.event);
|
|
102
|
+
* return new Response(null, { status: 204 });
|
|
103
|
+
* }
|
|
104
|
+
* ```
|
|
105
|
+
*/
|
|
14
106
|
export declare const safeVerify: (options: VerifyOptions) => Promise<{
|
|
15
107
|
readonly verified: true;
|
|
16
108
|
readonly value: VerifiedWebhook;
|
|
@@ -19,5 +111,5 @@ export declare const safeVerify: (options: VerifyOptions) => Promise<{
|
|
|
19
111
|
readonly error: AnyWebhookVerificationError;
|
|
20
112
|
}>;
|
|
21
113
|
export { DEFAULT_TOLERANCE_SECONDS, WEBHOOK_ID_HEADER, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_TIMESTAMP_HEADER, generateWebhookSecret, signWebhook, } from "./protocol.js";
|
|
22
|
-
export type { AnyWebhookVerificationError, SamvaWebhookEvent, VerifiedWebhook, } from "./protocol.js";
|
|
114
|
+
export type { AnyWebhookVerificationError, SamvaWebhookEvent, VerifiedWebhook, WebhookHeaderName, } from "./protocol.js";
|
|
23
115
|
export { InvalidWebhookHeadersError, InvalidWebhookSecretError, MalformedWebhookPayloadError, WebhookCryptoError, WebhookSignatureError, WebhookTimestampError, WebhookVerificationError, } from "./protocol.js";
|
package/dist/promise.js
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
|
-
import { _ as WebhookTimestampError, c as signWebhook, f as InvalidWebhookHeadersError, g as WebhookSignatureError, h as WebhookCryptoError, i as WEBHOOK_TIMESTAMP_HEADER, l as verifyWebhookAt, m as MalformedWebhookPayloadError, n as WEBHOOK_ID_HEADER, o as generateWebhookSecret, p as InvalidWebhookSecretError, r as WEBHOOK_SIGNATURE_HEADER, s as readWebhookHeaders, t as DEFAULT_TOLERANCE_SECONDS, v as WebhookVerificationError } from "./protocol-
|
|
1
|
+
import { _ as WebhookTimestampError, c as signWebhook, f as InvalidWebhookHeadersError, g as WebhookSignatureError, h as WebhookCryptoError, i as WEBHOOK_TIMESTAMP_HEADER, l as verifyWebhookAt, m as MalformedWebhookPayloadError, n as WEBHOOK_ID_HEADER, o as generateWebhookSecret, p as InvalidWebhookSecretError, r as WEBHOOK_SIGNATURE_HEADER, s as readWebhookHeaders, t as DEFAULT_TOLERANCE_SECONDS, v as WebhookVerificationError } from "./protocol-BGKUmvlI.js";
|
|
2
2
|
//#region src/promise.ts
|
|
3
|
+
/**
|
|
4
|
+
* Verifies a Samva webhook delivery from its body and header values, then returns its ID and
|
|
5
|
+
* parsed event.
|
|
6
|
+
*
|
|
7
|
+
* Verification follows Standard Webhooks: the `webhook-timestamp` must be within the replay
|
|
8
|
+
* tolerance of the current time, and an HMAC-SHA256 signature over `<id>.<timestamp>.<body>` with
|
|
9
|
+
* the endpoint secret must match one entry in `webhook-signature`. Only then is the body parsed.
|
|
10
|
+
* A valid delivery can arrive more than once, so handle each returned `id` once.
|
|
11
|
+
*
|
|
12
|
+
* Rejects with a subclass of `WebhookVerificationError`; {@link AnyWebhookVerificationError}
|
|
13
|
+
* lists them. A missing or empty header rejects with `InvalidWebhookHeadersError`, whose `header`
|
|
14
|
+
* names it. Use {@link safeVerify} for a result instead of a rejection.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* import { verify } from "@samva/webhooks";
|
|
19
|
+
*
|
|
20
|
+
* // Express: mount express.raw({ type: "application/json" }) on this route so body is a Buffer.
|
|
21
|
+
* app.post("/webhooks/samva", async (req, res) => {
|
|
22
|
+
* const verified = await verify({
|
|
23
|
+
* body: req.body,
|
|
24
|
+
* id: req.header("webhook-id"),
|
|
25
|
+
* timestamp: req.header("webhook-timestamp"),
|
|
26
|
+
* signature: req.header("webhook-signature"),
|
|
27
|
+
* secret: process.env.SAMVA_WEBHOOK_SECRET!,
|
|
28
|
+
* });
|
|
29
|
+
* await handleOnce(verified.id, verified.event);
|
|
30
|
+
* res.sendStatus(204);
|
|
31
|
+
* });
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
3
34
|
const verify = (options) => verifyWebhookAt({
|
|
4
35
|
body: options.body,
|
|
5
36
|
headers: {
|
|
@@ -11,6 +42,34 @@ const verify = (options) => verifyWebhookAt({
|
|
|
11
42
|
toleranceSeconds: options.toleranceSeconds,
|
|
12
43
|
nowMilliseconds: Date.now()
|
|
13
44
|
});
|
|
45
|
+
/**
|
|
46
|
+
* Verifies a Samva webhook delivery received as a Fetch API `Request` (Cloudflare Workers, Bun,
|
|
47
|
+
* Deno, Next.js route handlers, Hono, and similar), then returns its ID and parsed event.
|
|
48
|
+
*
|
|
49
|
+
* Reads the body from a clone, so the original request stays readable. Performs the same checks
|
|
50
|
+
* as {@link verify} and rejects with the same errors; a missing signing header rejects with
|
|
51
|
+
* `InvalidWebhookHeadersError`, whose `header` names it.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```ts
|
|
55
|
+
* import { verifyRequest, WebhookVerificationError } from "@samva/webhooks";
|
|
56
|
+
*
|
|
57
|
+
* export default {
|
|
58
|
+
* async fetch(request: Request, env: { SAMVA_WEBHOOK_SECRET: string }) {
|
|
59
|
+
* try {
|
|
60
|
+
* const { id, event } = await verifyRequest(request, env.SAMVA_WEBHOOK_SECRET);
|
|
61
|
+
* await handleOnce(id, event);
|
|
62
|
+
* return new Response(null, { status: 204 });
|
|
63
|
+
* } catch (error) {
|
|
64
|
+
* if (error instanceof WebhookVerificationError) {
|
|
65
|
+
* return new Response(null, { status: 400 });
|
|
66
|
+
* }
|
|
67
|
+
* throw error;
|
|
68
|
+
* }
|
|
69
|
+
* },
|
|
70
|
+
* };
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
14
73
|
const verifyRequest = async (request, secret, options) => verifyWebhookAt({
|
|
15
74
|
body: new Uint8Array(await request.clone().arrayBuffer()),
|
|
16
75
|
headers: readWebhookHeaders(request.headers),
|
|
@@ -18,6 +77,26 @@ const verifyRequest = async (request, secret, options) => verifyWebhookAt({
|
|
|
18
77
|
toleranceSeconds: options?.toleranceSeconds,
|
|
19
78
|
nowMilliseconds: Date.now()
|
|
20
79
|
});
|
|
80
|
+
/**
|
|
81
|
+
* Runs {@link verify} and resolves to a result instead of rejecting: `{ verified: true, value }`
|
|
82
|
+
* on success, or `{ verified: false, error }` carrying the verification failure. Any other error,
|
|
83
|
+
* such as a `TypeError` from input that violates the declared types, rejects.
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```ts
|
|
87
|
+
* import { safeVerify, type VerifyOptions } from "@samva/webhooks";
|
|
88
|
+
*
|
|
89
|
+
* async function receive(delivery: VerifyOptions): Promise<Response> {
|
|
90
|
+
* const result = await safeVerify(delivery);
|
|
91
|
+
* if (!result.verified) {
|
|
92
|
+
* console.warn("rejected webhook", result.error._tag);
|
|
93
|
+
* return new Response(null, { status: 400 });
|
|
94
|
+
* }
|
|
95
|
+
* await handleOnce(result.value.id, result.value.event);
|
|
96
|
+
* return new Response(null, { status: 204 });
|
|
97
|
+
* }
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
21
100
|
const safeVerify = async (options) => {
|
|
22
101
|
try {
|
|
23
102
|
return {
|
|
@@ -25,10 +104,11 @@ const safeVerify = async (options) => {
|
|
|
25
104
|
value: await verify(options)
|
|
26
105
|
};
|
|
27
106
|
} catch (error) {
|
|
28
|
-
return {
|
|
107
|
+
if (error instanceof WebhookVerificationError) return {
|
|
29
108
|
verified: false,
|
|
30
109
|
error
|
|
31
110
|
};
|
|
111
|
+
throw error;
|
|
32
112
|
}
|
|
33
113
|
};
|
|
34
114
|
//#endregion
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
//#region src/errors.ts
|
|
2
|
+
/**
|
|
3
|
+
* Base class for every failure this package reports while verifying a webhook delivery.
|
|
4
|
+
*
|
|
5
|
+
* Each subclass carries a literal `_tag` (also used as `name`), so you can branch on the failure
|
|
6
|
+
* with a `switch` or Effect's `catchTag`. `detail` is a human-readable reason for your logs; do not
|
|
7
|
+
* echo it to the sender, and do not match on its wording.
|
|
8
|
+
*/
|
|
2
9
|
var WebhookVerificationError = class extends Error {
|
|
10
|
+
/** Discriminant naming the concrete failure; equal to the subclass name. */
|
|
3
11
|
_tag;
|
|
12
|
+
/** Human-readable reason for logs. The wording is not a stable contract. */
|
|
4
13
|
detail;
|
|
5
14
|
constructor(tag, options) {
|
|
6
15
|
super(options.detail);
|
|
@@ -9,31 +18,67 @@ var WebhookVerificationError = class extends Error {
|
|
|
9
18
|
this.detail = options.detail;
|
|
10
19
|
}
|
|
11
20
|
};
|
|
21
|
+
/**
|
|
22
|
+
* The signing secret is unusable: it lacks the `whsec_` prefix, the remainder is not Base64, or
|
|
23
|
+
* the decoded key length is outside the accepted range (`detail` names the range). This is a
|
|
24
|
+
* configuration error on the receiving side, not a bad delivery; copy the endpoint's signing
|
|
25
|
+
* secret from Samva again.
|
|
26
|
+
*/
|
|
12
27
|
var InvalidWebhookSecretError = class extends WebhookVerificationError {
|
|
13
28
|
constructor(options) {
|
|
14
29
|
super("InvalidWebhookSecretError", options);
|
|
15
30
|
}
|
|
16
31
|
};
|
|
32
|
+
/**
|
|
33
|
+
* One of the `webhook-id`, `webhook-timestamp`, or `webhook-signature` headers is missing or
|
|
34
|
+
* empty; `header` names it. The request is not a Samva delivery, or something between Samva and
|
|
35
|
+
* your handler stripped the headers.
|
|
36
|
+
*/
|
|
17
37
|
var InvalidWebhookHeadersError = class extends WebhookVerificationError {
|
|
38
|
+
/** The first signing header, in id, timestamp, signature order, that is missing or empty. */
|
|
39
|
+
header;
|
|
18
40
|
constructor(options) {
|
|
19
41
|
super("InvalidWebhookHeadersError", options);
|
|
42
|
+
this.header = options.header;
|
|
20
43
|
}
|
|
21
44
|
};
|
|
45
|
+
/**
|
|
46
|
+
* The `webhook-timestamp` header is not an integer count of Unix seconds, it lies further from the
|
|
47
|
+
* current time than the replay tolerance, or the tolerance you passed is negative or not finite.
|
|
48
|
+
* A delivery outside the tolerance is rejected even when its signature is valid; that bounds how
|
|
49
|
+
* long a captured request can be replayed. Repeated failures on genuine deliveries usually mean
|
|
50
|
+
* the receiving server's clock is wrong.
|
|
51
|
+
*/
|
|
22
52
|
var WebhookTimestampError = class extends WebhookVerificationError {
|
|
23
53
|
constructor(options) {
|
|
24
54
|
super("WebhookTimestampError", options);
|
|
25
55
|
}
|
|
26
56
|
};
|
|
57
|
+
/**
|
|
58
|
+
* No signature in the `webhook-signature` header matches the delivery, or no verification key was
|
|
59
|
+
* supplied. Treat the request as forged and reject it. When genuine deliveries fail, the usual
|
|
60
|
+
* causes are the wrong endpoint secret or a body that was parsed and re-serialized before
|
|
61
|
+
* verification instead of passed through as the exact bytes received.
|
|
62
|
+
*/
|
|
27
63
|
var WebhookSignatureError = class extends WebhookVerificationError {
|
|
28
64
|
constructor(options) {
|
|
29
65
|
super("WebhookSignatureError", options);
|
|
30
66
|
}
|
|
31
67
|
};
|
|
68
|
+
/**
|
|
69
|
+
* The signature is valid, but the body is not UTF-8 JSON holding exactly the `type`,
|
|
70
|
+
* `timestamp`, and `data` fields of a Samva event envelope.
|
|
71
|
+
*/
|
|
32
72
|
var MalformedWebhookPayloadError = class extends WebhookVerificationError {
|
|
33
73
|
constructor(options) {
|
|
34
74
|
super("MalformedWebhookPayloadError", options);
|
|
35
75
|
}
|
|
36
76
|
};
|
|
77
|
+
/**
|
|
78
|
+
* The runtime's Web Crypto implementation could not import a key or compute a signature, none of
|
|
79
|
+
* the supplied public keys could be imported, or the Effect entrypoint could not read the request
|
|
80
|
+
* body. Points at the runtime or its configuration rather than at the delivery.
|
|
81
|
+
*/
|
|
37
82
|
var WebhookCryptoError = class extends WebhookVerificationError {
|
|
38
83
|
constructor(options) {
|
|
39
84
|
super("WebhookCryptoError", options);
|
|
@@ -41,9 +86,24 @@ var WebhookCryptoError = class extends WebhookVerificationError {
|
|
|
41
86
|
};
|
|
42
87
|
//#endregion
|
|
43
88
|
//#region src/protocol.ts
|
|
89
|
+
/**
|
|
90
|
+
* Header carrying the delivery's unique message ID. Retries of the same delivery reuse it, so it
|
|
91
|
+
* is the key for deduplicating deliveries you have already handled.
|
|
92
|
+
*/
|
|
44
93
|
const WEBHOOK_ID_HEADER = "webhook-id";
|
|
94
|
+
/** Header carrying the time Samva signed the delivery, as integer Unix seconds. */
|
|
45
95
|
const WEBHOOK_TIMESTAMP_HEADER = "webhook-timestamp";
|
|
96
|
+
/**
|
|
97
|
+
* Header carrying one or more space-separated `<version>,<base64 signature>` entries. More than one
|
|
98
|
+
* entry appears while a signing secret is being rotated; a delivery verifies when any entry of the
|
|
99
|
+
* expected version matches.
|
|
100
|
+
*/
|
|
46
101
|
const WEBHOOK_SIGNATURE_HEADER = "webhook-signature";
|
|
102
|
+
/**
|
|
103
|
+
* Replay tolerance, in seconds, used when a caller passes no `toleranceSeconds`. A delivery whose
|
|
104
|
+
* `webhook-timestamp` differs from the current time by more than this, in either direction, is
|
|
105
|
+
* rejected with {@link WebhookTimestampError}.
|
|
106
|
+
*/
|
|
47
107
|
const DEFAULT_TOLERANCE_SECONDS = 300;
|
|
48
108
|
const SECRET_PREFIX = "whsec_";
|
|
49
109
|
const ASYMMETRIC_ALGORITHM = {
|
|
@@ -150,20 +210,54 @@ const parseEvent = (body) => {
|
|
|
150
210
|
throw new MalformedWebhookPayloadError({ detail: "Webhook body must be valid UTF-8 JSON containing type, timestamp, and data" });
|
|
151
211
|
}
|
|
152
212
|
};
|
|
213
|
+
/**
|
|
214
|
+
* Creates a random `whsec_`-prefixed signing secret from the runtime's secure random source.
|
|
215
|
+
* Samva issues each endpoint's secret for you; use this to sign test deliveries with
|
|
216
|
+
* {@link signWebhook}.
|
|
217
|
+
*/
|
|
153
218
|
const generateWebhookSecret = () => {
|
|
154
219
|
const bytes = crypto.getRandomValues(new Uint8Array(GENERATED_SECRET_BYTES));
|
|
155
220
|
return `${SECRET_PREFIX}${bytesToBase64(bytes)}`;
|
|
156
221
|
};
|
|
222
|
+
/**
|
|
223
|
+
* Builds the bytes every signature version covers: `<id>.<timestamp>.<body>`. Use it to verify a
|
|
224
|
+
* signature with your own crypto code; {@link verifyWebhookAt} and
|
|
225
|
+
* {@link verifyWebhookAtWithPublicKeys} build it for you.
|
|
226
|
+
*/
|
|
157
227
|
const webhookSignedContent = (input) => signedBytes(input.id, input.timestamp, toBytes(input.body));
|
|
228
|
+
/** Formats raw signature bytes as one `<version>,<base64>` entry of the `webhook-signature` header. */
|
|
158
229
|
const encodeWebhookSignature = (version, signature) => `${version},${bytesToBase64(signature)}`;
|
|
230
|
+
/**
|
|
231
|
+
* Computes the `v1` HMAC-SHA256 signature Samva sends, formatted as one `webhook-signature`
|
|
232
|
+
* entry. Use it to produce signed test deliveries for your handler.
|
|
233
|
+
*
|
|
234
|
+
* `timestamp` is Unix seconds; a number is stringified, and the verifier requires an integer.
|
|
235
|
+
* Rejects with {@link InvalidWebhookSecretError} for an unusable secret and
|
|
236
|
+
* {@link WebhookCryptoError} when Web Crypto fails.
|
|
237
|
+
*/
|
|
159
238
|
const signWebhook = async (input) => {
|
|
160
239
|
const timestamp = String(input.timestamp);
|
|
161
240
|
const signature = await hmac(input.secret, input.id, timestamp, toBytes(input.body));
|
|
162
241
|
return encodeWebhookSignature("v1", signature);
|
|
163
242
|
};
|
|
243
|
+
const requireHeader = (header, value) => {
|
|
244
|
+
if (value === null || value === void 0) throw new InvalidWebhookHeadersError({
|
|
245
|
+
header,
|
|
246
|
+
detail: `${header} header is missing`
|
|
247
|
+
});
|
|
248
|
+
if (value.length === 0) throw new InvalidWebhookHeadersError({
|
|
249
|
+
header,
|
|
250
|
+
detail: `${header} header is empty`
|
|
251
|
+
});
|
|
252
|
+
return value;
|
|
253
|
+
};
|
|
254
|
+
const requireHeaders = (values) => ({
|
|
255
|
+
id: requireHeader(WEBHOOK_ID_HEADER, values.id),
|
|
256
|
+
timestamp: requireHeader(WEBHOOK_TIMESTAMP_HEADER, values.timestamp),
|
|
257
|
+
signature: requireHeader(WEBHOOK_SIGNATURE_HEADER, values.signature)
|
|
258
|
+
});
|
|
164
259
|
const freshHeaders = (input) => {
|
|
165
|
-
const { id, timestamp, signature } = input.headers;
|
|
166
|
-
if (id.length === 0 || timestamp.length === 0 || signature.length === 0) throw new InvalidWebhookHeadersError({ detail: "webhook-id, webhook-timestamp, and webhook-signature are required" });
|
|
260
|
+
const { id, timestamp, signature } = requireHeaders(input.headers);
|
|
167
261
|
const timestampSeconds = Number(timestamp);
|
|
168
262
|
if (!Number.isInteger(timestampSeconds)) throw new WebhookTimestampError({ detail: "webhook-timestamp must be Unix seconds" });
|
|
169
263
|
const toleranceSeconds = input.toleranceSeconds ?? 300;
|
|
@@ -175,6 +269,20 @@ const freshHeaders = (input) => {
|
|
|
175
269
|
signature
|
|
176
270
|
};
|
|
177
271
|
};
|
|
272
|
+
/**
|
|
273
|
+
* Verifies a `v1` (HMAC-SHA256) delivery against an explicit current time. The `verify` and
|
|
274
|
+
* `verifyRequest` entry points call this with the current clock; call it directly when your
|
|
275
|
+
* framework owns the clock or a test needs a fixed time.
|
|
276
|
+
*
|
|
277
|
+
* Checks, in order: all three headers are present and non-empty; the timestamp is integer Unix seconds within
|
|
278
|
+
* `toleranceSeconds` of `nowMilliseconds`; some `v1` entry in the signature header matches, using
|
|
279
|
+
* a constant-time comparison; the body is a Samva event envelope. The replay window rejects stale
|
|
280
|
+
* deliveries but does not detect a repeat inside it; deduplicate on the returned `id`.
|
|
281
|
+
*
|
|
282
|
+
* Rejects with {@link InvalidWebhookHeadersError}, {@link WebhookTimestampError},
|
|
283
|
+
* {@link InvalidWebhookSecretError}, {@link WebhookSignatureError},
|
|
284
|
+
* {@link MalformedWebhookPayloadError}, or {@link WebhookCryptoError}.
|
|
285
|
+
*/
|
|
178
286
|
const verifyWebhookAt = async (input) => {
|
|
179
287
|
const { id, timestamp, signature } = freshHeaders(input);
|
|
180
288
|
const body = toBytes(input.body);
|
|
@@ -185,6 +293,15 @@ const verifyWebhookAt = async (input) => {
|
|
|
185
293
|
event: parseEvent(body)
|
|
186
294
|
};
|
|
187
295
|
};
|
|
296
|
+
/**
|
|
297
|
+
* Verifies a `v2` (RSASSA-PKCS1-v1_5, SHA-256) delivery against one or more public keys, each a
|
|
298
|
+
* Base64-encoded SPKI DER key. Pass several keys during a key rotation; the delivery verifies when
|
|
299
|
+
* any `v2` signature entry matches any key.
|
|
300
|
+
*
|
|
301
|
+
* Header, timestamp, and envelope checks match {@link verifyWebhookAt}. Keys that fail to import
|
|
302
|
+
* are skipped. Rejects with {@link WebhookSignatureError} when `publicKeys` is empty or nothing
|
|
303
|
+
* matches, and {@link WebhookCryptoError} when none of the keys imports.
|
|
304
|
+
*/
|
|
188
305
|
const verifyWebhookAtWithPublicKeys = async (input) => {
|
|
189
306
|
const { id, timestamp, signature } = freshHeaders(input);
|
|
190
307
|
if (input.publicKeys.length === 0) throw new WebhookSignatureError({ detail: "No webhook verification key is available" });
|
|
@@ -199,12 +316,18 @@ const verifyWebhookAtWithPublicKeys = async (input) => {
|
|
|
199
316
|
event: parseEvent(body)
|
|
200
317
|
};
|
|
201
318
|
};
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
319
|
+
/**
|
|
320
|
+
* Reads the three signing headers from a Fetch API `Headers` object, for code that needs a header
|
|
321
|
+
* value, such as the timestamp, before or beside verification. Throws
|
|
322
|
+
* {@link InvalidWebhookHeadersError}, naming the header, when one is missing or empty; call it
|
|
323
|
+
* inside the same error handling as the verification that follows.
|
|
324
|
+
*/
|
|
325
|
+
const readWebhookHeaders = (headers) => requireHeaders({
|
|
326
|
+
id: headers.get(WEBHOOK_ID_HEADER),
|
|
327
|
+
timestamp: headers.get(WEBHOOK_TIMESTAMP_HEADER),
|
|
328
|
+
signature: headers.get(WEBHOOK_SIGNATURE_HEADER)
|
|
206
329
|
});
|
|
207
330
|
//#endregion
|
|
208
331
|
export { WebhookTimestampError as _, encodeWebhookSignature as a, signWebhook as c, webhookSignedContent as d, InvalidWebhookHeadersError as f, WebhookSignatureError as g, WebhookCryptoError as h, WEBHOOK_TIMESTAMP_HEADER as i, verifyWebhookAt as l, MalformedWebhookPayloadError as m, WEBHOOK_ID_HEADER as n, generateWebhookSecret as o, InvalidWebhookSecretError as p, WEBHOOK_SIGNATURE_HEADER as r, readWebhookHeaders as s, DEFAULT_TOLERANCE_SECONDS as t, verifyWebhookAtWithPublicKeys as u, WebhookVerificationError as v };
|
|
209
332
|
|
|
210
|
-
//# sourceMappingURL=protocol-
|
|
333
|
+
//# sourceMappingURL=protocol-BGKUmvlI.js.map
|
package/dist/protocol.d.ts
CHANGED
|
@@ -1,46 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Header carrying the delivery's unique message ID. Retries of the same delivery reuse it, so it
|
|
3
|
+
* is the key for deduplicating deliveries you have already handled.
|
|
4
|
+
*/
|
|
1
5
|
export declare const WEBHOOK_ID_HEADER = "webhook-id";
|
|
6
|
+
/** Header carrying the time Samva signed the delivery, as integer Unix seconds. */
|
|
2
7
|
export declare const WEBHOOK_TIMESTAMP_HEADER = "webhook-timestamp";
|
|
8
|
+
/**
|
|
9
|
+
* Header carrying one or more space-separated `<version>,<base64 signature>` entries. More than one
|
|
10
|
+
* entry appears while a signing secret is being rotated; a delivery verifies when any entry of the
|
|
11
|
+
* expected version matches.
|
|
12
|
+
*/
|
|
3
13
|
export declare const WEBHOOK_SIGNATURE_HEADER = "webhook-signature";
|
|
14
|
+
/**
|
|
15
|
+
* Replay tolerance, in seconds, used when a caller passes no `toleranceSeconds`. A delivery whose
|
|
16
|
+
* `webhook-timestamp` differs from the current time by more than this, in either direction, is
|
|
17
|
+
* rejected with {@link WebhookTimestampError}.
|
|
18
|
+
*/
|
|
4
19
|
export declare const DEFAULT_TOLERANCE_SECONDS = 300;
|
|
20
|
+
/** The event envelope carried in a verified delivery's body. */
|
|
5
21
|
export interface SamvaWebhookEvent {
|
|
22
|
+
/** Event type, such as `message.delivered`. Branch on it before reading `data`. */
|
|
6
23
|
readonly type: string;
|
|
24
|
+
/** When the event occurred, as an ISO 8601 string. Distinct from the delivery's signing time. */
|
|
7
25
|
readonly timestamp: string;
|
|
26
|
+
/** Event-specific payload. Its shape depends on `type`; validate it before use. */
|
|
8
27
|
readonly data: unknown;
|
|
9
28
|
}
|
|
29
|
+
/** A delivery whose signature, timestamp, and envelope all checked out. */
|
|
10
30
|
export interface VerifiedWebhook {
|
|
31
|
+
/**
|
|
32
|
+
* The delivery's `webhook-id` header. Stable across retries of the same delivery; record it to
|
|
33
|
+
* handle each delivery once, because a valid delivery may arrive more than once within the
|
|
34
|
+
* replay tolerance.
|
|
35
|
+
*/
|
|
11
36
|
readonly id: string;
|
|
12
37
|
readonly event: SamvaWebhookEvent;
|
|
13
38
|
}
|
|
39
|
+
/** The three signing headers of a delivery, each present and non-empty, read verbatim. */
|
|
14
40
|
export interface WebhookHeaders {
|
|
41
|
+
/** Value of {@link WEBHOOK_ID_HEADER}. */
|
|
15
42
|
readonly id: string;
|
|
43
|
+
/** Value of {@link WEBHOOK_TIMESTAMP_HEADER}. */
|
|
16
44
|
readonly timestamp: string;
|
|
45
|
+
/** Value of {@link WEBHOOK_SIGNATURE_HEADER}. */
|
|
17
46
|
readonly signature: string;
|
|
18
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* The three signing header values as your framework reports them. An absent header is `null` (as
|
|
50
|
+
* Fetch API `Headers.get` returns it) or `undefined` (as Node.js and Express return it); pass it
|
|
51
|
+
* through as-is and verification rejects it with {@link InvalidWebhookHeadersError}.
|
|
52
|
+
*/
|
|
53
|
+
export interface WebhookHeaderValues {
|
|
54
|
+
/** Value of {@link WEBHOOK_ID_HEADER}. */
|
|
55
|
+
readonly id: string | null | undefined;
|
|
56
|
+
/** Value of {@link WEBHOOK_TIMESTAMP_HEADER}. */
|
|
57
|
+
readonly timestamp: string | null | undefined;
|
|
58
|
+
/** Value of {@link WEBHOOK_SIGNATURE_HEADER}. */
|
|
59
|
+
readonly signature: string | null | undefined;
|
|
60
|
+
}
|
|
61
|
+
/** Input to {@link verifyWebhookAt}, which takes the current time explicitly. */
|
|
19
62
|
export interface VerifyWebhookInput {
|
|
63
|
+
/**
|
|
64
|
+
* The request body exactly as received. Pass the raw text or bytes; a body that was parsed and
|
|
65
|
+
* re-serialized no longer matches its signature.
|
|
66
|
+
*/
|
|
20
67
|
readonly body: string | Uint8Array;
|
|
21
|
-
readonly headers:
|
|
68
|
+
readonly headers: WebhookHeaderValues;
|
|
69
|
+
/** The endpoint's signing secret, including its `whsec_` prefix. Keep it on the server. */
|
|
22
70
|
readonly secret: string;
|
|
71
|
+
/** Replay tolerance in seconds; defaults to {@link DEFAULT_TOLERANCE_SECONDS}. */
|
|
23
72
|
readonly toleranceSeconds?: number | undefined;
|
|
73
|
+
/** The current time in Unix milliseconds, as `Date.now()` returns it. */
|
|
24
74
|
readonly nowMilliseconds: number;
|
|
25
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Signature scheme version inside a `webhook-signature` entry: `v1` is HMAC-SHA256 with the
|
|
78
|
+
* endpoint's shared secret; `v2` is RSASSA-PKCS1-v1_5 with SHA-256, checked against a public key.
|
|
79
|
+
*/
|
|
26
80
|
export type WebhookSignatureVersion = "v1" | "v2";
|
|
81
|
+
/**
|
|
82
|
+
* Creates a random `whsec_`-prefixed signing secret from the runtime's secure random source.
|
|
83
|
+
* Samva issues each endpoint's secret for you; use this to sign test deliveries with
|
|
84
|
+
* {@link signWebhook}.
|
|
85
|
+
*/
|
|
27
86
|
export declare const generateWebhookSecret: () => string;
|
|
87
|
+
/**
|
|
88
|
+
* Builds the bytes every signature version covers: `<id>.<timestamp>.<body>`. Use it to verify a
|
|
89
|
+
* signature with your own crypto code; {@link verifyWebhookAt} and
|
|
90
|
+
* {@link verifyWebhookAtWithPublicKeys} build it for you.
|
|
91
|
+
*/
|
|
28
92
|
export declare const webhookSignedContent: (input: {
|
|
29
93
|
readonly id: string;
|
|
30
94
|
readonly timestamp: string;
|
|
31
95
|
readonly body: string | Uint8Array;
|
|
32
96
|
}) => Uint8Array;
|
|
97
|
+
/** Formats raw signature bytes as one `<version>,<base64>` entry of the `webhook-signature` header. */
|
|
33
98
|
export declare const encodeWebhookSignature: (version: WebhookSignatureVersion, signature: Uint8Array) => string;
|
|
99
|
+
/**
|
|
100
|
+
* Computes the `v1` HMAC-SHA256 signature Samva sends, formatted as one `webhook-signature`
|
|
101
|
+
* entry. Use it to produce signed test deliveries for your handler.
|
|
102
|
+
*
|
|
103
|
+
* `timestamp` is Unix seconds; a number is stringified, and the verifier requires an integer.
|
|
104
|
+
* Rejects with {@link InvalidWebhookSecretError} for an unusable secret and
|
|
105
|
+
* {@link WebhookCryptoError} when Web Crypto fails.
|
|
106
|
+
*/
|
|
34
107
|
export declare const signWebhook: (input: {
|
|
35
108
|
readonly body: string | Uint8Array;
|
|
36
109
|
readonly id: string;
|
|
37
110
|
readonly timestamp: string | number;
|
|
38
111
|
readonly secret: string;
|
|
39
112
|
}) => Promise<string>;
|
|
113
|
+
/**
|
|
114
|
+
* Verifies a `v1` (HMAC-SHA256) delivery against an explicit current time. The `verify` and
|
|
115
|
+
* `verifyRequest` entry points call this with the current clock; call it directly when your
|
|
116
|
+
* framework owns the clock or a test needs a fixed time.
|
|
117
|
+
*
|
|
118
|
+
* Checks, in order: all three headers are present and non-empty; the timestamp is integer Unix seconds within
|
|
119
|
+
* `toleranceSeconds` of `nowMilliseconds`; some `v1` entry in the signature header matches, using
|
|
120
|
+
* a constant-time comparison; the body is a Samva event envelope. The replay window rejects stale
|
|
121
|
+
* deliveries but does not detect a repeat inside it; deduplicate on the returned `id`.
|
|
122
|
+
*
|
|
123
|
+
* Rejects with {@link InvalidWebhookHeadersError}, {@link WebhookTimestampError},
|
|
124
|
+
* {@link InvalidWebhookSecretError}, {@link WebhookSignatureError},
|
|
125
|
+
* {@link MalformedWebhookPayloadError}, or {@link WebhookCryptoError}.
|
|
126
|
+
*/
|
|
40
127
|
export declare const verifyWebhookAt: (input: VerifyWebhookInput) => Promise<VerifiedWebhook>;
|
|
128
|
+
/**
|
|
129
|
+
* Verifies a `v2` (RSASSA-PKCS1-v1_5, SHA-256) delivery against one or more public keys, each a
|
|
130
|
+
* Base64-encoded SPKI DER key. Pass several keys during a key rotation; the delivery verifies when
|
|
131
|
+
* any `v2` signature entry matches any key.
|
|
132
|
+
*
|
|
133
|
+
* Header, timestamp, and envelope checks match {@link verifyWebhookAt}. Keys that fail to import
|
|
134
|
+
* are skipped. Rejects with {@link WebhookSignatureError} when `publicKeys` is empty or nothing
|
|
135
|
+
* matches, and {@link WebhookCryptoError} when none of the keys imports.
|
|
136
|
+
*/
|
|
41
137
|
export declare const verifyWebhookAtWithPublicKeys: (input: Omit<VerifyWebhookInput, "secret"> & {
|
|
42
138
|
readonly publicKeys: ReadonlyArray<string>;
|
|
43
139
|
}) => Promise<VerifiedWebhook>;
|
|
140
|
+
/**
|
|
141
|
+
* Reads the three signing headers from a Fetch API `Headers` object, for code that needs a header
|
|
142
|
+
* value, such as the timestamp, before or beside verification. Throws
|
|
143
|
+
* {@link InvalidWebhookHeadersError}, naming the header, when one is missing or empty; call it
|
|
144
|
+
* inside the same error handling as the verification that follows.
|
|
145
|
+
*/
|
|
44
146
|
export declare const readWebhookHeaders: (headers: Headers) => WebhookHeaders;
|
|
45
147
|
export { InvalidWebhookHeadersError, InvalidWebhookSecretError, MalformedWebhookPayloadError, WebhookCryptoError, WebhookSignatureError, WebhookTimestampError, WebhookVerificationError, } from "./errors.js";
|
|
46
|
-
export type { AnyWebhookVerificationError } from "./errors.js";
|
|
148
|
+
export type { AnyWebhookVerificationError, WebhookHeaderName } from "./errors.js";
|
package/dist/protocol.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { _ as WebhookTimestampError, a as encodeWebhookSignature, c as signWebhook, d as webhookSignedContent, f as InvalidWebhookHeadersError, g as WebhookSignatureError, h as WebhookCryptoError, i as WEBHOOK_TIMESTAMP_HEADER, l as verifyWebhookAt, m as MalformedWebhookPayloadError, n as WEBHOOK_ID_HEADER, o as generateWebhookSecret, p as InvalidWebhookSecretError, r as WEBHOOK_SIGNATURE_HEADER, s as readWebhookHeaders, t as DEFAULT_TOLERANCE_SECONDS, u as verifyWebhookAtWithPublicKeys, v as WebhookVerificationError } from "./protocol-
|
|
1
|
+
import { _ as WebhookTimestampError, a as encodeWebhookSignature, c as signWebhook, d as webhookSignedContent, f as InvalidWebhookHeadersError, g as WebhookSignatureError, h as WebhookCryptoError, i as WEBHOOK_TIMESTAMP_HEADER, l as verifyWebhookAt, m as MalformedWebhookPayloadError, n as WEBHOOK_ID_HEADER, o as generateWebhookSecret, p as InvalidWebhookSecretError, r as WEBHOOK_SIGNATURE_HEADER, s as readWebhookHeaders, t as DEFAULT_TOLERANCE_SECONDS, u as verifyWebhookAtWithPublicKeys, v as WebhookVerificationError } from "./protocol-BGKUmvlI.js";
|
|
2
2
|
export { DEFAULT_TOLERANCE_SECONDS, InvalidWebhookHeadersError, InvalidWebhookSecretError, MalformedWebhookPayloadError, WEBHOOK_ID_HEADER, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_TIMESTAMP_HEADER, WebhookCryptoError, WebhookSignatureError, WebhookTimestampError, WebhookVerificationError, encodeWebhookSignature, generateWebhookSecret, readWebhookHeaders, signWebhook, verifyWebhookAt, verifyWebhookAtWithPublicKeys, webhookSignedContent };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@samva/webhooks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Runtime-neutral verification for Samva webhook deliveries",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"samva",
|
|
@@ -58,14 +58,14 @@
|
|
|
58
58
|
"typecheck": "tsc --noEmit"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
|
-
"@effect/vitest": "4.0.0-rc.
|
|
62
|
-
"effect": "4.0.0-rc.
|
|
61
|
+
"@effect/vitest": "4.0.0-rc.117",
|
|
62
|
+
"effect": "4.0.0-rc.117",
|
|
63
63
|
"tsdown": "^0.22.13",
|
|
64
64
|
"typescript": "^7.0.2",
|
|
65
65
|
"vitest": "^4.1.11"
|
|
66
66
|
},
|
|
67
67
|
"peerDependencies": {
|
|
68
|
-
"effect": "4.0.0-rc.
|
|
68
|
+
"effect": "4.0.0-rc.117"
|
|
69
69
|
},
|
|
70
70
|
"peerDependenciesMeta": {
|
|
71
71
|
"effect": {
|