@rotorsoft/act-http 1.8.3 → 1.8.4

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.
Files changed (51) hide show
  1. package/README.md +31 -11
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/@types/api/idempotency.d.ts +10 -3
  4. package/dist/@types/api/idempotency.d.ts.map +1 -1
  5. package/dist/@types/receiver/express/index.d.ts +17 -1
  6. package/dist/@types/receiver/express/index.d.ts.map +1 -1
  7. package/dist/@types/receiver/fastify/index.d.ts +17 -2
  8. package/dist/@types/receiver/fastify/index.d.ts.map +1 -1
  9. package/dist/@types/receiver/finalize.d.ts +34 -0
  10. package/dist/@types/receiver/finalize.d.ts.map +1 -0
  11. package/dist/@types/receiver/hono/index.d.ts +15 -0
  12. package/dist/@types/receiver/hono/index.d.ts.map +1 -1
  13. package/dist/@types/receiver/start.d.ts.map +1 -1
  14. package/dist/@types/receiver/trpc/index.d.ts.map +1 -1
  15. package/dist/api/index.cjs +9 -1
  16. package/dist/api/index.cjs.map +1 -1
  17. package/dist/api/index.js +1 -1
  18. package/dist/{chunk-K4HAOBRF.js → chunk-6YOMQRZO.js} +23 -5
  19. package/dist/chunk-6YOMQRZO.js.map +1 -0
  20. package/dist/{chunk-OQKYUDEK.js → chunk-FIYLGJAE.js} +10 -2
  21. package/dist/{chunk-OQKYUDEK.js.map → chunk-FIYLGJAE.js.map} +1 -1
  22. package/dist/{chunk-4CGAUB5H.js → chunk-NXSPB2NW.js} +20 -2
  23. package/dist/chunk-NXSPB2NW.js.map +1 -0
  24. package/dist/hono/index.cjs +9 -1
  25. package/dist/hono/index.cjs.map +1 -1
  26. package/dist/hono/index.js +1 -1
  27. package/dist/receiver/express/index.cjs +25 -1
  28. package/dist/receiver/express/index.cjs.map +1 -1
  29. package/dist/receiver/express/index.js +11 -3
  30. package/dist/receiver/express/index.js.map +1 -1
  31. package/dist/receiver/fastify/index.cjs +28 -1
  32. package/dist/receiver/fastify/index.cjs.map +1 -1
  33. package/dist/receiver/fastify/index.js +14 -3
  34. package/dist/receiver/fastify/index.js.map +1 -1
  35. package/dist/receiver/hono/index.cjs +36 -2
  36. package/dist/receiver/hono/index.cjs.map +1 -1
  37. package/dist/receiver/hono/index.js +2 -2
  38. package/dist/receiver/index.cjs +38 -2
  39. package/dist/receiver/index.cjs.map +1 -1
  40. package/dist/receiver/index.js +4 -2
  41. package/dist/receiver/index.js.map +1 -1
  42. package/dist/receiver/trpc/index.cjs +45 -6
  43. package/dist/receiver/trpc/index.cjs.map +1 -1
  44. package/dist/receiver/trpc/index.js +31 -8
  45. package/dist/receiver/trpc/index.js.map +1 -1
  46. package/dist/trpc/index.cjs +9 -1
  47. package/dist/trpc/index.cjs.map +1 -1
  48. package/dist/trpc/index.js +1 -1
  49. package/package.json +2 -2
  50. package/dist/chunk-4CGAUB5H.js.map +0 -1
  51. package/dist/chunk-K4HAOBRF.js.map +0 -1
@@ -5,7 +5,7 @@ import {
5
5
  runSseSubscription,
6
6
  toApiError,
7
7
  withIdempotency
8
- } from "../chunk-OQKYUDEK.js";
8
+ } from "../chunk-FIYLGJAE.js";
9
9
  import "../chunk-WGFX4G27.js";
10
10
  import "../chunk-PZ5AY32C.js";
11
11
 
@@ -96,6 +96,23 @@ async function checkWebhook(headers, body, options) {
96
96
  return { ok: true, key, deduped: !claimed };
97
97
  }
98
98
 
99
+ // src/receiver/finalize.ts
100
+ function make_finalizers(store, key, deduped) {
101
+ let settled = deduped;
102
+ return {
103
+ async commit() {
104
+ if (settled) return;
105
+ settled = true;
106
+ await store.commit(key);
107
+ },
108
+ async release() {
109
+ if (settled) return;
110
+ settled = true;
111
+ await store.release(key);
112
+ }
113
+ };
114
+ }
115
+
99
116
  // src/receiver/express/index.ts
100
117
  function webhookMiddleware(options) {
101
118
  return async function check(req, res, next) {
@@ -109,9 +126,16 @@ function webhookMiddleware(options) {
109
126
  res.status(result.status).json({ error: result.reason });
110
127
  return;
111
128
  }
129
+ const { commit, release } = make_finalizers(
130
+ options.store,
131
+ result.key,
132
+ result.deduped
133
+ );
112
134
  req.idempotency = {
113
135
  key: result.key,
114
- deduped: result.deduped
136
+ deduped: result.deduped,
137
+ commit,
138
+ release
115
139
  };
116
140
  next();
117
141
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/receiver/express/index.ts","../../../src/receiver/extract.ts","../../../src/receiver/verify.ts","../../../src/receiver/check.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/receiver/express\n *\n * Express adapter for the receiver-side webhook check.\n *\n * Usage:\n *\n * ```ts\n * import express from \"express\";\n * import { webhookMiddleware } from \"@rotorsoft/act-http/receiver/express\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n *\n * const app = express();\n * const dedup = new InMemoryIdempotencyStore();\n *\n * // Raw body capture required when signing is enabled.\n * app.use(express.raw({ type: \"application/json\" }));\n *\n * app.post(\n * \"/webhooks/orders\",\n * webhookMiddleware({ store: dedup, secret: process.env.WEBHOOK_SECRET }),\n * (req, res) => {\n * const { key, deduped } = (req as any).idempotency;\n * if (deduped) return res.json({ status: \"dedup-skipped\", key });\n * // ... process the inbound event ...\n * res.json({ status: \"processed\", key });\n * }\n * );\n * ```\n *\n * On failure: responds with the framework-idiomatic JSON shape\n * `{ error: <reason> }` at status 400 (missing-key) or 401\n * (verification failures), and does not call `next()`. On success:\n * attaches `req.idempotency = { key, deduped }` and calls `next()`.\n *\n * **Raw body requirement**: when `secret` is configured, mount\n * `express.raw({ type: \"application/json\" })` (or whatever\n * content-type your webhooks use) ahead of the receiver middleware.\n * The middleware reads `req.body` as a `Buffer | string` and converts\n * to a UTF-8 string for hashing. Skip when unsigned.\n */\nimport type { NextFunction, Request, RequestHandler, Response } from \"express\";\nimport { type CheckWebhookOptions, checkWebhook } from \"../check.js\";\n\n/**\n * Build an Express middleware that verifies the request signature\n * (when `secret` is set), enforces `Idempotency-Key`, and claims the\n * key on the configured store. See the module-level docs for usage.\n */\nexport function webhookMiddleware(\n options: CheckWebhookOptions\n): RequestHandler {\n return async function check(\n req: Request,\n res: Response,\n next: NextFunction\n ): Promise<void> {\n const rawBody = buffer_or_string(req.body);\n const result = await checkWebhook(\n req.headers as Record<string, string | string[] | undefined>,\n rawBody,\n options\n );\n if (!result.ok) {\n res.status(result.status).json({ error: result.reason });\n return;\n }\n (\n req as Request & { idempotency: { key: string; deduped: boolean } }\n ).idempotency = {\n key: result.key,\n deduped: result.deduped,\n };\n next();\n };\n}\n\nfunction buffer_or_string(body: unknown): string {\n if (typeof body === \"string\") return body;\n if (body instanceof Uint8Array) return Buffer.from(body).toString(\"utf8\");\n return \"\";\n}\n","/**\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 = pick_header(headers, \"x-webhook-signature\");\n if (!signature) return { ok: false, reason: \"missing-signature\" };\n\n const timestamp_str = pick_header(headers, \"x-webhook-timestamp\");\n if (!timestamp_str) return { ok: false, reason: \"missing-timestamp\" };\n const timestamp = Number.parseInt(timestamp_str, 10);\n if (Number.isNaN(timestamp) || String(timestamp) !== timestamp_str) {\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 provided_hex = signature.slice(\"sha256=\".length);\n if (!/^[0-9a-fA-F]{64}$/.test(provided_hex)) {\n return { ok: false, reason: \"bad-signature\" };\n }\n\n const expected_hex = createHmac(\"sha256\", secret)\n .update(`${timestamp_str}.${body}`)\n .digest(\"hex\");\n\n const a = Buffer.from(provided_hex, \"hex\");\n const b = Buffer.from(expected_hex, \"hex\");\n if (!timingSafeEqual(a, b)) {\n return { ok: false, reason: \"bad-signature\" };\n }\n\n return { ok: true };\n}\n\nfunction pick_header(\n headers: Record<string, string | string[] | undefined>,\n lower_name: string\n): string | undefined {\n for (const [name, value] of Object.entries(headers)) {\n if (name.toLowerCase() !== lower_name) 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":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/receiver/express/index.ts","../../../src/receiver/extract.ts","../../../src/receiver/verify.ts","../../../src/receiver/check.ts","../../../src/receiver/finalize.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/receiver/express\n *\n * Express adapter for the receiver-side webhook check.\n *\n * Usage:\n *\n * ```ts\n * import express from \"express\";\n * import { webhookMiddleware } from \"@rotorsoft/act-http/receiver/express\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n *\n * const app = express();\n * const dedup = new InMemoryIdempotencyStore();\n *\n * // Raw body capture required when signing is enabled.\n * app.use(express.raw({ type: \"application/json\" }));\n *\n * app.post(\n * \"/webhooks/orders\",\n * webhookMiddleware({ store: dedup, secret: process.env.WEBHOOK_SECRET }),\n * (req, res) => {\n * const { key, deduped } = (req as any).idempotency;\n * if (deduped) return res.json({ status: \"dedup-skipped\", key });\n * // ... process the inbound event ...\n * res.json({ status: \"processed\", key });\n * }\n * );\n * ```\n *\n * On failure: responds with the framework-idiomatic JSON shape\n * `{ error: <reason> }` at status 400 (missing-key) or 401\n * (verification failures), and does not call `next()`. On success:\n * attaches `req.idempotency = { key, deduped, commit, release }` and\n * calls `next()`.\n *\n * **Two-phase dedup**: the claim is *tentative*. Express runs the\n * middleware to completion before the route handler, so — unlike the\n * Hono adapter — it can't observe the handler's outcome to finalize\n * automatically. The route handler **must** call\n * `req.idempotency.commit()` on success or `req.idempotency.release()`\n * on a transient failure. Skipping both leaves the claim tentative:\n * it dedups concurrent duplicates but expires on TTL, so the delivery\n * is never permanently lost — it just isn't durably deduped either.\n *\n * **Raw body requirement**: when `secret` is configured, mount\n * `express.raw({ type: \"application/json\" })` (or whatever\n * content-type your webhooks use) ahead of the receiver middleware.\n * The middleware reads `req.body` as a `Buffer | string` and converts\n * to a UTF-8 string for hashing. Skip when unsigned.\n */\nimport type { NextFunction, Request, RequestHandler, Response } from \"express\";\nimport { type CheckWebhookOptions, checkWebhook } from \"../check.js\";\nimport { type Finalizers, make_finalizers } from \"../finalize.js\";\n\n/** Shape attached to `req.idempotency` by the Express adapter. */\nexport type ExpressIdempotency = {\n key: string;\n deduped: boolean;\n} & Finalizers;\n\n/**\n * Build an Express middleware that verifies the request signature\n * (when `secret` is set), enforces `Idempotency-Key`, and claims the\n * key on the configured store. See the module-level docs for usage.\n */\nexport function webhookMiddleware(\n options: CheckWebhookOptions\n): RequestHandler {\n return async function check(\n req: Request,\n res: Response,\n next: NextFunction\n ): Promise<void> {\n const rawBody = buffer_or_string(req.body);\n const result = await checkWebhook(\n req.headers as Record<string, string | string[] | undefined>,\n rawBody,\n options\n );\n if (!result.ok) {\n res.status(result.status).json({ error: result.reason });\n return;\n }\n const { commit, release } = make_finalizers(\n options.store,\n result.key,\n result.deduped\n );\n (req as Request & { idempotency: ExpressIdempotency }).idempotency = {\n key: result.key,\n deduped: result.deduped,\n commit,\n release,\n };\n next();\n };\n}\n\nfunction buffer_or_string(body: unknown): string {\n if (typeof body === \"string\") return body;\n if (body instanceof Uint8Array) return Buffer.from(body).toString(\"utf8\");\n return \"\";\n}\n","/**\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 = pick_header(headers, \"x-webhook-signature\");\n if (!signature) return { ok: false, reason: \"missing-signature\" };\n\n const timestamp_str = pick_header(headers, \"x-webhook-timestamp\");\n if (!timestamp_str) return { ok: false, reason: \"missing-timestamp\" };\n const timestamp = Number.parseInt(timestamp_str, 10);\n if (Number.isNaN(timestamp) || String(timestamp) !== timestamp_str) {\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 provided_hex = signature.slice(\"sha256=\".length);\n if (!/^[0-9a-fA-F]{64}$/.test(provided_hex)) {\n return { ok: false, reason: \"bad-signature\" };\n }\n\n const expected_hex = createHmac(\"sha256\", secret)\n .update(`${timestamp_str}.${body}`)\n .digest(\"hex\");\n\n const a = Buffer.from(provided_hex, \"hex\");\n const b = Buffer.from(expected_hex, \"hex\");\n if (!timingSafeEqual(a, b)) {\n return { ok: false, reason: \"bad-signature\" };\n }\n\n return { ok: true };\n}\n\nfunction pick_header(\n headers: Record<string, string | string[] | undefined>,\n lower_name: string\n): string | undefined {\n for (const [name, value] of Object.entries(headers)) {\n if (name.toLowerCase() !== lower_name) 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","import type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n\n/**\n * The two-phase finalizers a receiver adapter binds to a single\n * inbound delivery. The claim `checkWebhook` makes is *tentative*;\n * exactly one of these promotes or drops it once the business handler\n * resolves its outcome.\n */\nexport type Finalizers = {\n /**\n * Promote the tentative claim to a durable record so every later\n * retry of this key dedups. Call on handler success.\n */\n commit: () => void | Promise<void>;\n /**\n * Drop the tentative claim so the sender's retry re-processes.\n * Call on transient handler failure.\n */\n release: () => void | Promise<void>;\n};\n\n/**\n * Build the {@link Finalizers} for one delivery.\n *\n * Guarantees:\n *\n * - **Deduped deliveries are inert.** When `deduped` is `true` the key\n * was already committed by an earlier delivery; both finalizers are\n * no-ops so a duplicate can never release someone else's committed\n * claim.\n * - **Finalize-once.** After the first `commit` or `release` fires,\n * further calls are no-ops — an adapter that both auto-finalizes and\n * lets the operator call `commit()`/`release()` can't double-fire.\n */\nexport function make_finalizers(\n store: IdempotencyStore,\n key: string,\n deduped: boolean\n): Finalizers {\n let settled = deduped;\n return {\n async commit() {\n if (settled) return;\n settled = true;\n await store.commit(key);\n },\n async release() {\n if (settled) return;\n settled = true;\n await store.release(key);\n },\n };\n}\n"],"mappings":"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","names":[]}
@@ -1,6 +1,7 @@
1
1
  import {
2
- checkWebhook
3
- } from "../../chunk-4CGAUB5H.js";
2
+ checkWebhook,
3
+ make_finalizers
4
+ } from "../../chunk-NXSPB2NW.js";
4
5
  import "../../chunk-PZ5AY32C.js";
5
6
 
6
7
  // src/receiver/express/index.ts
@@ -16,9 +17,16 @@ function webhookMiddleware(options) {
16
17
  res.status(result.status).json({ error: result.reason });
17
18
  return;
18
19
  }
20
+ const { commit, release } = make_finalizers(
21
+ options.store,
22
+ result.key,
23
+ result.deduped
24
+ );
19
25
  req.idempotency = {
20
26
  key: result.key,
21
- deduped: result.deduped
27
+ deduped: result.deduped,
28
+ commit,
29
+ release
22
30
  };
23
31
  next();
24
32
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/receiver/express/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/receiver/express\n *\n * Express adapter for the receiver-side webhook check.\n *\n * Usage:\n *\n * ```ts\n * import express from \"express\";\n * import { webhookMiddleware } from \"@rotorsoft/act-http/receiver/express\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n *\n * const app = express();\n * const dedup = new InMemoryIdempotencyStore();\n *\n * // Raw body capture required when signing is enabled.\n * app.use(express.raw({ type: \"application/json\" }));\n *\n * app.post(\n * \"/webhooks/orders\",\n * webhookMiddleware({ store: dedup, secret: process.env.WEBHOOK_SECRET }),\n * (req, res) => {\n * const { key, deduped } = (req as any).idempotency;\n * if (deduped) return res.json({ status: \"dedup-skipped\", key });\n * // ... process the inbound event ...\n * res.json({ status: \"processed\", key });\n * }\n * );\n * ```\n *\n * On failure: responds with the framework-idiomatic JSON shape\n * `{ error: <reason> }` at status 400 (missing-key) or 401\n * (verification failures), and does not call `next()`. On success:\n * attaches `req.idempotency = { key, deduped }` and calls `next()`.\n *\n * **Raw body requirement**: when `secret` is configured, mount\n * `express.raw({ type: \"application/json\" })` (or whatever\n * content-type your webhooks use) ahead of the receiver middleware.\n * The middleware reads `req.body` as a `Buffer | string` and converts\n * to a UTF-8 string for hashing. Skip when unsigned.\n */\nimport type { NextFunction, Request, RequestHandler, Response } from \"express\";\nimport { type CheckWebhookOptions, checkWebhook } from \"../check.js\";\n\n/**\n * Build an Express middleware that verifies the request signature\n * (when `secret` is set), enforces `Idempotency-Key`, and claims the\n * key on the configured store. See the module-level docs for usage.\n */\nexport function webhookMiddleware(\n options: CheckWebhookOptions\n): RequestHandler {\n return async function check(\n req: Request,\n res: Response,\n next: NextFunction\n ): Promise<void> {\n const rawBody = buffer_or_string(req.body);\n const result = await checkWebhook(\n req.headers as Record<string, string | string[] | undefined>,\n rawBody,\n options\n );\n if (!result.ok) {\n res.status(result.status).json({ error: result.reason });\n return;\n }\n (\n req as Request & { idempotency: { key: string; deduped: boolean } }\n ).idempotency = {\n key: result.key,\n deduped: result.deduped,\n };\n next();\n };\n}\n\nfunction buffer_or_string(body: unknown): string {\n if (typeof body === \"string\") return body;\n if (body instanceof Uint8Array) return Buffer.from(body).toString(\"utf8\");\n return \"\";\n}\n"],"mappings":";;;;;;AAkDO,SAAS,kBACd,SACgB;AAChB,SAAO,eAAe,MACpB,KACA,KACA,MACe;AACf,UAAM,UAAU,iBAAiB,IAAI,IAAI;AACzC,UAAM,SAAS,MAAM;AAAA,MACnB,IAAI;AAAA,MACJ;AAAA,MACA;AAAA,IACF;AACA,QAAI,CAAC,OAAO,IAAI;AACd,UAAI,OAAO,OAAO,MAAM,EAAE,KAAK,EAAE,OAAO,OAAO,OAAO,CAAC;AACvD;AAAA,IACF;AACA,IACE,IACA,cAAc;AAAA,MACd,KAAK,OAAO;AAAA,MACZ,SAAS,OAAO;AAAA,IAClB;AACA,SAAK;AAAA,EACP;AACF;AAEA,SAAS,iBAAiB,MAAuB;AAC/C,MAAI,OAAO,SAAS,SAAU,QAAO;AACrC,MAAI,gBAAgB,WAAY,QAAO,OAAO,KAAK,IAAI,EAAE,SAAS,MAAM;AACxE,SAAO;AACT;","names":[]}
1
+ {"version":3,"sources":["../../../src/receiver/express/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/receiver/express\n *\n * Express adapter for the receiver-side webhook check.\n *\n * Usage:\n *\n * ```ts\n * import express from \"express\";\n * import { webhookMiddleware } from \"@rotorsoft/act-http/receiver/express\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n *\n * const app = express();\n * const dedup = new InMemoryIdempotencyStore();\n *\n * // Raw body capture required when signing is enabled.\n * app.use(express.raw({ type: \"application/json\" }));\n *\n * app.post(\n * \"/webhooks/orders\",\n * webhookMiddleware({ store: dedup, secret: process.env.WEBHOOK_SECRET }),\n * (req, res) => {\n * const { key, deduped } = (req as any).idempotency;\n * if (deduped) return res.json({ status: \"dedup-skipped\", key });\n * // ... process the inbound event ...\n * res.json({ status: \"processed\", key });\n * }\n * );\n * ```\n *\n * On failure: responds with the framework-idiomatic JSON shape\n * `{ error: <reason> }` at status 400 (missing-key) or 401\n * (verification failures), and does not call `next()`. On success:\n * attaches `req.idempotency = { key, deduped, commit, release }` and\n * calls `next()`.\n *\n * **Two-phase dedup**: the claim is *tentative*. Express runs the\n * middleware to completion before the route handler, so — unlike the\n * Hono adapter — it can't observe the handler's outcome to finalize\n * automatically. The route handler **must** call\n * `req.idempotency.commit()` on success or `req.idempotency.release()`\n * on a transient failure. Skipping both leaves the claim tentative:\n * it dedups concurrent duplicates but expires on TTL, so the delivery\n * is never permanently lost — it just isn't durably deduped either.\n *\n * **Raw body requirement**: when `secret` is configured, mount\n * `express.raw({ type: \"application/json\" })` (or whatever\n * content-type your webhooks use) ahead of the receiver middleware.\n * The middleware reads `req.body` as a `Buffer | string` and converts\n * to a UTF-8 string for hashing. Skip when unsigned.\n */\nimport type { NextFunction, Request, RequestHandler, Response } from \"express\";\nimport { type CheckWebhookOptions, checkWebhook } from \"../check.js\";\nimport { type Finalizers, make_finalizers } from \"../finalize.js\";\n\n/** Shape attached to `req.idempotency` by the Express adapter. */\nexport type ExpressIdempotency = {\n key: string;\n deduped: boolean;\n} & Finalizers;\n\n/**\n * Build an Express middleware that verifies the request signature\n * (when `secret` is set), enforces `Idempotency-Key`, and claims the\n * key on the configured store. See the module-level docs for usage.\n */\nexport function webhookMiddleware(\n options: CheckWebhookOptions\n): RequestHandler {\n return async function check(\n req: Request,\n res: Response,\n next: NextFunction\n ): Promise<void> {\n const rawBody = buffer_or_string(req.body);\n const result = await checkWebhook(\n req.headers as Record<string, string | string[] | undefined>,\n rawBody,\n options\n );\n if (!result.ok) {\n res.status(result.status).json({ error: result.reason });\n return;\n }\n const { commit, release } = make_finalizers(\n options.store,\n result.key,\n result.deduped\n );\n (req as Request & { idempotency: ExpressIdempotency }).idempotency = {\n key: result.key,\n deduped: result.deduped,\n commit,\n release,\n };\n next();\n };\n}\n\nfunction buffer_or_string(body: unknown): string {\n if (typeof body === \"string\") return body;\n if (body instanceof Uint8Array) return Buffer.from(body).toString(\"utf8\");\n return \"\";\n}\n"],"mappings":";;;;;;;AAmEO,SAAS,kBACd,SACgB;AAChB,SAAO,eAAe,MACpB,KACA,KACA,MACe;AACf,UAAM,UAAU,iBAAiB,IAAI,IAAI;AACzC,UAAM,SAAS,MAAM;AAAA,MACnB,IAAI;AAAA,MACJ;AAAA,MACA;AAAA,IACF;AACA,QAAI,CAAC,OAAO,IAAI;AACd,UAAI,OAAO,OAAO,MAAM,EAAE,KAAK,EAAE,OAAO,OAAO,OAAO,CAAC;AACvD;AAAA,IACF;AACA,UAAM,EAAE,QAAQ,QAAQ,IAAI;AAAA,MAC1B,QAAQ;AAAA,MACR,OAAO;AAAA,MACP,OAAO;AAAA,IACT;AACA,IAAC,IAAsD,cAAc;AAAA,MACnE,KAAK,OAAO;AAAA,MACZ,SAAS,OAAO;AAAA,MAChB;AAAA,MACA;AAAA,IACF;AACA,SAAK;AAAA,EACP;AACF;AAEA,SAAS,iBAAiB,MAAuB;AAC/C,MAAI,OAAO,SAAS,SAAU,QAAO;AACrC,MAAI,gBAAgB,WAAY,QAAO,OAAO,KAAK,IAAI,EAAE,SAAS,MAAM;AACxE,SAAO;AACT;","names":[]}
@@ -96,6 +96,23 @@ async function checkWebhook(headers, body, options) {
96
96
  return { ok: true, key, deduped: !claimed };
97
97
  }
98
98
 
99
+ // src/receiver/finalize.ts
100
+ function make_finalizers(store, key, deduped) {
101
+ let settled = deduped;
102
+ return {
103
+ async commit() {
104
+ if (settled) return;
105
+ settled = true;
106
+ await store.commit(key);
107
+ },
108
+ async release() {
109
+ if (settled) return;
110
+ settled = true;
111
+ await store.release(key);
112
+ }
113
+ };
114
+ }
115
+
99
116
  // src/receiver/fastify/index.ts
100
117
  function webhookMiddleware(options) {
101
118
  return async function check(request, reply) {
@@ -110,7 +127,17 @@ function webhookMiddleware(options) {
110
127
  await reply.status(result.status).send({ error: result.reason });
111
128
  return;
112
129
  }
113
- req.idempotency = { key: result.key, deduped: result.deduped };
130
+ const { commit, release } = make_finalizers(
131
+ options.store,
132
+ result.key,
133
+ result.deduped
134
+ );
135
+ req.idempotency = {
136
+ key: result.key,
137
+ deduped: result.deduped,
138
+ commit,
139
+ release
140
+ };
114
141
  };
115
142
  }
116
143
  // Annotate the CommonJS export names for ESM import in node:
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/receiver/fastify/index.ts","../../../src/receiver/extract.ts","../../../src/receiver/verify.ts","../../../src/receiver/check.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/receiver/fastify\n *\n * Fastify adapter for the receiver-side webhook check.\n *\n * Usage:\n *\n * ```ts\n * import Fastify from \"fastify\";\n * import { webhookMiddleware } from \"@rotorsoft/act-http/receiver/fastify\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n *\n * const app = Fastify();\n * const dedup = new InMemoryIdempotencyStore();\n *\n * app.post(\n * \"/webhooks/orders\",\n * {\n * preHandler: webhookMiddleware({\n * store: dedup,\n * secret: process.env.WEBHOOK_SECRET,\n * }),\n * },\n * async (request, reply) => {\n * const { key, deduped } = (request as any).idempotency;\n * if (deduped) return { status: \"dedup-skipped\", key };\n * // ... process the inbound event ...\n * return { status: \"processed\", key };\n * }\n * );\n * ```\n *\n * On failure: replies with `{ error: <reason> }` at status 400\n * (missing-key) or 401 (verification failures). On success: attaches\n * `request.idempotency = { key, deduped }` and lets the route handler\n * run.\n *\n * **Raw body requirement**: when `secret` is configured, register a\n * content-type parser that preserves the raw body string. Fastify's\n * default JSON parser eats the bytes — register a custom parser via\n * `app.addContentTypeParser(\"application/json\", { parseAs: \"string\" }, …)`\n * and stash the string on `request.rawBody` (Fastify pattern). The\n * middleware reads `request.rawBody` for hashing. Skip when unsigned.\n */\nimport type { FastifyReply, FastifyRequest } from \"fastify\";\nimport { type CheckWebhookOptions, checkWebhook } from \"../check.js\";\n\ntype WebhookRequest = FastifyRequest & {\n rawBody?: string;\n idempotency?: { key: string; deduped: boolean };\n};\n\n/**\n * Build a Fastify `preHandler` hook that verifies the request\n * signature (when `secret` is set), enforces `Idempotency-Key`, and\n * claims the key on the configured store. See the module-level docs\n * for usage.\n */\nexport function webhookMiddleware(\n options: CheckWebhookOptions\n): (request: FastifyRequest, reply: FastifyReply) => Promise<void> {\n return async function check(\n request: FastifyRequest,\n reply: FastifyReply\n ): Promise<void> {\n const req = request as WebhookRequest;\n const rawBody = req.rawBody ?? \"\";\n const result = await checkWebhook(\n req.headers as Record<string, string | string[] | undefined>,\n rawBody,\n options\n );\n if (!result.ok) {\n await reply.status(result.status).send({ error: result.reason });\n return;\n }\n req.idempotency = { key: result.key, deduped: result.deduped };\n };\n}\n","/**\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 = pick_header(headers, \"x-webhook-signature\");\n if (!signature) return { ok: false, reason: \"missing-signature\" };\n\n const timestamp_str = pick_header(headers, \"x-webhook-timestamp\");\n if (!timestamp_str) return { ok: false, reason: \"missing-timestamp\" };\n const timestamp = Number.parseInt(timestamp_str, 10);\n if (Number.isNaN(timestamp) || String(timestamp) !== timestamp_str) {\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 provided_hex = signature.slice(\"sha256=\".length);\n if (!/^[0-9a-fA-F]{64}$/.test(provided_hex)) {\n return { ok: false, reason: \"bad-signature\" };\n }\n\n const expected_hex = createHmac(\"sha256\", secret)\n .update(`${timestamp_str}.${body}`)\n .digest(\"hex\");\n\n const a = Buffer.from(provided_hex, \"hex\");\n const b = Buffer.from(expected_hex, \"hex\");\n if (!timingSafeEqual(a, b)) {\n return { ok: false, reason: \"bad-signature\" };\n }\n\n return { ok: true };\n}\n\nfunction pick_header(\n headers: Record<string, string | string[] | undefined>,\n lower_name: string\n): string | undefined {\n for (const [name, value] of Object.entries(headers)) {\n if (name.toLowerCase() !== lower_name) 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":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACsBO,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,yBAA4C;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,YAAY,SAAS,qBAAqB;AAC5D,MAAI,CAAC,UAAW,QAAO,EAAE,IAAI,OAAO,QAAQ,oBAAoB;AAEhE,QAAM,gBAAgB,YAAY,SAAS,qBAAqB;AAChE,MAAI,CAAC,cAAe,QAAO,EAAE,IAAI,OAAO,QAAQ,oBAAoB;AACpE,QAAM,YAAY,OAAO,SAAS,eAAe,EAAE;AACnD,MAAI,OAAO,MAAM,SAAS,KAAK,OAAO,SAAS,MAAM,eAAe;AAClE,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,eAAe,UAAU,MAAM,UAAU,MAAM;AACrD,MAAI,CAAC,oBAAoB,KAAK,YAAY,GAAG;AAC3C,WAAO,EAAE,IAAI,OAAO,QAAQ,gBAAgB;AAAA,EAC9C;AAEA,QAAM,mBAAe,+BAAW,UAAU,MAAM,EAC7C,OAAO,GAAG,aAAa,IAAI,IAAI,EAAE,EACjC,OAAO,KAAK;AAEf,QAAM,IAAI,OAAO,KAAK,cAAc,KAAK;AACzC,QAAM,IAAI,OAAO,KAAK,cAAc,KAAK;AACzC,MAAI,KAAC,oCAAgB,GAAG,CAAC,GAAG;AAC1B,WAAO,EAAE,IAAI,OAAO,QAAQ,gBAAgB;AAAA,EAC9C;AAEA,SAAO,EAAE,IAAI,KAAK;AACpB;AAEA,SAAS,YACP,SACA,YACoB;AACpB,aAAW,CAAC,MAAM,KAAK,KAAK,OAAO,QAAQ,OAAO,GAAG;AACnD,QAAI,KAAK,YAAY,MAAM,WAAY;AACvC,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;;;AHhCO,SAAS,kBACd,SACiE;AACjE,SAAO,eAAe,MACpB,SACA,OACe;AACf,UAAM,MAAM;AACZ,UAAM,UAAU,IAAI,WAAW;AAC/B,UAAM,SAAS,MAAM;AAAA,MACnB,IAAI;AAAA,MACJ;AAAA,MACA;AAAA,IACF;AACA,QAAI,CAAC,OAAO,IAAI;AACd,YAAM,MAAM,OAAO,OAAO,MAAM,EAAE,KAAK,EAAE,OAAO,OAAO,OAAO,CAAC;AAC/D;AAAA,IACF;AACA,QAAI,cAAc,EAAE,KAAK,OAAO,KAAK,SAAS,OAAO,QAAQ;AAAA,EAC/D;AACF;","names":[]}
1
+ {"version":3,"sources":["../../../src/receiver/fastify/index.ts","../../../src/receiver/extract.ts","../../../src/receiver/verify.ts","../../../src/receiver/check.ts","../../../src/receiver/finalize.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/receiver/fastify\n *\n * Fastify adapter for the receiver-side webhook check.\n *\n * Usage:\n *\n * ```ts\n * import Fastify from \"fastify\";\n * import { webhookMiddleware } from \"@rotorsoft/act-http/receiver/fastify\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n *\n * const app = Fastify();\n * const dedup = new InMemoryIdempotencyStore();\n *\n * app.post(\n * \"/webhooks/orders\",\n * {\n * preHandler: webhookMiddleware({\n * store: dedup,\n * secret: process.env.WEBHOOK_SECRET,\n * }),\n * },\n * async (request, reply) => {\n * const { key, deduped } = (request as any).idempotency;\n * if (deduped) return { status: \"dedup-skipped\", key };\n * // ... process the inbound event ...\n * return { status: \"processed\", key };\n * }\n * );\n * ```\n *\n * On failure: replies with `{ error: <reason> }` at status 400\n * (missing-key) or 401 (verification failures). On success: attaches\n * `request.idempotency = { key, deduped, commit, release }` and lets\n * the route handler run.\n *\n * **Two-phase dedup**: the claim is *tentative*. A Fastify\n * `preHandler` runs to completion before the route handler, so — like\n * the Express adapter — it can't finalize automatically. The route\n * handler **must** call `request.idempotency.commit()` on success or\n * `request.idempotency.release()` on a transient failure. Skipping\n * both leaves the claim tentative: it dedups concurrent duplicates but\n * expires on TTL, so a delivery is never permanently lost — it just\n * isn't durably deduped either.\n *\n * **Raw body requirement**: when `secret` is configured, register a\n * content-type parser that preserves the raw body string. Fastify's\n * default JSON parser eats the bytes — register a custom parser via\n * `app.addContentTypeParser(\"application/json\", { parseAs: \"string\" }, …)`\n * and stash the string on `request.rawBody` (Fastify pattern). The\n * middleware reads `request.rawBody` for hashing. Skip when unsigned.\n */\nimport type { FastifyReply, FastifyRequest } from \"fastify\";\nimport { type CheckWebhookOptions, checkWebhook } from \"../check.js\";\nimport { type Finalizers, make_finalizers } from \"../finalize.js\";\n\n/** Shape attached to `request.idempotency` by the Fastify adapter. */\nexport type FastifyIdempotency = {\n key: string;\n deduped: boolean;\n} & Finalizers;\n\ntype WebhookRequest = FastifyRequest & {\n rawBody?: string;\n idempotency?: FastifyIdempotency;\n};\n\n/**\n * Build a Fastify `preHandler` hook that verifies the request\n * signature (when `secret` is set), enforces `Idempotency-Key`, and\n * claims the key on the configured store. See the module-level docs\n * for usage.\n */\nexport function webhookMiddleware(\n options: CheckWebhookOptions\n): (request: FastifyRequest, reply: FastifyReply) => Promise<void> {\n return async function check(\n request: FastifyRequest,\n reply: FastifyReply\n ): Promise<void> {\n const req = request as WebhookRequest;\n const rawBody = req.rawBody ?? \"\";\n const result = await checkWebhook(\n req.headers as Record<string, string | string[] | undefined>,\n rawBody,\n options\n );\n if (!result.ok) {\n await reply.status(result.status).send({ error: result.reason });\n return;\n }\n const { commit, release } = make_finalizers(\n options.store,\n result.key,\n result.deduped\n );\n req.idempotency = {\n key: result.key,\n deduped: result.deduped,\n commit,\n release,\n };\n };\n}\n","/**\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 = pick_header(headers, \"x-webhook-signature\");\n if (!signature) return { ok: false, reason: \"missing-signature\" };\n\n const timestamp_str = pick_header(headers, \"x-webhook-timestamp\");\n if (!timestamp_str) return { ok: false, reason: \"missing-timestamp\" };\n const timestamp = Number.parseInt(timestamp_str, 10);\n if (Number.isNaN(timestamp) || String(timestamp) !== timestamp_str) {\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 provided_hex = signature.slice(\"sha256=\".length);\n if (!/^[0-9a-fA-F]{64}$/.test(provided_hex)) {\n return { ok: false, reason: \"bad-signature\" };\n }\n\n const expected_hex = createHmac(\"sha256\", secret)\n .update(`${timestamp_str}.${body}`)\n .digest(\"hex\");\n\n const a = Buffer.from(provided_hex, \"hex\");\n const b = Buffer.from(expected_hex, \"hex\");\n if (!timingSafeEqual(a, b)) {\n return { ok: false, reason: \"bad-signature\" };\n }\n\n return { ok: true };\n}\n\nfunction pick_header(\n headers: Record<string, string | string[] | undefined>,\n lower_name: string\n): string | undefined {\n for (const [name, value] of Object.entries(headers)) {\n if (name.toLowerCase() !== lower_name) 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","import type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n\n/**\n * The two-phase finalizers a receiver adapter binds to a single\n * inbound delivery. The claim `checkWebhook` makes is *tentative*;\n * exactly one of these promotes or drops it once the business handler\n * resolves its outcome.\n */\nexport type Finalizers = {\n /**\n * Promote the tentative claim to a durable record so every later\n * retry of this key dedups. Call on handler success.\n */\n commit: () => void | Promise<void>;\n /**\n * Drop the tentative claim so the sender's retry re-processes.\n * Call on transient handler failure.\n */\n release: () => void | Promise<void>;\n};\n\n/**\n * Build the {@link Finalizers} for one delivery.\n *\n * Guarantees:\n *\n * - **Deduped deliveries are inert.** When `deduped` is `true` the key\n * was already committed by an earlier delivery; both finalizers are\n * no-ops so a duplicate can never release someone else's committed\n * claim.\n * - **Finalize-once.** After the first `commit` or `release` fires,\n * further calls are no-ops — an adapter that both auto-finalizes and\n * lets the operator call `commit()`/`release()` can't double-fire.\n */\nexport function make_finalizers(\n store: IdempotencyStore,\n key: string,\n deduped: boolean\n): Finalizers {\n let settled = deduped;\n return {\n async commit() {\n if (settled) return;\n settled = true;\n await store.commit(key);\n },\n async release() {\n if (settled) return;\n settled = true;\n await store.release(key);\n },\n };\n}\n"],"mappings":"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","names":[]}
@@ -1,6 +1,7 @@
1
1
  import {
2
- checkWebhook
3
- } from "../../chunk-4CGAUB5H.js";
2
+ checkWebhook,
3
+ make_finalizers
4
+ } from "../../chunk-NXSPB2NW.js";
4
5
  import "../../chunk-PZ5AY32C.js";
5
6
 
6
7
  // src/receiver/fastify/index.ts
@@ -17,7 +18,17 @@ function webhookMiddleware(options) {
17
18
  await reply.status(result.status).send({ error: result.reason });
18
19
  return;
19
20
  }
20
- req.idempotency = { key: result.key, deduped: result.deduped };
21
+ const { commit, release } = make_finalizers(
22
+ options.store,
23
+ result.key,
24
+ result.deduped
25
+ );
26
+ req.idempotency = {
27
+ key: result.key,
28
+ deduped: result.deduped,
29
+ commit,
30
+ release
31
+ };
21
32
  };
22
33
  }
23
34
  export {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/receiver/fastify/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/receiver/fastify\n *\n * Fastify adapter for the receiver-side webhook check.\n *\n * Usage:\n *\n * ```ts\n * import Fastify from \"fastify\";\n * import { webhookMiddleware } from \"@rotorsoft/act-http/receiver/fastify\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n *\n * const app = Fastify();\n * const dedup = new InMemoryIdempotencyStore();\n *\n * app.post(\n * \"/webhooks/orders\",\n * {\n * preHandler: webhookMiddleware({\n * store: dedup,\n * secret: process.env.WEBHOOK_SECRET,\n * }),\n * },\n * async (request, reply) => {\n * const { key, deduped } = (request as any).idempotency;\n * if (deduped) return { status: \"dedup-skipped\", key };\n * // ... process the inbound event ...\n * return { status: \"processed\", key };\n * }\n * );\n * ```\n *\n * On failure: replies with `{ error: <reason> }` at status 400\n * (missing-key) or 401 (verification failures). On success: attaches\n * `request.idempotency = { key, deduped }` and lets the route handler\n * run.\n *\n * **Raw body requirement**: when `secret` is configured, register a\n * content-type parser that preserves the raw body string. Fastify's\n * default JSON parser eats the bytes — register a custom parser via\n * `app.addContentTypeParser(\"application/json\", { parseAs: \"string\" }, …)`\n * and stash the string on `request.rawBody` (Fastify pattern). The\n * middleware reads `request.rawBody` for hashing. Skip when unsigned.\n */\nimport type { FastifyReply, FastifyRequest } from \"fastify\";\nimport { type CheckWebhookOptions, checkWebhook } from \"../check.js\";\n\ntype WebhookRequest = FastifyRequest & {\n rawBody?: string;\n idempotency?: { key: string; deduped: boolean };\n};\n\n/**\n * Build a Fastify `preHandler` hook that verifies the request\n * signature (when `secret` is set), enforces `Idempotency-Key`, and\n * claims the key on the configured store. See the module-level docs\n * for usage.\n */\nexport function webhookMiddleware(\n options: CheckWebhookOptions\n): (request: FastifyRequest, reply: FastifyReply) => Promise<void> {\n return async function check(\n request: FastifyRequest,\n reply: FastifyReply\n ): Promise<void> {\n const req = request as WebhookRequest;\n const rawBody = req.rawBody ?? \"\";\n const result = await checkWebhook(\n req.headers as Record<string, string | string[] | undefined>,\n rawBody,\n options\n );\n if (!result.ok) {\n await reply.status(result.status).send({ error: result.reason });\n return;\n }\n req.idempotency = { key: result.key, deduped: result.deduped };\n };\n}\n"],"mappings":";;;;;;AA2DO,SAAS,kBACd,SACiE;AACjE,SAAO,eAAe,MACpB,SACA,OACe;AACf,UAAM,MAAM;AACZ,UAAM,UAAU,IAAI,WAAW;AAC/B,UAAM,SAAS,MAAM;AAAA,MACnB,IAAI;AAAA,MACJ;AAAA,MACA;AAAA,IACF;AACA,QAAI,CAAC,OAAO,IAAI;AACd,YAAM,MAAM,OAAO,OAAO,MAAM,EAAE,KAAK,EAAE,OAAO,OAAO,OAAO,CAAC;AAC/D;AAAA,IACF;AACA,QAAI,cAAc,EAAE,KAAK,OAAO,KAAK,SAAS,OAAO,QAAQ;AAAA,EAC/D;AACF;","names":[]}
1
+ {"version":3,"sources":["../../../src/receiver/fastify/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/receiver/fastify\n *\n * Fastify adapter for the receiver-side webhook check.\n *\n * Usage:\n *\n * ```ts\n * import Fastify from \"fastify\";\n * import { webhookMiddleware } from \"@rotorsoft/act-http/receiver/fastify\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n *\n * const app = Fastify();\n * const dedup = new InMemoryIdempotencyStore();\n *\n * app.post(\n * \"/webhooks/orders\",\n * {\n * preHandler: webhookMiddleware({\n * store: dedup,\n * secret: process.env.WEBHOOK_SECRET,\n * }),\n * },\n * async (request, reply) => {\n * const { key, deduped } = (request as any).idempotency;\n * if (deduped) return { status: \"dedup-skipped\", key };\n * // ... process the inbound event ...\n * return { status: \"processed\", key };\n * }\n * );\n * ```\n *\n * On failure: replies with `{ error: <reason> }` at status 400\n * (missing-key) or 401 (verification failures). On success: attaches\n * `request.idempotency = { key, deduped, commit, release }` and lets\n * the route handler run.\n *\n * **Two-phase dedup**: the claim is *tentative*. A Fastify\n * `preHandler` runs to completion before the route handler, so — like\n * the Express adapter — it can't finalize automatically. The route\n * handler **must** call `request.idempotency.commit()` on success or\n * `request.idempotency.release()` on a transient failure. Skipping\n * both leaves the claim tentative: it dedups concurrent duplicates but\n * expires on TTL, so a delivery is never permanently lost — it just\n * isn't durably deduped either.\n *\n * **Raw body requirement**: when `secret` is configured, register a\n * content-type parser that preserves the raw body string. Fastify's\n * default JSON parser eats the bytes — register a custom parser via\n * `app.addContentTypeParser(\"application/json\", { parseAs: \"string\" }, …)`\n * and stash the string on `request.rawBody` (Fastify pattern). The\n * middleware reads `request.rawBody` for hashing. Skip when unsigned.\n */\nimport type { FastifyReply, FastifyRequest } from \"fastify\";\nimport { type CheckWebhookOptions, checkWebhook } from \"../check.js\";\nimport { type Finalizers, make_finalizers } from \"../finalize.js\";\n\n/** Shape attached to `request.idempotency` by the Fastify adapter. */\nexport type FastifyIdempotency = {\n key: string;\n deduped: boolean;\n} & Finalizers;\n\ntype WebhookRequest = FastifyRequest & {\n rawBody?: string;\n idempotency?: FastifyIdempotency;\n};\n\n/**\n * Build a Fastify `preHandler` hook that verifies the request\n * signature (when `secret` is set), enforces `Idempotency-Key`, and\n * claims the key on the configured store. See the module-level docs\n * for usage.\n */\nexport function webhookMiddleware(\n options: CheckWebhookOptions\n): (request: FastifyRequest, reply: FastifyReply) => Promise<void> {\n return async function check(\n request: FastifyRequest,\n reply: FastifyReply\n ): Promise<void> {\n const req = request as WebhookRequest;\n const rawBody = req.rawBody ?? \"\";\n const result = await checkWebhook(\n req.headers as Record<string, string | string[] | undefined>,\n rawBody,\n options\n );\n if (!result.ok) {\n await reply.status(result.status).send({ error: result.reason });\n return;\n }\n const { commit, release } = make_finalizers(\n options.store,\n result.key,\n result.deduped\n );\n req.idempotency = {\n key: result.key,\n deduped: result.deduped,\n commit,\n release,\n };\n };\n}\n"],"mappings":";;;;;;;AA2EO,SAAS,kBACd,SACiE;AACjE,SAAO,eAAe,MACpB,SACA,OACe;AACf,UAAM,MAAM;AACZ,UAAM,UAAU,IAAI,WAAW;AAC/B,UAAM,SAAS,MAAM;AAAA,MACnB,IAAI;AAAA,MACJ;AAAA,MACA;AAAA,IACF;AACA,QAAI,CAAC,OAAO,IAAI;AACd,YAAM,MAAM,OAAO,OAAO,MAAM,EAAE,KAAK,EAAE,OAAO,OAAO,OAAO,CAAC;AAC/D;AAAA,IACF;AACA,UAAM,EAAE,QAAQ,QAAQ,IAAI;AAAA,MAC1B,QAAQ;AAAA,MACR,OAAO;AAAA,MACP,OAAO;AAAA,IACT;AACA,QAAI,cAAc;AAAA,MAChB,KAAK,OAAO;AAAA,MACZ,SAAS,OAAO;AAAA,MAChB;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACF;","names":[]}
@@ -96,6 +96,23 @@ async function checkWebhook(headers, body, options) {
96
96
  return { ok: true, key, deduped: !claimed };
97
97
  }
98
98
 
99
+ // src/receiver/finalize.ts
100
+ function make_finalizers(store, key, deduped) {
101
+ let settled = deduped;
102
+ return {
103
+ async commit() {
104
+ if (settled) return;
105
+ settled = true;
106
+ await store.commit(key);
107
+ },
108
+ async release() {
109
+ if (settled) return;
110
+ settled = true;
111
+ await store.release(key);
112
+ }
113
+ };
114
+ }
115
+
99
116
  // src/receiver/hono/index.ts
100
117
  function webhookMiddleware(options) {
101
118
  return async function check(c, next) {
@@ -105,8 +122,25 @@ function webhookMiddleware(options) {
105
122
  if (!result.ok) {
106
123
  return c.json({ error: result.reason }, result.status);
107
124
  }
108
- c.set("idempotency", { key: result.key, deduped: result.deduped });
109
- await next();
125
+ const { commit, release } = make_finalizers(
126
+ options.store,
127
+ result.key,
128
+ result.deduped
129
+ );
130
+ c.set("idempotency", {
131
+ key: result.key,
132
+ deduped: result.deduped,
133
+ commit,
134
+ release
135
+ });
136
+ try {
137
+ await next();
138
+ } catch (err) {
139
+ await release();
140
+ throw err;
141
+ }
142
+ if (c.res.status >= 500) await release();
143
+ else await commit();
110
144
  };
111
145
  }
112
146
  function headers_bag(headers) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/receiver/hono/index.ts","../../../src/receiver/extract.ts","../../../src/receiver/verify.ts","../../../src/receiver/check.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 = headers_bag(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 headers_bag(\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","/**\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 = pick_header(headers, \"x-webhook-signature\");\n if (!signature) return { ok: false, reason: \"missing-signature\" };\n\n const timestamp_str = pick_header(headers, \"x-webhook-timestamp\");\n if (!timestamp_str) return { ok: false, reason: \"missing-timestamp\" };\n const timestamp = Number.parseInt(timestamp_str, 10);\n if (Number.isNaN(timestamp) || String(timestamp) !== timestamp_str) {\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 provided_hex = signature.slice(\"sha256=\".length);\n if (!/^[0-9a-fA-F]{64}$/.test(provided_hex)) {\n return { ok: false, reason: \"bad-signature\" };\n }\n\n const expected_hex = createHmac(\"sha256\", secret)\n .update(`${timestamp_str}.${body}`)\n .digest(\"hex\");\n\n const a = Buffer.from(provided_hex, \"hex\");\n const b = Buffer.from(expected_hex, \"hex\");\n if (!timingSafeEqual(a, b)) {\n return { ok: false, reason: \"bad-signature\" };\n }\n\n return { ok: true };\n}\n\nfunction pick_header(\n headers: Record<string, string | string[] | undefined>,\n lower_name: string\n): string | undefined {\n for (const [name, value] of Object.entries(headers)) {\n if (name.toLowerCase() !== lower_name) 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":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/receiver/hono/index.ts","../../../src/receiver/extract.ts","../../../src/receiver/verify.ts","../../../src/receiver/check.ts","../../../src/receiver/finalize.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 * **Two-phase dedup**: the claim `checkWebhook` makes is *tentative*.\n * Because Hono middleware wraps the downstream chain, this adapter\n * finalizes the claim automatically after `await next()`: a downstream\n * 2xx **commits** the key (later retries dedup); a 5xx or a thrown\n * handler **releases** it (the sender's retry re-processes instead of\n * being silently dropped). Operators who need explicit control can\n * still call `c.get(\"idempotency\").commit()` / `.release()`.\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\";\nimport { make_finalizers } from \"../finalize.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 *\n * `commit` / `release` finalize the tentative claim — call one after\n * the handler resolves its outcome. This adapter also finalizes\n * automatically based on the response status, so explicit calls are\n * only needed when the auto-detection doesn't fit.\n */\nexport type WebhookVariables = {\n idempotency: {\n key: string;\n deduped: boolean;\n commit: () => void | Promise<void>;\n release: () => void | Promise<void>;\n };\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 = headers_bag(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 const { commit, release } = make_finalizers(\n options.store,\n result.key,\n result.deduped\n );\n c.set(\"idempotency\", {\n key: result.key,\n deduped: result.deduped,\n commit,\n release,\n });\n try {\n await next();\n } catch (err) {\n await release();\n throw err;\n }\n if (c.res.status >= 500) await release();\n else await commit();\n };\n}\n\nfunction headers_bag(\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","/**\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 = pick_header(headers, \"x-webhook-signature\");\n if (!signature) return { ok: false, reason: \"missing-signature\" };\n\n const timestamp_str = pick_header(headers, \"x-webhook-timestamp\");\n if (!timestamp_str) return { ok: false, reason: \"missing-timestamp\" };\n const timestamp = Number.parseInt(timestamp_str, 10);\n if (Number.isNaN(timestamp) || String(timestamp) !== timestamp_str) {\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 provided_hex = signature.slice(\"sha256=\".length);\n if (!/^[0-9a-fA-F]{64}$/.test(provided_hex)) {\n return { ok: false, reason: \"bad-signature\" };\n }\n\n const expected_hex = createHmac(\"sha256\", secret)\n .update(`${timestamp_str}.${body}`)\n .digest(\"hex\");\n\n const a = Buffer.from(provided_hex, \"hex\");\n const b = Buffer.from(expected_hex, \"hex\");\n if (!timingSafeEqual(a, b)) {\n return { ok: false, reason: \"bad-signature\" };\n }\n\n return { ok: true };\n}\n\nfunction pick_header(\n headers: Record<string, string | string[] | undefined>,\n lower_name: string\n): string | undefined {\n for (const [name, value] of Object.entries(headers)) {\n if (name.toLowerCase() !== lower_name) 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","import type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n\n/**\n * The two-phase finalizers a receiver adapter binds to a single\n * inbound delivery. The claim `checkWebhook` makes is *tentative*;\n * exactly one of these promotes or drops it once the business handler\n * resolves its outcome.\n */\nexport type Finalizers = {\n /**\n * Promote the tentative claim to a durable record so every later\n * retry of this key dedups. Call on handler success.\n */\n commit: () => void | Promise<void>;\n /**\n * Drop the tentative claim so the sender's retry re-processes.\n * Call on transient handler failure.\n */\n release: () => void | Promise<void>;\n};\n\n/**\n * Build the {@link Finalizers} for one delivery.\n *\n * Guarantees:\n *\n * - **Deduped deliveries are inert.** When `deduped` is `true` the key\n * was already committed by an earlier delivery; both finalizers are\n * no-ops so a duplicate can never release someone else's committed\n * claim.\n * - **Finalize-once.** After the first `commit` or `release` fires,\n * further calls are no-ops — an adapter that both auto-finalizes and\n * lets the operator call `commit()`/`release()` can't double-fire.\n */\nexport function make_finalizers(\n store: IdempotencyStore,\n key: string,\n deduped: boolean\n): Finalizers {\n let settled = deduped;\n return {\n async commit() {\n if (settled) return;\n settled = true;\n await store.commit(key);\n },\n async release() {\n if (settled) return;\n settled = true;\n await store.release(key);\n },\n };\n}\n"],"mappings":"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","names":[]}
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  webhookMiddleware
3
- } from "../../chunk-K4HAOBRF.js";
4
- import "../../chunk-4CGAUB5H.js";
3
+ } from "../../chunk-6YOMQRZO.js";
4
+ import "../../chunk-NXSPB2NW.js";
5
5
  import "../../chunk-PZ5AY32C.js";
6
6
  export {
7
7
  webhookMiddleware
@@ -112,6 +112,23 @@ async function checkWebhook(headers, body, options) {
112
112
  // src/receiver/start.ts
113
113
  var import_hono = require("hono");
114
114
 
115
+ // src/receiver/finalize.ts
116
+ function make_finalizers(store, key, deduped) {
117
+ let settled = deduped;
118
+ return {
119
+ async commit() {
120
+ if (settled) return;
121
+ settled = true;
122
+ await store.commit(key);
123
+ },
124
+ async release() {
125
+ if (settled) return;
126
+ settled = true;
127
+ await store.release(key);
128
+ }
129
+ };
130
+ }
131
+
115
132
  // src/receiver/hono/index.ts
116
133
  function webhookMiddleware(options) {
117
134
  return async function check(c, next) {
@@ -121,8 +138,25 @@ function webhookMiddleware(options) {
121
138
  if (!result.ok) {
122
139
  return c.json({ error: result.reason }, result.status);
123
140
  }
124
- c.set("idempotency", { key: result.key, deduped: result.deduped });
125
- await next();
141
+ const { commit, release } = make_finalizers(
142
+ options.store,
143
+ result.key,
144
+ result.deduped
145
+ );
146
+ c.set("idempotency", {
147
+ key: result.key,
148
+ deduped: result.deduped,
149
+ commit,
150
+ release
151
+ });
152
+ try {
153
+ await next();
154
+ } catch (err) {
155
+ await release();
156
+ throw err;
157
+ }
158
+ if (c.res.status >= 500) await release();
159
+ else await commit();
126
160
  };
127
161
  }
128
162
  function headers_bag(headers) {
@@ -167,6 +201,7 @@ function receiver(options) {
167
201
  try {
168
202
  await handler(validated, { key: idem.key });
169
203
  } catch (err) {
204
+ await options.store.release(idem.key);
170
205
  return c.json(
171
206
  {
172
207
  error: "handler-failed",
@@ -175,6 +210,7 @@ function receiver(options) {
175
210
  500
176
211
  );
177
212
  }
213
+ await options.store.commit(idem.key);
178
214
  }
179
215
  return c.body(null, 204);
180
216
  });