@profullstack/x402-gateway 0.8.1 → 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 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.8.1",
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
  /**
@@ -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/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 paid = [...training, ...scrapers, ...headless];
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)),