@profullstack/x402-gateway 0.9.0 → 0.10.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
@@ -92,8 +92,10 @@ export const GET = robotsRoute(gateway, { disallow: ['/login', '/api/'] });
92
92
  | `isPaidAgent` | training + scraper + headless lists | `(userAgent) => boolean` |
93
93
  | `scrapers` | `SCRAPER_AGENTS` | SEO resellers (AhrefsBot, SemrushBot, MJ12bot, DataForSeoBot, ...): charged and refused in robots.txt |
94
94
  | `headless` | `HEADLESS_AGENTS` | headless browsers that name themselves (Lightpanda, HeadlessChrome, PhantomJS): charged |
95
- | `aiIndex` | `AI_INDEX_AGENTS` | AI answer-engine indexers (meta-webindexer, YouBot, ExaSearchBot, AionBot): charged and named in robots.txt |
96
- | `challenge` | off | `{ paths: ['/i/'], cookie?: 'pf_js' }`: every GET to these paths must run a one-line script (sets a per-address, per-day HMAC cookie and reloads) or gets a 402. Named retrieval crawlers and address-verified search crawlers skip it; sessions and passes never see it. For residential swarms that wear a current browser string and never run the page |
95
+ | `aiIndex` | none | extra agents to charge and refuse in robots.txt. Since 0.10 the AI answer-engine indexers (`AI_INDEX_AGENTS`: meta-webindexer, YouBot, ExaSearchBot, AionBot) are welcome retrieval crawlers; pass `aiIndex: AI_INDEX_AGENTS` to charge them anyway |
96
+ | `verifiedRetrieval` | `VERIFIED_RETRIEVAL` | retrieval crawlers believed only from their operator's published addresses (OAI-SearchBot, ChatGPT-User, PerplexityBot, Perplexity-User); from elsewhere they are metered. Other retrieval and AI chat fetchers (Claude-User, Claude-SearchBot, Meta-ExternalFetcher, DuckAssistBot, MistralAI-User, the AI indexers) publish no addresses and are believed by name |
97
+ | `chargeImpostors` | `true` | a request wearing a `welcome` crawler's name (Googlebot, bingbot, Applebot, DuckDuckBot, Cloudflare-*) from an address outside that operator's published list is charged. Never judged without a readable address |
98
+ | `challenge` | off | `{ paths: ['/i/'], cookie?: 'pf_js', when?: (request, url) => boolean }`: every GET to these paths (that `when` accepts, if given) must run a one-line script (sets a per-address, per-day HMAC cookie and reloads) or gets a 402. Welcome crawlers (address-verified search crawlers, retrieval crawlers, AI chat fetchers) skip it; a client that did not ask for HTML gets the JSON offer instead of the script; sessions and passes never see it. For residential swarms that wear a current browser string and never run the page |
97
99
  | `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 |
98
100
  | `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 |
99
101
  | `training`, `retrieval` | the lists in `./agents` | |
@@ -190,3 +192,13 @@ A pass is `cp_<payload>.<hmac>`: its own expiry and the payment's nonce, signed
190
192
  ## Licence
191
193
 
192
194
  MIT
195
+
196
+ ## Crawler address lists
197
+
198
+ `src/ranges.js` is generated from each operator's published list (Google's googlebot.json, Bing, DuckDuckGo, OpenAI's searchbot/chatgpt-user, Perplexity, Cloudflare's ips-v4/ips-v6). Refresh it before every release:
199
+
200
+ ```sh
201
+ npm run ranges
202
+ ```
203
+
204
+ Behind Cloudflare's proxy the caller is read from `CF-Connecting-IP`, but only when the edge hop is itself a Cloudflare address.
package/index.d.ts CHANGED
@@ -70,10 +70,15 @@ export interface GatewayOptions {
70
70
  chargeSpoofedBrowsers?: boolean;
71
71
  /** Charge a desktop browser string ~2 years behind its auto-updates (a scraper rotation pool). Default true. */
72
72
  chargeStaleBrowsers?: boolean;
73
- /** AI answer-engine indexers: charged, named in robots.txt. Default AI_INDEX_AGENTS. */
73
+ /** Extra agents to charge and refuse in robots.txt, e.g. AI_INDEX_AGENTS. Default none: since 0.10 the AI indexers are welcome retrieval crawlers. */
74
74
  aiIndex?: string[];
75
+ /** Retrieval crawlers believed only from their published addresses (OpenAI, Perplexity). Default VERIFIED_RETRIEVAL; false disables. */
76
+ verifiedRetrieval?: WelcomeCrawler[] | false;
77
+ /** Charge a request wearing a welcome search crawler's name from outside its published ranges. Default true. */
78
+ chargeImpostors?: boolean;
75
79
  /** Require a one-line script (cookie + reload) on these paths before serving them. Off by default. */
76
- challenge?: { paths: string[]; cookie?: string } | false;
80
+ /** Only GETs to `paths` that `when` accepts (default: all of them), e.g. `(req, url) => url.search.length > 1` for faceted URLs. */
81
+ challenge?: { paths: string[]; cookie?: string; when?: (request: Request, url: URL) => boolean } | false;
77
82
  /** SEO resellers: charged, and refused in robots.txt. Default SCRAPER_AGENTS. */
78
83
  scrapers?: string[];
79
84
  /** Headless browsers that name themselves: charged. Default HEADLESS_AGENTS. */
@@ -229,6 +234,9 @@ export interface WelcomeCrawler {
229
234
  cidrs: string[];
230
235
  }
231
236
  export const WELCOME_CRAWLERS: WelcomeCrawler[];
237
+ export const VERIFIED_RETRIEVAL: WelcomeCrawler[];
238
+ /** The welcome crawler a request claims to be from outside its published ranges, or null. */
239
+ export function impostorCrawler(request: Request, list: WelcomeCrawler[]): string | null;
232
240
  export const RETRIEVAL_AGENTS: string[];
233
241
  export function isTrainingAgent(userAgent?: string | null, agents?: string[]): boolean;
234
242
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/x402-gateway",
3
- "version": "0.9.0",
3
+ "version": "0.10.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": [
@@ -69,7 +69,8 @@
69
69
  },
70
70
  "sideEffects": false,
71
71
  "scripts": {
72
- "test": "node --test"
72
+ "test": "node --test",
73
+ "ranges": "node scripts/ranges.mjs"
73
74
  },
74
75
  "dependencies": {
75
76
  "@profullstack/keys": "^0.1.0"
package/src/agents.js CHANGED
@@ -1,3 +1,5 @@
1
+ import * as R from './ranges.js';
2
+
1
3
  /**
2
4
  * Which crawlers pay, and which read free.
3
5
  *
@@ -52,20 +54,27 @@ export const SCRAPER_AGENTS = [
52
54
  * not read robots.txt, so they are charged, never named there.
53
55
  */
54
56
  /**
55
- * Crawlers that feed an AI answer engine's own index and send next to nobody
56
- * back: measured on our fleet 2026-10-19, meta-webindexer alone was ~5k
57
- * requests an hour with no referrals to show for it. Anthony's call the same
58
- * day: charge them like training crawlers. They read robots.txt, so they are
59
- * named there too.
57
+ * Crawlers that feed an AI answer engine's own index: meta-webindexer, YouBot,
58
+ * ExaSearchBot, AionBot. 0.9.0 charged them; Anthony's call on 2026-10-19 was
59
+ * the other way ("allow ... ai chat bots"), so since 0.10.0 they are retrieval
60
+ * crawlers: named welcome in robots.txt and never metered. The list stays
61
+ * exported for a site that wants to charge them anyway (`aiIndex`).
60
62
  */
61
63
  export const AI_INDEX_AGENTS = ['meta-webindexer', 'YouBot', 'ExaSearchBot', 'AionBot'];
62
64
 
63
65
  export const HEADLESS_AGENTS = ['Lightpanda', 'HeadlessChrome', 'PhantomJS'];
64
66
 
65
67
  /**
66
- * Retrieval crawlers, named in robots.txt so their operators can see they are
67
- * welcome. Google-Extended is Google's training token but Google documents it
68
- * as also gating grounding in the Gemini app, so it stays on this side.
68
+ * Retrieval crawlers and AI chat fetchers, named in robots.txt so their
69
+ * operators can see they are welcome, and never metered: a person asked an
70
+ * assistant about this page and the fetch is that person reading it.
71
+ * Google-Extended is Google's training token but Google documents it as also
72
+ * gating grounding in the Gemini app, so it stays on this side.
73
+ *
74
+ * Where the operator publishes its addresses (OpenAI, Perplexity) the name is
75
+ * only believed from them: see VERIFIED_RETRIEVAL. The rest are believed by
76
+ * name, which a swarm can copy; ThreatCrush's repeat-402 ladder is the
77
+ * backstop for that, not this list.
69
78
  */
70
79
  export const RETRIEVAL_AGENTS = [
71
80
  'OAI-SearchBot',
@@ -76,6 +85,10 @@ export const RETRIEVAL_AGENTS = [
76
85
  'Perplexity-User',
77
86
  'Google-Extended',
78
87
  'Bingbot',
88
+ 'Meta-ExternalFetcher',
89
+ 'DuckAssistBot',
90
+ 'MistralAI-User',
91
+ ...AI_INDEX_AGENTS,
79
92
  ];
80
93
 
81
94
  const lower = (list) => list.map((t) => t.toLowerCase());
@@ -96,20 +109,35 @@ export function isTrainingAgent(userAgent = '', agents = TRAINING_AGENTS) {
96
109
 
97
110
  /**
98
111
  * Search crawlers that are welcome everywhere, with the address ranges their
99
- * operators publish. A user agent alone is not enough to spare a caller from
100
- * the free allowance -- "Googlebot" is the first string a scraper copies -- so
101
- * a crawler is only spared when its address agrees with its name.
112
+ * operators publish. A user agent alone is not enough to spare a caller --
113
+ * "Googlebot" is the first string a scraper copies -- so a crawler is only
114
+ * spared when its address agrees with its name, and a request that wears one
115
+ * of these names from an address outside the list is an impostor and is
116
+ * charged (`chargeImpostors`).
102
117
  *
103
- * Ranges are the stable blocks of each operator's published list
104
- * (developers.google.com/static/search/apis/ipranges/googlebot.json,
105
- * bing.com/toolbox/bingbot.json, Apple's 17.0.0.0/8); a crawl from outside them
106
- * is simply metered like everyone else, which fails safe.
118
+ * The lists are the operators' own (src/ranges.js, regenerated by
119
+ * scripts/ranges.mjs before each release): googlebot.json, bingbot.json,
120
+ * duckduckbot.json, Cloudflare's ips-v4/ips-v6 for its own crawlers
121
+ * (Always Online, health checks, Radar), and Apple's 17.0.0.0/8 plus its
122
+ * AS714 IPv6 blocks for Applebot.
107
123
  */
108
124
  export const WELCOME_CRAWLERS = [
109
- { token: 'Googlebot', cidrs: ['66.249.64.0/19', '192.178.4.0/22', '34.100.182.96/28'] },
110
- {
111
- token: 'bingbot',
112
- 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'],
113
- },
114
- { token: 'Applebot', cidrs: ['17.0.0.0/8'] },
125
+ { token: 'Googlebot', cidrs: R.googlebot },
126
+ { token: 'bingbot', cidrs: R.bingbot },
127
+ { token: 'Applebot', cidrs: ['17.0.0.0/8', '2620:149::/32', '2a01:b740::/32'] },
128
+ { token: 'DuckDuckBot', cidrs: R.duckduckbot },
129
+ { token: 'Cloudflare-', cidrs: R.cloudflare },
130
+ ];
131
+
132
+ /**
133
+ * Retrieval crawlers whose operators publish their addresses. These are
134
+ * unmetered only from those addresses; from anywhere else they are metered
135
+ * like any caller (not charged outright: the lists move faster than releases,
136
+ * and a real ChatGPT-User from a new block should still read the page).
137
+ */
138
+ export const VERIFIED_RETRIEVAL = [
139
+ { token: 'OAI-SearchBot', cidrs: R.oaiSearchbot },
140
+ { token: 'ChatGPT-User', cidrs: R.chatgptUser },
141
+ { token: 'PerplexityBot', cidrs: R.perplexitybot },
142
+ { token: 'Perplexity-User', cidrs: R.perplexityUser },
115
143
  ];
package/src/edge.js CHANGED
@@ -1,3 +1,5 @@
1
+ import * as RANGES from './ranges.js';
2
+
1
3
  /**
2
4
  * The two checks that do not need a user agent to be honest.
3
5
  *
@@ -33,9 +35,51 @@ function ipv4ToInt(ip) {
33
35
  return n;
34
36
  }
35
37
 
36
- /** Parse "a.b.c.d/len" (or a bare address) into a matcher. Null if unreadable. */
38
+ function ipv6ToBig(ip) {
39
+ let s = String(ip).toLowerCase();
40
+ if (s.startsWith('[') && s.endsWith(']')) s = s.slice(1, -1);
41
+ const zone = s.indexOf('%');
42
+ if (zone >= 0) s = s.slice(0, zone);
43
+ if (!s.includes(':')) return null;
44
+ // An IPv4 tail ("::ffff:1.2.3.4") becomes the last two groups.
45
+ const tail = /(\d+\.\d+\.\d+\.\d+)$/.exec(s);
46
+ if (tail) {
47
+ const v4 = ipv4ToInt(tail[1]);
48
+ if (v4 === null) return null;
49
+ s = `${s.slice(0, -tail[1].length)}${(v4 >>> 16).toString(16)}:${(v4 & 0xffff).toString(16)}`;
50
+ }
51
+ const halves = s.split('::');
52
+ if (halves.length > 2) return null;
53
+ const head = halves[0] ? halves[0].split(':') : [];
54
+ const rest = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
55
+ const fill = halves.length === 2 ? 8 - head.length - rest.length : 0;
56
+ if (fill < 0 || (halves.length === 1 && head.length !== 8)) return null;
57
+ const groups = [...head, ...Array(fill).fill('0'), ...rest];
58
+ if (groups.length !== 8) return null;
59
+ let n = 0n;
60
+ for (const g of groups) {
61
+ if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
62
+ n = (n << 16n) | BigInt(Number.parseInt(g, 16));
63
+ }
64
+ return n;
65
+ }
66
+
67
+ const ALL6 = (1n << 128n) - 1n;
68
+
69
+ /**
70
+ * Parse "a.b.c.d/len" or "2001:db8::/32" (or a bare address) into a matcher.
71
+ * Null if unreadable.
72
+ */
37
73
  export function parseCidr(cidr) {
38
74
  const [ip, lenRaw] = String(cidr).trim().split('/');
75
+ if (ip.includes(':')) {
76
+ const base = ipv6ToBig(ip);
77
+ if (base === null) return null;
78
+ const len = lenRaw === undefined ? 128 : Number(lenRaw);
79
+ if (!Number.isInteger(len) || len < 0 || len > 128) return null;
80
+ const mask = len === 0 ? 0n : (ALL6 << BigInt(128 - len)) & ALL6;
81
+ return { v6: true, base: base & mask, mask, text: `${ip}/${len}` };
82
+ }
39
83
  const base = ipv4ToInt(ip);
40
84
  if (base === null) return null;
41
85
  const len = lenRaw === undefined ? 32 : Number(lenRaw);
@@ -49,13 +93,23 @@ export function compileCidrs(list = []) {
49
93
  return list.map(parseCidr).filter(Boolean);
50
94
  }
51
95
 
52
- /** Whether an IPv4 address falls inside any compiled range. */
96
+ /** Whether an address (IPv4 or IPv6) falls inside any compiled range. */
53
97
  export function inCidrs(ip, compiled) {
54
- const n = ipv4ToInt(String(ip ?? '').trim());
98
+ const s = String(ip ?? '').trim();
99
+ if (s.includes(':')) {
100
+ const n = ipv6ToBig(s);
101
+ if (n === null) return false;
102
+ // An IPv4-mapped address is the IPv4 address it maps.
103
+ if (n >> 32n === 0xffffn) return inCidrs(int4(Number(n & 0xffffffffn)), compiled);
104
+ return compiled.some((c) => c.v6 && (n & c.mask) === c.base);
105
+ }
106
+ const n = ipv4ToInt(s);
55
107
  if (n === null) return false;
56
- return compiled.some((c) => ((n & c.mask) >>> 0) === c.base);
108
+ return compiled.some((c) => !c.v6 && ((n & c.mask) >>> 0) === c.base);
57
109
  }
58
110
 
111
+ const int4 = (n) => [n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join('.');
112
+
59
113
  /**
60
114
  * The caller's address, as the edge reported it.
61
115
  *
@@ -72,6 +126,20 @@ export function inCidrs(ip, compiled) {
72
126
  * refused, nothing is charged, by this check.
73
127
  */
74
128
  export function clientIp(request) {
129
+ const edge = edgeIp(request);
130
+ /*
131
+ * Behind Cloudflare's proxy the edge hop is a Cloudflare address and the
132
+ * caller is in CF-Connecting-IP. That header is only believed when the hop
133
+ * really is Cloudflare's, so a client cannot set it to choose its address.
134
+ */
135
+ if (edge && inCidrs(edge, cloudflare())) {
136
+ const cf = request.headers.get('cf-connecting-ip')?.trim();
137
+ if (cf) return cf;
138
+ }
139
+ return edge;
140
+ }
141
+
142
+ function edgeIp(request) {
75
143
  const real = request.headers.get('x-real-ip')?.trim();
76
144
  if (real) return real;
77
145
  const xff = request.headers.get('x-forwarded-for');
@@ -80,6 +148,9 @@ export function clientIp(request) {
80
148
  return hops[hops.length - 1] ?? '';
81
149
  }
82
150
 
151
+ let CF = null;
152
+ const cloudflare = () => (CF ??= compileCidrs(RANGES.cloudflare));
153
+
83
154
  /* -------------------------------------------------------------- spoofing -- */
84
155
 
85
156
  const CLAIMS_CHROMIUM = /\bChrome\/\d+/;
@@ -210,3 +281,29 @@ export function welcomeCrawler(request, list) {
210
281
  }
211
282
  return null;
212
283
  }
284
+
285
+ /**
286
+ * The welcome crawler a request claims to be but is not: its user agent names
287
+ * one of `list` and its address is outside that crawler's published ranges.
288
+ * Null when it names none, when it is the real thing, or when there is no
289
+ * readable address to judge (fail safe: never charge on a guess).
290
+ *
291
+ * @param {Request} request
292
+ * @param {{token: string, cidrs: string[]}[]} list
293
+ */
294
+ export function impostorCrawler(request, list) {
295
+ if (!list?.length) return null;
296
+ const ua = (request.headers.get('user-agent') ?? '').toLowerCase();
297
+ if (!ua) return null;
298
+ const ip = clientIp(request);
299
+ if (!ip || (!ip.includes(':') && !/^\d+\.\d+\.\d+\.\d+$/.test(ip))) return null;
300
+ let claimed = null;
301
+ for (const c of list) {
302
+ if (!ua.includes(c.token.toLowerCase())) continue;
303
+ let compiled = COMPILED.get(c);
304
+ if (!compiled) COMPILED.set(c, (compiled = compileCidrs(c.cidrs)));
305
+ if (inCidrs(ip, compiled)) return null;
306
+ claimed ??= c.token;
307
+ }
308
+ return claimed;
309
+ }
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 { AI_INDEX_AGENTS, 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';
2
+ import { HEADLESS_AGENTS, isTrainingAgent, RETRIEVAL_AGENTS, SCRAPER_AGENTS, TRAINING_AGENTS, VERIFIED_RETRIEVAL, WELCOME_CRAWLERS } from './agents.js';
3
+ import { clientIp, compileCidrs, impostorCrawler, inCidrs, isSpoofedBrowser, isStaleBrowser, welcomeCrawler } from './edge.js';
4
4
  import { memoryQuotaStore, meters, normaliseQuota, quotaHeaders, spend } from './quota.js';
5
5
  import { challengePage, challengeToken, passedChallenge } from './challenge.js';
6
6
  import { renderPage } from './page.js';
@@ -17,8 +17,27 @@ import {
17
17
  verifyAndSettle,
18
18
  } from './x402.js';
19
19
 
20
- export { AI_INDEX_AGENTS, HEADLESS_AGENTS, isTrainingAgent, RETRIEVAL_AGENTS, SCRAPER_AGENTS, TRAINING_AGENTS, WELCOME_CRAWLERS } from './agents.js';
21
- export { clientIp, compileCidrs, currentMajor, inCidrs, isSpoofedBrowser, isStaleBrowser, parseCidr, welcomeCrawler } from './edge.js';
20
+ export {
21
+ AI_INDEX_AGENTS,
22
+ HEADLESS_AGENTS,
23
+ isTrainingAgent,
24
+ RETRIEVAL_AGENTS,
25
+ SCRAPER_AGENTS,
26
+ TRAINING_AGENTS,
27
+ VERIFIED_RETRIEVAL,
28
+ WELCOME_CRAWLERS,
29
+ } from './agents.js';
30
+ export {
31
+ clientIp,
32
+ compileCidrs,
33
+ currentMajor,
34
+ impostorCrawler,
35
+ inCidrs,
36
+ isSpoofedBrowser,
37
+ isStaleBrowser,
38
+ parseCidr,
39
+ welcomeCrawler,
40
+ } from './edge.js';
22
41
  export { challengePage, challengeToken, cookieValue, passedChallenge } from './challenge.js';
23
42
  export { renderPage } from './page.js';
24
43
  export { mintPass, readPass } from './pass.js';
@@ -61,8 +80,10 @@ export { isPowerKey, keyFrom, PROFULLSTACK_KEYS, verifyKey } from '@profullstack
61
80
  * @param {boolean} [options.chargeStaleBrowsers=true] charge a desktop browser string two years behind its auto-updates (a rotation pool)
62
81
  * @param {string[]} [options.scrapers] SEO resellers charged and refused in robots.txt; default SCRAPER_AGENTS
63
82
  * @param {string[]} [options.headless] headless browsers charged; default HEADLESS_AGENTS
64
- * @param {string[]} [options.aiIndex] AI answer-engine indexers charged and named in robots.txt; default AI_INDEX_AGENTS
65
- * @param {{paths: string[], cookie?: string}|false} [options.challenge] browser-looking requests to these paths must run a one-line script (cookie + reload) first; off by default
83
+ * @param {string[]} [options.aiIndex] extra agents to charge and refuse in robots.txt, e.g. AI_INDEX_AGENTS; default none (they are welcome retrieval crawlers since 0.10)
84
+ * @param {{token: string, cidrs: string[]}[]|false} [options.verifiedRetrieval] retrieval crawlers believed only from their published addresses; default VERIFIED_RETRIEVAL
85
+ * @param {boolean} [options.chargeImpostors=true] charge a request wearing a welcome search crawler's name from outside its published ranges
86
+ * @param {{paths: string[], cookie?: string, when?: (request: Request, url: URL) => boolean}|false} [options.challenge] requests to these paths (and, with `when`, only those it accepts, e.g. faceted URLs) must run a one-line script (cookie + reload) first; off by default
66
87
  * @param {{token: string, cidrs: string[]}[]|false} [options.welcome] search crawlers never metered when name and address agree; default WELCOME_CRAWLERS
67
88
  * @param {(request: Request) => boolean} [options.exempt] requests never charged, e.g. ones carrying a signed-in cookie
68
89
  * @param {string} [options.secret] pass signing secret; defaults to the CoinPay key
@@ -202,6 +223,40 @@ export function createGateway(options = {}) {
202
223
  return Boolean(await readPass(token, { secret }));
203
224
  };
204
225
 
226
+ /**
227
+ * Whether a request is charged by the lists, as a spoofed or stale browser
228
+ * string, or as an impostor wearing a welcome search crawler's name from an
229
+ * address that crawler's operator does not publish.
230
+ */
231
+ const isPaid = (request) => {
232
+ const ua = request.headers.get('user-agent') ?? '';
233
+ return Boolean(
234
+ o.isPaidAgent(ua) ||
235
+ (o.chargeSpoofedBrowsers && isSpoofedBrowser(request)) ||
236
+ (o.chargeStaleBrowsers && isStaleBrowser(ua)) ||
237
+ (o.chargeImpostors && impostorCrawler(request, o.welcome)),
238
+ );
239
+ };
240
+
241
+ /**
242
+ * Whether a request reads free and unmetered: a search crawler whose address
243
+ * agrees with its name, a retrieval crawler from its operator's published
244
+ * addresses, or a named retrieval crawler / AI chat fetcher whose operator
245
+ * publishes none. A name that belongs to an address-checked list is never
246
+ * believed on its name alone.
247
+ */
248
+ const isWelcome = (request) => {
249
+ if (welcomeCrawler(request, o.welcome)) return true;
250
+ if (welcomeCrawler(request, o.verifiedRetrieval)) return true;
251
+ const ua = (request.headers.get('user-agent') ?? '').toLowerCase();
252
+ if (!ua) return false;
253
+ // The default lists count even when switched off: switching off address
254
+ // checks must not turn "bingbot" into a name anyone can wear.
255
+ const checked = [...WELCOME_CRAWLERS, ...VERIFIED_RETRIEVAL, ...(o.welcome ?? []), ...(o.verifiedRetrieval ?? [])];
256
+ if (checked.some((c) => ua.includes(c.token.toLowerCase()))) return false;
257
+ return isTrainingAgent(ua, o.retrieval);
258
+ };
259
+
205
260
  /**
206
261
  * Answer one request with the sale.
207
262
  *
@@ -385,11 +440,7 @@ export function createGateway(options = {}) {
385
440
  if (await honours(token)) return null;
386
441
 
387
442
  const ua = request.headers.get('user-agent') ?? '';
388
- const pays =
389
- o.isPaidAgent(ua) ||
390
- (o.chargeSpoofedBrowsers && isSpoofedBrowser(request)) ||
391
- (o.chargeStaleBrowsers && isStaleBrowser(ua));
392
- if (pays && !isOpen(path)) return sell(request);
443
+ if (isPaid(request) && !isOpen(path)) return sell(request);
393
444
  if (isOpen(path)) return null;
394
445
 
395
446
  /*
@@ -398,22 +449,36 @@ export function createGateway(options = {}) {
398
449
  * per-address allowance, and answering it 402 is how a site leaves the
399
450
  * index. Name and address must agree, so copying the string buys nothing.
400
451
  */
401
- if (welcomeCrawler(request, o.welcome)) return null;
452
+ if (isWelcome(request)) return null;
402
453
 
403
454
  /*
404
455
  * Every caller on a challenged path proves it runs scripts, except the
405
- * retrieval crawlers named as welcome (they cite back) and the search
406
- * crawlers verified by address just above. Exempting anything that merely
456
+ * welcome crawlers just above (search crawlers verified by address,
457
+ * retrieval crawlers and AI chat fetchers). Exempting anything that merely
407
458
  * calls itself a bot would let the swarm walk in wearing "compatible;".
408
- * Only GET and HEAD, and only with a secret to sign the answer.
459
+ * Only GET and HEAD, and only with a secret to sign the answer. A caller
460
+ * that asked for HTML gets the one-line script; anything else gets the
461
+ * JSON offer, since it was never going to run a script.
409
462
  */
410
- if (o.challenge && secret && meters(o.challenge, path) && (request.method === 'GET' || request.method === 'HEAD')) {
411
- if (!isTrainingAgent(request.headers.get('user-agent') ?? '', o.retrieval)) {
412
- const ip = clientIp(request);
413
- if (ip && !(await passedChallenge(request, { secret, cookie: o.challenge.cookie, ip }))) {
414
- const token = await challengeToken(secret, ip);
415
- return html(challengePage({ token, cookie: o.challenge.cookie, buyUrl }), 402);
463
+ if (
464
+ o.challenge &&
465
+ secret &&
466
+ meters(o.challenge, path) &&
467
+ (!o.challenge.when || o.challenge.when(request, new URL(request.url))) &&
468
+ (request.method === 'GET' || request.method === 'HEAD')
469
+ ) {
470
+ const ip = clientIp(request);
471
+ if (ip && !(await passedChallenge(request, { secret, cookie: o.challenge.cookie, ip }))) {
472
+ if (!wantsHtml(request.headers.get('accept'))) {
473
+ return json(
474
+ receipt(1, {
475
+ error: `${path} is read in a browser (it sets a cookie with one line of script) or with a pass. Read ${buyUrl} for how.`,
476
+ }),
477
+ 402,
478
+ );
416
479
  }
480
+ const token = await challengeToken(secret, ip);
481
+ return html(challengePage({ token, cookie: o.challenge.cookie, buyUrl }), 402);
417
482
  }
418
483
  }
419
484
 
@@ -460,15 +525,8 @@ export function createGateway(options = {}) {
460
525
  * search crawler whose address matches its name. Exposed so a throttle
461
526
  * that meters every route charges and spares exactly the same callers.
462
527
  */
463
- isPaid: (request) => {
464
- const ua = request.headers.get('user-agent') ?? '';
465
- return (
466
- o.isPaidAgent(ua) ||
467
- (o.chargeSpoofedBrowsers && isSpoofedBrowser(request)) ||
468
- (o.chargeStaleBrowsers && isStaleBrowser(ua))
469
- );
470
- },
471
- isWelcome: (request) => Boolean(welcomeCrawler(request, o.welcome)),
528
+ isPaid,
529
+ isWelcome,
472
530
  /**
473
531
  * The x402 offer this gateway would make, and the full receipt body around
474
532
  * it, without answering a request.
@@ -542,7 +600,7 @@ function normalise(options) {
542
600
  const training = options.training ?? TRAINING_AGENTS;
543
601
  const scrapers = options.scrapers ?? SCRAPER_AGENTS;
544
602
  const headless = options.headless ?? HEADLESS_AGENTS;
545
- const aiIndex = options.aiIndex ?? AI_INDEX_AGENTS;
603
+ const aiIndex = options.aiIndex ?? [];
546
604
  const paid = [...training, ...scrapers, ...headless, ...aiIndex];
547
605
  return {
548
606
  siteUrl,
@@ -562,6 +620,7 @@ function normalise(options) {
562
620
  denyCidrs: options.denyCidrs ?? [],
563
621
  chargeSpoofedBrowsers: Boolean(options.chargeSpoofedBrowsers),
564
622
  chargeStaleBrowsers: options.chargeStaleBrowsers !== false,
623
+ chargeImpostors: options.chargeImpostors !== false,
565
624
  exempt: options.exempt ?? null,
566
625
  training,
567
626
  scrapers,
@@ -569,9 +628,14 @@ function normalise(options) {
569
628
  aiIndex,
570
629
  challenge:
571
630
  options.challenge && Array.isArray(options.challenge.paths) && options.challenge.paths.length
572
- ? { paths: options.challenge.paths, cookie: options.challenge.cookie ?? 'pf_js' }
631
+ ? {
632
+ paths: options.challenge.paths,
633
+ cookie: options.challenge.cookie ?? 'pf_js',
634
+ when: typeof options.challenge.when === 'function' ? options.challenge.when : null,
635
+ }
573
636
  : null,
574
637
  welcome: options.welcome === false ? null : (options.welcome ?? WELCOME_CRAWLERS),
638
+ verifiedRetrieval: options.verifiedRetrieval === false ? null : (options.verifiedRetrieval ?? VERIFIED_RETRIEVAL),
575
639
  retrieval: options.retrieval ?? RETRIEVAL_AGENTS,
576
640
  isPaidAgent: options.isPaidAgent ?? ((ua) => isTrainingAgent(ua, paid)),
577
641
  secret: options.secret ?? '',