@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/README.md +25 -3
- package/index.d.ts +65 -6
- package/package.json +1 -1
- package/src/challenge.js +43 -5
- package/src/checkout.js +199 -0
- package/src/index.js +416 -26
- package/src/page.js +99 -4
- package/src/x402.js +97 -9
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
311
|
-
|
|
312
|
-
|
|
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
|
|
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
|
|
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
|
-
{
|
|
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(
|
|
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: () =>
|
|
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, '&')
|
|
938
|
+
.replace(/</g, '<')
|
|
939
|
+
.replace(/>/g, '>')
|
|
940
|
+
.replace(/"/g, '"');
|
|
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 };
|