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}.
Changes
@@@ -55,6 +55,13 @@ MEDIA_APP_KEYS=MEDIA_APP_TENANT_QUOTA_MB=1024MEDIA_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=@@@ -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 |@@@ -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"@@@ -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",nnew file mode 100644@@@ -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()); },++};@@@ -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()),@@@ -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' });@@@ -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);@@@ -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;@@@ -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');@@@ -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);@@@ -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;nnew file mode 100644@@@ -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); });
@@ -55,6 +55,13 @@ MEDIA_APP_KEYS=
@@ -55,6 +55,13 @@ MEDIA_APP_KEYS=
MEDIA_APP_TENANT_QUOTA_MB=1024
MEDIA_APP_TENANT_QUOTA_MB=1024
MEDIA_APP_SANDBOX_QUOTA_MB=100
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) ─
# ── Object storage tiering (optional; local-only when unset) ─
# Backblaze B2 (canonical cold tier)
# Backblaze B2 (canonical cold tier)
MEDIA_B2_ENDPOINT=
MEDIA_B2_ENDPOINT=
@@ -369,6 +369,27 @@ that reaches port 4100 directly cannot choose its IP by sending `X-Forwarded-For
@@ -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`,
`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`).
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
## Health, readiness and metrics
| path | behavior |
| path | behavior |
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"express": "^4.19.2",
"express": "^4.19.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.71.0",
"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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"sharp": "^0.33.4"
"sharp": "^0.33.4"
}
}
@@ -1935,9 +1935,9 @@
@@ -1935,9 +1935,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"
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0",
"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/client-s3": "^3.600.0",
"@aws-sdk/lib-storage": "^3.600.0",
"@aws-sdk/lib-storage": "^3.600.0",
"@aws-sdk/s3-request-presigner": "^3.600.0",
"@aws-sdk/s3-request-presigner": "^3.600.0",
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -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()); },
+};
@@ -68,6 +68,13 @@ const config = {
@@ -68,6 +68,13 @@ const config = {
maxChildNamespaces: Math.max(0, intEnv('MEDIA_NAMESPACE_MAX_CHILDREN', 100)), // children per tenant
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).
// Job system (server/jobs/; docs/object-model.md#jobs).
jobs: {
jobs: {
enabled: !['0', 'false', 'off'].includes(String(process.env.MEDIA_JOBS_ENABLED || '').toLowerCase()),
enabled: !['0', 'false', 'off'].includes(String(process.env.MEDIA_JOBS_ENABLED || '').toLowerCase()),
@@ -32,6 +32,7 @@ const db = require('../db/database');
@@ -32,6 +32,7 @@ const db = require('../db/database');
const config = require('../config');
const config = require('../config');
const namespaces = require('../objects/namespaces');
const namespaces = require('../objects/namespaces');
const { tenantAuth, tenantCors } = require('../auth');
const { tenantAuth, tenantCors } = require('../auth');
+const { limits } = require('../actor-limits');
const router = express.Router({ mergeParams: true });
const router = express.Router({ mergeParams: true });
router.use(tenantCors);
router.use(tenantCors);
@@ -85,7 +86,9 @@ function sha256File(filePath) {
@@ -85,7 +86,9 @@ function sha256File(filePath) {
}
}
// ── Upload ───────────────────────────────────────────────────
// ── 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 {
try {
if (!req.file) return res.status(400).json({ error: 'No file uploaded (multipart field: file)' });
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('
@@ -149,7 +152,7 @@ router.post('/', tenantAuth({ allowUser: true, verb: 'write' }), upload.single('
});
});
// ── List ─────────────────────────────────────────────────────
// ── List ─────────────────────────────────────────────────────
-router.get('/', tenantAuth({ allowUser: true, verb: 'list' }), (req, res) => {
+router.get('/', tenantAuth({ allowUser: true, verb: 'list' }), limits('media.file.list'), (req, res) => {
try {
try {
const limit = Math.min(Math.max(parseInt(req.query.limit || '100', 10), 1), 500);
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);
const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
@@ -166,7 +169,7 @@ router.get('/', tenantAuth({ allowUser: true, verb: 'list' }), (req, res) => {
@@ -166,7 +169,7 @@ router.get('/', tenantAuth({ allowUser: true, verb: 'list' }), (req, res) => {
});
});
// ── Meta ─────────────────────────────────────────────────────
// ── 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 {
try {
const row = db.getFileByKey(String(req.params.key), req.appId);
const row = db.getFileByKey(String(req.params.key), req.appId);
if (!row) return res.status(404).json({ error: 'File not found' });
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) =>
@@ -177,7 +180,8 @@ router.get('/:key', tenantAuth({ allowUser: true, verb: 'read' }), (req, res) =>
});
});
// ── Delete ───────────────────────────────────────────────────
// ── 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 {
try {
const row = db.getFileByKey(String(req.params.key), req.appId);
const row = db.getFileByKey(String(req.params.key), req.appId);
if (!row) return res.status(404).json({ error: 'File not found' });
if (!row) return res.status(404).json({ error: 'File not found' });
@@ -56,6 +56,8 @@ app.set('trust proxy', require('./client-ip').TRUST_PROXY);
@@ -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 release = require('openvibe-shared/release').createRelease({ service: 'media', root: require('path').join(__dirname, '..') });
const observability = require('./observability');
const observability = require('./observability');
const instrumented = observability.instrument(app, { release: release.release });
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
// A restore-drill instance answers reads only: 403 for every other method, on every path (before the
// raw-body upload routes below).
// raw-body upload routes below).
if (drill.enabled) app.use(drill.readOnly);
if (drill.enabled) app.use(drill.readOnly);
@@ -25,10 +25,14 @@ const model = require('../objects/model');
@@ -25,10 +25,14 @@ const model = require('../objects/model');
const queue = require('./queue');
const queue = require('./queue');
const db = require('../db/database');
const db = require('../db/database');
const { tenantAuth, tenantCors, namespaceGrant } = require('../auth');
const { tenantAuth, tenantCors, namespaceGrant } = require('../auth');
+const { limits } = require('../actor-limits');
const transform = tenantAuth({ verb: 'transform', namespaced: true });
const transform = tenantAuth({ verb: 'transform', namespaced: true });
const read = tenantAuth({ verb: 'read' });
const read = tenantAuth({ verb: 'read' });
const list = tenantAuth({ verb: 'list' });
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) {
function problem(res, status, code, detail, extra) {
return http.sendProblem(res, status, code, { detail, extra });
return http.sendProblem(res, status, code, { detail, extra });
@@ -71,7 +75,7 @@ function sendJobError(res, err) {
@@ -71,7 +75,7 @@ function sendJobError(res, err) {
const router = express.Router({ mergeParams: true });
const router = express.Router({ mergeParams: true });
router.use(tenantCors);
router.use(tenantCors);
-router.get('/', list, (req, res) => {
+router.get('/', list, limits('media.job.list'), (req, res) => {
try {
try {
const q = req.query;
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');
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) => {
@@ -84,7 +88,7 @@ router.get('/', list, (req, res) => {
} catch (err) { sendJobError(res, err); }
} catch (err) { sendJobError(res, err); }
});
});
-router.post('/', transform, (req, res) => {
+router.post('/', transform, limits('media.job.create', QUEUE), (req, res) => {
try {
try {
const b = req.body || {};
const b = req.body || {};
const type = String(b.type || '');
const type = String(b.type || '');
@@ -117,12 +121,12 @@ router.post('/', transform, (req, res) => {
@@ -117,12 +121,12 @@ router.post('/', transform, (req, res) => {
} catch (err) { sendJobError(res, err); }
} 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);
const job = load(req, res);
if (job) res.json({ job: queue.jobPublic(job) });
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 {
try {
const job = load(req, res);
const job = load(req, res);
if (!job || !mayTransform(req, res, job.object_id ? model.getObject(job.object_id) : null)) return;
if (!job || !mayTransform(req, res, job.object_id ? model.getObject(job.object_id) : null)) return;
@@ -143,7 +147,9 @@ function cancelHandler(req, res) {
@@ -143,7 +147,9 @@ function cancelHandler(req, res) {
res.status(out.pending ? 202 : 200).json({ job: queue.jobPublic(out.job) });
res.status(out.pending ? 202 : 200).json({ job: queue.jobPublic(out.job) });
} catch (err) { sendJobError(res, err); }
} 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;
module.exports = router;
@@ -33,6 +33,7 @@ const drill = require('../drill');
@@ -33,6 +33,7 @@ const drill = require('../drill');
const { extractToken } = require('../user-auth');
const { extractToken } = require('../user-auth');
const explorer = require('./explorer');
const explorer = require('./explorer');
const pages = require('./pages');
const pages = require('./pages');
+const { limits } = require('../actor-limits');
const PRINCIPAL_SUB = /^(svc|app|mod):/;
const PRINCIPAL_SUB = /^(svc|app|mod):/;
const SITE_ORIGIN = (() => { try { return new URL(config.publicUrl).origin; } catch { return null; } })();
const SITE_ORIGIN = (() => { try { return new URL(config.publicUrl).origin; } catch { return null; } })();
@@ -122,7 +123,9 @@ function createMeRoutes({ auth }) {
@@ -122,7 +123,9 @@ function createMeRoutes({ auth }) {
const api = express.Router();
const api = express.Router();
api.use(privateHeaders, identify(auth));
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;
if (!needPerson(req, res)) return;
const f = explorer.parseFilters(req.query);
const f = explorer.parseFilters(req.query);
if (f.error) return problem(res, 400, 'media.me.invalid', f.error);
if (f.error) return problem(res, 400, 'media.me.invalid', f.error);
@@ -134,14 +137,14 @@ function createMeRoutes({ auth }) {
@@ -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;
if (!needPerson(req, res)) return;
const obj = explorer.detail(req.person.subject, req.params.id);
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');
if (!obj) return problem(res, 404, 'media.object.not_found', 'No such object of yours');
res.json(obj);
res.json(obj);
});
});
- api.get('/usage', (req, res) => {
+ api.get('/usage', limits('media.me.usage'), (req, res) => {
if (!needPerson(req, res)) return;
if (!needPerson(req, res)) return;
try {
try {
res.json(explorer.usage(req.person.subject));
res.json(explorer.usage(req.person.subject));
@@ -151,7 +154,7 @@ function createMeRoutes({ auth }) {
@@ -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 (!needPerson(req, res)) return;
if (!can(req.person, 'staff.site.view')) return problem(res, 403, 'capability.denied', 'staff.site.view not granted');
if (!can(req.person, 'staff.site.view')) return problem(res, 403, 'capability.denied', 'staff.site.view not granted');
const s = opsScope(req);
const s = opsScope(req);
@@ -164,7 +167,8 @@ function createMeRoutes({ auth }) {
@@ -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 (!needPerson(req, res)) return;
if (!can(req.person, 'staff.site.configure')) return problem(res, 403, 'capability.denied', 'staff.site.configure not granted');
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');
if (!sameOrigin(req)) return problem(res, 403, 'media.request.cross_origin', 'Only this site\'s own pages may ask for this');
@@ -17,6 +17,7 @@ const express = require('express');
@@ -17,6 +17,7 @@ const express = require('express');
const { http } = require('openvibe-contracts');
const { http } = require('openvibe-contracts');
const namespaces = require('./namespaces');
const namespaces = require('./namespaces');
const { tenantAuth, tenantCors, namespaceGrant } = require('../auth');
const { tenantAuth, tenantCors, namespaceGrant } = require('../auth');
+const { limits } = require('../actor-limits');
const router = express.Router({ mergeParams: true });
const router = express.Router({ mergeParams: true });
router.use(tenantCors);
router.use(tenantCors);
@@ -29,7 +30,8 @@ function shape(req, row) {
@@ -29,7 +30,8 @@ function shape(req, row) {
return namespaces.publicShape(req.appRow, row, namespaces.reconcile(req.appRow, row.namespace));
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 {
try {
const rows = namespaces.listForTenant(req.appId).filter(r => namespaceGrant(req, 'list', r.namespace).allowed);
const rows = namespaces.listForTenant(req.appId).filter(r => namespaceGrant(req, 'list', r.namespace).allowed);
res.json({ namespaces: rows.map(r => shape(req, r)) });
res.json({ namespaces: rows.map(r => shape(req, r)) });
@@ -39,7 +41,7 @@ router.get('/', tenantAuth({ verb: 'list', namespaced: true }), (req, res) => {
@@ -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 {
try {
const named = namespaces.resolveName(req.appRow, req.params.namespace);
const named = namespaces.resolveName(req.appRow, req.params.namespace);
if (named.error) return problem(res, 400, 'media.namespace.invalid', named.error);
if (named.error) return problem(res, 400, 'media.namespace.invalid', named.error);
@@ -54,6 +54,7 @@ const multipart = require('./multipart');
@@ -54,6 +54,7 @@ const multipart = require('./multipart');
const namespaces = require('./namespaces');
const namespaces = require('./namespaces');
const { tenantAuth, tenantCors, tenantPath, namespaceGrant } = require('../auth');
const { tenantAuth, tenantCors, tenantPath, namespaceGrant } = require('../auth');
const { announce } = require('../webhooks');
const { announce } = require('../webhooks');
+const { limits } = require('../actor-limits');
const MB = 1024 * 1024;
const MB = 1024 * 1024;
// Each route checks its verb for the namespace it works in (the object's, or the one init names).
// 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 });
@@ -62,6 +63,10 @@ const read = tenantAuth({ verb: 'read', namespaced: true });
const list = tenantAuth({ verb: 'list', namespaced: true });
const list = tenantAuth({ verb: 'list', namespaced: true });
const remove = tenantAuth({ verb: 'delete', namespaced: true });
const remove = tenantAuth({ verb: 'delete', namespaced: true });
const appOnly = tenantAuth();
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) {
function problem(res, status, code, detail, extra) {
return http.sendProblem(res, status, code, { detail, extra });
return http.sendProblem(res, status, code, { detail, extra });
@@ -288,7 +293,7 @@ async function putContent(req, res) {
@@ -288,7 +293,7 @@ async function putContent(req, res) {
const router = express.Router({ mergeParams: true });
const router = express.Router({ mergeParams: true });
router.use(tenantCors);
router.use(tenantCors);
-router.post('/', upload, (req, res) => {
+router.post('/', upload, limits('media.object.upload', UPLOAD), (req, res) => {
try {
try {
const b = req.body || {};
const b = req.body || {};
const kind = String(b.kind || 'file');
const kind = String(b.kind || 'file');
@@ -422,7 +427,8 @@ function loadUploading(req, res) {
@@ -422,7 +427,8 @@ function loadUploading(req, res) {
return obj;
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 {
try {
const obj = load(req, res, 'write');
const obj = load(req, res, 'write');
if (!obj) return;
if (!obj) return;
@@ -438,7 +444,7 @@ router.post('/:id/complete', contentAuth, tenantCors, (req, res) => {
@@ -438,7 +444,7 @@ router.post('/:id/complete', contentAuth, tenantCors, (req, res) => {
// ── Presigned single-PUT URL ─────────────────────────────────
// ── 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);
const obj = loadUploading(req, res);
if (!obj) return;
if (!obj) return;
const ttl = req.body && req.body.ttl != null ? Number(req.body.ttl) : (req.query.ttl != null ? Number(req.query.ttl) : undefined);
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 } = {}) {
@@ -465,7 +471,7 @@ function loadSession(req, res, obj, { open = true } = {}) {
return now;
return now;
}
}
-router.post('/:id/multipart', upload, (req, res) => {
+router.post('/:id/multipart', upload, limits('media.object.multipart', UPLOAD), (req, res) => {
try {
try {
const obj = loadUploading(req, res);
const obj = loadUploading(req, res);
if (!obj) return;
if (!obj) return;
@@ -496,7 +502,7 @@ router.post('/:id/multipart', upload, (req, res) => {
@@ -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');
const obj = load(req, res, 'read');
if (!obj) return;
if (!obj) return;
const session = loadSession(req, res, obj, { open: false });
const session = loadSession(req, res, obj, { open: false });
@@ -505,7 +511,8 @@ router.get('/:id/multipart/:uploadId', mpRead, (req, res) => {
@@ -505,7 +511,8 @@ router.get('/:id/multipart/:uploadId', mpRead, (req, res) => {
res.json(multipart.sessionPublic(multipart.getSession(session.id)));
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;
let session = null;
try {
try {
// A repeat of a complete that already succeeded (its answer was lost on the way): the object as it is.
// 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
@@ -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');
const obj = load(req, res, 'write');
if (!obj) return;
if (!obj) return;
if (!canWrite(req, obj)) return problem(res, 403, 'media.object.forbidden', 'Not your object');
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) => {
@@ -553,7 +561,7 @@ router.delete('/:id/multipart/:uploadId', mpWrite, (req, res) => {
res.json(multipart.sessionPublic(multipart.getSession(session.id), { parts: false }));
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 {
try {
const q = req.query;
const q = req.query;
const limit = Math.min(Math.max(parseInt(q.limit || '50', 10) || 50, 1), 200);
const limit = Math.min(Math.max(parseInt(q.limit || '50', 10) || 50, 1), 200);
@@ -601,12 +609,13 @@ router.get('/', list, (req, res) => {
@@ -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');
const obj = load(req, res, 'read');
if (obj) res.json(model.objectPublic(obj));
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 {
try {
const obj = load(req, res, 'delete');
const obj = load(req, res, 'delete');
if (!obj) return;
if (!obj) return;
@@ -624,7 +633,7 @@ router.delete('/:id', remove, (req, res) => {
@@ -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');
const obj = load(req, res, 'delete');
if (!obj) return;
if (!obj) return;
if (!canWrite(req, obj)) return problem(res, 403, 'media.object.forbidden', 'Not your object');
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) => {
@@ -638,7 +647,7 @@ router.post('/:id/restore', remove, (req, res) => {
res.json(model.objectPublic(back));
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');
const obj = load(req, res, 'read');
if (!obj) return;
if (!obj) return;
if (obj.lifecycle_status === 'deleted') return problem(res, 410, 'media.object.deleted', 'Object was deleted');
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) => {
@@ -657,7 +666,7 @@ router.get('/:id/download', read, (req, res) => {
// ── Retention holds ──────────────────────────────────────────
// ── 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');
const obj = load(req, res, 'read');
if (!obj) return;
if (!obj) return;
res.json({
res.json({
@@ -788,7 +797,10 @@ async function putPart(req, res) {
@@ -788,7 +797,10 @@ async function putPart(req, res) {
}
}
module.exports = router;
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.publicRouter = publicRouter;
module.exports.INLINE = INLINE;
module.exports.INLINE = INLINE;
new file mode 100644
new file mode 100644
@@ -0,0 +1,121 @@
@@ -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); });