openvibers/openvibe.network Commits

Add the owner API for a person's own machines (#18)

OP OpenVibers committed 2 Oct 2026 · d0efb560ee · 3 files, +222 −9

local checks ✓ 3/3 · CI ✓ 3/3 · review SHIP by DeepSeek harness

https://github.com/OpenVibers/OpenVibe.Network/pull/18

Changes

ddiff --git a/server/index.js b/server/index.js
iindex cbed9888..d2f8cf4d 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -496,6 +496,8 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
const pairingRouters = require('./registry/node-principals').routers({ guard: require('./identity/principals').guard('network.node.manage') });
app.use('/api/v1/node-pairing', rateLimit({ windowMs: 60_000, max: 10 }), pairingRouters.pairing);
app.use('/internal', pairingRouters.internal);
++ // The person's own machines (slice N5): list and revoke, session only, same limit as /api/v1/me/blocks.
++ app.use('/api/v1/me/nodes', rateLimit({ windowMs: 60_000, max: 60 }), require('./registry/node-principals').userRouter(requireAuth));
}
app.get('/internal/blocks', require('./identity/principals').guard('network.blocks.read'), require('./identity/blocks').internalHandler(db));
// The follow graph (WS-E task 4, ADR-030): public counts, a person's own follows, and who follows a target
ddiff --git a/server/registry/node-principals.js b/server/registry/node-principals.js
iindex be53bb52..4021c860 100644
---- a/server/registry/node-principals.js
++++ b/server/registry/node-principals.js
@@@ -13,6 +13,8 @@
* POST /api/v1/node-pairing none (the code) → 201 {principal, node_id, home_cell, credential, …}
* GET /internal/node-principals/:id network.node.manage → the principal, if the caller paired it
* POST /internal/node-principals/:id/revoke network.node.manage → the principal, revoked (idempotent)
++ * GET /api/v1/me/nodes session → the person's own machines, newest first
++ * POST /api/v1/me/nodes/:id/revoke session → their principal, revoked (idempotent)
*/
const crypto = require('crypto');
const express = require('express');
@@@ -159,19 +161,76 @@ async function redeem(db, body, now = Date.now()) {
return result;
}
--/** A principal `service` paired, or null (another service's principal is indistinguishable from none). */
--async function pairedBy(db, id, service) {
++/**
++ * The principal `id` matching a caller-owned scope (`where` + `params`, literals only), or null. A principal
++ * another caller owns is indistinguishable from none: the routes answer 404, never 403, so nobody can probe
++ * whose machine an id is.
++ */
++async function byScope(db, id, scope) {
if (!PRINCIPAL_ID.test(String(id))) return null;
-- return await db.prepare('SELECT * FROM platform_node_principals WHERE id = ? AND paired_by_service = ?').get(String(id), service) || null;
++ return await db.prepare(`SELECT * FROM platform_node_principals WHERE id = ? AND ${scope.where}`).get(String(id), ...scope.params) || null;
}
--/** Revoke a principal `service` paired; a second revoke changes nothing. → the row, or null. */
--async function revoke(db, id, service, now = Date.now()) {
-- if (!await pairedBy(db, id, service)) return null;
++/** A principal `service` paired, or null (another service's principal is indistinguishable from none). */
++const pairedBy = (db, id, service) => byScope(db, id, { where: 'paired_by_service = ?', params: [service] });
++
++/**
++ * Revoke a principal matching a caller-owned scope; a second revoke changes nothing. → the row, or null.
++ * The previous credential and its grace window are cleared. `credential_hash` stays: migrations/0014's
++ * platform_node_principals_credential CHECK requires one for a user-owned principal, and a revoked row cannot
++ * authenticate because every reader requires status = 'active'. One function, so the service route and the
++ * person's own route revoke identically.
++ */
++async function revokeScoped(db, id, scope, revokedBy, now = Date.now()) {
++ if (!await byScope(db, id, scope)) return null;
const at = iso(now);
await db.prepare(`UPDATE platform_node_principals SET status = 'revoked', revoked_at = ?, revoked_by = ?, credential_prev_hash = NULL,
-- prev_valid_until = NULL, updated_at = ? WHERE id = ? AND paired_by_service = ? AND status <> 'revoked'`).run(at, `svc:${service}`, at, String(id), service);
-- return pairedBy(db, id, service);
++ prev_valid_until = NULL, updated_at = ? WHERE id = ? AND ${scope.where} AND status <> 'revoked'`).run(at, revokedBy, at, String(id), ...scope.params);
++ return byScope(db, id, scope);
++}
++
++/** Revoke a principal `service` paired; revoked_by is svc:<service>. → the row, or null. */
++const revoke = (db, id, service, now = Date.now()) => revokeScoped(db, id, { where: 'paired_by_service = ?', params: [service] }, `svc:${service}`, now);
++
++/** Scope for a person's own machines: the principals they own, and no others. */
++const ownScope = (me) => ({ where: "owner_kind = 'user' AND owner_subject = ?", params: [me] });
++
++/** The signed-in person's subject id, or a RegistryError for a guest (never manages machines). */
++async function meOf(db, user) {
++ if (!user || user.is_anon) throw new RegistryError(403, 'registry.guest', 'sign in with an account to manage your machines');
++ const sid = await require('../identity/subjects').ensureUserSubject(db, user);
++ if (!SUBJECT.test(String(sid || ''))) throw new RegistryError(403, 'registry.guest', 'sign in with an account to manage your machines');
++ return sid;
++}
++
++// Mounted at /api/v1/me/nodes behind requireAuth (server/index.js): a person's own machines, and nothing else.
++function userRouter(requireAuth, now = () => Date.now()) {
++ const router = express.Router();
++ router.use(http.middleware());
++ const send = async (res, fn) => {
++ try { return await fn(); } catch (e) {
++ if (e instanceof RegistryError) return http.sendProblem(res, e.status, e.code, { detail: e.message });
++ console.error('[Node principals]', e.message);
++ return http.sendProblem(res, 500, 'registry.failed', { detail: 'the machines could not be read or changed' });
++ }
++ };
++ router.get('/', requireAuth, async (req, res) => await send(res, async () => {
++ res.set('Cache-Control', 'private, no-store');
++ const db = req.app.locals.db;
++ const me = await meOf(db, req.user);
++ const rows = await db.prepare(`SELECT * FROM platform_node_principals WHERE owner_kind = 'user' AND owner_subject = ?
++ ORDER BY created_at DESC, id DESC`).all(me);
++ res.json({ nodes: rows.map(principalView) });
++ }));
++ router.post('/:principal/revoke', requireAuth, async (req, res) => await send(res, async () => {
++ res.set('Cache-Control', 'private, no-store');
++ const db = req.app.locals.db;
++ const me = await meOf(db, req.user);
++ const p = await revokeScoped(db, req.params.principal, ownScope(me), me, now());
++ if (!p) return http.sendProblem(res, 404, 'registry.unknown_node', { detail: `no node principal ${req.params.principal}` });
++ res.json(principalView(p));
++ }));
++ return router;
}
function routers({ guard, now = () => Date.now() }) {
@@@ -220,4 +279,4 @@ function routers({ guard, now = () => Date.now() }) {
return { internal, pairing };
}
--module.exports = { CODE_TTL_MS, MAX_TRIES, createPairing, redeem, revoke, principalView, routers };
++module.exports = { CODE_TTL_MS, MAX_TRIES, createPairing, redeem, revoke, principalView, routers, userRouter };
ddiff --git a/test/node-principals-me.test.js b/test/node-principals-me.test.js
nnew file mode 100644
iindex 00000000..a136a429
---- /dev/null
++++ b/test/node-principals-me.test.js
@@@ -0,0 +1,152 @@
++'use strict';
++// The owner API for a person's own machines (plan T2, docs/t2-cells-and-node-principal.md sections 4.2 and 7, slice
++// N5): GET /api/v1/me/nodes lists only the principals the signed-in person owns (owner_kind user, owner_subject me),
++// newest first, in the same view the service read returns; POST /api/v1/me/nodes/:principal/revoke revokes one of
++// them (404 registry.unknown_node for anyone else's, idempotent), and no answer ever carries a hash, credential or
++// secret. A service token and no token are both refused.
++// node test/node-principals-me.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const http = require('http');
++const express = require('express');
++const { ids } = require('openvibe-contracts');
++const { getDb } = require('../server/db/database');
++const principals = require('../server/identity/principals');
++const nodePrincipals = require('../server/registry/node-principals');
++const { signToken } = require('../server/auth/routes');
++
++(async () => {
++const log = console.log; console.log = () => {};
++const db = getDb();
++console.log = log;
++await db.prepare("UPDATE oauth_clients SET client_secret = 'bot-secret' WHERE client_id = 'bot'").run();
++
++const alex = `usr_${ids.ulid()}`; const bella = `usr_${ids.ulid()}`;
++await db.prepare(`INSERT INTO users (id, username, display_name, password_hash, subject_id) VALUES
++ (9201, 'mealex', 'Alex', 'x', ?), (9202, 'meebella', 'Bella', 'x', ?)`).run(alex, bella);
++await db.prepare("INSERT INTO users (id, username, password_hash, is_anon) VALUES (9203, 'meeguest', 'x', 1)").run();
++
++const clock = { offset: 0 };
++const now = () => Date.now() + clock.offset;
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++const config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
++const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), signToken);
++
++const app = express();
++app.use(express.json());
++app.use(express.urlencoded({ extended: true }));
++Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
++app.use('/oauth', require('../server/auth/oauth-routes'));
++const pr = nodePrincipals.routers({ guard: principals.guard('network.node.manage'), now });
++app.use('/api/v1/node-pairing', pr.pairing);
++app.use('/internal', pr.internal);
++app.use('/api/v1/me/nodes', nodePrincipals.userRouter(requireAuth, now));
++const server = http.createServer(app);
++
++const logs = [];
++const out = process.stdout.write.bind(process.stdout);
++const err = process.stderr.write.bind(process.stderr);
++process.stdout.write = (c, ...a) => { logs.push(String(c)); return out(c, ...a); };
++process.stderr.write = (c, ...a) => { logs.push(String(c)); return err(c, ...a); };
++
++await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++const base = `http://127.0.0.1:${server.address().port}`;
++const call = (method, p, body, headers = {}) => fetch(`${base}${p}`, { method, headers: { 'content-type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined })
++ .then(async (x) => { const text = await x.text(); let json = null; try { json = JSON.parse(text); } catch { /* not JSON */ } return { status: x.status, headers: x.headers, text, body: json }; });
++const bearer = (t) => ({ authorization: `Bearer ${t}` });
++const serviceToken = async (id, secret) => (await (await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: id, client_secret: secret, audience: 'openvibe.network' }) })).json()).access_token;
++const session = async (id) => bearer(signToken(await db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config));
++let passed = 0;
++const check = async (name, fn) => { await fn(); passed++; log(` ok ${name}`); };
++const VIEW_KEYS = ['created_at', 'home_cell', 'last_seen_at', 'name', 'node_id', 'owner', 'paired_for', 'principal', 'revoked_at', 'status'];
++try {
++ const bot = bearer(await serviceToken('bot', 'bot-secret'));
++ const asAlex = await session(9201); const asBella = await session(9202); const asGuest = await session(9203);
++ const mint = (owner, ref) => call('POST', '/internal/node-pairings', { owner: { kind: 'user', subject: owner }, ref }, bot);
++ const pair = async (owner, name) => {
++ const { body: pairing } = await mint(owner, `me-${ids.ulid()}`);
++ const r = await call('POST', '/api/v1/node-pairing', { pairing: pairing.pairing_id, code: pairing.code, name });
++ assert.strictEqual(r.status, 201, r.text);
++ return r.body;
++ };
++
++ await check('only a person\'s session is accepted; no token and a service token are 401', async () => {
++ assert.strictEqual((await call('GET', '/api/v1/me/nodes')).status, 401);
++ assert.strictEqual((await call('POST', '/api/v1/me/nodes/nod_x/revoke')).status, 401);
++ assert.strictEqual((await call('GET', '/api/v1/me/nodes', null, bot)).status, 401, 'a service token is not a session');
++ const guest = await call('GET', '/api/v1/me/nodes', null, asGuest);
++ assert.deepStrictEqual([guest.status, guest.body.code], [403, 'registry.guest']);
++ });
++
++ // A pairs two machines, B one; the clock steps so "newest first" is unambiguous.
++ const a1 = await pair(alex, 'Workstation');
++ clock.offset += 1000;
++ const a2 = await pair(alex, 'Laptop');
++ clock.offset += 1000;
++ const b1 = await pair(bella, 'Bella\'s tower');
++ clock.offset += 1000;
++
++ await check('a person lists exactly their own machines, newest first, in the service view', async () => {
++ const a = await call('GET', '/api/v1/me/nodes', null, asAlex);
++ assert.strictEqual(a.status, 200, a.text);
++ assert.strictEqual(a.headers.get('cache-control'), 'private, no-store');
++ assert.deepStrictEqual(a.body.nodes.map(n => n.principal), [a2.principal, a1.principal], 'newest first, only Alex\'s');
++ for (const [i, paired] of [a2, a1].entries()) {
++ const view = a.body.nodes[i];
++ assert.deepStrictEqual(Object.keys(view).sort(), VIEW_KEYS);
++ assert.deepStrictEqual([view.owner, view.status, view.name, view.paired_for], [{ kind: 'user', subject: alex }, 'active', paired === a2 ? 'Laptop' : 'Workstation', { service: 'bot', ref: (await db.prepare('SELECT pairing_ref FROM platform_node_principals WHERE id = ?').get(paired.principal)).pairing_ref }]);
++ const internal = await call('GET', `/internal/node-principals/${paired.principal}`, null, bot);
++ assert.deepStrictEqual(view, internal.body, 'the owner sees exactly the service view');
++ }
++ const b = await call('GET', '/api/v1/me/nodes', null, asBella);
++ assert.deepStrictEqual(b.body.nodes.map(n => n.principal), [b1.principal], 'Bella sees only her machine');
++ });
++
++ await check('revoking someone else\'s machine is a 404 and changes nothing', async () => {
++ const r = await call('POST', `/api/v1/me/nodes/${b1.principal}/revoke`, null, asAlex);
++ assert.deepStrictEqual([r.status, r.body.code], [404, 'registry.unknown_node']);
++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
++ const row = await db.prepare('SELECT status, revoked_at, revoked_by FROM platform_node_principals WHERE id = ?').get(b1.principal);
++ assert.deepStrictEqual([row.status, row.revoked_at, row.revoked_by], ['active', null, null], 'Bella\'s machine is untouched');
++ assert.deepStrictEqual((await call('POST', `/api/v1/me/nodes/nod_${ids.ulid()}/revoke`, null, asAlex)).status, 404);
++ assert.deepStrictEqual((await call('POST', '/api/v1/me/nodes/not-a-node/revoke', null, asAlex)).status, 404);
++ });
++
++ await check('revoking your own machine clears the previous credential and is idempotent', async () => {
++ await db.prepare("UPDATE platform_node_principals SET credential_prev_hash = ?, prev_valid_until = ? WHERE id = ?").run('c'.repeat(64), '2026-10-02T00:01:00Z', a1.principal);
++ const r = await call('POST', `/api/v1/me/nodes/${a1.principal}/revoke`, null, asAlex);
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
++ assert.deepStrictEqual([r.body.principal, r.body.status], [a1.principal, 'revoked']);
++ assert.ok(r.body.revoked_at);
++ const row = await db.prepare('SELECT * FROM platform_node_principals WHERE id = ?').get(a1.principal);
++ assert.deepStrictEqual([row.revoked_at, row.revoked_by, row.credential_prev_hash, row.prev_valid_until, row.updated_at], [r.body.revoked_at, alex, null, null, r.body.revoked_at]);
++ assert.strictEqual(row.credential_hash, crypto.createHash('sha256').update(a1.credential).digest('hex'), 'the row still holds its credential: migrations/0014 requires one for a user-owned principal');
++ clock.offset += 5000;
++ const again = await call('POST', `/api/v1/me/nodes/${a1.principal}/revoke`, null, asAlex);
++ clock.offset -= 5000;
++ assert.deepStrictEqual([again.status, again.body], [200, r.body], 'a second revoke answers the same view');
++ });
++
++ await check('no answer ever carries a hash, credential or secret', async () => {
++ const answers = [
++ await call('GET', '/api/v1/me/nodes', null, asAlex),
++ await call('POST', `/api/v1/me/nodes/${a2.principal}/revoke`, null, asAlex),
++ await call('POST', `/api/v1/me/nodes/${b1.principal}/revoke`, null, asBella),
++ ];
++ for (const answer of answers) {
++ assert.ok(!/hash|credential|secret/i.test(answer.text), `an answer leaked a secret field: ${answer.text}`);
++ assert.ok(!answer.text.includes(a1.credential) && !answer.text.includes(a2.credential), 'an answer leaked a credential');
++ }
++ const listed = (await call('GET', '/api/v1/me/nodes', null, asAlex)).body.nodes;
++ assert.deepStrictEqual(listed.map(n => [n.principal, n.status]), [[a2.principal, 'revoked'], [a1.principal, 'revoked']], 'revoked machines stay in the owner\'s list with their status');
++ assert.ok(!logs.join('\n').includes(a1.credential), 'a credential was logged');
++ });
++ log(`node-principals-me: ${passed} checks passed`);
++} finally {
++ process.stdout.write = out;
++ process.stderr.write = err;
++ server.close();
++ await db.close?.();
++}
++})().catch((e) => { console.error(e); process.exit(1); });