openvibers/openvibe.tools Commits

Per-actor rate limits in every Tools app (roadmap WS-R task 4, openvibe-sdk v0.12.0 limits; openvibe-tools-shared 1.2.0), only where the guard has no per-caller limit of its own. apps/_shared/actor-limits.js makes one limiter per app from the SDK the app passes in (apps/_shared takes no dependencies). It reads the guard's resolved caller: a person as user:usr_…, a service or app by its principal, a browser session or nobody by the guard's hashed address key, so no raw address is ever logged. The guard, its tiered quotas, the older express-rate-limit limiters and TOOLS_GUARD are unchanged. Past a limit a route answers 429 problem+json rate_limited with Retry-After, logs one [Limits] line and counts tools_rate_limited_total{limit,window}.

OP OpenVibers committed 28 Sept 2026 · e55469f2bf · 31 files, +481 −37

- Registry reads (GET/HEAD /api/v1/tools[/:id[/schema]]) in all eight apps, by a signed-in person or a third-party principal (app:, mod:): TOOLS_LIMITS_MINUTE / TOOLS_LIMITS_HOUR, 120 and 3000. A refusal carries Access-Control-Allow-Origin * like the registry. Signed-out reads keep only the per-address /api/ limit, because many visitors share a carrier or campus address. A first-party service reading for itself is not counted, and neither is the gateway's minute poll of each satellite.
- A backstop on job submits and retries and on runs, in the gateway (run API and jobs facade), img, audio and docs: a person or an address 600 a minute and 20 000 an hour, a principal 3000 and 100 000. That is above the guard's highest allowances (tools-run: a person 480 a minute, a service 2400, one address's sessions together 540), so the guard still decides every normal caller's allowance, while a runaway caller is stopped even when the guard only reports (the default). A run the gateway streams to a satellite is counted there again.
- The gateway's admin route GET /api/internal/analytics: 30 a minute and 300 an hour, after the internal key and before the fan-out to every satellite.

Never limited: health, ready, release.json, metrics, the pages, /api/internal/jobs/:id and the signed /internal/events deliveries. openvibe-sdk is pinned at v0.12.0 in gateway, img, audio and docs, and added to text, yt, maps and food. Every app's openvibeToolsShared range is now ^1.2.0, with a CHANGELOG entry. README and apps/gateway/.env.example document it.

apps/_shared/test/actor-limits.test.js runs the real guard on a fixed limiter clock. A person's fourth registry read in a minute is refused with Retry-After 45 and a CORS header while another person passes. A third-party app is counted, a first-party service is not, signed-out reads (nobody, or a session) from one address all pass, and the next minute reopens. A person's 601st submit is refused while another person submits, runs have their own budget, and a service goes on past 600. The admin route is refused after 30. Health is never limited, and refusals are logged with no token or address and counted. apps/gateway/test/actor-limits.test.js checks the wiring on the real gateway process: one read a window per person, another person passes, and signed-out reads and health are never refused. npm test 55/55.

Changes

ddiff --git a/README.md b/README.md
iindex 7feb818..b643c0e 100644
---- a/README.md
++++ b/README.md
@@@ -347,6 +347,31 @@ Environment: `TOOLS_GUARD`, `TOOLS_GUARD_LIMITS` (JSON merged into the table, e.
`TOOLS_JOBS_MAX_QUEUED`, `TOOLS_JOBS_MAX_ACTIVE_PER_ADDRESS`, `TOOLS_DISK_BUDGET_MB`, `TOOLS_MAX_INPUT_PIXELS`, `TOOLS_PORTS_PER_CALLER`,
`TOOLS_PORT_TARGETS_PER_CALLER`, `AUDIO_MAX_DURATION`, `OV_TOOLS_AUDIENCE` (white-label installs).
++## Per-actor limits (`apps/_shared/actor-limits.js`)
++
++Roadmap WS-R task 4, openvibe-sdk/limits, one limiter per app, only where the guard has no per-caller
++limit of its own. The guard, its quotas, the older limiters and `TOOLS_GUARD` are unchanged; these limits
++read the guard's resolved caller (`guard.caller`): a person as `user:usr_…`, a service or app by its
++principal, a browser session or nobody by the guard's hashed address key (`ip:<HMAC>`, never a raw
++address). Past a limit: `429` problem+json `rate_limited` with `Retry-After`, one `[Limits]` log line and
++`tools_rate_limited_total{limit,window}`.
++
++| Where | Routes | Per caller, a minute / an hour |
++|---|---|---|
++| every app | Registry reads `GET`/`HEAD /api/v1/tools[/:id[/schema]]` by a signed-in person or a third-party principal (`app:…`, `mod:…`) | `TOOLS_LIMITS_MINUTE` / `TOOLS_LIMITS_HOUR` (120 / 3000) |
++| gateway, img, audio, docs | Backstop on job submits and retries (`POST /api/v1/jobs`, `…/:id/retry`) and runs (`POST /api/v1/tools/:id/run`) | a person or an address 600 / 20 000; a principal 3000 / 100 000 |
++| gateway | `GET /api/internal/analytics` (the Network admin's analytics page, after the internal key) | 30 / 300 |
++
++Signed-out registry reads keep only the per-address `/api/` limit (many visitors share a carrier or campus
++address); a first-party service (`svc:…`) reading for itself is not counted. The backstop sits above the
++guard's highest allowances (tools-run: a person 480 a minute, a service 2400, one address's sessions
++together 540; the day allowances average at most about 2100 and 21 000 an hour), so the guard still
++decides every normal caller's allowance, and a runaway caller is stopped even while the guard only
++reports. A run the gateway streams to a satellite is counted there again. Never limited: `/api/health`,
++`/api/ready`, `/release.json`, `/metrics`, the pages, `/api/internal/jobs/:id` and the signed
++`/internal/events` deliveries. Tests: `apps/_shared/test/actor-limits.test.js` (fixed clock) and
++`apps/gateway/test/actor-limits.test.js` (the real gateway).
++
## Canonical hosts and the service registry
Every satellite honours the gateway's `X-OV-Tool` / `X-OV-Host-Role` / `X-OV-Canonical-Host` / `X-OV-Short-Host`
ddiff --git a/apps/_shared/CHANGELOG.md b/apps/_shared/CHANGELOG.md
iindex 6b81876..4575906 100644
---- a/apps/_shared/CHANGELOG.md
++++ b/apps/_shared/CHANGELOG.md
@@@ -4,6 +4,19 @@ The Tools apps' shared runtime. Each app declares the range it needs as `"openvi
its `package.json`, and refuses to boot when this checkout does not satisfy it (`version.js`). A
breaking change bumps the major and every app's range in the same commit.
++## 1.2.0 — 2026-09-27
++
++Additive (roadmap WS-R task 4):
++
++- `actor-limits.js`: `createToolsLimits({ app, createActorLimiter, guard, registry })`, per-actor
++ limits (openvibe-sdk/limits, which the app passes in) only where the guard has no per-caller limit:
++ `registryReads` (GET/HEAD `/api/v1/tools[/:id[/schema]]` by a signed-in person or a third-party
++ principal, `TOOLS_LIMITS_MINUTE` / `TOOLS_LIMITS_HOUR`, 120 and 3000; signed-out reads and first-party
++ services are not counted), `backstop(name)` (a ceiling above the guard's quotas on job submits,
++ retries and runs: a person or address 600 a minute and 20 000 an hour, a principal 3000 and 100 000)
++ and `admin` (30 a minute, 300 an hour). Callers come from `guard.caller`; the guard is unchanged.
++ Refusals: 429 problem+json `rate_limited`, a `[Limits]` log line and `tools_rate_limited_total`.
++
## 1.1.0 — 2026-09-26
Additive (roadmap WS-N task 4):
ddiff --git a/apps/_shared/actor-limits.js b/apps/_shared/actor-limits.js
nnew file mode 100644
iindex 0000000..b00dbb5
---- /dev/null
++++ b/apps/_shared/actor-limits.js
@@@ -0,0 +1,125 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// Per-actor rate limits (roadmap WS-R task 4; openvibe-sdk/limits), one limiter per app, only where
++// the guard (./guard) has no per-caller limit of its own. The guard is not changed: its tiered quotas
++// (quotaClass × tier × cost, day allowances), the older express-rate-limit limiters, the per-target
++// throttle and TOOLS_GUARD's report/enforce mode stay exactly as they are. These limits read the
++// guard's resolved caller (guard.caller) and add:
++//
++// registry reads GET/HEAD /api/v1/tools[/:id[/schema]] by a signed-in person or a third-party
++// principal (app:…, mod:…): TOOLS_LIMITS_MINUTE / TOOLS_LIMITS_HOUR, 120 and 3000.
++// Signed-out reads (a browser session or nobody) keep only the per-address /api/
++// limit, since many visitors share a carrier or campus address; a first-party
++// service (svc:…) reading for itself is not counted, and neither is the gateway's
++// minute poll of each satellite's list (loopback, no token).
++// backstop job submits and retries (POST /api/v1/jobs, /api/v1/jobs/:id/retry) and runs
++// (POST /api/v1/tools/:id/run): a ceiling ABOVE the guard's quotas, so it never
++// decides a normal caller's allowance (the guard does) and still stops a runaway
++// one while the guard only reports (TOOLS_GUARD=report, the default).
++// admin the gateway's /api/internal/analytics (the Network admin's analytics page).
++//
++// Counted: a person as user:usr_…, a service or app by its principal (svc:…, app:…), a browser session
++// or nobody by the guard's hashed address key (ip:<HMAC>), so dropping the session cookie resets
++// nothing and no raw address is ever logged. Past a limit: 429 problem+json `rate_limited` with
++// Retry-After, one [Limits] log line and tools_rate_limited_total{limit,window}. Counters live in the
++// process: a restart forgets them.
++//
++// Never limited: /api/health, /api/ready, /release.json, /metrics, the pages, /api/internal/jobs/:id
++// (the facade's loopback lookup) and the signed /internal/events deliveries (sign-out cutoffs).
++//
++// apps/_shared takes no dependencies: the app passes openvibe-sdk/limits' createActorLimiter.
++//
++// const limits = createToolsLimits({ app: 'img', createActorLimiter: require('openvibe-sdk/limits').createActorLimiter, guard, registry: obs.registry });
++// app.use(limits.registryReads); // before createToolsApi
++// setupJobs({ …, limiters: [limits.backstop('tools.job.create'), …] });
++// ═══════════════════════════════════════════════════════════════
++
++const REGISTRY_RE = /^\/api\/v1\/tools(?:\/[^/]+(?:\/schema)?)?\/?$/;
++const FIRST_PARTY = /^svc:/;
++
++// The backstop, per caller. The guard's highest per-minute allowance is tools-run's: a person 480, a
++// principal (service tier) 2400, and all browser sessions of one address together 3 × 180 = 540; its day
++// allowances average at most about 2100 an hour for a person and 21 000 for a service. These ceilings sit
++// above every one of them, so only a caller far beyond its quota (while the guard reports) meets them.
++const BACKSTOP = {
++ principal: { minute: 3000, hour: 100000 },
++ person: { minute: 600, hour: 20000 },
++};
++
++// The Network admin's analytics page: each call reads up to 365 days of analytics in every satellite.
++// An admin opens the page now and then; 30 a minute leaves room for reloads.
++const ADMIN = { minute: 30, hour: 300 };
++
++const int = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : d; };
++
++/**
++ * @param {object} o
++ * @param {string} o.app this app's name (the log line)
++ * @param {Function} o.createActorLimiter openvibe-sdk/limits
++ * @param {object} o.guard this app's guard (guard.caller resolves who is asking)
++ * @param {object} [o.registry] the app's metrics registry (tools_rate_limited_total)
++ * @param {object} [o.env] TOOLS_LIMITS_MINUTE, TOOLS_LIMITS_HOUR
++ * @param {object} [o.log]
++ * @param {() => number} [o.now] the limiter's clock (tests)
++ */
++function createToolsLimits(o) {
++ const env = o.env || process.env;
++ const log = o.log || console;
++ const guard = o.guard;
++ const defaults = { minute: Math.max(1, int(env.TOOLS_LIMITS_MINUTE, 120)), hour: Math.max(1, int(env.TOOLS_LIMITS_HOUR, 3000)) };
++ const refused = o.registry
++ ? o.registry.counter({ name: 'tools_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
++ : null;
++
++ /** user:usr_… or the principal's sub; a session or nobody by the guard's hashed address key. */
++ function actor(req) {
++ const c = guard.caller(req);
++ if (c.kind === 'principal' || c.kind === 'user') return c.key;
++ return c.ipKey;
++ }
++
++ const limiter = o.createActorLimiter({
++ limits: defaults,
++ actor,
++ now: o.now,
++ onLimited(e) {
++ // The actor is a subject id, a principal or a hashed address key, never a token or an address.
++ log.warn(`[Limits] ${o.app}: ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
++ if (refused) refused.inc({ limit: e.name, window: e.window });
++ },
++ });
++
++ /** Counted on registry reads: a signed-in person or a third-party principal. */
++ function countedRead(req) {
++ const c = guard.caller(req);
++ if (c.kind === 'user') return true;
++ return c.kind === 'principal' && !FIRST_PARTY.test(String(c.key));
++ }
++
++ const read = limiter('tools.tool.read');
++ function registryReads(req, res, next) {
++ if ((req.method !== 'GET' && req.method !== 'HEAD') || !REGISTRY_RE.test(req.path) || !countedRead(req)) return next();
++ // The registry is open to every origin: a refusal must be readable there too.
++ res.setHeader('Access-Control-Allow-Origin', '*');
++ return read(req, res, next);
++ }
++
++ const backstops = new Map();
++ /** The ceiling above the guard's quotas for `name` (tools.job.create, tools.tool.run). */
++ function backstop(name) {
++ if (!backstops.has(name)) {
++ const principal = limiter(name, BACKSTOP.principal);
++ const person = limiter(name, BACKSTOP.person);
++ backstops.set(name, function actorBackstop(req, res, next) {
++ return (guard.caller(req).kind === 'principal' ? principal : person)(req, res, next);
++ });
++ }
++ return backstops.get(name);
++ }
++
++ const admin = limiter('tools.analytics.read', ADMIN);
++
++ return { registryReads, backstop, admin, limiter, actor, countedRead, defaults };
++}
++
++module.exports = { createToolsLimits, REGISTRY_RE, BACKSTOP, ADMIN };
ddiff --git a/apps/_shared/package.json b/apps/_shared/package.json
iindex 214a4a6..68fb28d 100644
---- a/apps/_shared/package.json
++++ b/apps/_shared/package.json
@@@ -1,7 +1,7 @@
{
"name": "openvibe-tools-shared",
-- "version": "1.1.0",
++ "version": "1.2.0",
"private": true,
-- "description": "The Tools apps' shared runtime (apps/_shared): guard, egress, jobs, uploads, usage, internal auth, release, graceful stop. No dependencies: its modules take packages from the app that requires them.",
++ "description": "The Tools apps' shared runtime (apps/_shared): guard, per-actor limits, egress, jobs, uploads, usage, internal auth, release, graceful stop. No dependencies: its modules take packages from the app that requires them.",
"license": "MIT"
}
ddiff --git a/apps/_shared/test/actor-limits.test.js b/apps/_shared/test/actor-limits.test.js
nnew file mode 100644
iindex 0000000..d104f0e
---- /dev/null
++++ b/apps/_shared/test/actor-limits.test.js
@@@ -0,0 +1,141 @@
++'use strict';
++// Per-actor limits (apps/_shared/actor-limits.js, roadmap WS-R task 4) on the real guard's callers, with a
++// fixed limiter clock:
++// • registry reads: a signed-in person past TOOLS_LIMITS_MINUTE gets 429 problem+json rate_limited with
++// Retry-After (readable from any origin) while another person passes; a third-party app is counted,
++// a first-party service is not; signed-out reads (nobody, or a browser session) are never refused per
++// actor; the next minute reopens
++// • the backstop on job submits and runs sits above the guard's quotas: a person's 601st submit in a
++// minute is refused and another person passes; a service principal goes on past 600
++// • the admin analytics route: 30 a minute
++// • health is never limited; refusals are logged (no token, no raw address) and counted
++const assert = require('assert');
++const crypto = require('crypto');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { dep } = require('./deps');
++
++const express = dep('express');
++const cookieParser = dep('cookie-parser');
++const contracts = dep('openvibe-contracts');
++const metrics = dep('openvibe-shared/metrics');
++const { createActorLimiter } = dep('openvibe-sdk/limits');
++const { createGuard, TRUST_PROXY } = require('../guard');
++const { createToolsLimits, BACKSTOP } = require('../actor-limits');
++
++const ISSUER = 'https://openvibe.network';
++const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
++function jwt(claims) {
++ const input = `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64(claims)}`;
++ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`;
++}
++const t = () => Math.floor(Date.now() / 1000);
++const ALICE = 'usr_01JAAAAAAAAAAAAAAAAAAAAAAA';
++const BOB = 'usr_01JBBBBBBBBBBBBBBBBBBBBBBB';
++const userToken = (sid) => jwt({ iss: ISSUER, sub: '42', id: 42, subject_id: sid, username: 'someone', aud: ['openvibe.tools', 'openvibe.network'], iat: t(), exp: t() + 3600 });
++const principal = (sub, extra = {}) => contracts.serviceAuth.signServiceToken({ iss: ISSUER, sub, actor_type: sub.startsWith('app:') ? 'app' : 'service', aud: ['openvibe.tools'], cap: ['tools.tool.read', 'tools.job.create', 'tools.tool.run'], ns: [], iat: t(), exp: t() + 900, jti: `tok_${crypto.randomBytes(12).toString('hex')}`, ...extra }, privateKey);
++const APP = principal('app:app_01JCCCCCCCCCCCCCCCCCCCCCCC', { project_id: 'prj_01JCCCCCCCCCCCCCCCCCCCCCCC', env: 'production' });
++const SVC = principal('svc:network');
++
++(async () => {
++ // The limiter's clock: 15 s into a minute, so the minute window has 45 s left. Registry reads: 3 a minute.
++ let clock = Date.UTC(2026, 8, 27, 12, 0, 15);
++ const lines = [];
++ const log = { log() {}, error() {}, warn: (m) => lines.push(String(m)) };
++ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-actor-limits-'));
++ const guard = createGuard({
++ app: 'test', dataDir, Database: dep('better-sqlite3'), contracts, specs: [], issuer: ISSUER,
++ keys: { get: () => publicKey, ensure: async () => publicKey }, env: { TOOLS_GUARD: 'report' }, log, pruneIntervalMs: 0,
++ });
++ const registry = metrics.createRegistry();
++ const limits = createToolsLimits({ app: 'test', createActorLimiter, guard, registry, log, now: () => clock, env: { TOOLS_LIMITS_MINUTE: '3', TOOLS_LIMITS_HOUR: '100' } });
++
++ const app = express();
++ app.set('trust proxy', TRUST_PROXY);
++ app.use(cookieParser());
++ app.use(guard.identify);
++ app.use(limits.registryReads);
++ const ok = (_req, res) => res.json({ ok: true });
++ app.get(['/api/v1/tools', '/api/v1/tools/:id', '/api/v1/tools/:id/schema'], ok);
++ app.post('/api/v1/jobs', limits.backstop('tools.job.create'), ok);
++ app.post('/api/v1/tools/:id/run', limits.backstop('tools.tool.run'), ok);
++ app.get('/api/internal/analytics', limits.admin, ok);
++ app.get('/api/health', ok);
++ const srv = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
++ const base = `http://127.0.0.1:${srv.address().port}`;
++ const call = async (p, { token, cookie, ip = '203.0.113.7', method = 'GET' } = {}) => {
++ const headers = { 'X-Forwarded-For': ip };
++ if (token) headers.Authorization = `Bearer ${token}`;
++ if (cookie) headers.Cookie = cookie;
++ const r = await fetch(base + p, { method, headers });
++ const text = await r.text();
++ let body = null; try { body = JSON.parse(text); } catch { /* not JSON */ }
++ return { status: r.status, h: r.headers, body, text };
++ };
++
++ try {
++ // ── Registry reads ──
++ const alice = userToken(ALICE);
++ for (let i = 0; i < 3; i++) assert.strictEqual((await call('/api/v1/tools', { token: alice })).status, 200, `read ${i + 1}`);
++ let r = await call('/api/v1/tools/webp-converter/schema', { token: alice });
++ assert.strictEqual(r.status, 429, r.text);
++ assert.strictEqual(r.h.get('retry-after'), '45');
++ assert.strictEqual(r.h.get('content-type'), 'application/problem+json');
++ assert.strictEqual(r.h.get('access-control-allow-origin'), '*', 'readable from any origin, like the registry');
++ assert.deepStrictEqual([r.body.code, r.body.status, r.body.retry_after_seconds], ['rate_limited', 429, 45]);
++ assert.ok(r.body.detail.includes('tools.tool.read'), r.body.detail);
++ assert.strictEqual((await call('/api/v1/tools', { token: userToken(BOB) })).status, 200, 'another person still passes');
++
++ for (let i = 0; i < 3; i++) assert.strictEqual((await call('/api/v1/tools', { token: APP })).status, 200);
++ assert.strictEqual((await call('/api/v1/tools', { token: APP })).status, 429, 'a third-party app is counted');
++ for (let i = 0; i < 6; i++) assert.strictEqual((await call('/api/v1/tools', { token: SVC })).status, 200, 'a first-party service is not counted');
++
++ // Signed-out reads keep only the per-address limit: nobody, or a browser session, from one address.
++ for (let i = 0; i < 8; i++) {
++ assert.strictEqual((await call('/api/v1/tools')).status, 200, `signed-out read ${i + 1}`);
++ assert.strictEqual((await call('/api/v1/tools/webp-converter', { cookie: `ov_tools_jobs=${'s'.repeat(40)}` })).status, 200);
++ }
++
++ clock += 45 * 1000;
++ assert.strictEqual((await call('/api/v1/tools', { token: alice })).status, 200, 'the next minute reopens');
++
++ // ── The backstop above the guard's quotas ──
++ clock = Date.UTC(2026, 8, 27, 12, 5, 0);
++ assert.deepStrictEqual(BACKSTOP.person, { minute: 600, hour: 20000 });
++ for (let i = 0; i < 600; i++) {
++ const s = (await call('/api/v1/jobs', { token: alice, method: 'POST' })).status;
++ if (s !== 200) assert.fail(`submit ${i + 1}: ${s}`);
++ }
++ r = await call('/api/v1/jobs', { token: alice, method: 'POST' });
++ assert.deepStrictEqual([r.status, r.body.code, r.h.get('retry-after')], [429, 'rate_limited', '60']);
++ assert.strictEqual((await call('/api/v1/jobs', { token: userToken(BOB), method: 'POST' })).status, 200, 'another person still submits');
++ assert.strictEqual((await call('/api/v1/tools/webp-converter/run', { token: alice, method: 'POST' })).status, 200, 'runs are their own budget');
++ for (let i = 0; i < 601; i++) {
++ const s = (await call('/api/v1/jobs', { token: SVC, method: 'POST' })).status;
++ if (s !== 200) assert.fail(`service submit ${i + 1}: ${s}`);
++ }
++
++ // ── The admin analytics route: 30 a minute ──
++ for (let i = 0; i < 30; i++) assert.strictEqual((await call('/api/internal/analytics', { ip: '127.0.0.1' })).status, 200);
++ assert.strictEqual((await call('/api/internal/analytics', { ip: '127.0.0.1' })).status, 429);
++
++ // ── Never limited; logged and counted ──
++ for (let i = 0; i < 8; i++) assert.strictEqual((await call('/api/health', { token: alice })).status, 200);
++ assert.ok(lines.includes(`[Limits] test: tools.tool.read: user:${ALICE} refused, over 3 per minute`), lines.join('\n'));
++ assert.ok(lines.includes(`[Limits] test: tools.job.create: user:${ALICE} refused, over 600 per minute`), lines.join('\n'));
++ const limitLines = lines.filter((l) => l.startsWith('[Limits]'));
++ assert.ok(!limitLines.some((l) => /Bearer|eyJ|203\.0\.113|127\.0\.0\.1/.test(l)), `no token or raw address in the log:\n${limitLines.join('\n')}`);
++ const text = registry.metrics();
++ const found = text.split('\n').filter((l) => l.includes('tools_rate_limited_total')).join('\n');
++ assert.ok(/tools_rate_limited_total\{limit="tools.tool.read",window="minute"\} 2/.test(text), found);
++ assert.ok(/tools_rate_limited_total\{limit="tools.job.create",window="minute"\} 1/.test(text), found);
++ assert.ok(/tools_rate_limited_total\{limit="tools.analytics.read",window="minute"\} 1/.test(text), found);
++ console.log('actor limits: registry reads, backstop, admin, never-limited routes, logs and metrics');
++ } finally {
++ await new Promise((r) => srv.close(r));
++ guard.close();
++ fs.rmSync(dataDir, { recursive: true, force: true });
++ }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/apps/audio/package-lock.json b/apps/audio/package-lock.json
iindex ad2148c..5d4c5b0 100644
---- a/apps/audio/package-lock.json
++++ b/apps/audio/package-lock.json
@@@ -20,7 +20,7 @@
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.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.0",
"uuid": "^11.1.0"
}
@@@ -1175,9 +1175,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/apps/audio/package.json b/apps/audio/package.json
iindex 9a4b4f3..6fd2c1e 100644
---- a/apps/audio/package.json
++++ b/apps/audio/package.json
@@@ -9,7 +9,7 @@
"dev": "node --watch server/index.js"
},
"license": "MIT",
-- "openvibeToolsShared": "^1.0.0",
++ "openvibeToolsShared": "^1.2.0",
"dependencies": {
"better-sqlite3": "^11.0.0",
"cookie-parser": "^1.4.7",
@@@ -22,7 +22,7 @@
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.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.0",
"uuid": "^11.1.0"
}
ddiff --git a/apps/audio/server/index.js b/apps/audio/server/index.js
iindex e2e5c4a..beb2c01 100644
---- a/apps/audio/server/index.js
++++ b/apps/audio/server/index.js
@@@ -134,6 +134,10 @@ app.use('/api/', apiLimiter, guard.apiQuota);
const toolRegistry = createLocalRegistry({ specs: require('./descriptors').SPECS, statusOf: requiresStatus((p) => (p === 'ffmpeg' ? !!which(process.env.FFMPEG_PATH || 'ffmpeg') : true)) });
// A signed-in person's page view of a tool goes into their tools.usage module (the launchers' recent tools).
app.use(require('../../_shared/usage').usagePages({ snapshot: toolRegistry.snapshot }));
++// Per-actor limits (apps/_shared/actor-limits.js, roadmap WS-R task 4): signed-in registry reads, and a backstop above the
++// guard's quotas on job submits, retries and runs.
++const actorLimits = require('../../_shared/actor-limits').createToolsLimits({ app: 'audio', createActorLimiter: require('openvibe-sdk/limits').createActorLimiter, guard, registry: obs.registry });
++app.use(actorLimits.registryReads);
app.use(createToolsApi({ snapshot: toolRegistry.snapshot }));
// ── Analytics Middleware ─────────────────────────────────────
@@@ -273,7 +277,7 @@ const jobs = jobsRuntime.setupJobs({
getPublicKey: guard.keys.get, issuer: config.networkUrl, guard,
define: defineJobs,
receive: uploadAny,
-- limiters: [burstLimiter, processLimiter, guard.toolQuota(hostTool)],
++ limiters: [actorLimits.backstop('tools.job.create'), burstLimiter, processLimiter, guard.toolQuota(hostTool)],
jobTool: (req, type, input) => { const d = guard.toolForJob('audio.process', String((input && input.tool) || req.ctx.defaultOp || 'convert'), req.ctx.toolId); return d ? d.id : null; },
defaults(req, input) {
const out = { ...input };
@@@ -290,7 +294,7 @@ const jobs = jobsRuntime.setupJobs({
const runApi = satelliteRunApi({
app: 'audio', guard, contracts, snapshot: toolRegistry.snapshot, system: () => jobs,
multer: require('multer'), uploadsDir: path.resolve(config.uploadsDir), ...ajvFrom(require), ports: () => satellitePorts(),
-- limiters: [burstLimiter, processLimiter],
++ limiters: [actorLimits.backstop('tools.tool.run'), burstLimiter, processLimiter],
});
app.use(runApi.handle);
ddiff --git a/apps/docs/package-lock.json b/apps/docs/package-lock.json
iindex 14a60d9..fcef55b 100644
---- a/apps/docs/package-lock.json
++++ b/apps/docs/package-lock.json
@@@ -19,7 +19,7 @@
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.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.0",
"pdf-lib": "^1.17.1",
"sharp": "^0.33.5",
@@@ -1254,9 +1254,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/apps/docs/package.json b/apps/docs/package.json
iindex c183bff..cc98f9a 100644
---- a/apps/docs/package.json
++++ b/apps/docs/package.json
@@@ -9,7 +9,7 @@
"dev": "node --watch server/index.js"
},
"license": "MIT",
-- "openvibeToolsShared": "^1.0.0",
++ "openvibeToolsShared": "^1.2.0",
"dependencies": {
"better-sqlite3": "^11.0.0",
"cookie-parser": "^1.4.7",
@@@ -21,7 +21,7 @@
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.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.0",
"pdf-lib": "^1.17.1",
"sharp": "^0.33.5",
ddiff --git a/apps/docs/server/index.js b/apps/docs/server/index.js
iindex 9e8f32f..71f5fe2 100644
---- a/apps/docs/server/index.js
++++ b/apps/docs/server/index.js
@@@ -151,6 +151,10 @@ const pdfPrograms = require('./tools/pdf');
const toolRegistry = createLocalRegistry({ specs: require('./descriptors').SPECS, statusOf: requiresStatus((p) => (pdfPrograms[p] ? pdfPrograms[p].available() : true)) });
// A signed-in person's page view of a tool goes into their tools.usage module (the launchers' recent tools).
app.use(require('../../_shared/usage').usagePages({ snapshot: toolRegistry.snapshot }));
++// Per-actor limits (apps/_shared/actor-limits.js, roadmap WS-R task 4): signed-in registry reads, and a backstop above the
++// guard's quotas on job submits, retries and runs.
++const actorLimits = require('../../_shared/actor-limits').createToolsLimits({ app: 'docs', createActorLimiter: require('openvibe-sdk/limits').createActorLimiter, guard, registry: obs.registry });
++app.use(actorLimits.registryReads);
app.use(createToolsApi({ snapshot: toolRegistry.snapshot }));
// ── Analytics Middleware ─────────────────────────────────────
@@@ -324,7 +328,7 @@ const jobs = jobsRuntime.setupJobs({
getPublicKey: guard.keys.get, issuer: config.networkUrl, guard,
define: (system) => defineJobs(system, pool),
receive: uploadAny,
-- limiters: [burstLimiter, processLimiter, guard.toolQuota(hostTool)],
++ limiters: [actorLimits.backstop('tools.job.create'), burstLimiter, processLimiter, guard.toolQuota(hostTool)],
jobTool: (req, type, input) => { const op = String((input && input.tool) || req.ctx.defaultOp || ''); const d = op ? guard.toolForJob('docs.process', op, req.ctx.toolId) : null; return d ? d.id : null; },
defaults(req, input) {
const out = { ...input };
@@@ -341,7 +345,7 @@ const jobs = jobsRuntime.setupJobs({
const runApi = satelliteRunApi({
app: 'docs', guard, contracts, snapshot: toolRegistry.snapshot, system: () => jobs,
multer: require('multer'), uploadsDir: path.resolve(config.uploadsDir), ...ajvFrom(require), ports: () => satellitePorts(),
-- limiters: [burstLimiter, processLimiter],
++ limiters: [actorLimits.backstop('tools.tool.run'), burstLimiter, processLimiter],
});
app.use(runApi.handle);
ddiff --git a/apps/food/package-lock.json b/apps/food/package-lock.json
iindex 32c5052..09286c3 100644
---- a/apps/food/package-lock.json
++++ b/apps/food/package-lock.json
@@@ -15,6 +15,7 @@
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.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.0"
}
},
@@@ -967,6 +968,15 @@
"wrappy": "1"
}
},
++ "node_modules/openvibe-sdk": {
++ "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"
++ }
++ },
"node_modules/openvibe-shared": {
"version": "1.23.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0",
ddiff --git a/apps/food/package.json b/apps/food/package.json
iindex 7b725b3..d3a91b0 100644
---- a/apps/food/package.json
++++ b/apps/food/package.json
@@@ -8,7 +8,7 @@
"start": "node server/index.js",
"dev": "node --watch server/index.js"
},
-- "openvibeToolsShared": "^1.0.0",
++ "openvibeToolsShared": "^1.2.0",
"dependencies": {
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
@@@ -17,6 +17,7 @@
"better-sqlite3": "^11.0.0",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.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.0"
}
}
ddiff --git a/apps/food/server/index.js b/apps/food/server/index.js
iindex 1640f90..3918614 100644
---- a/apps/food/server/index.js
++++ b/apps/food/server/index.js
@@@ -101,6 +101,10 @@ app.use('/api/', guard.apiQuota);
// gateway (openvibe.tools) answers the same routes for every tool and reads this list for status.
// The food finder is built on the maps backend; its descriptor lives with the maps app's.
const toolRegistry = createLocalRegistry({ specs: require('../../maps/server/descriptors').SPECS.filter(s => s.id === 'food') });
++// Per-actor limits (apps/_shared/actor-limits.js, roadmap WS-R task 4): signed-in registry reads (signed-out reads keep
++// the per-address limit only).
++const actorLimits = require('../../_shared/actor-limits').createToolsLimits({ app: 'food', createActorLimiter: require('openvibe-sdk/limits').createActorLimiter, guard, registry: obs.registry });
++app.use(actorLimits.registryReads);
app.use(createToolsApi({ snapshot: toolRegistry.snapshot }));
// ── Analytics Middleware ─────────────────────────────────────
ddiff --git a/apps/gateway/.env.example b/apps/gateway/.env.example
iindex 1906f14..e338c90 100644
---- a/apps/gateway/.env.example
++++ b/apps/gateway/.env.example
@@@ -38,6 +38,13 @@ INTERNAL_API_KEY=change-me-to-a-random-64-char-string
# Service registry (the catalog's service origins); defaults to OV_NETWORK_INTERNAL_URL + /api/v1/registry/services
#OV_REGISTRY_URL=http://127.0.0.1:4000/api/v1/registry/services
++# Per-actor limits (apps/_shared/actor-limits.js; every app reads the same tools.env): registry reads a
++# signed-in person or third-party app may make per minute and per hour; past them 429 rate_limited with
++# Retry-After. Signed-out reads keep the per-address limit only. The backstop on job submits and runs and
++# the admin analytics limit have fixed numbers (README). The guard's quotas are unchanged.
++#TOOLS_LIMITS_MINUTE=120
++#TOOLS_LIMITS_HOUR=3000
++
# ── Satellites: jobs (img, audio, docs read the same tools.env) ──
#TOOLS_JOBS_CONCURRENCY=2
#TOOLS_JOBS_CONCURRENCY_AUDIO=1
ddiff --git a/apps/gateway/package-lock.json b/apps/gateway/package-lock.json
iindex a079010..046fd3b 100644
---- a/apps/gateway/package-lock.json
++++ b/apps/gateway/package-lock.json
@@@ -18,7 +18,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.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.0"
}
},
@@@ -1052,9 +1052,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/apps/gateway/package.json b/apps/gateway/package.json
iindex 170444d..4b80d4d 100644
---- a/apps/gateway/package.json
++++ b/apps/gateway/package.json
@@@ -9,7 +9,7 @@
"dev": "NODE_ENV=development node server/index.js"
},
"license": "MIT",
-- "openvibeToolsShared": "^1.0.0",
++ "openvibeToolsShared": "^1.2.0",
"dependencies": {
"better-sqlite3": "^11.10.0",
"cookie-parser": "^1.4.7",
@@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.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.0"
}
}
ddiff --git a/apps/gateway/server/index.js b/apps/gateway/server/index.js
iindex 1833065..2bc9ee7 100644
---- a/apps/gateway/server/index.js
++++ b/apps/gateway/server/index.js
@@@ -193,6 +193,10 @@ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, keyGenerator: (req
// GET /api/v1/tools (tools.tool-list@1), /api/v1/tools/:id (tools.tool@1), /api/v1/tools/:id/schema.
// Every tool, every family; satellite hosts answer the same routes for their own tools. Counted by
// the /api/ limiter above. /api/catalog.json stays as it is (Network reads it).
++// Per-actor limits (apps/_shared/actor-limits.js, roadmap WS-R task 4): signed-in registry reads, a backstop above the
++// guard's quotas on runs and on the jobs facade's submits, and the admin analytics route.
++const actorLimits = require('../../_shared/actor-limits').createToolsLimits({ app: 'gateway', createActorLimiter: require('openvibe-sdk/limits').createActorLimiter, guard, registry: obs.registry });
++app.use(actorLimits.registryReads);
app.use(createToolsApi({ snapshot: toolRegistry.snapshot }));
// The launcher's recent tools: a signed-in person's tools.usage module (every device), otherwise this
@@@ -257,6 +261,10 @@ const runApi = createGatewayRun({
ports: () => SATELLITES, parseJson: jsonBody, jobIndex, ...ajvFrom(require),
});
const jobsFacade = createJobsFacade({ ports: () => SATELLITES, index: jobIndex, contracts: require('openvibe-contracts') });
++// The backstop above the guard's quotas (actorLimits, above), before a run or a submit does any work:
++// a satellite counts the same run or submit again when the gateway streams it there.
++app.post('/api/v1/tools/:id/run', actorLimits.backstop('tools.tool.run'));
++app.post(['/api/v1/jobs', '/api/v1/jobs/:id/retry'], actorLimits.backstop('tools.job.create'));
app.use(runApi.handle);
app.use((req, res, next) => { jobsFacade.handle(req, res, next); });
@@@ -397,7 +405,8 @@ function sendTool(req, res, file) {
// The gateway keeps no analytics of its own; it adds up the satellites'. Internal key + loopback only.
{
const { requireInternal } = require('../../_shared/internal-auth');
-- app.get('/api/internal/analytics', requireInternal, async (req, res) => {
++ // After the internal key, before the fan-out: per-actor limit (actorLimits.admin, 30 a minute).
++ app.get('/api/internal/analytics', requireInternal, actorLimits.admin, async (req, res) => {
const days = Math.min(parseInt(req.query.days, 10) || 30, 365);
const hours = req.query.hours ? Math.min(parseInt(req.query.hours, 10), 8760) : null;
const key = String(req.headers['x-internal-key']);
ddiff --git a/apps/gateway/test/actor-limits.test.js b/apps/gateway/test/actor-limits.test.js
nnew file mode 100644
iindex 0000000..941ecd9
---- /dev/null
++++ b/apps/gateway/test/actor-limits.test.js
@@@ -0,0 +1,52 @@
++'use strict';
++// Per-actor limits on the real gateway process (apps/_shared/actor-limits.js, roadmap WS-R task 4; the
++// numbers and the clock are checked in apps/_shared/test/actor-limits.test.js): with one read a window, a
++// signed-in person's second registry read is refused 429 rate_limited with Retry-After while another person
++// passes; signed-out reads and health are never refused per actor; the refusal is counted on /metrics.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { startApp } = require('../../_shared/test/spawn');
++const { startNetwork } = require('../../_shared/test/network');
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-gw-limits-'));
++
++(async () => {
++ const net = await startNetwork();
++ let gw = null;
++ try {
++ gw = await startApp('gateway', {
++ OV_NETWORK_URL: net.url, OV_NETWORK_INTERNAL_URL: net.url, DATA_DIR: path.join(tmp, 'gw'),
++ OV_DOMAINS_URL: 'http://127.0.0.1:9/api/domains', OV_REGISTRY_URL: 'http://127.0.0.1:9/registry',
++ TOOLS_SATELLITE_PORTS: 'img=9,audio=9,docs=9,yt=9,text=9,maps=9,food=9',
++ TOOLS_LIMITS_MINUTE: '1', TOOLS_LIMITS_HOUR: '1',
++ });
++ const get = async (p, token, ip = '203.0.113.9') => {
++ const r = await fetch(gw.base + p, { headers: { 'X-Forwarded-For': ip, ...(token ? { Authorization: `Bearer ${token}` } : {}) } });
++ const text = await r.text();
++ let body = null; try { body = JSON.parse(text); } catch { /* not JSON */ }
++ return { status: r.status, h: r.headers, body, text };
++ };
++ const alice = net.user();
++ assert.strictEqual((await get('/api/v1/tools', alice)).status, 200);
++ const r = await get('/api/v1/tools', alice);
++ assert.strictEqual(r.status, 429, r.text);
++ assert.ok(Number(r.h.get('retry-after')) >= 1, r.h.get('retry-after'));
++ assert.deepStrictEqual([r.body.code, r.body.status], ['rate_limited', 429]);
++ assert.strictEqual(r.h.get('access-control-allow-origin'), '*');
++ assert.strictEqual((await get('/api/v1/tools', net.user())).status, 200, 'another person still passes');
++ for (let i = 0; i < 5; i++) {
++ assert.strictEqual((await get('/api/v1/tools')).status, 200, `signed-out read ${i + 1}`);
++ assert.strictEqual((await get('/api/health', alice)).status, 200);
++ }
++ const m = await fetch(`${gw.base}/metrics`).then((x) => x.text());
++ assert.ok(/tools_rate_limited_total\{limit="tools.tool.read",window="(minute|hour)"\} 1/.test(m), m.split('\n').filter((l) => l.includes('rate_limited')).join('\n'));
++ assert.ok(/\[Limits\] gateway: tools\.tool\.read: user:usr_\w+ refused/.test(gw.output()), gw.output().slice(-2000));
++ console.log('gateway actor limits: registry reads per person, signed-out reads and health free, counted');
++ } finally {
++ if (gw) await gw.kill();
++ await net.close();
++ fs.rmSync(tmp, { recursive: true, force: true });
++ }
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/apps/img/package-lock.json b/apps/img/package-lock.json
iindex 1f111f6..5e68f84 100644
---- a/apps/img/package-lock.json
++++ b/apps/img/package-lock.json
@@@ -19,7 +19,7 @@
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.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.0",
"sharp": "^0.33.5",
"to-ico": "^1.1.5",
@@@ -1767,9 +1767,9 @@
"license": "MIT"
},
"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/apps/img/package.json b/apps/img/package.json
iindex 81b5a0f..054f700 100644
---- a/apps/img/package.json
++++ b/apps/img/package.json
@@@ -9,7 +9,7 @@
"dev": "node --watch server/index.js"
},
"license": "MIT",
-- "openvibeToolsShared": "^1.0.0",
++ "openvibeToolsShared": "^1.2.0",
"dependencies": {
"better-sqlite3": "^11.0.0",
"cookie-parser": "^1.4.7",
@@@ -21,7 +21,7 @@
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.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.0",
"sharp": "^0.33.5",
"to-ico": "^1.1.5",
ddiff --git a/apps/img/server/index.js b/apps/img/server/index.js
iindex f90a91f..6c45a1c 100644
---- a/apps/img/server/index.js
++++ b/apps/img/server/index.js
@@@ -157,6 +157,10 @@ app.use('/api/', apiLimiter, guard.apiQuota);
const toolRegistry = createLocalRegistry({ specs: require('./descriptors').SPECS, statusOf: requiresStatus((p) => (p === 'heif-dec' ? codec.heif.available() : true)) });
// A signed-in person's page view of a tool goes into their tools.usage module (the launchers' recent tools).
app.use(require('../../_shared/usage').usagePages({ snapshot: toolRegistry.snapshot }));
++// Per-actor limits (apps/_shared/actor-limits.js, roadmap WS-R task 4): signed-in registry reads, and a backstop above the
++// guard's quotas on job submits, retries and runs.
++const actorLimits = require('../../_shared/actor-limits').createToolsLimits({ app: 'img', createActorLimiter: require('openvibe-sdk/limits').createActorLimiter, guard, registry: obs.registry });
++app.use(actorLimits.registryReads);
app.use(createToolsApi({ snapshot: toolRegistry.snapshot }));
// ── Analytics Middleware ─────────────────────────────────────
@@@ -266,7 +270,7 @@ const jobs = jobsRuntime.setupJobs({
getPublicKey: guard.keys.get, issuer: config.networkUrl, guard,
define: (system) => defineJobs(system, pool),
receive: uploadSingle,
-- limiters: [burstLimiter, processLimiter, guard.toolQuota(hostTool)],
++ limiters: [actorLimits.backstop('tools.job.create'), burstLimiter, processLimiter, guard.toolQuota(hostTool)],
jobTool: (req, type, input) => { const d = guard.toolForJob('img.process', String((input && input.tool) || req.ctx.defaultOp || 'convert'), req.ctx.toolId); return d ? d.id : null; },
defaults(req, input) {
const out = { ...input };
@@@ -283,7 +287,7 @@ const jobs = jobsRuntime.setupJobs({
const runApi = satelliteRunApi({
app: 'img', guard, contracts, snapshot: toolRegistry.snapshot, system: () => jobs,
multer: require('multer'), uploadsDir: path.resolve(config.uploadsDir), ...ajvFrom(require), ports: () => satellitePorts(),
-- limiters: [burstLimiter, processLimiter],
++ limiters: [actorLimits.backstop('tools.tool.run'), burstLimiter, processLimiter],
});
app.use(runApi.handle);
ddiff --git a/apps/maps/package-lock.json b/apps/maps/package-lock.json
iindex df96769..1ee8d90 100644
---- a/apps/maps/package-lock.json
++++ b/apps/maps/package-lock.json
@@@ -19,6 +19,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"node-cache": "^5.1.2",
++ "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.0"
}
},
@@@ -1382,6 +1383,15 @@
"wrappy": "1"
}
},
++ "node_modules/openvibe-sdk": {
++ "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"
++ }
++ },
"node_modules/openvibe-shared": {
"version": "1.23.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0",
ddiff --git a/apps/maps/package.json b/apps/maps/package.json
iindex c6a0b8b..8d78964 100644
---- a/apps/maps/package.json
++++ b/apps/maps/package.json
@@@ -8,10 +8,11 @@
"dev": "NODE_ENV=development node server/index.js"
},
"license": "MIT",
-- "openvibeToolsShared": "^1.0.0",
++ "openvibeToolsShared": "^1.2.0",
"dependencies": {
"axios": "^1.7.0",
"better-sqlite3": "^11.0.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.0",
"cheerio": "^1.0.0-rc.12",
"cookie-parser": "^1.4.7",
ddiff --git a/apps/maps/server/index.js b/apps/maps/server/index.js
iindex b03138d..2755a4b 100644
---- a/apps/maps/server/index.js
++++ b/apps/maps/server/index.js
@@@ -103,6 +103,10 @@ app.use(['/api/geocode', '/api/search', '/api/weather', '/api/terrain', '/api/fo
const toolRegistry = createLocalRegistry({ specs: require('./descriptors').SPECS.filter(s => s.id === 'maps') });
// A signed-in person's page view of a tool goes into their tools.usage module (the launchers' recent tools).
app.use(require('../../_shared/usage').usagePages({ snapshot: toolRegistry.snapshot }));
++// Per-actor limits (apps/_shared/actor-limits.js, roadmap WS-R task 4): signed-in registry reads (signed-out reads keep
++// the per-address limit only).
++const actorLimits = require('../../_shared/actor-limits').createToolsLimits({ app: 'maps', createActorLimiter: require('openvibe-sdk/limits').createActorLimiter, guard, registry: obs.registry });
++app.use(actorLimits.registryReads);
app.use(createToolsApi({ snapshot: toolRegistry.snapshot }));
// ── Analytics Middleware ─────────────────────────────────────
ddiff --git a/apps/text/package-lock.json b/apps/text/package-lock.json
iindex 05a22af..95970ef 100644
---- a/apps/text/package-lock.json
++++ b/apps/text/package-lock.json
@@@ -16,6 +16,7 @@
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.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.0"
}
},
@@@ -968,6 +969,15 @@
"wrappy": "1"
}
},
++ "node_modules/openvibe-sdk": {
++ "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"
++ }
++ },
"node_modules/openvibe-shared": {
"version": "1.23.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0",
ddiff --git a/apps/text/package.json b/apps/text/package.json
iindex 43c879f..769dde9 100644
---- a/apps/text/package.json
++++ b/apps/text/package.json
@@@ -8,7 +8,7 @@
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js"
},
-- "openvibeToolsShared": "^1.0.0",
++ "openvibeToolsShared": "^1.2.0",
"dependencies": {
"express": "^4.18.2",
"helmet": "^7.1.0",
@@@ -17,6 +17,7 @@
"better-sqlite3": "^11.0.0",
"express-rate-limit": "^7.1.5",
"dotenv": "^16.3.1",
++ "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.0"
},
"license": "MIT"
ddiff --git a/apps/text/server/index.js b/apps/text/server/index.js
iindex 8899910..4e20496 100644
---- a/apps/text/server/index.js
++++ b/apps/text/server/index.js
@@@ -99,6 +99,10 @@ app.use('/api/', guard.apiQuota);
const toolRegistry = createLocalRegistry({ specs: require('./descriptors').SPECS });
// A signed-in person's page view of a tool goes into their tools.usage module (the launchers' recent tools).
app.use(require('../../_shared/usage').usagePages({ snapshot: toolRegistry.snapshot }));
++// Per-actor limits (apps/_shared/actor-limits.js, roadmap WS-R task 4): signed-in registry reads (signed-out reads keep
++// the per-address limit only).
++const actorLimits = require('../../_shared/actor-limits').createToolsLimits({ app: 'text', createActorLimiter: require('openvibe-sdk/limits').createActorLimiter, guard, registry: obs.registry });
++app.use(actorLimits.registryReads);
app.use(createToolsApi({ snapshot: toolRegistry.snapshot }));
// ── Analytics Middleware ─────────────────────────────────────
ddiff --git a/apps/yt/package-lock.json b/apps/yt/package-lock.json
iindex 6f4098a..50045d0 100644
---- a/apps/yt/package-lock.json
++++ b/apps/yt/package-lock.json
@@@ -17,6 +17,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
++ "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.0",
"uuid": "^11.1.0"
}
@@@ -799,6 +800,15 @@
"wrappy": "1"
}
},
++ "node_modules/openvibe-sdk": {
++ "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"
++ }
++ },
"node_modules/openvibe-shared": {
"version": "1.23.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0",
ddiff --git a/apps/yt/package.json b/apps/yt/package.json
iindex a0f21ed..1a95ab7 100644
---- a/apps/yt/package.json
++++ b/apps/yt/package.json
@@@ -9,7 +9,7 @@
"dev": "node --watch server/index.js"
},
"license": "MIT",
-- "openvibeToolsShared": "^1.0.0",
++ "openvibeToolsShared": "^1.2.0",
"dependencies": {
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
@@@ -18,6 +18,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"better-sqlite3": "^11.0.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.0",
"jsonwebtoken": "^9.0.2",
"uuid": "^11.1.0"
ddiff --git a/apps/yt/server/index.js b/apps/yt/server/index.js
iindex 361be0f..ab15c68 100644
---- a/apps/yt/server/index.js
++++ b/apps/yt/server/index.js
@@@ -120,6 +120,10 @@ app.use('/api/', (_req, res, next) => { res.setHeader('Cache-Control', 'no-store
const toolRegistry = createLocalRegistry({ specs: require('./descriptors').SPECS, statusOf: requiresStatus((p) => (p === 'yt-dlp' ? !!which(config.ytdlpPath) : true)) });
// A signed-in person's page view of a tool goes into their tools.usage module (the launchers' recent tools).
app.use(require('../../_shared/usage').usagePages({ snapshot: toolRegistry.snapshot }));
++// Per-actor limits (apps/_shared/actor-limits.js, roadmap WS-R task 4): signed-in registry reads (signed-out reads keep
++// the per-address limit only).
++const actorLimits = require('../../_shared/actor-limits').createToolsLimits({ app: 'yt', createActorLimiter: require('openvibe-sdk/limits').createActorLimiter, guard, registry: obs.registry });
++app.use(actorLimits.registryReads);
app.use(createToolsApi({ snapshot: toolRegistry.snapshot }));
// ── Analytics Middleware ─────────────────────────────────────