openvibers/openvibe.host Commits

Limits page source (roadmap WS-N task 7): GET /limits.json on the dashboard host lists the default project limits (projects per owner, sites, custom domains, deploys a day, storage, files and file size) for sandbox and production, read from config, with the problem each answers past it; tenant hosts never reach it (host-isolation)

OP OpenVibers committed 26 Sept 2026 · d1fc66db78 · 4 files, +53 −3


Changes

ddiff --git a/README.md b/README.md
iindex fc4b81f..3ae3178 100644
---- a/README.md
++++ b/README.md
@@@ -195,7 +195,7 @@ A Node service (`server/`, Express 4, better-sqlite3, port **4910**, service id
| Host | What answers |
|---|---|
--| `openvibe.host` (the `BASE_URL` host) and loopback | the dashboard, `/api/v1`, `/auth/*`, `/api/ready`, `/api/health`, `/release.json`, `/metrics` (direct loopback only), legal pages |
++| `openvibe.host` (the `BASE_URL` host) and loopback | the dashboard, `/api/v1`, `/auth/*`, `/api/ready`, `/api/health`, `/release.json`, `/limits.json` (the default project limits, from config), `/metrics` (direct loopback only), legal pages |
| `<site>.openvibe.host` (one label) | that site's active deploy, and nothing else: no API, no sign-in, no cookies |
| a custom domain with `status = verified` | the site it was verified for |
| anything else (including pending, failed or lapsed custom domains, and `a.b.openvibe.host`) | `404 Unknown host`, with no tenant content |
ddiff --git a/server/app.js b/server/app.js
iindex a087973..4f4ba42 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -10,7 +10,7 @@
* <site>.<sitesDomain> / verified custom domain → tenant static files (http/tenant.js) and
* nothing else: no API, no auth, no cookies
* the dashboard host (BASE_URL) and loopback → dashboard pages, /api/v1, /auth, /api/ready,
-- * /release.json, /metrics (loopback only)
++ * /release.json, /limits.json, /metrics (loopback only)
* anything else → 404 unknown host
*/
const path = require('path');
@@@ -131,6 +131,8 @@ function createApp(opts = {}) {
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-host', version: VERSION }));
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports into /metrics.
release.mount(app, { registry: registry });
++ // GET /limits.json: the developer limits enforced here, from config (WS-N task 7; Codes renders them).
++ require('./limits').mountLimits(app, config);
const readiness = createHostReadiness({ store, blobs, auth, outbox, release: release.release, minFreeBytes: () => config.uploads.minFreeBytes });
app.get('/api/ready', readiness.handler);
app.get('/metrics', sharedMetrics.metricsHandler(registry));
ddiff --git a/server/limits.js b/server/limits.js
nnew file mode 100644
iindex 0000000..ef4ac6f
---- /dev/null
++++ b/server/limits.js
@@@ -0,0 +1,39 @@
++'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 own override (a
++ * host_quotas row staff set) is on its quota page, not here.
++ *
++ * limits[] id, label, capability (the grant it bounds), unit (count | bytes | per_day),
++ * production and sandbox (the project's environment; 0 means none allowed),
++ * exceeded (what the caller gets past it)
++ */
++function limitsOf(config) {
++ const p = config.quotas.production;
++ const s = config.quotas.sandbox;
++ const row = (id, label, key, unit, exceeded) => ({ id, label, capability: 'host.site.manage', unit, production: p[key], sandbox: s[key], exceeded });
++ return {
++ service: 'host',
++ scope: 'per project and environment; projects per owner',
++ limits: [
++ { id: 'projects_per_owner', label: 'Projects one person owns', capability: 'host.site.manage', unit: 'count',
++ production: config.projects.maxPerOwner, sandbox: config.projects.maxPerOwner, exceeded: '429 quota.projects' },
++ row('sites', 'Sites', 'sites', 'count', '429 quota.sites'),
++ row('custom_domains', 'Custom domains', 'customDomains', 'count', '429 quota.custom_domains'),
++ row('deploys_per_day', 'Deploys in 24 hours', 'deploysPerDay', 'per_day', '429 quota.deploys_per_day'),
++ row('storage_bytes', 'Stored bytes (every deploy kept)', 'storageBytes', 'bytes', '413 quota.storage'),
++ row('max_files', 'Files in one deploy', 'maxFiles', 'count', '413 quota.max_files'),
++ row('max_file_bytes', 'Size of one file', 'maxFileBytes', 'bytes', '413 quota.max_file_bytes'),
++ ],
++ };
++}
++
++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/host-isolation.test.js b/test/host-isolation.test.js
iindex 30fd818..a82f40b 100644
---- a/test/host-isolation.test.js
++++ b/test/host-isolation.test.js
@@@ -74,13 +74,22 @@ const { boot, check, done } = require('./stageb/boot');
});
await check('tenant hosts never reach the API, auth, metrics or readiness', async () => {
-- for (const p of ['/api/v1/projects', '/api/ready', '/metrics', '/auth/login', '/release.json', '/css/host.css']) {
++ for (const p of ['/api/v1/projects', '/api/ready', '/metrics', '/auth/login', '/release.json', '/limits.json', '/css/host.css']) {
const r = await t.get('alpha.openvibe.host', p, { as: bob });
assert.strictEqual(r.status, 404, p);
assert.ok(!/projects|"ready"|http_requests_total|oauth/.test(r.text), `${p} leaked: ${r.text.slice(0, 100)}`);
}
});
++ await check('the dashboard answers /limits.json from config (WS-N task 7)', async () => {
++ const r = await t.api('GET', '/limits.json');
++ assert.strictEqual(r.status, 200);
++ const by = Object.fromEntries(r.json().limits.map((l) => [l.id, l]));
++ assert.deepStrictEqual([by.sites.production, by.sites.sandbox], [t.config.quotas.production.sites, t.config.quotas.sandbox.sites]);
++ assert.strictEqual(by.projects_per_owner.production, t.config.projects.maxPerOwner);
++ assert.strictEqual(by.storage_bytes.exceeded, '413 quota.storage');
++ });
++
await check('identical bytes are stored per project: deleting one project\'s copy never breaks the other', async () => {
const sha = betaFiles.find((f) => f.path === 'shared.txt').sha256;
const root = path.join(t.dir, 'objects', 'projects');