openvibers/openvibe.network Commits

A paired machine answers, revokes and lists agents by itself (#853)

OP OpenVibers committed 1 days ago · 895854c97d · 2 files, +111 −0

Changes

ddiff --git a/server/link/access.js b/server/link/access.js
iindex 7f43997a..d746a3de 100644
---- a/server/link/access.js
++++ b/server/link/access.js
@@@ -283,11 +283,33 @@ function routers({ requireAuth, nodeGuard, relayGuard }) {
const rows = await db.prepare('SELECT * FROM link_access_requests WHERE from_principal = ? OR to_principal = ? ORDER BY asked_at DESC LIMIT 100').all(p.id, p.id);
res.json({ requests: rows.map(requestView) });
}));
++ // A machine answers only the requests addressed to it (a Link holds a node token, not a user session), while the
++ // same decision from any signed-in surface settles it once.
++ self.post('/access-requests/:id/answer', express.json({ limit: '8kb' }), wrap(async (req, res, db) => {
++ const p = await selfNode(db, req);
++ const row = await db.prepare('SELECT to_principal FROM link_access_requests WHERE id = ?').get(req.params.id);
++ if (!row || row.to_principal !== p.id) fail(404, 'link_access.request_not_found', 'no such request for this machine');
++ const out = await answer(db, req.params.id, p.owner_subject, req.body);
++ res.status(out.expired ? 410 : 200).json(out);
++ }));
self.get('/access-grants', wrap(async (req, res, db) => {
const p = await selfNode(db, req);
const rows = await db.prepare('SELECT * FROM link_access_grants WHERE from_principal = ? OR to_principal = ? ORDER BY created_at DESC LIMIT 100').all(p.id, p.id);
res.json({ grants: rows.map(grantView) });
}));
++ // Either machine in a grant may end it, from its own node token; the owner routes do the same by person.
++ self.post('/access-grants/:id/revoke', wrap(async (req, res, db) => {
++ const p = await selfNode(db, req);
++ const row = await db.prepare('SELECT from_principal, to_principal FROM link_access_grants WHERE id = ?').get(req.params.id);
++ if (!row || (row.from_principal !== p.id && row.to_principal !== p.id)) fail(404, 'link_access.grant_not_found', 'no such grant for this machine');
++ res.json({ grant: await revoke(db, req.params.id, p.owner_subject) });
++ }));
++ // The person's agent directory, seen from the paired machine: the entries a local CLI may message.
++ self.get('/agents', wrap(async (req, res, db) => {
++ const p = await selfNode(db, req);
++ const rows = await db.prepare('SELECT * FROM agent_directory WHERE owner_subject = ? ORDER BY updated_at DESC, id LIMIT 200').all(p.owner_subject);
++ res.json({ agents: rows.map(require('./agents').directoryView) });
++ }));
self.post('/access-grants/:id/token', wrap(async (req, res, db) => res.json(await grantToken(db, req.params.id,
await selfNode(db, req), req.app.locals.privateKey, req.app.locals.config.jwt.issuer))));
self.get('/access-grants/:id/status', wrap(async (req, res, db) => {
ddiff --git a/test/link-access-node-self.test.js b/test/link-access-node-self.test.js
nnew file mode 100644
iindex 00000000..54d5fd89
---- /dev/null
++++ b/test/link-access-node-self.test.js
@@@ -0,0 +1,89 @@
++'use strict';
++// A paired machine, not a signed-in person, answers the requests addressed to it, ends a grant it is part of and
++// reads the person's agent directory (Link holds a node token). The routes must refuse another machine's request,
++// another machine's grant and show only the owner's agents.
++// node test/link-access-node-self.test.js
++const assert = require('node:assert/strict');
++const crypto = require('node:crypto');
++const http = require('node:http');
++const express = require('express');
++const { ids } = require('openvibe-contracts');
++const { getDb } = require('../server/db/database');
++const access = require('../server/link/access');
++const agents = require('../server/link/agents');
++
++(async () => {
++ const db = getDb(), owner = `usr_${ids.ulid()}`, other = `usr_${ids.ulid()}`;
++ for (const [subject, name] of [[owner, 'alex'], [other, 'sam']]) {
++ await db.prepare(`INSERT INTO users (username, email, password_hash, role, subject_id)
++ SELECT ?, ?, 'x', 'user', ? WHERE NOT EXISTS (SELECT 1 FROM users WHERE subject_id = ?)`)
++ .run(name, `${name}-${subject.slice(-8)}@example.test`, subject, subject);
++ }
++ const node = async (who) => {
++ const principal = `lnk_${ids.ulid()}`;
++ await db.prepare(`INSERT INTO platform_node_principals (id, node_id, home_cell, owner_kind, trust, owner_subject, credential_hash, created_by)
++ VALUES (?, ?, 'wnam-1', 'user', 'community', ?, ?, ?)`)
++ .run(principal, `n-${ids.ulid().toLowerCase()}`, who, crypto.createHash('sha256').update(principal).digest('hex'), who);
++ return { id: principal, owner_subject: who };
++ };
++ const a = await node(owner), b = await node(other), c = await node(owner);
++
++ const machines = { a, b, c };
++ const app = express();
++ app.locals.db = db;
++ app.locals.privateKey = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({ format: 'pem', type: 'pkcs1' });
++ app.locals.config = { jwt: { issuer: 'https://openvibe.network' } };
++ app.use('/api/v1/link/self', access.routers({
++ requireAuth: (req, res, next) => next(),
++ nodeGuard: (req, res, next) => {
++ const m = machines[String(req.headers['x-machine'] || '')];
++ if (!m) return res.status(401).json({ code: 'link_access.machine_required' });
++ req.principal = { sub: `link:${m.id}` };
++ next();
++ },
++ relayGuard: (req, res, next) => next(),
++ }).self);
++ const server = http.createServer(app);
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const call = async (method, path, machine, body) => {
++ const r = await fetch(base + path, {
++ method, headers: { 'x-machine': machine, ...(body ? { 'content-type': 'application/json' } : {}) },
++ body: body ? JSON.stringify(body) : undefined,
++ });
++ return { status: r.status, body: await r.json().catch(() => ({})) };
++ };
++ try {
++ // Another machine of the same owner cannot settle a request addressed to this one.
++ const forC = await access.ask(db, a, { to_principal: c.id, scopes: ['agent'], duration_seconds: 600 });
++ const wrongMachine = await call('POST', `/api/v1/link/self/access-requests/${forC.id}/answer`, 'a', { approve: true });
++ assert.deepEqual([wrongMachine.status, wrongMachine.body.code], [404, 'link_access.request_not_found']);
++ const answered = await call('POST', `/api/v1/link/self/access-requests/${forC.id}/answer`, 'c', { approve: true, scopes: ['agent'] });
++ assert.equal(answered.status, 200);
++ assert.equal(answered.body.request.status, 'approved');
++ assert.equal(answered.body.grant.to_principal, c.id);
++
++ // Either machine in a grant ends it; a machine outside it cannot.
++ const outsider = await call('POST', `/api/v1/link/self/access-grants/${answered.body.grant.id}/revoke`, 'b');
++ assert.deepEqual([outsider.status, outsider.body.code], [404, 'link_access.grant_not_found']);
++ const revoked = await call('POST', `/api/v1/link/self/access-grants/${answered.body.grant.id}/revoke`, 'a');
++ assert.equal(revoked.status, 200);
++ assert.ok(revoked.body.grant.revoked_at);
++ assert.equal(await access.liveGrant(db, answered.body.grant.id), null);
++
++ // The directory is the person's, seen from any of their paired machines, and no one else's.
++ await access.putSettings(db, c.id, owner, { agents_talk: true });
++ await access.putSettings(db, b.id, other, { agents_talk: true });
++ const local = await agents.register(db, { type: 'node', id: c.id, owner }, { kind: 'link-local', name: 'Codex', skills: ['code'], status: 'online' });
++ const foreign = await agents.register(db, { type: 'node', id: b.id, owner: other }, { kind: 'link-local', name: 'Muse', skills: [], status: 'online' });
++ const mine = await call('GET', '/api/v1/link/self/agents', 'a');
++ assert.equal(mine.status, 200);
++ assert.ok(mine.body.agents.some((agent) => agent.id === local.id && agent.machine_id === c.id));
++ assert.ok(!mine.body.agents.some((agent) => agent.id === foreign.id));
++ const theirs = await call('GET', '/api/v1/link/self/agents', 'b');
++ assert.ok(!theirs.body.agents.some((agent) => agent.id === local.id));
++ console.log('link access machine self: answer, revoke and the agent directory are scoped to the paired machine');
++ } finally {
++ await new Promise((r) => server.close(r));
++ }
++})().catch((err) => { console.error(err); process.exitCode = 1; });