openvibers/openrestream Commits

Per-actor limits on the ingest API's writes (roadmap WS-R task 4; openvibe-sdk 0.11.0 -> 0.12.0 openvibe-sdk/limits). server/api/actor-limits.js counts a signed-in person's writes by subject: OPENRE_LIMITS_MINUTE / _HOUR (120 and 3000), and tighter on top for key rotations (5 a minute, 20 an hour), destination tests (10/100, an outbound connection to someone else's server) and new streams (10/100). Past a limit: 429 rate_limited with Retry-After before the route runs, logged and counted in openre_rate_limited_total. Service callers are not counted: Live manages every streamer's ingest with its token, and its capability grant is its limit. Reads are not counted. test/actor-limits.test.js: one person refused while another passes, Live and reads never counted, five rotations a minute, the window reopens. npm test 18/18.

OP OpenVibers committed 28 Sept 2026 · cf423e3778 · 6 files, +113 −6


Changes

ddiff --git a/.env.example b/.env.example
iindex 16afca7..034cf61 100644
---- a/.env.example
++++ b/.env.example
@@@ -110,3 +110,8 @@ MEDIA_API_KEY=
# ── Links ─────────────────────────────────────────────────────
#OV_LIVE_URL=https://openvibe.live
++
++# Per-actor limits on a signed-in person's API writes (server/api/actor-limits.js, roadmap WS-R task 4), per minute
++# and per hour; key rotations, destination tests and new streams have tighter numbers. Services are not counted.
++#OPENRE_LIMITS_MINUTE=120
++#OPENRE_LIMITS_HOUR=3000
ddiff --git a/package-lock.json b/package-lock.json
iindex 8019d5e..063a026 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,7 +15,7 @@
"express": "^4.21.2",
"node-media-server": "2.7.4",
"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": {
@@@ -1131,9 +1131,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 a7fed4b..354da26 100644
---- a/package.json
++++ b/package.json
@@@ -24,7 +24,7 @@
"express": "^4.21.2",
"node-media-server": "2.7.4",
"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": {
ddiff --git a/server/api/actor-limits.js b/server/api/actor-limits.js
nnew file mode 100644
iindex 0000000..19f55b7
---- /dev/null
++++ b/server/api/actor-limits.js
@@@ -0,0 +1,49 @@
++/**
++ * Per-actor limits on the ingest API's writes (roadmap WS-R task 4; openvibe-sdk/limits).
++ *
++ * A signed-in person (req.caller.kind === 'user') is counted by subject. Service callers are not: Live manages every
++ * streamer's ingest with its service token, so counting svc:live as one caller would refuse the whole site; their
++ * capability grant is their limit. Reads are not counted. Every write takes OPENRE_LIMITS_MINUTE / OPENRE_LIMITS_HOUR
++ * (120 and 3000); key rotations, destination tests (an outbound connection to a third party) and new streams have
++ * tighter numbers on top. Past a limit: 429 problem+json `rate_limited` with Retry-After, before the route runs,
++ * logged and counted in openre_rate_limited_total{limit,window}. Counters live in this process.
++ */
++'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; };
++
++/** [name, method regex, path regex (relative to /api/v1), { minute, hour }]; the first match wins. */
++const ROUTES = [
++ // A rotation invalidates the key every encoder uses.
++ ['openre.key.rotate', /^POST$/, /^\/streams\/[^/]+\/keys\/rotate$/, { minute: 5, hour: 20 }],
++ // A test opens a connection to someone else's server.
++ ['openre.destination.test', /^POST$/, /^\/destinations\/[^/]+\/test$/, { minute: 10, hour: 100 }],
++ ['openre.stream.create', /^POST$/, /^\/streams\/?$/, { minute: 10, hour: 100 }],
++];
++
++function createOpenReActorLimits({ env = process.env, registry = null, now } = {}) {
++ const refused = registry && typeof registry.counter === 'function'
++ ? registry.counter({ name: 'openre_rate_limited_total', help: 'API writes refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
++ : null;
++ const limits = createActorLimiter({
++ limits: { minute: num(env.OPENRE_LIMITS_MINUTE, 120), hour: num(env.OPENRE_LIMITS_HOUR, 3000) },
++ actor: (req) => (req.caller && req.caller.kind === 'user' && req.caller.subject ? `user:${req.caller.subject}` : null),
++ ...(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('openre.api.write');
++ const named = ROUTES.map(([name, method, pathRe, own]) => ({ method, pathRe, mw: limits(name, own) }));
++ const WRITE = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
++ return function openreActorLimits(req, res, next) {
++ if (!WRITE.has(req.method)) return next();
++ const own = named.find((r) => r.method.test(req.method) && r.pathRe.test(req.path));
++ return write(req, res, (err) => (err ? next(err) : own ? own.mw(req, res, next) : next()));
++ };
++}
++
++module.exports = { createOpenReActorLimits, ROUTES };
ddiff --git a/server/app.js b/server/app.js
iindex f634f15..34abd53 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -83,7 +83,9 @@ function createApp({ rt, auth, keys, log = console, fetchImpl }) {
const apiJson = express.json({ limit: '256kb', type: ['application/json', 'application/*+json'] });
const v1 = createV1Router({ rt, auth });
-- app.use('/api/v1', apiJson, auth.middleware({ services: true }), v1.router);
++ // Per-actor limits on a signed-in person's writes (server/api/actor-limits.js; roadmap WS-R task 4).
++ const actorLimits = require('./api/actor-limits').createOpenReActorLimits({ registry: metrics.registry });
++ app.use('/api/v1', apiJson, auth.middleware({ services: true }), actorLimits, v1.router);
// Public HTTP-FLV playback of a live session, proxied from the worker that holds it. A viewer
// deploy of this API interrupts viewers of this URL (they reconnect), never the ingest.
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
nnew file mode 100644
iindex 0000000..4f72c22
---- /dev/null
++++ b/test/actor-limits.test.js
@@@ -0,0 +1,51 @@
++'use strict';
++// Per-actor limits on the ingest API's writes (server/api/actor-limits.js; roadmap WS-R task 4): a signed-in person is
++// counted by subject and refused 429 rate_limited with Retry-After before the route runs, while another person passes;
++// key rotations have their own tighter number; service callers (Live manages every streamer) and reads are never
++// counted; the window reopens.
++// node test/actor-limits.test.js
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const { createOpenReActorLimits } = require('../server/api/actor-limits');
++
++console.warn = () => {};
++(async () => {
++ let t = Date.UTC(2026, 8, 28, 3, 0, 0);
++ const app = express();
++ app.use((req, res, next) => {
++ const who = req.headers['x-who'] || '';
++ req.caller = who.startsWith('svc:') ? { kind: 'service', claims: { sub: who } } : who ? { kind: 'user', subject: who } : { kind: 'anonymous' };
++ next();
++ });
++ app.use('/api/v1', createOpenReActorLimits({ env: { OPENRE_LIMITS_MINUTE: '5' }, now: () => t }));
++ let ran = 0;
++ app.all('/api/v1/*', (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}/api/v1`;
++ const call = async (method, p, who) => {
++ const r = await fetch(base + p, { method, headers: who ? { 'x-who': who } : {} });
++ return { status: r.status, retry: r.headers.get('retry-after'), body: await r.json().catch(() => ({})) };
++ };
++ try {
++ for (let i = 0; i < 5; i++) assert.strictEqual((await call('PATCH', '/streams/s1', 'usr_a')).status, 200);
++ const before = ran;
++ const r = await call('PATCH', '/streams/s1', 'usr_a');
++ assert.deepStrictEqual([r.status, r.body.code, Number(r.retry) > 0, ran], [429, 'rate_limited', true, before]);
++ assert.strictEqual((await call('PATCH', '/streams/s1', 'usr_b')).status, 200, 'another person passes');
++ for (let i = 0; i < 20; i++) {
++ assert.strictEqual((await call('POST', '/streams/s1/keys/rotate', 'svc:live')).status, 200, 'Live (service) is never counted');
++ assert.strictEqual((await call('GET', '/streams', 'usr_a')).status, 200, 'reads are never counted');
++ }
++ t += 60 * 1000;
++ const rotations = [];
++ for (let i = 0; i < 6; i++) rotations.push((await call('POST', '/streams/s1/keys/rotate', 'usr_c')).status);
++ assert.deepStrictEqual(rotations, [200, 200, 200, 200, 200, 429], 'five key rotations a minute');
++ t += 60 * 1000;
++ assert.strictEqual((await call('PATCH', '/streams/s1', 'usr_a')).status, 200, 'the next minute');
++ } finally {
++ server.close();
++ }
++ console.log('actor limits: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });