goldrail
v0.0.2
Published
Price any HTTP endpoint in any payment dialect — the goldrail SDK and CLI.
Downloads
25
Maintainers
Readme
goldrail
Payments for Node, Bun, Deno and Workers. Put a price on a route, or pay for one — both sides of HTTP 402, in one package.
One word, no scope. The package is goldrail; framework shapes are
subpaths of it — goldrail/express, goldrail/hono, goldrail/next — and the
payer half is goldrail/pay. There is nothing scoped and nothing hyphenated to
type, in this ecosystem or any other.
The seller side embeds the Rust engine as a native addon: a decision made
in your Express app and one made by goldrail serve are made by the same code
against the same store. No money logic is reimplemented in TypeScript, because
a second implementation would be correct until the first divergence and
silently wrong afterwards.
npm install goldrailSeller: price a route
import { goldrail } from 'goldrail/express';app.use(goldrail());That is the integration. Prices, rails, exemptions and fail postures live in the store, not in your code — which is what keeps prices live-tunable and this snippet at one line forever. Point the engine at a store and a config:
export GOLDRAIL_STORE_URL=sqlite://goldrail.db
export GOLDRAIL_PAY_TO=eip3009=0xYourTreasuryAddressor pass them inline for the thirty-second demo:
app.use(goldrail({ store: 'sqlite://goldrail.db', config: 'goldrail.toml' }));Zero-arg discovery order: GOLDRAIL_* environment variables, then a
goldrail.toml beside the process. If nothing names a store, construction
throws and lists the options — including dev: true, which accepts an
in-memory store and says so in a warning. A payment layer that could not ask
the question will not answer it by serving everything for free.
Hono, Bun, Deno, Workers and Next route handlers use the fetch shape, and each has a subpath that names it:
import { goldrail } from 'goldrail/hono'; // app.use(goldrail())
import { goldrail } from 'goldrail/next'; // export const GET = goldrail(handler)
import { goldrailFetch } from 'goldrail'; // anything else with Request/Responsegoldrail/next also exports middleware() for middleware.ts. Prefer the
route-handler wrapper: Next's middleware runs before routing and never sees the
route's response, so a request priced there is charged on the decision alone and
a later 500 is not refunded. The subpath's docs say so at the export.
What the middleware does
- Asks the engine, forwarding only payment-related headers — never your customers' cookies or API keys.
- Applies the answer:
serve(relay, plusgoldrail-fundingandgoldrail-balanceheaders),challenge(402 carrying every enabled dialect), orreject(an RFC 9457 problem document). - Reports what your handler actually returned — on a normal finish, on a thrown handler, and on a dropped connection. This is not optional: refunds on upstream failure hang off it, and a middleware that skipped it would charge payers for responses they never received.
When the engine cannot be reached
Distinct from a route's fail posture, which is about payments that fail. This is about not being able to ask at all:
| onUnavailable | Behaviour |
| --- | --- |
| 'closed' (default) | Answer 503. Nothing is given away, and a broken decision plane stays loud. |
| 'open' | Serve unpaid. An outage in your payment plane never becomes an outage for your customers; the cost is that the endpoint is free while it lasts. |
Both are reported through onError. Neither is silent.
Admin panel, mounted in your app
import * as goldrail from 'goldrail';app.use('/ops/goldrail', goldrail.admin());The mounted admin carries its own authentication, RBAC and audit trail — on the Rust side. This handler forwards a method, a path and a body, and renders whatever comes back; it authenticates nothing and knows no routes, so there is exactly one implementation of the authorization boundary rather than two that drift.
An embedded engine has no admin surface unless you mount one, and mounting one requires a companion process to serve it:
goldrail admin --store sqlite://goldrail.db # same store as your app
export GOLDRAIL_ADMIN_URL=http://127.0.0.1:8402Without it, the admin route answers 503 with that instruction. Goldrail never ships an unauthenticated admin, mounted or not, so there is no local fallback.
Payer: pay for a route
const fetch = paidFetch(wallet, { maxPerRequest: 5000n });fetch, with payment. It discovers the challenge, picks an option, signs, and
retries once — refusing to loop when a seller re-challenges for an amount
already paid, because retrying that is a spend loop wearing a retry's clothes.
import { EvmWallet, paidFetch } from 'goldrail/pay';
const wallet = EvmWallet.fromPrivateKey(process.env.PAYER_KEY!);
const pay = paidFetch(wallet, {
maxPerRequest: 5_000n, // never authorize more than this at once
budget: 1_000_000n, // lifetime ceiling across every request
networks: ['base'], // only pay on these chains
payTo: ['0xSellerTreasury'], // only pay these addresses
});
const response = await pay('https://api.example.com/v1/data');
console.log(pay.spent, pay.balance);Every guard runs before the signature exists. An authorization that exists is collectible by whoever holds it, so checking a budget afterwards is not a budget. The lifetime ceiling is reserved rather than merely checked, which is what makes it hold for an agent with several requests in flight.
The payer is EVM-only, deliberately
paidFetch signs EIP-3009 on EVM chains (Base, Ethereum, Optimism,
Arbitrum, Polygon, Avalanche, and any eip155:<id>). A Solana or Stellar
challenge is refused by name with an UnsupportedMethodError — those methods
need ed25519 and their own transaction encodings, and half-signing them would
produce payloads that fail at the facilitator, which is a worse failure than a
clear refusal here. Sellers offering several methods work fine: the selector
skips the options this client cannot sign.
Use goldrail pay or your own signer for the others.
Keys stay in your process
The signer is local and dependency-free: Keccak-256 and secp256k1 with RFC 6979
deterministic k, validated in CI against viem's published signatures. The
private key lives in a real private field and is never logged or serialized.
Nothing here talks to a key service, and nothing here is a wallet.
Which transport am I on?
The native addon is the default. If a prebuilt binary is unavailable for your
platform, the package falls back to the decision API of a running goldrail
process (GOLDRAIL_URL) and prints one warning line saying so — decisions
become a network hop and report becomes best-effort, which is a fine trade to
make deliberately and a bad one to make by accident.
const middleware = goldrail();
const engine = await middleware.ready;
console.log(engine.transport); // 'native' | 'decision-api'Set transport: 'native' to turn a missing addon into a startup error instead
of a fallback — which is usually what a production deployment wants.
Headers, which are contract surface
| Header | Meaning |
| --- | --- |
| goldrail-funding | settled · credit · pending · exempt · shadow · free · unpaid |
| goldrail-balance | Atomic units remaining after this request |
| goldrail-amount-required | On a 402: the amount that converges (a top-up on the credit rail, the price on the per-request rail) |
| goldrail-ignored-directives | Directives the route did not allowlist — ignored, and said so |
These names are semver-governed: assert on them in CI and minor upgrades will not break you.
Building the addon from source
cd sdk/typescript
npm install
npm run build:native # napi build → native/goldrail.<platform>-<arch>.node
npm testNo build artifacts are committed: CI builds one addon per platform and
publishes them as the optional dependencies the loader resolves. Those
per-platform packages are the only scoped names in the project and nobody
ever types one — npm i goldrail pulls the right one for your machine.
GOLDRAIL_NATIVE=/path/to/goldrail.node points the loader at a specific
build. It is a pin, not a hint — when it is set, nothing else is tried, so
an operator who names a binary always gets that binary.
npm test compiles the suite before running it, so it works on every Node this
package supports rather than only on one new enough to strip types. The
end-to-end suite drives the real addon and skips with a printed notice when
it has not been built — never silently.
License
Apache-2.0
