@profullstack/x402-gateway 0.12.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 +1 -1
- package/index.d.ts +5 -3
- package/package.json +1 -1
- package/src/challenge.js +54 -11
- package/src/index.js +19 -5
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', 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. |
|
|
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; /** 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;
|
|
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,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; work?: number; now?: number }): Promise<boolean>;
|
|
280
|
-
export function challengePage(options: { token: string; cookie: string; buyUrl: string; work?: number }): 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>;
|
|
281
283
|
export function solves(seed: string, nonce: string | undefined, work: number): Promise<boolean>;
|
|
282
284
|
export function leadingZeros(bytes: Uint8Array, bits: number): boolean;
|
|
283
285
|
export function cookieValue(request: Request, name: string): string | null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/x402-gateway",
|
|
3
|
-
"version": "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
|
@@ -23,6 +23,15 @@
|
|
|
23
23
|
* starts with that many zero bits. A person pays about a second once a day; a
|
|
24
24
|
* swarm that changes address every page pays it on every page. The server
|
|
25
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.
|
|
26
35
|
*/
|
|
27
36
|
|
|
28
37
|
const enc = new TextEncoder();
|
|
@@ -41,6 +50,17 @@ async function hmac(secret, data) {
|
|
|
41
50
|
/** The token a browser at `ip` gets on day `day` (days since the epoch). */
|
|
42
51
|
export const challengeToken = (secret, ip, day = Math.floor(Date.now() / DAY)) => hmac(secret, `js|${ip}|${day}`);
|
|
43
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
|
+
|
|
44
64
|
/** Read one cookie by name. */
|
|
45
65
|
export function cookieValue(request, name) {
|
|
46
66
|
const raw = request.headers.get('cookie') ?? '';
|
|
@@ -68,31 +88,54 @@ export async function solves(seed, nonce, work) {
|
|
|
68
88
|
|
|
69
89
|
/**
|
|
70
90
|
* 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>`)
|
|
91
|
+
* and, with `work`, a nonce that answers it (cookie `<token>.<nonce>`), and,
|
|
92
|
+
* with `css`, the cookie the challenge stylesheet sets (`<cookie>_css`).
|
|
72
93
|
*/
|
|
73
|
-
export async function passedChallenge(request, { secret, cookie, ip, work = 0, now = Date.now() }) {
|
|
94
|
+
export async function passedChallenge(request, { secret, cookie, ip, work = 0, css = false, now = Date.now() }) {
|
|
74
95
|
const got = cookieValue(request, cookie);
|
|
75
96
|
if (!got) return false;
|
|
76
97
|
const [seed, nonce] = got.split('.');
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
return
|
|
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);
|
|
80
102
|
}
|
|
81
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
|
+
|
|
82
124
|
/** 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()
|
|
125
|
+
const solver = (token, cookie, work, css) => `(async()=>{const s=${JSON.stringify(token)},w=${work},e=new TextEncoder();${css ? WAIT_CSS : ''}
|
|
84
126
|
if(navigator.webdriver){document.body.insertAdjacentHTML('beforeend','<p>Automated browsers need a pass.</p>');return}
|
|
85
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))};
|
|
86
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)))));
|
|
87
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}}})();`;
|
|
88
130
|
|
|
89
131
|
/** The page that asks. Tiny on purpose: answering it must cost us nothing. */
|
|
90
|
-
export function challengePage({ token, cookie, buyUrl, work = 0 }) {
|
|
91
|
-
const
|
|
92
|
-
|
|
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)
|
|
93
136
|
: `document.cookie=${JSON.stringify(`${cookie}=${token}; Max-Age=172800; Path=/; SameSite=Lax; Secure`)};location.reload();`;
|
|
94
|
-
return `<!doctype html><html><head><meta charset="utf-8"><meta name="robots" content="noindex"><title>One moment</title
|
|
95
|
-
${work ? '<p>Checking your browser. This takes about a second, once a day.</p>\n' : ''}<script>${script}</script>
|
|
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>
|
|
96
139
|
<noscript><p>This page needs JavaScript. Automated access is sold by the day: <a href="${buyUrl}">${buyUrl}</a></p></noscript>
|
|
97
140
|
</body></html>`;
|
|
98
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 {
|
|
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, 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
|
|
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, work: o.challenge.work }))) {
|
|
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, work: o.challenge.work }), 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
|
|
|
@@ -1016,6 +1029,7 @@ function normalise(options) {
|
|
|
1016
1029
|
paths: options.challenge.paths,
|
|
1017
1030
|
cookie: options.challenge.cookie ?? 'pf_js',
|
|
1018
1031
|
work: challengeWork(options.challenge.work),
|
|
1032
|
+
cssPath: options.challenge.css ? (options.challenge.cssPath ?? '/__pf/c/') : null,
|
|
1019
1033
|
when: typeof options.challenge.when === 'function' ? options.challenge.when : null,
|
|
1020
1034
|
}
|
|
1021
1035
|
: null,
|