@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/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. */
|