From 79434a64cdc898ecf49026c32e878121fac8644f Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Sat, 23 May 2026 14:46:58 +0100 Subject: [PATCH 01/13] feat: add invoiceRateLimit to TollBoothCoreConfig --- src/core/types.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/core/types.ts b/src/core/types.ts index 6f15926..67b7a66 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -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'. */ From a6fc760e51f9fa26e2cf3e6d9b04a57ba150e868 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Sat, 23 May 2026 14:50:15 +0100 Subject: [PATCH 02/13] feat: forward invoiceRateLimit from Booth to engine --- src/booth.ts | 1 + 1 file changed, 1 insertion(+) 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, From fc437a83b5495133a6fd8cb1b936c2e06e44f961 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Sat, 23 May 2026 14:50:25 +0100 Subject: [PATCH 03/13] feat(example): expose INVOICE_MAX_AGE_MS for valhalla-proxy --- examples/valhalla-proxy/server.ts | 1 + 1 file changed, 1 insertion(+) 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, From bb2ca10cd09ebb2de7f391c6ae2be671db2eea26 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Sat, 23 May 2026 14:50:32 +0100 Subject: [PATCH 04/13] docs: document INVOICE_MAX_AGE_MS in env vars table --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 8e556c7..c91605f 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. | | `PORT` | 3000 | HTTP listen port | | `LND_REST_URL` | — | LND REST endpoint (integration tests) | | `LND_MACAROON` | — | LND admin macaroon, hex (integration tests) | From c783a3fac6736c1121bd0e8d8effedff06520ae0 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Sat, 23 May 2026 14:50:49 +0100 Subject: [PATCH 05/13] chore: release 4.7.0 --- CHANGELOG.md | 11 +++++++++++ package.json | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54ad3a1..3afdfa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## [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). + +### Internal +- `TollBoothCoreConfig` gains optional `invoiceRateLimit?: { maxPendingPerIp: number }`. +- `Booth` forwards `invoiceRateLimit` from `BoothConfig` into the engine. + ## [4.5.5](https://github.com/forgesworn/toll-booth/compare/v4.5.3...v4.5.5) (2026-04-12) 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", From cad920b6baf5d7d128c20974f99c6850b86d32ab Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Sat, 23 May 2026 14:54:10 +0100 Subject: [PATCH 06/13] test: add failing test for 402-path invoiceRateLimit --- src/core/toll-booth.test.ts | 90 +++++++++++++++++++++++++++++++++++++ src/core/types.ts | 2 +- 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/src/core/toll-booth.test.ts b/src/core/toll-booth.test.ts index 38ffd0c..698b777 100644 --- a/src/core/toll-booth.test.ts +++ b/src/core/toll-booth.test.ts @@ -813,6 +813,96 @@ describe('geo-fence', () => { }) }) +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 }), + } +} + +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) + }) +}) + 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/types.ts b/src/core/types.ts index 67b7a66..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 } From cdaa3a6a36aa9b8c59261c8c2242650af638f6b2 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Sat, 23 May 2026 14:55:09 +0100 Subject: [PATCH 07/13] feat: enforce invoiceRateLimit on 402 challenge path --- src/core/toll-booth.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/core/toll-booth.ts b/src/core/toll-booth.ts index 13b77fe..1507840 100644 --- a/src/core/toll-booth.ts +++ b/src/core/toll-booth.ts @@ -169,6 +169,23 @@ 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) + + // Apply the same pending-invoice cap that POST /create-invoice + // uses. Without this, a single IP can mint unbounded unpaid + // invoices by hammering any priced endpoint. + if (config.invoiceRateLimit?.maxPendingPerIp) { + const pending = storage.pendingInvoiceCount(ipHash) + if (pending >= config.invoiceRateLimit.maxPendingPerIp) { + return { + action: 'challenge', + status: 429, + headers: { 'Retry-After': '3600' }, + body: { error: 'Too many unpaid invoices; pay one to continue' }, + } + } + } + const statusToken = randomBytes(32).toString('hex') storage.storeInvoice( paymentHash, @@ -176,7 +193,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 From 05cad55867bf25ed36b305c9016211ca948f7225 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Sat, 23 May 2026 15:00:45 +0100 Subject: [PATCH 08/13] refactor: hoist invoiceRateLimit check before invoice mint + align error message --- src/core/toll-booth.test.ts | 28 ++++++++++++++-------------- src/core/toll-booth.ts | 33 +++++++++++++++++---------------- 2 files changed, 31 insertions(+), 30 deletions(-) diff --git a/src/core/toll-booth.test.ts b/src/core/toll-booth.test.ts index 698b777..4f7c6c8 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,20 +827,6 @@ describe('geo-fence', () => { }) }) -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 }), - } -} - describe('TollBoothEngine — invoiceRateLimit on 402 path', () => { it('returns 429 after maxPendingPerIp unpaid invoices from same IP', async () => { const engine = createTollBooth(makeConfig({ diff --git a/src/core/toll-booth.ts b/src/core/toll-booth.ts index 1507840..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 = {} @@ -170,22 +187,6 @@ export function createTollBooth(config: TollBoothCoreConfig): TollBoothEngine { if (l402Data?.payment_hash) { const paymentHash = l402Data.payment_hash as string const ipHash = hashIp(req.ip) - - // Apply the same pending-invoice cap that POST /create-invoice - // uses. Without this, a single IP can mint unbounded unpaid - // invoices by hammering any priced endpoint. - if (config.invoiceRateLimit?.maxPendingPerIp) { - const pending = storage.pendingInvoiceCount(ipHash) - if (pending >= config.invoiceRateLimit.maxPendingPerIp) { - return { - action: 'challenge', - status: 429, - headers: { 'Retry-After': '3600' }, - body: { error: 'Too many unpaid invoices; pay one to continue' }, - } - } - } - const statusToken = randomBytes(32).toString('hex') storage.storeInvoice( paymentHash, From 83aa3a6299b9aa2325635d6edf14a255403ab4cf Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Sat, 23 May 2026 15:02:12 +0100 Subject: [PATCH 09/13] test: assert no backend invoice mint on rate-limited request Adds a call-count assertion to the invoiceRateLimit suite: after maxPendingPerIp requests, the (N+1)th is blocked with 429 and backend.createInvoice call count remains exactly N. --- src/core/toll-booth.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/core/toll-booth.test.ts b/src/core/toll-booth.test.ts index 4f7c6c8..e9b74ed 100644 --- a/src/core/toll-booth.test.ts +++ b/src/core/toll-booth.test.ts @@ -901,6 +901,29 @@ describe('TollBoothEngine — invoiceRateLimit on 402 path', () => { 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', () => { From 440400dbf614080535f9b20db72bf7e968aeb5ff Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Sat, 23 May 2026 15:11:07 +0100 Subject: [PATCH 10/13] fix(adapters): forward engine status (429 vs 402) instead of hard-coding 402 Express, Web-Standard, and Hono adapters now use result.status from the discriminated union instead of a literal 402, so rate-limited IPs receive HTTP 429 as the engine intends. Adds a sibling 429 test in each adapter test file to cover the invoice-rate-limit challenge path. --- src/adapters/express.test.ts | 24 ++++++++++++++++++++++++ src/adapters/express.ts | 2 +- src/adapters/hono.test.ts | 17 +++++++++++++++++ src/adapters/hono.ts | 3 ++- src/adapters/web-standard.test.ts | 22 ++++++++++++++++++++++ src/adapters/web-standard.ts | 2 +- 6 files changed, 67 insertions(+), 3 deletions(-) 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, }) } From 5181f26b84f65240324605262dc123a5e44ce77c Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Sat, 23 May 2026 15:11:17 +0100 Subject: [PATCH 11/13] docs: clarify changelog and CLAUDE.md for 4.7.0 release - CHANGELOG: note TollBoothResult challenge status union widened to include 429 - CHANGELOG: clarify INVOICE_MAX_AGE_MS default is 1 hour in the example; library default remains 24 hours - CLAUDE.md: same INVOICE_MAX_AGE_MS scope clarification in env-vars table --- CHANGELOG.md | 3 ++- CLAUDE.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3afdfa1..60dcefd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,12 @@ ### 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). +- `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 c91605f..f7f335a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,7 +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. | +| `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) | From 8d3b2f4a37e2ca771cd3044e81578b9b83fda99c Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Sat, 23 May 2026 15:11:21 +0100 Subject: [PATCH 12/13] docs(readme): document invoiceRateLimit coverage of 402 challenge path Add a sentence to the production checklist noting that invoiceRateLimit.maxPendingPerIp caps both /create-invoice and the inline 402 challenge, returning 429 with Retry-After when exceeded. --- README.md | 1 + 1 file changed, 1 insertion(+) 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. --- From 99380769039e7ab686c041eb9f95aa123790fe54 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Sat, 23 May 2026 15:11:50 +0100 Subject: [PATCH 13/13] chore: sync package-lock.json with 4.7.0 release --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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",