{"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/3xx **commits** the key (later retries dedup); any **4xx or 5xx**\n * (or a thrown handler) **releases** it, so the sender's corrected retry\n * re-processes instead of being silently dropped (#1364). Fail-safe is\n * toward reprocessing: releasing a deterministic 4xx costs bounded\n * re-delivery, while committing it would lose a delivery the handler\n * never accepted (the shipped `receiver()` returns 422 on a schema\n * mismatch, where the handler never runs). Operators who need explicit\n * control can 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    // Commit only a genuine success (2xx/3xx); release on any 4xx/5xx so a\n    // failed delivery's corrected same-key retry re-processes instead of being\n    // deduped into a silent success (#1364).\n    if (c.res.status >= 400) 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 * client/configuration errors (`missing-key`, `empty-body` — HTTP 400)\n * from the five verification failures (authentication errors, HTTP 401)\n * so each maps to its own telemetry bucket. `empty-body` is the\n * misconfigured-raw-parser signal: `secret` is set but the resolved\n * body is empty, so hashing it would compute an HMAC over `${ts}.` and\n * reject every otherwise-valid signed request with a misleading\n * bad-signature — a distinct config error is far easier to diagnose.\n */\nexport type CheckFailureReason =\n  | \"missing-key\"\n  | \"empty-body\"\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    // Raw body not captured (default JSON parser ate the bytes) but a\n    // secret is configured — hashing the empty string is a guaranteed\n    // bad-signature. Surface a distinct configuration error instead of a\n    // misleading 401 so the operator mounts the raw-body parser.\n    if (body === \"\") {\n      return { ok: false, status: 400, reason: \"empty-body\" };\n    }\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":[]}