@furlpay/gateway 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 +246 -0
- package/SECURITY.md +65 -0
- package/dist/adapters/express.d.ts +43 -0
- package/dist/adapters/express.js +81 -0
- package/dist/adapters/fetch.d.ts +27 -0
- package/dist/adapters/fetch.js +63 -0
- package/dist/adapters/proxy.d.ts +26 -0
- package/dist/adapters/proxy.js +119 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +141 -0
- package/dist/facilitator.d.ts +55 -0
- package/dist/facilitator.js +78 -0
- package/dist/gateway.d.ts +122 -0
- package/dist/gateway.js +369 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/protocol.d.ts +173 -0
- package/dist/protocol.js +186 -0
- package/dist/store.d.ts +60 -0
- package/dist/store.js +134 -0
- package/package.json +51 -0
- package/src/adapters/express.ts +122 -0
- package/src/adapters/fetch.ts +88 -0
- package/src/adapters/proxy.ts +157 -0
- package/src/cli.ts +164 -0
- package/src/facilitator.ts +133 -0
- package/src/gateway.ts +514 -0
- package/src/index.ts +76 -0
- package/src/protocol.ts +371 -0
- package/src/store.ts +157 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FurlPay
|
|
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,246 @@
|
|
|
1
|
+
# @furlpay/gateway
|
|
2
|
+
|
|
3
|
+
**Paywall any API, MCP tool, dataset, or model endpoint with stablecoin pay-per-call.**
|
|
4
|
+
|
|
5
|
+
An x402 monetization gateway you host yourself. Point it at an endpoint, set a
|
|
6
|
+
price, and AI agents pay per request in USDC — no account, no API key, no
|
|
7
|
+
subscription, no invoice.
|
|
8
|
+
|
|
9
|
+
**Speaks x402 v1 and v2**, negotiated per request. Framework-agnostic. Zero
|
|
10
|
+
runtime dependencies. MIT.
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm install @furlpay/gateway
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Why this exists
|
|
19
|
+
|
|
20
|
+
Cloudflare and AWS both shipped x402 monetization at the edge in 2026, and both
|
|
21
|
+
route settlement through Coinbase's facilitator. That's fine if you're happy
|
|
22
|
+
being a tenant. This package is for everyone who wants the paywall to be *theirs*:
|
|
23
|
+
self-hosted, portable across clouds, with a facilitator you choose.
|
|
24
|
+
|
|
25
|
+
## v1 and v2, at the same time
|
|
26
|
+
|
|
27
|
+
x402 v2 (Dec 2025) is not a header rename. The shapes changed:
|
|
28
|
+
|
|
29
|
+
| | v1 | v2 |
|
|
30
|
+
| --- | --- | --- |
|
|
31
|
+
| request header | `X-PAYMENT` | `PAYMENT-SIGNATURE` |
|
|
32
|
+
| response header | `X-PAYMENT-RESPONSE` | `PAYMENT-RESPONSE` |
|
|
33
|
+
| 402 payload | in the **body** | in the **`PAYMENT-REQUIRED` header** |
|
|
34
|
+
| price field | `maxAmountRequired` | `amount` |
|
|
35
|
+
| resource metadata | per-requirement | hoisted to `ResourceInfo` |
|
|
36
|
+
| client echo | `extra` | full `accepted` requirements |
|
|
37
|
+
|
|
38
|
+
A 402 from this gateway **advertises both at once** — v2 in the `PAYMENT-REQUIRED`
|
|
39
|
+
header, v1 in the body. A v2 agent reads the header, a v1 agent reads the body,
|
|
40
|
+
and neither needs to know the other exists. (v2 moving payment data out of the
|
|
41
|
+
body is exactly what makes this possible.) The gateway then answers in whatever
|
|
42
|
+
dialect the client spoke, and calls the facilitator in that dialect too.
|
|
43
|
+
|
|
44
|
+
You don't configure any of this. It just works for both.
|
|
45
|
+
|
|
46
|
+
## Quickstart
|
|
47
|
+
|
|
48
|
+
### 1. Any fetch-based framework (Next.js, Hono, Workers, Bun, Deno)
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
// app/api/weather/route.ts
|
|
52
|
+
import { paywall } from "@furlpay/gateway";
|
|
53
|
+
|
|
54
|
+
const pay = paywall({
|
|
55
|
+
price: 0.01, // USD per call
|
|
56
|
+
payTo: process.env.MERCHANT_WALLET!, // your wallet
|
|
57
|
+
quoteSecret: process.env.QUOTE_SECRET!, // see "Configuration"
|
|
58
|
+
network: "base",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export const GET = pay(async (req, ctx, receipt) => {
|
|
62
|
+
// Only runs after payment has settled on-chain.
|
|
63
|
+
return Response.json({ tempC: 17, paidBy: receipt.payer });
|
|
64
|
+
});
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### 2. Express
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import express from "express";
|
|
71
|
+
import { expressPaywall, captureRawBody } from "@furlpay/gateway/express";
|
|
72
|
+
|
|
73
|
+
const app = express();
|
|
74
|
+
app.use(express.json({ verify: captureRawBody })); // needed for body binding
|
|
75
|
+
|
|
76
|
+
app.get("/api/weather", expressPaywall({ price: 0.01, payTo, quoteSecret }), (req, res) => {
|
|
77
|
+
res.json({ tempC: 17, paidBy: req.payment.payer });
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### 3. Reverse proxy — monetize something you can't modify
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
npx @furlpay/gateway secret # → generates a quote secret
|
|
85
|
+
export QUOTE_SECRET=<that value>
|
|
86
|
+
|
|
87
|
+
npx @furlpay/gateway expose http://localhost:8000 \
|
|
88
|
+
--price 0.01 \
|
|
89
|
+
--pay-to 0xYourWallet \
|
|
90
|
+
--network base \
|
|
91
|
+
--free /health,/openapi.json
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Your origin stays untouched and never learns it's being monetized. **The origin
|
|
95
|
+
must not be publicly reachable**, or agents will simply route around the paywall.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## How a payment actually works
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
agent gateway facilitator chain
|
|
103
|
+
│ GET /weather │ │ │
|
|
104
|
+
├──────────────────────────────►│ │ │
|
|
105
|
+
│ 402 + PaymentRequirements │ │ │
|
|
106
|
+
│◄──────────────────────────────┤ │ │
|
|
107
|
+
│ │ │ │
|
|
108
|
+
│ signs EIP-3009 authorization │ │ │
|
|
109
|
+
│ (OFF-CHAIN — no gas, no tx) │ │ │
|
|
110
|
+
│ │ │ │
|
|
111
|
+
│ GET /weather │ │ │
|
|
112
|
+
│ X-PAYMENT: base64(auth) │ │ │
|
|
113
|
+
├──────────────────────────────►│ verify + settle │ │
|
|
114
|
+
│ ├─────────────────────────────►│ submits, │
|
|
115
|
+
│ │ │ pays gas │
|
|
116
|
+
│ │ ├────────────►│
|
|
117
|
+
│ │ settled @ N confirmations │ │
|
|
118
|
+
│ │◄─────────────────────────────┤ │
|
|
119
|
+
│ 200 + X-PAYMENT-RESPONSE │ │ │
|
|
120
|
+
│◄──────────────────────────────┤ │ │
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**The agent never submits a transaction and never holds gas.** It signs an
|
|
124
|
+
authorization; the facilitator submits it and pays the gas. If you read a guide
|
|
125
|
+
that tells you to "verify the transaction hash the client sends you via RPC" —
|
|
126
|
+
that guide is not describing x402. It's describing a protocol that forces every
|
|
127
|
+
agent to hold native gas on every chain it might want to buy from, which is
|
|
128
|
+
exactly the problem x402 exists to remove.
|
|
129
|
+
|
|
130
|
+
## Configuration
|
|
131
|
+
|
|
132
|
+
| Option | Default | Notes |
|
|
133
|
+
| --- | --- | --- |
|
|
134
|
+
| `price` | — | USD per call. A function `(ctx) => number` prices per-route or per-caller. |
|
|
135
|
+
| `payTo` | — | Wallet that receives payment. |
|
|
136
|
+
| `quoteSecret` | — | HMAC secret binding quotes to resources. **No default, by design.** |
|
|
137
|
+
| `network` | `base` | `arbitrum`, `base`, `polygon`, `solana`, + testnets. |
|
|
138
|
+
| `asset` | canonical USDC | Override for a non-USDC token. |
|
|
139
|
+
| `facilitator` | FurlPay | URL or a `FacilitatorClient`. Any x402 facilitator works. |
|
|
140
|
+
| `facilitatorVersion` | mirrors client | Pin to `1` for a v1-only facilitator; v2 payments get downconverted. |
|
|
141
|
+
| `claimStore` | in-memory | **Must be shared in multi-instance deploys — see below.** |
|
|
142
|
+
| `serviceName` / `tags` / `iconUrl` | — | v2 discovery metadata. See below. |
|
|
143
|
+
| `bindBody` | `true` | Bind the quote to a hash of the request body. |
|
|
144
|
+
| `settle` | `true` | `false` verifies but does not collect. Rarely what you want. |
|
|
145
|
+
| `onSettled` | — | Fires on confirmed settlement — ledger, webhooks, metering. |
|
|
146
|
+
|
|
147
|
+
### Discovery is in the protocol — don't build an index
|
|
148
|
+
|
|
149
|
+
v2's `ResourceInfo` carries `description`, `serviceName`, `tags` and `iconUrl`,
|
|
150
|
+
and facilitators **crawl those fields**. Filling them in is how your resource
|
|
151
|
+
becomes discoverable to agents. You do not need a proprietary directory, and
|
|
152
|
+
building one would mean maintaining a walled garden that nobody's crawler visits.
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
paywall({
|
|
156
|
+
price: 0.01,
|
|
157
|
+
payTo,
|
|
158
|
+
quoteSecret,
|
|
159
|
+
serviceName: "Acme Weather",
|
|
160
|
+
tags: ["weather", "forecast"],
|
|
161
|
+
iconUrl: "https://acme.dev/icon.png",
|
|
162
|
+
});
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`quoteSecret` has no default because a default would be a *published* default,
|
|
166
|
+
and a published default is a forgeable quote for every deployment that didn't
|
|
167
|
+
override it. Generate one:
|
|
168
|
+
|
|
169
|
+
```sh
|
|
170
|
+
npx @furlpay/gateway secret
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Running more than one instance
|
|
174
|
+
|
|
175
|
+
Replay protection is a single-use claim on the EIP-3009 nonce and the quote ID.
|
|
176
|
+
That claim must be **atomic and shared**, or it isn't protection.
|
|
177
|
+
|
|
178
|
+
The classic bug: a `Set` in module scope looks like replay protection and passes
|
|
179
|
+
every local test, because in dev there's one process. In production there are N
|
|
180
|
+
instances, each with its own empty `Set`, so the same `X-PAYMENT` replayed N
|
|
181
|
+
times clears N different Sets and delivers the resource N times — **one payment,
|
|
182
|
+
N deliveries.**
|
|
183
|
+
|
|
184
|
+
The default `MemoryClaimStore` is correct on exactly one instance and reports
|
|
185
|
+
`isDurable === false`. On more than one, use the built-in Upstash store — it's a
|
|
186
|
+
single `SET NX EX` over the REST API, so it adds no dependency:
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
import { paywall, upstashClaimStore } from "@furlpay/gateway";
|
|
190
|
+
|
|
191
|
+
const pay = paywall({
|
|
192
|
+
price: 0.01,
|
|
193
|
+
payTo,
|
|
194
|
+
quoteSecret,
|
|
195
|
+
claimStore: upstashClaimStore(), // UPSTASH_REDIS_REST_URL + _TOKEN
|
|
196
|
+
});
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
It throws rather than start without credentials, because silently degrading to
|
|
200
|
+
in-memory would *look* durable and not be — the worst possible failure mode for
|
|
201
|
+
replay protection. Any other backend works via `claimStoreFrom(setNx)`.
|
|
202
|
+
|
|
203
|
+
Assert it at boot so you find out at deploy time, not during an incident:
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
if (process.env.NODE_ENV === "production" && !pay.gateway.replayProtectionDurable) {
|
|
207
|
+
throw new Error("multi-instance deploy with in-memory replay protection");
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## What this defends against
|
|
212
|
+
|
|
213
|
+
Every one of these is covered by a test in `test/gateway.test.js`.
|
|
214
|
+
|
|
215
|
+
| Attack | Defence |
|
|
216
|
+
| --- | --- |
|
|
217
|
+
| **Cross-resource substitution** — pay $0.001 for `/weather`, present it at `/gpt-5-inference` | Quotes carry an HMAC over `(method, resource, amount, quoteId, expiry[, bodyHash])`. A payment minted for one resource fails the binding check at any other — *even at the same price*. |
|
|
218
|
+
| **Body substitution** — pay for `{"model":"small"}`, retry with `{"model":"large"}` | The quote is bound to a hash of the exact body it was issued for. |
|
|
219
|
+
| **Replay / duplicate settlement** — send one `X-PAYMENT` N times | The nonce and quote ID are burned via an atomic single-use claim, before settlement. Concurrent replays of one payment yield exactly one grant. |
|
|
220
|
+
| **Revert-grant** — resource delivered on a settlement that later reorgs | Confirmation depth scales with value: 3 under $1, 6 under $10, 12 above. Shallower than required → no delivery. |
|
|
221
|
+
| **Cache leakage** — a CDN caches the paid 200 and serves it free | `Cache-Control: no-store` + `Vary: X-PAYMENT` on paid responses *and* on 402 quotes. In proxy mode the origin's own cache headers are overridden. |
|
|
222
|
+
| **Underpayment** — pay `9000` against a `10000` quote | Amounts compare as integers. (`"9000" > "10000"` lexicographically — a string compare here is a real vulnerability.) |
|
|
223
|
+
| **Quote forgery / tampering** | Constant-time HMAC compare. Amount, recipient, network, and expiry are all re-derived server-side; the client's echo is a lookup key, never an assertion. |
|
|
224
|
+
| **Cross-dialect double-spend** — pay a quote as v1, re-present the same quote as v2 with a fresh nonce | The quoteId claim is dialect-blind. One quote buys exactly one delivery, in any version. |
|
|
225
|
+
| **Facilitator outage** | Fails closed. A facilitator you can't reach costs you a sale, not the resource. |
|
|
226
|
+
| **Settlement error** | The nonce stays burned — a failed settle may still land on-chain, so freeing it would reopen replay against a payment that does confirm. Honest retries sign a fresh nonce, so this never blocks a real payer. |
|
|
227
|
+
| **`upto` over-reporting** | v2's `upto` scheme can settle *less* than authorized. The receipt reports what was actually taken, not what was authorized. |
|
|
228
|
+
|
|
229
|
+
For facilitator-side hardening (allowance overdraft, denial-of-settlement,
|
|
230
|
+
hidden-compute pricing), see [`@furlpay/x402-guard`](../x402-guard).
|
|
231
|
+
|
|
232
|
+
## Choosing a facilitator
|
|
233
|
+
|
|
234
|
+
The facilitator is the only party that touches a chain. The gateway never needs a
|
|
235
|
+
private key, an RPC endpoint, or a gas balance — which is what makes it safe to
|
|
236
|
+
self-host.
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
paywall({ facilitator: "https://furlpay.com/api/x402/facilitator" }) // default
|
|
240
|
+
paywall({ facilitator: "https://x402.org/facilitator" }) // or anyone else
|
|
241
|
+
paywall({ facilitator: myCustomClient }) // or your own
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
## License
|
|
245
|
+
|
|
246
|
+
MIT
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Reporting a vulnerability
|
|
4
|
+
|
|
5
|
+
Email **security@furlpay.com**. Please do not open a public issue for a
|
|
6
|
+
vulnerability.
|
|
7
|
+
|
|
8
|
+
Include what you can: affected version, a description, and ideally a
|
|
9
|
+
reproduction. We aim to acknowledge within 72 hours.
|
|
10
|
+
|
|
11
|
+
## What this package is, and is not
|
|
12
|
+
|
|
13
|
+
`@furlpay/gateway` gates a resource behind an x402 payment. It **never holds a
|
|
14
|
+
private key, an RPC endpoint, or a gas balance** — a facilitator does the
|
|
15
|
+
on-chain work. That boundary is deliberate: it is what makes the gateway safe to
|
|
16
|
+
self-host, and it means a compromise of the gateway cannot move funds.
|
|
17
|
+
|
|
18
|
+
**This package has not been independently audited.** The defences below are
|
|
19
|
+
tested (`npm test`), not certified. If you are gating something valuable, read
|
|
20
|
+
the code — that is why `src/` ships in the tarball.
|
|
21
|
+
|
|
22
|
+
## Defences, and their limits
|
|
23
|
+
|
|
24
|
+
| Property | Enforced by | Limit |
|
|
25
|
+
| --- | --- | --- |
|
|
26
|
+
| Quote is bound to one resource, method, price | HMAC over `(method, resource, amount, quoteId, expiry[, bodyHash])`, constant-time compare | Only as strong as `quoteSecret`. There is **no default secret** — a published default would be a forgeable quote for every deployment that did not override it. |
|
|
27
|
+
| Quote is bound to one request body | `bodyHash` in the MAC (`POST`/`PUT`/`PATCH`) | Off if you set `bindBody: false`, or on Express without `captureRawBody`. The Express adapter **fails closed** rather than silently degrade. |
|
|
28
|
+
| One payment buys one delivery | Atomic single-use claim on the EIP-3009 nonce **and** the quoteId, burned before settlement | **The default `MemoryClaimStore` is single-instance only.** On N instances it is not replay protection at all — see below. |
|
|
29
|
+
| Cross-dialect double-spend | The quoteId claim is version-blind, so a quote spent as v1 cannot be re-spent as v2 | — |
|
|
30
|
+
| Settlement finality | Confirmation depth scaled to value (3 / 6 / 12) | The facilitator must report depth honestly. A dishonest facilitator can lie; choose one you trust. |
|
|
31
|
+
| Paid content is not cached | `Cache-Control: no-store` + `Vary` on both dialects' headers, on paid responses *and* 402s | In proxy mode the origin's own cache headers are overridden. Behind your own CDN, verify it honours `no-store`. |
|
|
32
|
+
| Facilitator outage | Fails closed | An unreachable facilitator costs you a sale, not the resource. |
|
|
33
|
+
|
|
34
|
+
## The one that bites people
|
|
35
|
+
|
|
36
|
+
Replay protection is a single-use claim. That claim must be **atomic and shared**,
|
|
37
|
+
or it is not protection.
|
|
38
|
+
|
|
39
|
+
A `Set` in module scope looks like replay protection and passes every local test,
|
|
40
|
+
because in dev there is one process. In production there are N instances, each
|
|
41
|
+
with its own empty `Set` — so the same payment replayed N times clears N
|
|
42
|
+
different sets and delivers the resource N times. **One payment, N deliveries.**
|
|
43
|
+
|
|
44
|
+
The default `MemoryClaimStore` reports `isDurable === false`. Assert on it:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
if (process.env.NODE_ENV === "production" && !pay.gateway.replayProtectionDurable) {
|
|
48
|
+
throw new Error("multi-instance deploy with in-memory replay protection");
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Use `upstashClaimStore()` (or any `claimStoreFrom(setNx)`) in multi-instance
|
|
53
|
+
deployments. `upstashClaimStore()` throws rather than start without credentials,
|
|
54
|
+
because silently degrading to in-memory would *look* durable and not be.
|
|
55
|
+
|
|
56
|
+
## Reverse-proxy mode
|
|
57
|
+
|
|
58
|
+
The origin behind `startProxy()` **must not be publicly reachable**, or agents
|
|
59
|
+
will route around the paywall entirely. Bind it to loopback, or firewall it to
|
|
60
|
+
the gateway process.
|
|
61
|
+
|
|
62
|
+
## Related
|
|
63
|
+
|
|
64
|
+
Facilitator-side hardening (allowance overdraft, denial-of-settlement,
|
|
65
|
+
hidden-compute pricing) lives in [`@furlpay/x402-guard`](https://github.com/FurlPay/x402-guard).
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type GatewayOptions } from "../gateway.js";
|
|
2
|
+
import type { SettlementReceipt } from "../protocol.js";
|
|
3
|
+
interface ExpressLikeRequest {
|
|
4
|
+
method: string;
|
|
5
|
+
originalUrl?: string;
|
|
6
|
+
url: string;
|
|
7
|
+
headers: Record<string, string | string[] | undefined>;
|
|
8
|
+
protocol?: string;
|
|
9
|
+
secure?: boolean;
|
|
10
|
+
get?(name: string): string | undefined;
|
|
11
|
+
/** Raw body bytes — see `captureRawBody`. */
|
|
12
|
+
rawBody?: Buffer | string;
|
|
13
|
+
/** Set by this middleware once payment is confirmed. */
|
|
14
|
+
payment?: SettlementReceipt;
|
|
15
|
+
}
|
|
16
|
+
interface ExpressLikeResponse {
|
|
17
|
+
status(code: number): ExpressLikeResponse;
|
|
18
|
+
set(field: string, value: string): ExpressLikeResponse;
|
|
19
|
+
json(body: unknown): unknown;
|
|
20
|
+
}
|
|
21
|
+
type Next = (err?: unknown) => void;
|
|
22
|
+
/**
|
|
23
|
+
* `express.json()` discards the raw bytes, but body binding needs to hash
|
|
24
|
+
* exactly what the client sent — a re-serialized `req.body` is a different byte
|
|
25
|
+
* string (key order, whitespace) and would hash differently every time.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* app.use(express.json({ verify: captureRawBody }));
|
|
29
|
+
*/
|
|
30
|
+
export declare function captureRawBody(req: ExpressLikeRequest, _res: unknown, buf: Buffer): void;
|
|
31
|
+
/**
|
|
32
|
+
* Express middleware that paywalls everything mounted after it.
|
|
33
|
+
*
|
|
34
|
+
* On success `req.payment` holds the settlement receipt, so the route handler
|
|
35
|
+
* can meter, log, or attribute the call to the paying agent.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* app.get("/api/weather", expressPaywall({ ... }), (req, res) => {
|
|
39
|
+
* res.json({ tempC: 17, paidBy: req.payment.payer });
|
|
40
|
+
* });
|
|
41
|
+
*/
|
|
42
|
+
export declare function expressPaywall(opts: GatewayOptions): (req: ExpressLikeRequest, res: ExpressLikeResponse, next: Next) => Promise<void>;
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Gateway } from "../gateway.js";
|
|
2
|
+
/**
|
|
3
|
+
* `express.json()` discards the raw bytes, but body binding needs to hash
|
|
4
|
+
* exactly what the client sent — a re-serialized `req.body` is a different byte
|
|
5
|
+
* string (key order, whitespace) and would hash differently every time.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* app.use(express.json({ verify: captureRawBody }));
|
|
9
|
+
*/
|
|
10
|
+
export function captureRawBody(req, _res, buf) {
|
|
11
|
+
req.rawBody = buf;
|
|
12
|
+
}
|
|
13
|
+
function absoluteUrl(req) {
|
|
14
|
+
const headerHost = req.get?.("host") ?? req.headers.host;
|
|
15
|
+
const host = headerHost ?? "localhost";
|
|
16
|
+
const proto = req.secure ? "https" : (req.protocol ?? "http");
|
|
17
|
+
return `${proto}://${host}${req.originalUrl ?? req.url}`;
|
|
18
|
+
}
|
|
19
|
+
function flatHeaders(req) {
|
|
20
|
+
const out = {};
|
|
21
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
22
|
+
if (typeof v === "string")
|
|
23
|
+
out[k.toLowerCase()] = v;
|
|
24
|
+
else if (Array.isArray(v))
|
|
25
|
+
out[k.toLowerCase()] = v[0] ?? "";
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
const BODY_METHODS = new Set(["POST", "PUT", "PATCH"]);
|
|
30
|
+
/**
|
|
31
|
+
* Express middleware that paywalls everything mounted after it.
|
|
32
|
+
*
|
|
33
|
+
* On success `req.payment` holds the settlement receipt, so the route handler
|
|
34
|
+
* can meter, log, or attribute the call to the paying agent.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* app.get("/api/weather", expressPaywall({ ... }), (req, res) => {
|
|
38
|
+
* res.json({ tempC: 17, paidBy: req.payment.payer });
|
|
39
|
+
* });
|
|
40
|
+
*/
|
|
41
|
+
export function expressPaywall(opts) {
|
|
42
|
+
const gateway = new Gateway(opts);
|
|
43
|
+
const bindBody = opts.bindBody !== false;
|
|
44
|
+
return async function middleware(req, res, next) {
|
|
45
|
+
try {
|
|
46
|
+
const method = req.method.toUpperCase();
|
|
47
|
+
// Fail closed rather than silently downgrade: if body binding is on and we
|
|
48
|
+
// cannot see the raw bytes, a payment for one body would unlock any other.
|
|
49
|
+
if (bindBody && BODY_METHODS.has(method) && req.rawBody === undefined) {
|
|
50
|
+
res
|
|
51
|
+
.status(500)
|
|
52
|
+
.set("Cache-Control", "no-store")
|
|
53
|
+
.json({
|
|
54
|
+
error: "gateway_misconfigured: body binding is enabled but req.rawBody is unset. " +
|
|
55
|
+
"Add `app.use(express.json({ verify: captureRawBody }))`, or pass `bindBody: false` " +
|
|
56
|
+
"to accept that a payment for one body unlocks any other.",
|
|
57
|
+
});
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const decision = await gateway.authorize({
|
|
61
|
+
method,
|
|
62
|
+
url: absoluteUrl(req),
|
|
63
|
+
headers: flatHeaders(req),
|
|
64
|
+
body: req.rawBody,
|
|
65
|
+
});
|
|
66
|
+
if (decision.kind !== "allow") {
|
|
67
|
+
for (const [k, v] of Object.entries(decision.headers))
|
|
68
|
+
res.set(k, v);
|
|
69
|
+
res.status(decision.status).json(decision.body);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
for (const [k, v] of Object.entries(decision.headers))
|
|
73
|
+
res.set(k, v);
|
|
74
|
+
req.payment = decision.receipt;
|
|
75
|
+
next();
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
next(err);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Gateway, type GatewayOptions } from "../gateway.js";
|
|
2
|
+
import type { SettlementReceipt } from "../protocol.js";
|
|
3
|
+
export type FetchHandler<Ctx = unknown> = (req: Request, ctx: Ctx) => Response | Promise<Response>;
|
|
4
|
+
/** A handler that also receives the settlement receipt for the request. */
|
|
5
|
+
export type PaidHandler<Ctx = unknown> = (req: Request, ctx: Ctx, receipt: SettlementReceipt) => Response | Promise<Response>;
|
|
6
|
+
/**
|
|
7
|
+
* Wrap a fetch-style handler so it only runs after payment.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* // app/api/weather/route.ts
|
|
11
|
+
* import { paywall } from "@furlpay/gateway";
|
|
12
|
+
*
|
|
13
|
+
* const pay = paywall({
|
|
14
|
+
* price: 0.01,
|
|
15
|
+
* payTo: process.env.MERCHANT_WALLET!,
|
|
16
|
+
* quoteSecret: process.env.QUOTE_SECRET!,
|
|
17
|
+
* network: "base",
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* export const GET = pay(async () => Response.json({ tempC: 17 }));
|
|
21
|
+
*/
|
|
22
|
+
export declare function paywall(opts: GatewayOptions): {
|
|
23
|
+
<Ctx = unknown>(handler: PaidHandler<Ctx>): FetchHandler<Ctx>;
|
|
24
|
+
gateway: Gateway;
|
|
25
|
+
};
|
|
26
|
+
/** Escape hatch for hand-rolled routing: get the decision, do what you like. */
|
|
27
|
+
export declare function createGateway(opts: GatewayOptions): Gateway;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Gateway } from "../gateway.js";
|
|
2
|
+
function toResponse(decision) {
|
|
3
|
+
return new Response(JSON.stringify(decision.body), {
|
|
4
|
+
status: decision.status,
|
|
5
|
+
headers: decision.headers,
|
|
6
|
+
});
|
|
7
|
+
}
|
|
8
|
+
/** Read the body without consuming it for the wrapped handler. */
|
|
9
|
+
async function readBody(req) {
|
|
10
|
+
if (!req.body)
|
|
11
|
+
return undefined;
|
|
12
|
+
try {
|
|
13
|
+
return await req.clone().text();
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Wrap a fetch-style handler so it only runs after payment.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* // app/api/weather/route.ts
|
|
24
|
+
* import { paywall } from "@furlpay/gateway";
|
|
25
|
+
*
|
|
26
|
+
* const pay = paywall({
|
|
27
|
+
* price: 0.01,
|
|
28
|
+
* payTo: process.env.MERCHANT_WALLET!,
|
|
29
|
+
* quoteSecret: process.env.QUOTE_SECRET!,
|
|
30
|
+
* network: "base",
|
|
31
|
+
* });
|
|
32
|
+
*
|
|
33
|
+
* export const GET = pay(async () => Response.json({ tempC: 17 }));
|
|
34
|
+
*/
|
|
35
|
+
export function paywall(opts) {
|
|
36
|
+
const gateway = new Gateway(opts);
|
|
37
|
+
function wrap(handler) {
|
|
38
|
+
return async (req, ctx) => {
|
|
39
|
+
const decision = await gateway.authorize({
|
|
40
|
+
method: req.method,
|
|
41
|
+
url: req.url,
|
|
42
|
+
headers: req.headers,
|
|
43
|
+
body: await readBody(req),
|
|
44
|
+
});
|
|
45
|
+
if (decision.kind !== "allow")
|
|
46
|
+
return toResponse(decision);
|
|
47
|
+
const res = await handler(req, ctx, decision.receipt);
|
|
48
|
+
// Stamp the receipt and no-store onto whatever the handler returned. A
|
|
49
|
+
// paid 200 that a CDN caches is a paid resource served free to the next
|
|
50
|
+
// unpaid caller, so this is not optional.
|
|
51
|
+
const out = new Response(res.body, res);
|
|
52
|
+
for (const [k, v] of Object.entries(decision.headers))
|
|
53
|
+
out.headers.set(k, v);
|
|
54
|
+
return out;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
wrap.gateway = gateway;
|
|
58
|
+
return wrap;
|
|
59
|
+
}
|
|
60
|
+
/** Escape hatch for hand-rolled routing: get the decision, do what you like. */
|
|
61
|
+
export function createGateway(opts) {
|
|
62
|
+
return new Gateway(opts);
|
|
63
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import { type GatewayOptions } from "../gateway.js";
|
|
3
|
+
export interface ProxyOptions extends GatewayOptions {
|
|
4
|
+
/** Origin to forward paid requests to, e.g. "http://localhost:8000". */
|
|
5
|
+
origin: string;
|
|
6
|
+
port?: number;
|
|
7
|
+
hostname?: string;
|
|
8
|
+
/** Paths served free (health checks, docs, openapi.json). Exact or prefix*. */
|
|
9
|
+
freePaths?: string[];
|
|
10
|
+
/** Public base URL, when behind a TLS terminator. Resource identity — and so
|
|
11
|
+
* the quote binding — is derived from this, and it must match what the agent
|
|
12
|
+
* actually requested or every binding check fails. */
|
|
13
|
+
publicUrl?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Build (but do not start) the paywalling reverse proxy.
|
|
17
|
+
* Returns a standard `http.Server` — call `.listen()` yourself, or use
|
|
18
|
+
* `startProxy` which does it for you.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createProxy(opts: ProxyOptions): http.Server;
|
|
21
|
+
export interface RunningProxy {
|
|
22
|
+
server: http.Server;
|
|
23
|
+
port: number;
|
|
24
|
+
close(): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
export declare function startProxy(opts: ProxyOptions): Promise<RunningProxy>;
|