diff --git a/CHANGELOG.md b/CHANGELOG.md index 54ad3a1..60dcefd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## [4.7.0](https://github.com/forgesworn/toll-booth/compare/v4.6.1...v4.7.0) (2026-05-23) + + +### Added +- `invoiceRateLimit.maxPendingPerIp` now also applies to the 402 challenge code path, not only `POST /create-invoice`. A single client IP that exceeds the cap by hitting priced endpoints without paying will receive HTTP 429 (with `Retry-After: 3600`) instead of a fresh invoice + macaroon. +- `INVOICE_MAX_AGE_MS` env var on the `valhalla-proxy` example (default 1 hour in the example; the library's `invoiceMaxAgeMs` default remains 24 hours). + +### Internal +- `TollBoothCoreConfig` gains optional `invoiceRateLimit?: { maxPendingPerIp: number }`. +- `Booth` forwards `invoiceRateLimit` from `BoothConfig` into the engine. +- Public `TollBoothResult` type's `challenge` variant status union widened from `401 | 402` to `401 | 402 | 429`. Existing consumers narrowing on `status === 402` continue to compile; consumers should consider handling the new 429 case if they branch on the status field. + ## [4.5.5](https://github.com/forgesworn/toll-booth/compare/v4.5.3...v4.5.5) (2026-04-12) diff --git a/CLAUDE.md b/CLAUDE.md index 8e556c7..f7f335a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,6 +110,7 @@ Backend conformance tests (`conformance.ts`) export a shared factory; each backe | `ROOT_KEY` | — | Macaroon signing key (hex, 64 chars / 32 bytes). **Required for production.** | | `TRUST_PROXY` | false | Trust `X-Forwarded-For` / `X-Real-IP` headers | | `MAX_PENDING_PER_IP` | unset | If set, cap pending unpaid invoices per client IP (rate-limit /create-invoice abuse) | +| `INVOICE_MAX_AGE_MS` | 3600000 | Max age of stored invoices in milliseconds before hourly auto-prune deletes them. Default 1 hour in this example; the library default is 24 hours. | | `PORT` | 3000 | HTTP listen port | | `LND_REST_URL` | — | LND REST endpoint (integration tests) | | `LND_MACAROON` | — | LND admin macaroon, hex (integration tests) | diff --git a/README.md b/README.md index 9135858..a21f84d 100644 --- a/README.md +++ b/README.md @@ -404,6 +404,7 @@ Deploy toll-booth as a sidecar (Docker Compose, Kubernetes) or as a standalone g - Set `trustProxy: true` when behind a reverse proxy, or provide a `getClientIp` callback for per-client free-tier isolation. - If you implement `redeemCashu`, make it idempotent for the same `paymentHash` - crash recovery depends on it. - Rate-limit `/create-invoice` at your reverse proxy - each call creates a real Lightning invoice. +- You can also enable `invoiceRateLimit.maxPendingPerIp` in the Booth config to cap pending unpaid invoices per client IP. The cap applies to both `POST /create-invoice` and the 402 challenge issued by any priced endpoint — over the cap, the engine returns HTTP 429 (`Retry-After: 3600`) instead of minting another invoice. --- diff --git a/examples/valhalla-proxy/server.ts b/examples/valhalla-proxy/server.ts index 2ff6241..54700a5 100644 --- a/examples/valhalla-proxy/server.ts +++ b/examples/valhalla-proxy/server.ts @@ -34,6 +34,7 @@ const booth = new Booth({ upstream: process.env.VALHALLA_URL ?? 'http://localhost:8002', responseHeaders: { 'X-Coverage': 'GB' }, defaultInvoiceAmount: parseInt(process.env.DEFAULT_INVOICE_SATS ?? '1000', 10), + invoiceMaxAgeMs: parseInt(process.env.INVOICE_MAX_AGE_MS ?? '3600000', 10), dbPath: process.env.TOLL_BOOTH_DB_PATH ?? './toll-booth.db', rootKey: process.env.ROOT_KEY, trustProxy, diff --git a/package-lock.json b/package-lock.json index 8e5e7fa..640493f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@forgesworn/toll-booth", - "version": "4.6.1", + "version": "4.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@forgesworn/toll-booth", - "version": "4.6.1", + "version": "4.7.0", "license": "MIT", "dependencies": { "@cashu/cashu-ts": "^3.6.1", diff --git a/package.json b/package.json index 602b87e..c65e4a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@forgesworn/toll-booth", - "version": "4.6.1", + "version": "4.7.0", "type": "module", "description": "Monetise any API with HTTP 402 payments. Payment-rail agnostic middleware for Express, Hono, Deno, Bun, and Workers.", "license": "MIT", diff --git a/src/adapters/express.test.ts b/src/adapters/express.test.ts index 3d3c8fe..e43335f 100644 --- a/src/adapters/express.test.ts +++ b/src/adapters/express.test.ts @@ -63,6 +63,30 @@ async function requestRaw(app: express.Express, requestText: string): Promise { + it('returns 429 on second challenge when invoiceRateLimit exceeded', async () => { + const backend = mockBackend() + const storage = memoryStorage() + const engine = createTollBooth({ + backend, + storage, + pricing: { '/route': 10 }, + upstream: 'http://localhost:8002', + rootKey: ROOT_KEY, + invoiceRateLimit: { maxPendingPerIp: 1 }, + }) + + const app = express() + app.use('/route', createExpressMiddleware(engine, 'http://localhost:8002')) + + const res1 = await request(app, '/route', { method: 'POST' }) + expect(res1.status).toBe(402) + + const res2 = await request(app, '/route', { method: 'POST' }) + expect(res2.status).toBe(429) + const body2 = await res2.json() as Record + expect(body2).toHaveProperty('error') + }) + it('returns 402 for priced routes without auth', async () => { const backend = mockBackend() const storage = memoryStorage() diff --git a/src/adapters/express.ts b/src/adapters/express.ts index 82f976d..7bc9376 100644 --- a/src/adapters/express.ts +++ b/src/adapters/express.ts @@ -237,7 +237,7 @@ export function createExpressMiddleware( for (const [key, value] of Object.entries(extraHeaders)) { challengeHeaders.set(key, value) } - jsonWithSensitiveHeaders(res, result.body, 402, challengeHeaders) + jsonWithSensitiveHeaders(res, result.body, result.status, challengeHeaders) } catch (err) { // Distinguish upstream network errors from programming errors setSensitiveHeaders(res) diff --git a/src/adapters/hono.test.ts b/src/adapters/hono.test.ts index 07ef0a5..26a872e 100644 --- a/src/adapters/hono.test.ts +++ b/src/adapters/hono.test.ts @@ -65,6 +65,23 @@ describe('createHonoTollBooth', () => { expect(typeof body.creditBalance).toBe('number') }) + it('returns 429 on second challenge when invoiceRateLimit exceeded', async () => { + const { engine } = createTestEngine({ invoiceRateLimit: { maxPendingPerIp: 1 } }) + const { authMiddleware } = createHonoTollBooth({ engine }) + + const app = new Hono() + app.use('/api/test', authMiddleware) + app.get('/api/test', (c) => c.text('ok')) + + const res1 = await app.request('/api/test') + expect(res1.status).toBe(402) + + const res2 = await app.request('/api/test') + expect(res2.status).toBe(429) + const body2 = await res2.json() as Record + expect(body2).toHaveProperty('error') + }) + it('returns 402 challenge when no auth header is present', async () => { const { engine } = createTestEngine() const { authMiddleware } = createHonoTollBooth({ engine }) diff --git a/src/adapters/hono.ts b/src/adapters/hono.ts index bab2ea9..c5b4588 100644 --- a/src/adapters/hono.ts +++ b/src/adapters/hono.ts @@ -1,6 +1,7 @@ // src/adapters/hono.ts import { Hono } from 'hono' import type { Context, MiddlewareHandler } from 'hono' +import type { ContentfulStatusCode } from 'hono/utils/http-status' import type { TollBoothEngine } from '../core/toll-booth.js' import type { TollBoothRequest, CreateInvoiceRequest, NwcPayRequest, CashuRedeemRequest } from '../core/types.js' import { PAYMENT_HASH_RE } from '../core/types.js' @@ -177,7 +178,7 @@ export function createHonoTollBooth(config: HonoTollBoothConfig): HonoTollBooth c.header('Cache-Control', 'no-store') c.header('Pragma', 'no-cache') c.header('X-Content-Type-Options', 'nosniff') - return c.json(result.body, result.status as 402, result.headers) + return c.json(result.body, result.status as ContentfulStatusCode, result.headers) } if (result.action === 'blocked') { diff --git a/src/adapters/web-standard.test.ts b/src/adapters/web-standard.test.ts index 2ce946a..07f6b7b 100644 --- a/src/adapters/web-standard.test.ts +++ b/src/adapters/web-standard.test.ts @@ -81,6 +81,28 @@ describe('Web Standard adapter IP resolution', () => { }) describe('Web Standard adapter', () => { + it('returns 429 on second challenge when invoiceRateLimit exceeded', async () => { + const backend = mockBackend() + const storage = memoryStorage() + const engine = createTollBooth({ + backend, + storage, + pricing: { '/api/route': 10 }, + upstream: 'http://localhost:8002', + rootKey: ROOT_KEY, + invoiceRateLimit: { maxPendingPerIp: 1 }, + }) + + const handler = createWebStandardMiddleware(engine, 'http://localhost:8002') + const res1 = await handler(new Request('http://localhost/api/route', { method: 'POST' })) + expect(res1.status).toBe(402) + + const res2 = await handler(new Request('http://localhost/api/route', { method: 'POST' })) + expect(res2.status).toBe(429) + const body2 = await res2.json() as Record + expect(body2).toHaveProperty('error') + }) + it('returns 402 for priced routes without auth', async () => { const backend = mockBackend() const storage = memoryStorage() diff --git a/src/adapters/web-standard.ts b/src/adapters/web-standard.ts index 26655b2..8b2fc37 100644 --- a/src/adapters/web-standard.ts +++ b/src/adapters/web-standard.ts @@ -235,7 +235,7 @@ export function createWebStandardMiddleware( } applyNoStoreHeaders(challengeHeaders) return Response.json(result.body, { - status: 402, + status: result.status, headers: challengeHeaders, }) } diff --git a/src/booth.ts b/src/booth.ts index f56c92d..0351fe1 100644 --- a/src/booth.ts +++ b/src/booth.ts @@ -179,6 +179,7 @@ export class Booth { freeTier: config.freeTier, strictPricing: config.strictPricing, creditTiers: config.creditTiers, + invoiceRateLimit: config.invoiceRateLimit, serviceName: config.serviceName, description: config.description, blockedCountries: config.blockedCountries, diff --git a/src/core/toll-booth.test.ts b/src/core/toll-booth.test.ts index 38ffd0c..e9b74ed 100644 --- a/src/core/toll-booth.test.ts +++ b/src/core/toll-booth.test.ts @@ -19,6 +19,20 @@ function mockBackend(): LightningBackend { } } +function uniqueHashBackend(): LightningBackend { + let counter = 0 + return { + createInvoice: vi.fn().mockImplementation(async () => { + counter += 1 + return { + bolt11: `lnbc100n1mock_${counter}`, + paymentHash: counter.toString(16).padStart(64, '0'), + } + }), + checkInvoice: vi.fn().mockResolvedValue({ paid: false }), + } +} + function makePreimageAndHash(): { preimage: string; paymentHash: string } { const preimage = randomBytes(32).toString('hex') const paymentHash = createHash('sha256').update(Buffer.from(preimage, 'hex')).digest('hex') @@ -813,6 +827,105 @@ describe('geo-fence', () => { }) }) +describe('TollBoothEngine — invoiceRateLimit on 402 path', () => { + it('returns 429 after maxPendingPerIp unpaid invoices from same IP', async () => { + const engine = createTollBooth(makeConfig({ + backend: uniqueHashBackend(), + freeTier: undefined, + invoiceRateLimit: { maxPendingPerIp: 3 }, + })) + + const ip = '203.0.113.7' + + for (let i = 0; i < 3; i++) { + const result = await engine.handle(makeRequest({ ip })) + expect(result.action).toBe('challenge') + expect(result.status).toBe(402) + } + + const blocked = await engine.handle(makeRequest({ ip })) + expect(blocked.action).toBe('challenge') + expect(blocked.status).toBe(429) + expect(blocked.headers?.['Retry-After']).toBe('3600') + }) + + it('does not rate-limit across distinct IPs', async () => { + const engine = createTollBooth(makeConfig({ + backend: uniqueHashBackend(), + freeTier: undefined, + invoiceRateLimit: { maxPendingPerIp: 2 }, + })) + + for (let i = 0; i < 5; i++) { + const result = await engine.handle(makeRequest({ ip: `198.51.100.${i}` })) + expect(result.status).toBe(402) + } + }) + + it('is disabled when invoiceRateLimit is not configured', async () => { + const engine = createTollBooth(makeConfig({ + backend: uniqueHashBackend(), + freeTier: undefined, + })) + + for (let i = 0; i < 20; i++) { + const result = await engine.handle(makeRequest({ ip: '203.0.113.99' })) + expect(result.status).toBe(402) + } + }) + + it('settling a pending invoice frees a slot for the same IP', async () => { + const storage = memoryStorage() + const engine = createTollBooth(makeConfig({ + storage, + backend: uniqueHashBackend(), + freeTier: undefined, + invoiceRateLimit: { maxPendingPerIp: 2 }, + })) + + const ip = '203.0.113.42' + + const first = await engine.handle(makeRequest({ ip })) + const firstHash = (first.body as { l402: { payment_hash: string } }).l402.payment_hash + expect(first.status).toBe(402) + + const second = await engine.handle(makeRequest({ ip })) + expect(second.status).toBe(402) + + const blocked = await engine.handle(makeRequest({ ip })) + expect(blocked.status).toBe(429) + + // Settle the first invoice — pending count for this IP drops to 1. + expect(storage.settle(firstHash)).toBe(true) + + const allowedAgain = await engine.handle(makeRequest({ ip })) + expect(allowedAgain.status).toBe(402) + }) + + it('does not call backend.createInvoice for a rate-limited request', async () => { + const backend = uniqueHashBackend() + const engine = createTollBooth(makeConfig({ + backend, + freeTier: undefined, + invoiceRateLimit: { maxPendingPerIp: 2 }, + })) + + const ip = '203.0.113.55' + + // Send exactly maxPendingPerIp requests — each should mint an invoice. + for (let i = 0; i < 2; i++) { + const result = await engine.handle(makeRequest({ ip })) + expect(result.status).toBe(402) + } + expect((backend.createInvoice as ReturnType).mock.calls.length).toBe(2) + + // The next request must be rate-limited — createInvoice must NOT be called again. + const blocked = await engine.handle(makeRequest({ ip })) + expect(blocked.status).toBe(429) + expect((backend.createInvoice as ReturnType).mock.calls.length).toBe(2) + }) +}) + describe('agent-friendly 402 body', () => { it('includes booth and auth_hint when serviceName is configured', async () => { const engine = createTollBooth(makeConfig({ diff --git a/src/core/toll-booth.ts b/src/core/toll-booth.ts index 13b77fe..7cbdb25 100644 --- a/src/core/toll-booth.ts +++ b/src/core/toll-booth.ts @@ -92,6 +92,23 @@ export function createTollBooth(config: TollBoothCoreConfig): TollBoothEngine { // Inner helper: issue a multi-rail 402 challenge for this route async function issueChallenge(): Promise { + // Rate-limit pre-check: avoid calling backend.createInvoice for IPs + // already over the cap. Otherwise the LSP/Phoenixd mints an invoice + // we immediately throw away. + if (config.invoiceRateLimit?.maxPendingPerIp) { + const pending = storage.pendingInvoiceCount(hashIp(req.ip)) + if (pending >= config.invoiceRateLimit.maxPendingPerIp) { + return { + action: 'challenge', + status: 429, + // Retry-After 1 hour: matches the default invoiceMaxAgeMs auto-prune cycle, + // after which old pending invoices are cleared and the counter resets. + headers: { 'Retry-After': '3600' }, + body: { error: 'Invoice creation rate limit exceeded' }, + } + } + } + const challengeHeaders: Record = {} const challengeBody: Record = {} @@ -169,6 +186,7 @@ export function createTollBooth(config: TollBoothCoreConfig): TollBoothEngine { const l402Data = challengeBody.l402 as Record | undefined if (l402Data?.payment_hash) { const paymentHash = l402Data.payment_hash as string + const ipHash = hashIp(req.ip) const statusToken = randomBytes(32).toString('hex') storage.storeInvoice( paymentHash, @@ -176,7 +194,7 @@ export function createTollBooth(config: TollBoothCoreConfig): TollBoothEngine { defaultAmount, l402Data.macaroon as string, statusToken, - hashIp(req.ip), + ipHash, ) l402Data.payment_url = `/invoice-status/${paymentHash}?token=${statusToken}` l402Data.status_token = statusToken diff --git a/src/core/types.ts b/src/core/types.ts index 6f15926..83f260a 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -28,7 +28,7 @@ export interface TollBoothRequest { export type TollBoothResult = | { action: 'proxy'; upstream: string; headers: Record; paymentHash?: string; estimatedCost?: number; creditBalance?: number; freeRemaining?: number; tier?: string } - | { action: 'challenge'; status: 401 | 402; headers: Record; body: Record } + | { action: 'challenge'; status: 401 | 402 | 429; headers: Record; body: Record } | { action: 'pass'; upstream: string; headers: Record } | { action: 'blocked'; status: 403; body: Record } @@ -49,6 +49,13 @@ export interface TollBoothCoreConfig { rootKey: string freeTier?: { requestsPerDay: number } | { creditsPerDay: number } creditTiers?: CreditTier[] + /** + * Pending-invoice rate limit per client IP. When set, the engine will + * return 429 instead of issuing a fresh 402 + invoice once the same + * IP has this many unsettled invoices on file. Also applied by + * handleCreateInvoice for explicit POST /create-invoice calls. + */ + invoiceRateLimit?: { maxPendingPerIp: number } rails?: PaymentRail[] normalisedPricing?: Record /** Human-readable service name for invoice descriptions. Defaults to 'toll-booth'. */