@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 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, from the gateway's header or a bearer token. */
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 is a live pass this gateway minted. */
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.5.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
- /** The pass a request presents, from the named header or a bearer token. */
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
- return m ? m[1] : null;
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
- const paid = Boolean(token && (await readPass(token, { secret })));
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: async (token) => Boolean(token && (await readPass(token, { secret }))),
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>