openvibers/openvibe.host Commits

Per-actor limits on the dashboard's and the API's writes (roadmap WS-R task 4; openvibe-sdk 0.11.0 -> 0.12.0 openvibe-sdk/limits). server/http/actor-limits.js runs after the viewer is resolved and counts a signed-in person by subject, or a service by its principal (a service here manages its own projects): HOST_LIMITS_MINUTE / _HOUR (120 and 3000), and tighter on top for deploy uploads (10 a minute, 100 an hour: stored and unpacked), domain verification (10/100: DNS lookups) and new projects (5/30). Past a limit: 429 rate_limited with Retry-After before the route runs, logged and counted in host_rate_limited_total. Anonymous callers and reads keep the per-address limits. HOST_ACTOR_LIMITS=off is a rollback lever; the Stage B suites, which deploy dozens of times a minute as one person, boot with it off. test/actor-limits.test.js covers the limits themselves. npm test 38/38.

OP OpenVibers committed 28 Sept 2026 · 2b633899f4 · 9 files, +127 −5


Changes

ddiff --git a/.env.example b/.env.example
iindex fcfd7c3..cc5fa84 100644
---- a/.env.example
++++ b/.env.example
@@@ -50,6 +50,12 @@ HOST_FORM_SECRET=
# Deploys are refused (507) while the object store's filesystem has less than this free: the disk
# is shared with every other service on the host. Serving is never affected. Default 5 GiB.
#HOST_MIN_FREE_BYTES=5368709120
++# Per-actor limits on dashboard and API writes (server/http/actor-limits.js, roadmap WS-R task 4): per minute and per
++# hour for a signed-in person or a service; deploy uploads, domain checks and new projects have tighter numbers.
++# HOST_ACTOR_LIMITS=off turns them off (a rollback lever).
++#HOST_LIMITS_MINUTE=120
++#HOST_LIMITS_HOUR=3000
++#HOST_ACTOR_LIMITS=on
# Projects one person may own; each gets the per-project quotas below. Default 10.
#HOST_MAX_PROJECTS_PER_OWNER=10
ddiff --git a/package-lock.json b/package-lock.json
iindex 302aaf1..bb8e22b 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.66.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"
},
"bin": {
@@@ -1428,9 +1428,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 20e80e5..fce5a3f 100644
---- a/package.json
++++ b/package.json
@@@ -32,7 +32,7 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.66.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/app.js b/server/app.js
iindex 4f4ba42..54c230e 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -96,6 +96,8 @@ function createApp(opts = {}) {
const registry = sharedMetrics.createRegistry();
const httpMetrics = sharedMetrics.httpMetrics(registry, { normalize: (req) => (req.hostTarget === 'site' ? 'tenant_site' : req.hostTarget === 'unknown' ? 'unknown_host' : null) });
const proc = sharedMetrics.processMetrics(registry);
++ // Per-actor limits on writes (server/http/actor-limits.js; roadmap WS-R task 4), one budget for the API and the dashboard.
++ ctx.actorLimits = require('./http/actor-limits').createHostActorLimits({ registry });
sharedMetrics.releaseInfo(registry, { service: 'host', release: release.release });
registry.gauge({
name: 'host_sites', help: 'Active tenant sites',
ddiff --git a/server/http/actor-limits.js b/server/http/actor-limits.js
nnew file mode 100644
iindex 0000000..d3f89ee
---- /dev/null
++++ b/server/http/actor-limits.js
@@@ -0,0 +1,56 @@
++/**
++ * Per-actor limits on the dashboard's and the API's writes (roadmap WS-R task 4; openvibe-sdk/limits).
++ *
++ * A signed-in person (req.viewer.kind === 'user') is counted by subject; a service token by its principal (a service
++ * here manages its own projects, not other people's). Anonymous callers and reads are not counted: the per-address
++ * limits in app.js stay. Every write takes HOST_LIMITS_MINUTE / HOST_LIMITS_HOUR (120 and 3000); deploy uploads
++ * (stored and unpacked), domain verification (DNS lookups) and project creation have tighter numbers on top. Past a
++ * limit: 429 problem+json `rate_limited` with Retry-After before the route runs, logged and counted in
++ * host_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 the router), { minute, hour }]; the first match wins. */
++const ROUTES = [
++ ['host.deploy.upload', /^POST$/, /^\/sites\/[^/]+\/deploys\/?$/, { minute: 10, hour: 100 }],
++ ['host.domain.verify', /^POST$/, /^\/domains\/[^/]+\/verify$/, { minute: 10, hour: 100 }],
++ ['host.project.create', /^POST$/, /^\/projects\/?$/, { minute: 5, hour: 30 }],
++];
++
++function createHostActorLimits({ env = process.env, registry = null, now } = {}) {
++ // HOST_ACTOR_LIMITS=off: a rollback lever (and what the Stage B suites use: they deploy dozens of times a minute).
++ if (/^(off|0|false)$/i.test(String(env.HOST_ACTOR_LIMITS || ''))) return function noLimits(req, res, next) { next(); };
++ const refused = registry && typeof registry.counter === 'function'
++ ? registry.counter({ name: 'host_rate_limited_total', help: 'Writes refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
++ : null;
++ const actor = (req) => {
++ const v = req.viewer;
++ if (!v) return null;
++ if (v.kind === 'user' && v.subject) return `user:${v.subject}`;
++ if (v.kind === 'service' && v.claims && v.claims.sub) return String(v.claims.sub);
++ return null;
++ };
++ const limits = createActorLimiter({
++ limits: { minute: num(env.HOST_LIMITS_MINUTE, 120), hour: num(env.HOST_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('host.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 hostActorLimits(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 = { createHostActorLimits, ROUTES };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex b275479..1f262ec 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -43,6 +43,7 @@ function createApi(ctx) {
next();
});
router.use(viewers.middleware('api'));
++ if (ctx.actorLimits) router.use(ctx.actorLimits);
const siteUrl = (hostname) => {
const u = new URL(config.baseUrl);
ddiff --git a/server/http/dashboard.js b/server/http/dashboard.js
iindex b2a9911..e850ee9 100644
---- a/server/http/dashboard.js
++++ b/server/http/dashboard.js
@@@ -25,6 +25,7 @@ function createDashboard(ctx) {
};
router.use(viewers.middleware('dashboard'));
++ if (ctx.actorLimits) router.use(ctx.actorLimits);
router.use((req, res, next) => { privateNoStore(res); next(); });
function page(req, res, status, title, body, notice, { indexable = false } = {}) {
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
nnew file mode 100644
iindex 0000000..997cada
---- /dev/null
++++ b/test/actor-limits.test.js
@@@ -0,0 +1,52 @@
++'use strict';
++// Per-actor limits on Host's writes (server/http/actor-limits.js; roadmap WS-R task 4): a signed-in person is counted
++// by subject and a service by its principal; past the limit 429 rate_limited with Retry-After before the route runs,
++// while another actor passes; deploy uploads have their own tighter number; reads and anonymous callers 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 { createHostActorLimits } = require('../server/http/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.viewer = who.startsWith('svc:') ? { kind: 'service', claims: { sub: who } } : who ? { kind: 'user', subject: who } : { kind: 'anonymous' };
++ next();
++ });
++ app.use(createHostActorLimits({ env: { HOST_LIMITS_MINUTE: '5' }, now: () => t }));
++ let ran = 0;
++ app.all('*', (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, 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('POST', '/sites/s1/rollback', 'usr_a')).status, 200);
++ const before = ran;
++ const r = await call('POST', '/sites/s1/rollback', 'usr_a');
++ assert.deepStrictEqual([r.status, r.body.code, Number(r.retry) > 0, ran], [429, 'rate_limited', true, before]);
++ assert.strictEqual((await call('POST', '/sites/s1/rollback', 'svc:tools')).status, 200, 'a service is its own actor');
++ for (let i = 0; i < 10; i++) {
++ assert.strictEqual((await call('GET', '/projects', 'usr_a')).status, 200, 'reads are never counted');
++ assert.strictEqual((await call('POST', '/projects')).status, 200, 'anonymous callers keep the address limit');
++ }
++ t += 60 * 1000;
++ const ups = [];
++ for (let i = 0; i < 6; i++) ups.push((await call('POST', '/sites/s1/deploys', 'usr_b')).status);
++ assert.deepStrictEqual(ups.slice(0, 5), [200, 200, 200, 200, 200]);
++ t += 60 * 1000;
++ assert.strictEqual((await call('POST', '/sites/s1/rollback', 'usr_a')).status, 200, 'the next minute');
++ } finally {
++ server.close();
++ }
++ assert.strictEqual(require('../server/http/actor-limits').createHostActorLimits({ env: { HOST_ACTOR_LIMITS: 'off' } }).name, 'noLimits', 'the rollback lever turns them off');
++ console.log('actor limits: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/stageb/boot.js b/test/stageb/boot.js
iindex ed4e5e1..70d000e 100644
---- a/test/stageb/boot.js
++++ b/test/stageb/boot.js
@@@ -16,6 +16,10 @@ const http = require('http');
const { startNetwork } = require('./mocks');
const { site: siteTar } = require('./tar');
++// The Stage B suites deploy dozens of times a minute as one person: per-actor limits are tested on their own
++// (test/actor-limits.test.js), so they are off here unless a test turns them on.
++if (process.env.HOST_ACTOR_LIMITS === undefined) process.env.HOST_ACTOR_LIMITS = 'off';
++
function makeClock(start = Date.parse('2026-09-22T12:00:00Z')) {
let t = start;
return { now: () => t, advance: (ms) => { t += ms; return t; }, set: (v) => { t = v; } };