@profullstack/x402-gateway 0.11.0 → 0.12.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 +1 -1
- package/index.d.ts +5 -3
- package/package.json +1 -1
- package/src/challenge.js +43 -5
- package/src/index.js +11 -4
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. |
|
|
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; 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,10 @@ 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; now?: number }): Promise<boolean>;
|
|
280
|
+
export function challengePage(options: { token: string; cookie: string; buyUrl: string; work?: number }): string;
|
|
281
|
+
export function solves(seed: string, nonce: string | undefined, work: number): Promise<boolean>;
|
|
282
|
+
export function leadingZeros(bytes: Uint8Array, bits: number): boolean;
|
|
281
283
|
export function cookieValue(request: Request, name: string): string | null;
|
|
282
284
|
export interface WelcomeCrawler {
|
|
283
285
|
token: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/x402-gateway",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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,15 @@
|
|
|
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.
|
|
17
26
|
*/
|
|
18
27
|
|
|
19
28
|
const enc = new TextEncoder();
|
|
@@ -42,19 +51,48 @@ export function cookieValue(request, name) {
|
|
|
42
51
|
return null;
|
|
43
52
|
}
|
|
44
53
|
|
|
45
|
-
/** Whether
|
|
46
|
-
export
|
|
54
|
+
/** Whether a SHA-256 digest starts with `bits` zero bits. */
|
|
55
|
+
export function leadingZeros(bytes, bits) {
|
|
56
|
+
let i = 0;
|
|
57
|
+
for (; bits >= 8; bits -= 8) if (bytes[i++]) return false;
|
|
58
|
+
return !bits || !(bytes[i] >> (8 - bits));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether `nonce` answers the proof of work for `seed`. */
|
|
62
|
+
export async function solves(seed, nonce, work) {
|
|
63
|
+
if (!work) return true;
|
|
64
|
+
if (!/^\d{1,12}$/.test(nonce ?? '')) return false;
|
|
65
|
+
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', enc.encode(`${seed}.${nonce}`)));
|
|
66
|
+
return leadingZeros(digest, work);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Whether the request carries today's or yesterday's token for its address,
|
|
71
|
+
* and, with `work`, a nonce that answers it (cookie `<token>.<nonce>`).
|
|
72
|
+
*/
|
|
73
|
+
export async function passedChallenge(request, { secret, cookie, ip, work = 0, now = Date.now() }) {
|
|
47
74
|
const got = cookieValue(request, cookie);
|
|
48
75
|
if (!got) return false;
|
|
76
|
+
const [seed, nonce] = got.split('.');
|
|
49
77
|
const day = Math.floor(now / DAY);
|
|
50
|
-
for (const d of [day, day - 1]) if (
|
|
78
|
+
for (const d of [day, day - 1]) if (seed === (await challengeToken(secret, ip, d))) return solves(seed, nonce, work);
|
|
51
79
|
return false;
|
|
52
80
|
}
|
|
53
81
|
|
|
82
|
+
/** The script that finds the nonce, 256 digests at a time, then sets the cookie and reloads. */
|
|
83
|
+
const solver = (token, cookie, work) => `(async()=>{const s=${JSON.stringify(token)},w=${work},e=new TextEncoder();
|
|
84
|
+
if(navigator.webdriver){document.body.insertAdjacentHTML('beforeend','<p>Automated browsers need a pass.</p>');return}
|
|
85
|
+
const ok=b=>{let n=w,i=0;for(;n>=8;n-=8)if(b[i++])return 0;return!n||!(b[i]>>(8-n))};
|
|
86
|
+
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)))));
|
|
87
|
+
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}}})();`;
|
|
88
|
+
|
|
54
89
|
/** The page that asks. Tiny on purpose: answering it must cost us nothing. */
|
|
55
|
-
export function challengePage({ token, cookie, buyUrl }) {
|
|
90
|
+
export function challengePage({ token, cookie, buyUrl, work = 0 }) {
|
|
91
|
+
const script = work
|
|
92
|
+
? solver(token, cookie, work)
|
|
93
|
+
: `document.cookie=${JSON.stringify(`${cookie}=${token}; Max-Age=172800; Path=/; SameSite=Lax; Secure`)};location.reload();`;
|
|
56
94
|
return `<!doctype html><html><head><meta charset="utf-8"><meta name="robots" content="noindex"><title>One moment</title></head><body>
|
|
57
|
-
<
|
|
95
|
+
${work ? '<p>Checking your browser. This takes about a second, once a day.</p>\n' : ''}<script>${script}</script>
|
|
58
96
|
<noscript><p>This page needs JavaScript. Automated access is sold by the day: <a href="${buyUrl}">${buyUrl}</a></p></noscript>
|
|
59
97
|
</body></html>`;
|
|
60
98
|
}
|
package/src/index.js
CHANGED
|
@@ -41,7 +41,7 @@ export {
|
|
|
41
41
|
parseCidr,
|
|
42
42
|
welcomeCrawler,
|
|
43
43
|
} from './edge.js';
|
|
44
|
-
export { challengePage, challengeToken, cookieValue, passedChallenge } from './challenge.js';
|
|
44
|
+
export { challengePage, challengeToken, cookieValue, leadingZeros, passedChallenge, solves } from './challenge.js';
|
|
45
45
|
export { renderNotice, renderPage, renderPassPage } from './page.js';
|
|
46
46
|
export { checkoutClient, sessionIdFromWebhook, sessionState } from './checkout.js';
|
|
47
47
|
export { mintPass, readPass } from './pass.js';
|
|
@@ -97,7 +97,7 @@ export { isPowerKey, keyFrom, PROFULLSTACK_KEYS, verifyKey } from '@profullstack
|
|
|
97
97
|
* @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
98
|
* @param {{token: string, cidrs: string[]}[]|false} [options.verifiedRetrieval] retrieval crawlers believed only from their published addresses; default VERIFIED_RETRIEVAL
|
|
99
99
|
* @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
|
|
100
|
+
* @param {{paths: string[], cookie?: string, work?: number|boolean, 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; off by default
|
|
101
101
|
* @param {{token: string, cidrs: string[]}[]|false} [options.welcome] search crawlers never metered when name and address agree; default WELCOME_CRAWLERS
|
|
102
102
|
* @param {(request: Request) => boolean} [options.exempt] requests never charged, e.g. ones carrying a signed-in cookie
|
|
103
103
|
* @param {string} [options.secret] pass signing secret; defaults to the CoinPay key
|
|
@@ -830,7 +830,7 @@ export function createGateway(options = {}) {
|
|
|
830
830
|
(request.method === 'GET' || request.method === 'HEAD')
|
|
831
831
|
) {
|
|
832
832
|
const ip = clientIp(request);
|
|
833
|
-
if (ip && !(await passedChallenge(request, { secret, cookie: o.challenge.cookie, ip }))) {
|
|
833
|
+
if (ip && !(await passedChallenge(request, { secret, cookie: o.challenge.cookie, ip, work: o.challenge.work }))) {
|
|
834
834
|
if (!wantsHtml(request.headers.get('accept'))) {
|
|
835
835
|
return json(
|
|
836
836
|
receipt(1, {
|
|
@@ -841,7 +841,7 @@ export function createGateway(options = {}) {
|
|
|
841
841
|
);
|
|
842
842
|
}
|
|
843
843
|
const token = await challengeToken(secret, ip);
|
|
844
|
-
return html(challengePage({ token, cookie: o.challenge.cookie, buyUrl }), 402, required(1));
|
|
844
|
+
return html(challengePage({ token, cookie: o.challenge.cookie, buyUrl, work: o.challenge.work }), 402, required(1));
|
|
845
845
|
}
|
|
846
846
|
}
|
|
847
847
|
|
|
@@ -1015,6 +1015,7 @@ function normalise(options) {
|
|
|
1015
1015
|
? {
|
|
1016
1016
|
paths: options.challenge.paths,
|
|
1017
1017
|
cookie: options.challenge.cookie ?? 'pf_js',
|
|
1018
|
+
work: challengeWork(options.challenge.work),
|
|
1018
1019
|
when: typeof options.challenge.when === 'function' ? options.challenge.when : null,
|
|
1019
1020
|
}
|
|
1020
1021
|
: null,
|
|
@@ -1040,4 +1041,10 @@ function normalise(options) {
|
|
|
1040
1041
|
};
|
|
1041
1042
|
}
|
|
1042
1043
|
|
|
1044
|
+
/** `true` means the default 16 bits; a number is clamped to 0-24 (24 is ~16M digests, too slow for a phone). */
|
|
1045
|
+
function challengeWork(work) {
|
|
1046
|
+
if (work === true) return 16;
|
|
1047
|
+
return Number.isInteger(work) ? Math.max(0, Math.min(24, work)) : 0;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1043
1050
|
export { METHODS as X402_METHODS };
|