openvibers/openvibe.tips Commits
Per-actor rate limits on Tips' /api/v1 (roadmap WS-R task 4, openvibe-sdk v0.12.0 limits). server/api/actor-limits.js makes one limiter per app, counted once api/auth.js resolved the caller and before any work (the idempotency store, Billing, chat delivery): a service as its principal (svc:live), a person as user:usr_…, anyone else by address. Past a limit a route answers 429 problem+json rate_limited with Retry-After, logs one line and counts tips_rate_limited_total{limit,window}.
Changes
@@@ -73,6 +73,11 @@ TIPS_EVENTS_SECRET=#TIPS_MEDIA_HOSTS=youtube.com,www.youtube.com,m.youtube.com,music.youtube.com,youtu.be# Unpaid checkouts one supporter may hold within a day (more answer 429)#TIPS_MAX_PENDING_CHECKOUTS=20++# Per-actor limits on /api/v1 (server/api/actor-limits.js): reads one caller (a service principal,++# a person, else an address) may make per minute and per hour; past them 429 rate_limited with++# Retry-After. Payments (20 a minute per person) and other writes have their own numbers (README).++#TIPS_LIMITS_MINUTE=120++#TIPS_LIMITS_HOUR=3000# ── Background jobs: effects worker, due transfers, overlay window, outbox relay ──#TIPS_JOBS=on@@@ -274,6 +274,27 @@ that shows it. The fixes it made:The regressions are in `test/security.test.js`. It is an internal review; an independent one is still duebefore launch.++### Per-actor limits++++`/api/v1` also limits who calls it, once the credential is checked and before any work (the idempotency++store, Billing, chat delivery): `server/api/actor-limits.js`, openvibe-sdk/limits, roadmap WS-R task 4. A++service counts as its principal (`svc:live`), a person as `user:usr_…`, anyone else by address. Past a limit:++`429` problem+json `rate_limited` with `Retry-After`, one log line and `tips_rate_limited_total{limit,window}`.++++| Routes | Per caller |++|---|---|++| Every read | `TIPS_LIMITS_MINUTE` / `TIPS_LIMITS_HOUR` (120 a minute, 3000 an hour) |++| `POST /checkout`, `/paid-messages`, `/tts-requests`, `/media-requests` (one budget) | a person 20 / 200; a service 60 / 1200 |++| `POST /interactions/external` | 60 / 1200 |++| Profile, goal and overlay config changes, token revoke | 30 / 300 |++| `POST /overlay-tokens` | 10 / 60 |++| Hide and restore | 60 / 600 |++| Moderators add and remove; `POST /simulate` | 10 / 100 |++| `POST /me/erase` | 3 / 10 |++++Never limited: `/api/health`, `/api/ready`, `/release.json`, `/metrics`, the overlays (bounded by++`TIPS_OVERLAY_MAX_STREAMS`) and the signed Events deliveries at `/internal/events`. `test/actor-limits.test.js`.++## Import and reconciliation`scripts/import-live.js` reads a **copy** of Live's database (read-only) after Billing's own import, and@@@ -15,7 +15,7 @@"express": "^4.21.2","jsonwebtoken": "^9.0.2","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"},"engines": {@@@ -999,9 +999,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.11.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",-- "integrity": "sha512-sY/0bl5bhixjxLv2ACeL/AKIE2QlnndiDqkNuA5YGo2pC7AMvPqRP05y4OAtuJ7JvWtaBZ9gETB03u07uYp3wQ==",++ "version": "0.12.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",++ "integrity": "sha512-iG1h905O1f7y/PnP8xLA6GCVzXEBDfSaBj2tc1ucNreWg2sT/AJWosb8NW0WRMK5WS4sIQ6tMsprmDLj/fjjeQ==","license": "MIT","engines": {"node": ">=20"@@@ -22,7 +22,7 @@"express": "^4.21.2","jsonwebtoken": "^9.0.2","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"},"engines": {nnew file mode 100644@@@ -0,0 +1,48 @@++'use strict';++++/**++ * Per-actor rate limits at /api/v1 (roadmap WS-R task 4; openvibe-sdk/limits).++ *++ * Counted by who calls, as api/auth.js resolved req.principal: a service by its principal (svc:live), a++ * person by their subject (user:usr_…), anyone else by address. Past a limit the route answers 429++ * problem+json `rate_limited` with Retry-After before it does any work (before the idempotency store,++ * Billing or chat delivery); the refusal is logged once and counted in tips_rate_limited_total{limit,window}.++ * Reads get TIPS_LIMITS_MINUTE / TIPS_LIMITS_HOUR (120 and 3000); writes and payments set their own,++ * tighter numbers in api/v1.js. Counters live in this process: a restart forgets them.++ *++ * Never limited: /api/health, /api/ready, /release.json, /metrics, the overlays (a scoped token each,++ * capped by TIPS_OVERLAY_MAX_STREAMS) and the signed Events deliveries at /internal/events (Billing's++ * settlements: Events pushes at its own pace, and a 429 would only make it retry and fall behind).++ */++const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');++++function actor(req) {++ const p = req.principal;++ if (p && p.kind === 'service' && p.sub) return p.sub;++ if (p && p.kind === 'user' && p.subject) return `user:${p.subject}`;++ return defaultActor(req);++}++++/** limits(name, own) middleware for one app, plus limits.reads(name): the defaults on GET/HEAD. */++function createActorLimits({ config, now = () => Date.now(), registry = null, log = console }) {++ const refused = registry++ ? registry.counter({ name: 'tips_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })++ : null;++ const limiter = createActorLimiter({++ limits: { minute: config.actorLimits.minute, hour: config.actorLimits.hour },++ actor,++ now,++ onLimited(e) {++ // The actor is a principal, a subject id or an address, never a token.++ log.warn(`[Tips] limit ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);++ if (refused) refused.inc({ limit: e.name, window: e.window });++ },++ });++ limiter.reads = (name) => {++ const limit = limiter(name);++ return (req, res, next) => (req.method === 'GET' || req.method === 'HEAD' ? limit(req, res, next) : next());++ };++ return limiter;++}++++module.exports = { createActorLimits, actor };@@@ -64,11 +64,18 @@ const CAP = {moderate: 'tips.interaction.moderate', // in openvibe-contracts since v0.32.0 (was docs/capabilities-proposal/)};--function v1Router({ domain, apiAuth }) {++function v1Router({ domain, apiAuth, limits }) {const r = express.Router();const { db, profiles, goals, interactions, overlays } = domain;const idem = idempotent(db, domain.now);++ // Per-actor limits (api/actor-limits.js), after the credential and before any work: every read takes++ // the defaults; each write below names its own, tighter numbers.++ r.use(limits.reads('tips.read'));++ const edit = limits('tips.settings.update', { minute: 30, hour: 300 }); // profile, goals, overlay configs++ const moderate = limits('tips.interaction.moderate', { minute: 60, hour: 600 });++ const staffing = limits('tips.moderators.update', { minute: 10, hour: 100 });++/** Service with `cap`, or the user who owns `owner` (subject). Anonymous → 401. */function allow(req, cap, owner) {const p = req.principal;@@@ -122,9 +129,11 @@ function v1Router({ domain, apiAuth }) {return req.principal.subject;};r.get('/me/export', wrap((req, res) => res.json(interactions.exportFor(person(req)))));-- r.post('/me/erase', ...write((req, res) => res.json(interactions.erase(person(req)))));++ // A person erasing their own tips does it once; the network's account deletion arrives through++ // /internal/events, which is never limited.++ r.post('/me/erase', limits('tips.me.erase', { minute: 3, hour: 10 }), ...write((req, res) => res.json(interactions.erase(person(req)))));-- r.patch('/profiles/:creator', ...write((req, res) => {++ r.patch('/profiles/:creator', edit, ...write((req, res) => {const b = req.body || {};let p;if (req.params.creator === 'me') {@@@ -191,10 +200,16 @@ function v1Router({ domain, apiAuth }) {});});}-- r.post('/checkout', ...paid('tip', CAP.checkout));-- r.post('/paid-messages', ...paid('paid_message', CAP.superchat));-- r.post('/tts-requests', ...paid('tts', CAP.tts));-- r.post('/media-requests', ...paid('media_request', CAP.media));++ // Every paid request starts a Billing transfer or checkout and may reach the creator's chat: one++ // budget for all four, refusals and retries included. A person sends at most 20 a minute and 200 an++ // hour; a service relaying its supporters' tips gets one a second (60 and 1200).++ const personPays = limits('tips.payment', { minute: 20, hour: 200 });++ const servicePays = limits('tips.payment', { minute: 60, hour: 1200 });++ const pays = (req, res, next) => (req.principal.kind === 'service' ? servicePays : personPays)(req, res, next);++ r.post('/checkout', pays, ...paid('tip', CAP.checkout));++ r.post('/paid-messages', pays, ...paid('paid_message', CAP.superchat));++ r.post('/tts-requests', pays, ...paid('tts', CAP.tts));++ r.post('/media-requests', pays, ...paid('media_request', CAP.media));// ── Interactions ─────────────────────────────────────────r.get('/interactions', wrap((req, res) => {@@@ -229,7 +244,8 @@ function v1Router({ domain, apiAuth }) {res.json({ interaction: interactions.present(i, { viewer }) });}));-- r.post('/interactions/external', ...write((req, res) => {++ // A service importing tips made elsewhere records one per tip, at most one a second.++ r.post('/interactions/external', limits('tips.interaction.record', { minute: 60, hour: 1200 }), ...write((req, res) => {allow(req, CAP.interactionRecord);const b = req.body || {};const profile = creatorOf(req, b.creator);@@@ -259,7 +275,7 @@ function v1Router({ domain, apiAuth }) {if (!goalOwner(req, g.creator_subject)) return res.json({ goal: goals.publicGoal(g, profiles.bySubject(g.creator_subject).page) });res.json({ goal: goals.present(g, { contributions: true }) });}));-- r.post('/goals', ...write((req, res) => {++ r.post('/goals', edit, ...write((req, res) => {const b = req.body || {};const profile = creatorOf(req, b.creator);allow(req, CAP.goalCreate, profile.creator_subject);@@@ -271,8 +287,8 @@ function v1Router({ domain, apiAuth }) {allow(req, cap, g.creator_subject);return g;};-- r.patch('/goals/:id', ...write((req, res) => res.json({ goal: goals.update(ownGoal(req, CAP.goalUpdate), req.body || {}) })));-- r.post('/goals/:id/close', ...write((req, res) => res.json({ goal: goals.close(ownGoal(req, CAP.goalClose)) })));++ r.patch('/goals/:id', edit, ...write((req, res) => res.json({ goal: goals.update(ownGoal(req, CAP.goalUpdate), req.body || {}) })));++ r.post('/goals/:id/close', edit, ...write((req, res) => res.json({ goal: goals.close(ownGoal(req, CAP.goalClose)) })));// ── Overlay tokens and configs ───────────────────────────r.get('/overlay-tokens', wrap((req, res) => {@@@ -280,7 +296,8 @@ function v1Router({ domain, apiAuth }) {allow(req, CAP.tokenCreate, profile.creator_subject);res.json({ tokens: overlays.listTokens(profile.creator_subject).map(overlays.presentToken) });}));-- r.post('/overlay-tokens', ...write((req, res) => {++ // A token opens an overlay's event stream: a creator mints a few for their scenes.++ r.post('/overlay-tokens', limits('tips.overlay.token.create', { minute: 10, hour: 60 }), ...write((req, res) => {const b = req.body || {};const profile = creatorOf(req, b.creator);allow(req, CAP.tokenCreate, profile.creator_subject);@@@ -291,7 +308,7 @@ function v1Router({ domain, apiAuth }) {res.locals.storedBody = { token: out.token, secret: null, overlay_url: null, events_url: null, note: 'the secret is shown once; revoke this token and create another' };res.status(201).json({ token: out.token, secret: out.secret, overlay_url: out.overlay_url, events_url: out.events_url });}));-- r.post('/overlay-tokens/:id/revoke', ...write((req, res) => {++ r.post('/overlay-tokens/:id/revoke', edit, ...write((req, res) => {const t = overlays.getToken(req.params.id);if (!t) fail(404, 'tips.overlay_token_not_found', 'no such overlay token');allow(req, CAP.tokenRevoke, t.creator_subject);@@@ -309,13 +326,13 @@ function v1Router({ domain, apiAuth }) {allow(req, CAP.configGet, c.creator_subject);res.json({ config: overlays.presentConfig(c) });}));-- r.post('/overlay-configs', ...write((req, res) => {++ r.post('/overlay-configs', edit, ...write((req, res) => {const b = req.body || {};const profile = creatorOf(req, b.creator);allow(req, CAP.configUpdate, profile.creator_subject);res.status(201).json({ config: overlays.createConfig(profile.creator_subject, b) });}));-- r.patch('/overlay-configs/:id', ...write((req, res) => {++ r.patch('/overlay-configs/:id', edit, ...write((req, res) => {const c = overlays.getConfig(req.params.id);if (!c) fail(404, 'tips.overlay_config_not_found', 'no such overlay config');allow(req, CAP.configUpdate, c.creator_subject);@@@ -347,7 +364,7 @@ function v1Router({ domain, apiAuth }) {res.json({ log: moderation.log(profile.creator_subject) });}));for (const action of ['hide', 'restore']) {-- r.post(`/interactions/:id/${action}`, ...write((req, res) => {++ r.post(`/interactions/:id/${action}`, moderate, ...write((req, res) => {const i = interactions.get(req.params.id);if (!i) fail(404, 'tips.interaction_not_found', 'no such interaction');const actor = moderatorOf(req, i.creator_subject, { hideExistence: true });@@@ -360,21 +377,22 @@ function v1Router({ domain, apiAuth }) {allow(req, CAP.profileUpdate, profile.creator_subject);res.json({ moderators: moderation.listModerators(profile.creator_subject).map(moderation.presentModerator) });}));-- r.post('/moderators', ...write((req, res) => {++ r.post('/moderators', staffing, ...write((req, res) => {const b = req.body || {};const profile = creatorOf(req, b.creator);allow(req, CAP.profileUpdate, profile.creator_subject);const by = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;res.status(201).json({ moderator: moderation.addModerator(profile.creator_subject, b.moderator, { name: b.name, addedBy: by }) });}));-- r.post('/moderators/:subject/remove', ...write((req, res) => {++ r.post('/moderators/:subject/remove', staffing, ...write((req, res) => {const profile = creatorOf(req, (req.body || {}).creator);allow(req, CAP.profileUpdate, profile.creator_subject);res.json(moderation.removeModerator(profile.creator_subject, req.params.subject));}));// ── Simulation ───────────────────────────────────────────-- r.post('/simulate', ...write((req, res) => {++ // A simulated tip plays on the creator's overlays and chat like a real one.++ r.post('/simulate', limits('tips.simulation.run', { minute: 10, hour: 100 }), ...write((req, res) => {const b = req.body || {};const profile = creatorOf(req, b.creator);allow(req, CAP.simulate, profile.creator_subject);@@@ -27,6 +27,7 @@ const { consumerRouter } = require('./events/consumer');const { createDomain } = require('./domain');const { createApiAuth } = require('./api/auth');const { v1Router } = require('./api/v1');++const { createActorLimits } = require('./api/actor-limits');const { createSessionRoutes } = require('./web/session');const { createWebRoutes } = require('./web/routes');const { createLayout, assetVersion } = require('./web/layout');@@@ -91,7 +92,10 @@ function createApp(opts = {}) {// through nginx carries X-Forwarded-For and is refused (the signature is checked as well).app.use('/internal', (req, res, next) => (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']? http.sendProblem(res, 404, 'not_found', { ctx: req.ov }) : next()), consumer.router);-- app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth }));++ // Per-actor limits on /api/v1 (api/actor-limits.js), counted once apiAuth resolved the caller.++ // opts.limitsNow: the limiter's clock (tests).++ const limits = createActorLimits({ config, now: opts.limitsNow || now, registry: metrics.registry, log });++ app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits }));app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'tips', service: 'tips', host: 'openvibe.tips', name: 'OpenVibe.Tips', profile: 'ugc' })); }@@@ -101,6 +101,13 @@ function loadConfig(env = process.env) {pendingCheckouts: int(env.TIPS_MAX_PENDING_CHECKOUTS, 20), // unpaid checkouts per supporter per day},++ // Per-actor limits on /api/v1 (server/api/actor-limits.js, roadmap WS-R task 4): the reads one++ // caller may make per minute and per hour. Payments and other writes set tighter numbers in api/v1.js.++ actorLimits: {++ minute: Math.max(1, int(env.TIPS_LIMITS_MINUTE, 120)),++ hour: Math.max(1, int(env.TIPS_LIMITS_HOUR, 3000)),++ },++jobs: {enabled: env.TIPS_JOBS !== 'off',intervalMs: int(env.TIPS_JOBS_INTERVAL_MS, 2000),nnew file mode 100644@@@ -0,0 +1,79 @@++'use strict';++// Per-actor rate limits on /api/v1 (server/api/actor-limits.js, roadmap WS-R task 4): past its limit one++// caller gets 429 problem+json `rate_limited` with Retry-After, before the route does any work, while++// another caller still passes; the window reopens on the clock. Payments have their own budget: a++// refused one never reaches Billing. Health, ready, release.json, metrics and the Events deliveries are++// never limited; refusals are logged (no token) and counted.++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++const { actor } = require('../server/api/actor-limits');++++(async () => {++ // The limiter's clock: 15 s into a minute, so the minute window has 45 s left. Reads: 3 a minute.++ let clock = Date.UTC(2026, 8, 27, 12, 0, 15);++ const t = await boot({ env: { TIPS_LIMITS_MINUTE: '3', TIPS_LIMITS_HOUR: '100' }, appOpts: { limitsNow: () => clock } });++ const { billing } = t;++ await t.creator('alex');++ const viewer = t.network.newUser('viewer');++ const other = t.network.newUser('other');++ billing.fund(viewer.subject, 10_000);++ billing.fund(other.subject, 10_000);++++ await check('a read: 3 a minute per person, then 429 rate_limited with Retry-After; another caller passes', async () => {++ for (let i = 0; i < 3; i++) assert.strictEqual((await t.call('GET', '/api/v1/profiles/alex', { user: viewer })).status, 200);++ const r = await t.call('GET', '/api/v1/profiles/alex', { user: viewer });++ assert.strictEqual(r.status, 429, r.text);++ assert.strictEqual(r.headers.get('retry-after'), '45');++ assert.strictEqual(r.headers.get('content-type'), 'application/problem+json');++ assert.deepStrictEqual([r.json.code, r.json.status, r.json.retry_after_seconds], ['rate_limited', 429, 45]);++ assert.ok(r.json.detail.includes('tips.read'), r.json.detail);++ assert.strictEqual((await t.call('GET', '/api/v1/profiles/alex', { user: other })).status, 200, 'another person still passes');++ assert.strictEqual((await t.call('GET', '/api/v1/profiles/alex')).status, 200, 'a service is its own caller');++ assert.strictEqual((await t.call('GET', '/api/v1/profiles/alex', { token: 'not.a.token' })).status, 401, 'a bad token: 401 from auth, never a 429');++ clock += 45 * 1000;++ assert.strictEqual((await t.call('GET', '/api/v1/profiles/alex', { user: viewer })).status, 200, 'the next minute opens the window again');++ });++++ await check('payments: 20 a minute per person, the 21st refused before Billing; a relaying service has its own budget', async () => {++ clock = Date.UTC(2026, 8, 27, 12, 5, 0);++ for (let i = 0; i < 20; i++) {++ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 10 } });++ assert.strictEqual(r.status, 201, `tip ${i + 1}: ${r.text}`);++ }++ const transfers = billing.transfers().length;++ const r = await t.call('POST', '/api/v1/paid-messages', { user: viewer, body: { creator: 'alex', amount: 10, message: 'one more' } });++ assert.deepStrictEqual([r.status, r.json.code, r.headers.get('retry-after')], [429, 'rate_limited', '60']);++ assert.strictEqual(billing.transfers().length, transfers, 'Billing never heard of it');++ assert.strictEqual((await t.call('POST', '/api/v1/checkout', { user: other, body: { creator: 'alex', amount: 10 } })).status, 201, 'another person still tips');++ const relayed = await t.call('POST', '/api/v1/checkout', { body: { creator: 'alex', amount: 10, supporter: { type: 'user', id: viewer.subject } } });++ assert.strictEqual(relayed.status, 201, relayed.text);++ });++++ await check('health, ready, release.json, metrics and the Events deliveries are never limited', async () => {++ for (let i = 0; i < 6; i++) {++ assert.strictEqual((await t.call('GET', '/api/health', { token: null })).status, 200);++ assert.notStrictEqual((await t.call('GET', '/api/ready', { token: null })).status, 429);++ assert.strictEqual((await t.call('GET', '/release.json', { token: null })).status, 200);++ assert.strictEqual((await t.call('GET', '/metrics', { token: null })).status, 200);++ assert.notStrictEqual((await t.deliver({ event_id: `evt_limits_${i}`, event_type: 'test.nothing', version: 1 }, { seq: i + 1 })).status, 429);++ }++ });++++ await check('refusals are logged (the caller, never a token) and counted in tips_rate_limited_total', async () => {++ assert.ok(t.logs.some((l) => l === `[Tips] limit tips.read: user:${viewer.subject} refused, over 3 per minute`), t.logs.join('\n'));++ assert.ok(t.logs.some((l) => l === `[Tips] limit tips.payment: user:${viewer.subject} refused, over 20 per minute`));++ assert.ok(!t.logs.some((l) => /Bearer|eyJ/.test(l)), 'no token in the log');++ const m = (await t.call('GET', '/metrics', { token: null })).text;++ assert.ok(/tips_rate_limited_total\{limit="tips.read",window="minute"\} 1/.test(m), m.split('\n').filter((l) => l.includes('rate_limited')).join('\n'));++ assert.ok(/tips_rate_limited_total\{limit="tips.payment",window="minute"\} 1/.test(m));++ });++++ await check('who is counted', () => {++ assert.strictEqual(actor({ principal: { kind: 'service', sub: 'svc:live' } }), 'svc:live');++ assert.strictEqual(actor({ principal: { kind: 'user', subject: 'usr_a' } }), 'user:usr_a');++ assert.strictEqual(actor({ principal: { kind: 'anonymous' }, ip: '203.0.113.9' }), 'ip:203.0.113.9');++ });++++ await t.close();++ done();++})();@@@ -2,6 +2,7 @@/*** Boots Tips against the stubs on a random port with a temp database. Jobs are off: tests drive the* effects worker and transfer retries explicitly (domain.effects.drain(), processDueTransfers()).++ * opts.env overrides the environment; opts.appOpts is passed to createApp (e.g. limitsNow).** t.call(method, path, { body, user, cap, sub, key }) user: a stub user → Bearer user JWT;* otherwise a service token with `cap`@@@ -52,7 +53,7 @@ async function boot(opts = {}) {const clock = { offset: 0 };const logs = [];const log = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) };-- const app = createApp({ config, now: () => Date.now() + clock.offset, log });++ const app = createApp({ config, now: () => Date.now() + clock.offset, log, ...(opts.appOpts || {}) });await app.locals.keys.load();const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });const base = `http://127.0.0.1:${server.address().port}`;
@@ -73,6 +73,11 @@ TIPS_EVENTS_SECRET=
@@ -73,6 +73,11 @@ TIPS_EVENTS_SECRET=
#TIPS_MEDIA_HOSTS=youtube.com,www.youtube.com,m.youtube.com,music.youtube.com,youtu.be
#TIPS_MEDIA_HOSTS=youtube.com,www.youtube.com,m.youtube.com,music.youtube.com,youtu.be
# Unpaid checkouts one supporter may hold within a day (more answer 429)
# Unpaid checkouts one supporter may hold within a day (more answer 429)
#TIPS_MAX_PENDING_CHECKOUTS=20
#TIPS_MAX_PENDING_CHECKOUTS=20
+# Per-actor limits on /api/v1 (server/api/actor-limits.js): reads one caller (a service principal,
+# a person, else an address) may make per minute and per hour; past them 429 rate_limited with
+# Retry-After. Payments (20 a minute per person) and other writes have their own numbers (README).
+#TIPS_LIMITS_MINUTE=120
+#TIPS_LIMITS_HOUR=3000
# ── Background jobs: effects worker, due transfers, overlay window, outbox relay ──
# ── Background jobs: effects worker, due transfers, overlay window, outbox relay ──
#TIPS_JOBS=on
#TIPS_JOBS=on
@@ -274,6 +274,27 @@ that shows it. The fixes it made:
@@ -274,6 +274,27 @@ that shows it. The fixes it made:
The regressions are in `test/security.test.js`. It is an internal review; an independent one is still due
The regressions are in `test/security.test.js`. It is an internal review; an independent one is still due
before launch.
before launch.
+### Per-actor limits
+
+`/api/v1` also limits who calls it, once the credential is checked and before any work (the idempotency
+store, Billing, chat delivery): `server/api/actor-limits.js`, openvibe-sdk/limits, roadmap WS-R task 4. A
+service counts as its principal (`svc:live`), a person as `user:usr_…`, anyone else by address. Past a limit:
+`429` problem+json `rate_limited` with `Retry-After`, one log line and `tips_rate_limited_total{limit,window}`.
+
+| Routes | Per caller |
+|---|---|
+| Every read | `TIPS_LIMITS_MINUTE` / `TIPS_LIMITS_HOUR` (120 a minute, 3000 an hour) |
+| `POST /checkout`, `/paid-messages`, `/tts-requests`, `/media-requests` (one budget) | a person 20 / 200; a service 60 / 1200 |
+| `POST /interactions/external` | 60 / 1200 |
+| Profile, goal and overlay config changes, token revoke | 30 / 300 |
+| `POST /overlay-tokens` | 10 / 60 |
+| Hide and restore | 60 / 600 |
+| Moderators add and remove; `POST /simulate` | 10 / 100 |
+| `POST /me/erase` | 3 / 10 |
+
+Never limited: `/api/health`, `/api/ready`, `/release.json`, `/metrics`, the overlays (bounded by
+`TIPS_OVERLAY_MAX_STREAMS`) and the signed Events deliveries at `/internal/events`. `test/actor-limits.test.js`.
+
## Import and reconciliation
## Import and reconciliation
`scripts/import-live.js` reads a **copy** of Live's database (read-only) after Billing's own import, and
`scripts/import-live.js` reads a **copy** of Live's database (read-only) after Billing's own import, and
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
},
"engines": {
"engines": {
@@ -999,9 +999,9 @@
@@ -999,9 +999,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.11.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
- "integrity": "sha512-sY/0bl5bhixjxLv2ACeL/AKIE2QlnndiDqkNuA5YGo2pC7AMvPqRP05y4OAtuJ7JvWtaBZ9gETB03u07uYp3wQ==",
+ "version": "0.12.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
+ "integrity": "sha512-iG1h905O1f7y/PnP8xLA6GCVzXEBDfSaBj2tc1ucNreWg2sT/AJWosb8NW0WRMK5WS4sIQ6tMsprmDLj/fjjeQ==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
},
"engines": {
"engines": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+'use strict';
+
+/**
+ * Per-actor rate limits at /api/v1 (roadmap WS-R task 4; openvibe-sdk/limits).
+ *
+ * Counted by who calls, as api/auth.js resolved req.principal: a service by its principal (svc:live), a
+ * person by their subject (user:usr_…), anyone else by address. Past a limit the route answers 429
+ * problem+json `rate_limited` with Retry-After before it does any work (before the idempotency store,
+ * Billing or chat delivery); the refusal is logged once and counted in tips_rate_limited_total{limit,window}.
+ * Reads get TIPS_LIMITS_MINUTE / TIPS_LIMITS_HOUR (120 and 3000); writes and payments set their own,
+ * tighter numbers in api/v1.js. Counters live in this process: a restart forgets them.
+ *
+ * Never limited: /api/health, /api/ready, /release.json, /metrics, the overlays (a scoped token each,
+ * capped by TIPS_OVERLAY_MAX_STREAMS) and the signed Events deliveries at /internal/events (Billing's
+ * settlements: Events pushes at its own pace, and a 429 would only make it retry and fall behind).
+ */
+const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');
+
+function actor(req) {
+ const p = req.principal;
+ if (p && p.kind === 'service' && p.sub) return p.sub;
+ if (p && p.kind === 'user' && p.subject) return `user:${p.subject}`;
+ return defaultActor(req);
+}
+
+/** limits(name, own) middleware for one app, plus limits.reads(name): the defaults on GET/HEAD. */
+function createActorLimits({ config, now = () => Date.now(), registry = null, log = console }) {
+ const refused = registry
+ ? registry.counter({ name: 'tips_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
+ : null;
+ const limiter = createActorLimiter({
+ limits: { minute: config.actorLimits.minute, hour: config.actorLimits.hour },
+ actor,
+ now,
+ onLimited(e) {
+ // The actor is a principal, a subject id or an address, never a token.
+ log.warn(`[Tips] limit ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
+ if (refused) refused.inc({ limit: e.name, window: e.window });
+ },
+ });
+ limiter.reads = (name) => {
+ const limit = limiter(name);
+ return (req, res, next) => (req.method === 'GET' || req.method === 'HEAD' ? limit(req, res, next) : next());
+ };
+ return limiter;
+}
+
+module.exports = { createActorLimits, actor };
@@ -64,11 +64,18 @@ const CAP = {
@@ -64,11 +64,18 @@ const CAP = {
moderate: 'tips.interaction.moderate', // in openvibe-contracts since v0.32.0 (was docs/capabilities-proposal/)
moderate: 'tips.interaction.moderate', // in openvibe-contracts since v0.32.0 (was docs/capabilities-proposal/)
};
};
-function v1Router({ domain, apiAuth }) {
+function v1Router({ domain, apiAuth, limits }) {
const r = express.Router();
const r = express.Router();
const { db, profiles, goals, interactions, overlays } = domain;
const { db, profiles, goals, interactions, overlays } = domain;
const idem = idempotent(db, domain.now);
const idem = idempotent(db, domain.now);
+ // Per-actor limits (api/actor-limits.js), after the credential and before any work: every read takes
+ // the defaults; each write below names its own, tighter numbers.
+ r.use(limits.reads('tips.read'));
+ const edit = limits('tips.settings.update', { minute: 30, hour: 300 }); // profile, goals, overlay configs
+ const moderate = limits('tips.interaction.moderate', { minute: 60, hour: 600 });
+ const staffing = limits('tips.moderators.update', { minute: 10, hour: 100 });
+
/** Service with `cap`, or the user who owns `owner` (subject). Anonymous → 401. */
/** Service with `cap`, or the user who owns `owner` (subject). Anonymous → 401. */
function allow(req, cap, owner) {
function allow(req, cap, owner) {
const p = req.principal;
const p = req.principal;
@@ -122,9 +129,11 @@ function v1Router({ domain, apiAuth }) {
@@ -122,9 +129,11 @@ function v1Router({ domain, apiAuth }) {
return req.principal.subject;
return req.principal.subject;
};
};
r.get('/me/export', wrap((req, res) => res.json(interactions.exportFor(person(req)))));
r.get('/me/export', wrap((req, res) => res.json(interactions.exportFor(person(req)))));
- r.post('/me/erase', ...write((req, res) => res.json(interactions.erase(person(req)))));
+ // A person erasing their own tips does it once; the network's account deletion arrives through
+ // /internal/events, which is never limited.
+ r.post('/me/erase', limits('tips.me.erase', { minute: 3, hour: 10 }), ...write((req, res) => res.json(interactions.erase(person(req)))));
- r.patch('/profiles/:creator', ...write((req, res) => {
+ r.patch('/profiles/:creator', edit, ...write((req, res) => {
const b = req.body || {};
const b = req.body || {};
let p;
let p;
if (req.params.creator === 'me') {
if (req.params.creator === 'me') {
@@ -191,10 +200,16 @@ function v1Router({ domain, apiAuth }) {
@@ -191,10 +200,16 @@ function v1Router({ domain, apiAuth }) {
});
});
});
});
}
}
- r.post('/checkout', ...paid('tip', CAP.checkout));
- r.post('/paid-messages', ...paid('paid_message', CAP.superchat));
- r.post('/tts-requests', ...paid('tts', CAP.tts));
- r.post('/media-requests', ...paid('media_request', CAP.media));
+ // Every paid request starts a Billing transfer or checkout and may reach the creator's chat: one
+ // budget for all four, refusals and retries included. A person sends at most 20 a minute and 200 an
+ // hour; a service relaying its supporters' tips gets one a second (60 and 1200).
+ const personPays = limits('tips.payment', { minute: 20, hour: 200 });
+ const servicePays = limits('tips.payment', { minute: 60, hour: 1200 });
+ const pays = (req, res, next) => (req.principal.kind === 'service' ? servicePays : personPays)(req, res, next);
+ r.post('/checkout', pays, ...paid('tip', CAP.checkout));
+ r.post('/paid-messages', pays, ...paid('paid_message', CAP.superchat));
+ r.post('/tts-requests', pays, ...paid('tts', CAP.tts));
+ r.post('/media-requests', pays, ...paid('media_request', CAP.media));
// ── Interactions ─────────────────────────────────────────
// ── Interactions ─────────────────────────────────────────
r.get('/interactions', wrap((req, res) => {
r.get('/interactions', wrap((req, res) => {
@@ -229,7 +244,8 @@ function v1Router({ domain, apiAuth }) {
@@ -229,7 +244,8 @@ function v1Router({ domain, apiAuth }) {
res.json({ interaction: interactions.present(i, { viewer }) });
res.json({ interaction: interactions.present(i, { viewer }) });
}));
}));
- r.post('/interactions/external', ...write((req, res) => {
+ // A service importing tips made elsewhere records one per tip, at most one a second.
+ r.post('/interactions/external', limits('tips.interaction.record', { minute: 60, hour: 1200 }), ...write((req, res) => {
allow(req, CAP.interactionRecord);
allow(req, CAP.interactionRecord);
const b = req.body || {};
const b = req.body || {};
const profile = creatorOf(req, b.creator);
const profile = creatorOf(req, b.creator);
@@ -259,7 +275,7 @@ function v1Router({ domain, apiAuth }) {
@@ -259,7 +275,7 @@ function v1Router({ domain, apiAuth }) {
if (!goalOwner(req, g.creator_subject)) return res.json({ goal: goals.publicGoal(g, profiles.bySubject(g.creator_subject).page) });
if (!goalOwner(req, g.creator_subject)) return res.json({ goal: goals.publicGoal(g, profiles.bySubject(g.creator_subject).page) });
res.json({ goal: goals.present(g, { contributions: true }) });
res.json({ goal: goals.present(g, { contributions: true }) });
}));
}));
- r.post('/goals', ...write((req, res) => {
+ r.post('/goals', edit, ...write((req, res) => {
const b = req.body || {};
const b = req.body || {};
const profile = creatorOf(req, b.creator);
const profile = creatorOf(req, b.creator);
allow(req, CAP.goalCreate, profile.creator_subject);
allow(req, CAP.goalCreate, profile.creator_subject);
@@ -271,8 +287,8 @@ function v1Router({ domain, apiAuth }) {
@@ -271,8 +287,8 @@ function v1Router({ domain, apiAuth }) {
allow(req, cap, g.creator_subject);
allow(req, cap, g.creator_subject);
return g;
return g;
};
};
- r.patch('/goals/:id', ...write((req, res) => res.json({ goal: goals.update(ownGoal(req, CAP.goalUpdate), req.body || {}) })));
- r.post('/goals/:id/close', ...write((req, res) => res.json({ goal: goals.close(ownGoal(req, CAP.goalClose)) })));
+ r.patch('/goals/:id', edit, ...write((req, res) => res.json({ goal: goals.update(ownGoal(req, CAP.goalUpdate), req.body || {}) })));
+ r.post('/goals/:id/close', edit, ...write((req, res) => res.json({ goal: goals.close(ownGoal(req, CAP.goalClose)) })));
// ── Overlay tokens and configs ───────────────────────────
// ── Overlay tokens and configs ───────────────────────────
r.get('/overlay-tokens', wrap((req, res) => {
r.get('/overlay-tokens', wrap((req, res) => {
@@ -280,7 +296,8 @@ function v1Router({ domain, apiAuth }) {
@@ -280,7 +296,8 @@ function v1Router({ domain, apiAuth }) {
allow(req, CAP.tokenCreate, profile.creator_subject);
allow(req, CAP.tokenCreate, profile.creator_subject);
res.json({ tokens: overlays.listTokens(profile.creator_subject).map(overlays.presentToken) });
res.json({ tokens: overlays.listTokens(profile.creator_subject).map(overlays.presentToken) });
}));
}));
- r.post('/overlay-tokens', ...write((req, res) => {
+ // A token opens an overlay's event stream: a creator mints a few for their scenes.
+ r.post('/overlay-tokens', limits('tips.overlay.token.create', { minute: 10, hour: 60 }), ...write((req, res) => {
const b = req.body || {};
const b = req.body || {};
const profile = creatorOf(req, b.creator);
const profile = creatorOf(req, b.creator);
allow(req, CAP.tokenCreate, profile.creator_subject);
allow(req, CAP.tokenCreate, profile.creator_subject);
@@ -291,7 +308,7 @@ function v1Router({ domain, apiAuth }) {
@@ -291,7 +308,7 @@ function v1Router({ domain, apiAuth }) {
res.locals.storedBody = { token: out.token, secret: null, overlay_url: null, events_url: null, note: 'the secret is shown once; revoke this token and create another' };
res.locals.storedBody = { token: out.token, secret: null, overlay_url: null, events_url: null, note: 'the secret is shown once; revoke this token and create another' };
res.status(201).json({ token: out.token, secret: out.secret, overlay_url: out.overlay_url, events_url: out.events_url });
res.status(201).json({ token: out.token, secret: out.secret, overlay_url: out.overlay_url, events_url: out.events_url });
}));
}));
- r.post('/overlay-tokens/:id/revoke', ...write((req, res) => {
+ r.post('/overlay-tokens/:id/revoke', edit, ...write((req, res) => {
const t = overlays.getToken(req.params.id);
const t = overlays.getToken(req.params.id);
if (!t) fail(404, 'tips.overlay_token_not_found', 'no such overlay token');
if (!t) fail(404, 'tips.overlay_token_not_found', 'no such overlay token');
allow(req, CAP.tokenRevoke, t.creator_subject);
allow(req, CAP.tokenRevoke, t.creator_subject);
@@ -309,13 +326,13 @@ function v1Router({ domain, apiAuth }) {
@@ -309,13 +326,13 @@ function v1Router({ domain, apiAuth }) {
allow(req, CAP.configGet, c.creator_subject);
allow(req, CAP.configGet, c.creator_subject);
res.json({ config: overlays.presentConfig(c) });
res.json({ config: overlays.presentConfig(c) });
}));
}));
- r.post('/overlay-configs', ...write((req, res) => {
+ r.post('/overlay-configs', edit, ...write((req, res) => {
const b = req.body || {};
const b = req.body || {};
const profile = creatorOf(req, b.creator);
const profile = creatorOf(req, b.creator);
allow(req, CAP.configUpdate, profile.creator_subject);
allow(req, CAP.configUpdate, profile.creator_subject);
res.status(201).json({ config: overlays.createConfig(profile.creator_subject, b) });
res.status(201).json({ config: overlays.createConfig(profile.creator_subject, b) });
}));
}));
- r.patch('/overlay-configs/:id', ...write((req, res) => {
+ r.patch('/overlay-configs/:id', edit, ...write((req, res) => {
const c = overlays.getConfig(req.params.id);
const c = overlays.getConfig(req.params.id);
if (!c) fail(404, 'tips.overlay_config_not_found', 'no such overlay config');
if (!c) fail(404, 'tips.overlay_config_not_found', 'no such overlay config');
allow(req, CAP.configUpdate, c.creator_subject);
allow(req, CAP.configUpdate, c.creator_subject);
@@ -347,7 +364,7 @@ function v1Router({ domain, apiAuth }) {
@@ -347,7 +364,7 @@ function v1Router({ domain, apiAuth }) {
res.json({ log: moderation.log(profile.creator_subject) });
res.json({ log: moderation.log(profile.creator_subject) });
}));
}));
for (const action of ['hide', 'restore']) {
for (const action of ['hide', 'restore']) {
- r.post(`/interactions/:id/${action}`, ...write((req, res) => {
+ r.post(`/interactions/:id/${action}`, moderate, ...write((req, res) => {
const i = interactions.get(req.params.id);
const i = interactions.get(req.params.id);
if (!i) fail(404, 'tips.interaction_not_found', 'no such interaction');
if (!i) fail(404, 'tips.interaction_not_found', 'no such interaction');
const actor = moderatorOf(req, i.creator_subject, { hideExistence: true });
const actor = moderatorOf(req, i.creator_subject, { hideExistence: true });
@@ -360,21 +377,22 @@ function v1Router({ domain, apiAuth }) {
@@ -360,21 +377,22 @@ function v1Router({ domain, apiAuth }) {
allow(req, CAP.profileUpdate, profile.creator_subject);
allow(req, CAP.profileUpdate, profile.creator_subject);
res.json({ moderators: moderation.listModerators(profile.creator_subject).map(moderation.presentModerator) });
res.json({ moderators: moderation.listModerators(profile.creator_subject).map(moderation.presentModerator) });
}));
}));
- r.post('/moderators', ...write((req, res) => {
+ r.post('/moderators', staffing, ...write((req, res) => {
const b = req.body || {};
const b = req.body || {};
const profile = creatorOf(req, b.creator);
const profile = creatorOf(req, b.creator);
allow(req, CAP.profileUpdate, profile.creator_subject);
allow(req, CAP.profileUpdate, profile.creator_subject);
const by = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
const by = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
res.status(201).json({ moderator: moderation.addModerator(profile.creator_subject, b.moderator, { name: b.name, addedBy: by }) });
res.status(201).json({ moderator: moderation.addModerator(profile.creator_subject, b.moderator, { name: b.name, addedBy: by }) });
}));
}));
- r.post('/moderators/:subject/remove', ...write((req, res) => {
+ r.post('/moderators/:subject/remove', staffing, ...write((req, res) => {
const profile = creatorOf(req, (req.body || {}).creator);
const profile = creatorOf(req, (req.body || {}).creator);
allow(req, CAP.profileUpdate, profile.creator_subject);
allow(req, CAP.profileUpdate, profile.creator_subject);
res.json(moderation.removeModerator(profile.creator_subject, req.params.subject));
res.json(moderation.removeModerator(profile.creator_subject, req.params.subject));
}));
}));
// ── Simulation ───────────────────────────────────────────
// ── Simulation ───────────────────────────────────────────
- r.post('/simulate', ...write((req, res) => {
+ // A simulated tip plays on the creator's overlays and chat like a real one.
+ r.post('/simulate', limits('tips.simulation.run', { minute: 10, hour: 100 }), ...write((req, res) => {
const b = req.body || {};
const b = req.body || {};
const profile = creatorOf(req, b.creator);
const profile = creatorOf(req, b.creator);
allow(req, CAP.simulate, profile.creator_subject);
allow(req, CAP.simulate, profile.creator_subject);
@@ -27,6 +27,7 @@ const { consumerRouter } = require('./events/consumer');
@@ -27,6 +27,7 @@ const { consumerRouter } = require('./events/consumer');
const { createDomain } = require('./domain');
const { createDomain } = require('./domain');
const { createApiAuth } = require('./api/auth');
const { createApiAuth } = require('./api/auth');
const { v1Router } = require('./api/v1');
const { v1Router } = require('./api/v1');
+const { createActorLimits } = require('./api/actor-limits');
const { createSessionRoutes } = require('./web/session');
const { createSessionRoutes } = require('./web/session');
const { createWebRoutes } = require('./web/routes');
const { createWebRoutes } = require('./web/routes');
const { createLayout, assetVersion } = require('./web/layout');
const { createLayout, assetVersion } = require('./web/layout');
@@ -91,7 +92,10 @@ function createApp(opts = {}) {
@@ -91,7 +92,10 @@ function createApp(opts = {}) {
// through nginx carries X-Forwarded-For and is refused (the signature is checked as well).
// through nginx carries X-Forwarded-For and is refused (the signature is checked as well).
app.use('/internal', (req, res, next) => (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']
app.use('/internal', (req, res, next) => (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']
? http.sendProblem(res, 404, 'not_found', { ctx: req.ov }) : next()), consumer.router);
? http.sendProblem(res, 404, 'not_found', { ctx: req.ov }) : next()), consumer.router);
- app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth }));
+ // Per-actor limits on /api/v1 (api/actor-limits.js), counted once apiAuth resolved the caller.
+ // opts.limitsNow: the limiter's clock (tests).
+ const limits = createActorLimits({ config, now: opts.limitsNow || now, registry: metrics.registry, log });
+ app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits }));
app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));
app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'tips', service: 'tips', host: 'openvibe.tips', name: 'OpenVibe.Tips', profile: 'ugc' })); }
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'tips', service: 'tips', host: 'openvibe.tips', name: 'OpenVibe.Tips', profile: 'ugc' })); }
@@ -101,6 +101,13 @@ function loadConfig(env = process.env) {
@@ -101,6 +101,13 @@ function loadConfig(env = process.env) {
pendingCheckouts: int(env.TIPS_MAX_PENDING_CHECKOUTS, 20), // unpaid checkouts per supporter per day
pendingCheckouts: int(env.TIPS_MAX_PENDING_CHECKOUTS, 20), // unpaid checkouts per supporter per day
},
},
+ // Per-actor limits on /api/v1 (server/api/actor-limits.js, roadmap WS-R task 4): the reads one
+ // caller may make per minute and per hour. Payments and other writes set tighter numbers in api/v1.js.
+ actorLimits: {
+ minute: Math.max(1, int(env.TIPS_LIMITS_MINUTE, 120)),
+ hour: Math.max(1, int(env.TIPS_LIMITS_HOUR, 3000)),
+ },
+
jobs: {
jobs: {
enabled: env.TIPS_JOBS !== 'off',
enabled: env.TIPS_JOBS !== 'off',
intervalMs: int(env.TIPS_JOBS_INTERVAL_MS, 2000),
intervalMs: int(env.TIPS_JOBS_INTERVAL_MS, 2000),
new file mode 100644
new file mode 100644
@@ -0,0 +1,79 @@
@@ -0,0 +1,79 @@
+'use strict';
+// Per-actor rate limits on /api/v1 (server/api/actor-limits.js, roadmap WS-R task 4): past its limit one
+// caller gets 429 problem+json `rate_limited` with Retry-After, before the route does any work, while
+// another caller still passes; the window reopens on the clock. Payments have their own budget: a
+// refused one never reaches Billing. Health, ready, release.json, metrics and the Events deliveries are
+// never limited; refusals are logged (no token) and counted.
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+const { actor } = require('../server/api/actor-limits');
+
+(async () => {
+ // The limiter's clock: 15 s into a minute, so the minute window has 45 s left. Reads: 3 a minute.
+ let clock = Date.UTC(2026, 8, 27, 12, 0, 15);
+ const t = await boot({ env: { TIPS_LIMITS_MINUTE: '3', TIPS_LIMITS_HOUR: '100' }, appOpts: { limitsNow: () => clock } });
+ const { billing } = t;
+ await t.creator('alex');
+ const viewer = t.network.newUser('viewer');
+ const other = t.network.newUser('other');
+ billing.fund(viewer.subject, 10_000);
+ billing.fund(other.subject, 10_000);
+
+ await check('a read: 3 a minute per person, then 429 rate_limited with Retry-After; another caller passes', async () => {
+ for (let i = 0; i < 3; i++) assert.strictEqual((await t.call('GET', '/api/v1/profiles/alex', { user: viewer })).status, 200);
+ const r = await t.call('GET', '/api/v1/profiles/alex', { user: viewer });
+ assert.strictEqual(r.status, 429, r.text);
+ assert.strictEqual(r.headers.get('retry-after'), '45');
+ assert.strictEqual(r.headers.get('content-type'), 'application/problem+json');
+ assert.deepStrictEqual([r.json.code, r.json.status, r.json.retry_after_seconds], ['rate_limited', 429, 45]);
+ assert.ok(r.json.detail.includes('tips.read'), r.json.detail);
+ assert.strictEqual((await t.call('GET', '/api/v1/profiles/alex', { user: other })).status, 200, 'another person still passes');
+ assert.strictEqual((await t.call('GET', '/api/v1/profiles/alex')).status, 200, 'a service is its own caller');
+ assert.strictEqual((await t.call('GET', '/api/v1/profiles/alex', { token: 'not.a.token' })).status, 401, 'a bad token: 401 from auth, never a 429');
+ clock += 45 * 1000;
+ assert.strictEqual((await t.call('GET', '/api/v1/profiles/alex', { user: viewer })).status, 200, 'the next minute opens the window again');
+ });
+
+ await check('payments: 20 a minute per person, the 21st refused before Billing; a relaying service has its own budget', async () => {
+ clock = Date.UTC(2026, 8, 27, 12, 5, 0);
+ for (let i = 0; i < 20; i++) {
+ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 10 } });
+ assert.strictEqual(r.status, 201, `tip ${i + 1}: ${r.text}`);
+ }
+ const transfers = billing.transfers().length;
+ const r = await t.call('POST', '/api/v1/paid-messages', { user: viewer, body: { creator: 'alex', amount: 10, message: 'one more' } });
+ assert.deepStrictEqual([r.status, r.json.code, r.headers.get('retry-after')], [429, 'rate_limited', '60']);
+ assert.strictEqual(billing.transfers().length, transfers, 'Billing never heard of it');
+ assert.strictEqual((await t.call('POST', '/api/v1/checkout', { user: other, body: { creator: 'alex', amount: 10 } })).status, 201, 'another person still tips');
+ const relayed = await t.call('POST', '/api/v1/checkout', { body: { creator: 'alex', amount: 10, supporter: { type: 'user', id: viewer.subject } } });
+ assert.strictEqual(relayed.status, 201, relayed.text);
+ });
+
+ await check('health, ready, release.json, metrics and the Events deliveries are never limited', async () => {
+ for (let i = 0; i < 6; i++) {
+ assert.strictEqual((await t.call('GET', '/api/health', { token: null })).status, 200);
+ assert.notStrictEqual((await t.call('GET', '/api/ready', { token: null })).status, 429);
+ assert.strictEqual((await t.call('GET', '/release.json', { token: null })).status, 200);
+ assert.strictEqual((await t.call('GET', '/metrics', { token: null })).status, 200);
+ assert.notStrictEqual((await t.deliver({ event_id: `evt_limits_${i}`, event_type: 'test.nothing', version: 1 }, { seq: i + 1 })).status, 429);
+ }
+ });
+
+ await check('refusals are logged (the caller, never a token) and counted in tips_rate_limited_total', async () => {
+ assert.ok(t.logs.some((l) => l === `[Tips] limit tips.read: user:${viewer.subject} refused, over 3 per minute`), t.logs.join('\n'));
+ assert.ok(t.logs.some((l) => l === `[Tips] limit tips.payment: user:${viewer.subject} refused, over 20 per minute`));
+ assert.ok(!t.logs.some((l) => /Bearer|eyJ/.test(l)), 'no token in the log');
+ const m = (await t.call('GET', '/metrics', { token: null })).text;
+ assert.ok(/tips_rate_limited_total\{limit="tips.read",window="minute"\} 1/.test(m), m.split('\n').filter((l) => l.includes('rate_limited')).join('\n'));
+ assert.ok(/tips_rate_limited_total\{limit="tips.payment",window="minute"\} 1/.test(m));
+ });
+
+ await check('who is counted', () => {
+ assert.strictEqual(actor({ principal: { kind: 'service', sub: 'svc:live' } }), 'svc:live');
+ assert.strictEqual(actor({ principal: { kind: 'user', subject: 'usr_a' } }), 'user:usr_a');
+ assert.strictEqual(actor({ principal: { kind: 'anonymous' }, ip: '203.0.113.9' }), 'ip:203.0.113.9');
+ });
+
+ await t.close();
+ done();
+})();
@@ -2,6 +2,7 @@
@@ -2,6 +2,7 @@
/**
/**
* Boots Tips against the stubs on a random port with a temp database. Jobs are off: tests drive the
* Boots Tips against the stubs on a random port with a temp database. Jobs are off: tests drive the
* effects worker and transfer retries explicitly (domain.effects.drain(), processDueTransfers()).
* effects worker and transfer retries explicitly (domain.effects.drain(), processDueTransfers()).
+ * opts.env overrides the environment; opts.appOpts is passed to createApp (e.g. limitsNow).
*
*
* t.call(method, path, { body, user, cap, sub, key }) user: a stub user → Bearer user JWT;
* t.call(method, path, { body, user, cap, sub, key }) user: a stub user → Bearer user JWT;
* otherwise a service token with `cap`
* otherwise a service token with `cap`
@@ -52,7 +53,7 @@ async function boot(opts = {}) {
@@ -52,7 +53,7 @@ async function boot(opts = {}) {
const clock = { offset: 0 };
const clock = { offset: 0 };
const logs = [];
const logs = [];
const log = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) };
const log = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) };
- const app = createApp({ config, now: () => Date.now() + clock.offset, log });
+ const app = createApp({ config, now: () => Date.now() + clock.offset, log, ...(opts.appOpts || {}) });
await app.locals.keys.load();
await app.locals.keys.load();
const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;