@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 CHANGED
@@ -95,14 +95,18 @@ export const GET = robotsRoute(gateway, { disallow: ['/login', '/api/'] });
95
95
  | `aiIndex` | none | extra agents to charge and refuse in robots.txt. Since 0.10 the AI answer-engine indexers (`AI_INDEX_AGENTS`: meta-webindexer, YouBot, ExaSearchBot, AionBot) are welcome retrieval crawlers; pass `aiIndex: AI_INDEX_AGENTS` to charge them anyway |
96
96
  | `verifiedRetrieval` | `VERIFIED_RETRIEVAL` | retrieval crawlers believed only from their operator's published addresses (OAI-SearchBot, ChatGPT-User, PerplexityBot, Perplexity-User); from elsewhere they are metered. Other retrieval and AI chat fetchers (Claude-User, Claude-SearchBot, Meta-ExternalFetcher, DuckAssistBot, MistralAI-User, the AI indexers) publish no addresses and are believed by name |
97
97
  | `chargeImpostors` | `true` | a request wearing a `welcome` crawler's name (Googlebot, bingbot, Applebot, DuckDuckBot, Cloudflare-*) from an address outside that operator's published list is charged. Never judged without a readable address |
98
- | `challenge` | off | `{ paths: ['/i/'], cookie?: 'pf_js', when?: (request, url) => boolean }`: every GET to these paths (that `when` accepts, if given) must run a one-line script (sets a per-address, per-day HMAC cookie and reloads) or gets a 402. Welcome crawlers (address-verified search crawlers, retrieval crawlers, AI chat fetchers) skip it; a client that did not ask for HTML gets the JSON offer instead of the script; sessions and passes never see it. For residential swarms that wear a current browser string and never run the page |
98
+ | `challenge` | off | `{ paths: ['/i/'], cookie?: 'pf_js', work?: 0, when?: (request, url) => boolean }`: every GET to these paths (that `when` accepts, if given) must run a one-line script (sets a per-address, per-day HMAC cookie and reloads) or gets a 402. Welcome crawlers (address-verified search crawlers, retrieval crawlers, AI chat fetchers) skip it; a client that did not ask for HTML gets the JSON offer instead of the script; sessions and passes never see it. For residential swarms that wear a current browser string and never run the page. Set `work` (bits; `true` = 16, max 24) to make the script also solve a SHA-256 proof of work before it sets the cookie (`<token>.<nonce>`): ~250 ms at 16 bits on a desktop, once per address per day, but per page for a swarm that drives real headless Chrome and rotates its address every page. `navigator.webdriver` browsers are not given the cookie. |
99
99
  | `chargeStaleBrowsers` | `true` | charge a desktop Chrome/Edge/Firefox string about two years behind its auto-updates. Scraper rotation pools copy old strings; people's browsers update themselves. Phones, Firefox ESRs and the last version an old OS can run are never judged |
100
100
  | `welcome` | `WELCOME_CRAWLERS` | Googlebot, bingbot and Applebot read unmetered when their address is in the operator's published range. A copied string from anywhere else is metered like everyone; `false` turns this off |
101
101
  | `training`, `retrieval` | the lists in `./agents` | |
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 {
@@ -78,7 +105,7 @@ export interface GatewayOptions {
78
105
  chargeImpostors?: boolean;
79
106
  /** Require a one-line script (cookie + reload) on these paths before serving them. Off by default. */
80
107
  /** Only GETs to `paths` that `when` accepts (default: all of them), e.g. `(req, url) => url.search.length > 1` for faceted URLs. */
81
- challenge?: { paths: string[]; cookie?: string; when?: (request: Request, url: URL) => boolean } | false;
108
+ challenge?: { paths: string[]; cookie?: string; /** Proof-of-work bits the script must solve; true = 16. Default 0 (cookie + reload only). */ work?: number | boolean; when?: (request: Request, url: URL) => boolean } | false;
82
109
  /** SEO resellers: charged, and refused in robots.txt. Default SCRAPER_AGENTS. */
83
110
  scrapers?: string[];
84
111
  /** Headless browsers that name themselves: charged. Default HEADLESS_AGENTS. */
@@ -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;
@@ -226,8 +276,10 @@ export const SCRAPER_AGENTS: string[];
226
276
  export const HEADLESS_AGENTS: string[];
227
277
  export const AI_INDEX_AGENTS: string[];
228
278
  export function challengeToken(secret: string, ip: string, day?: number): Promise<string>;
229
- export function passedChallenge(request: Request, options: { secret: string; cookie: string; ip: string; now?: number }): Promise<boolean>;
230
- export function challengePage(options: { token: string; cookie: string; buyUrl: string }): string;
279
+ export function passedChallenge(request: Request, options: { secret: string; cookie: string; ip: string; work?: number; now?: number }): Promise<boolean>;
280
+ export function challengePage(options: { token: string; cookie: string; buyUrl: string; work?: number }): string;
281
+ export function solves(seed: string, nonce: string | undefined, work: number): Promise<boolean>;
282
+ export function leadingZeros(bytes: Uint8Array, bits: number): boolean;
231
283
  export function cookieValue(request: Request, name: string): string | null;
232
284
  export interface WelcomeCrawler {
233
285
  token: string;
@@ -261,6 +313,13 @@ export const METHODS: Array<{ key: string; network: string; asset: string; label
261
313
  export const X402_METHODS: typeof METHODS;
262
314
  export function buildOffer(args: { payTo: string; priceCents: number; resource: string; description?: string; maxTimeoutSeconds?: number; methods?: typeof METHODS }): Offer;
263
315
  export function decodePayment(header: string | null | undefined): Record<string, unknown> | null;
316
+ export function decodeHeader(header: string | null | undefined): Record<string, unknown> | null;
317
+ export function encodeHeader(value: unknown): string;
318
+ export function paymentRequiredHeader(offer: Offer, args?: { url?: string; description?: string; error?: string }): string;
319
+ export function paymentResponseHeader(args: { transaction?: string | null; network?: string | null; payer?: string | null }): string;
320
+ export function renderNotice(ctx: { siteName: string; siteUrl: string; buyUrl: string; title: string; body: string }): string;
321
+ export function renderPassPage(ctx: { siteName: string; siteUrl: string; buyUrl: string; token: string; expiresAt: string; days: number; header: string; cookie: string; email?: string | null }): string;
322
+ export function sessionIdFromWebhook(body: unknown): string | null;
264
323
  export function expectedFor(payment: unknown, offer: Offer): { amount: string; resource: string; payTo: string; asset: string } | null;
265
324
  /** The value a proof authorizes, in the token's smallest unit, or null. */
266
325
  export function paidValueOf(payment: unknown): bigint | null;
@@ -269,8 +328,8 @@ export function daysPaid(value: bigint | null, unit: string | number | bigint, m
269
328
  export function verifyAndSettle(
270
329
  payment: unknown,
271
330
  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 }>;
331
+ coinpay: { apiKey: string; baseUrl: string; fetch?: typeof fetch; metadata?: Record<string, unknown> | null },
332
+ ): Promise<{ ok: true; payer: string | null; ref: string | null; txHash: string | null; network: string | null } | { ok: false; reason: string; replay: boolean }>;
274
333
 
275
334
  /** ./hono */
276
335
  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.10.2",
3
+ "version": "0.12.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/challenge.js CHANGED
@@ -14,6 +14,15 @@
14
14
  * address. The page that asks is a 402 carrying a link to the sales page, so a
15
15
  * scraper that cannot run scripts learns what a pass costs, which is the same
16
16
  * answer every other refused crawler gets.
17
+ *
18
+ * That was not enough. On 2026-10-21 the slice of the swarm that drives a real
19
+ * Chrome ran the script and walked in: 402, then the same address in the same
20
+ * second, then a 200, then never again. It rotates its address on every page,
21
+ * so "once per address per day" is once per page and the script cost it one
22
+ * request. With `work` set the script must also find a nonce whose SHA-256
23
+ * starts with that many zero bits. A person pays about a second once a day; a
24
+ * swarm that changes address every page pays it on every page. The server
25
+ * checks the answer with one hash and keeps nothing.
17
26
  */
18
27
 
19
28
  const enc = new TextEncoder();
@@ -42,19 +51,48 @@ export function cookieValue(request, name) {
42
51
  return null;
43
52
  }
44
53
 
45
- /** Whether the request carries today's or yesterday's token for its address. */
46
- export async function passedChallenge(request, { secret, cookie, ip, now = Date.now() }) {
54
+ /** Whether a SHA-256 digest starts with `bits` zero bits. */
55
+ export function leadingZeros(bytes, bits) {
56
+ let i = 0;
57
+ for (; bits >= 8; bits -= 8) if (bytes[i++]) return false;
58
+ return !bits || !(bytes[i] >> (8 - bits));
59
+ }
60
+
61
+ /** Whether `nonce` answers the proof of work for `seed`. */
62
+ export async function solves(seed, nonce, work) {
63
+ if (!work) return true;
64
+ if (!/^\d{1,12}$/.test(nonce ?? '')) return false;
65
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', enc.encode(`${seed}.${nonce}`)));
66
+ return leadingZeros(digest, work);
67
+ }
68
+
69
+ /**
70
+ * Whether the request carries today's or yesterday's token for its address,
71
+ * and, with `work`, a nonce that answers it (cookie `<token>.<nonce>`).
72
+ */
73
+ export async function passedChallenge(request, { secret, cookie, ip, work = 0, now = Date.now() }) {
47
74
  const got = cookieValue(request, cookie);
48
75
  if (!got) return false;
76
+ const [seed, nonce] = got.split('.');
49
77
  const day = Math.floor(now / DAY);
50
- for (const d of [day, day - 1]) if (got === (await challengeToken(secret, ip, d))) return true;
78
+ for (const d of [day, day - 1]) if (seed === (await challengeToken(secret, ip, d))) return solves(seed, nonce, work);
51
79
  return false;
52
80
  }
53
81
 
82
+ /** The script that finds the nonce, 256 digests at a time, then sets the cookie and reloads. */
83
+ const solver = (token, cookie, work) => `(async()=>{const s=${JSON.stringify(token)},w=${work},e=new TextEncoder();
84
+ if(navigator.webdriver){document.body.insertAdjacentHTML('beforeend','<p>Automated browsers need a pass.</p>');return}
85
+ const ok=b=>{let n=w,i=0;for(;n>=8;n-=8)if(b[i++])return 0;return!n||!(b[i]>>(8-n))};
86
+ for(let n=0;;n+=256){const h=await Promise.all(Array.from({length:256},(_,k)=>crypto.subtle.digest('SHA-256',e.encode(s+'.'+(n+k)))));
87
+ for(let k=0;k<256;k++)if(ok(new Uint8Array(h[k]))){document.cookie=${JSON.stringify(`${cookie}=`)}+s+'.'+(n+k)+${JSON.stringify('; Max-Age=172800; Path=/; SameSite=Lax; Secure')};location.reload();return}}})();`;
88
+
54
89
  /** The page that asks. Tiny on purpose: answering it must cost us nothing. */
55
- export function challengePage({ token, cookie, buyUrl }) {
90
+ export function challengePage({ token, cookie, buyUrl, work = 0 }) {
91
+ const script = work
92
+ ? solver(token, cookie, work)
93
+ : `document.cookie=${JSON.stringify(`${cookie}=${token}; Max-Age=172800; Path=/; SameSite=Lax; Secure`)};location.reload();`;
56
94
  return `<!doctype html><html><head><meta charset="utf-8"><meta name="robots" content="noindex"><title>One moment</title></head><body>
57
- <script>document.cookie=${JSON.stringify(`${cookie}=${token}; Max-Age=172800; Path=/; SameSite=Lax; Secure`)};location.reload();</script>
95
+ ${work ? '<p>Checking your browser. This takes about a second, once a day.</p>\n' : ''}<script>${script}</script>
58
96
  <noscript><p>This page needs JavaScript. Automated access is sold by the day: <a href="${buyUrl}">${buyUrl}</a></p></noscript>
59
97
  </body></html>`;
60
98
  }
@@ -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
+ }