openvibers/openvibe.media Commits

Per-actor rate limits at Media's capability boundaries (roadmap WS-R task 4, openvibe-sdk v0.12.0 limits). server/actor-limits.js makes one limiter that counts each caller: the Network token's principal (svc:community, app:app_… of a developer project), the person signed in to /api/v2/me, or an app acting for one of its users (X-OV-User-Id, counted per user). An app's own API key is not counted, since that first-party server speaks for every visitor of its site and limits them itself; a browser with only an upload token is counted by address. Past a limit the route answers 429 problem+json rate_limited with Retry-After before any work, logs one [Limits] line and counts media_rate_limited_total{limit,window}.

OP OpenVibers committed 28 Sept 2026 · 1125a3ceb5 · 13 files, +280 −37

Reads (object, file, job and namespace lists and metadata, downloads, /api/v2/me) take MEDIA_LIMITS_MINUTE / MEDIA_LIMITS_HOUR, 120 and 3000. Object init, upload-url, multipart start and multipart complete, and v1 file upload, allow 30 a minute and 600 an hour: each reserves quota, makes rows or assembles and hashes bytes. Object content PUT and complete, object and file delete and restore, multipart abort and job cancel allow 60 and 1200. Multipart parts allow 600 and 20000, since a session already bounds them and 600 five-megabyte parts a minute is 50 MB/s. Job create and approve allow 10 and 200: they run sharp or ffmpeg on a worker with one or two slots. The staff recompute allows 6 and 60. The limit sits after the credential and before multer or the raw-body readers. Health, /api/ready, release.json, metrics and the signed /internal/events deliveries are never limited, and the existing address limits stay.

test/actor-limits.test.js: past 3 reads a minute one principal gets 429 rate_limited with Retry-After 45 on a fake clock while another passes and the next minute reopens; the app key is not counted and acting users are counted each; the 31st init in a minute is refused before any row is made; refusals are logged without credentials and counted.

Changes

ddiff --git a/.env.example b/.env.example
iindex a0bcca7..e3800d0 100644
---- a/.env.example
++++ b/.env.example
@@@ -55,6 +55,13 @@ MEDIA_APP_KEYS=
MEDIA_APP_TENANT_QUOTA_MB=1024
MEDIA_APP_SANDBOX_QUOTA_MB=100
++# ── Per-actor rate limits (server/actor-limits.js, roadmap WS-R task 4) ──
++# Requests each caller (a Network token's principal, a signed-in person, an app acting for one of its
++# users) may make to one read route per minute and per hour; past them: 429 rate_limited + Retry-After.
++# Uploads, deletes and jobs have their own, tighter numbers in the routes. An app's own API key is not counted.
++MEDIA_LIMITS_MINUTE=120
++MEDIA_LIMITS_HOUR=3000
++
# ── Object storage tiering (optional; local-only when unset) ─
# Backblaze B2 (canonical cold tier)
MEDIA_B2_ENDPOINT=
ddiff --git a/README.md b/README.md
iindex 3b0cc4c..4ad5c4e 100644
---- a/README.md
++++ b/README.md
@@@ -369,6 +369,27 @@ that reaches port 4100 directly cannot choose its IP by sending `X-Forwarded-For
`CF-Connecting-IP` itself. View counting, paste limits and the live-frame rate limit read `req.ip`,
never the headers (`test/trust-proxy.test.js`).
++## Per-actor limits
++
++nginx limits by address; the capability routes also limit by who calls (`server/actor-limits.js`,
++openvibe-sdk/limits, roadmap WS-R task 4): the Network token's principal (`svc:community`, `app:app_…`),
++the person signed in to `/api/v2/me`, or an app acting for one of its users (`X-OV-User-Id`). An app's
++own API key is not counted (a first-party server speaks for all its visitors); a browser with only an
++upload token is counted by address. Past a limit: 429 problem+json `rate_limited` with `Retry-After`,
++before the route does any work, and one `[Limits]` log line and `media_rate_limited_total{limit,window}`.
++
++| Routes | Per caller |
++|---|---|
++| Reads: objects, files, jobs, namespaces, `/api/v2/me` | `MEDIA_LIMITS_MINUTE` / `MEDIA_LIMITS_HOUR` (120 / 3000) |
++| Object init, `/upload-url`, `/multipart`, multipart complete; file upload | 30 a minute, 600 an hour |
++| Object content PUT, complete; object and file delete, restore, multipart abort; job cancel | 60 / 1200 |
++| Multipart parts | 600 / 20000 (the session already bounds them) |
++| Job create, approve | 10 / 200 |
++| `POST /api/v2/me/ops/recompute` | 6 / 60 |
++
++Never limited: `/healthz`, `/api/ready`, `/release.json`, `/metrics`, the signed `/internal/events` deliveries.
++Counters are per process (a restart forgets them). `test/actor-limits.test.js`.
++
## Health, readiness and metrics
| path | behavior |
ddiff --git a/package-lock.json b/package-lock.json
iindex 79490dd..4fed695 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"express": "^4.19.2",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"sharp": "^0.33.4"
}
@@@ -1935,9 +1935,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.11.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
-- "integrity": "sha512-sY/0bl5bhixjxLv2ACeL/AKIE2QlnndiDqkNuA5YGo2pC7AMvPqRP05y4OAtuJ7JvWtaBZ9gETB03u07uYp3wQ==",
++ "version": "0.12.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
++ "integrity": "sha512-iG1h905O1f7y/PnP8xLA6GCVzXEBDfSaBj2tc1ucNreWg2sT/AJWosb8NW0WRMK5WS4sIQ6tMsprmDLj/fjjeQ==",
"license": "MIT",
"engines": {
"node": ">=20"
ddiff --git a/package.json b/package.json
iindex ba9c3c0..ec72417 100644
---- a/package.json
++++ b/package.json
@@@ -13,7 +13,7 @@
"license": "MIT",
"dependencies": {
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.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",
"@aws-sdk/client-s3": "^3.600.0",
"@aws-sdk/lib-storage": "^3.600.0",
"@aws-sdk/s3-request-presigner": "^3.600.0",
ddiff --git a/server/actor-limits.js b/server/actor-limits.js
nnew file mode 100644
iindex 0000000..b6192dc
---- /dev/null
++++ b/server/actor-limits.js
@@@ -0,0 +1,57 @@
++'use strict';
++/**
++ * Per-actor rate limits at Media's capability boundaries (roadmap WS-R task 4; openvibe-sdk/limits).
++ *
++ * nginx limits by address. These limit by WHO calls: the Network token's principal (svc:community,
++ * app:app_… of a developer project), the person signed in to the object explorer (user:usr_…), or an
++ * app acting for one of its users (live:user:<id>, X-OV-User-Id). An app's own API key is not counted:
++ * it is a first-party server speaking for every visitor of its site, so one budget for it would refuse
++ * the whole site; that server limits its own visitors. A browser holding only an upload token (a
++ * presigned PUT) is counted by address. Past a limit the route answers 429 problem+json `rate_limited`
++ * with Retry-After before doing any work. Reads get MEDIA_LIMITS_MINUTE / MEDIA_LIMITS_HOUR (120 and
++ * 3000); uploads, deletes and jobs set their own, tighter numbers where they are mounted.
++ *
++ * Counters live in this process: a restart forgets them. Never on /healthz, /api/ready, /release.json,
++ * /metrics or the signed /internal/events deliveries (Events pushes at its own pace; a 429 there only
++ * makes it retry and fall behind).
++ */
++const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');
++const config = require('./config');
++
++let clock = () => Date.now();
++let refused = null; // media_rate_limited_total, once index.js has a metrics registry
++
++/** Who is counted: see above. null = not counted (an app's own API key). */
++function actor(req) {
++ if (req.principal && req.principal.sub) return req.principal.sub;
++ if (req.person && req.person.subject) return `user:${req.person.subject}`;
++ if (req.authType === 'user' && req.userId != null) return `${req.appId}:user:${req.userId}`;
++ if (req.authType === 'app') return null;
++ return defaultActor(req);
++}
++
++function onLimited(e) {
++ // The actor is a principal, a subject id or an address, never a credential.
++ console.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
++ if (refused) refused.inc({ limit: e.name, window: e.window });
++}
++
++const limits = createActorLimiter({
++ limits: { minute: config.limits.minute, hour: config.limits.hour },
++ actor,
++ now: () => clock(),
++ onLimited,
++});
++
++/** Count refusals in /metrics (server/index.js, after observability.instrument). */
++function bindMetrics(registry) {
++ if (refused || !registry) return;
++ refused = registry.counter({ name: 'media_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] });
++}
++
++module.exports = {
++ limits,
++ actor,
++ bindMetrics,
++ _setClockForTests(fn) { clock = fn || (() => Date.now()); },
++};
ddiff --git a/server/config.js b/server/config.js
iindex 631ebb9..3c5c553 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -68,6 +68,13 @@ const config = {
maxChildNamespaces: Math.max(0, intEnv('MEDIA_NAMESPACE_MAX_CHILDREN', 100)), // children per tenant
},
++ // Per-actor limits at the capability boundaries (server/actor-limits.js, roadmap WS-R task 4): what a
++ // read route allows each caller. Uploads, deletes and jobs set tighter numbers where they are mounted.
++ limits: {
++ minute: Math.max(1, intEnv('MEDIA_LIMITS_MINUTE', 120)),
++ hour: Math.max(1, intEnv('MEDIA_LIMITS_HOUR', 3000)),
++ },
++
// Job system (server/jobs/; docs/object-model.md#jobs).
jobs: {
enabled: !['0', 'false', 'off'].includes(String(process.env.MEDIA_JOBS_ENABLED || '').toLowerCase()),
ddiff --git a/server/files/routes.js b/server/files/routes.js
iindex 4e8bd57..da31ff2 100644
---- a/server/files/routes.js
++++ b/server/files/routes.js
@@@ -32,6 +32,7 @@ const db = require('../db/database');
const config = require('../config');
const namespaces = require('../objects/namespaces');
const { tenantAuth, tenantCors } = require('../auth');
++const { limits } = require('../actor-limits');
const router = express.Router({ mergeParams: true });
router.use(tenantCors);
@@@ -85,7 +86,9 @@ function sha256File(filePath) {
}
// ── Upload ───────────────────────────────────────────────────
--router.post('/', tenantAuth({ allowUser: true, verb: 'write' }), upload.single('file'), async (req, res) => {
++// Per-actor limits (server/actor-limits.js) sit after the credential and before multer reads the body.
++// An upload stores up to MAX_FILE_SIZE_MB and counts against the quota: one every two seconds at most.
++router.post('/', tenantAuth({ allowUser: true, verb: 'write' }), limits('media.file.upload', { minute: 30, hour: 600 }), upload.single('file'), async (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: 'No file uploaded (multipart field: file)' });
@@@ -149,7 +152,7 @@ router.post('/', tenantAuth({ allowUser: true, verb: 'write' }), upload.single('
});
// ── List ─────────────────────────────────────────────────────
--router.get('/', tenantAuth({ allowUser: true, verb: 'list' }), (req, res) => {
++router.get('/', tenantAuth({ allowUser: true, verb: 'list' }), limits('media.file.list'), (req, res) => {
try {
const limit = Math.min(Math.max(parseInt(req.query.limit || '100', 10), 1), 500);
const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
@@@ -166,7 +169,7 @@ router.get('/', tenantAuth({ allowUser: true, verb: 'list' }), (req, res) => {
});
// ── Meta ─────────────────────────────────────────────────────
--router.get('/:key', tenantAuth({ allowUser: true, verb: 'read' }), (req, res) => {
++router.get('/:key', tenantAuth({ allowUser: true, verb: 'read' }), limits('media.file.read'), (req, res) => {
try {
const row = db.getFileByKey(String(req.params.key), req.appId);
if (!row) return res.status(404).json({ error: 'File not found' });
@@@ -177,7 +180,8 @@ router.get('/:key', tenantAuth({ allowUser: true, verb: 'read' }), (req, res) =>
});
// ── Delete ───────────────────────────────────────────────────
--router.delete('/:key', tenantAuth({ allowUser: true, verb: 'delete' }), (req, res) => {
++// A delete removes bytes and frees quota: a write, tighter than reads.
++router.delete('/:key', tenantAuth({ allowUser: true, verb: 'delete' }), limits('media.file.delete', { minute: 60, hour: 1200 }), (req, res) => {
try {
const row = db.getFileByKey(String(req.params.key), req.appId);
if (!row) return res.status(404).json({ error: 'File not found' });
ddiff --git a/server/index.js b/server/index.js
iindex fe50c34..2ae6e1f 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -56,6 +56,8 @@ app.set('trust proxy', require('./client-ip').TRUST_PROXY);
const release = require('openvibe-shared/release').createRelease({ service: 'media', root: require('path').join(__dirname, '..') });
const observability = require('./observability');
const instrumented = observability.instrument(app, { release: release.release });
++// Per-actor limit refusals (server/actor-limits.js) are counted there: media_rate_limited_total.
++require('./actor-limits').bindMetrics(instrumented.registry);
// A restore-drill instance answers reads only: 403 for every other method, on every path (before the
// raw-body upload routes below).
if (drill.enabled) app.use(drill.readOnly);
ddiff --git a/server/jobs/routes.js b/server/jobs/routes.js
iindex 7e24c64..827db79 100644
---- a/server/jobs/routes.js
++++ b/server/jobs/routes.js
@@@ -25,10 +25,14 @@ const model = require('../objects/model');
const queue = require('./queue');
const db = require('../db/database');
const { tenantAuth, tenantCors, namespaceGrant } = require('../auth');
++const { limits } = require('../actor-limits');
const transform = tenantAuth({ verb: 'transform', namespaced: true });
const read = tenantAuth({ verb: 'read' });
const list = tenantAuth({ verb: 'list' });
++// Per-actor limits (server/actor-limits.js): reads take the defaults. A job runs sharp or ffmpeg on a
++// worker with one or two slots, so a caller queues or approves at most one every six seconds.
++const QUEUE = { minute: 10, hour: 200 };
function problem(res, status, code, detail, extra) {
return http.sendProblem(res, status, code, { detail, extra });
@@@ -71,7 +75,7 @@ function sendJobError(res, err) {
const router = express.Router({ mergeParams: true });
router.use(tenantCors);
--router.get('/', list, (req, res) => {
++router.get('/', list, limits('media.job.list'), (req, res) => {
try {
const q = req.query;
if (q.cursor && !/^mjob_[0-9A-Za-z_]{1,40}$/.test(String(q.cursor))) return problem(res, 400, 'media.job.invalid', 'Bad cursor');
@@@ -84,7 +88,7 @@ router.get('/', list, (req, res) => {
} catch (err) { sendJobError(res, err); }
});
--router.post('/', transform, (req, res) => {
++router.post('/', transform, limits('media.job.create', QUEUE), (req, res) => {
try {
const b = req.body || {};
const type = String(b.type || '');
@@@ -117,12 +121,12 @@ router.post('/', transform, (req, res) => {
} catch (err) { sendJobError(res, err); }
});
--router.get('/:jobId', read, (req, res) => {
++router.get('/:jobId', read, limits('media.job.read'), (req, res) => {
const job = load(req, res);
if (job) res.json({ job: queue.jobPublic(job) });
});
--router.post('/:jobId/approve', transform, (req, res) => {
++router.post('/:jobId/approve', transform, limits('media.job.approve', QUEUE), (req, res) => {
try {
const job = load(req, res);
if (!job || !mayTransform(req, res, job.object_id ? model.getObject(job.object_id) : null)) return;
@@@ -143,7 +147,9 @@ function cancelHandler(req, res) {
res.status(out.pending ? 202 : 200).json({ job: queue.jobPublic(out.job) });
} catch (err) { sendJobError(res, err); }
}
--router.post('/:jobId/cancel', transform, cancelHandler);
--router.delete('/:jobId', transform, cancelHandler);
++// A cancel only stops work, so it is looser than queueing; both spellings share one budget.
++const cancelLimit = limits('media.job.cancel', { minute: 60, hour: 1200 });
++router.post('/:jobId/cancel', transform, cancelLimit, cancelHandler);
++router.delete('/:jobId', transform, cancelLimit, cancelHandler);
module.exports = router;
ddiff --git a/server/me/routes.js b/server/me/routes.js
iindex 836d4c5..f77c083 100644
---- a/server/me/routes.js
++++ b/server/me/routes.js
@@@ -33,6 +33,7 @@ const drill = require('../drill');
const { extractToken } = require('../user-auth');
const explorer = require('./explorer');
const pages = require('./pages');
++const { limits } = require('../actor-limits');
const PRINCIPAL_SUB = /^(svc|app|mod):/;
const SITE_ORIGIN = (() => { try { return new URL(config.publicUrl).origin; } catch { return null; } })();
@@@ -122,7 +123,9 @@ function createMeRoutes({ auth }) {
const api = express.Router();
api.use(privateHeaders, identify(auth));
-- api.get('/objects', (req, res) => {
++ // Per-actor limits (server/actor-limits.js), counted by the signed-in person once identify() ran:
++ // reads take the defaults.
++ api.get('/objects', limits('media.me.objects'), (req, res) => {
if (!needPerson(req, res)) return;
const f = explorer.parseFilters(req.query);
if (f.error) return problem(res, 400, 'media.me.invalid', f.error);
@@@ -134,14 +137,14 @@ function createMeRoutes({ auth }) {
}
});
-- api.get('/objects/:id', (req, res) => {
++ api.get('/objects/:id', limits('media.me.object'), (req, res) => {
if (!needPerson(req, res)) return;
const obj = explorer.detail(req.person.subject, req.params.id);
if (!obj) return problem(res, 404, 'media.object.not_found', 'No such object of yours');
res.json(obj);
});
-- api.get('/usage', (req, res) => {
++ api.get('/usage', limits('media.me.usage'), (req, res) => {
if (!needPerson(req, res)) return;
try {
res.json(explorer.usage(req.person.subject));
@@@ -151,7 +154,7 @@ function createMeRoutes({ auth }) {
}
});
-- api.get('/ops', (req, res) => {
++ api.get('/ops', limits('media.me.ops'), (req, res) => {
if (!needPerson(req, res)) return;
if (!can(req.person, 'staff.site.view')) return problem(res, 403, 'capability.denied', 'staff.site.view not granted');
const s = opsScope(req);
@@@ -164,7 +167,8 @@ function createMeRoutes({ auth }) {
}
});
-- api.post('/ops/recompute', (req, res) => {
++ // A recompute counts every namespace's rows again: a staff button, pressed a few times at most.
++ api.post('/ops/recompute', limits('media.me.ops_recompute', { minute: 6, hour: 60 }), (req, res) => {
if (!needPerson(req, res)) return;
if (!can(req.person, 'staff.site.configure')) return problem(res, 403, 'capability.denied', 'staff.site.configure not granted');
if (!sameOrigin(req)) return problem(res, 403, 'media.request.cross_origin', 'Only this site\'s own pages may ask for this');
ddiff --git a/server/objects/namespace-routes.js b/server/objects/namespace-routes.js
iindex 9573a7a..6b5f677 100644
---- a/server/objects/namespace-routes.js
++++ b/server/objects/namespace-routes.js
@@@ -17,6 +17,7 @@ const express = require('express');
const { http } = require('openvibe-contracts');
const namespaces = require('./namespaces');
const { tenantAuth, tenantCors, namespaceGrant } = require('../auth');
++const { limits } = require('../actor-limits');
const router = express.Router({ mergeParams: true });
router.use(tenantCors);
@@@ -29,7 +30,8 @@ function shape(req, row) {
return namespaces.publicShape(req.appRow, row, namespaces.reconcile(req.appRow, row.namespace));
}
--router.get('/', tenantAuth({ verb: 'list', namespaced: true }), (req, res) => {
++// Reads, with the default per-actor limits (server/actor-limits.js).
++router.get('/', tenantAuth({ verb: 'list', namespaced: true }), limits('media.namespace.list'), (req, res) => {
try {
const rows = namespaces.listForTenant(req.appId).filter(r => namespaceGrant(req, 'list', r.namespace).allowed);
res.json({ namespaces: rows.map(r => shape(req, r)) });
@@@ -39,7 +41,7 @@ router.get('/', tenantAuth({ verb: 'list', namespaced: true }), (req, res) => {
}
});
--router.get('/:namespace', tenantAuth({ verb: 'read', namespaced: true }), (req, res) => {
++router.get('/:namespace', tenantAuth({ verb: 'read', namespaced: true }), limits('media.namespace.read'), (req, res) => {
try {
const named = namespaces.resolveName(req.appRow, req.params.namespace);
if (named.error) return problem(res, 400, 'media.namespace.invalid', named.error);
ddiff --git a/server/objects/routes.js b/server/objects/routes.js
iindex b854993..b1582d0 100644
---- a/server/objects/routes.js
++++ b/server/objects/routes.js
@@@ -54,6 +54,7 @@ const multipart = require('./multipart');
const namespaces = require('./namespaces');
const { tenantAuth, tenantCors, tenantPath, namespaceGrant } = require('../auth');
const { announce } = require('../webhooks');
++const { limits } = require('../actor-limits');
const MB = 1024 * 1024;
// Each route checks its verb for the namespace it works in (the object's, or the one init names).
@@@ -62,6 +63,10 @@ const read = tenantAuth({ verb: 'read', namespaced: true });
const list = tenantAuth({ verb: 'list', namespaced: true });
const remove = tenantAuth({ verb: 'delete', namespaced: true });
const appOnly = tenantAuth();
++// Per-actor limits (server/actor-limits.js), after the credential and before the work. Reads take the
++// defaults (MEDIA_LIMITS_MINUTE / _HOUR). An init reserves quota and makes a row, and each upload step
++// writes or hashes bytes, so the upload routes allow a steady app a new object every two seconds at most.
++const UPLOAD = { minute: 30, hour: 600 };
function problem(res, status, code, detail, extra) {
return http.sendProblem(res, status, code, { detail, extra });
@@@ -288,7 +293,7 @@ async function putContent(req, res) {
const router = express.Router({ mergeParams: true });
router.use(tenantCors);
--router.post('/', upload, (req, res) => {
++router.post('/', upload, limits('media.object.upload', UPLOAD), (req, res) => {
try {
const b = req.body || {};
const kind = String(b.kind || 'file');
@@@ -422,7 +427,8 @@ function loadUploading(req, res) {
return obj;
}
--router.post('/:id/complete', contentAuth, tenantCors, (req, res) => {
++// Complete re-reads the stored bytes (type sniff, hash); twice the init rate leaves room for retries.
++router.post('/:id/complete', contentAuth, tenantCors, limits('media.object.complete', { minute: 60, hour: 1200 }), (req, res) => {
try {
const obj = load(req, res, 'write');
if (!obj) return;
@@@ -438,7 +444,7 @@ router.post('/:id/complete', contentAuth, tenantCors, (req, res) => {
// ── Presigned single-PUT URL ─────────────────────────────────
--router.post('/:id/upload-url', upload, (req, res) => {
++router.post('/:id/upload-url', upload, limits('media.object.upload_url', UPLOAD), (req, res) => {
const obj = loadUploading(req, res);
if (!obj) return;
const ttl = req.body && req.body.ttl != null ? Number(req.body.ttl) : (req.query.ttl != null ? Number(req.query.ttl) : undefined);
@@@ -465,7 +471,7 @@ function loadSession(req, res, obj, { open = true } = {}) {
return now;
}
--router.post('/:id/multipart', upload, (req, res) => {
++router.post('/:id/multipart', upload, limits('media.object.multipart', UPLOAD), (req, res) => {
try {
const obj = loadUploading(req, res);
if (!obj) return;
@@@ -496,7 +502,7 @@ router.post('/:id/multipart', upload, (req, res) => {
}
});
--router.get('/:id/multipart/:uploadId', mpRead, (req, res) => {
++router.get('/:id/multipart/:uploadId', mpRead, limits('media.object.multipart_read'), (req, res) => {
const obj = load(req, res, 'read');
if (!obj) return;
const session = loadSession(req, res, obj, { open: false });
@@@ -505,7 +511,8 @@ router.get('/:id/multipart/:uploadId', mpRead, (req, res) => {
res.json(multipart.sessionPublic(multipart.getSession(session.id)));
});
--router.post('/:id/multipart/:uploadId/complete', mpWrite, tenantCors, async (req, res) => {
++// Assembling concatenates and hashes every part of an object up to MEDIA_MULTIPART_MAX_MB.
++router.post('/:id/multipart/:uploadId/complete', mpWrite, tenantCors, limits('media.object.multipart_complete', UPLOAD), async (req, res) => {
let session = null;
try {
// A repeat of a complete that already succeeded (its answer was lost on the way): the object as it is.
@@@ -542,7 +549,8 @@ router.post('/:id/multipart/:uploadId/complete', mpWrite, tenantCors, async (req
}
});
--router.delete('/:id/multipart/:uploadId', mpWrite, (req, res) => {
++// An abort deletes the parts and frees the reservation: a write, cheaper than an upload.
++router.delete('/:id/multipart/:uploadId', mpWrite, limits('media.object.multipart_abort', { minute: 60, hour: 1200 }), (req, res) => {
const obj = load(req, res, 'write');
if (!obj) return;
if (!canWrite(req, obj)) return problem(res, 403, 'media.object.forbidden', 'Not your object');
@@@ -553,7 +561,7 @@ router.delete('/:id/multipart/:uploadId', mpWrite, (req, res) => {
res.json(multipart.sessionPublic(multipart.getSession(session.id), { parts: false }));
});
--router.get('/', list, (req, res) => {
++router.get('/', list, limits('media.object.list'), (req, res) => {
try {
const q = req.query;
const limit = Math.min(Math.max(parseInt(q.limit || '50', 10) || 50, 1), 200);
@@@ -601,12 +609,13 @@ router.get('/', list, (req, res) => {
}
});
--router.get('/:id', read, (req, res) => {
++router.get('/:id', read, limits('media.object.read'), (req, res) => {
const obj = load(req, res, 'read');
if (obj) res.json(model.objectPublic(obj));
});
--router.delete('/:id', remove, (req, res) => {
++// Deletes and restores change lifecycle, quota usage and events: writes, tighter than reads.
++router.delete('/:id', remove, limits('media.object.delete', { minute: 60, hour: 1200 }), (req, res) => {
try {
const obj = load(req, res, 'delete');
if (!obj) return;
@@@ -624,7 +633,7 @@ router.delete('/:id', remove, (req, res) => {
}
});
--router.post('/:id/restore', remove, (req, res) => {
++router.post('/:id/restore', remove, limits('media.object.restore', { minute: 60, hour: 1200 }), (req, res) => {
const obj = load(req, res, 'delete');
if (!obj) return;
if (!canWrite(req, obj)) return problem(res, 403, 'media.object.forbidden', 'Not your object');
@@@ -638,7 +647,7 @@ router.post('/:id/restore', remove, (req, res) => {
res.json(model.objectPublic(back));
});
--router.get('/:id/download', read, (req, res) => {
++router.get('/:id/download', read, limits('media.object.download'), (req, res) => {
const obj = load(req, res, 'read');
if (!obj) return;
if (obj.lifecycle_status === 'deleted') return problem(res, 410, 'media.object.deleted', 'Object was deleted');
@@@ -657,7 +666,7 @@ router.get('/:id/download', read, (req, res) => {
// ── Retention holds ──────────────────────────────────────────
--router.get('/:id/holds', read, (req, res) => {
++router.get('/:id/holds', read, limits('media.object.holds'), (req, res) => {
const obj = load(req, res, 'read');
if (!obj) return;
res.json({
@@@ -788,7 +797,10 @@ async function putPart(req, res) {
}
module.exports = router;
--module.exports.contentHandlers = [contentAuth, tenantCors, putContent];
--module.exports.partHandlers = [mpWrite, tenantCors, putPart];
++// The bytes of one object: sent once, retried after a failure.
++module.exports.contentHandlers = [contentAuth, tenantCors, limits('media.object.content', { minute: 60, hour: 1200 }), putContent];
++// A multipart upload sends its parts in a row (at least MEDIA_MULTIPART_MIN_PART_MB each, 5 MB: 600 a
++// minute is 50 MB/s) and its session already bounds them; this stops only a runaway loop.
++module.exports.partHandlers = [mpWrite, tenantCors, limits('media.object.part', { minute: 600, hour: 20000 }), putPart];
module.exports.publicRouter = publicRouter;
module.exports.INLINE = INLINE;
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
nnew file mode 100644
iindex 0000000..a4f69d8
---- /dev/null
++++ b/test/actor-limits.test.js
@@@ -0,0 +1,121 @@
++'use strict';
++// Per-actor rate limits at the capability boundaries (server/actor-limits.js, roadmap WS-R task 4): past
++// its limit one caller gets 429 problem+json `rate_limited` with Retry-After, before the route does any
++// work, while another caller still passes; the window reopens on the clock; an app's own API key is not
++// counted, an app acting for one of its users is counted per user; refusals are logged and counted.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const crypto = require('crypto');
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-limits-'));
++const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };
++process.env.DB_PATH = path.join(tmp, 'media.db');
++process.env.VOD_PATH = dir('vods');
++process.env.FILES_PATH = dir('files');
++process.env.OBJECTS_PATH = dir('objects');
++process.env.THUMBNAILS_PATH = dir('thumbnails');
++process.env.OV_NETWORK_URL = 'https://openvibe.network';
++process.env.MEDIA_PUBLIC_URL = 'https://media.test';
++process.env.MEDIA_SIGNING_SECRET = 'test-signing-secret';
++process.env.MEDIA_LIMITS_MINUTE = '3';
++process.env.MEDIA_LIMITS_HOUR = '100';
++
++const db = require('../server/db/database');
++const auth = require('../server/auth');
++const actorLimits = require('../server/actor-limits');
++const objectRoutes = require('../server/objects/routes');
++const { serviceAuth } = require('openvibe-contracts');
++const express = require('express');
++
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++auth._setNetworkPublicKeyForTests(keys.publicKey);
++db.upsertApp({ app_id: 'live', api_key: 'live-key' });
++const iat = Math.floor(Date.now() / 1000);
++const tok = (sub) => serviceAuth.signServiceToken({ iss: 'https://openvibe.network', sub, actor_type: 'service', aud: ['openvibe.media'], cap: ['media.object.upload', 'media.object.read'], ns: ['live'], iat, exp: iat + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, keys.privateKey);
++const ONE = tok('svc:one'), TWO = tok('svc:two');
++
++// The limiter's clock: 15 s into a minute, so the minute window has 45 s left.
++let t = Date.UTC(2026, 8, 27, 12, 0, 15);
++actorLimits._setClockForTests(() => t);
++const refusals = [];
++actorLimits.bindMetrics({ counter: () => ({ inc: (labels) => refusals.push(labels) }) });
++const logged = [];
++const warn = console.warn;
++console.warn = (...a) => { logged.push(a.join(' ')); };
++
++const app = express();
++app.use(express.json());
++app.use('/api/v2/:app/objects', objectRoutes);
++const server = http.createServer(app);
++
++(async () => {
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const call = async (method, p, { bearer, body, headers = {} } = {}) => {
++ const h = { ...headers };
++ if (bearer) h.authorization = `Bearer ${bearer}`;
++ let payload;
++ if (body !== undefined) { h['content-type'] = 'application/json'; payload = JSON.stringify(body); }
++ const res = await fetch(base + p, { method, headers: h, body: payload });
++ const text = await res.text();
++ let json = null; try { json = JSON.parse(text); } catch { /* not json */ }
++ return { status: res.status, body: json, headers: res.headers };
++ };
++ const O = '/api/v2/live/objects';
++
++ // ── A read route: MEDIA_LIMITS_MINUTE (3 here) per caller ──
++ for (let i = 0; i < 3; i++) assert.strictEqual((await call('GET', O, { bearer: ONE })).status, 200);
++ let r = await call('GET', O, { bearer: ONE });
++ assert.strictEqual(r.status, 429, 'the fourth read in the minute is refused');
++ assert.strictEqual(r.headers.get('retry-after'), '45', 'Retry-After: the rest of the minute');
++ assert.ok(/^application\/problem\+json/.test(r.headers.get('content-type')));
++ assert.deepStrictEqual([r.body.code, r.body.status, r.body.retry_after_seconds], ['rate_limited', 429, 45]);
++ assert.ok(r.body.detail.includes('media.object.list'), r.body.detail);
++ assert.strictEqual((await call('GET', O, { bearer: TWO })).status, 200, 'another principal still passes');
++ assert.strictEqual((await call('GET', O, { bearer: 'not-a-token' })).status, 401, 'no credential: 401 from auth first, never a 429');
++ assert.strictEqual((await call('GET', `${O}/med_missing`, { bearer: ONE })).status, 404, 'each route has its own count');
++ t += 45 * 1000;
++ assert.strictEqual((await call('GET', O, { bearer: ONE })).status, 200, 'the next minute opens the window again');
++ console.log('✅ reads: 3 per minute per principal, 429 rate_limited + Retry-After, another principal passes');
++
++ // ── An app's own key is not counted; an app acting for a user is counted per user ──
++ for (let i = 0; i < 8; i++) assert.strictEqual((await call('GET', O, { bearer: 'live-key' })).status, 200);
++ const asUser = (id) => ({ bearer: 'live-key', headers: { 'x-ov-user-id': String(id) } });
++ for (let i = 0; i < 3; i++) assert.strictEqual((await call('GET', O, asUser(7))).status, 200);
++ r = await call('GET', O, asUser(7));
++ assert.deepStrictEqual([r.status, r.body.code], [429, 'rate_limited'], 'user 7 of live is over');
++ assert.strictEqual((await call('GET', O, asUser(8))).status, 200, 'user 8 of live still passes');
++ assert.strictEqual((await call('GET', O, { bearer: 'live-key' })).status, 200, 'the app itself still passes');
++ console.log('✅ app key not counted; X-OV-User-Id counted per user');
++
++ // ── A write route has its own, tighter numbers: init 30 per minute ──
++ t = Date.UTC(2026, 8, 27, 12, 5, 0);
++ const init = (bearer) => call('POST', O, { bearer, body: { kind: 'file', mime_type: 'text/plain', size_bytes: 5, visibility: 'private' } });
++ for (let i = 0; i < 30; i++) assert.strictEqual((await init(ONE)).status, 201);
++ r = await init(ONE);
++ assert.deepStrictEqual([r.status, r.body.code, r.headers.get('retry-after')], [429, 'rate_limited', '60']);
++ const before = db.get('SELECT COUNT(*) AS n FROM media_objects').n;
++ await init(ONE);
++ assert.strictEqual(db.get('SELECT COUNT(*) AS n FROM media_objects').n, before, 'a refused init makes nothing');
++ assert.strictEqual((await init(TWO)).status, 201, 'another principal still uploads');
++ console.log('✅ uploads: 30 inits per minute per principal, refused before any row is made');
++
++ // ── Refusals are logged (no credential) and counted ──
++ assert.ok(refusals.some((l) => l.limit === 'media.object.list' && l.window === 'minute'));
++ assert.ok(refusals.some((l) => l.limit === 'media.object.upload' && l.window === 'minute'));
++ assert.ok(logged.some((l) => l.includes('[Limits] media.object.upload: svc:one refused, over 30 per minute')), logged.join('\n'));
++ assert.ok(!logged.some((l) => l.includes('live-key') || l.includes(ONE)), 'no credential in the log');
++ assert.strictEqual(actorLimits.actor({ authType: 'app', appId: 'live', ip: '127.0.0.1' }), null);
++ assert.strictEqual(actorLimits.actor({ ip: '203.0.113.9' }), 'ip:203.0.113.9', 'an upload token alone: by address');
++ assert.strictEqual(actorLimits.actor({ person: { subject: 'usr_x' } }), 'user:usr_x');
++ console.log('✅ refusals logged and counted (media_rate_limited_total)');
++
++ console.warn = warn;
++ server.close();
++ db.close();
++ fs.rmSync(tmp, { recursive: true, force: true });
++ console.log('\nactor-limits: all passed');
++})().catch((err) => { console.warn = warn; console.error(err); process.exit(1); });