@profullstack/x402-gateway 0.11.0 → 0.13.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
@@ -95,7 +95,7 @@ export const GET = robotsRoute(gateway, { disallow: ['/login', '/api/'] });
95
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
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
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 |
98
+ | `challenge` | off | `{ paths: ['/i/'], cookie?: 'pf_js', work?: 0, 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. Set `work` (bits; `true` = 16, max 24) to make the script also solve a SHA-256 proof of work before it sets the cookie (`<token>.<nonce>`): ~250 ms at 16 bits on a desktop, once per address per day, but per page for a swarm that drives real headless Chrome and rotates its address every page. `navigator.webdriver` browsers are not given the cookie. Set `css: true` (path `cssPath`, default `/__pf/c/`) to also require a second cookie that only fetching a per-address stylesheet linked from the page sets: scrapers that block stylesheets to save bandwidth never pass (nichedb, 2026-10-21: 11 of 5,394 swarm addresses ever loaded CSS). |
99
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 |
100
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 |
101
101
  | `training`, `retrieval` | the lists in `./agents` | |
package/index.d.ts CHANGED
@@ -105,7 +105,7 @@ export interface GatewayOptions {
105
105
  chargeImpostors?: boolean;
106
106
  /** Require a one-line script (cookie + reload) on these paths before serving them. Off by default. */
107
107
  /** Only GETs to `paths` that `when` accepts (default: all of them), e.g. `(req, url) => url.search.length > 1` for faceted URLs. */
108
- challenge?: { paths: string[]; cookie?: string; when?: (request: Request, url: URL) => boolean } | false;
108
+ challenge?: { paths: string[]; cookie?: string; /** Proof-of-work bits the script must solve; true = 16. Default 0 (cookie + reload only). */ work?: number | boolean; /** Link a per-address stylesheet whose fetch sets `<cookie>_css`; both cookies are then required. */ css?: boolean; /** Where that stylesheet is served. Default '/__pf/c/'. */ cssPath?: string; when?: (request: Request, url: URL) => boolean } | false;
109
109
  /** SEO resellers: charged, and refused in robots.txt. Default SCRAPER_AGENTS. */
110
110
  scrapers?: string[];
111
111
  /** Headless browsers that name themselves: charged. Default HEADLESS_AGENTS. */
@@ -276,8 +276,12 @@ export const SCRAPER_AGENTS: string[];
276
276
  export const HEADLESS_AGENTS: string[];
277
277
  export const AI_INDEX_AGENTS: string[];
278
278
  export function challengeToken(secret: string, ip: string, day?: number): Promise<string>;
279
- export function passedChallenge(request: Request, options: { secret: string; cookie: string; ip: string; now?: number }): Promise<boolean>;
280
- export function challengePage(options: { token: string; cookie: string; buyUrl: string }): string;
279
+ export function passedChallenge(request: Request, options: { secret: string; cookie: string; ip: string; work?: number; css?: boolean; now?: number }): Promise<boolean>;
280
+ export function challengePage(options: { token: string; cookie: string; buyUrl: string; work?: number; cssPath?: string | null }): string;
281
+ export function stylesheetToken(secret: string, ip: string, day?: number): Promise<string>;
282
+ export function stylesheetResponse(request: Request, options: { secret: string; cookie: string; ip: string | null; cssPath: string; now?: number }): Promise<Response>;
283
+ export function solves(seed: string, nonce: string | undefined, work: number): Promise<boolean>;
284
+ export function leadingZeros(bytes: Uint8Array, bits: number): boolean;
281
285
  export function cookieValue(request: Request, name: string): string | null;
282
286
  export interface WelcomeCrawler {
283
287
  token: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/x402-gateway",
3
- "version": "0.11.0",
3
+ "version": "0.13.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/challenge.js CHANGED
@@ -14,6 +14,24 @@
14
14
  * address. The page that asks is a 402 carrying a link to the sales page, so a
15
15
  * scraper that cannot run scripts learns what a pass costs, which is the same
16
16
  * answer every other refused crawler gets.
17
+ *
18
+ * That was not enough. On 2026-10-21 the slice of the swarm that drives a real
19
+ * Chrome ran the script and walked in: 402, then the same address in the same
20
+ * second, then a 200, then never again. It rotates its address on every page,
21
+ * so "once per address per day" is once per page and the script cost it one
22
+ * request. With `work` set the script must also find a nonce whose SHA-256
23
+ * starts with that many zero bits. A person pays about a second once a day; a
24
+ * swarm that changes address every page pays it on every page. The server
25
+ * checks the answer with one hash and keeps nothing.
26
+ *
27
+ * The proof of work did not slow it either (~46 item pages a minute before,
28
+ * ~40 after): its Chrome is real and solves 17 bits easily. But it never
29
+ * loads the page's stylesheet. Of 5,394 addresses that read an item page as
30
+ * a browser, 11 fetched any CSS or JS; a real Chrome fetches the stylesheet
31
+ * on every first visit. With `css` set the page links a stylesheet unique to
32
+ * the address and day, and fetching it is what sets the second cookie. A
33
+ * scraper that blocks stylesheets to save bandwidth never gets it, however
34
+ * much script it runs.
17
35
  */
18
36
 
19
37
  const enc = new TextEncoder();
@@ -32,6 +50,17 @@ async function hmac(secret, data) {
32
50
  /** The token a browser at `ip` gets on day `day` (days since the epoch). */
33
51
  export const challengeToken = (secret, ip, day = Math.floor(Date.now() / DAY)) => hmac(secret, `js|${ip}|${day}`);
34
52
 
53
+ /** The second cookie, set only by fetching the challenge stylesheet. */
54
+ export const stylesheetToken = (secret, ip, day = Math.floor(Date.now() / DAY)) => hmac(secret, `css|${ip}|${day}`);
55
+
56
+ /** Whether `got` is today's or yesterday's value of `mint(day)`. */
57
+ async function current(got, mint, now) {
58
+ if (!got) return false;
59
+ const day = Math.floor(now / DAY);
60
+ for (const d of [day, day - 1]) if (got === (await mint(d))) return true;
61
+ return false;
62
+ }
63
+
35
64
  /** Read one cookie by name. */
36
65
  export function cookieValue(request, name) {
37
66
  const raw = request.headers.get('cookie') ?? '';
@@ -42,19 +71,71 @@ export function cookieValue(request, name) {
42
71
  return null;
43
72
  }
44
73
 
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() }) {
74
+ /** Whether a SHA-256 digest starts with `bits` zero bits. */
75
+ export function leadingZeros(bytes, bits) {
76
+ let i = 0;
77
+ for (; bits >= 8; bits -= 8) if (bytes[i++]) return false;
78
+ return !bits || !(bytes[i] >> (8 - bits));
79
+ }
80
+
81
+ /** Whether `nonce` answers the proof of work for `seed`. */
82
+ export async function solves(seed, nonce, work) {
83
+ if (!work) return true;
84
+ if (!/^\d{1,12}$/.test(nonce ?? '')) return false;
85
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', enc.encode(`${seed}.${nonce}`)));
86
+ return leadingZeros(digest, work);
87
+ }
88
+
89
+ /**
90
+ * Whether the request carries today's or yesterday's token for its address,
91
+ * and, with `work`, a nonce that answers it (cookie `<token>.<nonce>`), and,
92
+ * with `css`, the cookie the challenge stylesheet sets (`<cookie>_css`).
93
+ */
94
+ export async function passedChallenge(request, { secret, cookie, ip, work = 0, css = false, now = Date.now() }) {
47
95
  const got = cookieValue(request, cookie);
48
96
  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;
97
+ const [seed, nonce] = got.split('.');
98
+ if (!(await current(seed, (d) => challengeToken(secret, ip, d), now))) return false;
99
+ if (!(await solves(seed, nonce, work))) return false;
100
+ if (!css) return true;
101
+ return current(cookieValue(request, `${cookie}_css`), (d) => stylesheetToken(secret, ip, d), now);
52
102
  }
53
103
 
104
+ /**
105
+ * The challenge stylesheet: `<cssPath><token>`. Sets `<cookie>_css` when the
106
+ * token in the URL is today's or yesterday's for the caller's address, so the
107
+ * URL cannot be fetched on behalf of another address. Always answers CSS, so a
108
+ * browser never logs a broken stylesheet.
109
+ */
110
+ export async function stylesheetResponse(request, { secret, cookie, ip, cssPath, now = Date.now() }) {
111
+ const token = new URL(request.url).pathname.slice(cssPath.length);
112
+ const headers = new Headers({ 'content-type': 'text/css; charset=utf-8', 'cache-control': 'no-store' });
113
+ if (ip && /^[0-9a-f]{32}$/.test(token) && (await current(token, (d) => challengeToken(secret, ip, d), now))) {
114
+ const value = await stylesheetToken(secret, ip);
115
+ headers.append('set-cookie', `${cookie}_css=${value}; Max-Age=172800; Path=/; SameSite=Lax; Secure; HttpOnly`);
116
+ }
117
+ return new Response('/* ok */\n', { status: 200, headers });
118
+ }
119
+
120
+ /** Wait for the challenge stylesheet; if it cannot load, say so rather than reload forever. */
121
+ const WAIT_CSS = `const l=document.getElementById('pfc');if(!(l.sheet||await new Promise(r=>{l.onload=()=>r(1);l.onerror=()=>r(0)}))){document.body.insertAdjacentHTML('beforeend','<p>This check needs the page stylesheet. Reload, or buy a pass.</p>');return}
122
+ `;
123
+
124
+ /** The script that finds the nonce, 256 digests at a time, then sets the cookie and reloads. */
125
+ const solver = (token, cookie, work, css) => `(async()=>{const s=${JSON.stringify(token)},w=${work},e=new TextEncoder();${css ? WAIT_CSS : ''}
126
+ if(navigator.webdriver){document.body.insertAdjacentHTML('beforeend','<p>Automated browsers need a pass.</p>');return}
127
+ const ok=b=>{let n=w,i=0;for(;n>=8;n-=8)if(b[i++])return 0;return!n||!(b[i]>>(8-n))};
128
+ for(let n=0;;n+=256){const h=await Promise.all(Array.from({length:256},(_,k)=>crypto.subtle.digest('SHA-256',e.encode(s+'.'+(n+k)))));
129
+ for(let k=0;k<256;k++)if(ok(new Uint8Array(h[k]))){document.cookie=${JSON.stringify(`${cookie}=`)}+s+'.'+(n+k)+${JSON.stringify('; Max-Age=172800; Path=/; SameSite=Lax; Secure')};location.reload();return}}})();`;
130
+
54
131
  /** 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>
132
+ export function challengePage({ token, cookie, buyUrl, work = 0, cssPath = null }) {
133
+ const css = Boolean(cssPath);
134
+ const script = work || css
135
+ ? solver(token, cookie, work, css)
136
+ : `document.cookie=${JSON.stringify(`${cookie}=${token}; Max-Age=172800; Path=/; SameSite=Lax; Secure`)};location.reload();`;
137
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="robots" content="noindex"><title>One moment</title>${css ? `<link id="pfc" rel="stylesheet" href="${cssPath}${token}">` : ''}</head><body>
138
+ ${work || css ? '<p>Checking your browser. This takes about a second, once a day.</p>\n' : ''}<script>${script}</script>
58
139
  <noscript><p>This page needs JavaScript. Automated access is sold by the day: <a href="${buyUrl}">${buyUrl}</a></p></noscript>
59
140
  </body></html>`;
60
141
  }
package/src/index.js CHANGED
@@ -2,7 +2,7 @@ import { HEADER as POWER_KEY_HEADER, isPowerKey, keyFrom, PROFULLSTACK_KEYS, SHO
2
2
  import { HEADLESS_AGENTS, isTrainingAgent, RETRIEVAL_AGENTS, SCRAPER_AGENTS, TRAINING_AGENTS, VERIFIED_RETRIEVAL, WELCOME_CRAWLERS } from './agents.js';
3
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, cookieValue, passedChallenge } from './challenge.js';
5
+ import { challengePage, challengeToken, cookieValue, passedChallenge, stylesheetResponse } from './challenge.js';
6
6
  import { checkoutClient, isEmail, isSessionId, sessionIdFromWebhook, sessionState } from './checkout.js';
7
7
  import { renderNotice, renderPage, renderPassPage } from './page.js';
8
8
  import { mintPass, readPass } from './pass.js';
@@ -41,7 +41,16 @@ export {
41
41
  parseCidr,
42
42
  welcomeCrawler,
43
43
  } from './edge.js';
44
- export { challengePage, challengeToken, cookieValue, passedChallenge } from './challenge.js';
44
+ export {
45
+ challengePage,
46
+ challengeToken,
47
+ cookieValue,
48
+ leadingZeros,
49
+ passedChallenge,
50
+ solves,
51
+ stylesheetResponse,
52
+ stylesheetToken,
53
+ } from './challenge.js';
45
54
  export { renderNotice, renderPage, renderPassPage } from './page.js';
46
55
  export { checkoutClient, sessionIdFromWebhook, sessionState } from './checkout.js';
47
56
  export { mintPass, readPass } from './pass.js';
@@ -97,7 +106,7 @@ export { isPowerKey, keyFrom, PROFULLSTACK_KEYS, verifyKey } from '@profullstack
97
106
  * @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)
98
107
  * @param {{token: string, cidrs: string[]}[]|false} [options.verifiedRetrieval] retrieval crawlers believed only from their published addresses; default VERIFIED_RETRIEVAL
99
108
  * @param {boolean} [options.chargeImpostors=true] charge a request wearing a welcome search crawler's name from outside its published ranges
100
- * @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
109
+ * @param {{paths: string[], cookie?: string, work?: number|boolean, css?: boolean, cssPath?: 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; with `work` (bits, `true` = 16) the script must also solve a SHA-256 proof of work, so a swarm that rotates its address every page pays it on every page; with `css` the page links a per-address stylesheet (at `cssPath`, default `/__pf/c/`) whose fetch sets a second cookie, so a scraper that blocks stylesheets never passes; off by default
101
110
  * @param {{token: string, cidrs: string[]}[]|false} [options.welcome] search crawlers never metered when name and address agree; default WELCOME_CRAWLERS
102
111
  * @param {(request: Request) => boolean} [options.exempt] requests never charged, e.g. ones carrying a signed-in cookie
103
112
  * @param {string} [options.secret] pass signing secret; defaults to the CoinPay key
@@ -789,6 +798,10 @@ export function createGateway(options = {}) {
789
798
  const answer = await checkoutRoute(request, path);
790
799
  if (answer) return answer;
791
800
  }
801
+ // The challenge stylesheet answers everyone: fetching it is the test.
802
+ if (o.challenge?.cssPath && secret && path.startsWith(o.challenge.cssPath)) {
803
+ return stylesheetResponse(request, { secret, cookie: o.challenge.cookie, ip: clientIp(request), cssPath: o.challenge.cssPath });
804
+ }
792
805
  if (o.exempt && o.exempt(request)) return null;
793
806
 
794
807
  /*
@@ -830,7 +843,7 @@ export function createGateway(options = {}) {
830
843
  (request.method === 'GET' || request.method === 'HEAD')
831
844
  ) {
832
845
  const ip = clientIp(request);
833
- if (ip && !(await passedChallenge(request, { secret, cookie: o.challenge.cookie, ip }))) {
846
+ if (ip && !(await passedChallenge(request, { secret, cookie: o.challenge.cookie, ip, work: o.challenge.work, css: Boolean(o.challenge.cssPath) }))) {
834
847
  if (!wantsHtml(request.headers.get('accept'))) {
835
848
  return json(
836
849
  receipt(1, {
@@ -841,7 +854,7 @@ export function createGateway(options = {}) {
841
854
  );
842
855
  }
843
856
  const token = await challengeToken(secret, ip);
844
- return html(challengePage({ token, cookie: o.challenge.cookie, buyUrl }), 402, required(1));
857
+ return html(challengePage({ token, cookie: o.challenge.cookie, buyUrl, work: o.challenge.work, cssPath: o.challenge.cssPath }), 402, required(1));
845
858
  }
846
859
  }
847
860
 
@@ -1015,6 +1028,8 @@ function normalise(options) {
1015
1028
  ? {
1016
1029
  paths: options.challenge.paths,
1017
1030
  cookie: options.challenge.cookie ?? 'pf_js',
1031
+ work: challengeWork(options.challenge.work),
1032
+ cssPath: options.challenge.css ? (options.challenge.cssPath ?? '/__pf/c/') : null,
1018
1033
  when: typeof options.challenge.when === 'function' ? options.challenge.when : null,
1019
1034
  }
1020
1035
  : null,
@@ -1040,4 +1055,10 @@ function normalise(options) {
1040
1055
  };
1041
1056
  }
1042
1057
 
1058
+ /** `true` means the default 16 bits; a number is clamped to 0-24 (24 is ~16M digests, too slow for a phone). */
1059
+ function challengeWork(work) {
1060
+ if (work === true) return 16;
1061
+ return Number.isInteger(work) ? Math.max(0, Math.min(24, work)) : 0;
1062
+ }
1063
+
1043
1064
  export { METHODS as X402_METHODS };