@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 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 list | `(userAgent) => boolean` |
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, from the gateway's header or a bearer token. */
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 is a live pass this gateway minted. */
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.6.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 { isTrainingAgent, RETRIEVAL_AGENTS, TRAINING_AGENTS } from './agents.js';
2
- import { clientIp, compileCidrs, inCidrs, isSpoofedBrowser } from './edge.js';
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
- /** The pass a request presents, from the named header or a bearer token. */
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
- return m ? m[1] : null;
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
- const paid = Boolean(token && (await readPass(token, { secret })));
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(request.headers.get('user-agent') ?? '') ||
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: async (token) => Boolean(token && (await readPass(token, { secret }))),
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({ siteUrl: o.siteUrl, path: o.path, training: o.training, retrieval: o.retrieval, ...extra }),
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, training)),
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>