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
12 changes: 7 additions & 5 deletions bun.lock

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

2 changes: 1 addition & 1 deletion packages/feed/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"dependencies": {
"@mozilla/readability": "^0.6.0",
"@profullstack/submit-feed": "^0.1.3",
"@profullstack/x402-client": "0.3.0",
"@profullstack/x402-client": "0.4.0",
"fast-xml-parser": "^5.11.2",
"linkedom": "^0.18.14"
}
Expand Down
44 changes: 31 additions & 13 deletions packages/feed/src/paid.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,42 @@
/**
* The fetch the crawler reaches other people's sites with — paying when asked.
*
* A site behind an x402 gateway answers a crawler with 402 and an offer. With
* `X402_PRIVATE_KEY` set, the client signs the offer with the shared crawler
* wallet, buys the pass, files it by origin and presents it on every later
* request to that site, so a gated site costs a dollar a day rather than
* being silently empty. Without the key this is the global fetch, unchanged.
* A site behind an x402 gateway answers a crawler with 402 and an offer. The
* shared crawler wallet pays it, buys the pass, files it by origin and
* presents it on every later request to that site, so a gated site costs a
* dollar a day rather than being silently empty.
*
* The ceiling is five dollars a payment: a hostile or misconfigured offer
* cannot drain the wallet by asking. Read through a non-literal accessor
* because Next inlines `process.env.NAME` at build time, and this module is
* shared by the poller and the web reader.
* The key is sealed (fleet operator rule, 2026-10-28): `X402_SEALED_KEY` is
* ciphertext, and the process pays only while an operator has unlocked it
* with `x402 vault unlock http://127.0.0.1:<X402_UNLOCK_PORT>` (7 days by
* default). While locked, or after a restart, payments pause: this is the
* plain fetch and one `[alert]` line is logged. The plaintext
* `X402_PRIVATE_KEY` still works only while no sealed key is set and
* `X402_LEGACY_PLAINTEXT_KEY` is not "off" (the migration window).
*
* The ceiling is five dollars a payment. Env is read through a non-literal
* accessor because Next inlines `process.env.NAME` at build time, and this
* module is shared by the poller and the web reader.
*/

import { createClient } from '@profullstack/x402-client';
import { payerFromEnv } from '@profullstack/x402-client/sealed';

const key = globalThis.process?.env?.[['X402', 'PRIVATE_KEY'].join('_')];
const env = globalThis.process?.env ?? {};
const read = (...parts) => env[parts.join('_')];
const SITE = read('X402', 'SITE') || 'rssamplifier.com';

/** The paying client, or null when no key is configured. */
export const x402 = key ? createClient({ key, maxUsd: 5 }) : null;
/** The payer (sealed or, during migration, plaintext), or null when none is configured. */
export const x402 = payerFromEnv({
site: SITE,
env: {
X402_SEALED_KEY: read('X402', 'SEALED', 'KEY'),
X402_UNLOCK_PORT: read('X402', 'UNLOCK', 'PORT'),
X402_UNLOCK_HOST: read('X402', 'UNLOCK', 'HOST'),
X402_PRIVATE_KEY: read('X402', 'PRIVATE', 'KEY'),
X402_LEGACY_PLAINTEXT_KEY: read('X402', 'LEGACY', 'PLAINTEXT', 'KEY'),
},
clientOptions: { maxUsd: 5 },
});

/** @type {typeof fetch} */
export const paidFetch = x402 ? (input, init) => x402.fetch(input, init) : (input, init) => globalThis.fetch(input, init);
32 changes: 31 additions & 1 deletion packages/feed/test/paid.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { test } from 'node:test';

import { paidFetch, x402 } from '../src/paid.js';

test('without X402_PRIVATE_KEY the crawler fetch is the plain fetch', async () => {
test('without any x402 key the crawler fetch is the plain fetch', async () => {
// The test process has no key; a process with one gets the paying client.
assert.equal(x402, null);

Expand All @@ -21,3 +21,33 @@ test('without X402_PRIVATE_KEY the crawler fetch is the plain fetch', async () =
await new Promise((resolve) => server.close(resolve));
}
});

test('a sealed key starts locked: no payment, the 402 comes back, one [alert]', async () => {
const { sealPayerKey } = await import('@profullstack/x402-client/sealed');
const { sealed } = await sealPayerKey(`0x${'0'.repeat(63)}1`, 'a long test passphrase');
const lines = [];
const orig = console.error;
console.error = (l) => lines.push(String(l));
const keep = { ...process.env };
process.env.X402_SEALED_KEY = sealed;
process.env.X402_PRIVATE_KEY = `0x${'0'.repeat(63)}2`; // must be ignored while sealed
process.env.X402_SITE = 'paid.test';
const server = createServer((req, res) => {
res.writeHead(402, { 'content-type': 'application/json' });
res.end(JSON.stringify({ x402Version: 2, accepts: [] }));
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
try {
const mod = await import(`../src/paid.js?sealed=${Date.now()}`);
assert.equal(mod.x402.kind, 'sealed');
assert.equal(mod.x402.payer.locked, true);
const res = await mod.paidFetch(`http://127.0.0.1:${server.address().port}/`);
assert.equal(res.status, 402);
await mod.paidFetch(`http://127.0.0.1:${server.address().port}/again`);
assert.equal(lines.filter((l) => l.startsWith('[alert] paid.test: operator keys locked')).length, 1);
} finally {
console.error = orig;
process.env = keep;
await new Promise((resolve) => server.close(resolve));
}
});
Loading