openvibers/openvibe.network Commits

Per-actor limits on the account API's writes (roadmap WS-R task 4; openvibe-sdk 0.11.0 -> 0.12.0 openvibe-sdk/limits). server/auth/actor-limits.js counts writes by the person whose session token makes them (their subject).

OP OpenVibers committed 28 Sept 2026 · ddd87b6844 · 6 files, +158 −6

Limits, per minute and per hour:
- Every write: NETWORK_LIMITS_MINUTE / NETWORK_LIMITS_HOUR, 120 and 3000.
- Tighter numbers on top of that:
  - account export, deletion and merge: 10/40;
  - project creation: 5/30;
  - app and credential creation: 10/60;
  - theme submissions and imports: 10/60;
  - avatar: 10/60;
  - follows: 60/600;
  - blocks: 30/300.
Past a limit the request answers 429 rate_limited with Retry-After before any route runs; the refusal is logged by subject and counted in network_rate_limited_total.

Not counted, and kept on the per-address limits: reads; signed-out writes; service and app tokens, since a first-party service speaks for many people; sign-in (/api/auth, its own limiter); provider webhooks; realtime tickets.

test/actor-limits.test.js is in the chain: one person refused before the route runs while another passes; reads, signed-out writes, service tokens and sign-in never counted; a named route's own limit; the window reopening. npm test passes.

Changes

ddiff --git a/.env.example b/.env.example
iindex 9a475b84..18ffbbf2 100644
---- a/.env.example
++++ b/.env.example
@@@ -13,6 +13,11 @@ OV_NETWORK_URL=http://localhost:4000
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
OV_NETWORK_LOGIN_URL=http://localhost:4000
++# Per-actor limits on API writes by a person's session (server/auth/actor-limits.js, roadmap WS-R task 4),
++# per minute and per hour; account, project, theme, avatar, follow and block writes have tighter numbers of their own.
++#NETWORK_LIMITS_MINUTE=120
++#NETWORK_LIMITS_HOUR=3000
++
# Security — CHANGE THESE IN PRODUCTION
# Internal API key shared with OpenVibe.Live, OpenVibe.Tools, OpenVibe.Games,
# and OpenVibe.Media for /internal/* calls (header X-Internal-Key)
ddiff --git a/package-lock.json b/package-lock.json
iindex 3e68115d..2bca78cd 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -21,7 +21,7 @@
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.75.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",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
@@@ -1481,9 +1481,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 357b7e53..42ecde27 100644
---- a/package.json
++++ b/package.json
@@@ -6,7 +6,7 @@
"scripts": {
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/operator-alerts.test.js && node test/follows.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/account-merge.test.js && node test/account-data.test.js && node test/mod-principals.test.js && node test/status-incidents.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/developer-export-tokens.test.js && node test/project-usage.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/notification-events.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.test.js && node test/security-secrets.test.js && node test/security-private.test.js && node test/security-idor.test.js && node test/security-ssrf.test.js && node test/deploy-wrapper.test.js",
++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/operator-alerts.test.js && node test/follows.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/account-merge.test.js && node test/account-data.test.js && node test/mod-principals.test.js && node test/actor-limits.test.js && node test/status-incidents.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/developer-export-tokens.test.js && node test/project-usage.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/notification-events.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.test.js && node test/security-secrets.test.js && node test/security-private.test.js && node test/security-idor.test.js && node test/security-ssrf.test.js && node test/deploy-wrapper.test.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
@@@ -24,7 +24,7 @@
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.75.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",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
ddiff --git a/server/auth/actor-limits.js b/server/auth/actor-limits.js
nnew file mode 100644
iindex 00000000..069219de
---- /dev/null
++++ b/server/auth/actor-limits.js
@@@ -0,0 +1,82 @@
++/**
++ * Per-actor limits on the account API's writes (roadmap WS-R task 4; openvibe-sdk/limits).
++ *
++ * The per-address limits in server/index.js stay (120 a minute on /api, the auth limiter, and the per-route ones).
++ * This counts WRITES by the person whose session token makes them (user:<subject>, or user:<id> before a subject
++ * exists). Reads are not counted per actor, and neither are signed-out writes, nor service or app tokens (a
++ * first-party service speaks for many people): those keep the per-address limits.
++ *
++ * Every write takes NETWORK_LIMITS_MINUTE / NETWORK_LIMITS_HOUR (120 and 3000) as `network.api.write`; the table
++ * below gives sensitive writes their own, tighter numbers on top. Past a limit the request answers 429 problem+json
++ * `rate_limited` with Retry-After before any route runs; the refusal is logged by subject (never a token) and counted
++ * in network_rate_limited_total{limit,window}. Counters live in this process.
++ *
++ * Never counted: /api/auth/* (sign-in has its own limiter), /api/webhooks (providers), /api/v1/realtime tickets.
++ * /internal, /oauth, /api/health, /api/ready, /metrics and the JWKS are outside what this sees or are reads.
++ */
++'use strict';
++
++const jwt = require('jsonwebtoken');
++const { createActorLimiter } = require('openvibe-sdk/limits');
++const { isUserSessionClaims, requestToken } = require('./session');
++
++const num = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) && n > 0 ? n : d; };
++
++const EXEMPT = [/^\/auth\//, /^\/webhooks(\/|$)/, /^\/v1\/realtime(\/|$)/];
++
++/** [name, method regex, path regex (relative to /api), { minute, hour }]; the first match wins. */
++const ROUTES = [
++ // Export, deletion and merge start work in every service and send mail.
++ // (a merge takes a few steps: start, confirm, maybe a retry)
++ ['network.account', /^(POST|PUT|DELETE)$/, /^\/v1\/account(\/|$)/, { minute: 10, hour: 40 }],
++ // Developer projects: creating apps and rotating credentials mint secrets.
++ ['network.project.create', /^POST$/, /^\/v1\/projects\/?$/, { minute: 5, hour: 30 }],
++ ['network.project.credentials', /^POST$/, /^\/v1\/projects\/[^/]+\/(apps(\/[^/]+)?\/credentials.*|apps\/?)$/, { minute: 10, hour: 60 }],
++ // Theme submissions and imports land in the staff review queue.
++ ['network.theme.submit', /^POST$/, /^\/themes\/?(import\/?)?$/, { minute: 10, hour: 60 }],
++ ['network.avatar', /^(POST|PUT|DELETE)$/, /^\/profile\/avatar(\/|$)/, { minute: 10, hour: 60 }],
++ ['network.follow', /^(PUT|POST|DELETE)$/, /^\/v1\/me\/follows(\/|$)/, { minute: 60, hour: 600 }],
++ ['network.block', /^(PUT|POST|DELETE)$/, /^\/v1\/me\/blocks(\/|$)/, { minute: 30, hour: 300 }],
++];
++
++function createNetworkActorLimits({ env = process.env, publicKey, issuer, registry = null, now } = {}) {
++ let refused = null;
++ if (registry && typeof registry.counter === 'function') {
++ refused = registry.counter({ name: 'network_rate_limited_total', help: 'API writes refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] });
++ }
++ const algorithm = String(publicKey || '').includes('BEGIN') ? 'RS256' : 'HS256';
++ /** The person whose session makes the request; null for no session, a service or app token, or a bad token. */
++ function actor(req) {
++ const token = requestToken(req);
++ if (!token || !publicKey) return null;
++ try {
++ const d = jwt.verify(token, publicKey, { algorithms: [algorithm], issuer, ignoreExpiration: true });
++ if (!isUserSessionClaims(d)) return null;
++ if (d.subject_id) return `user:${d.subject_id}`;
++ return d.sub != null || d.id != null ? `user:${d.sub != null ? d.sub : d.id}` : null;
++ } catch { return null; }
++ }
++ const limits = createActorLimiter({
++ limits: { minute: num(env.NETWORK_LIMITS_MINUTE, 120), hour: num(env.NETWORK_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('network.api.write');
++ const named = ROUTES.map(([name, method, pathRe, own]) => ({ 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;
++ if (EXEMPT.some((re) => re.test(p))) return next();
++ const own = named.find((r) => r.method.test(req.method) && r.pathRe.test(p));
++ return write(req, res, (err) => (err ? next(err) : own ? own.mw(req, res, next) : next()));
++ }
++ middleware.limits = limits;
++ return middleware;
++}
++
++module.exports = { createNetworkActorLimits, EXEMPT, ROUTES };
ddiff --git a/server/index.js b/server/index.js
iindex efd87c26..4a257387 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -359,6 +359,9 @@ app.locals.privateKey = privateKey;
app.locals.publicKey = publicKey;
app.locals.config = config;
++// Per-actor limits on API writes, by the person whose session makes them (server/auth/actor-limits.js; WS-R task 4).
++app.use('/api/', require('./auth/actor-limits').createNetworkActorLimits({ publicKey, issuer: config.jwt.issuer, registry: observability.registry }));
++
// Provider secrets: environment first, database fallback (server/secrets.js). Names and sources only.
console.log(`[Secrets] ${require('./secrets').summary(db)}`);
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
nnew file mode 100644
iindex 00000000..2f5dd644
---- /dev/null
++++ b/test/actor-limits.test.js
@@@ -0,0 +1,62 @@
++'use strict';
++// Per-actor limits on the account API's writes (server/auth/actor-limits.js; roadmap WS-R task 4): a person's session
++// writes count by subject, 429 problem+json rate_limited with Retry-After before the route runs, while another person
++// passes; sensitive writes have tighter numbers on top; reads, signed-out writes, service tokens and sign-in routes are
++// never counted; the window reopens.
++// node test/actor-limits.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const http = require('http');
++const express = require('express');
++const jwt = require('jsonwebtoken');
++const { createNetworkActorLimits, ROUTES } = require('../server/auth/actor-limits');
++
++const ISS = 'https://openvibe.network';
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
++const session = (id, subject) => jwt.sign({ sub: id, id, subject_id: subject, username: `u${id}` }, keys.privateKey, { algorithm: 'RS256', issuer: ISS, expiresIn: '1h' });
++const service = jwt.sign({ sub: 'svc:live', actor_type: 'service', cap: [] }, keys.privateKey, { algorithm: 'RS256', issuer: ISS, expiresIn: '1h' });
++console.warn = () => {};
++
++(async () => {
++ let t = Date.UTC(2026, 8, 28, 3, 0, 0);
++ const counted = [];
++ const app = express();
++ app.use('/api/', createNetworkActorLimits({ env: { NETWORK_LIMITS_MINUTE: '5' }, publicKey: keys.publicKey, issuer: ISS, registry: { counter: () => ({ inc: (l) => counted.push(l) }) }, 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 = session(1, 'usr_01JAB2C3D4E5F6G7H8J9K0MNP1');
++ const bob = session(2, 'usr_01JAB2C3D4E5F6G7H8J9K0MNP2');
++ try {
++ for (let i = 0; i < 5; i++) assert.strictEqual((await call('PUT', '/api/themes/me', ann)).status, 200);
++ const before = ran;
++ const r = await call('PUT', '/api/themes/me', ann);
++ assert.deepStrictEqual([r.status, r.body.code, Number(r.retry) > 0, ran], [429, 'rate_limited', true, before]);
++ assert.strictEqual((await call('PUT', '/api/themes/me', bob)).status, 200, 'another person passes');
++ for (let i = 0; i < 10; i++) {
++ assert.strictEqual((await call('GET', '/api/v1/me/follows', ann)).status, 200, 'reads are not counted');
++ assert.strictEqual((await call('POST', '/api/v1/projects')).status, 200, 'signed-out writes are not counted');
++ assert.strictEqual((await call('POST', '/api/v1/projects', service)).status, 200, 'service tokens are not counted');
++ assert.strictEqual((await call('POST', '/api/auth/login', ann)).status, 200, 'sign-in has its own limiter');
++ }
++ t += 60 * 1000;
++ const carol = session(3, 'usr_01JAB2C3D4E5F6G7H8J9K0MNP3');
++ for (let i = 0; i < 5; i++) assert.strictEqual((await call('POST', '/api/v1/projects', carol)).status, 200);
++ const p = await call('POST', '/api/v1/projects', carol);
++ assert.strictEqual(p.status, 429);
++ assert.ok(ROUTES.find((x) => x[0] === 'network.theme.submit')[2].test('/themes/import'));
++ assert.ok(ROUTES.find((x) => x[0] === 'network.account')[2].test('/v1/account/export'));
++ t += 60 * 1000;
++ assert.strictEqual((await call('PUT', '/api/themes/me', ann)).status, 200, 'the next minute');
++ assert.ok(counted.some((l) => l.limit === 'network.api.write'));
++ } finally {
++ server.close();
++ }
++ console.log('actor limits: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });