@profullstack/x402-gateway 0.10.2 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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>. This page, fetched with <code>Accept: application/json</code>, returns the x402 offer: USDC, <code>exact</code> scheme, on ${esc(networks || 'Base, Polygon or Ethereum')}.</li>
148
- <li>Sign the payment and retry with the proof in an <code>X-PAYMENT</code> header. The response is a JSON receipt carrying a pass.</li>
149
- <li>Send the pass in <code>${esc(header)}</code> on every request until it expires — ${esc(window)} per day paid, so a proof for three times the price buys three. When it expires, buy another. The sale is the pass, not the page: fetch the page again with the pass.</li>
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: &lt;base64 proof&gt;" ${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 in an `X-PAYMENT` request header, and CoinPay's /api/x402/verify then
7
- * /api/x402/settle do the cryptography and the on-chain transfer (EIP-3009,
8
- * with CoinPay's relayer paying the gas, so the buyer needs no ETH).
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
- /** The proof out of an X-PAYMENT header. Null if it is not base64 JSON. */
92
- export function decodePayment(header) {
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 s = await call('/api/x402/settle', { payment });
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: v.json.payment?.from ?? null, ref: s.json.txHash ?? nonceOf(payment) };
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. */