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
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"dependencies": {
"@profullstack/player": "^0.3.1",
"@profullstack/rssamplifier": "workspace:*",
"@profullstack/x402-gateway": "0.4.0",
"@profullstack/x402-gateway": "^0.6.0",
"@rssamplifier/auth": "workspace:*",
"@rssamplifier/db": "workspace:*",
"@rssamplifier/feed": "workspace:*",
Expand Down
38 changes: 31 additions & 7 deletions apps/web/src/proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { NextResponse } from 'next/server';

import { SIGNED_IN_HINT_COOKIE, hintToRestore } from './lib/session-hint.js';
import { challenge } from './lib/challenge.js';
import { gate, hasValidPass } from './lib/crawl-gateway.js';
import { gate, gateway, hasValidPass } from './lib/crawl-gateway.js';
import { attempt, callerIdentity } from './lib/crawlThrottle.js';
import { countRequest } from './lib/trafficCounter.js';
import { TIERS, tierFor } from './lib/tiers.js';
Expand Down Expand Up @@ -126,11 +126,26 @@ export async function proxy(request) {
/**
* The refusal.
*
* Says which rung the caller is on and what the next one costs, because a 429
* that only says "slow down" leaves a caller with nothing to do but retry —
* and the whole point of a ladder is that there is somewhere to go. The upgrade
* path is spelled out rather than linked alone: an agent reading this is
* exactly the reader who can act on it without a human.
* Says which rung the caller is on and what the next one costs, because a
* refusal that only says "slow down" leaves a caller with nothing to do but
* retry — and the whole point of a ladder is that there is somewhere to go. The
* upgrade path is spelled out rather than linked alone: an agent reading this
* is exactly the reader who can act on it without a human.
*
* ## Why this is a 402 and not a 429
*
* It said the price in prose and then made the reader go and fetch /crawl to
* find out what to sign. An x402 client cannot act on prose. Carrying the
* offer here — the same one /crawl serves, read straight off the gateway so
* the two can never quote different numbers — means a program that hits the
* wall can pay and carry on inside the same exchange, which is the difference
* between a price and a sign about a price. Tripping the anonymous rung takes
* 120 requests in a minute against a steady reader's thirty, so the caller
* being answered here is a machine.
*
* The paid rungs still get 429. A caller at the sponsor ceiling has already
* bought everything there is, and answering it 402 would be asking it to pay
* twice for nothing.
*
* @param {{ retryAfter: number }} verdict
* @param {{ name: string, burst: number, hourly: number }} tier
Expand Down Expand Up @@ -167,8 +182,17 @@ function tooMany(verdict, tier) {
how: 'Fetch https://rssamplifier.com/crawl with Accept: application/json for an x402 offer, pay it, then send the pass in the x-crawl-pass header. No account and no human needed.',
};

/*
* The offer itself, for the rungs that can still buy one. Read off the
* gateway rather than rebuilt here: payTo, the price, the currency and the
* network live in one place, and a second copy is how a site ends up quoting
* a number its own /crawl disagrees with.
*/
const offer = buyable ? gateway.offer() : null;

return NextResponse.json(
{
...(offer ?? {}),
error: 'rate limit exceeded',
// Said plainly, because the alternative is that they guess and retry. The
// directory is still open to them; this is a speed limit, not a door.
Expand All @@ -180,7 +204,7 @@ function tooMany(verdict, tier) {
retryAfter: verdict.retryAfter,
},
{
status: 429,
status: offer ? 402 : 429,
headers: {
'retry-after': String(verdict.retryAfter),
'cache-control': 'no-store',
Expand Down
56 changes: 56 additions & 0 deletions apps/web/test/proxy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,59 @@ test('the masthead repair still skips what has no masthead to fix', () => {
assert.ok(!wants.test('/robots.txt'));
assert.ok(!wants.test('/topics/physics.rss'), 'a feed is subscribed to, not read in a browser');
});

/**
* The refusal is a 402 carrying the offer, not a 429 describing one.
*
* It used to say the price in prose and send the reader off to /crawl to find
* out what to sign. An x402 client cannot act on prose, so a program that hit
* the wall had the same two options it started with: slow down, or spread
* itself over a proxy pool. Carrying the offer means it can pay and carry on
* inside the same exchange.
*
* Read off the source for the same reason the matcher is: importing proxy.js
* under node:test fails on a bare `next/server` specifier, which is why none
* of the tests above drive the proxy either.
*/
test('the refusal carries an offer a program can pay', () => {
const source = proxySource();

assert.match(
source,
/const offer = buyable \? gateway\.offer\(\) : null;/,
'the offer is read off the gateway, so /crawl and this can never quote different numbers',
);
assert.match(
source,
/status: offer \? 402 : 429/,
'a rung that can still buy something is answered 402; one that cannot stays 429',
);
assert.ok(
source.includes('...(offer ?? {}),'),
'and the accepts array is spread into the body',
);
assert.ok(
!/\bstatus: 429\b/.test(source),
'no refusal is left answering a flat 429 with a price only a person can read',
);
});

test('the offer the refusal carries is payable', async () => {
// The half that cannot be read off the source: that gateway.offer() is a
// real x402 offer rather than an empty accepts list. Without a CoinPay key
// and a payTo it is empty by design, and the refusal falls back to 429.
const { gateway } = await import('../src/lib/crawl-gateway.js');
const offer = gateway.offer();

assert.equal(offer.x402Version, 2, 'an x402 v2 offer');
assert.ok(Array.isArray(offer.accepts), 'with an accepts array');

if (gateway.enabled) {
assert.ok(offer.accepts.length > 0, 'naming at least one network to pay on');
for (const entry of offer.accepts) {
assert.equal(entry.scheme, 'exact');
assert.ok(entry.payTo, 'and where the money goes');
assert.ok(BigInt(entry.amount) > 0n, 'and how much');
}
}
});
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ allowBuilds:
msgpackr-extract: false
minimumReleaseAgeExclude:
- '@profullstack/player@0.2.0 || 0.3.1'
- '@profullstack/x402-gateway@0.1.0 || 0.2.1 || 0.3.0 || 0.4.0'
- '@profullstack/x402-gateway@0.1.0 || 0.2.1 || 0.3.0 || 0.4.0 || 0.6.0'
- '@profullstack/x402-client@0.2.0'
- '@profullstack/leaderboard@0.3.0 || 0.3.1'
- '@profullstack/partners@0.2.0'
Loading