@profullstack/x402-gateway 0.8.1 → 0.10.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/README.md CHANGED
@@ -92,6 +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` | 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' }`: 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. 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 |
95
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 |
96
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 |
97
101
  | `training`, `retrieval` | the lists in `./agents` | |
@@ -188,3 +192,13 @@ A pass is `cp_<payload>.<hmac>`: its own expiry and the payment's nonce, signed
188
192
  ## Licence
189
193
 
190
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,6 +70,14 @@ 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
+ /** 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
+ 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;
79
+ /** Require a one-line script (cookie + reload) on these paths before serving them. Off by default. */
80
+ challenge?: { paths: string[]; cookie?: string } | false;
73
81
  /** SEO resellers: charged, and refused in robots.txt. Default SCRAPER_AGENTS. */
74
82
  scrapers?: string[];
75
83
  /** Headless browsers that name themselves: charged. Default HEADLESS_AGENTS. */
@@ -215,11 +223,19 @@ export function wantsHtml(accept?: string | null): boolean;
215
223
  export const TRAINING_AGENTS: string[];
216
224
  export const SCRAPER_AGENTS: string[];
217
225
  export const HEADLESS_AGENTS: string[];
226
+ export const AI_INDEX_AGENTS: string[];
227
+ export function challengeToken(secret: string, ip: string, day?: number): Promise<string>;
228
+ export function passedChallenge(request: Request, options: { secret: string; cookie: string; ip: string; now?: number }): Promise<boolean>;
229
+ export function challengePage(options: { token: string; cookie: string; buyUrl: string }): string;
230
+ export function cookieValue(request: Request, name: string): string | null;
218
231
  export interface WelcomeCrawler {
219
232
  token: string;
220
233
  cidrs: string[];
221
234
  }
222
235
  export const WELCOME_CRAWLERS: WelcomeCrawler[];
236
+ export const VERIFIED_RETRIEVAL: WelcomeCrawler[];
237
+ /** The welcome crawler a request claims to be from outside its published ranges, or null. */
238
+ export function impostorCrawler(request: Request, list: WelcomeCrawler[]): string | null;
223
239
  export const RETRIEVAL_AGENTS: string[];
224
240
  export function isTrainingAgent(userAgent?: string | null, agents?: string[]): boolean;
225
241
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/x402-gateway",
3
- "version": "0.8.1",
3
+ "version": "0.10.0",
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
  *
@@ -51,12 +53,28 @@ export const SCRAPER_AGENTS = [
51
53
  * 151k requests from 63k addresses on one site in a day (2026-10-19). They do
52
54
  * not read robots.txt, so they are charged, never named there.
53
55
  */
56
+ /**
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`).
62
+ */
63
+ export const AI_INDEX_AGENTS = ['meta-webindexer', 'YouBot', 'ExaSearchBot', 'AionBot'];
64
+
54
65
  export const HEADLESS_AGENTS = ['Lightpanda', 'HeadlessChrome', 'PhantomJS'];
55
66
 
56
67
  /**
57
- * Retrieval crawlers, named in robots.txt so their operators can see they are
58
- * welcome. Google-Extended is Google's training token but Google documents it
59
- * 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.
60
78
  */
61
79
  export const RETRIEVAL_AGENTS = [
62
80
  'OAI-SearchBot',
@@ -67,6 +85,10 @@ export const RETRIEVAL_AGENTS = [
67
85
  'Perplexity-User',
68
86
  'Google-Extended',
69
87
  'Bingbot',
88
+ 'Meta-ExternalFetcher',
89
+ 'DuckAssistBot',
90
+ 'MistralAI-User',
91
+ ...AI_INDEX_AGENTS,
70
92
  ];
71
93
 
72
94
  const lower = (list) => list.map((t) => t.toLowerCase());
@@ -87,20 +109,35 @@ export function isTrainingAgent(userAgent = '', agents = TRAINING_AGENTS) {
87
109
 
88
110
  /**
89
111
  * 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.
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`).
93
117
  *
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.
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.
98
123
  */
99
124
  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'] },
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 },
106
143
  ];
@@ -0,0 +1,60 @@
1
+ /**
2
+ * A question only a browser answers: run this script, then ask again.
3
+ *
4
+ * The swarm that walks a site's item pages from a million residential
5
+ * addresses wears a current Chrome string, sends one request per address and
6
+ * never runs the page: on nichedb.dev (2026-10-19) 4.6M browser-looking
7
+ * requests a day came from 1.58M addresses, and the analytics beacon fired for
8
+ * about 2% of them. No user agent rule and no per-address allowance can see
9
+ * that. A person's browser can: it runs a one-line script that sets a cookie
10
+ * and reloads, once a day, and from then on is never asked again.
11
+ *
12
+ * The cookie is an HMAC of the caller's address and the day, so it cannot be
13
+ * minted without asking, and an address that rotates has to ask from each new
14
+ * address. The page that asks is a 402 carrying a link to the sales page, so a
15
+ * scraper that cannot run scripts learns what a pass costs, which is the same
16
+ * answer every other refused crawler gets.
17
+ */
18
+
19
+ const enc = new TextEncoder();
20
+ const DAY = 86_400_000;
21
+
22
+ async function hmac(secret, data) {
23
+ const key = await crypto.subtle.importKey('raw', enc.encode(String(secret)), { name: 'HMAC', hash: 'SHA-256' }, false, [
24
+ 'sign',
25
+ ]);
26
+ const sig = new Uint8Array(await crypto.subtle.sign('HMAC', key, enc.encode(data)));
27
+ let hex = '';
28
+ for (const b of sig.subarray(0, 16)) hex += b.toString(16).padStart(2, '0');
29
+ return hex;
30
+ }
31
+
32
+ /** The token a browser at `ip` gets on day `day` (days since the epoch). */
33
+ export const challengeToken = (secret, ip, day = Math.floor(Date.now() / DAY)) => hmac(secret, `js|${ip}|${day}`);
34
+
35
+ /** Read one cookie by name. */
36
+ export function cookieValue(request, name) {
37
+ const raw = request.headers.get('cookie') ?? '';
38
+ for (const part of raw.split(';')) {
39
+ const i = part.indexOf('=');
40
+ if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /** Whether the request carries today's or yesterday's token for its address. */
46
+ export async function passedChallenge(request, { secret, cookie, ip, now = Date.now() }) {
47
+ const got = cookieValue(request, cookie);
48
+ if (!got) return false;
49
+ const day = Math.floor(now / DAY);
50
+ for (const d of [day, day - 1]) if (got === (await challengeToken(secret, ip, d))) return true;
51
+ return false;
52
+ }
53
+
54
+ /** The page that asks. Tiny on purpose: answering it must cost us nothing. */
55
+ export function challengePage({ token, cookie, buyUrl }) {
56
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="robots" content="noindex"><title>One moment</title></head><body>
57
+ <script>document.cookie=${JSON.stringify(`${cookie}=${token}; Max-Age=172800; Path=/; SameSite=Lax; Secure`)};location.reload();</script>
58
+ <noscript><p>This page needs JavaScript. Automated access is sold by the day: <a href="${buyUrl}">${buyUrl}</a></p></noscript>
59
+ </body></html>`;
60
+ }
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,7 +1,8 @@
1
1
  import { HEADER as POWER_KEY_HEADER, isPowerKey, keyFrom, PROFULLSTACK_KEYS, SHOP_URL, verifyKey } from '@profullstack/keys';
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';
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
+ import { challengePage, challengeToken, passedChallenge } from './challenge.js';
5
6
  import { renderPage } from './page.js';
6
7
  import { mintPass, readPass } from './pass.js';
7
8
  import { robotsTxt } from './robots.js';
@@ -16,8 +17,28 @@ import {
16
17
  verifyAndSettle,
17
18
  } from './x402.js';
18
19
 
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';
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';
41
+ export { challengePage, challengeToken, cookieValue, passedChallenge } from './challenge.js';
21
42
  export { renderPage } from './page.js';
22
43
  export { mintPass, readPass } from './pass.js';
23
44
  export { robotsTxt } from './robots.js';
@@ -59,6 +80,10 @@ export { isPowerKey, keyFrom, PROFULLSTACK_KEYS, verifyKey } from '@profullstack
59
80
  * @param {boolean} [options.chargeStaleBrowsers=true] charge a desktop browser string two years behind its auto-updates (a rotation pool)
60
81
  * @param {string[]} [options.scrapers] SEO resellers charged and refused in robots.txt; default SCRAPER_AGENTS
61
82
  * @param {string[]} [options.headless] headless browsers charged; default HEADLESS_AGENTS
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}|false} [options.challenge] browser-looking requests to these paths must run a one-line script (cookie + reload) first; off by default
62
87
  * @param {{token: string, cidrs: string[]}[]|false} [options.welcome] search crawlers never metered when name and address agree; default WELCOME_CRAWLERS
63
88
  * @param {(request: Request) => boolean} [options.exempt] requests never charged, e.g. ones carrying a signed-in cookie
64
89
  * @param {string} [options.secret] pass signing secret; defaults to the CoinPay key
@@ -198,6 +223,40 @@ export function createGateway(options = {}) {
198
223
  return Boolean(await readPass(token, { secret }));
199
224
  };
200
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
+
201
260
  /**
202
261
  * Answer one request with the sale.
203
262
  *
@@ -381,11 +440,7 @@ export function createGateway(options = {}) {
381
440
  if (await honours(token)) return null;
382
441
 
383
442
  const ua = request.headers.get('user-agent') ?? '';
384
- const pays =
385
- o.isPaidAgent(ua) ||
386
- (o.chargeSpoofedBrowsers && isSpoofedBrowser(request)) ||
387
- (o.chargeStaleBrowsers && isStaleBrowser(ua));
388
- if (pays && !isOpen(path)) return sell(request);
443
+ if (isPaid(request) && !isOpen(path)) return sell(request);
389
444
  if (isOpen(path)) return null;
390
445
 
391
446
  /*
@@ -394,7 +449,32 @@ export function createGateway(options = {}) {
394
449
  * per-address allowance, and answering it 402 is how a site leaves the
395
450
  * index. Name and address must agree, so copying the string buys nothing.
396
451
  */
397
- if (welcomeCrawler(request, o.welcome)) return null;
452
+ if (isWelcome(request)) return null;
453
+
454
+ /*
455
+ * Every caller on a challenged path proves it runs scripts, except the
456
+ * welcome crawlers just above (search crawlers verified by address,
457
+ * retrieval crawlers and AI chat fetchers). Exempting anything that merely
458
+ * calls itself a bot would let the swarm walk in wearing "compatible;".
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.
462
+ */
463
+ if (o.challenge && secret && meters(o.challenge, path) && (request.method === 'GET' || request.method === 'HEAD')) {
464
+ const ip = clientIp(request);
465
+ if (ip && !(await passedChallenge(request, { secret, cookie: o.challenge.cookie, ip }))) {
466
+ if (!wantsHtml(request.headers.get('accept'))) {
467
+ return json(
468
+ receipt(1, {
469
+ error: `${path} is read in a browser (it sets a cookie with one line of script) or with a pass. Read ${buyUrl} for how.`,
470
+ }),
471
+ 402,
472
+ );
473
+ }
474
+ const token = await challengeToken(secret, ip);
475
+ return html(challengePage({ token, cookie: o.challenge.cookie, buyUrl }), 402);
476
+ }
477
+ }
398
478
 
399
479
  /*
400
480
  * Everyone else gets the free allowance. Running out is answered with a
@@ -439,15 +519,8 @@ export function createGateway(options = {}) {
439
519
  * search crawler whose address matches its name. Exposed so a throttle
440
520
  * that meters every route charges and spares exactly the same callers.
441
521
  */
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)),
522
+ isPaid,
523
+ isWelcome,
451
524
  /**
452
525
  * The x402 offer this gateway would make, and the full receipt body around
453
526
  * it, without answering a request.
@@ -467,7 +540,7 @@ export function createGateway(options = {}) {
467
540
  robotsTxt({
468
541
  siteUrl: o.siteUrl,
469
542
  path: o.path,
470
- training: [...o.training, ...o.scrapers],
543
+ training: [...o.training, ...o.scrapers, ...o.aiIndex],
471
544
  retrieval: o.retrieval,
472
545
  ...extra,
473
546
  }),
@@ -521,7 +594,8 @@ function normalise(options) {
521
594
  const training = options.training ?? TRAINING_AGENTS;
522
595
  const scrapers = options.scrapers ?? SCRAPER_AGENTS;
523
596
  const headless = options.headless ?? HEADLESS_AGENTS;
524
- const paid = [...training, ...scrapers, ...headless];
597
+ const aiIndex = options.aiIndex ?? [];
598
+ const paid = [...training, ...scrapers, ...headless, ...aiIndex];
525
599
  return {
526
600
  siteUrl,
527
601
  siteName: options.siteName || new URL(siteUrl).hostname,
@@ -540,11 +614,18 @@ function normalise(options) {
540
614
  denyCidrs: options.denyCidrs ?? [],
541
615
  chargeSpoofedBrowsers: Boolean(options.chargeSpoofedBrowsers),
542
616
  chargeStaleBrowsers: options.chargeStaleBrowsers !== false,
617
+ chargeImpostors: options.chargeImpostors !== false,
543
618
  exempt: options.exempt ?? null,
544
619
  training,
545
620
  scrapers,
546
621
  headless,
622
+ aiIndex,
623
+ challenge:
624
+ options.challenge && Array.isArray(options.challenge.paths) && options.challenge.paths.length
625
+ ? { paths: options.challenge.paths, cookie: options.challenge.cookie ?? 'pf_js' }
626
+ : null,
547
627
  welcome: options.welcome === false ? null : (options.welcome ?? WELCOME_CRAWLERS),
628
+ verifiedRetrieval: options.verifiedRetrieval === false ? null : (options.verifiedRetrieval ?? VERIFIED_RETRIEVAL),
548
629
  retrieval: options.retrieval ?? RETRIEVAL_AGENTS,
549
630
  isPaidAgent: options.isPaidAgent ?? ((ua) => isTrainingAgent(ua, paid)),
550
631
  secret: options.secret ?? '',