openvibers/openvibe.network Commits

A11: authorize browsing through the owner's Link device (#702)

OP OpenVibers committed 4 days ago · 878d6a241c · 10 files, +308 −2

* Authorize per-session Link browsing and add owner settings

* Restrict cloud desk credential reads to Bot-paired machines

* Bind cloud desks through Bot and validate migration coverage

* Reconcile Link egress migration checks with current main

Changes

ddiff --git a/migrations/0058_link_egress.sql b/migrations/0058_link_egress.sql
nnew file mode 100644
iindex 00000000..a778df96
---- /dev/null
++++ b/migrations/0058_link_egress.sql
@@@ -0,0 +1,26 @@
++-- phase: expand
++-- Expand only. 0057 is reserved by the open Companion blind-fill PR.
++CREATE TABLE link_egress_settings (
++ owner_subject TEXT PRIMARY KEY,
++ egress_via_link BOOLEAN NOT NULL DEFAULT true,
++ offline_choice TEXT NOT NULL DEFAULT 'openvibe' CHECK (offline_choice IN ('openvibe', 'wait')),
++ monthly_bytes BIGINT NOT NULL DEFAULT 20000000000 CHECK (monthly_bytes BETWEEN 0 AND 10000000000000),
++ excluded_sites JSONB NOT NULL DEFAULT '[]'
++);
++CREATE TABLE link_egress_devices (
++ node_id TEXT PRIMARY KEY REFERENCES platform_node_principals(id),
++ relay_url TEXT NOT NULL,
++ checked_at BIGINT NOT NULL
++);
++CREATE TABLE link_egress_sessions (
++ session_id TEXT PRIMARY KEY,
++ owner_subject TEXT NOT NULL,
++ machine TEXT NOT NULL,
++ desk TEXT NOT NULL,
++ node_id TEXT REFERENCES platform_node_principals(id),
++ credential_hash TEXT NOT NULL,
++ expires_at BIGINT NOT NULL,
++ ended BOOLEAN NOT NULL DEFAULT false,
++ UNIQUE (machine, desk)
++);
++CREATE INDEX link_egress_sessions_expiry ON link_egress_sessions(expires_at);
ddiff --git a/server/node/egress-settings.js b/server/node/egress-settings.js
nnew file mode 100644
iindex 00000000..a0efaa9a
---- /dev/null
++++ b/server/node/egress-settings.js
@@@ -0,0 +1,28 @@
++'use strict';
++(() => {
++ const form = document.querySelector('[data-egress-settings]');
++ if (!form) return;
++ const status = form.querySelector('[data-egress-status]');
++ const url = '/api/v1/me/nodes/egress';
++ let loaded = false;
++ fetch(url, { credentials: 'same-origin', cache: 'no-store' }).then(async r => {
++ if (!r.ok) throw Error('Sign in to change your browsing settings.');
++ const p = await r.json();
++ form.elements.enabled.checked = p.egress_via_link;
++ form.elements.offline.value = p.offline_choice;
++ form.elements.cap.value = p.monthly_bytes / 1e9;
++ form.elements.sites.value = p.excluded_sites.join(', ');
++ loaded = true;
++ }).catch(e => { status.textContent = e.message; });
++ form.addEventListener('submit', async e => {
++ e.preventDefault();
++ if (!loaded) { status.textContent = 'Sign in to change your browsing settings.'; return; }
++ try {
++ const r = await fetch(url, { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ egress_via_link: form.elements.enabled.checked, offline_choice: form.elements.offline.value,
++ monthly_bytes: Math.round(Number(form.elements.cap.value) * 1e9),
++ excluded_sites: form.elements.sites.value.split(/[\s,]+/).filter(Boolean) }) });
++ status.textContent = r.ok ? 'Browsing settings saved.' : 'Check the limit and site names, then try again.';
++ } catch { status.textContent = 'Your settings could not be saved. Try again.'; }
++ });
++})();
ddiff --git a/server/node/egress.js b/server/node/egress.js
nnew file mode 100644
iindex 00000000..1d80f93b
---- /dev/null
++++ b/server/node/egress.js
@@@ -0,0 +1,178 @@
++'use strict';
++
++// Run is authoritative for session ownership; Network is authoritative for device ownership.
++// Session credentials are random, short lived, stored only as hashes, and rechecked on EVERY open.
++const crypto = require('crypto');
++const express = require('express');
++const { http } = require('openvibe-contracts');
++const { ensureUserSubject } = require('../identity/subjects');
++const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const SESSION = /^ses_[0-9A-HJKMNP-TV-Z]{26}$/;
++const MACHINE = /^dev_[0-9A-HJKMNP-TV-Z]{26}$/;
++const NODE = /^nod_[0-9A-HJKMNP-TV-Z]{26}$/;
++const DESK = /^desk-[a-z0-9][a-z0-9-]{0,23}$/;
++const TOKEN = /^[A-Za-z0-9_-]{43}$/;
++const DEFAULT_BYTES = 20_000_000_000;
++const EXPLANATION = "Your Actor's browsing on OpenVibe computers goes out through this computer's internet, so sites see your usual address.";
++const sha = value => crypto.createHash('sha256').update(value).digest('hex');
++const fail = (status, code) => Object.assign(new Error(code), { status, code: `egress.${code}` });
++const wrap = fn => async (req, res) => {
++ res.set('Cache-Control', 'no-store');
++ try { await fn(req, res); } catch (e) {
++ http.sendProblem(res, e.status || 503, e.code || 'egress.unavailable', { detail: 'Your browsing connection is unavailable.' });
++ }
++};
++
++async function settings(db, owner) {
++ const row = await db.prepare('SELECT * FROM link_egress_settings WHERE owner_subject = ?').get(owner);
++ return row ? { egress_via_link: row.egress_via_link, offline_choice: row.offline_choice,
++ monthly_bytes: Number(row.monthly_bytes), excluded_sites: row.excluded_sites } :
++ { egress_via_link: true, offline_choice: 'openvibe', monthly_bytes: DEFAULT_BYTES, excluded_sites: [] };
++}
++
++function validateSettings(body) {
++ if (!body || typeof body.egress_via_link !== 'boolean' || !['openvibe', 'wait'].includes(body.offline_choice) ||
++ !Number.isSafeInteger(body.monthly_bytes) || body.monthly_bytes < 0 || body.monthly_bytes > 10_000_000_000_000 ||
++ !Array.isArray(body.excluded_sites) || body.excluded_sites.length > 100) throw fail(422, 'invalid_settings');
++ const sites = body.excluded_sites.map(s => typeof s === 'string' ? s.trim().toLowerCase() : '');
++ if (sites.some(s => s.length > 253 || !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(s))) throw fail(422, 'invalid_site');
++ return { ...body, excluded_sites: [...new Set(sites)] };
++}
++
++async function node(db, id) {
++ if (!NODE.test(id || '')) throw fail(403, 'node_required');
++ const row = await db.prepare('SELECT id, owner_kind, owner_subject, status, name, pairing_ref, paired_by_service FROM platform_node_principals WHERE id = ?').get(id);
++ if (!row || row.status !== 'active') throw fail(403, 'node_unavailable');
++ return row;
++}
++async function ownNode(db, req) {
++ if (!String(req.principal?.sub).startsWith('node:')) throw fail(403, 'node_required');
++ return node(db, req.principal.sub.slice(5));
++}
++
++async function cloudMachine(bot, principal) {
++ if (principal.paired_by_service !== 'bot' || principal.owner_kind !== 'user') throw fail(403, 'node_required');
++ // Pairing refs identify a robot or worker group, not a dev_ machine. Bot owns this binding.
++ const machines = (await bot.list(principal.owner_subject)).machines || [];
++ const machine = machines.find(m => m.node_id === principal.id && MACHINE.test(m.id || ''));
++ if (!machine) throw fail(403, 'node_unavailable');
++ return machine.id;
++}
++
++async function selectRoute(db, owner, now = Date.now()) {
++ const prefs = await settings(db, owner);
++ const devices = await db.prepare(`SELECT n.id, n.name, d.relay_url, d.checked_at FROM platform_node_principals n
++ LEFT JOIN link_egress_devices d ON d.node_id = n.id
++ WHERE n.owner_kind = 'user' AND n.owner_subject = ? AND n.status = 'active'
++ ORDER BY d.checked_at DESC NULLS LAST, n.id`).all(owner);
++ if (!prefs.egress_via_link || !devices.length) return { mode: 'direct', ...prefs };
++ const online = devices.find(d => Number(d.checked_at) > now - 45_000);
++ if (!online) return { mode: prefs.offline_choice === 'wait' ? 'wait' : 'direct', ...prefs };
++ return { mode: 'link', device: online.id, device_name: online.name || 'My computer', relay_url: online.relay_url, ...prefs };
++}
++
++async function open(db, { session_id: session, credential, node_id: device }, now = Date.now()) {
++ if (!SESSION.test(session || '') || !TOKEN.test(credential || '') || !NODE.test(device || '')) throw fail(403, 'refused');
++ const row = await db.prepare('SELECT * FROM link_egress_sessions WHERE session_id = ?').get(session);
++ if (!row || row.ended || Number(row.expires_at) <= now || row.node_id !== device ||
++ !crypto.timingSafeEqual(Buffer.from(row.credential_hash, 'hex'), Buffer.from(sha(credential), 'hex'))) throw fail(403, 'refused');
++ const target = await node(db, device);
++ // Never trust the ownership captured at registration: unpairing, transfers and opt-out take effect here.
++ if (target.owner_kind !== 'user' || target.owner_subject !== row.owner_subject) throw fail(403, 'owner_mismatch');
++ const prefs = await settings(db, row.owner_subject);
++ if (!prefs.egress_via_link || prefs.monthly_bytes === 0) throw fail(403, 'disabled');
++ return { session_id: session, node_id: device, expires_at: Number(row.expires_at), ...prefs };
++}
++
++function mount({ user, self, internal, pairing, requireAuth, now = () => Date.now() }) {
++ if (user) {
++ user.get('/egress', requireAuth, wrap(async (req, res) => {
++ const owner = await ensureUserSubject(req.app.locals.db, req.user);
++ if (!SUBJECT.test(owner || '')) throw fail(403, 'person_required');
++ res.json({ ...await settings(req.app.locals.db, owner), explanation: EXPLANATION });
++ }));
++ user.put('/egress', requireAuth, express.json({ limit: '16kb' }), wrap(async (req, res) => {
++ const db = req.app.locals.db;
++ const owner = await ensureUserSubject(db, req.user);
++ if (!SUBJECT.test(owner || '')) throw fail(403, 'person_required');
++ const p = validateSettings(req.body);
++ await db.prepare(`INSERT INTO link_egress_settings(owner_subject, egress_via_link, offline_choice, monthly_bytes, excluded_sites)
++ VALUES (?, ?, ?, ?, ?::jsonb) ON CONFLICT(owner_subject) DO UPDATE SET
++ egress_via_link = EXCLUDED.egress_via_link, offline_choice = EXCLUDED.offline_choice,
++ monthly_bytes = EXCLUDED.monthly_bytes, excluded_sites = EXCLUDED.excluded_sites`)
++ .run(owner, p.egress_via_link, p.offline_choice, p.monthly_bytes, JSON.stringify(p.excluded_sites));
++ res.json(await settings(db, owner));
++ }));
++ }
++ if (self) {
++ self.get('/egress/identity', wrap(async (req, res) => {
++ const n = await ownNode(req.app.locals.db, req);
++ if (n.owner_kind !== 'user') throw fail(403, 'person_required');
++ res.json({ node_id: n.id, owner: n.owner_subject, name: n.name });
++ }));
++ self.put('/egress/heartbeat', express.json({ limit: '2kb' }), wrap(async (req, res) => {
++ const db = req.app.locals.db, n = await ownNode(db, req);
++ if (n.owner_kind !== 'user') throw fail(403, 'person_required');
++ const url = String(req.body?.relay_url || '');
++ // A device cannot make a cloud host connect to an arbitrary URL or local address.
++ if (!/^wss:\/\/[a-z0-9-]+\.openvibe\.link\/__ov_egress$/.test(url)) throw fail(422, 'invalid_relay');
++ await db.prepare(`INSERT INTO link_egress_devices(node_id, relay_url, checked_at) VALUES (?, ?, ?)
++ ON CONFLICT(node_id) DO UPDATE SET relay_url = EXCLUDED.relay_url, checked_at = EXCLUDED.checked_at`).run(n.id, url, now());
++ res.json(await settings(db, n.owner_subject));
++ }));
++ // A cloud Node can read only the decision for a desk assigned to its own Bot machine id.
++ self.get('/egress/desks/:desk', wrap(async (req, res) => {
++ const db = req.app.locals.db, n = await ownNode(db, req);
++ if (!DESK.test(req.params.desk)) throw fail(404, 'desk_unknown');
++ const { config, privateKey } = req.app.locals;
++ const bot = require('../registry/machines').createBotClient({ baseUrl: config.services.bot.internalUrl, privateKey, issuer: config.jwt.issuer });
++ const machine = await cloudMachine(bot, n);
++ const row = await db.prepare('SELECT * FROM link_egress_sessions WHERE machine = ? AND desk = ? AND ended = false AND expires_at > ?')
++ .get(machine, req.params.desk, now());
++ if (!row) throw fail(404, 'desk_unknown');
++ const route = await selectRoute(db, row.owner_subject, now());
++ const credential = crypto.randomBytes(32).toString('base64url');
++ await db.prepare('UPDATE link_egress_sessions SET credential_hash = ?, node_id = ? WHERE session_id = ? AND ended = false')
++ .run(sha(credential), route.device || null, row.session_id);
++ res.json({ session_id: row.session_id, credential, ...route });
++ }));
++ }
++ if (internal) {
++ const guard = require('../identity/principals').guard('network.registry.read');
++ const runOnly = (req, res, next) => req.principal?.sub === 'svc:run' ? next() : http.sendProblem(res, 403, 'egress.run_required');
++ internal.put('/egress/sessions/:session', guard, runOnly, express.json({ limit: '4kb' }), wrap(async (req, res) => {
++ const db = req.app.locals.db, b = req.body || {};
++ if (!SESSION.test(req.params.session) || !SUBJECT.test(b.owner || '') || !MACHINE.test(b.machine || '') ||
++ !DESK.test(b.desk || '') || !TOKEN.test(b.credential || '') || !Number.isSafeInteger(b.expires_at) ||
++ b.expires_at <= now() || b.expires_at > now() + 86_400_000) throw fail(422, 'invalid_session');
++ const route = await selectRoute(db, b.owner, now());
++ const old = await db.prepare('SELECT owner_subject FROM link_egress_sessions WHERE session_id = ?').get(req.params.session);
++ if (old && old.owner_subject !== b.owner) throw fail(403, 'owner_mismatch');
++ await db.prepare(`INSERT INTO link_egress_sessions(session_id, owner_subject, machine, desk, node_id, credential_hash, expires_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(session_id) DO UPDATE SET machine = EXCLUDED.machine,
++ desk = EXCLUDED.desk, node_id = EXCLUDED.node_id, credential_hash = EXCLUDED.credential_hash,
++ expires_at = EXCLUDED.expires_at, ended = false`).run(req.params.session, b.owner, b.machine, b.desk,
++ route.device || null, sha(b.credential), b.expires_at);
++ res.json(route);
++ }));
++ internal.post('/egress/sessions/:session/renew', guard, runOnly, wrap(async (req, res) => {
++ await req.app.locals.db.prepare('UPDATE link_egress_sessions SET expires_at = ? WHERE session_id = ? AND ended = false')
++ .run(now() + 120_000, req.params.session);
++ res.status(204).end();
++ }));
++ internal.delete('/egress/sessions/:session', guard, runOnly, wrap(async (req, res) => {
++ await req.app.locals.db.prepare('UPDATE link_egress_sessions SET ended = true WHERE session_id = ?').run(req.params.session);
++ res.status(204).end();
++ }));
++ }
++
++}
++
++function publicRouter() {
++ const r = express.Router();
++ r.post('/api/v1/link-egress/open', require('express-rate-limit')({ windowMs: 10_000, max: 3000 }),
++ express.json({ limit: '2kb' }), wrap(async (req, res) => res.json(await open(req.app.locals.db, req.body || {}))));
++ return r;
++}
++
++module.exports = { cloudMachine, publicRouter, mount, settings, validateSettings, selectRoute, open, EXPLANATION, DEFAULT_BYTES };
ddiff --git a/server/node/page.js b/server/node/page.js
iindex 29da4c21..848fc344 100644
---- a/server/node/page.js
++++ b/server/node/page.js
@@@ -198,6 +198,15 @@ ${installBlock(release, userAgent)}
<p class="np-cta" style="margin-top:12px">${kit.button({ href: 'https://openvibe.my/machines', label: 'Open your machines', kind: 'primary' })} ${kit.button({ href: '/pair', label: 'Pair a machine', kind: 'quiet' })}</p>
</section>
++<section class="np-sec" id="browsing"><h2>Your usual internet address</h2>
++<p>Your Actor's browsing on OpenVibe computers goes out through this computer's internet, so sites see your usual address.</p>
++<form class="np-send" data-egress-settings>
++<label><span><input type="checkbox" name="enabled" checked> Use my computer's internet</span></label>
++<label>When my computer is offline<select name="offline"><option value="openvibe">Use OpenVibe's address</option><option value="wait">Wait for my computer</option></select></label>
++<label>Monthly limit (GB)<input type="number" name="cap" min="0" max="10000" value="20" step="1"></label>
++<label>Sites that should use OpenVibe's address<textarea name="sites" placeholder="example.com, another.example"></textarea></label>
++<button type="submit">Save browsing settings</button><p role="status" data-egress-status></p>
++</form></section>
<section class="np-sec" id="send" aria-labelledby="h-send"><h2 id="h-send">Send to my machine</h2>
<p class="np-fine">Choose one of your paired machines and send a file or a short text note. Link encrypts it for that machine before it leaves this page; the relay only holds the encrypted envelope for up to 24 hours.</p>
<form class="np-send" data-link-send>
@@@ -230,6 +239,7 @@ ${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact'
${require('../boost').SCRIPT}
<script src="/shared/kit-web.js" defer></script>
<script type="module" src="/send.js"></script>
++<script src="/egress-settings.js" defer></script>
<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:'https://openvibe.network',sessionUrl:'/auth/me',loginUrl:'/auth/login?next={path}',logoutUrl:'/auth/logout?next={url}',silentLogin:'/auth/login?silent=1&next={url}',links:[{label:'Link',href:'/',icon:'fa-desktop',featured:true,active:true},{label:'Machines',href:'/machines',icon:'fa-laptop'},{label:'Network',href:'https://openvibe.network/'},{label:'My Account',href:'https://openvibe.network/my'}]});}catch(e){}});</script>
</body>
</html>`;
ddiff --git a/server/node/routes.js b/server/node/routes.js
iindex 8e1c5c54..0dba95e8 100644
---- a/server/node/routes.js
++++ b/server/node/routes.js
@@@ -63,6 +63,7 @@ function llmsTxt() {
*/
function createNodeRoutes({ releaseId, releaseApi = release } = {}) {
const router = require('express').Router();
++ router.use(require('./egress').publicRouter());
router.use(async (req, res, next) => {
const host = hostOf(req);
ddiff --git a/server/registry/node-principals.js b/server/registry/node-principals.js
iindex fcec3dbd..18455bd4 100644
---- a/server/registry/node-principals.js
++++ b/server/registry/node-principals.js
@@@ -340,6 +340,7 @@ async function meOf(db, user) {
// 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();
++ require('../node/egress').mount({ user: router, requireAuth, now });
router.use(http.middleware());
const send = async (res, fn) => {
try { return await fn(); } catch (e) {
@@@ -472,6 +473,7 @@ async function rotateCredential(db, id, now = Date.now()) {
// the token's signature, audience and capability; ownership is selfOf's, before any write.
function selfRouter(now = () => Date.now()) {
const router = express.Router();
++ require('../node/egress').mount({ self: router, now });
router.use(http.middleware());
const send = async (res, fn) => {
res.set('Cache-Control', 'no-store');
@@@ -537,6 +539,7 @@ function routers({ guard, now = () => Date.now() }) {
paired_for: { service: p.paired_by_service, ref: p.pairing_ref },
});
});
++ require('../node/egress').mount({ internal, pairing, now });
return { internal, pairing };
}
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex 96691405..5287e50b 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand']]);
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
ddiff --git a/test/link-egress.test.js b/test/link-egress.test.js
nnew file mode 100644
iindex 00000000..bd3f22fd
---- /dev/null
++++ b/test/link-egress.test.js
@@@ -0,0 +1,59 @@
++'use strict';
++const assert = require('assert/strict');
++const crypto = require('crypto');
++const { ids } = require('openvibe-contracts');
++const { getDb } = require('../server/db/database');
++const principals = require('../server/registry/node-principals');
++const egress = require('../server/node/egress');
++
++(async () => {
++ const db = getDb();
++ const owner = `usr_${ids.ulid()}`, other = `usr_${ids.ulid()}`;
++ await db.prepare("INSERT INTO users(id, username, password_hash, subject_id) VALUES (9211, 'egressone', 'x', ?), (9212, 'egresstwo', 'x', ?)").run(owner, other);
++ const pair = async subject => {
++ const p = await principals.createPairing(db, 'bot', { owner: { kind: 'user', subject }, ref: `dev_${ids.ulid()}` });
++ return (await principals.redeem(db, { pairing: p.pairing_id, code: p.code, name: 'Home computer' })).principal;
++ };
++ const own = await pair(owner), foreign = await pair(other);
++ const machineId = `dev_${ids.ulid()}`;
++ const bot = { list: async subject => { assert.equal(subject, owner); return { machines: [{ id: machineId, node_id: own.id }] }; } };
++ assert.equal(await egress.cloudMachine(bot, { ...own, pairing_ref: 'worker:someone', paired_by_service: 'bot' }), machineId);
++ await assert.rejects(egress.cloudMachine({ list: async () => ({ machines: [{ id: machineId, node_id: foreign.id }] }) }, own), { code: 'egress.node_unavailable' });
++ const now = Date.now();
++ const defaults = await egress.settings(db, owner);
++ assert.deepEqual(defaults, { egress_via_link: true, offline_choice: 'openvibe', monthly_bytes: 20_000_000_000, excluded_sites: [] });
++ assert.equal((await egress.selectRoute(db, owner, now)).mode, 'direct', 'offline defaults to OpenVibe');
++ await db.prepare("INSERT INTO link_egress_settings(owner_subject, offline_choice) VALUES (?, 'wait')").run(owner);
++ assert.equal((await egress.selectRoute(db, owner, now)).mode, 'wait');
++ await db.prepare('INSERT INTO link_egress_devices(node_id, relay_url, checked_at) VALUES (?, ?, ?)')
++ .run(own.id, 'wss://egress--mine.openvibe.link/__ov_egress', now);
++ assert.equal((await egress.selectRoute(db, owner, now)).device, own.id);
++ assert.equal((await egress.selectRoute(db, other, now)).mode, 'direct', 'another account never gets my online device');
++ const session = `ses_${ids.ulid()}`, credential = crypto.randomBytes(32).toString('base64url');
++ const hash = crypto.createHash('sha256').update(credential).digest('hex');
++ await db.prepare(`INSERT INTO link_egress_sessions(session_id,owner_subject,machine,desk,node_id,credential_hash,expires_at)
++ VALUES (?,?,'machine','desk-test',?,?,?)`).run(session, owner, own.id, hash, now + 60000);
++ const request = { session_id: session, credential, node_id: own.id };
++ assert.equal((await egress.open(db, request, now)).node_id, own.id);
++ await assert.rejects(egress.open(db, { ...request, credential: crypto.randomBytes(32).toString('base64url') }, now), { code: 'egress.refused' });
++ await assert.rejects(egress.open(db, { ...request, node_id: foreign.id }, now), { code: 'egress.refused' });
++ await db.prepare('UPDATE link_egress_sessions SET node_id = ? WHERE session_id = ?').run(foreign.id, session);
++ await assert.rejects(egress.open(db, { ...request, node_id: foreign.id }, now), { code: 'egress.owner_mismatch' }, 'session owner must equal current device owner');
++ await db.prepare('UPDATE link_egress_sessions SET node_id = ? WHERE session_id = ?').run(own.id, session);
++ // Ownership is read on each open, rather than trusting the prior successful open.
++ await db.prepare('UPDATE platform_node_principals SET owner_subject = ? WHERE id = ?').run(other, own.id);
++ await assert.rejects(egress.open(db, request, now), { code: 'egress.owner_mismatch' });
++ await db.prepare('UPDATE platform_node_principals SET owner_subject = ? WHERE id = ?').run(owner, own.id);
++ await db.prepare('UPDATE link_egress_settings SET egress_via_link = false WHERE owner_subject = ?').run(owner);
++ assert.equal((await egress.selectRoute(db, owner, now)).mode, 'direct');
++ await assert.rejects(egress.open(db, request, now), { code: 'egress.disabled' }, 'opt-out stops even an existing credential');
++ await db.prepare('UPDATE link_egress_settings SET egress_via_link = true, monthly_bytes = 0 WHERE owner_subject = ?').run(owner);
++ await assert.rejects(egress.open(db, request, now), { code: 'egress.disabled' });
++ await db.prepare('UPDATE link_egress_settings SET monthly_bytes = 20 WHERE owner_subject = ?').run(owner);
++ await assert.rejects(egress.open(db, request, now + 60001), { code: 'egress.refused' });
++ await db.prepare('UPDATE link_egress_sessions SET ended = true WHERE session_id = ?').run(session);
++ await assert.rejects(egress.open(db, request, now), { code: 'egress.refused' });
++ assert.deepEqual(egress.validateSettings({ ...defaults, excluded_sites: ['Example.com', 'example.com'] }).excluded_sites, ['example.com']);
++ for (const site of ['*.com', 'https://example.com', '127.0.0.1', 'example.com;DIRECT']) assert.throws(() => egress.validateSettings({ ...defaults, excluded_sites: [site] }));
++ console.log('link egress: default, offline choices, owner mismatch on every open, opt-out, expiry, stop and settings passed');
++})().catch(e => { console.error(e); process.exitCode = 1; });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 0fdef86a..1b29b5e4 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -110,6 +110,7 @@ const PATTERNS = {
};
// The reviewed list: where each goes, and why a stranger cannot choose it (or how it is guarded).
const REVIEWED = {
++ 'server/node/egress-settings.js fetch': [2, 'browser-only GET and PUT to the fixed same-origin /api/v1/me/nodes/egress settings route; no caller-chosen host or URL'],
'server/connect/egress.js http.request': [1, 'Connect gateway (E7): only the app definition\'s fixed API host, through openvibe-shared/egress (IP literals refused before the socket, public DNS answers only, redirects followed by hand with every hop re-checked); never a URL from the input (Opus review 11-06)'],
'server/home/activity.js fetch': [1, 'front door activity: fixed first-party loopback URLs (OV_LIVE/COMMUNITY/BLOG_INTERNAL_URL, operator config) for public lists, never a request value'],
'server/admin/analytics-routes.js fetch': [2, 'staff analytics: the services\' internal URLs from the registry (owner/admin set), and Insights\' Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; never a request value'],
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex f170f761..634d989f 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');