openvibers/openvibe.network Commits
Audited service grants and staff APIs (WS-D tasks 3 and 4, Contracts 0.48.0): the owner grants, changes or revokes a service principal's capability at /api/admin/grants (and the admin Service grants tab), always with a reason and optionally an expiry; the audience is the capability owner's; each change writes principal_grant_changes and network.principal_grant.changed in one transaction; an expired grant leaves tokens at once and its expiry is recorded once; the seed stays DEFAULT_GRANTS. GET /api/v1/staff/capabilities (the caller's role and staff capabilities) and GET /api/v1/staff/moderators[?service=] (staff with staff.moderation.logs, or a service token with network.staff.read)
Changes
@@@ -20,7 +20,7 @@"helmet": "^8.1.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.46.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.48.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0","uuid": "^11.1.0",@@@ -1465,9 +1465,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.46.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.46.0",-- "integrity": "sha512-qCCtl46rThCNVjJQLz9pN64ufXqNDKgFc7g41loL7si/EzSScaO79FbJSaM1m4rt6hcMfQvOzerwOaRpsIczkg==",++ "version": "0.48.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.48.0",++ "integrity": "sha512-RhVy8Sn6yej9U96tyvZe+QeDrBiYJypue2l/J8AufkA/t3RFRIjeyDcwCWTD2JXXB9eGcIO9dnKdprR0UNQ60Q==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -6,7 +6,7 @@"scripts": {"start": "node server/index.js","dev": "NODE_ENV=development node server/index.js",-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js",++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/grants-admin.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js","reset-db": "node server/reset-db.js","grant-admin": "node server/grant-admin.js"},@@@ -23,7 +23,7 @@"helmet": "^8.1.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.46.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.48.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0","uuid": "^11.1.0",@@@ -213,6 +213,7 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa<button class="admin-tab-btn" onclick="showTab('vkeys',this)"><i class="fa-solid fa-key"></i> Keys</button><button class="admin-tab-btn" onclick="showTab('audit',this)"><i class="fa-solid fa-clipboard-list"></i> Audit</button><button class="admin-tab-btn" onclick="showTab('modlog',this)"><i class="fa-solid fa-gavel"></i> Moderation log</button>++ <button class="admin-tab-btn owner-only" onclick="showTab('grants',this)"><i class="fa-solid fa-key"></i> Service grants</button><button class="admin-tab-btn" onclick="showTab('analytics',this)"><i class="fa-solid fa-chart-pie"></i> Analytics</button><button class="admin-tab-btn owner-only" onclick="showTab('domains',this)"><i class="fa-solid fa-globe"></i> Domains</button><button class="admin-tab-btn owner-only" onclick="showTab('deploy',this)"><i class="fa-solid fa-server"></i> Deploy</button>@@@ -404,6 +405,26 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa<div style="display:flex;justify-content:center;margin-top:12px"><button id="modlog-more" class="btn" hidden onclick="loadModLog(false)">Load more</button></div></div>++ <!-- Service grants (WS-D task 3): what each service principal may do, owner-only and audited -->++ <div class="section" id="sec-grants">++ <p class="muted" style="margin:0 0 10px">What each service may ask other services for. Tokens carry a change within 5 minutes. Every change needs a reason, is audited below and is announced as <code>network.principal_grant.changed</code>. Defaults come from the code.</p>++ <form id="grant-form" style="display:flex;flex-wrap:wrap;gap:8px;align-items:end;margin-bottom:12px" onsubmit="event.preventDefault(); submitGrant()">++ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Service<input id="grant-client" placeholder="live" maxlength="64" style="width:110px" required></label>++ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Capability<input id="grant-cap" placeholder="network.staff.read" maxlength="120" style="width:210px" required></label>++ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Namespaces<input id="grant-ns" placeholder="comma separated (optional)" maxlength="400" style="width:180px"></label>++ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Expires<input id="grant-exp" type="date"></label>++ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Reason<input id="grant-reason" maxlength="500" style="width:220px" required></label>++ <button class="btn" type="submit">Grant</button>++ </form>++ <div style="display:flex;gap:8px;align-items:center;margin-bottom:8px">++ <label for="grant-filter" class="muted" style="font-size:12px">Show</label>++ <input id="grant-filter" placeholder="service" maxlength="64" style="width:120px" oninput="renderGrants()">++ </div>++ <div id="grants-content"><div class="loading">Loading...</div></div>++ <h3 style="margin:18px 0 8px;font-size:14px">Changes</h3>++ <div id="grant-changes"></div>++ </div>++<!-- Analytics --><div class="section" id="sec-analytics"><div class="sub-tabs" id="analytics-sub-tabs">@@@ -799,7 +820,7 @@ let currentTab = 'dashboard';// Tabs restricted to the network owner (API keys / money / infrastructure / core settings).// NOTE: 'ai' is NOT here — admins may use its analytics/explorer tools; only the// AI configuration form inside it is owner-gated (see loadAi).--const OWNER_ONLY_TABS = new Set(['deploy', 'domains', 'payments', 'cashouts', 'storage', 'email', 'settings']);++const OWNER_ONLY_TABS = new Set(['deploy', 'domains', 'payments', 'cashouts', 'storage', 'email', 'settings', 'grants']);const tabLoaders = {dashboard: loadDashboard,users: loadUsers,@@@ -820,6 +841,7 @@ const tabLoaders = {vkeys: loadVKeys,audit: loadAudit,modlog: () => loadModLog(true),++ grants: () => loadGrants(),analytics: loadAnalytics,deploy: loadDeployOverview,domains: loadDomains,@@@ -3584,6 +3606,42 @@ async function exportModLog() {setTimeout(() => URL.revokeObjectURL(url), 5000);} catch (err) { alert(err.message || 'Export failed'); }}++let _grants = [];++async function loadGrants() {++ const c = document.getElementById('grants-content');++ try {++ const [g, ch] = await Promise.all([api('/api/admin/grants'), api('/api/admin/grants/changes?limit=50')]);++ _grants = g.grants || [];++ renderGrants();++ const rows = (ch.changes || []).map(x => `<tr><td style="white-space:nowrap;font-size:11px" title="${esc(x.at)}">${timeAgo(x.at)}</td><td><strong>${esc(x.change)}</strong></td><td><code>${esc(x.client_id)}</code></td><td><code>${esc(x.capability)}</code></td><td style="font-size:11px">${esc(x.reason || '-')}</td><td style="font-size:11px"><code>${esc(x.actor || 'system')}</code></td></tr>`).join('');++ document.getElementById('grant-changes').innerHTML = rows ? `<table class="admin-table"><thead><tr><th>When</th><th>Change</th><th>Service</th><th>Capability</th><th>Reason</th><th>By</th></tr></thead><tbody>${rows}</tbody></table>` : '<p class="muted">No changes yet: every grant is still a default.</p>';++ } catch (err) {++ c.innerHTML = `<p class="muted">Grants could not be loaded: ${esc(err.message || 'error')}</p>`;++ }++}++function renderGrants() {++ const f = (document.getElementById('grant-filter').value || '').trim().toLowerCase();++ const list = _grants.filter(g => !f || g.client_id.includes(f));++ const badge = { active: 'badge-success', revoked: 'badge-danger', expired: 'badge-warning' };++ document.getElementById('grants-content').innerHTML = list.length ? `<table class="admin-table"><thead><tr><th>Service</th><th>Capability</th><th>Audience</th><th>Namespaces</th><th>State</th><th>Expires</th><th>By</th><th></th></tr></thead><tbody>${list.map(g => `++ <tr><td><code>${esc(g.client_id)}</code></td><td><code>${esc(g.capability)}</code></td><td style="font-size:11px">${esc(g.audience)}</td>++ <td style="font-size:11px">${esc((g.namespaces || []).join(', ') || '-')}</td><td><span class="badge ${badge[g.state] || ''}">${esc(g.state)}</span></td>++ <td style="font-size:11px">${g.expires_at ? esc(g.expires_at.slice(0, 10)) : '-'}</td><td style="font-size:11px">${esc(g.granted_by === 'default' ? 'default' : (g.granted_by || '-'))}</td>++ <td>${g.state === 'active' ? `<button class="btn btn-sm" data-client="${esc(g.client_id)}" data-cap="${esc(g.capability)}" onclick="revokeGrant(this.dataset.client, this.dataset.cap)">Revoke</button>` : ''}</td></tr>`).join('')}</tbody></table>` : '<p class="muted">No grants match.</p>';++}++async function submitGrant() {++ const v = (id) => document.getElementById(id).value.trim();++ const body = { client_id: v('grant-client'), capability: v('grant-cap'), reason: v('grant-reason'), namespaces: v('grant-ns') ? v('grant-ns').split(',').map(x => x.trim()).filter(Boolean) : [] };++ if (v('grant-exp')) body.expires_at = `${v('grant-exp')}T00:00:00Z`;++ try { await api('/api/admin/grants', { method: 'POST', body }); document.getElementById('grant-reason').value = ''; loadGrants(); }++ catch (err) { alert(err.message || 'Grant failed'); }++}++async function revokeGrant(client, capability) {++ const reason = prompt(`Revoke ${capability} from ${client}? Reason:`);++ if (!reason) return;++ try { await api('/api/admin/grants/revoke', { method: 'POST', body: { client_id: client, capability, reason } }); loadGrants(); }++ catch (err) { alert(err.message || 'Revoke failed'); }++}async function loadModLog(reset) {const c = document.getElementById('modlog-content');const more = document.getElementById('modlog-more');nnew file mode 100644@@@ -0,0 +1,59 @@++'use strict';++/**++ * Staff APIs (roadmap WS-D task 4), from the staff capability map in openvibe-contracts:++ *++ * GET /api/v1/staff/capabilities the signed-in person's role, owner flag and staff capabilities++ * GET /api/v1/staff/moderators the network's staff (global moderators, admins, the owner) with++ * their capabilities; ?service=chat|live|community|pastes|calls keeps++ * those who moderate it. For staff holding staff.moderation.logs, or a++ * service token with network.staff.read (Contracts 0.47.0).++ *++ * Role assignment stays PUT /api/admin/users/:id/role (audited; only the owner grants or changes admin).++ */++const express = require('express');++const { staff } = require('openvibe-contracts');++const { staffClaims } = require('../auth/staff-claims');++const { isOwner } = require('../auth/owner-guard');++++// Which staff capability moderates a service; anything else is general content moderation.++const MODERATES = { chat: 'staff.moderation.chat', live: 'staff.moderation.channels', community: 'staff.moderation.discussions', pastes: 'staff.moderation.pastes', calls: 'staff.moderation.calls' };++++function staffList(db, { service = null } = {}) {++ const need = service ? (MODERATES[service] || 'staff.content.moderate') : null;++ return db.prepare("SELECT id, username, display_name, role, subject_id FROM users WHERE role IN ('global_mod', 'admin') AND COALESCE(is_banned, 0) = 0 ORDER BY role DESC, username")++ .all()++ .map((u) => {++ const owner = u.role === 'admin' && isOwner(u);++ return { subject: u.subject_id || null, username: u.username, display_name: u.display_name || u.username, role: owner ? 'owner' : u.role, is_owner: owner, capabilities: staff.capabilitiesOf({ role: u.role, is_owner: owner }) };++ })++ .filter((s) => !need || s.capabilities.includes(need));++}++++function createStaffApi({ db, requireAuth, guard }) {++ const r = express.Router();++ r.get('/capabilities', requireAuth, (req, res) => {++ const claims = staffClaims(req.user);++ res.set('Cache-Control', 'private, no-store');++ res.json({ role: claims.is_owner ? 'owner' : req.user.role || 'user', is_owner: !!claims.is_owner, staff_map: staff.map.version, capabilities: claims.staff_caps || [] });++ });++ const serviceGuard = guard('network.staff.read', { legacy: false });++ const list = (req, res) => {++ const service = typeof req.query.service === 'string' && /^[a-z][a-z0-9_-]{0,31}$/.test(req.query.service) ? req.query.service : null;++ res.set('Cache-Control', 'private, no-store');++ res.json({ service, staff: staffList(db, { service }) });++ };++ r.get('/moderators', (req, res, next) => {++ // A service token (sub svc:*) goes through the capability guard; a person must be staff.++ const bearer = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '');++ let sub = '';++ try { sub = JSON.parse(Buffer.from(bearer.split('.')[1] || '', 'base64url').toString('utf8')).sub || ''; } catch { /* not a JWT */ }++ if (String(sub).startsWith('svc:')) return serviceGuard(req, res, () => list(req, res));++ return requireAuth(req, res, () => {++ if (!staff.can(staffClaims(req.user), 'staff.moderation.logs')) return res.status(403).json({ error: 'forbidden', detail: 'staff.moderation.logs required' });++ return list(req, res);++ });++ });++ return r;++}++++module.exports = { createStaffApi, staffList, MODERATES };nnew file mode 100644@@@ -0,0 +1,207 @@++'use strict';++/**++ * Audited service grants (roadmap WS-D task 3; Contracts 0.48.0 network.principal_grant.changed; a++ * developer app's grant is network.grant.changed, from server/developer/store.js).++ *++ * principal_grants stays the one table tokens are issued from (principals.js grantsFor). DEFAULT_GRANTS++ * remain its seed; every change made here is owner-only, needs a reason, may carry an expiry, writes a++ * principal_grant_changes row and queues network.principal_grant.changed in the same transaction. A service's++ * next token (5 minutes at most) carries the change.++ *++ * GET /api/admin/grants[?client=] every grant with its state (active, revoked, expired)++ * GET /api/admin/grants/changes[?client=] the audit trail, newest first++ * POST /api/admin/grants { client_id, capability, audience?, namespaces?, expires_at?, reason }++ * POST /api/admin/grants/revoke { client_id, capability, audience?, reason }++ *++ * The audience defaults to, and must be, the capability owner's (openvibe.<owner>). Expired grants stop++ * counting at once (grantsFor filters them); expireDue(), every 10 minutes, records each expiry once.++ */++const express = require('express');++const { ids, validate, capabilities } = require('openvibe-contracts');++const eventRelay = require('../developer/event-relay');++const { isOwner } = require('../auth/owner-guard');++++const EVENT_TYPE = 'network.principal_grant.changed';++const CLIENT_RE = /^[a-z][a-z0-9_-]{0,63}$/;++const CAP_RE = /^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/;++const GRANTABLE = new Set(['active', 'planned']);++++class GrantError extends Error {++ constructor(status, message) { super(message); this.status = status; }++}++++function ensureSchema(db) {++ const cols = new Set(db.prepare('PRAGMA table_info(principal_grants)').all().map((c) => c.name));++ if (!cols.has('expires_at')) db.exec('ALTER TABLE principal_grants ADD COLUMN expires_at DATETIME');++ if (!cols.has('reason')) db.exec('ALTER TABLE principal_grants ADD COLUMN reason TEXT');++ if (!cols.has('revoked_by')) db.exec('ALTER TABLE principal_grants ADD COLUMN revoked_by TEXT');++ db.exec(`CREATE TABLE IF NOT EXISTS principal_grant_changes (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ client_id TEXT NOT NULL,++ capability TEXT NOT NULL,++ audience TEXT NOT NULL,++ change TEXT NOT NULL CHECK (change IN ('granted', 'updated', 'revoked', 'expired')),++ namespaces TEXT NOT NULL DEFAULT '[]',++ expires_at TEXT,++ reason TEXT,++ actor TEXT,++ event_id TEXT,++ at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))++ );++ CREATE INDEX IF NOT EXISTS idx_grant_changes_client ON principal_grant_changes(client_id, id);`);++}++++const nowIso = () => new Date().toISOString();++const sqlNow = () => nowIso().replace('T', ' ').slice(0, 19);++const clean = (v, n) => (v == null ? null : String(v).replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, n) || null);++function isoOrNull(v) {++ if (v == null || v === '') return null;++ const d = new Date(String(v));++ if (Number.isNaN(d.getTime())) throw new GrantError(400, 'expires_at is not a date');++ return d.toISOString();++}++// principal_grants keeps SQLite's 'YYYY-MM-DD HH:MM:SS' (UTC) so it compares with CURRENT_TIMESTAMP.++const toSql = (iso) => (iso ? iso.replace('T', ' ').slice(0, 19) : null);++const fromSql = (v) => (v ? new Date(`${String(v).replace(' ', 'T')}Z`).toISOString() : null);++++function stateOf(r, now = sqlNow()) {++ if (r.revoked_at) return r.revoked_by === 'expiry' ? 'expired' : 'revoked';++ if (r.expires_at && r.expires_at <= now) return 'expired';++ return 'active';++}++++function list(db, { client = null } = {}) {++ const rows = client++ ? db.prepare('SELECT * FROM principal_grants WHERE client_id = ? ORDER BY client_id, capability, audience').all(String(client))++ : db.prepare('SELECT * FROM principal_grants ORDER BY client_id, capability, audience').all();++ const now = sqlNow();++ return rows.map((r) => ({++ client_id: r.client_id, capability: r.capability, audience: r.audience, namespaces: JSON.parse(r.namespaces || '[]'),++ state: stateOf(r, now), granted_by: r.granted_by, granted_at: fromSql(r.granted_at), expires_at: fromSql(r.expires_at),++ revoked_at: fromSql(r.revoked_at), revoked_by: r.revoked_by || null, reason: r.reason || null,++ }));++}++++function changes(db, { client = null, limit = 100 } = {}) {++ const n = Math.min(Math.max(parseInt(limit, 10) || 100, 1), 500);++ const rows = client++ ? db.prepare('SELECT * FROM principal_grant_changes WHERE client_id = ? ORDER BY id DESC LIMIT ?').all(String(client), n)++ : db.prepare('SELECT * FROM principal_grant_changes ORDER BY id DESC LIMIT ?').all(n);++ return rows.map((r) => ({ ...r, namespaces: JSON.parse(r.namespaces || '[]') }));++}++++/** The audit row and the event, inside the caller's transaction. */++function record(db, { client_id, capability, audience, change, namespaces, expires_at, reason, actor }) {++ const ms = Date.now();++ const env = {++ event_id: ids.newId('event', ms), event_type: EVENT_TYPE, version: 1, source: 'network',++ actor: actor ? { type: 'user', id: actor } : { type: 'system', id: 'network' },++ timestamp: new Date(ms).toISOString(), visibility: 'internal', priority: 'important',++ subject: { type: 'grant', id: `${client_id}:${capability}@${audience}` },++ payload: { client_id, capability, audience, change, namespaces, expires_at: expires_at || null, reason: reason || null, actor_subject: actor || null },++ };++ const v = validate('events.event-envelope@1', env);++ const pv = validate(`${EVENT_TYPE}@1`, env.payload);++ if (!v.valid || !pv.valid) throw new Error(`grants: bad event: ${JSON.stringify((v.errors || []).concat(pv.errors || [])).slice(0, 300)}`);++ eventRelay.writerFor(db).enqueue(env);++ db.prepare(`INSERT INTO principal_grant_changes (client_id, capability, audience, change, namespaces, expires_at, reason, actor, event_id)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(client_id, capability, audience, change, JSON.stringify(namespaces), expires_at || null, reason || null, actor || null, env.event_id);++}++++function kick(db) { const o = eventRelay.outboxFor(db); if (o) o.kick(); }++++/** Check a request's grant target. → { client_id, capability, audience } */++function target(db, body) {++ const client_id = String(body.client_id || '');++ const capability = String(body.capability || '');++ if (!CLIENT_RE.test(client_id)) throw new GrantError(400, 'client_id is malformed');++ if (!db.prepare('SELECT 1 FROM oauth_clients WHERE client_id = ? AND is_first_party = 1').get(client_id)) throw new GrantError(404, `${client_id} is not a service principal`);++ if (!CAP_RE.test(capability)) throw new GrantError(400, 'capability is malformed');++ const cap = capabilities.get(capability);++ if (!cap) throw new GrantError(400, `${capability} is not in openvibe-contracts`);++ const audience = body.audience ? String(body.audience) : `openvibe.${cap.owner}`;++ if (audience !== `openvibe.${cap.owner}`) throw new GrantError(400, `${capability} is enforced by ${cap.owner}: the audience is openvibe.${cap.owner}`);++ return { client_id, capability, audience, status: cap.status };++}++++function grant(db, body, actor) {++ const t = target(db, body);++ if (!GRANTABLE.has(t.status)) throw new GrantError(400, `${t.capability} is ${t.status}`);++ const reason = clean(body.reason, 500);++ if (!reason || reason.length < 3) throw new GrantError(400, 'a reason is required');++ const namespaces = Array.isArray(body.namespaces) ? [...new Set(body.namespaces.map((n) => String(n).trim()).filter(Boolean))].slice(0, 100) : [];++ if (namespaces.some((n) => !/^[a-z][a-z0-9_.*-]{0,199}$/.test(n))) throw new GrantError(400, 'a namespace is malformed');++ const expires_at = isoOrNull(body.expires_at);++ if (expires_at && expires_at <= nowIso()) throw new GrantError(400, 'expires_at is in the past');++ return db.transaction(() => {++ const prev = db.prepare('SELECT * FROM principal_grants WHERE client_id = ? AND capability = ? AND audience = ?').get(t.client_id, t.capability, t.audience);++ const wasActive = prev && stateOf(prev) === 'active';++ db.prepare(`INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by, granted_at, expires_at, reason, revoked_at, revoked_by)++ VALUES (@client_id, @capability, @audience, @namespaces, @actor, CURRENT_TIMESTAMP, @expires, @reason, NULL, NULL)++ ON CONFLICT(client_id, capability, audience) DO UPDATE SET namespaces = excluded.namespaces, granted_by = excluded.granted_by,++ granted_at = excluded.granted_at, expires_at = excluded.expires_at, reason = excluded.reason, revoked_at = NULL, revoked_by = NULL`)++ .run({ ...t, namespaces: JSON.stringify(namespaces), actor: actor || 'owner', expires: toSql(expires_at), reason });++ const change = wasActive ? 'updated' : 'granted';++ record(db, { ...t, change, namespaces, expires_at, reason, actor });++ return { ...t, change, namespaces, expires_at };++ })();++}++++function revoke(db, body, actor) {++ const t = target(db, body);++ const reason = clean(body.reason, 500);++ if (!reason || reason.length < 3) throw new GrantError(400, 'a reason is required');++ return db.transaction(() => {++ const prev = db.prepare('SELECT * FROM principal_grants WHERE client_id = ? AND capability = ? AND audience = ?').get(t.client_id, t.capability, t.audience);++ if (!prev || stateOf(prev) !== 'active') throw new GrantError(404, 'no active grant to revoke');++ db.prepare("UPDATE principal_grants SET revoked_at = CURRENT_TIMESTAMP, revoked_by = ?, reason = ? WHERE client_id = ? AND capability = ? AND audience = ?")++ .run(actor || 'owner', reason, t.client_id, t.capability, t.audience);++ const namespaces = JSON.parse(prev.namespaces || '[]');++ record(db, { ...t, change: 'revoked', namespaces, expires_at: fromSql(prev.expires_at), reason, actor });++ return { ...t, change: 'revoked' };++ })();++}++++/** Record every grant whose expiry has passed, once. → how many */++function expireDue(db) {++ const due = db.prepare('SELECT * FROM principal_grants WHERE revoked_at IS NULL AND expires_at IS NOT NULL AND expires_at <= CURRENT_TIMESTAMP').all();++ for (const g of due) {++ db.transaction(() => {++ const n = db.prepare("UPDATE principal_grants SET revoked_at = expires_at, revoked_by = 'expiry' WHERE client_id = ? AND capability = ? AND audience = ? AND revoked_at IS NULL")++ .run(g.client_id, g.capability, g.audience).changes;++ if (n) record(db, { client_id: g.client_id, capability: g.capability, audience: g.audience, change: 'expired', namespaces: JSON.parse(g.namespaces || '[]'), expires_at: fromSql(g.expires_at), reason: g.reason || null, actor: null });++ })();++ }++ if (due.length) kick(db);++ return due.length;++}++++function router(db) {++ const r = express.Router();++ r.use((req, res, next) => (req.user && isOwner(req.user) ? next() : res.status(403).json({ ok: false, error: 'Only the owner manages service grants' })));++ const send = (res, fn) => {++ try { res.json({ ok: true, ...fn() }); } catch (err) {++ if (err instanceof GrantError) return res.status(err.status).json({ ok: false, error: err.message });++ console.error('[Grants]', err.message);++ res.status(500).json({ ok: false, error: 'grant change failed' });++ }++ };++ const actorOf = (req) => (/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(req.user.subject_id || '')) ? req.user.subject_id : null);++ r.get('/', (req, res) => { res.set('Cache-Control', 'private, no-store'); send(res, () => ({ grants: list(db, { client: req.query.client }) })); });++ r.get('/changes', (req, res) => { res.set('Cache-Control', 'private, no-store'); send(res, () => ({ changes: changes(db, { client: req.query.client, limit: req.query.limit }) })); });++ r.post('/', (req, res) => send(res, () => { const out = grant(db, req.body || {}, actorOf(req)); kick(db); return { grant: out }; }));++ r.post('/revoke', (req, res) => send(res, () => { const out = revoke(db, req.body || {}, actorOf(req)); kick(db); return { grant: out }; }));++ return r;++}++++let timer = null;++function start(db, { intervalMs = 10 * 60 * 1000 } = {}) {++ if (timer) return;++ const run = () => { try { expireDue(db); } catch (err) { console.warn('[Grants] expiry:', err.message); } };++ timer = setInterval(run, intervalMs);++ if (timer.unref) timer.unref();++ setImmediate(run);++}++function stop() { if (timer) clearInterval(timer); timer = null; }++++module.exports = { ensureSchema, list, changes, grant, revoke, expireDue, router, start, stop, stateOf, GrantError };@@@ -238,10 +238,13 @@ function ensureSchema(db) {seed.run(client, cap, aud, JSON.stringify(ns));if (ns.length) fillNs.run(JSON.stringify(ns), client, cap, aud);}++ // Owner changes (expiry, reason, who revoked) and their audit trail (WS-D task 3).++ require('./grants-admin').ensureSchema(db);}function grantsFor(db, clientId, audience) {-- return db.prepare('SELECT capability, namespaces FROM principal_grants WHERE client_id = ? AND audience = ? AND revoked_at IS NULL ORDER BY capability')++ // An expired grant stops counting at once; grants-admin.expireDue() records the expiry.++ return db.prepare('SELECT capability, namespaces FROM principal_grants WHERE client_id = ? AND audience = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) ORDER BY capability').all(clientId, audience).map(r => ({ capability: r.capability, namespaces: JSON.parse(r.namespaces || '[]') }));}@@@ -384,6 +384,8 @@ const jwt = require('jsonwebtoken');const requireAuth = require('./auth/session').makeRequireAuth(() => ({ db, publicKey, config }), authRoutes.signToken);// The moderation audit log for staff (staff.moderation.logs): server/admin/moderation-audit.js.app.use('/api/v1/staff/moderation-audit', requireAuth, moderationAudit.router());++// Staff capabilities and the staff list (WS-D task 4; services need network.staff.read).++app.use('/api/v1/staff', require('./admin/staff-api').createStaffApi({ db, requireAuth, guard: require('./identity/principals').guard }));// Username history (WS-B task 6): who holds a name now, so sites redirect /@old → /@new and pick up a// new name before they have seen it. Banned accounts and unknown names are 404.app.get('/api/v1/users/names/:name', rateLimit({ windowMs: 60_000, max: 240 }), (req, res) => {@@@ -473,6 +475,7 @@ require('./developer/event-relay').startRelay(db, { eventsUrl: config.eventsInte// network.user.updated (WS-B task 2): profile, role and ban changes, recorded by triggers on users and relayed// through the same outbox (server/identity/profile-events.js).require('./identity/profile-events').start(db);++require('./identity/grants-admin').start(db);// Notification API (authenticated users)app.use('/api/notifications', createNotificationRoutes(db, notificationService, requireAuth));@@@ -554,6 +557,8 @@ app.get('/api/admin/ssh-info', requireAuth, requireAdmin, (req, res) => {app.use('/api/setup', createSetupRoutes(db, config));// Admin panel API++// Service grants: owner-only, audited, network.principal_grant.changed (WS-D task 3).++app.use('/api/admin/grants', requireAuth, require('./identity/grants-admin').router(db));app.use('/api/admin', createAdminRoutes(db, notificationService, emailService, requireAuth));// Analytics admin APInnew file mode 100644@@@ -0,0 +1,117 @@++'use strict';++// Audited service grants (WS-D task 3, Contracts 0.48.0) and the staff APIs (WS-D task 4): only the owner++// grants, changes or revokes a service principal's capability, always with a reason; each change writes an++// audit row and network.principal_grant.changed in the same transaction; an expired grant stops counting++// at once and its expiry is recorded once; tokens (grantsFor) follow. Staff capabilities and the staff++// list come from the staff map.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const express = require('express');++const { validate, capabilities } = require('openvibe-contracts');++const { initDb } = require('../server/db/database');++++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-grants-'));++const log = console.log; console.log = () => {};++const db = initDb(path.join(dir, 'network.db'));++console.log = log;++const principals = require('../server/identity/principals');++const grants = require('../server/identity/grants-admin');++for (const c of ['live', 'media']) db.prepare("INSERT OR IGNORE INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, 'x', ?, '[]', 1)").run(c, c);++db.prepare("INSERT OR IGNORE INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('thirdparty', 'x', 'x', '[]', 0)").run();++principals.ensureSchema(db);++const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const events = () => db.prepare("SELECT envelope FROM network_event_outbox ORDER BY id").all().map((r) => JSON.parse(r.envelope)).filter((e) => e.event_type === 'network.principal_grant.changed');++const has = (client, cap, aud = 'openvibe.network') => principals.grantsFor(db, client, aud).some((g) => g.capability === cap);++const ok = (e) => { assert.ok(validate('events.event-envelope@1', e).valid); const v = validate('network.principal_grant.changed@1', e.payload); assert.ok(v.valid, JSON.stringify(v.errors)); };++++// Every default grant names its capability owner's audience, so any of them can be revoked here.++for (const [client, cap, aud] of principals.DEFAULT_GRANTS) {++ const c = capabilities.get(cap);++ assert.ok(c, `default grant ${client} ${cap} is in openvibe-contracts`);++ assert.strictEqual(aud, `openvibe.${c.owner}`, `default grant ${client} ${cap} → ${aud}`);++}++assert.strictEqual(events().length, 0, 'the seed is not announced');++++// Grant, update, revoke.++assert.ok(!has('live', 'network.staff.read'));++let out = grants.grant(db, { client_id: 'live', capability: 'network.staff.read', reason: 'route reports to moderators', expires_at: '2099-01-01T00:00:00Z' }, OWNER);++assert.deepStrictEqual([out.change, out.audience], ['granted', 'openvibe.network'], 'the audience is the capability owner');++assert.ok(has('live', 'network.staff.read'));++let ev = events().at(-1); ok(ev);++assert.deepStrictEqual(ev.actor, { type: 'user', id: OWNER });++assert.deepStrictEqual(ev.subject, { type: 'grant', id: 'live:[email protected]' });++assert.strictEqual(ev.payload.expires_at, '2099-01-01T00:00:00.000Z');++out = grants.grant(db, { client_id: 'live', capability: 'network.staff.read', reason: 'no expiry after all' }, OWNER);++assert.strictEqual(out.change, 'updated');++grants.revoke(db, { client_id: 'live', capability: 'network.staff.read', reason: 'not needed' }, OWNER);++assert.ok(!has('live', 'network.staff.read'), 'the next token no longer carries it');++assert.deepStrictEqual(events().map((e) => e.payload.change), ['granted', 'updated', 'revoked']);++events().forEach(ok);++assert.throws(() => grants.revoke(db, { client_id: 'live', capability: 'network.staff.read', reason: 'again' }, OWNER), /no active grant/);++assert.deepStrictEqual(grants.changes(db, { client: 'live' }).map((c) => c.change), ['revoked', 'updated', 'granted']);++assert.strictEqual(grants.list(db, { client: 'live' }).find((g) => g.capability === 'network.staff.read').state, 'revoked');++++// Refusals.++const refused = (body, re) => assert.throws(() => grants.grant(db, { reason: 'because', ...body }, OWNER), re);++refused({ client_id: 'nobody', capability: 'network.staff.read' }, /not a service principal/);++refused({ client_id: 'thirdparty', capability: 'network.staff.read' }, /not a service principal/);++refused({ client_id: 'live', capability: 'network.nope.read' }, /not in openvibe-contracts/);++refused({ client_id: 'live', capability: 'Network.Staff' }, /malformed/);++refused({ client_id: 'live', capability: 'network.staff.read', audience: 'openvibe.media' }, /enforced by network/);++refused({ client_id: 'live', capability: 'network.staff.read', expires_at: '2001-01-01' }, /in the past/);++assert.throws(() => grants.grant(db, { client_id: 'live', capability: 'network.staff.read', reason: '' }, OWNER), /reason is required/);++refused({ client_id: 'live', capability: 'network.staff.read', namespaces: ['Bad Namespace'] }, /namespace is malformed/);++const before = events().length;++assert.strictEqual(events().length, before, 'a refused change announces nothing');++++// Expiry: stops counting at once, recorded once.++grants.grant(db, { client_id: 'media', capability: 'network.staff.read', reason: 'a week', expires_at: '2099-01-01' }, OWNER);++db.prepare("UPDATE principal_grants SET expires_at = datetime('now', '-1 minute') WHERE client_id = 'media' AND capability = 'network.staff.read'").run();++assert.ok(!has('media', 'network.staff.read'), 'expired: no longer in tokens, before the job runs');++assert.strictEqual(grants.list(db, { client: 'media' }).find((g) => g.capability === 'network.staff.read').state, 'expired');++assert.strictEqual(grants.expireDue(db), 1);++assert.strictEqual(grants.expireDue(db), 0, 'recorded once');++ev = events().at(-1); ok(ev);++assert.deepStrictEqual([ev.payload.change, ev.payload.actor_subject, ev.actor.type], ['expired', null, 'system']);++++// Routes: owner only.++(async () => {++ const { createStaffApi } = require('../server/admin/staff-api');++ db.prepare("INSERT INTO users (id, username, password_hash, role, subject_id) VALUES (1, 'goosely', 'x', 'admin', ?), (2, 'mod', 'x', 'global_mod', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR'), (3, 'someone', 'x', 'user', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPS')").run(OWNER);++ let as = 1;++ const requireAuth = (req, res, next) => { req.user = db.prepare('SELECT * FROM users WHERE id = ?').get(as); next(); };++ const app = express();++ app.use(express.json());++ app.use('/api/admin/grants', requireAuth, grants.router(db));++ app.use('/api/v1/staff', createStaffApi({ db, requireAuth, guard: () => (req, res) => res.status(401).json({ error: 'service token' }) }));++ const srv = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });++ const base = `http://127.0.0.1:${srv.address().port}`;++ const call = async (method, p, body) => { const r = await fetch(base + p, { method, headers: { 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }); return { status: r.status, body: await r.json() }; };++++ let r = await call('POST', '/api/admin/grants', { client_id: 'live', capability: 'network.staff.read', reason: 'owner via API' });++ assert.strictEqual(r.status, 200); assert.strictEqual(r.body.grant.change, 'granted');++ assert.strictEqual(events().at(-1).payload.actor_subject, OWNER);++ assert.strictEqual((await call('GET', '/api/admin/grants?client=live')).body.grants.find((g) => g.capability === 'network.staff.read').state, 'active');++ assert.strictEqual((await call('POST', '/api/admin/grants/revoke', { client_id: 'live', capability: 'network.staff.read' })).status, 400, 'a reason is required');++ as = 2;++ assert.strictEqual((await call('GET', '/api/admin/grants')).status, 403, 'a global moderator cannot');++ assert.strictEqual((await call('POST', '/api/admin/grants/revoke', { client_id: 'live', capability: 'network.staff.read', reason: 'x y z' })).status, 403);++++ // Staff APIs.++ r = await call('GET', '/api/v1/staff/capabilities');++ assert.strictEqual(r.body.role, 'global_mod'); assert.ok(r.body.capabilities.includes('staff.moderation.logs'));++ r = await call('GET', '/api/v1/staff/moderators?service=chat');++ assert.deepStrictEqual(r.body.staff.map((s) => [s.username, s.role]).sort(), [['goosely', 'owner'], ['mod', 'global_mod']]);++ as = 3;++ assert.deepStrictEqual((await call('GET', '/api/v1/staff/capabilities')).body.capabilities, []);++ assert.strictEqual((await call('GET', '/api/v1/staff/moderators')).status, 403, 'not staff');++ const fake = `x.${Buffer.from(JSON.stringify({ sub: 'svc:live' })).toString('base64url')}.y`;++ const s = await fetch(`${base}/api/v1/staff/moderators`, { headers: { authorization: `Bearer ${fake}` } });++ assert.strictEqual(s.status, 401, 'a service token goes through the capability guard');++ srv.close();++ fs.rmSync(dir, { recursive: true, force: true });++ console.log('grants admin and staff APIs: all checks passed');++})().catch((err) => { console.error(err); process.exit(1); });
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.46.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.48.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
@@ -1465,9 +1465,9 @@
@@ -1465,9 +1465,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.46.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.46.0",
- "integrity": "sha512-qCCtl46rThCNVjJQLz9pN64ufXqNDKgFc7g41loL7si/EzSScaO79FbJSaM1m4rt6hcMfQvOzerwOaRpsIczkg==",
+ "version": "0.48.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.48.0",
+ "integrity": "sha512-RhVy8Sn6yej9U96tyvZe+QeDrBiYJypue2l/J8AufkA/t3RFRIjeyDcwCWTD2JXXB9eGcIO9dnKdprR0UNQ60Q==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js",
+ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/grants-admin.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js",
"reset-db": "node server/reset-db.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.46.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.48.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
@@ -213,6 +213,7 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
@@ -213,6 +213,7 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
<button class="admin-tab-btn" onclick="showTab('vkeys',this)"><i class="fa-solid fa-key"></i> Keys</button>
<button class="admin-tab-btn" onclick="showTab('vkeys',this)"><i class="fa-solid fa-key"></i> Keys</button>
<button class="admin-tab-btn" onclick="showTab('audit',this)"><i class="fa-solid fa-clipboard-list"></i> Audit</button>
<button class="admin-tab-btn" onclick="showTab('audit',this)"><i class="fa-solid fa-clipboard-list"></i> Audit</button>
<button class="admin-tab-btn" onclick="showTab('modlog',this)"><i class="fa-solid fa-gavel"></i> Moderation log</button>
<button class="admin-tab-btn" onclick="showTab('modlog',this)"><i class="fa-solid fa-gavel"></i> Moderation log</button>
+ <button class="admin-tab-btn owner-only" onclick="showTab('grants',this)"><i class="fa-solid fa-key"></i> Service grants</button>
<button class="admin-tab-btn" onclick="showTab('analytics',this)"><i class="fa-solid fa-chart-pie"></i> Analytics</button>
<button class="admin-tab-btn" onclick="showTab('analytics',this)"><i class="fa-solid fa-chart-pie"></i> Analytics</button>
<button class="admin-tab-btn owner-only" onclick="showTab('domains',this)"><i class="fa-solid fa-globe"></i> Domains</button>
<button class="admin-tab-btn owner-only" onclick="showTab('domains',this)"><i class="fa-solid fa-globe"></i> Domains</button>
<button class="admin-tab-btn owner-only" onclick="showTab('deploy',this)"><i class="fa-solid fa-server"></i> Deploy</button>
<button class="admin-tab-btn owner-only" onclick="showTab('deploy',this)"><i class="fa-solid fa-server"></i> Deploy</button>
@@ -404,6 +405,26 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
@@ -404,6 +405,26 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
<div style="display:flex;justify-content:center;margin-top:12px"><button id="modlog-more" class="btn" hidden onclick="loadModLog(false)">Load more</button></div>
<div style="display:flex;justify-content:center;margin-top:12px"><button id="modlog-more" class="btn" hidden onclick="loadModLog(false)">Load more</button></div>
</div>
</div>
+ <!-- Service grants (WS-D task 3): what each service principal may do, owner-only and audited -->
+ <div class="section" id="sec-grants">
+ <p class="muted" style="margin:0 0 10px">What each service may ask other services for. Tokens carry a change within 5 minutes. Every change needs a reason, is audited below and is announced as <code>network.principal_grant.changed</code>. Defaults come from the code.</p>
+ <form id="grant-form" style="display:flex;flex-wrap:wrap;gap:8px;align-items:end;margin-bottom:12px" onsubmit="event.preventDefault(); submitGrant()">
+ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Service<input id="grant-client" placeholder="live" maxlength="64" style="width:110px" required></label>
+ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Capability<input id="grant-cap" placeholder="network.staff.read" maxlength="120" style="width:210px" required></label>
+ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Namespaces<input id="grant-ns" placeholder="comma separated (optional)" maxlength="400" style="width:180px"></label>
+ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Expires<input id="grant-exp" type="date"></label>
+ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Reason<input id="grant-reason" maxlength="500" style="width:220px" required></label>
+ <button class="btn" type="submit">Grant</button>
+ </form>
+ <div style="display:flex;gap:8px;align-items:center;margin-bottom:8px">
+ <label for="grant-filter" class="muted" style="font-size:12px">Show</label>
+ <input id="grant-filter" placeholder="service" maxlength="64" style="width:120px" oninput="renderGrants()">
+ </div>
+ <div id="grants-content"><div class="loading">Loading...</div></div>
+ <h3 style="margin:18px 0 8px;font-size:14px">Changes</h3>
+ <div id="grant-changes"></div>
+ </div>
+
<!-- Analytics -->
<!-- Analytics -->
<div class="section" id="sec-analytics">
<div class="section" id="sec-analytics">
<div class="sub-tabs" id="analytics-sub-tabs">
<div class="sub-tabs" id="analytics-sub-tabs">
@@ -799,7 +820,7 @@ let currentTab = 'dashboard';
@@ -799,7 +820,7 @@ let currentTab = 'dashboard';
// Tabs restricted to the network owner (API keys / money / infrastructure / core settings).
// Tabs restricted to the network owner (API keys / money / infrastructure / core settings).
// NOTE: 'ai' is NOT here — admins may use its analytics/explorer tools; only the
// NOTE: 'ai' is NOT here — admins may use its analytics/explorer tools; only the
// AI configuration form inside it is owner-gated (see loadAi).
// AI configuration form inside it is owner-gated (see loadAi).
-const OWNER_ONLY_TABS = new Set(['deploy', 'domains', 'payments', 'cashouts', 'storage', 'email', 'settings']);
+const OWNER_ONLY_TABS = new Set(['deploy', 'domains', 'payments', 'cashouts', 'storage', 'email', 'settings', 'grants']);
const tabLoaders = {
const tabLoaders = {
dashboard: loadDashboard,
dashboard: loadDashboard,
users: loadUsers,
users: loadUsers,
@@ -820,6 +841,7 @@ const tabLoaders = {
@@ -820,6 +841,7 @@ const tabLoaders = {
vkeys: loadVKeys,
vkeys: loadVKeys,
audit: loadAudit,
audit: loadAudit,
modlog: () => loadModLog(true),
modlog: () => loadModLog(true),
+ grants: () => loadGrants(),
analytics: loadAnalytics,
analytics: loadAnalytics,
deploy: loadDeployOverview,
deploy: loadDeployOverview,
domains: loadDomains,
domains: loadDomains,
@@ -3584,6 +3606,42 @@ async function exportModLog() {
@@ -3584,6 +3606,42 @@ async function exportModLog() {
setTimeout(() => URL.revokeObjectURL(url), 5000);
setTimeout(() => URL.revokeObjectURL(url), 5000);
} catch (err) { alert(err.message || 'Export failed'); }
} catch (err) { alert(err.message || 'Export failed'); }
}
}
+let _grants = [];
+async function loadGrants() {
+ const c = document.getElementById('grants-content');
+ try {
+ const [g, ch] = await Promise.all([api('/api/admin/grants'), api('/api/admin/grants/changes?limit=50')]);
+ _grants = g.grants || [];
+ renderGrants();
+ const rows = (ch.changes || []).map(x => `<tr><td style="white-space:nowrap;font-size:11px" title="${esc(x.at)}">${timeAgo(x.at)}</td><td><strong>${esc(x.change)}</strong></td><td><code>${esc(x.client_id)}</code></td><td><code>${esc(x.capability)}</code></td><td style="font-size:11px">${esc(x.reason || '-')}</td><td style="font-size:11px"><code>${esc(x.actor || 'system')}</code></td></tr>`).join('');
+ document.getElementById('grant-changes').innerHTML = rows ? `<table class="admin-table"><thead><tr><th>When</th><th>Change</th><th>Service</th><th>Capability</th><th>Reason</th><th>By</th></tr></thead><tbody>${rows}</tbody></table>` : '<p class="muted">No changes yet: every grant is still a default.</p>';
+ } catch (err) {
+ c.innerHTML = `<p class="muted">Grants could not be loaded: ${esc(err.message || 'error')}</p>`;
+ }
+}
+function renderGrants() {
+ const f = (document.getElementById('grant-filter').value || '').trim().toLowerCase();
+ const list = _grants.filter(g => !f || g.client_id.includes(f));
+ const badge = { active: 'badge-success', revoked: 'badge-danger', expired: 'badge-warning' };
+ document.getElementById('grants-content').innerHTML = list.length ? `<table class="admin-table"><thead><tr><th>Service</th><th>Capability</th><th>Audience</th><th>Namespaces</th><th>State</th><th>Expires</th><th>By</th><th></th></tr></thead><tbody>${list.map(g => `
+ <tr><td><code>${esc(g.client_id)}</code></td><td><code>${esc(g.capability)}</code></td><td style="font-size:11px">${esc(g.audience)}</td>
+ <td style="font-size:11px">${esc((g.namespaces || []).join(', ') || '-')}</td><td><span class="badge ${badge[g.state] || ''}">${esc(g.state)}</span></td>
+ <td style="font-size:11px">${g.expires_at ? esc(g.expires_at.slice(0, 10)) : '-'}</td><td style="font-size:11px">${esc(g.granted_by === 'default' ? 'default' : (g.granted_by || '-'))}</td>
+ <td>${g.state === 'active' ? `<button class="btn btn-sm" data-client="${esc(g.client_id)}" data-cap="${esc(g.capability)}" onclick="revokeGrant(this.dataset.client, this.dataset.cap)">Revoke</button>` : ''}</td></tr>`).join('')}</tbody></table>` : '<p class="muted">No grants match.</p>';
+}
+async function submitGrant() {
+ const v = (id) => document.getElementById(id).value.trim();
+ const body = { client_id: v('grant-client'), capability: v('grant-cap'), reason: v('grant-reason'), namespaces: v('grant-ns') ? v('grant-ns').split(',').map(x => x.trim()).filter(Boolean) : [] };
+ if (v('grant-exp')) body.expires_at = `${v('grant-exp')}T00:00:00Z`;
+ try { await api('/api/admin/grants', { method: 'POST', body }); document.getElementById('grant-reason').value = ''; loadGrants(); }
+ catch (err) { alert(err.message || 'Grant failed'); }
+}
+async function revokeGrant(client, capability) {
+ const reason = prompt(`Revoke ${capability} from ${client}? Reason:`);
+ if (!reason) return;
+ try { await api('/api/admin/grants/revoke', { method: 'POST', body: { client_id: client, capability, reason } }); loadGrants(); }
+ catch (err) { alert(err.message || 'Revoke failed'); }
+}
async function loadModLog(reset) {
async function loadModLog(reset) {
const c = document.getElementById('modlog-content');
const c = document.getElementById('modlog-content');
const more = document.getElementById('modlog-more');
const more = document.getElementById('modlog-more');
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -0,0 +1,59 @@
+'use strict';
+/**
+ * Staff APIs (roadmap WS-D task 4), from the staff capability map in openvibe-contracts:
+ *
+ * GET /api/v1/staff/capabilities the signed-in person's role, owner flag and staff capabilities
+ * GET /api/v1/staff/moderators the network's staff (global moderators, admins, the owner) with
+ * their capabilities; ?service=chat|live|community|pastes|calls keeps
+ * those who moderate it. For staff holding staff.moderation.logs, or a
+ * service token with network.staff.read (Contracts 0.47.0).
+ *
+ * Role assignment stays PUT /api/admin/users/:id/role (audited; only the owner grants or changes admin).
+ */
+const express = require('express');
+const { staff } = require('openvibe-contracts');
+const { staffClaims } = require('../auth/staff-claims');
+const { isOwner } = require('../auth/owner-guard');
+
+// Which staff capability moderates a service; anything else is general content moderation.
+const MODERATES = { chat: 'staff.moderation.chat', live: 'staff.moderation.channels', community: 'staff.moderation.discussions', pastes: 'staff.moderation.pastes', calls: 'staff.moderation.calls' };
+
+function staffList(db, { service = null } = {}) {
+ const need = service ? (MODERATES[service] || 'staff.content.moderate') : null;
+ return db.prepare("SELECT id, username, display_name, role, subject_id FROM users WHERE role IN ('global_mod', 'admin') AND COALESCE(is_banned, 0) = 0 ORDER BY role DESC, username")
+ .all()
+ .map((u) => {
+ const owner = u.role === 'admin' && isOwner(u);
+ return { subject: u.subject_id || null, username: u.username, display_name: u.display_name || u.username, role: owner ? 'owner' : u.role, is_owner: owner, capabilities: staff.capabilitiesOf({ role: u.role, is_owner: owner }) };
+ })
+ .filter((s) => !need || s.capabilities.includes(need));
+}
+
+function createStaffApi({ db, requireAuth, guard }) {
+ const r = express.Router();
+ r.get('/capabilities', requireAuth, (req, res) => {
+ const claims = staffClaims(req.user);
+ res.set('Cache-Control', 'private, no-store');
+ res.json({ role: claims.is_owner ? 'owner' : req.user.role || 'user', is_owner: !!claims.is_owner, staff_map: staff.map.version, capabilities: claims.staff_caps || [] });
+ });
+ const serviceGuard = guard('network.staff.read', { legacy: false });
+ const list = (req, res) => {
+ const service = typeof req.query.service === 'string' && /^[a-z][a-z0-9_-]{0,31}$/.test(req.query.service) ? req.query.service : null;
+ res.set('Cache-Control', 'private, no-store');
+ res.json({ service, staff: staffList(db, { service }) });
+ };
+ r.get('/moderators', (req, res, next) => {
+ // A service token (sub svc:*) goes through the capability guard; a person must be staff.
+ const bearer = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '');
+ let sub = '';
+ try { sub = JSON.parse(Buffer.from(bearer.split('.')[1] || '', 'base64url').toString('utf8')).sub || ''; } catch { /* not a JWT */ }
+ if (String(sub).startsWith('svc:')) return serviceGuard(req, res, () => list(req, res));
+ return requireAuth(req, res, () => {
+ if (!staff.can(staffClaims(req.user), 'staff.moderation.logs')) return res.status(403).json({ error: 'forbidden', detail: 'staff.moderation.logs required' });
+ return list(req, res);
+ });
+ });
+ return r;
+}
+
+module.exports = { createStaffApi, staffList, MODERATES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,207 @@
@@ -0,0 +1,207 @@
+'use strict';
+/**
+ * Audited service grants (roadmap WS-D task 3; Contracts 0.48.0 network.principal_grant.changed; a
+ * developer app's grant is network.grant.changed, from server/developer/store.js).
+ *
+ * principal_grants stays the one table tokens are issued from (principals.js grantsFor). DEFAULT_GRANTS
+ * remain its seed; every change made here is owner-only, needs a reason, may carry an expiry, writes a
+ * principal_grant_changes row and queues network.principal_grant.changed in the same transaction. A service's
+ * next token (5 minutes at most) carries the change.
+ *
+ * GET /api/admin/grants[?client=] every grant with its state (active, revoked, expired)
+ * GET /api/admin/grants/changes[?client=] the audit trail, newest first
+ * POST /api/admin/grants { client_id, capability, audience?, namespaces?, expires_at?, reason }
+ * POST /api/admin/grants/revoke { client_id, capability, audience?, reason }
+ *
+ * The audience defaults to, and must be, the capability owner's (openvibe.<owner>). Expired grants stop
+ * counting at once (grantsFor filters them); expireDue(), every 10 minutes, records each expiry once.
+ */
+const express = require('express');
+const { ids, validate, capabilities } = require('openvibe-contracts');
+const eventRelay = require('../developer/event-relay');
+const { isOwner } = require('../auth/owner-guard');
+
+const EVENT_TYPE = 'network.principal_grant.changed';
+const CLIENT_RE = /^[a-z][a-z0-9_-]{0,63}$/;
+const CAP_RE = /^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/;
+const GRANTABLE = new Set(['active', 'planned']);
+
+class GrantError extends Error {
+ constructor(status, message) { super(message); this.status = status; }
+}
+
+function ensureSchema(db) {
+ const cols = new Set(db.prepare('PRAGMA table_info(principal_grants)').all().map((c) => c.name));
+ if (!cols.has('expires_at')) db.exec('ALTER TABLE principal_grants ADD COLUMN expires_at DATETIME');
+ if (!cols.has('reason')) db.exec('ALTER TABLE principal_grants ADD COLUMN reason TEXT');
+ if (!cols.has('revoked_by')) db.exec('ALTER TABLE principal_grants ADD COLUMN revoked_by TEXT');
+ db.exec(`CREATE TABLE IF NOT EXISTS principal_grant_changes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ client_id TEXT NOT NULL,
+ capability TEXT NOT NULL,
+ audience TEXT NOT NULL,
+ change TEXT NOT NULL CHECK (change IN ('granted', 'updated', 'revoked', 'expired')),
+ namespaces TEXT NOT NULL DEFAULT '[]',
+ expires_at TEXT,
+ reason TEXT,
+ actor TEXT,
+ event_id TEXT,
+ at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
+ );
+ CREATE INDEX IF NOT EXISTS idx_grant_changes_client ON principal_grant_changes(client_id, id);`);
+}
+
+const nowIso = () => new Date().toISOString();
+const sqlNow = () => nowIso().replace('T', ' ').slice(0, 19);
+const clean = (v, n) => (v == null ? null : String(v).replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, n) || null);
+function isoOrNull(v) {
+ if (v == null || v === '') return null;
+ const d = new Date(String(v));
+ if (Number.isNaN(d.getTime())) throw new GrantError(400, 'expires_at is not a date');
+ return d.toISOString();
+}
+// principal_grants keeps SQLite's 'YYYY-MM-DD HH:MM:SS' (UTC) so it compares with CURRENT_TIMESTAMP.
+const toSql = (iso) => (iso ? iso.replace('T', ' ').slice(0, 19) : null);
+const fromSql = (v) => (v ? new Date(`${String(v).replace(' ', 'T')}Z`).toISOString() : null);
+
+function stateOf(r, now = sqlNow()) {
+ if (r.revoked_at) return r.revoked_by === 'expiry' ? 'expired' : 'revoked';
+ if (r.expires_at && r.expires_at <= now) return 'expired';
+ return 'active';
+}
+
+function list(db, { client = null } = {}) {
+ const rows = client
+ ? db.prepare('SELECT * FROM principal_grants WHERE client_id = ? ORDER BY client_id, capability, audience').all(String(client))
+ : db.prepare('SELECT * FROM principal_grants ORDER BY client_id, capability, audience').all();
+ const now = sqlNow();
+ return rows.map((r) => ({
+ client_id: r.client_id, capability: r.capability, audience: r.audience, namespaces: JSON.parse(r.namespaces || '[]'),
+ state: stateOf(r, now), granted_by: r.granted_by, granted_at: fromSql(r.granted_at), expires_at: fromSql(r.expires_at),
+ revoked_at: fromSql(r.revoked_at), revoked_by: r.revoked_by || null, reason: r.reason || null,
+ }));
+}
+
+function changes(db, { client = null, limit = 100 } = {}) {
+ const n = Math.min(Math.max(parseInt(limit, 10) || 100, 1), 500);
+ const rows = client
+ ? db.prepare('SELECT * FROM principal_grant_changes WHERE client_id = ? ORDER BY id DESC LIMIT ?').all(String(client), n)
+ : db.prepare('SELECT * FROM principal_grant_changes ORDER BY id DESC LIMIT ?').all(n);
+ return rows.map((r) => ({ ...r, namespaces: JSON.parse(r.namespaces || '[]') }));
+}
+
+/** The audit row and the event, inside the caller's transaction. */
+function record(db, { client_id, capability, audience, change, namespaces, expires_at, reason, actor }) {
+ const ms = Date.now();
+ const env = {
+ event_id: ids.newId('event', ms), event_type: EVENT_TYPE, version: 1, source: 'network',
+ actor: actor ? { type: 'user', id: actor } : { type: 'system', id: 'network' },
+ timestamp: new Date(ms).toISOString(), visibility: 'internal', priority: 'important',
+ subject: { type: 'grant', id: `${client_id}:${capability}@${audience}` },
+ payload: { client_id, capability, audience, change, namespaces, expires_at: expires_at || null, reason: reason || null, actor_subject: actor || null },
+ };
+ const v = validate('events.event-envelope@1', env);
+ const pv = validate(`${EVENT_TYPE}@1`, env.payload);
+ if (!v.valid || !pv.valid) throw new Error(`grants: bad event: ${JSON.stringify((v.errors || []).concat(pv.errors || [])).slice(0, 300)}`);
+ eventRelay.writerFor(db).enqueue(env);
+ db.prepare(`INSERT INTO principal_grant_changes (client_id, capability, audience, change, namespaces, expires_at, reason, actor, event_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(client_id, capability, audience, change, JSON.stringify(namespaces), expires_at || null, reason || null, actor || null, env.event_id);
+}
+
+function kick(db) { const o = eventRelay.outboxFor(db); if (o) o.kick(); }
+
+/** Check a request's grant target. → { client_id, capability, audience } */
+function target(db, body) {
+ const client_id = String(body.client_id || '');
+ const capability = String(body.capability || '');
+ if (!CLIENT_RE.test(client_id)) throw new GrantError(400, 'client_id is malformed');
+ if (!db.prepare('SELECT 1 FROM oauth_clients WHERE client_id = ? AND is_first_party = 1').get(client_id)) throw new GrantError(404, `${client_id} is not a service principal`);
+ if (!CAP_RE.test(capability)) throw new GrantError(400, 'capability is malformed');
+ const cap = capabilities.get(capability);
+ if (!cap) throw new GrantError(400, `${capability} is not in openvibe-contracts`);
+ const audience = body.audience ? String(body.audience) : `openvibe.${cap.owner}`;
+ if (audience !== `openvibe.${cap.owner}`) throw new GrantError(400, `${capability} is enforced by ${cap.owner}: the audience is openvibe.${cap.owner}`);
+ return { client_id, capability, audience, status: cap.status };
+}
+
+function grant(db, body, actor) {
+ const t = target(db, body);
+ if (!GRANTABLE.has(t.status)) throw new GrantError(400, `${t.capability} is ${t.status}`);
+ const reason = clean(body.reason, 500);
+ if (!reason || reason.length < 3) throw new GrantError(400, 'a reason is required');
+ const namespaces = Array.isArray(body.namespaces) ? [...new Set(body.namespaces.map((n) => String(n).trim()).filter(Boolean))].slice(0, 100) : [];
+ if (namespaces.some((n) => !/^[a-z][a-z0-9_.*-]{0,199}$/.test(n))) throw new GrantError(400, 'a namespace is malformed');
+ const expires_at = isoOrNull(body.expires_at);
+ if (expires_at && expires_at <= nowIso()) throw new GrantError(400, 'expires_at is in the past');
+ return db.transaction(() => {
+ const prev = db.prepare('SELECT * FROM principal_grants WHERE client_id = ? AND capability = ? AND audience = ?').get(t.client_id, t.capability, t.audience);
+ const wasActive = prev && stateOf(prev) === 'active';
+ db.prepare(`INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by, granted_at, expires_at, reason, revoked_at, revoked_by)
+ VALUES (@client_id, @capability, @audience, @namespaces, @actor, CURRENT_TIMESTAMP, @expires, @reason, NULL, NULL)
+ ON CONFLICT(client_id, capability, audience) DO UPDATE SET namespaces = excluded.namespaces, granted_by = excluded.granted_by,
+ granted_at = excluded.granted_at, expires_at = excluded.expires_at, reason = excluded.reason, revoked_at = NULL, revoked_by = NULL`)
+ .run({ ...t, namespaces: JSON.stringify(namespaces), actor: actor || 'owner', expires: toSql(expires_at), reason });
+ const change = wasActive ? 'updated' : 'granted';
+ record(db, { ...t, change, namespaces, expires_at, reason, actor });
+ return { ...t, change, namespaces, expires_at };
+ })();
+}
+
+function revoke(db, body, actor) {
+ const t = target(db, body);
+ const reason = clean(body.reason, 500);
+ if (!reason || reason.length < 3) throw new GrantError(400, 'a reason is required');
+ return db.transaction(() => {
+ const prev = db.prepare('SELECT * FROM principal_grants WHERE client_id = ? AND capability = ? AND audience = ?').get(t.client_id, t.capability, t.audience);
+ if (!prev || stateOf(prev) !== 'active') throw new GrantError(404, 'no active grant to revoke');
+ db.prepare("UPDATE principal_grants SET revoked_at = CURRENT_TIMESTAMP, revoked_by = ?, reason = ? WHERE client_id = ? AND capability = ? AND audience = ?")
+ .run(actor || 'owner', reason, t.client_id, t.capability, t.audience);
+ const namespaces = JSON.parse(prev.namespaces || '[]');
+ record(db, { ...t, change: 'revoked', namespaces, expires_at: fromSql(prev.expires_at), reason, actor });
+ return { ...t, change: 'revoked' };
+ })();
+}
+
+/** Record every grant whose expiry has passed, once. → how many */
+function expireDue(db) {
+ const due = db.prepare('SELECT * FROM principal_grants WHERE revoked_at IS NULL AND expires_at IS NOT NULL AND expires_at <= CURRENT_TIMESTAMP').all();
+ for (const g of due) {
+ db.transaction(() => {
+ const n = db.prepare("UPDATE principal_grants SET revoked_at = expires_at, revoked_by = 'expiry' WHERE client_id = ? AND capability = ? AND audience = ? AND revoked_at IS NULL")
+ .run(g.client_id, g.capability, g.audience).changes;
+ if (n) record(db, { client_id: g.client_id, capability: g.capability, audience: g.audience, change: 'expired', namespaces: JSON.parse(g.namespaces || '[]'), expires_at: fromSql(g.expires_at), reason: g.reason || null, actor: null });
+ })();
+ }
+ if (due.length) kick(db);
+ return due.length;
+}
+
+function router(db) {
+ const r = express.Router();
+ r.use((req, res, next) => (req.user && isOwner(req.user) ? next() : res.status(403).json({ ok: false, error: 'Only the owner manages service grants' })));
+ const send = (res, fn) => {
+ try { res.json({ ok: true, ...fn() }); } catch (err) {
+ if (err instanceof GrantError) return res.status(err.status).json({ ok: false, error: err.message });
+ console.error('[Grants]', err.message);
+ res.status(500).json({ ok: false, error: 'grant change failed' });
+ }
+ };
+ const actorOf = (req) => (/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(req.user.subject_id || '')) ? req.user.subject_id : null);
+ r.get('/', (req, res) => { res.set('Cache-Control', 'private, no-store'); send(res, () => ({ grants: list(db, { client: req.query.client }) })); });
+ r.get('/changes', (req, res) => { res.set('Cache-Control', 'private, no-store'); send(res, () => ({ changes: changes(db, { client: req.query.client, limit: req.query.limit }) })); });
+ r.post('/', (req, res) => send(res, () => { const out = grant(db, req.body || {}, actorOf(req)); kick(db); return { grant: out }; }));
+ r.post('/revoke', (req, res) => send(res, () => { const out = revoke(db, req.body || {}, actorOf(req)); kick(db); return { grant: out }; }));
+ return r;
+}
+
+let timer = null;
+function start(db, { intervalMs = 10 * 60 * 1000 } = {}) {
+ if (timer) return;
+ const run = () => { try { expireDue(db); } catch (err) { console.warn('[Grants] expiry:', err.message); } };
+ timer = setInterval(run, intervalMs);
+ if (timer.unref) timer.unref();
+ setImmediate(run);
+}
+function stop() { if (timer) clearInterval(timer); timer = null; }
+
+module.exports = { ensureSchema, list, changes, grant, revoke, expireDue, router, start, stop, stateOf, GrantError };
@@ -238,10 +238,13 @@ function ensureSchema(db) {
@@ -238,10 +238,13 @@ function ensureSchema(db) {
seed.run(client, cap, aud, JSON.stringify(ns));
seed.run(client, cap, aud, JSON.stringify(ns));
if (ns.length) fillNs.run(JSON.stringify(ns), client, cap, aud);
if (ns.length) fillNs.run(JSON.stringify(ns), client, cap, aud);
}
}
+ // Owner changes (expiry, reason, who revoked) and their audit trail (WS-D task 3).
+ require('./grants-admin').ensureSchema(db);
}
}
function grantsFor(db, clientId, audience) {
function grantsFor(db, clientId, audience) {
- return db.prepare('SELECT capability, namespaces FROM principal_grants WHERE client_id = ? AND audience = ? AND revoked_at IS NULL ORDER BY capability')
+ // An expired grant stops counting at once; grants-admin.expireDue() records the expiry.
+ return db.prepare('SELECT capability, namespaces FROM principal_grants WHERE client_id = ? AND audience = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) ORDER BY capability')
.all(clientId, audience).map(r => ({ capability: r.capability, namespaces: JSON.parse(r.namespaces || '[]') }));
.all(clientId, audience).map(r => ({ capability: r.capability, namespaces: JSON.parse(r.namespaces || '[]') }));
}
}
@@ -384,6 +384,8 @@ const jwt = require('jsonwebtoken');
@@ -384,6 +384,8 @@ const jwt = require('jsonwebtoken');
const requireAuth = require('./auth/session').makeRequireAuth(() => ({ db, publicKey, config }), authRoutes.signToken);
const requireAuth = require('./auth/session').makeRequireAuth(() => ({ db, publicKey, config }), authRoutes.signToken);
// The moderation audit log for staff (staff.moderation.logs): server/admin/moderation-audit.js.
// The moderation audit log for staff (staff.moderation.logs): server/admin/moderation-audit.js.
app.use('/api/v1/staff/moderation-audit', requireAuth, moderationAudit.router());
app.use('/api/v1/staff/moderation-audit', requireAuth, moderationAudit.router());
+// Staff capabilities and the staff list (WS-D task 4; services need network.staff.read).
+app.use('/api/v1/staff', require('./admin/staff-api').createStaffApi({ db, requireAuth, guard: require('./identity/principals').guard }));
// Username history (WS-B task 6): who holds a name now, so sites redirect /@old → /@new and pick up a
// Username history (WS-B task 6): who holds a name now, so sites redirect /@old → /@new and pick up a
// new name before they have seen it. Banned accounts and unknown names are 404.
// new name before they have seen it. Banned accounts and unknown names are 404.
app.get('/api/v1/users/names/:name', rateLimit({ windowMs: 60_000, max: 240 }), (req, res) => {
app.get('/api/v1/users/names/:name', rateLimit({ windowMs: 60_000, max: 240 }), (req, res) => {
@@ -473,6 +475,7 @@ require('./developer/event-relay').startRelay(db, { eventsUrl: config.eventsInte
@@ -473,6 +475,7 @@ require('./developer/event-relay').startRelay(db, { eventsUrl: config.eventsInte
// network.user.updated (WS-B task 2): profile, role and ban changes, recorded by triggers on users and relayed
// network.user.updated (WS-B task 2): profile, role and ban changes, recorded by triggers on users and relayed
// through the same outbox (server/identity/profile-events.js).
// through the same outbox (server/identity/profile-events.js).
require('./identity/profile-events').start(db);
require('./identity/profile-events').start(db);
+require('./identity/grants-admin').start(db);
// Notification API (authenticated users)
// Notification API (authenticated users)
app.use('/api/notifications', createNotificationRoutes(db, notificationService, requireAuth));
app.use('/api/notifications', createNotificationRoutes(db, notificationService, requireAuth));
@@ -554,6 +557,8 @@ app.get('/api/admin/ssh-info', requireAuth, requireAdmin, (req, res) => {
@@ -554,6 +557,8 @@ app.get('/api/admin/ssh-info', requireAuth, requireAdmin, (req, res) => {
app.use('/api/setup', createSetupRoutes(db, config));
app.use('/api/setup', createSetupRoutes(db, config));
// Admin panel API
// Admin panel API
+// Service grants: owner-only, audited, network.principal_grant.changed (WS-D task 3).
+app.use('/api/admin/grants', requireAuth, require('./identity/grants-admin').router(db));
app.use('/api/admin', createAdminRoutes(db, notificationService, emailService, requireAuth));
app.use('/api/admin', createAdminRoutes(db, notificationService, emailService, requireAuth));
// Analytics admin API
// Analytics admin API
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -0,0 +1,117 @@
+'use strict';
+// Audited service grants (WS-D task 3, Contracts 0.48.0) and the staff APIs (WS-D task 4): only the owner
+// grants, changes or revokes a service principal's capability, always with a reason; each change writes an
+// audit row and network.principal_grant.changed in the same transaction; an expired grant stops counting
+// at once and its expiry is recorded once; tokens (grantsFor) follow. Staff capabilities and the staff
+// list come from the staff map.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const express = require('express');
+const { validate, capabilities } = require('openvibe-contracts');
+const { initDb } = require('../server/db/database');
+
+const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-grants-'));
+const log = console.log; console.log = () => {};
+const db = initDb(path.join(dir, 'network.db'));
+console.log = log;
+const principals = require('../server/identity/principals');
+const grants = require('../server/identity/grants-admin');
+for (const c of ['live', 'media']) db.prepare("INSERT OR IGNORE INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, 'x', ?, '[]', 1)").run(c, c);
+db.prepare("INSERT OR IGNORE INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('thirdparty', 'x', 'x', '[]', 0)").run();
+principals.ensureSchema(db);
+const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const events = () => db.prepare("SELECT envelope FROM network_event_outbox ORDER BY id").all().map((r) => JSON.parse(r.envelope)).filter((e) => e.event_type === 'network.principal_grant.changed');
+const has = (client, cap, aud = 'openvibe.network') => principals.grantsFor(db, client, aud).some((g) => g.capability === cap);
+const ok = (e) => { assert.ok(validate('events.event-envelope@1', e).valid); const v = validate('network.principal_grant.changed@1', e.payload); assert.ok(v.valid, JSON.stringify(v.errors)); };
+
+// Every default grant names its capability owner's audience, so any of them can be revoked here.
+for (const [client, cap, aud] of principals.DEFAULT_GRANTS) {
+ const c = capabilities.get(cap);
+ assert.ok(c, `default grant ${client} ${cap} is in openvibe-contracts`);
+ assert.strictEqual(aud, `openvibe.${c.owner}`, `default grant ${client} ${cap} → ${aud}`);
+}
+assert.strictEqual(events().length, 0, 'the seed is not announced');
+
+// Grant, update, revoke.
+assert.ok(!has('live', 'network.staff.read'));
+let out = grants.grant(db, { client_id: 'live', capability: 'network.staff.read', reason: 'route reports to moderators', expires_at: '2099-01-01T00:00:00Z' }, OWNER);
+assert.deepStrictEqual([out.change, out.audience], ['granted', 'openvibe.network'], 'the audience is the capability owner');
+assert.ok(has('live', 'network.staff.read'));
+let ev = events().at(-1); ok(ev);
+assert.deepStrictEqual(ev.actor, { type: 'user', id: OWNER });
+assert.deepStrictEqual(ev.subject, { type: 'grant', id: 'live:[email protected]' });
+assert.strictEqual(ev.payload.expires_at, '2099-01-01T00:00:00.000Z');
+out = grants.grant(db, { client_id: 'live', capability: 'network.staff.read', reason: 'no expiry after all' }, OWNER);
+assert.strictEqual(out.change, 'updated');
+grants.revoke(db, { client_id: 'live', capability: 'network.staff.read', reason: 'not needed' }, OWNER);
+assert.ok(!has('live', 'network.staff.read'), 'the next token no longer carries it');
+assert.deepStrictEqual(events().map((e) => e.payload.change), ['granted', 'updated', 'revoked']);
+events().forEach(ok);
+assert.throws(() => grants.revoke(db, { client_id: 'live', capability: 'network.staff.read', reason: 'again' }, OWNER), /no active grant/);
+assert.deepStrictEqual(grants.changes(db, { client: 'live' }).map((c) => c.change), ['revoked', 'updated', 'granted']);
+assert.strictEqual(grants.list(db, { client: 'live' }).find((g) => g.capability === 'network.staff.read').state, 'revoked');
+
+// Refusals.
+const refused = (body, re) => assert.throws(() => grants.grant(db, { reason: 'because', ...body }, OWNER), re);
+refused({ client_id: 'nobody', capability: 'network.staff.read' }, /not a service principal/);
+refused({ client_id: 'thirdparty', capability: 'network.staff.read' }, /not a service principal/);
+refused({ client_id: 'live', capability: 'network.nope.read' }, /not in openvibe-contracts/);
+refused({ client_id: 'live', capability: 'Network.Staff' }, /malformed/);
+refused({ client_id: 'live', capability: 'network.staff.read', audience: 'openvibe.media' }, /enforced by network/);
+refused({ client_id: 'live', capability: 'network.staff.read', expires_at: '2001-01-01' }, /in the past/);
+assert.throws(() => grants.grant(db, { client_id: 'live', capability: 'network.staff.read', reason: '' }, OWNER), /reason is required/);
+refused({ client_id: 'live', capability: 'network.staff.read', namespaces: ['Bad Namespace'] }, /namespace is malformed/);
+const before = events().length;
+assert.strictEqual(events().length, before, 'a refused change announces nothing');
+
+// Expiry: stops counting at once, recorded once.
+grants.grant(db, { client_id: 'media', capability: 'network.staff.read', reason: 'a week', expires_at: '2099-01-01' }, OWNER);
+db.prepare("UPDATE principal_grants SET expires_at = datetime('now', '-1 minute') WHERE client_id = 'media' AND capability = 'network.staff.read'").run();
+assert.ok(!has('media', 'network.staff.read'), 'expired: no longer in tokens, before the job runs');
+assert.strictEqual(grants.list(db, { client: 'media' }).find((g) => g.capability === 'network.staff.read').state, 'expired');
+assert.strictEqual(grants.expireDue(db), 1);
+assert.strictEqual(grants.expireDue(db), 0, 'recorded once');
+ev = events().at(-1); ok(ev);
+assert.deepStrictEqual([ev.payload.change, ev.payload.actor_subject, ev.actor.type], ['expired', null, 'system']);
+
+// Routes: owner only.
+(async () => {
+ const { createStaffApi } = require('../server/admin/staff-api');
+ db.prepare("INSERT INTO users (id, username, password_hash, role, subject_id) VALUES (1, 'goosely', 'x', 'admin', ?), (2, 'mod', 'x', 'global_mod', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR'), (3, 'someone', 'x', 'user', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPS')").run(OWNER);
+ let as = 1;
+ const requireAuth = (req, res, next) => { req.user = db.prepare('SELECT * FROM users WHERE id = ?').get(as); next(); };
+ const app = express();
+ app.use(express.json());
+ app.use('/api/admin/grants', requireAuth, grants.router(db));
+ app.use('/api/v1/staff', createStaffApi({ db, requireAuth, guard: () => (req, res) => res.status(401).json({ error: 'service token' }) }));
+ const srv = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
+ const base = `http://127.0.0.1:${srv.address().port}`;
+ const call = async (method, p, body) => { const r = await fetch(base + p, { method, headers: { 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }); return { status: r.status, body: await r.json() }; };
+
+ let r = await call('POST', '/api/admin/grants', { client_id: 'live', capability: 'network.staff.read', reason: 'owner via API' });
+ assert.strictEqual(r.status, 200); assert.strictEqual(r.body.grant.change, 'granted');
+ assert.strictEqual(events().at(-1).payload.actor_subject, OWNER);
+ assert.strictEqual((await call('GET', '/api/admin/grants?client=live')).body.grants.find((g) => g.capability === 'network.staff.read').state, 'active');
+ assert.strictEqual((await call('POST', '/api/admin/grants/revoke', { client_id: 'live', capability: 'network.staff.read' })).status, 400, 'a reason is required');
+ as = 2;
+ assert.strictEqual((await call('GET', '/api/admin/grants')).status, 403, 'a global moderator cannot');
+ assert.strictEqual((await call('POST', '/api/admin/grants/revoke', { client_id: 'live', capability: 'network.staff.read', reason: 'x y z' })).status, 403);
+
+ // Staff APIs.
+ r = await call('GET', '/api/v1/staff/capabilities');
+ assert.strictEqual(r.body.role, 'global_mod'); assert.ok(r.body.capabilities.includes('staff.moderation.logs'));
+ r = await call('GET', '/api/v1/staff/moderators?service=chat');
+ assert.deepStrictEqual(r.body.staff.map((s) => [s.username, s.role]).sort(), [['goosely', 'owner'], ['mod', 'global_mod']]);
+ as = 3;
+ assert.deepStrictEqual((await call('GET', '/api/v1/staff/capabilities')).body.capabilities, []);
+ assert.strictEqual((await call('GET', '/api/v1/staff/moderators')).status, 403, 'not staff');
+ const fake = `x.${Buffer.from(JSON.stringify({ sub: 'svc:live' })).toString('base64url')}.y`;
+ const s = await fetch(`${base}/api/v1/staff/moderators`, { headers: { authorization: `Bearer ${fake}` } });
+ assert.strictEqual(s.status, 401, 'a service token goes through the capability guard');
+ srv.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ console.log('grants admin and staff APIs: all checks passed');
+})().catch((err) => { console.error(err); process.exit(1); });