lightning-toll 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jeletor
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,419 @@
1
+ # ⚡ lightning-toll
2
+
3
+ **Drop-in Express middleware that puts any API endpoint behind a Lightning paywall.** Consumers pay per request with Bitcoin Lightning — no API keys to manage, no billing system, no Stripe integration. Just `npm install lightning-toll`, wrap your routes, and start earning sats. Implements the [L402 protocol](https://docs.lightning.engineering/the-lightning-network/l402) with proper macaroon credentials.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install lightning-toll lightning-agent
9
+ ```
10
+
11
+ `express` is a peer dependency (use your existing Express app).
12
+
13
+ ## Quick Start
14
+
15
+ ### Server (5 lines)
16
+
17
+ ```js
18
+ const express = require('express');
19
+ const { createToll } = require('lightning-toll');
20
+
21
+ const app = express();
22
+ const toll = createToll({ wallet: process.env.NWC_URL, secret: 'your-hmac-secret' });
23
+
24
+ app.get('/api/joke', toll({ sats: 5 }), (req, res) => res.json({ joke: '...' }));
25
+ app.listen(3000);
26
+ ```
27
+
28
+ ### Client (3 lines)
29
+
30
+ ```js
31
+ const { tollFetch } = require('lightning-toll/client');
32
+ const res = await tollFetch('https://api.example.com/joke', { wallet: process.env.NWC_URL });
33
+ const data = await res.json(); // Paid 5 sats automatically
34
+ ```
35
+
36
+ ## How It Works — L402 Protocol
37
+
38
+ ```
39
+ Client Server
40
+ | |
41
+ | GET /api/joke |
42
+ | ─────────────────────────────────> |
43
+ | |
44
+ | 402 Payment Required |
45
+ | WWW-Authenticate: L402 invoice="..",|
46
+ | macaroon=".." |
47
+ | <───────────────────────────────── |
48
+ | |
49
+ | [Pays Lightning invoice] |
50
+ | [Gets preimage as receipt] |
51
+ | |
52
+ | GET /api/joke |
53
+ | Authorization: L402 <mac>:<preimage>|
54
+ | ─────────────────────────────────> |
55
+ | |
56
+ | 200 OK { joke: "..." } |
57
+ | <───────────────────────────────── |
58
+ ```
59
+
60
+ 1. Client requests an endpoint without payment
61
+ 2. Server returns **402 Payment Required** with a Lightning invoice and a macaroon
62
+ 3. Client pays the invoice with any Lightning wallet
63
+ 4. Client retries with `Authorization: L402 <macaroon>:<preimage>`
64
+ 5. Server verifies the preimage matches the payment hash, checks the macaroon, and grants access
65
+
66
+ ## API Reference
67
+
68
+ ### `createToll(options)`
69
+
70
+ Creates a toll booth instance. Returns a `toll()` function for creating per-route middleware.
71
+
72
+ ```js
73
+ const { createToll } = require('lightning-toll');
74
+
75
+ const toll = createToll({
76
+ // Required
77
+ wallet: process.env.NWC_URL, // NWC connection string OR lightning-agent wallet instance
78
+ secret: 'hmac-signing-secret', // Secret for macaroon HMAC signatures
79
+
80
+ // Optional
81
+ defaultSats: 10, // Default price if not set per-route (default: 10)
82
+ invoiceExpiry: 300, // Invoice expiry in seconds (default: 300 = 5 min)
83
+ macaroonExpiry: 3600, // How long a paid macaroon stays valid (default: 3600 = 1 hour)
84
+ bindEndpoint: true, // Bind macaroons to the specific endpoint (default: true)
85
+ bindMethod: true, // Bind macaroons to the HTTP method (default: true)
86
+ bindIp: false, // Bind macaroons to client IP (default: false)
87
+
88
+ // Callbacks
89
+ onPayment: (info) => {
90
+ console.log(`Paid: ${info.amountSats} sats for ${info.endpoint}`);
91
+ // info: { paymentHash, amountSats, endpoint, preimage, settledAt, clientId }
92
+ }
93
+ });
94
+ ```
95
+
96
+ #### Using a wallet instance
97
+
98
+ You can pass an NWC URL string (and lightning-toll creates the wallet internally), or pass a pre-created `lightning-agent` wallet:
99
+
100
+ ```js
101
+ const { createWallet } = require('lightning-agent');
102
+ const wallet = createWallet(process.env.NWC_URL);
103
+
104
+ const toll = createToll({ wallet, secret: 'my-secret' });
105
+ ```
106
+
107
+ ### `toll(routeOptions)` — Route Middleware
108
+
109
+ ```js
110
+ // Fixed price
111
+ app.get('/api/data', toll({ sats: 21 }), handler);
112
+
113
+ // Dynamic price based on request
114
+ app.get('/api/search', toll({
115
+ price: (req) => req.query.premium ? 50 : 10,
116
+ description: (req) => `Search: ${req.query.q}`
117
+ }), handler);
118
+
119
+ // Free tier + paid
120
+ app.get('/api/data', toll({
121
+ sats: 21,
122
+ freeRequests: 10, // Free requests per window per client
123
+ freeWindow: '1h' // Window duration: '30m', '1h', '1d', etc.
124
+ }), handler);
125
+
126
+ // Custom description
127
+ app.get('/api/ai', toll({
128
+ sats: 100,
129
+ description: 'AI inference — GPT-4 quality'
130
+ }), handler);
131
+ ```
132
+
133
+ #### Route Options
134
+
135
+ | Option | Type | Description |
136
+ |--------|------|-------------|
137
+ | `sats` | `number` | Fixed price in satoshis |
138
+ | `price` | `(req) => number` | Dynamic pricing function |
139
+ | `description` | `string \| (req) => string` | Invoice description |
140
+ | `freeRequests` | `number` | Free requests per window per client |
141
+ | `freeWindow` | `string \| number` | Free tier window (`'1h'`, `'30m'`, `'1d'`, or milliseconds) |
142
+
143
+ ### `req.toll` — Payment Info
144
+
145
+ After the middleware runs, `req.toll` is set on the request:
146
+
147
+ ```js
148
+ app.get('/api/data', toll({ sats: 5 }), (req, res) => {
149
+ if (req.toll.paid) {
150
+ // Client paid with Lightning
151
+ console.log(req.toll.paymentHash);
152
+ console.log(req.toll.amountSats);
153
+ }
154
+ if (req.toll.free) {
155
+ // Client used a free tier request
156
+ }
157
+ res.json({ data: '...' });
158
+ });
159
+ ```
160
+
161
+ ### `toll.dashboard()` — Stats Endpoint
162
+
163
+ ```js
164
+ app.get('/api/stats', toll.dashboard());
165
+ ```
166
+
167
+ Returns JSON:
168
+
169
+ ```json
170
+ {
171
+ "totalRevenue": 1250,
172
+ "totalRequests": 340,
173
+ "totalPaid": 125,
174
+ "uniquePayers": 42,
175
+ "endpoints": {
176
+ "/api/joke": { "revenue": 500, "requests": 100, "paid": 100, "free": 0 },
177
+ "/api/data": { "revenue": 750, "requests": 240, "paid": 25, "free": 215 }
178
+ },
179
+ "recentPayments": [
180
+ {
181
+ "endpoint": "/api/joke",
182
+ "amountSats": 5,
183
+ "payerId": "203.0.113.1",
184
+ "paymentHash": "abc123...",
185
+ "timestamp": 1706817600000
186
+ }
187
+ ]
188
+ }
189
+ ```
190
+
191
+ Stats are in-memory by default. To persist them, read `toll.stats.toJSON()` periodically and restore on startup.
192
+
193
+ ### `toll.stats` — Direct Stats Access
194
+
195
+ ```js
196
+ const stats = toll.stats.toJSON();
197
+ console.log(`Total revenue: ${stats.totalRevenue} sats`);
198
+ ```
199
+
200
+ ## Client SDK
201
+
202
+ ### `TollClient`
203
+
204
+ A client that automatically handles L402 payment flows:
205
+
206
+ ```js
207
+ const { TollClient } = require('lightning-toll/client');
208
+
209
+ const client = new TollClient({
210
+ wallet: process.env.NWC_URL, // NWC URL or wallet instance
211
+ maxSats: 100, // Budget cap per request (default: 100)
212
+ autoRetry: true, // Auto-pay and retry on 402 (default: true)
213
+ headers: { // Default headers for all requests
214
+ 'User-Agent': 'MyApp/1.0'
215
+ }
216
+ });
217
+
218
+ // Transparent fetch — handles 402 automatically
219
+ const res = await client.fetch('https://api.example.com/joke');
220
+ const data = await res.json();
221
+
222
+ // Per-request budget override
223
+ const res2 = await client.fetch('https://api.example.com/expensive', {
224
+ maxSats: 500
225
+ });
226
+
227
+ // Clean up
228
+ client.close();
229
+ ```
230
+
231
+ ### `tollFetch(url, options)`
232
+
233
+ One-shot fetch with auto-payment — no client setup needed:
234
+
235
+ ```js
236
+ const { tollFetch } = require('lightning-toll/client');
237
+
238
+ const res = await tollFetch('https://api.example.com/joke', {
239
+ wallet: process.env.NWC_URL,
240
+ maxSats: 50
241
+ });
242
+ const data = await res.json();
243
+ ```
244
+
245
+ #### Options
246
+
247
+ | Option | Type | Default | Description |
248
+ |--------|------|---------|-------------|
249
+ | `wallet` | `string \| object` | required | NWC URL or wallet instance |
250
+ | `maxSats` | `number` | 50 | Max sats to auto-pay |
251
+ | `method` | `string` | `'GET'` | HTTP method |
252
+ | `headers` | `object` | `{}` | Request headers |
253
+ | `body` | `*` | - | Request body |
254
+
255
+ ## Macaroon Caveats
256
+
257
+ Macaroons are bearer credentials with embedded restrictions (caveats). Each caveat narrows the scope of what the credential allows.
258
+
259
+ ### Supported Caveats
260
+
261
+ | Caveat | Description | Default |
262
+ |--------|-------------|---------|
263
+ | `expires_at` | Unix timestamp — macaroon expires after this | Always set (based on `macaroonExpiry`) |
264
+ | `endpoint` | Path the macaroon is valid for | Set when `bindEndpoint: true` |
265
+ | `method` | HTTP method restriction | Set when `bindMethod: true` |
266
+ | `ip` | Client IP restriction | Set when `bindIp: true` |
267
+
268
+ ### How Macaroons Work
269
+
270
+ ```
271
+ 1. Server creates macaroon:
272
+ HMAC(secret, paymentHash) → sig₁
273
+ HMAC(sig₁, "expires_at = 1706900000") → sig₂
274
+ HMAC(sig₂, "endpoint = /api/joke") → final_signature
275
+
276
+ 2. Macaroon = { id: paymentHash, caveats: [...], signature: final_sig }
277
+
278
+ 3. Verification: recompute the HMAC chain and compare signatures
279
+ ```
280
+
281
+ Macaroons use chained HMAC-SHA256. Each caveat is folded into the signature, making it impossible to remove caveats without invalidating the signature.
282
+
283
+ ### Security Model
284
+
285
+ - **Payment binding:** The macaroon ID is the Lightning payment hash. The preimage (proof of payment) must match.
286
+ - **Caveat verification:** All caveats are checked against the current request context.
287
+ - **Timing-safe comparison:** Signature verification uses `crypto.timingSafeEqual`.
288
+ - **No replay:** Each preimage+macaroon combination is checked cryptographically. The preimage can only match one payment hash.
289
+
290
+ ## Free Tier Configuration
291
+
292
+ Give users a taste before they pay:
293
+
294
+ ```js
295
+ app.get('/api/data', toll({
296
+ sats: 21,
297
+ freeRequests: 10, // 10 free requests...
298
+ freeWindow: '1h' // ...per hour, per client IP
299
+ }), handler);
300
+ ```
301
+
302
+ Free tier tracking is per client IP by default. The window resets after the specified duration. Supported window formats:
303
+
304
+ - `'30s'` — 30 seconds
305
+ - `'5m'` — 5 minutes
306
+ - `'1h'` — 1 hour
307
+ - `'1d'` — 1 day
308
+ - `3600000` — milliseconds directly
309
+
310
+ ## Dynamic Pricing
311
+
312
+ Price APIs based on request content:
313
+
314
+ ```js
315
+ // Price by query complexity
316
+ app.get('/api/search', toll({
317
+ price: (req) => {
318
+ if (req.query.deep === 'true') return 50;
319
+ if (req.query.premium === 'true') return 20;
320
+ return 5;
321
+ }
322
+ }), handler);
323
+
324
+ // Price by content length
325
+ app.post('/api/translate', toll({
326
+ price: (req) => {
327
+ const chars = (req.body?.text || '').length;
328
+ return Math.max(1, Math.ceil(chars / 100)); // 1 sat per 100 chars
329
+ }
330
+ }), handler);
331
+
332
+ // Price by time of day (surge pricing)
333
+ app.get('/api/premium', toll({
334
+ price: (req) => {
335
+ const hour = new Date().getHours();
336
+ return hour >= 9 && hour <= 17 ? 50 : 10; // Peak vs off-peak
337
+ }
338
+ }), handler);
339
+ ```
340
+
341
+ ## 402 Response Format
342
+
343
+ When a client hits a toll-gated endpoint without payment:
344
+
345
+ ```
346
+ HTTP/1.1 402 Payment Required
347
+ WWW-Authenticate: L402 invoice="lnbc50n1pj...", macaroon="eyJpZCI..."
348
+ Content-Type: application/json
349
+
350
+ {
351
+ "status": 402,
352
+ "message": "Payment Required",
353
+ "paymentHash": "a1b2c3d4...",
354
+ "invoice": "lnbc50n1pj...",
355
+ "macaroon": "eyJpZCI...",
356
+ "amountSats": 5,
357
+ "description": "Random joke",
358
+ "protocol": "L402",
359
+ "instructions": {
360
+ "step1": "Pay the Lightning invoice above",
361
+ "step2": "Get the preimage from the payment receipt",
362
+ "step3": "Retry the request with header: Authorization: L402 <macaroon>:<preimage>"
363
+ }
364
+ }
365
+ ```
366
+
367
+ ## Security Considerations
368
+
369
+ - **Use a strong secret.** The HMAC secret should be a random string of at least 32 characters. Use `crypto.randomBytes(32).toString('hex')`.
370
+ - **HTTPS in production.** Macaroons and preimages are bearer credentials — always use HTTPS.
371
+ - **Invoice expiry.** Default is 5 minutes. Shorter = safer, but gives users less time to pay.
372
+ - **Macaroon expiry.** Default is 1 hour. A paid macaroon can be reused until it expires.
373
+ - **IP binding.** Enable `bindIp: true` if you want macaroons tied to a specific client IP. Beware of NAT and proxies.
374
+ - **Rate limiting.** lightning-toll doesn't include rate limiting beyond the free tier. Use a proper rate limiter (like `express-rate-limit`) for DDoS protection.
375
+ - **Stats persistence.** Stats are in-memory by default and reset on restart. For production, periodically snapshot `toll.stats.toJSON()` to a database.
376
+
377
+ ## Why Lightning Instead of API Keys?
378
+
379
+ | | API Keys / Stripe | lightning-toll |
380
+ |---|---|---|
381
+ | **Setup time** | Hours–days (Stripe onboarding, billing pages) | Minutes (`npm install` + 5 lines of code) |
382
+ | **User friction** | Sign up, enter credit card, wait for approval | Scan QR code, pay instantly |
383
+ | **Minimum viable payment** | $0.50+ (credit card minimums) | 1 sat (~$0.0005) — true micropayments |
384
+ | **Chargebacks** | Yes (costly) | No — Lightning payments are final |
385
+ | **KYC required** | Yes (for Stripe/PayPal) | No |
386
+ | **Geographic restrictions** | Yes | No — works globally, instantly |
387
+ | **Privacy** | Full identity required | Pseudonymous by default |
388
+ | **Settlement** | Days to weeks | Instant |
389
+
390
+ ## Demo
391
+
392
+ Run the included demo server:
393
+
394
+ ```bash
395
+ cd demo
396
+ npm install
397
+ NWC_URL="nostr+walletconnect://..." node server.js
398
+ ```
399
+
400
+ Open `http://localhost:3402` for an interactive UI with:
401
+ - Multiple toll-gated endpoints at different price points
402
+ - "Try it" buttons showing the 402 response flow
403
+ - Live revenue dashboard
404
+ - Code examples
405
+
406
+ ### Demo Endpoints
407
+
408
+ | Endpoint | Price | Description |
409
+ |----------|-------|-------------|
410
+ | `GET /api/joke` | 5 sats | Random programming joke |
411
+ | `GET /api/time` | 1 sat | Current server time |
412
+ | `POST /api/echo` | 1 sat/word | Echo text with dynamic pricing |
413
+ | `GET /api/fortune` | 10 sats | Bitcoin-themed fortune cookie |
414
+ | `GET /api/free-tier` | 21 sats (3 free/hr) | Free tier demo |
415
+ | `GET /api/stats` | Free | Revenue dashboard |
416
+
417
+ ## License
418
+
419
+ MIT — [Jeletor](https://github.com/jeletor)
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "lightning-toll",
3
+ "version": "0.1.0",
4
+ "description": "Drop-in Express middleware for Lightning-gated API endpoints. L402 protocol, macaroons, auto-pay client — monetize any API with Bitcoin Lightning.",
5
+ "main": "src/index.js",
6
+ "exports": {
7
+ ".": "./src/index.js",
8
+ "./client": "./src/client/index.js"
9
+ },
10
+ "keywords": [
11
+ "lightning",
12
+ "bitcoin",
13
+ "l402",
14
+ "api",
15
+ "paywall",
16
+ "middleware",
17
+ "express",
18
+ "macaroon",
19
+ "micropayments",
20
+ "monetization",
21
+ "toll",
22
+ "402"
23
+ ],
24
+ "author": "Jeletor",
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/jeletor/lightning-toll.git"
29
+ },
30
+ "homepage": "https://github.com/jeletor/lightning-toll#readme",
31
+ "bugs": {
32
+ "url": "https://github.com/jeletor/lightning-toll/issues"
33
+ },
34
+ "dependencies": {
35
+ "lightning-agent": "^0.3.0"
36
+ },
37
+ "peerDependencies": {
38
+ "express": "^4.0.0 || ^5.0.0"
39
+ },
40
+ "peerDependenciesMeta": {
41
+ "express": {
42
+ "optional": true
43
+ }
44
+ },
45
+ "engines": {
46
+ "node": ">=16.0.0"
47
+ },
48
+ "files": [
49
+ "src/",
50
+ "README.md",
51
+ "LICENSE"
52
+ ]
53
+ }
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+
3
+ const { parseAuthorization } = require('../l402');
4
+
5
+ /**
6
+ * Auto-pay fetch wrapper.
7
+ * When a 402 response is received, automatically pays the Lightning invoice
8
+ * and retries the request with the L402 authorization header.
9
+ *
10
+ * @param {string} url - URL to fetch
11
+ * @param {object} [fetchOpts] - Standard fetch options
12
+ * @param {object} payOpts
13
+ * @param {object} payOpts.wallet - lightning-agent wallet instance
14
+ * @param {number} [payOpts.maxSats=100] - Maximum sats to pay per request
15
+ * @param {boolean} [payOpts.autoRetry=true] - Automatically pay and retry on 402
16
+ * @param {object} [payOpts.headers] - Additional headers
17
+ * @returns {Promise<Response>}
18
+ */
19
+ async function autoPay(url, fetchOpts = {}, payOpts = {}) {
20
+ const wallet = payOpts.wallet;
21
+ if (!wallet) throw new Error('lightning-toll/client: wallet is required');
22
+
23
+ const maxSats = payOpts.maxSats || 100;
24
+ const autoRetry = payOpts.autoRetry !== false;
25
+
26
+ // Make the initial request
27
+ const mergedHeaders = { ...payOpts.headers, ...fetchOpts.headers };
28
+ const res = await fetch(url, { ...fetchOpts, headers: mergedHeaders });
29
+
30
+ // If not 402, return as-is
31
+ if (res.status !== 402) return res;
32
+
33
+ // If auto-retry is disabled, return the 402
34
+ if (!autoRetry) return res;
35
+
36
+ // Parse the 402 response
37
+ let body;
38
+ try {
39
+ body = await res.json();
40
+ } catch {
41
+ throw new Error('lightning-toll/client: Could not parse 402 response body');
42
+ }
43
+
44
+ if (!body.invoice) {
45
+ throw new Error('lightning-toll/client: 402 response missing invoice');
46
+ }
47
+ if (!body.macaroon) {
48
+ throw new Error('lightning-toll/client: 402 response missing macaroon');
49
+ }
50
+
51
+ // Check budget
52
+ const amountSats = body.amountSats || 0;
53
+ if (amountSats > maxSats) {
54
+ throw new Error(`lightning-toll/client: Price ${amountSats} sats exceeds budget of ${maxSats} sats`);
55
+ }
56
+
57
+ // Pay the invoice
58
+ const payResult = await wallet.payInvoice(body.invoice);
59
+ if (!payResult || !payResult.preimage) {
60
+ throw new Error('lightning-toll/client: Payment failed — no preimage returned');
61
+ }
62
+
63
+ // Retry with L402 authorization
64
+ const authHeader = `L402 ${body.macaroon}:${payResult.preimage}`;
65
+ const retryHeaders = {
66
+ ...mergedHeaders,
67
+ Authorization: authHeader
68
+ };
69
+
70
+ const retryRes = await fetch(url, { ...fetchOpts, headers: retryHeaders });
71
+ return retryRes;
72
+ }
73
+
74
+ module.exports = { autoPay };