From 3a5108a5102f30b5c89818b4d98e68dfa469f6a6 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 9 Sep 2026 16:49:18 +0000 Subject: [PATCH] 0.6.0: hand back the offer without answering a request A site with its own refusal -- rssamplifier's tiered limiter, whose 429 body says which rung the caller is on and what the next one costs -- wants to keep that body and add a machine-readable offer to it, so an x402 client can pay on the spot instead of being told in prose to go and fetch /crawl. The alternative is every such site rebuilding payTo, price, currency and network from its own copy of the config, which is how two of them end up quoting different numbers. Both were already computed inside the closure; this only stops them being private. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YDGCxTmEPs3ecwjjLJDQXh --- index.d.ts | 4 ++++ package.json | 2 +- src/index.js | 14 ++++++++++++++ test/quota.test.js | 28 ++++++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/index.d.ts b/index.d.ts index d9a002d..1337e4f 100644 --- a/index.d.ts +++ b/index.d.ts @@ -180,6 +180,10 @@ export interface Gateway { passFrom: (request: Request) => string | null; /** Whether that token is a live pass this gateway minted. */ verifyPass: (token: string | null) => Promise; + /** The x402 offer this gateway would make, without answering a request. */ + offer: (days?: number) => Offer; + /** The full 402 body around that offer: the offer plus the pass terms. */ + receipt: (days?: number, extra?: Record) => Offer & Record; } export function createGateway(options: GatewayOptions): Gateway; diff --git a/package.json b/package.json index a347bb5..ee8cbf5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/x402-gateway", - "version": "0.5.0", + "version": "0.6.0", "type": "module", "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, and a robots.txt that keeps search crawlers welcome.", "keywords": [ diff --git a/src/index.js b/src/index.js index 754d47b..1493327 100644 --- a/src/index.js +++ b/src/index.js @@ -388,6 +388,20 @@ export function createGateway(options = {}) { */ passFrom, verifyPass: async (token) => Boolean(token && (await readPass(token, { secret }))), + /** + * The x402 offer this gateway would make, and the full receipt body around + * it, without answering a request. + * + * A site with its own refusal -- a tiered limiter whose 429 body says which + * rung the caller is on and what the next one costs -- wants to keep that + * body and add a machine-readable offer to it, so an x402 client can pay + * on the spot instead of being told in prose to go and fetch /crawl. The + * alternative is every such site rebuilding payTo, price, currency and + * network from its own copy of the config, which is how two of them end up + * quoting different numbers. + */ + offer, + receipt, /** robots.txt with this gateway's lists and sales path. */ robotsTxt: (extra = {}) => robotsTxt({ siteUrl: o.siteUrl, path: o.path, training: o.training, retrieval: o.retrieval, ...extra }), diff --git a/test/quota.test.js b/test/quota.test.js index 38dcfb2..09d8877 100644 --- a/test/quota.test.js +++ b/test/quota.test.js @@ -341,3 +341,31 @@ describe("verifyPass", () => { assert.equal(gate.passFrom(new Request(`${SITE}/`)), null); }); }); + +describe('offer and receipt, for a site with its own refusal', () => { + it('hand back what the 402 would have carried, without a request', () => { + const gate = gateway(null); + const offer = gate.offer(); + assert.equal(offer.x402Version, 2); + assert.ok(offer.accepts.length > 0); + assert.equal(offer.accepts[0].payTo, PAY_TO); + + const receipt = gate.receipt(); + assert.equal(receipt.pass.price, '1.00 USD'); + assert.equal(receipt.pass.buy, `${SITE}/crawl`); + assert.deepEqual(receipt.accepts, offer.accepts); + }); + + it('quote more than one day', () => { + const gate = gateway(null); + const one = BigInt(gate.offer(1).accepts[0].amount); + const three = BigInt(gate.offer(3).accepts[0].amount); + assert.equal(three, one * 3n); + assert.equal(gate.receipt(3).pass.days, 3); + }); + + it('offer nothing when payments are not switched on', () => { + const unpaid = createGateway({ siteUrl: SITE }); + assert.deepEqual(unpaid.offer().accepts, []); + }); +});