@profullstack/x402-gateway 0.2.2 → 0.3.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
@@ -4,6 +4,8 @@ Sell crawl access to AI training crawlers, by the day, over [x402](https://x402.
4
4
 
5
5
  People read your site free. So do search engines and the retrieval crawlers behind AI answers, because they send readers back. A crawler that copies pages into a training corpus sends nobody back, so it pays: every page answers `402 Payment Required` with an x402 offer, paying the offer returns a signed pass, and the pass opens the site for a day.
6
6
 
7
+ A crawler that wants longer buys more days in one payment. `?days=7` on the sales page quotes seven days at the daily price, and the days a proof buys are read off the value it authorizes, so paying seven times the price — however it was asked for — returns a pass that expires seven days out. `maxDays` caps how many one proof can buy.
8
+
7
9
  One middleware. No database. Runs in Node, Bun and at the edge.
8
10
 
9
11
  ```
@@ -82,7 +84,8 @@ export const GET = robotsRoute(gateway, { disallow: ['/login', '/api/'] });
82
84
  | `coinpay.apiKey` | | a **scoped** CoinPay key (`cp_live_…`, from the business's API Keys tab) with `payments:create`. The legacy business key is refused by CoinPay's x402 routes. |
83
85
  | `payTo` | | EVM address that receives the USDC, on Base, Polygon and Ethereum alike |
84
86
  | `priceCents` | `100` | |
85
- | `passMinutes` | `1440` | a day |
87
+ | `passMinutes` | `1440` | a day: the term one price buys |
88
+ | `maxDays` | `30` | the most terms one proof may buy at once |
86
89
  | `header` | `x-crawl-pass` | where the pass goes; `Authorization: Bearer` works too |
87
90
  | `path` | `/crawl` | the sales page |
88
91
  | `openPaths` | `[]` | extra paths a refused crawler may read (`robots.txt`, the sales page, `security.txt` and `.well-known/` always are) |
package/index.d.ts CHANGED
@@ -7,7 +7,12 @@ export interface Sale {
7
7
  token: string;
8
8
  expiresAt: string;
9
9
  userAgent: string;
10
+ /** Per term (`passMinutes`). */
10
11
  priceCents: number;
12
+ /** Terms this proof bought. */
13
+ days: number;
14
+ /** `priceCents * days`. */
15
+ totalCents: number;
11
16
  currency: string;
12
17
  }
13
18
 
@@ -15,8 +20,14 @@ export interface PageContext {
15
20
  siteName: string;
16
21
  siteUrl: string;
17
22
  buyUrl: string;
23
+ /** Per day, e.g. "1.00 USD". */
18
24
  price: string;
19
25
  minutes: number;
26
+ /** Days this page's offer quotes (`?days=`), 1 by default. */
27
+ days: number;
28
+ /** `price` times `days`. */
29
+ total: string;
30
+ maxDays: number;
20
31
  header: string;
21
32
  enabled: boolean;
22
33
  offer: Offer;
@@ -37,8 +48,10 @@ export interface GatewayOptions {
37
48
  /** Default 100 ($1). */
38
49
  priceCents?: number;
39
50
  currency?: string;
40
- /** What a payment buys. Default 1440 (a day). */
51
+ /** What one price buys. Default 1440 (a day). */
41
52
  passMinutes?: number;
53
+ /** The most terms one proof may buy at once (`?days=` and paid multiples are clamped to it). Default 30. */
54
+ maxDays?: number;
42
55
  /** Request header the pass is presented in. Default 'x-crawl-pass'. */
43
56
  header?: string;
44
57
  /** The sales page. Default '/crawl'. */
@@ -133,6 +146,10 @@ export const X402_METHODS: typeof METHODS;
133
146
  export function buildOffer(args: { payTo: string; priceCents: number; resource: string; description?: string; maxTimeoutSeconds?: number; methods?: typeof METHODS }): Offer;
134
147
  export function decodePayment(header: string | null | undefined): Record<string, unknown> | null;
135
148
  export function expectedFor(payment: unknown, offer: Offer): { amount: string; resource: string; payTo: string; asset: string } | null;
149
+ /** The value a proof authorizes, in the token's smallest unit, or null. */
150
+ export function paidValueOf(payment: unknown): bigint | null;
151
+ /** How many terms `value` buys at `unit` per term: a whole number in [1, maxDays], or 0. */
152
+ export function daysPaid(value: bigint | null, unit: string | number | bigint, maxDays: number): number;
136
153
  export function verifyAndSettle(
137
154
  payment: unknown,
138
155
  expected: { amount: string; resource: string; payTo: string; asset: string },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/x402-gateway",
3
- "version": "0.2.2",
3
+ "version": "0.3.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, and a robots.txt that keeps search crawlers welcome.",
6
6
  "keywords": [
package/src/index.js CHANGED
@@ -29,7 +29,9 @@ export { buildOffer, decodePayment, expectedFor, METHODS, verifyAndSettle } from
29
29
  * or as an HTML sales page if it asked for HTML -- on every path but the few it
30
30
  * needs to read to comply. Paying the offer, at the sales page or on any 402'd
31
31
  * URL, returns a signed pass good for `passMinutes`, presented in `header` on
32
- * every request after that.
32
+ * every request after that. A crawler that wants longer buys more days at
33
+ * once: `?days=N` on the sales page quotes N terms, and a proof for N times
34
+ * the price — however it was asked for — buys a pass that lasts N terms.
33
35
  *
34
36
  * Framework-agnostic: `handle(request)` takes a Fetch `Request` and resolves to
35
37
  * a `Response` to send, or null to let the request through. The adapters in
@@ -42,7 +44,8 @@ export { buildOffer, decodePayment, expectedFor, METHODS, verifyAndSettle } from
42
44
  * @param {string} [options.payTo] EVM address that receives the USDC
43
45
  * @param {number} [options.priceCents=100]
44
46
  * @param {string} [options.currency='USD']
45
- * @param {number} [options.passMinutes=1440] a day
47
+ * @param {number} [options.passMinutes=1440] a day: the term one payment buys
48
+ * @param {number} [options.maxDays=30] the most terms one proof may buy at once
46
49
  * @param {string} [options.header='x-crawl-pass']
47
50
  * @param {string} [options.path='/crawl'] the sales page
48
51
  * @param {string[]} [options.openPaths] extra paths a refused crawler may read
@@ -65,22 +68,45 @@ export function createGateway(options = {}) {
65
68
  const denied = compileCidrs(o.denyCidrs);
66
69
  const isOpen = (path) => openPaths.some((p) => (p.endsWith('/') ? path.startsWith(p) : path === p));
67
70
 
68
- const price = `${(o.priceCents / 100).toFixed(2)} ${o.currency}`;
71
+ const money = (cents) => `${(cents / 100).toFixed(2)} ${o.currency}`;
72
+ const price = money(o.priceCents);
69
73
  const buyUrl = `${o.siteUrl}${o.path}`;
70
74
 
71
- const offer = () =>
75
+ /**
76
+ * How many terms a request is asking to buy: `?days=N`, clamped to
77
+ * [1, maxDays]. Anything unparseable is one day, which is what the offer
78
+ * always meant before there was a way to ask for more.
79
+ */
80
+ const daysFrom = (request) => {
81
+ const raw = new URL(request.url).searchParams.get('days');
82
+ const n = Number.parseInt(raw ?? '', 10);
83
+ if (!Number.isFinite(n) || n < 1) return 1;
84
+ return Math.min(n, o.maxDays);
85
+ };
86
+
87
+ /** The offer for `days` terms: the same entries, `days` times the price. */
88
+ const offer = (days = 1) =>
72
89
  enabled
73
90
  ? buildOffer({
74
91
  payTo: o.payTo,
75
- priceCents: o.priceCents,
92
+ priceCents: o.priceCents * days,
76
93
  resource: buyUrl,
77
- description: `${o.passMinutes} minutes of crawl access to ${o.siteUrl}`,
94
+ description: `${days * o.passMinutes} minutes of crawl access to ${o.siteUrl}${days > 1 ? ` (${days} × ${o.passMinutes})` : ''}`,
78
95
  })
79
96
  : { x402Version: 2, accepts: [] };
80
97
 
81
- const receipt = (extra = {}) => ({
82
- ...offer(),
83
- pass: { price, minutes: o.passMinutes, header: o.header, buy: buyUrl },
98
+ const receipt = (days = 1, extra = {}) => ({
99
+ ...offer(days),
100
+ pass: {
101
+ price,
102
+ minutes: o.passMinutes,
103
+ days,
104
+ total: money(o.priceCents * days),
105
+ maxDays: o.maxDays,
106
+ header: o.header,
107
+ buy: days > 1 ? `${buyUrl}?days=${days}` : buyUrl,
108
+ buyDays: `${buyUrl}?days=<n>`,
109
+ },
84
110
  ...extra,
85
111
  });
86
112
 
@@ -96,12 +122,15 @@ export function createGateway(options = {}) {
96
122
  const html = (body, status) =>
97
123
  new Response(body, { status, headers: { 'content-type': 'text/html; charset=utf-8', ...noStore } });
98
124
 
99
- const pageCtx = () => ({
125
+ const pageCtx = (days = 1) => ({
126
+ days,
127
+ total: money(o.priceCents * days),
100
128
  siteName: o.siteName,
101
129
  siteUrl: o.siteUrl,
102
130
  buyUrl,
103
131
  price,
104
132
  minutes: o.passMinutes,
133
+ maxDays: o.maxDays,
105
134
  header: o.header,
106
135
  enabled,
107
136
  offer: offer(),
@@ -129,14 +158,35 @@ export function createGateway(options = {}) {
129
158
  async function sell(request) {
130
159
  const ua = request.headers.get('user-agent') ?? '';
131
160
  const proofHeader = request.headers.get('x-payment');
161
+ const asked = daysFrom(request);
132
162
 
133
163
  if (proofHeader) {
134
- if (!enabled) return json(receipt({ error: 'Payments are not switched on here.' }), 402);
164
+ if (!enabled) return json(receipt(asked, { error: 'Payments are not switched on here.' }), 402);
135
165
  const payment = decodePayment(proofHeader);
136
- if (!payment) return json(receipt({ error: 'X-PAYMENT is not base64 JSON.' }), 402);
137
- const current = offer();
138
- const expected = expectedFor(payment, current);
139
- if (!expected) return json(receipt({ error: 'Proof does not match an offered network.' }), 402);
166
+ if (!payment) return json(receipt(asked, { error: 'X-PAYMENT is not base64 JSON.' }), 402);
167
+ const unit = expectedFor(payment, offer(1));
168
+ if (!unit) return json(receipt(asked, { error: 'Proof does not match an offered network.' }), 402);
169
+
170
+ /*
171
+ * The money decides the term, not the URL. A proof is an authorization
172
+ * for an exact value, and the value the buyer signed is what CoinPay
173
+ * will move -- so the days it buys are read off the proof: a whole
174
+ * number of day-prices, at most maxDays. `?days=` shaped the offer the
175
+ * buyer read; if they then signed for a different multiple, they get
176
+ * what they paid for, and if they signed for something that is not a
177
+ * multiple they get nothing, before anyone is charged.
178
+ */
179
+ const days = daysPaid(paidValueOf(payment), unit.amount, o.maxDays);
180
+ if (!days) {
181
+ return json(
182
+ receipt(asked, {
183
+ error: `Pay a whole number of days: ${unit.amount} per day in the token's smallest unit, up to ${o.maxDays} days. Add ?days=<n> to ${buyUrl} for the offer.`,
184
+ }),
185
+ 402,
186
+ );
187
+ }
188
+ const expected = expectedFor(payment, offer(days));
189
+ const term = days * o.passMinutes * 60;
140
190
 
141
191
  const now = Math.floor(Date.now() / 1000);
142
192
  const coinpay = { apiKey: o.coinpay.apiKey, baseUrl: o.coinpay.baseUrl, fetch: o.fetch };
@@ -145,7 +195,7 @@ export function createGateway(options = {}) {
145
195
  let expiresAt = null;
146
196
  let replayed = false;
147
197
  if (result.ok) {
148
- expiresAt = now + o.passMinutes * 60;
198
+ expiresAt = now + term;
149
199
  } else if (result.replay) {
150
200
  /*
151
201
  * Paid once, lost the answer, asked again with the same proof. Answered
@@ -158,12 +208,12 @@ export function createGateway(options = {}) {
158
208
  const paid = await settleAgain(payment, coinpay);
159
209
  const validBefore = validBeforeOf(payment);
160
210
  if (paid && validBefore) {
161
- expiresAt = Math.min(now + o.passMinutes * 60, validBefore + o.passMinutes * 60);
211
+ expiresAt = Math.min(now + term, validBefore + term);
162
212
  replayed = true;
163
213
  }
164
214
  }
165
215
  if (!expiresAt || expiresAt <= now) {
166
- return json(receipt({ error: result.reason ?? 'Payment could not be settled.' }), 402);
216
+ return json(receipt(days, { error: result.reason ?? 'Payment could not be settled.' }), 402);
167
217
  }
168
218
 
169
219
  const ref = nonceOf(payment) ?? result.ref ?? null;
@@ -178,6 +228,8 @@ export function createGateway(options = {}) {
178
228
  expiresAt: expires,
179
229
  userAgent: ua,
180
230
  priceCents: o.priceCents,
231
+ days,
232
+ totalCents: o.priceCents * days,
181
233
  currency: o.currency,
182
234
  });
183
235
  } catch {
@@ -189,6 +241,8 @@ export function createGateway(options = {}) {
189
241
  ok: true,
190
242
  pass: pass.token,
191
243
  expires_at: expires,
244
+ days,
245
+ minutes: days * o.passMinutes,
192
246
  header: o.header,
193
247
  replayed,
194
248
  use: `curl -H "${o.header}: ${pass.token}" ${o.siteUrl}/`,
@@ -198,8 +252,8 @@ export function createGateway(options = {}) {
198
252
  );
199
253
  }
200
254
 
201
- if (wantsHtml(request.headers.get('accept'))) return html(o.page(pageCtx()), 402);
202
- return json(receipt({ error: `Payment required for training crawlers. Read ${buyUrl} for how.` }), 402);
255
+ if (wantsHtml(request.headers.get('accept'))) return html(o.page(pageCtx(asked)), 402);
256
+ return json(receipt(asked, { error: `Payment required for training crawlers. Read ${buyUrl} for how.` }), 402);
203
257
  }
204
258
 
205
259
  /**
@@ -253,6 +307,42 @@ export function createGateway(options = {}) {
253
307
  /** Whether the caller would rather read a page than a JSON offer. */
254
308
  export const wantsHtml = (accept = '') => String(accept ?? '').toLowerCase().includes('text/html');
255
309
 
310
+ /** The value a proof authorizes, in the token's smallest unit, or null. */
311
+ export function paidValueOf(payment) {
312
+ const raw = payment?.payload?.authorization?.value;
313
+ if (raw === undefined || raw === null || raw === '') return null;
314
+ try {
315
+ const value = BigInt(raw);
316
+ return value > 0n ? value : null;
317
+ } catch {
318
+ return null;
319
+ }
320
+ }
321
+
322
+ /**
323
+ * How many terms a paid value buys at `unit` per term: a whole number in
324
+ * [1, maxDays], or 0 when it is not one. Integer arithmetic on the smallest
325
+ * unit, so a price that is not a round number of cents still divides exactly.
326
+ *
327
+ * @param {bigint|null} value
328
+ * @param {string|number|bigint} unit
329
+ * @param {number} maxDays
330
+ * @returns {number}
331
+ */
332
+ export function daysPaid(value, unit, maxDays) {
333
+ if (value === null) return 0;
334
+ let per;
335
+ try {
336
+ per = BigInt(unit);
337
+ } catch {
338
+ return 0;
339
+ }
340
+ if (per <= 0n || value % per !== 0n) return 0;
341
+ const days = value / per;
342
+ if (days < 1n || days > BigInt(maxDays)) return 0;
343
+ return Number(days);
344
+ }
345
+
256
346
  function normalise(options) {
257
347
  const siteUrl = String(options.siteUrl ?? '').replace(/\/+$/, '');
258
348
  if (!siteUrl) throw new Error('createGateway needs siteUrl');
@@ -268,6 +358,7 @@ function normalise(options) {
268
358
  priceCents: Number.isFinite(options.priceCents) ? options.priceCents : 100,
269
359
  currency: options.currency ?? 'USD',
270
360
  passMinutes: Number.isFinite(options.passMinutes) && options.passMinutes > 0 ? options.passMinutes : 1440,
361
+ maxDays: Number.isInteger(options.maxDays) && options.maxDays >= 1 ? options.maxDays : 30,
271
362
  header: String(options.header ?? 'x-crawl-pass').toLowerCase(),
272
363
  path: options.path ?? '/crawl',
273
364
  openPaths: options.openPaths ?? [],
package/src/page.js CHANGED
@@ -55,6 +55,9 @@ export function renderPage(ctx) {
55
55
  training = [],
56
56
  retrieval = [],
57
57
  contact,
58
+ days = 1,
59
+ total = price,
60
+ maxDays = 30,
58
61
  } = ctx;
59
62
  const window =
60
63
  minutes === 1440
@@ -82,7 +85,12 @@ export function renderPage(ctx) {
82
85
  <h1>Training crawlers pay for access here.</h1>
83
86
  <p class="mut">People read <a href="${esc(siteUrl)}">${esc(siteName)}</a> free. So do search engines and the retrieval crawlers behind AI answers, because they send readers back. A crawler that copies pages into a training corpus sends nobody back, so it pays for the time it spends.</p>
84
87
 
85
- <div class="price">${esc(price)} <span class="mut" style="font-size:1rem;font-weight:400">for ${esc(window)} of requests</span></div>
88
+ <div class="price">${esc(days > 1 ? total : price)} <span class="mut" style="font-size:1rem;font-weight:400">for ${esc(days > 1 ? `${days} × ${window}` : window)} of requests</span></div>
89
+ ${
90
+ days > 1
91
+ ? `<p class="mut">This offer is for ${days} days at ${esc(price)} a day. The plain page at <code>${esc(buyUrl)}</code> quotes one.</p>`
92
+ : `<p class="mut">Want longer? Add <code>?days=&lt;n&gt;</code> to this URL for an offer of up to ${maxDays} days at ${esc(price)} a day, or simply pay a whole multiple of the price: the pass lasts as many days as you paid for.</p>`
93
+ }
86
94
  ${
87
95
  enabled
88
96
  ? ''
@@ -93,13 +101,15 @@ ${
93
101
  <ol>
94
102
  <li>Any page you fetch answers <code>402 Payment Required</code>. This page, fetched with <code>Accept: application/json</code>, returns the x402 offer: USDC, <code>exact</code> scheme, on ${esc(networks || 'Base, Polygon or Ethereum')}.</li>
95
103
  <li>Sign the payment and retry with the proof in an <code>X-PAYMENT</code> header. The response is a JSON receipt carrying a pass.</li>
96
- <li>Send the pass in <code>${esc(header)}</code> on every request for the next ${esc(window)}. When it expires, buy another. The sale is the pass, not the page: fetch the page again with the pass.</li>
104
+ <li>Send the pass in <code>${esc(header)}</code> on every request until it expires ${esc(window)} per day paid, so a proof for three times the price buys three. When it expires, buy another. The sale is the pass, not the page: fetch the page again with the pass.</li>
97
105
  </ol>
98
106
 
99
107
  <h2>Pay with the CoinPay CLI</h2>
100
108
  <p>Settlement is by CoinPay: the buyer's USDC goes straight to the site's wallet and CoinPay's relayer pays the gas, so you need USDC and nothing else.</p>
101
109
  <pre><code>npm install -g @profullstack/coinpay
102
- coinpay x402 pay ${esc(buyUrl)} --output pass.json</code></pre>
110
+ coinpay x402 pay ${esc(buyUrl)} --output pass.json
111
+ # or a week at once:
112
+ coinpay x402 pay "${esc(buyUrl)}?days=7" --output pass.json</code></pre>
103
113
  <p>The command fetches this page, reads the offer, opens a browser tab to approve the payment with the CoinPay Wallet extension or any EIP-6963 wallet (MetaMask, Rabby, Coinbase Wallet), and writes the receipt to <code>pass.json</code>. Then:</p>
104
114
  <pre><code>PASS=$(node -p "require('./pass.json').pass")
105
115
  curl -H "${esc(header)}: $PASS" ${esc(siteUrl)}/</code></pre>
@@ -109,7 +119,8 @@ curl -H "${esc(header)}: $PASS" ${esc(siteUrl)}/</code></pre>
109
119
  # 402 with { "x402Version": 2, "accepts": [ ... ] }
110
120
  # sign an EIP-3009 transferWithAuthorization for one entry, then:
111
121
  curl -sS -H "X-PAYMENT: &lt;base64 proof&gt;" ${esc(buyUrl)}
112
- # 200 with { "ok": true, "pass": "cp_...", "expires_at": "...", "header": "${esc(header)}" }</code></pre>
122
+ # 200 with { "ok": true, "pass": "cp_...", "expires_at": "...", "days": 1, "header": "${esc(header)}" }</code></pre>
123
+ <p class="mut">The days a proof buys are read off the value it authorizes: a whole multiple of the one-day amount, up to ${maxDays}. <code>?days=&lt;n&gt;</code> only changes what the offer quotes, so a standard client that pays exactly what is asked gets <em>n</em> days.</p>
113
124
  <p class="mut">The proof is x402 v2 in CoinPay's dialect: <code>{ x402Version: 2, scheme: "exact", network: "&lt;CAIP-2&gt;", payload: { signature, authorization } }</code>, base64-encoded. A proof is single-use; retrying with the same one returns the same pass, not a second charge.</p>
114
125
 
115
126
  <h2>Who pays and who does not</h2>