Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)


Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
1 change: 1 addition & 0 deletions examples/valhalla-proxy/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
24 changes: 24 additions & 0 deletions src/adapters/express.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,30 @@ async function requestRaw(app: express.Express, requestText: string): Promise<st
}

describe('Express adapter', () => {
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<string, unknown>
expect(body2).toHaveProperty('error')
})

it('returns 402 for priced routes without auth', async () => {
const backend = mockBackend()
const storage = memoryStorage()
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/express.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 17 additions & 0 deletions src/adapters/hono.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TollBoothEnv>()
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<string, unknown>
expect(body2).toHaveProperty('error')
})

it('returns 402 challenge when no auth header is present', async () => {
const { engine } = createTestEngine()
const { authMiddleware } = createHonoTollBooth({ engine })
Expand Down
3 changes: 2 additions & 1 deletion src/adapters/hono.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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') {
Expand Down
22 changes: 22 additions & 0 deletions src/adapters/web-standard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
expect(body2).toHaveProperty('error')
})

it('returns 402 for priced routes without auth', async () => {
const backend = mockBackend()
const storage = memoryStorage()
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/web-standard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ export function createWebStandardMiddleware(
}
applyNoStoreHeaders(challengeHeaders)
return Response.json(result.body, {
status: 402,
status: result.status,
headers: challengeHeaders,
})
}
Expand Down
1 change: 1 addition & 0 deletions src/booth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
113 changes: 113 additions & 0 deletions src/core/toll-booth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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<typeof vi.fn>).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<typeof vi.fn>).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({
Expand Down
20 changes: 19 additions & 1 deletion src/core/toll-booth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TollBoothResult> {
// 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<string, string> = {}
const challengeBody: Record<string, unknown> = {}

Expand Down Expand Up @@ -169,14 +186,15 @@ export function createTollBooth(config: TollBoothCoreConfig): TollBoothEngine {
const l402Data = challengeBody.l402 as Record<string, unknown> | 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,
(l402Data.invoice as string) ?? '',
defaultAmount,
l402Data.macaroon as string,
statusToken,
hashIp(req.ip),
ipHash,
)
l402Data.payment_url = `/invoice-status/${paymentHash}?token=${statusToken}`
l402Data.status_token = statusToken
Expand Down
9 changes: 8 additions & 1 deletion src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export interface TollBoothRequest {

export type TollBoothResult =
| { action: 'proxy'; upstream: string; headers: Record<string, string>; paymentHash?: string; estimatedCost?: number; creditBalance?: number; freeRemaining?: number; tier?: string }
| { action: 'challenge'; status: 401 | 402; headers: Record<string, string>; body: Record<string, unknown> }
| { action: 'challenge'; status: 401 | 402 | 429; headers: Record<string, string>; body: Record<string, unknown> }
| { action: 'pass'; upstream: string; headers: Record<string, string> }
| { action: 'blocked'; status: 403; body: Record<string, unknown> }

Expand All @@ -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<string, PriceInfo>
/** Human-readable service name for invoice descriptions. Defaults to 'toll-booth'. */
Expand Down