@speeddev/l402-express 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 +21 -0
- package/README.md +236 -0
- package/bin/generate-secret.js +3 -0
- package/constants.js +26 -0
- package/errors.js +7 -0
- package/index.d.ts +80 -0
- package/index.js +150 -0
- package/macaroon.js +50 -0
- package/package.json +63 -0
- package/speed.js +25 -0
- package/validation.js +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tryspeed.com
|
|
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,236 @@
|
|
|
1
|
+
# @speeddev/l402-express
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@speeddev/l402-express)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
[](https://nodejs.org)
|
|
6
|
+
|
|
7
|
+
Express middleware that enforces [L402](https://docs.lightning.engineering/the-lightning-network/l402) payment-required flows using [Speed](https://www.tryspeed.com) Lightning invoices and macaroon credentials.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Contents
|
|
12
|
+
|
|
13
|
+
- [How it works](#how-it-works)
|
|
14
|
+
- [Prerequisites](#prerequisites)
|
|
15
|
+
- [Installation](#installation)
|
|
16
|
+
- [Quick start](#quick-start)
|
|
17
|
+
- [Configuration reference](#configuration-reference)
|
|
18
|
+
- [Generating the macaroon secret](#generating-the-macaroon-secret)
|
|
19
|
+
- [API key security](#api-key-security)
|
|
20
|
+
- [Environment variables](#environment-variables)
|
|
21
|
+
- [Error responses](#error-responses)
|
|
22
|
+
- [TypeScript](#typescript)
|
|
23
|
+
- [License](#license)
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## How it works
|
|
28
|
+
|
|
29
|
+
L402 is an HTTP-native payment protocol built on Lightning Network. The flow has three steps:
|
|
30
|
+
|
|
31
|
+
1. **Challenge** — a client makes a request to a protected endpoint without payment credentials. The middleware responds with `402 Payment Required`, a Lightning invoice (created via the Speed API), and a signed macaroon.
|
|
32
|
+
2. **Payment** — the client pays the Lightning invoice and receives a preimage (proof of payment).
|
|
33
|
+
3. **Access** — the client retries the request with an `Authorization: L402 <macaroon>:<preimage>` header. The middleware verifies the macaroon signature, the caveats (method, path, amount, currency, expiry), and the preimage against the payment hash. On success the request is forwarded to your route handler.
|
|
34
|
+
|
|
35
|
+
Responses to verified requests are cached for the duration of the macaroon's TTL (10 minutes), so replaying a valid credential returns the cached response instantly without hitting your handler again.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Prerequisites
|
|
40
|
+
|
|
41
|
+
- **Node.js 18 or higher**
|
|
42
|
+
- **Express 4 or higher**
|
|
43
|
+
- **A Speed account and API key** — sign up and manage keys at [app.tryspeed.com/dashboard](https://app.tryspeed.com/dashboard)
|
|
44
|
+
|
|
45
|
+
### Getting a Speed API key
|
|
46
|
+
|
|
47
|
+
1. Log in to the [Speed Web Application](https://app.tryspeed.com/dashboard).
|
|
48
|
+
2. Select the mode — **Test** for development, **Live** for production.
|
|
49
|
+
3. Navigate to **Developers → API keys → Standard keys**.
|
|
50
|
+
4. Click **Reveal key** to copy your secret key (prefix `sk_test_…` or `sk_live_…`).
|
|
51
|
+
|
|
52
|
+
Use your **publishable key** or **secret key** as the `speedApiKey` option. The publishable key is sufficient for creating payments. Secret keys (`sk_test_…` / `sk_live_…`) work too but carry broader account privileges — prefer the publishable key when it covers your use case.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Installation
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npm install @speeddev/l402-express
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`express` is a peer dependency and must be installed separately if you have not done so already:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npm install express
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Quick start
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
import express from 'express';
|
|
74
|
+
import l402Middleware from '@speeddev/l402-express';
|
|
75
|
+
|
|
76
|
+
const app = express();
|
|
77
|
+
|
|
78
|
+
app.use(
|
|
79
|
+
l402Middleware({
|
|
80
|
+
speedApiKey: process.env.SPEED_KEY,
|
|
81
|
+
macaroonSecret: process.env.SPEED_MACAROON_SECRET,
|
|
82
|
+
configs: [
|
|
83
|
+
{
|
|
84
|
+
method: 'GET',
|
|
85
|
+
path: '/api/report',
|
|
86
|
+
amount: 100, // $1.00 USD
|
|
87
|
+
currency: 'USD',
|
|
88
|
+
targetCurrency: 'SATS',
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
})
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
app.get('/api/report', (req, res) => {
|
|
95
|
+
res.json({ data: 'paid content here' });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
app.listen(3000);
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Any route not listed in `configs` passes through freely without a payment check.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Configuration reference
|
|
106
|
+
|
|
107
|
+
### Middleware options
|
|
108
|
+
|
|
109
|
+
| Option | Type | Required | Description |
|
|
110
|
+
|---|---|---|---|
|
|
111
|
+
| `speedApiKey` | `string` | Yes | Your Speed secret API key (`sk_test_…` or `sk_live_…`). |
|
|
112
|
+
| `macaroonSecret` | `string` | Yes | 32-byte hex-encoded secret used to sign and verify macaroons. Generate one with `npx l402-generate-secret`. |
|
|
113
|
+
| `caveatTtlMs` | `number` | No | Macaroon TTL in milliseconds. Defaults to 10 minutes when omitted. Must be a positive number if provided. |
|
|
114
|
+
| `configs` | `RouteConfig[]` | Yes | Array of route-level payment rules. |
|
|
115
|
+
|
|
116
|
+
### RouteConfig
|
|
117
|
+
|
|
118
|
+
| Field | Type | Required | Description |
|
|
119
|
+
|---|---|---|---|
|
|
120
|
+
| `method` | `string` | Yes | HTTP method to match, uppercase (e.g. `'GET'`, `'POST'`). |
|
|
121
|
+
| `path` | `string` | Yes | [path-to-regexp](https://github.com/pillarjs/path-to-regexp) pattern (e.g. `'/api/resource/:id'`). |
|
|
122
|
+
| `amount` | `number` | Yes | Payment amount in the smallest unit of `currency` (e.g. cents for `USD`, whole units for `SATS`). |
|
|
123
|
+
| `currency` | `string` | Yes | ISO 4217 currency code for the payment amount (e.g. `'USD'`, `'EUR'`, `'SATS'`). |
|
|
124
|
+
| `targetCurrency` | `string` | No | Cryptocurrency to settle in: `'SATS'`, `'USDT'`, or `'USDC'`. Defaults to `'SATS'` when omitted. |
|
|
125
|
+
|
|
126
|
+
**Example — charge $2.50 USD, settle in SATS:**
|
|
127
|
+
|
|
128
|
+
```js
|
|
129
|
+
{
|
|
130
|
+
method: 'POST',
|
|
131
|
+
path: '/api/generate',
|
|
132
|
+
amount: 250,
|
|
133
|
+
currency: 'USD',
|
|
134
|
+
targetCurrency: 'SATS',
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
**Example — charge 1000 sats directly:**
|
|
139
|
+
|
|
140
|
+
```js
|
|
141
|
+
{
|
|
142
|
+
method: 'GET',
|
|
143
|
+
path: '/api/data',
|
|
144
|
+
amount: 1000,
|
|
145
|
+
currency: 'SATS',
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## Generating the macaroon secret
|
|
152
|
+
|
|
153
|
+
The macaroon secret is a private 32-byte key used to sign credentials. It must be kept secret — anyone who obtains it can forge valid macaroons.
|
|
154
|
+
|
|
155
|
+
Generate one with the CLI tool bundled in this package:
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
npx l402-generate-secret
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
This prints a 64-character hex string. Store it as an environment variable and never commit it to source control.
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
b3f1a2c4e5d6... ← copy this into SPEED_MACAROON_SECRET
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Rotate this secret if you suspect it has been compromised. All previously issued macaroons will immediately become invalid.
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## API key security
|
|
172
|
+
|
|
173
|
+
Speed API keys carry full account privileges. Follow these practices:
|
|
174
|
+
|
|
175
|
+
- **Never** embed secret keys in client-side code, public repositories, or anywhere outside a secure server environment.
|
|
176
|
+
- Store keys in environment variables or a secrets manager (e.g. AWS Secrets Manager, HashiCorp Vault).
|
|
177
|
+
- Use **Test mode keys** (`sk_test_…`) during development and **Live mode keys** (`sk_live_…`) in production only.
|
|
178
|
+
- If a key is compromised, rotate it immediately from **Developers → API keys** in the [Speed dashboard](https://app.tryspeed.com/dashboard). A rotated key is permanently disabled; you cannot rotate the same key again within 24 hours.
|
|
179
|
+
- Consider [restricted API keys](https://app.tryspeed.com/apikeys/restricted-keys) to limit the scope of what each key can do.
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## Environment variables
|
|
184
|
+
|
|
185
|
+
The middleware does not read environment variables directly. Pass your secrets explicitly via the options object:
|
|
186
|
+
|
|
187
|
+
```js
|
|
188
|
+
l402Middleware({
|
|
189
|
+
speedApiKey: process.env.SPEED_KEY,
|
|
190
|
+
macaroonSecret: process.env.SPEED_MACAROON_SECRET,
|
|
191
|
+
configs: [...],
|
|
192
|
+
})
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Recommended `.env` layout (use [dotenv](https://github.com/motdotla/dotenv) or your platform's secret injection):
|
|
196
|
+
|
|
197
|
+
```env
|
|
198
|
+
SPEED_KEY=sk_test_...
|
|
199
|
+
SPEED_MACAROON_SECRET=<64-char hex from npx l402-generate-secret>
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## Error responses
|
|
205
|
+
|
|
206
|
+
| Status | Condition | Body |
|
|
207
|
+
|---|---|---|
|
|
208
|
+
| `402` | No `Authorization` header — payment required. | `{}` with `WWW-Authenticate` header containing the macaroon and Lightning invoice. |
|
|
209
|
+
| `400` | `Authorization` header present but malformed or macaroon verification failed. | `{ "message": "Malformed 'authorization' header" }` |
|
|
210
|
+
| `401` | Preimage does not match the payment hash in the macaroon. | `{ "message": "Invalid payment preimage" }` |
|
|
211
|
+
| `409` | A request with the same macaroon is already being processed. | `{ "message": "Payment is already being processed" }` |
|
|
212
|
+
| `500` | Speed API call failed when creating the invoice. | `{ "message": "Internal server error" }` |
|
|
213
|
+
|
|
214
|
+
### WWW-Authenticate header format
|
|
215
|
+
|
|
216
|
+
```
|
|
217
|
+
L402 macaroon="<base64-encoded macaroon>", invoice="<BOLT11 invoice>"
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
The client pays the `invoice` and uses the returned preimage together with the `macaroon` to form the `Authorization` header:
|
|
221
|
+
|
|
222
|
+
```
|
|
223
|
+
Authorization: L402 <macaroon>:<preimage>
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## TypeScript
|
|
229
|
+
|
|
230
|
+
The package ships with a bundled declaration file. No `@types` package is needed. Named types `RouteConfig` and `L402MiddlewareOptions` are available as named exports.
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## License
|
|
235
|
+
|
|
236
|
+
[MIT](./LICENSE)
|
package/constants.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export const MACAROON_VERSION = 2;
|
|
2
|
+
|
|
3
|
+
export const CAVEAT_KEYS = {
|
|
4
|
+
AMOUNT: 'amount',
|
|
5
|
+
CURRENCY: 'currency',
|
|
6
|
+
METHOD: 'method',
|
|
7
|
+
PATH: 'path',
|
|
8
|
+
PAYMENT_HASH: 'payment_hash',
|
|
9
|
+
EXPIRES_AT: 'expires_at'
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const HEADERS = {
|
|
13
|
+
AUTHORIZATION: 'authorization',
|
|
14
|
+
WWW_AUTHENTICATE: 'www-authenticate',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_CAVEAT_TTL_MS = 1000 * 60 * 10;
|
|
18
|
+
export const DEFAULT_TARGET_CURRENCY = 'SATS';
|
|
19
|
+
export const HASH_ALGORITHM = 'SHA-256';
|
|
20
|
+
export const L402_SCHEME = 'L402';
|
|
21
|
+
export const MACAROON_SECRET_HEX_LENGTH = 64;
|
|
22
|
+
export const MAX_CAVEATS = 20;
|
|
23
|
+
export const SPEED_BASE_URL = 'https://api.tryspeed.com';
|
|
24
|
+
export const SPEED_PAYMENT_METHOD = 'lightning';
|
|
25
|
+
export const VALID_TARGET_CURRENCIES = [DEFAULT_TARGET_CURRENCY, 'USDT', 'USDC'];
|
|
26
|
+
export const FETCH_TIMEOUT_MS = 10_000;
|
package/errors.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const ERROR_MESSAGES = {
|
|
2
|
+
INTERNAL_SERVER_ERROR: "Internal server error",
|
|
3
|
+
PAYMENT_ALREADY_PROCESSING: "Payment is already being processed",
|
|
4
|
+
MALFORMED_AUTH_HEADER: "Malformed 'authorization' header",
|
|
5
|
+
INVALID_PREIMAGE: "Invalid payment preimage",
|
|
6
|
+
TOO_MANY_CAVEATS: "Macaroon contains too many caveats",
|
|
7
|
+
};
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { RequestHandler } from 'express';
|
|
2
|
+
|
|
3
|
+
/** HTTP method string (uppercase). */
|
|
4
|
+
export type HttpMethod =
|
|
5
|
+
| 'GET'
|
|
6
|
+
| 'POST'
|
|
7
|
+
| 'PUT'
|
|
8
|
+
| 'PATCH'
|
|
9
|
+
| 'DELETE'
|
|
10
|
+
| 'HEAD'
|
|
11
|
+
| 'OPTIONS';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Per-route payment configuration.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* { method: 'GET', path: '/api/data', amount: 1000, currency: 'USD' }
|
|
18
|
+
*/
|
|
19
|
+
export interface RouteConfig {
|
|
20
|
+
/** HTTP method to match (case-sensitive, uppercase). */
|
|
21
|
+
method: HttpMethod;
|
|
22
|
+
/**
|
|
23
|
+
* Express-style path pattern passed to `path-to-regexp`.
|
|
24
|
+
* @example '/api/resource/:id'
|
|
25
|
+
*/
|
|
26
|
+
path: string;
|
|
27
|
+
/** Payment amount in the smallest unit of `currency` (e.g. cents for USD, sats for SATS). */
|
|
28
|
+
amount: number;
|
|
29
|
+
/** ISO 4217 currency code or `'SATS'` for satoshis. */
|
|
30
|
+
currency: string;
|
|
31
|
+
/**
|
|
32
|
+
* Target settlement currency for the Speed invoice.
|
|
33
|
+
* Defaults to `'SATS'` when omitted.
|
|
34
|
+
*/
|
|
35
|
+
targetCurrency?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Options passed to the `l402Middleware` factory. */
|
|
39
|
+
export interface L402MiddlewareOptions {
|
|
40
|
+
/** Speed API key used to create Lightning invoices. */
|
|
41
|
+
speedApiKey: string;
|
|
42
|
+
/** Hex-encoded secret used to sign and verify macaroons. */
|
|
43
|
+
macaroonSecret: string;
|
|
44
|
+
/**
|
|
45
|
+
* Macaroon TTL in milliseconds. Defaults to 10 minutes when omitted.
|
|
46
|
+
* Must be a positive number if provided.
|
|
47
|
+
*/
|
|
48
|
+
caveatTtlMs?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Route-level payment rules.
|
|
51
|
+
* Routes not listed here are passed through without a payment check.
|
|
52
|
+
*/
|
|
53
|
+
configs: RouteConfig[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Creates an Express middleware that enforces L402 payment-required flows.
|
|
58
|
+
*
|
|
59
|
+
* - Unlisted routes pass through freely.
|
|
60
|
+
* - Requests without an `Authorization` header receive a `402` challenge
|
|
61
|
+
* containing a Speed Lightning invoice and a signed macaroon.
|
|
62
|
+
* - Requests with a valid `L402 <macaroon>:<preimage>` credential are
|
|
63
|
+
* forwarded to the next handler; responses are cached for the macaroon TTL.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```ts
|
|
67
|
+
* import l402Middleware from '@speeddev/l402-express';
|
|
68
|
+
*
|
|
69
|
+
* app.use(l402Middleware({
|
|
70
|
+
* speedApiKey: process.env.SPEED_KEY!,
|
|
71
|
+
* macaroonSecret: process.env.SPEED_MACAROON_SECRET!,
|
|
72
|
+
* configs: [
|
|
73
|
+
* { method: 'GET', path: '/api/data', amount: 1000, currency: 'USD' },
|
|
74
|
+
* ],
|
|
75
|
+
* }));
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
declare function l402Middleware(options: L402MiddlewareOptions): RequestHandler;
|
|
79
|
+
|
|
80
|
+
export default l402Middleware;
|
package/index.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { importMacaroon } from 'macaroon';
|
|
2
|
+
import { match } from 'path-to-regexp';
|
|
3
|
+
import { createMacaroon, verifyMacaroon } from './macaroon.js';
|
|
4
|
+
import { createSpeedInvoice } from './speed.js';
|
|
5
|
+
import NodeCache from 'node-cache';
|
|
6
|
+
import { webcrypto } from 'node:crypto';
|
|
7
|
+
import { CAVEAT_KEYS, HEADERS, L402_SCHEME, HASH_ALGORITHM, MAX_CAVEATS } from './constants.js';
|
|
8
|
+
import { ERROR_MESSAGES } from './errors.js';
|
|
9
|
+
import { validateOptions } from './validation.js';
|
|
10
|
+
|
|
11
|
+
const decoder = new TextDecoder();
|
|
12
|
+
|
|
13
|
+
const l402Middleware = ({ speedApiKey, macaroonSecret, caveatTtlMs, configs }) => {
|
|
14
|
+
validateOptions({ speedApiKey, macaroonSecret, caveatTtlMs, configs });
|
|
15
|
+
|
|
16
|
+
const cache = new NodeCache();
|
|
17
|
+
const lock = new Map();
|
|
18
|
+
|
|
19
|
+
const endpointMatchers = configs.map(config => ({
|
|
20
|
+
method: config.method,
|
|
21
|
+
matchPath: match(config.path),
|
|
22
|
+
config,
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
return async (request, response, next) => {
|
|
26
|
+
|
|
27
|
+
const endpointConfig = getEndpointConfig(request, endpointMatchers);
|
|
28
|
+
if (isFreeEndpoint(endpointConfig)) {
|
|
29
|
+
next();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (isPaymentMissing(request)) {
|
|
33
|
+
await sendPaymentChallenge(response, endpointConfig, speedApiKey, macaroonSecret, caveatTtlMs);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const authorizationHeader = request.headers[HEADERS.AUTHORIZATION].trim();
|
|
38
|
+
if (authorizationHeader.length > 2048) {
|
|
39
|
+
return response.status(400).json({ message: ERROR_MESSAGES.MALFORMED_AUTH_HEADER });
|
|
40
|
+
}
|
|
41
|
+
const l402Match = authorizationHeader.match(
|
|
42
|
+
/^L402\s+([^:\s]+):([^:\s]+)$/i
|
|
43
|
+
);
|
|
44
|
+
if (!l402Match) {
|
|
45
|
+
response.status(400).json({ message: ERROR_MESSAGES.MALFORMED_AUTH_HEADER });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const [, encodedMacaroon, receivedPreimage] = l402Match;
|
|
50
|
+
let macaroonIdentifier;
|
|
51
|
+
try {
|
|
52
|
+
const macaroonObject = JSON.parse(
|
|
53
|
+
Buffer.from(encodedMacaroon, 'base64').toString('utf8')
|
|
54
|
+
);
|
|
55
|
+
const macaroon = importMacaroon(macaroonObject);
|
|
56
|
+
if (macaroon.caveats.length > MAX_CAVEATS) {
|
|
57
|
+
response.status(400).json({ message: ERROR_MESSAGES.TOO_MANY_CAVEATS });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
macaroonIdentifier = Buffer.from(macaroon.identifier).toString("utf-8");
|
|
61
|
+
|
|
62
|
+
const cachedResponse = cache.get(macaroonIdentifier);
|
|
63
|
+
if (cachedResponse) {
|
|
64
|
+
response.status(cachedResponse.status).set('Content-Type', cachedResponse.contentType).send(cachedResponse.body);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
verifyMacaroon(macaroon, endpointConfig, macaroonSecret);
|
|
69
|
+
const preimageValid = await isPreimageValid(macaroon, receivedPreimage);
|
|
70
|
+
|
|
71
|
+
if (lock.get(macaroonIdentifier)) {
|
|
72
|
+
response.status(409).json({ message: ERROR_MESSAGES.PAYMENT_ALREADY_PROCESSING });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (preimageValid) {
|
|
77
|
+
lock.set(macaroonIdentifier, true);
|
|
78
|
+
const expiresAt = Number(extractCaveatFromMacaroon(macaroon, CAVEAT_KEYS.EXPIRES_AT));
|
|
79
|
+
const originalSend = response.send.bind(response);
|
|
80
|
+
response.send = (body) => {
|
|
81
|
+
const ttl = Math.floor((expiresAt - Date.now()) / 1000);
|
|
82
|
+
if (ttl > 0) {
|
|
83
|
+
cache.set(macaroonIdentifier, { body, contentType: response.getHeader('Content-Type'), status: response.statusCode }, ttl);
|
|
84
|
+
}
|
|
85
|
+
return originalSend(body);
|
|
86
|
+
};
|
|
87
|
+
response.on('finish', () => {
|
|
88
|
+
lock.delete(macaroonIdentifier);
|
|
89
|
+
});
|
|
90
|
+
next();
|
|
91
|
+
} else {
|
|
92
|
+
response.status(401).json({ message: ERROR_MESSAGES.INVALID_PREIMAGE });
|
|
93
|
+
}
|
|
94
|
+
} catch (error) {
|
|
95
|
+
console.error(error);
|
|
96
|
+
response.status(400).json({ message: ERROR_MESSAGES.MALFORMED_AUTH_HEADER });
|
|
97
|
+
if (macaroonIdentifier) lock.delete(macaroonIdentifier);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
async function sendPaymentChallenge(response, endpointConfig, speedApiKey, macaroonSecret, caveatTtlMs) {
|
|
103
|
+
try {
|
|
104
|
+
const invoiceResponse = await createSpeedInvoice(endpointConfig.currency, endpointConfig.amount, endpointConfig.targetCurrency, speedApiKey);
|
|
105
|
+
const lightningInvoice = invoiceResponse.payment_method_options.lightning.payment_request;
|
|
106
|
+
const macaroon = createMacaroon(endpointConfig, lightningInvoice, macaroonSecret, caveatTtlMs);
|
|
107
|
+
response.status(402).set(HEADERS.WWW_AUTHENTICATE, `${L402_SCHEME} macaroon="${macaroon}", invoice="${lightningInvoice}"`).json({});
|
|
108
|
+
} catch (err) {
|
|
109
|
+
console.error(err);
|
|
110
|
+
response.status(500).json({ message: ERROR_MESSAGES.INTERNAL_SERVER_ERROR });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function getEndpointConfig(request, endpointMatchers) {
|
|
115
|
+
const entry = endpointMatchers.find(
|
|
116
|
+
e => e.method === request.method && e.matchPath(request.path)
|
|
117
|
+
);
|
|
118
|
+
return entry?.config;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isFreeEndpoint(endpointConfig) {
|
|
122
|
+
return endpointConfig == undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isPaymentMissing(request) {
|
|
126
|
+
const authorizationHeader = request.headers[HEADERS.AUTHORIZATION];
|
|
127
|
+
return !authorizationHeader?.trim();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function extractCaveatFromMacaroon(macaroon, caveatKey) {
|
|
131
|
+
const prefix = `${caveatKey} = `;
|
|
132
|
+
for (const c of macaroon.caveats) {
|
|
133
|
+
const decoded = decoder.decode(c.identifier);
|
|
134
|
+
if (decoded.startsWith(prefix)) return decoded.slice(prefix.length);
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function computePreimageHash(preimage) {
|
|
140
|
+
const hashBuffer = await webcrypto.subtle.digest(HASH_ALGORITHM, Buffer.from(preimage, 'hex'));
|
|
141
|
+
return Buffer.from(hashBuffer).toString("hex");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function isPreimageValid(macaroon, receivedPreimage) {
|
|
145
|
+
const paymentHash = extractCaveatFromMacaroon(macaroon, CAVEAT_KEYS.PAYMENT_HASH);
|
|
146
|
+
const receivedPaymentHash = await computePreimageHash(receivedPreimage);
|
|
147
|
+
return paymentHash === receivedPaymentHash;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export default l402Middleware;
|
package/macaroon.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { newMacaroon } from 'macaroon';
|
|
2
|
+
import { decode } from 'light-bolt11-decoder';
|
|
3
|
+
import { MACAROON_VERSION, CAVEAT_KEYS, DEFAULT_CAVEAT_TTL_MS } from './constants.js';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
export function createMacaroon(routeConfig, bolt11Invoice, macaroonSecret, caveatTtlMs) {
|
|
7
|
+
const paymentHashSection = decode(bolt11Invoice).sections.find(s => s.name === CAVEAT_KEYS.PAYMENT_HASH);
|
|
8
|
+
if (!paymentHashSection) {
|
|
9
|
+
throw new Error('Invalid BOLT11 invoice: missing payment_hash');
|
|
10
|
+
}
|
|
11
|
+
const paymentHash = paymentHashSection.value;
|
|
12
|
+
const macaroon = newMacaroon({
|
|
13
|
+
version: MACAROON_VERSION,
|
|
14
|
+
rootKey: Buffer.from(macaroonSecret, 'hex'),
|
|
15
|
+
identifier: randomUUID(),
|
|
16
|
+
});
|
|
17
|
+
const effectiveTtlMs = caveatTtlMs ?? DEFAULT_CAVEAT_TTL_MS;
|
|
18
|
+
|
|
19
|
+
macaroon.addFirstPartyCaveat(`${CAVEAT_KEYS.PAYMENT_HASH} = ${paymentHash}`);
|
|
20
|
+
macaroon.addFirstPartyCaveat(`${CAVEAT_KEYS.EXPIRES_AT} = ${Date.now() + effectiveTtlMs}`);
|
|
21
|
+
macaroon.addFirstPartyCaveat(`${CAVEAT_KEYS.METHOD} = ${routeConfig.method}`);
|
|
22
|
+
macaroon.addFirstPartyCaveat(`${CAVEAT_KEYS.PATH} = ${routeConfig.path}`);
|
|
23
|
+
macaroon.addFirstPartyCaveat(`${CAVEAT_KEYS.AMOUNT} = ${routeConfig.amount}`);
|
|
24
|
+
macaroon.addFirstPartyCaveat(`${CAVEAT_KEYS.CURRENCY} = ${routeConfig.currency}`);
|
|
25
|
+
const serializedMacaroon = macaroon.exportJSON();
|
|
26
|
+
return Buffer.from(JSON.stringify(serializedMacaroon)).toString('base64');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function verifyMacaroon(macaroon, routeConfig, macaroonSecret) {
|
|
30
|
+
macaroon.verify(
|
|
31
|
+
Buffer.from(macaroonSecret, 'hex'),
|
|
32
|
+
(caveat) => {
|
|
33
|
+
if (caveat === `${CAVEAT_KEYS.METHOD} = ${routeConfig.method}`) return;
|
|
34
|
+
if (caveat === `${CAVEAT_KEYS.PATH} = ${routeConfig.path}`) return;
|
|
35
|
+
if (caveat === `${CAVEAT_KEYS.AMOUNT} = ${routeConfig.amount}`) return;
|
|
36
|
+
if (caveat === `${CAVEAT_KEYS.CURRENCY} = ${routeConfig.currency}`) return;
|
|
37
|
+
|
|
38
|
+
const [caveatKey, caveatValue] = caveat.split(' = ');
|
|
39
|
+
if (caveatKey === CAVEAT_KEYS.PAYMENT_HASH) return;
|
|
40
|
+
if (caveatKey === CAVEAT_KEYS.EXPIRES_AT) {
|
|
41
|
+
const expiresAt = Number(caveatValue);
|
|
42
|
+
if (isNaN(expiresAt) || Date.now() > expiresAt) return `caveat not satisfied: ${caveat}`;
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return `caveat not satisfied: ${caveat}`;
|
|
47
|
+
},
|
|
48
|
+
[]
|
|
49
|
+
);
|
|
50
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@speeddev/l402-express",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"main": "index.js",
|
|
5
|
+
"types": "index.d.ts",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"import": "./index.js",
|
|
9
|
+
"types": "./index.d.ts"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"l402-generate-secret": "bin/generate-secret.js"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test specs/test.js"
|
|
17
|
+
},
|
|
18
|
+
"author": "Speed <support@tryspeed.com>",
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"description": "Express middleware for L402 Lightning Network payments — issues Speed payment challenges, verifies macaroon credentials, and gates API access to paid requests only",
|
|
21
|
+
"keywords": [
|
|
22
|
+
"l402",
|
|
23
|
+
"lightning",
|
|
24
|
+
"bitcoin",
|
|
25
|
+
"payments",
|
|
26
|
+
"express",
|
|
27
|
+
"middleware",
|
|
28
|
+
"macaroon",
|
|
29
|
+
"speed"
|
|
30
|
+
],
|
|
31
|
+
"homepage": "https://github.com/TrySpeed/speed-agentic-payment-sdk/tree/main/server/javascript/express#readme",
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/TrySpeed/speed-agentic-payment-sdk/issues"
|
|
34
|
+
},
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/TrySpeed/speed-agentic-payment-sdk.git",
|
|
38
|
+
"directory": "server/javascript/express"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=18"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"index.js",
|
|
45
|
+
"index.d.ts",
|
|
46
|
+
"constants.js",
|
|
47
|
+
"errors.js",
|
|
48
|
+
"macaroon.js",
|
|
49
|
+
"speed.js",
|
|
50
|
+
"validation.js",
|
|
51
|
+
"bin/"
|
|
52
|
+
],
|
|
53
|
+
"type": "module",
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"light-bolt11-decoder": "^3.2.0",
|
|
56
|
+
"macaroon": "^3.0.4",
|
|
57
|
+
"node-cache": "^5.1.2",
|
|
58
|
+
"path-to-regexp": "^8.4.2"
|
|
59
|
+
},
|
|
60
|
+
"peerDependencies": {
|
|
61
|
+
"express": ">=4.19.2"
|
|
62
|
+
}
|
|
63
|
+
}
|
package/speed.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { SPEED_BASE_URL, DEFAULT_TARGET_CURRENCY, SPEED_PAYMENT_METHOD, FETCH_TIMEOUT_MS } from './constants.js';
|
|
2
|
+
|
|
3
|
+
export async function createSpeedInvoice(currency, amount, targetCurrency, apiKey) {
|
|
4
|
+
const invoicePayload = {
|
|
5
|
+
currency,
|
|
6
|
+
amount,
|
|
7
|
+
target_currency: targetCurrency ?? DEFAULT_TARGET_CURRENCY,
|
|
8
|
+
payment_methods: [SPEED_PAYMENT_METHOD]
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const apiResponse = await fetch(`${SPEED_BASE_URL}/payments`, {
|
|
12
|
+
headers: {
|
|
13
|
+
"Authorization": `Basic ${Buffer.from(`${apiKey}:`).toString('base64')}`,
|
|
14
|
+
"Content-Type": "application/json",
|
|
15
|
+
},
|
|
16
|
+
method: "POST",
|
|
17
|
+
body: JSON.stringify(invoicePayload),
|
|
18
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
19
|
+
});
|
|
20
|
+
if (!apiResponse.ok) {
|
|
21
|
+
const errorBody = await apiResponse.text();
|
|
22
|
+
throw new Error(`Speed API error (${apiResponse.status}): ${errorBody}`);
|
|
23
|
+
}
|
|
24
|
+
return apiResponse.json();
|
|
25
|
+
}
|
package/validation.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { MACAROON_SECRET_HEX_LENGTH } from './constants.js';
|
|
2
|
+
|
|
3
|
+
const HEX_RE = new RegExp(`^[0-9a-fA-F]{${MACAROON_SECRET_HEX_LENGTH}}$`);
|
|
4
|
+
|
|
5
|
+
export function validateOptions({ speedApiKey, macaroonSecret, caveatTtlMs, configs }) {
|
|
6
|
+
if (!speedApiKey || typeof speedApiKey !== 'string') {
|
|
7
|
+
throw new Error('l402: speedApiKey must be a non-empty string');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (!macaroonSecret || typeof macaroonSecret !== 'string') {
|
|
11
|
+
throw new Error('l402: macaroonSecret must be a non-empty string');
|
|
12
|
+
}
|
|
13
|
+
if (!HEX_RE.test(macaroonSecret)) {
|
|
14
|
+
throw new Error(`l402: macaroonSecret must be a ${MACAROON_SECRET_HEX_LENGTH}-character hex string (32 bytes). Generate one with: npx l402-generate-secret`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (!Array.isArray(configs) || configs.length === 0) {
|
|
18
|
+
throw new Error('l402: configs must be a non-empty array');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (caveatTtlMs !== undefined && (typeof caveatTtlMs !== 'number' || caveatTtlMs <= 0)) {
|
|
22
|
+
throw new Error('l402: caveatTtlMs must be a positive number');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
for (let i = 0; i < configs.length; i++) {
|
|
27
|
+
const c = configs[i];
|
|
28
|
+
const label = `configs[${i}]`;
|
|
29
|
+
|
|
30
|
+
if (!c.method || typeof c.method !== 'string') {
|
|
31
|
+
throw new Error(`l402: ${label}.method must be a non-empty string (e.g. 'GET')`);
|
|
32
|
+
}
|
|
33
|
+
c.method = c.method.toUpperCase();
|
|
34
|
+
if (!c.path || typeof c.path !== 'string') {
|
|
35
|
+
throw new Error(`l402: ${label}.path must be a non-empty string`);
|
|
36
|
+
}
|
|
37
|
+
if (typeof c.amount !== 'number' || !isFinite(c.amount) || c.amount <= 0) {
|
|
38
|
+
throw new Error(`l402: ${label}.amount must be a positive finite number`);
|
|
39
|
+
}
|
|
40
|
+
if (!c.currency || typeof c.currency !== 'string') {
|
|
41
|
+
throw new Error(`l402: ${label}.currency must be a non-empty string (e.g. 'USD', 'SATS')`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const key = `${configs[i].method.toUpperCase()}:${configs[i].path}`;
|
|
45
|
+
if (seen.has(key)) {
|
|
46
|
+
throw new Error(`l402: duplicate route label — ${key} is already defined`);
|
|
47
|
+
}
|
|
48
|
+
seen.add(key);
|
|
49
|
+
}
|
|
50
|
+
}
|