@profullstack/x402-gateway 0.6.0 → 0.8.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 +15 -1
- package/index.d.ts +35 -2
- package/package.json +6 -2
- package/src/agents.js +47 -0
- package/src/edge.js +94 -0
- package/src/index.js +88 -13
- package/src/page.js +6 -0
package/README.md
CHANGED
|
@@ -89,7 +89,11 @@ export const GET = robotsRoute(gateway, { disallow: ['/login', '/api/'] });
|
|
|
89
89
|
| `header` | `x-crawl-pass` | where the pass goes; `Authorization: Bearer` works too |
|
|
90
90
|
| `path` | `/crawl` | the sales page |
|
|
91
91
|
| `openPaths` | `[]` | extra paths a refused crawler may read (`robots.txt`, the sales page, `security.txt` and `.well-known/` always are) |
|
|
92
|
-
| `isPaidAgent` | training
|
|
92
|
+
| `isPaidAgent` | training + scraper + headless lists | `(userAgent) => boolean` |
|
|
93
|
+
| `scrapers` | `SCRAPER_AGENTS` | SEO resellers (AhrefsBot, SemrushBot, MJ12bot, DataForSeoBot, ...): charged and refused in robots.txt |
|
|
94
|
+
| `headless` | `HEADLESS_AGENTS` | headless browsers that name themselves (Lightpanda, HeadlessChrome, PhantomJS): charged |
|
|
95
|
+
| `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
|
+
| `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 |
|
|
93
97
|
| `training`, `retrieval` | the lists in `./agents` | |
|
|
94
98
|
| `secret` | the CoinPay key | pass signing secret |
|
|
95
99
|
| `page` | built in | `(ctx) => html` |
|
|
@@ -99,9 +103,19 @@ export const GET = robotsRoute(gateway, { disallow: ['/login', '/api/'] });
|
|
|
99
103
|
| `denyCidrs` | `[]` | IPv4 ranges answered with a tiny `403` before anything else. For a VPS fleet that spoofs a browser: hosting ranges serve no readers. |
|
|
100
104
|
| `chargeSpoofedBrowsers` | `false` | charge a request that claims `Chrome/…` but sends no `Sec-Fetch-Mode`. Every Chromium since 76, headless included, sends it on every request and no script or extension can remove it, so its absence means an HTTP client with a copied string. Firefox and Safari are not judged. |
|
|
101
105
|
| `exempt` | | `(request) => boolean`, never charged: e.g. a request carrying your signed-in cookie |
|
|
106
|
+
| `powerKeys` | Profullstack's | Power Keys honoured as a pass, kid to public key. `false` switches it off. See below. |
|
|
107
|
+
| `revokedKeys` | | Power Key subjects no longer honoured |
|
|
102
108
|
|
|
103
109
|
Without `coinpay.apiKey` and `payTo` the gateway still answers training crawlers with 402 and the page says payments are off. Nothing is sold, but nothing is given away either.
|
|
104
110
|
|
|
111
|
+
## One key for every site
|
|
112
|
+
|
|
113
|
+
A day pass opens one site for a day. A [Power Key](https://profullstack.com/shop) is bought once and opens every Profullstack property for good: no crawler paywall, no allowance, no per-site pass. It is an Ed25519-signed token from [`@profullstack/keys`](https://www.npmjs.com/package/@profullstack/keys), and the gateway verifies it offline with the public key that package ships, so a site honours it with no configuration and no call to anyone.
|
|
114
|
+
|
|
115
|
+
A client sends it as `Authorization: Bearer pfs_...`, in `x-power-key`, or as `x-api-key`. `passFrom` reads it and `verifyPass` verifies it, so a throttle built on the gateway skips it the same way it skips a day pass. The 402 body carries `powerKey: { shop, header }` and the sales page says where to buy one.
|
|
116
|
+
|
|
117
|
+
Running the gateway somewhere that is not a Profullstack property? Pass `powerKeys: false`, or your own `{ kid: publicKey }` map to sell keys of your own with `mintKey` from the same package.
|
|
118
|
+
|
|
105
119
|
## A free allowance that sells instead of refusing
|
|
106
120
|
|
|
107
121
|
Named crawlers are one problem. The other is an ordinary client hammering the
|
package/index.d.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface PageContext {
|
|
|
34
34
|
training: string[];
|
|
35
35
|
retrieval: string[];
|
|
36
36
|
contact: string;
|
|
37
|
+
/** Where a Power Key is bought and the header it goes in, or null when they are switched off. */
|
|
38
|
+
powerKey?: { shop: string; header: string } | null;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
export interface GatewayOptions {
|
|
@@ -66,6 +68,14 @@ export interface GatewayOptions {
|
|
|
66
68
|
denyCidrs?: string[];
|
|
67
69
|
/** Charge a request that claims "Chrome/…" but lacks the Sec-Fetch-Mode header every Chromium sends. Default false. */
|
|
68
70
|
chargeSpoofedBrowsers?: boolean;
|
|
71
|
+
/** Charge a desktop browser string ~2 years behind its auto-updates (a scraper rotation pool). Default true. */
|
|
72
|
+
chargeStaleBrowsers?: boolean;
|
|
73
|
+
/** SEO resellers: charged, and refused in robots.txt. Default SCRAPER_AGENTS. */
|
|
74
|
+
scrapers?: string[];
|
|
75
|
+
/** Headless browsers that name themselves: charged. Default HEADLESS_AGENTS. */
|
|
76
|
+
headless?: string[];
|
|
77
|
+
/** Search crawlers never metered when their address matches their name. Default WELCOME_CRAWLERS; false disables. */
|
|
78
|
+
welcome?: WelcomeCrawler[] | false;
|
|
69
79
|
/** Requests never charged, e.g. ones carrying a signed-in cookie. */
|
|
70
80
|
exempt?: (request: Request) => boolean;
|
|
71
81
|
/** Pass signing secret. Defaults to the CoinPay key. */
|
|
@@ -82,6 +92,15 @@ export interface GatewayOptions {
|
|
|
82
92
|
freeQuota?: number | FreeQuotaOptions;
|
|
83
93
|
/** What a pass unlocks, listed on the sales page and in the 402 body. */
|
|
84
94
|
benefits?: string[];
|
|
95
|
+
/**
|
|
96
|
+
* Power Keys honoured as a pass: kid -> base64url Ed25519 public key, as
|
|
97
|
+
* @profullstack/keys ships them. Defaults to Profullstack's keys, so a key
|
|
98
|
+
* bought at profullstack.com/shop opens this site with no configuration.
|
|
99
|
+
* `false` switches it off.
|
|
100
|
+
*/
|
|
101
|
+
powerKeys?: Record<string, string> | false;
|
|
102
|
+
/** Power Key subjects no longer honoured (refunded, leaked). */
|
|
103
|
+
revokedKeys?: Iterable<string>;
|
|
85
104
|
fetch?: typeof fetch;
|
|
86
105
|
}
|
|
87
106
|
|
|
@@ -176,10 +195,14 @@ export interface Gateway {
|
|
|
176
195
|
};
|
|
177
196
|
robotsTxt: (extra?: RobotsOptions) => string;
|
|
178
197
|
page: () => string;
|
|
179
|
-
/** The pass this request presents
|
|
198
|
+
/** The pass this request presents: a day pass from the gateway's header or a bearer token, or a Power Key. */
|
|
180
199
|
passFrom: (request: Request) => string | null;
|
|
181
|
-
/** Whether that token
|
|
200
|
+
/** Whether that token opens the site: a live day pass this gateway minted, or a valid, unrevoked Power Key. */
|
|
182
201
|
verifyPass: (token: string | null) => Promise<boolean>;
|
|
202
|
+
/** Whether the gateway's own rules charge this request (lists, spoofed or stale browser). */
|
|
203
|
+
isPaid: (request: Request) => boolean;
|
|
204
|
+
/** Whether this is a welcome search crawler whose address matches its name. */
|
|
205
|
+
isWelcome: (request: Request) => boolean;
|
|
183
206
|
/** The x402 offer this gateway would make, without answering a request. */
|
|
184
207
|
offer: (days?: number) => Offer;
|
|
185
208
|
/** The full 402 body around that offer: the offer plus the pass terms. */
|
|
@@ -190,6 +213,13 @@ export function createGateway(options: GatewayOptions): Gateway;
|
|
|
190
213
|
export function wantsHtml(accept?: string | null): boolean;
|
|
191
214
|
|
|
192
215
|
export const TRAINING_AGENTS: string[];
|
|
216
|
+
export const SCRAPER_AGENTS: string[];
|
|
217
|
+
export const HEADLESS_AGENTS: string[];
|
|
218
|
+
export interface WelcomeCrawler {
|
|
219
|
+
token: string;
|
|
220
|
+
cidrs: string[];
|
|
221
|
+
}
|
|
222
|
+
export const WELCOME_CRAWLERS: WelcomeCrawler[];
|
|
193
223
|
export const RETRIEVAL_AGENTS: string[];
|
|
194
224
|
export function isTrainingAgent(userAgent?: string | null, agents?: string[]): boolean;
|
|
195
225
|
|
|
@@ -202,6 +232,9 @@ export function compileCidrs(list?: string[]): Cidr[];
|
|
|
202
232
|
export function inCidrs(ip: string, compiled: Cidr[]): boolean;
|
|
203
233
|
export function clientIp(request: Request): string;
|
|
204
234
|
export function isSpoofedBrowser(request: Request): boolean;
|
|
235
|
+
export function isStaleBrowser(userAgent: string, now?: number, maxMajorsBehind?: number): boolean;
|
|
236
|
+
export function currentMajor(browser: 'chrome' | 'firefox', now?: number): number;
|
|
237
|
+
export function welcomeCrawler(request: Request, list: WelcomeCrawler[] | null | undefined): string | null;
|
|
205
238
|
export function renderPage(ctx: PageContext): string;
|
|
206
239
|
|
|
207
240
|
export function mintPass(args: { secret: string; ref: string | null; expiresAt: number; now?: number }): Promise<{ token: string; expiresAt: number; ref: string | null }>;
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/x402-gateway",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"type": "module",
|
|
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, and a robots.txt that keeps search crawlers welcome.",
|
|
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": [
|
|
7
7
|
"x402",
|
|
8
|
+
"power-key",
|
|
8
9
|
"402",
|
|
9
10
|
"payment-required",
|
|
10
11
|
"crawler",
|
|
@@ -69,5 +70,8 @@
|
|
|
69
70
|
"sideEffects": false,
|
|
70
71
|
"scripts": {
|
|
71
72
|
"test": "node --test"
|
|
73
|
+
},
|
|
74
|
+
"dependencies": {
|
|
75
|
+
"@profullstack/keys": "^0.1.0"
|
|
72
76
|
}
|
|
73
77
|
}
|
package/src/agents.js
CHANGED
|
@@ -24,8 +24,35 @@ export const TRAINING_AGENTS = [
|
|
|
24
24
|
'FacebookBot',
|
|
25
25
|
'Bytespider',
|
|
26
26
|
'Applebot-Extended',
|
|
27
|
+
// Amazon documents Amazonbot's crawl as one that "may be used to train
|
|
28
|
+
// Amazon AI models", and it sends no reader back from any of it.
|
|
29
|
+
'Amazonbot',
|
|
27
30
|
];
|
|
28
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Crawlers that resell what they read and send nobody back: SEO backlink and
|
|
34
|
+
* keyword databases. They do obey robots.txt, so they are refused there as well
|
|
35
|
+
* as charged, which is the cheapest outcome of all: they stop asking.
|
|
36
|
+
*/
|
|
37
|
+
export const SCRAPER_AGENTS = [
|
|
38
|
+
'AhrefsBot',
|
|
39
|
+
'SemrushBot',
|
|
40
|
+
'MJ12bot',
|
|
41
|
+
'DataForSeoBot',
|
|
42
|
+
'DotBot',
|
|
43
|
+
'BLEXBot',
|
|
44
|
+
'serpstatbot',
|
|
45
|
+
'Barkrowler',
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Headless browsers that say so. Nobody reads a page through one of these; a
|
|
50
|
+
* scrape does, and it runs the page's scripts while it is at it. Lightpanda was
|
|
51
|
+
* 151k requests from 63k addresses on one site in a day (2026-10-19). They do
|
|
52
|
+
* not read robots.txt, so they are charged, never named there.
|
|
53
|
+
*/
|
|
54
|
+
export const HEADLESS_AGENTS = ['Lightpanda', 'HeadlessChrome', 'PhantomJS'];
|
|
55
|
+
|
|
29
56
|
/**
|
|
30
57
|
* Retrieval crawlers, named in robots.txt so their operators can see they are
|
|
31
58
|
* welcome. Google-Extended is Google's training token but Google documents it
|
|
@@ -57,3 +84,23 @@ export function isTrainingAgent(userAgent = '', agents = TRAINING_AGENTS) {
|
|
|
57
84
|
if (!ua) return false;
|
|
58
85
|
return lower(agents).some((t) => ua.includes(t));
|
|
59
86
|
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Search crawlers that are welcome everywhere, with the address ranges their
|
|
90
|
+
* operators publish. A user agent alone is not enough to spare a caller from
|
|
91
|
+
* the free allowance -- "Googlebot" is the first string a scraper copies -- so
|
|
92
|
+
* a crawler is only spared when its address agrees with its name.
|
|
93
|
+
*
|
|
94
|
+
* Ranges are the stable blocks of each operator's published list
|
|
95
|
+
* (developers.google.com/static/search/apis/ipranges/googlebot.json,
|
|
96
|
+
* bing.com/toolbox/bingbot.json, Apple's 17.0.0.0/8); a crawl from outside them
|
|
97
|
+
* is simply metered like everyone else, which fails safe.
|
|
98
|
+
*/
|
|
99
|
+
export const WELCOME_CRAWLERS = [
|
|
100
|
+
{ token: 'Googlebot', cidrs: ['66.249.64.0/19', '192.178.4.0/22', '34.100.182.96/28'] },
|
|
101
|
+
{
|
|
102
|
+
token: 'bingbot',
|
|
103
|
+
cidrs: ['40.77.167.0/24', '157.55.39.0/24', '207.46.13.0/24', '52.167.144.0/24', '40.77.188.0/22', '20.15.133.160/27'],
|
|
104
|
+
},
|
|
105
|
+
{ token: 'Applebot', cidrs: ['17.0.0.0/8'] },
|
|
106
|
+
];
|
package/src/edge.js
CHANGED
|
@@ -111,3 +111,97 @@ export function isSpoofedBrowser(request) {
|
|
|
111
111
|
if (DECLARES_ITSELF.test(ua)) return false;
|
|
112
112
|
return !request.headers.has('sec-fetch-mode');
|
|
113
113
|
}
|
|
114
|
+
|
|
115
|
+
/* ---------------------------------------------------------- stale strings -- */
|
|
116
|
+
|
|
117
|
+
/*
|
|
118
|
+
* Where each browser's version stood on a known day, and how fast it moves:
|
|
119
|
+
* both ship a major every four weeks. Computing "current" from the date keeps
|
|
120
|
+
* the check from going stale itself, which a hard-coded number would within a
|
|
121
|
+
* year.
|
|
122
|
+
*/
|
|
123
|
+
const DAY = 86_400_000;
|
|
124
|
+
const CADENCE = 28 * DAY;
|
|
125
|
+
const CHROME_ANCHOR = { major: 120, at: Date.UTC(2023, 11, 5) };
|
|
126
|
+
const FIREFOX_ANCHOR = { major: 121, at: Date.UTC(2023, 11, 19) };
|
|
127
|
+
|
|
128
|
+
/** The major a browser that auto-updates would be on at `now`. */
|
|
129
|
+
export function currentMajor(browser, now = Date.now()) {
|
|
130
|
+
const a = browser === 'firefox' ? FIREFOX_ANCHOR : CHROME_ANCHOR;
|
|
131
|
+
return a.major + Math.max(0, Math.floor((now - a.at) / CADENCE));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/*
|
|
135
|
+
* The last version an old operating system can run, and Firefox's extended
|
|
136
|
+
* support releases. A person stuck on one of these is still a person, so these
|
|
137
|
+
* are never called stale, however old.
|
|
138
|
+
*/
|
|
139
|
+
const TERMINAL_CHROME = [
|
|
140
|
+
{ major: 109, os: /Windows NT 6\./ }, // Windows 7, 8, 8.1
|
|
141
|
+
{ major: 116, os: /Macintosh/ }, // macOS 10.13, 10.14
|
|
142
|
+
{ major: 128, os: /Macintosh/ }, // macOS 10.15
|
|
143
|
+
{ major: 138, os: /Macintosh/ }, // macOS 11
|
|
144
|
+
];
|
|
145
|
+
const FIREFOX_ESR = [115, 128, 140, 153];
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A desktop browser string far older than anything that auto-updates.
|
|
149
|
+
*
|
|
150
|
+
* Chrome, Edge and Firefox update themselves every four weeks, so a desktop
|
|
151
|
+
* "Chrome/120" two years after Chrome 120 shipped is a string copied into a
|
|
152
|
+
* scraper's rotation pool, not a browser. That is exactly what the swarms do:
|
|
153
|
+
* on 2026-10-19 one pool of ten strings (Chrome 118-120, Edge 119-120, Firefox
|
|
154
|
+
* 120-121) walked watchnews.now and r4ck.dev from thousands of residential
|
|
155
|
+
* addresses, running the page scripts as it went.
|
|
156
|
+
*
|
|
157
|
+
* Only desktop strings are judged. Phones and embedded webviews legitimately
|
|
158
|
+
* sit years behind, and a declared crawler is judged by the lists instead.
|
|
159
|
+
*
|
|
160
|
+
* @param {string} ua
|
|
161
|
+
* @param {number} [now]
|
|
162
|
+
* @param {number} [maxMajorsBehind=24] about 22 months of releases
|
|
163
|
+
*/
|
|
164
|
+
export function isStaleBrowser(ua, now = Date.now(), maxMajorsBehind = 24) {
|
|
165
|
+
const s = String(ua ?? '');
|
|
166
|
+
if (!/Windows NT|Macintosh|X11; (?:Linux|Ubuntu|Fedora)/.test(s)) return false;
|
|
167
|
+
if (/Mobile|Android|iPhone|iPad|CrOS/.test(s)) return false;
|
|
168
|
+
if (DECLARES_ITSELF.test(s)) return false;
|
|
169
|
+
|
|
170
|
+
const ff = /\bFirefox\/(\d+)/.exec(s);
|
|
171
|
+
if (ff) {
|
|
172
|
+
const major = Number(ff[1]);
|
|
173
|
+
if (FIREFOX_ESR.includes(major)) return false;
|
|
174
|
+
return major < currentMajor('firefox', now) - maxMajorsBehind;
|
|
175
|
+
}
|
|
176
|
+
const chrome = /\bChrome\/(\d+)/.exec(s);
|
|
177
|
+
if (chrome) {
|
|
178
|
+
const major = Number(chrome[1]);
|
|
179
|
+
if (TERMINAL_CHROME.some((t) => t.major === major && t.os.test(s))) return false;
|
|
180
|
+
return major < currentMajor('chrome', now) - maxMajorsBehind;
|
|
181
|
+
}
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/* -------------------------------------------------------- welcome crawlers -- */
|
|
186
|
+
|
|
187
|
+
const COMPILED = new WeakMap();
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The welcome crawler a request is, judged by name AND address, or null.
|
|
191
|
+
*
|
|
192
|
+
* @param {Request} request
|
|
193
|
+
* @param {{token: string, cidrs: string[]}[]} list e.g. WELCOME_CRAWLERS
|
|
194
|
+
*/
|
|
195
|
+
export function welcomeCrawler(request, list) {
|
|
196
|
+
if (!list?.length) return null;
|
|
197
|
+
const ua = (request.headers.get('user-agent') ?? '').toLowerCase();
|
|
198
|
+
if (!ua) return null;
|
|
199
|
+
const ip = clientIp(request);
|
|
200
|
+
for (const c of list) {
|
|
201
|
+
if (!ua.includes(c.token.toLowerCase())) continue;
|
|
202
|
+
let compiled = COMPILED.get(c);
|
|
203
|
+
if (!compiled) COMPILED.set(c, (compiled = compileCidrs(c.cidrs)));
|
|
204
|
+
if (inCidrs(ip, compiled)) return c.token;
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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';
|
|
3
|
+
import { clientIp, compileCidrs, inCidrs, isSpoofedBrowser, isStaleBrowser, welcomeCrawler } from './edge.js';
|
|
3
4
|
import { memoryQuotaStore, meters, normaliseQuota, quotaHeaders, spend } from './quota.js';
|
|
4
5
|
import { renderPage } from './page.js';
|
|
5
6
|
import { mintPass, readPass } from './pass.js';
|
|
@@ -15,13 +16,14 @@ import {
|
|
|
15
16
|
verifyAndSettle,
|
|
16
17
|
} from './x402.js';
|
|
17
18
|
|
|
18
|
-
export { isTrainingAgent, RETRIEVAL_AGENTS, TRAINING_AGENTS } from './agents.js';
|
|
19
|
-
export { clientIp, compileCidrs, inCidrs, isSpoofedBrowser, parseCidr } from './edge.js';
|
|
19
|
+
export { HEADLESS_AGENTS, isTrainingAgent, RETRIEVAL_AGENTS, SCRAPER_AGENTS, TRAINING_AGENTS, WELCOME_CRAWLERS } from './agents.js';
|
|
20
|
+
export { clientIp, compileCidrs, currentMajor, inCidrs, isSpoofedBrowser, isStaleBrowser, parseCidr, welcomeCrawler } from './edge.js';
|
|
20
21
|
export { renderPage } from './page.js';
|
|
21
22
|
export { mintPass, readPass } from './pass.js';
|
|
22
23
|
export { robotsTxt } from './robots.js';
|
|
23
24
|
export { buildOffer, decodePayment, expectedFor, METHODS, verifyAndSettle } from './x402.js';
|
|
24
25
|
export { memoryQuotaStore, normaliseQuota, quotaHeaders, spend } from './quota.js';
|
|
26
|
+
export { isPowerKey, keyFrom, PROFULLSTACK_KEYS, verifyKey } from '@profullstack/keys';
|
|
25
27
|
|
|
26
28
|
/**
|
|
27
29
|
* A gateway that sells crawl access to training crawlers, by the day, over x402.
|
|
@@ -54,8 +56,14 @@ export { memoryQuotaStore, normaliseQuota, quotaHeaders, spend } from './quota.j
|
|
|
54
56
|
* @param {(ua: string) => boolean} [options.isPaidAgent]
|
|
55
57
|
* @param {string[]} [options.denyCidrs] IPv4 ranges answered 403 before anything else, e.g. a VPS fleet's provider
|
|
56
58
|
* @param {boolean} [options.chargeSpoofedBrowsers=false] charge a "Chrome/…" request that lacks the Sec-Fetch-Mode header every Chromium sends
|
|
59
|
+
* @param {boolean} [options.chargeStaleBrowsers=true] charge a desktop browser string two years behind its auto-updates (a rotation pool)
|
|
60
|
+
* @param {string[]} [options.scrapers] SEO resellers charged and refused in robots.txt; default SCRAPER_AGENTS
|
|
61
|
+
* @param {string[]} [options.headless] headless browsers charged; default HEADLESS_AGENTS
|
|
62
|
+
* @param {{token: string, cidrs: string[]}[]|false} [options.welcome] search crawlers never metered when name and address agree; default WELCOME_CRAWLERS
|
|
57
63
|
* @param {(request: Request) => boolean} [options.exempt] requests never charged, e.g. ones carrying a signed-in cookie
|
|
58
64
|
* @param {string} [options.secret] pass signing secret; defaults to the CoinPay key
|
|
65
|
+
* @param {Record<string,string>|false} [options.powerKeys] kid -> public key of the Power Keys honoured as a pass; defaults to Profullstack's, `false` switches it off
|
|
66
|
+
* @param {Iterable<string>} [options.revokedKeys] Power Key subjects no longer honoured
|
|
59
67
|
* @param {(ctx: object) => string} [options.page] custom sales page renderer
|
|
60
68
|
* @param {string} [options.contact] mailto: or URL for bulk deals
|
|
61
69
|
* @param {(sale: object) => void|Promise<void>} [options.onSale] accounting hook; awaited before the receipt goes out, and its errors are swallowed
|
|
@@ -109,6 +117,13 @@ export function createGateway(options = {}) {
|
|
|
109
117
|
buy: days > 1 ? `${buyUrl}?days=${days}` : buyUrl,
|
|
110
118
|
buyDays: `${buyUrl}?days=<n>`,
|
|
111
119
|
},
|
|
120
|
+
/*
|
|
121
|
+
* The other way in. A Power Key from the shop opens this site and every
|
|
122
|
+
* other Profullstack property for good, so the 402 says so: a client that
|
|
123
|
+
* has one but sent it in the wrong place, or that would rather buy once
|
|
124
|
+
* than pay by the day, learns here where and how.
|
|
125
|
+
*/
|
|
126
|
+
...(o.powerKeys ? { powerKey: { shop: SHOP_URL, header: POWER_KEY_HEADER, bearer: true } } : {}),
|
|
112
127
|
...extra,
|
|
113
128
|
});
|
|
114
129
|
|
|
@@ -152,14 +167,35 @@ export function createGateway(options = {}) {
|
|
|
152
167
|
training: o.training,
|
|
153
168
|
retrieval: o.retrieval,
|
|
154
169
|
contact: o.contact,
|
|
170
|
+
powerKey: o.powerKeys ? { shop: SHOP_URL, header: POWER_KEY_HEADER } : null,
|
|
155
171
|
});
|
|
156
172
|
|
|
157
|
-
/**
|
|
173
|
+
/**
|
|
174
|
+
* The pass a request presents: a day pass from the named header or a bearer
|
|
175
|
+
* token, or a Power Key wherever @profullstack/keys reads one.
|
|
176
|
+
*/
|
|
158
177
|
const passFrom = (request) => {
|
|
159
178
|
const direct = request.headers.get(o.header);
|
|
160
179
|
if (direct) return direct.trim();
|
|
161
180
|
const m = /^Bearer\s+(cp_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)$/i.exec(request.headers.get('authorization') ?? '');
|
|
162
|
-
|
|
181
|
+
if (m) return m[1];
|
|
182
|
+
return o.powerKeys ? keyFrom(request) : null;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Whether a token opens the site: a live day pass this gateway minted, or a
|
|
187
|
+
* Power Key signed by a key in `powerKeys` and not revoked. A Power Key is
|
|
188
|
+
* bought once at the shop and honoured everywhere, which is the point of it:
|
|
189
|
+
* the site never asks anyone whether it is good, it checks the signature.
|
|
190
|
+
*/
|
|
191
|
+
const honours = async (token) => {
|
|
192
|
+
if (!token) return false;
|
|
193
|
+
if (isPowerKey(token)) {
|
|
194
|
+
if (!o.powerKeys) return false;
|
|
195
|
+
const result = await verifyKey(token, { publicKeys: o.powerKeys, revoked: o.revokedKeys });
|
|
196
|
+
return result.ok;
|
|
197
|
+
}
|
|
198
|
+
return Boolean(await readPass(token, { secret }));
|
|
163
199
|
};
|
|
164
200
|
|
|
165
201
|
/**
|
|
@@ -342,15 +378,24 @@ export function createGateway(options = {}) {
|
|
|
342
378
|
* were the only reason to refuse and is not now that a quota exists.
|
|
343
379
|
*/
|
|
344
380
|
const token = passFrom(request);
|
|
345
|
-
|
|
346
|
-
if (paid) return null;
|
|
381
|
+
if (await honours(token)) return null;
|
|
347
382
|
|
|
383
|
+
const ua = request.headers.get('user-agent') ?? '';
|
|
348
384
|
const pays =
|
|
349
|
-
o.isPaidAgent(
|
|
350
|
-
(o.chargeSpoofedBrowsers && isSpoofedBrowser(request))
|
|
385
|
+
o.isPaidAgent(ua) ||
|
|
386
|
+
(o.chargeSpoofedBrowsers && isSpoofedBrowser(request)) ||
|
|
387
|
+
(o.chargeStaleBrowsers && isStaleBrowser(ua));
|
|
351
388
|
if (pays && !isOpen(path)) return sell(request);
|
|
352
389
|
if (isOpen(path)) return null;
|
|
353
390
|
|
|
391
|
+
/*
|
|
392
|
+
* A search crawler that is who it says it is reads free and unmetered.
|
|
393
|
+
* Googlebot walking a large site from a handful of addresses outruns any
|
|
394
|
+
* per-address allowance, and answering it 402 is how a site leaves the
|
|
395
|
+
* index. Name and address must agree, so copying the string buys nothing.
|
|
396
|
+
*/
|
|
397
|
+
if (welcomeCrawler(request, o.welcome)) return null;
|
|
398
|
+
|
|
354
399
|
/*
|
|
355
400
|
* Everyone else gets the free allowance. Running out is answered with a
|
|
356
401
|
* price rather than a 429: the caller has just shown it wants more than
|
|
@@ -387,7 +432,22 @@ export function createGateway(options = {}) {
|
|
|
387
432
|
* paying crawlers twice.
|
|
388
433
|
*/
|
|
389
434
|
passFrom,
|
|
390
|
-
verifyPass:
|
|
435
|
+
verifyPass: honours,
|
|
436
|
+
/**
|
|
437
|
+
* Whether a request is a charged crawler by this gateway's rules (the
|
|
438
|
+
* lists, a spoofed or a stale browser string), and whether it is a welcome
|
|
439
|
+
* search crawler whose address matches its name. Exposed so a throttle
|
|
440
|
+
* that meters every route charges and spares exactly the same callers.
|
|
441
|
+
*/
|
|
442
|
+
isPaid: (request) => {
|
|
443
|
+
const ua = request.headers.get('user-agent') ?? '';
|
|
444
|
+
return (
|
|
445
|
+
o.isPaidAgent(ua) ||
|
|
446
|
+
(o.chargeSpoofedBrowsers && isSpoofedBrowser(request)) ||
|
|
447
|
+
(o.chargeStaleBrowsers && isStaleBrowser(ua))
|
|
448
|
+
);
|
|
449
|
+
},
|
|
450
|
+
isWelcome: (request) => Boolean(welcomeCrawler(request, o.welcome)),
|
|
391
451
|
/**
|
|
392
452
|
* The x402 offer this gateway would make, and the full receipt body around
|
|
393
453
|
* it, without answering a request.
|
|
@@ -404,7 +464,13 @@ export function createGateway(options = {}) {
|
|
|
404
464
|
receipt,
|
|
405
465
|
/** robots.txt with this gateway's lists and sales path. */
|
|
406
466
|
robotsTxt: (extra = {}) =>
|
|
407
|
-
robotsTxt({
|
|
467
|
+
robotsTxt({
|
|
468
|
+
siteUrl: o.siteUrl,
|
|
469
|
+
path: o.path,
|
|
470
|
+
training: [...o.training, ...o.scrapers],
|
|
471
|
+
retrieval: o.retrieval,
|
|
472
|
+
...extra,
|
|
473
|
+
}),
|
|
408
474
|
/** The sales page as HTML, for a site that mounts it on a route of its own. */
|
|
409
475
|
page: () => o.page(pageCtx()),
|
|
410
476
|
};
|
|
@@ -453,6 +519,9 @@ function normalise(options) {
|
|
|
453
519
|
const siteUrl = String(options.siteUrl ?? '').replace(/\/+$/, '');
|
|
454
520
|
if (!siteUrl) throw new Error('createGateway needs siteUrl');
|
|
455
521
|
const training = options.training ?? TRAINING_AGENTS;
|
|
522
|
+
const scrapers = options.scrapers ?? SCRAPER_AGENTS;
|
|
523
|
+
const headless = options.headless ?? HEADLESS_AGENTS;
|
|
524
|
+
const paid = [...training, ...scrapers, ...headless];
|
|
456
525
|
return {
|
|
457
526
|
siteUrl,
|
|
458
527
|
siteName: options.siteName || new URL(siteUrl).hostname,
|
|
@@ -470,16 +539,22 @@ function normalise(options) {
|
|
|
470
539
|
openPaths: options.openPaths ?? [],
|
|
471
540
|
denyCidrs: options.denyCidrs ?? [],
|
|
472
541
|
chargeSpoofedBrowsers: Boolean(options.chargeSpoofedBrowsers),
|
|
542
|
+
chargeStaleBrowsers: options.chargeStaleBrowsers !== false,
|
|
473
543
|
exempt: options.exempt ?? null,
|
|
474
544
|
training,
|
|
545
|
+
scrapers,
|
|
546
|
+
headless,
|
|
547
|
+
welcome: options.welcome === false ? null : (options.welcome ?? WELCOME_CRAWLERS),
|
|
475
548
|
retrieval: options.retrieval ?? RETRIEVAL_AGENTS,
|
|
476
|
-
isPaidAgent: options.isPaidAgent ?? ((ua) => isTrainingAgent(ua,
|
|
549
|
+
isPaidAgent: options.isPaidAgent ?? ((ua) => isTrainingAgent(ua, paid)),
|
|
477
550
|
secret: options.secret ?? '',
|
|
478
551
|
page: options.page ?? renderPage,
|
|
479
552
|
contact: options.contact ?? '',
|
|
480
553
|
onSale: options.onSale ?? null,
|
|
481
554
|
freeQuota: normaliseQuota(options.freeQuota),
|
|
482
555
|
benefits: Array.isArray(options.benefits) ? options.benefits : null,
|
|
556
|
+
powerKeys: options.powerKeys === false ? null : (options.powerKeys ?? PROFULLSTACK_KEYS),
|
|
557
|
+
revokedKeys: options.revokedKeys ?? null,
|
|
483
558
|
fetch: options.fetch ?? globalThis.fetch,
|
|
484
559
|
};
|
|
485
560
|
}
|
package/src/page.js
CHANGED
|
@@ -60,6 +60,7 @@ export function renderPage(ctx) {
|
|
|
60
60
|
maxDays = 30,
|
|
61
61
|
quota = null,
|
|
62
62
|
benefits = null,
|
|
63
|
+
powerKey = null,
|
|
63
64
|
} = ctx;
|
|
64
65
|
|
|
65
66
|
/*
|
|
@@ -95,6 +96,10 @@ export function renderPage(ctx) {
|
|
|
95
96
|
benefits && benefits.length
|
|
96
97
|
? `<h2>What a pass gets you</h2>\n<ul>\n${benefits.map((b) => ` <li>${esc(b)}</li>`).join('\n')}\n</ul>`
|
|
97
98
|
: '';
|
|
99
|
+
const powerKeyNote = powerKey
|
|
100
|
+
? `<h2>Or buy one key for every Profullstack site</h2>
|
|
101
|
+
<p>A <a href="${esc(powerKey.shop)}">Power Key</a> is bought once and opens this site and every other Profullstack property, with no day passes and no limits. Send it as <code>Authorization: Bearer pfs_...</code> or in <code>${esc(powerKey.header)}</code> and this page never answers 402 again.</p>`
|
|
102
|
+
: '';
|
|
98
103
|
const window =
|
|
99
104
|
minutes === 1440
|
|
100
105
|
? 'one day'
|
|
@@ -134,6 +139,7 @@ ${
|
|
|
134
139
|
}
|
|
135
140
|
|
|
136
141
|
${unlocks}
|
|
142
|
+
${powerKeyNote}
|
|
137
143
|
${arithmetic}
|
|
138
144
|
|
|
139
145
|
<h2>How it works</h2>
|