@thecryptodonkey/toll-booth 3.2.1 → 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.
- package/dist/adapters/express.d.ts +1 -0
- package/dist/adapters/express.js +8 -5
- package/dist/adapters/hono.js +43 -2
- package/dist/adapters/proxy-headers.js +8 -2
- package/dist/backends/cln.js +2 -1
- package/dist/backends/nwc.js +3 -0
- package/dist/booth.js +8 -0
- package/dist/core/cashu-redeem.js +11 -0
- package/dist/core/l402-rail.js +4 -1
- package/dist/core/toll-booth.js +12 -1
- package/dist/core/x402-rail.js +6 -2
- package/dist/demo.js +14 -1
- package/dist/free-tier.js +11 -7
- package/dist/storage/memory.js +13 -2
- package/dist/storage/sqlite.js +6 -7
- package/llms.txt +11 -5
- package/package.json +5 -2
|
@@ -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.
|
package/dist/adapters/express.js
CHANGED
|
@@ -162,6 +162,7 @@ export function createExpressMiddleware(engineOrConfig, upstreamArg) {
|
|
|
162
162
|
}
|
|
163
163
|
catch (err) {
|
|
164
164
|
// Distinguish upstream network errors from programming errors
|
|
165
|
+
setSensitiveHeaders(res);
|
|
165
166
|
if (err instanceof TypeError && (err.message.includes('fetch') || err.message.includes('network'))) {
|
|
166
167
|
res.status(502).json({ error: 'Upstream unavailable' });
|
|
167
168
|
}
|
|
@@ -232,11 +233,13 @@ export function createExpressCreateInvoiceHandler(depsOrConfig) {
|
|
|
232
233
|
if (rejectOversizedBody(req, res))
|
|
233
234
|
return;
|
|
234
235
|
const body = req.body ?? {};
|
|
235
|
-
const ip = config.
|
|
236
|
-
?
|
|
237
|
-
|
|
238
|
-
req.
|
|
239
|
-
|
|
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';
|
|
240
243
|
const result = await handleCreateInvoice(deps, { ...body, clientIp: ip });
|
|
241
244
|
if (!result.success) {
|
|
242
245
|
jsonWithSensitiveHeaders(res, { error: result.error, tiers: result.tiers }, result.status ?? 400);
|
package/dist/adapters/hono.js
CHANGED
|
@@ -7,9 +7,44 @@ import { handleNwcPay } from '../core/nwc-pay.js';
|
|
|
7
7
|
import { handleCashuRedeem } from '../core/cashu-redeem.js';
|
|
8
8
|
import { applySecurityHeaders, appendVary, parseForwardedIp } from './proxy-headers.js';
|
|
9
9
|
const MAX_BODY_BYTES = 65_536;
|
|
10
|
+
/**
|
|
11
|
+
* Reads the request body as text with a streaming byte limit.
|
|
12
|
+
* Aborts mid-stream when the limit is exceeded, preventing memory
|
|
13
|
+
* exhaustion from large chunked requests without a Content-Length header.
|
|
14
|
+
*/
|
|
15
|
+
async function readBodyTextWithinLimit(c, maxBytes) {
|
|
16
|
+
const body = c.req.raw.body;
|
|
17
|
+
if (!body)
|
|
18
|
+
return '';
|
|
19
|
+
const reader = body.getReader();
|
|
20
|
+
const decoder = new TextDecoder();
|
|
21
|
+
let totalBytes = 0;
|
|
22
|
+
let text = '';
|
|
23
|
+
try {
|
|
24
|
+
while (true) {
|
|
25
|
+
const { done, value } = await reader.read();
|
|
26
|
+
if (done)
|
|
27
|
+
break;
|
|
28
|
+
totalBytes += value.byteLength;
|
|
29
|
+
if (totalBytes > maxBytes) {
|
|
30
|
+
await reader.cancel();
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
text += decoder.decode(value, { stream: true });
|
|
34
|
+
}
|
|
35
|
+
return text + decoder.decode();
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
reader.releaseLock();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
10
44
|
/**
|
|
11
45
|
* Parses request body as JSON with a size limit.
|
|
12
46
|
* Returns undefined on oversized, empty, or malformed bodies.
|
|
47
|
+
* Uses streaming reads to abort early on large chunked payloads.
|
|
13
48
|
*/
|
|
14
49
|
async function safeParseJson(c, maxBytes = MAX_BODY_BYTES) {
|
|
15
50
|
const contentLength = c.req.header('content-length');
|
|
@@ -19,8 +54,8 @@ async function safeParseJson(c, maxBytes = MAX_BODY_BYTES) {
|
|
|
19
54
|
return undefined;
|
|
20
55
|
}
|
|
21
56
|
try {
|
|
22
|
-
const text = await c
|
|
23
|
-
if (
|
|
57
|
+
const text = await readBodyTextWithinLimit(c, maxBytes);
|
|
58
|
+
if (text === undefined)
|
|
24
59
|
return undefined;
|
|
25
60
|
if (!text.trim())
|
|
26
61
|
return {};
|
|
@@ -44,6 +79,12 @@ async function safeParseJson(c, maxBytes = MAX_BODY_BYTES) {
|
|
|
44
79
|
*/
|
|
45
80
|
export function createHonoTollBooth(config) {
|
|
46
81
|
const { engine } = config;
|
|
82
|
+
// Warn when free-tier is enabled without trustProxy or getClientIp
|
|
83
|
+
if (engine.freeTier && !config.trustProxy && !config.getClientIp) {
|
|
84
|
+
console.error('[toll-booth] WARNING: freeTier enabled without trustProxy in Hono adapter. ' +
|
|
85
|
+
'All clients will share the 0.0.0.0 IP bucket. ' +
|
|
86
|
+
'Set trustProxy: true or provide a getClientIp callback.');
|
|
87
|
+
}
|
|
47
88
|
const authMiddleware = async (c, next) => {
|
|
48
89
|
const req = c.req.raw;
|
|
49
90
|
const ip = config.getClientIp?.(c)
|
|
@@ -39,6 +39,7 @@ export function applySecurityHeaders(headers) {
|
|
|
39
39
|
headers.set('X-Frame-Options', 'DENY');
|
|
40
40
|
headers.set('Referrer-Policy', 'no-referrer');
|
|
41
41
|
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
|
42
|
+
headers.set('Content-Security-Policy', "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; form-action 'none'; frame-ancestors 'none'");
|
|
42
43
|
return headers;
|
|
43
44
|
}
|
|
44
45
|
export function appendVary(headers, value) {
|
|
@@ -58,11 +59,16 @@ export function appendVary(headers, value) {
|
|
|
58
59
|
* via crafted X-Forwarded-For headers.
|
|
59
60
|
*/
|
|
60
61
|
const IPV4_RE = /^(\d{1,3}\.){3}\d{1,3}$/;
|
|
61
|
-
const IPV6_RE = /^[0-9a-fA-F:]
|
|
62
|
+
const IPV6_RE = /^[0-9a-fA-F:]{2,45}$/;
|
|
62
63
|
export function isPlausibleIp(value) {
|
|
63
64
|
if (!value || value.length > 45)
|
|
64
65
|
return false;
|
|
65
|
-
|
|
66
|
+
if (IPV4_RE.test(value)) {
|
|
67
|
+
// Reject octets > 255
|
|
68
|
+
return value.split('.').every(o => parseInt(o, 10) <= 255);
|
|
69
|
+
}
|
|
70
|
+
// IPv6 must contain at least one colon
|
|
71
|
+
return IPV6_RE.test(value) && value.includes(':');
|
|
66
72
|
}
|
|
67
73
|
/**
|
|
68
74
|
* Extracts and validates the client IP from an X-Forwarded-For header value.
|
package/dist/backends/cln.js
CHANGED
|
@@ -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()}-${
|
|
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/backends/nwc.js
CHANGED
|
@@ -46,6 +46,9 @@ export function nwcBackend(config) {
|
|
|
46
46
|
return { bolt11: tx.invoice, paymentHash: tx.payment_hash };
|
|
47
47
|
},
|
|
48
48
|
async checkInvoice(paymentHash) {
|
|
49
|
+
if (!/^[0-9a-f]{64}$/.test(paymentHash)) {
|
|
50
|
+
return { paid: false };
|
|
51
|
+
}
|
|
49
52
|
const nwc = await getClient();
|
|
50
53
|
try {
|
|
51
54
|
const tx = await nwc.lookupInvoice({ payment_hash: paymentHash });
|
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 {
|
package/dist/core/l402-rail.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/dist/core/toll-booth.js
CHANGED
|
@@ -160,13 +160,24 @@ export function createTollBooth(config) {
|
|
|
160
160
|
}
|
|
161
161
|
// Track estimated cost with currency for reconciliation
|
|
162
162
|
if (result.paymentId) {
|
|
163
|
-
// Evict stale entries
|
|
163
|
+
// Evict stale entries; if still at cap, drop oldest entries
|
|
164
164
|
if (estimatedCosts.size >= MAX_ESTIMATED_COSTS) {
|
|
165
165
|
const now = Date.now();
|
|
166
166
|
for (const [key, entry] of estimatedCosts) {
|
|
167
167
|
if (now - entry.ts > MAX_AGE_MS)
|
|
168
168
|
estimatedCosts.delete(key);
|
|
169
169
|
}
|
|
170
|
+
// Force-evict oldest entries if still at capacity
|
|
171
|
+
if (estimatedCosts.size >= MAX_ESTIMATED_COSTS) {
|
|
172
|
+
const overflow = estimatedCosts.size - MAX_ESTIMATED_COSTS + 1;
|
|
173
|
+
let evicted = 0;
|
|
174
|
+
for (const key of estimatedCosts.keys()) {
|
|
175
|
+
if (evicted >= overflow)
|
|
176
|
+
break;
|
|
177
|
+
estimatedCosts.delete(key);
|
|
178
|
+
evicted++;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
170
181
|
}
|
|
171
182
|
estimatedCosts.set(result.paymentId, { cost, ts: Date.now(), currency: result.currency });
|
|
172
183
|
}
|
package/dist/core/x402-rail.js
CHANGED
|
@@ -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;
|
|
@@ -50,9 +51,12 @@ export function createX402Rail(config) {
|
|
|
50
51
|
if (!result.valid) {
|
|
51
52
|
return { authenticated: false, paymentId: result.txHash || '', mode: 'per-request', currency: 'usd' };
|
|
52
53
|
}
|
|
53
|
-
// Credit mode: persist balance to storage (mirrors L402 rail's settleWithCredit)
|
|
54
|
+
// Credit mode: persist balance to storage (mirrors L402 rail's settleWithCredit).
|
|
55
|
+
// Generate a random settlement secret; the txHash is public on-chain
|
|
56
|
+
// and must never be used as a bearer credential.
|
|
54
57
|
if (creditMode && storage && !storage.isSettled(result.txHash)) {
|
|
55
|
-
|
|
58
|
+
const settlementSecret = randomBytes(32).toString('hex');
|
|
59
|
+
storage.settleWithCredit(result.txHash, result.amount, settlementSecret, 'usd');
|
|
56
60
|
}
|
|
57
61
|
const creditBalance = creditMode && storage
|
|
58
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
|
-
|
|
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';
|
package/dist/free-tier.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
// src/free-tier.ts
|
|
2
2
|
/** Maximum number of distinct IPs tracked before new IPs are denied. */
|
|
3
3
|
const MAX_TRACKED_IPS = 100_000;
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* Validates that a string is a plausible IP address or IP hash.
|
|
6
|
+
* More permissive than the proxy-headers version because the toll-booth
|
|
7
|
+
* engine passes hashIp() output (32-char hex) to freeTier.check().
|
|
8
|
+
*/
|
|
5
9
|
const IPV4_RE = /^(\d{1,3}\.){3}\d{1,3}$/;
|
|
6
|
-
const
|
|
7
|
-
function
|
|
8
|
-
if (!value || value.length >
|
|
10
|
+
const HEX_OR_IPV6_RE = /^[0-9a-fA-F:]{2,64}$/;
|
|
11
|
+
function isPlausibleIpOrHash(value) {
|
|
12
|
+
if (!value || value.length > 64)
|
|
9
13
|
return false;
|
|
10
|
-
return IPV4_RE.test(value) ||
|
|
14
|
+
return IPV4_RE.test(value) || HEX_OR_IPV6_RE.test(value);
|
|
11
15
|
}
|
|
12
16
|
export class FreeTier {
|
|
13
17
|
requestsPerDay;
|
|
@@ -25,7 +29,7 @@ export class FreeTier {
|
|
|
25
29
|
}
|
|
26
30
|
check(ip, _cost) {
|
|
27
31
|
// Reject non-IP strings to prevent arbitrary values filling the tracking map
|
|
28
|
-
if (!
|
|
32
|
+
if (!isPlausibleIpOrHash(ip)) {
|
|
29
33
|
return { allowed: false, remaining: 0 };
|
|
30
34
|
}
|
|
31
35
|
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
|
@@ -66,7 +70,7 @@ export class CreditFreeTier {
|
|
|
66
70
|
}
|
|
67
71
|
check(ip, cost) {
|
|
68
72
|
// Reject non-IP strings to prevent arbitrary values filling the tracking map
|
|
69
|
-
if (!
|
|
73
|
+
if (!isPlausibleIpOrHash(ip)) {
|
|
70
74
|
return { allowed: false, remaining: 0 };
|
|
71
75
|
}
|
|
72
76
|
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
package/dist/storage/memory.js
CHANGED
|
@@ -161,14 +161,25 @@ export function memoryStorage() {
|
|
|
161
161
|
for (const [hash, inv] of invoices) {
|
|
162
162
|
if (inv.createdAt < cutoff && !claims.has(hash)) {
|
|
163
163
|
invoices.delete(hash);
|
|
164
|
+
invoiceIps.delete(hash);
|
|
164
165
|
pruned++;
|
|
165
166
|
}
|
|
166
167
|
}
|
|
167
168
|
return pruned;
|
|
168
169
|
},
|
|
169
170
|
pruneStaleRecords(_maxAgeMs) {
|
|
170
|
-
//
|
|
171
|
-
|
|
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;
|
|
172
183
|
},
|
|
173
184
|
close() {
|
|
174
185
|
balances.clear();
|
package/dist/storage/sqlite.js
CHANGED
|
@@ -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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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)
|
|
@@ -364,14 +363,14 @@ export function sqliteStorage(config) {
|
|
|
364
363
|
const result = stmtPruneInvoices.run(maxAgeSecs);
|
|
365
364
|
return result.changes;
|
|
366
365
|
},
|
|
367
|
-
pruneStaleRecords(maxAgeMs) {
|
|
366
|
+
pruneStaleRecords: db.transaction((maxAgeMs) => {
|
|
368
367
|
const maxAgeSecs = Math.floor(maxAgeMs / 1000);
|
|
369
368
|
let total = 0;
|
|
370
369
|
total += stmtPruneZeroCredits.run(maxAgeSecs).changes;
|
|
371
|
-
|
|
370
|
+
// Settlement markers are intentionally never pruned (replay protection).
|
|
372
371
|
total += stmtPruneClaims.run(maxAgeSecs).changes;
|
|
373
372
|
return total;
|
|
374
|
-
},
|
|
373
|
+
}),
|
|
375
374
|
close() {
|
|
376
375
|
db.close();
|
|
377
376
|
},
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
- **
|
|
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.
|
|
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": {
|