openvibers/openvibe.sdk Commits

v0.12.0: openvibe-sdk/limits (roadmap WS-R task 4). createActorLimiter({ limits: { minute, hour, day } }) gives limits(name, ownLimits), Express middleware counting each actor in fixed windows at a capability boundary: the verified principal (svc:live, app:…), else the signed-in person (user:<subject>), else ip:<address>; actor(req) overrides, null skips. Past a limit it answers 429 problem+json rate_limited with Retry-After from the tightest exceeded window, before the route runs. A route's limits replace the defaults window by window; counters are per process and bounded (maxActors); onLimited observes refusals. Node only (browser: null), ESM and types. test/limits.test.js; browser-entries and pack tests list the new subpath. npm test 33/33.

OP OpenVibers committed 28 Sept 2026 · 0a4909d11a · 13 files, +209 −8


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 9cf4697..a7fe256 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -3,6 +3,14 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
++## 0.12.0 (2026-09-28)
++
++**`openvibe-sdk/limits`** (roadmap WS-R task 4): per-actor rate limits at a service's capability boundaries, where nginx can only limit by address. `createActorLimiter({ limits: { minute, hour, day } })` returns `limits(name, ownLimits)`, Express middleware that counts each actor in fixed windows and refuses past a limit with 429 problem+json (`rate_limited`, `Retry-After` from the tightest exceeded window) before the route runs.
++- The actor is the verified principal (`svc:live`, `app:…`), else the signed-in person (`user:<subject>`), else `ip:<address>`; `actor(req)` overrides it, and null skips counting.
++- A route's limits replace the defaults window by window.
++- Counters are per process and bounded (`maxActors`, oldest dropped first), and `onLimited` observes refusals.
++Node only. Additive.
++
## 0.11.0 (2026-09-25)
Two more clients (roadmap WS-F task 4). **`openvibe-sdk/chat`**: OpenVibe.Chat over REST as the token's person: `global.send/history/search`, `rooms.list/create/get/update/messages/send/deleteMessage/join/leave/read/members/setMember`, `dms.list/create/get/messages/send/deleteMessage/read/unread/blocks/block/unblock`, `exportMine()`; writes are never retried, a room or conversation you may not read is `null`. **`openvibe-sdk/ai`**: OpenVibe.AI's run API: `runs.create(workflow, input, { wait, version, idempotencyKey, target, attribution, onBehalfOf, options })`, `runs.get/list/cancel/retry/citations/addCitations`, `runs.waitFor(id)` (polls until succeeded, failed or cancelled), and the direct operations `chat/generate/summarize/classify/extract/enrich/embed`; a create is retried only with an idempotency key. Both in the browser bundle, ESM and types. Additive.
ddiff --git a/README.md b/README.md
iindex 4c387f8..29ac5f8 100644
---- a/README.md
++++ b/README.md
@@@ -165,6 +165,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/media` | both (credentials: server) | `createMediaClient(client, {app, apiKey?, actingUserId?})`: `files.upload/list/iterate/get/delete`; `mediaUrls(origin)` public URL helpers; `createObjectsClient({app, baseUrl?, tokenClient \| apiKey \| client})`: object API v2 `upload` (single or multipart by size, resumable), `resume`, `get`, `signedUrl`, `delete`, `list`, `iterate`, and `jobs.create/get/list/approve/cancel/wait`; Media's origin from the platform descriptor when `baseUrl` is omitted |
| `openvibe-sdk/community` | both | `createCommunityClient(client, {actingSubject?, origin?, sourceRef?, staff?})`: `pastes.list/iterate/get/create/update/delete/fork/like/copy/versions/byUser/config`, `pastes.comments.list/create/delete`, `as(subject)` |
| `openvibe-sdk/search` | both | `createSearchClient(client, {actingSubject?})`: `query(text, {owner, type, lang, filter, facets, limit, cursor})`, `iterate(text, {…, max})`, `suggest(text, {owner, type, limit})`, `document(owner, type, id)` (null when missing or not visible) |
++| `openvibe-sdk/limits` | node | `createActorLimiter({limits: {minute, hour, day}, actor?, maxActors?, onLimited?})` → `limits(name, {minute?, hour?, day?})` Express middleware: per-actor fixed windows at a capability boundary (service principal, else the signed-in person, else `ip:`), 429 problem+json `rate_limited` with Retry-After before the handler; `defaultActor(req)`, `.stats()` |
| `openvibe-sdk/chat` | both | `createChatClient(client)`: `global.send/history/search`, `rooms.*` (list, create, get, update, messages, send, deleteMessage, join, leave, read, members, setMember), `dms.*` (list, create, get, messages, send, deleteMessage, read, unread, blocks, block, unblock), `exportMine()` |
| `openvibe-sdk/ai` | both | `createAiClient(client)`: `runs.create(workflow, input, {wait, idempotencyKey, …})`, `runs.get/list/cancel/retry/citations/addCitations/waitFor`, `chat/generate/summarize/classify/extract/enrich/embed` |
| `openvibe-sdk/jobs` | both | `createJobsClient(client, {baseUrl?})`: `submit` (Idempotency-Key), `get`, `cancel`, `retry`, `reference`, `unreference`, `events` (SSE, reattaches), `wait`, `file` (raw Response); `isTerminal()`. Default origin: the Tools gateway's jobs facade |
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 2fb47c4..f827f2d 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.11.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.12.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, registry, modules, realtime, media, community, search, chat, ai, jobs, tools, projects). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -40,7 +40,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.11.0';
++const SDK_VERSION = '0.12.0';
module.exports = {
createClient,
ddiff --git a/esm/limits.mjs b/esm/limits.mjs
nnew file mode 100644
iindex 0000000..0477f89
---- /dev/null
++++ b/esm/limits.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/limits.js. Do not edit.
++import cjs from '../src/limits.js';
++
++export const {
++ createActorLimiter, defaultActor, WINDOWS,
++} = cjs;
++export default cjs;
ddiff --git a/package-lock.json b/package-lock.json
iindex b8f74b9..70031f5 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.11.0",
++ "version": "0.12.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.11.0",
++ "version": "0.12.0",
"license": "MIT",
"devDependencies": {
"better-sqlite3": "^11.10.0",
ddiff --git a/package.json b/package.json
iindex e5df83f..b61e7e2 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.11.0",
++ "version": "0.12.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
@@@ -70,6 +70,12 @@
"import": "./esm/search.mjs",
"default": "./src/search.js"
},
++ "./limits": {
++ "types": "./types/limits.d.ts",
++ "browser": null,
++ "import": "./esm/limits.mjs",
++ "default": "./src/limits.js"
++ },
"./chat": {
"types": "./types/chat.d.ts",
"import": "./esm/chat.mjs",
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex 766a3c7..ffd6252 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -24,6 +24,7 @@ const ENTRIES = {
'media.mjs': '../src/media.js',
'community.mjs': '../src/community.js',
'search.mjs': '../src/search.js',
++ 'limits.mjs': '../src/limits.js',
'chat.mjs': '../src/chat.js',
'ai.mjs': '../src/ai.js',
'jobs.mjs': '../src/jobs.js',
ddiff --git a/src/core/index.js b/src/core/index.js
iindex f068bd7..e91818c 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.11.0';
++const SDK_VERSION = '0.12.0';
module.exports = {
createClient,
ddiff --git a/src/limits.js b/src/limits.js
nnew file mode 100644
iindex 0000000..235e00e
---- /dev/null
++++ b/src/limits.js
@@@ -0,0 +1,98 @@
++'use strict';
++/**
++ * openvibe-sdk/limits — per-actor rate limits at a service's capability boundaries (roadmap WS-R task 4).
++ *
++ * nginx limits by address; a capability route needs limits by WHO calls it: a service principal, a signed-in
++ * person, a developer app, or (only when nobody is signed in) the address. createActorLimiter() gives Express
++ * middleware that counts each actor in fixed windows and answers 429 problem+json (`rate_limited`, Retry-After)
++ * past a limit, before the route does any work.
++ *
++ * const { createActorLimiter } = require('openvibe-sdk/limits');
++ * const limits = createActorLimiter({ limits: { minute: 120, hour: 2000 } });
++ * router.post('/objects', guard, limits('media.object.upload', { minute: 30 }), handler);
++ *
++ * The actor, in order: req.principal.sub (a verified service or app token: svc:live, app:app_…), then the signed-in
++ * person (req.user.subject_id, req.user.id, req.auth.subject), then `ip:<req.ip>`. Pass `actor(req)` to decide it
++ * yourself; return null to skip counting (a trusted internal caller). Counters live in this process (a restart
++ * forgets them) and are capped (maxActors, oldest forgotten first), so memory stays bounded under a flood.
++ * Each limit is { minute?, hour?, day? }; a route's own limits replace the defaults window by window.
++ */
++
++const WINDOWS = { minute: 60, hour: 3600, day: 86400 };
++
++function defaultActor(req) {
++ const p = req.principal;
++ if (p && !p.legacy && typeof p.sub === 'string' && p.sub) return p.sub;
++ const u = req.user;
++ if (u && (u.subject_id || u.id != null)) return u.subject_id ? `user:${u.subject_id}` : `user:${u.id}`;
++ if (req.auth && req.auth.subject) return `user:${req.auth.subject}`;
++ return `ip:${req.ip || (req.socket && req.socket.remoteAddress) || 'unknown'}`;
++}
++
++function problem(res, retryAfter, detail) {
++ res.statusCode = 429;
++ res.setHeader('Retry-After', String(retryAfter));
++ res.setHeader('Content-Type', 'application/problem+json');
++ res.setHeader('Cache-Control', 'no-store');
++ res.end(JSON.stringify({ type: 'https://openvibe.network/problems/rate_limited', title: 'Too many requests', status: 429, code: 'rate_limited', error: 'rate_limited', detail, retry_after_seconds: retryAfter }));
++}
++
++/**
++ * @param {object} [opts]
++ * @param {{minute?:number,hour?:number,day?:number}} [opts.limits] defaults for every route
++ * @param {(req) => string|null} [opts.actor]
++ * @param {number} [opts.maxActors] counters kept (default 50 000)
++ * @param {() => number} [opts.now] clock in ms (tests)
++ * @param {(e: {actor, name, window, limit}) => void} [opts.onLimited] called on every refusal (metrics, logs)
++ * @returns {((name: string, limits?: object) => Function) & { stats(): object, reset(): void }}
++ */
++function createActorLimiter({ limits = { minute: 120 }, actor = defaultActor, maxActors = 50000, now = () => Date.now(), onLimited = null } = {}) {
++ for (const w of Object.keys(limits)) if (!WINDOWS[w]) throw new Error(`limits: unknown window ${w}`);
++ const counters = new Map(); // `${name}|${actor}` -> { [window]: { start, count } }
++ const stats = { allowed: 0, limited: 0, actors: 0 };
++
++ function touch(key) {
++ const c = counters.get(key);
++ if (c) { counters.delete(key); counters.set(key, c); return c; }
++ const fresh = {};
++ counters.set(key, fresh);
++ if (counters.size > maxActors) counters.delete(counters.keys().next().value);
++ stats.actors = counters.size;
++ return fresh;
++ }
++
++ function middleware(name, own = {}) {
++ if (typeof name !== 'string' || !name) throw new Error('limits: every limit names its capability or route');
++ const merged = { ...limits, ...own };
++ for (const w of Object.keys(merged)) if (!WINDOWS[w]) throw new Error(`limits: unknown window ${w}`);
++ const windows = Object.entries(merged).filter(([, max]) => Number.isFinite(max) && max >= 0);
++ return function actorLimit(req, res, next) {
++ const who = actor(req);
++ if (who == null) return next();
++ const t = Math.floor(now() / 1000);
++ const c = touch(`${name}|${who}`);
++ let worst = null;
++ for (const [w, max] of windows) {
++ const start = Math.floor(t / WINDOWS[w]) * WINDOWS[w];
++ if (!c[w] || c[w].start !== start) c[w] = { start, count: 0 };
++ if (c[w].count >= max) {
++ const retry = start + WINDOWS[w] - t;
++ if (!worst || retry > worst.retry) worst = { window: w, max, retry };
++ }
++ }
++ if (worst) {
++ stats.limited++;
++ if (onLimited) { try { onLimited({ actor: who, name, window: worst.window, limit: worst.max }); } catch { /* observers never block */ } }
++ return problem(res, Math.max(1, worst.retry), `${name}: at most ${worst.max} per ${worst.window} for each caller`);
++ }
++ for (const [w] of windows) c[w].count++;
++ stats.allowed++;
++ return next();
++ };
++ }
++ middleware.stats = () => ({ ...stats });
++ middleware.reset = () => { counters.clear(); stats.actors = 0; };
++ return middleware;
++}
++
++module.exports = { createActorLimiter, defaultActor, WINDOWS };
ddiff --git a/test/browser-entries.test.js b/test/browser-entries.test.js
iindex 8369cf5..33ad042 100644
---- a/test/browser-entries.test.js
++++ b/test/browser-entries.test.js
@@@ -76,7 +76,7 @@ run([
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
}
assert.deepEqual(problems, []);
-- assert.deepEqual(blocked.sort(), ['./events', './identity', './testing', './vip']);
++ assert.deepEqual(blocked.sort(), ['./events', './identity', './limits', './testing', './vip']);
const rel = [...scanned].map((f) => path.relative(ROOT, f));
for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {
assert.ok(rel.includes(must), `${must} is scanned`);
ddiff --git a/test/limits.test.js b/test/limits.test.js
nnew file mode 100644
iindex 0000000..9555c63
---- /dev/null
++++ b/test/limits.test.js
@@@ -0,0 +1,64 @@
++'use strict';
++/**
++ * limits: per-actor fixed windows at a capability boundary (roadmap WS-R task 4): each actor is counted apart
++ * (service principal, signed-in person, else address), a route's limits replace the defaults window by window, the
++ * tightest exceeded window decides Retry-After, refusals are problem+json 429 before the handler runs, a null actor is
++ * not counted, and the counters stay bounded.
++ */
++const assert = require('node:assert/strict');
++const { run } = require('./helpers');
++const { createActorLimiter, defaultActor } = require('../src/limits');
++
++function call(mw, req) {
++ return new Promise((resolve) => {
++ const headers = {};
++ const res = { statusCode: 200, setHeader: (k, v) => { headers[k.toLowerCase()] = v; }, end: (body) => resolve({ status: res.statusCode, headers, body: body ? JSON.parse(body) : null, passed: false }) };
++ mw(req, res, () => resolve({ status: 200, passed: true }));
++ });
++}
++
++run([
++ ['the actor: principal, then person, then address', () => {
++ assert.equal(defaultActor({ principal: { sub: 'svc:live' }, user: { subject_id: 'usr_x' } }), 'svc:live');
++ assert.equal(defaultActor({ principal: { legacy: true }, user: { subject_id: 'usr_x' } }), 'user:usr_x');
++ assert.equal(defaultActor({ user: { id: 42 } }), 'user:42');
++ assert.equal(defaultActor({ auth: { subject: 'usr_y' } }), 'user:usr_y');
++ assert.equal(defaultActor({ ip: '203.0.113.9' }), 'ip:203.0.113.9');
++ }],
++ ['each actor has its own window; the route decides; 429 before the handler', async () => {
++ let t = 1_000_000_000;
++ const limits = createActorLimiter({ limits: { minute: 100, hour: 1000 }, now: () => t });
++ const mw = limits('media.object.upload', { minute: 2 });
++ const a = { principal: { sub: 'svc:live' } };
++ const b = { principal: { sub: 'svc:tools' } };
++ assert.equal((await call(mw, a)).passed, true);
++ assert.equal((await call(mw, a)).passed, true);
++ const refused = await call(mw, a);
++ assert.equal(refused.status, 429);
++ assert.equal(refused.headers['content-type'], 'application/problem+json');
++ assert.equal(refused.body.code, 'rate_limited');
++ assert.ok(Number(refused.headers['retry-after']) >= 1 && Number(refused.headers['retry-after']) <= 60);
++ assert.equal((await call(mw, b)).passed, true, 'another actor is not affected');
++ t += 60_000;
++ assert.equal((await call(mw, a)).passed, true, 'a new minute');
++ assert.deepEqual([limits.stats().limited, limits.stats().allowed], [1, 4]);
++ }],
++ ['the tightest exceeded window decides Retry-After; a null actor is not counted', async () => {
++ let t = 1_000_000_000;
++ const limits = createActorLimiter({ limits: { minute: 5, hour: 3 }, now: () => t, actor: (req) => req.who || null });
++ const mw = limits('x.y.z');
++ for (let i = 0; i < 3; i++) assert.equal((await call(mw, { who: 'p' })).passed, true);
++ const r = await call(mw, { who: 'p' });
++ assert.equal(r.status, 429);
++ assert.ok(Number(r.headers['retry-after']) > 60, 'the hour window, not the minute');
++ for (let i = 0; i < 10; i++) assert.equal((await call(mw, {})).passed, true, 'uncounted');
++ }],
++ ['counters stay bounded; bad windows are refused at setup', async () => {
++ const limits = createActorLimiter({ limits: { minute: 1 }, maxActors: 10 });
++ const mw = limits('a.b.c');
++ for (let i = 0; i < 50; i++) await call(mw, { ip: `198.51.100.${i}` });
++ assert.equal(limits.stats().actors, 10);
++ assert.throws(() => createActorLimiter({ limits: { week: 1 } }), /unknown window/);
++ assert.throws(() => limits(''), /names its capability/);
++ }],
++]);
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex 9055df6..f3d1989 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -66,7 +66,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
-- assert.equal(subpaths.length, 20);
++ assert.equal(subpaths.length, 21);
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
ddiff --git a/types/limits.d.ts b/types/limits.d.ts
nnew file mode 100644
iindex 0000000..737f1ec
---- /dev/null
++++ b/types/limits.d.ts
@@@ -0,0 +1,16 @@
++import type { IncomingMessage, ServerResponse } from 'http';
++
++export interface WindowLimits { minute?: number; hour?: number; day?: number }
++export type ActorOf = (req: IncomingMessage & Record<string, any>) => string | null;
++export interface LimitedEvent { actor: string; name: string; window: 'minute' | 'hour' | 'day'; limit: number }
++export type LimitMiddleware = (req: IncomingMessage & Record<string, any>, res: ServerResponse, next: (err?: unknown) => void) => void;
++export interface ActorLimiter {
++ /** Middleware for one capability or route; its limits replace the defaults window by window. */
++ (name: string, limits?: WindowLimits): LimitMiddleware;
++ stats(): { allowed: number; limited: number; actors: number };
++ reset(): void;
++}
++export function createActorLimiter(opts?: { limits?: WindowLimits; actor?: ActorOf; maxActors?: number; now?: () => number; onLimited?: (e: LimitedEvent) => void }): ActorLimiter;
++/** req.principal.sub, else the signed-in person (user:<subject>), else ip:<address>. */
++export const defaultActor: ActorOf;
++export const WINDOWS: { minute: 60; hour: 3600; day: 86400 };