diff --git a/index.d.ts b/index.d.ts index 72e7765..8e3eeac 100644 --- a/index.d.ts +++ b/index.d.ts @@ -72,6 +72,7 @@ export interface GatewayOptions { secret?: string; page?: (ctx: PageContext) => string; contact?: string; + /** 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. */ onSale?: (sale: Sale) => void | Promise; fetch?: typeof fetch; } diff --git a/package.json b/package.json index 989663f..1a322ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/x402-gateway", - "version": "0.3.0", + "version": "0.3.1", "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 b41e801..6c8864e 100644 --- a/src/index.js +++ b/src/index.js @@ -56,7 +56,7 @@ export { buildOffer, decodePayment, expectedFor, METHODS, verifyAndSettle } from * @param {string} [options.secret] pass signing secret; defaults to the CoinPay key * @param {(ctx: object) => string} [options.page] custom sales page renderer * @param {string} [options.contact] mailto: or URL for bulk deals - * @param {(sale: object) => void|Promise} [options.onSale] accounting hook, never awaited for the answer + * @param {(sale: object) => void|Promise} [options.onSale] accounting hook; awaited before the receipt goes out, and its errors are swallowed * @param {typeof fetch} [options.fetch] for tests */ export function createGateway(options = {}) { diff --git a/test/gateway.test.js b/test/gateway.test.js index 938011d..b03c4ab 100644 --- a/test/gateway.test.js +++ b/test/gateway.test.js @@ -211,6 +211,37 @@ describe('the gate', () => { assert.match(page.headers.get('content-type'), /text\/html/); }); + it('waits for onSale before answering, so a sale can be recorded', async () => { + /* + * The buyer's receipt must not go out before the sale is written down. + * A hook that is not waited for is a hook that loses the sale whenever the + * runtime stops the moment the response is sent, which is exactly what an + * edge function does. The JSDoc used to promise the opposite. + */ + const order = []; + const { gateway } = gatewayFor({ + onSale: async (sale) => { + await new Promise((r) => setTimeout(r, 30)); + order.push(`recorded:${sale.payer}`); + }, + }); + const res = await gateway.handle(req('/events/1', { headers: { 'x-payment': proof({ nonce: '0xslow' }) } })); + order.push('answered'); + assert.equal(res.status, 200); + assert.deepEqual(order, ['recorded:0xPAYER', 'answered']); + }); + + it('a failing onSale still sells the pass', async () => { + const { gateway } = gatewayFor({ + onSale: async () => { + throw new Error('the database is down'); + }, + }); + const res = await gateway.handle(req('/events/1', { headers: { 'x-payment': proof({ nonce: '0xboom' }) } })); + assert.equal(res.status, 200, 'accounting must never cost a buyer the pass it paid for'); + assert.match((await res.json()).pass, /^cp_/); + }); + it('verifies, settles, mints a pass, and then honours it', async () => { const sales = []; const { gateway, cp } = gatewayFor({ onSale: (s) => sales.push(s) });