diff --git a/apps/web/package.json b/apps/web/package.json index 45f80a7..549e4c0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -28,7 +28,7 @@ "@profullstack/openprofile": "^0.2.0", "@profullstack/partners": "0.2.0", "@profullstack/throttle": "^0.3.3", - "@profullstack/x402-gateway": "^0.11.0", + "@profullstack/x402-gateway": "0.12.0", "@simplewebauthn/browser": "^13.2.0", "hono": "^4.10.3" } diff --git a/apps/web/src/lib/api-meter.js b/apps/web/src/lib/api-meter.js new file mode 100644 index 0000000..a72faa5 --- /dev/null +++ b/apps/web/src/lib/api-meter.js @@ -0,0 +1,72 @@ +import { createHash } from 'node:crypto'; +import { config } from '@nichedb/config'; +import * as q from '@nichedb/db/queries'; +import { callerAddress } from './auth-throttle.js'; +import { apiLimitFor, planOf } from './premium.js'; +import { gatewayFor } from './pricing.js'; + +const site = () => config.siteUrl; +const HOUR = 3600; + +/** + * Per-hour metering for the machine surfaces (/api/v1 and MCP): by key when + * there is one, by crawl pass when there is one, by address otherwise. + * + * WHY THE PASS COUNTS HERE. x402 is how an agent pays for this data, and an + * agent reads it through the API and MCP far more than through pages. Until + * 2026-10-21 a paid pass got through the gateway and then met this meter as a + * stranger: 600 an hour per address, then a 429 that offered a membership in + * prose. Paying bought nothing on the surfaces an agent uses, and running out + * offered nothing an x402 client could pay. Now a pass gets the premium + * allowance, and a stranger who runs out gets a 402 carrying the same x402 + * offer as every other refusal on the site (and an X-PAYMENT/PAYMENT-SIGNATURE + * sent with the request settles on the spot). + */ +export async function apiMeter(c, next) { + const user = c.get('user'); + const viaKey = c.get('viaKey'); + const plan = planOf(c); + const { gateway } = await gatewayFor(c.req.raw); + const pass = viaKey ? null : gateway.passFrom(c.req.raw); + const paid = pass ? await gateway.verifyPass(pass) : false; + const bucket = viaKey + ? `key:${user.api_key_id}` + : paid + ? `pass:${createHash('sha256').update(pass).digest('hex').slice(0, 32)}` + : `ip:${callerAddress(c) ?? 'unknown'}`; + const limit = viaKey + ? apiLimitFor(plan) + : paid + ? config.api.premiumPerHour + : config.api.anonPerHour; + const used = await q.bumpApiUsage(bucket); + c.header('x-ratelimit-limit', String(limit)); + c.header('x-ratelimit-remaining', String(Math.max(0, limit - used))); + c.header('x-plan', paid ? 'crawl-pass' : plan); + if (used <= limit) return next(); + + if (viaKey || paid) { + c.header('retry-after', String(HOUR)); + return c.json( + { + error: `Rate limit of ${limit}/hour reached for this ${viaKey ? 'key' : 'pass'}.`, + plan, + ...(plan === 'free' && viaKey + ? { + premium: { + requests_per_hour: config.api.premiumPerHour, + price: `$${(config.premium.dayCents / 100).toFixed(2)} a day`, + url: `${site()}/premium`, + }, + } + : {}), + }, + 429, + ); + } + const resetSeconds = HOUR - (Math.floor(Date.now() / 1000) % HOUR); + return gateway.sell(c.req.raw, { + usage: { count: used, remaining: 0, resetSeconds, overLimit: true }, + quota: { requests: limit, windowSeconds: HOUR }, + }); +} diff --git a/apps/web/src/lib/pricing.js b/apps/web/src/lib/pricing.js index 92c50cf..76f59ab 100644 --- a/apps/web/src/lib/pricing.js +++ b/apps/web/src/lib/pricing.js @@ -146,7 +146,15 @@ export function gatewayOptions(priceCents) { * the plain pages stay a normal page for every reader and link preview. * Item pages are challenged whole (Anthony 2026-10-19: "do it"). */ + /* + * 2026-10-21: the slice of the swarm driving real headless Chrome ran the + * one-line script and walked in (402, same address same second, 200, never + * seen again). `work` makes the script solve a proof of work first: + * ~0.5 s on a desktop once a day for a person, the same on EVERY page for + * a swarm that changes address each page. + */ challenge: { + work: 17, paths: ['/i/', '/feeds/', '/tlds', '/tlds/'], when: (_request, url) => url.pathname.startsWith('/i/') || url.search.length > 1, }, diff --git a/apps/web/src/routes/api.js b/apps/web/src/routes/api.js index bf8b15a..3509aa4 100644 --- a/apps/web/src/routes/api.js +++ b/apps/web/src/routes/api.js @@ -6,9 +6,9 @@ import * as q from '@nichedb/db/queries'; import { enqueueRun } from '@nichedb/queue'; import { COMMANDS } from '@profullstack/nichedb'; import { mintPass } from '@profullstack/x402-gateway'; -import { callerAddress } from '../lib/auth-throttle.js'; +import { apiMeter } from '../lib/api-meter.js'; import { isProUser, render, requireUser } from '../lib/http.js'; -import { apiLimitFor, planOf } from '../lib/premium.js'; +import { planOf } from '../lib/premium.js'; import { SCANNER_CONTEXT_NOTE, scannerContextOptions } from '../lib/scanner-context.js'; import { allowedEnrichers, collectionOut, feedOut, itemOut, sourceOut } from '../lib/serialize.js'; import { @@ -24,44 +24,6 @@ import { ApiDocs, CliDocs } from '../views/admin.jsx'; const lim = (v, d, max) => Math.min(Math.max(1, Number(v) || d), max); const site = () => config.siteUrl; -/** - * Per-hour metering: by key when there is one, by address otherwise, and the - * allowance is the plan's. A 429 says what the next tier up would have given - * them, because the moment somebody hits a limit is the moment the price of - * not hitting it is worth knowing. - */ -async function rateLimit(c, next) { - const user = c.get('user'); - const viaKey = c.get('viaKey'); - const plan = planOf(c); - const bucket = viaKey ? `key:${user.api_key_id}` : `ip:${callerAddress(c) ?? 'unknown'}`; - const limit = viaKey ? apiLimitFor(plan) : config.api.anonPerHour; - const used = await q.bumpApiUsage(bucket); - c.header('x-ratelimit-limit', String(limit)); - c.header('x-ratelimit-remaining', String(Math.max(0, limit - used))); - c.header('x-plan', plan); - if (used > limit) { - c.header('retry-after', '3600'); - return c.json( - { - error: `Rate limit of ${limit}/hour reached for this ${viaKey ? 'key' : 'address'}.`, - plan, - ...(plan === 'free' - ? { - premium: { - requests_per_hour: config.api.premiumPerHour, - price: `$${(config.premium.dayCents / 100).toFixed(2)} a day`, - url: `${site()}/premium`, - }, - } - : {}), - }, - 429, - ); - } - await next(); -} - async function collectionOrNull(slug) { if (!slug) return null; const col = await q.getCollection(slug); @@ -77,7 +39,7 @@ export function registerApi(app) { c.html(await render()), ); - app.use('/api/v1/*', rateLimit); + app.use('/api/v1/*', apiMeter); app.use('/api/v1/*', async (c, next) => { c.header('access-control-allow-origin', '*'); c.header('access-control-allow-headers', 'content-type, authorization'); diff --git a/apps/web/src/routes/mcp.js b/apps/web/src/routes/mcp.js index e748d58..481724f 100644 --- a/apps/web/src/routes/mcp.js +++ b/apps/web/src/routes/mcp.js @@ -1,4 +1,5 @@ import { config } from '@nichedb/config'; +import { apiMeter } from '../lib/api-meter.js'; import { render } from '../lib/http.js'; import { ERRORS, fail } from '../lib/mcp/protocol.js'; import { handle } from '../lib/mcp/server.js'; @@ -33,7 +34,9 @@ const KEY_TOOLS = new Set([ export function registerMcp(app) { for (const path of ['/mcp', '/api/mcp']) { - app.post(path, async (c) => { + // The same hourly meter as /api/v1: a pass is honoured, a stranger who + // runs out is offered x402. MCP had no meter of its own before 2026-10-21. + app.post(path, apiMeter, async (c) => { const header = (name) => c.req.header(name) ?? null; const ctx = { header, user: c.get('user') }; let payload; diff --git a/bun.lock b/bun.lock index fe32cb4..302f6eb 100644 --- a/bun.lock +++ b/bun.lock @@ -41,7 +41,7 @@ "@profullstack/openprofile": "^0.2.0", "@profullstack/partners": "0.2.0", "@profullstack/throttle": "^0.3.3", - "@profullstack/x402-gateway": "^0.11.0", + "@profullstack/x402-gateway": "0.12.0", "@simplewebauthn/browser": "^13.2.0", "hono": "^4.10.3", }, @@ -351,7 +351,7 @@ "@profullstack/throttle": ["@profullstack/throttle@0.3.3", "", { "dependencies": { "@profullstack/x402-gateway": "^0.6.0 || ^0.7.0 || ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0" } }, "sha512-GigeMGXS/1T2UseW0gYvFbxgMuo89/bhW92xMPv/IKAR860mhCdo2G8U4Schciw27seDXVGNErWpTSUC2zGoiA=="], - "@profullstack/x402-gateway": ["@profullstack/x402-gateway@0.11.0", "", { "dependencies": { "@profullstack/keys": "^0.1.0" } }, "sha512-Lfrz+SRTwQZfcoCaVtaaMNPJx74Lp/5oyS7vJJAEuJmH1TjBzhE7dWquISkj1p42oc+VW5I9xH9p7YhEKqmr4Q=="], + "@profullstack/x402-gateway": ["@profullstack/x402-gateway@0.12.0", "", { "dependencies": { "@profullstack/keys": "^0.1.0" } }, "sha512-14rsLsJH/0eSebZ+GxRkWqdFV4t8i/7EsG/vfwcBRRv5Gpq8Nbepkbqc0z/wIeyUqhO1K+0z45N7y1TmnVDFSg=="], "@redis/bloom": ["@redis/bloom@1.2.0", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg=="], @@ -597,6 +597,8 @@ "@inquirer/select/@inquirer/type": ["@inquirer/type@4.1.1", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-yJoHYrMnxIsJZCY+0Vb66Dy3he3kL3e2wOBKhoSwWWAzZAY82emlxwgprCtp6yRixvNRNq9ztfRWQYPNr3Go7A=="], + "@profullstack/throttle/@profullstack/x402-gateway": ["@profullstack/x402-gateway@0.11.0", "", { "dependencies": { "@profullstack/keys": "^0.1.0" } }, "sha512-Lfrz+SRTwQZfcoCaVtaaMNPJx74Lp/5oyS7vJJAEuJmH1TjBzhE7dWquISkj1p42oc+VW5I9xH9p7YhEKqmr4Q=="], + "@redis/client/cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], "@types/mute-stream/@types/node": ["@types/node@26.4.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA=="],