openvibers/openvibe.codes Commits

Per-project usage page (roadmap WS-N task 4): /projects/:project/usage?days=7|30|90&env=all|production|sandbox, for the project's owner, admins and staff, renders OpenVibe.Network's GET /api/v1/projects/:project/usage (network.project-usage-result@1, read with the person's own Network token like every project read): usage per service and capability (cards) and per day with the job type or tool, each recorded quota's use in its current window with a <meter> for headroom (or why a minute or hour window cannot be shown), the published limits of the services the project used (their /limits.json, through the reader /docs/limits now shares: server/domain/limits.js), errors by code, and the last sampled failures with their trace ids and job or event ids. Server-rendered and complete without JavaScript (the range is a GET form); developers and viewers get 403 without Network being asked; a Network failure shows as it answered. The project page links to it and no longer says usage is not collected; /docs/limits points to it. test/project-usage.test.js (mock Network serves /usage and records query strings)

OP OpenVibers committed 26 Sept 2026 · 253c29d57b · 10 files, +392 −43


Changes

ddiff --git a/README.md b/README.md
iindex f340ad1..4fee3a0 100644
---- a/README.md
++++ b/README.md
@@@ -30,7 +30,7 @@ A place where a developer outside the network goes from an OpenVibe account to a
| Surface | Path | Notes |
|---|---|---|
--| Portal | `/projects`, `/projects/:project`, `/projects/:project/apps/:app` | Create projects; add members; create sandbox/production apps (secret shown once); rotate and revoke credentials; request, approve, deny and revoke grants through a scope editor that offers only grantable capabilities (public, or partner when in the allowance) with each one's description, owner and visibility; quotas; audit (admin+); **export** a project's metadata as one JSON document (`/projects/:project/export`: Network's project, members, apps with credential ids and hints, grants, quotas and, for admin+, audit, plus Codes' releases with manifests and logs, trust tiers and playground runs; never a secret); owners and admins also download the **full archive** (`POST /projects/:project/export/archive`, one zip: that document with the audit log, the release manifests, the project's Media objects with download URLs and its namespaces, and its retained app events, for production and sandbox; read with Network's read-only export tokens; all or nothing when a service fails; [format](https://openvibe.codes/docs/export)) and **delete** a project (owner, confirmed by name: Network archives it — it offers archiving, not erasure — then Codes deletes drafts, their manifests and playground runs, and revokes published releases so installers are told). Network's errors are shown with its status, code, detail and request id. |
++| Portal | `/projects`, `/projects/:project`, `/projects/:project/apps/:app` | Create projects; add members; create sandbox/production apps (secret shown once); rotate and revoke credentials; request, approve, deny and revoke grants through a scope editor that offers only grantable capabilities (public, or partner when in the allowance) with each one's description, owner and visibility; quotas; **usage** (owner, admins and staff: `/projects/:project/usage?days=7|30|90&env=all|production|sandbox`, Network's `GET /api/v1/projects/:project/usage` rendered server-side: usage by service, capability and day, each recorded quota's headroom as a `<meter>`, the published limits of the services used (their `/limits.json`), errors by code and the sampled failures with their trace ids and job or event ids; the numbers come from the services' hourly `*.usage.recorded` rollups, which Network adds up per day, so the current hour is not counted yet); audit (admin+); **export** a project's metadata as one JSON document (`/projects/:project/export`: Network's project, members, apps with credential ids and hints, grants, quotas and, for admin+, audit, plus Codes' releases with manifests and logs, trust tiers and playground runs; never a secret); owners and admins also download the **full archive** (`POST /projects/:project/export/archive`, one zip: that document with the audit log, the release manifests, the project's Media objects with download URLs and its namespaces, and its retained app events, for production and sandbox; read with Network's read-only export tokens; all or nothing when a service fails; [format](https://openvibe.codes/docs/export)) and **delete** a project (owner, confirmed by name: Network archives it — it offers archiving, not erasure — then Codes deletes drafts, their manifests and playground runs, and revokes published releases so installers are told). Network's errors are shown with its status, code, detail and request id. |
| OAuth helper | `/oauth`, `/oauth/test-callback` | Explains authorization code + PKCE (S256) for apps, builds a test authorize URL, and a callback that shows `code`/`state`/`error` and **never exchanges** the code. |
| Webhook tools | `/tools/webhooks` | Verify a delivery as a receiver that requires signature v2 does: `X-OpenVibe-Signature-V2: t=<ts>,v2=<HMAC of "<ts>.<raw body>">` must match and `t` must be within ±300 s of now (the page shows the age and the window, and tells a stale-but-correct signature from a wrong one); `X-OpenVibe-Timestamp` must equal `t`; v1 `X-OpenVibe-Signature: sha256=<HMAC>` is checked and shown but never decides. In the browser with Web Crypto, or on the server without JavaScript with openvibe-sdk's `verifyDelivery`/`verifyDeliveryV2`; constant-time; secret dropped. Generate a sample delivery signed with v1 and v2 (`signDeliveryHeaders`) for any event type the contracts catalog lists. |
| Docs | `/docs/*` | Contracts (field tables, fixtures as examples, raw schema), capability catalog (grantable ones highlighted), event types, SDK reference from its `.d.ts` files, ADRs. Every page states the versions it was generated from. The service registry is read live from Network, with health as Network reports it. |
@@@ -84,7 +84,7 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4900
npx openvibe-contracts-check --service codes --src server
```
--Tests: ADR-013 trust-tier migration; secrets never persisted or re-displayed; Network errors surfaced honestly (404/403/409/422/503, unreachable, expired session refresh); scope editor offers only grantable capabilities and refuses forged requests before Network sees them; generated docs match the pinned versions (every contract, capability, event type, SDK module); webhook verification, v1 and v2 (tampering, prefixes, lengths, the ±300 s window both ways, timestamp header mismatch, v1 never deciding, constant-time, the in-browser verifier agreeing); playgrounds refuse without the grant and call nothing; project export (complete, no secrets, audit for admin+ only, never partial) and delete (owner only, Network first, drafts and runs removed, public releases revoked with an event); manifest validation; release lifecycle and events; no internal key anywhere; PKCE sign-in; readiness, release.json, loopback metrics; the released codes manifest matching the code.
++Tests: ADR-013 trust-tier migration; secrets never persisted or re-displayed; Network errors surfaced honestly (404/403/409/422/503, unreachable, expired session refresh); scope editor offers only grantable capabilities and refuses forged requests before Network sees them; generated docs match the pinned versions (every contract, capability, event type, SDK module); webhook verification, v1 and v2 (tampering, prefixes, lengths, the ±300 s window both ways, timestamp header mismatch, v1 never deciding, constant-time, the in-browser verifier agreeing); playgrounds refuse without the grant and call nothing; the usage page (owner, admins and staff only, Network not asked for anyone else, filters forwarded as offered, a Network failure shown as it answered); project export (complete, no secrets, audit for admin+ only, never partial) and delete (owner only, Network first, drafts and runs removed, public releases revoked with an event); manifest validation; release lifecycle and events; no internal key anywhere; PKCE sign-in; readiness, release.json, loopback metrics; the released codes manifest matching the code.
## Acceptance (must be true before "done")
ddiff --git a/public/css/codes.css b/public/css/codes.css
iindex a0dba3a..09f4e9c 100644
---- a/public/css/codes.css
++++ b/public/css/codes.css
@@@ -72,3 +72,5 @@ fieldset { border: 1px solid var(--border, #1f2d47); border-radius: 8px; display
.prose { max-width: 820px; }
.buttons { display: flex; gap: 8px; flex-wrap: wrap; }
@media (max-width: 640px) { .facts { grid-template-columns: 1fr; } .facts dt { margin-top: 6px; } .toc ul { columns: 1; } }
++.num { font-variant-numeric: tabular-nums; white-space: nowrap; }
++meter.headroom { width: 8rem; max-width: 100%; vertical-align: middle; }
ddiff --git a/server/app.js b/server/app.js
iindex a18d847..5e19a8b 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -31,6 +31,7 @@ const { createTrust } = require('./domain/trust');
const { createReleases } = require('./domain/releases');
const { createPlayground } = require('./domain/playground');
const { createArchiver } = require('./domain/project-archive');
++const { createLimitsReader } = require('./domain/limits');
const { createDocsRoutes } = require('./http/docs');
const { createToolRoutes } = require('./http/tools');
const { createPageRoutes } = require('./http/pages');
@@@ -60,7 +61,9 @@ function createApp(opts = {}) {
const playground = createPlayground({ store, config, network, keys, fetchImpl, log });
const archiver = createArchiver({ config, fetchImpl, log });
-- const ctx = { config, store, docs, keys, sso, network, outbox, trust, releases, playground, archiver, log };
++ // Each enforcing service's /limits.json, for /docs/limits and the project usage page.
++ const limits = createLimitsReader();
++ const ctx = { config, store, docs, keys, sso, network, outbox, trust, releases, playground, archiver, limits, log };
const app = express();
app.disable('x-powered-by');
ddiff --git a/server/clients/network.js b/server/clients/network.js
iindex 4a166b7..981e9be 100644
---- a/server/clients/network.js
++++ b/server/clients/network.js
@@@ -75,6 +75,8 @@ function createNetworkClient({ config, fetchImpl, log = console }) {
? call(t, 'DELETE', `${A(p, a)}/grants/${enc(cap)}`)
: call(t, 'POST', `${A(p, a)}/grants/${enc(cap)}/${decision === 'approved' ? 'approve' : 'deny'}`, {})),
quotas: (t, p) => call(t, 'GET', `${P(p)}/quotas`),
++ // Usage per day, quotas with their use and recent errors (network.project-usage-result@1; owner/admin).
++ usage: (t, p, { days, env } = {}) => call(t, 'GET', `${P(p)}/usage?${new URLSearchParams({ days: String(days || 30), env: env || 'all' })}`),
audit: (t, p, { before, limit } = {}) => call(t, 'GET', `${P(p)}/audit${before || limit ? `?${new URLSearchParams({ ...(before ? { before: String(before) } : {}), ...(limit ? { limit: String(limit) } : {}) })}` : ''}`),
// A 5-minute read-only token for the project export (owner/admin; Network checks and audits).
exportToken: (t, p, body) => call(t, 'POST', `${P(p)}/export-tokens`, body),
ddiff --git a/server/domain/limits.js b/server/domain/limits.js
nnew file mode 100644
iindex 0000000..dce63bd
---- /dev/null
++++ b/server/domain/limits.js
@@@ -0,0 +1,57 @@
++'use strict';
++
++/**
++ * The limits a developer project meets, as each enforcing service reports them in its /limits.json
++ * (read from its running configuration, WS-N task 7). Shared by /docs/limits and a project's usage
++ * page (WS-N task 4). A service that does not answer gives a problem, never remembered numbers; a
++ * good answer is kept for ttlMs so a page view never waits on a service twice in a row.
++ */
++
++/** The services that publish /limits.json, with where Codes reads them and where people can. */
++function limitSources(env = process.env) {
++ return [
++ // public: null until openvibe.host serves Host itself (WS-N task 10, Stage B step 2); read internally meanwhile.
++ { service: 'host', name: 'OpenVibe Host', internal: env.OV_HOST_INTERNAL_URL || 'http://127.0.0.1:4910', public: null },
++ { service: 'events', name: 'OpenVibe Events', internal: env.OV_EVENTS_INTERNAL_URL || 'http://127.0.0.1:4300', public: 'https://events.openvibe.network/limits.json' },
++ { service: 'media', name: 'OpenVibe Media', internal: env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100', public: 'https://openvibe.media/limits.json' },
++ ];
++}
++
++function createLimitsReader({ sources = limitSources(), ttlMs = 300_000, fetchImpl = (...a) => globalThis.fetch(...a) } = {}) {
++ const cache = new Map();
++ /** → { body } | { problem } for one source. */
++ async function read(src) {
++ const hit = cache.get(src.service);
++ if (hit && Date.now() - hit.at < ttlMs) return { body: hit.body };
++ try {
++ const out = await fetchImpl(`${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`);
++ cache.set(src.service, { at: Date.now(), body });
++ return { body };
++ } catch (err) {
++ return { problem: { code: 'codes.limits_unavailable', detail: err.message } };
++ }
++ }
++ const bySource = (service) => sources.find((s) => s.service === service) || null;
++ return { sources, read, bySource };
++}
++
++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`);
++
++/** A limit's value as people read it (null: no limit, 0: none allowed). */
++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 === 'hours') return `${n} hours`;
++ if (unit === 'days') return `${n} days`;
++ return n;
++}
++
++module.exports = { limitSources, createLimitsReader, amount, bytes };
ddiff --git a/server/http/docs.js b/server/http/docs.js
iindex c61231b..83d8506 100644
---- a/server/http/docs.js
++++ b/server/http/docs.js
@@@ -24,6 +24,7 @@ const { asyncRouter } = require('./router');
const { html, raw, table, code, badge, time, problemBox } = require('../render/html');
const { send } = require('../render/layout');
const { markdown } = require('../render/markdown');
++const { createLimitsReader, amount } = require('../domain/limits');
function createDocsRoutes(ctx) {
const { docs, config, network } = ctx;
@@@ -415,40 +416,9 @@ ${table(['Rule', 'Value'], [
// 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 = [
-- // public: null until openvibe.host serves Host itself (WS-N task 10, Stage B step 2); read internally meanwhile.
-- { service: 'host', name: 'OpenVibe Host', internal: process.env.OV_HOST_INTERNAL_URL || 'http://127.0.0.1:4910', public: null },
-- { 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' },
-- { service: 'media', name: 'OpenVibe Media', internal: process.env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100', public: 'https://openvibe.media/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 === 'hours') return `${n} hours`;
-- if (unit === 'days') return `${n} days`;
-- return n;
-- }
++ const limitsReader = ctx.limits || createLimitsReader();
++ const LIMIT_SOURCES = limitsReader.sources;
++ const limitsOf = limitsReader.read;
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));
@@@ -467,6 +437,7 @@ ${table(['Rule', 'Value'], [
<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>
++<li><strong>What a project used</strong> is on its usage page (<code>/projects/&lt;project&gt;/usage</code>, for the owner and admins): per day, service and capability, with the headroom of its recorded quotas and its recent failures. The services report it as hourly rollups, so it trails by an hour.</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">${src.public ? html`From <a href="${src.public}"><code>${src.public.replace('https://', '')}</code></a>` : html`From ${src.name}'s <code>/limits.json</code>`}${body.scope ? html`: ${body.scope}` : ''}.</p>
ddiff --git a/server/http/portal.js b/server/http/portal.js
iindex 9da7411..6e3d6fc 100644
---- a/server/http/portal.js
++++ b/server/http/portal.js
@@@ -1,7 +1,7 @@
'use strict';
/**
-- * The signed-in portal: projects, members, apps, credentials, grants, quotas and audit — all over
++ * The signed-in portal: projects, members, apps, credentials, grants, quotas, usage and audit — all over
* OpenVibe.Network's /api/v1/projects API with the person's own access token (ADR-014: Network owns
* every one of these; Codes stores none of them). Plus the Codes-owned parts that hang off an app:
* releases (manifest editor, publish/deprecate/revoke) and playgrounds.
@@@ -12,6 +12,8 @@
* - a client secret appears only in the response to the create/rotate that produced it, with
* Cache-Control: no-store, and is never stored, logged or redirected through a URL
* - quotas are "recorded limit — enforced by <service>": Network records, the owner enforces
++ * - usage (owner and admins, staff) is what Network added up from the services' hourly rollups,
++ * shown with its lag; Codes computes nothing of its own
* - the scope editor offers only capabilities apps can be granted (public, or partner when in the
* allowance) and refuses to forward anything else
*/
@@@ -27,6 +29,7 @@ const { validationResult } = require('./tools');
const { buildExport } = require('../domain/project-export');
const { ArchiveError } = require('../domain/project-archive');
const { statusBadge } = require('./pages');
++const { usageBody, usageQuery } = require('../render/usage');
const PRJ_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const APP_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
@@@ -38,7 +41,7 @@ const REL_RE = /^rel_[0-9A-HJKMNP-TV-Z]{26}$/;
const atLeast = (role, need) => Boolean(role) && RANK[role] >= RANK[need];
function createPortalRoutes(ctx) {
-- const { config, docs, network, sso, releases, trust, playground, archiver, log } = ctx;
++ const { config, docs, network, sso, releases, trust, playground, archiver, limits, log } = ctx;
const r = asyncRouter();
const form = express.urlencoded({ extended: false, limit: '96kb' });
@@@ -142,7 +145,7 @@ ${archived ? notice('This project is archived: every app in it is revoked.', 'ba
<dt>Environments</dt><dd>${envs.join(', ')}${project.environment_policy === 'sandbox' ? html` <span class="muted small">(production apps need staff to enable production for this project)</span>` : ''}</dd>
<dt>Allowance</dt><dd>${(project.allowance || []).length ? html`<ul class="plain">${project.allowance.map((id) => html`<li><a href="/docs/capabilities/${id}"><code>${id}</code></a></li>`)}</ul>` : html`<span class="muted">empty: staff decide which capabilities this project's apps may hold</span>`}</dd>
<dt>Created</dt><dd>${time(project.created_at)}</dd></dl>
--${atLeast(role, 'admin') || req.viewer.staff ? html`<p><a href="/projects/${project.id}/audit">Audit log</a></p>` : ''}
++${atLeast(role, 'admin') || req.viewer.staff ? html`<p><a href="/projects/${project.id}/usage">Usage, quotas and errors</a> · <a href="/projects/${project.id}/audit">Audit log</a></p>` : ''}
<h2>Apps</h2>
${apps.ok ? table(['App', 'Environment', 'Type', 'Grants', 'Created'], (apps.data.apps || []).map((a) => [
@@@ -162,7 +165,7 @@ ${quotas.ok ? html`${table(['Capability', 'Limit', 'Window', 'Unit', 'Enforcemen
html`<a href="/docs/capabilities/${q.capability}"><code>${q.capability}</code></a>`, String(q.limit), q.window, q.unit,
html`recorded limit — enforced by <code>${q.enforced_by || 'the owning service'}</code>`,
]), { empty: 'No quotas recorded for this project.' })}
--<p class="muted small">${quotas.data.note || ''} Codes shows these limits; it does not enforce them, and no usage numbers are collected yet.</p>` : problemBox(quotas.problem, { title: 'Quotas could not be loaded' })}
++<p class="muted small">${quotas.data.note || ''} Codes shows these limits; it does not enforce them.${atLeast(role, 'admin') || req.viewer.staff ? html` <a href="/projects/${project.id}/usage#quotas">What each window has used</a>.` : ' The owner and admins see what each window has used.'}</p>` : problemBox(quotas.problem, { title: 'Quotas could not be loaded' })}
<h2>Members</h2>
${members.ok ? table(['Member', 'Role', 'Added', ''], (members.data.members || []).map((m) => [
@@@ -316,6 +319,23 @@ ${got.data.next_before ? html`<p><a href="/projects/${project.id}/audit?before=$
});
});
++ // ── Usage (WS-N task 4): Network's per-day numbers from the services' rollups ──
++ r.get('/:project/usage', idParams, async (req, res) => {
++ const project = await loadProject(req, res);
++ if (!project) return;
++ const crumbs = [{ label: 'Projects', href: '/projects' }, { label: project.name, href: back(req) }, { label: 'Usage' }];
++ if (!atLeast(project.role, 'admin') && !req.viewer.staff) {
++ return page(req, res, { title: `Usage · ${project.name}`, crumbs, body: html`<h1>Usage</h1><p>A project's usage and errors are for its owner and admins.</p><p><a href="${back(req)}">Back to the project</a></p>` }, 403);
++ }
++ const query = usageQuery(req.query);
++ const got = await net(req, res, (t) => network.projects.usage(t, project.id, query));
++ if (!got.ok) return problemPage(req, res, got.problem, { title: 'Usage', back: back(req) });
++ // The published limits of the services this project used (cached by the reader).
++ const used = new Set((got.data.totals || []).map((t) => t.service));
++ const answers = limits ? await Promise.all(limits.sources.filter((src) => used.has(src.service)).map(async (src) => ({ src, ...(await limits.read(src)) }))) : [];
++ page(req, res, { title: `Usage · ${project.name}`, crumbs, body: usageBody({ project, usage: got.data, limits: answers, query }) });
++ });
++
// ── Apps ────────────────────────────────────────────────
const redirectList = (text) => String(text || '').split(/\s+/).map((s) => s.trim()).filter(Boolean).slice(0, 11);
ddiff --git a/server/render/usage.js b/server/render/usage.js
nnew file mode 100644
iindex 0000000..137c053
---- /dev/null
++++ b/server/render/usage.js
@@@ -0,0 +1,108 @@
++'use strict';
++
++/**
++ * A project's usage page (roadmap WS-N task 4): what OpenVibe.Network answers for
++ * GET /api/v1/projects/:project/usage (network.project-usage-result@1), next to the limits each
++ * enforcing service publishes. Server-rendered and complete without JavaScript: the filters are a GET
++ * form, headroom is a <meter>, every number is text.
++ */
++const { html, raw, table, code, time, problemBox } = require('./html');
++const { amount } = require('../domain/limits');
++
++const DAYS = [7, 30, 90];
++const ENVS = ['all', 'production', 'sandbox'];
++const SERVICE_NAMES = { tools: 'OpenVibe Tools', events: 'OpenVibe Events', media: 'OpenVibe Media', host: 'OpenVibe Host', ai: 'OpenVibe AI' };
++
++const num = (v) => html`<span class="num">${Number(v).toLocaleString('en-US')}</span>`;
++const cap = (id) => html`<a href="/docs/capabilities/${id}"><code>${id}</code></a>`;
++const rate = (errors, quantity) => (quantity > 0 ? `${+((errors / quantity) * 100).toFixed(1)}%` : '—');
++const per = (window) => (window === 'total' ? 'in total' : `a ${window}`);
++
++/** ?days=&env= as the page offers them (anything else falls back to 30 days, every environment). */
++function usageQuery(query = {}) {
++ const days = DAYS.includes(Number(query.days)) ? Number(query.days) : 30;
++ const env = ENVS.includes(String(query.env)) ? String(query.env) : 'all';
++ return { days, env };
++}
++
++function filters(projectId, { days, env }) {
++ return html`<form method="get" action="/projects/${projectId}/usage" class="inline-form" aria-label="Usage range">
++<label>Last <select name="days">${DAYS.map((d) => html`<option value="${d}"${d === days ? raw(' selected') : ''}>${d} days</option>`)}</select></label>
++<label>Environment <select name="env">${ENVS.map((e) => html`<option value="${e}"${e === env ? raw(' selected') : ''}>${e === 'all' ? 'both' : e}</option>`)}</select></label>
++<button type="submit">Show</button></form>`;
++}
++
++function quotaRows(quotas) {
++ return quotas.map((q) => {
++ const used = q.used == null
++ ? html`— <span class="muted small">${q.note || ''}</span>`
++ : html`${num(q.used)} ${q.unit}${q.window_start ? html` <span class="muted small">since ${time(q.window_start)}</span>` : ''}${q.note ? html` <span class="muted small">(${q.note})</span>` : ''}`;
++ const headroom = q.used == null ? '—' : html`<meter class="headroom" min="0" max="${Math.max(q.limit, 1)}" value="${Math.min(q.used, Math.max(q.limit, 1))}" low="${Math.floor(q.limit * 0.8)}" high="${Math.floor(q.limit * 0.95)}" optimum="0">${rate(q.used, q.limit)}</meter> ${num(q.remaining)} left${q.limit > 0 ? html` <span class="muted small">(${rate(q.used, q.limit)} used)</span>` : ''}`;
++ return [cap(q.capability), html`${num(q.limit)} ${q.unit} ${per(q.window)}`, used, headroom,
++ html`recorded limit — enforced by <code>${q.enforced_by || 'the owning service'}</code>`];
++ });
++}
++
++/** The limits of the services this project used, for the capabilities it used. */
++function serviceLimits(u, limitAnswers, env) {
++ const used = new Set(u.totals.map((t) => t.capability));
++ const services = [...new Set(u.totals.map((t) => t.service))].sort();
++ if (!services.length) return '';
++ const cols = env === 'all' ? ['sandbox', 'production'] : [env];
++ return html`<h2 id="limits">Service limits</h2>
++<p class="muted small">The defaults each service enforces for every project, as it reports them now (<a href="/docs/limits">all limits</a>). A rate limit is enforced as it happens; these hourly numbers cannot show a single busy minute.</p>
++${services.map((svc) => {
++ const a = limitAnswers.find((x) => x.src.service === svc);
++ const name = SERVICE_NAMES[svc] || svc;
++ if (!a) return html`<h3>${name}</h3><p class="muted small">${name} does not publish its limits yet.</p>`;
++ if (a.problem) return html`<h3>${name}</h3>${problemBox(a.problem, { title: `${name}'s limits could not be read` })}`;
++ const rows = a.body.limits.filter((l) => l.capability && used.has(l.capability));
++ return html`<h3>${name}</h3>${table(['Limit', 'Capability', ...cols.map((c) => c[0].toUpperCase() + c.slice(1)), 'Past it'],
++ rows.map((l) => [l.label, cap(l.capability), ...cols.map((c) => amount(l[c], l.unit)), l.exceeded ? code(l.exceeded) : '—']),
++ { empty: `${name} publishes no limit for the capabilities used here.` })}`;
++ })}`;
++}
++
++/**
++ * @param {object} o
++ * @param {object} o.project Network's project view
++ * @param {object} o.usage network.project-usage-result@1
++ * @param {Array} o.limits [{ src, body } | { src, problem }] from the limits reader
++ * @param {{ days: number, env: string }} o.query
++ */
++function usageBody({ project, usage: u, limits: limitAnswers, query }) {
++ const byService = [...u.totals].sort((a, b) => a.service.localeCompare(b.service) || a.capability.localeCompare(b.capability));
++ return html`<h1>Usage <small>${project.name}</small></h1>
++<p class="muted">${u.freshness}${u.last_recorded_at ? html` Last rollup received ${time(u.last_recorded_at)}.` : ''} Showing ${u.range.from} to ${u.range.to} (UTC).</p>
++${filters(project.id, query)}
++
++<h2 id="summary">Summary</h2>
++${byService.length ? html`<ul class="cards">${byService.map((t) => html`<li><strong>${num(t.quantity)} ${t.unit}</strong>
++<span>${cap(t.capability)} · ${t.env}</span>
++<span>${t.errors ? html`${num(t.errors)} errors (${rate(t.errors, t.quantity)})` : 'no errors'}</span></li>`)}</ul>`
++ : html`<p class="muted">No usage in these days. Usage is counted for apps of this project, per environment, by the service that does the work.</p>`}
++
++<h2 id="quotas">Quotas</h2>
++${table(['Capability', 'Limit', 'Used this window', 'Headroom', 'Enforcement'], quotaRows(u.quotas),
++ { empty: 'No quotas are recorded for this project (staff set them). The service limits below apply to every project.' })}
++
++${serviceLimits(u, limitAnswers, query.env)}
++
++<h2 id="daily">By day</h2>
++${table(['Day (UTC)', 'Service', 'Capability', 'Detail', 'Environment', 'Used', 'Errors'], u.daily.map((d) => [
++ d.day, d.service, cap(d.capability), d.dimension ? code(d.dimension) : '—', d.env, html`${num(d.quantity)} ${d.unit}`, d.errors ? num(d.errors) : '0',
++ ]), { empty: 'No usage in these days.' })}
++
++<h2 id="errors">Errors</h2>
++<p>${num(u.errors.total)} ${u.errors.total === 1 ? 'error' : 'errors'} in these days.</p>
++${table(['Code', 'Service', 'Capability', 'Count'], u.errors.by_code.map((e) => [code(e.code), e.service, cap(e.capability), num(e.count)]), { empty: 'No errors.' })}
++<h3 id="recent">Recent failures</h3>
++<p class="muted small">The last failures the services sampled, newest first. A trace id is the <code>traceparent</code> trace of the request that failed (for a job, the request that submitted it; for a webhook, the event delivered): look it up in your own logs. A job or event id can be read with your app's token.</p>
++${table(['When', 'Environment', 'Capability', 'Code', 'Status', 'Trace id', 'Job or event'], u.errors.recent.map((e) => [
++ time(e.at), e.env, cap(e.capability), code(e.code), e.status == null ? '—' : String(e.status), e.trace_id ? code(e.trace_id) : '—', e.ref ? code(e.ref) : '—',
++ ]), { empty: 'No failures sampled in these days.' })}
++
++<p class="muted small">Counts come from the services that own each capability (OpenVibe Tools and OpenVibe Events today) as hourly rollups through OpenVibe Events; OpenVibe.Network adds them up per day. They say how much and what failed, never who did it.</p>`;
++}
++
++module.exports = { usageBody, usageQuery, DAYS, ENVS };
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex b503fac..86b062d 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -9,6 +9,8 @@
* with the same shapes, roles and problem codes as OpenVibe.Network's developer API
* (simplified: no audit paging, no rate limits). setDown(true) makes it refuse
* connections-equivalent (503) and drop() closes the socket.
++ * GET /api/v1/projects/:project/usage (owner/admin or staff; days 1-90, env all|sandbox|production)
++ * answers what setUsage() stored for the project, or an empty network.project-usage-result@1.
* POST /api/v1/projects/:project/export-tokens as Network mints them (owner/admin only,
* sub app:app_<project ULID>, read-only caps, purpose export), recorded in exportTokens.
* Events POST /api/v1/events: verifies the app token (openvibe-contracts), needs the capability;
@@@ -59,7 +61,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
const st = {
users: new Map(), byUsername: new Map(), projects: new Map(), members: new Map(), apps: new Map(), creds: new Map(),
-- grants: new Map(), quotas: new Map(), audit: [], codes: new Map(), refresh: new Map(),
++ grants: new Map(), quotas: new Map(), usage: new Map(), audit: [], codes: new Map(), refresh: new Map(),
catalog: null, down: false, requests: [], tokenRequests: [], exportTokens: [], exportTtl: 300,
};
let issuer = null;
@@@ -153,6 +155,18 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
if (m === 'DELETE') { if (s !== u.subject && !need('admin')) return; if (mem.get(s) === 'owner') return problem(json, 403, 'member.forbidden', 'the owner cannot be removed'); mem.delete(s); res204(json); return; }
}
if (rest[0] === 'quotas' && m === 'GET') return json(200, { quotas: [...st.quotas.values()].filter((x) => x.project_id === p.id).map((x) => ({ capability: x.capability, limit: x.limit, window: x.window, unit: x.unit, enforced_by: `openvibe.${capabilities.get(x.capability).owner}`, updated_at: x.updated_at })), note: 'quotas are enforced by the service that owns each capability; Network records and exposes them' });
++ if (rest[0] === 'usage' && m === 'GET') {
++ if ((!role || RANK[role] < RANK.admin) && !staff) return problem(json, 403, 'project.forbidden', 'requires admin role');
++ const days = q.get('days') || '30';
++ const env = q.get('env') || 'all';
++ if (!/^\d{1,3}$/.test(days) || Number(days) < 1 || Number(days) > 90) return problem(json, 422, 'usage.invalid', 'days is a whole number from 1 to 90');
++ if (!['all', 'sandbox', 'production'].includes(env)) return problem(json, 422, 'usage.invalid', 'env is all, sandbox or production');
++ const today = new Date().toISOString().slice(0, 10);
++ const range = { days: Number(days), from: new Date(Date.now() - (Number(days) - 1) * 86400000).toISOString().slice(0, 10), to: today };
++ const u = st.usage.get(p.id);
++ if (u) return json(200, { ...u, project_id: p.id, env, range });
++ return json(200, { project_id: p.id, env, range, generated_at: new Date().toISOString(), last_recorded_at: null, freshness: 'No usage has been recorded for this project yet.', totals: [], daily: [], quotas: [], errors: { total: 0, by_code: [], recent: [] } });
++ }
if (rest[0] === 'audit' && m === 'GET') { if (!role || RANK[role] < RANK.admin) { if (!staff) return problem(json, 403, 'project.forbidden', 'requires admin role'); } return json(200, { entries: st.audit.filter((e) => e.project_id === p.id).slice().reverse(), next_before: null }); }
if (rest[0] === 'apps') {
if (rest.length === 1 && m === 'GET') return json(200, { apps: [...st.apps.values()].filter((a) => a.project_id === p.id).map(appView) });
@@@ -241,7 +255,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
const srv = await listen(async (req, raw, json, res) => {
const url = new URL(req.url, 'http://x');
-- st.requests.push({ method: req.method, path: url.pathname, headers: req.headers });
++ st.requests.push({ method: req.method, path: url.pathname, query: url.search, headers: req.headers });
if (st.down) return problem(json, 503, 'network.down', 'Network is restarting');
if (url.pathname === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });
if (url.pathname === '/.well-known/openvibe') return json(200, { name: 'OpenVibe', issuer, contracts: { version: require('openvibe-contracts/package.json').version }, services: [] });
@@@ -310,6 +324,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },
setAllowance(projectId, caps) { st.projects.get(projectId).allowance = caps; },
setEnvironmentPolicy(projectId, policy) { st.projects.get(projectId).environment_policy = policy; },
++ setUsage(projectId, body) { st.usage.set(projectId, body); },
setQuota(projectId, capability, limit, window, unit) { st.quotas.set(`${projectId} ${capability}`, { project_id: projectId, capability, limit, window, unit, updated_at: new Date().toISOString() }); },
setCatalog(list) { st.catalog = list; },
setDown(v) { st.down = v; },
ddiff --git a/test/project-usage.test.js b/test/project-usage.test.js
nnew file mode 100644
iindex 0000000..9e143a1
---- /dev/null
++++ b/test/project-usage.test.js
@@@ -0,0 +1,171 @@
++'use strict';
++/**
++ * A project's usage page (WS-N task 4): /projects/:project/usage renders what Network's
++ * GET /api/v1/projects/:project/usage answers (usage by service, capability and day, quotas with
++ * their headroom, errors by code and the sampled failures with trace ids) next to the limits of the
++ * services the project used, server-side and complete without JavaScript. Only the owner, admins and
++ * staff see it (and Network is not asked for anyone else); the range filters are a GET form whose
++ * values reach Network as offered; a Network failure is shown as it answered. Never "free".
++ */
++const assert = require('assert');
++const http = require('http');
++const { usageQuery } = require('../server/render/usage');
++
++const EVENTS_LIMITS = {
++ 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: 20, sandbox: 5, exceeded: '429 events.quota_exceeded' },
++ ],
++};
++const TRACE = '4bf92f3577b34da6a3ce929d0e0e4736';
++const JOB = 'job_01JAB2C3D4E5F6G7H8J9K0MNPR';
++
++function usageFor(projectId) {
++ const today = new Date().toISOString().slice(0, 10);
++ return {
++ project_id: projectId, env: 'all', range: { days: 30, from: today, to: today }, generated_at: new Date().toISOString(),
++ last_recorded_at: new Date().toISOString(),
++ freshness: 'Usage arrives as hourly rollups a few minutes after each hour closes (UTC); the current hour is not counted yet.',
++ totals: [
++ { service: 'tools', capability: 'tools.job.create', unit: 'jobs', env: 'production', quantity: 4200, errors: 3 },
++ { service: 'events', capability: 'events.app.publish', unit: 'events', env: 'sandbox', quantity: 1800, errors: 61 },
++ ],
++ daily: [
++ { day: today, service: 'tools', capability: 'tools.job.create', dimension: 'img.process', unit: 'jobs', env: 'production', quantity: 4200, errors: 3 },
++ { day: today, service: 'events', capability: 'events.app.publish', dimension: null, unit: 'events', env: 'sandbox', quantity: 1800, errors: 61 },
++ ],
++ quotas: [
++ { capability: 'tools.job.create', limit: 5000, window: 'day', unit: 'jobs', enforced_by: 'openvibe.tools', used: 4200, remaining: 800, window_start: `${today}T00:00:00.000Z`, note: null },
++ { capability: 'events.app.publish', limit: 120, window: 'minute', unit: 'events', enforced_by: 'openvibe.events', used: null, remaining: null, window_start: null, note: 'a minute window is enforced by the service as it happens; hourly rollups cannot show it' },
++ ],
++ errors: {
++ total: 64,
++ by_code: [
++ { service: 'events', capability: 'events.app.publish', code: 'events.quota_exceeded', count: 60 },
++ { service: 'tools', capability: 'tools.job.create', code: 'tools.job.timeout', count: 3 },
++ ],
++ recent: [
++ { at: new Date().toISOString(), env: 'production', service: 'tools', capability: 'tools.job.create', code: 'tools.job.timeout', status: 504, trace_id: TRACE, ref: JOB },
++ { at: new Date().toISOString(), env: 'sandbox', service: 'events', capability: 'events.app.publish', code: 'events.quota_exceeded', status: 429, trace_id: null, ref: null },
++ ],
++ },
++ };
++}
++
++(async () => {
++ const limitsServer = http.createServer((req, res) => {
++ if (req.url !== '/limits.json') { res.statusCode = 404; return res.end(); }
++ res.setHeader('Content-Type', 'application/json');
++ res.end(JSON.stringify(EVENTS_LIMITS));
++ });
++ await new Promise((r) => limitsServer.listen(0, '127.0.0.1', r));
++ process.env.OV_EVENTS_INTERNAL_URL = `http://127.0.0.1:${limitsServer.address().port}`;
++ const { boot, check, done } = require('./helpers/boot');
++ const t = await boot();
++ const owner = t.network.addUser('ona');
++ const admin = t.network.addUser('ada');
++ const dev = t.network.addUser('dev');
++ const viewer = t.network.addUser('vee');
++ const stranger = t.network.addUser('sam');
++ const staff = t.network.addUser('staff', { role: 'admin' });
++ const projectId = await t.project(owner, 'Metered');
++ for (const [who, role] of [['ada', 'admin'], ['dev', 'developer'], ['vee', 'viewer']]) {
++ assert.strictEqual((await t.get(`/projects/${projectId}/members`, { as: owner, form: { username: who, role } })).status, 303);
++ }
++ const page = `/projects/${projectId}/usage`;
++ const usageAsked = (from) => t.network.requests.slice(from).filter((x) => x.path === `/api/v1/projects/${projectId}/usage`);
++
++ await check('before any usage: an honest empty page', async () => {
++ const r = await t.get(page, { as: owner });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.match(r.text, /No usage has been recorded for this project yet/);
++ assert.match(r.text, /No usage in these days/);
++ assert.match(r.text, /No quotas are recorded for this project/);
++ });
++
++ t.network.setUsage(projectId, usageFor(projectId));
++
++ await check('the owner sees usage by service, capability and day, quota headroom, errors and trace ids, server-rendered', async () => {
++ const r = await t.get(page, { as: owner });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ const body = r.text;
++ assert.match(body, /<h1>Usage <small>Metered<\/small><\/h1>/);
++ assert.match(body, /hourly rollups/);
++ // Summary and by day.
++ assert.match(body, /4,200<\/span> jobs/);
++ assert.match(body, /<code>tools\.job\.create<\/code>/);
++ assert.match(body, /<code>img\.process<\/code>/);
++ assert.match(body, /1,800<\/span> events/);
++ assert.match(body, /61<\/span> errors \(3\.4%\)/);
++ // Quotas: a meter for the day window, the reason for the minute one.
++ assert.match(body, /<meter class="headroom" min="0" max="5000" value="4200" low="4000" high="4750" optimum="0">84%<\/meter>/);
++ assert.match(body, /800<\/span> left/);
++ assert.match(body, /a minute window is enforced by the service as it happens/);
++ assert.match(body, /recorded limit — enforced by <code>openvibe\.tools<\/code>/);
++ // Service limits of the services used: Events publishes them, Tools does not yet.
++ assert.match(body, /Events published per minute/);
++ assert.match(body, /30 a minute/);
++ assert.match(body, /OpenVibe Tools does not publish its limits yet/);
++ // Errors.
++ assert.match(body, /<code>events\.quota_exceeded<\/code>/);
++ assert.match(body, /64<\/span> errors in these days/);
++ assert.ok(body.includes(`<code>${TRACE}</code>`), 'the sampled trace id');
++ assert.ok(body.includes(`<code>${JOB}</code>`), 'the failed job');
++ // Works without JavaScript: the range is a GET form; nothing here needs a script of Codes'.
++ assert.match(body, new RegExp(`<form method="get" action="/projects/${projectId}/usage"`));
++ assert.ok(!/src="\/js\//.test(body), 'no Codes script on the page');
++ assert.ok(!/\bfree\b|\$0/i.test(body), 'never "free" or "$0"');
++ });
++
++ await check('the range filters reach Network as offered; anything else falls back to 30 days, both environments', async () => {
++ const before = t.network.requests.length;
++ let r = await t.get(`${page}?days=7&env=sandbox`, { as: owner });
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /<option value="7" selected>/);
++ assert.match(r.text, /<option value="sandbox" selected>/);
++ assert.deepStrictEqual(usageAsked(before).map((x) => x.query), ['?days=7&env=sandbox']);
++ const again = t.network.requests.length;
++ r = await t.get(`${page}?days=365&env=staging`, { as: owner });
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /<option value="30" selected>/);
++ assert.deepStrictEqual(usageAsked(again).map((x) => x.query), ['?days=30&env=all']);
++ assert.deepStrictEqual(usageQuery({ days: '90', env: 'production' }), { days: 90, env: 'production' });
++ assert.deepStrictEqual(usageQuery({ days: '1; drop', env: '<x>' }), { days: 30, env: 'all' });
++ });
++
++ await check('admins and staff see it; developers and viewers get 403 without Network being asked; strangers 404', async () => {
++ assert.strictEqual((await t.get(page, { as: admin })).status, 200);
++ assert.strictEqual((await t.get(page, { as: staff })).status, 200);
++ const before = t.network.requests.length;
++ for (const who of [dev, viewer]) {
++ const r = await t.get(page, { as: who });
++ assert.strictEqual(r.status, 403, who.username);
++ assert.match(r.text, /for its owner and admins/);
++ }
++ assert.strictEqual(usageAsked(before).length, 0, 'Network was not asked for their usage');
++ assert.strictEqual((await t.get(page, { as: stranger })).status, 404);
++ const anon = await t.get(page);
++ assert.strictEqual(anon.status, 401);
++ });
++
++ await check('the project page links to the usage page for its owner, and no longer says usage is not collected', async () => {
++ const r = await t.get(`/projects/${projectId}`, { as: owner });
++ assert.ok(r.text.includes(`href="/projects/${projectId}/usage"`));
++ assert.ok(!/no usage numbers are collected/.test(r.text));
++ const d = await t.get(`/projects/${projectId}`, { as: dev });
++ assert.ok(!d.text.includes(`href="/projects/${projectId}/usage"`), 'no link for a developer');
++ });
++
++ await check('a Network failure is shown as it answered, never as an empty page', async () => {
++ t.network.setDown(true);
++ const r = await t.get(page, { as: owner });
++ t.network.setDown(false);
++ assert.ok(r.status >= 500, String(r.status));
++ assert.ok(!/No usage in these days/.test(r.text));
++ });
++
++ await t.close();
++ limitsServer.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });