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}.
Changes
@@@ -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 registryEvery satellite honours the gateway's `X-OV-Tool` / `X-OV-Host-Role` / `X-OV-Canonical-Host` / `X-OV-Short-Host`@@@ -4,6 +4,19 @@ The Tools apps' shared runtime. Each app declares the range it needs as `"openviits `package.json`, and refuses to boot when this checkout does not satisfy it (`version.js`). Abreaking 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-26Additive (roadmap WS-N task 4):nnew file mode 100644@@@ -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 };@@@ -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"}nnew file mode 100644@@@ -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); });@@@ -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"@@@ -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"}@@@ -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);@@@ -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"@@@ -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",@@@ -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);@@@ -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",@@@ -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"}}@@@ -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 ─────────────────────────────────────@@@ -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@@@ -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"@@@ -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"}}@@@ -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']);nnew file mode 100644@@@ -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); });@@@ -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"@@@ -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",@@@ -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);@@@ -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",@@@ -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",@@@ -103,6 +103,10 @@ app.use(['/api/geocode', '/api/search', '/api/weather', '/api/terrain', '/api/foconst 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 ─────────────────────────────────────@@@ -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",@@@ -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"@@@ -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 ─────────────────────────────────────@@@ -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",@@@ -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"@@@ -120,6 +120,10 @@ app.use('/api/', (_req, res, next) => { res.setHeader('Cache-Control', 'no-storeconst 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 ─────────────────────────────────────
@@ -347,6 +347,31 @@ Environment: `TOOLS_GUARD`, `TOOLS_GUARD_LIMITS` (JSON merged into the table, e.
@@ -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_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).
`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
## 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`
Every satellite honours the gateway's `X-OV-Tool` / `X-OV-Host-Role` / `X-OV-Canonical-Host` / `X-OV-Short-Host`
@@ -4,6 +4,19 @@ The Tools apps' shared runtime. Each app declares the range it needs as `"openvi
@@ -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
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.
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
## 1.1.0 — 2026-09-26
Additive (roadmap WS-N task 4):
Additive (roadmap WS-N task 4):
new file mode 100644
new file mode 100644
@@ -0,0 +1,125 @@
@@ -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 };
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
{
{
"name": "openvibe-tools-shared",
"name": "openvibe-tools-shared",
- "version": "1.1.0",
+ "version": "1.2.0",
"private": true,
"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"
"license": "MIT"
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,141 @@
@@ -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); });
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"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.63.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0",
"uuid": "^11.1.0"
"uuid": "^11.1.0"
}
}
@@ -1175,9 +1175,9 @@
@@ -1175,9 +1175,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"
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
"dev": "node --watch server/index.js"
"dev": "node --watch server/index.js"
},
},
"license": "MIT",
"license": "MIT",
- "openvibeToolsShared": "^1.0.0",
+ "openvibeToolsShared": "^1.2.0",
"dependencies": {
"dependencies": {
"better-sqlite3": "^11.0.0",
"better-sqlite3": "^11.0.0",
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"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.63.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0",
"uuid": "^11.1.0"
"uuid": "^11.1.0"
}
}
@@ -134,6 +134,10 @@ app.use('/api/', apiLimiter, guard.apiQuota);
@@ -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)) });
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).
// 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 }));
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 }));
app.use(createToolsApi({ snapshot: toolRegistry.snapshot }));
// ── Analytics Middleware ─────────────────────────────────────
// ── Analytics Middleware ─────────────────────────────────────
@@ -273,7 +277,7 @@ const jobs = jobsRuntime.setupJobs({
@@ -273,7 +277,7 @@ const jobs = jobsRuntime.setupJobs({
getPublicKey: guard.keys.get, issuer: config.networkUrl, guard,
getPublicKey: guard.keys.get, issuer: config.networkUrl, guard,
define: defineJobs,
define: defineJobs,
receive: uploadAny,
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; },
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) {
defaults(req, input) {
const out = { ...input };
const out = { ...input };
@@ -290,7 +294,7 @@ const jobs = jobsRuntime.setupJobs({
@@ -290,7 +294,7 @@ const jobs = jobsRuntime.setupJobs({
const runApi = satelliteRunApi({
const runApi = satelliteRunApi({
app: 'audio', guard, contracts, snapshot: toolRegistry.snapshot, system: () => jobs,
app: 'audio', guard, contracts, snapshot: toolRegistry.snapshot, system: () => jobs,
multer: require('multer'), uploadsDir: path.resolve(config.uploadsDir), ...ajvFrom(require), ports: () => satellitePorts(),
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);
app.use(runApi.handle);
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"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.63.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0",
"pdf-lib": "^1.17.1",
"pdf-lib": "^1.17.1",
"sharp": "^0.33.5",
"sharp": "^0.33.5",
@@ -1254,9 +1254,9 @@
@@ -1254,9 +1254,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"
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
"dev": "node --watch server/index.js"
"dev": "node --watch server/index.js"
},
},
"license": "MIT",
"license": "MIT",
- "openvibeToolsShared": "^1.0.0",
+ "openvibeToolsShared": "^1.2.0",
"dependencies": {
"dependencies": {
"better-sqlite3": "^11.0.0",
"better-sqlite3": "^11.0.0",
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
@@ -21,7 +21,7 @@
@@ -21,7 +21,7 @@
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"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.63.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0",
"pdf-lib": "^1.17.1",
"pdf-lib": "^1.17.1",
"sharp": "^0.33.5",
"sharp": "^0.33.5",
@@ -151,6 +151,10 @@ const pdfPrograms = require('./tools/pdf');
@@ -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)) });
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).
// 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 }));
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 }));
app.use(createToolsApi({ snapshot: toolRegistry.snapshot }));
// ── Analytics Middleware ─────────────────────────────────────
// ── Analytics Middleware ─────────────────────────────────────
@@ -324,7 +328,7 @@ const jobs = jobsRuntime.setupJobs({
@@ -324,7 +328,7 @@ const jobs = jobsRuntime.setupJobs({
getPublicKey: guard.keys.get, issuer: config.networkUrl, guard,
getPublicKey: guard.keys.get, issuer: config.networkUrl, guard,
define: (system) => defineJobs(system, pool),
define: (system) => defineJobs(system, pool),
receive: uploadAny,
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; },
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) {
defaults(req, input) {
const out = { ...input };
const out = { ...input };
@@ -341,7 +345,7 @@ const jobs = jobsRuntime.setupJobs({
@@ -341,7 +345,7 @@ const jobs = jobsRuntime.setupJobs({
const runApi = satelliteRunApi({
const runApi = satelliteRunApi({
app: 'docs', guard, contracts, snapshot: toolRegistry.snapshot, system: () => jobs,
app: 'docs', guard, contracts, snapshot: toolRegistry.snapshot, system: () => jobs,
multer: require('multer'), uploadsDir: path.resolve(config.uploadsDir), ...ajvFrom(require), ports: () => satellitePorts(),
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);
app.use(runApi.handle);
@@ -15,6 +15,7 @@
@@ -15,6 +15,7 @@
"express": "^4.18.2",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"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"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0"
}
}
},
},
@@ -967,6 +968,15 @@
@@ -967,6 +968,15 @@
"wrappy": "1"
"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": {
"node_modules/openvibe-shared": {
"version": "1.23.0",
"version": "1.23.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0",
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "node --watch server/index.js"
"dev": "node --watch server/index.js"
},
},
- "openvibeToolsShared": "^1.0.0",
+ "openvibeToolsShared": "^1.2.0",
"dependencies": {
"dependencies": {
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"cors": "^2.8.5",
@@ -17,6 +17,7 @@
@@ -17,6 +17,7 @@
"better-sqlite3": "^11.0.0",
"better-sqlite3": "^11.0.0",
"express-rate-limit": "^7.1.5",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"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"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0"
}
}
}
}
@@ -101,6 +101,10 @@ app.use('/api/', guard.apiQuota);
@@ -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.
// 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.
// 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') });
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 }));
app.use(createToolsApi({ snapshot: toolRegistry.snapshot }));
// ── Analytics Middleware ─────────────────────────────────────
// ── Analytics Middleware ─────────────────────────────────────
@@ -38,6 +38,13 @@ INTERNAL_API_KEY=change-me-to-a-random-64-char-string
@@ -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
# 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
#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) ──
# ── Satellites: jobs (img, audio, docs read the same tools.env) ──
#TOOLS_JOBS_CONCURRENCY=2
#TOOLS_JOBS_CONCURRENCY=2
#TOOLS_JOBS_CONCURRENCY_AUDIO=1
#TOOLS_JOBS_CONCURRENCY_AUDIO=1
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.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"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0"
}
}
},
},
@@ -1052,9 +1052,9 @@
@@ -1052,9 +1052,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"
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
"dev": "NODE_ENV=development node server/index.js"
"dev": "NODE_ENV=development node server/index.js"
},
},
"license": "MIT",
"license": "MIT",
- "openvibeToolsShared": "^1.0.0",
+ "openvibeToolsShared": "^1.2.0",
"dependencies": {
"dependencies": {
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^11.10.0",
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.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"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0"
}
}
}
}
@@ -193,6 +193,10 @@ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, keyGenerator: (req
@@ -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.
// 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
// 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).
// 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 }));
app.use(createToolsApi({ snapshot: toolRegistry.snapshot }));
// The launcher's recent tools: a signed-in person's tools.usage module (every device), otherwise this
// The launcher's recent tools: a signed-in person's tools.usage module (every device), otherwise this
@@ -257,6 +261,10 @@ const runApi = createGatewayRun({
@@ -257,6 +261,10 @@ const runApi = createGatewayRun({
ports: () => SATELLITES, parseJson: jsonBody, jobIndex, ...ajvFrom(require),
ports: () => SATELLITES, parseJson: jsonBody, jobIndex, ...ajvFrom(require),
});
});
const jobsFacade = createJobsFacade({ ports: () => SATELLITES, index: jobIndex, contracts: require('openvibe-contracts') });
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(runApi.handle);
app.use((req, res, next) => { jobsFacade.handle(req, res, next); });
app.use((req, res, next) => { jobsFacade.handle(req, res, next); });
@@ -397,7 +405,8 @@ function sendTool(req, res, file) {
@@ -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.
// The gateway keeps no analytics of its own; it adds up the satellites'. Internal key + loopback only.
{
{
const { requireInternal } = require('../../_shared/internal-auth');
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 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 hours = req.query.hours ? Math.min(parseInt(req.query.hours, 10), 8760) : null;
const key = String(req.headers['x-internal-key']);
const key = String(req.headers['x-internal-key']);
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -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); });
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"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.63.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0",
"sharp": "^0.33.5",
"sharp": "^0.33.5",
"to-ico": "^1.1.5",
"to-ico": "^1.1.5",
@@ -1767,9 +1767,9 @@
@@ -1767,9 +1767,9 @@
"license": "MIT"
"license": "MIT"
},
},
"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"
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
"dev": "node --watch server/index.js"
"dev": "node --watch server/index.js"
},
},
"license": "MIT",
"license": "MIT",
- "openvibeToolsShared": "^1.0.0",
+ "openvibeToolsShared": "^1.2.0",
"dependencies": {
"dependencies": {
"better-sqlite3": "^11.0.0",
"better-sqlite3": "^11.0.0",
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
@@ -21,7 +21,7 @@
@@ -21,7 +21,7 @@
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"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.63.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0",
"sharp": "^0.33.5",
"sharp": "^0.33.5",
"to-ico": "^1.1.5",
"to-ico": "^1.1.5",
@@ -157,6 +157,10 @@ app.use('/api/', apiLimiter, guard.apiQuota);
@@ -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)) });
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).
// 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 }));
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 }));
app.use(createToolsApi({ snapshot: toolRegistry.snapshot }));
// ── Analytics Middleware ─────────────────────────────────────
// ── Analytics Middleware ─────────────────────────────────────
@@ -266,7 +270,7 @@ const jobs = jobsRuntime.setupJobs({
@@ -266,7 +270,7 @@ const jobs = jobsRuntime.setupJobs({
getPublicKey: guard.keys.get, issuer: config.networkUrl, guard,
getPublicKey: guard.keys.get, issuer: config.networkUrl, guard,
define: (system) => defineJobs(system, pool),
define: (system) => defineJobs(system, pool),
receive: uploadSingle,
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; },
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) {
defaults(req, input) {
const out = { ...input };
const out = { ...input };
@@ -283,7 +287,7 @@ const jobs = jobsRuntime.setupJobs({
@@ -283,7 +287,7 @@ const jobs = jobsRuntime.setupJobs({
const runApi = satelliteRunApi({
const runApi = satelliteRunApi({
app: 'img', guard, contracts, snapshot: toolRegistry.snapshot, system: () => jobs,
app: 'img', guard, contracts, snapshot: toolRegistry.snapshot, system: () => jobs,
multer: require('multer'), uploadsDir: path.resolve(config.uploadsDir), ...ajvFrom(require), ports: () => satellitePorts(),
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);
app.use(runApi.handle);
@@ -19,6 +19,7 @@
@@ -19,6 +19,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"node-cache": "^5.1.2",
"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"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0"
}
}
},
},
@@ -1382,6 +1383,15 @@
@@ -1382,6 +1383,15 @@
"wrappy": "1"
"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": {
"node_modules/openvibe-shared": {
"version": "1.23.0",
"version": "1.23.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0",
@@ -8,10 +8,11 @@
@@ -8,10 +8,11 @@
"dev": "NODE_ENV=development node server/index.js"
"dev": "NODE_ENV=development node server/index.js"
},
},
"license": "MIT",
"license": "MIT",
- "openvibeToolsShared": "^1.0.0",
+ "openvibeToolsShared": "^1.2.0",
"dependencies": {
"dependencies": {
"axios": "^1.7.0",
"axios": "^1.7.0",
"better-sqlite3": "^11.0.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0",
"cheerio": "^1.0.0-rc.12",
"cheerio": "^1.0.0-rc.12",
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
@@ -103,6 +103,10 @@ app.use(['/api/geocode', '/api/search', '/api/weather', '/api/terrain', '/api/fo
@@ -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') });
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).
// 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 }));
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 }));
app.use(createToolsApi({ snapshot: toolRegistry.snapshot }));
// ── Analytics Middleware ─────────────────────────────────────
// ── Analytics Middleware ─────────────────────────────────────
@@ -16,6 +16,7 @@
@@ -16,6 +16,7 @@
"express": "^4.18.2",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"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"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0"
}
}
},
},
@@ -968,6 +969,15 @@
@@ -968,6 +969,15 @@
"wrappy": "1"
"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": {
"node_modules/openvibe-shared": {
"version": "1.23.0",
"version": "1.23.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0",
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
"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"
},
},
- "openvibeToolsShared": "^1.0.0",
+ "openvibeToolsShared": "^1.2.0",
"dependencies": {
"dependencies": {
"express": "^4.18.2",
"express": "^4.18.2",
"helmet": "^7.1.0",
"helmet": "^7.1.0",
@@ -17,6 +17,7 @@
@@ -17,6 +17,7 @@
"better-sqlite3": "^11.0.0",
"better-sqlite3": "^11.0.0",
"express-rate-limit": "^7.1.5",
"express-rate-limit": "^7.1.5",
"dotenv": "^16.3.1",
"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"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0"
},
},
"license": "MIT"
"license": "MIT"
@@ -99,6 +99,10 @@ app.use('/api/', guard.apiQuota);
@@ -99,6 +99,10 @@ app.use('/api/', guard.apiQuota);
const toolRegistry = createLocalRegistry({ specs: require('./descriptors').SPECS });
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).
// 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 }));
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 }));
app.use(createToolsApi({ snapshot: toolRegistry.snapshot }));
// ── Analytics Middleware ─────────────────────────────────────
// ── Analytics Middleware ─────────────────────────────────────
@@ -17,6 +17,7 @@
@@ -17,6 +17,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0",
"uuid": "^11.1.0"
"uuid": "^11.1.0"
}
}
@@ -799,6 +800,15 @@
@@ -799,6 +800,15 @@
"wrappy": "1"
"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": {
"node_modules/openvibe-shared": {
"version": "1.23.0",
"version": "1.23.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0",
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
"dev": "node --watch server/index.js"
"dev": "node --watch server/index.js"
},
},
"license": "MIT",
"license": "MIT",
- "openvibeToolsShared": "^1.0.0",
+ "openvibeToolsShared": "^1.2.0",
"dependencies": {
"dependencies": {
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"cors": "^2.8.5",
@@ -18,6 +18,7 @@
@@ -18,6 +18,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"better-sqlite3": "^11.0.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"uuid": "^11.1.0"
"uuid": "^11.1.0"
@@ -120,6 +120,10 @@ app.use('/api/', (_req, res, next) => { res.setHeader('Cache-Control', 'no-store
@@ -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)) });
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).
// 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 }));
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 }));
app.use(createToolsApi({ snapshot: toolRegistry.snapshot }));
// ── Analytics Middleware ─────────────────────────────────────
// ── Analytics Middleware ─────────────────────────────────────