openvibers/openvibe.network Commits
A11: authorize browsing through the owner's Link device (#702)
Changes
nnew file mode 100644@@@ -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);nnew file mode 100644@@@ -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.'; }++ });++})();nnew file mode 100644@@@ -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 };@@@ -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>`;@@@ -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);@@@ -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 };}@@@ -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.nnew file mode 100644@@@ -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; });@@@ -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'],@@@ -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 twiceassert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -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.'; }
+ });
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,178 @@
@@ -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 };
@@ -198,6 +198,15 @@ ${installBlock(release, userAgent)}
@@ -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>
<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>
+<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>
<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>
<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>
<form class="np-send" data-link-send>
@@ -230,6 +239,7 @@ ${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact'
@@ -230,6 +239,7 @@ ${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact'
${require('../boost').SCRIPT}
${require('../boost').SCRIPT}
<script src="/shared/kit-web.js" defer></script>
<script src="/shared/kit-web.js" defer></script>
<script type="module" src="/send.js"></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>
<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>
</body>
</html>`;
</html>`;
@@ -63,6 +63,7 @@ function llmsTxt() {
@@ -63,6 +63,7 @@ function llmsTxt() {
*/
*/
function createNodeRoutes({ releaseId, releaseApi = release } = {}) {
function createNodeRoutes({ releaseId, releaseApi = release } = {}) {
const router = require('express').Router();
const router = require('express').Router();
+ router.use(require('./egress').publicRouter());
router.use(async (req, res, next) => {
router.use(async (req, res, next) => {
const host = hostOf(req);
const host = hostOf(req);
@@ -340,6 +340,7 @@ async function meOf(db, user) {
@@ -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.
// 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()) {
function userRouter(requireAuth, now = () => Date.now()) {
const router = express.Router();
const router = express.Router();
+ require('../node/egress').mount({ user: router, requireAuth, now });
router.use(http.middleware());
router.use(http.middleware());
const send = async (res, fn) => {
const send = async (res, fn) => {
try { return await fn(); } catch (e) {
try { return await fn(); } catch (e) {
@@ -472,6 +473,7 @@ async function rotateCredential(db, id, now = Date.now()) {
@@ -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.
// the token's signature, audience and capability; ownership is selfOf's, before any write.
function selfRouter(now = () => Date.now()) {
function selfRouter(now = () => Date.now()) {
const router = express.Router();
const router = express.Router();
+ require('../node/egress').mount({ self: router, now });
router.use(http.middleware());
router.use(http.middleware());
const send = async (res, fn) => {
const send = async (res, fn) => {
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
@@ -537,6 +539,7 @@ function routers({ guard, now = () => Date.now() }) {
@@ -537,6 +539,7 @@ function routers({ guard, now = () => Date.now() }) {
paired_for: { service: p.paired_by_service, ref: p.pairing_ref },
paired_for: { service: p.paired_by_service, ref: p.pairing_ref },
});
});
});
});
+ require('../node/egress').mount({ internal, pairing, now });
return { internal, pairing };
return { internal, pairing };
}
}
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
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.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
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.
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -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; });
@@ -110,6 +110,7 @@ const PATTERNS = {
@@ -110,6 +110,7 @@ const PATTERNS = {
};
};
// The reviewed list: where each goes, and why a stranger cannot choose it (or how it is guarded).
// The reviewed list: where each goes, and why a stranger cannot choose it (or how it is guarded).
const REVIEWED = {
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/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/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'],
'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'],
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
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');
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
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');
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');