@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 +21 -0
- package/README.md +96 -0
- package/dist/index.cjs +103 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +113 -0
- package/dist/index.d.ts +113 -0
- package/dist/index.js +97 -0
- package/dist/index.js.map +1 -0
- package/package.json +80 -0
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"]}
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
}
|