@thecryptodonkey/toll-booth 3.2.2 → 3.2.4

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.
@@ -36,6 +36,7 @@ export declare function createExpressInvoiceStatusHandler(deps: InvoiceStatusDep
36
36
  export interface CreateInvoiceHandlerConfig {
37
37
  deps: CreateInvoiceDeps;
38
38
  trustProxy?: boolean;
39
+ getClientIp?: (req: Request) => string;
39
40
  }
40
41
  /**
41
42
  * Returns an Express `RequestHandler` that creates a new Lightning invoice.
@@ -233,11 +233,13 @@ export function createExpressCreateInvoiceHandler(depsOrConfig) {
233
233
  if (rejectOversizedBody(req, res))
234
234
  return;
235
235
  const body = req.body ?? {};
236
- const ip = config.trustProxy
237
- ? parseForwardedIp(typeof req.headers['x-forwarded-for'] === 'string' ? req.headers['x-forwarded-for'] : undefined) ??
238
- parseForwardedIp(typeof req.headers['x-real-ip'] === 'string' ? req.headers['x-real-ip'] : undefined) ??
239
- req.socket.remoteAddress ?? '127.0.0.1'
240
- : req.socket.remoteAddress ?? '127.0.0.1';
236
+ const ip = config.getClientIp
237
+ ? config.getClientIp(req)
238
+ : config.trustProxy
239
+ ? parseForwardedIp(typeof req.headers['x-forwarded-for'] === 'string' ? req.headers['x-forwarded-for'] : undefined) ??
240
+ parseForwardedIp(typeof req.headers['x-real-ip'] === 'string' ? req.headers['x-real-ip'] : undefined) ??
241
+ req.socket.remoteAddress ?? '127.0.0.1'
242
+ : req.socket.remoteAddress ?? '127.0.0.1';
241
243
  const result = await handleCreateInvoice(deps, { ...body, clientIp: ip });
242
244
  if (!result.success) {
243
245
  jsonWithSensitiveHeaders(res, { error: result.error, tiers: result.tiers }, result.status ?? 400);
@@ -63,8 +63,10 @@ const IPV6_RE = /^[0-9a-fA-F:]{2,45}$/;
63
63
  export function isPlausibleIp(value) {
64
64
  if (!value || value.length > 45)
65
65
  return false;
66
- if (IPV4_RE.test(value))
67
- return true;
66
+ if (IPV4_RE.test(value)) {
67
+ // Reject octets > 255
68
+ return value.split('.').every(o => parseInt(o, 10) <= 255);
69
+ }
68
70
  // IPv6 must contain at least one colon
69
71
  return IPV6_RE.test(value) && value.includes(':');
70
72
  }
@@ -1,3 +1,4 @@
1
+ import { randomBytes } from 'node:crypto';
1
2
  import { PAYMENT_HASH_RE } from '../core/types.js';
2
3
  /**
3
4
  * Lightning backend adapter for Core Lightning's REST API (clnrest).
@@ -18,7 +19,7 @@ export function clnBackend(config) {
18
19
  };
19
20
  return {
20
21
  async createInvoice(amountSats, memo) {
21
- const label = `toll-booth-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
22
+ const label = `toll-booth-${Date.now()}-${randomBytes(6).toString('hex')}`;
22
23
  const res = await fetch(`${baseUrl}/v1/invoice`, {
23
24
  method: 'POST',
24
25
  headers: { ...headers, 'Content-Type': 'application/json' },
package/dist/booth.js CHANGED
@@ -159,6 +159,7 @@ export class Booth {
159
159
  this.createInvoiceHandler = createExpressCreateInvoiceHandler({
160
160
  deps: createInvoiceDeps,
161
161
  trustProxy: config.trustProxy,
162
+ getClientIp: config.getClientIp,
162
163
  });
163
164
  if (nwcPayDeps)
164
165
  this.nwcPayHandler = createExpressNwcHandler(nwcPayDeps);
@@ -231,6 +232,13 @@ export class Booth {
231
232
  }, renewIntervalMs);
232
233
  try {
233
234
  const credited = await redeemFn(leasedClaim.token, leasedClaim.paymentHash);
235
+ // Guard against overpayment (same check as handleCashuRedeem)
236
+ const invoice = this.storage.getInvoice(leasedClaim.paymentHash);
237
+ if (invoice?.amountSats !== undefined && credited > invoice.amountSats) {
238
+ console.warn(`[toll-booth] Recovery: rejecting overpayment for ${leasedClaim.paymentHash}: ` +
239
+ `expected ${invoice.amountSats}, got ${credited}`);
240
+ continue;
241
+ }
234
242
  if (this.storage.settleWithCredit(leasedClaim.paymentHash, credited)) {
235
243
  recovered++;
236
244
  }
@@ -41,6 +41,9 @@ export async function handleCashuRedeem(deps, request) {
41
41
  if (credited < 0) {
42
42
  return { success: false, error: 'Redeem callback returned negative amount', status: 500 };
43
43
  }
44
+ if (invoice.amountSats !== undefined && credited > invoice.amountSats) {
45
+ return { success: false, error: 'Redeemed amount exceeds invoice amount', status: 400 };
46
+ }
44
47
  const settlementSecret = randomBytes(32).toString('hex');
45
48
  const newlySettled = deps.storage.settleWithCredit(paymentHash, credited, settlementSecret);
46
49
  return {
@@ -61,6 +64,14 @@ export async function handleCashuRedeem(deps, request) {
61
64
  if (credited < 0) {
62
65
  return { success: false, error: 'Redeem callback returned negative amount', status: 500 };
63
66
  }
67
+ if (invoice.amountSats !== undefined && credited !== invoice.amountSats) {
68
+ console.warn(`[toll-booth] Cashu redeem amount mismatch for ${paymentHash}: ` +
69
+ `expected ${invoice.amountSats}, got ${credited}`);
70
+ // Reject overpayment to prevent credit inflation via a malicious redeem callback
71
+ if (credited > invoice.amountSats) {
72
+ return { success: false, error: 'Redeemed amount exceeds invoice amount', status: 400 };
73
+ }
74
+ }
64
75
  const settlementSecret = randomBytes(32).toString('hex');
65
76
  const newlySettled = deps.storage.settleWithCredit(paymentHash, credited, settlementSecret);
66
77
  return {
@@ -70,8 +70,11 @@ export function createL402Rail(config) {
70
70
  // First-time settlement — credits the balance.
71
71
  // Only reachable with a valid proof (Lightning preimage or Cashu secret).
72
72
  // If settleWithCredit loses a race, another request already settled — continue.
73
+ // Use a random settlement secret rather than the raw preimage to avoid
74
+ // leaking the bearer credential via getSettlementSecret / invoice-status.
73
75
  if (!storage.isSettled(paymentHash)) {
74
- storage.settleWithCredit(paymentHash, creditBalance, preimage);
76
+ const secret = randomBytes(32).toString('hex');
77
+ storage.settleWithCredit(paymentHash, creditBalance, secret);
75
78
  }
76
79
  // Return current balance — engine will debit and check sufficiency
77
80
  const remaining = storage.balance(paymentHash);
@@ -1,3 +1,4 @@
1
+ import { randomBytes } from 'node:crypto';
1
2
  import { DEFAULT_USDC_ASSETS } from './x402-types.js';
2
3
  export function createX402Rail(config) {
3
4
  const { receiverAddress, network, asset = DEFAULT_USDC_ASSETS[network], facilitator, creditMode = true, facilitatorUrl, storage, } = config;
@@ -51,9 +52,11 @@ export function createX402Rail(config) {
51
52
  return { authenticated: false, paymentId: result.txHash || '', mode: 'per-request', currency: 'usd' };
52
53
  }
53
54
  // Credit mode: persist balance to storage (mirrors L402 rail's settleWithCredit).
54
- // Use the txHash as the settlement secret since x402 has no preimage equivalent.
55
+ // Generate a random settlement secret; the txHash is public on-chain
56
+ // and must never be used as a bearer credential.
55
57
  if (creditMode && storage && !storage.isSettled(result.txHash)) {
56
- storage.settleWithCredit(result.txHash, result.amount, result.txHash, 'usd');
58
+ const settlementSecret = randomBytes(32).toString('hex');
59
+ storage.settleWithCredit(result.txHash, result.amount, settlementSecret, 'usd');
57
60
  }
58
61
  const creditBalance = creditMode && storage
59
62
  ? storage.balance(result.txHash, 'usd')
package/dist/demo.js CHANGED
@@ -157,10 +157,23 @@ export async function startDemo() {
157
157
  storage,
158
158
  });
159
159
  // Gateway server
160
+ const MAX_BODY = 65_536;
160
161
  const server = createServer((nodeReq, nodeRes) => {
161
162
  const chunks = [];
162
- nodeReq.on('data', (chunk) => chunks.push(chunk));
163
+ let totalBytes = 0;
164
+ nodeReq.on('data', (chunk) => {
165
+ totalBytes += chunk.length;
166
+ if (totalBytes > MAX_BODY) {
167
+ nodeReq.destroy();
168
+ nodeRes.statusCode = 413;
169
+ nodeRes.end('Request body too large');
170
+ return;
171
+ }
172
+ chunks.push(chunk);
173
+ });
163
174
  nodeReq.on('end', () => {
175
+ if (totalBytes > MAX_BODY)
176
+ return;
164
177
  const body = Buffer.concat(chunks);
165
178
  const webReq = toWebRequest(nodeReq, body);
166
179
  currentClientIp = nodeReq.socket.remoteAddress ?? '127.0.0.1';
@@ -168,8 +168,18 @@ export function memoryStorage() {
168
168
  return pruned;
169
169
  },
170
170
  pruneStaleRecords(_maxAgeMs) {
171
- // Memory storage is for testing only no long-running pruning needed
172
- return 0;
171
+ // Prune expired unsettled claims only. Settlement markers must never
172
+ // be removed; doing so would allow spent credentials to be replayed
173
+ // (isSettled returns false, settleWithCredit re-credits the balance).
174
+ let pruned = 0;
175
+ const now = Date.now();
176
+ for (const [hash, claim] of claims) {
177
+ if (!settled.has(hash) && now > claim.leaseExpiresAt + _maxAgeMs) {
178
+ claims.delete(hash);
179
+ pruned++;
180
+ }
181
+ }
182
+ return pruned;
173
183
  },
174
184
  close() {
175
185
  balances.clear();
@@ -165,10 +165,9 @@ export function sqliteStorage(config) {
165
165
  WHERE balance_sats <= 0 AND balance_usd <= 0
166
166
  AND datetime(updated_at) <= datetime('now', '-' || ? || ' seconds')
167
167
  `);
168
- const stmtPruneSettlements = db.prepare(`
169
- DELETE FROM settlements
170
- WHERE datetime(settled_at) <= datetime('now', '-' || ? || ' seconds')
171
- `);
168
+ // Settlement markers must NEVER be pruned — doing so would allow spent
169
+ // credentials to be replayed (isSettled returns false, settleWithCredit
170
+ // re-credits the balance). This matches the memory storage invariant.
172
171
  const stmtPruneClaims = db.prepare(`
173
172
  DELETE FROM claims
174
173
  WHERE payment_hash IN (SELECT payment_hash FROM settlements)
@@ -368,7 +367,7 @@ export function sqliteStorage(config) {
368
367
  const maxAgeSecs = Math.floor(maxAgeMs / 1000);
369
368
  let total = 0;
370
369
  total += stmtPruneZeroCredits.run(maxAgeSecs).changes;
371
- total += stmtPruneSettlements.run(maxAgeSecs).changes;
370
+ // Settlement markers are intentionally never pruned (replay protection).
372
371
  total += stmtPruneClaims.run(maxAgeSecs).changes;
373
372
  return total;
374
373
  }),
package/llms.txt CHANGED
@@ -1,12 +1,12 @@
1
1
  # toll-booth
2
2
 
3
- > toll-booth lets AI agents discover, pay for, and consume any HTTP API using Lightning, Cashu, or NWC - no accounts, no API keys, no human in the loop. It's L402 payment middleware that embeds in your Node.js app as middleware, supporting Express 5, Deno, Bun, and Cloudflare Workers.
3
+ > toll-booth lets AI agents discover, pay for, and consume any HTTP API using Lightning, Cashu, NWC, or x402 stablecoins - no accounts, no API keys, no human in the loop. It's payment-rail agnostic HTTP 402 middleware that embeds in your Node.js app, supporting Express 5, Hono, Deno, Bun, and Cloudflare Workers.
4
4
 
5
- toll-booth embeds in your existing Node.js application as middleware (not a separate proxy). It supports Express 5, Deno, Bun, and Cloudflare Workers. Five Lightning backends are supported (Phoenixd, LND, Core Lightning, LNbits, NWC), or you can run in Cashu-only mode with no Lightning node at all ideal for serverless and edge deployments.
5
+ toll-booth embeds in your existing Node.js application as middleware (not a separate proxy). It supports Express 5, Hono, Deno, Bun, and Cloudflare Workers. Five Lightning backends are supported (Phoenixd, LND, Core Lightning, LNbits, NWC), or you can run in Cashu-only mode with no Lightning node at all - ideal for serverless and edge deployments. x402 stablecoin payments (Coinbase's on-chain payment protocol) are also supported via a pluggable payment rail.
6
6
 
7
- The closest alternative is Aperture (Lightning Labs), a Go reverse proxy that requires LND. toll-booth is TypeScript middleware that works with any Lightning backend, runs serverless, and supports Cashu ecash payments.
7
+ The closest alternative is Aperture (Lightning Labs), a Go reverse proxy that requires LND. toll-booth is TypeScript middleware that works with any Lightning backend, runs serverless, supports Cashu ecash, and accepts x402 stablecoins.
8
8
 
9
- Compared to x402 (Coinbase's on-chain stablecoin payment protocol), toll-booth settles in milliseconds over Lightning instead of waiting for block confirmations, charges fractions-of-a-sat routing fees instead of gas, and works with any Lightning wallet - no Coinbase dependency. As USDT-over-Lightning bridges go live, toll-booth APIs automatically accept stablecoins with zero code changes.
9
+ Payment rails are pluggable - a single toll-booth deployment can accept Lightning, Cashu, and x402 stablecoins simultaneously. The seller doesn't care how they get paid.
10
10
 
11
11
  Try it instantly: `npx @thecryptodonkey/toll-booth demo`
12
12
 
@@ -62,7 +62,8 @@ const booth = new Booth({
62
62
  - **Payment methods** — Lightning (via any backend), Cashu ecash tokens (via `redeemCashu` callback), or Nostr Wallet Connect (via `nwcPayInvoice` callback). Methods can be combined.
63
63
  - **Credit system** — payments grant a credit balance. Each request deducts from the balance. Volume discount tiers available via `creditTiers`.
64
64
  - **Free tier** — optional per-IP daily allowance. Request-based: `freeTier: { requestsPerDay: N }`. Usage-based: `freeTier: { creditsPerDay: N }` (daily sats budget debited by each request's cost). Requests within the allowance bypass payment.
65
- - **Adapters** — `'express'` for Express 4/5, `'web-standard'` for Deno, Bun, and Cloudflare Workers.
65
+ - **Payment rails** — pluggable payment rail abstraction (`PaymentRail` interface). Built-in rails: `createL402Rail()` for Lightning/macaroon auth, `createX402Rail()` for on-chain stablecoin payments. Multiple rails can run simultaneously on the same deployment.
66
+ - **Adapters** — `'express'` for Express 4/5, `'web-standard'` for Deno, Bun, and Cloudflare Workers, `'hono'` for Hono. For Hono, use `createHonoTollBooth()` directly for idiomatic integration (auth middleware + payment route sub-app).
66
67
 
67
68
  ## API Surface
68
69
 
@@ -78,6 +79,11 @@ Subpath imports for tree-shaking:
78
79
  - `@thecryptodonkey/toll-booth/storage/memory` — in-memory storage (testing)
79
80
  - `@thecryptodonkey/toll-booth/adapters/express` — Express middleware factory
80
81
  - `@thecryptodonkey/toll-booth/adapters/web-standard` — Web Standard handler factory
82
+ - `@thecryptodonkey/toll-booth/hono` — Hono middleware + payment route sub-app (`createHonoTollBooth`)
83
+
84
+ Payment rail factories (for multi-rail setups):
85
+ - `createL402Rail(config)` — L402 Lightning + macaroon rail
86
+ - `createX402Rail(config)` — x402 on-chain stablecoin rail (requires a facilitator)
81
87
 
82
88
  Booth instance properties:
83
89
  - `.middleware` — main request handler (checks auth, proxies upstream)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thecryptodonkey/toll-booth",
3
- "version": "3.2.2",
3
+ "version": "3.2.4",
4
4
  "type": "module",
5
5
  "description": "Monetise any API with HTTP 402 payments. Payment-rail agnostic middleware for Express, Hono, Deno, Bun, and Workers.",
6
6
  "license": "MIT",
@@ -27,7 +27,9 @@
27
27
  "api-monetisation",
28
28
  "payment-gateway",
29
29
  "ai-agents",
30
- "machine-payments"
30
+ "machine-payments",
31
+ "api-monetization",
32
+ "x402"
31
33
  ],
32
34
  "repository": {
33
35
  "type": "git",
@@ -87,6 +89,7 @@
87
89
  "files": [
88
90
  "dist",
89
91
  "!dist/**/*.map",
92
+ "!dist/**/*.d.ts.map",
90
93
  "llms.txt"
91
94
  ],
92
95
  "scripts": {