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, []); + }); +});