Skip to content

Meter every route, and pay partners what a pass was actually worth - #36

Merged
ralyodio merged 2 commits into
mainfrom
feat/throttle
Sep 9, 2026
Merged

Meter every route, and pay partners what a pass was actually worth#36
ralyodio merged 2 commits into
mainfrom
feat/throttle

Conversation

@ralyodio

@ralyodio ralyodio commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Two things, and the second was found on the way to the first.


🚨 The bug: every crawl pass sold paid partners nothing

apps/web/package.json asked for "@profullstack/x402-gateway": "^0.1.0" and bun.lock resolved 0.1.0. That version's onSale payload is:

payer, ref, token, expiresAt, userAgent, priceCents

No totalCents. No days. Both arrived in 0.3.0 with multi-day passes. lib/pricing.js reads them anyway, and everything downstream treats a missing figure as nothing:

Where What happens
partners.js:95 !Number(undefined) is truesplitSale returns 0; the publishers whose writing was crawled are paid nothing
attribution.js:46-47 Number(undefined) || 0no niche revenue booked, no operator owed a share
queries.js:1006 the crawl_sales row stores a null total_cents

So the seller side of the marketplace has been recording sales it never paid out on, for as long as passes have been sold.

Bumping to ^0.6.0 fixes it going forward. The rows already written are still wrongcrawl_sales wants a look for null total_cents before anyone trusts that ledger.

A caret range on a 0.x version only matches patches, which is why ^0.1.0 sat five minors behind without anything ever saying so.


The throttle

The gate charges crawlers that say who they are. Nothing charged the ones that do not, and nothing counted a page route at all.

That is the shape that failed on coinpayportal on 2026-09-08: a headless browser found a route nobody had listed and walked 19,000 of its URLs a day for two days, declaring nothing, tripping no list.

@profullstack/throttle now meters every route at 100 req/min per caller, answered 402 with this buyer's own offer rather than 429.

Path Limit
/auth/ 10/min, address-bucketed however credentialed
/healthz never metered
/mcp 600/min — a surface for using the data, not copying it
everything else 100/min

One throttle per gateway, sharing one store. The price here is the buyer's own, so the gateway is chosen per request and a refusal has to quote the price that buyer would pay. But if the counting split along with the price, a caller crossing a discount threshold mid-window would be handed a fresh hundred requests for the privilege. Both halves have a test.

Verified

bun test510 passed, 0 failed, across 25 files. Seven new tests in test/throttle.test.js, driven through the site's own gatewayAt() rather than a stand-in, since the per-buyer price is the part worth testing.

The gateway also deduped: ^0.5.0 on the throttle side could not resolve 0.6.0 (caret on 0.x is patch-only), so a nested second copy was being installed until @profullstack/throttle@0.2.2 raised that floor.

🤖 Generated with Claude Code

Two things, and the second one was found on the way to the first.

**The throttle.** The gate charges crawlers that say who they are. Nothing
charged the ones that do not, and nothing counted a page route at all. That
is the shape that failed on coinpayportal on 2026-09-08: a headless browser
found a route nobody had listed and walked 19,000 of its URLs a day for two
days, declaring nothing, tripping no list. So @profullstack/throttle now
meters every route at 100 requests a minute per caller, answered 402 with
this buyer's own offer rather than 429.

The price here is the buyer's own -- a dollar a day at list, less the more it
has spent -- so the gateway is chosen per request, and a refusal has to quote
the price that buyer would actually pay. One throttle per gateway, sharing
ONE store: if the counting split along with the price, a caller crossing a
discount threshold mid-window would be handed a fresh hundred requests for
the privilege. Both halves are tested.

**The bug.** apps/web asked for x402-gateway ^0.1.0 and got 0.1.0, whose
onSale payload has no totalCents and no days; both arrived in 0.3.0 with
multi-day passes. pricing.js reads them anyway, and everything downstream
treats a missing figure as nothing:

  partners.js:95   !Number(undefined) is true, so splitSale returns 0 and
                   the publishers whose writing was crawled are paid nothing
  attribution.js:47 totalCents 0, so no niche revenue is booked and no
                   operator is owed a share
  queries.js:1006  the crawl_sales row stores a null total_cents

So the seller side of the marketplace has been recording sales it never paid
out on, for as long as passes have been sold. Bumping to ^0.6.0 fixes it
going forward; the rows already written are still wrong, and crawl_sales
wants a look for null total_cents before anyone trusts that ledger.

A caret range on a 0.x version only matches patches, which is why ^0.1.0 sat
five minors behind without anything ever saying so.

510 tests pass, seven of them new.

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

socket-security Bot commented Sep 9, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​profullstack/​throttle@​0.2.27410010091100
Updated@​profullstack/​x402-gateway@​0.1.0 ⏵ 0.6.076 +1100100 +194 +7100

View full report

The 402 test passed here and failed in CI. gatewayAt memoises one gateway per
price, and pricing.js builds the list-price one at import -- so when
pricing.test.js loaded first, the cached list-price gateway had been built
before this file set COINPAY_X402_KEY, could not take money, and every
refusal was a 429. Locally the files happened to load the other way round.

Asking for a price nothing else uses means the gateway is always constructed
after the environment is set. Verified in both file orders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDGCxTmEPs3ecwjjLJDQXh
@ralyodio
ralyodio merged commit ef6af3e into main Sep 9, 2026
3 checks passed
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