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)

OP OpenVibers committed 25 Sept 2026 · 8b48007729 · 8 files, +457 −8


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex ac6ad415..929c34e6 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex c22c4349..baf74d06 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/public/admin.html b/public/admin.html
iindex 22b4e21b..fbaffc45 100644
---- a/public/admin.html
++++ b/public/admin.html
@@@ -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');
ddiff --git a/server/admin/staff-api.js b/server/admin/staff-api.js
nnew file mode 100644
iindex 00000000..c0e57d03
---- /dev/null
++++ b/server/admin/staff-api.js
@@@ -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 };
ddiff --git a/server/identity/grants-admin.js b/server/identity/grants-admin.js
nnew file mode 100644
iindex 00000000..95752c5c
---- /dev/null
++++ b/server/identity/grants-admin.js
@@@ -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 };
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 98a59fd4..8e437141 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -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 || '[]') }));
}
ddiff --git a/server/index.js b/server/index.js
iindex 6f6eba22..a1f2cea5 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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 API
ddiff --git a/test/grants-admin.test.js b/test/grants-admin.test.js
nnew file mode 100644
iindex 00000000..d22863fe
---- /dev/null
++++ b/test/grants-admin.test.js
@@@ -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); });