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).
Changes
@@@ -13,6 +13,11 @@ OV_NETWORK_URL=http://localhost:4000OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000OV_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)@@@ -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"@@@ -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"nnew file mode 100644@@@ -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 };@@@ -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)}`);nnew file mode 100644@@@ -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); });
@@ -13,6 +13,11 @@ OV_NETWORK_URL=http://localhost:4000
@@ -13,6 +13,11 @@ OV_NETWORK_URL=http://localhost:4000
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
OV_NETWORK_LOGIN_URL=http://localhost: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
# Security — CHANGE THESE IN PRODUCTION
# Internal API key shared with OpenVibe.Live, OpenVibe.Tools, OpenVibe.Games,
# Internal API key shared with OpenVibe.Live, OpenVibe.Tools, OpenVibe.Games,
# and OpenVibe.Media for /internal/* calls (header X-Internal-Key)
# and OpenVibe.Media for /internal/* calls (header X-Internal-Key)
@@ -21,7 +21,7 @@
@@ -21,7 +21,7 @@
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.75.0",
"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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
"web-push": "^3.6.7"
@@ -1481,9 +1481,9 @@
@@ -1481,9 +1481,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"
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development 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",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.75.0",
"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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
"web-push": "^3.6.7"
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -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 };
@@ -359,6 +359,9 @@ app.locals.privateKey = privateKey;
@@ -359,6 +359,9 @@ app.locals.privateKey = privateKey;
app.locals.publicKey = publicKey;
app.locals.publicKey = publicKey;
app.locals.config = config;
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.
// Provider secrets: environment first, database fallback (server/secrets.js). Names and sources only.
console.log(`[Secrets] ${require('./secrets').summary(db)}`);
console.log(`[Secrets] ${require('./secrets').summary(db)}`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -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); });