@rotorsoft/act-http 1.8.4 → 1.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/.tsbuildinfo +1 -1
- package/dist/@types/api/errors.d.ts.map +1 -1
- package/dist/@types/api/index.d.ts +1 -1
- package/dist/@types/api/index.d.ts.map +1 -1
- package/dist/@types/api/sse-wiring.d.ts +45 -1
- package/dist/@types/api/sse-wiring.d.ts.map +1 -1
- package/dist/@types/hono/index.d.ts.map +1 -1
- package/dist/@types/openapi/index.d.ts.map +1 -1
- package/dist/@types/receiver/check.d.ts +8 -4
- package/dist/@types/receiver/check.d.ts.map +1 -1
- package/dist/@types/receiver/express/index.d.ts +6 -1
- package/dist/@types/receiver/express/index.d.ts.map +1 -1
- package/dist/@types/receiver/fastify/index.d.ts +5 -1
- package/dist/@types/receiver/fastify/index.d.ts.map +1 -1
- package/dist/@types/sse/broadcast.d.ts.map +1 -1
- package/dist/@types/sse/presence.d.ts.map +1 -1
- package/dist/@types/sse/state-cache.d.ts.map +1 -1
- package/dist/@types/trpc/index.d.ts.map +1 -1
- package/dist/@types/webhook/types.d.ts.map +1 -1
- package/dist/api/index.cjs +19 -7
- package/dist/api/index.cjs.map +1 -1
- package/dist/api/index.js +3 -1
- package/dist/{chunk-NXSPB2NW.js → chunk-DMDU25DN.js} +4 -1
- package/dist/chunk-DMDU25DN.js.map +1 -0
- package/dist/{chunk-FIYLGJAE.js → chunk-IKLTEKBL.js} +19 -8
- package/dist/chunk-IKLTEKBL.js.map +1 -0
- package/dist/{chunk-6YOMQRZO.js → chunk-M7XFCGYR.js} +2 -2
- package/dist/hono/index.cjs +36 -9
- package/dist/hono/index.cjs.map +1 -1
- package/dist/hono/index.js +20 -3
- package/dist/hono/index.js.map +1 -1
- package/dist/openapi/index.cjs +22 -2
- package/dist/openapi/index.cjs.map +1 -1
- package/dist/openapi/index.js +22 -2
- package/dist/openapi/index.js.map +1 -1
- package/dist/receiver/express/index.cjs +3 -0
- package/dist/receiver/express/index.cjs.map +1 -1
- package/dist/receiver/express/index.js +1 -1
- package/dist/receiver/express/index.js.map +1 -1
- package/dist/receiver/fastify/index.cjs +3 -0
- package/dist/receiver/fastify/index.cjs.map +1 -1
- package/dist/receiver/fastify/index.js +1 -1
- package/dist/receiver/fastify/index.js.map +1 -1
- package/dist/receiver/hono/index.cjs +3 -0
- package/dist/receiver/hono/index.cjs.map +1 -1
- package/dist/receiver/hono/index.js +2 -2
- package/dist/receiver/index.cjs +3 -0
- package/dist/receiver/index.cjs.map +1 -1
- package/dist/receiver/index.js +2 -2
- package/dist/receiver/trpc/index.cjs +3 -0
- package/dist/receiver/trpc/index.cjs.map +1 -1
- package/dist/receiver/trpc/index.js +1 -1
- package/dist/trpc/index.cjs +25 -12
- package/dist/trpc/index.cjs.map +1 -1
- package/dist/trpc/index.js +9 -6
- package/dist/trpc/index.js.map +1 -1
- package/package.json +4 -4
- package/dist/chunk-FIYLGJAE.js.map +0 -1
- package/dist/chunk-NXSPB2NW.js.map +0 -1
- /package/dist/{chunk-6YOMQRZO.js.map → chunk-M7XFCGYR.js.map} +0 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/receiver/index.ts","../../src/receiver/extract.ts","../../src/receiver/verify.ts","../../src/receiver/check.ts","../../src/receiver/start.ts","../../src/receiver/finalize.ts","../../src/receiver/hono/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/receiver\n *\n * Server-side helpers for the inbound HTTP role — the receiver that\n * sits on the other end of an `@rotorsoft/act-http/webhook` POST.\n *\n * The subpath hosts two primitives today:\n *\n * - {@link extractIdempotencyKey} — case-insensitive\n * `Idempotency-Key` parser; pair with `IdempotencyStore.claim`\n * from `@rotorsoft/act-ops/idempotency` for dedup.\n * - {@link verifyWebhook} — HMAC-SHA256 signature + timestamp\n * verifier; pair with `webhook({ secret })` from\n * `@rotorsoft/act-http/webhook` for authenticated, replay-resistant\n * delivery.\n *\n * The framework-agnostic middleware that wires these into request\n * handlers, plus per-framework adapters (tRPC / Express / Fastify /\n * Hono), lands in #744 (ACT-1116).\n *\n * Sibling subpaths in the same package:\n *\n * - `@rotorsoft/act-http/webhook` — the sender side: outbound POSTs,\n * automatic `Idempotency-Key`, status-classified retries, optional\n * HMAC signing.\n * - `@rotorsoft/act-http/sse` — incremental state broadcast over\n * Server-Sent Events.\n *\n * The receiver subpath ships from the same package as `/webhook` so\n * a service that both sends and receives webhooks installs one\n * dependency. The dedup contract that links them lives in\n * `@rotorsoft/act-ops/idempotency`.\n */\n\nexport {\n type CheckFailureReason,\n type CheckResult,\n type CheckWebhookOptions,\n checkWebhook,\n} from \"./check.js\";\nexport { extractIdempotencyKey } from \"./extract.js\";\nexport { receiver } from \"./start.js\";\nexport {\n type VerifyOptions,\n type VerifyResult,\n verifyWebhook,\n} from \"./verify.js\";\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 {\n Receiver,\n ReceiverBuilder,\n ReceiverContext,\n ReceiverOptions,\n Validator,\n} from \"@rotorsoft/act-ops/receiver\";\nimport { Hono } from \"hono\";\nimport { webhookMiddleware } from \"./hono/index.js\";\n\n/**\n * Recommended factory for \"I want to receive webhooks.\" Returns a\n * {@link ReceiverBuilder} the operator configures fluently:\n *\n * ```ts\n * import { receiver } from \"@rotorsoft/act-http/receiver\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n * import { z } from \"zod\";\n *\n * const r = receiver({\n * port: 4001,\n * store: new InMemoryIdempotencyStore(),\n * secret: process.env.WEBHOOK_SECRET,\n * })\n * .on(\"OrderConfirmed\", z.object({\n * orderId: z.string(),\n * total: z.number(),\n * }), async (event, ctx) => {\n * // event.orderId and event.total are typed\n * // ctx.key is the deduplicated Idempotency-Key\n * await process_order(event.orderId, event.total);\n * })\n * .build();\n *\n * await r.listen();\n * ```\n *\n * Matches Act's builder pattern: `receiver(...)` is the factory,\n * `.on()` registers handlers fluently, `.build()` finalizes and\n * produces an immutable {@link Receiver} — at which point the type\n * loses `.on()` and gains the runtime methods (`listen` / `close` /\n * `fetch`). The lifecycle phases are split at the type level.\n *\n * Internally uses Hono for routing — the universal-runtime choice\n * that gives one code path coverage across Node, AWS Lambda,\n * Cloudflare Workers, Vercel Edge, Bun, and Deno. For operators\n * with an existing tRPC / Express / Fastify / Hono app who need to\n * compose the receiver with their own middleware stack, the\n * lower-level `webhookMiddleware` from\n * `@rotorsoft/act-http/receiver/<framework>` is the escape hatch.\n *\n * `@hono/node-server` is imported lazily inside `.listen()` so\n * Lambda / edge consumers (who never call `.listen()`) don't need\n * it installed.\n */\nexport function receiver(options: ReceiverOptions): ReceiverBuilder {\n const app = new Hono<{\n Variables: { idempotency: { key: string; deduped: boolean } };\n }>();\n\n const middleware = webhookMiddleware({\n store: options.store,\n secret: options.secret,\n });\n\n let built = false;\n\n const builder: ReceiverBuilder = {\n on<T>(\n name: string,\n schema: Validator<T>,\n handler: (event: T, ctx: ReceiverContext) => Promise<void>\n ): ReceiverBuilder {\n if (built) {\n throw new Error(\n `Cannot register handler \"${name}\" after .build() — handlers are frozen once the receiver is built.`\n );\n }\n\n app.post(`/${name}`, middleware, async (c) => {\n let validated: T;\n try {\n const body = await c.req.json();\n validated = schema.parse(body);\n } catch (err) {\n return c.json(\n {\n error: \"validation-failed\",\n detail: (err as Error).message,\n },\n 422\n );\n }\n\n const idem = c.get(\"idempotency\");\n if (!idem.deduped) {\n try {\n await handler(validated, { key: idem.key });\n } catch (err) {\n // Transient failure: release the tentative claim so the\n // sender's retry re-processes instead of being deduped\n // into a silent success and permanently lost.\n await options.store.release(idem.key);\n return c.json(\n {\n error: \"handler-failed\",\n detail: (err as Error).message,\n },\n 500\n );\n }\n // Success: promote the tentative claim to a durable record\n // so every later retry of this key dedups.\n await options.store.commit(idem.key);\n }\n\n return c.body(null, 204);\n });\n\n return builder;\n },\n\n build(): Receiver {\n built = true;\n\n // `any`: server lifecycle handle from @hono/node-server\n let server: any | undefined;\n\n return {\n async listen(): Promise<void> {\n const { serve } = await import(\"@hono/node-server\");\n const launched = serve({ fetch: app.fetch, port: options.port });\n server = launched;\n await new Promise<void>((resolve) => {\n launched.once(\"listening\", () => resolve());\n });\n },\n\n async close(): Promise<void> {\n if (!server) return;\n const s = server;\n server = undefined;\n await new Promise<void>((resolve) => s.close(() => resolve()));\n },\n\n async fetch(request: Request): Promise<Response> {\n return app.fetch(request);\n },\n };\n },\n };\n\n return builder;\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","/**\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"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/receiver/index.ts","../../src/receiver/extract.ts","../../src/receiver/verify.ts","../../src/receiver/check.ts","../../src/receiver/start.ts","../../src/receiver/finalize.ts","../../src/receiver/hono/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/receiver\n *\n * Server-side helpers for the inbound HTTP role — the receiver that\n * sits on the other end of an `@rotorsoft/act-http/webhook` POST.\n *\n * The subpath hosts two primitives today:\n *\n * - {@link extractIdempotencyKey} — case-insensitive\n * `Idempotency-Key` parser; pair with `IdempotencyStore.claim`\n * from `@rotorsoft/act-ops/idempotency` for dedup.\n * - {@link verifyWebhook} — HMAC-SHA256 signature + timestamp\n * verifier; pair with `webhook({ secret })` from\n * `@rotorsoft/act-http/webhook` for authenticated, replay-resistant\n * delivery.\n *\n * The framework-agnostic middleware that wires these into request\n * handlers, plus per-framework adapters (tRPC / Express / Fastify /\n * Hono), lands in #744 (ACT-1116).\n *\n * Sibling subpaths in the same package:\n *\n * - `@rotorsoft/act-http/webhook` — the sender side: outbound POSTs,\n * automatic `Idempotency-Key`, status-classified retries, optional\n * HMAC signing.\n * - `@rotorsoft/act-http/sse` — incremental state broadcast over\n * Server-Sent Events.\n *\n * The receiver subpath ships from the same package as `/webhook` so\n * a service that both sends and receives webhooks installs one\n * dependency. The dedup contract that links them lives in\n * `@rotorsoft/act-ops/idempotency`.\n */\n\nexport {\n type CheckFailureReason,\n type CheckResult,\n type CheckWebhookOptions,\n checkWebhook,\n} from \"./check.js\";\nexport { extractIdempotencyKey } from \"./extract.js\";\nexport { receiver } from \"./start.js\";\nexport {\n type VerifyOptions,\n type VerifyResult,\n verifyWebhook,\n} from \"./verify.js\";\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 {\n Receiver,\n ReceiverBuilder,\n ReceiverContext,\n ReceiverOptions,\n Validator,\n} from \"@rotorsoft/act-ops/receiver\";\nimport { Hono } from \"hono\";\nimport { webhookMiddleware } from \"./hono/index.js\";\n\n/**\n * Recommended factory for \"I want to receive webhooks.\" Returns a\n * {@link ReceiverBuilder} the operator configures fluently:\n *\n * ```ts\n * import { receiver } from \"@rotorsoft/act-http/receiver\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n * import { z } from \"zod\";\n *\n * const r = receiver({\n * port: 4001,\n * store: new InMemoryIdempotencyStore(),\n * secret: process.env.WEBHOOK_SECRET,\n * })\n * .on(\"OrderConfirmed\", z.object({\n * orderId: z.string(),\n * total: z.number(),\n * }), async (event, ctx) => {\n * // event.orderId and event.total are typed\n * // ctx.key is the deduplicated Idempotency-Key\n * await process_order(event.orderId, event.total);\n * })\n * .build();\n *\n * await r.listen();\n * ```\n *\n * Matches Act's builder pattern: `receiver(...)` is the factory,\n * `.on()` registers handlers fluently, `.build()` finalizes and\n * produces an immutable {@link Receiver} — at which point the type\n * loses `.on()` and gains the runtime methods (`listen` / `close` /\n * `fetch`). The lifecycle phases are split at the type level.\n *\n * Internally uses Hono for routing — the universal-runtime choice\n * that gives one code path coverage across Node, AWS Lambda,\n * Cloudflare Workers, Vercel Edge, Bun, and Deno. For operators\n * with an existing tRPC / Express / Fastify / Hono app who need to\n * compose the receiver with their own middleware stack, the\n * lower-level `webhookMiddleware` from\n * `@rotorsoft/act-http/receiver/<framework>` is the escape hatch.\n *\n * `@hono/node-server` is imported lazily inside `.listen()` so\n * Lambda / edge consumers (who never call `.listen()`) don't need\n * it installed.\n */\nexport function receiver(options: ReceiverOptions): ReceiverBuilder {\n const app = new Hono<{\n Variables: { idempotency: { key: string; deduped: boolean } };\n }>();\n\n const middleware = webhookMiddleware({\n store: options.store,\n secret: options.secret,\n });\n\n let built = false;\n\n const builder: ReceiverBuilder = {\n on<T>(\n name: string,\n schema: Validator<T>,\n handler: (event: T, ctx: ReceiverContext) => Promise<void>\n ): ReceiverBuilder {\n if (built) {\n throw new Error(\n `Cannot register handler \"${name}\" after .build() — handlers are frozen once the receiver is built.`\n );\n }\n\n app.post(`/${name}`, middleware, async (c) => {\n let validated: T;\n try {\n const body = await c.req.json();\n validated = schema.parse(body);\n } catch (err) {\n return c.json(\n {\n error: \"validation-failed\",\n detail: (err as Error).message,\n },\n 422\n );\n }\n\n const idem = c.get(\"idempotency\");\n if (!idem.deduped) {\n try {\n await handler(validated, { key: idem.key });\n } catch (err) {\n // Transient failure: release the tentative claim so the\n // sender's retry re-processes instead of being deduped\n // into a silent success and permanently lost.\n await options.store.release(idem.key);\n return c.json(\n {\n error: \"handler-failed\",\n detail: (err as Error).message,\n },\n 500\n );\n }\n // Success: promote the tentative claim to a durable record\n // so every later retry of this key dedups.\n await options.store.commit(idem.key);\n }\n\n return c.body(null, 204);\n });\n\n return builder;\n },\n\n build(): Receiver {\n built = true;\n\n // `any`: server lifecycle handle from @hono/node-server\n let server: any | undefined;\n\n return {\n async listen(): Promise<void> {\n const { serve } = await import(\"@hono/node-server\");\n const launched = serve({ fetch: app.fetch, port: options.port });\n server = launched;\n await new Promise<void>((resolve) => {\n launched.once(\"listening\", () => resolve());\n });\n },\n\n async close(): Promise<void> {\n if (!server) return;\n const s = server;\n server = undefined;\n await new Promise<void>((resolve) => s.close(() => resolve()));\n },\n\n async fetch(request: Request): Promise<Response> {\n return app.fetch(request);\n },\n };\n },\n };\n\n return builder;\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","/**\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"],"mappings":"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","names":[]}
|
package/dist/receiver/index.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
webhookMiddleware
|
|
3
|
-
} from "../chunk-
|
|
3
|
+
} from "../chunk-M7XFCGYR.js";
|
|
4
4
|
import {
|
|
5
5
|
checkWebhook,
|
|
6
6
|
extractIdempotencyKey,
|
|
7
7
|
verifyWebhook
|
|
8
|
-
} from "../chunk-
|
|
8
|
+
} from "../chunk-DMDU25DN.js";
|
|
9
9
|
import "../chunk-PZ5AY32C.js";
|
|
10
10
|
|
|
11
11
|
// src/receiver/start.ts
|
|
@@ -81,6 +81,9 @@ function pick_header(headers, lower_name) {
|
|
|
81
81
|
// src/receiver/check.ts
|
|
82
82
|
async function checkWebhook(headers, body, options) {
|
|
83
83
|
if (options.secret !== void 0) {
|
|
84
|
+
if (body === "") {
|
|
85
|
+
return { ok: false, status: 400, reason: "empty-body" };
|
|
86
|
+
}
|
|
84
87
|
const verification = verifyWebhook(
|
|
85
88
|
headers,
|
|
86
89
|
body,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"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 * `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
|
+
{"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":[]}
|
package/dist/trpc/index.cjs
CHANGED
|
@@ -14602,19 +14602,23 @@ async function withIdempotency(store, key, handler) {
|
|
|
14602
14602
|
// src/api/sse-wiring.ts
|
|
14603
14603
|
var DEFAULT_SSE_MAX_CONNECTIONS = 500;
|
|
14604
14604
|
var DEFAULT_SSE_HEARTBEAT_MS = 3e4;
|
|
14605
|
+
var DEFAULT_SSE_MAX_PENDING_PER_CONNECTION = 256;
|
|
14605
14606
|
var SseOptionsSchema = external_exports.object({
|
|
14606
14607
|
maxConnections: external_exports.number().int().min(1).max(1e4).default(DEFAULT_SSE_MAX_CONNECTIONS),
|
|
14607
|
-
heartbeatMs: external_exports.number().int().min(15e3).max(3e5).default(DEFAULT_SSE_HEARTBEAT_MS)
|
|
14608
|
+
heartbeatMs: external_exports.number().int().min(15e3).max(3e5).default(DEFAULT_SSE_HEARTBEAT_MS),
|
|
14609
|
+
maxPendingPerConnection: external_exports.number().int().min(1).max(1e5).default(DEFAULT_SSE_MAX_PENDING_PER_CONNECTION)
|
|
14608
14610
|
});
|
|
14609
14611
|
function resolveSseConfig(options) {
|
|
14610
14612
|
const parsed = SseOptionsSchema.parse({
|
|
14611
14613
|
maxConnections: options.maxConnections,
|
|
14612
|
-
heartbeatMs: options.heartbeatMs
|
|
14614
|
+
heartbeatMs: options.heartbeatMs,
|
|
14615
|
+
maxPendingPerConnection: options.maxPendingPerConnection
|
|
14613
14616
|
});
|
|
14614
14617
|
return {
|
|
14615
14618
|
channel: options.channel,
|
|
14616
14619
|
maxConnections: parsed.maxConnections,
|
|
14617
|
-
heartbeatMs: parsed.heartbeatMs
|
|
14620
|
+
heartbeatMs: parsed.heartbeatMs,
|
|
14621
|
+
maxPendingPerConnection: parsed.maxPendingPerConnection
|
|
14618
14622
|
};
|
|
14619
14623
|
}
|
|
14620
14624
|
var SseConnectionCounter = class {
|
|
@@ -14646,13 +14650,19 @@ var SseConnectionCounter = class {
|
|
|
14646
14650
|
return this._open;
|
|
14647
14651
|
}
|
|
14648
14652
|
};
|
|
14649
|
-
async function* runSseSubscription(channel, stream_id, accounting, signal,
|
|
14650
|
-
|
|
14651
|
-
|
|
14653
|
+
async function* runSseSubscription(channel, stream_id, accounting, signal, options) {
|
|
14654
|
+
let acquired = false;
|
|
14655
|
+
if (accounting) {
|
|
14656
|
+
acquired = accounting.acquire();
|
|
14657
|
+
if (!acquired && options?.on_cap_exceeded) {
|
|
14658
|
+
options.on_cap_exceeded();
|
|
14659
|
+
}
|
|
14652
14660
|
}
|
|
14661
|
+
const max_pending = options?.maxPending ?? DEFAULT_SSE_MAX_PENDING_PER_CONNECTION;
|
|
14653
14662
|
const pending = [];
|
|
14654
14663
|
let resolve_wait = null;
|
|
14655
14664
|
const unsubscribe = channel.subscribe(stream_id, (msg) => {
|
|
14665
|
+
if (pending.length >= max_pending) pending.shift();
|
|
14656
14666
|
pending.push(msg);
|
|
14657
14667
|
resolve_wait?.();
|
|
14658
14668
|
});
|
|
@@ -14676,7 +14686,7 @@ async function* runSseSubscription(channel, stream_id, accounting, signal, on_ca
|
|
|
14676
14686
|
} finally {
|
|
14677
14687
|
signal?.removeEventListener("abort", on_abort);
|
|
14678
14688
|
unsubscribe();
|
|
14679
|
-
accounting?.release();
|
|
14689
|
+
if (acquired) accounting?.release();
|
|
14680
14690
|
}
|
|
14681
14691
|
}
|
|
14682
14692
|
|
|
@@ -14774,11 +14784,14 @@ function trpc(app, options) {
|
|
|
14774
14784
|
input.stream,
|
|
14775
14785
|
sse_counter,
|
|
14776
14786
|
signal,
|
|
14777
|
-
|
|
14778
|
-
|
|
14779
|
-
|
|
14780
|
-
|
|
14781
|
-
|
|
14787
|
+
{
|
|
14788
|
+
maxPending: sse_config.maxPendingPerConnection,
|
|
14789
|
+
on_cap_exceeded: () => {
|
|
14790
|
+
throw new import_server.TRPCError({
|
|
14791
|
+
code: "TOO_MANY_REQUESTS",
|
|
14792
|
+
message: "max concurrent SSE subscriptions reached"
|
|
14793
|
+
});
|
|
14794
|
+
}
|
|
14782
14795
|
}
|
|
14783
14796
|
)
|
|
14784
14797
|
);
|