openvibers/openvibe.bot Commits

Bot: a machine revoked on OpenVibe.Network stops at once (network.node.revoked revokes its devices and closes the socket) (#112)

OP OpenVibers committed 29 days ago · 9c6bee09d3 · 4 files, +52 −4

**A machine revoked on OpenVibe.Network stops on Bot at once.**

**Found:** while cleaning up a production proof. Revoking a person's paired machine on Network (the owner's own revoke, the pairing service's, or an account deletion) announces `network.node.revoked` so "a service holding a machine's session stops it at once". Bot did not consume it: its device row stayed live, and a connected machine kept its socket until the next reauth deadline (up to 330 s). Only a revoke started on Bot itself closed it at once.

**Change:**
- **Subscription:** `server/index.js` subscribes Bot's Events endpoint to `network.node.revoked` beside the two account topics (`startSubscriptions` topics).
- **Handling:** `server/app.js` passes the account-data consumer an `onEvent`. For `network.node.revoked` it calls `domain.devices.revokeByNode(principal_id)`, which revokes every live device bound to that principal in one statement, without asking Network again. Each revoked device's socket is closed at once (`hub.closeDevice`, close 4003). Other topics are acknowledged as before, and a redelivery changes nothing.

**Test:** `node-principal.test.js` adds a check. With a connected node machine, a signed `{ event }` delivery of `network.node.revoked` closes its socket with 4003, the device row is revoked and offline, and a repeat delivery is harmless. The other node checks (bind, reauth deadline, Bot-side revoke, rotate) and `account-data.test.js` pass. 18 reached test files pass.

Changes

ddiff --git a/server/app.js b/server/app.js
iindex a162609..0e66028 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -131,7 +131,19 @@ function createApp(opts = {}) {
const accountSend = opts.accountSend || (config.oauth.clientSecret
? createNetworkSender({ networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Bot cannot answer account events'); });
-- app.post('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend, log }));
++ // network.node.revoked (a person's paired machine revoked on Network): its devices are revoked and a live session is
++ // closed at once, instead of at the machine's next reauth deadline.
++ const onEvent = async (ev) => {
++ if (ev && ev.event_type === 'network.node.revoked') {
++ const principal = String((ev.payload && ev.payload.principal_id) || '');
++ if (!/^nod_[0-9A-HJKMNP-TV-Z]{26}$/.test(principal)) return 'applied';
++ const revoked = await domain.devices.revokeByNode(principal);
++ for (const d of revoked) hub.closeDevice(d.id, 'machine revoked on OpenVibe.Network');
++ if (revoked.length) log.log(`[Bot] node ${principal} revoked on Network: ${revoked.length} device(s) revoked`);
++ }
++ return 'applied';
++ };
++ app.post('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend, onEvent, log }));
// The OpenVibe Frame's scripts and the showcase stylesheet the front page links (content-hashed URLs).
app.use('/shared', require('openvibe-shared/serve').handler());
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex 550f7e2..8cbfb2c 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -601,6 +601,17 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
return updated;
}
++ /**
++ * Network revoked a node principal (network.node.revoked: its owner, the pairing service or an account deletion):
++ * every live device bound to it is revoked here too, without asking Network again. → the revoked rows
++ */
++ async function revokeByNode(principalId) {
++ const at = iso(now());
++ const rows = await db.many('UPDATE devices SET revoked_at = $2, credential_prev_hash = NULL, prev_valid_until = NULL, updated_at = $2 WHERE node_principal = $1 AND revoked_at IS NULL RETURNING *', [String(principalId), at]);
++ if (rows.length) changed();
++ return rows;
++ }
++
// ── Network-paired devices (node principals, T2 §9.2 B1) ──────────────────────────────────────
/**
* The live device bound to Network node principal `principalId`, creating it on first use. Idempotent:
@@@ -1087,7 +1098,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
localProfile, saveLocalProfile, useCatalogueProfile },
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl },
-- devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, touchSeen, setOnline, ownerOf, onChange },
++ devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, revokeByNode, touchSeen, setOnline, ownerOf, onChange },
machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer, setWatchProbe },
streaming: { get: streaming, set: setStreaming },
video: { live: liveVideo },
ddiff --git a/server/index.js b/server/index.js
iindex dc052c4..29c8a6e 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -19,7 +19,7 @@ const { seedProfiles } = require('./profiles');
const { createApp } = require('./app');
const { createValkey } = require('openvibe-sdk/valkey');
const { gracefulStop } = require('openvibe-sdk/service');
--const { startSubscriptions } = require('openvibe-sdk/account-data');
++const { startSubscriptions, TOPICS } = require('openvibe-sdk/account-data');
const { createRegistry } = require('openvibe-shared/metrics');
async function main() {
@@@ -68,7 +68,9 @@ async function main() {
// kit's 5000 ms default is the right value.
// The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,
// BOT_EVENTS_SECRET or the client secret.
++ // Plus network.node.revoked: a machine revoked on Network is revoked here at once (app.js onEvent).
const subscriptions = startSubscriptions({
++ topics: [...TOPICS, 'network.node.revoked'],
eventsUrl: config.events.url, endpoint: `http://127.0.0.1:${config.port}/internal/events`, secret: config.events.secrets[0],
networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
});
ddiff --git a/test/node-principal.test.js b/test/node-principal.test.js
iindex d7c7374..bf14078 100644
---- a/test/node-principal.test.js
++++ b/test/node-principal.test.js
@@@ -4,12 +4,14 @@
// as for a credential, `status` persists what the machine declares, `reauth` keeps the socket, and revoke
// and rotate reach Network and the machine. Credential devices keep today's behaviour.
const assert = require('assert');
++const { signDeliveryHeaders } = require('openvibe-sdk/events');
const { boot, check, done } = require('./helpers/app');
const WHIP_BASE = 'https://whip.test/ingest';
++const EVENTS_SECRET = `whsec_${'noderevoked'.repeat(4)}`;
(async () => {
-- const t = await boot({ env: { BOT_WHIP_BASE: WHIP_BASE } });
++ const t = await boot({ env: { BOT_WHIP_BASE: WHIP_BASE, BOT_EVENTS_SECRET: EVENTS_SECRET } });
const alex = t.network.newUser('alex');
const sam = t.network.newUser('sam');
@@@ -202,6 +204,27 @@ const WHIP_BASE = 'https://whip.test/ingest';
c.close();
});
++ await check('network.node.revoked revokes the machine\'s devices and closes its live socket at once', async () => {
++ const { principal } = await paired();
++ const c = await connect(t.network.signNode(principal));
++ const hello = await c.waitFor((m) => m.type === 'hello');
++ assert.ok(t.hub.isOnline(hello.device_id));
++ const event = {
++ event_id: 'evt_01JZ0000000000000000000N01', event_type: 'network.node.revoked', version: 1, source: 'network',
++ actor: { type: 'user', id: alex.subject }, timestamp: new Date().toISOString(), visibility: 'internal',
++ subject: { type: 'node', id: principal },
++ payload: { node_id: `n-${'a'.repeat(26)}`, principal_id: principal, owner: { kind: 'user', subject: alex.subject }, reason: 'owner', at: new Date().toISOString() },
++ };
++ const body = JSON.stringify({ event }); // Events delivers { cursor?, event }
++ const res = await fetch(`${t.base}/internal/events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, EVENTS_SECRET) } });
++ assert.strictEqual(res.status < 300, true, `${res.status} ${await res.text()}`);
++ assert.strictEqual(await c.waitForClose(), 4003, 'the socket closes at once, not at the reauth deadline');
++ assert.ok((await deviceRow(hello.device_id)).revoked_at, 'the device is revoked');
++ assert.ok(!t.hub.isOnline(hello.device_id));
++ const again = await fetch(`${t.base}/internal/events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, EVENTS_SECRET) } });
++ assert.strictEqual(again.status < 300, true, 'a redelivery is harmless');
++ });
++
await t.close();
done();
})();