openvibers/openvibe.network Commits
Mod principals (roadmap WS-M task 3, ADR-013; openvibe-contracts 0.71.0 -> 0.72.0). server/identity/mod-principals.js: a mod install is the principal mod:<mod_id>, whose grants are the approved subset of what its manifest requests.
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.71.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.72.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0","uuid": "^11.1.0",@@@ -1465,9 +1465,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.71.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0",-- "integrity": "sha512-BLS6gP/MKuwhvTdWO8UYUaOyhmf7ni0cQ8r7150o57R5TrCnzdWQEVESxfMFCGgSbm2wdKtdZwTID69nxD14Tg==",++ "version": "0.72.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.72.0",++ "integrity": "sha512-g8J8FKbFV259vWll5cz9l9t/RbGu0GXKRBE+/mU4/xvtnEgywlIpJaB2VoIY8nMz989CRDWCGLAt5OedfKnVwQ==","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/operator-alerts.test.js && node test/follows.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/account-merge.test.js && node test/account-data.test.js && node test/status-incidents.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/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.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/developer-export-tokens.test.js && node test/project-usage.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/notification-events.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 && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.test.js && node test/security-secrets.test.js && node test/security-private.test.js && node test/security-idor.test.js && node test/security-ssrf.test.js && node test/deploy-wrapper.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/operator-alerts.test.js && node test/follows.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/account-merge.test.js && node test/account-data.test.js && node test/mod-principals.test.js && node test/status-incidents.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/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.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/developer-export-tokens.test.js && node test/project-usage.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/notification-events.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 && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.test.js && node test/security-secrets.test.js && node test/security-private.test.js && node test/security-idor.test.js && node test/security-ssrf.test.js && node test/deploy-wrapper.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.71.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.72.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0","uuid": "^11.1.0",nnew file mode 100644@@@ -0,0 +1,221 @@++'use strict';++/**++ * Mod principals (roadmap WS-M task 3, ADR-013; Contracts 0.72.0 mods.grant.manage, network.mod-principal@1,++ * network.mod.grants_changed@1).++ *++ * A mod install is a principal, `mod:<mod_id>`, whose grants are the approved subset of the capabilities its++ * manifest requests. The runtime that installs it (OpenVibe.Games) registers it here and is its owner:++ * POST /internal/mods { manifest, approve[], actor }: requested = the manifest's++ * capabilities; approve (each requested) is approved, the rest pending.++ * Registering again answers the existing principal.++ * POST /internal/mods/:mod_id/grants { capability, action: approve|revoke, actor }++ * POST /internal/mods/:mod_id/revoke { actor, reason }: the install ends; every grant is revoked for good++ * GET /internal/mods[?owner=] /:mod_id the owner's principals++ * all with a service token carrying mods.grant.manage; a service only changes the mods it registered.++ * Staff (staff.games.manage) do the same from /api/admin/mods, each change with a written reason and an audit row.++ * Every change bumps the revision and queues network.mod.grants_changed with the complete approved set, so the++ * runtime's own copy (its hot path) follows a change staff made here.++ */++const express = require('express');++const { validate, staff } = require('openvibe-contracts');++const eventRelay = require('../developer/event-relay');++const idsLib = require('openvibe-contracts').ids;++++const MOD_RE = /^mod_[0-9A-HJKMNP-TV-Z]{26}$/;++const CAP_RE = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+){2,}$/;++++class ModPrincipalError extends Error {++ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }++}++++function ensureSchema(db) {++ db.exec(`++ CREATE TABLE IF NOT EXISTS mod_principals (++ mod_id TEXT PRIMARY KEY,++ owner TEXT NOT NULL,++ runtime TEXT NOT NULL,++ name TEXT,++ version TEXT,++ publisher TEXT,++ manifest TEXT NOT NULL,++ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')),++ revision INTEGER NOT NULL DEFAULT 1,++ created_at TEXT NOT NULL,++ created_by TEXT,++ updated_at TEXT NOT NULL,++ revoked_at TEXT,++ revoked_by TEXT++ );++ CREATE TABLE IF NOT EXISTS mod_principal_grants (++ mod_id TEXT NOT NULL,++ capability TEXT NOT NULL,++ state TEXT NOT NULL CHECK (state IN ('pending', 'approved', 'revoked')),++ changed_at TEXT NOT NULL,++ changed_by TEXT,++ PRIMARY KEY (mod_id, capability)++ );++ `);++}++++const iso = (ms) => new Date(ms).toISOString();++++function view(db, modId) {++ const p = db.prepare('SELECT * FROM mod_principals WHERE mod_id = ?').get(modId);++ if (!p) return null;++ const grants = db.prepare('SELECT capability, state FROM mod_principal_grants WHERE mod_id = ? ORDER BY capability').all(modId);++ const by = (s) => grants.filter((g) => g.state === s).map((g) => g.capability);++ const out = {++ principal: `mod:${p.mod_id}`, mod_id: p.mod_id, owner: p.owner, runtime: p.runtime, status: p.status,++ requested: grants.map((g) => g.capability), approved: by('approved'), pending: by('pending'), revoked: by('revoked'),++ revision: p.revision, updated_at: p.updated_at,++ };++ if (p.name) out.name = p.name;++ if (p.version) out.version = p.version;++ return out;++}++++function announce(db, v, change, by, reason) {++ const at = v.updated_at;++ const payload = { mod_id: v.mod_id, owner: v.owner, status: v.status, approved: v.approved, pending: v.pending, revision: v.revision, change, by };++ if (reason) payload.reason = String(reason).slice(0, 500);++ const env = {++ event_id: idsLib.newId('event', Date.parse(at)), event_type: 'network.mod.grants_changed', version: 1, source: 'network',++ actor: by === 'staff' ? { type: 'service', id: 'network' } : { type: 'service', id: v.owner }, timestamp: at, visibility: 'internal',++ subject: { type: 'mod', id: v.mod_id }, payload,++ };++ const ev = validate('events.event-envelope@1', env);++ const pv = validate('network.mod.grants_changed@1', payload);++ if (!ev.valid || !pv.valid) throw new Error(`mod-principals: bad event ${JSON.stringify((ev.errors || []).concat(pv.errors || [])).slice(0, 300)}`);++ eventRelay.writerFor(db).enqueue(env);++}++++function kick(db) { try { const live = eventRelay.outboxFor(db); if (live) live.kick(); } catch { /* the relay polls */ } }++++/** Register an install's principal (idempotent). → { status: 201|200, body: network.mod-principal@1 } */++function register(db, owner, body, { now = Date.now() } = {}) {++ ensureSchema(db);++ const v = validate('network.mod-install-request@1', body);++ if (!v.valid) throw new ModPrincipalError(400, 'mod.invalid_request', JSON.stringify(v.errors).slice(0, 300));++ const mv = validate('mods.mod-manifest@1', body.manifest);++ if (!mv.valid) throw new ModPrincipalError(422, 'mod.manifest_invalid', JSON.stringify(mv.errors).slice(0, 300));++ const m = body.manifest;++ const requested = [...new Set((m.permissions && m.permissions.capabilities) || [])].sort();++ const approve = [...new Set(body.approve || [])];++ const unrequested = approve.filter((c) => !requested.includes(c));++ if (unrequested.length) throw new ModPrincipalError(422, 'mod.not_requested', `not requested by the manifest: ${unrequested.join(', ')}`);++ const existing = db.prepare('SELECT owner FROM mod_principals WHERE mod_id = ?').get(m.id);++ if (existing) {++ if (existing.owner !== owner) throw new ModPrincipalError(409, 'mod.other_owner', 'that mod is registered by another runtime');++ return { status: 200, body: view(db, m.id) };++ }++ const at = iso(now);++ db.transaction(() => {++ db.prepare(`INSERT INTO mod_principals (mod_id, owner, runtime, name, version, publisher, manifest, created_at, created_by, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)++ .run(m.id, owner, m.runtime, m.name || null, m.version || null, m.publisher && m.publisher.id ? String(m.publisher.id).slice(0, 80) : null,++ JSON.stringify(m), at, body.actor ? String(body.actor).slice(0, 80) : owner, at);++ const g = db.prepare('INSERT INTO mod_principal_grants (mod_id, capability, state, changed_at, changed_by) VALUES (?, ?, ?, ?, ?)');++ for (const c of requested) g.run(m.id, c, approve.includes(c) ? 'approved' : 'pending', at, body.actor || owner);++ announce(db, view(db, m.id), { action: 'register' }, 'runtime');++ })();++ kick(db);++ return { status: 201, body: view(db, m.id) };++}++++function load(db, modId, owner) {++ ensureSchema(db);++ const p = MOD_RE.test(String(modId)) ? db.prepare('SELECT * FROM mod_principals WHERE mod_id = ?').get(modId) : null;++ if (!p) throw new ModPrincipalError(404, 'mod.not_found', 'no such mod principal');++ if (owner && p.owner !== owner) throw new ModPrincipalError(403, 'mod.other_owner', 'that mod is registered by another runtime');++ return p;++}++++/** Approve or revoke one capability. by: 'runtime' (owner given) or 'staff'. → network.mod-principal@1 */++function change(db, modId, { capability, action, actor, reason }, { owner = null, by = 'runtime', now = Date.now() } = {}) {++ const v = validate('network.mod-grant-change@1', { capability, action, ...(actor ? { actor: String(actor).slice(0, 80) } : {}), ...(reason ? { reason: String(reason).slice(0, 500) } : {}) });++ if (!v.valid || !CAP_RE.test(String(capability))) throw new ModPrincipalError(400, 'mod.invalid_request', 'capability and action (approve|revoke) are required');++ const p = load(db, modId, owner);++ if (p.status === 'revoked') throw new ModPrincipalError(409, 'mod.revoked', 'a revoked install is never granted anything again');++ const g = db.prepare('SELECT state FROM mod_principal_grants WHERE mod_id = ? AND capability = ?').get(modId, capability);++ if (!g) throw new ModPrincipalError(422, 'mod.not_requested', 'the manifest does not request that capability');++ const to = action === 'approve' ? 'approved' : 'revoked';++ if (g.state === to || (action === 'revoke' && g.state === 'pending')) return view(db, modId);++ const at = iso(now);++ db.transaction(() => {++ db.prepare('UPDATE mod_principal_grants SET state = ?, changed_at = ?, changed_by = ? WHERE mod_id = ? AND capability = ?').run(to, at, actor || by, modId, capability);++ db.prepare('UPDATE mod_principals SET revision = revision + 1, updated_at = ? WHERE mod_id = ?').run(at, modId);++ announce(db, view(db, modId), { action, capability }, by, reason);++ })();++ kick(db);++ return view(db, modId);++}++++/** End the install: every grant revoked, for good. → network.mod-principal@1 */++function revokeAll(db, modId, { actor, reason } = {}, { owner = null, by = 'runtime', now = Date.now() } = {}) {++ const p = load(db, modId, owner);++ if (p.status === 'revoked') return view(db, modId);++ const at = iso(now);++ db.transaction(() => {++ db.prepare("UPDATE mod_principal_grants SET state = 'revoked', changed_at = ?, changed_by = ? WHERE mod_id = ? AND state = 'approved'").run(at, actor || by, modId);++ db.prepare("UPDATE mod_principals SET status = 'revoked', revision = revision + 1, updated_at = ?, revoked_at = ?, revoked_by = ? WHERE mod_id = ?").run(at, at, actor || by, modId);++ announce(db, view(db, modId), { action: 'revoke_all' }, by, reason);++ })();++ kick(db);++ return view(db, modId);++}++++function sendError(res, e) {++ if (e instanceof ModPrincipalError) return res.status(e.status).json({ error: e.code, detail: e.message });++ console.error('[ModPrincipals]', e);++ return res.status(500).json({ error: 'mod.failed', detail: 'that did not work' });++}++++/** Routers: `internal` under /internal/mods (guard: mods.grant.manage), `admin` under /api/admin/mods (staff.games.manage). */++function routers({ guard, requireAuth, staffClaims }) {++ const owner = (req) => String((req.principal && req.principal.sub) || '').replace(/^svc:/, '');++ const internal = express.Router();++ internal.use(guard);++ internal.get('/', (req, res) => {++ const db = req.app.locals.db; ensureSchema(db);++ const rows = db.prepare('SELECT mod_id FROM mod_principals WHERE owner = ? ORDER BY created_at').all(owner(req));++ res.json({ mods: rows.map((r) => view(db, r.mod_id)) });++ });++ internal.get('/:id', (req, res) => { try { load(req.app.locals.db, req.params.id, owner(req)); res.json(view(req.app.locals.db, req.params.id)); } catch (e) { sendError(res, e); } });++ internal.post('/', (req, res) => { try { const out = register(req.app.locals.db, owner(req), req.body || {}); res.status(out.status).json(out.body); } catch (e) { sendError(res, e); } });++ internal.post('/:id/grants', (req, res) => { try { res.json(change(req.app.locals.db, req.params.id, req.body || {}, { owner: owner(req) })); } catch (e) { sendError(res, e); } });++ internal.post('/:id/revoke', (req, res) => { try { res.json(revokeAll(req.app.locals.db, req.params.id, req.body || {}, { owner: owner(req) })); } catch (e) { sendError(res, e); } });++++ const admin = express.Router();++ admin.use(express.json({ limit: '8kb' }));++ const staffOnly = (req, res, next) => (staff.can(staffClaims(req.user), 'staff.games.manage') ? next() : res.status(403).json({ error: 'forbidden', detail: 'staff.games.manage required' }));++ const reasoned = (req, res) => {++ const reason = String((req.body && req.body.reason) || '').trim();++ if (reason.length < 10) { res.status(400).json({ error: 'mod.reason_required', detail: 'a written reason (10 characters or more)' }); return null; }++ return reason.slice(0, 500);++ };++ const audit = (req, action, detail) => req.app.locals.db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, action, JSON.stringify(detail));++ admin.get('/', requireAuth, staffOnly, (req, res) => {++ const db = req.app.locals.db; ensureSchema(db);++ res.set('Cache-Control', 'private, no-store').json({ mods: db.prepare('SELECT mod_id FROM mod_principals ORDER BY updated_at DESC LIMIT 500').all().map((r) => view(db, r.mod_id)) });++ });++ admin.post('/:id/grants', requireAuth, staffOnly, (req, res) => {++ const reason = reasoned(req, res); if (!reason) return;++ const actor = req.user.subject_id || `user:${req.user.id}`;++ try {++ const out = change(req.app.locals.db, req.params.id, { capability: req.body.capability, action: req.body.action, actor, reason }, { by: 'staff' });++ audit(req, 'mod_grant_change', { mod_id: req.params.id, capability: req.body.capability, action: req.body.action, reason });++ res.json(out);++ } catch (e) { sendError(res, e); }++ });++ admin.post('/:id/revoke', requireAuth, staffOnly, (req, res) => {++ const reason = reasoned(req, res); if (!reason) return;++ try {++ const out = revokeAll(req.app.locals.db, req.params.id, { actor: req.user.subject_id || `user:${req.user.id}`, reason }, { by: 'staff' });++ audit(req, 'mod_revoke', { mod_id: req.params.id, reason });++ res.json(out);++ } catch (e) { sendError(res, e); }++ });++ return { internal, admin };++}++++module.exports = { ensureSchema, register, change, revokeAll, view, routers, ModPrincipalError };@@@ -55,6 +55,8 @@ const DEFAULT_GRANTS = [// Media and Tools subscribe to network.user.token_valid_after too (their sign-in refuses older tokens).['media', 'events.subscription.manage', 'openvibe.events', []],['tools', 'events.subscription.manage', 'openvibe.events', []],++ // Mod principals (ADR-013, WS-M task 3): Games registers its mod installs and changes their grants.++ ['games', 'mods.grant.manage', SELF_AUDIENCE, []],// Account export and deletion (ADR-033): the services that keep data about people push their export part and// confirm a deletion; the holders of each grant are the services Network waits for....['live', 'chat', 'community', 'media', 'games'].flatMap((svc) => [@@@ -419,6 +419,16 @@ app.use('/api/v1/me/blocks', rateLimit({ windowMs: 60_000, max: 60 }), require('app.use('/api/v1/account', rateLimit({ windowMs: 60_000, max: 20 }), mergeRouters.me);app.use('/api/admin/account-merges', mergeRouters.admin);}++// Mod principals (roadmap WS-M task 3, ADR-013): the runtime that installs a mod registers mod:<mod_id> and changes++// its grants (mods.grant.manage, service token only); staff (staff.games.manage) from /api/admin/mods.++{++ const modRouters = require('./identity/mod-principals').routers({++ guard: require('./identity/principals').guard('mods.grant.manage', { legacy: false }), requireAuth, staffClaims: require('./auth/staff-claims').staffClaims,++ });++ require('./identity/mod-principals').ensureSchema(db);++ app.use('/internal/mods', modRouters.internal);++ app.use('/api/admin/mods', modRouters.admin);++}// Account export and deletion (roadmap WS-B task 7, ADR-033): the person's export job and scheduled deletion;// services push export parts and confirm deletions with service tokens; staff (staff.users.manage) see what is// outstanding. Archives live next to the database for 7 days.nnew file mode 100644@@@ -0,0 +1,122 @@++'use strict';++// Mod principals (roadmap WS-M task 3, ADR-013; Contracts 0.72.0). A runtime holding mods.grant.manage registers an++// install's principal mod:<mod_id>: the manifest's capabilities are requested, the approved subset approved, the++// rest pending; registering again answers the same principal; another runtime can neither register nor change it.++// Approve and revoke move one capability (only requested ones); revoking the install ends every grant for good.++// Staff (staff.games.manage) change grants with a written reason and an audit row. Every change bumps the revision++// and queues network.mod.grants_changed with the complete approved set.++// node test/mod-principals.test.js++const assert = require('assert');++const crypto = require('crypto');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const express = require('express');++const jwt = require('jsonwebtoken');++const { validate, serviceAuth } = require('openvibe-contracts');++const { initDb } = require('../server/db/database');++const subjects = require('../server/identity/subjects');++++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-mod-principals-'));++const log = console.log; console.log = () => {};++const db = initDb(path.join(dir, 'network.db'));++console.log = log;++for (const c of ['games']) db.prepare("INSERT OR IGNORE INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, 'x', ?, '[]', 1)").run(c, c);++const principals = require('../server/identity/principals');++principals.ensureSchema(db);++const modPrincipals = require('../server/identity/mod-principals');++const ISSUER = 'https://openvibe.network';++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });++const config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };++const svc = (name, cap = ['mods.grant.manage']) => serviceAuth.signServiceToken({ iss: ISSUER, sub: `svc:${name}`, actor_type: 'service', aud: ['openvibe.network'], cap,++ iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, keys.privateKey);++const mk = (name, role) => {++ const id = db.prepare("INSERT INTO users (username, password_hash, role) VALUES (?, 'x', ?)").run(name, role).lastInsertRowid;++ subjects.ensureUserSubject(db, db.prepare('SELECT * FROM users WHERE id = ?').get(id));++ return db.prepare('SELECT * FROM users WHERE id = ?').get(id);++};++const tok = (u) => jwt.sign({ sub: u.id, id: u.id, subject_id: u.subject_id, username: u.username, role: u.role, iat: Math.floor(Date.now() / 1000) - 10 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });++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.mod.grants_changed');++const manifest = JSON.parse(fs.readFileSync(require.resolve('openvibe-contracts/fixtures/mods.mod-manifest/valid/market-stall-1.1.json'), 'utf8'));++++const authRoutes = require('../server/auth/routes');++const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), authRoutes.signToken);++const app = express();++Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });++app.use(express.json());++const r = modPrincipals.routers({ guard: principals.guard('mods.grant.manage', { legacy: false }), requireAuth, staffClaims: require('../server/auth/staff-claims').staffClaims });++app.use('/internal/mods', r.internal);++app.use('/api/admin/mods', r.admin);++const server = http.createServer(app);++++(async () => {++ await new Promise((res) => server.listen(0, '127.0.0.1', res));++ const base = `http://127.0.0.1:${server.address().port}`;++ const call = (method, p, token, body) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: body ? JSON.stringify(body) : undefined })++ .then(async (x) => ({ status: x.status, body: await x.json().catch(() => ({})) }));++ try {++ assert.ok(principals.grantsFor(db, 'games', 'openvibe.network').some((g) => g.capability === 'mods.grant.manage'), 'Games holds mods.grant.manage');++ const games = svc('games');++ const [a, b] = manifest.permissions.capabilities;++++ // ── Register ──++ let x = await call('POST', '/internal/mods', svc('games', ['identity.subject.resolve']), { manifest, approve: [a] });++ assert.strictEqual(x.status, 403, 'a token without mods.grant.manage');++ x = await call('POST', '/internal/mods', games, { manifest, approve: ['games.world.announce'] });++ assert.deepStrictEqual([x.status, x.body.error], [422, 'mod.not_requested']);++ x = await call('POST', '/internal/mods', games, { manifest: { ...manifest, id: 'nope' }, approve: [] });++ assert.deepStrictEqual([x.status, x.body.error], [422, 'mod.manifest_invalid']);++ x = await call('POST', '/internal/mods', games, { manifest, approve: [a], actor: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0' });++ assert.strictEqual(x.status, 201, JSON.stringify(x.body));++ assert.ok(validate('network.mod-principal@1', x.body).valid, JSON.stringify(validate('network.mod-principal@1', x.body).errors));++ assert.deepStrictEqual([x.body.principal, x.body.owner, x.body.approved, x.body.pending, x.body.revision], [`mod:${manifest.id}`, 'games', [a], [b], 1]);++ x = await call('POST', '/internal/mods', games, { manifest, approve: [a, b] });++ assert.deepStrictEqual([x.status, x.body.approved, x.body.revision], [200, [a], 1], 'registering again answers the existing principal');++ x = await call('POST', '/internal/mods', svc('tools'), { manifest, approve: [] });++ assert.deepStrictEqual([x.status, x.body.error], [409, 'mod.other_owner']);++ x = await call('POST', `/internal/mods/${manifest.id}/grants`, svc('tools'), { capability: b, action: 'approve' });++ assert.deepStrictEqual([x.status, x.body.error], [403, 'mod.other_owner'], 'another runtime cannot change it');++ x = await call('GET', '/internal/mods', svc('tools'));++ assert.deepStrictEqual(x.body.mods, [], "another runtime does not see Games' mods");++++ // ── Approve and revoke ──++ x = await call('POST', `/internal/mods/${manifest.id}/grants`, games, { capability: 'games.world.announce', action: 'approve' });++ assert.deepStrictEqual([x.status, x.body.error], [422, 'mod.not_requested']);++ x = await call('POST', `/internal/mods/${manifest.id}/grants`, games, { capability: b, action: 'approve' });++ assert.deepStrictEqual([x.body.approved, x.body.pending, x.body.revision], [[a, b].sort(), [], 2]);++ x = await call('POST', `/internal/mods/${manifest.id}/grants`, games, { capability: b, action: 'approve' });++ assert.strictEqual(x.body.revision, 2, 'no change, no revision');++ x = await call('POST', `/internal/mods/${manifest.id}/grants`, games, { capability: a, action: 'revoke' });++ assert.deepStrictEqual([x.body.approved, x.body.revoked, x.body.revision], [[b], [a], 3]);++ x = await call('GET', `/internal/mods/${manifest.id}`, games);++ assert.strictEqual(x.body.revision, 3);++++ // ── Staff ──++ const boss = mk('boss', 'admin'); const eve = mk('eve', 'user');++ x = await call('GET', '/api/admin/mods', tok(eve));++ assert.strictEqual(x.status, 403);++ x = await call('POST', `/api/admin/mods/${manifest.id}/grants`, tok(boss), { capability: b, action: 'revoke', reason: 'no' });++ assert.deepStrictEqual([x.status, x.body.error], [400, 'mod.reason_required']);++ x = await call('POST', `/api/admin/mods/${manifest.id}/grants`, tok(boss), { capability: b, action: 'revoke', reason: 'placed props in the spawn area' });++ assert.deepStrictEqual([x.status, x.body.approved, x.body.revision], [200, [], 4]);++ assert.ok(db.prepare("SELECT 1 FROM audit_log WHERE action = 'mod_grant_change'").get(), 'audited');++++ // ── Revoke the install ──++ x = await call('POST', `/internal/mods/${manifest.id}/revoke`, games, { actor: 'games', reason: 'the install ended' });++ assert.deepStrictEqual([x.body.status, x.body.approved, x.body.revision], ['revoked', [], 5]);++ x = await call('POST', `/internal/mods/${manifest.id}/grants`, games, { capability: a, action: 'approve' });++ assert.deepStrictEqual([x.status, x.body.error], [409, 'mod.revoked'], 'never granted again');++++ // Every change announced once, with the complete approved set.++ const ev = events();++ assert.deepStrictEqual(ev.map((e) => [e.payload.revision, e.payload.change.action, e.payload.by, e.payload.approved.length]),++ [[1, 'register', 'runtime', 1], [2, 'approve', 'runtime', 2], [3, 'revoke', 'runtime', 1], [4, 'revoke', 'staff', 0], [5, 'revoke_all', 'runtime', 0]]);++ for (const e of ev) assert.ok(validate('network.mod.grants_changed@1', e.payload).valid && e.visibility === 'internal' && e.subject.type === 'mod');++ assert.strictEqual(ev[3].payload.reason, 'placed props in the spawn area');++ } finally {++ server.close();++ fs.rmSync(dir, { recursive: true, force: true });++ }++ console.log('mod principals: all checks passed');++})().catch((e) => { console.error(e); 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.71.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.72.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.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.71.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0",
- "integrity": "sha512-BLS6gP/MKuwhvTdWO8UYUaOyhmf7ni0cQ8r7150o57R5TrCnzdWQEVESxfMFCGgSbm2wdKtdZwTID69nxD14Tg==",
+ "version": "0.72.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.72.0",
+ "integrity": "sha512-g8J8FKbFV259vWll5cz9l9t/RbGu0GXKRBE+/mU4/xvtnEgywlIpJaB2VoIY8nMz989CRDWCGLAt5OedfKnVwQ==",
"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/operator-alerts.test.js && node test/follows.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/account-merge.test.js && node test/account-data.test.js && node test/status-incidents.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/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.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/developer-export-tokens.test.js && node test/project-usage.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/notification-events.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 && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.test.js && node test/security-secrets.test.js && node test/security-private.test.js && node test/security-idor.test.js && node test/security-ssrf.test.js && node test/deploy-wrapper.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/operator-alerts.test.js && node test/follows.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/account-merge.test.js && node test/account-data.test.js && node test/mod-principals.test.js && node test/status-incidents.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/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.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/developer-export-tokens.test.js && node test/project-usage.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/notification-events.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 && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.test.js && node test/security-secrets.test.js && node test/security-private.test.js && node test/security-idor.test.js && node test/security-ssrf.test.js && node test/deploy-wrapper.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.71.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.72.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
new file mode 100644
new file mode 100644
@@ -0,0 +1,221 @@
@@ -0,0 +1,221 @@
+'use strict';
+/**
+ * Mod principals (roadmap WS-M task 3, ADR-013; Contracts 0.72.0 mods.grant.manage, network.mod-principal@1,
+ * network.mod.grants_changed@1).
+ *
+ * A mod install is a principal, `mod:<mod_id>`, whose grants are the approved subset of the capabilities its
+ * manifest requests. The runtime that installs it (OpenVibe.Games) registers it here and is its owner:
+ * POST /internal/mods { manifest, approve[], actor }: requested = the manifest's
+ * capabilities; approve (each requested) is approved, the rest pending.
+ * Registering again answers the existing principal.
+ * POST /internal/mods/:mod_id/grants { capability, action: approve|revoke, actor }
+ * POST /internal/mods/:mod_id/revoke { actor, reason }: the install ends; every grant is revoked for good
+ * GET /internal/mods[?owner=] /:mod_id the owner's principals
+ * all with a service token carrying mods.grant.manage; a service only changes the mods it registered.
+ * Staff (staff.games.manage) do the same from /api/admin/mods, each change with a written reason and an audit row.
+ * Every change bumps the revision and queues network.mod.grants_changed with the complete approved set, so the
+ * runtime's own copy (its hot path) follows a change staff made here.
+ */
+const express = require('express');
+const { validate, staff } = require('openvibe-contracts');
+const eventRelay = require('../developer/event-relay');
+const idsLib = require('openvibe-contracts').ids;
+
+const MOD_RE = /^mod_[0-9A-HJKMNP-TV-Z]{26}$/;
+const CAP_RE = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+){2,}$/;
+
+class ModPrincipalError extends Error {
+ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
+}
+
+function ensureSchema(db) {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS mod_principals (
+ mod_id TEXT PRIMARY KEY,
+ owner TEXT NOT NULL,
+ runtime TEXT NOT NULL,
+ name TEXT,
+ version TEXT,
+ publisher TEXT,
+ manifest TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')),
+ revision INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL,
+ created_by TEXT,
+ updated_at TEXT NOT NULL,
+ revoked_at TEXT,
+ revoked_by TEXT
+ );
+ CREATE TABLE IF NOT EXISTS mod_principal_grants (
+ mod_id TEXT NOT NULL,
+ capability TEXT NOT NULL,
+ state TEXT NOT NULL CHECK (state IN ('pending', 'approved', 'revoked')),
+ changed_at TEXT NOT NULL,
+ changed_by TEXT,
+ PRIMARY KEY (mod_id, capability)
+ );
+ `);
+}
+
+const iso = (ms) => new Date(ms).toISOString();
+
+function view(db, modId) {
+ const p = db.prepare('SELECT * FROM mod_principals WHERE mod_id = ?').get(modId);
+ if (!p) return null;
+ const grants = db.prepare('SELECT capability, state FROM mod_principal_grants WHERE mod_id = ? ORDER BY capability').all(modId);
+ const by = (s) => grants.filter((g) => g.state === s).map((g) => g.capability);
+ const out = {
+ principal: `mod:${p.mod_id}`, mod_id: p.mod_id, owner: p.owner, runtime: p.runtime, status: p.status,
+ requested: grants.map((g) => g.capability), approved: by('approved'), pending: by('pending'), revoked: by('revoked'),
+ revision: p.revision, updated_at: p.updated_at,
+ };
+ if (p.name) out.name = p.name;
+ if (p.version) out.version = p.version;
+ return out;
+}
+
+function announce(db, v, change, by, reason) {
+ const at = v.updated_at;
+ const payload = { mod_id: v.mod_id, owner: v.owner, status: v.status, approved: v.approved, pending: v.pending, revision: v.revision, change, by };
+ if (reason) payload.reason = String(reason).slice(0, 500);
+ const env = {
+ event_id: idsLib.newId('event', Date.parse(at)), event_type: 'network.mod.grants_changed', version: 1, source: 'network',
+ actor: by === 'staff' ? { type: 'service', id: 'network' } : { type: 'service', id: v.owner }, timestamp: at, visibility: 'internal',
+ subject: { type: 'mod', id: v.mod_id }, payload,
+ };
+ const ev = validate('events.event-envelope@1', env);
+ const pv = validate('network.mod.grants_changed@1', payload);
+ if (!ev.valid || !pv.valid) throw new Error(`mod-principals: bad event ${JSON.stringify((ev.errors || []).concat(pv.errors || [])).slice(0, 300)}`);
+ eventRelay.writerFor(db).enqueue(env);
+}
+
+function kick(db) { try { const live = eventRelay.outboxFor(db); if (live) live.kick(); } catch { /* the relay polls */ } }
+
+/** Register an install's principal (idempotent). → { status: 201|200, body: network.mod-principal@1 } */
+function register(db, owner, body, { now = Date.now() } = {}) {
+ ensureSchema(db);
+ const v = validate('network.mod-install-request@1', body);
+ if (!v.valid) throw new ModPrincipalError(400, 'mod.invalid_request', JSON.stringify(v.errors).slice(0, 300));
+ const mv = validate('mods.mod-manifest@1', body.manifest);
+ if (!mv.valid) throw new ModPrincipalError(422, 'mod.manifest_invalid', JSON.stringify(mv.errors).slice(0, 300));
+ const m = body.manifest;
+ const requested = [...new Set((m.permissions && m.permissions.capabilities) || [])].sort();
+ const approve = [...new Set(body.approve || [])];
+ const unrequested = approve.filter((c) => !requested.includes(c));
+ if (unrequested.length) throw new ModPrincipalError(422, 'mod.not_requested', `not requested by the manifest: ${unrequested.join(', ')}`);
+ const existing = db.prepare('SELECT owner FROM mod_principals WHERE mod_id = ?').get(m.id);
+ if (existing) {
+ if (existing.owner !== owner) throw new ModPrincipalError(409, 'mod.other_owner', 'that mod is registered by another runtime');
+ return { status: 200, body: view(db, m.id) };
+ }
+ const at = iso(now);
+ db.transaction(() => {
+ db.prepare(`INSERT INTO mod_principals (mod_id, owner, runtime, name, version, publisher, manifest, created_at, created_by, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
+ .run(m.id, owner, m.runtime, m.name || null, m.version || null, m.publisher && m.publisher.id ? String(m.publisher.id).slice(0, 80) : null,
+ JSON.stringify(m), at, body.actor ? String(body.actor).slice(0, 80) : owner, at);
+ const g = db.prepare('INSERT INTO mod_principal_grants (mod_id, capability, state, changed_at, changed_by) VALUES (?, ?, ?, ?, ?)');
+ for (const c of requested) g.run(m.id, c, approve.includes(c) ? 'approved' : 'pending', at, body.actor || owner);
+ announce(db, view(db, m.id), { action: 'register' }, 'runtime');
+ })();
+ kick(db);
+ return { status: 201, body: view(db, m.id) };
+}
+
+function load(db, modId, owner) {
+ ensureSchema(db);
+ const p = MOD_RE.test(String(modId)) ? db.prepare('SELECT * FROM mod_principals WHERE mod_id = ?').get(modId) : null;
+ if (!p) throw new ModPrincipalError(404, 'mod.not_found', 'no such mod principal');
+ if (owner && p.owner !== owner) throw new ModPrincipalError(403, 'mod.other_owner', 'that mod is registered by another runtime');
+ return p;
+}
+
+/** Approve or revoke one capability. by: 'runtime' (owner given) or 'staff'. → network.mod-principal@1 */
+function change(db, modId, { capability, action, actor, reason }, { owner = null, by = 'runtime', now = Date.now() } = {}) {
+ const v = validate('network.mod-grant-change@1', { capability, action, ...(actor ? { actor: String(actor).slice(0, 80) } : {}), ...(reason ? { reason: String(reason).slice(0, 500) } : {}) });
+ if (!v.valid || !CAP_RE.test(String(capability))) throw new ModPrincipalError(400, 'mod.invalid_request', 'capability and action (approve|revoke) are required');
+ const p = load(db, modId, owner);
+ if (p.status === 'revoked') throw new ModPrincipalError(409, 'mod.revoked', 'a revoked install is never granted anything again');
+ const g = db.prepare('SELECT state FROM mod_principal_grants WHERE mod_id = ? AND capability = ?').get(modId, capability);
+ if (!g) throw new ModPrincipalError(422, 'mod.not_requested', 'the manifest does not request that capability');
+ const to = action === 'approve' ? 'approved' : 'revoked';
+ if (g.state === to || (action === 'revoke' && g.state === 'pending')) return view(db, modId);
+ const at = iso(now);
+ db.transaction(() => {
+ db.prepare('UPDATE mod_principal_grants SET state = ?, changed_at = ?, changed_by = ? WHERE mod_id = ? AND capability = ?').run(to, at, actor || by, modId, capability);
+ db.prepare('UPDATE mod_principals SET revision = revision + 1, updated_at = ? WHERE mod_id = ?').run(at, modId);
+ announce(db, view(db, modId), { action, capability }, by, reason);
+ })();
+ kick(db);
+ return view(db, modId);
+}
+
+/** End the install: every grant revoked, for good. → network.mod-principal@1 */
+function revokeAll(db, modId, { actor, reason } = {}, { owner = null, by = 'runtime', now = Date.now() } = {}) {
+ const p = load(db, modId, owner);
+ if (p.status === 'revoked') return view(db, modId);
+ const at = iso(now);
+ db.transaction(() => {
+ db.prepare("UPDATE mod_principal_grants SET state = 'revoked', changed_at = ?, changed_by = ? WHERE mod_id = ? AND state = 'approved'").run(at, actor || by, modId);
+ db.prepare("UPDATE mod_principals SET status = 'revoked', revision = revision + 1, updated_at = ?, revoked_at = ?, revoked_by = ? WHERE mod_id = ?").run(at, at, actor || by, modId);
+ announce(db, view(db, modId), { action: 'revoke_all' }, by, reason);
+ })();
+ kick(db);
+ return view(db, modId);
+}
+
+function sendError(res, e) {
+ if (e instanceof ModPrincipalError) return res.status(e.status).json({ error: e.code, detail: e.message });
+ console.error('[ModPrincipals]', e);
+ return res.status(500).json({ error: 'mod.failed', detail: 'that did not work' });
+}
+
+/** Routers: `internal` under /internal/mods (guard: mods.grant.manage), `admin` under /api/admin/mods (staff.games.manage). */
+function routers({ guard, requireAuth, staffClaims }) {
+ const owner = (req) => String((req.principal && req.principal.sub) || '').replace(/^svc:/, '');
+ const internal = express.Router();
+ internal.use(guard);
+ internal.get('/', (req, res) => {
+ const db = req.app.locals.db; ensureSchema(db);
+ const rows = db.prepare('SELECT mod_id FROM mod_principals WHERE owner = ? ORDER BY created_at').all(owner(req));
+ res.json({ mods: rows.map((r) => view(db, r.mod_id)) });
+ });
+ internal.get('/:id', (req, res) => { try { load(req.app.locals.db, req.params.id, owner(req)); res.json(view(req.app.locals.db, req.params.id)); } catch (e) { sendError(res, e); } });
+ internal.post('/', (req, res) => { try { const out = register(req.app.locals.db, owner(req), req.body || {}); res.status(out.status).json(out.body); } catch (e) { sendError(res, e); } });
+ internal.post('/:id/grants', (req, res) => { try { res.json(change(req.app.locals.db, req.params.id, req.body || {}, { owner: owner(req) })); } catch (e) { sendError(res, e); } });
+ internal.post('/:id/revoke', (req, res) => { try { res.json(revokeAll(req.app.locals.db, req.params.id, req.body || {}, { owner: owner(req) })); } catch (e) { sendError(res, e); } });
+
+ const admin = express.Router();
+ admin.use(express.json({ limit: '8kb' }));
+ const staffOnly = (req, res, next) => (staff.can(staffClaims(req.user), 'staff.games.manage') ? next() : res.status(403).json({ error: 'forbidden', detail: 'staff.games.manage required' }));
+ const reasoned = (req, res) => {
+ const reason = String((req.body && req.body.reason) || '').trim();
+ if (reason.length < 10) { res.status(400).json({ error: 'mod.reason_required', detail: 'a written reason (10 characters or more)' }); return null; }
+ return reason.slice(0, 500);
+ };
+ const audit = (req, action, detail) => req.app.locals.db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, action, JSON.stringify(detail));
+ admin.get('/', requireAuth, staffOnly, (req, res) => {
+ const db = req.app.locals.db; ensureSchema(db);
+ res.set('Cache-Control', 'private, no-store').json({ mods: db.prepare('SELECT mod_id FROM mod_principals ORDER BY updated_at DESC LIMIT 500').all().map((r) => view(db, r.mod_id)) });
+ });
+ admin.post('/:id/grants', requireAuth, staffOnly, (req, res) => {
+ const reason = reasoned(req, res); if (!reason) return;
+ const actor = req.user.subject_id || `user:${req.user.id}`;
+ try {
+ const out = change(req.app.locals.db, req.params.id, { capability: req.body.capability, action: req.body.action, actor, reason }, { by: 'staff' });
+ audit(req, 'mod_grant_change', { mod_id: req.params.id, capability: req.body.capability, action: req.body.action, reason });
+ res.json(out);
+ } catch (e) { sendError(res, e); }
+ });
+ admin.post('/:id/revoke', requireAuth, staffOnly, (req, res) => {
+ const reason = reasoned(req, res); if (!reason) return;
+ try {
+ const out = revokeAll(req.app.locals.db, req.params.id, { actor: req.user.subject_id || `user:${req.user.id}`, reason }, { by: 'staff' });
+ audit(req, 'mod_revoke', { mod_id: req.params.id, reason });
+ res.json(out);
+ } catch (e) { sendError(res, e); }
+ });
+ return { internal, admin };
+}
+
+module.exports = { ensureSchema, register, change, revokeAll, view, routers, ModPrincipalError };
@@ -55,6 +55,8 @@ const DEFAULT_GRANTS = [
@@ -55,6 +55,8 @@ const DEFAULT_GRANTS = [
// Media and Tools subscribe to network.user.token_valid_after too (their sign-in refuses older tokens).
// Media and Tools subscribe to network.user.token_valid_after too (their sign-in refuses older tokens).
['media', 'events.subscription.manage', 'openvibe.events', []],
['media', 'events.subscription.manage', 'openvibe.events', []],
['tools', 'events.subscription.manage', 'openvibe.events', []],
['tools', 'events.subscription.manage', 'openvibe.events', []],
+ // Mod principals (ADR-013, WS-M task 3): Games registers its mod installs and changes their grants.
+ ['games', 'mods.grant.manage', SELF_AUDIENCE, []],
// Account export and deletion (ADR-033): the services that keep data about people push their export part and
// Account export and deletion (ADR-033): the services that keep data about people push their export part and
// confirm a deletion; the holders of each grant are the services Network waits for.
// confirm a deletion; the holders of each grant are the services Network waits for.
...['live', 'chat', 'community', 'media', 'games'].flatMap((svc) => [
...['live', 'chat', 'community', 'media', 'games'].flatMap((svc) => [
@@ -419,6 +419,16 @@ app.use('/api/v1/me/blocks', rateLimit({ windowMs: 60_000, max: 60 }), require('
@@ -419,6 +419,16 @@ app.use('/api/v1/me/blocks', rateLimit({ windowMs: 60_000, max: 60 }), require('
app.use('/api/v1/account', rateLimit({ windowMs: 60_000, max: 20 }), mergeRouters.me);
app.use('/api/v1/account', rateLimit({ windowMs: 60_000, max: 20 }), mergeRouters.me);
app.use('/api/admin/account-merges', mergeRouters.admin);
app.use('/api/admin/account-merges', mergeRouters.admin);
}
}
+// Mod principals (roadmap WS-M task 3, ADR-013): the runtime that installs a mod registers mod:<mod_id> and changes
+// its grants (mods.grant.manage, service token only); staff (staff.games.manage) from /api/admin/mods.
+{
+ const modRouters = require('./identity/mod-principals').routers({
+ guard: require('./identity/principals').guard('mods.grant.manage', { legacy: false }), requireAuth, staffClaims: require('./auth/staff-claims').staffClaims,
+ });
+ require('./identity/mod-principals').ensureSchema(db);
+ app.use('/internal/mods', modRouters.internal);
+ app.use('/api/admin/mods', modRouters.admin);
+}
// Account export and deletion (roadmap WS-B task 7, ADR-033): the person's export job and scheduled deletion;
// Account export and deletion (roadmap WS-B task 7, ADR-033): the person's export job and scheduled deletion;
// services push export parts and confirm deletions with service tokens; staff (staff.users.manage) see what is
// services push export parts and confirm deletions with service tokens; staff (staff.users.manage) see what is
// outstanding. Archives live next to the database for 7 days.
// outstanding. Archives live next to the database for 7 days.
new file mode 100644
new file mode 100644
@@ -0,0 +1,122 @@
@@ -0,0 +1,122 @@
+'use strict';
+// Mod principals (roadmap WS-M task 3, ADR-013; Contracts 0.72.0). A runtime holding mods.grant.manage registers an
+// install's principal mod:<mod_id>: the manifest's capabilities are requested, the approved subset approved, the
+// rest pending; registering again answers the same principal; another runtime can neither register nor change it.
+// Approve and revoke move one capability (only requested ones); revoking the install ends every grant for good.
+// Staff (staff.games.manage) change grants with a written reason and an audit row. Every change bumps the revision
+// and queues network.mod.grants_changed with the complete approved set.
+// node test/mod-principals.test.js
+const assert = require('assert');
+const crypto = require('crypto');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const express = require('express');
+const jwt = require('jsonwebtoken');
+const { validate, serviceAuth } = require('openvibe-contracts');
+const { initDb } = require('../server/db/database');
+const subjects = require('../server/identity/subjects');
+
+const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-mod-principals-'));
+const log = console.log; console.log = () => {};
+const db = initDb(path.join(dir, 'network.db'));
+console.log = log;
+for (const c of ['games']) db.prepare("INSERT OR IGNORE INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, 'x', ?, '[]', 1)").run(c, c);
+const principals = require('../server/identity/principals');
+principals.ensureSchema(db);
+const modPrincipals = require('../server/identity/mod-principals');
+const ISSUER = 'https://openvibe.network';
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
+const config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };
+const svc = (name, cap = ['mods.grant.manage']) => serviceAuth.signServiceToken({ iss: ISSUER, sub: `svc:${name}`, actor_type: 'service', aud: ['openvibe.network'], cap,
+ iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, keys.privateKey);
+const mk = (name, role) => {
+ const id = db.prepare("INSERT INTO users (username, password_hash, role) VALUES (?, 'x', ?)").run(name, role).lastInsertRowid;
+ subjects.ensureUserSubject(db, db.prepare('SELECT * FROM users WHERE id = ?').get(id));
+ return db.prepare('SELECT * FROM users WHERE id = ?').get(id);
+};
+const tok = (u) => jwt.sign({ sub: u.id, id: u.id, subject_id: u.subject_id, username: u.username, role: u.role, iat: Math.floor(Date.now() / 1000) - 10 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
+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.mod.grants_changed');
+const manifest = JSON.parse(fs.readFileSync(require.resolve('openvibe-contracts/fixtures/mods.mod-manifest/valid/market-stall-1.1.json'), 'utf8'));
+
+const authRoutes = require('../server/auth/routes');
+const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), authRoutes.signToken);
+const app = express();
+Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
+app.use(express.json());
+const r = modPrincipals.routers({ guard: principals.guard('mods.grant.manage', { legacy: false }), requireAuth, staffClaims: require('../server/auth/staff-claims').staffClaims });
+app.use('/internal/mods', r.internal);
+app.use('/api/admin/mods', r.admin);
+const server = http.createServer(app);
+
+(async () => {
+ await new Promise((res) => server.listen(0, '127.0.0.1', res));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const call = (method, p, token, body) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: body ? JSON.stringify(body) : undefined })
+ .then(async (x) => ({ status: x.status, body: await x.json().catch(() => ({})) }));
+ try {
+ assert.ok(principals.grantsFor(db, 'games', 'openvibe.network').some((g) => g.capability === 'mods.grant.manage'), 'Games holds mods.grant.manage');
+ const games = svc('games');
+ const [a, b] = manifest.permissions.capabilities;
+
+ // ── Register ──
+ let x = await call('POST', '/internal/mods', svc('games', ['identity.subject.resolve']), { manifest, approve: [a] });
+ assert.strictEqual(x.status, 403, 'a token without mods.grant.manage');
+ x = await call('POST', '/internal/mods', games, { manifest, approve: ['games.world.announce'] });
+ assert.deepStrictEqual([x.status, x.body.error], [422, 'mod.not_requested']);
+ x = await call('POST', '/internal/mods', games, { manifest: { ...manifest, id: 'nope' }, approve: [] });
+ assert.deepStrictEqual([x.status, x.body.error], [422, 'mod.manifest_invalid']);
+ x = await call('POST', '/internal/mods', games, { manifest, approve: [a], actor: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0' });
+ assert.strictEqual(x.status, 201, JSON.stringify(x.body));
+ assert.ok(validate('network.mod-principal@1', x.body).valid, JSON.stringify(validate('network.mod-principal@1', x.body).errors));
+ assert.deepStrictEqual([x.body.principal, x.body.owner, x.body.approved, x.body.pending, x.body.revision], [`mod:${manifest.id}`, 'games', [a], [b], 1]);
+ x = await call('POST', '/internal/mods', games, { manifest, approve: [a, b] });
+ assert.deepStrictEqual([x.status, x.body.approved, x.body.revision], [200, [a], 1], 'registering again answers the existing principal');
+ x = await call('POST', '/internal/mods', svc('tools'), { manifest, approve: [] });
+ assert.deepStrictEqual([x.status, x.body.error], [409, 'mod.other_owner']);
+ x = await call('POST', `/internal/mods/${manifest.id}/grants`, svc('tools'), { capability: b, action: 'approve' });
+ assert.deepStrictEqual([x.status, x.body.error], [403, 'mod.other_owner'], 'another runtime cannot change it');
+ x = await call('GET', '/internal/mods', svc('tools'));
+ assert.deepStrictEqual(x.body.mods, [], "another runtime does not see Games' mods");
+
+ // ── Approve and revoke ──
+ x = await call('POST', `/internal/mods/${manifest.id}/grants`, games, { capability: 'games.world.announce', action: 'approve' });
+ assert.deepStrictEqual([x.status, x.body.error], [422, 'mod.not_requested']);
+ x = await call('POST', `/internal/mods/${manifest.id}/grants`, games, { capability: b, action: 'approve' });
+ assert.deepStrictEqual([x.body.approved, x.body.pending, x.body.revision], [[a, b].sort(), [], 2]);
+ x = await call('POST', `/internal/mods/${manifest.id}/grants`, games, { capability: b, action: 'approve' });
+ assert.strictEqual(x.body.revision, 2, 'no change, no revision');
+ x = await call('POST', `/internal/mods/${manifest.id}/grants`, games, { capability: a, action: 'revoke' });
+ assert.deepStrictEqual([x.body.approved, x.body.revoked, x.body.revision], [[b], [a], 3]);
+ x = await call('GET', `/internal/mods/${manifest.id}`, games);
+ assert.strictEqual(x.body.revision, 3);
+
+ // ── Staff ──
+ const boss = mk('boss', 'admin'); const eve = mk('eve', 'user');
+ x = await call('GET', '/api/admin/mods', tok(eve));
+ assert.strictEqual(x.status, 403);
+ x = await call('POST', `/api/admin/mods/${manifest.id}/grants`, tok(boss), { capability: b, action: 'revoke', reason: 'no' });
+ assert.deepStrictEqual([x.status, x.body.error], [400, 'mod.reason_required']);
+ x = await call('POST', `/api/admin/mods/${manifest.id}/grants`, tok(boss), { capability: b, action: 'revoke', reason: 'placed props in the spawn area' });
+ assert.deepStrictEqual([x.status, x.body.approved, x.body.revision], [200, [], 4]);
+ assert.ok(db.prepare("SELECT 1 FROM audit_log WHERE action = 'mod_grant_change'").get(), 'audited');
+
+ // ── Revoke the install ──
+ x = await call('POST', `/internal/mods/${manifest.id}/revoke`, games, { actor: 'games', reason: 'the install ended' });
+ assert.deepStrictEqual([x.body.status, x.body.approved, x.body.revision], ['revoked', [], 5]);
+ x = await call('POST', `/internal/mods/${manifest.id}/grants`, games, { capability: a, action: 'approve' });
+ assert.deepStrictEqual([x.status, x.body.error], [409, 'mod.revoked'], 'never granted again');
+
+ // Every change announced once, with the complete approved set.
+ const ev = events();
+ assert.deepStrictEqual(ev.map((e) => [e.payload.revision, e.payload.change.action, e.payload.by, e.payload.approved.length]),
+ [[1, 'register', 'runtime', 1], [2, 'approve', 'runtime', 2], [3, 'revoke', 'runtime', 1], [4, 'revoke', 'staff', 0], [5, 'revoke_all', 'runtime', 0]]);
+ for (const e of ev) assert.ok(validate('network.mod.grants_changed@1', e.payload).valid && e.visibility === 'internal' && e.subject.type === 'mod');
+ assert.strictEqual(ev[3].payload.reason, 'placed props in the spawn area');
+ } finally {
+ server.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ console.log('mod principals: all checks passed');
+})().catch((e) => { console.error(e); process.exit(1); });