@btx-tools/middleware-fastify 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 visitor-code
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # @btx-tools/middleware-fastify
2
+
3
+ Drop-in **Fastify** admission gate backed by BTX service challenges. Same flow + ergonomics as [`@btx-tools/middleware-express`](https://www.npmjs.com/package/@btx-tools/middleware-express), tailored to Fastify's preHandler hook + reply API.
4
+
5
+ ```bash
6
+ pnpm add @btx-tools/middleware-fastify @btx-tools/challenges-sdk fastify
7
+ ```
8
+
9
+ ## Quickstart
10
+
11
+ ```ts
12
+ import Fastify from 'fastify';
13
+ import { BtxChallengeClient } from '@btx-tools/challenges-sdk';
14
+ import { btxAdmission } from '@btx-tools/middleware-fastify';
15
+
16
+ const client = new BtxChallengeClient({
17
+ rpcUrl: 'http://127.0.0.1:19334',
18
+ rpcAuth: { user: 'rpcuser', pass: 'rpcpass' },
19
+ });
20
+
21
+ const fastify = Fastify();
22
+
23
+ fastify.post('/v1/generate', {
24
+ preHandler: btxAdmission({
25
+ client,
26
+ purpose: 'ai_inference_gate',
27
+ resource: (req) => `model:${(req.body as any).model}|route:${req.url}`,
28
+ subject: (req) => `tenant:${(req.body as any).tenant_id}`,
29
+ issueParams: { target_solve_time_s: 1.0, expires_in_s: 60 },
30
+ onError: (err, req) => req.log.error({ err }, 'btx admission error'),
31
+ }),
32
+ }, async (request, reply) => {
33
+ // request.btx?.result is populated with the redeem VerifyResult
34
+ return { ok: true, generated: '...' };
35
+ });
36
+
37
+ await fastify.listen({ port: 3000 });
38
+ ```
39
+
40
+ ## How it works
41
+
42
+ Stateless **echo-the-challenge** flow:
43
+
44
+ 1. **First request** has no proof headers → middleware calls `client.issue()` → replies `402 Payment Required` with `X-BTX-Challenge` header containing the challenge JSON + a body listing the headers the client should add on retry.
45
+ 2. **Client solves** the challenge (locally or via RPC) and **retries** with `X-BTX-Challenge` (echoed), `X-BTX-Proof-Nonce`, `X-BTX-Proof-Digest`.
46
+ 3. Middleware calls `client.redeem()` → if `result.valid === true`, sets `request.btx = { result }` and runs the route handler. Else replies `403`.
47
+
48
+ No server-side challenge store; the client echoes the challenge back. Scales horizontally. Cons: the challenge JSON (~3-5 KB) lives in an HTTP header, so check your reverse proxy's `large_client_header_buffers` / equivalent.
49
+
50
+ ## API
51
+
52
+ ### `btxAdmission(opts): preHandlerAsyncHookHandler`
53
+
54
+ Returns a Fastify preHandler hook to attach per-route.
55
+
56
+ #### Options
57
+
58
+ | Field | Type | Notes |
59
+ |---|---|---|
60
+ | `client` | `BtxChallengeClient` | required. Construct once at boot. |
61
+ | `purpose` | `string \| (req) => string` | required. Logical purpose label. |
62
+ | `resource` | `string \| (req) => string` | required. Resource identifier. |
63
+ | `subject` | `string \| (req) => string` | required. Subject identifier. |
64
+ | `issueParams` | `Partial<IssueParams>` | optional. Extra params forwarded to `client.issue()`. |
65
+ | `onAdmit` | `(req, result) => void` | optional. Fires on successful admission. |
66
+ | `onError` | `(err, req) => void` | optional. Fires when `client.issue()` or `client.redeem()` throws, exactly once before the preHandler re-throws. Use for logging. Audit ref: D-1. |
67
+ | `isProofPresent` | `(req) => boolean` | optional. Override the default `headers[x-btx-challenge] && headers[x-btx-proof-nonce] && headers[x-btx-proof-digest]` check. |
68
+
69
+ ### Header constants
70
+
71
+ | Constant | Value |
72
+ |---|---|
73
+ | `HEADER_CHALLENGE` | `'x-btx-challenge'` |
74
+ | `HEADER_CHALLENGE_ID` | `'x-btx-challenge-id'` |
75
+ | `HEADER_PROOF_NONCE` | `'x-btx-proof-nonce'` |
76
+ | `HEADER_PROOF_DIGEST` | `'x-btx-proof-digest'` |
77
+
78
+ (Fastify lowercases incoming header names, hence the lowercase form here. Outgoing `reply.header()` accepts any case.)
79
+
80
+ ## Error handling
81
+
82
+ When `client.issue()` or `client.redeem()` throws (e.g., btxd RPC down, network error), the middleware:
83
+ 1. Calls `opts.onError(err, req)` if provided
84
+ 2. Re-throws — Fastify's standard error-handling pipeline kicks in (default 500, or whatever your error handler returns)
85
+
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
+
88
+ ## Requirements
89
+
90
+ - **Node.js** ≥ 18.17
91
+ - **Fastify** ^4.0.0 or ^5.0.0 (peer dep)
92
+ - **@btx-tools/challenges-sdk** ^0.0.4 (peer dep)
93
+
94
+ ## License
95
+
96
+ MIT. See [LICENSE](./LICENSE).
package/dist/index.cjs ADDED
@@ -0,0 +1,103 @@
1
+ 'use strict';
2
+
3
+ // src/index.ts
4
+ var HEADER_CHALLENGE = "x-btx-challenge";
5
+ var HEADER_CHALLENGE_ID = "x-btx-challenge-id";
6
+ var HEADER_PROOF_NONCE = "x-btx-proof-nonce";
7
+ var HEADER_PROOF_DIGEST = "x-btx-proof-digest";
8
+ function btxAdmission(opts) {
9
+ const proofPresent = opts.isProofPresent ?? defaultIsProofPresent;
10
+ return async function btxAdmissionPreHandler(request, reply) {
11
+ if (!proofPresent(request)) {
12
+ await issueAndRespond(request, reply, opts);
13
+ return;
14
+ }
15
+ await redeemAndAdmit(request, reply, opts);
16
+ };
17
+ }
18
+ function defaultIsProofPresent(req) {
19
+ const h = req.headers;
20
+ return Boolean(h[HEADER_CHALLENGE] && h[HEADER_PROOF_NONCE] && h[HEADER_PROOF_DIGEST]);
21
+ }
22
+ async function issueAndRespond(req, reply, opts) {
23
+ try {
24
+ const purpose = resolve(opts.purpose, req);
25
+ const resource = resolve(opts.resource, req);
26
+ const subject = resolve(opts.subject, req);
27
+ const challenge = await opts.client.issue({
28
+ purpose,
29
+ resource,
30
+ subject,
31
+ ...opts.issueParams
32
+ });
33
+ await reply.code(402).header(HEADER_CHALLENGE, JSON.stringify(challenge)).header("content-type", "application/json").send({
34
+ challenge,
35
+ retry_with: [HEADER_CHALLENGE, HEADER_PROOF_NONCE, HEADER_PROOF_DIGEST]
36
+ });
37
+ } catch (err) {
38
+ opts.onError?.(err, req);
39
+ throw err;
40
+ }
41
+ }
42
+ async function redeemAndAdmit(req, reply, opts) {
43
+ const challengeRaw = headerValue(req, HEADER_CHALLENGE);
44
+ const nonce = headerValue(req, HEADER_PROOF_NONCE);
45
+ const digest = headerValue(req, HEADER_PROOF_DIGEST);
46
+ if (!challengeRaw) {
47
+ await reply.code(400).header("content-type", "application/json").send({
48
+ error: "missing_challenge_header",
49
+ message: `Retry must include the original challenge in the ${HEADER_CHALLENGE} header (echo-back).`
50
+ });
51
+ return;
52
+ }
53
+ let challenge;
54
+ try {
55
+ challenge = JSON.parse(challengeRaw);
56
+ } catch {
57
+ await reply.code(400).header("content-type", "application/json").send({
58
+ error: "malformed_challenge_header",
59
+ message: `${HEADER_CHALLENGE} must be a JSON-encoded Challenge envelope.`
60
+ });
61
+ return;
62
+ }
63
+ const idHeader = headerValue(req, HEADER_CHALLENGE_ID);
64
+ if (idHeader && idHeader !== challenge.challenge_id) {
65
+ await reply.code(400).header("content-type", "application/json").send({
66
+ error: "challenge_id_mismatch",
67
+ message: `${HEADER_CHALLENGE_ID} does not match challenge_id in ${HEADER_CHALLENGE}.`
68
+ });
69
+ return;
70
+ }
71
+ try {
72
+ const result = await opts.client.redeem(challenge, nonce, digest);
73
+ if (!result.valid) {
74
+ await reply.code(403).header("content-type", "application/json").send({
75
+ valid: false,
76
+ reason: result.reason,
77
+ expired: result.expired
78
+ });
79
+ return;
80
+ }
81
+ req.btx = { result };
82
+ opts.onAdmit?.(req, result);
83
+ } catch (err) {
84
+ opts.onError?.(err, req);
85
+ throw err;
86
+ }
87
+ }
88
+ function resolve(value, req) {
89
+ return typeof value === "function" ? value(req) : value;
90
+ }
91
+ function headerValue(req, name) {
92
+ const v = req.headers[name];
93
+ if (Array.isArray(v)) return v[0];
94
+ return v;
95
+ }
96
+
97
+ exports.HEADER_CHALLENGE = HEADER_CHALLENGE;
98
+ exports.HEADER_CHALLENGE_ID = HEADER_CHALLENGE_ID;
99
+ exports.HEADER_PROOF_DIGEST = HEADER_PROOF_DIGEST;
100
+ exports.HEADER_PROOF_NONCE = HEADER_PROOF_NONCE;
101
+ exports.btxAdmission = btxAdmission;
102
+ //# sourceMappingURL=index.cjs.map
103
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +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"]}
@@ -0,0 +1,113 @@
1
+ import { FastifyRequest, preHandlerAsyncHookHandler } from 'fastify';
2
+ import { VerifyResult, BtxChallengeClient, IssueParams } from '@btx-tools/challenges-sdk';
3
+
4
+ /**
5
+ * @btx-tools/middleware-fastify
6
+ *
7
+ * Drop-in Fastify admission gate backed by BTX service challenges.
8
+ * Mirrors the behavior of `@btx-tools/middleware-express` for the Fastify
9
+ * ecosystem.
10
+ *
11
+ * Flow (stateless, echo-the-challenge):
12
+ *
13
+ * client → POST /v1/generate (no proof headers)
14
+ * server → 402 Payment Required
15
+ * X-BTX-Challenge: <stringified challenge JSON>
16
+ * body: { challenge, retry_with: [...] }
17
+ *
18
+ * client solves locally (or via RPC), retries:
19
+ * client → POST /v1/generate
20
+ * X-BTX-Challenge: <echoed challenge JSON>
21
+ * X-BTX-Challenge-Id: <id> (optional sanity check)
22
+ * X-BTX-Proof-Nonce: <hex>
23
+ * X-BTX-Proof-Digest: <hex>
24
+ * server → 200 OK (handler runs; request.btx?.result is the VerifyResult)
25
+ *
26
+ * Invalid proof → 403 with { valid: false, reason }.
27
+ * btxd RPC error → Fastify's error pipeline (throw from preHandler).
28
+ *
29
+ * Stateless design notes:
30
+ * - Server never stores issued challenges; client echoes the challenge back
31
+ * in `X-BTX-Challenge`. Pros: scales horizontally, no sticky routing.
32
+ * Cons: the challenge JSON (~3-5 KB) lives in an HTTP header, so check
33
+ * your reverse proxy's `large_client_header_buffers` / equivalent.
34
+ * - A stateful variant (server-side `challenge_id` cache) is a future
35
+ * enhancement queued as `btxAdmission({ store })`.
36
+ *
37
+ * Usage (per-route):
38
+ * ```ts
39
+ * import Fastify from 'fastify';
40
+ * import { BtxChallengeClient } from '@btx-tools/challenges-sdk';
41
+ * import { btxAdmission } from '@btx-tools/middleware-fastify';
42
+ *
43
+ * const client = new BtxChallengeClient({ rpcUrl: '...', rpcAuth: { user, pass } });
44
+ * const fastify = Fastify();
45
+ *
46
+ * fastify.post('/v1/generate', {
47
+ * preHandler: btxAdmission({
48
+ * client,
49
+ * purpose: 'ai_inference_gate',
50
+ * resource: (req) => `model:${(req.body as any).model}|route:${req.url}`,
51
+ * subject: (req) => `tenant:${(req.body as any).tenant_id}`,
52
+ * issueParams: { target_solve_time_s: 1.0, expires_in_s: 60 },
53
+ * }),
54
+ * }, async (request, reply) => {
55
+ * // request.btx?.result is populated with the redeem VerifyResult
56
+ * return { ok: true };
57
+ * });
58
+ * ```
59
+ */
60
+
61
+ declare const HEADER_CHALLENGE = "x-btx-challenge";
62
+ declare const HEADER_CHALLENGE_ID = "x-btx-challenge-id";
63
+ declare const HEADER_PROOF_NONCE = "x-btx-proof-nonce";
64
+ declare const HEADER_PROOF_DIGEST = "x-btx-proof-digest";
65
+ type StringOrFn = string | ((req: FastifyRequest) => string);
66
+ /** Options for {@link btxAdmission}. */
67
+ interface BtxAdmissionOpts {
68
+ /** The BTX RPC client (constructed once at boot). */
69
+ client: BtxChallengeClient;
70
+ /** Logical purpose label, e.g. `'ai_inference_gate'` or `'rate_limit'`. */
71
+ purpose: StringOrFn;
72
+ /** Resource identifier, e.g. `(req) => \`model:${req.body.model}|route:${req.url}\``. */
73
+ resource: StringOrFn;
74
+ /** Subject identifier, e.g. `(req) => \`tenant:${req.user.id}\``. */
75
+ subject: StringOrFn;
76
+ /** Extra issue params forwarded to `client.issue()` (target_solve_time_s, expires_in_s, etc.). */
77
+ issueParams?: Partial<Omit<IssueParams, 'purpose' | 'resource' | 'subject'>>;
78
+ /** Optional hook fired on successful admission. Receives `req` + the redeem result. */
79
+ onAdmit?: (req: FastifyRequest, result: VerifyResult) => void;
80
+ /**
81
+ * Optional hook fired when `client.issue()` or `client.redeem()` throws.
82
+ * Receives the original error + the request. Fires exactly once before
83
+ * the preHandler re-throws to hand off to Fastify's error pipeline.
84
+ * Use this for logging/observability — don't mutate the error or the
85
+ * reply. Audit ref: D-1.
86
+ */
87
+ onError?: (err: unknown, req: FastifyRequest) => void;
88
+ /**
89
+ * Override the default "is the proof present?" check. By default it returns
90
+ * true iff all of `x-btx-challenge`, `x-btx-proof-nonce`, `x-btx-proof-digest`
91
+ * are set.
92
+ */
93
+ isProofPresent?: (req: FastifyRequest) => boolean;
94
+ }
95
+ declare module 'fastify' {
96
+ interface FastifyRequest {
97
+ /**
98
+ * Namespaced container for BTX middleware state. Populated on successful
99
+ * admission. Mirrors `req.btx` from middleware-express (audit C-3 namespace).
100
+ */
101
+ btx?: {
102
+ /** The `client.redeem()` result that admitted this request. */
103
+ result: VerifyResult;
104
+ };
105
+ }
106
+ }
107
+ /**
108
+ * Build a Fastify `preHandler` hook that gates downstream handlers behind
109
+ * a BTX service challenge. Use per-route via `{ preHandler: btxAdmission(opts) }`.
110
+ */
111
+ declare function btxAdmission(opts: BtxAdmissionOpts): preHandlerAsyncHookHandler;
112
+
113
+ export { type BtxAdmissionOpts, HEADER_CHALLENGE, HEADER_CHALLENGE_ID, HEADER_PROOF_DIGEST, HEADER_PROOF_NONCE, btxAdmission };
@@ -0,0 +1,113 @@
1
+ import { FastifyRequest, preHandlerAsyncHookHandler } from 'fastify';
2
+ import { VerifyResult, BtxChallengeClient, IssueParams } from '@btx-tools/challenges-sdk';
3
+
4
+ /**
5
+ * @btx-tools/middleware-fastify
6
+ *
7
+ * Drop-in Fastify admission gate backed by BTX service challenges.
8
+ * Mirrors the behavior of `@btx-tools/middleware-express` for the Fastify
9
+ * ecosystem.
10
+ *
11
+ * Flow (stateless, echo-the-challenge):
12
+ *
13
+ * client → POST /v1/generate (no proof headers)
14
+ * server → 402 Payment Required
15
+ * X-BTX-Challenge: <stringified challenge JSON>
16
+ * body: { challenge, retry_with: [...] }
17
+ *
18
+ * client solves locally (or via RPC), retries:
19
+ * client → POST /v1/generate
20
+ * X-BTX-Challenge: <echoed challenge JSON>
21
+ * X-BTX-Challenge-Id: <id> (optional sanity check)
22
+ * X-BTX-Proof-Nonce: <hex>
23
+ * X-BTX-Proof-Digest: <hex>
24
+ * server → 200 OK (handler runs; request.btx?.result is the VerifyResult)
25
+ *
26
+ * Invalid proof → 403 with { valid: false, reason }.
27
+ * btxd RPC error → Fastify's error pipeline (throw from preHandler).
28
+ *
29
+ * Stateless design notes:
30
+ * - Server never stores issued challenges; client echoes the challenge back
31
+ * in `X-BTX-Challenge`. Pros: scales horizontally, no sticky routing.
32
+ * Cons: the challenge JSON (~3-5 KB) lives in an HTTP header, so check
33
+ * your reverse proxy's `large_client_header_buffers` / equivalent.
34
+ * - A stateful variant (server-side `challenge_id` cache) is a future
35
+ * enhancement queued as `btxAdmission({ store })`.
36
+ *
37
+ * Usage (per-route):
38
+ * ```ts
39
+ * import Fastify from 'fastify';
40
+ * import { BtxChallengeClient } from '@btx-tools/challenges-sdk';
41
+ * import { btxAdmission } from '@btx-tools/middleware-fastify';
42
+ *
43
+ * const client = new BtxChallengeClient({ rpcUrl: '...', rpcAuth: { user, pass } });
44
+ * const fastify = Fastify();
45
+ *
46
+ * fastify.post('/v1/generate', {
47
+ * preHandler: btxAdmission({
48
+ * client,
49
+ * purpose: 'ai_inference_gate',
50
+ * resource: (req) => `model:${(req.body as any).model}|route:${req.url}`,
51
+ * subject: (req) => `tenant:${(req.body as any).tenant_id}`,
52
+ * issueParams: { target_solve_time_s: 1.0, expires_in_s: 60 },
53
+ * }),
54
+ * }, async (request, reply) => {
55
+ * // request.btx?.result is populated with the redeem VerifyResult
56
+ * return { ok: true };
57
+ * });
58
+ * ```
59
+ */
60
+
61
+ declare const HEADER_CHALLENGE = "x-btx-challenge";
62
+ declare const HEADER_CHALLENGE_ID = "x-btx-challenge-id";
63
+ declare const HEADER_PROOF_NONCE = "x-btx-proof-nonce";
64
+ declare const HEADER_PROOF_DIGEST = "x-btx-proof-digest";
65
+ type StringOrFn = string | ((req: FastifyRequest) => string);
66
+ /** Options for {@link btxAdmission}. */
67
+ interface BtxAdmissionOpts {
68
+ /** The BTX RPC client (constructed once at boot). */
69
+ client: BtxChallengeClient;
70
+ /** Logical purpose label, e.g. `'ai_inference_gate'` or `'rate_limit'`. */
71
+ purpose: StringOrFn;
72
+ /** Resource identifier, e.g. `(req) => \`model:${req.body.model}|route:${req.url}\``. */
73
+ resource: StringOrFn;
74
+ /** Subject identifier, e.g. `(req) => \`tenant:${req.user.id}\``. */
75
+ subject: StringOrFn;
76
+ /** Extra issue params forwarded to `client.issue()` (target_solve_time_s, expires_in_s, etc.). */
77
+ issueParams?: Partial<Omit<IssueParams, 'purpose' | 'resource' | 'subject'>>;
78
+ /** Optional hook fired on successful admission. Receives `req` + the redeem result. */
79
+ onAdmit?: (req: FastifyRequest, result: VerifyResult) => void;
80
+ /**
81
+ * Optional hook fired when `client.issue()` or `client.redeem()` throws.
82
+ * Receives the original error + the request. Fires exactly once before
83
+ * the preHandler re-throws to hand off to Fastify's error pipeline.
84
+ * Use this for logging/observability — don't mutate the error or the
85
+ * reply. Audit ref: D-1.
86
+ */
87
+ onError?: (err: unknown, req: FastifyRequest) => void;
88
+ /**
89
+ * Override the default "is the proof present?" check. By default it returns
90
+ * true iff all of `x-btx-challenge`, `x-btx-proof-nonce`, `x-btx-proof-digest`
91
+ * are set.
92
+ */
93
+ isProofPresent?: (req: FastifyRequest) => boolean;
94
+ }
95
+ declare module 'fastify' {
96
+ interface FastifyRequest {
97
+ /**
98
+ * Namespaced container for BTX middleware state. Populated on successful
99
+ * admission. Mirrors `req.btx` from middleware-express (audit C-3 namespace).
100
+ */
101
+ btx?: {
102
+ /** The `client.redeem()` result that admitted this request. */
103
+ result: VerifyResult;
104
+ };
105
+ }
106
+ }
107
+ /**
108
+ * Build a Fastify `preHandler` hook that gates downstream handlers behind
109
+ * a BTX service challenge. Use per-route via `{ preHandler: btxAdmission(opts) }`.
110
+ */
111
+ declare function btxAdmission(opts: BtxAdmissionOpts): preHandlerAsyncHookHandler;
112
+
113
+ export { type BtxAdmissionOpts, HEADER_CHALLENGE, HEADER_CHALLENGE_ID, HEADER_PROOF_DIGEST, HEADER_PROOF_NONCE, btxAdmission };
package/dist/index.js ADDED
@@ -0,0 +1,97 @@
1
+ // src/index.ts
2
+ var HEADER_CHALLENGE = "x-btx-challenge";
3
+ var HEADER_CHALLENGE_ID = "x-btx-challenge-id";
4
+ var HEADER_PROOF_NONCE = "x-btx-proof-nonce";
5
+ var HEADER_PROOF_DIGEST = "x-btx-proof-digest";
6
+ function btxAdmission(opts) {
7
+ const proofPresent = opts.isProofPresent ?? defaultIsProofPresent;
8
+ return async function btxAdmissionPreHandler(request, reply) {
9
+ if (!proofPresent(request)) {
10
+ await issueAndRespond(request, reply, opts);
11
+ return;
12
+ }
13
+ await redeemAndAdmit(request, reply, opts);
14
+ };
15
+ }
16
+ function defaultIsProofPresent(req) {
17
+ const h = req.headers;
18
+ return Boolean(h[HEADER_CHALLENGE] && h[HEADER_PROOF_NONCE] && h[HEADER_PROOF_DIGEST]);
19
+ }
20
+ async function issueAndRespond(req, reply, opts) {
21
+ try {
22
+ const purpose = resolve(opts.purpose, req);
23
+ const resource = resolve(opts.resource, req);
24
+ const subject = resolve(opts.subject, req);
25
+ const challenge = await opts.client.issue({
26
+ purpose,
27
+ resource,
28
+ subject,
29
+ ...opts.issueParams
30
+ });
31
+ await reply.code(402).header(HEADER_CHALLENGE, JSON.stringify(challenge)).header("content-type", "application/json").send({
32
+ challenge,
33
+ retry_with: [HEADER_CHALLENGE, HEADER_PROOF_NONCE, HEADER_PROOF_DIGEST]
34
+ });
35
+ } catch (err) {
36
+ opts.onError?.(err, req);
37
+ throw err;
38
+ }
39
+ }
40
+ async function redeemAndAdmit(req, reply, opts) {
41
+ const challengeRaw = headerValue(req, HEADER_CHALLENGE);
42
+ const nonce = headerValue(req, HEADER_PROOF_NONCE);
43
+ const digest = headerValue(req, HEADER_PROOF_DIGEST);
44
+ if (!challengeRaw) {
45
+ await reply.code(400).header("content-type", "application/json").send({
46
+ error: "missing_challenge_header",
47
+ message: `Retry must include the original challenge in the ${HEADER_CHALLENGE} header (echo-back).`
48
+ });
49
+ return;
50
+ }
51
+ let challenge;
52
+ try {
53
+ challenge = JSON.parse(challengeRaw);
54
+ } catch {
55
+ await reply.code(400).header("content-type", "application/json").send({
56
+ error: "malformed_challenge_header",
57
+ message: `${HEADER_CHALLENGE} must be a JSON-encoded Challenge envelope.`
58
+ });
59
+ return;
60
+ }
61
+ const idHeader = headerValue(req, HEADER_CHALLENGE_ID);
62
+ if (idHeader && idHeader !== challenge.challenge_id) {
63
+ await reply.code(400).header("content-type", "application/json").send({
64
+ error: "challenge_id_mismatch",
65
+ message: `${HEADER_CHALLENGE_ID} does not match challenge_id in ${HEADER_CHALLENGE}.`
66
+ });
67
+ return;
68
+ }
69
+ try {
70
+ const result = await opts.client.redeem(challenge, nonce, digest);
71
+ if (!result.valid) {
72
+ await reply.code(403).header("content-type", "application/json").send({
73
+ valid: false,
74
+ reason: result.reason,
75
+ expired: result.expired
76
+ });
77
+ return;
78
+ }
79
+ req.btx = { result };
80
+ opts.onAdmit?.(req, result);
81
+ } catch (err) {
82
+ opts.onError?.(err, req);
83
+ throw err;
84
+ }
85
+ }
86
+ function resolve(value, req) {
87
+ return typeof value === "function" ? value(req) : value;
88
+ }
89
+ function headerValue(req, name) {
90
+ const v = req.headers[name];
91
+ if (Array.isArray(v)) return v[0];
92
+ return v;
93
+ }
94
+
95
+ export { HEADER_CHALLENGE, HEADER_CHALLENGE_ID, HEADER_PROOF_DIGEST, HEADER_PROOF_NONCE, btxAdmission };
96
+ //# sourceMappingURL=index.js.map
97
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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"]}
package/package.json ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "@btx-tools/middleware-fastify",
3
+ "version": "0.1.0",
4
+ "description": "Fastify plugin for @btx-tools/challenges-sdk — drop-in BTX service-challenge admission gate",
5
+ "author": "visitor-code <visitor@friction.market>",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "sideEffects": false,
9
+ "main": "./dist/index.cjs",
10
+ "module": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "import": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ },
18
+ "require": {
19
+ "types": "./dist/index.d.cts",
20
+ "default": "./dist/index.cjs"
21
+ }
22
+ }
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "README.md",
27
+ "LICENSE"
28
+ ],
29
+ "peerDependencies": {
30
+ "@btx-tools/challenges-sdk": "^0.0.4 || ^0.1.0",
31
+ "fastify": "^4.0.0 || ^5.0.0"
32
+ },
33
+ "dependencies": {
34
+ "fastify-plugin": "^5.0.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^22.0.0",
38
+ "fastify": "^5.0.0",
39
+ "prettier": "^3.3.0",
40
+ "tsup": "^8.3.0",
41
+ "typescript": "^5.6.0",
42
+ "vitest": "^2.1.0",
43
+ "@btx-tools/challenges-sdk": "0.1.0"
44
+ },
45
+ "keywords": [
46
+ "btx",
47
+ "fastify",
48
+ "middleware",
49
+ "plugin",
50
+ "admission",
51
+ "rate-limit",
52
+ "captcha",
53
+ "ai-gating",
54
+ "service-challenges",
55
+ "proof-of-work"
56
+ ],
57
+ "repository": {
58
+ "type": "git",
59
+ "url": "git+https://github.com/btx-tools/btx-challenges-sdk.git",
60
+ "directory": "packages/middleware-fastify"
61
+ },
62
+ "homepage": "https://github.com/btx-tools/btx-challenges-sdk/tree/main/packages/middleware-fastify#readme",
63
+ "bugs": {
64
+ "url": "https://github.com/btx-tools/btx-challenges-sdk/issues"
65
+ },
66
+ "publishConfig": {
67
+ "access": "public"
68
+ },
69
+ "engines": {
70
+ "node": ">=18.17"
71
+ },
72
+ "scripts": {
73
+ "build": "tsup",
74
+ "test": "vitest run",
75
+ "test:watch": "vitest",
76
+ "test:unit": "vitest run tests/unit",
77
+ "type-check": "tsc --noEmit",
78
+ "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\""
79
+ }
80
+ }