@profullstack/x402-gateway 0.10.2 → 0.12.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/src/index.js CHANGED
@@ -2,8 +2,9 @@ import { HEADER as POWER_KEY_HEADER, isPowerKey, keyFrom, PROFULLSTACK_KEYS, SHO
2
2
  import { HEADLESS_AGENTS, isTrainingAgent, RETRIEVAL_AGENTS, SCRAPER_AGENTS, TRAINING_AGENTS, VERIFIED_RETRIEVAL, WELCOME_CRAWLERS } from './agents.js';
3
3
  import { clientIp, compileCidrs, impostorCrawler, inCidrs, isSpoofedBrowser, isStaleBrowser, welcomeCrawler } from './edge.js';
4
4
  import { memoryQuotaStore, meters, normaliseQuota, quotaHeaders, spend } from './quota.js';
5
- import { challengePage, challengeToken, passedChallenge } from './challenge.js';
6
- import { renderPage } from './page.js';
5
+ import { challengePage, challengeToken, cookieValue, passedChallenge } from './challenge.js';
6
+ import { checkoutClient, isEmail, isSessionId, sessionIdFromWebhook, sessionState } from './checkout.js';
7
+ import { renderNotice, renderPage, renderPassPage } from './page.js';
7
8
  import { mintPass, readPass } from './pass.js';
8
9
  import { robotsTxt } from './robots.js';
9
10
  import {
@@ -12,6 +13,8 @@ import {
12
13
  expectedFor,
13
14
  METHODS,
14
15
  nonceOf,
16
+ paymentRequiredHeader,
17
+ paymentResponseHeader,
15
18
  settleAgain,
16
19
  validBeforeOf,
17
20
  verifyAndSettle,
@@ -38,11 +41,22 @@ export {
38
41
  parseCidr,
39
42
  welcomeCrawler,
40
43
  } from './edge.js';
41
- export { challengePage, challengeToken, cookieValue, passedChallenge } from './challenge.js';
42
- export { renderPage } from './page.js';
44
+ export { challengePage, challengeToken, cookieValue, leadingZeros, passedChallenge, solves } from './challenge.js';
45
+ export { renderNotice, renderPage, renderPassPage } from './page.js';
46
+ export { checkoutClient, sessionIdFromWebhook, sessionState } from './checkout.js';
43
47
  export { mintPass, readPass } from './pass.js';
44
48
  export { robotsTxt } from './robots.js';
45
- export { buildOffer, decodePayment, expectedFor, METHODS, verifyAndSettle } from './x402.js';
49
+ export {
50
+ buildOffer,
51
+ decodeHeader,
52
+ decodePayment,
53
+ encodeHeader,
54
+ expectedFor,
55
+ METHODS,
56
+ paymentRequiredHeader,
57
+ paymentResponseHeader,
58
+ verifyAndSettle,
59
+ } from './x402.js';
46
60
  export { memoryQuotaStore, normaliseQuota, quotaHeaders, spend } from './quota.js';
47
61
  export { isPowerKey, keyFrom, PROFULLSTACK_KEYS, verifyKey } from '@profullstack/keys';
48
62
 
@@ -83,7 +97,7 @@ export { isPowerKey, keyFrom, PROFULLSTACK_KEYS, verifyKey } from '@profullstack
83
97
  * @param {string[]} [options.aiIndex] extra agents to charge and refuse in robots.txt, e.g. AI_INDEX_AGENTS; default none (they are welcome retrieval crawlers since 0.10)
84
98
  * @param {{token: string, cidrs: string[]}[]|false} [options.verifiedRetrieval] retrieval crawlers believed only from their published addresses; default VERIFIED_RETRIEVAL
85
99
  * @param {boolean} [options.chargeImpostors=true] charge a request wearing a welcome search crawler's name from outside its published ranges
86
- * @param {{paths: string[], cookie?: string, when?: (request: Request, url: URL) => boolean}|false} [options.challenge] requests to these paths (and, with `when`, only those it accepts, e.g. faceted URLs) must run a one-line script (cookie + reload) first; off by default
100
+ * @param {{paths: string[], cookie?: string, work?: number|boolean, when?: (request: Request, url: URL) => boolean}|false} [options.challenge] requests to these paths (and, with `when`, only those it accepts, e.g. faceted URLs) must run a one-line script (cookie + reload) first; with `work` (bits, `true` = 16) the script must also solve a SHA-256 proof of work, so a swarm that rotates its address every page pays it on every page; off by default
87
101
  * @param {{token: string, cidrs: string[]}[]|false} [options.welcome] search crawlers never metered when name and address agree; default WELCOME_CRAWLERS
88
102
  * @param {(request: Request) => boolean} [options.exempt] requests never charged, e.g. ones carrying a signed-in cookie
89
103
  * @param {string} [options.secret] pass signing secret; defaults to the CoinPay key
@@ -91,7 +105,11 @@ export { isPowerKey, keyFrom, PROFULLSTACK_KEYS, verifyKey } from '@profullstack
91
105
  * @param {Iterable<string>} [options.revokedKeys] Power Key subjects no longer honoured
92
106
  * @param {(ctx: object) => string} [options.page] custom sales page renderer
93
107
  * @param {string} [options.contact] mailto: or URL for bulk deals
94
- * @param {(sale: object) => void|Promise<void>} [options.onSale] accounting hook; awaited before the receipt goes out, and its errors are swallowed
108
+ * @param {(sale: object) => void|Promise<void>} [options.onSale] accounting hook; awaited before the receipt goes out, and its errors are swallowed. Carries `property`, `resourcePath`, `payer`, `userAgent`, `ipHash` and `source` ('x402' or 'checkout'); a checkout sale may be reported more than once (return page and webhook), so dedupe on `ref`
109
+ * @param {string|((request: Request) => string)} [options.property] the site this sale is for, recorded with CoinPay; defaults to the siteUrl hostname
110
+ * @param {string} [options.ipSalt] key for the address hash recorded with a sale; defaults to the pass secret
111
+ * @param {{apiKey?: string, baseUrl?: string, account?: (request: Request) => string|null|Promise<string|null>, expiresInMinutes?: number}|false} [options.checkout] the human "Buy a day pass" button (CoinPay hosted checkout, any coin or PayPal); on by default with the CoinPay key, shown only once CoinPay serves checkout sessions
112
+ * @param {string} [options.passCookie='crawl_pass'] cookie a pass is also read from
95
113
  * @param {typeof fetch} [options.fetch] for tests
96
114
  */
97
115
  export function createGateway(options = {}) {
@@ -114,6 +132,68 @@ export function createGateway(options = {}) {
114
132
  */
115
133
  const howUrl = `${buyUrl}?format=html`;
116
134
 
135
+ /** The site a sale is for: config, or what the host app says per request. */
136
+ const propertyOf = (request) => {
137
+ if (typeof o.property === 'function') {
138
+ try {
139
+ const p = o.property(request);
140
+ if (p) return String(p);
141
+ } catch {}
142
+ } else if (o.property) {
143
+ return o.property;
144
+ }
145
+ return new URL(o.siteUrl).hostname;
146
+ };
147
+
148
+ /**
149
+ * The page a sale bought. On a gated URL that is the URL itself; on the
150
+ * sales page it is whatever `?from=` says (the button and the 402 carry it),
151
+ * since "/crawl" names the shop rather than the page anyone wanted.
152
+ */
153
+ const resourcePathOf = (request) => {
154
+ const url = new URL(request.url);
155
+ const from = url.searchParams.get('from');
156
+ if (url.pathname === o.path || url.pathname.startsWith(`${o.path}/`)) {
157
+ if (from && from.startsWith('/') && !from.startsWith('//') && from.length <= 1024) return from;
158
+ return url.pathname;
159
+ }
160
+ const params = new URLSearchParams(url.search);
161
+ for (const k of ['days', 'format', 'from']) params.delete(k);
162
+ const q = params.toString();
163
+ return `${url.pathname}${q ? `?${q}` : ''}`.slice(0, 1024);
164
+ };
165
+
166
+ /** A keyed hash of the caller's address: enough to tell repeat buyers apart, never the address. */
167
+ const ipHashOf = async (request) => {
168
+ const ip = clientIp(request);
169
+ const key = o.ipSalt || secret;
170
+ if (!ip || !key) return null;
171
+ const k = await crypto.subtle.importKey('raw', new TextEncoder().encode(String(key)), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
172
+ const sig = new Uint8Array(await crypto.subtle.sign('HMAC', k, new TextEncoder().encode(`ip:${ip}`)));
173
+ return Array.from(sig.slice(0, 16), (b) => b.toString(16).padStart(2, '0')).join('');
174
+ };
175
+
176
+ /** Who paid and where, as CoinPay's ledger and onSale both get it. */
177
+ const saleContext = async (request, source) => ({
178
+ property: propertyOf(request),
179
+ resource_path: resourcePathOf(request),
180
+ user_agent: (request.headers.get('user-agent') ?? '').slice(0, 512),
181
+ ip_hash: await ipHashOf(request),
182
+ source,
183
+ });
184
+
185
+ /*
186
+ * The human checkout. On whenever there is a key to create sessions with
187
+ * and a secret to sign the session cookie; shown only once CoinPay answers.
188
+ */
189
+ const checkoutPath = `${o.path}/checkout`;
190
+ const checkoutKey = o.checkout ? o.checkout.apiKey || o.coinpay.apiKey : '';
191
+ const cpCheckout =
192
+ o.checkout && checkoutKey && secret
193
+ ? checkoutClient({ apiKey: checkoutKey, baseUrl: o.checkout.baseUrl || o.coinpay.baseUrl, fetch: o.fetch })
194
+ : null;
195
+ const checkoutCookie = 'x402_checkout';
196
+
117
197
  /**
118
198
  * How many terms a request is asking to buy: `?days=N`, clamped to
119
199
  * [1, maxDays]. Anything unparseable is one day, which is what the offer
@@ -161,8 +241,24 @@ export function createGateway(options = {}) {
161
241
 
162
242
  const noStore = {
163
243
  'cache-control': 'no-store',
164
- vary: 'Accept, User-Agent, X-Payment',
244
+ vary: 'Accept, User-Agent, X-Payment, Payment-Signature',
245
+ 'access-control-expose-headers': 'PAYMENT-REQUIRED, PAYMENT-RESPONSE',
165
246
  };
247
+
248
+ /**
249
+ * The spec's PAYMENT-REQUIRED header for a 402: the same offer as the body,
250
+ * base64 JSON. Absent when payments are off, since an empty offer is no
251
+ * offer.
252
+ */
253
+ const required = (days = 1, error) =>
254
+ enabled
255
+ ? {
256
+ 'payment-required': paymentRequiredHeader(offer(days), {
257
+ url: days > 1 ? `${buyUrl}?days=${days}` : buyUrl,
258
+ error,
259
+ }),
260
+ }
261
+ : {};
166
262
  const json = (body, status, headers = {}) =>
167
263
  new Response(JSON.stringify(body, null, 2), {
168
264
  status,
@@ -200,8 +296,18 @@ export function createGateway(options = {}) {
200
296
  retrieval: o.retrieval,
201
297
  contact: o.contact,
202
298
  powerKey: o.powerKeys ? { shop: SHOP_URL, header: POWER_KEY_HEADER } : null,
299
+ checkout: null,
203
300
  });
204
301
 
302
+ /** The page context with the checkout button, when CoinPay serves sessions. */
303
+ const pageCtxFor = async (request, days, usage, quota) => {
304
+ const ctx = pageCtx(days, usage, quota);
305
+ if (cpCheckout && (await cpCheckout.available())) {
306
+ ctx.checkout = { action: checkoutPath, from: resourcePathOf(request), days };
307
+ }
308
+ return ctx;
309
+ };
310
+
205
311
  /**
206
312
  * The pass a request presents: a day pass from the named header or a bearer
207
313
  * token, or a Power Key wherever @profullstack/keys reads one.
@@ -211,7 +317,10 @@ export function createGateway(options = {}) {
211
317
  if (direct) return direct.trim();
212
318
  const m = /^Bearer\s+(cp_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)$/i.exec(request.headers.get('authorization') ?? '');
213
319
  if (m) return m[1];
214
- return o.powerKeys ? keyFrom(request) : null;
320
+ const pk = o.powerKeys ? keyFrom(request) : null;
321
+ if (pk) return pk;
322
+ const cookie = cookieValue(request, o.passCookie);
323
+ return cookie && cookie.startsWith('cp_') ? cookie : null;
215
324
  };
216
325
 
217
326
  /**
@@ -274,7 +383,8 @@ export function createGateway(options = {}) {
274
383
  */
275
384
  async function sell(request, context = {}) {
276
385
  const ua = request.headers.get('user-agent') ?? '';
277
- const proofHeader = request.headers.get('x-payment');
386
+ // The spec's header first, then the one CoinPay's payer has always sent.
387
+ const proofHeader = request.headers.get('payment-signature') || request.headers.get('x-payment');
278
388
  const asked = daysFrom(request);
279
389
  // Present when the free allowance is what stopped this request, rather than
280
390
  // the crawler lists. It changes what the 402 says, not what it costs.
@@ -291,10 +401,11 @@ export function createGateway(options = {}) {
291
401
 
292
402
  if (proofHeader) {
293
403
  if (!enabled) return json(receipt(asked, { error: 'Payments are not switched on here.' }), 402);
404
+ const refuse = (days, error) => json(receipt(days, { error }), 402, required(days, error));
294
405
  const payment = decodePayment(proofHeader);
295
- if (!payment) return json(receipt(asked, { error: 'X-PAYMENT is not base64 JSON.' }), 402);
406
+ if (!payment) return refuse(asked, 'The payment proof (PAYMENT-SIGNATURE or X-PAYMENT) is not base64 JSON.');
296
407
  const unit = expectedFor(payment, offer(1));
297
- if (!unit) return json(receipt(asked, { error: 'Proof does not match an offered network.' }), 402);
408
+ if (!unit) return refuse(asked, 'Proof does not match an offered network.');
298
409
 
299
410
  /*
300
411
  * The money decides the term, not the URL. A proof is an authorization
@@ -307,11 +418,9 @@ export function createGateway(options = {}) {
307
418
  */
308
419
  const days = daysPaid(paidValueOf(payment), unit.amount, o.maxDays);
309
420
  if (!days) {
310
- return json(
311
- receipt(asked, {
312
- 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.`,
313
- }),
314
- 402,
421
+ return refuse(
422
+ asked,
423
+ `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.`,
315
424
  );
316
425
  }
317
426
  const expected = expectedFor(payment, offer(days));
@@ -319,7 +428,8 @@ export function createGateway(options = {}) {
319
428
 
320
429
  const now = Math.floor(Date.now() / 1000);
321
430
  const coinpay = { apiKey: o.coinpay.apiKey, baseUrl: o.coinpay.baseUrl, fetch: o.fetch };
322
- const result = await verifyAndSettle(payment, expected, coinpay);
431
+ const sale = await saleContext(request, 'x402');
432
+ const result = await verifyAndSettle(payment, expected, { ...coinpay, metadata: sale });
323
433
 
324
434
  let expiresAt = null;
325
435
  let replayed = false;
@@ -342,7 +452,7 @@ export function createGateway(options = {}) {
342
452
  }
343
453
  }
344
454
  if (!expiresAt || expiresAt <= now) {
345
- return json(receipt(days, { error: result.reason ?? 'Payment could not be settled.' }), 402);
455
+ return refuse(days, result.reason ?? 'Payment could not be settled.');
346
456
  }
347
457
 
348
458
  const ref = nonceOf(payment) ?? result.ref ?? null;
@@ -360,6 +470,13 @@ export function createGateway(options = {}) {
360
470
  days,
361
471
  totalCents: o.priceCents * days,
362
472
  currency: o.currency,
473
+ property: sale.property,
474
+ resourcePath: sale.resource_path,
475
+ ipHash: sale.ip_hash,
476
+ source: 'x402',
477
+ network: result.network ?? payment.network ?? null,
478
+ txHash: result.txHash ?? null,
479
+ metadata: { ...sale, ...(result.payer ? { payer: result.payer } : {}) },
363
480
  });
364
481
  } catch {
365
482
  // Accounting must never cost a buyer the pass it paid for.
@@ -377,12 +494,20 @@ export function createGateway(options = {}) {
377
494
  use: `curl -H "${o.header}: ${pass.token}" ${o.siteUrl}/`,
378
495
  },
379
496
  200,
380
- { [o.header]: pass.token, [`${o.header}-expires`]: expires },
497
+ {
498
+ [o.header]: pass.token,
499
+ [`${o.header}-expires`]: expires,
500
+ 'payment-response': paymentResponseHeader({
501
+ transaction: result.txHash ?? ref,
502
+ network: result.network ?? payment.network,
503
+ payer: result.payer,
504
+ }),
505
+ },
381
506
  );
382
507
  }
383
508
 
384
509
  if (wantsHtml(request.headers.get('accept')) || new URL(request.url).searchParams.get('format') === 'html') {
385
- return html(o.page(pageCtx(asked, usage, quota)), 402, rateHeaders);
510
+ return html(o.page(await pageCtxFor(request, asked, usage, quota)), 402, { ...rateHeaders, ...required(asked) });
386
511
  }
387
512
 
388
513
  if (usage && quota) {
@@ -404,11 +529,237 @@ export function createGateway(options = {}) {
404
529
  ...(o.benefits ? { unlocks: o.benefits } : {}),
405
530
  }),
406
531
  402,
407
- rateHeaders,
532
+ { ...rateHeaders, ...required(asked) },
408
533
  );
409
534
  }
410
535
 
411
- return json(receipt(asked, { error: `Payment required for training crawlers. Read ${howUrl} for how.` }), 402);
536
+ return json(receipt(asked, { error: `Payment required for training crawlers. Read ${howUrl} for how.` }), 402, required(asked));
537
+ }
538
+
539
+ /* ------------------------------------------------------------ checkout -- */
540
+
541
+ /** Sign and read the cookie that carries a session id to the return page. */
542
+ const signId = async (id) => {
543
+ const k = await crypto.subtle.importKey('raw', new TextEncoder().encode(String(secret)), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
544
+ const sig = new Uint8Array(await crypto.subtle.sign('HMAC', k, new TextEncoder().encode(`checkout:${id}`)));
545
+ return Array.from(sig.slice(0, 16), (b) => b.toString(16).padStart(2, '0')).join('');
546
+ };
547
+ const sessionFromCookie = async (request) => {
548
+ const raw = cookieValue(request, checkoutCookie);
549
+ if (!raw) return null;
550
+ const dot = raw.lastIndexOf('.');
551
+ if (dot < 0) return null;
552
+ const id = decodeURIComponent(raw.slice(0, dot));
553
+ if (!isSessionId(id)) return null;
554
+ return (await signId(id)) === raw.slice(dot + 1) ? id : null;
555
+ };
556
+
557
+ const notice = (status, title, body, headers = {}) =>
558
+ html(o.renderNotice({ siteName: o.siteName, siteUrl: o.siteUrl, buyUrl: howUrl, title, body }), status, headers);
559
+
560
+ /** Read a form or JSON body, whichever the buyer sent. */
561
+ const readForm = async (request) => {
562
+ const type = (request.headers.get('content-type') ?? '').toLowerCase();
563
+ try {
564
+ if (type.includes('application/json')) {
565
+ const j = await request.json();
566
+ return j && typeof j === 'object' ? j : {};
567
+ }
568
+ const text = await request.text();
569
+ return Object.fromEntries(new URLSearchParams(text.slice(0, 8192)));
570
+ } catch {
571
+ return {};
572
+ }
573
+ };
574
+
575
+ /** POST /crawl/checkout: create a CoinPay session for a pass and send the buyer to it. */
576
+ async function startCheckout(request) {
577
+ if (!cpCheckout || !enabled) return notice(404, 'Checkout is not available here', 'This site does not sell passes through checkout yet.');
578
+ if (request.method !== 'POST') return notice(405, 'Use the button', `Open <a href="${howUrl}">the pass page</a> and press Buy.`, { allow: 'POST' });
579
+ const form = await readForm(request);
580
+ const email = String(form.email ?? '').trim().toLowerCase();
581
+ if (!isEmail(email)) return notice(400, 'An email address is needed', `CoinPay sends the receipt there. <a href="${howUrl}">Go back</a> and enter one.`);
582
+ const n = Number.parseInt(String(form.days ?? '1'), 10);
583
+ const days = Number.isFinite(n) && n >= 1 ? Math.min(n, o.maxDays) : 1;
584
+ const fromRaw = String(form.from ?? '');
585
+ const from = fromRaw.startsWith('/') && !fromRaw.startsWith('//') ? fromRaw.slice(0, 1024) : o.path;
586
+
587
+ let account = null;
588
+ if (o.checkout.account) {
589
+ try {
590
+ account = (await o.checkout.account(request)) || null;
591
+ } catch {}
592
+ }
593
+ const property = propertyOf(request);
594
+ const ctx = await saleContext(request, 'checkout');
595
+ const cents = o.priceCents * days;
596
+ const minutes = o.checkout.expiresInMinutes ?? 60;
597
+ const created = await cpCheckout.create({
598
+ amount: Number((cents / 100).toFixed(2)),
599
+ currency: 'USD',
600
+ description: `${days === 1 ? 'Day pass' : `${days}-day pass`} for ${o.siteName}`,
601
+ customer_email: email,
602
+ account_id: account ? String(account) : email,
603
+ property,
604
+ metadata: {
605
+ kind: 'crawl_pass',
606
+ source: 'x402-gateway',
607
+ property,
608
+ resource_path: from,
609
+ days,
610
+ price_cents: o.priceCents,
611
+ user_agent: ctx.user_agent,
612
+ ip_hash: ctx.ip_hash,
613
+ },
614
+ success_url: `${o.siteUrl}${checkoutPath}/return?session_id={CHECKOUT_SESSION_ID}`,
615
+ cancel_url: howUrl,
616
+ expires_in_minutes: minutes,
617
+ });
618
+ if (!created.ok) {
619
+ return created.missing
620
+ ? notice(503, 'Checkout is not open yet', `Card-free checkout is still being switched on. Meanwhile a pass can be bought over x402: see <a href="${howUrl}">the pass page</a>.`)
621
+ : notice(502, 'Checkout could not start', `CoinPay did not create the payment (${esc(created.reason)}). Try again in a minute, or <a href="${howUrl}">go back</a>.`);
622
+ }
623
+ const cookie = `${checkoutCookie}=${encodeURIComponent(created.id)}.${await signId(created.id)}; Path=${checkoutPath}; Max-Age=${(minutes + 60) * 60}; HttpOnly; Secure; SameSite=Lax`;
624
+ if ((request.headers.get('accept') ?? '').includes('application/json')) {
625
+ return json({ id: created.id, url: created.url }, 200, { 'set-cookie': cookie });
626
+ }
627
+ return new Response(null, { status: 303, headers: { location: created.url, 'set-cookie': cookie, ...noStore } });
628
+ }
629
+
630
+ /**
631
+ * Mint the pass a paid session bought, from what CoinPay says about it.
632
+ * Resolves to `{ ok: true, token, expiresAt, days, state }`, or
633
+ * `{ ok: false, pending|dead, state }`.
634
+ *
635
+ * The pass runs from when the session was paid, so reloading the return
636
+ * page re-mints the same pass rather than a longer one.
637
+ */
638
+ async function passForSession(id) {
639
+ if (!cpCheckout || !isSessionId(id)) return { ok: false, state: null };
640
+ const session = await cpCheckout.get(id);
641
+ if (!session) return { ok: false, state: null };
642
+ const state = sessionState(session);
643
+ if (state.id && state.id !== id) return { ok: false, state };
644
+ if (!state.paid) return { ok: false, pending: !state.dead, dead: state.dead, state };
645
+ // A session for another property of the same business opens nothing here.
646
+ const property = o.property && typeof o.property === 'string' ? o.property : new URL(o.siteUrl).hostname;
647
+ if (state.property && typeof o.property !== 'function' && String(state.property).toLowerCase() !== property.toLowerCase()) {
648
+ return { ok: false, foreign: true, state };
649
+ }
650
+ if (state.metadata?.kind && state.metadata.kind !== 'crawl_pass') return { ok: false, foreign: true, state };
651
+ if (state.currency !== 'USD' || !Number.isFinite(state.cents) || state.cents < o.priceCents) return { ok: false, state };
652
+ const days = Math.min(o.maxDays, Math.floor(state.cents / o.priceCents));
653
+ const now = Math.floor(Date.now() / 1000);
654
+ const from = state.paidAt && state.paidAt <= now ? state.paidAt : now;
655
+ const expiresAt = from + days * o.passMinutes * 60;
656
+ if (expiresAt <= now) return { ok: false, expired: true, state };
657
+ const pass = await mintPass({ secret, ref: `checkout:${id}`, expiresAt: expiresAt, now: Math.min(from, now) });
658
+ return { ok: true, token: pass.token, expiresAt: pass.expiresAt, days, state };
659
+ }
660
+
661
+ /** Tell onSale about a checkout sale. Swallows the hook's errors, like the x402 path. */
662
+ const reportCheckout = async (id, minted, request) => {
663
+ if (!o.onSale) return;
664
+ const st = minted.state;
665
+ try {
666
+ await o.onSale({
667
+ payer: st.email ?? null,
668
+ ref: `checkout:${id}`,
669
+ token: minted.token,
670
+ expiresAt: new Date(minted.expiresAt * 1000).toISOString(),
671
+ userAgent: st.metadata?.user_agent ?? request?.headers.get('user-agent') ?? '',
672
+ priceCents: o.priceCents,
673
+ days: minted.days,
674
+ totalCents: st.cents,
675
+ currency: 'USD',
676
+ property: st.property ?? propertyOf(request ?? new Request(o.siteUrl)),
677
+ resourcePath: st.metadata?.resource_path ?? null,
678
+ ipHash: st.metadata?.ip_hash ?? null,
679
+ source: 'checkout',
680
+ email: st.email ?? null,
681
+ accountId: st.accountId ?? null,
682
+ checkoutSessionId: id,
683
+ metadata: st.metadata,
684
+ });
685
+ } catch {}
686
+ };
687
+
688
+ /** GET /crawl/checkout/return: the buyer is back from CoinPay; show the pass. */
689
+ async function checkoutReturn(request) {
690
+ if (!cpCheckout) return notice(404, 'Checkout is not available here', 'This site does not sell passes through checkout yet.');
691
+ const fromCookie = await sessionFromCookie(request);
692
+ const q = new URL(request.url).searchParams;
693
+ const fromQuery = ['session_id', 'checkout_session_id', 'session'].map((k) => q.get(k)).find((v) => isSessionId(v ?? ''));
694
+ const id = fromCookie ?? fromQuery ?? null;
695
+ if (!id) return notice(400, 'No checkout to show', `This page opens after paying at CoinPay. <a href="${howUrl}">Buy a pass</a>.`);
696
+ const minted = await passForSession(id);
697
+ if (!minted.ok) {
698
+ if (minted.pending) {
699
+ return notice(
700
+ 202,
701
+ 'Waiting for the payment to confirm',
702
+ 'CoinPay has not marked this payment paid yet. Crypto payments need a block or two; this page checks again every 15 seconds.',
703
+ { refresh: '15' },
704
+ );
705
+ }
706
+ if (minted.dead) return notice(410, 'This checkout closed unpaid', `Nothing was charged. <a href="${howUrl}">Start again</a>.`);
707
+ if (minted.expired) return notice(410, 'This pass has already run out', `<a href="${howUrl}">Buy another</a>.`);
708
+ return notice(502, 'Could not read this checkout', 'CoinPay did not answer for this payment. Reload in a minute; a paid pass is not lost.');
709
+ }
710
+ // Report the sale once per browser: the cookie is cleared on the way out.
711
+ if (fromCookie) await reportCheckout(id, minted, request);
712
+ const expires = new Date(minted.expiresAt * 1000).toISOString();
713
+ const maxAge = Math.max(0, minted.expiresAt - Math.floor(Date.now() / 1000));
714
+ const headers = new Headers({ 'content-type': 'text/html; charset=utf-8', ...noStore, [o.header]: minted.token, [`${o.header}-expires`]: expires });
715
+ headers.append('set-cookie', `${o.passCookie}=${minted.token}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`);
716
+ headers.append('set-cookie', `${checkoutCookie}=; Path=${checkoutPath}; Max-Age=0; HttpOnly; Secure; SameSite=Lax`);
717
+ const body = o.renderPassPage({
718
+ siteName: o.siteName,
719
+ siteUrl: o.siteUrl,
720
+ buyUrl: howUrl,
721
+ token: minted.token,
722
+ expiresAt: expires,
723
+ days: minted.days,
724
+ header: o.header,
725
+ cookie: o.passCookie,
726
+ email: minted.state.email,
727
+ });
728
+ return new Response(body, { status: 200, headers });
729
+ }
730
+
731
+ /**
732
+ * A CoinPay webhook about a checkout session. The body is only a hint: the
733
+ * session is read back from CoinPay with this site's key, so a forged call
734
+ * mints nothing. Resolves to the minted pass (for a site that emails it) or
735
+ * null. Mount it on a site's existing webhook route, or let the gateway
736
+ * answer POST /crawl/checkout/webhook itself.
737
+ */
738
+ async function checkoutPaid(bodyOrId, request) {
739
+ const id = typeof bodyOrId === 'string' ? bodyOrId : sessionIdFromWebhook(bodyOrId);
740
+ if (!id) return null;
741
+ const minted = await passForSession(id);
742
+ if (!minted.ok) return null;
743
+ await reportCheckout(id, minted, request);
744
+ return { token: minted.token, expiresAt: new Date(minted.expiresAt * 1000).toISOString(), days: minted.days, email: minted.state.email, accountId: minted.state.accountId, ref: `checkout:${id}` };
745
+ }
746
+
747
+ async function checkoutWebhook(request) {
748
+ if (request.method !== 'POST') return json({ ok: false, error: 'POST only' }, 405, { allow: 'POST' });
749
+ let body = {};
750
+ try {
751
+ body = await request.json();
752
+ } catch {}
753
+ const minted = await checkoutPaid(body, request);
754
+ return json({ ok: true, minted: Boolean(minted) }, 200);
755
+ }
756
+
757
+ /** The routes under the sales page. Null for anything else. */
758
+ async function checkoutRoute(request, path) {
759
+ if (path === checkoutPath) return startCheckout(request);
760
+ if (path === `${checkoutPath}/return`) return checkoutReturn(request);
761
+ if (path === `${checkoutPath}/webhook`) return checkoutWebhook(request);
762
+ return null;
412
763
  }
413
764
 
414
765
  /**
@@ -434,6 +785,10 @@ export function createGateway(options = {}) {
434
785
 
435
786
  const path = new URL(request.url).pathname;
436
787
  if (path === o.path) return sell(request);
788
+ if (path.startsWith(`${checkoutPath}`) && (path === checkoutPath || path.startsWith(`${checkoutPath}/`))) {
789
+ const answer = await checkoutRoute(request, path);
790
+ if (answer) return answer;
791
+ }
437
792
  if (o.exempt && o.exempt(request)) return null;
438
793
 
439
794
  /*
@@ -475,17 +830,18 @@ export function createGateway(options = {}) {
475
830
  (request.method === 'GET' || request.method === 'HEAD')
476
831
  ) {
477
832
  const ip = clientIp(request);
478
- if (ip && !(await passedChallenge(request, { secret, cookie: o.challenge.cookie, ip }))) {
833
+ if (ip && !(await passedChallenge(request, { secret, cookie: o.challenge.cookie, ip, work: o.challenge.work }))) {
479
834
  if (!wantsHtml(request.headers.get('accept'))) {
480
835
  return json(
481
836
  receipt(1, {
482
837
  error: `${path} is read in a browser (it sets a cookie with one line of script) or with a pass. Read ${howUrl} for how.`,
483
838
  }),
484
839
  402,
840
+ required(1),
485
841
  );
486
842
  }
487
843
  const token = await challengeToken(secret, ip);
488
- return html(challengePage({ token, cookie: o.challenge.cookie, buyUrl }), 402);
844
+ return html(challengePage({ token, cookie: o.challenge.cookie, buyUrl, work: o.challenge.work }), 402, required(1));
489
845
  }
490
846
  }
491
847
 
@@ -558,10 +914,31 @@ export function createGateway(options = {}) {
558
914
  ...extra,
559
915
  }),
560
916
  /** The sales page as HTML, for a site that mounts it on a route of its own. */
561
- page: () => o.page(pageCtx()),
917
+ page: () => {
918
+ const ctx = pageCtx();
919
+ if (cpCheckout && cpCheckout.known()) ctx.checkout = { action: checkoutPath, from: o.path, days: 1 };
920
+ return o.page(ctx);
921
+ },
922
+ /** The PAYMENT-REQUIRED header value for `days`, for a site that answers its own 402. */
923
+ paymentRequired: (days = 1, error) => required(days, error)['payment-required'] ?? null,
924
+ /** The sale context (property, page, UA, address hash) a request would be recorded with. */
925
+ saleContext,
926
+ /** Whether the checkout button shows: CoinPay serves checkout sessions and a key is set. */
927
+ checkoutAvailable: async () => Boolean(cpCheckout && (await cpCheckout.available())),
928
+ /** A CoinPay webhook handler (Fetch Request -> Response) that mints a paid session's pass. */
929
+ checkoutWebhook,
930
+ /** Mint the pass for a paid checkout session, from a webhook body or a session id. */
931
+ checkoutPaid,
562
932
  };
563
933
  }
564
934
 
935
+ const esc = (s) =>
936
+ String(s ?? '')
937
+ .replace(/&/g, '&amp;')
938
+ .replace(/</g, '&lt;')
939
+ .replace(/>/g, '&gt;')
940
+ .replace(/"/g, '&quot;');
941
+
565
942
  /** Whether the caller would rather read a page than a JSON offer. */
566
943
  export const wantsHtml = (accept = '') => String(accept ?? '').toLowerCase().includes('text/html');
567
944
 
@@ -638,6 +1015,7 @@ function normalise(options) {
638
1015
  ? {
639
1016
  paths: options.challenge.paths,
640
1017
  cookie: options.challenge.cookie ?? 'pf_js',
1018
+ work: challengeWork(options.challenge.work),
641
1019
  when: typeof options.challenge.when === 'function' ? options.challenge.when : null,
642
1020
  }
643
1021
  : null,
@@ -649,6 +1027,12 @@ function normalise(options) {
649
1027
  page: options.page ?? renderPage,
650
1028
  contact: options.contact ?? '',
651
1029
  onSale: options.onSale ?? null,
1030
+ property: typeof options.property === 'function' || typeof options.property === 'string' ? options.property : null,
1031
+ ipSalt: options.ipSalt ?? '',
1032
+ checkout: options.checkout === false ? null : { ...(options.checkout ?? {}) },
1033
+ passCookie: String(options.passCookie ?? 'crawl_pass'),
1034
+ renderNotice: options.renderNotice ?? renderNotice,
1035
+ renderPassPage: options.renderPassPage ?? renderPassPage,
652
1036
  freeQuota: normaliseQuota(options.freeQuota),
653
1037
  benefits: Array.isArray(options.benefits) ? options.benefits : null,
654
1038
  powerKeys: options.powerKeys === false ? null : (options.powerKeys ?? PROFULLSTACK_KEYS),
@@ -657,4 +1041,10 @@ function normalise(options) {
657
1041
  };
658
1042
  }
659
1043
 
1044
+ /** `true` means the default 16 bits; a number is clamped to 0-24 (24 is ~16M digests, too slow for a phone). */
1045
+ function challengeWork(work) {
1046
+ if (work === true) return 16;
1047
+ return Number.isInteger(work) ? Math.max(0, Math.min(24, work)) : 0;
1048
+ }
1049
+
660
1050
  export { METHODS as X402_METHODS };