@profullstack/x402-gateway 0.8.0 → 0.9.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 +2 -0
- package/index.d.ts +9 -0
- package/package.json +1 -1
- package/src/agents.js +9 -0
- package/src/challenge.js +60 -0
- package/src/edge.js +7 -2
- package/src/index.js +31 -4
package/README.md
CHANGED
|
@@ -92,6 +92,8 @@ 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
97
|
| `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
98
|
| `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
99
|
| `training`, `retrieval` | the lists in `./agents` | |
|
package/index.d.ts
CHANGED
|
@@ -70,6 +70,10 @@ 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. */
|
|
74
|
+
aiIndex?: string[];
|
|
75
|
+
/** Require a one-line script (cookie + reload) on these paths before serving them. Off by default. */
|
|
76
|
+
challenge?: { paths: string[]; cookie?: string } | false;
|
|
73
77
|
/** SEO resellers: charged, and refused in robots.txt. Default SCRAPER_AGENTS. */
|
|
74
78
|
scrapers?: string[];
|
|
75
79
|
/** Headless browsers that name themselves: charged. Default HEADLESS_AGENTS. */
|
|
@@ -215,6 +219,11 @@ export function wantsHtml(accept?: string | null): boolean;
|
|
|
215
219
|
export const TRAINING_AGENTS: string[];
|
|
216
220
|
export const SCRAPER_AGENTS: string[];
|
|
217
221
|
export const HEADLESS_AGENTS: string[];
|
|
222
|
+
export const AI_INDEX_AGENTS: string[];
|
|
223
|
+
export function challengeToken(secret: string, ip: string, day?: number): Promise<string>;
|
|
224
|
+
export function passedChallenge(request: Request, options: { secret: string; cookie: string; ip: string; now?: number }): Promise<boolean>;
|
|
225
|
+
export function challengePage(options: { token: string; cookie: string; buyUrl: string }): string;
|
|
226
|
+
export function cookieValue(request: Request, name: string): string | null;
|
|
218
227
|
export interface WelcomeCrawler {
|
|
219
228
|
token: string;
|
|
220
229
|
cidrs: string[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/x402-gateway",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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": [
|
package/src/agents.js
CHANGED
|
@@ -51,6 +51,15 @@ export const SCRAPER_AGENTS = [
|
|
|
51
51
|
* 151k requests from 63k addresses on one site in a day (2026-10-19). They do
|
|
52
52
|
* not read robots.txt, so they are charged, never named there.
|
|
53
53
|
*/
|
|
54
|
+
/**
|
|
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.
|
|
60
|
+
*/
|
|
61
|
+
export const AI_INDEX_AGENTS = ['meta-webindexer', 'YouBot', 'ExaSearchBot', 'AionBot'];
|
|
62
|
+
|
|
54
63
|
export const HEADLESS_AGENTS = ['Lightpanda', 'HeadlessChrome', 'PhantomJS'];
|
|
55
64
|
|
|
56
65
|
/**
|
package/src/challenge.js
ADDED
|
@@ -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
|
@@ -143,6 +143,8 @@ const TERMINAL_CHROME = [
|
|
|
143
143
|
{ major: 138, os: /Macintosh/ }, // macOS 11
|
|
144
144
|
];
|
|
145
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/;
|
|
146
148
|
|
|
147
149
|
/**
|
|
148
150
|
* A desktop browser string far older than anything that auto-updates.
|
|
@@ -163,8 +165,11 @@ const FIREFOX_ESR = [115, 128, 140, 153];
|
|
|
163
165
|
*/
|
|
164
166
|
export function isStaleBrowser(ua, now = Date.now(), maxMajorsBehind = 24) {
|
|
165
167
|
const s = String(ua ?? '');
|
|
166
|
-
if (!/Windows NT|Macintosh|X11; (?:Linux|Ubuntu|Fedora)/.test(s)) return false;
|
|
167
|
-
|
|
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;
|
|
168
173
|
if (DECLARES_ITSELF.test(s)) return false;
|
|
169
174
|
|
|
170
175
|
const ff = /\bFirefox\/(\d+)/.exec(s);
|
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';
|
|
2
|
+
import { AI_INDEX_AGENTS, HEADLESS_AGENTS, isTrainingAgent, RETRIEVAL_AGENTS, SCRAPER_AGENTS, TRAINING_AGENTS, WELCOME_CRAWLERS } from './agents.js';
|
|
3
3
|
import { clientIp, compileCidrs, 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,9 @@ 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 { AI_INDEX_AGENTS, HEADLESS_AGENTS, isTrainingAgent, RETRIEVAL_AGENTS, SCRAPER_AGENTS, TRAINING_AGENTS, WELCOME_CRAWLERS } from './agents.js';
|
|
20
21
|
export { clientIp, compileCidrs, currentMajor, inCidrs, isSpoofedBrowser, isStaleBrowser, parseCidr, welcomeCrawler } from './edge.js';
|
|
22
|
+
export { challengePage, challengeToken, cookieValue, passedChallenge } from './challenge.js';
|
|
21
23
|
export { renderPage } from './page.js';
|
|
22
24
|
export { mintPass, readPass } from './pass.js';
|
|
23
25
|
export { robotsTxt } from './robots.js';
|
|
@@ -59,6 +61,8 @@ export { isPowerKey, keyFrom, PROFULLSTACK_KEYS, verifyKey } from '@profullstack
|
|
|
59
61
|
* @param {boolean} [options.chargeStaleBrowsers=true] charge a desktop browser string two years behind its auto-updates (a rotation pool)
|
|
60
62
|
* @param {string[]} [options.scrapers] SEO resellers charged and refused in robots.txt; default SCRAPER_AGENTS
|
|
61
63
|
* @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
|
|
62
66
|
* @param {{token: string, cidrs: string[]}[]|false} [options.welcome] search crawlers never metered when name and address agree; default WELCOME_CRAWLERS
|
|
63
67
|
* @param {(request: Request) => boolean} [options.exempt] requests never charged, e.g. ones carrying a signed-in cookie
|
|
64
68
|
* @param {string} [options.secret] pass signing secret; defaults to the CoinPay key
|
|
@@ -396,6 +400,23 @@ export function createGateway(options = {}) {
|
|
|
396
400
|
*/
|
|
397
401
|
if (welcomeCrawler(request, o.welcome)) return null;
|
|
398
402
|
|
|
403
|
+
/*
|
|
404
|
+
* 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
|
|
407
|
+
* 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.
|
|
409
|
+
*/
|
|
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);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
399
420
|
/*
|
|
400
421
|
* Everyone else gets the free allowance. Running out is answered with a
|
|
401
422
|
* price rather than a 429: the caller has just shown it wants more than
|
|
@@ -467,7 +488,7 @@ export function createGateway(options = {}) {
|
|
|
467
488
|
robotsTxt({
|
|
468
489
|
siteUrl: o.siteUrl,
|
|
469
490
|
path: o.path,
|
|
470
|
-
training: [...o.training, ...o.scrapers],
|
|
491
|
+
training: [...o.training, ...o.scrapers, ...o.aiIndex],
|
|
471
492
|
retrieval: o.retrieval,
|
|
472
493
|
...extra,
|
|
473
494
|
}),
|
|
@@ -521,7 +542,8 @@ function normalise(options) {
|
|
|
521
542
|
const training = options.training ?? TRAINING_AGENTS;
|
|
522
543
|
const scrapers = options.scrapers ?? SCRAPER_AGENTS;
|
|
523
544
|
const headless = options.headless ?? HEADLESS_AGENTS;
|
|
524
|
-
const
|
|
545
|
+
const aiIndex = options.aiIndex ?? AI_INDEX_AGENTS;
|
|
546
|
+
const paid = [...training, ...scrapers, ...headless, ...aiIndex];
|
|
525
547
|
return {
|
|
526
548
|
siteUrl,
|
|
527
549
|
siteName: options.siteName || new URL(siteUrl).hostname,
|
|
@@ -544,6 +566,11 @@ function normalise(options) {
|
|
|
544
566
|
training,
|
|
545
567
|
scrapers,
|
|
546
568
|
headless,
|
|
569
|
+
aiIndex,
|
|
570
|
+
challenge:
|
|
571
|
+
options.challenge && Array.isArray(options.challenge.paths) && options.challenge.paths.length
|
|
572
|
+
? { paths: options.challenge.paths, cookie: options.challenge.cookie ?? 'pf_js' }
|
|
573
|
+
: null,
|
|
547
574
|
welcome: options.welcome === false ? null : (options.welcome ?? WELCOME_CRAWLERS),
|
|
548
575
|
retrieval: options.retrieval ?? RETRIEVAL_AGENTS,
|
|
549
576
|
isPaidAgent: options.isPaidAgent ?? ((ua) => isTrainingAgent(ua, paid)),
|