@rotorsoft/act-http 1.10.21 → 1.10.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +4 -4
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/@types/api/sse-wiring.d.ts +1 -1
  4. package/dist/@types/hono/index.d.ts +18 -18
  5. package/dist/@types/hono/index.d.ts.map +1 -1
  6. package/dist/@types/openapi/index.d.ts +17 -17
  7. package/dist/@types/openapi/index.d.ts.map +1 -1
  8. package/dist/@types/receiver/express/index.d.ts.map +1 -1
  9. package/dist/@types/receiver/fastify/index.d.ts.map +1 -1
  10. package/dist/@types/receiver/finalize.d.ts +15 -2
  11. package/dist/@types/receiver/finalize.d.ts.map +1 -1
  12. package/dist/@types/receiver/hono/index.d.ts.map +1 -1
  13. package/dist/@types/receiver/index.d.ts +8 -4
  14. package/dist/@types/receiver/index.d.ts.map +1 -1
  15. package/dist/@types/receiver/trpc/index.d.ts.map +1 -1
  16. package/dist/@types/trpc/index.d.ts +38 -38
  17. package/dist/@types/trpc/index.d.ts.map +1 -1
  18. package/dist/api/index.cjs.map +1 -1
  19. package/dist/api/index.js +1 -1
  20. package/dist/{chunk-DMDU25DN.js → chunk-G5FNOSDX.js} +5 -3
  21. package/dist/{chunk-DMDU25DN.js.map → chunk-G5FNOSDX.js.map} +1 -1
  22. package/dist/{chunk-KC2NW7XQ.js → chunk-TTVMQYNN.js} +8 -17
  23. package/dist/chunk-TTVMQYNN.js.map +1 -0
  24. package/dist/{chunk-BGXQRSSQ.js → chunk-ZQGGGU4E.js} +1 -1
  25. package/dist/chunk-ZQGGGU4E.js.map +1 -0
  26. package/dist/hono/index.cjs.map +1 -1
  27. package/dist/hono/index.js +1 -1
  28. package/dist/hono/index.js.map +1 -1
  29. package/dist/openapi/index.cjs.map +1 -1
  30. package/dist/openapi/index.js.map +1 -1
  31. package/dist/receiver/express/index.cjs +4 -12
  32. package/dist/receiver/express/index.cjs.map +1 -1
  33. package/dist/receiver/express/index.js +3 -13
  34. package/dist/receiver/express/index.js.map +1 -1
  35. package/dist/receiver/fastify/index.cjs +4 -12
  36. package/dist/receiver/fastify/index.cjs.map +1 -1
  37. package/dist/receiver/fastify/index.js +3 -13
  38. package/dist/receiver/fastify/index.js.map +1 -1
  39. package/dist/receiver/hono/index.cjs +8 -15
  40. package/dist/receiver/hono/index.cjs.map +1 -1
  41. package/dist/receiver/hono/index.js +2 -2
  42. package/dist/receiver/index.cjs +8 -15
  43. package/dist/receiver/index.cjs.map +1 -1
  44. package/dist/receiver/index.js +2 -2
  45. package/dist/receiver/trpc/index.cjs +8 -15
  46. package/dist/receiver/trpc/index.cjs.map +1 -1
  47. package/dist/receiver/trpc/index.js +7 -16
  48. package/dist/receiver/trpc/index.js.map +1 -1
  49. package/dist/trpc/index.cjs.map +1 -1
  50. package/dist/trpc/index.js +1 -1
  51. package/dist/trpc/index.js.map +1 -1
  52. package/package.json +2 -2
  53. package/dist/chunk-BGXQRSSQ.js.map +0 -1
  54. package/dist/chunk-KC2NW7XQ.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/openapi/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/openapi\n *\n * OpenAPI subpath for the auto-generated API epic (#835). Walks the\n * registry of a built `Act` once at function call and returns a\n * valid OpenAPI 3.1 document describing the same action surface the\n * Hono sibling at `@rotorsoft/act-http/hono` serves. The doc is\n * suitable for cross-language client codegen (Go, Python, Swift,\n * Kotlin, .NET), for serving over `/openapi.json` alongside a live\n * API, and for the broader OpenAPI tooling ecosystem (Postman,\n * Swagger UI, Redoc, schemathesis, API gateways).\n *\n * Usage:\n *\n * ```ts\n * import { openapi } from \"@rotorsoft/act-http/openapi\";\n *\n * const doc = openapi(app, {\n * info: { title: \"Wolfdesk API\", version: \"1.0.0\" },\n * servers: [{ url: \"https://api.example.com\" }],\n * });\n * // doc: OpenAPIDocument — valid OpenAPI 3.1 object\n * ```\n *\n * **Pure data emit.** No runtime dep on Hono or tRPC. The subpath is\n * consumable standalone for hosts that serve their API some other\n * way (Fastify, Lambda, edge) or that only want the doc for client\n * codegen. The emit walks the registry once at function call; cost\n * is linear in `N actions × M Zod fields each`, single-digit\n * milliseconds for a 100-endpoint registry.\n *\n * **Determinism.** Output is stable across runs given the same\n * registry — entries land in `Object.entries(app.registry.actions)`\n * iteration order, schemas come from the action's Zod definition,\n * and the rest of the doc is fixed content. CI can snapshot the\n * result to catch unintended API surface changes.\n *\n * The same shared utilities at `@rotorsoft/act-http/api` underwrite\n * cross-transport consistency: the `ApiError` envelope is referenced\n * once from `components.schemas`, every error response points at it,\n * and the same shape ships from the tRPC and Hono siblings at\n * runtime. A client speaking the doc and the live REST API sees one\n * envelope per framework error.\n *\n * **The doc describes the Hono surface, not tRPC.** tRPC's URL\n * shape (`POST /trpc/<procedure>`, JSON-RPC-style body framing,\n * batching) doesn't model cleanly as OpenAPI operations — and tRPC\n * consumers already share types directly via `typeof router`, so\n * OpenAPI buys them nothing. The path shape this emitter produces\n * (`POST <basePath>/actions/<name>`) matches the\n * `@rotorsoft/act-http/hono` sibling exactly: same default\n * `basePath`, same request/response shapes, same envelope. If the\n * operator overrides `basePath` on the Hono adapter, they pass the\n * same override here so the doc keeps matching the live routes by\n * construction. tRPC and Hono can run side-by-side at different\n * mount points on the same Act instance; the OpenAPI doc covers\n * the REST half only.\n */\nimport { pii_fields } from \"@rotorsoft/act\";\nimport { z } from \"zod\";\n\n/**\n * Minimal OpenAPI 3.1 document type. The emitter targets the 3.1\n * spec; the type is intentionally a structural subset rather than\n * the full surface from `openapi-types` so callers can post-process\n * without fighting the type system. Cast to a fuller type from\n * `openapi-types` when downstream consumers need it.\n */\nexport type OpenAPIDocument = {\n readonly openapi: \"3.1.0\";\n readonly info: OpenAPIInfo;\n readonly servers?: ReadonlyArray<OpenAPIServer>;\n readonly paths: Record<string, OpenAPIPathItem>;\n readonly components: OpenAPIComponents;\n};\n\n/**\n * Per-call options for {@link openapi}. The host supplies the\n * document-shape fields the registry can't derive (title, version,\n * servers) and toggles for the cross-cutting headers that the live\n * REST API may accept.\n *\n * - `info` — required. `title` and `version` must be non-empty;\n * `info` may carry any other top-level OpenAPI info fields.\n * - `servers` — optional. Each entry's `url` may contain\n * `{variable}` template syntax; bare URLs are validated through\n * `URL`'s parser and reject malformed inputs.\n * - `basePath` — optional, default `/api`. Mirrors the Hono\n * sibling's default so the doc describes the same paths the\n * generated REST surface serves.\n * - `idempotency` — optional, default `false`. When `true`, every\n * mutation operation documents a **required** `Idempotency-Key`\n * request header (the generated route fail-closes with 400 when the\n * header is absent, so the doc marks it required, not optional).\n * - `expectedVersion` — optional, default `false`. When `true`,\n * every mutation operation documents an optional `If-Match`\n * request header carrying the expected stream version.\n */\nexport type OpenAPIOptions = {\n readonly info: OpenAPIInfo;\n readonly servers?: ReadonlyArray<OpenAPIServer>;\n readonly basePath?: string;\n readonly idempotency?: boolean;\n readonly expectedVersion?: boolean;\n};\n\nexport type OpenAPIInfo = {\n readonly title: string;\n readonly version: string;\n readonly description?: string;\n readonly summary?: string;\n readonly termsOfService?: string;\n readonly contact?: {\n readonly name?: string;\n readonly url?: string;\n readonly email?: string;\n };\n readonly license?: {\n readonly name: string;\n readonly identifier?: string;\n readonly url?: string;\n };\n};\n\nexport type OpenAPIServer = {\n readonly url: string;\n readonly description?: string;\n readonly variables?: Record<\n string,\n {\n readonly default: string;\n readonly enum?: ReadonlyArray<string>;\n readonly description?: string;\n }\n >;\n};\n\nexport type OpenAPIPathItem = {\n readonly post?: OpenAPIOperation;\n};\n\nexport type OpenAPIOperation = {\n readonly operationId: string;\n readonly summary: string;\n readonly tags: ReadonlyArray<string>;\n readonly parameters?: ReadonlyArray<OpenAPIParameter>;\n readonly requestBody?: OpenAPIRequestBody;\n readonly responses: Record<string, OpenAPIResponse>;\n};\n\nexport type OpenAPIParameter = {\n readonly name: string;\n readonly in: \"header\" | \"query\" | \"path\" | \"cookie\";\n readonly required?: boolean;\n readonly description?: string;\n readonly schema?: Record<string, unknown>;\n};\n\nexport type OpenAPIRequestBody = {\n readonly required?: boolean;\n readonly content: Record<\n string,\n { readonly schema: Record<string, unknown> }\n >;\n};\n\nexport type OpenAPIResponse =\n | {\n readonly description: string;\n readonly content?: Record<\n string,\n { readonly schema: Record<string, unknown> }\n >;\n }\n | { readonly $ref: string };\n\nexport type OpenAPIComponents = {\n readonly schemas: Record<string, Record<string, unknown>>;\n readonly responses: Record<string, OpenAPIResponse>;\n};\n\nconst DEFAULT_BASE_PATH = \"/api\";\n\n/**\n * The shared `ApiError` envelope schema, ref'd by every error\n * response. Matches the runtime envelope at\n * `@rotorsoft/act-http/api`'s `toApiError` exactly — keep in sync if\n * the envelope shape evolves.\n */\nconst API_ERROR_SCHEMA: Record<string, unknown> = {\n type: \"object\",\n properties: {\n error: { type: \"string\" },\n detail: { type: \"string\" },\n code: { type: \"string\" },\n },\n required: [\"error\"],\n};\n\n/**\n * The generic snapshot-array response schema returned on a\n * successful mutation. Each action commits one or more events; the\n * shape here is `Snapshot[]` with the per-action state and event\n * data left as open `object` (deeper per-action typing would need\n * to walk `state.events[*]` and emit a discriminated union — a\n * future refinement once the doc consumers ask for it).\n */\nconst SNAPSHOT_ARRAY_SCHEMA: Record<string, unknown> = {\n type: \"array\",\n items: {\n type: \"object\",\n properties: {\n state: { type: \"object\" },\n event: { type: \"object\" },\n version: { type: \"integer\" },\n patches: { type: \"integer\" },\n snaps: { type: \"integer\" },\n cache_hit: { type: \"boolean\" },\n replayed: { type: \"integer\" },\n },\n required: [\"state\", \"version\", \"patches\", \"snaps\", \"cache_hit\", \"replayed\"],\n },\n};\n\n/**\n * The error-response shape every operation references via\n * `$ref: \"#/components/responses/ApiError\"`. Single source of\n * truth — bug fixes to the envelope flow to every operation\n * automatically.\n */\nconst API_ERROR_RESPONSE: OpenAPIResponse = {\n description: \"Error envelope per @rotorsoft/act-http/api's toApiError.\",\n content: {\n \"application/json\": {\n schema: { $ref: \"#/components/schemas/ApiError\" },\n },\n },\n};\n\n/**\n * Server URL refinement: OpenAPI permits `{variable}` template syntax\n * inside server URLs, so we substitute each capture with `x` before\n * parsing. The character class forbids both `{` and `}` inside the\n * template, matching OpenAPI's variable-name grammar exactly and\n * eliminating the catastrophic-backtracking surface CodeQL flagged\n * on `[^}]+`.\n *\n * @internal\n */\nfunction is_valid_server_url(url: string): boolean {\n try {\n new URL(url.replace(/\\{[^{}]+\\}/g, \"x\"));\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Zod schema for the minimum required shape of {@link OpenAPIOptions}.\n * Same declarative-validation pattern the rest of the framework uses;\n * the OpenAPI surface is mostly free-form (`description`, `contact`,\n * `license` — the spec lets through anything that JSON-stringifies),\n * so only the load-bearing fields land here. Hosts overriding into\n * unusual shapes still get the rest of the doc emitted; this schema\n * only fails the construction call when `info.title` / `info.version`\n * is missing-or-empty or a `servers[].url` won't parse.\n *\n * @internal\n */\nconst OpenAPIOptionsSchema = z.object({\n info: z.object({\n title: z\n .string({ message: \"openapi: info.title is required (non-empty string)\" })\n .trim()\n .min(1, \"openapi: info.title is required (non-empty string)\"),\n version: z\n .string({\n message: \"openapi: info.version is required (non-empty string)\",\n })\n .trim()\n .min(1, \"openapi: info.version is required (non-empty string)\"),\n }),\n servers: z\n .array(\n z.object({\n url: z.string().refine(is_valid_server_url, {\n message: \"openapi: invalid server url\",\n }),\n })\n )\n .optional(),\n});\n\nfunction validate_options(options: OpenAPIOptions): void {\n OpenAPIOptionsSchema.parse({\n info: { title: options.info?.title, version: options.info?.version },\n servers: options.servers,\n });\n}\n\nfunction strip_json_schema_meta(\n schema: Record<string, unknown>\n): Record<string, unknown> {\n // `$schema` at the top of an inline schema is harmless in OpenAPI\n // 3.1 but adds noise to the doc. Drop it.\n const { $schema: _, ...rest } = schema;\n return rest;\n}\n\n/**\n * Annotate the emitted request-body schema's sensitive properties so\n * codegen / Swagger UI treat them as secrets rather than echoing them\n * freely. `z.toJSONSchema` has no knowledge of the sensitive registry\n * (`sensitive()` marks schemas out-of-band in a WeakMap), so we walk the\n * action's declared sensitive fields — via `pii_fields`, the same lookup\n * the orchestrator uses — and mark each matching property `writeOnly:\n * true` + `format: password`. Non-object schemas and actions with no\n * sensitive fields pass through untouched (the zero-cost common path).\n *\n * `pii_fields` only reports top-level keys of a `z.object`, and Zod's\n * JSON Schema emit always renders those as members of `properties` — so\n * once `fields` is non-empty, `body_schema.properties[field]` is\n * guaranteed present. No defensive fallback needed.\n *\n * @internal\n */\nfunction mark_sensitive_fields(\n body_schema: Record<string, unknown>,\n zod_schema: z.ZodType\n): Record<string, unknown> {\n const fields = pii_fields(zod_schema);\n if (fields.length === 0) return body_schema;\n const properties = body_schema.properties as Record<\n string,\n Record<string, unknown>\n >;\n const next_properties: Record<string, Record<string, unknown>> = {\n ...properties,\n };\n for (const field of fields) {\n next_properties[field] = {\n ...properties[field],\n writeOnly: true,\n format: \"password\",\n };\n }\n return { ...body_schema, properties: next_properties };\n}\n\nfunction build_operation(\n action_name: string,\n body_schema: Record<string, unknown>,\n options: OpenAPIOptions\n): OpenAPIOperation {\n const parameters: OpenAPIParameter[] = [];\n if (options.idempotency) {\n parameters.push({\n name: \"Idempotency-Key\",\n in: \"header\",\n // Required: the generated route fail-closes with 400 when idempotency is\n // enabled and the header is absent, so the doc must not advertise it as\n // optional (#1287).\n required: true,\n description:\n \"Required idempotency token. A duplicate of a request that already succeeded produces a 409 Conflict (the first result is not cached on the server). A duplicate that arrives while the original is still running produces a retryable 503 with Retry-After, because the original may yet fail.\",\n schema: { type: \"string\" },\n });\n }\n if (options.expectedVersion) {\n parameters.push({\n name: \"If-Match\",\n in: \"header\",\n required: false,\n description:\n \"Expected stream version for optimistic concurrency. Mismatch produces a 412 Precondition Failed via ConcurrencyError.\",\n schema: { type: \"string\" },\n });\n }\n\n return {\n operationId: action_name,\n summary: `Commit the ${action_name} action`,\n tags: [\"Actions\"],\n ...(parameters.length > 0 ? { parameters } : {}),\n requestBody: {\n required: true,\n content: {\n \"application/json\": { schema: body_schema },\n },\n },\n responses: {\n \"200\": {\n description: \"Action committed; returns the snapshot array.\",\n content: {\n \"application/json\": {\n schema: { $ref: \"#/components/schemas/SnapshotArray\" },\n },\n },\n },\n \"400\": { $ref: \"#/components/responses/ApiError\" },\n \"409\": { $ref: \"#/components/responses/ApiError\" },\n \"410\": { $ref: \"#/components/responses/ApiError\" },\n \"412\": { $ref: \"#/components/responses/ApiError\" },\n \"422\": { $ref: \"#/components/responses/ApiError\" },\n \"500\": { $ref: \"#/components/responses/ApiError\" },\n ...(options.idempotency\n ? { \"503\": { $ref: \"#/components/responses/ApiError\" } }\n : {}),\n },\n };\n}\n\n/**\n * Build an OpenAPI 3.1 document describing a built `Act` instance's\n * action surface.\n *\n * Walks `app.registry.actions` once and emits a `POST\n * <basePath>/actions/<actionName>` operation per action, deriving\n * the request-body schema from each action's Zod definition via\n * `z.toJSONSchema` (Zod 4's native JSON Schema 2020-12 emitter,\n * which is the OpenAPI 3.1 schema dialect — no conversion layer\n * needed). Error responses reference the shared\n * `#/components/responses/ApiError` so a single envelope shape\n * covers every error path.\n *\n * @param app A built `Act` orchestrator. Required.\n * @param options Document-shape fields (`info.title`, `info.version`,\n * servers, base path) and toggles for the cross-cutting headers\n * the live REST API may accept (`Idempotency-Key`, `If-Match`).\n * @returns A valid OpenAPI 3.1 document object — serve as\n * `/openapi.json`, ship to a CDN, write to disk during CI, or\n * pipe to a codegen tool.\n *\n * @throws Error if `info.title` / `info.version` is missing or\n * empty, or if any `servers[].url` fails URL parsing after\n * `{variable}` substitution.\n */\n/**\n * Structural shape of the Act surface this emitter walks. Letting\n * TApp infer to the caller's concrete `Act<TSchemaReg, ...>` instead\n * of forcing it to fit a narrow framework-typed upper bound keeps\n * the caller's variance from leaking — and avoids `any` in the\n * signature.\n *\n * @internal\n */\ntype ActRegistryView = {\n readonly registry: {\n actions: Record<\n string,\n {\n readonly name: string;\n readonly actions: Record<string, unknown>;\n }\n >;\n };\n};\n\nexport function openapi<TApp extends ActRegistryView>(\n app: TApp,\n options: OpenAPIOptions\n): OpenAPIDocument {\n validate_options(options);\n const base_path = options.basePath ?? DEFAULT_BASE_PATH;\n const paths: Record<string, OpenAPIPathItem> = {};\n\n for (const [action_name, state] of Object.entries(app.registry.actions)) {\n const zod_schema = state.actions[action_name] as z.ZodType;\n const body_schema = mark_sensitive_fields(\n strip_json_schema_meta(\n // `unrepresentable: \"any\"` emits an open (`{}`) schema for a Zod type\n // with no JSON-Schema form (z.date/z.bigint/z.map/z.set/...) instead of\n // Zod 4's default `\"throw\"`, which would abort the WHOLE document over\n // one such field — while hono() serves the same action fine (#1328).\n // `io: \"input\"` describes what a client must SEND: a `.default()`\n // field is optional, extra keys are not rejected (Zod strips them),\n // and a `.pipe()`/`.transform()` documents its input type. Zod's\n // default, `\"output\"`, describes the parsed result instead.\n z.toJSONSchema(zod_schema, {\n unrepresentable: \"any\",\n io: \"input\",\n }) as Record<string, unknown>\n ),\n zod_schema\n );\n paths[`${base_path}/actions/${action_name}`] = {\n post: build_operation(action_name, body_schema, options),\n };\n }\n\n return {\n openapi: \"3.1.0\",\n info: options.info,\n ...(options.servers ? { servers: options.servers } : {}),\n paths,\n components: {\n schemas: {\n ApiError: API_ERROR_SCHEMA,\n SnapshotArray: SNAPSHOT_ARRAY_SCHEMA,\n },\n responses: {\n ApiError: API_ERROR_RESPONSE,\n },\n },\n };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/openapi/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/openapi\n *\n * OpenAPI subpath for the auto-generated API epic (#835). Walks the\n * registry of a built `Act` once at function call and returns a\n * valid OpenAPI 3.1 document describing the same action surface the\n * Hono sibling at `@rotorsoft/act-http/hono` serves. The doc is\n * suitable for cross-language client codegen (Go, Python, Swift,\n * Kotlin, .NET), for serving over `/openapi.json` alongside a live\n * API, and for the broader OpenAPI tooling ecosystem (Postman,\n * Swagger UI, Redoc, schemathesis, API gateways).\n *\n * Usage:\n *\n * ```ts\n * import { openapi } from \"@rotorsoft/act-http/openapi\";\n *\n * const doc = openapi(app, {\n * info: { title: \"Wolfdesk API\", version: \"1.0.0\" },\n * servers: [{ url: \"https://api.example.com\" }],\n * });\n * // doc: OpenAPIDocument — valid OpenAPI 3.1 object\n * ```\n *\n * **Pure data emit.** No runtime dep on Hono or tRPC. The subpath is\n * consumable standalone for hosts that serve their API some other\n * way (Fastify, Lambda, edge) or that only want the doc for client\n * codegen. The emit walks the registry once at function call; cost\n * is linear in `N actions × M Zod fields each`, single-digit\n * milliseconds for a 100-endpoint registry.\n *\n * **Determinism.** Output is stable across runs given the same\n * registry — entries land in `Object.entries(app.registry.actions)`\n * iteration order, schemas come from the action's Zod definition,\n * and the rest of the doc is fixed content. CI can snapshot the\n * result to catch unintended API surface changes.\n *\n * The same shared utilities at `@rotorsoft/act-http/api` underwrite\n * cross-transport consistency: the `ApiError` envelope is referenced\n * once from `components.schemas`, every error response points at it,\n * and the same shape ships from the tRPC and Hono siblings at\n * runtime. A client speaking the doc and the live REST API sees one\n * envelope per framework error.\n *\n * **The doc describes the Hono surface, not tRPC.** tRPC's URL\n * shape (`POST /trpc/<procedure>`, JSON-RPC-style body framing,\n * batching) doesn't model cleanly as OpenAPI operations — and tRPC\n * consumers already share types directly via `typeof router`, so\n * OpenAPI buys them nothing. The path shape this emitter produces\n * (`POST <basePath>/actions/<name>`) matches the\n * `@rotorsoft/act-http/hono` sibling exactly: same default\n * `basePath`, same request/response shapes, same envelope. If the\n * operator overrides `basePath` on the Hono adapter, they pass the\n * same override here so the doc keeps matching the live routes by\n * construction. tRPC and Hono can run side-by-side at different\n * mount points on the same Act instance; the OpenAPI doc covers\n * the REST half only.\n */\nimport { pii_fields } from \"@rotorsoft/act\";\nimport { z } from \"zod\";\n\n/**\n * Minimal OpenAPI 3.1 document type. The emitter targets the 3.1\n * spec; the type is intentionally a structural subset rather than\n * the full surface from `openapi-types` so callers can post-process\n * without fighting the type system. Cast to a fuller type from\n * `openapi-types` when downstream consumers need it.\n */\nexport type OpenAPIDocument = {\n readonly openapi: \"3.1.0\";\n readonly info: OpenAPIInfo;\n readonly servers?: ReadonlyArray<OpenAPIServer>;\n readonly paths: Record<string, OpenAPIPathItem>;\n readonly components: OpenAPIComponents;\n};\n\n/**\n * Per-call options for {@link openapi}. The host supplies the\n * document-shape fields the registry can't derive (title, version,\n * servers) and toggles for the cross-cutting headers that the live\n * REST API may accept.\n *\n * - `info` — required. `title` and `version` must be non-empty;\n * `info` may carry any other top-level OpenAPI info fields.\n * - `servers` — optional. Each entry's `url` may contain\n * `{variable}` template syntax; bare URLs are validated through\n * `URL`'s parser and reject malformed inputs.\n * - `basePath` — optional, default `/api`. Mirrors the Hono\n * sibling's default so the doc describes the same paths the\n * generated REST surface serves.\n * - `idempotency` — optional, default `false`. When `true`, every\n * mutation operation documents a **required** `Idempotency-Key`\n * request header (the generated route fail-closes with 400 when the\n * header is absent, so the doc marks it required, not optional).\n * - `expectedVersion` — optional, default `false`. When `true`,\n * every mutation operation documents an optional `If-Match`\n * request header carrying the expected stream version.\n */\nexport type OpenAPIOptions = {\n readonly info: OpenAPIInfo;\n readonly servers?: ReadonlyArray<OpenAPIServer>;\n readonly basePath?: string;\n readonly idempotency?: boolean;\n readonly expectedVersion?: boolean;\n};\n\nexport type OpenAPIInfo = {\n readonly title: string;\n readonly version: string;\n readonly description?: string;\n readonly summary?: string;\n readonly termsOfService?: string;\n readonly contact?: {\n readonly name?: string;\n readonly url?: string;\n readonly email?: string;\n };\n readonly license?: {\n readonly name: string;\n readonly identifier?: string;\n readonly url?: string;\n };\n};\n\nexport type OpenAPIServer = {\n readonly url: string;\n readonly description?: string;\n readonly variables?: Record<\n string,\n {\n readonly default: string;\n readonly enum?: ReadonlyArray<string>;\n readonly description?: string;\n }\n >;\n};\n\nexport type OpenAPIPathItem = {\n readonly post?: OpenAPIOperation;\n};\n\nexport type OpenAPIOperation = {\n readonly operationId: string;\n readonly summary: string;\n readonly tags: ReadonlyArray<string>;\n readonly parameters?: ReadonlyArray<OpenAPIParameter>;\n readonly requestBody?: OpenAPIRequestBody;\n readonly responses: Record<string, OpenAPIResponse>;\n};\n\nexport type OpenAPIParameter = {\n readonly name: string;\n readonly in: \"header\" | \"query\" | \"path\" | \"cookie\";\n readonly required?: boolean;\n readonly description?: string;\n readonly schema?: Record<string, unknown>;\n};\n\nexport type OpenAPIRequestBody = {\n readonly required?: boolean;\n readonly content: Record<\n string,\n { readonly schema: Record<string, unknown> }\n >;\n};\n\nexport type OpenAPIResponse =\n | {\n readonly description: string;\n readonly content?: Record<\n string,\n { readonly schema: Record<string, unknown> }\n >;\n }\n | { readonly $ref: string };\n\nexport type OpenAPIComponents = {\n readonly schemas: Record<string, Record<string, unknown>>;\n readonly responses: Record<string, OpenAPIResponse>;\n};\n\nconst DEFAULT_BASE_PATH = \"/api\";\n\n/**\n * The shared `ApiError` envelope schema, ref'd by every error\n * response. Matches the runtime envelope at\n * `@rotorsoft/act-http/api`'s `toApiError` exactly — keep in sync if\n * the envelope shape evolves.\n */\nconst API_ERROR_SCHEMA: Record<string, unknown> = {\n type: \"object\",\n properties: {\n error: { type: \"string\" },\n detail: { type: \"string\" },\n code: { type: \"string\" },\n },\n required: [\"error\"],\n};\n\n/**\n * The generic snapshot-array response schema returned on a\n * successful mutation. Each action commits one or more events; the\n * shape here is `Snapshot[]` with the per-action state and event\n * data left as open `object` (deeper per-action typing would need\n * to walk `state.events[*]` and emit a discriminated union — a\n * future refinement once the doc consumers ask for it).\n */\nconst SNAPSHOT_ARRAY_SCHEMA: Record<string, unknown> = {\n type: \"array\",\n items: {\n type: \"object\",\n properties: {\n state: { type: \"object\" },\n event: { type: \"object\" },\n version: { type: \"integer\" },\n patches: { type: \"integer\" },\n snaps: { type: \"integer\" },\n cache_hit: { type: \"boolean\" },\n replayed: { type: \"integer\" },\n },\n required: [\"state\", \"version\", \"patches\", \"snaps\", \"cache_hit\", \"replayed\"],\n },\n};\n\n/**\n * The error-response shape every operation references via\n * `$ref: \"#/components/responses/ApiError\"`. Single source of\n * truth — bug fixes to the envelope flow to every operation\n * automatically.\n */\nconst API_ERROR_RESPONSE: OpenAPIResponse = {\n description: \"Error envelope per @rotorsoft/act-http/api's toApiError.\",\n content: {\n \"application/json\": {\n schema: { $ref: \"#/components/schemas/ApiError\" },\n },\n },\n};\n\n/**\n * Server URL refinement: OpenAPI permits `{variable}` template syntax\n * inside server URLs, so we substitute each capture with `x` before\n * parsing. The character class forbids both `{` and `}` inside the\n * template, matching OpenAPI's variable-name grammar exactly and\n * eliminating the catastrophic-backtracking surface CodeQL flagged\n * on `[^}]+`.\n *\n * @internal\n */\nfunction is_valid_server_url(url: string): boolean {\n try {\n new URL(url.replace(/\\{[^{}]+\\}/g, \"x\"));\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Zod schema for the minimum required shape of {@link OpenAPIOptions}.\n * Same declarative-validation pattern the rest of the framework uses;\n * the OpenAPI surface is mostly free-form (`description`, `contact`,\n * `license` — the spec lets through anything that JSON-stringifies),\n * so only the load-bearing fields land here. Hosts overriding into\n * unusual shapes still get the rest of the doc emitted; this schema\n * only fails the construction call when `info.title` / `info.version`\n * is missing-or-empty or a `servers[].url` won't parse.\n *\n * @internal\n */\nconst OpenAPIOptionsSchema = z.object({\n info: z.object({\n title: z\n .string({ message: \"openapi: info.title is required (non-empty string)\" })\n .trim()\n .min(1, \"openapi: info.title is required (non-empty string)\"),\n version: z\n .string({\n message: \"openapi: info.version is required (non-empty string)\",\n })\n .trim()\n .min(1, \"openapi: info.version is required (non-empty string)\"),\n }),\n servers: z\n .array(\n z.object({\n url: z.string().refine(is_valid_server_url, {\n message: \"openapi: invalid server url\",\n }),\n })\n )\n .optional(),\n});\n\nfunction validate_options(options: OpenAPIOptions): void {\n OpenAPIOptionsSchema.parse({\n info: { title: options.info?.title, version: options.info?.version },\n servers: options.servers,\n });\n}\n\nfunction strip_json_schema_meta(\n schema: Record<string, unknown>\n): Record<string, unknown> {\n // `$schema` at the top of an inline schema is harmless in OpenAPI\n // 3.1 but adds noise to the doc. Drop it.\n const { $schema: _, ...rest } = schema;\n return rest;\n}\n\n/**\n * Annotate the emitted request-body schema's sensitive properties so\n * codegen / Swagger UI treat them as secrets rather than echoing them\n * freely. `z.toJSONSchema` has no knowledge of the sensitive registry\n * (`sensitive()` marks schemas out-of-band in a WeakMap), so we walk the\n * action's declared sensitive fields — via `pii_fields`, the same lookup\n * the orchestrator uses — and mark each matching property `writeOnly:\n * true` + `format: password`. Non-object schemas and actions with no\n * sensitive fields pass through untouched (the zero-cost common path).\n *\n * `pii_fields` only reports top-level keys of a `z.object`, and Zod's\n * JSON Schema emit always renders those as members of `properties` — so\n * once `fields` is non-empty, `body_schema.properties[field]` is\n * guaranteed present. No defensive fallback needed.\n *\n * @internal\n */\nfunction mark_sensitive_fields(\n body_schema: Record<string, unknown>,\n zod_schema: z.ZodType\n): Record<string, unknown> {\n const fields = pii_fields(zod_schema);\n if (fields.length === 0) return body_schema;\n const properties = body_schema.properties as Record<\n string,\n Record<string, unknown>\n >;\n const next_properties: Record<string, Record<string, unknown>> = {\n ...properties,\n };\n for (const field of fields) {\n next_properties[field] = {\n ...properties[field],\n writeOnly: true,\n format: \"password\",\n };\n }\n return { ...body_schema, properties: next_properties };\n}\n\nfunction build_operation(\n action_name: string,\n body_schema: Record<string, unknown>,\n options: OpenAPIOptions\n): OpenAPIOperation {\n const parameters: OpenAPIParameter[] = [];\n if (options.idempotency) {\n parameters.push({\n name: \"Idempotency-Key\",\n in: \"header\",\n // Required: the generated route fail-closes with 400 when idempotency is\n // enabled and the header is absent, so the doc must not advertise it as\n // optional (#1287).\n required: true,\n description:\n \"Required idempotency token. A duplicate of a request that already succeeded produces a 409 Conflict (the first result is not cached on the server). A duplicate that arrives while the original is still running produces a retryable 503 with Retry-After, because the original may yet fail.\",\n schema: { type: \"string\" },\n });\n }\n if (options.expectedVersion) {\n parameters.push({\n name: \"If-Match\",\n in: \"header\",\n required: false,\n description:\n \"Expected stream version for optimistic concurrency. Mismatch produces a 412 Precondition Failed via ConcurrencyError.\",\n schema: { type: \"string\" },\n });\n }\n\n return {\n operationId: action_name,\n summary: `Commit the ${action_name} action`,\n tags: [\"Actions\"],\n ...(parameters.length > 0 ? { parameters } : {}),\n requestBody: {\n required: true,\n content: {\n \"application/json\": { schema: body_schema },\n },\n },\n responses: {\n \"200\": {\n description: \"Action committed; returns the snapshot array.\",\n content: {\n \"application/json\": {\n schema: { $ref: \"#/components/schemas/SnapshotArray\" },\n },\n },\n },\n \"400\": { $ref: \"#/components/responses/ApiError\" },\n \"409\": { $ref: \"#/components/responses/ApiError\" },\n \"410\": { $ref: \"#/components/responses/ApiError\" },\n \"412\": { $ref: \"#/components/responses/ApiError\" },\n \"422\": { $ref: \"#/components/responses/ApiError\" },\n \"500\": { $ref: \"#/components/responses/ApiError\" },\n ...(options.idempotency\n ? { \"503\": { $ref: \"#/components/responses/ApiError\" } }\n : {}),\n },\n };\n}\n\n/**\n * Structural shape of the Act surface this emitter walks. Letting\n * TApp infer to the caller's concrete `Act<TSchemaReg, ...>` instead\n * of forcing it to fit a narrow framework-typed upper bound keeps\n * the caller's variance from leaking — and avoids `any` in the\n * signature.\n *\n * @internal\n */\ntype ActRegistryView = {\n readonly registry: {\n actions: Record<\n string,\n {\n readonly name: string;\n readonly actions: Record<string, unknown>;\n }\n >;\n };\n};\n\n/**\n * Build an OpenAPI 3.1 document describing a built `Act` instance's\n * action surface.\n *\n * Walks `app.registry.actions` once and emits a `POST\n * <basePath>/actions/<actionName>` operation per action, deriving\n * the request-body schema from each action's Zod definition via\n * `z.toJSONSchema` (Zod 4's native JSON Schema 2020-12 emitter,\n * which is the OpenAPI 3.1 schema dialect — no conversion layer\n * needed). Error responses reference the shared\n * `#/components/responses/ApiError` so a single envelope shape\n * covers every error path.\n *\n * @param app A built `Act` orchestrator. Required.\n * @param options Document-shape fields (`info.title`, `info.version`,\n * servers, base path) and toggles for the cross-cutting headers\n * the live REST API may accept (`Idempotency-Key`, `If-Match`).\n * @returns A valid OpenAPI 3.1 document object — serve as\n * `/openapi.json`, ship to a CDN, write to disk during CI, or\n * pipe to a codegen tool.\n *\n * @throws Error if `info.title` / `info.version` is missing or\n * empty, or if any `servers[].url` fails URL parsing after\n * `{variable}` substitution.\n */\nexport function openapi<TApp extends ActRegistryView>(\n app: TApp,\n options: OpenAPIOptions\n): OpenAPIDocument {\n validate_options(options);\n const base_path = options.basePath ?? DEFAULT_BASE_PATH;\n const paths: Record<string, OpenAPIPathItem> = {};\n\n for (const [action_name, state] of Object.entries(app.registry.actions)) {\n const zod_schema = state.actions[action_name] as z.ZodType;\n const body_schema = mark_sensitive_fields(\n strip_json_schema_meta(\n // `unrepresentable: \"any\"` emits an open (`{}`) schema for a Zod type\n // with no JSON-Schema form (z.date/z.bigint/z.map/z.set/...) instead of\n // Zod 4's default `\"throw\"`, which would abort the WHOLE document over\n // one such field — while hono() serves the same action fine (#1328).\n // `io: \"input\"` describes what a client must SEND: a `.default()`\n // field is optional, extra keys are not rejected (Zod strips them),\n // and a `.pipe()`/`.transform()` documents its input type. Zod's\n // default, `\"output\"`, describes the parsed result instead.\n z.toJSONSchema(zod_schema, {\n unrepresentable: \"any\",\n io: \"input\",\n }) as Record<string, unknown>\n ),\n zod_schema\n );\n paths[`${base_path}/actions/${action_name}`] = {\n post: build_operation(action_name, body_schema, options),\n };\n }\n\n return {\n openapi: \"3.1.0\",\n info: options.info,\n ...(options.servers ? { servers: options.servers } : {}),\n paths,\n components: {\n schemas: {\n ApiError: API_ERROR_SCHEMA,\n SnapshotArray: SNAPSHOT_ARRAY_SCHEMA,\n },\n responses: {\n ApiError: API_ERROR_RESPONSE,\n },\n },\n };\n}\n"],"mappings":"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","names":[]}
@@ -100,9 +100,11 @@ async function checkWebhook(headers, body, options) {
100
100
  }
101
101
 
102
102
  // src/receiver/finalize.ts
103
- function make_finalizers(store, key, deduped) {
103
+ function make_idempotency(store, { key, deduped }) {
104
104
  let settled = deduped;
105
105
  return {
106
+ key,
107
+ deduped,
106
108
  async commit() {
107
109
  if (settled) return;
108
110
  settled = true;
@@ -129,17 +131,7 @@ function webhookMiddleware(options) {
129
131
  res.status(result.status).json({ error: result.reason });
130
132
  return;
131
133
  }
132
- const { commit, release } = make_finalizers(
133
- options.store,
134
- result.key,
135
- result.deduped
136
- );
137
- req.idempotency = {
138
- key: result.key,
139
- deduped: result.deduped,
140
- commit,
141
- release
142
- };
134
+ req.idempotency = make_idempotency(options.store, result);
143
135
  next();
144
136
  };
145
137
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/receiver/express/index.ts","../../../src/receiver/extract.ts","../../../src/receiver/verify.ts","../../../src/receiver/check.ts","../../../src/receiver/finalize.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/receiver/express\n *\n * Express adapter for the receiver-side webhook check.\n *\n * Usage:\n *\n * ```ts\n * import express from \"express\";\n * import { webhookMiddleware } from \"@rotorsoft/act-http/receiver/express\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n *\n * const app = express();\n * const dedup = new InMemoryIdempotencyStore();\n *\n * // Raw body capture required when signing is enabled.\n * app.use(express.raw({ type: \"application/json\" }));\n *\n * app.post(\n * \"/webhooks/orders\",\n * webhookMiddleware({ store: dedup, secret: process.env.WEBHOOK_SECRET }),\n * (req, res) => {\n * const { key, deduped } = (req as any).idempotency;\n * if (deduped) return res.json({ status: \"dedup-skipped\", key });\n * // ... process the inbound event ...\n * res.json({ status: \"processed\", key });\n * }\n * );\n * ```\n *\n * On failure: responds with the framework-idiomatic JSON shape\n * `{ error: <reason> }` at status 400 (missing-key) or 401\n * (verification failures), and does not call `next()`. On success:\n * attaches `req.idempotency = { key, deduped, commit, release }` and\n * calls `next()`.\n *\n * **Two-phase dedup**: the claim is *tentative*. Express runs the\n * middleware to completion before the route handler, so — unlike the\n * Hono adapter — it can't observe the handler's outcome to finalize\n * automatically. The route handler **must** call\n * `req.idempotency.commit()` on success or `req.idempotency.release()`\n * on a transient failure. Skipping both leaves the claim tentative:\n * it dedups concurrent duplicates but expires on TTL, so the delivery\n * is never permanently lost — it just isn't durably deduped either.\n *\n * **Raw body requirement**: when `secret` is configured, mount\n * `express.raw({ type: \"application/json\" })` (or whatever\n * content-type your webhooks use) ahead of the receiver middleware.\n * The middleware reads `req.body` as a `Buffer | string` and converts\n * to a UTF-8 string for hashing. Skip when unsigned. If the raw parser\n * isn't mounted (the default `express.json()` ate the bytes, leaving a\n * parsed object the adapter can't hash), the middleware short-circuits\n * with `400 { error: \"empty-body\" }` instead of hashing an empty string\n * and rejecting every valid request with a misleading `401\n * bad-signature`.\n */\nimport type { NextFunction, Request, RequestHandler, Response } from \"express\";\nimport { type CheckWebhookOptions, checkWebhook } from \"../check.js\";\nimport { type Finalizers, make_finalizers } from \"../finalize.js\";\n\n/** Shape attached to `req.idempotency` by the Express adapter. */\nexport type ExpressIdempotency = {\n key: string;\n deduped: boolean;\n} & Finalizers;\n\n/**\n * Build an Express middleware that verifies the request signature\n * (when `secret` is set), enforces `Idempotency-Key`, and claims the\n * key on the configured store. See the module-level docs for usage.\n */\nexport function webhookMiddleware(\n options: CheckWebhookOptions\n): RequestHandler {\n return async function check(\n req: Request,\n res: Response,\n next: NextFunction\n ): Promise<void> {\n const rawBody = buffer_or_string(req.body);\n const result = await checkWebhook(\n req.headers as Record<string, string | string[] | undefined>,\n rawBody,\n options\n );\n if (!result.ok) {\n res.status(result.status).json({ error: result.reason });\n return;\n }\n const { commit, release } = make_finalizers(\n options.store,\n result.key,\n result.deduped\n );\n (req as Request & { idempotency: ExpressIdempotency }).idempotency = {\n key: result.key,\n deduped: result.deduped,\n commit,\n release,\n };\n next();\n };\n}\n\nfunction buffer_or_string(body: unknown): string {\n if (typeof body === \"string\") return body;\n if (body instanceof Uint8Array) return Buffer.from(body).toString(\"utf8\");\n return \"\";\n}\n","/**\n * Pull the `Idempotency-Key` header from a Node-style headers bag,\n * case-insensitive. Returns `undefined` when any of the following\n * carries no usable key:\n *\n * - the header is missing\n * - its value is an array (ambiguous — can't pick one without a\n * policy the receiver hasn't declared)\n * - its value is the empty string (carries no idempotency\n * information; structurally equivalent to \"no header at all\")\n *\n * Pair with `IdempotencyStore.claim` from\n * `@rotorsoft/act-ops/idempotency`: extract the key from the inbound\n * request, claim it on the store, return a `deduped` marker when the\n * claim fails. The framework-agnostic middleware that wires these\n * together lands in #744.\n *\n * Validation beyond \"is there a usable key?\" (length bounds, format\n * checks, normalization) is intentionally out of scope. Receivers\n * picking a policy can layer it on top — or, when #744 ships, opt\n * into the middleware's opinionated defaults.\n */\nexport function extractIdempotencyKey(\n headers: Record<string, string | string[] | undefined>\n): string | undefined {\n for (const [name, value] of Object.entries(headers)) {\n if (name.toLowerCase() !== \"idempotency-key\") continue;\n if (Array.isArray(value)) return undefined;\n if (value === \"\") return undefined;\n return value;\n }\n return undefined;\n}\n","import { createHmac, timingSafeEqual } from \"node:crypto\";\n\n/**\n * Outcome of {@link verifyWebhook}. Either the request signature\n * checks out, or one of five distinct failure reasons applies. Each\n * reason maps to an operator-meaningful telemetry bucket — separated\n * deliberately so dashboards can distinguish \"client lost its secret\"\n * from \"client clock is wrong\" from \"this is a replay attack.\"\n */\nexport type VerifyResult =\n | { ok: true }\n | {\n ok: false;\n reason:\n | \"missing-signature\"\n | \"missing-timestamp\"\n | \"stale\"\n | \"future\"\n | \"bad-signature\";\n };\n\n/** Options for {@link verifyWebhook}. */\nexport type VerifyOptions = {\n /**\n * Maximum acceptable timestamp drift in either direction, in\n * seconds. Default: 300 (±5 minutes) — matches Stripe / GitHub /\n * Slack conventions. Tightening narrows the replay window;\n * loosening accommodates clients with worse clock sync.\n */\n maxAgeSeconds?: number;\n /**\n * Current Unix-seconds time. Exposed for tests; production\n * callers should leave it undefined so wall-clock is used.\n */\n now?: number;\n};\n\n/**\n * Verify an inbound webhook's signature and timestamp against the\n * shared secret. Pair with the sender side: configure\n * `webhook({ secret })` from `@rotorsoft/act-http/webhook`.\n *\n * Returns `{ ok: true }` on success or `{ ok: false; reason }` on\n * failure. The reasons are:\n *\n * - `missing-signature` — no `X-Webhook-Signature` header, value\n * was an array, or value was empty.\n * - `missing-timestamp` — no `X-Webhook-Timestamp` header, value\n * was empty, or value isn't a parseable integer.\n * - `stale` — timestamp older than `maxAgeSeconds` from `now`.\n * - `future` — timestamp more than `maxAgeSeconds` ahead of `now`.\n * - `bad-signature` — signature header didn't start with `sha256=`,\n * wasn't 64 hex chars, or the recomputed HMAC didn't match\n * (constant-time compare).\n *\n * The signed payload is `${timestamp}.${body}`, so `body` must be\n * the **raw request body bytes**. Any pre-parse normalization\n * (whitespace trimming, JSON re-stringification) would change the\n * hash and reject every otherwise-valid request. Framework adapters\n * in #744 will provide the raw body alongside the parsed one.\n *\n * Uses Node's `crypto.timingSafeEqual` for the final comparison to\n * avoid signature-equality timing attacks.\n */\nexport function verifyWebhook(\n headers: Record<string, string | string[] | undefined>,\n body: string,\n secret: string,\n options?: VerifyOptions\n): VerifyResult {\n const maxAgeSeconds = options?.maxAgeSeconds ?? 300;\n const now = options?.now ?? Math.floor(Date.now() / 1000);\n\n const signature = pick_header(headers, \"x-webhook-signature\");\n if (!signature) return { ok: false, reason: \"missing-signature\" };\n\n const timestamp_str = pick_header(headers, \"x-webhook-timestamp\");\n if (!timestamp_str) return { ok: false, reason: \"missing-timestamp\" };\n const timestamp = Number.parseInt(timestamp_str, 10);\n if (Number.isNaN(timestamp) || String(timestamp) !== timestamp_str) {\n return { ok: false, reason: \"missing-timestamp\" };\n }\n\n const delta = now - timestamp;\n if (delta > maxAgeSeconds) return { ok: false, reason: \"stale\" };\n if (delta < -maxAgeSeconds) return { ok: false, reason: \"future\" };\n\n if (!signature.startsWith(\"sha256=\")) {\n return { ok: false, reason: \"bad-signature\" };\n }\n const provided_hex = signature.slice(\"sha256=\".length);\n if (!/^[0-9a-fA-F]{64}$/.test(provided_hex)) {\n return { ok: false, reason: \"bad-signature\" };\n }\n\n const expected_hex = createHmac(\"sha256\", secret)\n .update(`${timestamp_str}.${body}`)\n .digest(\"hex\");\n\n const a = Buffer.from(provided_hex, \"hex\");\n const b = Buffer.from(expected_hex, \"hex\");\n if (!timingSafeEqual(a, b)) {\n return { ok: false, reason: \"bad-signature\" };\n }\n\n return { ok: true };\n}\n\nfunction pick_header(\n headers: Record<string, string | string[] | undefined>,\n lower_name: string\n): string | undefined {\n for (const [name, value] of Object.entries(headers)) {\n if (name.toLowerCase() !== lower_name) continue;\n if (Array.isArray(value) || value === undefined || value === \"\") {\n return undefined;\n }\n return value;\n }\n return undefined;\n}\n","import type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\nimport { extractIdempotencyKey } from \"./extract.js\";\nimport { type VerifyOptions, verifyWebhook } from \"./verify.js\";\n\n/**\n * Failure reasons returned by {@link checkWebhook}. The shape splits\n * 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":[]}
1
+ {"version":3,"sources":["../../../src/receiver/express/index.ts","../../../src/receiver/extract.ts","../../../src/receiver/verify.ts","../../../src/receiver/check.ts","../../../src/receiver/finalize.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/receiver/express\n *\n * Express adapter for the receiver-side webhook check.\n *\n * Usage:\n *\n * ```ts\n * import express from \"express\";\n * import { webhookMiddleware } from \"@rotorsoft/act-http/receiver/express\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n *\n * const app = express();\n * const dedup = new InMemoryIdempotencyStore();\n *\n * // Raw body capture required when signing is enabled.\n * app.use(express.raw({ type: \"application/json\" }));\n *\n * app.post(\n * \"/webhooks/orders\",\n * webhookMiddleware({ store: dedup, secret: process.env.WEBHOOK_SECRET }),\n * (req, res) => {\n * const { key, deduped } = (req as any).idempotency;\n * if (deduped) return res.json({ status: \"dedup-skipped\", key });\n * // ... process the inbound event ...\n * res.json({ status: \"processed\", key });\n * }\n * );\n * ```\n *\n * On failure: responds with the framework-idiomatic JSON shape\n * `{ error: <reason> }` at status 400 (missing-key) or 401\n * (verification failures), and does not call `next()`. On success:\n * attaches `req.idempotency = { key, deduped, commit, release }` and\n * calls `next()`.\n *\n * **Two-phase dedup**: the claim is *tentative*. Express runs the\n * middleware to completion before the route handler, so — unlike the\n * Hono adapter — it can't observe the handler's outcome to finalize\n * automatically. The route handler **must** call\n * `req.idempotency.commit()` on success or `req.idempotency.release()`\n * on a transient failure. Skipping both leaves the claim tentative:\n * it dedups concurrent duplicates but expires on TTL, so the delivery\n * is never permanently lost — it just isn't durably deduped either.\n *\n * **Raw body requirement**: when `secret` is configured, mount\n * `express.raw({ type: \"application/json\" })` (or whatever\n * content-type your webhooks use) ahead of the receiver middleware.\n * The middleware reads `req.body` as a `Buffer | string` and converts\n * to a UTF-8 string for hashing. Skip when unsigned. If the raw parser\n * isn't mounted (the default `express.json()` ate the bytes, leaving a\n * parsed object the adapter can't hash), the middleware short-circuits\n * with `400 { error: \"empty-body\" }` instead of hashing an empty string\n * and rejecting every valid request with a misleading `401\n * bad-signature`.\n */\nimport type { NextFunction, Request, RequestHandler, Response } from \"express\";\nimport { type CheckWebhookOptions, checkWebhook } from \"../check.js\";\nimport { type Finalizers, make_idempotency } from \"../finalize.js\";\n\n/** Shape attached to `req.idempotency` by the Express adapter. */\nexport type ExpressIdempotency = {\n key: string;\n deduped: boolean;\n} & Finalizers;\n\n/**\n * Build an Express middleware that verifies the request signature\n * (when `secret` is set), enforces `Idempotency-Key`, and claims the\n * key on the configured store. See the module-level docs for usage.\n */\nexport function webhookMiddleware(\n options: CheckWebhookOptions\n): RequestHandler {\n return async function check(\n req: Request,\n res: Response,\n next: NextFunction\n ): Promise<void> {\n const rawBody = buffer_or_string(req.body);\n const result = await checkWebhook(\n req.headers as Record<string, string | string[] | undefined>,\n rawBody,\n options\n );\n if (!result.ok) {\n res.status(result.status).json({ error: result.reason });\n return;\n }\n (req as Request & { idempotency: ExpressIdempotency }).idempotency =\n make_idempotency(options.store, result);\n next();\n };\n}\n\nfunction buffer_or_string(body: unknown): string {\n if (typeof body === \"string\") return body;\n if (body instanceof Uint8Array) return Buffer.from(body).toString(\"utf8\");\n return \"\";\n}\n","/**\n * Pull the `Idempotency-Key` header from a Node-style headers bag,\n * case-insensitive. Returns `undefined` when any of the following\n * carries no usable key:\n *\n * - the header is missing\n * - its value is an array (ambiguous — can't pick one without a\n * policy the receiver hasn't declared)\n * - its value is the empty string (carries no idempotency\n * information; structurally equivalent to \"no header at all\")\n *\n * Pair with `IdempotencyStore.claim` from\n * `@rotorsoft/act-ops/idempotency`: extract the key from the inbound\n * request, claim it on the store, return a `deduped` marker when the\n * claim fails. The framework-agnostic middleware that wires these\n * together lands in #744.\n *\n * Validation beyond \"is there a usable key?\" (length bounds, format\n * checks, normalization) is intentionally out of scope. Receivers\n * picking a policy can layer it on top — or, when #744 ships, opt\n * into the middleware's opinionated defaults.\n */\nexport function extractIdempotencyKey(\n headers: Record<string, string | string[] | undefined>\n): string | undefined {\n for (const [name, value] of Object.entries(headers)) {\n if (name.toLowerCase() !== \"idempotency-key\") continue;\n if (Array.isArray(value)) return undefined;\n if (value === \"\") return undefined;\n return value;\n }\n return undefined;\n}\n","import { createHmac, timingSafeEqual } from \"node:crypto\";\n\n/**\n * Outcome of {@link verifyWebhook}. Either the request signature\n * checks out, or one of five distinct failure reasons applies. Each\n * reason maps to an operator-meaningful telemetry bucket — separated\n * deliberately so dashboards can distinguish \"client lost its secret\"\n * from \"client clock is wrong\" from \"this is a replay attack.\"\n */\nexport type VerifyResult =\n | { ok: true }\n | {\n ok: false;\n reason:\n | \"missing-signature\"\n | \"missing-timestamp\"\n | \"stale\"\n | \"future\"\n | \"bad-signature\";\n };\n\n/** Options for {@link verifyWebhook}. */\nexport type VerifyOptions = {\n /**\n * Maximum acceptable timestamp drift in either direction, in\n * seconds. Default: 300 (±5 minutes) — matches Stripe / GitHub /\n * Slack conventions. Tightening narrows the replay window;\n * loosening accommodates clients with worse clock sync.\n */\n maxAgeSeconds?: number;\n /**\n * Current Unix-seconds time. Exposed for tests; production\n * callers should leave it undefined so wall-clock is used.\n */\n now?: number;\n};\n\n/**\n * Verify an inbound webhook's signature and timestamp against the\n * shared secret. Pair with the sender side: configure\n * `webhook({ secret })` from `@rotorsoft/act-http/webhook`.\n *\n * Returns `{ ok: true }` on success or `{ ok: false; reason }` on\n * failure. The reasons are:\n *\n * - `missing-signature` — no `X-Webhook-Signature` header, value\n * was an array, or value was empty.\n * - `missing-timestamp` — no `X-Webhook-Timestamp` header, value\n * was empty, or value isn't a parseable integer.\n * - `stale` — timestamp older than `maxAgeSeconds` from `now`.\n * - `future` — timestamp more than `maxAgeSeconds` ahead of `now`.\n * - `bad-signature` — signature header didn't start with `sha256=`,\n * wasn't 64 hex chars, or the recomputed HMAC didn't match\n * (constant-time compare).\n *\n * The signed payload is `${timestamp}.${body}`, so `body` must be\n * the **raw request body bytes**. Any pre-parse normalization\n * (whitespace trimming, JSON re-stringification) would change the\n * hash and reject every otherwise-valid request. Framework adapters\n * in #744 will provide the raw body alongside the parsed one.\n *\n * Uses Node's `crypto.timingSafeEqual` for the final comparison to\n * avoid signature-equality timing attacks.\n */\nexport function verifyWebhook(\n headers: Record<string, string | string[] | undefined>,\n body: string,\n secret: string,\n options?: VerifyOptions\n): VerifyResult {\n const maxAgeSeconds = options?.maxAgeSeconds ?? 300;\n const now = options?.now ?? Math.floor(Date.now() / 1000);\n\n const signature = pick_header(headers, \"x-webhook-signature\");\n if (!signature) return { ok: false, reason: \"missing-signature\" };\n\n const timestamp_str = pick_header(headers, \"x-webhook-timestamp\");\n if (!timestamp_str) return { ok: false, reason: \"missing-timestamp\" };\n const timestamp = Number.parseInt(timestamp_str, 10);\n if (Number.isNaN(timestamp) || String(timestamp) !== timestamp_str) {\n return { ok: false, reason: \"missing-timestamp\" };\n }\n\n const delta = now - timestamp;\n if (delta > maxAgeSeconds) return { ok: false, reason: \"stale\" };\n if (delta < -maxAgeSeconds) return { ok: false, reason: \"future\" };\n\n if (!signature.startsWith(\"sha256=\")) {\n return { ok: false, reason: \"bad-signature\" };\n }\n const provided_hex = signature.slice(\"sha256=\".length);\n if (!/^[0-9a-fA-F]{64}$/.test(provided_hex)) {\n return { ok: false, reason: \"bad-signature\" };\n }\n\n const expected_hex = createHmac(\"sha256\", secret)\n .update(`${timestamp_str}.${body}`)\n .digest(\"hex\");\n\n const a = Buffer.from(provided_hex, \"hex\");\n const b = Buffer.from(expected_hex, \"hex\");\n if (!timingSafeEqual(a, b)) {\n return { ok: false, reason: \"bad-signature\" };\n }\n\n return { ok: true };\n}\n\nfunction pick_header(\n headers: Record<string, string | string[] | undefined>,\n lower_name: string\n): string | undefined {\n for (const [name, value] of Object.entries(headers)) {\n if (name.toLowerCase() !== lower_name) continue;\n if (Array.isArray(value) || value === undefined || value === \"\") {\n return undefined;\n }\n return value;\n }\n return undefined;\n}\n","import type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\nimport { extractIdempotencyKey } from \"./extract.js\";\nimport { type VerifyOptions, verifyWebhook } from \"./verify.js\";\n\n/**\n * Failure reasons returned by {@link checkWebhook}. The shape splits\n * 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 * The `idempotency` bag a receiver adapter hands its route for one\n * delivery: the claimed key, whether it was a duplicate, and the\n * {@link Finalizers}.\n */\nexport type Idempotency = { key: string; deduped: boolean } & Finalizers;\n\n/**\n * Build the {@link Idempotency} bag for one delivery that passed\n * `checkWebhook`.\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_idempotency(\n store: IdempotencyStore,\n { key, deduped }: { key: string; deduped: boolean }\n): Idempotency {\n let settled = deduped;\n return {\n key,\n deduped,\n async commit() {\n if (settled) return;\n settled = true;\n await store.commit(key);\n },\n async release() {\n if (settled) return;\n settled = true;\n await store.release(key);\n },\n };\n}\n"],"mappings":"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","names":[]}
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  checkWebhook,
3
- make_finalizers
4
- } from "../../chunk-DMDU25DN.js";
3
+ make_idempotency
4
+ } from "../../chunk-G5FNOSDX.js";
5
5
  import "../../chunk-PZ5AY32C.js";
6
6
 
7
7
  // src/receiver/express/index.ts
@@ -17,17 +17,7 @@ function webhookMiddleware(options) {
17
17
  res.status(result.status).json({ error: result.reason });
18
18
  return;
19
19
  }
20
- const { commit, release } = make_finalizers(
21
- options.store,
22
- result.key,
23
- result.deduped
24
- );
25
- req.idempotency = {
26
- key: result.key,
27
- deduped: result.deduped,
28
- commit,
29
- release
30
- };
20
+ req.idempotency = make_idempotency(options.store, result);
31
21
  next();
32
22
  };
33
23
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/receiver/express/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/receiver/express\n *\n * Express adapter for the receiver-side webhook check.\n *\n * Usage:\n *\n * ```ts\n * import express from \"express\";\n * import { webhookMiddleware } from \"@rotorsoft/act-http/receiver/express\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n *\n * const app = express();\n * const dedup = new InMemoryIdempotencyStore();\n *\n * // Raw body capture required when signing is enabled.\n * app.use(express.raw({ type: \"application/json\" }));\n *\n * app.post(\n * \"/webhooks/orders\",\n * webhookMiddleware({ store: dedup, secret: process.env.WEBHOOK_SECRET }),\n * (req, res) => {\n * const { key, deduped } = (req as any).idempotency;\n * if (deduped) return res.json({ status: \"dedup-skipped\", key });\n * // ... process the inbound event ...\n * res.json({ status: \"processed\", key });\n * }\n * );\n * ```\n *\n * On failure: responds with the framework-idiomatic JSON shape\n * `{ error: <reason> }` at status 400 (missing-key) or 401\n * (verification failures), and does not call `next()`. On success:\n * attaches `req.idempotency = { key, deduped, commit, release }` and\n * calls `next()`.\n *\n * **Two-phase dedup**: the claim is *tentative*. Express runs the\n * middleware to completion before the route handler, so — unlike the\n * Hono adapter — it can't observe the handler's outcome to finalize\n * automatically. The route handler **must** call\n * `req.idempotency.commit()` on success or `req.idempotency.release()`\n * on a transient failure. Skipping both leaves the claim tentative:\n * it dedups concurrent duplicates but expires on TTL, so the delivery\n * is never permanently lost — it just isn't durably deduped either.\n *\n * **Raw body requirement**: when `secret` is configured, mount\n * `express.raw({ type: \"application/json\" })` (or whatever\n * content-type your webhooks use) ahead of the receiver middleware.\n * The middleware reads `req.body` as a `Buffer | string` and converts\n * to a UTF-8 string for hashing. Skip when unsigned. If the raw parser\n * isn't mounted (the default `express.json()` ate the bytes, leaving a\n * parsed object the adapter can't hash), the middleware short-circuits\n * with `400 { error: \"empty-body\" }` instead of hashing an empty string\n * and rejecting every valid request with a misleading `401\n * bad-signature`.\n */\nimport type { NextFunction, Request, RequestHandler, Response } from \"express\";\nimport { type CheckWebhookOptions, checkWebhook } from \"../check.js\";\nimport { type Finalizers, make_finalizers } from \"../finalize.js\";\n\n/** Shape attached to `req.idempotency` by the Express adapter. */\nexport type ExpressIdempotency = {\n key: string;\n deduped: boolean;\n} & Finalizers;\n\n/**\n * Build an Express middleware that verifies the request signature\n * (when `secret` is set), enforces `Idempotency-Key`, and claims the\n * key on the configured store. See the module-level docs for usage.\n */\nexport function webhookMiddleware(\n options: CheckWebhookOptions\n): RequestHandler {\n return async function check(\n req: Request,\n res: Response,\n next: NextFunction\n ): Promise<void> {\n const rawBody = buffer_or_string(req.body);\n const result = await checkWebhook(\n req.headers as Record<string, string | string[] | undefined>,\n rawBody,\n options\n );\n if (!result.ok) {\n res.status(result.status).json({ error: result.reason });\n return;\n }\n const { commit, release } = make_finalizers(\n options.store,\n result.key,\n result.deduped\n );\n (req as Request & { idempotency: ExpressIdempotency }).idempotency = {\n key: result.key,\n deduped: result.deduped,\n commit,\n release,\n };\n next();\n };\n}\n\nfunction buffer_or_string(body: unknown): string {\n if (typeof body === \"string\") return body;\n if (body instanceof Uint8Array) return Buffer.from(body).toString(\"utf8\");\n return \"\";\n}\n"],"mappings":";;;;;;;AAwEO,SAAS,kBACd,SACgB;AAChB,SAAO,eAAe,MACpB,KACA,KACA,MACe;AACf,UAAM,UAAU,iBAAiB,IAAI,IAAI;AACzC,UAAM,SAAS,MAAM;AAAA,MACnB,IAAI;AAAA,MACJ;AAAA,MACA;AAAA,IACF;AACA,QAAI,CAAC,OAAO,IAAI;AACd,UAAI,OAAO,OAAO,MAAM,EAAE,KAAK,EAAE,OAAO,OAAO,OAAO,CAAC;AACvD;AAAA,IACF;AACA,UAAM,EAAE,QAAQ,QAAQ,IAAI;AAAA,MAC1B,QAAQ;AAAA,MACR,OAAO;AAAA,MACP,OAAO;AAAA,IACT;AACA,IAAC,IAAsD,cAAc;AAAA,MACnE,KAAK,OAAO;AAAA,MACZ,SAAS,OAAO;AAAA,MAChB;AAAA,MACA;AAAA,IACF;AACA,SAAK;AAAA,EACP;AACF;AAEA,SAAS,iBAAiB,MAAuB;AAC/C,MAAI,OAAO,SAAS,SAAU,QAAO;AACrC,MAAI,gBAAgB,WAAY,QAAO,OAAO,KAAK,IAAI,EAAE,SAAS,MAAM;AACxE,SAAO;AACT;","names":[]}
1
+ {"version":3,"sources":["../../../src/receiver/express/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/receiver/express\n *\n * Express adapter for the receiver-side webhook check.\n *\n * Usage:\n *\n * ```ts\n * import express from \"express\";\n * import { webhookMiddleware } from \"@rotorsoft/act-http/receiver/express\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n *\n * const app = express();\n * const dedup = new InMemoryIdempotencyStore();\n *\n * // Raw body capture required when signing is enabled.\n * app.use(express.raw({ type: \"application/json\" }));\n *\n * app.post(\n * \"/webhooks/orders\",\n * webhookMiddleware({ store: dedup, secret: process.env.WEBHOOK_SECRET }),\n * (req, res) => {\n * const { key, deduped } = (req as any).idempotency;\n * if (deduped) return res.json({ status: \"dedup-skipped\", key });\n * // ... process the inbound event ...\n * res.json({ status: \"processed\", key });\n * }\n * );\n * ```\n *\n * On failure: responds with the framework-idiomatic JSON shape\n * `{ error: <reason> }` at status 400 (missing-key) or 401\n * (verification failures), and does not call `next()`. On success:\n * attaches `req.idempotency = { key, deduped, commit, release }` and\n * calls `next()`.\n *\n * **Two-phase dedup**: the claim is *tentative*. Express runs the\n * middleware to completion before the route handler, so — unlike the\n * Hono adapter — it can't observe the handler's outcome to finalize\n * automatically. The route handler **must** call\n * `req.idempotency.commit()` on success or `req.idempotency.release()`\n * on a transient failure. Skipping both leaves the claim tentative:\n * it dedups concurrent duplicates but expires on TTL, so the delivery\n * is never permanently lost — it just isn't durably deduped either.\n *\n * **Raw body requirement**: when `secret` is configured, mount\n * `express.raw({ type: \"application/json\" })` (or whatever\n * content-type your webhooks use) ahead of the receiver middleware.\n * The middleware reads `req.body` as a `Buffer | string` and converts\n * to a UTF-8 string for hashing. Skip when unsigned. If the raw parser\n * isn't mounted (the default `express.json()` ate the bytes, leaving a\n * parsed object the adapter can't hash), the middleware short-circuits\n * with `400 { error: \"empty-body\" }` instead of hashing an empty string\n * and rejecting every valid request with a misleading `401\n * bad-signature`.\n */\nimport type { NextFunction, Request, RequestHandler, Response } from \"express\";\nimport { type CheckWebhookOptions, checkWebhook } from \"../check.js\";\nimport { type Finalizers, make_idempotency } from \"../finalize.js\";\n\n/** Shape attached to `req.idempotency` by the Express adapter. */\nexport type ExpressIdempotency = {\n key: string;\n deduped: boolean;\n} & Finalizers;\n\n/**\n * Build an Express middleware that verifies the request signature\n * (when `secret` is set), enforces `Idempotency-Key`, and claims the\n * key on the configured store. See the module-level docs for usage.\n */\nexport function webhookMiddleware(\n options: CheckWebhookOptions\n): RequestHandler {\n return async function check(\n req: Request,\n res: Response,\n next: NextFunction\n ): Promise<void> {\n const rawBody = buffer_or_string(req.body);\n const result = await checkWebhook(\n req.headers as Record<string, string | string[] | undefined>,\n rawBody,\n options\n );\n if (!result.ok) {\n res.status(result.status).json({ error: result.reason });\n return;\n }\n (req as Request & { idempotency: ExpressIdempotency }).idempotency =\n make_idempotency(options.store, result);\n next();\n };\n}\n\nfunction buffer_or_string(body: unknown): string {\n if (typeof body === \"string\") return body;\n if (body instanceof Uint8Array) return Buffer.from(body).toString(\"utf8\");\n return \"\";\n}\n"],"mappings":";;;;;;;AAwEO,SAAS,kBACd,SACgB;AAChB,SAAO,eAAe,MACpB,KACA,KACA,MACe;AACf,UAAM,UAAU,iBAAiB,IAAI,IAAI;AACzC,UAAM,SAAS,MAAM;AAAA,MACnB,IAAI;AAAA,MACJ;AAAA,MACA;AAAA,IACF;AACA,QAAI,CAAC,OAAO,IAAI;AACd,UAAI,OAAO,OAAO,MAAM,EAAE,KAAK,EAAE,OAAO,OAAO,OAAO,CAAC;AACvD;AAAA,IACF;AACA,IAAC,IAAsD,cACrD,iBAAiB,QAAQ,OAAO,MAAM;AACxC,SAAK;AAAA,EACP;AACF;AAEA,SAAS,iBAAiB,MAAuB;AAC/C,MAAI,OAAO,SAAS,SAAU,QAAO;AACrC,MAAI,gBAAgB,WAAY,QAAO,OAAO,KAAK,IAAI,EAAE,SAAS,MAAM;AACxE,SAAO;AACT;","names":[]}
@@ -100,9 +100,11 @@ async function checkWebhook(headers, body, options) {
100
100
  }
101
101
 
102
102
  // src/receiver/finalize.ts
103
- function make_finalizers(store, key, deduped) {
103
+ function make_idempotency(store, { key, deduped }) {
104
104
  let settled = deduped;
105
105
  return {
106
+ key,
107
+ deduped,
106
108
  async commit() {
107
109
  if (settled) return;
108
110
  settled = true;
@@ -130,17 +132,7 @@ function webhookMiddleware(options) {
130
132
  await reply.status(result.status).send({ error: result.reason });
131
133
  return;
132
134
  }
133
- const { commit, release } = make_finalizers(
134
- options.store,
135
- result.key,
136
- result.deduped
137
- );
138
- req.idempotency = {
139
- key: result.key,
140
- deduped: result.deduped,
141
- commit,
142
- release
143
- };
135
+ req.idempotency = make_idempotency(options.store, result);
144
136
  };
145
137
  }
146
138
  // Annotate the CommonJS export names for ESM import in node:
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/receiver/fastify/index.ts","../../../src/receiver/extract.ts","../../../src/receiver/verify.ts","../../../src/receiver/check.ts","../../../src/receiver/finalize.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/receiver/fastify\n *\n * Fastify adapter for the receiver-side webhook check.\n *\n * Usage:\n *\n * ```ts\n * import Fastify from \"fastify\";\n * import { webhookMiddleware } from \"@rotorsoft/act-http/receiver/fastify\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n *\n * const app = Fastify();\n * const dedup = new InMemoryIdempotencyStore();\n *\n * app.post(\n * \"/webhooks/orders\",\n * {\n * preHandler: webhookMiddleware({\n * store: dedup,\n * secret: process.env.WEBHOOK_SECRET,\n * }),\n * },\n * async (request, reply) => {\n * const { key, deduped } = (request as any).idempotency;\n * if (deduped) return { status: \"dedup-skipped\", key };\n * // ... process the inbound event ...\n * return { status: \"processed\", key };\n * }\n * );\n * ```\n *\n * On failure: replies with `{ error: <reason> }` at status 400\n * (missing-key) or 401 (verification failures). On success: attaches\n * `request.idempotency = { key, deduped, commit, release }` and lets\n * the route handler run.\n *\n * **Two-phase dedup**: the claim is *tentative*. A Fastify\n * `preHandler` runs to completion before the route handler, so — like\n * the Express adapter — it can't finalize automatically. The route\n * handler **must** call `request.idempotency.commit()` on success or\n * `request.idempotency.release()` on a transient failure. Skipping\n * both leaves the claim tentative: it dedups concurrent duplicates but\n * expires on TTL, so a delivery is never permanently lost — it just\n * isn't durably deduped either.\n *\n * **Raw body requirement**: when `secret` is configured, register a\n * content-type parser that preserves the raw body string. Fastify's\n * default JSON parser eats the bytes — register a custom parser via\n * `app.addContentTypeParser(\"application/json\", { parseAs: \"string\" }, …)`\n * and stash the string on `request.rawBody` (Fastify pattern). The\n * middleware reads `request.rawBody` for hashing. Skip when unsigned. If\n * `request.rawBody` is empty because the parser wasn't registered, the\n * middleware short-circuits with `400 { error: \"empty-body\" }` instead\n * of hashing an empty string and rejecting every valid request with a\n * misleading `401 bad-signature`.\n */\nimport type { FastifyReply, FastifyRequest } from \"fastify\";\nimport { type CheckWebhookOptions, checkWebhook } from \"../check.js\";\nimport { type Finalizers, make_finalizers } from \"../finalize.js\";\n\n/** Shape attached to `request.idempotency` by the Fastify adapter. */\nexport type FastifyIdempotency = {\n key: string;\n deduped: boolean;\n} & Finalizers;\n\ntype WebhookRequest = FastifyRequest & {\n rawBody?: string;\n idempotency?: FastifyIdempotency;\n};\n\n/**\n * Build a Fastify `preHandler` hook that verifies the request\n * signature (when `secret` is set), enforces `Idempotency-Key`, and\n * claims the key on the configured store. See the module-level docs\n * for usage.\n */\nexport function webhookMiddleware(\n options: CheckWebhookOptions\n): (request: FastifyRequest, reply: FastifyReply) => Promise<void> {\n return async function check(\n request: FastifyRequest,\n reply: FastifyReply\n ): Promise<void> {\n const req = request as WebhookRequest;\n const rawBody = req.rawBody ?? \"\";\n const result = await checkWebhook(\n req.headers as Record<string, string | string[] | undefined>,\n rawBody,\n options\n );\n if (!result.ok) {\n await reply.status(result.status).send({ error: result.reason });\n return;\n }\n const { commit, release } = make_finalizers(\n options.store,\n result.key,\n result.deduped\n );\n req.idempotency = {\n key: result.key,\n deduped: result.deduped,\n commit,\n release,\n };\n };\n}\n","/**\n * Pull the `Idempotency-Key` header from a Node-style headers bag,\n * case-insensitive. Returns `undefined` when any of the following\n * carries no usable key:\n *\n * - the header is missing\n * - its value is an array (ambiguous — can't pick one without a\n * policy the receiver hasn't declared)\n * - its value is the empty string (carries no idempotency\n * information; structurally equivalent to \"no header at all\")\n *\n * Pair with `IdempotencyStore.claim` from\n * `@rotorsoft/act-ops/idempotency`: extract the key from the inbound\n * request, claim it on the store, return a `deduped` marker when the\n * claim fails. The framework-agnostic middleware that wires these\n * together lands in #744.\n *\n * Validation beyond \"is there a usable key?\" (length bounds, format\n * checks, normalization) is intentionally out of scope. Receivers\n * picking a policy can layer it on top — or, when #744 ships, opt\n * into the middleware's opinionated defaults.\n */\nexport function extractIdempotencyKey(\n headers: Record<string, string | string[] | undefined>\n): string | undefined {\n for (const [name, value] of Object.entries(headers)) {\n if (name.toLowerCase() !== \"idempotency-key\") continue;\n if (Array.isArray(value)) return undefined;\n if (value === \"\") return undefined;\n return value;\n }\n return undefined;\n}\n","import { createHmac, timingSafeEqual } from \"node:crypto\";\n\n/**\n * Outcome of {@link verifyWebhook}. Either the request signature\n * checks out, or one of five distinct failure reasons applies. Each\n * reason maps to an operator-meaningful telemetry bucket — separated\n * deliberately so dashboards can distinguish \"client lost its secret\"\n * from \"client clock is wrong\" from \"this is a replay attack.\"\n */\nexport type VerifyResult =\n | { ok: true }\n | {\n ok: false;\n reason:\n | \"missing-signature\"\n | \"missing-timestamp\"\n | \"stale\"\n | \"future\"\n | \"bad-signature\";\n };\n\n/** Options for {@link verifyWebhook}. */\nexport type VerifyOptions = {\n /**\n * Maximum acceptable timestamp drift in either direction, in\n * seconds. Default: 300 (±5 minutes) — matches Stripe / GitHub /\n * Slack conventions. Tightening narrows the replay window;\n * loosening accommodates clients with worse clock sync.\n */\n maxAgeSeconds?: number;\n /**\n * Current Unix-seconds time. Exposed for tests; production\n * callers should leave it undefined so wall-clock is used.\n */\n now?: number;\n};\n\n/**\n * Verify an inbound webhook's signature and timestamp against the\n * shared secret. Pair with the sender side: configure\n * `webhook({ secret })` from `@rotorsoft/act-http/webhook`.\n *\n * Returns `{ ok: true }` on success or `{ ok: false; reason }` on\n * failure. The reasons are:\n *\n * - `missing-signature` — no `X-Webhook-Signature` header, value\n * was an array, or value was empty.\n * - `missing-timestamp` — no `X-Webhook-Timestamp` header, value\n * was empty, or value isn't a parseable integer.\n * - `stale` — timestamp older than `maxAgeSeconds` from `now`.\n * - `future` — timestamp more than `maxAgeSeconds` ahead of `now`.\n * - `bad-signature` — signature header didn't start with `sha256=`,\n * wasn't 64 hex chars, or the recomputed HMAC didn't match\n * (constant-time compare).\n *\n * The signed payload is `${timestamp}.${body}`, so `body` must be\n * the **raw request body bytes**. Any pre-parse normalization\n * (whitespace trimming, JSON re-stringification) would change the\n * hash and reject every otherwise-valid request. Framework adapters\n * in #744 will provide the raw body alongside the parsed one.\n *\n * Uses Node's `crypto.timingSafeEqual` for the final comparison to\n * avoid signature-equality timing attacks.\n */\nexport function verifyWebhook(\n headers: Record<string, string | string[] | undefined>,\n body: string,\n secret: string,\n options?: VerifyOptions\n): VerifyResult {\n const maxAgeSeconds = options?.maxAgeSeconds ?? 300;\n const now = options?.now ?? Math.floor(Date.now() / 1000);\n\n const signature = pick_header(headers, \"x-webhook-signature\");\n if (!signature) return { ok: false, reason: \"missing-signature\" };\n\n const timestamp_str = pick_header(headers, \"x-webhook-timestamp\");\n if (!timestamp_str) return { ok: false, reason: \"missing-timestamp\" };\n const timestamp = Number.parseInt(timestamp_str, 10);\n if (Number.isNaN(timestamp) || String(timestamp) !== timestamp_str) {\n return { ok: false, reason: \"missing-timestamp\" };\n }\n\n const delta = now - timestamp;\n if (delta > maxAgeSeconds) return { ok: false, reason: \"stale\" };\n if (delta < -maxAgeSeconds) return { ok: false, reason: \"future\" };\n\n if (!signature.startsWith(\"sha256=\")) {\n return { ok: false, reason: \"bad-signature\" };\n }\n const provided_hex = signature.slice(\"sha256=\".length);\n if (!/^[0-9a-fA-F]{64}$/.test(provided_hex)) {\n return { ok: false, reason: \"bad-signature\" };\n }\n\n const expected_hex = createHmac(\"sha256\", secret)\n .update(`${timestamp_str}.${body}`)\n .digest(\"hex\");\n\n const a = Buffer.from(provided_hex, \"hex\");\n const b = Buffer.from(expected_hex, \"hex\");\n if (!timingSafeEqual(a, b)) {\n return { ok: false, reason: \"bad-signature\" };\n }\n\n return { ok: true };\n}\n\nfunction pick_header(\n headers: Record<string, string | string[] | undefined>,\n lower_name: string\n): string | undefined {\n for (const [name, value] of Object.entries(headers)) {\n if (name.toLowerCase() !== lower_name) continue;\n if (Array.isArray(value) || value === undefined || value === \"\") {\n return undefined;\n }\n return value;\n }\n return undefined;\n}\n","import type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\nimport { extractIdempotencyKey } from \"./extract.js\";\nimport { type VerifyOptions, verifyWebhook } from \"./verify.js\";\n\n/**\n * Failure reasons returned by {@link checkWebhook}. The shape splits\n * 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":[]}
1
+ {"version":3,"sources":["../../../src/receiver/fastify/index.ts","../../../src/receiver/extract.ts","../../../src/receiver/verify.ts","../../../src/receiver/check.ts","../../../src/receiver/finalize.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/receiver/fastify\n *\n * Fastify adapter for the receiver-side webhook check.\n *\n * Usage:\n *\n * ```ts\n * import Fastify from \"fastify\";\n * import { webhookMiddleware } from \"@rotorsoft/act-http/receiver/fastify\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n *\n * const app = Fastify();\n * const dedup = new InMemoryIdempotencyStore();\n *\n * app.post(\n * \"/webhooks/orders\",\n * {\n * preHandler: webhookMiddleware({\n * store: dedup,\n * secret: process.env.WEBHOOK_SECRET,\n * }),\n * },\n * async (request, reply) => {\n * const { key, deduped } = (request as any).idempotency;\n * if (deduped) return { status: \"dedup-skipped\", key };\n * // ... process the inbound event ...\n * return { status: \"processed\", key };\n * }\n * );\n * ```\n *\n * On failure: replies with `{ error: <reason> }` at status 400\n * (missing-key) or 401 (verification failures). On success: attaches\n * `request.idempotency = { key, deduped, commit, release }` and lets\n * the route handler run.\n *\n * **Two-phase dedup**: the claim is *tentative*. A Fastify\n * `preHandler` runs to completion before the route handler, so — like\n * the Express adapter — it can't finalize automatically. The route\n * handler **must** call `request.idempotency.commit()` on success or\n * `request.idempotency.release()` on a transient failure. Skipping\n * both leaves the claim tentative: it dedups concurrent duplicates but\n * expires on TTL, so a delivery is never permanently lost — it just\n * isn't durably deduped either.\n *\n * **Raw body requirement**: when `secret` is configured, register a\n * content-type parser that preserves the raw body string. Fastify's\n * default JSON parser eats the bytes — register a custom parser via\n * `app.addContentTypeParser(\"application/json\", { parseAs: \"string\" }, …)`\n * and stash the string on `request.rawBody` (Fastify pattern). The\n * middleware reads `request.rawBody` for hashing. Skip when unsigned. If\n * `request.rawBody` is empty because the parser wasn't registered, the\n * middleware short-circuits with `400 { error: \"empty-body\" }` instead\n * of hashing an empty string and rejecting every valid request with a\n * misleading `401 bad-signature`.\n */\nimport type { FastifyReply, FastifyRequest } from \"fastify\";\nimport { type CheckWebhookOptions, checkWebhook } from \"../check.js\";\nimport { type Finalizers, make_idempotency } from \"../finalize.js\";\n\n/** Shape attached to `request.idempotency` by the Fastify adapter. */\nexport type FastifyIdempotency = {\n key: string;\n deduped: boolean;\n} & Finalizers;\n\ntype WebhookRequest = FastifyRequest & {\n rawBody?: string;\n idempotency?: FastifyIdempotency;\n};\n\n/**\n * Build a Fastify `preHandler` hook that verifies the request\n * signature (when `secret` is set), enforces `Idempotency-Key`, and\n * claims the key on the configured store. See the module-level docs\n * for usage.\n */\nexport function webhookMiddleware(\n options: CheckWebhookOptions\n): (request: FastifyRequest, reply: FastifyReply) => Promise<void> {\n return async function check(\n request: FastifyRequest,\n reply: FastifyReply\n ): Promise<void> {\n const req = request as WebhookRequest;\n const rawBody = req.rawBody ?? \"\";\n const result = await checkWebhook(\n req.headers as Record<string, string | string[] | undefined>,\n rawBody,\n options\n );\n if (!result.ok) {\n await reply.status(result.status).send({ error: result.reason });\n return;\n }\n req.idempotency = make_idempotency(options.store, result);\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 * The `idempotency` bag a receiver adapter hands its route for one\n * delivery: the claimed key, whether it was a duplicate, and the\n * {@link Finalizers}.\n */\nexport type Idempotency = { key: string; deduped: boolean } & Finalizers;\n\n/**\n * Build the {@link Idempotency} bag for one delivery that passed\n * `checkWebhook`.\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_idempotency(\n store: IdempotencyStore,\n { key, deduped }: { key: string; deduped: boolean }\n): Idempotency {\n let settled = deduped;\n return {\n key,\n deduped,\n async commit() {\n if (settled) return;\n settled = true;\n await store.commit(key);\n },\n async release() {\n if (settled) return;\n settled = true;\n await store.release(key);\n },\n };\n}\n"],"mappings":"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","names":[]}
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  checkWebhook,
3
- make_finalizers
4
- } from "../../chunk-DMDU25DN.js";
3
+ make_idempotency
4
+ } from "../../chunk-G5FNOSDX.js";
5
5
  import "../../chunk-PZ5AY32C.js";
6
6
 
7
7
  // src/receiver/fastify/index.ts
@@ -18,17 +18,7 @@ function webhookMiddleware(options) {
18
18
  await reply.status(result.status).send({ error: result.reason });
19
19
  return;
20
20
  }
21
- const { commit, release } = make_finalizers(
22
- options.store,
23
- result.key,
24
- result.deduped
25
- );
26
- req.idempotency = {
27
- key: result.key,
28
- deduped: result.deduped,
29
- commit,
30
- release
31
- };
21
+ req.idempotency = make_idempotency(options.store, result);
32
22
  };
33
23
  }
34
24
  export {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/receiver/fastify/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/receiver/fastify\n *\n * Fastify adapter for the receiver-side webhook check.\n *\n * Usage:\n *\n * ```ts\n * import Fastify from \"fastify\";\n * import { webhookMiddleware } from \"@rotorsoft/act-http/receiver/fastify\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n *\n * const app = Fastify();\n * const dedup = new InMemoryIdempotencyStore();\n *\n * app.post(\n * \"/webhooks/orders\",\n * {\n * preHandler: webhookMiddleware({\n * store: dedup,\n * secret: process.env.WEBHOOK_SECRET,\n * }),\n * },\n * async (request, reply) => {\n * const { key, deduped } = (request as any).idempotency;\n * if (deduped) return { status: \"dedup-skipped\", key };\n * // ... process the inbound event ...\n * return { status: \"processed\", key };\n * }\n * );\n * ```\n *\n * On failure: replies with `{ error: <reason> }` at status 400\n * (missing-key) or 401 (verification failures). On success: attaches\n * `request.idempotency = { key, deduped, commit, release }` and lets\n * the route handler run.\n *\n * **Two-phase dedup**: the claim is *tentative*. A Fastify\n * `preHandler` runs to completion before the route handler, so — like\n * the Express adapter — it can't finalize automatically. The route\n * handler **must** call `request.idempotency.commit()` on success or\n * `request.idempotency.release()` on a transient failure. Skipping\n * both leaves the claim tentative: it dedups concurrent duplicates but\n * expires on TTL, so a delivery is never permanently lost — it just\n * isn't durably deduped either.\n *\n * **Raw body requirement**: when `secret` is configured, register a\n * content-type parser that preserves the raw body string. Fastify's\n * default JSON parser eats the bytes — register a custom parser via\n * `app.addContentTypeParser(\"application/json\", { parseAs: \"string\" }, …)`\n * and stash the string on `request.rawBody` (Fastify pattern). The\n * middleware reads `request.rawBody` for hashing. Skip when unsigned. If\n * `request.rawBody` is empty because the parser wasn't registered, the\n * middleware short-circuits with `400 { error: \"empty-body\" }` instead\n * of hashing an empty string and rejecting every valid request with a\n * misleading `401 bad-signature`.\n */\nimport type { FastifyReply, FastifyRequest } from \"fastify\";\nimport { type CheckWebhookOptions, checkWebhook } from \"../check.js\";\nimport { type Finalizers, make_finalizers } from \"../finalize.js\";\n\n/** Shape attached to `request.idempotency` by the Fastify adapter. */\nexport type FastifyIdempotency = {\n key: string;\n deduped: boolean;\n} & Finalizers;\n\ntype WebhookRequest = FastifyRequest & {\n rawBody?: string;\n idempotency?: FastifyIdempotency;\n};\n\n/**\n * Build a Fastify `preHandler` hook that verifies the request\n * signature (when `secret` is set), enforces `Idempotency-Key`, and\n * claims the key on the configured store. See the module-level docs\n * for usage.\n */\nexport function webhookMiddleware(\n options: CheckWebhookOptions\n): (request: FastifyRequest, reply: FastifyReply) => Promise<void> {\n return async function check(\n request: FastifyRequest,\n reply: FastifyReply\n ): Promise<void> {\n const req = request as WebhookRequest;\n const rawBody = req.rawBody ?? \"\";\n const result = await checkWebhook(\n req.headers as Record<string, string | string[] | undefined>,\n rawBody,\n options\n );\n if (!result.ok) {\n await reply.status(result.status).send({ error: result.reason });\n return;\n }\n const { commit, release } = make_finalizers(\n options.store,\n result.key,\n result.deduped\n );\n req.idempotency = {\n key: result.key,\n deduped: result.deduped,\n commit,\n release,\n };\n };\n}\n"],"mappings":";;;;;;;AA+EO,SAAS,kBACd,SACiE;AACjE,SAAO,eAAe,MACpB,SACA,OACe;AACf,UAAM,MAAM;AACZ,UAAM,UAAU,IAAI,WAAW;AAC/B,UAAM,SAAS,MAAM;AAAA,MACnB,IAAI;AAAA,MACJ;AAAA,MACA;AAAA,IACF;AACA,QAAI,CAAC,OAAO,IAAI;AACd,YAAM,MAAM,OAAO,OAAO,MAAM,EAAE,KAAK,EAAE,OAAO,OAAO,OAAO,CAAC;AAC/D;AAAA,IACF;AACA,UAAM,EAAE,QAAQ,QAAQ,IAAI;AAAA,MAC1B,QAAQ;AAAA,MACR,OAAO;AAAA,MACP,OAAO;AAAA,IACT;AACA,QAAI,cAAc;AAAA,MAChB,KAAK,OAAO;AAAA,MACZ,SAAS,OAAO;AAAA,MAChB;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACF;","names":[]}
1
+ {"version":3,"sources":["../../../src/receiver/fastify/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/receiver/fastify\n *\n * Fastify adapter for the receiver-side webhook check.\n *\n * Usage:\n *\n * ```ts\n * import Fastify from \"fastify\";\n * import { webhookMiddleware } from \"@rotorsoft/act-http/receiver/fastify\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n *\n * const app = Fastify();\n * const dedup = new InMemoryIdempotencyStore();\n *\n * app.post(\n * \"/webhooks/orders\",\n * {\n * preHandler: webhookMiddleware({\n * store: dedup,\n * secret: process.env.WEBHOOK_SECRET,\n * }),\n * },\n * async (request, reply) => {\n * const { key, deduped } = (request as any).idempotency;\n * if (deduped) return { status: \"dedup-skipped\", key };\n * // ... process the inbound event ...\n * return { status: \"processed\", key };\n * }\n * );\n * ```\n *\n * On failure: replies with `{ error: <reason> }` at status 400\n * (missing-key) or 401 (verification failures). On success: attaches\n * `request.idempotency = { key, deduped, commit, release }` and lets\n * the route handler run.\n *\n * **Two-phase dedup**: the claim is *tentative*. A Fastify\n * `preHandler` runs to completion before the route handler, so — like\n * the Express adapter — it can't finalize automatically. The route\n * handler **must** call `request.idempotency.commit()` on success or\n * `request.idempotency.release()` on a transient failure. Skipping\n * both leaves the claim tentative: it dedups concurrent duplicates but\n * expires on TTL, so a delivery is never permanently lost — it just\n * isn't durably deduped either.\n *\n * **Raw body requirement**: when `secret` is configured, register a\n * content-type parser that preserves the raw body string. Fastify's\n * default JSON parser eats the bytes — register a custom parser via\n * `app.addContentTypeParser(\"application/json\", { parseAs: \"string\" }, …)`\n * and stash the string on `request.rawBody` (Fastify pattern). The\n * middleware reads `request.rawBody` for hashing. Skip when unsigned. If\n * `request.rawBody` is empty because the parser wasn't registered, the\n * middleware short-circuits with `400 { error: \"empty-body\" }` instead\n * of hashing an empty string and rejecting every valid request with a\n * misleading `401 bad-signature`.\n */\nimport type { FastifyReply, FastifyRequest } from \"fastify\";\nimport { type CheckWebhookOptions, checkWebhook } from \"../check.js\";\nimport { type Finalizers, make_idempotency } from \"../finalize.js\";\n\n/** Shape attached to `request.idempotency` by the Fastify adapter. */\nexport type FastifyIdempotency = {\n key: string;\n deduped: boolean;\n} & Finalizers;\n\ntype WebhookRequest = FastifyRequest & {\n rawBody?: string;\n idempotency?: FastifyIdempotency;\n};\n\n/**\n * Build a Fastify `preHandler` hook that verifies the request\n * signature (when `secret` is set), enforces `Idempotency-Key`, and\n * claims the key on the configured store. See the module-level docs\n * for usage.\n */\nexport function webhookMiddleware(\n options: CheckWebhookOptions\n): (request: FastifyRequest, reply: FastifyReply) => Promise<void> {\n return async function check(\n request: FastifyRequest,\n reply: FastifyReply\n ): Promise<void> {\n const req = request as WebhookRequest;\n const rawBody = req.rawBody ?? \"\";\n const result = await checkWebhook(\n req.headers as Record<string, string | string[] | undefined>,\n rawBody,\n options\n );\n if (!result.ok) {\n await reply.status(result.status).send({ error: result.reason });\n return;\n }\n req.idempotency = make_idempotency(options.store, result);\n };\n}\n"],"mappings":";;;;;;;AA+EO,SAAS,kBACd,SACiE;AACjE,SAAO,eAAe,MACpB,SACA,OACe;AACf,UAAM,MAAM;AACZ,UAAM,UAAU,IAAI,WAAW;AAC/B,UAAM,SAAS,MAAM;AAAA,MACnB,IAAI;AAAA,MACJ;AAAA,MACA;AAAA,IACF;AACA,QAAI,CAAC,OAAO,IAAI;AACd,YAAM,MAAM,OAAO,OAAO,MAAM,EAAE,KAAK,EAAE,OAAO,OAAO,OAAO,CAAC;AAC/D;AAAA,IACF;AACA,QAAI,cAAc,iBAAiB,QAAQ,OAAO,MAAM;AAAA,EAC1D;AACF;","names":[]}
@@ -100,9 +100,11 @@ async function checkWebhook(headers, body, options) {
100
100
  }
101
101
 
102
102
  // src/receiver/finalize.ts
103
- function make_finalizers(store, key, deduped) {
103
+ function make_idempotency(store, { key, deduped }) {
104
104
  let settled = deduped;
105
105
  return {
106
+ key,
107
+ deduped,
106
108
  async commit() {
107
109
  if (settled) return;
108
110
  settled = true;
@@ -125,25 +127,16 @@ function webhookMiddleware(options) {
125
127
  if (!result.ok) {
126
128
  return c.json({ error: result.reason }, result.status);
127
129
  }
128
- const { commit, release } = make_finalizers(
129
- options.store,
130
- result.key,
131
- result.deduped
132
- );
133
- c.set("idempotency", {
134
- key: result.key,
135
- deduped: result.deduped,
136
- commit,
137
- release
138
- });
130
+ const idempotency = make_idempotency(options.store, result);
131
+ c.set("idempotency", idempotency);
139
132
  try {
140
133
  await next();
141
134
  } catch (err) {
142
- await release();
135
+ await idempotency.release();
143
136
  throw err;
144
137
  }
145
- if (c.res.status >= 400) await release();
146
- else await commit();
138
+ if (c.res.status >= 400) await idempotency.release();
139
+ else await idempotency.commit();
147
140
  };
148
141
  }
149
142
  function headers_bag(headers) {