Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
fetch?: typeof fetch;
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down
2 changes: 1 addition & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>} [options.onSale] accounting hook, never awaited for the answer
* @param {(sale: object) => void|Promise<void>} [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 = {}) {
Expand Down
31 changes: 31 additions & 0 deletions test/gateway.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) });
Expand Down
Loading