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.
Changes
@@@ -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@@@ -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"@@@ -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",@@@ -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);nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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); });
@@ -80,6 +80,13 @@ INTERNAL_API_KEY=change-me-to-a-random-secret
@@ -80,6 +80,13 @@ INTERNAL_API_KEY=change-me-to-a-random-secret
# Path to the Network's RS256 public key (offline JWT verification)
# Path to the Network's RS256 public key (offline JWT verification)
#OV_NETWORK_PUBLIC_KEY=./data/keys/openvibe-network-public.pem
#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) ───────────────
# ── OpenVibe.Media (VODs / clips / pastes / thumbnails service) ───────────────
# Internal API base (server-to-server, Bearer MEDIA_API_KEY)
# Internal API base (server-to-server, Bearer MEDIA_API_KEY)
MEDIA_URL=http://127.0.0.1:4100
MEDIA_URL=http://127.0.0.1:4100
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -2830,9 +2830,9 @@
@@ -2830,9 +2830,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"
@@ -45,7 +45,7 @@
@@ -45,7 +45,7 @@
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -410,6 +410,8 @@ app.locals.analytics = analytics;
@@ -410,6 +410,8 @@ app.locals.analytics = analytics;
if (!drill.enabled) app.use(analytics.middleware());
if (!drill.enabled) app.use(analytics.middleware());
app.use('/api/', apiLimiter);
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/login', authLimiter);
app.use('/api/auth/register', authLimiter);
app.use('/api/auth/register', authLimiter);
app.use('/api/auth/avatar', uploadLimiter);
app.use('/api/auth/avatar', uploadLimiter);
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -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); });