@profullstack/x402-gateway 0.1.0 → 0.2.1
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/README.md +8 -0
- package/index.d.ts +14 -0
- package/package.json +3 -2
- package/src/edge.js +101 -0
- package/src/index.js +27 -1
package/README.md
CHANGED
|
@@ -93,8 +93,16 @@ export const GET = robotsRoute(gateway, { disallow: ['/login', '/api/'] });
|
|
|
93
93
|
| `contact` | | mailto: or URL for bulk deals |
|
|
94
94
|
| `onSale` | | `({ payer, ref, token, expiresAt, userAgent, priceCents, currency }) => …`, for accounting |
|
|
95
95
|
|
|
96
|
+
| `denyCidrs` | `[]` | IPv4 ranges answered with a tiny `403` before anything else. For a VPS fleet that spoofs a browser: hosting ranges serve no readers. |
|
|
97
|
+
| `chargeSpoofedBrowsers` | `false` | charge a request that claims `Chrome/…` but sends no `Sec-Fetch-Mode`. Every Chromium since 76, headless included, sends it on every request and no script or extension can remove it, so its absence means an HTTP client with a copied string. Firefox and Safari are not judged. |
|
|
98
|
+
| `exempt` | | `(request) => boolean`, never charged: e.g. a request carrying your signed-in cookie |
|
|
99
|
+
|
|
96
100
|
Without `coinpay.apiKey` and `payTo` the gateway still answers training crawlers with 402 and the page says payments are off. Nothing is sold, but nothing is given away either.
|
|
97
101
|
|
|
102
|
+
## Crawlers that do not say who they are
|
|
103
|
+
|
|
104
|
+
The lists catch crawlers that name themselves. Two do not: a VPS fleet wearing a browser string, and a residential-proxy rotation cycling a few Chrome strings across hundreds of addresses. `denyCidrs` handles the first (`['51.38.0.0/16', '54.38.0.0/16', …]` for one provider's ranges); `chargeSpoofedBrowsers` handles both by asking a question only a browser can answer. A request that answers it is left alone. One that cannot gets the same 402 as GPTBot, which costs the site a hash instead of a render.
|
|
105
|
+
|
|
98
106
|
## How the money moves
|
|
99
107
|
|
|
100
108
|
The offer is x402 v2 in CoinPay's dialect: USDC under the `exact` scheme on Base, Polygon or Ethereum, EIP-3009 `transferWithAuthorization`. The buyer signs, the gateway sends the proof to CoinPay's `/api/x402/verify` and `/api/x402/settle`, and CoinPay's relayer broadcasts the transfer, paying the gas. The USDC goes straight to `payTo`.
|
package/index.d.ts
CHANGED
|
@@ -49,6 +49,12 @@ export interface GatewayOptions {
|
|
|
49
49
|
retrieval?: string[];
|
|
50
50
|
/** Who is charged. Default: the training list, substring-matched on the user agent. */
|
|
51
51
|
isPaidAgent?: (userAgent: string) => boolean;
|
|
52
|
+
/** IPv4 CIDRs answered 403 before anything else (a VPS fleet's provider ranges). */
|
|
53
|
+
denyCidrs?: string[];
|
|
54
|
+
/** Charge a request that claims "Chrome/…" but lacks the Sec-Fetch-Mode header every Chromium sends. Default false. */
|
|
55
|
+
chargeSpoofedBrowsers?: boolean;
|
|
56
|
+
/** Requests never charged, e.g. ones carrying a signed-in cookie. */
|
|
57
|
+
exempt?: (request: Request) => boolean;
|
|
52
58
|
/** Pass signing secret. Defaults to the CoinPay key. */
|
|
53
59
|
secret?: string;
|
|
54
60
|
page?: (ctx: PageContext) => string;
|
|
@@ -109,6 +115,14 @@ export const RETRIEVAL_AGENTS: string[];
|
|
|
109
115
|
export function isTrainingAgent(userAgent?: string | null, agents?: string[]): boolean;
|
|
110
116
|
|
|
111
117
|
export function robotsTxt(options: RobotsOptions & { siteUrl: string }): string;
|
|
118
|
+
|
|
119
|
+
/** ./edge (also re-exported from the root) */
|
|
120
|
+
export interface Cidr { base: number; mask: number; text: string }
|
|
121
|
+
export function parseCidr(cidr: string): Cidr | null;
|
|
122
|
+
export function compileCidrs(list?: string[]): Cidr[];
|
|
123
|
+
export function inCidrs(ip: string, compiled: Cidr[]): boolean;
|
|
124
|
+
export function clientIp(request: Request): string;
|
|
125
|
+
export function isSpoofedBrowser(request: Request): boolean;
|
|
112
126
|
export function renderPage(ctx: PageContext): string;
|
|
113
127
|
|
|
114
128
|
export function mintPass(args: { secret: string; ref: string | null; expiresAt: number; now?: number }): Promise<{ token: string; expiresAt: number; ref: string | null }>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/x402-gateway",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Sell crawl access to AI training crawlers by the day over x402, settled by CoinPay. One middleware: 402 with an offer, a sales page with CLI instructions, signed passes, and a robots.txt that keeps search crawlers welcome.",
|
|
6
6
|
"keywords": [
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
"./hono": { "types": "./index.d.ts", "import": "./src/hono.js" },
|
|
31
31
|
"./next": { "types": "./index.d.ts", "import": "./src/next.js" },
|
|
32
32
|
"./robots": { "types": "./index.d.ts", "import": "./src/robots.js" },
|
|
33
|
-
"./agents": { "types": "./index.d.ts", "import": "./src/agents.js" }
|
|
33
|
+
"./agents": { "types": "./index.d.ts", "import": "./src/agents.js" },
|
|
34
|
+
"./edge": { "types": "./index.d.ts", "import": "./src/edge.js" }
|
|
34
35
|
},
|
|
35
36
|
"types": "./index.d.ts",
|
|
36
37
|
"files": ["src", "index.d.ts", "README.md", "LICENSE"],
|
package/src/edge.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two checks that do not need a user agent to be honest.
|
|
3
|
+
*
|
|
4
|
+
* A crawler that names itself is charged by the lists in ./agents. The ones
|
|
5
|
+
* that do not -- a VPS fleet wearing "Chrome/148", a residential-proxy
|
|
6
|
+
* rotation cycling three Chrome strings across five hundred addresses -- need
|
|
7
|
+
* something the request cannot help giving away. Two things qualify:
|
|
8
|
+
*
|
|
9
|
+
* 1. Where it came from. A hosting provider's address range serves no
|
|
10
|
+
* readers, only machines. A CIDR denylist answers those with a tiny 403
|
|
11
|
+
* before anything else runs.
|
|
12
|
+
*
|
|
13
|
+
* 2. Whether it is the browser it claims to be. Every Chromium since 76,
|
|
14
|
+
* headless included, sends `Sec-Fetch-Mode` on every request; it is a
|
|
15
|
+
* forbidden header, so no page script and no extension can remove it.
|
|
16
|
+
* A request that says "Chrome/145" and does not send it is an HTTP client
|
|
17
|
+
* with a copied string. That is not a person, and it is charged like any
|
|
18
|
+
* other crawler.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/* ------------------------------------------------------------------ CIDRs -- */
|
|
22
|
+
|
|
23
|
+
function ipv4ToInt(ip) {
|
|
24
|
+
const parts = ip.split('.');
|
|
25
|
+
if (parts.length !== 4) return null;
|
|
26
|
+
let n = 0;
|
|
27
|
+
for (const p of parts) {
|
|
28
|
+
if (!/^\d{1,3}$/.test(p)) return null;
|
|
29
|
+
const v = Number(p);
|
|
30
|
+
if (v > 255) return null;
|
|
31
|
+
n = n * 256 + v;
|
|
32
|
+
}
|
|
33
|
+
return n;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Parse "a.b.c.d/len" (or a bare address) into a matcher. Null if unreadable. */
|
|
37
|
+
export function parseCidr(cidr) {
|
|
38
|
+
const [ip, lenRaw] = String(cidr).trim().split('/');
|
|
39
|
+
const base = ipv4ToInt(ip);
|
|
40
|
+
if (base === null) return null;
|
|
41
|
+
const len = lenRaw === undefined ? 32 : Number(lenRaw);
|
|
42
|
+
if (!Number.isInteger(len) || len < 0 || len > 32) return null;
|
|
43
|
+
const mask = len === 0 ? 0 : (0xffffffff << (32 - len)) >>> 0;
|
|
44
|
+
return { base: (base & mask) >>> 0, mask, text: `${ip}/${len}` };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Compile a denylist once. Unreadable entries are dropped, not guessed at. */
|
|
48
|
+
export function compileCidrs(list = []) {
|
|
49
|
+
return list.map(parseCidr).filter(Boolean);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Whether an IPv4 address falls inside any compiled range. */
|
|
53
|
+
export function inCidrs(ip, compiled) {
|
|
54
|
+
const n = ipv4ToInt(String(ip ?? '').trim());
|
|
55
|
+
if (n === null) return false;
|
|
56
|
+
return compiled.some((c) => ((n & c.mask) >>> 0) === c.base);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The caller's address, as the edge reported it.
|
|
61
|
+
*
|
|
62
|
+
* `x-forwarded-for` is a list the client can seed, and a denylist read from
|
|
63
|
+
* its first entry is a denylist any client can step around by sending one.
|
|
64
|
+
* The entry our own edge appends is the LAST, on every platform in front of
|
|
65
|
+
* these sites (Railway's proxy, nginx with `$proxy_add_x_forwarded_for`), so
|
|
66
|
+
* last is what is used. `x-real-ip` is nginx's spelling of the same hop and
|
|
67
|
+
* is preferred when present, because nginx sets it from the socket and
|
|
68
|
+
* nothing a client sends survives into it.
|
|
69
|
+
*
|
|
70
|
+
* A CDN in front of the edge would make the last hop the CDN's; put its
|
|
71
|
+
* ranges nowhere near `denyCidrs` and this still fails safe: nothing is
|
|
72
|
+
* refused, nothing is charged, by this check.
|
|
73
|
+
*/
|
|
74
|
+
export function clientIp(request) {
|
|
75
|
+
const real = request.headers.get('x-real-ip')?.trim();
|
|
76
|
+
if (real) return real;
|
|
77
|
+
const xff = request.headers.get('x-forwarded-for');
|
|
78
|
+
if (!xff) return '';
|
|
79
|
+
const hops = xff.split(',').map((h) => h.trim()).filter(Boolean);
|
|
80
|
+
return hops[hops.length - 1] ?? '';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* -------------------------------------------------------------- spoofing -- */
|
|
84
|
+
|
|
85
|
+
const CLAIMS_CHROMIUM = /\bChrome\/\d+/;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A request that claims a Chromium user agent but carries none of the
|
|
89
|
+
* fetch-metadata headers Chromium cannot omit.
|
|
90
|
+
*
|
|
91
|
+
* Only Chromium is judged: Firefox and Safari added Sec-Fetch later and
|
|
92
|
+
* older builds of both are still out there, so their absence proves nothing.
|
|
93
|
+
* `sec-fetch-mode` is the one checked because it is present on every request
|
|
94
|
+
* kind -- navigation, subresource, fetch -- unlike `sec-ch-ua`, which a
|
|
95
|
+
* privacy proxy may strip.
|
|
96
|
+
*/
|
|
97
|
+
export function isSpoofedBrowser(request) {
|
|
98
|
+
const ua = request.headers.get('user-agent') ?? '';
|
|
99
|
+
if (!CLAIMS_CHROMIUM.test(ua)) return false;
|
|
100
|
+
return !request.headers.has('sec-fetch-mode');
|
|
101
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isTrainingAgent, RETRIEVAL_AGENTS, TRAINING_AGENTS } from './agents.js';
|
|
2
|
+
import { clientIp, compileCidrs, inCidrs, isSpoofedBrowser } from './edge.js';
|
|
2
3
|
import { renderPage } from './page.js';
|
|
3
4
|
import { mintPass, readPass } from './pass.js';
|
|
4
5
|
import { robotsTxt } from './robots.js';
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
} from './x402.js';
|
|
15
16
|
|
|
16
17
|
export { isTrainingAgent, RETRIEVAL_AGENTS, TRAINING_AGENTS } from './agents.js';
|
|
18
|
+
export { clientIp, compileCidrs, inCidrs, isSpoofedBrowser, parseCidr } from './edge.js';
|
|
17
19
|
export { renderPage } from './page.js';
|
|
18
20
|
export { mintPass, readPass } from './pass.js';
|
|
19
21
|
export { robotsTxt } from './robots.js';
|
|
@@ -45,6 +47,9 @@ export { buildOffer, decodePayment, expectedFor, METHODS, verifyAndSettle } from
|
|
|
45
47
|
* @param {string} [options.path='/crawl'] the sales page
|
|
46
48
|
* @param {string[]} [options.openPaths] extra paths a refused crawler may read
|
|
47
49
|
* @param {(ua: string) => boolean} [options.isPaidAgent]
|
|
50
|
+
* @param {string[]} [options.denyCidrs] IPv4 ranges answered 403 before anything else, e.g. a VPS fleet's provider
|
|
51
|
+
* @param {boolean} [options.chargeSpoofedBrowsers=false] charge a "Chrome/…" request that lacks the Sec-Fetch-Mode header every Chromium sends
|
|
52
|
+
* @param {(request: Request) => boolean} [options.exempt] requests never charged, e.g. ones carrying a signed-in cookie
|
|
48
53
|
* @param {string} [options.secret] pass signing secret; defaults to the CoinPay key
|
|
49
54
|
* @param {(ctx: object) => string} [options.page] custom sales page renderer
|
|
50
55
|
* @param {string} [options.contact] mailto: or URL for bulk deals
|
|
@@ -57,6 +62,7 @@ export function createGateway(options = {}) {
|
|
|
57
62
|
const secret = o.secret || o.coinpay.apiKey || null;
|
|
58
63
|
|
|
59
64
|
const openPaths = ['/robots.txt', o.path, '/security.txt', '/.well-known/', ...o.openPaths];
|
|
65
|
+
const denied = compileCidrs(o.denyCidrs);
|
|
60
66
|
const isOpen = (path) => openPaths.some((p) => (p.endsWith('/') ? path.startsWith(p) : path === p));
|
|
61
67
|
|
|
62
68
|
const price = `${(o.priceCents / 100).toFixed(2)} ${o.currency}`;
|
|
@@ -204,9 +210,26 @@ export function createGateway(options = {}) {
|
|
|
204
210
|
* starts from the user agent.
|
|
205
211
|
*/
|
|
206
212
|
async function handle(request) {
|
|
213
|
+
/*
|
|
214
|
+
* Addresses that serve no readers are refused before anything else, with
|
|
215
|
+
* a body small enough that refusing costs nothing. Not 402: there is no
|
|
216
|
+
* pass on sale to a hosting range that spoofs a browser, because whoever
|
|
217
|
+
* runs it has already declined to say who they are.
|
|
218
|
+
*/
|
|
219
|
+
if (denied.length && inCidrs(clientIp(request), denied)) {
|
|
220
|
+
return new Response('Not available from this network.\n', {
|
|
221
|
+
status: 403,
|
|
222
|
+
headers: { 'content-type': 'text/plain; charset=utf-8', ...noStore },
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
207
226
|
const path = new URL(request.url).pathname;
|
|
208
227
|
if (path === o.path) return sell(request);
|
|
209
|
-
if (
|
|
228
|
+
if (o.exempt && o.exempt(request)) return null;
|
|
229
|
+
const pays =
|
|
230
|
+
o.isPaidAgent(request.headers.get('user-agent') ?? '') ||
|
|
231
|
+
(o.chargeSpoofedBrowsers && isSpoofedBrowser(request));
|
|
232
|
+
if (!pays) return null;
|
|
210
233
|
if (isOpen(path)) return null;
|
|
211
234
|
|
|
212
235
|
const token = passFrom(request);
|
|
@@ -248,6 +271,9 @@ function normalise(options) {
|
|
|
248
271
|
header: String(options.header ?? 'x-crawl-pass').toLowerCase(),
|
|
249
272
|
path: options.path ?? '/crawl',
|
|
250
273
|
openPaths: options.openPaths ?? [],
|
|
274
|
+
denyCidrs: options.denyCidrs ?? [],
|
|
275
|
+
chargeSpoofedBrowsers: Boolean(options.chargeSpoofedBrowsers),
|
|
276
|
+
exempt: options.exempt ?? null,
|
|
251
277
|
training,
|
|
252
278
|
retrieval: options.retrieval ?? RETRIEVAL_AGENTS,
|
|
253
279
|
isPaidAgent: options.isPaidAgent ?? ((ua) => isTrainingAgent(ua, training)),
|