@btx-tools SDK — BTX service-challenge admission control
    Preparing search index...

    Module @btx-tools/middleware-express

    @btx-tools/middleware-express

    License npm

    Drop-in Express admission gate backed by BTX service challenges. Turns an Express route into a chain-anchored proof-of-work checkpoint with one line.

    📖 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 → redeem flow: 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. See core → Prerequisites.

    Status: 🟢 1.0.0 — stable, peer @btx-tools/challenges-sdk@^1.0.0. The result is exposed as req.btx.result (if upgrading from 0.1.x, this was renamed from the old flat req.btxResult).

    End-to-end example: clone the repo and run examples/02-express-gate for a working server + client pair you can copy from. Walks the full 402 → solve → 200 → 403-replay flow against a live btxd.

    npm install @btx-tools/middleware-express @btx-tools/challenges-sdk express
    

    express is a peer dependency — bring your own version (^4 or ^5).

    import express from 'express';
    import { BtxChallengeClient } from '@btx-tools/challenges-sdk';
    import { btxAdmission } from '@btx-tools/middleware-express';

    const client = new BtxChallengeClient({
    rpcUrl: 'http://127.0.0.1:19334', // dedicated NON-mining btxd
    rpcAuth: { user: 'rpcuser', pass: 'rpcpass' },
    });

    const app = express();
    app.use(express.json());

    app.post(
    '/v1/generate',
    btxAdmission({
    client,
    purpose: 'ai_inference_gate',
    resource: (req) => `model:${req.body.model}|route:${req.path}`,
    subject: (req) => `tenant:${req.body.tenant_id}`,
    issueParams: { target_solve_time_s: 1.0, expires_in_s: 60 },
    }),
    async (req, res) => {
    // req.btx?.result is the redeem VerifyResult — proof of admission
    res.json({ ok: true, generated: '...' });
    },
    );

    app.listen(3000);

    That's it. The middleware does the full issue / redeem dance for you.

    Stateless echo-the-challenge flow. Server never stores issued challenges; the client echoes the challenge JSON back on retry.

    ClientPOST /v1/generate                            (no proof headers)
    Server402 Payment Required
    X-BTX-Challenge: <stringified challenge JSON>
    { "challenge": {...}, "retry_with": [...] }

    [client solves locally (Solver.solve from @btx-tools/challenges-sdk) or via RPC]

    ClientPOST /v1/generate
    X-BTX-Challenge: <echoed challenge JSON>
    X-BTX-Proof-Nonce: <hex>
    X-BTX-Proof-Digest: <hex>
    Server200 OK (req.btx?.result populated; your handler runs)

    Failure cases (server-side, all return JSON):

    Code When
    400 Bad Request retry missing X-BTX-Challenge echo, malformed JSON, or X-BTX-Challenge-Id mismatch
    402 Payment Required normal first-request response — client should solve and retry
    403 Forbidden redeem failed — { valid: false, reason }. Possible reasons: invalid_proof, expired, already_redeemed, unknown_challenge
    500 Internal Server Error btxd RPC layer threw — surfaced via next(err), handled by your Express error middleware

    Returns a standard Express RequestHandler.

    Option Type Required Description
    client BtxChallengeClient The RPC client constructed at boot
    purpose string | (req) => string Logical purpose label — 'ai_inference_gate', 'rate_limit', 'api_gate', or your own
    resource string | (req) => string Resource identifier — what's being gated
    subject string | (req) => string Subject identifier — who's being challenged
    issueParams Partial<IssueParams> Forwarded to client.issue() (e.g. target_solve_time_s, expires_in_s)
    onAdmit (req, result) => void Hook fired on successful admission
    onError (err, req) => void New in 0.2.0 — hook fired exactly once when client.issue() or client.redeem() throws, before next(err) is invoked. Use for logging / observability; don't mutate the error
    isProofPresent (req) => boolean Override the default "all 3 proof headers set?" check
    import {
    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'
    } from '@btx-tools/middleware-express';

    The middleware augments Express.Request with req.btx?: { result: VerifyResult } so your handler can introspect the redemption outcome:

    app.post('/v1/gate', btxAdmission({ ... }), (req, res) => {
    console.log('admitted:', req.btx?.result.reason); // 'ok' on success
    });

    Migration from 0.1.x: rename req.btxResultreq.btx?.result in your handlers. The 0.1.x flat btxResult field polluted the global Express.Request type — the namespaced btx is scoped enough that it shouldn't collide with other middleware (audit finding C-3).

    When client.issue() or client.redeem() throws, the middleware calls next(err) so Express's error pipeline handles it. Be aware that Express's default error handler in development mode includes the full stack trace in the HTTP response — a thrown BtxRpcError may carry server-internal details (btxd URL, RPC method names, response snippets) that you don't want exposed to clients. Configure a custom Express error handler that returns a sanitized response:

    app.use((err, req, res, next) => {
    // Optional: also log via your APM here
    res.status(500).json({ error: 'internal_error' }); // never err.message
    });

    For observability without an error handler, use the onError hook in BtxAdmissionOpts (added 0.2.0).

    The X-BTX-Challenge echo header carries a JSON-stringified challenge envelope (~3–5 KB). This sits within standard HTTP header limits (most servers default to 8 KB), but check your reverse proxy if you front Express with nginx, Caddy, Cloudflare, or AWS ALB. Specifically:

    • nginx: large_client_header_buffers 4 16k
    • Caddy: limits are looser by default; check http.server.max_header_bytes
    • Cloudflare: tied to your plan
    • AWS ALB: 16 KB headers, 60 KB total — fine
    • AWS API Gateway: 10 KB per header, 16 KB total — fine

    If header size becomes a constraint, the stateful variant (server-side challenge_id cache) is queued as a future enhancement: btxAdmission({ store: new LruStore() }).

    Because the middleware is stateless, the issuing server and the redeeming server can be different instances. No sticky routing required.

    (btxd's proof_policy.locally_issued_required: true means the redeem RPC must hit the same btxd that issued the challenge. The Express layer is stateless; the btxd layer downstream is what enforces the binding. If you run a multi-node btxd cluster, you'll want shared issued_challenge_store config — see the btxd service-challenges docs.)

    Same caveat as the core SDK: btxd's service-challenge solver shares the matmul backend with block mining. On a mining-loaded node, the solvematmulservicechallenge RPC (used by Solver.solve({ mode: 'rpc' }) for server-side solving) queues behind block work for 10+ minutes. For production use, point BtxChallengeClient at a dedicated non-mining btxd (e.g. gen=0 in btx.conf).

    The X-BTX-Challenge, X-BTX-Proof-Nonce, and X-BTX-Proof-Digest headers are custom, which means browser fetches to your gated route will trigger a CORS preflight. Make sure your CORS middleware allows them:

    import cors from 'cors';
    app.use(
    cors({
    origin: 'https://your-frontend.example',
    allowedHeaders: [
    'content-type',
    'x-btx-challenge',
    'x-btx-challenge-id',
    'x-btx-proof-nonce',
    'x-btx-proof-digest',
    ],
    exposedHeaders: [
    'x-btx-challenge', // so the browser can READ the 402's challenge header
    ],
    }),
    );

    Without exposedHeaders 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.

    Interfaces

    BtxAdmissionOpts

    Type Aliases

    StringOrFn

    Variables

    HEADER_CHALLENGE
    HEADER_CHALLENGE_ID
    HEADER_PROOF_DIGEST
    HEADER_PROOF_NONCE

    Functions

    btxAdmission