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)
Changes
@@@ -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:4900npx 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")@@@ -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; }@@@ -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');@@@ -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),nnew file mode 100644@@@ -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 };@@@ -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/<project>/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>@@@ -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', 'Enforcemenhtml`<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);nnew file mode 100644@@@ -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 };@@@ -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.mconst 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.mif (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.mconst 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.missueCode(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; },nnew file mode 100644@@@ -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); });
@@ -30,7 +30,7 @@ A place where a developer outside the network goes from an OpenVibe account to a
@@ -30,7 +30,7 @@ A place where a developer outside the network goes from an OpenVibe account to a
| Surface | Path | Notes |
| 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. |
| 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. |
| 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. |
| 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
@@ -84,7 +84,7 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4900
npx openvibe-contracts-check --service codes --src server
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")
## Acceptance (must be true before "done")
@@ -72,3 +72,5 @@ fieldset { border: 1px solid var(--border, #1f2d47); border-radius: 8px; display
@@ -72,3 +72,5 @@ fieldset { border: 1px solid var(--border, #1f2d47); border-radius: 8px; display
.prose { max-width: 820px; }
.prose { max-width: 820px; }
.buttons { display: flex; gap: 8px; flex-wrap: wrap; }
.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; } }
@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; }
@@ -31,6 +31,7 @@ const { createTrust } = require('./domain/trust');
@@ -31,6 +31,7 @@ const { createTrust } = require('./domain/trust');
const { createReleases } = require('./domain/releases');
const { createReleases } = require('./domain/releases');
const { createPlayground } = require('./domain/playground');
const { createPlayground } = require('./domain/playground');
const { createArchiver } = require('./domain/project-archive');
const { createArchiver } = require('./domain/project-archive');
+const { createLimitsReader } = require('./domain/limits');
const { createDocsRoutes } = require('./http/docs');
const { createDocsRoutes } = require('./http/docs');
const { createToolRoutes } = require('./http/tools');
const { createToolRoutes } = require('./http/tools');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
@@ -60,7 +61,9 @@ function createApp(opts = {}) {
@@ -60,7 +61,9 @@ function createApp(opts = {}) {
const playground = createPlayground({ store, config, network, keys, fetchImpl, log });
const playground = createPlayground({ store, config, network, keys, fetchImpl, log });
const archiver = createArchiver({ config, 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();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -75,6 +75,8 @@ function createNetworkClient({ config, fetchImpl, log = console }) {
@@ -75,6 +75,8 @@ function createNetworkClient({ config, fetchImpl, log = console }) {
? call(t, 'DELETE', `${A(p, a)}/grants/${enc(cap)}`)
? call(t, 'DELETE', `${A(p, a)}/grants/${enc(cap)}`)
: call(t, 'POST', `${A(p, a)}/grants/${enc(cap)}/${decision === 'approved' ? 'approve' : 'deny'}`, {})),
: call(t, 'POST', `${A(p, a)}/grants/${enc(cap)}/${decision === 'approved' ? 'approve' : 'deny'}`, {})),
quotas: (t, p) => call(t, 'GET', `${P(p)}/quotas`),
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) } : {}) })}` : ''}`),
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).
// 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),
exportToken: (t, p, body) => call(t, 'POST', `${P(p)}/export-tokens`, body),
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -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 };
@@ -24,6 +24,7 @@ const { asyncRouter } = require('./router');
@@ -24,6 +24,7 @@ const { asyncRouter } = require('./router');
const { html, raw, table, code, badge, time, problemBox } = require('../render/html');
const { html, raw, table, code, badge, time, problemBox } = require('../render/html');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const { markdown } = require('../render/markdown');
const { markdown } = require('../render/markdown');
+const { createLimitsReader, amount } = require('../domain/limits');
function createDocsRoutes(ctx) {
function createDocsRoutes(ctx) {
const { docs, config, network } = ctx;
const { docs, config, network } = ctx;
@@ -415,40 +416,9 @@ ${table(['Rule', 'Value'], [
@@ -415,40 +416,9 @@ ${table(['Rule', 'Value'], [
// Each service that publishes a /limits.json (read from its running config) is shown as it
// 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
// 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.
// 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) => {
r.get('/limits', async (req, res) => {
const answers = await Promise.all(LIMIT_SOURCES.map(async (src) => ({ src, ...(await limitsOf(src)) })));
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 covered = new Set(LIMIT_SOURCES.map((s) => s.service));
@@ -467,6 +437,7 @@ ${table(['Rule', 'Value'], [
@@ -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>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>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>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/<project>/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>
</ul>
${answers.map(({ src, body, problem }) => html`<h2 id="${src.service}">${src.name}</h2>
${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>
${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>
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'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
* 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:
* 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.
* releases (manifest editor, publish/deprecate/revoke) and playgrounds.
@@ -12,6 +12,8 @@
@@ -12,6 +12,8 @@
* - a client secret appears only in the response to the create/rotate that produced it, with
* - 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
* 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
* - 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
* - the scope editor offers only capabilities apps can be granted (public, or partner when in the
* allowance) and refuses to forward anything else
* allowance) and refuses to forward anything else
*/
*/
@@ -27,6 +29,7 @@ const { validationResult } = require('./tools');
@@ -27,6 +29,7 @@ const { validationResult } = require('./tools');
const { buildExport } = require('../domain/project-export');
const { buildExport } = require('../domain/project-export');
const { ArchiveError } = require('../domain/project-archive');
const { ArchiveError } = require('../domain/project-archive');
const { statusBadge } = require('./pages');
const { statusBadge } = require('./pages');
+const { usageBody, usageQuery } = require('../render/usage');
const PRJ_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const PRJ_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const APP_RE = /^app_[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}$/;
@@ -38,7 +41,7 @@ const REL_RE = /^rel_[0-9A-HJKMNP-TV-Z]{26}$/;
const atLeast = (role, need) => Boolean(role) && RANK[role] >= RANK[need];
const atLeast = (role, need) => Boolean(role) && RANK[role] >= RANK[need];
function createPortalRoutes(ctx) {
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 r = asyncRouter();
const form = express.urlencoded({ extended: false, limit: '96kb' });
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
@@ -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>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>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>
<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>
<h2>Apps</h2>
${apps.ok ? table(['App', 'Environment', 'Type', 'Grants', 'Created'], (apps.data.apps || []).map((a) => [
${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
@@ -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`<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>`,
html`recorded limit — enforced by <code>${q.enforced_by || 'the owning service'}</code>`,
]), { empty: 'No quotas recorded for this project.' })}
]), { 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>
<h2>Members</h2>
${members.ok ? table(['Member', 'Role', 'Added', ''], (members.data.members || []).map((m) => [
${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=$
@@ -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 ────────────────────────────────────────────────
// ── Apps ────────────────────────────────────────────────
const redirectList = (text) => String(text || '').split(/\s+/).map((s) => s.trim()).filter(Boolean).slice(0, 11);
const redirectList = (text) => String(text || '').split(/\s+/).map((s) => s.trim()).filter(Boolean).slice(0, 11);
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -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 };
@@ -9,6 +9,8 @@
@@ -9,6 +9,8 @@
* with the same shapes, roles and problem codes as OpenVibe.Network's developer API
* 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
* (simplified: no audit paging, no rate limits). setDown(true) makes it refuse
* connections-equivalent (503) and drop() closes the socket.
* 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,
* 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.
* 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;
* 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
@@ -59,7 +61,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
const st = {
const st = {
users: new Map(), byUsername: new Map(), projects: new Map(), members: new Map(), apps: new Map(), creds: new Map(),
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,
catalog: null, down: false, requests: [], tokenRequests: [], exportTokens: [], exportTtl: 300,
};
};
let issuer = null;
let issuer = null;
@@ -153,6 +155,18 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -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 (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] === '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] === '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[0] === 'apps') {
if (rest.length === 1 && m === 'GET') return json(200, { apps: [...st.apps.values()].filter((a) => a.project_id === p.id).map(appView) });
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
@@ -241,7 +255,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
const srv = await listen(async (req, raw, json, res) => {
const srv = await listen(async (req, raw, json, res) => {
const url = new URL(req.url, 'http://x');
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 (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 === '/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: [] });
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
@@ -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; },
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; },
setAllowance(projectId, caps) { st.projects.get(projectId).allowance = caps; },
setEnvironmentPolicy(projectId, policy) { st.projects.get(projectId).environment_policy = policy; },
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() }); },
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; },
setCatalog(list) { st.catalog = list; },
setDown(v) { st.down = v; },
setDown(v) { st.down = v; },
new file mode 100644
new file mode 100644
@@ -0,0 +1,171 @@
@@ -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); });