{"version":3,"sources":["../../../src/receiver/trpc/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/trpc\n *\n * tRPC adapter for the receiver-side webhook check. Composes\n * `extractIdempotencyKey` + `verifyWebhook` + `IdempotencyStore.claim`\n * into a single middleware factory.\n *\n * Usage:\n *\n * ```ts\n * import { initTRPC, TRPCError } from \"@trpc/server\";\n * import { webhookMiddleware } from \"@rotorsoft/act-http/receiver/trpc\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n *\n * type Ctx = {\n *   headers: Record<string, string | string[] | undefined>;\n *   rawBody: string;\n * };\n *\n * const t = initTRPC.context<Ctx>().create();\n * const dedup = new InMemoryIdempotencyStore();\n *\n * const idempotent = t.procedure.use(\n *   webhookMiddleware({ store: dedup, secret: process.env.WEBHOOK_SECRET })\n * );\n * ```\n *\n * The middleware throws a `TRPCError` with `BAD_REQUEST` for\n * `missing-key` and `UNAUTHORIZED` for any verification failure.\n * On success it injects `{ key, deduped, commit, release }` into the\n * request context under the `idempotency` property.\n *\n * **Two-phase dedup**: the claim is *tentative*. Because a tRPC\n * middleware wraps `next()`, this adapter finalizes automatically:\n * the downstream resolver returning **commits** the key, and a thrown\n * error **releases** it so the sender's retry re-processes. The\n * bound `ctx.idempotency.commit()` / `.release()` are exposed for\n * resolvers that need to finalize a partial success explicitly.\n *\n * **Raw body requirement**: when `secret` is configured, the middleware\n * needs the raw request bytes for HMAC verification. Capture them in\n * `createContext` — most tRPC HTTP adapters expose the raw stream;\n * read it into a string and stash it on `ctx.rawBody`. Skip when\n * unsigned (no `secret`) — the middleware never reads `rawBody` in\n * that mode.\n */\nimport { TRPCError } from \"@trpc/server\";\nimport { type CheckWebhookOptions, checkWebhook } from \"../check.js\";\nimport { make_finalizers } from \"../finalize.js\";\n\n/**\n * Build a tRPC middleware that verifies the request signature (when\n * `secret` is set), enforces `Idempotency-Key`, and claims the key on\n * the configured store. See the module-level docs for usage.\n *\n * The returned function uses permissive `any` typing because tRPC's\n * `MiddlewareFunction` type lives in `unstable-core-do-not-import`\n * (internal namespace, not for external import). Type-safety at the\n * call site comes from `t.procedure.use(...)` validating the\n * middleware shape against the procedure's context — the operator's\n * tRPC context must include `headers` and `rawBody`, and downstream\n * handlers see `ctx.idempotency = { key, deduped }`.\n */\n// `any`: tRPC's internal middleware shape\nexport function webhookMiddleware(options: CheckWebhookOptions): any {\n  return async function check(opts: {\n    ctx: {\n      headers: Record<string, string | string[] | undefined>;\n      rawBody: string;\n    };\n    // `any`: see above\n    next: (next: { ctx: any }) => Promise<any>;\n  }) {\n    const result = await checkWebhook(\n      opts.ctx.headers,\n      opts.ctx.rawBody,\n      options\n    );\n    if (!result.ok) {\n      throw new TRPCError({\n        code: result.status === 400 ? \"BAD_REQUEST\" : \"UNAUTHORIZED\",\n        message: result.reason,\n      });\n    }\n    const { commit, release } = make_finalizers(\n      options.store,\n      result.key,\n      result.deduped\n    );\n    let outcome: unknown;\n    try {\n      outcome = await opts.next({\n        ctx: {\n          ...opts.ctx,\n          idempotency: {\n            key: result.key,\n            deduped: result.deduped,\n            commit,\n            release,\n          },\n        },\n      });\n    } catch (err) {\n      // Resolver threw — release the tentative claim so a retry\n      // re-processes instead of being deduped into a silent success.\n      await release();\n      throw err;\n    }\n    // tRPC middleware results carry `{ ok: boolean }`; an `ok: false`\n    // result is an error the resolver returned rather than threw.\n    if (\n      typeof outcome === \"object\" &&\n      outcome !== null &&\n      \"ok\" in outcome &&\n      (outcome as { ok: unknown }).ok === false\n    ) {\n      await release();\n    } else {\n      await commit();\n    }\n    return outcome;\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 * 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":[]}