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)
Changes
@@@ -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 |@@@ -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));nnew file mode 100644@@@ -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 };@@@ -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');
@@ -195,7 +195,7 @@ A Node service (`server/`, Express 4, better-sqlite3, port **4910**, service id
@@ -195,7 +195,7 @@ A Node service (`server/`, Express 4, better-sqlite3, port **4910**, service id
| Host | What answers |
| 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 |
| `<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 |
| 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 |
| anything else (including pending, failed or lapsed custom domains, and `a.b.openvibe.host`) | `404 Unknown host`, with no tenant content |
@@ -10,7 +10,7 @@
@@ -10,7 +10,7 @@
* <site>.<sitesDomain> / verified custom domain → tenant static files (http/tenant.js) and
* <site>.<sitesDomain> / verified custom domain → tenant static files (http/tenant.js) and
* nothing else: no API, no auth, no cookies
* nothing else: no API, no auth, no cookies
* the dashboard host (BASE_URL) and loopback → dashboard pages, /api/v1, /auth, /api/ready,
* 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
* anything else → 404 unknown host
*/
*/
const path = require('path');
const path = require('path');
@@ -131,6 +131,8 @@ function createApp(opts = {}) {
@@ -131,6 +131,8 @@ function createApp(opts = {}) {
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-host', version: VERSION }));
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.
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports into /metrics.
release.mount(app, { registry: registry });
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 });
const readiness = createHostReadiness({ store, blobs, auth, outbox, release: release.release, minFreeBytes: () => config.uploads.minFreeBytes });
app.get('/api/ready', readiness.handler);
app.get('/api/ready', readiness.handler);
app.get('/metrics', sharedMetrics.metricsHandler(registry));
app.get('/metrics', sharedMetrics.metricsHandler(registry));
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -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 };
@@ -74,13 +74,22 @@ const { boot, check, done } = require('./stageb/boot');
@@ -74,13 +74,22 @@ const { boot, check, done } = require('./stageb/boot');
});
});
await check('tenant hosts never reach the API, auth, metrics or readiness', async () => {
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 });
const r = await t.get('alpha.openvibe.host', p, { as: bob });
assert.strictEqual(r.status, 404, p);
assert.strictEqual(r.status, 404, p);
assert.ok(!/projects|"ready"|http_requests_total|oauth/.test(r.text), `${p} leaked: ${r.text.slice(0, 100)}`);
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 () => {
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 sha = betaFiles.find((f) => f.path === 'shared.txt').sha256;
const root = path.join(t.dir, 'objects', 'projects');
const root = path.join(t.dir, 'objects', 'projects');