Skip to content

onSale is awaited: say so, and prove it - #5

Merged
ralyodio merged 1 commit into
mainfrom
onsale-is-awaited
Sep 6, 2026
Merged

ralyodio merged 1 commit into
mainfrom
onsale-is-awaited

Conversation

@ralyodio

@ralyodio ralyodio commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

The onSale hook has always been awaited before the buyer's receipt is sent, inside a try/catch. The JSDoc promised the opposite:

- @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

That is a dangerous thing to get backwards. Anyone reading the old comment would write a fire-and-forget hook, and in the runtime where this gateway most often sits, that silently loses sales: an edge function can stop the moment the response is sent, so an un-awaited database write never lands. The sale is gone and the buyer still has the pass.

Two tests now hold the real behaviour down:

  • a slow onSale finishes before the 200 receipt is returned
  • a throwing onSale still sells the pass, because accounting must never cost a buyer what it paid for

No behaviour change, so this is a patch. Found while wiring the sales ledger on tipoffwatch and genrewatch, which record a sale in this hook.

🤖 Generated with Claude Code

https://claude.ai/code/session_0144uEVbZK3jdaQkwcLYTXPE

The hook has always been awaited before the receipt goes out, inside a
try/catch, but the JSDoc promised the opposite. That matters where it is
most likely to be relied on: an edge runtime can stop the moment a response
is sent, so 'fire and forget' there means the sale is silently lost. Two
tests now hold the behaviour down, including that a throwing hook still
sells the pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0144uEVbZK3jdaQkwcLYTXPE
ralyodio added a commit to profullstack/tipoffwatch.com that referenced this pull request Sep 6, 2026
The gateway awaits onSale before sending the buyer's receipt (its JSDoc said
otherwise; profullstack/x402-gateway#5 fixes the comment and tests the
behaviour). Dropping the promise made the write fire and forget, which is
fine while a long-lived server keeps running and silently loses the sale
anywhere the runtime can stop once the response is sent.

Returning it makes the sale land first. A rejection is still swallowed by the
gateway, so a database failure never costs a buyer the pass it paid for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0144uEVbZK3jdaQkwcLYTXPE
ralyodio added a commit to profullstack/genrewatch.com that referenced this pull request Sep 6, 2026
The gateway awaits onSale before sending the buyer's receipt (its JSDoc said
otherwise; profullstack/x402-gateway#5 fixes the comment and tests the
behaviour). Dropping the promise made the write fire and forget, which is
fine while a long-lived server keeps running and silently loses the sale
anywhere the runtime can stop once the response is sent.

Returning it makes the sale land first. A rejection is still swallowed by the
gateway, so a database failure never costs a buyer the pass it paid for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0144uEVbZK3jdaQkwcLYTXPE
@ralyodio
ralyodio merged commit ab54efd into main Sep 6, 2026
6 checks passed
@ralyodio
ralyodio deleted the onsale-is-awaited branch September 6, 2026 03:10
ralyodio added a commit to profullstack/tipoffwatch.com that referenced this pull request Sep 6, 2026
* Write down what the crawler paywall does, and put it on a public board

The gateway has been answering 402 and selling day passes for weeks and none
of it was recorded: a sale existed only for as long as the response took to
send. So 'what did the paywall earn' and 'which agent is the customer' had no
answer. Two tables now hold it. A sale is money (crawl_sales, unique on the
payment ref so a settlement delivered twice books once). A refusal is demand
(crawl_demand, counted per agent per day rather than stored per request, so
thousands a day stay small enough to keep forever).

Both writes are fire and forget. Bookkeeping must not fail a payment the
buyer already made, or turn a 402 into a 500.

The board shows the two sides apart: agents paying ranked by money, agents
not paying ranked by volume. The same agent can sit on both, and the numbers
are never pooled, because 'paid us $1' and 'asked 5,000 times for free' are
not the same fact. No commission rate anywhere, since nobody earns here.

/leaderboard is in openPaths with and without the trailing slash: the gateway
prefix-matches only entries ending in a slash, so the bare path would open the
index and still charge for every board on it.

Verified against a throwaway Postgres: all 34 migrations apply, a duplicate
ref books once, five refusals make one row of five, a live 402 from ClaudeBot
increments crawl_demand, and GPTBot reads the board at 200 while / stays 402.
A DATE column bound as a JS Date 500s the whole board, so the day is sent as
a day.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0144uEVbZK3jdaQkwcLYTXPE

* Return the sale promise, so the write lands before the receipt

The gateway awaits onSale before sending the buyer's receipt (its JSDoc said
otherwise; profullstack/x402-gateway#5 fixes the comment and tests the
behaviour). Dropping the promise made the write fire and forget, which is
fine while a long-lived server keeps running and silently loses the sale
anywhere the runtime can stop once the response is sent.

Returning it makes the sale land first. A rejection is still swallowed by the
gateway, so a database failure never costs a buyer the pass it paid for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0144uEVbZK3jdaQkwcLYTXPE

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ralyodio added a commit to profullstack/genrewatch.com that referenced this pull request Sep 6, 2026
* Write down what the crawler paywall does, and put it on a public board

Ported from the tipoffwatch change. The gateway has been answering 402 and
selling day passes without recording any of it, so neither the revenue nor
the customer survived the response.

Two tables: crawl_sales (money, unique on the payment ref so a settlement
delivered twice books once) and crawl_demand (refusals, counted per agent per
day rather than stored per request). Both writes are fire and forget, because
bookkeeping must not fail a payment or turn a 402 into a 500.

The board shows the sides apart: agents paying ranked by money, agents not
paying ranked by volume. The same agent can sit on both and the numbers are
never pooled.

This gateway had no openPaths at all, so the board needed them added, with
and without the trailing slash.

Verified against a throwaway Postgres: 19 migrations apply, a duplicate ref
books once, five refusals make one row of five, a live 402 from CCBot
increments crawl_demand, GPTBot reads the board at 200 and still gets 402
on /.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0144uEVbZK3jdaQkwcLYTXPE

* Return the sale promise, so the write lands before the receipt

The gateway awaits onSale before sending the buyer's receipt (its JSDoc said
otherwise; profullstack/x402-gateway#5 fixes the comment and tests the
behaviour). Dropping the promise made the write fire and forget, which is
fine while a long-lived server keeps running and silently loses the sale
anywhere the runtime can stop once the response is sent.

Returning it makes the sale land first. A rejection is still swallowed by the
gateway, so a database failure never costs a buyer the pass it paid for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0144uEVbZK3jdaQkwcLYTXPE

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ralyodio added a commit to profullstack/rssamplifier.com that referenced this pull request Sep 6, 2026
traffic_hourly has counted who asks and how many we turn away since
2026-09-02. The half it could never answer is the half with money in it: the
gateway has been selling day passes and writing none of it down, so a sale
existed only for as long as the response took to send.

crawl_sales is that half. One row per sale, unique on the payment ref so a
settlement delivered twice books once. The hook returns its promise rather
than dropping it, because the gateway awaits onSale before the receipt goes
out (its JSDoc said otherwise; profullstack/x402-gateway#5), and a rejection
is swallowed there, so a database failure still sells the pass it was paid
for.

The board projects both tables instead of keeping a third copy that could
disagree with either. Only badges get a table, being awarded rather than
derived. Sales are tagged with classifyAgent, so a family reads the same on
both sides.

The two sides never share a list. Agents paying are ranked by money; agents
asking are ranked by requests and by refusals. 'Paid us $3' and 'asked
209,000 times' are not the same fact.

/leaderboard is in OPEN_PATHS with and without the trailing slash: the
gateway prefix-matches only entries ending in a slash, so the bare path would
open the index and still charge for every board on it.

Verified against a local libSQL file: the migration applies, a duplicate ref
books once, a sale with no payer still lands, the route handler serves both
sides off real traffic_hourly rows, RSS names the side, a share card renders
and an unknown board 404s. The existing 382 web tests still pass.


Claude-Session: https://claude.ai/code/session_0144uEVbZK3jdaQkwcLYTXPE

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant