@btx-tools/middleware-hono 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 +119 -0
- package/dist/index.cjs +115 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +114 -0
- package/dist/index.d.ts +114 -0
- package/dist/index.js +109 -0
- package/dist/index.js.map +1 -0
- package/package.json +79 -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,119 @@
|
|
|
1
|
+
# @btx-tools/middleware-hono
|
|
2
|
+
|
|
3
|
+
Drop-in **Hono** admission gate backed by BTX service challenges. Works on Node, Deno, Bun, **Cloudflare Workers**, and other edge runtimes Hono targets. Same flow + ergonomics as [`@btx-tools/middleware-express`](https://www.npmjs.com/package/@btx-tools/middleware-express) and [`@btx-tools/middleware-fastify`](https://www.npmjs.com/package/@btx-tools/middleware-fastify), tailored to Hono's middleware model + `c.set('btx', ...)` variables.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pnpm add @btx-tools/middleware-hono @btx-tools/challenges-sdk hono
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Quickstart
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { Hono } from 'hono';
|
|
13
|
+
import { BtxChallengeClient } from '@btx-tools/challenges-sdk';
|
|
14
|
+
import { btxAdmission, type BtxAdmissionVariables } from '@btx-tools/middleware-hono';
|
|
15
|
+
|
|
16
|
+
const client = new BtxChallengeClient({
|
|
17
|
+
rpcUrl: 'http://127.0.0.1:19334',
|
|
18
|
+
rpcAuth: { user: 'rpcuser', pass: 'rpcpass' },
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const app = new Hono<{ Variables: BtxAdmissionVariables }>();
|
|
22
|
+
|
|
23
|
+
app.post('/v1/generate',
|
|
24
|
+
btxAdmission({
|
|
25
|
+
client,
|
|
26
|
+
purpose: 'ai_inference_gate',
|
|
27
|
+
resource: (c) => `route:${c.req.path}`,
|
|
28
|
+
subject: async (c) => `tenant:${(await c.req.json()).tenant_id}`,
|
|
29
|
+
issueParams: { target_solve_time_s: 1.0, expires_in_s: 60 },
|
|
30
|
+
onError: (err, c) => c.var.logger?.error({ err }, 'btx admission error'),
|
|
31
|
+
}),
|
|
32
|
+
async (c) => {
|
|
33
|
+
const admit = c.get('btx').result;
|
|
34
|
+
return c.json({ ok: true, reason: admit.reason });
|
|
35
|
+
},
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
export default app;
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## How it works
|
|
42
|
+
|
|
43
|
+
Stateless **echo-the-challenge** flow:
|
|
44
|
+
|
|
45
|
+
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.
|
|
46
|
+
2. **Client solves** the challenge (locally or via RPC) and **retries** with `X-BTX-Challenge` (echoed), `X-BTX-Proof-Nonce`, `X-BTX-Proof-Digest`.
|
|
47
|
+
3. Middleware calls `client.redeem()` → if `result.valid === true`, sets `c.set('btx', { result })` and yields to `await next()` (route handler runs). Else replies `403`.
|
|
48
|
+
|
|
49
|
+
No server-side challenge store. Scales horizontally; the challenge JSON rides in the `X-BTX-Challenge` header on retry (~3-5 KB). Check edge-runtime header-size limits — Cloudflare Workers and Fastly accept large headers, but Vercel Edge caps at smaller sizes.
|
|
50
|
+
|
|
51
|
+
## API
|
|
52
|
+
|
|
53
|
+
### `btxAdmission(opts): MiddlewareHandler`
|
|
54
|
+
|
|
55
|
+
Returns a Hono middleware function to attach per-route.
|
|
56
|
+
|
|
57
|
+
#### Options
|
|
58
|
+
|
|
59
|
+
| Field | Type | Notes |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `client` | `BtxChallengeClient` | required. Construct once at boot. |
|
|
62
|
+
| `purpose` | `string \| (c) => string \| (c) => Promise<string>` | required. Logical purpose label. Async resolver supported so you can `await c.req.json()`. |
|
|
63
|
+
| `resource` | `string \| (c) => string \| (c) => Promise<string>` | required. |
|
|
64
|
+
| `subject` | `string \| (c) => string \| (c) => Promise<string>` | required. |
|
|
65
|
+
| `issueParams` | `Partial<IssueParams>` | optional. |
|
|
66
|
+
| `onAdmit` | `(c, result) => void` | optional. Fires on successful admission. |
|
|
67
|
+
| `onError` | `(err, c) => void` | optional. Fires when `client.issue()` or `client.redeem()` throws. Re-thrown to Hono's `onError`. Audit ref: D-1. |
|
|
68
|
+
| `isProofPresent` | `(c) => boolean` | optional. Predicate override. |
|
|
69
|
+
|
|
70
|
+
### `BtxAdmissionVariables`
|
|
71
|
+
|
|
72
|
+
Type the Hono instance with this for `c.get('btx')` type narrowing:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
const app = new Hono<{ Variables: BtxAdmissionVariables }>();
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
After admission, `c.get('btx')` is `{ result: VerifyResult } | undefined`.
|
|
79
|
+
|
|
80
|
+
### Header constants
|
|
81
|
+
|
|
82
|
+
| Constant | Value |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `HEADER_CHALLENGE` | `'x-btx-challenge'` |
|
|
85
|
+
| `HEADER_CHALLENGE_ID` | `'x-btx-challenge-id'` |
|
|
86
|
+
| `HEADER_PROOF_NONCE` | `'x-btx-proof-nonce'` |
|
|
87
|
+
| `HEADER_PROOF_DIGEST` | `'x-btx-proof-digest'` |
|
|
88
|
+
|
|
89
|
+
## Error handling
|
|
90
|
+
|
|
91
|
+
When `client.issue()` or `client.redeem()` throws (e.g., btxd RPC down, network error), the middleware:
|
|
92
|
+
1. Calls `opts.onError(err, c)` if provided
|
|
93
|
+
2. Re-throws — Hono's `app.onError()` handler kicks in
|
|
94
|
+
|
|
95
|
+
Use `app.onError()` to map BTX errors to your preferred response shape:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
app.onError((err, c) => {
|
|
99
|
+
if (err instanceof BtxNetworkError) return c.json({ error: 'btxd unreachable' }, 503);
|
|
100
|
+
return c.json({ error: 'internal' }, 500);
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Edge-runtime notes
|
|
105
|
+
|
|
106
|
+
- **Cloudflare Workers / Pages**: works out of the box. `BtxChallengeClient` uses `fetch()` which is the native Workers networking primitive.
|
|
107
|
+
- **Deno Deploy**: same — `fetch()` is standard.
|
|
108
|
+
- **Bun**: same.
|
|
109
|
+
- **Vercel Edge**: works, but check max header size (Vercel Edge caps incoming headers around 16 KB). For high-difficulty challenges the JSON envelope might exceed this — consider switching to a stateful challenge-store middleware variant if you hit this.
|
|
110
|
+
|
|
111
|
+
## Requirements
|
|
112
|
+
|
|
113
|
+
- **Node.js** ≥ 18.17 (when running on Node)
|
|
114
|
+
- **Hono** ^4.0.0 (peer dep)
|
|
115
|
+
- **@btx-tools/challenges-sdk** ^0.0.4 (peer dep)
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT. See [LICENSE](./LICENSE).
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
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 btxAdmissionMiddleware(c, next) {
|
|
11
|
+
if (!proofPresent(c)) {
|
|
12
|
+
return issueAndRespond(c, opts);
|
|
13
|
+
}
|
|
14
|
+
return redeemAndAdmit(c, next, opts);
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function defaultIsProofPresent(c) {
|
|
18
|
+
return Boolean(
|
|
19
|
+
c.req.header(HEADER_CHALLENGE) && c.req.header(HEADER_PROOF_NONCE) && c.req.header(HEADER_PROOF_DIGEST)
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
async function issueAndRespond(c, opts) {
|
|
23
|
+
try {
|
|
24
|
+
const purpose = await resolve(opts.purpose, c);
|
|
25
|
+
const resource = await resolve(opts.resource, c);
|
|
26
|
+
const subject = await resolve(opts.subject, c);
|
|
27
|
+
const challenge = await opts.client.issue({
|
|
28
|
+
purpose,
|
|
29
|
+
resource,
|
|
30
|
+
subject,
|
|
31
|
+
...opts.issueParams
|
|
32
|
+
});
|
|
33
|
+
c.header(HEADER_CHALLENGE, JSON.stringify(challenge));
|
|
34
|
+
return c.json(
|
|
35
|
+
{
|
|
36
|
+
challenge,
|
|
37
|
+
retry_with: [HEADER_CHALLENGE, HEADER_PROOF_NONCE, HEADER_PROOF_DIGEST]
|
|
38
|
+
},
|
|
39
|
+
402
|
|
40
|
+
);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
opts.onError?.(err, c);
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async function redeemAndAdmit(c, next, opts) {
|
|
47
|
+
const challengeRaw = c.req.header(HEADER_CHALLENGE);
|
|
48
|
+
const nonce = c.req.header(HEADER_PROOF_NONCE);
|
|
49
|
+
const digest = c.req.header(HEADER_PROOF_DIGEST);
|
|
50
|
+
if (!challengeRaw) {
|
|
51
|
+
return c.json(
|
|
52
|
+
{
|
|
53
|
+
error: "missing_challenge_header",
|
|
54
|
+
message: `Retry must include the original challenge in the ${HEADER_CHALLENGE} header (echo-back).`
|
|
55
|
+
},
|
|
56
|
+
400
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
let challenge;
|
|
60
|
+
try {
|
|
61
|
+
challenge = JSON.parse(challengeRaw);
|
|
62
|
+
} catch {
|
|
63
|
+
return c.json(
|
|
64
|
+
{
|
|
65
|
+
error: "malformed_challenge_header",
|
|
66
|
+
message: `${HEADER_CHALLENGE} must be a JSON-encoded Challenge envelope.`
|
|
67
|
+
},
|
|
68
|
+
400
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
const idHeader = c.req.header(HEADER_CHALLENGE_ID);
|
|
72
|
+
if (idHeader && idHeader !== challenge.challenge_id) {
|
|
73
|
+
return c.json(
|
|
74
|
+
{
|
|
75
|
+
error: "challenge_id_mismatch",
|
|
76
|
+
message: `${HEADER_CHALLENGE_ID} does not match challenge_id in ${HEADER_CHALLENGE}.`
|
|
77
|
+
},
|
|
78
|
+
400
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const result = await opts.client.redeem(challenge, nonce, digest);
|
|
83
|
+
if (!result.valid) {
|
|
84
|
+
return c.json(
|
|
85
|
+
{
|
|
86
|
+
valid: false,
|
|
87
|
+
reason: result.reason,
|
|
88
|
+
expired: result.expired
|
|
89
|
+
},
|
|
90
|
+
403
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
c.set("btx", { result });
|
|
94
|
+
opts.onAdmit?.(c, result);
|
|
95
|
+
await next();
|
|
96
|
+
return;
|
|
97
|
+
} catch (err) {
|
|
98
|
+
opts.onError?.(err, c);
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async function resolve(value, c) {
|
|
103
|
+
if (typeof value === "function") {
|
|
104
|
+
return await value(c);
|
|
105
|
+
}
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
exports.HEADER_CHALLENGE = HEADER_CHALLENGE;
|
|
110
|
+
exports.HEADER_CHALLENGE_ID = HEADER_CHALLENGE_ID;
|
|
111
|
+
exports.HEADER_PROOF_DIGEST = HEADER_PROOF_DIGEST;
|
|
112
|
+
exports.HEADER_PROOF_NONCE = HEADER_PROOF_NONCE;
|
|
113
|
+
exports.btxAdmission = btxAdmission;
|
|
114
|
+
//# sourceMappingURL=index.cjs.map
|
|
115
|
+
//# 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-hono\n *\n * Drop-in **Hono** admission gate backed by BTX service challenges.\n * Mirrors the behavior of `@btx-tools/middleware-express` and\n * `@btx-tools/middleware-fastify` for Hono's middleware model — works on\n * Node, Deno, Bun, Cloudflare Workers, and other edge runtimes Hono targets.\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; c.get('btx').result is the VerifyResult)\n *\n * Invalid proof → 403 with { valid: false, reason }.\n * btxd RPC error → throws — Hono's onError handler catches it.\n *\n * Usage (per-route):\n * ```ts\n * import { Hono } from 'hono';\n * import { BtxChallengeClient } from '@btx-tools/challenges-sdk';\n * import { btxAdmission } from '@btx-tools/middleware-hono';\n *\n * const client = new BtxChallengeClient({ rpcUrl: '...', rpcAuth: { user, pass } });\n * const app = new Hono<{ Variables: { btx: { result: import('@btx-tools/challenges-sdk').VerifyResult } } }>();\n *\n * app.post('/v1/generate',\n * btxAdmission({\n * client,\n * purpose: 'ai_inference_gate',\n * resource: (c) => `route:${c.req.path}`,\n * subject: async (c) => `tenant:${(await c.req.json()).tenant_id}`,\n * issueParams: { target_solve_time_s: 1.0, expires_in_s: 60 },\n * }),\n * async (c) => {\n * const admit = c.get('btx').result;\n * return c.json({ ok: true, reason: admit.reason });\n * },\n * );\n * ```\n */\n\nimport type { Context, MiddlewareHandler } from 'hono';\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: Web standard Headers (which Hono uses) are case-insensitive on get but\n// typically lowercased internally. Use lowercase names to keep consistent.\n\n// ----------------------------------------------------------------------------\n// Types\n// ----------------------------------------------------------------------------\n\n/**\n * Resolver for purpose/resource/subject. Can be a static string, a sync\n * function over Context, or an async function for cases where the resolver\n * needs to await `c.req.json()` (Hono request body is a stream until consumed).\n */\ntype StringOrFn = string | ((c: Context) => string) | ((c: Context) => Promise<string>);\n\n/**\n * Hono `Variables` shape expected on the context. Apps that consume this\n * middleware should type their Hono instance as:\n *\n * ```ts\n * const app = new Hono<{ Variables: BtxAdmissionVariables }>();\n * ```\n *\n * Then `c.get('btx')` is type-narrowed to `{ result: VerifyResult } | undefined`.\n */\nexport interface BtxAdmissionVariables {\n btx: { result: VerifyResult };\n}\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. `(c) => \\`model:${(await c.req.json()).model}|route:${c.req.path}\\``. */\n resource: StringOrFn;\n /** Subject identifier, e.g. `(c) => \\`tenant:${c.req.header('x-tenant-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 `c` + the redeem result. */\n onAdmit?: (c: Context, result: VerifyResult) => void;\n /**\n * Optional hook fired when `client.issue()` or `client.redeem()` throws.\n * Receives the original error + the context. Fires exactly once before\n * the middleware re-throws to hand off to Hono's `onError` handler.\n * Use this for logging/observability. Audit ref: D-1.\n */\n onError?: (err: unknown, c: Context) => 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 * headers are set.\n */\n isProofPresent?: (c: Context) => boolean;\n}\n\n// ----------------------------------------------------------------------------\n// Middleware\n// ----------------------------------------------------------------------------\n\n/**\n * Build a Hono middleware that gates downstream handlers behind\n * a BTX service challenge. Use per-route by attaching it as a route argument.\n */\nexport function btxAdmission(opts: BtxAdmissionOpts): MiddlewareHandler {\n const proofPresent = opts.isProofPresent ?? defaultIsProofPresent;\n\n return async function btxAdmissionMiddleware(c, next) {\n if (!proofPresent(c)) {\n return issueAndRespond(c, opts);\n }\n return redeemAndAdmit(c, next, opts);\n };\n}\n\nfunction defaultIsProofPresent(c: Context): boolean {\n return Boolean(\n c.req.header(HEADER_CHALLENGE) &&\n c.req.header(HEADER_PROOF_NONCE) &&\n c.req.header(HEADER_PROOF_DIGEST),\n );\n}\n\nasync function issueAndRespond(c: Context, opts: BtxAdmissionOpts): Promise<Response> {\n try {\n const purpose = await resolve(opts.purpose, c);\n const resource = await resolve(opts.resource, c);\n const subject = await resolve(opts.subject, c);\n const challenge = await opts.client.issue({\n purpose,\n resource,\n subject,\n ...opts.issueParams,\n });\n c.header(HEADER_CHALLENGE, JSON.stringify(challenge));\n return c.json(\n {\n challenge,\n retry_with: [HEADER_CHALLENGE, HEADER_PROOF_NONCE, HEADER_PROOF_DIGEST],\n },\n 402,\n );\n } catch (err) {\n opts.onError?.(err, c);\n throw err;\n }\n}\n\nasync function redeemAndAdmit(\n c: Context,\n next: () => Promise<void>,\n opts: BtxAdmissionOpts,\n): Promise<Response | void> {\n const challengeRaw = c.req.header(HEADER_CHALLENGE);\n const nonce = c.req.header(HEADER_PROOF_NONCE);\n const digest = c.req.header(HEADER_PROOF_DIGEST);\n\n if (!challengeRaw) {\n return c.json(\n {\n error: 'missing_challenge_header',\n message: `Retry must include the original challenge in the ${HEADER_CHALLENGE} header (echo-back).`,\n },\n 400,\n );\n }\n\n let challenge: Challenge;\n try {\n challenge = JSON.parse(challengeRaw) as Challenge;\n } catch {\n return c.json(\n {\n error: 'malformed_challenge_header',\n message: `${HEADER_CHALLENGE} must be a JSON-encoded Challenge envelope.`,\n },\n 400,\n );\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 = c.req.header(HEADER_CHALLENGE_ID);\n if (idHeader && idHeader !== challenge.challenge_id) {\n return c.json(\n {\n error: 'challenge_id_mismatch',\n message: `${HEADER_CHALLENGE_ID} does not match challenge_id in ${HEADER_CHALLENGE}.`,\n },\n 400,\n );\n }\n\n try {\n const result = await opts.client.redeem(challenge, nonce!, digest!);\n if (!result.valid) {\n return c.json(\n {\n valid: false,\n reason: result.reason,\n expired: result.expired,\n },\n 403,\n );\n }\n c.set('btx', { result });\n opts.onAdmit?.(c, result);\n await next();\n return;\n } catch (err) {\n opts.onError?.(err, c);\n throw err;\n }\n}\n\nasync function resolve(value: StringOrFn, c: Context): Promise<string> {\n if (typeof value === 'function') {\n return await value(c);\n }\n return value;\n}\n"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { Context, MiddlewareHandler } from 'hono';
|
|
2
|
+
import { BtxChallengeClient, IssueParams, VerifyResult } from '@btx-tools/challenges-sdk';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @btx-tools/middleware-hono
|
|
6
|
+
*
|
|
7
|
+
* Drop-in **Hono** admission gate backed by BTX service challenges.
|
|
8
|
+
* Mirrors the behavior of `@btx-tools/middleware-express` and
|
|
9
|
+
* `@btx-tools/middleware-fastify` for Hono's middleware model — works on
|
|
10
|
+
* Node, Deno, Bun, Cloudflare Workers, and other edge runtimes Hono targets.
|
|
11
|
+
*
|
|
12
|
+
* Flow (stateless, echo-the-challenge):
|
|
13
|
+
*
|
|
14
|
+
* client → POST /v1/generate (no proof headers)
|
|
15
|
+
* server → 402 Payment Required
|
|
16
|
+
* X-BTX-Challenge: <stringified challenge JSON>
|
|
17
|
+
* body: { challenge, retry_with: [...] }
|
|
18
|
+
*
|
|
19
|
+
* client solves locally (or via RPC), retries:
|
|
20
|
+
* client → POST /v1/generate
|
|
21
|
+
* X-BTX-Challenge: <echoed challenge JSON>
|
|
22
|
+
* X-BTX-Challenge-Id: <id> (optional sanity check)
|
|
23
|
+
* X-BTX-Proof-Nonce: <hex>
|
|
24
|
+
* X-BTX-Proof-Digest: <hex>
|
|
25
|
+
* server → 200 OK (handler runs; c.get('btx').result is the VerifyResult)
|
|
26
|
+
*
|
|
27
|
+
* Invalid proof → 403 with { valid: false, reason }.
|
|
28
|
+
* btxd RPC error → throws — Hono's onError handler catches it.
|
|
29
|
+
*
|
|
30
|
+
* Usage (per-route):
|
|
31
|
+
* ```ts
|
|
32
|
+
* import { Hono } from 'hono';
|
|
33
|
+
* import { BtxChallengeClient } from '@btx-tools/challenges-sdk';
|
|
34
|
+
* import { btxAdmission } from '@btx-tools/middleware-hono';
|
|
35
|
+
*
|
|
36
|
+
* const client = new BtxChallengeClient({ rpcUrl: '...', rpcAuth: { user, pass } });
|
|
37
|
+
* const app = new Hono<{ Variables: { btx: { result: import('@btx-tools/challenges-sdk').VerifyResult } } }>();
|
|
38
|
+
*
|
|
39
|
+
* app.post('/v1/generate',
|
|
40
|
+
* btxAdmission({
|
|
41
|
+
* client,
|
|
42
|
+
* purpose: 'ai_inference_gate',
|
|
43
|
+
* resource: (c) => `route:${c.req.path}`,
|
|
44
|
+
* subject: async (c) => `tenant:${(await c.req.json()).tenant_id}`,
|
|
45
|
+
* issueParams: { target_solve_time_s: 1.0, expires_in_s: 60 },
|
|
46
|
+
* }),
|
|
47
|
+
* async (c) => {
|
|
48
|
+
* const admit = c.get('btx').result;
|
|
49
|
+
* return c.json({ ok: true, reason: admit.reason });
|
|
50
|
+
* },
|
|
51
|
+
* );
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
declare const HEADER_CHALLENGE = "x-btx-challenge";
|
|
56
|
+
declare const HEADER_CHALLENGE_ID = "x-btx-challenge-id";
|
|
57
|
+
declare const HEADER_PROOF_NONCE = "x-btx-proof-nonce";
|
|
58
|
+
declare const HEADER_PROOF_DIGEST = "x-btx-proof-digest";
|
|
59
|
+
/**
|
|
60
|
+
* Resolver for purpose/resource/subject. Can be a static string, a sync
|
|
61
|
+
* function over Context, or an async function for cases where the resolver
|
|
62
|
+
* needs to await `c.req.json()` (Hono request body is a stream until consumed).
|
|
63
|
+
*/
|
|
64
|
+
type StringOrFn = string | ((c: Context) => string) | ((c: Context) => Promise<string>);
|
|
65
|
+
/**
|
|
66
|
+
* Hono `Variables` shape expected on the context. Apps that consume this
|
|
67
|
+
* middleware should type their Hono instance as:
|
|
68
|
+
*
|
|
69
|
+
* ```ts
|
|
70
|
+
* const app = new Hono<{ Variables: BtxAdmissionVariables }>();
|
|
71
|
+
* ```
|
|
72
|
+
*
|
|
73
|
+
* Then `c.get('btx')` is type-narrowed to `{ result: VerifyResult } | undefined`.
|
|
74
|
+
*/
|
|
75
|
+
interface BtxAdmissionVariables {
|
|
76
|
+
btx: {
|
|
77
|
+
result: VerifyResult;
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** Options for {@link btxAdmission}. */
|
|
81
|
+
interface BtxAdmissionOpts {
|
|
82
|
+
/** The BTX RPC client (constructed once at boot). */
|
|
83
|
+
client: BtxChallengeClient;
|
|
84
|
+
/** Logical purpose label, e.g. `'ai_inference_gate'` or `'rate_limit'`. */
|
|
85
|
+
purpose: StringOrFn;
|
|
86
|
+
/** Resource identifier, e.g. `(c) => \`model:${(await c.req.json()).model}|route:${c.req.path}\``. */
|
|
87
|
+
resource: StringOrFn;
|
|
88
|
+
/** Subject identifier, e.g. `(c) => \`tenant:${c.req.header('x-tenant-id')}\``. */
|
|
89
|
+
subject: StringOrFn;
|
|
90
|
+
/** Extra issue params forwarded to `client.issue()` (target_solve_time_s, expires_in_s, etc.). */
|
|
91
|
+
issueParams?: Partial<Omit<IssueParams, 'purpose' | 'resource' | 'subject'>>;
|
|
92
|
+
/** Optional hook fired on successful admission. Receives `c` + the redeem result. */
|
|
93
|
+
onAdmit?: (c: Context, result: VerifyResult) => void;
|
|
94
|
+
/**
|
|
95
|
+
* Optional hook fired when `client.issue()` or `client.redeem()` throws.
|
|
96
|
+
* Receives the original error + the context. Fires exactly once before
|
|
97
|
+
* the middleware re-throws to hand off to Hono's `onError` handler.
|
|
98
|
+
* Use this for logging/observability. Audit ref: D-1.
|
|
99
|
+
*/
|
|
100
|
+
onError?: (err: unknown, c: Context) => void;
|
|
101
|
+
/**
|
|
102
|
+
* Override the default "is the proof present?" check. By default it returns
|
|
103
|
+
* true iff all of `x-btx-challenge`, `x-btx-proof-nonce`, `x-btx-proof-digest`
|
|
104
|
+
* headers are set.
|
|
105
|
+
*/
|
|
106
|
+
isProofPresent?: (c: Context) => boolean;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Build a Hono middleware that gates downstream handlers behind
|
|
110
|
+
* a BTX service challenge. Use per-route by attaching it as a route argument.
|
|
111
|
+
*/
|
|
112
|
+
declare function btxAdmission(opts: BtxAdmissionOpts): MiddlewareHandler;
|
|
113
|
+
|
|
114
|
+
export { type BtxAdmissionOpts, type BtxAdmissionVariables, HEADER_CHALLENGE, HEADER_CHALLENGE_ID, HEADER_PROOF_DIGEST, HEADER_PROOF_NONCE, btxAdmission };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { Context, MiddlewareHandler } from 'hono';
|
|
2
|
+
import { BtxChallengeClient, IssueParams, VerifyResult } from '@btx-tools/challenges-sdk';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @btx-tools/middleware-hono
|
|
6
|
+
*
|
|
7
|
+
* Drop-in **Hono** admission gate backed by BTX service challenges.
|
|
8
|
+
* Mirrors the behavior of `@btx-tools/middleware-express` and
|
|
9
|
+
* `@btx-tools/middleware-fastify` for Hono's middleware model — works on
|
|
10
|
+
* Node, Deno, Bun, Cloudflare Workers, and other edge runtimes Hono targets.
|
|
11
|
+
*
|
|
12
|
+
* Flow (stateless, echo-the-challenge):
|
|
13
|
+
*
|
|
14
|
+
* client → POST /v1/generate (no proof headers)
|
|
15
|
+
* server → 402 Payment Required
|
|
16
|
+
* X-BTX-Challenge: <stringified challenge JSON>
|
|
17
|
+
* body: { challenge, retry_with: [...] }
|
|
18
|
+
*
|
|
19
|
+
* client solves locally (or via RPC), retries:
|
|
20
|
+
* client → POST /v1/generate
|
|
21
|
+
* X-BTX-Challenge: <echoed challenge JSON>
|
|
22
|
+
* X-BTX-Challenge-Id: <id> (optional sanity check)
|
|
23
|
+
* X-BTX-Proof-Nonce: <hex>
|
|
24
|
+
* X-BTX-Proof-Digest: <hex>
|
|
25
|
+
* server → 200 OK (handler runs; c.get('btx').result is the VerifyResult)
|
|
26
|
+
*
|
|
27
|
+
* Invalid proof → 403 with { valid: false, reason }.
|
|
28
|
+
* btxd RPC error → throws — Hono's onError handler catches it.
|
|
29
|
+
*
|
|
30
|
+
* Usage (per-route):
|
|
31
|
+
* ```ts
|
|
32
|
+
* import { Hono } from 'hono';
|
|
33
|
+
* import { BtxChallengeClient } from '@btx-tools/challenges-sdk';
|
|
34
|
+
* import { btxAdmission } from '@btx-tools/middleware-hono';
|
|
35
|
+
*
|
|
36
|
+
* const client = new BtxChallengeClient({ rpcUrl: '...', rpcAuth: { user, pass } });
|
|
37
|
+
* const app = new Hono<{ Variables: { btx: { result: import('@btx-tools/challenges-sdk').VerifyResult } } }>();
|
|
38
|
+
*
|
|
39
|
+
* app.post('/v1/generate',
|
|
40
|
+
* btxAdmission({
|
|
41
|
+
* client,
|
|
42
|
+
* purpose: 'ai_inference_gate',
|
|
43
|
+
* resource: (c) => `route:${c.req.path}`,
|
|
44
|
+
* subject: async (c) => `tenant:${(await c.req.json()).tenant_id}`,
|
|
45
|
+
* issueParams: { target_solve_time_s: 1.0, expires_in_s: 60 },
|
|
46
|
+
* }),
|
|
47
|
+
* async (c) => {
|
|
48
|
+
* const admit = c.get('btx').result;
|
|
49
|
+
* return c.json({ ok: true, reason: admit.reason });
|
|
50
|
+
* },
|
|
51
|
+
* );
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
declare const HEADER_CHALLENGE = "x-btx-challenge";
|
|
56
|
+
declare const HEADER_CHALLENGE_ID = "x-btx-challenge-id";
|
|
57
|
+
declare const HEADER_PROOF_NONCE = "x-btx-proof-nonce";
|
|
58
|
+
declare const HEADER_PROOF_DIGEST = "x-btx-proof-digest";
|
|
59
|
+
/**
|
|
60
|
+
* Resolver for purpose/resource/subject. Can be a static string, a sync
|
|
61
|
+
* function over Context, or an async function for cases where the resolver
|
|
62
|
+
* needs to await `c.req.json()` (Hono request body is a stream until consumed).
|
|
63
|
+
*/
|
|
64
|
+
type StringOrFn = string | ((c: Context) => string) | ((c: Context) => Promise<string>);
|
|
65
|
+
/**
|
|
66
|
+
* Hono `Variables` shape expected on the context. Apps that consume this
|
|
67
|
+
* middleware should type their Hono instance as:
|
|
68
|
+
*
|
|
69
|
+
* ```ts
|
|
70
|
+
* const app = new Hono<{ Variables: BtxAdmissionVariables }>();
|
|
71
|
+
* ```
|
|
72
|
+
*
|
|
73
|
+
* Then `c.get('btx')` is type-narrowed to `{ result: VerifyResult } | undefined`.
|
|
74
|
+
*/
|
|
75
|
+
interface BtxAdmissionVariables {
|
|
76
|
+
btx: {
|
|
77
|
+
result: VerifyResult;
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** Options for {@link btxAdmission}. */
|
|
81
|
+
interface BtxAdmissionOpts {
|
|
82
|
+
/** The BTX RPC client (constructed once at boot). */
|
|
83
|
+
client: BtxChallengeClient;
|
|
84
|
+
/** Logical purpose label, e.g. `'ai_inference_gate'` or `'rate_limit'`. */
|
|
85
|
+
purpose: StringOrFn;
|
|
86
|
+
/** Resource identifier, e.g. `(c) => \`model:${(await c.req.json()).model}|route:${c.req.path}\``. */
|
|
87
|
+
resource: StringOrFn;
|
|
88
|
+
/** Subject identifier, e.g. `(c) => \`tenant:${c.req.header('x-tenant-id')}\``. */
|
|
89
|
+
subject: StringOrFn;
|
|
90
|
+
/** Extra issue params forwarded to `client.issue()` (target_solve_time_s, expires_in_s, etc.). */
|
|
91
|
+
issueParams?: Partial<Omit<IssueParams, 'purpose' | 'resource' | 'subject'>>;
|
|
92
|
+
/** Optional hook fired on successful admission. Receives `c` + the redeem result. */
|
|
93
|
+
onAdmit?: (c: Context, result: VerifyResult) => void;
|
|
94
|
+
/**
|
|
95
|
+
* Optional hook fired when `client.issue()` or `client.redeem()` throws.
|
|
96
|
+
* Receives the original error + the context. Fires exactly once before
|
|
97
|
+
* the middleware re-throws to hand off to Hono's `onError` handler.
|
|
98
|
+
* Use this for logging/observability. Audit ref: D-1.
|
|
99
|
+
*/
|
|
100
|
+
onError?: (err: unknown, c: Context) => void;
|
|
101
|
+
/**
|
|
102
|
+
* Override the default "is the proof present?" check. By default it returns
|
|
103
|
+
* true iff all of `x-btx-challenge`, `x-btx-proof-nonce`, `x-btx-proof-digest`
|
|
104
|
+
* headers are set.
|
|
105
|
+
*/
|
|
106
|
+
isProofPresent?: (c: Context) => boolean;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Build a Hono middleware that gates downstream handlers behind
|
|
110
|
+
* a BTX service challenge. Use per-route by attaching it as a route argument.
|
|
111
|
+
*/
|
|
112
|
+
declare function btxAdmission(opts: BtxAdmissionOpts): MiddlewareHandler;
|
|
113
|
+
|
|
114
|
+
export { type BtxAdmissionOpts, type BtxAdmissionVariables, HEADER_CHALLENGE, HEADER_CHALLENGE_ID, HEADER_PROOF_DIGEST, HEADER_PROOF_NONCE, btxAdmission };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
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 btxAdmissionMiddleware(c, next) {
|
|
9
|
+
if (!proofPresent(c)) {
|
|
10
|
+
return issueAndRespond(c, opts);
|
|
11
|
+
}
|
|
12
|
+
return redeemAndAdmit(c, next, opts);
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function defaultIsProofPresent(c) {
|
|
16
|
+
return Boolean(
|
|
17
|
+
c.req.header(HEADER_CHALLENGE) && c.req.header(HEADER_PROOF_NONCE) && c.req.header(HEADER_PROOF_DIGEST)
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
async function issueAndRespond(c, opts) {
|
|
21
|
+
try {
|
|
22
|
+
const purpose = await resolve(opts.purpose, c);
|
|
23
|
+
const resource = await resolve(opts.resource, c);
|
|
24
|
+
const subject = await resolve(opts.subject, c);
|
|
25
|
+
const challenge = await opts.client.issue({
|
|
26
|
+
purpose,
|
|
27
|
+
resource,
|
|
28
|
+
subject,
|
|
29
|
+
...opts.issueParams
|
|
30
|
+
});
|
|
31
|
+
c.header(HEADER_CHALLENGE, JSON.stringify(challenge));
|
|
32
|
+
return c.json(
|
|
33
|
+
{
|
|
34
|
+
challenge,
|
|
35
|
+
retry_with: [HEADER_CHALLENGE, HEADER_PROOF_NONCE, HEADER_PROOF_DIGEST]
|
|
36
|
+
},
|
|
37
|
+
402
|
|
38
|
+
);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
opts.onError?.(err, c);
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function redeemAndAdmit(c, next, opts) {
|
|
45
|
+
const challengeRaw = c.req.header(HEADER_CHALLENGE);
|
|
46
|
+
const nonce = c.req.header(HEADER_PROOF_NONCE);
|
|
47
|
+
const digest = c.req.header(HEADER_PROOF_DIGEST);
|
|
48
|
+
if (!challengeRaw) {
|
|
49
|
+
return c.json(
|
|
50
|
+
{
|
|
51
|
+
error: "missing_challenge_header",
|
|
52
|
+
message: `Retry must include the original challenge in the ${HEADER_CHALLENGE} header (echo-back).`
|
|
53
|
+
},
|
|
54
|
+
400
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
let challenge;
|
|
58
|
+
try {
|
|
59
|
+
challenge = JSON.parse(challengeRaw);
|
|
60
|
+
} catch {
|
|
61
|
+
return c.json(
|
|
62
|
+
{
|
|
63
|
+
error: "malformed_challenge_header",
|
|
64
|
+
message: `${HEADER_CHALLENGE} must be a JSON-encoded Challenge envelope.`
|
|
65
|
+
},
|
|
66
|
+
400
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
const idHeader = c.req.header(HEADER_CHALLENGE_ID);
|
|
70
|
+
if (idHeader && idHeader !== challenge.challenge_id) {
|
|
71
|
+
return c.json(
|
|
72
|
+
{
|
|
73
|
+
error: "challenge_id_mismatch",
|
|
74
|
+
message: `${HEADER_CHALLENGE_ID} does not match challenge_id in ${HEADER_CHALLENGE}.`
|
|
75
|
+
},
|
|
76
|
+
400
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const result = await opts.client.redeem(challenge, nonce, digest);
|
|
81
|
+
if (!result.valid) {
|
|
82
|
+
return c.json(
|
|
83
|
+
{
|
|
84
|
+
valid: false,
|
|
85
|
+
reason: result.reason,
|
|
86
|
+
expired: result.expired
|
|
87
|
+
},
|
|
88
|
+
403
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
c.set("btx", { result });
|
|
92
|
+
opts.onAdmit?.(c, result);
|
|
93
|
+
await next();
|
|
94
|
+
return;
|
|
95
|
+
} catch (err) {
|
|
96
|
+
opts.onError?.(err, c);
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function resolve(value, c) {
|
|
101
|
+
if (typeof value === "function") {
|
|
102
|
+
return await value(c);
|
|
103
|
+
}
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export { HEADER_CHALLENGE, HEADER_CHALLENGE_ID, HEADER_PROOF_DIGEST, HEADER_PROOF_NONCE, btxAdmission };
|
|
108
|
+
//# sourceMappingURL=index.js.map
|
|
109
|
+
//# 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-hono\n *\n * Drop-in **Hono** admission gate backed by BTX service challenges.\n * Mirrors the behavior of `@btx-tools/middleware-express` and\n * `@btx-tools/middleware-fastify` for Hono's middleware model — works on\n * Node, Deno, Bun, Cloudflare Workers, and other edge runtimes Hono targets.\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; c.get('btx').result is the VerifyResult)\n *\n * Invalid proof → 403 with { valid: false, reason }.\n * btxd RPC error → throws — Hono's onError handler catches it.\n *\n * Usage (per-route):\n * ```ts\n * import { Hono } from 'hono';\n * import { BtxChallengeClient } from '@btx-tools/challenges-sdk';\n * import { btxAdmission } from '@btx-tools/middleware-hono';\n *\n * const client = new BtxChallengeClient({ rpcUrl: '...', rpcAuth: { user, pass } });\n * const app = new Hono<{ Variables: { btx: { result: import('@btx-tools/challenges-sdk').VerifyResult } } }>();\n *\n * app.post('/v1/generate',\n * btxAdmission({\n * client,\n * purpose: 'ai_inference_gate',\n * resource: (c) => `route:${c.req.path}`,\n * subject: async (c) => `tenant:${(await c.req.json()).tenant_id}`,\n * issueParams: { target_solve_time_s: 1.0, expires_in_s: 60 },\n * }),\n * async (c) => {\n * const admit = c.get('btx').result;\n * return c.json({ ok: true, reason: admit.reason });\n * },\n * );\n * ```\n */\n\nimport type { Context, MiddlewareHandler } from 'hono';\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: Web standard Headers (which Hono uses) are case-insensitive on get but\n// typically lowercased internally. Use lowercase names to keep consistent.\n\n// ----------------------------------------------------------------------------\n// Types\n// ----------------------------------------------------------------------------\n\n/**\n * Resolver for purpose/resource/subject. Can be a static string, a sync\n * function over Context, or an async function for cases where the resolver\n * needs to await `c.req.json()` (Hono request body is a stream until consumed).\n */\ntype StringOrFn = string | ((c: Context) => string) | ((c: Context) => Promise<string>);\n\n/**\n * Hono `Variables` shape expected on the context. Apps that consume this\n * middleware should type their Hono instance as:\n *\n * ```ts\n * const app = new Hono<{ Variables: BtxAdmissionVariables }>();\n * ```\n *\n * Then `c.get('btx')` is type-narrowed to `{ result: VerifyResult } | undefined`.\n */\nexport interface BtxAdmissionVariables {\n btx: { result: VerifyResult };\n}\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. `(c) => \\`model:${(await c.req.json()).model}|route:${c.req.path}\\``. */\n resource: StringOrFn;\n /** Subject identifier, e.g. `(c) => \\`tenant:${c.req.header('x-tenant-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 `c` + the redeem result. */\n onAdmit?: (c: Context, result: VerifyResult) => void;\n /**\n * Optional hook fired when `client.issue()` or `client.redeem()` throws.\n * Receives the original error + the context. Fires exactly once before\n * the middleware re-throws to hand off to Hono's `onError` handler.\n * Use this for logging/observability. Audit ref: D-1.\n */\n onError?: (err: unknown, c: Context) => 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 * headers are set.\n */\n isProofPresent?: (c: Context) => boolean;\n}\n\n// ----------------------------------------------------------------------------\n// Middleware\n// ----------------------------------------------------------------------------\n\n/**\n * Build a Hono middleware that gates downstream handlers behind\n * a BTX service challenge. Use per-route by attaching it as a route argument.\n */\nexport function btxAdmission(opts: BtxAdmissionOpts): MiddlewareHandler {\n const proofPresent = opts.isProofPresent ?? defaultIsProofPresent;\n\n return async function btxAdmissionMiddleware(c, next) {\n if (!proofPresent(c)) {\n return issueAndRespond(c, opts);\n }\n return redeemAndAdmit(c, next, opts);\n };\n}\n\nfunction defaultIsProofPresent(c: Context): boolean {\n return Boolean(\n c.req.header(HEADER_CHALLENGE) &&\n c.req.header(HEADER_PROOF_NONCE) &&\n c.req.header(HEADER_PROOF_DIGEST),\n );\n}\n\nasync function issueAndRespond(c: Context, opts: BtxAdmissionOpts): Promise<Response> {\n try {\n const purpose = await resolve(opts.purpose, c);\n const resource = await resolve(opts.resource, c);\n const subject = await resolve(opts.subject, c);\n const challenge = await opts.client.issue({\n purpose,\n resource,\n subject,\n ...opts.issueParams,\n });\n c.header(HEADER_CHALLENGE, JSON.stringify(challenge));\n return c.json(\n {\n challenge,\n retry_with: [HEADER_CHALLENGE, HEADER_PROOF_NONCE, HEADER_PROOF_DIGEST],\n },\n 402,\n );\n } catch (err) {\n opts.onError?.(err, c);\n throw err;\n }\n}\n\nasync function redeemAndAdmit(\n c: Context,\n next: () => Promise<void>,\n opts: BtxAdmissionOpts,\n): Promise<Response | void> {\n const challengeRaw = c.req.header(HEADER_CHALLENGE);\n const nonce = c.req.header(HEADER_PROOF_NONCE);\n const digest = c.req.header(HEADER_PROOF_DIGEST);\n\n if (!challengeRaw) {\n return c.json(\n {\n error: 'missing_challenge_header',\n message: `Retry must include the original challenge in the ${HEADER_CHALLENGE} header (echo-back).`,\n },\n 400,\n );\n }\n\n let challenge: Challenge;\n try {\n challenge = JSON.parse(challengeRaw) as Challenge;\n } catch {\n return c.json(\n {\n error: 'malformed_challenge_header',\n message: `${HEADER_CHALLENGE} must be a JSON-encoded Challenge envelope.`,\n },\n 400,\n );\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 = c.req.header(HEADER_CHALLENGE_ID);\n if (idHeader && idHeader !== challenge.challenge_id) {\n return c.json(\n {\n error: 'challenge_id_mismatch',\n message: `${HEADER_CHALLENGE_ID} does not match challenge_id in ${HEADER_CHALLENGE}.`,\n },\n 400,\n );\n }\n\n try {\n const result = await opts.client.redeem(challenge, nonce!, digest!);\n if (!result.valid) {\n return c.json(\n {\n valid: false,\n reason: result.reason,\n expired: result.expired,\n },\n 403,\n );\n }\n c.set('btx', { result });\n opts.onAdmit?.(c, result);\n await next();\n return;\n } catch (err) {\n opts.onError?.(err, c);\n throw err;\n }\n}\n\nasync function resolve(value: StringOrFn, c: Context): Promise<string> {\n if (typeof value === 'function') {\n return await value(c);\n }\n return value;\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@btx-tools/middleware-hono",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Hono middleware for @btx-tools/challenges-sdk — drop-in BTX service-challenge admission gate (Node + edge)",
|
|
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
|
+
"hono": "^4.0.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^22.0.0",
|
|
35
|
+
"hono": "^4.6.0",
|
|
36
|
+
"prettier": "^3.3.0",
|
|
37
|
+
"tsup": "^8.3.0",
|
|
38
|
+
"typescript": "^5.6.0",
|
|
39
|
+
"vitest": "^2.1.0",
|
|
40
|
+
"@btx-tools/challenges-sdk": "0.1.0"
|
|
41
|
+
},
|
|
42
|
+
"keywords": [
|
|
43
|
+
"btx",
|
|
44
|
+
"hono",
|
|
45
|
+
"middleware",
|
|
46
|
+
"edge",
|
|
47
|
+
"cloudflare-workers",
|
|
48
|
+
"deno",
|
|
49
|
+
"admission",
|
|
50
|
+
"rate-limit",
|
|
51
|
+
"captcha",
|
|
52
|
+
"ai-gating",
|
|
53
|
+
"service-challenges",
|
|
54
|
+
"proof-of-work"
|
|
55
|
+
],
|
|
56
|
+
"repository": {
|
|
57
|
+
"type": "git",
|
|
58
|
+
"url": "git+https://github.com/btx-tools/btx-challenges-sdk.git",
|
|
59
|
+
"directory": "packages/middleware-hono"
|
|
60
|
+
},
|
|
61
|
+
"homepage": "https://github.com/btx-tools/btx-challenges-sdk/tree/main/packages/middleware-hono#readme",
|
|
62
|
+
"bugs": {
|
|
63
|
+
"url": "https://github.com/btx-tools/btx-challenges-sdk/issues"
|
|
64
|
+
},
|
|
65
|
+
"publishConfig": {
|
|
66
|
+
"access": "public"
|
|
67
|
+
},
|
|
68
|
+
"engines": {
|
|
69
|
+
"node": ">=18.17"
|
|
70
|
+
},
|
|
71
|
+
"scripts": {
|
|
72
|
+
"build": "tsup",
|
|
73
|
+
"test": "vitest run",
|
|
74
|
+
"test:watch": "vitest",
|
|
75
|
+
"test:unit": "vitest run tests/unit",
|
|
76
|
+
"type-check": "tsc --noEmit",
|
|
77
|
+
"format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\""
|
|
78
|
+
}
|
|
79
|
+
}
|