openvibers/openvibe.live Commits

Per-actor limits on Live's API writes (roadmap WS-R task 4; openvibe-sdk 0.11.0 -> 0.12.0 openvibe-sdk/limits). server/net/actor-limits.js counts writes by the person behind the token: their Network subject, or the person who made an hbt_ token.

OP OpenVibers committed 28 Sept 2026 · 85fc2faf85 · 6 files, +198 −5

Limits, per minute and per hour:
- Every write: LIVE_LIMITS_MINUTE / LIVE_LIMITS_HOUR, 120 and 3000.
- Tighter numbers on top of that:
  - clip creation and trims: 10/100 (Media cuts video);
  - paste creation: 30/600;
  - follows: 60/600;
  - stream-key regeneration: 5/20;
  - image uploads: 10/100;
  - comments: 20/300;
  - money (checkouts, subscriptions, donations, cash-outs): 10/60;
  - AI key tests: 5/30.
Past a limit the request answers 429 rate_limited with Retry-After before any route runs. The refusal is logged by subject, never by token, and counted in live_rate_limited_total.

Not counted: reads (the SPA reads a lot, and viewers share addresses) and signed-out writes, which keep the per-address limits. Device and broadcast traffic that must keep flowing is exempt: robot controls, ONVIF, kiosks, RobotStreamer, recording chunks and finalize, live thumbnails, broadcaster heartbeats, diag logs and /api/auth. Channel-point heartbeats stay in the general budget. Off in a restore drill.

test/actor-limits.test.js covers:
- one person refused before the route runs while another passes;
- reads, signed-out writes and device traffic are never counted;
- a named route's own limit;
- the window reopening;
- refusals counted in the metric.
npm test 150/150.

Changes

ddiff --git a/.env.example b/.env.example
iindex 20fbe34f..7fe8428b 100644
---- a/.env.example
++++ b/.env.example
@@@ -80,6 +80,13 @@ INTERNAL_API_KEY=change-me-to-a-random-secret
# Path to the Network's RS256 public key (offline JWT verification)
#OV_NETWORK_PUBLIC_KEY=./data/keys/openvibe-network-public.pem
++# ── Per-actor limits on API writes (server/net/actor-limits.js, roadmap WS-R task 4) ──
++# Writes by a signed-in person or their hbt_ token, per minute and per hour; named routes (clips, pastes, follows,
++# stream keys, uploads, comments, money, AI key tests) have tighter numbers of their own. Reads, signed-out writes
++# and device traffic are not counted here (the per-address limits apply).
++#LIVE_LIMITS_MINUTE=120
++#LIVE_LIMITS_HOUR=3000
++
# ── OpenVibe.Media (VODs / clips / pastes / thumbnails service) ───────────────
# Internal API base (server-to-server, Bearer MEDIA_API_KEY)
MEDIA_URL=http://127.0.0.1:4100
ddiff --git a/package-lock.json b/package-lock.json
iindex b3ac27c9..d9178a2a 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -26,7 +26,7 @@
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.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.23.1",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
@@@ -2830,9 +2830,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"
ddiff --git a/package.json b/package.json
iindex 8b37ab24..a79aa096 100644
---- a/package.json
++++ b/package.json
@@@ -45,7 +45,7 @@
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.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.23.1",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
ddiff --git a/server/index.js b/server/index.js
iindex e9a53856..6d30d96e 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -410,6 +410,8 @@ app.locals.analytics = analytics;
if (!drill.enabled) app.use(analytics.middleware());
app.use('/api/', apiLimiter);
++// Per-actor limits on writes, by the person behind the token (server/net/actor-limits.js; roadmap WS-R task 4).
++if (!drill.enabled) app.use('/api/', require('./net/actor-limits').createLiveActorLimits({ registry: metricsRegistry }));
app.use('/api/auth/login', authLimiter);
app.use('/api/auth/register', authLimiter);
app.use('/api/auth/avatar', uploadLimiter);
ddiff --git a/server/net/actor-limits.js b/server/net/actor-limits.js
nnew file mode 100644
iindex 00000000..73febc22
---- /dev/null
++++ b/server/net/actor-limits.js
@@@ -0,0 +1,100 @@
++/**
++ * Per-actor limits on Live's API writes (roadmap WS-R task 4; openvibe-sdk/limits).
++ *
++ * The per-address limits in server/index.js (900 reads / 180 writes a minute on /api, the auth and upload
++ * limiters) stay. This counts WRITES by who makes them: a signed-in person (user:<subject>, or user:<id> before
++ * their Network subject is known), and an hbt_ API token as the person who made it. Reads are not counted per
++ * actor (the SPA reads a lot, and viewers share carrier addresses), and neither are signed-out writes: those keep
++ * the per-address limit.
++ *
++ * Every write takes LIVE_LIMITS_MINUTE / LIVE_LIMITS_HOUR (120 and 3000) under the name `live.api.write`; the
++ * table below gives expensive or sensitive writes their own, tighter numbers. Past a limit the request answers
++ * 429 problem+json `rate_limited` with Retry-After before any route runs; the refusal is logged (the actor is a
++ * subject or a user id, never a token) and counted in live_rate_limited_total{limit,window}. Counters live in this
++ * process: a restart forgets them.
++ *
++ * Never counted: device and broadcast traffic that must keep flowing — robot controls, ONVIF, kiosks,
++ * RobotStreamer publishing, recording chunks and finalize, live thumbnails, broadcaster heartbeats and diag logs —
++ * and /api/auth (its own limiter). Everything outside /api (/internal, /whip, /metrics, …) is never seen here.
++ */
++'use strict';
++
++const { createActorLimiter } = require('openvibe-sdk/limits');
++
++const num = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) && n > 0 ? n : d; };
++
++/** Writes never counted per actor (paths relative to /api). */
++const EXEMPT = [
++ /^\/auth\//, /^\/controls(\/|$)/, /^\/onvif(\/|$)/, /^\/kiosk(\/|$)/, /^\/robotstreamer(\/|$)/,
++ /^\/vods\/stream\/[^/]+\/(chunk|finalize)$/, /^\/thumbnails\/live(\/|$)/,
++ /^\/streams\/[^/]+\/heartbeat$/, /^\/streams\/diag-log$/,
++];
++
++/**
++ * Writes with their own numbers: [name, method regex, path regex, { minute, hour }]. The first match wins.
++ * Each number is well above what a person does by hand and stops a script.
++ */
++const ROUTES = [
++ // Creating a clip cuts video on Media.
++ ['live.clip.create', /^POST$/, /^\/(vods\/clips|clips)(\/[^/]+\/trim)?$/, { minute: 10, hour: 100 }],
++ // A paste stores content on Community (anonymous pastes keep their own address limit in pastes.js).
++ ['live.paste.create', /^POST$/, /^\/pastes\/?$/, { minute: 30, hour: 600 }],
++ ['live.follow', /^(POST|DELETE)$/, /^\/streams\/(channel\/[^/]+|[^/]+)\/follow$/, { minute: 60, hour: 600 }],
++ // A new stream key invalidates the old one; nobody needs it more than a few times.
++ ['live.stream.key', /^POST$/, /^\/streams\/managed\/[^/]+\/regenerate-key$/, { minute: 5, hour: 20 }],
++ // Image uploads (panel, goal media, offline screen, avatar, emotes, themes) are stored and re-encoded.
++ ['live.upload', /^(POST|PUT)$/, /^\/(streams\/(panel-image|goal-media|channel\/offline-screen)|emotes(\/.*)?|themes\/.*upload.*)$/, { minute: 10, hour: 100 }],
++ ['live.comment', /^POST$/, /^\/comments(\/|$)/, { minute: 20, hour: 300 }],
++ // Money: checkouts, subscriptions, donations, cash-outs. Channel-point heartbeats and redemptions keep the general
++ // write budget (a heartbeat every half minute per tab); provider webhooks carry no person and are never counted.
++ ['live.money', /^POST$/, /^\/(payments\/(bucks\/checkout|subscribe|subscriptions\/[^/]+\/cancel)|funds\/(purchase|donate|cashout|recycle))$/, { minute: 10, hour: 60 }],
++ // Testing an AI provider key calls that provider.
++ ['live.ai.test', /^POST$/, /^\/ai-viewers\/byo\/test$/, { minute: 5, hour: 30 }],
++];
++
++function createLiveActorLimits({ env = process.env, registry = null, now } = {}) {
++ let refused = null;
++ if (registry && typeof registry.counter === 'function') {
++ refused = registry.counter({ name: 'live_rate_limited_total', help: 'API writes refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] });
++ }
++ const auth = require('../auth/auth');
++ /** The person behind the request's token, without failing it (requireAuth still decides later). */
++ function actor(req) {
++ const token = auth.extractToken(req);
++ if (!token) return null;
++ try {
++ if (String(token).startsWith('hbt_')) {
++ const u = auth.authenticateApiToken(token);
++ return u ? `user:${u.subject_id || u.id}` : null;
++ }
++ const d = auth.verifyToken(token);
++ if (!d) return null;
++ const subject = d.subject_id || d.sub_subject || (typeof d.sub === 'string' && d.sub.startsWith('usr_') ? d.sub : null);
++ return subject ? `user:${subject}` : (d.sub != null ? `user:${d.sub}` : null);
++ } catch { return null; }
++ }
++ const limits = createActorLimiter({
++ limits: { minute: num(env.LIVE_LIMITS_MINUTE, 120), hour: num(env.LIVE_LIMITS_HOUR, 3000) },
++ actor,
++ ...(now ? { now } : {}),
++ onLimited(e) {
++ console.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
++ if (refused) refused.inc({ limit: e.name, window: e.window });
++ },
++ });
++ const write = limits('live.api.write');
++ const named = ROUTES.map(([name, method, pathRe, own]) => ({ name, method, pathRe, mw: limits(name, own) }));
++ const WRITE = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
++ function middleware(req, res, next) {
++ if (!WRITE.has(req.method)) return next();
++ const p = req.path; // relative to the /api mount
++ if (EXEMPT.some((re) => re.test(p))) return next();
++ const own = named.find((r) => r.method.test(req.method) && r.pathRe.test(p));
++ // A named route counts against its own numbers AND the general write budget.
++ return write(req, res, (err) => (err ? next(err) : own ? own.mw(req, res, next) : next()));
++ }
++ middleware.limits = limits;
++ return middleware;
++}
++
++module.exports = { createLiveActorLimits, EXEMPT, ROUTES };
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
nnew file mode 100644
iindex 00000000..26ca9c10
---- /dev/null
++++ b/test/actor-limits.test.js
@@@ -0,0 +1,84 @@
++'use strict';
++// Per-actor limits on Live's API writes (server/net/actor-limits.js; roadmap WS-R task 4): a signed-in person's
++// writes are counted by their subject, past the limit 429 problem+json rate_limited with Retry-After before the route
++// runs, while another person still passes; named routes have their own tighter numbers on top; reads, signed-out
++// writes and device traffic (controls, recording chunks, heartbeats) are never counted; the window reopens.
++// node test/actor-limits.test.js
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const crypto = require('crypto');
++const express = require('express');
++const jwt = require('jsonwebtoken');
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-limits-'));
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);
++process.env.DB_PATH = path.join(tmp, 'live.db');
++process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');
++process.env.OV_NETWORK_URL = 'https://openvibe.network';
++process.env.NODE_ENV = 'test';
++console.warn = () => {};
++const ISS = 'https://openvibe.network';
++const tokenFor = (id, subject) => jwt.sign({ sub: String(id), username: `u${id}`, subject_id: subject }, keys.privateKey, { algorithm: 'RS256', issuer: ISS, expiresIn: 600 });
++
++(async () => {
++ require('../server/db/database').initDb();
++ let t = Date.UTC(2026, 8, 28, 3, 0, 0);
++ const counted = [];
++ const registry = { counter: () => ({ inc: (l) => counted.push(l) }) };
++ const { createLiveActorLimits } = require('../server/net/actor-limits');
++ const app = express();
++ app.use('/api/', createLiveActorLimits({ env: { LIVE_LIMITS_MINUTE: '5', LIVE_LIMITS_HOUR: '100' }, registry, now: () => t }));
++ let ran = 0;
++ app.all('/api/*', (req, res) => { ran++; res.json({ ok: true }); });
++ const server = http.createServer(app);
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const call = async (method, p, token) => {
++ const r = await fetch(base + p, { method, headers: token ? { authorization: `Bearer ${token}` } : {} });
++ return { status: r.status, retry: r.headers.get('retry-after'), body: await r.json().catch(() => ({})) };
++ };
++ const ann = tokenFor(10, 'usr_01JAB2C3D4E5F6G7H8J9K0MNP1');
++ const bob = tokenFor(11, 'usr_01JAB2C3D4E5F6G7H8J9K0MNP2');
++ try {
++ for (let i = 0; i < 5; i++) assert.strictEqual((await call('PUT', '/api/streams/channel', ann)).status, 200, `write ${i + 1}`);
++ const before = ran;
++ const refused = await call('PUT', '/api/streams/channel', ann);
++ assert.deepStrictEqual([refused.status, refused.body.code, Number(refused.retry) > 0], [429, 'rate_limited', true]);
++ assert.strictEqual(ran, before, 'refused before the route ran');
++ assert.strictEqual((await call('PUT', '/api/streams/channel', bob)).status, 200, 'another person passes');
++ for (let i = 0; i < 20; i++) assert.strictEqual((await call('GET', '/api/streams', ann)).status, 200, 'reads are not counted');
++ for (let i = 0; i < 10; i++) assert.strictEqual((await call('POST', '/api/pastes')).status, 200, 'signed-out writes keep the address limit only');
++ for (let i = 0; i < 10; i++) {
++ assert.strictEqual((await call('POST', '/api/controls/1/move', ann)).status, 200, 'robot controls are never counted');
++ assert.strictEqual((await call('POST', '/api/vods/stream/9/chunk', ann)).status, 200, 'recording chunks are never counted');
++ assert.strictEqual((await call('POST', '/api/streams/9/heartbeat', ann)).status, 200, 'heartbeats are never counted');
++ }
++ // Channel-point heartbeats are ordinary writes, not money: a second person sends 5 (the test's general limit).
++ const dave = tokenFor(13, 'usr_01JAB2C3D4E5F6G7H8J9K0MNP4');
++ t += 60 * 1000;
++ for (let i = 0; i < 5; i++) assert.strictEqual((await call('POST', '/api/coins/heartbeat', dave)).status, 200);
++ const { ROUTES } = require('../server/net/actor-limits');
++ const money = ROUTES.find((r) => r[0] === 'live.money');
++ assert.ok(money[2].test('/funds/donate') && money[2].test('/payments/bucks/checkout') && !money[2].test('/coins/heartbeat') && !money[2].test('/payments/webhook/stripe'));
++ // A named route has its own limit, and the general write budget applies too.
++ t += 60 * 1000;
++ const carol = tokenFor(12, 'usr_01JAB2C3D4E5F6G7H8J9K0MNP3');
++ for (let i = 0; i < 5; i++) assert.strictEqual((await call('POST', '/api/streams/managed/4/regenerate-key', carol)).status, 200);
++ const key = await call('POST', '/api/streams/managed/4/regenerate-key', carol);
++ assert.strictEqual(key.status, 429);
++ assert.ok(/live\.(stream\.key|api\.write)/.test(key.body.detail), key.body.detail);
++ // The next minute opens the window again.
++ t += 60 * 1000;
++ assert.strictEqual((await call('PUT', '/api/streams/channel', ann)).status, 200);
++ assert.ok(counted.some((l) => l.limit === 'live.api.write' && l.window === 'minute'), 'refusals are counted');
++ } finally {
++ server.close();
++ fs.rmSync(tmp, { recursive: true, force: true });
++ }
++ console.log('actor limits: all checks passed');
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });