@profullstack/x402-gateway 0.9.0 → 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 +14 -2
- package/index.d.ts +8 -1
- package/package.json +3 -2
- package/src/agents.js +49 -21
- package/src/edge.js +101 -4
- package/src/index.js +83 -29
- package/src/ranges.js +1165 -0
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` |
|
|
96
|
-
| `
|
|
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 |
|
|
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,8 +70,12 @@ 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
|
-
/**
|
|
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
80
|
challenge?: { paths: string[]; cookie?: string } | false;
|
|
77
81
|
/** SEO resellers: charged, and refused in robots.txt. Default SCRAPER_AGENTS. */
|
|
@@ -229,6 +233,9 @@ export interface WelcomeCrawler {
|
|
|
229
233
|
cidrs: string[];
|
|
230
234
|
}
|
|
231
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;
|
|
232
239
|
export const RETRIEVAL_AGENTS: string[];
|
|
233
240
|
export function isTrainingAgent(userAgent?: string | null, agents?: string[]): boolean;
|
|
234
241
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/x402-gateway",
|
|
3
|
-
"version": "0.
|
|
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
|
*
|
|
@@ -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
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
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:
|
|
110
|
-
{
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
},
|
|
114
|
-
|
|
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
|
-
|
|
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
|
|
96
|
+
/** Whether an address (IPv4 or IPv6) falls inside any compiled range. */
|
|
53
97
|
export function inCidrs(ip, compiled) {
|
|
54
|
-
const
|
|
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 {
|
|
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 {
|
|
21
|
-
|
|
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,7 +80,9 @@ 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]
|
|
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
|
|
65
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
|
|
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
|
|
@@ -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
|
-
|
|
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,30 @@ 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 (
|
|
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
|
-
*
|
|
406
|
-
* crawlers
|
|
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
463
|
if (o.challenge && secret && meters(o.challenge, path) && (request.method === 'GET' || request.method === 'HEAD')) {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
if (
|
|
414
|
-
|
|
415
|
-
|
|
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
|
+
);
|
|
416
473
|
}
|
|
474
|
+
const token = await challengeToken(secret, ip);
|
|
475
|
+
return html(challengePage({ token, cookie: o.challenge.cookie, buyUrl }), 402);
|
|
417
476
|
}
|
|
418
477
|
}
|
|
419
478
|
|
|
@@ -460,15 +519,8 @@ export function createGateway(options = {}) {
|
|
|
460
519
|
* search crawler whose address matches its name. Exposed so a throttle
|
|
461
520
|
* that meters every route charges and spares exactly the same callers.
|
|
462
521
|
*/
|
|
463
|
-
isPaid
|
|
464
|
-
|
|
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)),
|
|
522
|
+
isPaid,
|
|
523
|
+
isWelcome,
|
|
472
524
|
/**
|
|
473
525
|
* The x402 offer this gateway would make, and the full receipt body around
|
|
474
526
|
* it, without answering a request.
|
|
@@ -542,7 +594,7 @@ function normalise(options) {
|
|
|
542
594
|
const training = options.training ?? TRAINING_AGENTS;
|
|
543
595
|
const scrapers = options.scrapers ?? SCRAPER_AGENTS;
|
|
544
596
|
const headless = options.headless ?? HEADLESS_AGENTS;
|
|
545
|
-
const aiIndex = options.aiIndex ??
|
|
597
|
+
const aiIndex = options.aiIndex ?? [];
|
|
546
598
|
const paid = [...training, ...scrapers, ...headless, ...aiIndex];
|
|
547
599
|
return {
|
|
548
600
|
siteUrl,
|
|
@@ -562,6 +614,7 @@ function normalise(options) {
|
|
|
562
614
|
denyCidrs: options.denyCidrs ?? [],
|
|
563
615
|
chargeSpoofedBrowsers: Boolean(options.chargeSpoofedBrowsers),
|
|
564
616
|
chargeStaleBrowsers: options.chargeStaleBrowsers !== false,
|
|
617
|
+
chargeImpostors: options.chargeImpostors !== false,
|
|
565
618
|
exempt: options.exempt ?? null,
|
|
566
619
|
training,
|
|
567
620
|
scrapers,
|
|
@@ -572,6 +625,7 @@ function normalise(options) {
|
|
|
572
625
|
? { paths: options.challenge.paths, cookie: options.challenge.cookie ?? 'pf_js' }
|
|
573
626
|
: null,
|
|
574
627
|
welcome: options.welcome === false ? null : (options.welcome ?? WELCOME_CRAWLERS),
|
|
628
|
+
verifiedRetrieval: options.verifiedRetrieval === false ? null : (options.verifiedRetrieval ?? VERIFIED_RETRIEVAL),
|
|
575
629
|
retrieval: options.retrieval ?? RETRIEVAL_AGENTS,
|
|
576
630
|
isPaidAgent: options.isPaidAgent ?? ((ua) => isTrainingAgent(ua, paid)),
|
|
577
631
|
secret: options.secret ?? '',
|