openvibers/openvibe.codes Commits

Limits and tiers page (roadmap WS-N task 7): /docs/limits states the policy (sandbox and production, enforced at the capability by its owner, 429/413 problems with retry hints, trust tiers never change the grant check, overrides are per project) and renders Host's and Events' /limits.json as they answer, cached 5 minutes; a service not answering shows its problem and no numbers; grantable capabilities whose owner publishes no limits are listed with their quota class. Linked from /docs and the sitemap

OP OpenVibers committed 26 Sept 2026 · 2985c649e9 · 3 files, +148 −1


Changes

ddiff --git a/server/http/docs.js b/server/http/docs.js
iindex 974c56f..ad647cf 100644
---- a/server/http/docs.js
++++ b/server/http/docs.js
@@@ -15,6 +15,7 @@
* /docs/events event types from the service manifests
* /docs/services the Network's registry with health as reported (never invented)
* /docs/billing the billing policy as OpenVibe.Billing's /policy.json states it (never restated)
++ * /docs/limits limits and tiers: each service's /limits.json as it answers (never restated)
* /docs/sdk[/:module] SDK reference from its .d.ts files
* /docs/adr/:id ADRs as published in openvibe-contracts
*/
@@@ -54,6 +55,7 @@ function createDocsRoutes(ctx) {
<li><a href="/docs/services"><strong>Services</strong></a><span>the registry as OpenVibe.Network reports it, with health</span></li>
<li><a href="/docs/tools"><strong>Tools API</strong></a><span>every OpenVibe tool you can call from code, from the live registry</span></li>
<li><a href="/docs/billing"><strong>Billing policy</strong></a><span>prices, the creator split, fees, holds and cashouts, live from OpenVibe.Billing</span></li>
++<li><a href="/docs/limits"><strong>Limits and tiers</strong></a><span>what a project may do in sandbox and production, live from the services that enforce it</span></li>
<li><a href="/docs/sdk"><strong>SDK</strong></a><span>${docs.sdk.length} modules of openvibe-sdk from their type definitions</span></li>
<li><a href="/policy/rfc"><strong>Decisions</strong></a><span>${docs.adrs.length} architecture decision records</span></li>
</ul>
@@@ -407,6 +409,72 @@ ${table(['Rule', 'Value'], [
});
});
++ // ── Limits and tiers (live from the enforcing services, WS-N task 7) ──
++ // Each service that publishes a /limits.json (read from its running config) is shown as it
++ // answered; a service not answering shows a problem, never remembered numbers. Public
++ // capabilities whose owner publishes none are listed with their quota class and said so.
++ const LIMIT_SOURCES = [
++ { service: 'host', name: 'OpenVibe Host', internal: process.env.OV_HOST_INTERNAL_URL || 'http://127.0.0.1:4910', public: 'https://openvibe.host/limits.json' },
++ { service: 'events', name: 'OpenVibe Events', internal: process.env.OV_EVENTS_INTERNAL_URL || 'http://127.0.0.1:4300', public: 'https://events.openvibe.network/limits.json' },
++ ];
++ const limitsCache = new Map();
++ async function limitsOf(src) {
++ const hit = limitsCache.get(src.service);
++ if (hit && Date.now() - hit.at < 300_000) return { body: hit.body };
++ try {
++ const out = await fetch(`${src.internal.replace(/\/$/, '')}/limits.json`, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(4000) });
++ if (!out.ok) throw new Error(`${src.name} answered ${out.status}`);
++ const body = await out.json();
++ if (!body || !Array.isArray(body.limits)) throw new Error(`${src.name} answered without a limits list`);
++ limitsCache.set(src.service, { at: Date.now(), body });
++ return { body };
++ } catch (err) {
++ return { problem: { code: 'codes.limits_unavailable', detail: err.message } };
++ }
++ }
++ const MB = 1024 * 1024;
++ const bytes = (n) => (n >= 1024 * MB ? `${+(n / 1024 / MB).toFixed(2)} GB` : n >= MB ? `${+(n / MB).toFixed(2)} MB` : n >= 1024 ? `${+(n / 1024).toFixed(1)} KB` : `${n} bytes`);
++ function amount(v, unit) {
++ if (v === null || v === undefined) return 'no limit';
++ if (v === 0) return 'none';
++ const n = Number(v).toLocaleString('en-US');
++ if (unit === 'bytes') return bytes(Number(v));
++ if (unit === 'per_minute') return `${n} a minute`;
++ if (unit === 'per_day') return `${n} in 24 hours`;
++ if (unit === 'days') return `${n} days`;
++ return n;
++ }
++ r.get('/limits', async (req, res) => {
++ const answers = await Promise.all(LIMIT_SOURCES.map(async (src) => ({ src, ...(await limitsOf(src)) })));
++ const covered = new Set(LIMIT_SOURCES.map((s) => s.service));
++ const rest = docs.capabilities.filter((c) => c.grantable && !covered.has(c.owner));
++ const owners = [...new Set(rest.map((c) => c.owner))].sort();
++ page(req, res, {
++ title: 'Limits and tiers',
++ description: 'What an OpenVibe project may do in sandbox and production, as the services that enforce it answer.',
++ crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'Limits' }],
++ body: html`<h1>Limits and tiers</h1>
++<p>OpenVibe runs every project within limits it can keep paying for. Usage is metered per project and per environment, and each limit below is what the service that enforces it reports from its running configuration. Codes reads it every few minutes and never keeps its own copy.</p>
++<h2>How limits work</h2>
++<ul>
++<li><strong>Two environments.</strong> A project's <code>sandbox</code> is for building and testing: smaller limits, shorter retention, kept apart from production data. <code>production</code> is what people use.</li>
++<li><strong>Enforced at the capability.</strong> The service that owns a capability checks the token's grant and the project's limit in one place, before it does the work. Nothing else can grant more.</li>
++<li><strong>Past a limit</strong> the answer is a problem (<code>application/problem+json</code>) with a stable code: <code>429</code> for a rate or a count, <code>413</code> for a size. Rate limits say when to retry (<code>Retry-After</code> or <code>retry_after</code>).</li>
++<li><strong>Trust tiers</strong> (unreviewed, reviewed, first-party; <a href="/docs/adr/ADR-013">ADR-013</a>) change defaults and discovery, never the grant check. Every tier meets the same limits today.</li>
++<li><strong>Raising a limit</strong> is a per-project override staff set at the owning service; the numbers here are the defaults every project starts with.</li>
++</ul>
++${answers.map(({ src, body, problem }) => html`<h2 id="${src.service}">${src.name}</h2>
++${problem ? problemBox(problem, { title: `${src.name}'s limits could not be read` }) : html`<p class="muted small">From <a href="${src.public}"><code>${src.public.replace('https://', '')}</code></a>${body.scope ? html`: ${body.scope}` : ''}.</p>
++${table(['Limit', 'Capability', 'Sandbox', 'Production', 'Past it'], body.limits.map((l) => [
++ l.label, l.capability ? html`<a href="/docs/capabilities/${l.capability}"><code>${l.capability}</code></a>` : '—',
++ amount(l.sandbox, l.unit), amount(l.production, l.unit), l.exceeded ? code(l.exceeded) : '—',
++]))}`}`)}
++<h2>Other capabilities</h2>
++<p>These can be granted to apps, but their owners do not publish their limits yet. Each shows its quota class from <code>openvibe-contracts v${docs.contractsVersion}</code>.</p>
++${table(['Owner', 'Capabilities (quota class)'], owners.map((o) => [code(o), html`${rest.filter((c) => c.owner === o).map((c, i) => html`${i ? ', ' : ''}<a href="/docs/capabilities/${c.id}">${c.id}</a> (${c.quotaClass})`)}`]))}`,
++ });
++ });
++
// ── SDK ─────────────────────────────────────────────────
r.get('/sdk', (req, res) => {
page(req, res, {
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex f443389..852af2a 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -271,7 +271,7 @@ ${signedIn ? releaseActions(req, rel) : ''}
].join('\n'));
});
r.get('/sitemap.xml', (req, res) => {
-- const urls = ['/', '/docs', '/docs/api', '/docs/updates', '/docs/contracts', '/docs/capabilities', '/docs/events', '/docs/services', '/docs/billing', '/docs/tools', '/docs/sdk', '/oauth', '/tools/webhooks', '/manifests/validate',
++ const urls = ['/', '/docs', '/docs/api', '/docs/updates', '/docs/contracts', '/docs/capabilities', '/docs/events', '/docs/services', '/docs/billing', '/docs/limits', '/docs/tools', '/docs/sdk', '/oauth', '/tools/webhooks', '/manifests/validate',
'/policy', '/policy/rfc', '/policy/compatibility', '/policy/licensing', '/policy/transparency',
...governance.filter((g) => !g.draft).map((g) => `/policy/${g.slug}`),
...docs.contracts.map((c) => `/docs/contracts/${c.id}`), ...docs.capabilities.map((c) => `/docs/capabilities/${c.id}`),
ddiff --git a/test/docs-limits.test.js b/test/docs-limits.test.js
nnew file mode 100644
iindex 0000000..b06def9
---- /dev/null
++++ b/test/docs-limits.test.js
@@@ -0,0 +1,79 @@
++'use strict';
++/**
++ * /docs/limits (WS-N task 7) renders each enforcing service's /limits.json and nothing of its own:
++ * the numbers are the ones the service answered (0 is "none", null is "no limit"), a service not
++ * answering shows a problem and no numbers, and grantable capabilities whose owner publishes no
++ * limits are listed as such. The copy never calls anything free.
++ */
++const assert = require('assert');
++const http = require('http');
++
++const HOST = {
++ service: 'host', scope: 'per project and environment; projects per owner',
++ limits: [
++ { id: 'custom_domains', label: 'Custom domains', capability: 'host.site.manage', unit: 'count', production: 5, sandbox: 0, exceeded: '429 quota.custom_domains' },
++ { id: 'storage_bytes', label: 'Stored bytes (every deploy kept)', capability: 'host.site.manage', unit: 'bytes', production: 1073741824, sandbox: 104857600, exceeded: '413 quota.storage' },
++ { id: 'deploys_per_day', label: 'Deploys in 24 hours', capability: 'host.site.manage', unit: 'per_day', production: 50, sandbox: 20, exceeded: '429 quota.deploys_per_day' },
++ ],
++};
++const EVENTS = {
++ service: 'events', scope: 'per project and environment (ADR-014)',
++ limits: [
++ { id: 'publish_per_minute', label: 'Events published per minute', capability: 'events.app.publish', unit: 'per_minute', production: 120, sandbox: 30, exceeded: '429 events.quota_exceeded' },
++ { id: 'subscriptions', label: 'Webhook subscriptions', capability: 'events.app.subscribe', unit: 'count', production: null, sandbox: 5, exceeded: '429 events.quota_exceeded' },
++ { id: 'retention_days', label: 'Days an event is kept', capability: 'events.app.read', unit: 'days', production: 30, sandbox: 7, exceeded: 'pruned' },
++ ],
++};
++
++function serve(body) {
++ const s = http.createServer((req, res) => {
++ if (!s.up) { res.statusCode = 503; return res.end('{}'); }
++ if (req.url !== '/limits.json') { res.statusCode = 404; return res.end(); }
++ res.setHeader('Content-Type', 'application/json');
++ res.end(JSON.stringify(body));
++ });
++ s.up = true;
++ return new Promise((r) => s.listen(0, '127.0.0.1', () => r(s)));
++}
++
++(async () => {
++ const host = await serve(HOST);
++ const events = await serve(EVENTS);
++ process.env.OV_HOST_INTERNAL_URL = `http://127.0.0.1:${host.address().port}`;
++ process.env.OV_EVENTS_INTERNAL_URL = `http://127.0.0.1:${events.address().port}`;
++ const { boot, check, done } = require('./helpers/boot');
++ const t = await boot();
++
++ await check('the page shows what each service answered', async () => {
++ const r = await t.get('/docs/limits');
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ for (const s of ['1 GB', '100 MB', '50 in 24 hours', '120 a minute', '30 a minute', '7 days', 'quota.custom_domains', 'events.quota_exceeded',
++ 'openvibe.host/limits.json', 'events.openvibe.network/limits.json', 'per project and environment (ADR-014)']) {
++ assert.ok(r.text.includes(s), `the page says ${s}`);
++ }
++ assert.match(r.text, /Custom domains<\/td><td[^>]*>[^<]*<a[^>]*><code>host\.site\.manage<\/code><\/a><\/td><td[^>]*>none<\/td>/, 'sandbox 0 reads "none"');
++ assert.match(r.text, /Webhook subscriptions.*?<td[^>]*>5<\/td><td[^>]*>no limit<\/td>/s, 'production null reads "no limit"');
++ assert.ok(!/\bfree\b/i.test(r.text.replace(/<[^>]+>/g, ' ')), 'no "free" copy');
++ assert.ok(r.text.includes('Other capabilities') && r.text.includes('/docs/capabilities/media.object.upload'), 'owners without /limits.json are listed');
++ assert.ok(!/Other capabilities[\s\S]*events\.app\.publish \(/.test(r.text), 'a covered owner is not listed again');
++ assert.ok((await t.get('/docs')).text.includes('href="/docs/limits"'), 'the docs index links it');
++ assert.ok((await t.get('/sitemap.xml')).text.includes('/docs/limits'), 'and the sitemap');
++ });
++
++ await check('a service down on a cold cache: its problem, no numbers; the others still show', async () => {
++ events.up = false;
++ for (const k of Object.keys(require.cache)) if (k.includes('/server/http/docs.js')) delete require.cache[k];
++ const t2 = await boot();
++ const r = await t2.get('/docs/limits');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('OpenVibe Events&#39;s limits could not be read') || r.text.includes("OpenVibe Events's limits could not be read"), 'the problem is shown');
++ assert.ok(!r.text.includes('120 a minute'), 'no Events numbers without Events');
++ assert.ok(r.text.includes('50 in 24 hours'), 'Host still shows');
++ await t2.close();
++ });
++
++ await t.close();
++ host.close();
++ events.close();
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });