openvibers/openvibe.services Commits
Services: the console manages project secrets — set once, grant to a service, never shown again (ADR-056) (#65)
Changes
@@@ -31,7 +31,7 @@ A place where a developer outside the network goes from an OpenVibe account to a| Surface | Path | Notes ||---|---|---|--| Portal | `/projects`, `/projects/mine`, `/projects/:project`, `/projects/:project/resources`, `/projects/:project/apps/:app` | Create projects; add members; **your resources** (`/projects/mine`: what the signed-in person owns outside any project, such as robots, chat rooms, streams, watches and Actor tasks. Every authority is asked with `?owner=<their subject>` (openvibe-sdk 0.41), and the merged page is kept to their own rows in no project, so an authority without the filter yet never shows another person's. Only the session's own subject is ever asked for); **resources** (every member and staff: `/projects/:project/resources?service=&kind=`, the merged index for that one project, server-rendered: name and OVRN, kind, state, owner, created and updated, 50 a page on the index's own cursor; Network confirms membership before any authority is asked, and an authority that could not be read is named with the problem it gave); 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 Services' 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.services/docs/export)) and **delete** a project (owner, confirmed by name: Network archives it — it offers archiving, not erasure — then Services 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/mine`, `/projects/:project`, `/projects/:project/resources`, `/projects/:project/apps/:app` | Create projects; add members; **your resources** (`/projects/mine`: what the signed-in person owns outside any project, such as robots, chat rooms, streams, watches and Actor tasks. Every authority is asked with `?owner=<their subject>` (openvibe-sdk 0.41), and the merged page is kept to their own rows in no project, so an authority without the filter yet never shows another person's. Only the session's own subject is ever asked for); **resources** (every member and staff: `/projects/:project/resources?service=&kind=`, the merged index for that one project, server-rendered: name and OVRN, kind, state, owner, created and updated, 50 a page on the index's own cursor; Network confirms membership before any authority is asked, and an authority that could not be read is named with the problem it gave); **secrets** (`/projects/:project/secrets`, ADR-056: Network's metadata for every member, name, version, fingerprint and which services may sign with each or receive it for which origins; admins and owners set a value, which passes through to Network once and is never shown, stored or logged here, delete it and grant or revoke services); 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 Services' 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.services/docs/export)) and **delete** a project (owner, confirmed by name: Network archives it — it offers archiving, not erasure — then Services 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`. v2 only: the v1 header (`X-OpenVibe-Signature`) was retired on 2026-09-28 and has no field. In the browser with Web Crypto, or on the server without JavaScript with openvibe-sdk's `verifyDeliveryV2`; constant-time; secret dropped. Generate a sample delivery signed as Events signs it (`X-OpenVibe-Timestamp` 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. |@@@ -16,7 +16,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.176.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.177.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.7","pg": "^8.23.0"@@@ -890,9 +890,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.176.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.176.0",-- "integrity": "sha512-COLEoRbjs6he+Tb2LvazezNWvHzB6CEWflebrgEyMaTd1hhE5zWJAAL/mdvR0LOzVDAX6vb1GPdTw83AulLfCg==",++ "version": "0.177.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.177.0",++ "integrity": "sha512-MargGRsErmonGrjiJWY3Nz1SgB16CBqx8iJZOcFeoQ8o1zZOUC2glagejMWjpB9QSiMN6jECG/SzUqdPmGxP9A==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -27,7 +27,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.176.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.177.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.7","pg": "^8.23.0"@@@ -78,6 +78,12 @@ function createNetworkClient({ config, fetchImpl, log = console }) {// 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) } : {}) })}` : ''}`),++ // Project secrets (ADR-056): metadata only; a value goes in and never comes back (admin+ writes).++ secrets: (t, p) => call(t, 'GET', `${P(p)}/secrets`),++ putSecret: (t, p, env, name, value) => call(t, 'PUT', `${P(p)}/secrets/${enc(env)}/${enc(name)}`, { value }),++ deleteSecret: (t, p, env, name) => call(t, 'DELETE', `${P(p)}/secrets/${enc(env)}/${enc(name)}`),++ putSecretGrant: (t, p, env, name, service, body) => call(t, 'PUT', `${P(p)}/secrets/${enc(env)}/${enc(name)}/grants/${enc(service)}`, body),++ deleteSecretGrant: (t, p, env, name, service) => call(t, 'DELETE', `${P(p)}/secrets/${enc(env)}/${enc(name)}/grants/${enc(service)}`),// 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),};@@@ -46,6 +46,8 @@ const BUDGETS = {'services.app.manage': { minute: 30, hour: 300 },// Rotating or revoking a credential mints or kills a secret: a few at a time.'services.app.credential': { minute: 10, hour: 60 },++ // Setting, deleting or granting a project secret (ADR-056): a few at a time, as credentials.++ 'services.secret.manage': { minute: 10, hour: 60 },// Suspend, resume or delete from the resources page (each one reaches the service that holds the resource).'services.resource.control': { minute: 20, hour: 200 },// A playground run calls Events or Media with the app's own credential: above the runs an hour@@@ -248,7 +248,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>--<p><a href="/projects/${project.id}/resources">Resources</a>${atLeast(role, 'admin') || req.viewer.staff ? html` · <a href="/projects/${project.id}/usage">Usage, quotas and errors</a> · <a href="/projects/${project.id}/audit">Audit log</a>` : ''}</p>++<p><a href="/projects/${project.id}/resources">Resources</a> · <a href="/projects/${project.id}/secrets">Secrets</a>${atLeast(role, 'admin') || req.viewer.staff ? html` · <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) => [@@@ -575,6 +575,89 @@ ${result && (filter.cursor || result.next_cursor) ? html`<p>${filter.cursor ? htpage(req, res, { title: `Usage · ${project.name}`, crumbs, body: usageBody({ project, usage: got.data, limits: answers, query }) });});++ // ── Secrets (ADR-056) ───────────────────────────────────++ // Network keeps them; a value passes through this process once, on its way to Network, and is never shown, stored++ // or logged here. Members and staff see metadata; admins and owners set, delete and grant.++ const SECRET_NAME_RE = /^[a-z][a-z0-9_-]{0,63}$/;++ const SECRET_ENVS = ['sandbox', 'production'];++ const SERVICE_ID_RE = /^[a-z][a-z0-9-]{1,39}$/;++ const secretPath = (req) => `/projects/${req.params.project}/secrets`;++ const secretParams = (req, res, next) => {++ const { env, name, service } = req.params;++ if ((env && !SECRET_ENVS.includes(env)) || (name && !SECRET_NAME_RE.test(name)) || (service && !SERVICE_ID_RE.test(service))) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1>` }, 404);++ next();++ };++ r.get('/:project/secrets', idParams, async (req, res) => {++ res.set('Cache-Control', 'no-store');++ const project = await loadProject(req, res);++ if (!project) return;++ const got = await net(req, res, (t) => network.projects.secrets(t, project.id));++ if (!got.ok) return problemPage(req, res, got.problem, { title: 'Secrets', back: back(req) });++ const list = got.data.secrets || [];++ const admin = atLeast(project.role, 'admin') && !project.archived_at;++ const envs = (project.environments || ['sandbox']).filter((e) => SECRET_ENVS.includes(e));++ const c = csrf(req);++ const grantCell = (s) => (s.grants.length ? html`<ul class="plain">${s.grants.map((g) => html`<li><code>${g.service}</code> ${g.purposes.join(' + ')}${g.origins.length ? html` <span class="muted small">to ${g.origins.join(', ')}</span>` : ''}<br><span class="muted small">${g.last_used_at ? html`last used ${time(g.last_used_at)}` : 'not used yet'}</span>${admin ? html` <form method="post" action="${secretPath(req)}/${s.environment}/${s.name}/grants/${g.service}/revoke" class="inline-form">${csrfField(c)}<button type="submit" class="small">Revoke</button></form>` : ''}</li>`)}</ul>` : html`<span class="muted">no service may use it</span>`);++ const rows = list.map((s) => [++ html`<code>${s.name}</code><br><span class="muted small">${s.environment}</span>`,++ html`v${String(s.version)}<br><span class="muted small">fingerprint <code>${s.fingerprint}</code></span>`,++ html`${time(s.updated_at)}<br><span class="muted small">${s.updated_by}</span>`,++ grantCell(s),++ admin ? html`<details><summary>Grant a service</summary><form method="post" action="${secretPath(req)}/${s.environment}/${s.name}/grants" class="stack">${csrfField(c)}++<label>Service <input name="service" required pattern="[a-z][a-z0-9-]{1,39}" placeholder="actor" maxlength="40"></label>++<fieldset><legend>It may</legend><label><input type="checkbox" name="purpose_sign" value="1" checked> sign with it (the value never leaves Network)</label>++<label><input type="checkbox" name="purpose_reveal" value="1"> receive it, to send to these origins only</label></fieldset>++<label>Origins (reveal; one https origin a line) <textarea name="origins" rows="2" placeholder="https://api.example.com"></textarea></label>++<button type="submit">Grant</button></form>++<form method="post" action="${secretPath(req)}/${s.environment}/${s.name}/delete" class="inline-form">${csrfField(c)}<label><input type="checkbox" name="confirm" value="yes" required> delete ${s.name}</label> <button type="submit" class="danger">Delete</button></form></details>` : '',++ ]);++ page(req, res, {++ title: `Secrets · ${project.name}`,++ crumbs: [{ label: 'Projects', href: '/projects' }, { label: project.name, href: back(req) }, { label: 'Secrets' }],++ body: html`<h1>Secrets</h1>${req.query.done ? notice(String(req.query.done).slice(0, 80), 'ok') : ''}++<p class="lede">Values your project's services use by name: a webhook signing key, a token for your own API. OpenVibe.Network keeps them encrypted. Once set, a value is never shown again, here or anywhere; a service uses it only when you grant it, to sign with it or to send it to the origins you list.</p>++${table(admin ? ['Name', 'Version', 'Set', 'Who may use it', ''] : ['Name', 'Version', 'Set', 'Who may use it'], rows, { empty: 'No secrets yet.' })}++${admin ? html`<h2>Set a secret</h2>++<form method="post" action="${secretPath(req)}" class="stack" autocomplete="off">${csrfField(c)}++<label>Environment <select name="environment">${envs.map((e) => html`<option value="${e}">${e}</option>`)}</select></label>++<label>Name <input name="name" required pattern="[a-z][a-z0-9_\-]{0,63}" maxlength="64" placeholder="webhook-signing"></label>++<label>Value <textarea name="value" required rows="3" maxlength="8192" spellcheck="false" autocomplete="off"></textarea></label>++<p class="muted small">Setting a name again replaces its value and adds one to its version; its grants stay. The fingerprint tells two values apart without showing either.</p>++<button type="submit">Save</button></form>` : html`<p class="muted small">Admins and owners set secrets and decide which services may use them.</p>`}++<p><a href="/docs/adr/ADR-056">How project secrets work</a> · <a href="${back(req)}">Back to the project</a></p>`,++ });++ });++ r.post('/:project/secrets', idParams, B('services.secret.manage'), form, guard, async (req, res) => {++ res.set('Cache-Control', 'no-store');++ const b = req.body || {};++ const env = String(b.environment || '');++ const name = String(b.name || '');++ if (!SECRET_ENVS.includes(env) || !SECRET_NAME_RE.test(name) || typeof b.value !== 'string' || !b.value) {++ return page(req, res, { title: 'Not saved', body: html`<h1>Not saved</h1><p>Choose an environment, a name of lowercase letters, digits, _ or - (starting with a letter) and a value.</p><p><a href="${secretPath(req)}">Back</a></p>` }, 422);++ }++ const got = await net(req, res, (t) => network.projects.putSecret(t, req.params.project, env, name, b.value));++ b.value = undefined;++ after(req, res, got, secretPath(req), `${name} saved (version ${got.ok ? got.data.version : ''})`);++ });++ r.post('/:project/secrets/:env/:name/delete', idParams, secretParams, B('services.secret.manage'), form, guard, async (req, res) => {++ if ((req.body || {}).confirm !== 'yes') return page(req, res, { title: 'Not deleted', body: html`<h1>Not deleted</h1><p>Tick the box to confirm.</p><p><a href="${secretPath(req)}">Back</a></p>` }, 422);++ const got = await net(req, res, (t) => network.projects.deleteSecret(t, req.params.project, req.params.env, req.params.name));++ after(req, res, got, secretPath(req), `${req.params.name} deleted`);++ });++ r.post('/:project/secrets/:env/:name/grants', idParams, secretParams, B('services.secret.manage'), form, guard, async (req, res) => {++ const b = req.body || {};++ const service = String(b.service || '').trim();++ const purposes = [b.purpose_sign ? 'sign' : null, b.purpose_reveal ? 'reveal' : null].filter(Boolean);++ const origins = String(b.origins || '').split(/\s+/).map((s) => s.trim()).filter(Boolean).slice(0, 10);++ if (!SERVICE_ID_RE.test(service) || !purposes.length) return page(req, res, { title: 'Not granted', body: html`<h1>Not granted</h1><p>Name a service (its id, such as actor) and what it may do.</p><p><a href="${secretPath(req)}">Back</a></p>` }, 422);++ const got = await net(req, res, (t) => network.projects.putSecretGrant(t, req.params.project, req.params.env, req.params.name, service, { purposes, origins: purposes.includes('reveal') ? origins : [] }));++ after(req, res, got, secretPath(req), `${service} may ${purposes.join(' and ')} with ${req.params.name}`);++ });++ r.post('/:project/secrets/:env/:name/grants/:service/revoke', idParams, secretParams, B('services.secret.manage'), form, guard, async (req, res) => {++ const got = await net(req, res, (t) => network.projects.deleteSecretGrant(t, req.params.project, req.params.env, req.params.name, req.params.service));++ after(req, res, got, secretPath(req), `${req.params.service} may no longer use ${req.params.name}`);++ });++// ── Apps ────────────────────────────────────────────────const redirectList = (text) => String(text || '').split(/\s+/).map((s) => s.trim()).filter(Boolean).slice(0, 11);@@@ -68,6 +68,9 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.mgrants: new Map(), quotas: new Map(), usage: new Map(), audit: [], services: new Map(), refresh: new Map(),catalog: null, down: false, requests: [], tokenRequests: [], exportTokens: [], exportTtl: 300,agents: new Map(), confirmations: new Map(), standingRules: new Set(),++ // Project secrets (ADR-056): key "<project> <env> <name>" -> { version, fingerprint, value, grants: Map(service -> grant) }.++ // The value is kept only so tests can check what reached Network; the mock never answers it.++ secrets: new Map(),};let issuer = null;let nextUserId = 1;@@@ -172,6 +175,33 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.mif (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] === 'secrets') {++ const view = (key, x) => { const [, env, name] = key.split(' '); return { project_id: p.id, environment: env, name, version: x.version, fingerprint: x.fingerprint, grants: [...x.grants.values()], created_at: x.created_at, created_by: x.created_by, updated_at: x.updated_at, updated_by: x.updated_by }; };++ if (rest.length === 1 && m === 'GET') return json(200, { secrets: [...st.secrets.entries()].filter(([k]) => k.startsWith(`${p.id} `)).map(([k, x]) => view(k, x)) });++ const [env, name, sub, service] = rest.slice(1);++ if (!need('admin')) return;++ if (!['sandbox', 'production'].includes(env) || !/^[a-z][a-z0-9_-]{0,63}$/.test(name || '')) return problem(json, 422, 'secret.invalid', 'bad environment or name');++ const key = `${p.id} ${env} ${name}`;++ const at = new Date().toISOString();++ if (!sub && m === 'PUT') {++ if (typeof body.value !== 'string' || !body.value) return problem(json, 422, 'secret.invalid', 'value must be a non-empty string');++ const prev = st.secrets.get(key);++ const x = { version: prev ? prev.version + 1 : 1, fingerprint: crypto.createHash('sha256').update(body.value).digest('hex').slice(0, 12), value: body.value, grants: prev ? prev.grants : new Map(), created_at: prev ? prev.created_at : at, created_by: prev ? prev.created_by : u.subject, updated_at: at, updated_by: u.subject };++ st.secrets.set(key, x);++ return json(200, view(key, x));++ }++ const x = st.secrets.get(key);++ if (!x) return problem(json, 404, 'secret.not_found', 'no such secret');++ if (!sub && m === 'DELETE') { st.secrets.delete(key); return res204(json); }++ if (sub === 'grants' && m === 'PUT') {++ const purposes = Array.isArray(body.purposes) ? body.purposes : [];++ const origins = Array.isArray(body.origins) ? body.origins : [];++ if (!purposes.length || (purposes.includes('reveal') && !origins.length)) return problem(json, 422, 'secret.invalid', 'reveal names the origins the value may be sent to');++ x.grants.set(service, { service, purposes: [...purposes].sort(), origins: [...origins].sort(), created_at: at, created_by: u.subject, last_used_at: null });++ return json(200, view(key, x));++ }++ if (sub === 'grants' && m === 'DELETE') { if (!x.grants.delete(service)) return problem(json, 404, 'secret.grant_not_found', 'no grant'); return res204(json); }++ }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) });nnew file mode 100644@@@ -0,0 +1,78 @@++'use strict';++/**++ * Project secrets in the console (ADR-056): /projects/:project/secrets shows Network's metadata (name, version,++ * fingerprint, grants) to every member, and the set, delete and grant forms to admins and owners only. A value goes++ * from the form to Network once and appears nowhere after: not on any page, not in Services' database, not in its logs.++ * Grants forward the purposes and origins as chosen; Network's refusals are shown as it answered them.++ */++const assert = require('assert');++const crypto = require('crypto');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ const owner = t.network.addUser('ona');++ const admin = t.network.addUser('ada');++ const viewer = t.network.addUser('vee');++ const P = await t.project(owner, 'Signed');++ for (const [who, role] of [['ada', 'admin'], ['vee', 'viewer']]) {++ assert.strictEqual((await t.get(`/projects/${P}/members`, { as: owner, form: { username: who, role } })).status, 303);++ }++ const page = `/projects/${P}/secrets`;++ const VALUE = `whk_${crypto.randomBytes(24).toString('hex')}`;++ const stored = () => t.network.state.secrets.get(`${P} sandbox webhook-signing`);++++ await check('the project page links its secrets; an empty list says so, with the form for owners only', async () => {++ assert.ok((await t.get(`/projects/${P}`, { as: viewer })).text.includes(`href="/projects/${P}/secrets"`));++ const r = await t.get(page, { as: owner });++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /No secrets yet\./);++ assert.match(r.text, /<h2>Set a secret<\/h2>/);++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');++ const v = await t.get(page, { as: viewer });++ assert.ok(!v.text.includes('<h2>Set a secret</h2>'), 'a viewer gets no form');++ });++++ await check('an owner sets a value: it reaches Network once and appears on no page, table or log line', async () => {++ const r = await t.get(page, { as: owner, form: { environment: 'sandbox', name: 'webhook-signing', value: VALUE } });++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));++ assert.match(r.headers.get('location'), new RegExp(`^/projects/${P}/secrets\\?done=webhook-signing%20saved`));++ assert.strictEqual(stored().value, VALUE, 'Network got the value');++ const after = await t.get(page, { as: admin });++ assert.ok(after.text.includes('webhook-signing') && after.text.includes(stored().fingerprint), 'name and fingerprint shown');++ assert.ok(!after.text.includes(VALUE), 'never the value');++ assert.ok(!(await t.dbDump()).includes(VALUE), 'not in Services\' database');++ assert.ok(!t.logs().includes(VALUE), 'not in its logs');++ });++++ await check('a grant forwards what was chosen; reveal without origins is refused by Network and shown so', async () => {++ let r = await t.get(`${page}/sandbox/webhook-signing/grants`, { as: admin, form: { service: 'actor', purpose_sign: '1' } });++ assert.strictEqual(r.status, 303);++ assert.deepStrictEqual(stored().grants.get('actor'), { ...stored().grants.get('actor'), purposes: ['sign'], origins: [] });++ assert.match((await t.get(page, { as: viewer })).text, /<code>actor<\/code> sign/);++ r = await t.get(`${page}/sandbox/webhook-signing/grants`, { as: admin, form: { service: 'watch', purpose_reveal: '1' } });++ assert.strictEqual(r.status, 422, 'Network refused it');++ assert.match(r.text, /secret\.invalid/);++ r = await t.get(`${page}/sandbox/webhook-signing/grants`, { as: admin, form: { service: 'watch', purpose_reveal: '1', origins: 'https://api.example.com\nhttps://b.example.com' } });++ assert.strictEqual(r.status, 303);++ assert.deepStrictEqual(stored().grants.get('watch').origins, ['https://api.example.com', 'https://b.example.com']);++ });++++ await check('a viewer cannot set, grant or delete: Network refuses and nothing changes', async () => {++ const r = await t.get(page, { as: viewer, form: { environment: 'sandbox', name: 'x', value: 'v' } });++ assert.strictEqual(r.status, 403);++ assert.ok(!t.network.state.secrets.has(`${P} sandbox x`));++ });++++ await check('revoking a grant and deleting a secret (with the confirmation box)', async () => {++ assert.strictEqual((await t.get(`${page}/sandbox/webhook-signing/grants/watch/revoke`, { as: admin, form: {} })).status, 303);++ assert.ok(!stored().grants.has('watch'));++ assert.strictEqual((await t.get(`${page}/sandbox/webhook-signing/delete`, { as: owner, form: {} })).status, 422, 'unconfirmed');++ assert.strictEqual((await t.get(`${page}/sandbox/webhook-signing/delete`, { as: owner, form: { confirm: 'yes' } })).status, 303);++ assert.strictEqual(stored(), undefined);++ assert.strictEqual((await t.get(`${page}/staging/x/delete`, { as: owner, form: { confirm: 'yes' } })).status, 404, 'a bad environment never reaches Network');++ });++++ await t.close();++ done();++})();
@@ -31,7 +31,7 @@ A place where a developer outside the network goes from an OpenVibe account to a
@@ -31,7 +31,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/mine`, `/projects/:project`, `/projects/:project/resources`, `/projects/:project/apps/:app` | Create projects; add members; **your resources** (`/projects/mine`: what the signed-in person owns outside any project, such as robots, chat rooms, streams, watches and Actor tasks. Every authority is asked with `?owner=<their subject>` (openvibe-sdk 0.41), and the merged page is kept to their own rows in no project, so an authority without the filter yet never shows another person's. Only the session's own subject is ever asked for); **resources** (every member and staff: `/projects/:project/resources?service=&kind=`, the merged index for that one project, server-rendered: name and OVRN, kind, state, owner, created and updated, 50 a page on the index's own cursor; Network confirms membership before any authority is asked, and an authority that could not be read is named with the problem it gave); 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 Services' 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.services/docs/export)) and **delete** a project (owner, confirmed by name: Network archives it — it offers archiving, not erasure — then Services 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/mine`, `/projects/:project`, `/projects/:project/resources`, `/projects/:project/apps/:app` | Create projects; add members; **your resources** (`/projects/mine`: what the signed-in person owns outside any project, such as robots, chat rooms, streams, watches and Actor tasks. Every authority is asked with `?owner=<their subject>` (openvibe-sdk 0.41), and the merged page is kept to their own rows in no project, so an authority without the filter yet never shows another person's. Only the session's own subject is ever asked for); **resources** (every member and staff: `/projects/:project/resources?service=&kind=`, the merged index for that one project, server-rendered: name and OVRN, kind, state, owner, created and updated, 50 a page on the index's own cursor; Network confirms membership before any authority is asked, and an authority that could not be read is named with the problem it gave); **secrets** (`/projects/:project/secrets`, ADR-056: Network's metadata for every member, name, version, fingerprint and which services may sign with each or receive it for which origins; admins and owners set a value, which passes through to Network once and is never shown, stored or logged here, delete it and grant or revoke services); 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 Services' 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.services/docs/export)) and **delete** a project (owner, confirmed by name: Network archives it — it offers archiving, not erasure — then Services 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`. v2 only: the v1 header (`X-OpenVibe-Signature`) was retired on 2026-09-28 and has no field. In the browser with Web Crypto, or on the server without JavaScript with openvibe-sdk's `verifyDeliveryV2`; constant-time; secret dropped. Generate a sample delivery signed as Events signs it (`X-OpenVibe-Timestamp` 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`. v2 only: the v1 header (`X-OpenVibe-Signature`) was retired on 2026-09-28 and has no field. In the browser with Web Crypto, or on the server without JavaScript with openvibe-sdk's `verifyDeliveryV2`; constant-time; secret dropped. Generate a sample delivery signed as Events signs it (`X-OpenVibe-Timestamp` 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. |
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.176.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.177.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.7",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.7",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -890,9 +890,9 @@
@@ -890,9 +890,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.176.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.176.0",
- "integrity": "sha512-COLEoRbjs6he+Tb2LvazezNWvHzB6CEWflebrgEyMaTd1hhE5zWJAAL/mdvR0LOzVDAX6vb1GPdTw83AulLfCg==",
+ "version": "0.177.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.177.0",
+ "integrity": "sha512-MargGRsErmonGrjiJWY3Nz1SgB16CBqx8iJZOcFeoQ8o1zZOUC2glagejMWjpB9QSiMN6jECG/SzUqdPmGxP9A==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.176.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.177.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.7",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.7",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -78,6 +78,12 @@ function createNetworkClient({ config, fetchImpl, log = console }) {
@@ -78,6 +78,12 @@ function createNetworkClient({ config, fetchImpl, log = console }) {
// Usage per day, quotas with their use and recent errors (network.project-usage-result@1; owner/admin).
// 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' })}`),
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) } : {}) })}` : ''}`),
+ // Project secrets (ADR-056): metadata only; a value goes in and never comes back (admin+ writes).
+ secrets: (t, p) => call(t, 'GET', `${P(p)}/secrets`),
+ putSecret: (t, p, env, name, value) => call(t, 'PUT', `${P(p)}/secrets/${enc(env)}/${enc(name)}`, { value }),
+ deleteSecret: (t, p, env, name) => call(t, 'DELETE', `${P(p)}/secrets/${enc(env)}/${enc(name)}`),
+ putSecretGrant: (t, p, env, name, service, body) => call(t, 'PUT', `${P(p)}/secrets/${enc(env)}/${enc(name)}/grants/${enc(service)}`, body),
+ deleteSecretGrant: (t, p, env, name, service) => call(t, 'DELETE', `${P(p)}/secrets/${enc(env)}/${enc(name)}/grants/${enc(service)}`),
// 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),
};
};
@@ -46,6 +46,8 @@ const BUDGETS = {
@@ -46,6 +46,8 @@ const BUDGETS = {
'services.app.manage': { minute: 30, hour: 300 },
'services.app.manage': { minute: 30, hour: 300 },
// Rotating or revoking a credential mints or kills a secret: a few at a time.
// Rotating or revoking a credential mints or kills a secret: a few at a time.
'services.app.credential': { minute: 10, hour: 60 },
'services.app.credential': { minute: 10, hour: 60 },
+ // Setting, deleting or granting a project secret (ADR-056): a few at a time, as credentials.
+ 'services.secret.manage': { minute: 10, hour: 60 },
// Suspend, resume or delete from the resources page (each one reaches the service that holds the resource).
// Suspend, resume or delete from the resources page (each one reaches the service that holds the resource).
'services.resource.control': { minute: 20, hour: 200 },
'services.resource.control': { minute: 20, hour: 200 },
// A playground run calls Events or Media with the app's own credential: above the runs an hour
// A playground run calls Events or Media with the app's own credential: above the runs an hour
@@ -248,7 +248,7 @@ ${archived ? notice('This project is archived: every app in it is revoked.', 'ba
@@ -248,7 +248,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>
-<p><a href="/projects/${project.id}/resources">Resources</a>${atLeast(role, 'admin') || req.viewer.staff ? html` · <a href="/projects/${project.id}/usage">Usage, quotas and errors</a> · <a href="/projects/${project.id}/audit">Audit log</a>` : ''}</p>
+<p><a href="/projects/${project.id}/resources">Resources</a> · <a href="/projects/${project.id}/secrets">Secrets</a>${atLeast(role, 'admin') || req.viewer.staff ? html` · <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) => [
@@ -575,6 +575,89 @@ ${result && (filter.cursor || result.next_cursor) ? html`<p>${filter.cursor ? ht
@@ -575,6 +575,89 @@ ${result && (filter.cursor || result.next_cursor) ? html`<p>${filter.cursor ? ht
page(req, res, { title: `Usage · ${project.name}`, crumbs, body: usageBody({ project, usage: got.data, limits: answers, query }) });
page(req, res, { title: `Usage · ${project.name}`, crumbs, body: usageBody({ project, usage: got.data, limits: answers, query }) });
});
});
+ // ── Secrets (ADR-056) ───────────────────────────────────
+ // Network keeps them; a value passes through this process once, on its way to Network, and is never shown, stored
+ // or logged here. Members and staff see metadata; admins and owners set, delete and grant.
+ const SECRET_NAME_RE = /^[a-z][a-z0-9_-]{0,63}$/;
+ const SECRET_ENVS = ['sandbox', 'production'];
+ const SERVICE_ID_RE = /^[a-z][a-z0-9-]{1,39}$/;
+ const secretPath = (req) => `/projects/${req.params.project}/secrets`;
+ const secretParams = (req, res, next) => {
+ const { env, name, service } = req.params;
+ if ((env && !SECRET_ENVS.includes(env)) || (name && !SECRET_NAME_RE.test(name)) || (service && !SERVICE_ID_RE.test(service))) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1>` }, 404);
+ next();
+ };
+ r.get('/:project/secrets', idParams, async (req, res) => {
+ res.set('Cache-Control', 'no-store');
+ const project = await loadProject(req, res);
+ if (!project) return;
+ const got = await net(req, res, (t) => network.projects.secrets(t, project.id));
+ if (!got.ok) return problemPage(req, res, got.problem, { title: 'Secrets', back: back(req) });
+ const list = got.data.secrets || [];
+ const admin = atLeast(project.role, 'admin') && !project.archived_at;
+ const envs = (project.environments || ['sandbox']).filter((e) => SECRET_ENVS.includes(e));
+ const c = csrf(req);
+ const grantCell = (s) => (s.grants.length ? html`<ul class="plain">${s.grants.map((g) => html`<li><code>${g.service}</code> ${g.purposes.join(' + ')}${g.origins.length ? html` <span class="muted small">to ${g.origins.join(', ')}</span>` : ''}<br><span class="muted small">${g.last_used_at ? html`last used ${time(g.last_used_at)}` : 'not used yet'}</span>${admin ? html` <form method="post" action="${secretPath(req)}/${s.environment}/${s.name}/grants/${g.service}/revoke" class="inline-form">${csrfField(c)}<button type="submit" class="small">Revoke</button></form>` : ''}</li>`)}</ul>` : html`<span class="muted">no service may use it</span>`);
+ const rows = list.map((s) => [
+ html`<code>${s.name}</code><br><span class="muted small">${s.environment}</span>`,
+ html`v${String(s.version)}<br><span class="muted small">fingerprint <code>${s.fingerprint}</code></span>`,
+ html`${time(s.updated_at)}<br><span class="muted small">${s.updated_by}</span>`,
+ grantCell(s),
+ admin ? html`<details><summary>Grant a service</summary><form method="post" action="${secretPath(req)}/${s.environment}/${s.name}/grants" class="stack">${csrfField(c)}
+<label>Service <input name="service" required pattern="[a-z][a-z0-9-]{1,39}" placeholder="actor" maxlength="40"></label>
+<fieldset><legend>It may</legend><label><input type="checkbox" name="purpose_sign" value="1" checked> sign with it (the value never leaves Network)</label>
+<label><input type="checkbox" name="purpose_reveal" value="1"> receive it, to send to these origins only</label></fieldset>
+<label>Origins (reveal; one https origin a line) <textarea name="origins" rows="2" placeholder="https://api.example.com"></textarea></label>
+<button type="submit">Grant</button></form>
+<form method="post" action="${secretPath(req)}/${s.environment}/${s.name}/delete" class="inline-form">${csrfField(c)}<label><input type="checkbox" name="confirm" value="yes" required> delete ${s.name}</label> <button type="submit" class="danger">Delete</button></form></details>` : '',
+ ]);
+ page(req, res, {
+ title: `Secrets · ${project.name}`,
+ crumbs: [{ label: 'Projects', href: '/projects' }, { label: project.name, href: back(req) }, { label: 'Secrets' }],
+ body: html`<h1>Secrets</h1>${req.query.done ? notice(String(req.query.done).slice(0, 80), 'ok') : ''}
+<p class="lede">Values your project's services use by name: a webhook signing key, a token for your own API. OpenVibe.Network keeps them encrypted. Once set, a value is never shown again, here or anywhere; a service uses it only when you grant it, to sign with it or to send it to the origins you list.</p>
+${table(admin ? ['Name', 'Version', 'Set', 'Who may use it', ''] : ['Name', 'Version', 'Set', 'Who may use it'], rows, { empty: 'No secrets yet.' })}
+${admin ? html`<h2>Set a secret</h2>
+<form method="post" action="${secretPath(req)}" class="stack" autocomplete="off">${csrfField(c)}
+<label>Environment <select name="environment">${envs.map((e) => html`<option value="${e}">${e}</option>`)}</select></label>
+<label>Name <input name="name" required pattern="[a-z][a-z0-9_\-]{0,63}" maxlength="64" placeholder="webhook-signing"></label>
+<label>Value <textarea name="value" required rows="3" maxlength="8192" spellcheck="false" autocomplete="off"></textarea></label>
+<p class="muted small">Setting a name again replaces its value and adds one to its version; its grants stay. The fingerprint tells two values apart without showing either.</p>
+<button type="submit">Save</button></form>` : html`<p class="muted small">Admins and owners set secrets and decide which services may use them.</p>`}
+<p><a href="/docs/adr/ADR-056">How project secrets work</a> · <a href="${back(req)}">Back to the project</a></p>`,
+ });
+ });
+ r.post('/:project/secrets', idParams, B('services.secret.manage'), form, guard, async (req, res) => {
+ res.set('Cache-Control', 'no-store');
+ const b = req.body || {};
+ const env = String(b.environment || '');
+ const name = String(b.name || '');
+ if (!SECRET_ENVS.includes(env) || !SECRET_NAME_RE.test(name) || typeof b.value !== 'string' || !b.value) {
+ return page(req, res, { title: 'Not saved', body: html`<h1>Not saved</h1><p>Choose an environment, a name of lowercase letters, digits, _ or - (starting with a letter) and a value.</p><p><a href="${secretPath(req)}">Back</a></p>` }, 422);
+ }
+ const got = await net(req, res, (t) => network.projects.putSecret(t, req.params.project, env, name, b.value));
+ b.value = undefined;
+ after(req, res, got, secretPath(req), `${name} saved (version ${got.ok ? got.data.version : ''})`);
+ });
+ r.post('/:project/secrets/:env/:name/delete', idParams, secretParams, B('services.secret.manage'), form, guard, async (req, res) => {
+ if ((req.body || {}).confirm !== 'yes') return page(req, res, { title: 'Not deleted', body: html`<h1>Not deleted</h1><p>Tick the box to confirm.</p><p><a href="${secretPath(req)}">Back</a></p>` }, 422);
+ const got = await net(req, res, (t) => network.projects.deleteSecret(t, req.params.project, req.params.env, req.params.name));
+ after(req, res, got, secretPath(req), `${req.params.name} deleted`);
+ });
+ r.post('/:project/secrets/:env/:name/grants', idParams, secretParams, B('services.secret.manage'), form, guard, async (req, res) => {
+ const b = req.body || {};
+ const service = String(b.service || '').trim();
+ const purposes = [b.purpose_sign ? 'sign' : null, b.purpose_reveal ? 'reveal' : null].filter(Boolean);
+ const origins = String(b.origins || '').split(/\s+/).map((s) => s.trim()).filter(Boolean).slice(0, 10);
+ if (!SERVICE_ID_RE.test(service) || !purposes.length) return page(req, res, { title: 'Not granted', body: html`<h1>Not granted</h1><p>Name a service (its id, such as actor) and what it may do.</p><p><a href="${secretPath(req)}">Back</a></p>` }, 422);
+ const got = await net(req, res, (t) => network.projects.putSecretGrant(t, req.params.project, req.params.env, req.params.name, service, { purposes, origins: purposes.includes('reveal') ? origins : [] }));
+ after(req, res, got, secretPath(req), `${service} may ${purposes.join(' and ')} with ${req.params.name}`);
+ });
+ r.post('/:project/secrets/:env/:name/grants/:service/revoke', idParams, secretParams, B('services.secret.manage'), form, guard, async (req, res) => {
+ const got = await net(req, res, (t) => network.projects.deleteSecretGrant(t, req.params.project, req.params.env, req.params.name, req.params.service));
+ after(req, res, got, secretPath(req), `${req.params.service} may no longer use ${req.params.name}`);
+ });
+
// ── 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);
@@ -68,6 +68,9 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -68,6 +68,9 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
grants: new Map(), quotas: new Map(), usage: new Map(), audit: [], services: new Map(), refresh: new Map(),
grants: new Map(), quotas: new Map(), usage: new Map(), audit: [], services: new Map(), refresh: new Map(),
catalog: null, down: false, requests: [], tokenRequests: [], exportTokens: [], exportTtl: 300,
catalog: null, down: false, requests: [], tokenRequests: [], exportTokens: [], exportTtl: 300,
agents: new Map(), confirmations: new Map(), standingRules: new Set(),
agents: new Map(), confirmations: new Map(), standingRules: new Set(),
+ // Project secrets (ADR-056): key "<project> <env> <name>" -> { version, fingerprint, value, grants: Map(service -> grant) }.
+ // The value is kept only so tests can check what reached Network; the mock never answers it.
+ secrets: new Map(),
};
};
let issuer = null;
let issuer = null;
let nextUserId = 1;
let nextUserId = 1;
@@ -172,6 +175,33 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -172,6 +175,33 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
if (u) return json(200, { ...u, project_id: p.id, env, range });
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: [] } });
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] === 'secrets') {
+ const view = (key, x) => { const [, env, name] = key.split(' '); return { project_id: p.id, environment: env, name, version: x.version, fingerprint: x.fingerprint, grants: [...x.grants.values()], created_at: x.created_at, created_by: x.created_by, updated_at: x.updated_at, updated_by: x.updated_by }; };
+ if (rest.length === 1 && m === 'GET') return json(200, { secrets: [...st.secrets.entries()].filter(([k]) => k.startsWith(`${p.id} `)).map(([k, x]) => view(k, x)) });
+ const [env, name, sub, service] = rest.slice(1);
+ if (!need('admin')) return;
+ if (!['sandbox', 'production'].includes(env) || !/^[a-z][a-z0-9_-]{0,63}$/.test(name || '')) return problem(json, 422, 'secret.invalid', 'bad environment or name');
+ const key = `${p.id} ${env} ${name}`;
+ const at = new Date().toISOString();
+ if (!sub && m === 'PUT') {
+ if (typeof body.value !== 'string' || !body.value) return problem(json, 422, 'secret.invalid', 'value must be a non-empty string');
+ const prev = st.secrets.get(key);
+ const x = { version: prev ? prev.version + 1 : 1, fingerprint: crypto.createHash('sha256').update(body.value).digest('hex').slice(0, 12), value: body.value, grants: prev ? prev.grants : new Map(), created_at: prev ? prev.created_at : at, created_by: prev ? prev.created_by : u.subject, updated_at: at, updated_by: u.subject };
+ st.secrets.set(key, x);
+ return json(200, view(key, x));
+ }
+ const x = st.secrets.get(key);
+ if (!x) return problem(json, 404, 'secret.not_found', 'no such secret');
+ if (!sub && m === 'DELETE') { st.secrets.delete(key); return res204(json); }
+ if (sub === 'grants' && m === 'PUT') {
+ const purposes = Array.isArray(body.purposes) ? body.purposes : [];
+ const origins = Array.isArray(body.origins) ? body.origins : [];
+ if (!purposes.length || (purposes.includes('reveal') && !origins.length)) return problem(json, 422, 'secret.invalid', 'reveal names the origins the value may be sent to');
+ x.grants.set(service, { service, purposes: [...purposes].sort(), origins: [...origins].sort(), created_at: at, created_by: u.subject, last_used_at: null });
+ return json(200, view(key, x));
+ }
+ if (sub === 'grants' && m === 'DELETE') { if (!x.grants.delete(service)) return problem(json, 404, 'secret.grant_not_found', 'no grant'); return res204(json); }
+ }
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) });
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
+'use strict';
+/**
+ * Project secrets in the console (ADR-056): /projects/:project/secrets shows Network's metadata (name, version,
+ * fingerprint, grants) to every member, and the set, delete and grant forms to admins and owners only. A value goes
+ * from the form to Network once and appears nowhere after: not on any page, not in Services' database, not in its logs.
+ * Grants forward the purposes and origins as chosen; Network's refusals are shown as it answered them.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ const owner = t.network.addUser('ona');
+ const admin = t.network.addUser('ada');
+ const viewer = t.network.addUser('vee');
+ const P = await t.project(owner, 'Signed');
+ for (const [who, role] of [['ada', 'admin'], ['vee', 'viewer']]) {
+ assert.strictEqual((await t.get(`/projects/${P}/members`, { as: owner, form: { username: who, role } })).status, 303);
+ }
+ const page = `/projects/${P}/secrets`;
+ const VALUE = `whk_${crypto.randomBytes(24).toString('hex')}`;
+ const stored = () => t.network.state.secrets.get(`${P} sandbox webhook-signing`);
+
+ await check('the project page links its secrets; an empty list says so, with the form for owners only', async () => {
+ assert.ok((await t.get(`/projects/${P}`, { as: viewer })).text.includes(`href="/projects/${P}/secrets"`));
+ const r = await t.get(page, { as: owner });
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /No secrets yet\./);
+ assert.match(r.text, /<h2>Set a secret<\/h2>/);
+ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
+ const v = await t.get(page, { as: viewer });
+ assert.ok(!v.text.includes('<h2>Set a secret</h2>'), 'a viewer gets no form');
+ });
+
+ await check('an owner sets a value: it reaches Network once and appears on no page, table or log line', async () => {
+ const r = await t.get(page, { as: owner, form: { environment: 'sandbox', name: 'webhook-signing', value: VALUE } });
+ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
+ assert.match(r.headers.get('location'), new RegExp(`^/projects/${P}/secrets\\?done=webhook-signing%20saved`));
+ assert.strictEqual(stored().value, VALUE, 'Network got the value');
+ const after = await t.get(page, { as: admin });
+ assert.ok(after.text.includes('webhook-signing') && after.text.includes(stored().fingerprint), 'name and fingerprint shown');
+ assert.ok(!after.text.includes(VALUE), 'never the value');
+ assert.ok(!(await t.dbDump()).includes(VALUE), 'not in Services\' database');
+ assert.ok(!t.logs().includes(VALUE), 'not in its logs');
+ });
+
+ await check('a grant forwards what was chosen; reveal without origins is refused by Network and shown so', async () => {
+ let r = await t.get(`${page}/sandbox/webhook-signing/grants`, { as: admin, form: { service: 'actor', purpose_sign: '1' } });
+ assert.strictEqual(r.status, 303);
+ assert.deepStrictEqual(stored().grants.get('actor'), { ...stored().grants.get('actor'), purposes: ['sign'], origins: [] });
+ assert.match((await t.get(page, { as: viewer })).text, /<code>actor<\/code> sign/);
+ r = await t.get(`${page}/sandbox/webhook-signing/grants`, { as: admin, form: { service: 'watch', purpose_reveal: '1' } });
+ assert.strictEqual(r.status, 422, 'Network refused it');
+ assert.match(r.text, /secret\.invalid/);
+ r = await t.get(`${page}/sandbox/webhook-signing/grants`, { as: admin, form: { service: 'watch', purpose_reveal: '1', origins: 'https://api.example.com\nhttps://b.example.com' } });
+ assert.strictEqual(r.status, 303);
+ assert.deepStrictEqual(stored().grants.get('watch').origins, ['https://api.example.com', 'https://b.example.com']);
+ });
+
+ await check('a viewer cannot set, grant or delete: Network refuses and nothing changes', async () => {
+ const r = await t.get(page, { as: viewer, form: { environment: 'sandbox', name: 'x', value: 'v' } });
+ assert.strictEqual(r.status, 403);
+ assert.ok(!t.network.state.secrets.has(`${P} sandbox x`));
+ });
+
+ await check('revoking a grant and deleting a secret (with the confirmation box)', async () => {
+ assert.strictEqual((await t.get(`${page}/sandbox/webhook-signing/grants/watch/revoke`, { as: admin, form: {} })).status, 303);
+ assert.ok(!stored().grants.has('watch'));
+ assert.strictEqual((await t.get(`${page}/sandbox/webhook-signing/delete`, { as: owner, form: {} })).status, 422, 'unconfirmed');
+ assert.strictEqual((await t.get(`${page}/sandbox/webhook-signing/delete`, { as: owner, form: { confirm: 'yes' } })).status, 303);
+ assert.strictEqual(stored(), undefined);
+ assert.strictEqual((await t.get(`${page}/staging/x/delete`, { as: owner, form: { confirm: 'yes' } })).status, 404, 'a bad environment never reaches Network');
+ });
+
+ await t.close();
+ done();
+})();