openvibers/openvibe.media Commits

Limits page source (roadmap WS-N task 7): GET /limits.json lists the developer limits enforced here (tenant storage per environment, child namespaces, single-part, multipart and part sizes, public playback size, how long an unfinished upload holds its quota, the restore window) from the running config, with the problem each answers past it; public, CORS *; openvibe.codes/docs/limits renders it

OP OpenVibers committed 26 Sept 2026 · 0fc4452e97 · 3 files, +79 −0


Changes

ddiff --git a/server/index.js b/server/index.js
iindex 99a4064..f3ee8c9 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -126,6 +126,8 @@ app.options(['/api/v1/:app/*', '/api/v2/:app/*'], (req, res) => {
// takes open tabs' update reports into /metrics (release_client_updates_total). A restore drill
// answers that POST with the readOnly 403 above, like every other write.
release.mount(app, { registry: instrumented.registry });
++// GET /limits.json: the developer limits enforced here, from config (WS-N task 7; Codes renders them).
++require('./limits').mountLimits(app, config);
app.get('/healthz', (req, res) => {
res.json({
ddiff --git a/server/limits.js b/server/limits.js
nnew file mode 100644
iindex 0000000..7a3a11b
---- /dev/null
++++ b/server/limits.js
@@@ -0,0 +1,48 @@
++'use strict';
++/**
++ * GET /limits.json (roadmap WS-N task 7): the limits a developer project meets here, read from the
++ * running configuration, so OpenVibe.Codes' limits page (openvibe.codes/docs/limits) shows what is
++ * enforced and never restates it. Public: these are the defaults a project's tenant starts with; an
++ * operator's per-namespace quota (scripts/namespaces.js) is not here.
++ *
++ * limits[] id, label, capability (the grant it bounds), unit (count | bytes | hours | days),
++ * production and sandbox (the project's environment), exceeded (what the caller gets past it)
++ */
++const MB = 1024 * 1024;
++
++function limitsOf(config) {
++ const o = config.objects;
++ const t = config.apps;
++ const both = (v) => ({ production: v, sandbox: v });
++ return {
++ service: 'media',
++ scope: 'per project tenant and environment; namespaces app.<project_id> and app.<project_id>.sandbox (WS-G task 2)',
++ limits: [
++ { id: 'storage_bytes', label: 'Stored bytes (every namespace of the tenant)', capability: 'media.object.upload', unit: 'bytes',
++ production: t.projectQuotaMb * MB, sandbox: t.sandboxQuotaMb * MB, exceeded: '413 media.quota.exceeded' },
++ { id: 'child_namespaces', label: 'Namespaces below the root', capability: 'media.object.upload', unit: 'count',
++ ...both(o.maxChildNamespaces), exceeded: '413 media.quota.namespaces_exceeded' },
++ { id: 'single_upload_bytes', label: 'Size of a single-part upload', capability: 'media.object.upload', unit: 'bytes',
++ ...both(o.maxUploadMb * MB), exceeded: '413 media.object.too_large' },
++ { id: 'multipart_bytes', label: 'Size of a multipart upload', capability: 'media.object.upload', unit: 'bytes',
++ ...both(o.multipartMaxMb * MB), exceeded: '413 media.object.too_large' },
++ { id: 'part_bytes', label: 'Size of one multipart part', capability: 'media.object.upload', unit: 'bytes',
++ ...both(o.multipartMaxPartMb * MB), exceeded: '413 media.object.too_large' },
++ { id: 'public_object_bytes', label: 'Size of an object played publicly', capability: 'media.object.upload', unit: 'bytes',
++ ...both(o.publicMaxMb * MB), exceeded: '422 media.invariant.public_object_too_large' },
++ { id: 'unfinished_upload_hours', label: 'Hours an unfinished upload holds its quota', capability: 'media.object.upload', unit: 'hours',
++ ...both(o.reservationHours), exceeded: 'failed, bytes freed' },
++ { id: 'deleted_retention_days', label: 'Days a deleted object can be restored', capability: 'media.object.delete', unit: 'days',
++ ...both(o.retentionDays), exceeded: 'purged' },
++ ],
++ };
++}
++
++function mountLimits(app, config) {
++ const body = limitsOf(config);
++ app.get('/limits.json', (_req, res) => {
++ res.set('Cache-Control', 'public, max-age=300').set('Access-Control-Allow-Origin', '*').json(body);
++ });
++}
++
++module.exports = { limitsOf, mountLimits };
ddiff --git a/test/limits.test.js b/test/limits.test.js
nnew file mode 100644
iindex 0000000..03a619e
---- /dev/null
++++ b/test/limits.test.js
@@@ -0,0 +1,29 @@
++'use strict';
++// GET /limits.json (WS-N task 7): the developer limits Media enforces, read from the running config
++// (an env override is what it answers), public with CORS *; Codes' /docs/limits renders it.
++process.env.MEDIA_APP_SANDBOX_QUOTA_MB = '50';
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const config = require('../server/config');
++const { limitsOf, mountLimits } = require('../server/limits');
++
++(async () => {
++ const MB = 1024 * 1024;
++ const by = Object.fromEntries(limitsOf(config).limits.map((l) => [l.id, l]));
++ assert.deepStrictEqual([by.storage_bytes.production, by.storage_bytes.sandbox], [config.apps.projectQuotaMb * MB, 50 * MB], 'an env override is what it answers');
++ assert.strictEqual(by.child_namespaces.production, config.objects.maxChildNamespaces);
++ assert.strictEqual(by.storage_bytes.exceeded, '413 media.quota.exceeded');
++ assert.strictEqual(by.deleted_retention_days.capability, 'media.object.delete');
++ for (const l of Object.values(by)) assert.ok(Number.isInteger(l.production) && Number.isInteger(l.sandbox) && l.unit && l.exceeded, l.id);
++
++ const app = express();
++ mountLimits(app, config);
++ const server = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
++ const res = await fetch(`http://127.0.0.1:${server.address().port}/limits.json`);
++ assert.strictEqual(res.status, 200);
++ assert.strictEqual(res.headers.get('access-control-allow-origin'), '*');
++ assert.strictEqual((await res.json()).service, 'media');
++ server.close();
++ console.log('limits: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });