@rotorsoft/act-http 1.0.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/README.md +115 -3
- package/dist/.tsbuildinfo +1 -1
- package/dist/@types/receiver/check.d.ts +66 -0
- package/dist/@types/receiver/check.d.ts.map +1 -0
- package/dist/@types/receiver/express/index.d.ts +51 -0
- package/dist/@types/receiver/express/index.d.ts.map +1 -0
- package/dist/@types/receiver/extract.d.ts +24 -0
- package/dist/@types/receiver/extract.d.ts.map +1 -0
- package/dist/@types/receiver/fastify/index.d.ts +55 -0
- package/dist/@types/receiver/fastify/index.d.ts.map +1 -0
- package/dist/@types/receiver/hono/index.d.ts +60 -0
- package/dist/@types/receiver/hono/index.d.ts.map +1 -0
- package/dist/@types/receiver/index.d.ts +39 -0
- package/dist/@types/receiver/index.d.ts.map +1 -0
- package/dist/@types/receiver/start.d.ts +48 -0
- package/dist/@types/receiver/start.d.ts.map +1 -0
- package/dist/@types/receiver/trpc/index.d.ts +16 -0
- package/dist/@types/receiver/trpc/index.d.ts.map +1 -0
- package/dist/@types/receiver/verify.d.ts +57 -0
- package/dist/@types/receiver/verify.d.ts.map +1 -0
- package/dist/@types/webhook/classify.d.ts +59 -0
- package/dist/@types/webhook/classify.d.ts.map +1 -0
- package/dist/@types/webhook/index.d.ts +3 -2
- package/dist/@types/webhook/index.d.ts.map +1 -1
- package/dist/@types/webhook/sign.d.ts +25 -0
- package/dist/@types/webhook/sign.d.ts.map +1 -0
- package/dist/@types/webhook/types.d.ts +61 -20
- package/dist/@types/webhook/types.d.ts.map +1 -1
- package/dist/chunk-F7VWYZ37.js +29 -0
- package/dist/chunk-F7VWYZ37.js.map +1 -0
- package/dist/chunk-NOIXOF2I.js +78 -0
- package/dist/chunk-NOIXOF2I.js.map +1 -0
- package/dist/dist-NWMJQI4E.js +647 -0
- package/dist/dist-NWMJQI4E.js.map +1 -0
- package/dist/receiver/express/index.cjs +128 -0
- package/dist/receiver/express/index.cjs.map +1 -0
- package/dist/receiver/express/index.js +33 -0
- package/dist/receiver/express/index.js.map +1 -0
- package/dist/receiver/fastify/index.cjs +120 -0
- package/dist/receiver/fastify/index.cjs.map +1 -0
- package/dist/receiver/fastify/index.js +25 -0
- package/dist/receiver/fastify/index.js.map +1 -0
- package/dist/receiver/hono/index.cjs +123 -0
- package/dist/receiver/hono/index.cjs.map +1 -0
- package/dist/receiver/hono/index.js +8 -0
- package/dist/receiver/hono/index.js.map +1 -0
- package/dist/receiver/index.cjs +2943 -0
- package/dist/receiver/index.cjs.map +1 -0
- package/dist/receiver/index.js +2162 -0
- package/dist/receiver/index.js.map +1 -0
- package/dist/receiver/trpc/index.cjs +126 -0
- package/dist/receiver/trpc/index.cjs.map +1 -0
- package/dist/receiver/trpc/index.js +31 -0
- package/dist/receiver/trpc/index.js.map +1 -0
- package/dist/webhook/index.cjs +66 -6
- package/dist/webhook/index.cjs.map +1 -1
- package/dist/webhook/index.js +62 -6
- package/dist/webhook/index.js.map +1 -1
- package/package.json +47 -3
|
@@ -54,48 +54,89 @@ export type WebhookConfig<TEvents extends Schemas = Schemas> = {
|
|
|
54
54
|
* Injection point for tests. Defaults to global `fetch`.
|
|
55
55
|
*/
|
|
56
56
|
readonly fetch?: typeof fetch;
|
|
57
|
+
/**
|
|
58
|
+
* HMAC-SHA256 signing key. When set, the webhook helper attaches
|
|
59
|
+
* two headers to every request:
|
|
60
|
+
*
|
|
61
|
+
* - `X-Webhook-Signature: sha256=<hex>` — HMAC of
|
|
62
|
+
* `${timestamp}.${body}` (`body` is the final serialized payload)
|
|
63
|
+
* - `X-Webhook-Timestamp: <unix-seconds>`
|
|
64
|
+
*
|
|
65
|
+
* Pair with `verifyWebhook` from `@rotorsoft/act-http/receiver` on
|
|
66
|
+
* the receiving side. When undefined, no signature headers are
|
|
67
|
+
* added — back-compat with consumers that don't need signing.
|
|
68
|
+
*
|
|
69
|
+
* Callers can override either header by returning it from the
|
|
70
|
+
* `headers` resolver (case-insensitive), the same way the
|
|
71
|
+
* `Idempotency-Key` and `Content-Type` defaults yield to caller
|
|
72
|
+
* intent.
|
|
73
|
+
*/
|
|
74
|
+
readonly secret?: string;
|
|
57
75
|
};
|
|
58
76
|
/**
|
|
59
|
-
* Common fields carried on
|
|
77
|
+
* Common fields carried on every HTTP delivery error in this package.
|
|
60
78
|
*/
|
|
61
|
-
type
|
|
79
|
+
export type HttpDeliveryErrorInit = {
|
|
62
80
|
status: number;
|
|
63
81
|
url: string;
|
|
64
82
|
responseBody?: string;
|
|
65
83
|
};
|
|
66
84
|
/**
|
|
67
|
-
* Thrown when
|
|
85
|
+
* Thrown when an HTTP delivery fails in a way the drain pipeline
|
|
68
86
|
* should retry: network failure, timeout, or 5xx response. `status` is
|
|
69
87
|
* `0` for network / timeout errors, the HTTP status code otherwise.
|
|
70
88
|
*
|
|
71
|
-
* The class itself is the retry signal — if
|
|
89
|
+
* The class itself is the retry signal — if a reaction throws this,
|
|
72
90
|
* drain treats it like any other error (counts against `maxRetries`,
|
|
73
|
-
* paces with `backoff`). For permanent failures,
|
|
74
|
-
* {@link
|
|
91
|
+
* paces with `backoff`). For permanent failures, throw
|
|
92
|
+
* {@link NonRetryableHttpError} instead.
|
|
93
|
+
*
|
|
94
|
+
* Generic enough to cover any custom HTTP-like integration (gRPC
|
|
95
|
+
* bridges, SDK-based reactions). {@link WebhookError} is a
|
|
96
|
+
* webhook-specific subclass kept for backward compatibility.
|
|
75
97
|
*/
|
|
76
|
-
export declare class
|
|
98
|
+
export declare class RetryableHttpError extends Error {
|
|
77
99
|
readonly status: number;
|
|
78
100
|
readonly url: string;
|
|
79
101
|
readonly responseBody?: string;
|
|
80
|
-
constructor(message: string, init:
|
|
102
|
+
constructor(message: string, init: HttpDeliveryErrorInit);
|
|
81
103
|
}
|
|
82
104
|
/**
|
|
83
|
-
* Thrown when
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
105
|
+
* Thrown when an HTTP delivery returns a 3xx or 4xx response —
|
|
106
|
+
* permanent client errors that won't recover on retry. Extends
|
|
107
|
+
* {@link NonRetryableError} so the drain finalizer blocks the stream
|
|
108
|
+
* on the first failed attempt (when `blockOnError` is true) — no
|
|
109
|
+
* wasted retries on a malformed payload or wrong URL.
|
|
87
110
|
*
|
|
88
|
-
*
|
|
89
|
-
* {@link
|
|
90
|
-
*
|
|
91
|
-
* either retryable or non-retryable webhook failures should check both
|
|
92
|
-
* classes, or check the shared fields directly.
|
|
111
|
+
* Generic enough to cover any custom HTTP-like integration.
|
|
112
|
+
* {@link NonRetryableWebhookError} is a webhook-specific subclass kept
|
|
113
|
+
* for backward compatibility.
|
|
93
114
|
*/
|
|
94
|
-
export declare class
|
|
115
|
+
export declare class NonRetryableHttpError extends NonRetryableError {
|
|
95
116
|
readonly status: number;
|
|
96
117
|
readonly url: string;
|
|
97
118
|
readonly responseBody?: string;
|
|
98
|
-
constructor(message: string, init:
|
|
119
|
+
constructor(message: string, init: HttpDeliveryErrorInit);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Webhook-specific subclass of {@link RetryableHttpError}. Thrown by
|
|
123
|
+
* the {@link webhook} helper on 5xx responses, network failures, and
|
|
124
|
+
* timeouts. Existing `instanceof WebhookError` checks continue to
|
|
125
|
+
* work; new code targeting the generic HTTP integration shape can
|
|
126
|
+
* catch {@link RetryableHttpError} instead and handle webhook +
|
|
127
|
+
* custom integrations uniformly.
|
|
128
|
+
*/
|
|
129
|
+
export declare class WebhookError extends RetryableHttpError {
|
|
130
|
+
constructor(message: string, init: HttpDeliveryErrorInit);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Webhook-specific subclass of {@link NonRetryableHttpError}. Thrown
|
|
134
|
+
* by the {@link webhook} helper on 3xx/4xx responses. Existing
|
|
135
|
+
* `instanceof NonRetryableWebhookError` checks continue to work; new
|
|
136
|
+
* code can catch {@link NonRetryableHttpError} or
|
|
137
|
+
* {@link NonRetryableError} for broader coverage.
|
|
138
|
+
*/
|
|
139
|
+
export declare class NonRetryableWebhookError extends NonRetryableHttpError {
|
|
140
|
+
constructor(message: string, init: HttpDeliveryErrorInit);
|
|
99
141
|
}
|
|
100
|
-
export {};
|
|
101
142
|
//# sourceMappingURL=types.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/webhook/types.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,SAAS,EACd,iBAAiB,EACjB,KAAK,OAAO,EACb,MAAM,gBAAgB,CAAC;AAExB;;;;;;GAMG;AACH,MAAM,MAAM,eAAe,CAAC,OAAO,SAAS,OAAO,EAAE,CAAC,IAClD,CAAC,GACD,CAAC,CAAC,KAAK,EAAE,SAAS,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC;AAEtD;;;;GAIG;AACH,MAAM,MAAM,WAAW,GACnB,MAAM,GACN;IAAE,QAAQ,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;CAAE,GACjC,SAAS,OAAO,EAAE,CAAC;AAEvB;;;;GAIG;AACH,MAAM,MAAM,aAAa,CAAC,OAAO,SAAS,OAAO,GAAG,OAAO,IAAI;IAC7D,wDAAwD;IACxD,QAAQ,CAAC,GAAG,EAAE,eAAe,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;IAC/C,yCAAyC;IACzC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,KAAK,GAAG,OAAO,GAAG,QAAQ,CAAC;IACtD;;;;;OAKG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,eAAe,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACpE;;;;;OAKG;IACH,QAAQ,CAAC,IAAI,CAAC,EACV,WAAW,GACX,CAAC,CAAC,KAAK,EAAE,SAAS,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,KAAK,WAAW,CAAC,CAAC;IAChE;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B;;;;OAIG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,CACxB,KAAK,EAAE,SAAS,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,KACrC,MAAM,GAAG,IAAI,CAAC;IACnB;;OAEG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/webhook/types.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,SAAS,EACd,iBAAiB,EACjB,KAAK,OAAO,EACb,MAAM,gBAAgB,CAAC;AAExB;;;;;;GAMG;AACH,MAAM,MAAM,eAAe,CAAC,OAAO,SAAS,OAAO,EAAE,CAAC,IAClD,CAAC,GACD,CAAC,CAAC,KAAK,EAAE,SAAS,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC;AAEtD;;;;GAIG;AACH,MAAM,MAAM,WAAW,GACnB,MAAM,GACN;IAAE,QAAQ,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;CAAE,GACjC,SAAS,OAAO,EAAE,CAAC;AAEvB;;;;GAIG;AACH,MAAM,MAAM,aAAa,CAAC,OAAO,SAAS,OAAO,GAAG,OAAO,IAAI;IAC7D,wDAAwD;IACxD,QAAQ,CAAC,GAAG,EAAE,eAAe,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;IAC/C,yCAAyC;IACzC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,KAAK,GAAG,OAAO,GAAG,QAAQ,CAAC;IACtD;;;;;OAKG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,eAAe,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACpE;;;;;OAKG;IACH,QAAQ,CAAC,IAAI,CAAC,EACV,WAAW,GACX,CAAC,CAAC,KAAK,EAAE,SAAS,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,KAAK,WAAW,CAAC,CAAC;IAChE;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B;;;;OAIG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,CACxB,KAAK,EAAE,SAAS,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,KACrC,MAAM,GAAG,IAAI,CAAC;IACnB;;OAEG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;IAC9B;;;;;;;;;;;;;;;;OAgBG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,qBAAqB,GAAG;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,qBAAa,kBAAmB,SAAQ,KAAK;IAC3C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;gBAEnB,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,qBAAqB;CAOzD;AAED;;;;;;;;;;GAUG;AACH,qBAAa,qBAAsB,SAAQ,iBAAiB;IAC1D,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;gBAEnB,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,qBAAqB;CAOzD;AAED;;;;;;;GAOG;AACH,qBAAa,YAAa,SAAQ,kBAAkB;gBACtC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,qBAAqB;CAIzD;AAED;;;;;;GAMG;AACH,qBAAa,wBAAyB,SAAQ,qBAAqB;gBACrD,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,qBAAqB;CAIzD"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import {
|
|
2
|
+
checkWebhook
|
|
3
|
+
} from "./chunk-NOIXOF2I.js";
|
|
4
|
+
|
|
5
|
+
// src/receiver/hono/index.ts
|
|
6
|
+
function webhookMiddleware(options) {
|
|
7
|
+
return async function check(c, next) {
|
|
8
|
+
const headers = headersBag(c.req.raw.headers);
|
|
9
|
+
const rawBody = await c.req.text();
|
|
10
|
+
const result = await checkWebhook(headers, rawBody, options);
|
|
11
|
+
if (!result.ok) {
|
|
12
|
+
return c.json({ error: result.reason }, result.status);
|
|
13
|
+
}
|
|
14
|
+
c.set("idempotency", { key: result.key, deduped: result.deduped });
|
|
15
|
+
await next();
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function headersBag(headers) {
|
|
19
|
+
const out = {};
|
|
20
|
+
headers.forEach((value, key) => {
|
|
21
|
+
out[key] = value;
|
|
22
|
+
});
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
webhookMiddleware
|
|
28
|
+
};
|
|
29
|
+
//# sourceMappingURL=chunk-F7VWYZ37.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/receiver/hono/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/receiver/hono\n *\n * Hono adapter for the receiver-side webhook check.\n *\n * Usage:\n *\n * ```ts\n * import { Hono } from \"hono\";\n * import { webhookMiddleware } from \"@rotorsoft/act-http/receiver/hono\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n *\n * const app = new Hono();\n * const dedup = new InMemoryIdempotencyStore();\n *\n * app.post(\n * \"/webhooks/orders\",\n * webhookMiddleware({ store: dedup, secret: process.env.WEBHOOK_SECRET }),\n * async (c) => {\n * const idem = c.get(\"idempotency\") as { key: string; deduped: boolean };\n * if (idem.deduped) return c.json({ status: \"dedup-skipped\", key: idem.key });\n * // ... process the inbound event ...\n * return c.json({ status: \"processed\", key: idem.key });\n * }\n * );\n * ```\n *\n * On failure: returns `c.json({ error: <reason> }, status)` directly\n * (Hono short-circuits when middleware returns a Response). On\n * success: stashes `c.set(\"idempotency\", { key, deduped })` and\n * continues with `await next()`.\n *\n * **Raw body**: Hono exposes `await c.req.text()` natively, which\n * the middleware reads when `secret` is configured. No extra setup\n * needed.\n */\nimport type { MiddlewareHandler } from \"hono\";\nimport { type CheckWebhookOptions, checkWebhook } from \"../check.js\";\n\n/**\n * Variables this middleware contributes to the Hono context. The\n * generic on the returned {@link MiddlewareHandler} threads it\n * through so route handlers downstream of `app.post(..., webhookMiddleware(...), handler)`\n * see `c.get(\"idempotency\")` typed without a manual cast.\n */\nexport type WebhookVariables = {\n idempotency: { key: string; deduped: boolean };\n};\n\n/**\n * Build a Hono middleware that verifies the request signature (when\n * `secret` is set), enforces `Idempotency-Key`, and claims the key\n * on the configured store. See the module-level docs for usage.\n */\nexport function webhookMiddleware(\n options: CheckWebhookOptions\n): MiddlewareHandler<{ Variables: WebhookVariables }> {\n return async function check(c, next) {\n const headers = headersBag(c.req.raw.headers);\n const rawBody = await c.req.text();\n const result = await checkWebhook(headers, rawBody, options);\n if (!result.ok) {\n return c.json({ error: result.reason }, result.status);\n }\n c.set(\"idempotency\", { key: result.key, deduped: result.deduped });\n await next();\n };\n}\n\nfunction headersBag(\n headers: Headers\n): Record<string, string | string[] | undefined> {\n const out: Record<string, string | string[] | undefined> = {};\n headers.forEach((value, key) => {\n out[key] = value;\n });\n return out;\n}\n"],"mappings":";;;;;AAuDO,SAAS,kBACd,SACoD;AACpD,SAAO,eAAe,MAAM,GAAG,MAAM;AACnC,UAAM,UAAU,WAAW,EAAE,IAAI,IAAI,OAAO;AAC5C,UAAM,UAAU,MAAM,EAAE,IAAI,KAAK;AACjC,UAAM,SAAS,MAAM,aAAa,SAAS,SAAS,OAAO;AAC3D,QAAI,CAAC,OAAO,IAAI;AACd,aAAO,EAAE,KAAK,EAAE,OAAO,OAAO,OAAO,GAAG,OAAO,MAAM;AAAA,IACvD;AACA,MAAE,IAAI,eAAe,EAAE,KAAK,OAAO,KAAK,SAAS,OAAO,QAAQ,CAAC;AACjE,UAAM,KAAK;AAAA,EACb;AACF;AAEA,SAAS,WACP,SAC+C;AAC/C,QAAM,MAAqD,CAAC;AAC5D,UAAQ,QAAQ,CAAC,OAAO,QAAQ;AAC9B,QAAI,GAAG,IAAI;AAAA,EACb,CAAC;AACD,SAAO;AACT;","names":[]}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// src/receiver/extract.ts
|
|
2
|
+
function extractIdempotencyKey(headers) {
|
|
3
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
4
|
+
if (name.toLowerCase() !== "idempotency-key") continue;
|
|
5
|
+
if (Array.isArray(value)) return void 0;
|
|
6
|
+
if (value === "") return void 0;
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
return void 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// src/receiver/verify.ts
|
|
13
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
14
|
+
function verifyWebhook(headers, body, secret, options) {
|
|
15
|
+
const maxAgeSeconds = options?.maxAgeSeconds ?? 300;
|
|
16
|
+
const now = options?.now ?? Math.floor(Date.now() / 1e3);
|
|
17
|
+
const signature = pickHeader(headers, "x-webhook-signature");
|
|
18
|
+
if (!signature) return { ok: false, reason: "missing-signature" };
|
|
19
|
+
const timestampStr = pickHeader(headers, "x-webhook-timestamp");
|
|
20
|
+
if (!timestampStr) return { ok: false, reason: "missing-timestamp" };
|
|
21
|
+
const timestamp = Number.parseInt(timestampStr, 10);
|
|
22
|
+
if (Number.isNaN(timestamp) || String(timestamp) !== timestampStr) {
|
|
23
|
+
return { ok: false, reason: "missing-timestamp" };
|
|
24
|
+
}
|
|
25
|
+
const delta = now - timestamp;
|
|
26
|
+
if (delta > maxAgeSeconds) return { ok: false, reason: "stale" };
|
|
27
|
+
if (delta < -maxAgeSeconds) return { ok: false, reason: "future" };
|
|
28
|
+
if (!signature.startsWith("sha256=")) {
|
|
29
|
+
return { ok: false, reason: "bad-signature" };
|
|
30
|
+
}
|
|
31
|
+
const providedHex = signature.slice("sha256=".length);
|
|
32
|
+
if (!/^[0-9a-fA-F]{64}$/.test(providedHex)) {
|
|
33
|
+
return { ok: false, reason: "bad-signature" };
|
|
34
|
+
}
|
|
35
|
+
const expectedHex = createHmac("sha256", secret).update(`${timestampStr}.${body}`).digest("hex");
|
|
36
|
+
const a = Buffer.from(providedHex, "hex");
|
|
37
|
+
const b = Buffer.from(expectedHex, "hex");
|
|
38
|
+
if (!timingSafeEqual(a, b)) {
|
|
39
|
+
return { ok: false, reason: "bad-signature" };
|
|
40
|
+
}
|
|
41
|
+
return { ok: true };
|
|
42
|
+
}
|
|
43
|
+
function pickHeader(headers, lowerName) {
|
|
44
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
45
|
+
if (name.toLowerCase() !== lowerName) continue;
|
|
46
|
+
if (Array.isArray(value) || value === void 0 || value === "") {
|
|
47
|
+
return void 0;
|
|
48
|
+
}
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
return void 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// src/receiver/check.ts
|
|
55
|
+
async function checkWebhook(headers, body, options) {
|
|
56
|
+
if (options.secret !== void 0) {
|
|
57
|
+
const verification = verifyWebhook(
|
|
58
|
+
headers,
|
|
59
|
+
body,
|
|
60
|
+
options.secret,
|
|
61
|
+
options.verify
|
|
62
|
+
);
|
|
63
|
+
if (!verification.ok) {
|
|
64
|
+
return { ok: false, status: 401, reason: verification.reason };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const key = extractIdempotencyKey(headers);
|
|
68
|
+
if (!key) return { ok: false, status: 400, reason: "missing-key" };
|
|
69
|
+
const claimed = await options.store.claim(key);
|
|
70
|
+
return { ok: true, key, deduped: !claimed };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export {
|
|
74
|
+
extractIdempotencyKey,
|
|
75
|
+
verifyWebhook,
|
|
76
|
+
checkWebhook
|
|
77
|
+
};
|
|
78
|
+
//# sourceMappingURL=chunk-NOIXOF2I.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/receiver/extract.ts","../src/receiver/verify.ts","../src/receiver/check.ts"],"sourcesContent":["/**\n * Pull the `Idempotency-Key` header from a Node-style headers bag,\n * case-insensitive. Returns `undefined` when any of the following\n * carries no usable key:\n *\n * - the header is missing\n * - its value is an array (ambiguous — can't pick one without a\n * policy the receiver hasn't declared)\n * - its value is the empty string (carries no idempotency\n * information; structurally equivalent to \"no header at all\")\n *\n * Pair with `IdempotencyStore.claim` from\n * `@rotorsoft/act-ops/idempotency`: extract the key from the inbound\n * request, claim it on the store, return a `deduped` marker when the\n * claim fails. The framework-agnostic middleware that wires these\n * together lands in #744.\n *\n * Validation beyond \"is there a usable key?\" (length bounds, format\n * checks, normalization) is intentionally out of scope. Receivers\n * picking a policy can layer it on top — or, when #744 ships, opt\n * into the middleware's opinionated defaults.\n */\nexport function extractIdempotencyKey(\n headers: Record<string, string | string[] | undefined>\n): string | undefined {\n for (const [name, value] of Object.entries(headers)) {\n if (name.toLowerCase() !== \"idempotency-key\") continue;\n if (Array.isArray(value)) return undefined;\n if (value === \"\") return undefined;\n return value;\n }\n return undefined;\n}\n","import { createHmac, timingSafeEqual } from \"node:crypto\";\n\n/**\n * Outcome of {@link verifyWebhook}. Either the request signature\n * checks out, or one of five distinct failure reasons applies. Each\n * reason maps to an operator-meaningful telemetry bucket — separated\n * deliberately so dashboards can distinguish \"client lost its secret\"\n * from \"client clock is wrong\" from \"this is a replay attack.\"\n */\nexport type VerifyResult =\n | { ok: true }\n | {\n ok: false;\n reason:\n | \"missing-signature\"\n | \"missing-timestamp\"\n | \"stale\"\n | \"future\"\n | \"bad-signature\";\n };\n\n/** Options for {@link verifyWebhook}. */\nexport type VerifyOptions = {\n /**\n * Maximum acceptable timestamp drift in either direction, in\n * seconds. Default: 300 (±5 minutes) — matches Stripe / GitHub /\n * Slack conventions. Tightening narrows the replay window;\n * loosening accommodates clients with worse clock sync.\n */\n maxAgeSeconds?: number;\n /**\n * Current Unix-seconds time. Exposed for tests; production\n * callers should leave it undefined so wall-clock is used.\n */\n now?: number;\n};\n\n/**\n * Verify an inbound webhook's signature and timestamp against the\n * shared secret. Pair with the sender side: configure\n * `webhook({ secret })` from `@rotorsoft/act-http/webhook`.\n *\n * Returns `{ ok: true }` on success or `{ ok: false; reason }` on\n * failure. The reasons are:\n *\n * - `missing-signature` — no `X-Webhook-Signature` header, value\n * was an array, or value was empty.\n * - `missing-timestamp` — no `X-Webhook-Timestamp` header, value\n * was empty, or value isn't a parseable integer.\n * - `stale` — timestamp older than `maxAgeSeconds` from `now`.\n * - `future` — timestamp more than `maxAgeSeconds` ahead of `now`.\n * - `bad-signature` — signature header didn't start with `sha256=`,\n * wasn't 64 hex chars, or the recomputed HMAC didn't match\n * (constant-time compare).\n *\n * The signed payload is `${timestamp}.${body}`, so `body` must be\n * the **raw request body bytes**. Any pre-parse normalization\n * (whitespace trimming, JSON re-stringification) would change the\n * hash and reject every otherwise-valid request. Framework adapters\n * in #744 will provide the raw body alongside the parsed one.\n *\n * Uses Node's `crypto.timingSafeEqual` for the final comparison to\n * avoid signature-equality timing attacks.\n */\nexport function verifyWebhook(\n headers: Record<string, string | string[] | undefined>,\n body: string,\n secret: string,\n options?: VerifyOptions\n): VerifyResult {\n const maxAgeSeconds = options?.maxAgeSeconds ?? 300;\n const now = options?.now ?? Math.floor(Date.now() / 1000);\n\n const signature = pickHeader(headers, \"x-webhook-signature\");\n if (!signature) return { ok: false, reason: \"missing-signature\" };\n\n const timestampStr = pickHeader(headers, \"x-webhook-timestamp\");\n if (!timestampStr) return { ok: false, reason: \"missing-timestamp\" };\n const timestamp = Number.parseInt(timestampStr, 10);\n if (Number.isNaN(timestamp) || String(timestamp) !== timestampStr) {\n return { ok: false, reason: \"missing-timestamp\" };\n }\n\n const delta = now - timestamp;\n if (delta > maxAgeSeconds) return { ok: false, reason: \"stale\" };\n if (delta < -maxAgeSeconds) return { ok: false, reason: \"future\" };\n\n if (!signature.startsWith(\"sha256=\")) {\n return { ok: false, reason: \"bad-signature\" };\n }\n const providedHex = signature.slice(\"sha256=\".length);\n if (!/^[0-9a-fA-F]{64}$/.test(providedHex)) {\n return { ok: false, reason: \"bad-signature\" };\n }\n\n const expectedHex = createHmac(\"sha256\", secret)\n .update(`${timestampStr}.${body}`)\n .digest(\"hex\");\n\n const a = Buffer.from(providedHex, \"hex\");\n const b = Buffer.from(expectedHex, \"hex\");\n if (!timingSafeEqual(a, b)) {\n return { ok: false, reason: \"bad-signature\" };\n }\n\n return { ok: true };\n}\n\nfunction pickHeader(\n headers: Record<string, string | string[] | undefined>,\n lowerName: string\n): string | undefined {\n for (const [name, value] of Object.entries(headers)) {\n if (name.toLowerCase() !== lowerName) continue;\n if (Array.isArray(value) || value === undefined || value === \"\") {\n return undefined;\n }\n return value;\n }\n return undefined;\n}\n","import type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\nimport { extractIdempotencyKey } from \"./extract.js\";\nimport { type VerifyOptions, verifyWebhook } from \"./verify.js\";\n\n/**\n * Failure reasons returned by {@link checkWebhook}. The shape splits\n * `missing-key` (a client error, mapped to HTTP 400) from the five\n * verification failures (authentication errors, HTTP 401) so each\n * maps to its own telemetry bucket.\n */\nexport type CheckFailureReason =\n | \"missing-key\"\n | \"missing-signature\"\n | \"missing-timestamp\"\n | \"stale\"\n | \"future\"\n | \"bad-signature\";\n\n/**\n * Outcome of {@link checkWebhook}. Either the request passed every\n * configured check and carries a usable idempotency key, or it\n * failed one of them and the framework adapter should reply with the\n * corresponding HTTP status.\n */\nexport type CheckResult =\n | { ok: false; status: 400 | 401; reason: CheckFailureReason }\n | { ok: true; key: string; deduped: boolean };\n\n/** Options for {@link checkWebhook}. */\nexport type CheckWebhookOptions = {\n /** Idempotency store the framework-agnostic core claims the key on. */\n store: IdempotencyStore;\n /**\n * Optional HMAC-SHA256 secret. When set, the request's\n * `X-Webhook-Signature` and `X-Webhook-Timestamp` headers are\n * verified before the dedup claim. When omitted, signature\n * verification is skipped (unsigned receivers).\n */\n secret?: string;\n /**\n * Verification options forwarded to {@link verifyWebhook}. Only\n * meaningful when `secret` is set. Defaults to a ±300-second\n * timestamp window.\n */\n verify?: VerifyOptions;\n};\n\n/**\n * Framework-agnostic receiver check: verify the signature (when a\n * secret is configured), extract the `Idempotency-Key`, and claim\n * it on the store. Returns the request's fate as a discriminated\n * union the per-framework adapter translates into the framework's\n * idiomatic 4xx response or context injection.\n *\n * **Order of checks** (matters):\n *\n * 1. Verify signature + timestamp window (when `secret` is set).\n * Rejecting bad signatures *before* extracting and claiming the\n * key keeps attacker-supplied keys out of the dedup store —\n * otherwise a flood of spoofed POSTs would pollute the LRU.\n * 2. Extract the `Idempotency-Key`. Missing → reject with 400.\n * 3. Claim the key on the store. If already seen, return\n * `{ ok: true; deduped: true }` so the framework adapter can\n * short-circuit the handler without re-running side effects.\n *\n * The dedup store may be sync (`InMemoryIdempotencyStore`) or async\n * (durable adapters like a future `PostgresIdempotencyStore`); the\n * core awaits unconditionally so both shapes compose cleanly.\n */\nexport async function checkWebhook(\n headers: Record<string, string | string[] | undefined>,\n body: string,\n options: CheckWebhookOptions\n): Promise<CheckResult> {\n if (options.secret !== undefined) {\n const verification = verifyWebhook(\n headers,\n body,\n options.secret,\n options.verify\n );\n if (!verification.ok) {\n return { ok: false, status: 401, reason: verification.reason };\n }\n }\n\n const key = extractIdempotencyKey(headers);\n if (!key) return { ok: false, status: 400, reason: \"missing-key\" };\n\n const claimed = await options.store.claim(key);\n return { ok: true, key, deduped: !claimed };\n}\n"],"mappings":";AAsBO,SAAS,sBACd,SACoB;AACpB,aAAW,CAAC,MAAM,KAAK,KAAK,OAAO,QAAQ,OAAO,GAAG;AACnD,QAAI,KAAK,YAAY,MAAM,kBAAmB;AAC9C,QAAI,MAAM,QAAQ,KAAK,EAAG,QAAO;AACjC,QAAI,UAAU,GAAI,QAAO;AACzB,WAAO;AAAA,EACT;AACA,SAAO;AACT;;;AChCA,SAAS,YAAY,uBAAuB;AAgErC,SAAS,cACd,SACA,MACA,QACA,SACc;AACd,QAAM,gBAAgB,SAAS,iBAAiB;AAChD,QAAM,MAAM,SAAS,OAAO,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI;AAExD,QAAM,YAAY,WAAW,SAAS,qBAAqB;AAC3D,MAAI,CAAC,UAAW,QAAO,EAAE,IAAI,OAAO,QAAQ,oBAAoB;AAEhE,QAAM,eAAe,WAAW,SAAS,qBAAqB;AAC9D,MAAI,CAAC,aAAc,QAAO,EAAE,IAAI,OAAO,QAAQ,oBAAoB;AACnE,QAAM,YAAY,OAAO,SAAS,cAAc,EAAE;AAClD,MAAI,OAAO,MAAM,SAAS,KAAK,OAAO,SAAS,MAAM,cAAc;AACjE,WAAO,EAAE,IAAI,OAAO,QAAQ,oBAAoB;AAAA,EAClD;AAEA,QAAM,QAAQ,MAAM;AACpB,MAAI,QAAQ,cAAe,QAAO,EAAE,IAAI,OAAO,QAAQ,QAAQ;AAC/D,MAAI,QAAQ,CAAC,cAAe,QAAO,EAAE,IAAI,OAAO,QAAQ,SAAS;AAEjE,MAAI,CAAC,UAAU,WAAW,SAAS,GAAG;AACpC,WAAO,EAAE,IAAI,OAAO,QAAQ,gBAAgB;AAAA,EAC9C;AACA,QAAM,cAAc,UAAU,MAAM,UAAU,MAAM;AACpD,MAAI,CAAC,oBAAoB,KAAK,WAAW,GAAG;AAC1C,WAAO,EAAE,IAAI,OAAO,QAAQ,gBAAgB;AAAA,EAC9C;AAEA,QAAM,cAAc,WAAW,UAAU,MAAM,EAC5C,OAAO,GAAG,YAAY,IAAI,IAAI,EAAE,EAChC,OAAO,KAAK;AAEf,QAAM,IAAI,OAAO,KAAK,aAAa,KAAK;AACxC,QAAM,IAAI,OAAO,KAAK,aAAa,KAAK;AACxC,MAAI,CAAC,gBAAgB,GAAG,CAAC,GAAG;AAC1B,WAAO,EAAE,IAAI,OAAO,QAAQ,gBAAgB;AAAA,EAC9C;AAEA,SAAO,EAAE,IAAI,KAAK;AACpB;AAEA,SAAS,WACP,SACA,WACoB;AACpB,aAAW,CAAC,MAAM,KAAK,KAAK,OAAO,QAAQ,OAAO,GAAG;AACnD,QAAI,KAAK,YAAY,MAAM,UAAW;AACtC,QAAI,MAAM,QAAQ,KAAK,KAAK,UAAU,UAAa,UAAU,IAAI;AAC/D,aAAO;AAAA,IACT;AACA,WAAO;AAAA,EACT;AACA,SAAO;AACT;;;ACnDA,eAAsB,aACpB,SACA,MACA,SACsB;AACtB,MAAI,QAAQ,WAAW,QAAW;AAChC,UAAM,eAAe;AAAA,MACnB;AAAA,MACA;AAAA,MACA,QAAQ;AAAA,MACR,QAAQ;AAAA,IACV;AACA,QAAI,CAAC,aAAa,IAAI;AACpB,aAAO,EAAE,IAAI,OAAO,QAAQ,KAAK,QAAQ,aAAa,OAAO;AAAA,IAC/D;AAAA,EACF;AAEA,QAAM,MAAM,sBAAsB,OAAO;AACzC,MAAI,CAAC,IAAK,QAAO,EAAE,IAAI,OAAO,QAAQ,KAAK,QAAQ,cAAc;AAEjE,QAAM,UAAU,MAAM,QAAQ,MAAM,MAAM,GAAG;AAC7C,SAAO,EAAE,IAAI,MAAM,KAAK,SAAS,CAAC,QAAQ;AAC5C;","names":[]}
|