From e44372132e8d535d437b8e482b0890a45785fc8b Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 28 Oct 2026 21:29:37 +0000 Subject: [PATCH] sec: x402 payer key sealed, operator unlock, payments pause while locked X402_SEALED_KEY (ciphertext) via @profullstack/x402-client 0.4.0 sealed payer; control server on X402_UNLOCK_PORT for `x402 vault unlock`; one [alert] line per lock. Plaintext X402_PRIVATE_KEY only while no sealed key is set and X402_LEGACY_PLAINTEXT_KEY != off. Co-Authored-By: Claude Opus 5.5 --- bun.lock | 12 +++++---- packages/feed/package.json | 2 +- packages/feed/src/paid.js | 44 +++++++++++++++++++++++---------- packages/feed/test/paid.test.js | 32 +++++++++++++++++++++++- 4 files changed, 70 insertions(+), 20 deletions(-) diff --git a/bun.lock b/bun.lock index f07a78c..4160f1e 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,7 @@ }, "apps/cli": { "name": "@profullstack/rssamplifier", - "version": "0.5.0", + "version": "0.6.0", "bin": { "rssamplifier": "./bin/rssamplifier.js", "rssamp": "./bin/rssamplifier.js", @@ -35,7 +35,7 @@ }, "apps/web": { "name": "@rssamplifier/web", - "version": "0.1.8", + "version": "0.2.0", "dependencies": { "@logicsrc/openaccess": "^0.3.1", "@profullstack/footer": "latest", @@ -109,7 +109,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", }, @@ -131,7 +131,7 @@ }, "packages/notify": { "name": "@rssamplifier/notify", - "version": "0.2.1", + "version": "0.3.0", "dependencies": { "@profullstack/notifications": "^0.1.4", "@profullstack/webhooks": "0.2.1", @@ -372,11 +372,13 @@ "@profullstack/scraper-daemon": ["@profullstack/scraper-daemon@0.2.2", "", { "peerDependencies": { "bullmq": ">=6", "ioredis": ">=5" }, "optionalPeers": ["bullmq", "ioredis"], "bin": { "scraper-daemon": "bin/scraper-daemon.js" } }, "sha512-V/+WT7q6VOedkkepYszctkXdfvWGZbmEZMi9Q7nejO35pg8kPVwR0W53GjswdsAXM/AqA+wMCfB5/oQo1Ihifw=="], + "@profullstack/sealed-secrets": ["@profullstack/sealed-secrets@0.1.0", "", {}, "sha512-6LRa+yUT7PQ5tZNoYEnf3VVxsyw/dqHqAtYpk/aS/bntxf3mQH+2MwNjQoBd7fCqdN97TvZup8qhrpfUcZlfng=="], + "@profullstack/submit-feed": ["@profullstack/submit-feed@0.1.3", "", { "dependencies": { "fast-xml-parser": "^5.2.5" } }, "sha512-RMH294Q1qFJ6L01pU5q/yowshx1Xpu4gfGC2OuNaxyZ5jySqM9FvgqvVuWH+G18711SqzNq+DL8dvbAaBgBIvA=="], "@profullstack/webhooks": ["@profullstack/webhooks@0.2.1", "", {}, "sha512-QHeSkpu4O44oJ620qhQqxqFlD3u/OhupmXRnBHw4MGKuDTa0R+Y/727nnF7psSHxxSfnbWfNuVl15z3IXuaZiw=="], - "@profullstack/x402-client": ["@profullstack/x402-client@0.3.0", "", { "dependencies": { "@noble/curves": "^2.4.0", "@noble/hashes": "^2.4.0" }, "bin": { "x402": "bin/x402.js" } }, "sha512-ytKhBrnjXFef2X2vgORw6d25tZfmo1qinxk/1mXlpeVticHP5UxidElFtjdjSt1LcHVlAEQR6m/+p4obmxDzJQ=="], + "@profullstack/x402-client": ["@profullstack/x402-client@0.4.0", "", { "dependencies": { "@noble/curves": "^2.4.0", "@noble/hashes": "^2.4.0", "@profullstack/sealed-secrets": "^0.1.0" }, "bin": { "x402": "bin/x402.js" } }, "sha512-WbPFeey61OH23HZc20/Nr3E2EvA7bhrWMFIqtbzGlsJm0cXOxjM2WIyRDGzi5ssfVQtSwVPvhaUYA66nJOOuhQ=="], "@profullstack/x402-gateway": ["@profullstack/x402-gateway@0.13.1", "", { "dependencies": { "@profullstack/keys": "^0.1.0" } }, "sha512-V7liSO0tShZRlp8dancuDuUSF/rqrr5YF3bzLoZy9wn6ixf1RIRPYTepe72s36JOdSVzi3wfuhaZFm9y0RpV4w=="], diff --git a/packages/feed/package.json b/packages/feed/package.json index d73893f..a560a15 100644 --- a/packages/feed/package.json +++ b/packages/feed/package.json @@ -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" } diff --git a/packages/feed/src/paid.js b/packages/feed/src/paid.js index c18eb9e..921bfac 100644 --- a/packages/feed/src/paid.js +++ b/packages/feed/src/paid.js @@ -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:` (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); diff --git a/packages/feed/test/paid.test.js b/packages/feed/test/paid.test.js index 8f4168a..7e2b8b3 100644 --- a/packages/feed/test/paid.test.js +++ b/packages/feed/test/paid.test.js @@ -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); @@ -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)); + } +});