@profullstack/x402-gateway 0.5.0 → 0.7.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 +10 -0
- package/index.d.ts +17 -2
- package/package.json +6 -2
- package/src/index.js +52 -5
- package/src/page.js +6 -0
package/README.md
CHANGED
|
@@ -99,9 +99,19 @@ export const GET = robotsRoute(gateway, { disallow: ['/login', '/api/'] });
|
|
|
99
99
|
| `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
100
|
| `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
101
|
| `exempt` | | `(request) => boolean`, never charged: e.g. a request carrying your signed-in cookie |
|
|
102
|
+
| `powerKeys` | Profullstack's | Power Keys honoured as a pass, kid to public key. `false` switches it off. See below. |
|
|
103
|
+
| `revokedKeys` | | Power Key subjects no longer honoured |
|
|
102
104
|
|
|
103
105
|
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
106
|
|
|
107
|
+
## One key for every site
|
|
108
|
+
|
|
109
|
+
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.
|
|
110
|
+
|
|
111
|
+
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.
|
|
112
|
+
|
|
113
|
+
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.
|
|
114
|
+
|
|
105
115
|
## A free allowance that sells instead of refusing
|
|
106
116
|
|
|
107
117
|
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 {
|
|
@@ -82,6 +84,15 @@ export interface GatewayOptions {
|
|
|
82
84
|
freeQuota?: number | FreeQuotaOptions;
|
|
83
85
|
/** What a pass unlocks, listed on the sales page and in the 402 body. */
|
|
84
86
|
benefits?: string[];
|
|
87
|
+
/**
|
|
88
|
+
* Power Keys honoured as a pass: kid -> base64url Ed25519 public key, as
|
|
89
|
+
* @profullstack/keys ships them. Defaults to Profullstack's keys, so a key
|
|
90
|
+
* bought at profullstack.com/shop opens this site with no configuration.
|
|
91
|
+
* `false` switches it off.
|
|
92
|
+
*/
|
|
93
|
+
powerKeys?: Record<string, string> | false;
|
|
94
|
+
/** Power Key subjects no longer honoured (refunded, leaked). */
|
|
95
|
+
revokedKeys?: Iterable<string>;
|
|
85
96
|
fetch?: typeof fetch;
|
|
86
97
|
}
|
|
87
98
|
|
|
@@ -176,10 +187,14 @@ export interface Gateway {
|
|
|
176
187
|
};
|
|
177
188
|
robotsTxt: (extra?: RobotsOptions) => string;
|
|
178
189
|
page: () => string;
|
|
179
|
-
/** The pass this request presents
|
|
190
|
+
/** The pass this request presents: a day pass from the gateway's header or a bearer token, or a Power Key. */
|
|
180
191
|
passFrom: (request: Request) => string | null;
|
|
181
|
-
/** Whether that token
|
|
192
|
+
/** Whether that token opens the site: a live day pass this gateway minted, or a valid, unrevoked Power Key. */
|
|
182
193
|
verifyPass: (token: string | null) => Promise<boolean>;
|
|
194
|
+
/** The x402 offer this gateway would make, without answering a request. */
|
|
195
|
+
offer: (days?: number) => Offer;
|
|
196
|
+
/** The full 402 body around that offer: the offer plus the pass terms. */
|
|
197
|
+
receipt: (days?: number, extra?: Record<string, unknown>) => Offer & Record<string, unknown>;
|
|
183
198
|
}
|
|
184
199
|
|
|
185
200
|
export function createGateway(options: GatewayOptions): Gateway;
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/x402-gateway",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { HEADER as POWER_KEY_HEADER, isPowerKey, keyFrom, PROFULLSTACK_KEYS, SHOP_URL, verifyKey } from '@profullstack/keys';
|
|
1
2
|
import { isTrainingAgent, RETRIEVAL_AGENTS, TRAINING_AGENTS } from './agents.js';
|
|
2
3
|
import { clientIp, compileCidrs, inCidrs, isSpoofedBrowser } from './edge.js';
|
|
3
4
|
import { memoryQuotaStore, meters, normaliseQuota, quotaHeaders, spend } from './quota.js';
|
|
@@ -22,6 +23,7 @@ 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.
|
|
@@ -56,6 +58,8 @@ export { memoryQuotaStore, normaliseQuota, quotaHeaders, spend } from './quota.j
|
|
|
56
58
|
* @param {boolean} [options.chargeSpoofedBrowsers=false] charge a "Chrome/…" request that lacks the Sec-Fetch-Mode header every Chromium sends
|
|
57
59
|
* @param {(request: Request) => boolean} [options.exempt] requests never charged, e.g. ones carrying a signed-in cookie
|
|
58
60
|
* @param {string} [options.secret] pass signing secret; defaults to the CoinPay key
|
|
61
|
+
* @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
|
|
62
|
+
* @param {Iterable<string>} [options.revokedKeys] Power Key subjects no longer honoured
|
|
59
63
|
* @param {(ctx: object) => string} [options.page] custom sales page renderer
|
|
60
64
|
* @param {string} [options.contact] mailto: or URL for bulk deals
|
|
61
65
|
* @param {(sale: object) => void|Promise<void>} [options.onSale] accounting hook; awaited before the receipt goes out, and its errors are swallowed
|
|
@@ -109,6 +113,13 @@ export function createGateway(options = {}) {
|
|
|
109
113
|
buy: days > 1 ? `${buyUrl}?days=${days}` : buyUrl,
|
|
110
114
|
buyDays: `${buyUrl}?days=<n>`,
|
|
111
115
|
},
|
|
116
|
+
/*
|
|
117
|
+
* The other way in. A Power Key from the shop opens this site and every
|
|
118
|
+
* other Profullstack property for good, so the 402 says so: a client that
|
|
119
|
+
* has one but sent it in the wrong place, or that would rather buy once
|
|
120
|
+
* than pay by the day, learns here where and how.
|
|
121
|
+
*/
|
|
122
|
+
...(o.powerKeys ? { powerKey: { shop: SHOP_URL, header: POWER_KEY_HEADER, bearer: true } } : {}),
|
|
112
123
|
...extra,
|
|
113
124
|
});
|
|
114
125
|
|
|
@@ -152,14 +163,35 @@ export function createGateway(options = {}) {
|
|
|
152
163
|
training: o.training,
|
|
153
164
|
retrieval: o.retrieval,
|
|
154
165
|
contact: o.contact,
|
|
166
|
+
powerKey: o.powerKeys ? { shop: SHOP_URL, header: POWER_KEY_HEADER } : null,
|
|
155
167
|
});
|
|
156
168
|
|
|
157
|
-
/**
|
|
169
|
+
/**
|
|
170
|
+
* The pass a request presents: a day pass from the named header or a bearer
|
|
171
|
+
* token, or a Power Key wherever @profullstack/keys reads one.
|
|
172
|
+
*/
|
|
158
173
|
const passFrom = (request) => {
|
|
159
174
|
const direct = request.headers.get(o.header);
|
|
160
175
|
if (direct) return direct.trim();
|
|
161
176
|
const m = /^Bearer\s+(cp_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)$/i.exec(request.headers.get('authorization') ?? '');
|
|
162
|
-
|
|
177
|
+
if (m) return m[1];
|
|
178
|
+
return o.powerKeys ? keyFrom(request) : null;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Whether a token opens the site: a live day pass this gateway minted, or a
|
|
183
|
+
* Power Key signed by a key in `powerKeys` and not revoked. A Power Key is
|
|
184
|
+
* bought once at the shop and honoured everywhere, which is the point of it:
|
|
185
|
+
* the site never asks anyone whether it is good, it checks the signature.
|
|
186
|
+
*/
|
|
187
|
+
const honours = async (token) => {
|
|
188
|
+
if (!token) return false;
|
|
189
|
+
if (isPowerKey(token)) {
|
|
190
|
+
if (!o.powerKeys) return false;
|
|
191
|
+
const result = await verifyKey(token, { publicKeys: o.powerKeys, revoked: o.revokedKeys });
|
|
192
|
+
return result.ok;
|
|
193
|
+
}
|
|
194
|
+
return Boolean(await readPass(token, { secret }));
|
|
163
195
|
};
|
|
164
196
|
|
|
165
197
|
/**
|
|
@@ -342,8 +374,7 @@ export function createGateway(options = {}) {
|
|
|
342
374
|
* were the only reason to refuse and is not now that a quota exists.
|
|
343
375
|
*/
|
|
344
376
|
const token = passFrom(request);
|
|
345
|
-
|
|
346
|
-
if (paid) return null;
|
|
377
|
+
if (await honours(token)) return null;
|
|
347
378
|
|
|
348
379
|
const pays =
|
|
349
380
|
o.isPaidAgent(request.headers.get('user-agent') ?? '') ||
|
|
@@ -387,7 +418,21 @@ export function createGateway(options = {}) {
|
|
|
387
418
|
* paying crawlers twice.
|
|
388
419
|
*/
|
|
389
420
|
passFrom,
|
|
390
|
-
verifyPass:
|
|
421
|
+
verifyPass: honours,
|
|
422
|
+
/**
|
|
423
|
+
* The x402 offer this gateway would make, and the full receipt body around
|
|
424
|
+
* it, without answering a request.
|
|
425
|
+
*
|
|
426
|
+
* A site with its own refusal -- a tiered limiter whose 429 body says which
|
|
427
|
+
* rung the caller is on and what the next one costs -- wants to keep that
|
|
428
|
+
* body and add a machine-readable offer to it, so an x402 client can pay
|
|
429
|
+
* on the spot instead of being told in prose to go and fetch /crawl. The
|
|
430
|
+
* alternative is every such site rebuilding payTo, price, currency and
|
|
431
|
+
* network from its own copy of the config, which is how two of them end up
|
|
432
|
+
* quoting different numbers.
|
|
433
|
+
*/
|
|
434
|
+
offer,
|
|
435
|
+
receipt,
|
|
391
436
|
/** robots.txt with this gateway's lists and sales path. */
|
|
392
437
|
robotsTxt: (extra = {}) =>
|
|
393
438
|
robotsTxt({ siteUrl: o.siteUrl, path: o.path, training: o.training, retrieval: o.retrieval, ...extra }),
|
|
@@ -466,6 +511,8 @@ function normalise(options) {
|
|
|
466
511
|
onSale: options.onSale ?? null,
|
|
467
512
|
freeQuota: normaliseQuota(options.freeQuota),
|
|
468
513
|
benefits: Array.isArray(options.benefits) ? options.benefits : null,
|
|
514
|
+
powerKeys: options.powerKeys === false ? null : (options.powerKeys ?? PROFULLSTACK_KEYS),
|
|
515
|
+
revokedKeys: options.revokedKeys ?? null,
|
|
469
516
|
fetch: options.fetch ?? globalThis.fetch,
|
|
470
517
|
};
|
|
471
518
|
}
|
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>
|