@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 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.0",
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 (!o.isPaidAgent(request.headers.get('user-agent') ?? '')) return null;
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)),