@btx-tools/middleware-fastify 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -85,6 +85,29 @@ When `client.issue()` or `client.redeem()` throws (e.g., btxd RPC down, network
85
85
 
86
86
  For HTTPS / production deployments, terminate TLS at a reverse proxy (Caddy, nginx, Cloudflare) in front of btxd. **Do NOT expose btxd's RPC port directly to the public internet.**
87
87
 
88
+ ## CORS
89
+
90
+ The `X-BTX-Challenge`, `X-BTX-Proof-Nonce`, and `X-BTX-Proof-Digest` headers are **custom**, which triggers a CORS preflight for any browser-originated fetch. Configure `@fastify/cors`:
91
+
92
+ ```ts
93
+ import cors from '@fastify/cors';
94
+ await fastify.register(cors, {
95
+ origin: 'https://your-frontend.example',
96
+ allowedHeaders: [
97
+ 'content-type',
98
+ 'x-btx-challenge',
99
+ 'x-btx-challenge-id',
100
+ 'x-btx-proof-nonce',
101
+ 'x-btx-proof-digest',
102
+ ],
103
+ exposedHeaders: [
104
+ 'x-btx-challenge', // so the browser can READ the 402's challenge header
105
+ ],
106
+ });
107
+ ```
108
+
109
+ Without `exposedHeaders` including `x-btx-challenge`, the browser sees the 402 status but **cannot** read the challenge JSON from the response header.
110
+
88
111
  ## Requirements
89
112
 
90
113
  - **Node.js** ≥ 18.17
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @btx-tools/middleware-fastify\n *\n * Drop-in Fastify admission gate backed by BTX service challenges.\n * Mirrors the behavior of `@btx-tools/middleware-express` for the Fastify\n * ecosystem.\n *\n * Flow (stateless, echo-the-challenge):\n *\n * client → POST /v1/generate (no proof headers)\n * server → 402 Payment Required\n * X-BTX-Challenge: <stringified challenge JSON>\n * body: { challenge, retry_with: [...] }\n *\n * client solves locally (or via RPC), retries:\n * client → POST /v1/generate\n * X-BTX-Challenge: <echoed challenge JSON>\n * X-BTX-Challenge-Id: <id> (optional sanity check)\n * X-BTX-Proof-Nonce: <hex>\n * X-BTX-Proof-Digest: <hex>\n * server → 200 OK (handler runs; request.btx?.result is the VerifyResult)\n *\n * Invalid proof → 403 with { valid: false, reason }.\n * btxd RPC error → Fastify's error pipeline (throw from preHandler).\n *\n * Stateless design notes:\n * - Server never stores issued challenges; client echoes the challenge back\n * in `X-BTX-Challenge`. Pros: scales horizontally, no sticky routing.\n * Cons: the challenge JSON (~3-5 KB) lives in an HTTP header, so check\n * your reverse proxy's `large_client_header_buffers` / equivalent.\n * - A stateful variant (server-side `challenge_id` cache) is a future\n * enhancement queued as `btxAdmission({ store })`.\n *\n * Usage (per-route):\n * ```ts\n * import Fastify from 'fastify';\n * import { BtxChallengeClient } from '@btx-tools/challenges-sdk';\n * import { btxAdmission } from '@btx-tools/middleware-fastify';\n *\n * const client = new BtxChallengeClient({ rpcUrl: '...', rpcAuth: { user, pass } });\n * const fastify = Fastify();\n *\n * fastify.post('/v1/generate', {\n * preHandler: btxAdmission({\n * client,\n * purpose: 'ai_inference_gate',\n * resource: (req) => `model:${(req.body as any).model}|route:${req.url}`,\n * subject: (req) => `tenant:${(req.body as any).tenant_id}`,\n * issueParams: { target_solve_time_s: 1.0, expires_in_s: 60 },\n * }),\n * }, async (request, reply) => {\n * // request.btx?.result is populated with the redeem VerifyResult\n * return { ok: true };\n * });\n * ```\n */\n\nimport type {\n FastifyReply,\n FastifyRequest,\n preHandlerAsyncHookHandler,\n} from 'fastify';\n\nimport type {\n BtxChallengeClient,\n Challenge,\n IssueParams,\n VerifyResult,\n} from '@btx-tools/challenges-sdk';\n\n// ----------------------------------------------------------------------------\n// Public constants\n// ----------------------------------------------------------------------------\n\nexport const HEADER_CHALLENGE = 'x-btx-challenge';\nexport const HEADER_CHALLENGE_ID = 'x-btx-challenge-id';\nexport const HEADER_PROOF_NONCE = 'x-btx-proof-nonce';\nexport const HEADER_PROOF_DIGEST = 'x-btx-proof-digest';\n// Note: Fastify normalizes incoming header names to lowercase. Outgoing\n// reply.header() accepts any case.\n\n// ----------------------------------------------------------------------------\n// Types\n// ----------------------------------------------------------------------------\n\ntype StringOrFn = string | ((req: FastifyRequest) => string);\n\n/** Options for {@link btxAdmission}. */\nexport interface BtxAdmissionOpts {\n /** The BTX RPC client (constructed once at boot). */\n client: BtxChallengeClient;\n /** Logical purpose label, e.g. `'ai_inference_gate'` or `'rate_limit'`. */\n purpose: StringOrFn;\n /** Resource identifier, e.g. `(req) => \\`model:${req.body.model}|route:${req.url}\\``. */\n resource: StringOrFn;\n /** Subject identifier, e.g. `(req) => \\`tenant:${req.user.id}\\``. */\n subject: StringOrFn;\n /** Extra issue params forwarded to `client.issue()` (target_solve_time_s, expires_in_s, etc.). */\n issueParams?: Partial<Omit<IssueParams, 'purpose' | 'resource' | 'subject'>>;\n /** Optional hook fired on successful admission. Receives `req` + the redeem result. */\n onAdmit?: (req: FastifyRequest, result: VerifyResult) => void;\n /**\n * Optional hook fired when `client.issue()` or `client.redeem()` throws.\n * Receives the original error + the request. Fires exactly once before\n * the preHandler re-throws to hand off to Fastify's error pipeline.\n * Use this for logging/observability — don't mutate the error or the\n * reply. Audit ref: D-1.\n */\n onError?: (err: unknown, req: FastifyRequest) => void;\n /**\n * Override the default \"is the proof present?\" check. By default it returns\n * true iff all of `x-btx-challenge`, `x-btx-proof-nonce`, `x-btx-proof-digest`\n * are set.\n */\n isProofPresent?: (req: FastifyRequest) => boolean;\n}\n\ndeclare module 'fastify' {\n interface FastifyRequest {\n /**\n * Namespaced container for BTX middleware state. Populated on successful\n * admission. Mirrors `req.btx` from middleware-express (audit C-3 namespace).\n */\n btx?: {\n /** The `client.redeem()` result that admitted this request. */\n result: VerifyResult;\n };\n }\n}\n\n// ----------------------------------------------------------------------------\n// Middleware\n// ----------------------------------------------------------------------------\n\n/**\n * Build a Fastify `preHandler` hook that gates downstream handlers behind\n * a BTX service challenge. Use per-route via `{ preHandler: btxAdmission(opts) }`.\n */\nexport function btxAdmission(opts: BtxAdmissionOpts): preHandlerAsyncHookHandler {\n const proofPresent = opts.isProofPresent ?? defaultIsProofPresent;\n\n return async function btxAdmissionPreHandler(request, reply) {\n if (!proofPresent(request)) {\n await issueAndRespond(request, reply, opts);\n return;\n }\n await redeemAndAdmit(request, reply, opts);\n };\n}\n\nfunction defaultIsProofPresent(req: FastifyRequest): boolean {\n const h = req.headers;\n return Boolean(h[HEADER_CHALLENGE] && h[HEADER_PROOF_NONCE] && h[HEADER_PROOF_DIGEST]);\n}\n\nasync function issueAndRespond(\n req: FastifyRequest,\n reply: FastifyReply,\n opts: BtxAdmissionOpts,\n): Promise<void> {\n try {\n const purpose = resolve(opts.purpose, req);\n const resource = resolve(opts.resource, req);\n const subject = resolve(opts.subject, req);\n const challenge = await opts.client.issue({\n purpose,\n resource,\n subject,\n ...opts.issueParams,\n });\n await reply\n .code(402)\n .header(HEADER_CHALLENGE, JSON.stringify(challenge))\n .header('content-type', 'application/json')\n .send({\n challenge,\n retry_with: [HEADER_CHALLENGE, HEADER_PROOF_NONCE, HEADER_PROOF_DIGEST],\n });\n } catch (err) {\n opts.onError?.(err, req);\n throw err;\n }\n}\n\nasync function redeemAndAdmit(\n req: FastifyRequest,\n reply: FastifyReply,\n opts: BtxAdmissionOpts,\n): Promise<void> {\n const challengeRaw = headerValue(req, HEADER_CHALLENGE);\n const nonce = headerValue(req, HEADER_PROOF_NONCE);\n const digest = headerValue(req, HEADER_PROOF_DIGEST);\n\n if (!challengeRaw) {\n await reply.code(400).header('content-type', 'application/json').send({\n error: 'missing_challenge_header',\n message: `Retry must include the original challenge in the ${HEADER_CHALLENGE} header (echo-back).`,\n });\n return;\n }\n\n let challenge: Challenge;\n try {\n challenge = JSON.parse(challengeRaw) as Challenge;\n } catch {\n await reply.code(400).header('content-type', 'application/json').send({\n error: 'malformed_challenge_header',\n message: `${HEADER_CHALLENGE} must be a JSON-encoded Challenge envelope.`,\n });\n return;\n }\n\n // Optional sanity check: if the client also sent the challenge_id header,\n // make sure it matches the embedded id.\n const idHeader = headerValue(req, HEADER_CHALLENGE_ID);\n if (idHeader && idHeader !== challenge.challenge_id) {\n await reply.code(400).header('content-type', 'application/json').send({\n error: 'challenge_id_mismatch',\n message: `${HEADER_CHALLENGE_ID} does not match challenge_id in ${HEADER_CHALLENGE}.`,\n });\n return;\n }\n\n try {\n const result = await opts.client.redeem(challenge, nonce!, digest!);\n if (!result.valid) {\n await reply.code(403).header('content-type', 'application/json').send({\n valid: false,\n reason: result.reason,\n expired: result.expired,\n });\n return;\n }\n req.btx = { result };\n opts.onAdmit?.(req, result);\n // Fall through to the route handler — no explicit reply.send here.\n } catch (err) {\n opts.onError?.(err, req);\n throw err;\n }\n}\n\nfunction resolve(value: StringOrFn, req: FastifyRequest): string {\n return typeof value === 'function' ? value(req) : value;\n}\n\n/** Fastify header values can be string | string[] | undefined; normalize to string. */\nfunction headerValue(req: FastifyRequest, name: string): string | undefined {\n const v = req.headers[name];\n if (Array.isArray(v)) return v[0];\n return v;\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @btx-tools/middleware-fastify\n *\n * Drop-in Fastify admission gate backed by BTX service challenges.\n * Mirrors the behavior of `@btx-tools/middleware-express` for the Fastify\n * ecosystem.\n *\n * Flow (stateless, echo-the-challenge):\n *\n * client → POST /v1/generate (no proof headers)\n * server → 402 Payment Required\n * X-BTX-Challenge: <stringified challenge JSON>\n * body: { challenge, retry_with: [...] }\n *\n * client solves locally (or via RPC), retries:\n * client → POST /v1/generate\n * X-BTX-Challenge: <echoed challenge JSON>\n * X-BTX-Challenge-Id: <id> (optional sanity check)\n * X-BTX-Proof-Nonce: <hex>\n * X-BTX-Proof-Digest: <hex>\n * server → 200 OK (handler runs; request.btx?.result is the VerifyResult)\n *\n * Invalid proof → 403 with { valid: false, reason }.\n * btxd RPC error → Fastify's error pipeline (throw from preHandler).\n *\n * Stateless design notes:\n * - Server never stores issued challenges; client echoes the challenge back\n * in `X-BTX-Challenge`. Pros: scales horizontally, no sticky routing.\n * Cons: the challenge JSON (~3-5 KB) lives in an HTTP header, so check\n * your reverse proxy's `large_client_header_buffers` / equivalent.\n * - A stateful variant (server-side `challenge_id` cache) is a future\n * enhancement queued as `btxAdmission({ store })`.\n *\n * Usage (per-route):\n * ```ts\n * import Fastify from 'fastify';\n * import { BtxChallengeClient } from '@btx-tools/challenges-sdk';\n * import { btxAdmission } from '@btx-tools/middleware-fastify';\n *\n * const client = new BtxChallengeClient({ rpcUrl: '...', rpcAuth: { user, pass } });\n * const fastify = Fastify();\n *\n * fastify.post('/v1/generate', {\n * preHandler: btxAdmission({\n * client,\n * purpose: 'ai_inference_gate',\n * resource: (req) => `model:${(req.body as any).model}|route:${req.url}`,\n * subject: (req) => `tenant:${(req.body as any).tenant_id}`,\n * issueParams: { target_solve_time_s: 1.0, expires_in_s: 60 },\n * }),\n * }, async (request, reply) => {\n * // request.btx?.result is populated with the redeem VerifyResult\n * return { ok: true };\n * });\n * ```\n */\n\nimport type {\n FastifyReply,\n FastifyRequest,\n preHandlerAsyncHookHandler,\n} from 'fastify';\n\nimport type {\n BtxChallengeClient,\n Challenge,\n IssueParams,\n VerifyResult,\n} from '@btx-tools/challenges-sdk';\n\n// ----------------------------------------------------------------------------\n// Public constants\n// ----------------------------------------------------------------------------\n\nexport const HEADER_CHALLENGE = 'x-btx-challenge';\nexport const HEADER_CHALLENGE_ID = 'x-btx-challenge-id';\nexport const HEADER_PROOF_NONCE = 'x-btx-proof-nonce';\nexport const HEADER_PROOF_DIGEST = 'x-btx-proof-digest';\n// Note: Fastify normalizes incoming header names to lowercase. Outgoing\n// reply.header() accepts any case.\n\n// ----------------------------------------------------------------------------\n// Types\n// ----------------------------------------------------------------------------\n\ntype StringOrFn = string | ((req: FastifyRequest) => string);\n\n/** Options for {@link btxAdmission}. */\nexport interface BtxAdmissionOpts {\n /** The BTX RPC client (constructed once at boot). */\n client: BtxChallengeClient;\n /** Logical purpose label, e.g. `'ai_inference_gate'` or `'rate_limit'`. */\n purpose: StringOrFn;\n /** Resource identifier, e.g. `(req) => \\`model:${req.body.model}|route:${req.url}\\``. */\n resource: StringOrFn;\n /** Subject identifier, e.g. `(req) => \\`tenant:${req.user.id}\\``. */\n subject: StringOrFn;\n /** Extra issue params forwarded to `client.issue()` (target_solve_time_s, expires_in_s, etc.). */\n issueParams?: Partial<Omit<IssueParams, 'purpose' | 'resource' | 'subject'>>;\n /** Optional hook fired on successful admission. Receives `req` + the redeem result. */\n onAdmit?: (req: FastifyRequest, result: VerifyResult) => void;\n /**\n * Optional hook fired when `client.issue()` or `client.redeem()` throws.\n * Receives the original error + the request. Fires exactly once before\n * the preHandler re-throws to hand off to Fastify's error pipeline.\n * Use this for logging/observability — don't mutate the error or the\n * reply. Audit ref: D-1.\n */\n onError?: (err: unknown, req: FastifyRequest) => void;\n /**\n * Override the default \"is the proof present?\" check. By default it returns\n * true iff all of `x-btx-challenge`, `x-btx-proof-nonce`, `x-btx-proof-digest`\n * are set.\n */\n isProofPresent?: (req: FastifyRequest) => boolean;\n}\n\ndeclare module 'fastify' {\n interface FastifyRequest {\n /**\n * Namespaced container for BTX middleware state. Populated on successful\n * admission. Mirrors `req.btx` from middleware-express (audit C-3 namespace).\n */\n btx?: {\n /** The `client.redeem()` result that admitted this request. */\n result: VerifyResult;\n };\n }\n}\n\n// ----------------------------------------------------------------------------\n// Middleware\n// ----------------------------------------------------------------------------\n\n/**\n * Build a Fastify `preHandler` hook that gates downstream handlers behind\n * a BTX service challenge. Use per-route via `{ preHandler: btxAdmission(opts) }`.\n */\nexport function btxAdmission(opts: BtxAdmissionOpts): preHandlerAsyncHookHandler {\n const proofPresent = opts.isProofPresent ?? defaultIsProofPresent;\n\n return async function btxAdmissionPreHandler(request, reply) {\n if (!proofPresent(request)) {\n await issueAndRespond(request, reply, opts);\n return;\n }\n await redeemAndAdmit(request, reply, opts);\n };\n}\n\nfunction defaultIsProofPresent(req: FastifyRequest): boolean {\n const h = req.headers;\n return Boolean(h[HEADER_CHALLENGE] && h[HEADER_PROOF_NONCE] && h[HEADER_PROOF_DIGEST]);\n}\n\nasync function issueAndRespond(\n req: FastifyRequest,\n reply: FastifyReply,\n opts: BtxAdmissionOpts,\n): Promise<void> {\n try {\n const purpose = resolve(opts.purpose, req);\n const resource = resolve(opts.resource, req);\n const subject = resolve(opts.subject, req);\n const challenge = await opts.client.issue({\n purpose,\n resource,\n subject,\n ...opts.issueParams,\n });\n await reply\n .code(402)\n .header(HEADER_CHALLENGE, JSON.stringify(challenge))\n .header('content-type', 'application/json')\n .send({\n challenge,\n retry_with: [HEADER_CHALLENGE, HEADER_PROOF_NONCE, HEADER_PROOF_DIGEST],\n });\n } catch (err) {\n opts.onError?.(err, req);\n throw err;\n }\n}\n\nasync function redeemAndAdmit(\n req: FastifyRequest,\n reply: FastifyReply,\n opts: BtxAdmissionOpts,\n): Promise<void> {\n const challengeRaw = headerValue(req, HEADER_CHALLENGE);\n const nonce = headerValue(req, HEADER_PROOF_NONCE);\n const digest = headerValue(req, HEADER_PROOF_DIGEST);\n\n if (!challengeRaw) {\n await reply.code(400).header('content-type', 'application/json').send({\n error: 'missing_challenge_header',\n message: `Retry must include the original challenge in the ${HEADER_CHALLENGE} header (echo-back).`,\n });\n return;\n }\n\n let challenge: Challenge;\n try {\n challenge = JSON.parse(challengeRaw) as Challenge;\n } catch {\n await reply.code(400).header('content-type', 'application/json').send({\n error: 'malformed_challenge_header',\n message: `${HEADER_CHALLENGE} must be a JSON-encoded Challenge envelope.`,\n });\n return;\n }\n\n // Optional sanity check: if the client also sent the challenge_id header,\n // make sure it matches the embedded id.\n const idHeader = headerValue(req, HEADER_CHALLENGE_ID);\n if (idHeader && idHeader !== challenge.challenge_id) {\n await reply.code(400).header('content-type', 'application/json').send({\n error: 'challenge_id_mismatch',\n message: `${HEADER_CHALLENGE_ID} does not match challenge_id in ${HEADER_CHALLENGE}.`,\n });\n return;\n }\n\n try {\n const result = await opts.client.redeem(challenge, nonce!, digest!);\n if (!result.valid) {\n await reply.code(403).header('content-type', 'application/json').send({\n valid: false,\n reason: result.reason,\n expired: result.expired,\n });\n return;\n }\n req.btx = { result };\n opts.onAdmit?.(req, result);\n // Fall through to the route handler — no explicit reply.send here.\n } catch (err) {\n opts.onError?.(err, req);\n throw err;\n }\n}\n\nfunction resolve(value: StringOrFn, req: FastifyRequest): string {\n return typeof value === 'function' ? value(req) : value;\n}\n\n/**\n * Fastify header values can be string | string[] | undefined; normalize to\n * string by returning the first occurrence.\n *\n * Note (audit M-7 2026-05-23): when a duplicate header is sent (HTTP permits\n * duplicates), we intentionally pick the FIRST value. This matches standard\n * proxy behavior and avoids ambiguity. If a reverse proxy in front reorders\n * duplicate headers, the SDK's behavior follows that proxy's order. None of\n * the BTX headers should legitimately arrive duplicated under normal use.\n */\nfunction headerValue(req: FastifyRequest, name: string): string | undefined {\n const v = req.headers[name];\n if (Array.isArray(v)) return v[0];\n return v;\n}\n"]}
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @btx-tools/middleware-fastify\n *\n * Drop-in Fastify admission gate backed by BTX service challenges.\n * Mirrors the behavior of `@btx-tools/middleware-express` for the Fastify\n * ecosystem.\n *\n * Flow (stateless, echo-the-challenge):\n *\n * client → POST /v1/generate (no proof headers)\n * server → 402 Payment Required\n * X-BTX-Challenge: <stringified challenge JSON>\n * body: { challenge, retry_with: [...] }\n *\n * client solves locally (or via RPC), retries:\n * client → POST /v1/generate\n * X-BTX-Challenge: <echoed challenge JSON>\n * X-BTX-Challenge-Id: <id> (optional sanity check)\n * X-BTX-Proof-Nonce: <hex>\n * X-BTX-Proof-Digest: <hex>\n * server → 200 OK (handler runs; request.btx?.result is the VerifyResult)\n *\n * Invalid proof → 403 with { valid: false, reason }.\n * btxd RPC error → Fastify's error pipeline (throw from preHandler).\n *\n * Stateless design notes:\n * - Server never stores issued challenges; client echoes the challenge back\n * in `X-BTX-Challenge`. Pros: scales horizontally, no sticky routing.\n * Cons: the challenge JSON (~3-5 KB) lives in an HTTP header, so check\n * your reverse proxy's `large_client_header_buffers` / equivalent.\n * - A stateful variant (server-side `challenge_id` cache) is a future\n * enhancement queued as `btxAdmission({ store })`.\n *\n * Usage (per-route):\n * ```ts\n * import Fastify from 'fastify';\n * import { BtxChallengeClient } from '@btx-tools/challenges-sdk';\n * import { btxAdmission } from '@btx-tools/middleware-fastify';\n *\n * const client = new BtxChallengeClient({ rpcUrl: '...', rpcAuth: { user, pass } });\n * const fastify = Fastify();\n *\n * fastify.post('/v1/generate', {\n * preHandler: btxAdmission({\n * client,\n * purpose: 'ai_inference_gate',\n * resource: (req) => `model:${(req.body as any).model}|route:${req.url}`,\n * subject: (req) => `tenant:${(req.body as any).tenant_id}`,\n * issueParams: { target_solve_time_s: 1.0, expires_in_s: 60 },\n * }),\n * }, async (request, reply) => {\n * // request.btx?.result is populated with the redeem VerifyResult\n * return { ok: true };\n * });\n * ```\n */\n\nimport type {\n FastifyReply,\n FastifyRequest,\n preHandlerAsyncHookHandler,\n} from 'fastify';\n\nimport type {\n BtxChallengeClient,\n Challenge,\n IssueParams,\n VerifyResult,\n} from '@btx-tools/challenges-sdk';\n\n// ----------------------------------------------------------------------------\n// Public constants\n// ----------------------------------------------------------------------------\n\nexport const HEADER_CHALLENGE = 'x-btx-challenge';\nexport const HEADER_CHALLENGE_ID = 'x-btx-challenge-id';\nexport const HEADER_PROOF_NONCE = 'x-btx-proof-nonce';\nexport const HEADER_PROOF_DIGEST = 'x-btx-proof-digest';\n// Note: Fastify normalizes incoming header names to lowercase. Outgoing\n// reply.header() accepts any case.\n\n// ----------------------------------------------------------------------------\n// Types\n// ----------------------------------------------------------------------------\n\ntype StringOrFn = string | ((req: FastifyRequest) => string);\n\n/** Options for {@link btxAdmission}. */\nexport interface BtxAdmissionOpts {\n /** The BTX RPC client (constructed once at boot). */\n client: BtxChallengeClient;\n /** Logical purpose label, e.g. `'ai_inference_gate'` or `'rate_limit'`. */\n purpose: StringOrFn;\n /** Resource identifier, e.g. `(req) => \\`model:${req.body.model}|route:${req.url}\\``. */\n resource: StringOrFn;\n /** Subject identifier, e.g. `(req) => \\`tenant:${req.user.id}\\``. */\n subject: StringOrFn;\n /** Extra issue params forwarded to `client.issue()` (target_solve_time_s, expires_in_s, etc.). */\n issueParams?: Partial<Omit<IssueParams, 'purpose' | 'resource' | 'subject'>>;\n /** Optional hook fired on successful admission. Receives `req` + the redeem result. */\n onAdmit?: (req: FastifyRequest, result: VerifyResult) => void;\n /**\n * Optional hook fired when `client.issue()` or `client.redeem()` throws.\n * Receives the original error + the request. Fires exactly once before\n * the preHandler re-throws to hand off to Fastify's error pipeline.\n * Use this for logging/observability — don't mutate the error or the\n * reply. Audit ref: D-1.\n */\n onError?: (err: unknown, req: FastifyRequest) => void;\n /**\n * Override the default \"is the proof present?\" check. By default it returns\n * true iff all of `x-btx-challenge`, `x-btx-proof-nonce`, `x-btx-proof-digest`\n * are set.\n */\n isProofPresent?: (req: FastifyRequest) => boolean;\n}\n\ndeclare module 'fastify' {\n interface FastifyRequest {\n /**\n * Namespaced container for BTX middleware state. Populated on successful\n * admission. Mirrors `req.btx` from middleware-express (audit C-3 namespace).\n */\n btx?: {\n /** The `client.redeem()` result that admitted this request. */\n result: VerifyResult;\n };\n }\n}\n\n// ----------------------------------------------------------------------------\n// Middleware\n// ----------------------------------------------------------------------------\n\n/**\n * Build a Fastify `preHandler` hook that gates downstream handlers behind\n * a BTX service challenge. Use per-route via `{ preHandler: btxAdmission(opts) }`.\n */\nexport function btxAdmission(opts: BtxAdmissionOpts): preHandlerAsyncHookHandler {\n const proofPresent = opts.isProofPresent ?? defaultIsProofPresent;\n\n return async function btxAdmissionPreHandler(request, reply) {\n if (!proofPresent(request)) {\n await issueAndRespond(request, reply, opts);\n return;\n }\n await redeemAndAdmit(request, reply, opts);\n };\n}\n\nfunction defaultIsProofPresent(req: FastifyRequest): boolean {\n const h = req.headers;\n return Boolean(h[HEADER_CHALLENGE] && h[HEADER_PROOF_NONCE] && h[HEADER_PROOF_DIGEST]);\n}\n\nasync function issueAndRespond(\n req: FastifyRequest,\n reply: FastifyReply,\n opts: BtxAdmissionOpts,\n): Promise<void> {\n try {\n const purpose = resolve(opts.purpose, req);\n const resource = resolve(opts.resource, req);\n const subject = resolve(opts.subject, req);\n const challenge = await opts.client.issue({\n purpose,\n resource,\n subject,\n ...opts.issueParams,\n });\n await reply\n .code(402)\n .header(HEADER_CHALLENGE, JSON.stringify(challenge))\n .header('content-type', 'application/json')\n .send({\n challenge,\n retry_with: [HEADER_CHALLENGE, HEADER_PROOF_NONCE, HEADER_PROOF_DIGEST],\n });\n } catch (err) {\n opts.onError?.(err, req);\n throw err;\n }\n}\n\nasync function redeemAndAdmit(\n req: FastifyRequest,\n reply: FastifyReply,\n opts: BtxAdmissionOpts,\n): Promise<void> {\n const challengeRaw = headerValue(req, HEADER_CHALLENGE);\n const nonce = headerValue(req, HEADER_PROOF_NONCE);\n const digest = headerValue(req, HEADER_PROOF_DIGEST);\n\n if (!challengeRaw) {\n await reply.code(400).header('content-type', 'application/json').send({\n error: 'missing_challenge_header',\n message: `Retry must include the original challenge in the ${HEADER_CHALLENGE} header (echo-back).`,\n });\n return;\n }\n\n let challenge: Challenge;\n try {\n challenge = JSON.parse(challengeRaw) as Challenge;\n } catch {\n await reply.code(400).header('content-type', 'application/json').send({\n error: 'malformed_challenge_header',\n message: `${HEADER_CHALLENGE} must be a JSON-encoded Challenge envelope.`,\n });\n return;\n }\n\n // Optional sanity check: if the client also sent the challenge_id header,\n // make sure it matches the embedded id.\n const idHeader = headerValue(req, HEADER_CHALLENGE_ID);\n if (idHeader && idHeader !== challenge.challenge_id) {\n await reply.code(400).header('content-type', 'application/json').send({\n error: 'challenge_id_mismatch',\n message: `${HEADER_CHALLENGE_ID} does not match challenge_id in ${HEADER_CHALLENGE}.`,\n });\n return;\n }\n\n try {\n const result = await opts.client.redeem(challenge, nonce!, digest!);\n if (!result.valid) {\n await reply.code(403).header('content-type', 'application/json').send({\n valid: false,\n reason: result.reason,\n expired: result.expired,\n });\n return;\n }\n req.btx = { result };\n opts.onAdmit?.(req, result);\n // Fall through to the route handler — no explicit reply.send here.\n } catch (err) {\n opts.onError?.(err, req);\n throw err;\n }\n}\n\nfunction resolve(value: StringOrFn, req: FastifyRequest): string {\n return typeof value === 'function' ? value(req) : value;\n}\n\n/** Fastify header values can be string | string[] | undefined; normalize to string. */\nfunction headerValue(req: FastifyRequest, name: string): string | undefined {\n const v = req.headers[name];\n if (Array.isArray(v)) return v[0];\n return v;\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @btx-tools/middleware-fastify\n *\n * Drop-in Fastify admission gate backed by BTX service challenges.\n * Mirrors the behavior of `@btx-tools/middleware-express` for the Fastify\n * ecosystem.\n *\n * Flow (stateless, echo-the-challenge):\n *\n * client → POST /v1/generate (no proof headers)\n * server → 402 Payment Required\n * X-BTX-Challenge: <stringified challenge JSON>\n * body: { challenge, retry_with: [...] }\n *\n * client solves locally (or via RPC), retries:\n * client → POST /v1/generate\n * X-BTX-Challenge: <echoed challenge JSON>\n * X-BTX-Challenge-Id: <id> (optional sanity check)\n * X-BTX-Proof-Nonce: <hex>\n * X-BTX-Proof-Digest: <hex>\n * server → 200 OK (handler runs; request.btx?.result is the VerifyResult)\n *\n * Invalid proof → 403 with { valid: false, reason }.\n * btxd RPC error → Fastify's error pipeline (throw from preHandler).\n *\n * Stateless design notes:\n * - Server never stores issued challenges; client echoes the challenge back\n * in `X-BTX-Challenge`. Pros: scales horizontally, no sticky routing.\n * Cons: the challenge JSON (~3-5 KB) lives in an HTTP header, so check\n * your reverse proxy's `large_client_header_buffers` / equivalent.\n * - A stateful variant (server-side `challenge_id` cache) is a future\n * enhancement queued as `btxAdmission({ store })`.\n *\n * Usage (per-route):\n * ```ts\n * import Fastify from 'fastify';\n * import { BtxChallengeClient } from '@btx-tools/challenges-sdk';\n * import { btxAdmission } from '@btx-tools/middleware-fastify';\n *\n * const client = new BtxChallengeClient({ rpcUrl: '...', rpcAuth: { user, pass } });\n * const fastify = Fastify();\n *\n * fastify.post('/v1/generate', {\n * preHandler: btxAdmission({\n * client,\n * purpose: 'ai_inference_gate',\n * resource: (req) => `model:${(req.body as any).model}|route:${req.url}`,\n * subject: (req) => `tenant:${(req.body as any).tenant_id}`,\n * issueParams: { target_solve_time_s: 1.0, expires_in_s: 60 },\n * }),\n * }, async (request, reply) => {\n * // request.btx?.result is populated with the redeem VerifyResult\n * return { ok: true };\n * });\n * ```\n */\n\nimport type {\n FastifyReply,\n FastifyRequest,\n preHandlerAsyncHookHandler,\n} from 'fastify';\n\nimport type {\n BtxChallengeClient,\n Challenge,\n IssueParams,\n VerifyResult,\n} from '@btx-tools/challenges-sdk';\n\n// ----------------------------------------------------------------------------\n// Public constants\n// ----------------------------------------------------------------------------\n\nexport const HEADER_CHALLENGE = 'x-btx-challenge';\nexport const HEADER_CHALLENGE_ID = 'x-btx-challenge-id';\nexport const HEADER_PROOF_NONCE = 'x-btx-proof-nonce';\nexport const HEADER_PROOF_DIGEST = 'x-btx-proof-digest';\n// Note: Fastify normalizes incoming header names to lowercase. Outgoing\n// reply.header() accepts any case.\n\n// ----------------------------------------------------------------------------\n// Types\n// ----------------------------------------------------------------------------\n\ntype StringOrFn = string | ((req: FastifyRequest) => string);\n\n/** Options for {@link btxAdmission}. */\nexport interface BtxAdmissionOpts {\n /** The BTX RPC client (constructed once at boot). */\n client: BtxChallengeClient;\n /** Logical purpose label, e.g. `'ai_inference_gate'` or `'rate_limit'`. */\n purpose: StringOrFn;\n /** Resource identifier, e.g. `(req) => \\`model:${req.body.model}|route:${req.url}\\``. */\n resource: StringOrFn;\n /** Subject identifier, e.g. `(req) => \\`tenant:${req.user.id}\\``. */\n subject: StringOrFn;\n /** Extra issue params forwarded to `client.issue()` (target_solve_time_s, expires_in_s, etc.). */\n issueParams?: Partial<Omit<IssueParams, 'purpose' | 'resource' | 'subject'>>;\n /** Optional hook fired on successful admission. Receives `req` + the redeem result. */\n onAdmit?: (req: FastifyRequest, result: VerifyResult) => void;\n /**\n * Optional hook fired when `client.issue()` or `client.redeem()` throws.\n * Receives the original error + the request. Fires exactly once before\n * the preHandler re-throws to hand off to Fastify's error pipeline.\n * Use this for logging/observability — don't mutate the error or the\n * reply. Audit ref: D-1.\n */\n onError?: (err: unknown, req: FastifyRequest) => void;\n /**\n * Override the default \"is the proof present?\" check. By default it returns\n * true iff all of `x-btx-challenge`, `x-btx-proof-nonce`, `x-btx-proof-digest`\n * are set.\n */\n isProofPresent?: (req: FastifyRequest) => boolean;\n}\n\ndeclare module 'fastify' {\n interface FastifyRequest {\n /**\n * Namespaced container for BTX middleware state. Populated on successful\n * admission. Mirrors `req.btx` from middleware-express (audit C-3 namespace).\n */\n btx?: {\n /** The `client.redeem()` result that admitted this request. */\n result: VerifyResult;\n };\n }\n}\n\n// ----------------------------------------------------------------------------\n// Middleware\n// ----------------------------------------------------------------------------\n\n/**\n * Build a Fastify `preHandler` hook that gates downstream handlers behind\n * a BTX service challenge. Use per-route via `{ preHandler: btxAdmission(opts) }`.\n */\nexport function btxAdmission(opts: BtxAdmissionOpts): preHandlerAsyncHookHandler {\n const proofPresent = opts.isProofPresent ?? defaultIsProofPresent;\n\n return async function btxAdmissionPreHandler(request, reply) {\n if (!proofPresent(request)) {\n await issueAndRespond(request, reply, opts);\n return;\n }\n await redeemAndAdmit(request, reply, opts);\n };\n}\n\nfunction defaultIsProofPresent(req: FastifyRequest): boolean {\n const h = req.headers;\n return Boolean(h[HEADER_CHALLENGE] && h[HEADER_PROOF_NONCE] && h[HEADER_PROOF_DIGEST]);\n}\n\nasync function issueAndRespond(\n req: FastifyRequest,\n reply: FastifyReply,\n opts: BtxAdmissionOpts,\n): Promise<void> {\n try {\n const purpose = resolve(opts.purpose, req);\n const resource = resolve(opts.resource, req);\n const subject = resolve(opts.subject, req);\n const challenge = await opts.client.issue({\n purpose,\n resource,\n subject,\n ...opts.issueParams,\n });\n await reply\n .code(402)\n .header(HEADER_CHALLENGE, JSON.stringify(challenge))\n .header('content-type', 'application/json')\n .send({\n challenge,\n retry_with: [HEADER_CHALLENGE, HEADER_PROOF_NONCE, HEADER_PROOF_DIGEST],\n });\n } catch (err) {\n opts.onError?.(err, req);\n throw err;\n }\n}\n\nasync function redeemAndAdmit(\n req: FastifyRequest,\n reply: FastifyReply,\n opts: BtxAdmissionOpts,\n): Promise<void> {\n const challengeRaw = headerValue(req, HEADER_CHALLENGE);\n const nonce = headerValue(req, HEADER_PROOF_NONCE);\n const digest = headerValue(req, HEADER_PROOF_DIGEST);\n\n if (!challengeRaw) {\n await reply.code(400).header('content-type', 'application/json').send({\n error: 'missing_challenge_header',\n message: `Retry must include the original challenge in the ${HEADER_CHALLENGE} header (echo-back).`,\n });\n return;\n }\n\n let challenge: Challenge;\n try {\n challenge = JSON.parse(challengeRaw) as Challenge;\n } catch {\n await reply.code(400).header('content-type', 'application/json').send({\n error: 'malformed_challenge_header',\n message: `${HEADER_CHALLENGE} must be a JSON-encoded Challenge envelope.`,\n });\n return;\n }\n\n // Optional sanity check: if the client also sent the challenge_id header,\n // make sure it matches the embedded id.\n const idHeader = headerValue(req, HEADER_CHALLENGE_ID);\n if (idHeader && idHeader !== challenge.challenge_id) {\n await reply.code(400).header('content-type', 'application/json').send({\n error: 'challenge_id_mismatch',\n message: `${HEADER_CHALLENGE_ID} does not match challenge_id in ${HEADER_CHALLENGE}.`,\n });\n return;\n }\n\n try {\n const result = await opts.client.redeem(challenge, nonce!, digest!);\n if (!result.valid) {\n await reply.code(403).header('content-type', 'application/json').send({\n valid: false,\n reason: result.reason,\n expired: result.expired,\n });\n return;\n }\n req.btx = { result };\n opts.onAdmit?.(req, result);\n // Fall through to the route handler — no explicit reply.send here.\n } catch (err) {\n opts.onError?.(err, req);\n throw err;\n }\n}\n\nfunction resolve(value: StringOrFn, req: FastifyRequest): string {\n return typeof value === 'function' ? value(req) : value;\n}\n\n/**\n * Fastify header values can be string | string[] | undefined; normalize to\n * string by returning the first occurrence.\n *\n * Note (audit M-7 2026-05-23): when a duplicate header is sent (HTTP permits\n * duplicates), we intentionally pick the FIRST value. This matches standard\n * proxy behavior and avoids ambiguity. If a reverse proxy in front reorders\n * duplicate headers, the SDK's behavior follows that proxy's order. None of\n * the BTX headers should legitimately arrive duplicated under normal use.\n */\nfunction headerValue(req: FastifyRequest, name: string): string | undefined {\n const v = req.headers[name];\n if (Array.isArray(v)) return v[0];\n return v;\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@btx-tools/middleware-fastify",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Fastify plugin for @btx-tools/challenges-sdk — drop-in BTX service-challenge admission gate",
5
5
  "author": "visitor-code <visitor@friction.market>",
6
6
  "license": "MIT",
@@ -40,7 +40,7 @@
40
40
  "tsup": "^8.3.0",
41
41
  "typescript": "^5.6.0",
42
42
  "vitest": "^2.1.0",
43
- "@btx-tools/challenges-sdk": "0.1.0"
43
+ "@btx-tools/challenges-sdk": "0.1.1"
44
44
  },
45
45
  "keywords": [
46
46
  "btx",