@profullstack/x402-gateway 0.7.0 → 0.8.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
@@ -89,7 +89,11 @@ export const GET = robotsRoute(gateway, { disallow: ['/login', '/api/'] });
89
89
  | `header` | `x-crawl-pass` | where the pass goes; `Authorization: Bearer` works too |
90
90
  | `path` | `/crawl` | the sales page |
91
91
  | `openPaths` | `[]` | extra paths a refused crawler may read (`robots.txt`, the sales page, `security.txt` and `.well-known/` always are) |
92
- | `isPaidAgent` | training list | `(userAgent) => boolean` |
92
+ | `isPaidAgent` | training + scraper + headless lists | `(userAgent) => boolean` |
93
+ | `scrapers` | `SCRAPER_AGENTS` | SEO resellers (AhrefsBot, SemrushBot, MJ12bot, DataForSeoBot, ...): charged and refused in robots.txt |
94
+ | `headless` | `HEADLESS_AGENTS` | headless browsers that name themselves (Lightpanda, HeadlessChrome, PhantomJS): charged |
95
+ | `chargeStaleBrowsers` | `true` | charge a desktop Chrome/Edge/Firefox string about two years behind its auto-updates. Scraper rotation pools copy old strings; people's browsers update themselves. Phones, Firefox ESRs and the last version an old OS can run are never judged |
96
+ | `welcome` | `WELCOME_CRAWLERS` | Googlebot, bingbot and Applebot read unmetered when their address is in the operator's published range. A copied string from anywhere else is metered like everyone; `false` turns this off |
93
97
  | `training`, `retrieval` | the lists in `./agents` | |
94
98
  | `secret` | the CoinPay key | pass signing secret |
95
99
  | `page` | built in | `(ctx) => html` |
package/index.d.ts CHANGED
@@ -68,6 +68,14 @@ export interface GatewayOptions {
68
68
  denyCidrs?: string[];
69
69
  /** Charge a request that claims "Chrome/…" but lacks the Sec-Fetch-Mode header every Chromium sends. Default false. */
70
70
  chargeSpoofedBrowsers?: boolean;
71
+ /** Charge a desktop browser string ~2 years behind its auto-updates (a scraper rotation pool). Default true. */
72
+ chargeStaleBrowsers?: boolean;
73
+ /** SEO resellers: charged, and refused in robots.txt. Default SCRAPER_AGENTS. */
74
+ scrapers?: string[];
75
+ /** Headless browsers that name themselves: charged. Default HEADLESS_AGENTS. */
76
+ headless?: string[];
77
+ /** Search crawlers never metered when their address matches their name. Default WELCOME_CRAWLERS; false disables. */
78
+ welcome?: WelcomeCrawler[] | false;
71
79
  /** Requests never charged, e.g. ones carrying a signed-in cookie. */
72
80
  exempt?: (request: Request) => boolean;
73
81
  /** Pass signing secret. Defaults to the CoinPay key. */
@@ -191,6 +199,10 @@ export interface Gateway {
191
199
  passFrom: (request: Request) => string | null;
192
200
  /** Whether that token opens the site: a live day pass this gateway minted, or a valid, unrevoked Power Key. */
193
201
  verifyPass: (token: string | null) => Promise<boolean>;
202
+ /** Whether the gateway's own rules charge this request (lists, spoofed or stale browser). */
203
+ isPaid: (request: Request) => boolean;
204
+ /** Whether this is a welcome search crawler whose address matches its name. */
205
+ isWelcome: (request: Request) => boolean;
194
206
  /** The x402 offer this gateway would make, without answering a request. */
195
207
  offer: (days?: number) => Offer;
196
208
  /** The full 402 body around that offer: the offer plus the pass terms. */
@@ -201,6 +213,13 @@ export function createGateway(options: GatewayOptions): Gateway;
201
213
  export function wantsHtml(accept?: string | null): boolean;
202
214
 
203
215
  export const TRAINING_AGENTS: string[];
216
+ export const SCRAPER_AGENTS: string[];
217
+ export const HEADLESS_AGENTS: string[];
218
+ export interface WelcomeCrawler {
219
+ token: string;
220
+ cidrs: string[];
221
+ }
222
+ export const WELCOME_CRAWLERS: WelcomeCrawler[];
204
223
  export const RETRIEVAL_AGENTS: string[];
205
224
  export function isTrainingAgent(userAgent?: string | null, agents?: string[]): boolean;
206
225
 
@@ -213,6 +232,9 @@ export function compileCidrs(list?: string[]): Cidr[];
213
232
  export function inCidrs(ip: string, compiled: Cidr[]): boolean;
214
233
  export function clientIp(request: Request): string;
215
234
  export function isSpoofedBrowser(request: Request): boolean;
235
+ export function isStaleBrowser(userAgent: string, now?: number, maxMajorsBehind?: number): boolean;
236
+ export function currentMajor(browser: 'chrome' | 'firefox', now?: number): number;
237
+ export function welcomeCrawler(request: Request, list: WelcomeCrawler[] | null | undefined): string | null;
216
238
  export function renderPage(ctx: PageContext): string;
217
239
 
218
240
  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.7.0",
3
+ "version": "0.8.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, Profullstack Power Keys honoured as a pass, and a robots.txt that keeps search crawlers welcome.",
6
6
  "keywords": [
package/src/agents.js CHANGED
@@ -24,8 +24,35 @@ export const TRAINING_AGENTS = [
24
24
  'FacebookBot',
25
25
  'Bytespider',
26
26
  'Applebot-Extended',
27
+ // Amazon documents Amazonbot's crawl as one that "may be used to train
28
+ // Amazon AI models", and it sends no reader back from any of it.
29
+ 'Amazonbot',
27
30
  ];
28
31
 
32
+ /**
33
+ * Crawlers that resell what they read and send nobody back: SEO backlink and
34
+ * keyword databases. They do obey robots.txt, so they are refused there as well
35
+ * as charged, which is the cheapest outcome of all: they stop asking.
36
+ */
37
+ export const SCRAPER_AGENTS = [
38
+ 'AhrefsBot',
39
+ 'SemrushBot',
40
+ 'MJ12bot',
41
+ 'DataForSeoBot',
42
+ 'DotBot',
43
+ 'BLEXBot',
44
+ 'serpstatbot',
45
+ 'Barkrowler',
46
+ ];
47
+
48
+ /**
49
+ * Headless browsers that say so. Nobody reads a page through one of these; a
50
+ * scrape does, and it runs the page's scripts while it is at it. Lightpanda was
51
+ * 151k requests from 63k addresses on one site in a day (2026-10-19). They do
52
+ * not read robots.txt, so they are charged, never named there.
53
+ */
54
+ export const HEADLESS_AGENTS = ['Lightpanda', 'HeadlessChrome', 'PhantomJS'];
55
+
29
56
  /**
30
57
  * Retrieval crawlers, named in robots.txt so their operators can see they are
31
58
  * welcome. Google-Extended is Google's training token but Google documents it
@@ -57,3 +84,23 @@ export function isTrainingAgent(userAgent = '', agents = TRAINING_AGENTS) {
57
84
  if (!ua) return false;
58
85
  return lower(agents).some((t) => ua.includes(t));
59
86
  }
87
+
88
+ /**
89
+ * Search crawlers that are welcome everywhere, with the address ranges their
90
+ * operators publish. A user agent alone is not enough to spare a caller from
91
+ * the free allowance -- "Googlebot" is the first string a scraper copies -- so
92
+ * a crawler is only spared when its address agrees with its name.
93
+ *
94
+ * Ranges are the stable blocks of each operator's published list
95
+ * (developers.google.com/static/search/apis/ipranges/googlebot.json,
96
+ * bing.com/toolbox/bingbot.json, Apple's 17.0.0.0/8); a crawl from outside them
97
+ * is simply metered like everyone else, which fails safe.
98
+ */
99
+ export const WELCOME_CRAWLERS = [
100
+ { token: 'Googlebot', cidrs: ['66.249.64.0/19', '192.178.4.0/22', '34.100.182.96/28'] },
101
+ {
102
+ token: 'bingbot',
103
+ cidrs: ['40.77.167.0/24', '157.55.39.0/24', '207.46.13.0/24', '52.167.144.0/24', '40.77.188.0/22', '20.15.133.160/27'],
104
+ },
105
+ { token: 'Applebot', cidrs: ['17.0.0.0/8'] },
106
+ ];
package/src/edge.js CHANGED
@@ -111,3 +111,102 @@ export function isSpoofedBrowser(request) {
111
111
  if (DECLARES_ITSELF.test(ua)) return false;
112
112
  return !request.headers.has('sec-fetch-mode');
113
113
  }
114
+
115
+ /* ---------------------------------------------------------- stale strings -- */
116
+
117
+ /*
118
+ * Where each browser's version stood on a known day, and how fast it moves:
119
+ * both ship a major every four weeks. Computing "current" from the date keeps
120
+ * the check from going stale itself, which a hard-coded number would within a
121
+ * year.
122
+ */
123
+ const DAY = 86_400_000;
124
+ const CADENCE = 28 * DAY;
125
+ const CHROME_ANCHOR = { major: 120, at: Date.UTC(2023, 11, 5) };
126
+ const FIREFOX_ANCHOR = { major: 121, at: Date.UTC(2023, 11, 19) };
127
+
128
+ /** The major a browser that auto-updates would be on at `now`. */
129
+ export function currentMajor(browser, now = Date.now()) {
130
+ const a = browser === 'firefox' ? FIREFOX_ANCHOR : CHROME_ANCHOR;
131
+ return a.major + Math.max(0, Math.floor((now - a.at) / CADENCE));
132
+ }
133
+
134
+ /*
135
+ * The last version an old operating system can run, and Firefox's extended
136
+ * support releases. A person stuck on one of these is still a person, so these
137
+ * are never called stale, however old.
138
+ */
139
+ const TERMINAL_CHROME = [
140
+ { major: 109, os: /Windows NT 6\./ }, // Windows 7, 8, 8.1
141
+ { major: 116, os: /Macintosh/ }, // macOS 10.13, 10.14
142
+ { major: 128, os: /Macintosh/ }, // macOS 10.15
143
+ { major: 138, os: /Macintosh/ }, // macOS 11
144
+ ];
145
+ const FIREFOX_ESR = [115, 128, 140, 153];
146
+ const EMBEDDED =
147
+ /Mobile|Android|iPhone|iPad|CrOS|CrKey|SMART-TV|SmartTV|Tizen|Web0S|WebOS|NetCast|BRAVIA|HbbTV|Roku|AFT[A-Z]|PlayStation|Xbox|Nintendo|OculusBrowser|Quest/;
148
+
149
+ /**
150
+ * A desktop browser string far older than anything that auto-updates.
151
+ *
152
+ * Chrome, Edge and Firefox update themselves every four weeks, so a desktop
153
+ * "Chrome/120" two years after Chrome 120 shipped is a string copied into a
154
+ * scraper's rotation pool, not a browser. That is exactly what the swarms do:
155
+ * on 2026-10-19 one pool of ten strings (Chrome 118-120, Edge 119-120, Firefox
156
+ * 120-121) walked watchnews.now and r4ck.dev from thousands of residential
157
+ * addresses, running the page scripts as it went.
158
+ *
159
+ * Only desktop strings are judged. Phones and embedded webviews legitimately
160
+ * sit years behind, and a declared crawler is judged by the lists instead.
161
+ *
162
+ * @param {string} ua
163
+ * @param {number} [now]
164
+ * @param {number} [maxMajorsBehind=24] about 22 months of releases
165
+ */
166
+ export function isStaleBrowser(ua, now = Date.now(), maxMajorsBehind = 24) {
167
+ const s = String(ua ?? '');
168
+ if (!/Windows NT|Macintosh|X11; (?:Linux|Ubuntu|Fedora) x86_64/.test(s)) return false;
169
+ // Phones, TVs, cast sticks, consoles and headsets: an embedded browser is
170
+ // frozen at whatever its firmware shipped. A Chromecast says "X11; Linux
171
+ // armv7l ... Chrome/80 ... CrKey" for years, and it is somebody's living room.
172
+ if (EMBEDDED.test(s)) return false;
173
+ if (DECLARES_ITSELF.test(s)) return false;
174
+
175
+ const ff = /\bFirefox\/(\d+)/.exec(s);
176
+ if (ff) {
177
+ const major = Number(ff[1]);
178
+ if (FIREFOX_ESR.includes(major)) return false;
179
+ return major < currentMajor('firefox', now) - maxMajorsBehind;
180
+ }
181
+ const chrome = /\bChrome\/(\d+)/.exec(s);
182
+ if (chrome) {
183
+ const major = Number(chrome[1]);
184
+ if (TERMINAL_CHROME.some((t) => t.major === major && t.os.test(s))) return false;
185
+ return major < currentMajor('chrome', now) - maxMajorsBehind;
186
+ }
187
+ return false;
188
+ }
189
+
190
+ /* -------------------------------------------------------- welcome crawlers -- */
191
+
192
+ const COMPILED = new WeakMap();
193
+
194
+ /**
195
+ * The welcome crawler a request is, judged by name AND address, or null.
196
+ *
197
+ * @param {Request} request
198
+ * @param {{token: string, cidrs: string[]}[]} list e.g. WELCOME_CRAWLERS
199
+ */
200
+ export function welcomeCrawler(request, list) {
201
+ if (!list?.length) return null;
202
+ const ua = (request.headers.get('user-agent') ?? '').toLowerCase();
203
+ if (!ua) return null;
204
+ const ip = clientIp(request);
205
+ for (const c of list) {
206
+ if (!ua.includes(c.token.toLowerCase())) continue;
207
+ let compiled = COMPILED.get(c);
208
+ if (!compiled) COMPILED.set(c, (compiled = compileCidrs(c.cidrs)));
209
+ if (inCidrs(ip, compiled)) return c.token;
210
+ }
211
+ return null;
212
+ }
package/src/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { HEADER as POWER_KEY_HEADER, isPowerKey, keyFrom, PROFULLSTACK_KEYS, SHOP_URL, verifyKey } from '@profullstack/keys';
2
- import { isTrainingAgent, RETRIEVAL_AGENTS, TRAINING_AGENTS } from './agents.js';
3
- import { clientIp, compileCidrs, inCidrs, isSpoofedBrowser } from './edge.js';
2
+ import { HEADLESS_AGENTS, isTrainingAgent, RETRIEVAL_AGENTS, SCRAPER_AGENTS, TRAINING_AGENTS, WELCOME_CRAWLERS } from './agents.js';
3
+ import { clientIp, compileCidrs, inCidrs, isSpoofedBrowser, isStaleBrowser, welcomeCrawler } from './edge.js';
4
4
  import { memoryQuotaStore, meters, normaliseQuota, quotaHeaders, spend } from './quota.js';
5
5
  import { renderPage } from './page.js';
6
6
  import { mintPass, readPass } from './pass.js';
@@ -16,8 +16,8 @@ import {
16
16
  verifyAndSettle,
17
17
  } from './x402.js';
18
18
 
19
- export { isTrainingAgent, RETRIEVAL_AGENTS, TRAINING_AGENTS } from './agents.js';
20
- export { clientIp, compileCidrs, inCidrs, isSpoofedBrowser, parseCidr } from './edge.js';
19
+ export { HEADLESS_AGENTS, isTrainingAgent, RETRIEVAL_AGENTS, SCRAPER_AGENTS, TRAINING_AGENTS, WELCOME_CRAWLERS } from './agents.js';
20
+ export { clientIp, compileCidrs, currentMajor, inCidrs, isSpoofedBrowser, isStaleBrowser, parseCidr, welcomeCrawler } from './edge.js';
21
21
  export { renderPage } from './page.js';
22
22
  export { mintPass, readPass } from './pass.js';
23
23
  export { robotsTxt } from './robots.js';
@@ -56,6 +56,10 @@ export { isPowerKey, keyFrom, PROFULLSTACK_KEYS, verifyKey } from '@profullstack
56
56
  * @param {(ua: string) => boolean} [options.isPaidAgent]
57
57
  * @param {string[]} [options.denyCidrs] IPv4 ranges answered 403 before anything else, e.g. a VPS fleet's provider
58
58
  * @param {boolean} [options.chargeSpoofedBrowsers=false] charge a "Chrome/…" request that lacks the Sec-Fetch-Mode header every Chromium sends
59
+ * @param {boolean} [options.chargeStaleBrowsers=true] charge a desktop browser string two years behind its auto-updates (a rotation pool)
60
+ * @param {string[]} [options.scrapers] SEO resellers charged and refused in robots.txt; default SCRAPER_AGENTS
61
+ * @param {string[]} [options.headless] headless browsers charged; default HEADLESS_AGENTS
62
+ * @param {{token: string, cidrs: string[]}[]|false} [options.welcome] search crawlers never metered when name and address agree; default WELCOME_CRAWLERS
59
63
  * @param {(request: Request) => boolean} [options.exempt] requests never charged, e.g. ones carrying a signed-in cookie
60
64
  * @param {string} [options.secret] pass signing secret; defaults to the CoinPay key
61
65
  * @param {Record<string,string>|false} [options.powerKeys] kid -> public key of the Power Keys honoured as a pass; defaults to Profullstack's, `false` switches it off
@@ -376,12 +380,22 @@ export function createGateway(options = {}) {
376
380
  const token = passFrom(request);
377
381
  if (await honours(token)) return null;
378
382
 
383
+ const ua = request.headers.get('user-agent') ?? '';
379
384
  const pays =
380
- o.isPaidAgent(request.headers.get('user-agent') ?? '') ||
381
- (o.chargeSpoofedBrowsers && isSpoofedBrowser(request));
385
+ o.isPaidAgent(ua) ||
386
+ (o.chargeSpoofedBrowsers && isSpoofedBrowser(request)) ||
387
+ (o.chargeStaleBrowsers && isStaleBrowser(ua));
382
388
  if (pays && !isOpen(path)) return sell(request);
383
389
  if (isOpen(path)) return null;
384
390
 
391
+ /*
392
+ * A search crawler that is who it says it is reads free and unmetered.
393
+ * Googlebot walking a large site from a handful of addresses outruns any
394
+ * per-address allowance, and answering it 402 is how a site leaves the
395
+ * index. Name and address must agree, so copying the string buys nothing.
396
+ */
397
+ if (welcomeCrawler(request, o.welcome)) return null;
398
+
385
399
  /*
386
400
  * Everyone else gets the free allowance. Running out is answered with a
387
401
  * price rather than a 429: the caller has just shown it wants more than
@@ -419,6 +433,21 @@ export function createGateway(options = {}) {
419
433
  */
420
434
  passFrom,
421
435
  verifyPass: honours,
436
+ /**
437
+ * Whether a request is a charged crawler by this gateway's rules (the
438
+ * lists, a spoofed or a stale browser string), and whether it is a welcome
439
+ * search crawler whose address matches its name. Exposed so a throttle
440
+ * that meters every route charges and spares exactly the same callers.
441
+ */
442
+ isPaid: (request) => {
443
+ const ua = request.headers.get('user-agent') ?? '';
444
+ return (
445
+ o.isPaidAgent(ua) ||
446
+ (o.chargeSpoofedBrowsers && isSpoofedBrowser(request)) ||
447
+ (o.chargeStaleBrowsers && isStaleBrowser(ua))
448
+ );
449
+ },
450
+ isWelcome: (request) => Boolean(welcomeCrawler(request, o.welcome)),
422
451
  /**
423
452
  * The x402 offer this gateway would make, and the full receipt body around
424
453
  * it, without answering a request.
@@ -435,7 +464,13 @@ export function createGateway(options = {}) {
435
464
  receipt,
436
465
  /** robots.txt with this gateway's lists and sales path. */
437
466
  robotsTxt: (extra = {}) =>
438
- robotsTxt({ siteUrl: o.siteUrl, path: o.path, training: o.training, retrieval: o.retrieval, ...extra }),
467
+ robotsTxt({
468
+ siteUrl: o.siteUrl,
469
+ path: o.path,
470
+ training: [...o.training, ...o.scrapers],
471
+ retrieval: o.retrieval,
472
+ ...extra,
473
+ }),
439
474
  /** The sales page as HTML, for a site that mounts it on a route of its own. */
440
475
  page: () => o.page(pageCtx()),
441
476
  };
@@ -484,6 +519,9 @@ function normalise(options) {
484
519
  const siteUrl = String(options.siteUrl ?? '').replace(/\/+$/, '');
485
520
  if (!siteUrl) throw new Error('createGateway needs siteUrl');
486
521
  const training = options.training ?? TRAINING_AGENTS;
522
+ const scrapers = options.scrapers ?? SCRAPER_AGENTS;
523
+ const headless = options.headless ?? HEADLESS_AGENTS;
524
+ const paid = [...training, ...scrapers, ...headless];
487
525
  return {
488
526
  siteUrl,
489
527
  siteName: options.siteName || new URL(siteUrl).hostname,
@@ -501,10 +539,14 @@ function normalise(options) {
501
539
  openPaths: options.openPaths ?? [],
502
540
  denyCidrs: options.denyCidrs ?? [],
503
541
  chargeSpoofedBrowsers: Boolean(options.chargeSpoofedBrowsers),
542
+ chargeStaleBrowsers: options.chargeStaleBrowsers !== false,
504
543
  exempt: options.exempt ?? null,
505
544
  training,
545
+ scrapers,
546
+ headless,
547
+ welcome: options.welcome === false ? null : (options.welcome ?? WELCOME_CRAWLERS),
506
548
  retrieval: options.retrieval ?? RETRIEVAL_AGENTS,
507
- isPaidAgent: options.isPaidAgent ?? ((ua) => isTrainingAgent(ua, training)),
549
+ isPaidAgent: options.isPaidAgent ?? ((ua) => isTrainingAgent(ua, paid)),
508
550
  secret: options.secret ?? '',
509
551
  page: options.page ?? renderPage,
510
552
  contact: options.contact ?? '',