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 and @btx-tools/middleware-fastify, tailored to Hono's middleware model + c.set('btx', ...) variables.
📖 API Reference — TypeDoc for all @btx-tools/* SDK packages.
New to BTX service challenges? This puts a chain-anchored proof-of-work checkpoint in front of a route — the caller spends a few seconds of verifiable compute instead of a CAPTCHA or a signup. Concept +
issue → solve → redeemflow: see the core SDK README.Prerequisites: you need a reachable BTX node (
btxd) — non-mining for fast (~1–4 s) solves; there's no hosted endpoint, so you can't use this with zero BTX infrastructure. (Edge note: a Worker/edge runtime can't reach127.0.0.1— point at a public/tunnelledbtxd.) See core → Prerequisites.
End-to-end example: a runnable adopter example is in
examples/02-express-gate(Express-based; the wiring shape is structurally identical for Hono — swap the route + middleware call). A Hono-native parity example covering both Node and edge deploy is queued for the SDK Phase 3.5 roadmap.
pnpm add @btx-tools/middleware-hono @btx-tools/challenges-sdk hono
import { Hono } from 'hono';
import { BtxChallengeClient } from '@btx-tools/challenges-sdk';
import { btxAdmission, type BtxAdmissionVariables } from '@btx-tools/middleware-hono';
const client = new BtxChallengeClient({
rpcUrl: 'http://127.0.0.1:19334',
rpcAuth: { user: 'rpcuser', pass: 'rpcpass' },
});
const app = new Hono<{ Variables: BtxAdmissionVariables }>();
app.post(
'/v1/generate',
btxAdmission({
client,
purpose: 'ai_inference_gate',
resource: (c) => `route:${c.req.path}`,
subject: async (c) => `tenant:${(await c.req.json()).tenant_id}`,
issueParams: { target_solve_time_s: 1.0, expires_in_s: 60 },
onError: (err, c) => c.var.logger?.error({ err }, 'btx admission error'),
}),
async (c) => {
const admit = c.get('btx').result;
return c.json({ ok: true, reason: admit.reason });
},
);
export default app;
Hono's c.req.json() is one-shot — once consumed, the body stream is gone. If your resource / subject resolver does await c.req.json(), the route handler downstream cannot read the body again and will throw BodyAlreadyUsedError.
❌ This breaks:
(btxAdmission({
// ...
resource: async (c) => `model:${(await c.req.json()).model}`,
}),
async (c) => {
const body = await c.req.json(); // ← throws — body already consumed!
return c.json({ ok: true });
});
✅ Two safe patterns:
// Pattern 1: cache the body once at the top, pass through context
app.post('/v1/generate', async (c, next) => {
c.set('body', await c.req.json());
return next();
});
app.post('/v1/generate',
btxAdmission({
// ...
resource: (c) => `model:${(c.get('body') as { model: string }).model}`,
}),
async (c) => {
const body = c.get('body');
return c.json({ ok: true, body });
},
);
// Pattern 2: derive resolver inputs from headers, not body
btxAdmission({
// ...
resource: (c) => `model:${c.req.header('x-model') ?? 'default'}`,
}),
Stateless echo-the-challenge flow:
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.X-BTX-Challenge (echoed), X-BTX-Proof-Nonce, X-BTX-Proof-Digest.client.redeem() → if result.valid === true, sets c.set('btx', { result }) and yields to await next() (route handler runs). Else replies 403.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.
btxAdmission(opts): MiddlewareHandlerReturns a Hono middleware function to attach per-route.
| Field | Type | Notes |
|---|---|---|
client |
BtxChallengeClient |
required. Construct once at boot. |
purpose |
string | (c) => string | (c) => Promise<string> |
required. Logical purpose label. Async resolver supported so you can await c.req.json(). |
resource |
string | (c) => string | (c) => Promise<string> |
required. |
subject |
string | (c) => string | (c) => Promise<string> |
required. |
issueParams |
Partial<IssueParams> |
optional. |
onAdmit |
(c, result) => void |
optional. Fires on successful admission. |
onError |
(err, c) => void |
optional. Fires when client.issue() or client.redeem() throws. Re-thrown to Hono's onError. Audit ref: D-1. |
isProofPresent |
(c) => boolean |
optional. Predicate override. |
BtxAdmissionVariablesType the Hono instance with this for c.get('btx') type narrowing:
const app = new Hono<{ Variables: BtxAdmissionVariables }>();
After admission, c.get('btx') is { result: VerifyResult } | undefined.
| Constant | Value |
|---|---|
HEADER_CHALLENGE |
'x-btx-challenge' |
HEADER_CHALLENGE_ID |
'x-btx-challenge-id' |
HEADER_PROOF_NONCE |
'x-btx-proof-nonce' |
HEADER_PROOF_DIGEST |
'x-btx-proof-digest' |
When client.issue() or client.redeem() throws (e.g., btxd RPC down, network error), the middleware:
opts.onError(err, c) if providedapp.onError() handler kicks inUse app.onError() to map BTX errors to your preferred response shape:
app.onError((err, c) => {
if (err instanceof BtxNetworkError) return c.json({ error: 'btxd unreachable' }, 503);
return c.json({ error: 'internal' }, 500);
});
BtxChallengeClient uses fetch() to reach btxd's JSON-RPC endpoint. Edge runtimes cannot reach 127.0.0.1 — they're sandboxed away from the host loopback. You need a publicly reachable btxd RPC URL:
rpcallowip in btx.conf permits the egress IP)Do not put btxd's RPC port directly on the public internet without auth + TLS termination.
fetch() is native; no Node polyfills needed.fetch() is standard.X-BTX-Challenge header is ~3-5 KB for default difficulty; check your platform's documentation if you set high target_solve_time_s or run into preflight errors. For very large challenges, consider a stateful challenge-store middleware variant.The X-BTX-Challenge, X-BTX-Proof-Nonce, and X-BTX-Proof-Digest headers are custom, which triggers a CORS preflight for any browser-originated fetch. Configure Hono's built-in cors middleware:
import { cors } from 'hono/cors';
app.use(
'/v1/*',
cors({
origin: 'https://your-frontend.example',
allowHeaders: [
'content-type',
'x-btx-challenge',
'x-btx-challenge-id',
'x-btx-proof-nonce',
'x-btx-proof-digest',
],
exposeHeaders: [
'x-btx-challenge', // so the browser can READ the 402's challenge header
],
}),
);
Without exposeHeaders including x-btx-challenge, the browser sees the 402 status but cannot read the challenge JSON from the response header (Web Fetch hides non-CORS-safelisted response headers by default).
MIT. See LICENSE.