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.

OP OpenVibers committed 27 Sept 2026 · c3a64415c7 · 6 files, +361 −6

The runtime that installs it registers it with mods.grant.manage (service token only; Games holds the default grant) and is its owner:
- POST /internal/mods: the manifest (validated as mods.mod-manifest@1) and the subset approved at install, each one requested; the rest stays pending. Registering again answers the existing principal.
- POST /internal/mods/:id/grants: approve or revoke one requested capability.
- POST /internal/mods/:id/revoke: the install ends and every grant is revoked for good.
- GET /internal/mods[/:id].
Another runtime can neither register nor change or see it. Staff with staff.games.manage use /api/admin/mods: a list, grant changes and revoke, each with a written reason and an audit row.

Every change bumps the revision and queues network.mod.grants_changed with the complete approved set, the change, and runtime or staff, so the runtime's own copy follows a staff change. test/mod-principals.test.js is in the chain; npm test passes.

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 6dd6f63f..c8e0e6f6 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.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",
ddiff --git a/package.json b/package.json
iindex e7079e4a..c051a996 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/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",
ddiff --git a/server/identity/mod-principals.js b/server/identity/mod-principals.js
nnew file mode 100644
iindex 00000000..4bb94250
---- /dev/null
++++ b/server/identity/mod-principals.js
@@@ -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 };
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex e0cf24ae..c3cb21cc 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -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) => [
ddiff --git a/server/index.js b/server/index.js
iindex 60f68963..efd87c26 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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.
ddiff --git a/test/mod-principals.test.js b/test/mod-principals.test.js
nnew file mode 100644
iindex 00000000..ac366f5d
---- /dev/null
++++ b/test/mod-principals.test.js
@@@ -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); });