@profullstack/x402-gateway 0.10.2 → 0.11.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 +24 -2
- package/index.d.ts +60 -3
- package/package.json +1 -1
- package/src/checkout.js +199 -0
- package/src/index.js +406 -23
- package/src/page.js +99 -4
- package/src/x402.js +97 -9
package/README.md
CHANGED
|
@@ -102,7 +102,11 @@ export const GET = robotsRoute(gateway, { disallow: ['/login', '/api/'] });
|
|
|
102
102
|
| `secret` | the CoinPay key | pass signing secret |
|
|
103
103
|
| `page` | built in | `(ctx) => html` |
|
|
104
104
|
| `contact` | | mailto: or URL for bulk deals |
|
|
105
|
-
| `onSale` | | `({ payer, ref, token, expiresAt, userAgent, priceCents, currency }) => …`, for accounting |
|
|
105
|
+
| `onSale` | | `({ payer, ref, token, expiresAt, userAgent, priceCents, days, totalCents, currency, property, resourcePath, ipHash, source, ... }) => …`, for accounting. `source` is `'x402'` or `'checkout'` (which adds `email`, `accountId`, `checkoutSessionId`); a checkout sale can be reported by both the return page and the webhook, so dedupe on `ref` |
|
|
106
|
+
| `property` | the siteUrl hostname | the site a sale is recorded for, sent to CoinPay with every settle. A function `(request) => string` reads it per request, e.g. from `Host` |
|
|
107
|
+
| `ipSalt` | the pass secret | key for the address hash recorded with a sale (the address itself is never sent) |
|
|
108
|
+
| `checkout` | on, with `coinpay.apiKey` | the human "Buy a day pass" button: `{ apiKey?, baseUrl?, account?: (request) => userId, expiresInMinutes? }`. `false` hides it. Shown only while CoinPay answers `/api/checkout/sessions` |
|
|
109
|
+
| `passCookie` | `crawl_pass` | cookie a pass is also read from (the checkout return page sets it) |
|
|
106
110
|
|
|
107
111
|
| `denyCidrs` | `[]` | IPv4 ranges answered with a tiny `403` before anything else. For a VPS fleet that spoofs a browser: hosting ranges serve no readers. |
|
|
108
112
|
| `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. |
|
|
@@ -181,10 +185,28 @@ The lists catch crawlers that name themselves. Two do not: a VPS fleet wearing a
|
|
|
181
185
|
|
|
182
186
|
## How the money moves
|
|
183
187
|
|
|
184
|
-
The offer is x402 v2 in CoinPay's dialect: USDC under the `exact` scheme on Base, Polygon or Ethereum, EIP-3009 `transferWithAuthorization`. The buyer signs, the gateway sends the proof to CoinPay's `/api/x402/verify` and `/api/x402/settle`, and CoinPay's relayer broadcasts the transfer, paying the gas. The USDC goes straight to `payTo`.
|
|
188
|
+
The offer is x402 v2 in CoinPay's dialect (and in the spec's `PAYMENT-REQUIRED` header): USDC under the `exact` scheme on Base, Polygon or Ethereum, EIP-3009 `transferWithAuthorization`. The buyer signs, the gateway sends the proof to CoinPay's `/api/x402/verify` and `/api/x402/settle`, and CoinPay's relayer broadcasts the transfer, paying the gas. The USDC goes straight to `payTo`.
|
|
185
189
|
|
|
186
190
|
A pass is `cp_<payload>.<hmac>`: its own expiry and the payment's nonce, signed with HMAC-SHA256. Verifying one is a hash, not a query. A proof is single-use; retrying with the same proof returns a pass bounded by the proof's own validity window, so a lost response is not a lost dollar and a replayed header is not a free day.
|
|
187
191
|
|
|
192
|
+
## The x402 v2 headers
|
|
193
|
+
|
|
194
|
+
Every 402 that carries an offer also carries it in `PAYMENT-REQUIRED`: the spec's `PaymentRequired` object, base64 JSON, with the resource at the top level. A proof is read from `PAYMENT-SIGNATURE` first and `X-PAYMENT` second, in either CoinPay's shape (`scheme` and `network` at the top) or the spec's (`accepted: { scheme, network, ... }`). A sale answers with `PAYMENT-RESPONSE` (`{ success, transaction, network, payer }`) beside the JSON receipt. Nothing old was removed: the body offer, `X-PAYMENT` and the receipt are unchanged.
|
|
195
|
+
|
|
196
|
+
## Who paid, and where
|
|
197
|
+
|
|
198
|
+
Every settle call sends CoinPay `metadata: { property, resource_path, payer, user_agent, ip_hash, source }`, which CoinPay keeps on the `x402_payments` row. `resource_path` is the page actually asked for: the gated URL itself, or `?from=` on the sales page (the buy button carries it). `ip_hash` is an HMAC of the address keyed by `ipSalt`, never the address. `onSale` gets the same fields.
|
|
199
|
+
|
|
200
|
+
## People: Buy a day pass
|
|
201
|
+
|
|
202
|
+
The sales page shows a **Buy a day pass** form (email, days) whenever CoinPay serves hosted checkout sessions. It posts to `/crawl/checkout`, which creates a session (`POST {coinpay}/api/checkout/sessions` with `amount`, `currency: 'USD'`, `customer_email`, `account_id`, `property`, `metadata`, `success_url`, `cancel_url`) and redirects to CoinPay, where the buyer pays with any coin the business takes or PayPal. The session id rides back in a signed cookie to `/crawl/checkout/return`, which reads the session from CoinPay, and once it is paid mints the same `cp_...` pass the x402 path mints, valid from when it was paid, and shows it with how to send it (header, bearer, or the `crawl_pass` cookie it also sets). Until CoinPay serves the endpoint (a 404 on the probe, rechecked every few minutes) the form is simply not shown.
|
|
203
|
+
|
|
204
|
+
`account_id` is the buyer's email unless `checkout.account(request)` returns the signed-in user. CoinPay webhooks for checkout sessions can go to `/crawl/checkout/webhook`, or to a site's own route via `gateway.checkoutWebhook(request)` / `gateway.checkoutPaid(body)`: the body is only a hint, the session is re-read from CoinPay before anything is minted.
|
|
205
|
+
|
|
206
|
+
## Which coins x402 offers
|
|
207
|
+
|
|
208
|
+
USDC on Base, Polygon and Ethereum, and nothing else on purpose: CoinPay's v2 settle broadcasts EIP-3009 `transferWithAuthorization` only on chains 1, 137 and 8453, and USDC is the only EIP-3009 dollar token there (USDT has none). An offer for anything else would verify and never settle. Every other coin, and PayPal, is the checkout button's job.
|
|
209
|
+
|
|
188
210
|
## Who is on which list
|
|
189
211
|
|
|
190
212
|
`TRAINING_AGENTS` is the documented corpus-crawl token of each operator; `RETRIEVAL_AGENTS` the search half of the same pairs: GPTBot / OAI-SearchBot, ClaudeBot / Claude-SearchBot, meta-externalagent / Meta-ExternalFetcher, Applebot-Extended / Applebot. Google-Extended stays welcome because Google documents it as also gating Gemini app grounding. Matching is a substring of the user agent, which identifies a self-declared crawler and nothing more: a crawler wearing a browser's user agent walks past this, and that one needs blocking at the edge.
|
package/index.d.ts
CHANGED
|
@@ -14,6 +14,31 @@ export interface Sale {
|
|
|
14
14
|
/** `priceCents * days`. */
|
|
15
15
|
totalCents: number;
|
|
16
16
|
currency: string;
|
|
17
|
+
/** The site the sale is for (option `property`, default the siteUrl hostname). */
|
|
18
|
+
property?: string;
|
|
19
|
+
/** The page bought: the gated URL's path, or `?from=` on the sales page. */
|
|
20
|
+
resourcePath?: string | null;
|
|
21
|
+
/** Keyed hash of the buyer's address (never the address). */
|
|
22
|
+
ipHash?: string | null;
|
|
23
|
+
/** 'x402' for a proof, 'checkout' for a CoinPay hosted checkout. */
|
|
24
|
+
source?: 'x402' | 'checkout';
|
|
25
|
+
network?: string | null;
|
|
26
|
+
txHash?: string | null;
|
|
27
|
+
/** Checkout only. */
|
|
28
|
+
email?: string | null;
|
|
29
|
+
accountId?: string | null;
|
|
30
|
+
checkoutSessionId?: string;
|
|
31
|
+
/** What was sent to CoinPay with the sale. */
|
|
32
|
+
metadata?: Record<string, unknown>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CheckoutOptions {
|
|
36
|
+
/** Key for POST /api/checkout/sessions; defaults to `coinpay.apiKey`. */
|
|
37
|
+
apiKey?: string;
|
|
38
|
+
baseUrl?: string;
|
|
39
|
+
/** The signed-in site user, recorded as `account_id`; the buyer's email otherwise. */
|
|
40
|
+
account?: (request: Request) => string | null | undefined | Promise<string | null | undefined>;
|
|
41
|
+
expiresInMinutes?: number;
|
|
17
42
|
}
|
|
18
43
|
|
|
19
44
|
export interface PageContext {
|
|
@@ -36,6 +61,8 @@ export interface PageContext {
|
|
|
36
61
|
contact: string;
|
|
37
62
|
/** Where a Power Key is bought and the header it goes in, or null when they are switched off. */
|
|
38
63
|
powerKey?: { shop: string; header: string } | null;
|
|
64
|
+
/** The "Buy a day pass" form, or null when CoinPay's hosted checkout is not available. */
|
|
65
|
+
checkout?: { action: string; from: string; days: number } | null;
|
|
39
66
|
}
|
|
40
67
|
|
|
41
68
|
export interface GatewayOptions {
|
|
@@ -93,6 +120,14 @@ export interface GatewayOptions {
|
|
|
93
120
|
contact?: string;
|
|
94
121
|
/** Awaited before the buyer's receipt is sent, and its errors are swallowed: a sale is recorded, and a recording failure never costs the buyer the pass. */
|
|
95
122
|
onSale?: (sale: Sale) => void | Promise<void>;
|
|
123
|
+
/** The site a sale is recorded for; defaults to the siteUrl hostname. A function reads it per request (e.g. from Host). */
|
|
124
|
+
property?: string | ((request: Request) => string | null | undefined);
|
|
125
|
+
/** Key for the recorded address hash; defaults to the pass secret. */
|
|
126
|
+
ipSalt?: string;
|
|
127
|
+
/** The human checkout (CoinPay hosted, any coin or PayPal). On by default; `false` hides it. */
|
|
128
|
+
checkout?: CheckoutOptions | false;
|
|
129
|
+
/** Cookie a pass is also read from. Default 'crawl_pass'. */
|
|
130
|
+
passCookie?: string;
|
|
96
131
|
/**
|
|
97
132
|
* A free allowance for ordinary callers, after which the gateway answers 402
|
|
98
133
|
* with the offer instead of letting the request through. A bare number means
|
|
@@ -196,7 +231,9 @@ export interface Gateway {
|
|
|
196
231
|
handle: Handle;
|
|
197
232
|
sell: (request: Request, context?: SellContext) => Promise<Response>;
|
|
198
233
|
enabled: boolean;
|
|
199
|
-
options: Required<Omit<GatewayOptions, 'onSale' | 'fetch' | 'page' | 'isPaidAgent'>> & {
|
|
234
|
+
options: Required<Omit<GatewayOptions, 'onSale' | 'fetch' | 'page' | 'isPaidAgent' | 'property' | 'checkout'>> & {
|
|
235
|
+
property: GatewayOptions['property'] | null;
|
|
236
|
+
checkout: CheckoutOptions | null;
|
|
200
237
|
onSale: GatewayOptions['onSale'] | null;
|
|
201
238
|
fetch: typeof fetch;
|
|
202
239
|
page: (ctx: PageContext) => string;
|
|
@@ -216,6 +253,19 @@ export interface Gateway {
|
|
|
216
253
|
offer: (days?: number) => Offer;
|
|
217
254
|
/** The full 402 body around that offer: the offer plus the pass terms. */
|
|
218
255
|
receipt: (days?: number, extra?: Record<string, unknown>) => Offer & Record<string, unknown>;
|
|
256
|
+
/** The PAYMENT-REQUIRED header value (base64 JSON) for `days`, or null when payments are off. */
|
|
257
|
+
paymentRequired: (days?: number, error?: string) => string | null;
|
|
258
|
+
/** Who and where a request would be recorded as. */
|
|
259
|
+
saleContext: (request: Request, source: 'x402' | 'checkout') => Promise<{ property: string; resource_path: string; user_agent: string; ip_hash: string | null; source: string }>;
|
|
260
|
+
/** Whether the checkout button shows. */
|
|
261
|
+
checkoutAvailable: () => Promise<boolean>;
|
|
262
|
+
/** A webhook handler for CoinPay checkout events: re-reads the session from CoinPay and mints its pass. */
|
|
263
|
+
checkoutWebhook: (request: Request) => Promise<Response>;
|
|
264
|
+
/** Mint the pass for a paid session, from a webhook body or a session id. Null unless CoinPay says it is paid. */
|
|
265
|
+
checkoutPaid: (
|
|
266
|
+
bodyOrId: unknown,
|
|
267
|
+
request?: Request,
|
|
268
|
+
) => Promise<{ token: string; expiresAt: string; days: number; email: string | null; accountId: string | null; ref: string } | null>;
|
|
219
269
|
}
|
|
220
270
|
|
|
221
271
|
export function createGateway(options: GatewayOptions): Gateway;
|
|
@@ -261,6 +311,13 @@ export const METHODS: Array<{ key: string; network: string; asset: string; label
|
|
|
261
311
|
export const X402_METHODS: typeof METHODS;
|
|
262
312
|
export function buildOffer(args: { payTo: string; priceCents: number; resource: string; description?: string; maxTimeoutSeconds?: number; methods?: typeof METHODS }): Offer;
|
|
263
313
|
export function decodePayment(header: string | null | undefined): Record<string, unknown> | null;
|
|
314
|
+
export function decodeHeader(header: string | null | undefined): Record<string, unknown> | null;
|
|
315
|
+
export function encodeHeader(value: unknown): string;
|
|
316
|
+
export function paymentRequiredHeader(offer: Offer, args?: { url?: string; description?: string; error?: string }): string;
|
|
317
|
+
export function paymentResponseHeader(args: { transaction?: string | null; network?: string | null; payer?: string | null }): string;
|
|
318
|
+
export function renderNotice(ctx: { siteName: string; siteUrl: string; buyUrl: string; title: string; body: string }): string;
|
|
319
|
+
export function renderPassPage(ctx: { siteName: string; siteUrl: string; buyUrl: string; token: string; expiresAt: string; days: number; header: string; cookie: string; email?: string | null }): string;
|
|
320
|
+
export function sessionIdFromWebhook(body: unknown): string | null;
|
|
264
321
|
export function expectedFor(payment: unknown, offer: Offer): { amount: string; resource: string; payTo: string; asset: string } | null;
|
|
265
322
|
/** The value a proof authorizes, in the token's smallest unit, or null. */
|
|
266
323
|
export function paidValueOf(payment: unknown): bigint | null;
|
|
@@ -269,8 +326,8 @@ export function daysPaid(value: bigint | null, unit: string | number | bigint, m
|
|
|
269
326
|
export function verifyAndSettle(
|
|
270
327
|
payment: unknown,
|
|
271
328
|
expected: { amount: string; resource: string; payTo: string; asset: string },
|
|
272
|
-
coinpay: { apiKey: string; baseUrl: string; fetch?: typeof fetch },
|
|
273
|
-
): Promise<{ ok: true; payer: string | null; ref: string | null } | { ok: false; reason: string; replay: boolean }>;
|
|
329
|
+
coinpay: { apiKey: string; baseUrl: string; fetch?: typeof fetch; metadata?: Record<string, unknown> | null },
|
|
330
|
+
): Promise<{ ok: true; payer: string | null; ref: string | null; txHash: string | null; network: string | null } | { ok: false; reason: string; replay: boolean }>;
|
|
274
331
|
|
|
275
332
|
/** ./hono */
|
|
276
333
|
export function x402Gateway(gatewayOrOptions: Gateway | GatewayOptions): (c: { req: { raw: Request } }, next: () => Promise<void>) => Promise<Response | undefined>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/x402-gateway",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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, Profullstack Power Keys honoured as a pass, and a robots.txt that keeps search crawlers welcome.",
|
|
6
6
|
"keywords": [
|
package/src/checkout.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The human way in: a day pass bought through CoinPay's hosted checkout.
|
|
3
|
+
*
|
|
4
|
+
* An x402 proof needs a wallet that signs EIP-3009, which is a program's
|
|
5
|
+
* tool. A person who landed on the sales page wants a button: pay with any
|
|
6
|
+
* coin CoinPay takes, or PayPal, and get the same pass the x402 path mints.
|
|
7
|
+
*
|
|
8
|
+
* CoinPay hosts the payment (`POST /api/checkout/sessions` -> `{ id, url }`),
|
|
9
|
+
* and the gateway stays stateless: the session id rides in a signed cookie to
|
|
10
|
+
* the return page, the return page asks CoinPay whether that session is paid,
|
|
11
|
+
* and the pass is minted from what CoinPay answers. Nothing the buyer's
|
|
12
|
+
* browser says is believed about money.
|
|
13
|
+
*
|
|
14
|
+
* The endpoint is new, so its absence is expected: `available()` probes it
|
|
15
|
+
* and the sales page shows the button only when it answers.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const PAID = new Set(['paid', 'complete', 'completed', 'succeeded', 'success', 'confirmed', 'settled', 'forwarded']);
|
|
19
|
+
const DEAD = new Set(['expired', 'cancelled', 'canceled', 'failed', 'void', 'voided', 'refunded']);
|
|
20
|
+
|
|
21
|
+
/** How long a probe answer is believed, by outcome. */
|
|
22
|
+
const PROBE_TTL_UP = 10 * 60 * 1000;
|
|
23
|
+
const PROBE_TTL_DOWN = 5 * 60 * 1000;
|
|
24
|
+
|
|
25
|
+
/** Probe answers shared by every gateway in the process that talks to one CoinPay. */
|
|
26
|
+
const probes = new Map();
|
|
27
|
+
|
|
28
|
+
/** A CoinPay checkout session id as the gateway will accept one: short, URL-safe. */
|
|
29
|
+
export const isSessionId = (id) => typeof id === 'string' && /^[A-Za-z0-9_-]{6,128}$/.test(id);
|
|
30
|
+
|
|
31
|
+
/** Whether a string looks like an email address, enough to send a receipt to. */
|
|
32
|
+
export const isEmail = (s) => typeof s === 'string' && s.length <= 254 && /^[^\s@<>"']+@[^\s@<>"']+\.[^\s@<>"']+$/.test(s);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {object} args
|
|
36
|
+
* @param {string} args.apiKey
|
|
37
|
+
* @param {string} args.baseUrl
|
|
38
|
+
* @param {typeof fetch} [args.fetch]
|
|
39
|
+
*/
|
|
40
|
+
export function checkoutClient({ apiKey, baseUrl, fetch: f = globalThis.fetch }) {
|
|
41
|
+
const root = `${baseUrl}/api/checkout/sessions`;
|
|
42
|
+
const headers = { authorization: `Bearer ${apiKey}`, accept: 'application/json' };
|
|
43
|
+
|
|
44
|
+
const readJson = async (res) => {
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(await res.text());
|
|
47
|
+
} catch {
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Mark the endpoint as missing, so the button hides until the next probe. */
|
|
53
|
+
const markDown = () => probes.set(root, { ok: false, at: Date.now() });
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
/**
|
|
57
|
+
* Whether CoinPay serves checkout sessions yet. A 404 means the route is
|
|
58
|
+
* not deployed; any other answer below 500 (401, 405, 200) means it is.
|
|
59
|
+
* Errors and timeouts read as "not now" without being cached for long.
|
|
60
|
+
*/
|
|
61
|
+
async available() {
|
|
62
|
+
const hit = probes.get(root);
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
if (hit && now - hit.at < (hit.ok ? PROBE_TTL_UP : PROBE_TTL_DOWN)) return hit.ok;
|
|
65
|
+
let ok = false;
|
|
66
|
+
try {
|
|
67
|
+
const res = await f(root, { method: 'GET', headers, signal: AbortSignal.timeout(3000) });
|
|
68
|
+
ok = res.status !== 404 && res.status < 500;
|
|
69
|
+
if (res.status >= 500) {
|
|
70
|
+
// Up but unwell: believe it for a minute, not ten.
|
|
71
|
+
probes.set(root, { ok: false, at: now - PROBE_TTL_DOWN + 60_000 });
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
probes.set(root, { ok: false, at: now - PROBE_TTL_DOWN + 60_000 });
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
probes.set(root, { ok, at: now });
|
|
79
|
+
return ok;
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
/** The last probe answer without asking again: for a renderer that cannot wait. */
|
|
83
|
+
known() {
|
|
84
|
+
return probes.get(root)?.ok ?? false;
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Create a session. Resolves to `{ ok: true, id, url }`, or
|
|
89
|
+
* `{ ok: false, status, missing, reason }` where `missing` means the
|
|
90
|
+
* endpoint is not deployed.
|
|
91
|
+
*/
|
|
92
|
+
async create(body) {
|
|
93
|
+
let res;
|
|
94
|
+
try {
|
|
95
|
+
res = await f(root, {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: { ...headers, 'content-type': 'application/json' },
|
|
98
|
+
body: JSON.stringify(body),
|
|
99
|
+
signal: AbortSignal.timeout(15000),
|
|
100
|
+
});
|
|
101
|
+
} catch (err) {
|
|
102
|
+
return { ok: false, status: 0, missing: false, reason: String(err?.message ?? err) };
|
|
103
|
+
}
|
|
104
|
+
const json = await readJson(res);
|
|
105
|
+
if (res.status === 404) {
|
|
106
|
+
markDown();
|
|
107
|
+
return { ok: false, status: 404, missing: true, reason: 'checkout sessions are not available' };
|
|
108
|
+
}
|
|
109
|
+
const s = json?.session ?? json?.data ?? json;
|
|
110
|
+
const id = s?.id ?? s?.session_id ?? s?.checkout_session_id;
|
|
111
|
+
const url = s?.url ?? s?.checkout_url ?? s?.hosted_url;
|
|
112
|
+
if (!res.ok || !isSessionId(String(id ?? '')) || !/^https:\/\//.test(String(url ?? ''))) {
|
|
113
|
+
return { ok: false, status: res.status, missing: false, reason: String(json?.error ?? `checkout failed (${res.status})`) };
|
|
114
|
+
}
|
|
115
|
+
return { ok: true, id: String(id), url: String(url) };
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
/** Read a session back from CoinPay. Null when it cannot be read. */
|
|
119
|
+
async get(id) {
|
|
120
|
+
if (!isSessionId(id)) return null;
|
|
121
|
+
try {
|
|
122
|
+
const res = await f(`${root}/${encodeURIComponent(id)}`, {
|
|
123
|
+
method: 'GET',
|
|
124
|
+
headers,
|
|
125
|
+
signal: AbortSignal.timeout(15000),
|
|
126
|
+
});
|
|
127
|
+
if (!res.ok) return null;
|
|
128
|
+
const json = await readJson(res);
|
|
129
|
+
const s = json?.session ?? json?.data ?? json;
|
|
130
|
+
return s && typeof s === 'object' ? s : null;
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const toSeconds = (v) => {
|
|
139
|
+
if (v === undefined || v === null || v === '') return null;
|
|
140
|
+
const n = typeof v === 'number' ? v : Date.parse(String(v));
|
|
141
|
+
if (!Number.isFinite(n)) return null;
|
|
142
|
+
// A number below 10^11 is already seconds; anything larger is milliseconds.
|
|
143
|
+
return Math.floor(n < 1e11 ? n : n / 1000);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* What a session says, normalised: whether it is paid, still open, or dead,
|
|
148
|
+
* how much it was for, when it was paid, and who and where it was for.
|
|
149
|
+
*
|
|
150
|
+
* `amount` is read in dollars (the unit the session was created in) unless
|
|
151
|
+
* the session also names `amount_cents`.
|
|
152
|
+
*/
|
|
153
|
+
export function sessionState(session) {
|
|
154
|
+
const status = String(session?.status ?? session?.payment_status ?? '').toLowerCase();
|
|
155
|
+
const paid = session?.paid === true || PAID.has(status);
|
|
156
|
+
const dead = !paid && DEAD.has(status);
|
|
157
|
+
const metadata = session?.metadata && typeof session.metadata === 'object' ? session.metadata : {};
|
|
158
|
+
let cents = null;
|
|
159
|
+
if (Number.isFinite(Number(session?.amount_cents))) cents = Math.round(Number(session.amount_cents));
|
|
160
|
+
else if (session?.amount !== undefined && Number.isFinite(Number(session.amount))) cents = Math.round(Number(session.amount) * 100);
|
|
161
|
+
const currency = String(session?.currency ?? 'USD').toUpperCase();
|
|
162
|
+
const paidAt =
|
|
163
|
+
toSeconds(session?.paid_at) ??
|
|
164
|
+
toSeconds(session?.completed_at) ??
|
|
165
|
+
toSeconds(session?.confirmed_at) ??
|
|
166
|
+
toSeconds(session?.updated_at) ??
|
|
167
|
+
toSeconds(session?.created_at);
|
|
168
|
+
return {
|
|
169
|
+
id: session?.id ? String(session.id) : null,
|
|
170
|
+
status,
|
|
171
|
+
paid,
|
|
172
|
+
dead,
|
|
173
|
+
cents,
|
|
174
|
+
currency,
|
|
175
|
+
paidAt,
|
|
176
|
+
property: session?.property ?? metadata.property ?? null,
|
|
177
|
+
email: session?.customer_email ?? metadata.customer_email ?? null,
|
|
178
|
+
accountId: session?.account_id ?? metadata.account_id ?? null,
|
|
179
|
+
metadata,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The session id a CoinPay webhook is about. Webhook bodies are nested
|
|
185
|
+
* (`{ id: <event>, type, data: { ... } }`), so the top-level `id` is the
|
|
186
|
+
* EVENT and is never taken for the session.
|
|
187
|
+
*/
|
|
188
|
+
export function sessionIdFromWebhook(body) {
|
|
189
|
+
const candidates = [
|
|
190
|
+
body?.checkout_session_id,
|
|
191
|
+
body?.data?.checkout_session_id,
|
|
192
|
+
body?.data?.session_id,
|
|
193
|
+
body?.data?.object?.checkout_session_id,
|
|
194
|
+
body?.data?.object?.id,
|
|
195
|
+
body?.data?.id,
|
|
196
|
+
];
|
|
197
|
+
for (const c of candidates) if (isSessionId(String(c ?? ''))) return String(c);
|
|
198
|
+
return null;
|
|
199
|
+
}
|
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,
|
|
@@ -39,10 +42,21 @@ export {
|
|
|
39
42
|
welcomeCrawler,
|
|
40
43
|
} from './edge.js';
|
|
41
44
|
export { challengePage, challengeToken, cookieValue, passedChallenge } from './challenge.js';
|
|
42
|
-
export { renderPage } from './page.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
|
|
|
@@ -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
|
/*
|
|
@@ -482,10 +837,11 @@ export function createGateway(options = {}) {
|
|
|
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 }), 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
|
|
|
@@ -649,6 +1026,12 @@ function normalise(options) {
|
|
|
649
1026
|
page: options.page ?? renderPage,
|
|
650
1027
|
contact: options.contact ?? '',
|
|
651
1028
|
onSale: options.onSale ?? null,
|
|
1029
|
+
property: typeof options.property === 'function' || typeof options.property === 'string' ? options.property : null,
|
|
1030
|
+
ipSalt: options.ipSalt ?? '',
|
|
1031
|
+
checkout: options.checkout === false ? null : { ...(options.checkout ?? {}) },
|
|
1032
|
+
passCookie: String(options.passCookie ?? 'crawl_pass'),
|
|
1033
|
+
renderNotice: options.renderNotice ?? renderNotice,
|
|
1034
|
+
renderPassPage: options.renderPassPage ?? renderPassPage,
|
|
652
1035
|
freeQuota: normaliseQuota(options.freeQuota),
|
|
653
1036
|
benefits: Array.isArray(options.benefits) ? options.benefits : null,
|
|
654
1037
|
powerKeys: options.powerKeys === false ? null : (options.powerKeys ?? PROFULLSTACK_KEYS),
|
package/src/page.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Plain HTML with no script, because the reader is a program that renders
|
|
5
5
|
* nothing, or a person who was sent the link by one. Every number on it comes
|
|
6
|
-
* from the gateway's options; nothing is typed in twice.
|
|
6
|
+
* from the gateway's options; nothing is typed in twice. The one form on it,
|
|
7
|
+
* the checkout button, is a plain POST and works with scripts off.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
const esc = (s) =>
|
|
@@ -24,6 +25,12 @@ pre{background:var(--code);border:1px solid var(--line);border-radius:6px;paddin
|
|
|
24
25
|
code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.92em}
|
|
25
26
|
ol,ul{padding-left:1.3rem}li{margin:.3rem 0}
|
|
26
27
|
.price{font-size:2.2rem;font-weight:700;color:var(--acc);margin:.25rem 0}
|
|
28
|
+
.buy{border:1px solid var(--line);border-radius:8px;padding:1rem 1.1rem;margin:1.25rem 0}
|
|
29
|
+
.buy h2{margin-top:0}.buy form{display:flex;flex-wrap:wrap;gap:.5rem;align-items:center;margin:.6rem 0 .2rem}
|
|
30
|
+
.buy input{font:inherit;padding:.5rem .6rem;border:1px solid var(--line);border-radius:6px;background:var(--bg);color:var(--fg);min-width:0}
|
|
31
|
+
.buy input[type=email]{flex:1 1 14rem}.buy input[type=number]{width:5rem}
|
|
32
|
+
.buy button{font:inherit;font-weight:600;padding:.55rem 1rem;border:0;border-radius:6px;background:var(--acc);color:#fff;cursor:pointer}
|
|
33
|
+
.token{word-break:break-all}
|
|
27
34
|
table{border-collapse:collapse;margin:.5rem 0;font-size:.95rem}td,th{border-bottom:1px solid var(--line);padding:.35rem .6rem;text-align:left}
|
|
28
35
|
footer{margin-top:3rem;color:var(--mut);font-size:.85rem}
|
|
29
36
|
`;
|
|
@@ -61,6 +68,7 @@ export function renderPage(ctx) {
|
|
|
61
68
|
quota = null,
|
|
62
69
|
benefits = null,
|
|
63
70
|
powerKey = null,
|
|
71
|
+
checkout = null,
|
|
64
72
|
} = ctx;
|
|
65
73
|
|
|
66
74
|
/*
|
|
@@ -112,6 +120,26 @@ export function renderPage(ctx) {
|
|
|
112
120
|
: `${minutes} minutes`;
|
|
113
121
|
const networks = (offer?.accepts ?? []).map((a) => a.network).join(', ');
|
|
114
122
|
|
|
123
|
+
/*
|
|
124
|
+
* The button for people. Shown only when CoinPay serves hosted checkout, so
|
|
125
|
+
* a site never offers a payment it cannot take.
|
|
126
|
+
*/
|
|
127
|
+
const buyBox = checkout
|
|
128
|
+
? `<section class="buy">
|
|
129
|
+
<h2>Buy a day pass</h2>
|
|
130
|
+
<p>Pay ${esc(price)} a day with any coin CoinPay takes or with PayPal. The pass appears on the next page and works with any HTTP client.</p>
|
|
131
|
+
<form method="post" action="${esc(checkout.action)}">
|
|
132
|
+
<input type="hidden" name="from" value="${esc(checkout.from ?? '')}">
|
|
133
|
+
<label class="mut" for="pass-email">Email for the receipt</label>
|
|
134
|
+
<input id="pass-email" type="email" name="email" required autocomplete="email" placeholder="you@example.com">
|
|
135
|
+
<label class="mut" for="pass-days">Days</label>
|
|
136
|
+
<input id="pass-days" type="number" name="days" min="1" max="${esc(String(maxDays))}" value="${esc(String(checkout.days ?? days ?? 1))}">
|
|
137
|
+
<button type="submit">Buy a day pass</button>
|
|
138
|
+
</form>
|
|
139
|
+
<p class="mut">Checkout is hosted by CoinPay. Up to ${esc(String(maxDays))} days at once.</p>
|
|
140
|
+
</section>`
|
|
141
|
+
: '';
|
|
142
|
+
|
|
115
143
|
return `<!doctype html>
|
|
116
144
|
<html lang="en">
|
|
117
145
|
<head>
|
|
@@ -137,6 +165,7 @@ ${
|
|
|
137
165
|
? ''
|
|
138
166
|
: '<p><strong>Payments are not switched on here yet.</strong> The offer below is empty until the operator configures a payout address, so for now this crawler is simply refused.</p>'
|
|
139
167
|
}
|
|
168
|
+
${enabled ? buyBox : ''}
|
|
140
169
|
|
|
141
170
|
${unlocks}
|
|
142
171
|
${powerKeyNote}
|
|
@@ -144,9 +173,9 @@ ${arithmetic}
|
|
|
144
173
|
|
|
145
174
|
<h2>How it works</h2>
|
|
146
175
|
<ol>
|
|
147
|
-
<li>Any page you fetch answers <code>402 Payment Required</code
|
|
148
|
-
<li>Sign the payment and retry with the proof in
|
|
149
|
-
<li>Send the pass in <code>${esc(header)}</code> on every request until it expires
|
|
176
|
+
<li>Any page you fetch answers <code>402 Payment Required</code> with the x402 offer in a <code>PAYMENT-REQUIRED</code> header (base64 JSON). This page, fetched with <code>Accept: application/json</code>, also returns it as the body: USDC, <code>exact</code> scheme, on ${esc(networks || 'Base, Polygon or Ethereum')}.</li>
|
|
177
|
+
<li>Sign the payment and retry with the proof in a <code>PAYMENT-SIGNATURE</code> (or <code>X-PAYMENT</code>) header. The response is a JSON receipt carrying a pass, with the settlement in <code>PAYMENT-RESPONSE</code>.</li>
|
|
178
|
+
<li>Send the pass in <code>${esc(header)}</code> (or <code>Authorization: Bearer</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>
|
|
150
179
|
</ol>
|
|
151
180
|
|
|
152
181
|
<h2>Pay with the CoinPay CLI</h2>
|
|
@@ -187,3 +216,69 @@ curl -sS -H "X-PAYMENT: <base64 proof>" ${esc(buyUrl)}
|
|
|
187
216
|
</html>
|
|
188
217
|
`;
|
|
189
218
|
}
|
|
219
|
+
|
|
220
|
+
const shell = (siteName, title, inner) => `<!doctype html>
|
|
221
|
+
<html lang="en">
|
|
222
|
+
<head>
|
|
223
|
+
<meta charset="utf-8">
|
|
224
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
225
|
+
<meta name="robots" content="noindex">
|
|
226
|
+
<title>${esc(title)} · ${esc(siteName)}</title>
|
|
227
|
+
<style>${CSS}</style>
|
|
228
|
+
</head>
|
|
229
|
+
<body>
|
|
230
|
+
<main>
|
|
231
|
+
${inner}
|
|
232
|
+
<footer>Served by @profullstack/x402-gateway.</footer>
|
|
233
|
+
</main>
|
|
234
|
+
</body>
|
|
235
|
+
</html>
|
|
236
|
+
`;
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* A short status page for the checkout routes: waiting, closed, refused.
|
|
240
|
+
* `body` is HTML the gateway wrote; nothing in it comes from the request.
|
|
241
|
+
*
|
|
242
|
+
* @param {{siteName: string, siteUrl: string, buyUrl: string, title: string, body: string}} ctx
|
|
243
|
+
*/
|
|
244
|
+
export function renderNotice({ siteName, title, body }) {
|
|
245
|
+
return shell(siteName, title, `<h1>${esc(title)}</h1>\n<p>${body}</p>`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The page a buyer lands on after paying at CoinPay: the pass, and the three
|
|
250
|
+
* ways to send it.
|
|
251
|
+
*
|
|
252
|
+
* @param {object} ctx
|
|
253
|
+
* @param {string} ctx.siteName
|
|
254
|
+
* @param {string} ctx.siteUrl
|
|
255
|
+
* @param {string} ctx.buyUrl
|
|
256
|
+
* @param {string} ctx.token
|
|
257
|
+
* @param {string} ctx.expiresAt ISO time
|
|
258
|
+
* @param {number} ctx.days
|
|
259
|
+
* @param {string} ctx.header request header the pass goes in
|
|
260
|
+
* @param {string} ctx.cookie cookie the pass is also read from
|
|
261
|
+
* @param {string|null} [ctx.email]
|
|
262
|
+
*/
|
|
263
|
+
export function renderPassPage({ siteName, siteUrl, token, expiresAt, days, header, cookie, email }) {
|
|
264
|
+
const term = days === 1 ? 'one day' : `${days} days`;
|
|
265
|
+
return shell(
|
|
266
|
+
siteName,
|
|
267
|
+
'Your pass',
|
|
268
|
+
`<h1>Paid. Here is your pass.</h1>
|
|
269
|
+
<p class="mut">Good for ${esc(term)} on <a href="${esc(siteUrl)}">${esc(siteName)}</a>, until <strong>${esc(expiresAt)}</strong>.${email ? ` The receipt went to ${esc(email)}.` : ''} Copy it now: this page is the only place it is shown, and the pass is all a crawler needs.</p>
|
|
270
|
+
<pre class="token"><code>${esc(token)}</code></pre>
|
|
271
|
+
|
|
272
|
+
<h2>How to use it</h2>
|
|
273
|
+
<p>Send it on every request, in whichever of these your client supports:</p>
|
|
274
|
+
<pre><code># a header
|
|
275
|
+
curl -H "${esc(header)}: ${esc(token)}" ${esc(siteUrl)}/
|
|
276
|
+
|
|
277
|
+
# a bearer token
|
|
278
|
+
curl -H "Authorization: Bearer ${esc(token)}" ${esc(siteUrl)}/
|
|
279
|
+
|
|
280
|
+
# a cookie (this browser already has it)
|
|
281
|
+
curl --cookie "${esc(cookie)}=${esc(token)}" ${esc(siteUrl)}/</code></pre>
|
|
282
|
+
<p class="mut">When it runs out, buy another at the same page. Every Profullstack site sells its own pass; a Power Key opens them all.</p>`,
|
|
283
|
+
);
|
|
284
|
+
}
|
package/src/x402.js
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* x402 v2, in CoinPayPortal's dialect.
|
|
2
|
+
* x402 v2, in CoinPayPortal's dialect, plus the spec's transport headers.
|
|
3
3
|
*
|
|
4
4
|
* Checked against @profullstack/coinpay/x402-v2 rather than remembered: the 402
|
|
5
5
|
* body is JSON `{ x402Version: 2, accepts: [...] }`, the proof arrives as base64
|
|
6
|
-
* JSON
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* JSON, and CoinPay's /api/x402/verify then /api/x402/settle do the
|
|
7
|
+
* cryptography and the on-chain transfer (EIP-3009, with CoinPay's relayer
|
|
8
|
+
* paying the gas, so the buyer needs no ETH).
|
|
9
|
+
*
|
|
10
|
+
* The x402 v2 transport spec moves all three messages into headers:
|
|
11
|
+
* `PAYMENT-REQUIRED` (the offer, base64 JSON) on the 402, `PAYMENT-SIGNATURE`
|
|
12
|
+
* (the proof) on the retry, and `PAYMENT-RESPONSE` (the settlement) on the
|
|
13
|
+
* 200. The gateway speaks both: the body and `X-PAYMENT` stay for the clients
|
|
14
|
+
* that already pay this way, and the headers are there for spec-strict ones.
|
|
9
15
|
*
|
|
10
16
|
* Reimplemented here in a few dozen lines because the SDK also carries the
|
|
11
17
|
* CLI's interactive-prompt dependency, which a web server has no business
|
|
@@ -22,6 +28,13 @@
|
|
|
22
28
|
*
|
|
23
29
|
* Base first. Merchant order is the payer's preference order, and Base is where
|
|
24
30
|
* the relayer's gas is cheapest.
|
|
31
|
+
*
|
|
32
|
+
* Why not more: CoinPay's v2 settle broadcasts `transferWithAuthorization`
|
|
33
|
+
* only on chains its relayer has an RPC and a funded wallet for, which are
|
|
34
|
+
* exactly 1, 137 and 8453, and USDC is the only EIP-3009 dollar token on them
|
|
35
|
+
* (USDT has no EIP-3009). Advertising anything else would quote a payment
|
|
36
|
+
* that verifies and then never settles. Buyers who want another coin, or
|
|
37
|
+
* PayPal, use the hosted checkout on the sales page instead.
|
|
25
38
|
*/
|
|
26
39
|
export const METHODS = [
|
|
27
40
|
{
|
|
@@ -88,8 +101,16 @@ function fromBase64(s) {
|
|
|
88
101
|
return new TextDecoder().decode(bytes);
|
|
89
102
|
}
|
|
90
103
|
|
|
91
|
-
/**
|
|
92
|
-
export function
|
|
104
|
+
/** utf8 JSON -> base64, without Buffer, for the PAYMENT-* headers. */
|
|
105
|
+
export function encodeHeader(value) {
|
|
106
|
+
const bytes = new TextEncoder().encode(JSON.stringify(value));
|
|
107
|
+
let bin = '';
|
|
108
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
109
|
+
return btoa(bin);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A PAYMENT-* header back into an object. Null if it is not base64 JSON. */
|
|
113
|
+
export function decodeHeader(header) {
|
|
93
114
|
if (!header) return null;
|
|
94
115
|
try {
|
|
95
116
|
const parsed = JSON.parse(fromBase64(header));
|
|
@@ -99,6 +120,65 @@ export function decodePayment(header) {
|
|
|
99
120
|
}
|
|
100
121
|
}
|
|
101
122
|
|
|
123
|
+
/**
|
|
124
|
+
* The proof out of a PAYMENT-SIGNATURE or X-PAYMENT header. Null if it is not
|
|
125
|
+
* base64 JSON.
|
|
126
|
+
*
|
|
127
|
+
* Two shapes arrive. CoinPay's payer puts `scheme` and `network` at the top
|
|
128
|
+
* level; a spec v2 client nests them in `accepted`, the requirement it chose.
|
|
129
|
+
* Both carry the same `payload`, so the spec shape is lifted into CoinPay's,
|
|
130
|
+
* which is what CoinPay's verify and settle read.
|
|
131
|
+
*/
|
|
132
|
+
export function decodePayment(header) {
|
|
133
|
+
const parsed = decodeHeader(header);
|
|
134
|
+
if (!parsed) return null;
|
|
135
|
+
const chosen = parsed.accepted && typeof parsed.accepted === 'object' ? parsed.accepted : null;
|
|
136
|
+
if (chosen) {
|
|
137
|
+
if (!parsed.network && chosen.network) parsed.network = chosen.network;
|
|
138
|
+
if (!parsed.scheme && chosen.scheme) parsed.scheme = chosen.scheme;
|
|
139
|
+
if (parsed.x402Version === undefined) parsed.x402Version = 2;
|
|
140
|
+
}
|
|
141
|
+
return parsed;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The PAYMENT-REQUIRED header for an offer: the spec's PaymentRequired object,
|
|
146
|
+
* base64 JSON. The resource moves to the top level, as the spec has it, and
|
|
147
|
+
* each requirement keeps only the spec's own fields, so a strict schema has
|
|
148
|
+
* nothing to choke on.
|
|
149
|
+
*/
|
|
150
|
+
export function paymentRequiredHeader(offer, { url, description, error } = {}) {
|
|
151
|
+
const first = offer?.accepts?.[0];
|
|
152
|
+
return encodeHeader({
|
|
153
|
+
x402Version: 2,
|
|
154
|
+
...(error ? { error: String(error) } : {}),
|
|
155
|
+
resource: {
|
|
156
|
+
url: url ?? first?.resource ?? '',
|
|
157
|
+
description: description ?? first?.description ?? '',
|
|
158
|
+
mimeType: first?.mimeType ?? 'application/json',
|
|
159
|
+
},
|
|
160
|
+
accepts: (offer?.accepts ?? []).map((a) => ({
|
|
161
|
+
scheme: a.scheme,
|
|
162
|
+
network: a.network,
|
|
163
|
+
amount: a.amount,
|
|
164
|
+
asset: a.asset,
|
|
165
|
+
payTo: a.payTo,
|
|
166
|
+
maxTimeoutSeconds: a.maxTimeoutSeconds,
|
|
167
|
+
extra: a.extra,
|
|
168
|
+
})),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The PAYMENT-RESPONSE header for a settled payment: the spec's SettlementResponse. */
|
|
173
|
+
export function paymentResponseHeader({ transaction, network, payer }) {
|
|
174
|
+
return encodeHeader({
|
|
175
|
+
success: true,
|
|
176
|
+
transaction: transaction ?? '',
|
|
177
|
+
network: network ?? '',
|
|
178
|
+
...(payer ? { payer } : {}),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
102
182
|
/**
|
|
103
183
|
* What CoinPay must hold the proof to.
|
|
104
184
|
*
|
|
@@ -132,7 +212,7 @@ export const validBeforeOf = (payment) => {
|
|
|
132
212
|
* gateway answers that with a pass rather than a second charge, bounded by the
|
|
133
213
|
* proof's own validity window so the same header cannot buy hour after hour.
|
|
134
214
|
*/
|
|
135
|
-
export async function verifyAndSettle(payment, expected, { apiKey, baseUrl, fetch: f = globalThis.fetch }) {
|
|
215
|
+
export async function verifyAndSettle(payment, expected, { apiKey, baseUrl, fetch: f = globalThis.fetch, metadata = null }) {
|
|
136
216
|
const call = async (path, body) => {
|
|
137
217
|
const res = await f(`${baseUrl}${path}`, {
|
|
138
218
|
method: 'POST',
|
|
@@ -153,12 +233,20 @@ export async function verifyAndSettle(payment, expected, { apiKey, baseUrl, fetc
|
|
|
153
233
|
const reason = String(v.json?.error ?? v.json?.reason ?? `verify failed (${v.status})`);
|
|
154
234
|
return { ok: false, reason, replay: /already used|replay/i.test(reason) };
|
|
155
235
|
}
|
|
156
|
-
const
|
|
236
|
+
const payer = v.json.payment?.from ?? null;
|
|
237
|
+
/*
|
|
238
|
+
* Who paid and where, for CoinPay's ledger: the property, the page, the
|
|
239
|
+
* payer, the user agent and a hash of the address. CoinPay keeps it on the
|
|
240
|
+
* x402_payments row (and ignores it where it does not), so a sale can be
|
|
241
|
+
* traced to the site and page it bought rather than only to the business.
|
|
242
|
+
*/
|
|
243
|
+
const settleBody = metadata ? { payment, metadata: { ...metadata, ...(payer ? { payer } : {}) } } : { payment };
|
|
244
|
+
const s = await call('/api/x402/settle', settleBody);
|
|
157
245
|
if (!s.json?.settled) {
|
|
158
246
|
const reason = String(s.json?.error ?? `settle failed (${s.status})`);
|
|
159
247
|
return { ok: false, reason, replay: /already settled|already being settled/i.test(reason) };
|
|
160
248
|
}
|
|
161
|
-
return { ok: true, payer:
|
|
249
|
+
return { ok: true, payer, ref: s.json.txHash ?? nonceOf(payment), txHash: s.json.txHash ?? null, network: s.json.network ?? payment?.network ?? null };
|
|
162
250
|
}
|
|
163
251
|
|
|
164
252
|
/** Whether the proof has already been paid, when a settle is asked about twice. */
|