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 @@ -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"
}
Expand Down
72 changes: 72 additions & 0 deletions apps/web/src/lib/api-meter.js
Original file line number Diff line number Diff line change
@@ -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 },
});
}
8 changes: 8 additions & 0 deletions apps/web/src/lib/pricing.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
44 changes: 3 additions & 41 deletions apps/web/src/routes/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
Expand All @@ -77,7 +39,7 @@ export function registerApi(app) {
c.html(await render(<CliDocs user={c.get('user')} commands={COMMANDS} />)),
);

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');
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/routes/mcp.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 4 additions & 2 deletions bun.lock

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

Loading