openvibers/openvibe.network Commits
Add owner Tools worker fleet and routing controls (#795)
Changes
@@@ -684,6 +684,7 @@<script src="/js/admin/users.js" defer></script><script src="/js/admin/moderation.js" defer></script><script src="/js/admin/settings.js" defer></script>++<script src="/js/admin/tools-workers.js" defer></script><script src="/js/admin/payments.js" defer></script><script src="/js/admin/glossary.js" defer></script><script src="/js/admin/ai.js" defer></script>@@@ -372,6 +372,7 @@ async function loadToolsSettings() {<div style="margin-top:16px"><button type="button" class="btn btn-primary" onclick="saveToolsSettings()"><i class="fa-solid fa-floppy-disk"></i> Save Network settings</button></div></form>`;} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }++ finally { if (currentUser && currentUser.is_owner) await loadToolsWorkers(); }}let _toolsSettingsOriginal = {};nnew file mode 100644@@@ -0,0 +1,54 @@++/* Owner-only Tools infrastructure controls. Credentials stay in this response/DOM only. */++async function loadToolsWorkers() {++ if (!currentUser || !currentUser.is_owner) return;++ const parent = document.getElementById('settings-tools');++ let panel = document.getElementById('tools-workers-panel');++ if (!panel) { panel = document.createElement('section'); panel.id = 'tools-workers-panel'; parent.appendChild(panel); }++ panel.innerHTML = '<h2>Tools workers</h2><p>Loading workers…</p>';++ try {++ const fleet = await api('/api/admin/tools-workers/fleet');++ const value = n => n == null ? '—' : esc(Number(n).toLocaleString());++ panel.innerHTML = `<h2>Tools workers</h2>++<p><a href="https://openvibe.tools/workers" target="_blank" rel="noopener">Run Tools on your own machine</a>. Jobs today counts execution attempts in UTC and survives job expiry. Load is the one-minute system load.</p>++<div style="overflow-x:auto"><table><thead><tr><th>Worker / location</th><th>Status</th><th>Load</th><th>Free / slots</th><th>Active jobs</th><th>Jobs today (${esc(fleet.day)})</th><th>Controls</th></tr></thead><tbody>++${fleet.workers.map(w => `<tr><td>${esc(w.label)}<br><code>${esc(w.id)}</code><br>${esc(w.location)}</td><td>${w.revoked ? 'Revoked' : !w.online ? 'Offline' : w.draining ? 'Draining' : 'Online'}</td><td>${w.load == null ? '—' : esc(Number(w.load).toFixed(2))}</td><td>${value(w.free)} / ${value(w.slots)}</td><td>${value(w.active_jobs)}</td><td>${value(w.jobs_today)}</td><td>${w.id === 'host' ? 'Fallback' : w.revoked ? '' : `<button class="ovk-btn ovk-btn--quiet" data-worker-drain="${esc(w.id)}" data-draining="${w.draining ? 'false' : 'true'}">${w.draining ? 'Resume' : 'Drain'}</button> <button class="ovk-btn ovk-btn--quiet" data-worker-revoke="${esc(w.id)}">Revoke credential</button>`}</td></tr>`).join('')}++</tbody></table></div>++<button type="button" class="ovk-btn ovk-btn--quiet" id="tools-workers-refresh">Refresh workers</button>++<form id="tools-workers-routing"><h3>Routing</h3><label><input type="checkbox" id="tools-workers-enabled" ${fleet.routing.enabled ? 'checked' : ''}> Enable worker routing</label><p><label for="tools-workers-order">Order (home, host, or worker ids; separated by commas)</label><br><textarea id="tools-workers-order" rows="3">${esc(fleet.routing.order.join(', '))}</textarea></p><p>Within each group, free slots and recent latency decide. Fetching jobs stay on the host. Draining stops new assignments and lets current jobs finish.</p><button class="ovk-btn ovk-btn--primary" type="submit">Save routing</button></form>++<form id="tools-workers-create"><h3>Add a worker</h3><label>Label <input name="label" required maxlength="80" pattern="[A-Za-z0-9_ .-]+" placeholder="Dell tower one"></label> <label>Location <select name="location"><option value="home">Home</option><option value="host">Host</option></select></label> <button class="ovk-btn ovk-btn--primary" type="submit">Create one-time credential</button></form>++<div id="tools-workers-credential" hidden><p>Save this token in a private file on the worker. It is shown only now and cannot be retrieved again.</p><pre id="tools-workers-token"></pre><button type="button" class="ovk-btn ovk-btn--quiet" id="tools-workers-clear">Clear credential</button></div>++<p id="tools-workers-status" role="status"></p>`;++ const status = message => { panel.querySelector('#tools-workers-status').textContent = message; };++ const action = async (button, fn) => { button.disabled = true; try { await fn(); } catch (e) { status(e.message); } finally { button.disabled = false; } };++ panel.querySelector('#tools-workers-refresh').onclick = () => loadToolsWorkers();++ panel.querySelector('#tools-workers-clear').onclick = () => { panel.querySelector('#tools-workers-token').textContent = ''; panel.querySelector('#tools-workers-credential').hidden = true; };++ for (const button of panel.querySelectorAll('[data-worker-drain]')) button.onclick = () => action(button, async () => {++ await api(`/api/admin/tools-workers/workers/${button.dataset.workerDrain}/drain`, { method: 'PUT', body: { draining: button.dataset.draining === 'true' } });++ await loadToolsWorkers();++ });++ for (const button of panel.querySelectorAll('[data-worker-revoke]')) button.onclick = () => action(button, async () => {++ await api(`/api/admin/tools-workers/workers/${button.dataset.workerRevoke}`, { method: 'DELETE' });++ await loadToolsWorkers();++ });++ panel.querySelector('#tools-workers-routing').onsubmit = event => {++ event.preventDefault();++ return action(event.target.querySelector('button'), async () => {++ const order = panel.querySelector('#tools-workers-order').value.split(/[\s,]+/).filter(Boolean);++ await api('/api/admin/tools-workers/routing', { method: 'PUT', body: { enabled: panel.querySelector('#tools-workers-enabled').checked, order } });++ await loadToolsWorkers();++ });++ };++ panel.querySelector('#tools-workers-create').onsubmit = event => {++ event.preventDefault();++ return action(event.target.querySelector('button'), async () => {++ const fields = new FormData(event.target);++ const credential = await api('/api/admin/tools-workers/workers', { method: 'POST', body: { label: fields.get('label'), location: fields.get('location') } });++ // Render immediately: a later fleet fetch must not lose the one-time response.++ panel.querySelector('#tools-workers-token').textContent = `Worker id: ${credential.id}\nToken: ${credential.token}`;++ panel.querySelector('#tools-workers-credential').hidden = false;++ status('Worker created. Save the credential before refreshing.');++ event.target.reset();++ });++ };++ } catch (e) { panel.innerHTML = `<h2>Tools workers</h2><p>${esc(e.message)}</p><button class="ovk-btn ovk-btn--quiet" type="button" onclick="loadToolsWorkers()">Retry</button>`; }++}@@@ -66,6 +66,7 @@ function createServiceProxies({ requireAuth, signToken, services }) {const live = () => services.live.internalUrl;const chat = () => services.chat.internalUrl;++ r.use('/tools-workers', require('./tools-workers').createToolsWorkers({ requireAuth, signToken, services }));r.use('/streamer', requireAuth, admin, to(live, '/api/admin', 'OpenVibe.Live', 'Streamer proxy error'));r.use('/streamer-mod', requireAuth, staff, to(live, '/api/mod', 'OpenVibe.Live', 'Mod proxy error'));r.use('/streamer-tts', requireAuth, admin, to(chat, '/api/tts', 'OpenVibe.Chat', 'TTS proxy error'));nnew file mode 100644@@@ -0,0 +1,40 @@++'use strict';++const express = require('express');++const { requireOwner } = require('../auth/owner-guard');++/** Fixed owner-only Tools paths; no caller-selected URL or path is forwarded. */++function createToolsWorkers({ requireAuth, signToken, services }) {++ const router = express.Router();++ router.use((_req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });++ router.use(requireAuth, requireOwner);++ router.use((req, res, next) => {++ if (!['GET', 'HEAD'].includes(req.method)) {++ const expected = new URL(req.app.locals.config?.baseUrl || 'https://openvibe.network').origin;++ if (req.headers.origin !== expected) return res.status(403).json({ error: 'Origin required' });++ }++ next();++ });++ const forward = pathFor => async (req, res) => {++ try {++ const path = pathFor(req);++ if (!path) return res.status(400).json({ error: 'Invalid worker id' });++ const token = req.user && req.app.locals.privateKey && typeof signToken === 'function'++ ? signToken(req.user, req.app.locals.privateKey, req.app.locals.config, { renew: req.tokenClaims || {} }) : req.token;++ const target = new URL(path, services.tools.internalUrl);++ const upstream = await fetch(target, {++ method: req.method, headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Origin: 'https://openvibe.tools' },++ body: ['GET', 'HEAD'].includes(req.method) ? undefined : JSON.stringify(req.body || {}),++ signal: AbortSignal.timeout(10000), redirect: 'error',++ });++ res.status(upstream.status).json(await upstream.json());++ } catch { res.status(502).json({ error: 'Could not reach Tools worker controls' }); }++ };++ const worker = suffix => req => /^wrk_[a-f0-9]{24}$/.test(req.params.id) ? `/api/v1/owner/workers/${req.params.id}${suffix}` : null;++ router.get('/fleet', forward(() => '/api/v1/owner/workers'));++ router.post('/workers', forward(() => '/api/v1/owner/workers'));++ router.put('/workers/:id/drain', forward(worker('/drain')));++ router.delete('/workers/:id', forward(worker('')));++ router.get('/routing', forward(() => '/api/v1/owner/worker-routing'));++ router.put('/routing', forward(() => '/api/v1/owner/worker-routing'));++ return router;++}++module.exports = { createToolsWorkers };@@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) asconst files = fs.readdirSync(path.join(pub, 'js', 'admin')).filter((f) => f.endsWith('.js')).sort();// The module map, in load order (public/js/admin/…).--const modules = ['core.js', 'dashboard.js', 'users.js', 'moderation.js', 'settings.js', 'payments.js', 'glossary.js', 'ai.js', 'inference.js', 'setup.js', 'content.js', 'analytics.js', 'deploy.js', 'boot.js'];++const modules = ['core.js', 'dashboard.js', 'users.js', 'moderation.js', 'settings.js', 'tools-workers.js', 'payments.js', 'glossary.js', 'ai.js', 'inference.js', 'setup.js', 'content.js', 'analytics.js', 'deploy.js', 'boot.js'];assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/admin\/([^"]+)"([^>]*)><\/script>/g)];const loaded = tags.map((m) => m[2]);@@@ -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/admin/tools-workers.js fetch': [1, 'Owner-only fixed Tools admin paths at the operator-configured internal URL; no caller URL; redirects refused.'],'server/link/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'],nnew file mode 100644@@@ -0,0 +1,62 @@++'use strict';++const assert = require('node:assert/strict');++const http = require('node:http');++const fs = require('node:fs');++const vm = require('node:vm');++const express = require('express');++const { parseHTML } = require('linkedom');++const { createServiceProxies } = require('../server/admin/service-proxies');++(async () => {++ const seen = [];++ const upstream = http.createServer((req, res) => {++ let body = ''; req.on('data', d => { body += d; });++ req.on('end', () => { seen.push({ path: req.url, auth: req.headers.authorization, origin: req.headers.origin, method: req.method, body }); res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ ok: true })); });++ });++ await new Promise(r => upstream.listen(0, '127.0.0.1', r));++ const app = express(); app.use(express.json());++ app.locals.config = { baseUrl: 'https://openvibe.network' }; app.locals.privateKey = 'fixture';++ const requireAuth = (req, res, next) => { if (!req.headers['x-test-user']) return res.sendStatus(401); req.user = { id: 'test', username: req.headers['x-test-user'], role: 'admin' }; req.token = 'original-fixture'; next(); };++ app.use('/api/admin', createServiceProxies({ requireAuth, signToken: () => 'reminted-fixture', services: { tools: { internalUrl: `http://127.0.0.1:${upstream.address().port}` } } }));++ const server = await new Promise(r => { const s = app.listen(0, '127.0.0.1', () => r(s)); });++ const base = `http://127.0.0.1:${server.address().port}/api/admin/tools-workers`;++ const id = 'wrk_' + '1'.repeat(24);++ const owner = process.env.OWNER_USERNAME || 'goosely';++ async function call(p, method = 'GET', who = owner, origin = 'https://openvibe.network') {++ return fetch(base + p, { method, headers: { 'x-test-user': who, origin, 'content-type': 'application/json' }, body: method === 'GET' ? undefined : JSON.stringify({ draining: true }) });++ }++ try {++ assert.equal((await call('/fleet', 'GET', 'other-admin')).status, 403);++ assert.equal((await call('/workers', 'POST', owner, 'https://elsewhere.example')).status, 403);++ assert.equal(seen.length, 0);++ const response = await call('/fleet'); assert.equal(response.status, 200); assert.equal(response.headers.get('cache-control'), 'no-store');++ assert.deepEqual(seen[0], { path: '/api/v1/owner/workers', method: 'GET', body: '', auth: 'Bearer reminted-fixture', origin: 'https://openvibe.tools' });++ assert.equal((await call(`/workers/${id}/drain`, 'PUT')).status, 200);++ assert.equal(seen.at(-1).path, `/api/v1/owner/workers/${id}/drain`);++ assert.equal((await call('/workers/not-a-worker/drain', 'PUT')).status, 400);++ assert.equal((await call('/workers/anything/extra', 'POST')).status, 404);++ assert.equal((await call('/routing', 'PUT')).status, 200);++ assert.equal(seen.at(-1).path, '/api/v1/owner/worker-routing');++ const { document } = parseHTML('<html><body><div id="settings-tools"></div></body></html>');++ const calls = [];++ const fleet = { day: '2026-11-10', routing: { enabled: false, order: ['home', 'host'] }, workers: [{ id, label: '<img src=x onerror=bad>', location: 'home', online: true, revoked: false, draining: false, slots: 2, free: 1, active_jobs: 1, jobs_today: 7, load: 0.2 }] };++ const context = vm.createContext({ document, currentUser: { is_owner: true }, esc: s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]),++ FormData: class { constructor(form) { this.form = form; } get(name) { return this.form.querySelector(`[name="${name}"]`).value; } },++ api: async (p, options) => { calls.push({ p, options }); return p.endsWith('/fleet') ? fleet : p.endsWith('/workers') ? { id, token: 'one-time-fixture' } : {}; },++ });++ vm.runInContext(fs.readFileSync('public/js/admin/tools-workers.js', 'utf8'), context);++ await context.loadToolsWorkers();++ assert.equal(document.querySelector('img'), null, 'worker labels are escaped');++ assert.match(document.querySelector('#tools-workers-panel').textContent, /Jobs today/);++ await document.querySelector('[data-worker-drain]').onclick();++ assert.equal(calls.find(c => c.p.endsWith('/drain')).options.body.draining, true);++ const form = document.querySelector('#tools-workers-create'); form.reset = () => {};++ form.querySelector('[name="label"]').value = 'Tower';++ await form.onsubmit({ preventDefault() {}, target: form });++ assert.match(document.querySelector('#tools-workers-token').textContent, /one-time-fixture/);++ document.querySelector('#tools-workers-clear').onclick();++ assert.equal(document.querySelector('#tools-workers-token').textContent, '');++ const count = calls.length; context.currentUser.is_owner = false;++ await context.loadToolsWorkers(); assert.equal(calls.length, count, 'nonowners do not request fleet data');++ console.log('Tools console: owner/origin gates, fixed proxy paths, reminted auth, no-store, fleet rendering, escaping, drain and one-time credential clear passed');++ } finally { await new Promise(r => server.close(r)); await new Promise(r => upstream.close(r)); }++})().catch(e => { console.error(e); process.exitCode = 1; });
@@ -684,6 +684,7 @@
@@ -684,6 +684,7 @@
<script src="/js/admin/users.js" defer></script>
<script src="/js/admin/users.js" defer></script>
<script src="/js/admin/moderation.js" defer></script>
<script src="/js/admin/moderation.js" defer></script>
<script src="/js/admin/settings.js" defer></script>
<script src="/js/admin/settings.js" defer></script>
+<script src="/js/admin/tools-workers.js" defer></script>
<script src="/js/admin/payments.js" defer></script>
<script src="/js/admin/payments.js" defer></script>
<script src="/js/admin/glossary.js" defer></script>
<script src="/js/admin/glossary.js" defer></script>
<script src="/js/admin/ai.js" defer></script>
<script src="/js/admin/ai.js" defer></script>
@@ -372,6 +372,7 @@ async function loadToolsSettings() {
@@ -372,6 +372,7 @@ async function loadToolsSettings() {
<div style="margin-top:16px"><button type="button" class="btn btn-primary" onclick="saveToolsSettings()"><i class="fa-solid fa-floppy-disk"></i> Save Network settings</button></div>
<div style="margin-top:16px"><button type="button" class="btn btn-primary" onclick="saveToolsSettings()"><i class="fa-solid fa-floppy-disk"></i> Save Network settings</button></div>
</form>`;
</form>`;
} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
+ finally { if (currentUser && currentUser.is_owner) await loadToolsWorkers(); }
}
}
let _toolsSettingsOriginal = {};
let _toolsSettingsOriginal = {};
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
+/* Owner-only Tools infrastructure controls. Credentials stay in this response/DOM only. */
+async function loadToolsWorkers() {
+ if (!currentUser || !currentUser.is_owner) return;
+ const parent = document.getElementById('settings-tools');
+ let panel = document.getElementById('tools-workers-panel');
+ if (!panel) { panel = document.createElement('section'); panel.id = 'tools-workers-panel'; parent.appendChild(panel); }
+ panel.innerHTML = '<h2>Tools workers</h2><p>Loading workers…</p>';
+ try {
+ const fleet = await api('/api/admin/tools-workers/fleet');
+ const value = n => n == null ? '—' : esc(Number(n).toLocaleString());
+ panel.innerHTML = `<h2>Tools workers</h2>
+<p><a href="https://openvibe.tools/workers" target="_blank" rel="noopener">Run Tools on your own machine</a>. Jobs today counts execution attempts in UTC and survives job expiry. Load is the one-minute system load.</p>
+<div style="overflow-x:auto"><table><thead><tr><th>Worker / location</th><th>Status</th><th>Load</th><th>Free / slots</th><th>Active jobs</th><th>Jobs today (${esc(fleet.day)})</th><th>Controls</th></tr></thead><tbody>
+${fleet.workers.map(w => `<tr><td>${esc(w.label)}<br><code>${esc(w.id)}</code><br>${esc(w.location)}</td><td>${w.revoked ? 'Revoked' : !w.online ? 'Offline' : w.draining ? 'Draining' : 'Online'}</td><td>${w.load == null ? '—' : esc(Number(w.load).toFixed(2))}</td><td>${value(w.free)} / ${value(w.slots)}</td><td>${value(w.active_jobs)}</td><td>${value(w.jobs_today)}</td><td>${w.id === 'host' ? 'Fallback' : w.revoked ? '' : `<button class="ovk-btn ovk-btn--quiet" data-worker-drain="${esc(w.id)}" data-draining="${w.draining ? 'false' : 'true'}">${w.draining ? 'Resume' : 'Drain'}</button> <button class="ovk-btn ovk-btn--quiet" data-worker-revoke="${esc(w.id)}">Revoke credential</button>`}</td></tr>`).join('')}
+</tbody></table></div>
+<button type="button" class="ovk-btn ovk-btn--quiet" id="tools-workers-refresh">Refresh workers</button>
+<form id="tools-workers-routing"><h3>Routing</h3><label><input type="checkbox" id="tools-workers-enabled" ${fleet.routing.enabled ? 'checked' : ''}> Enable worker routing</label><p><label for="tools-workers-order">Order (home, host, or worker ids; separated by commas)</label><br><textarea id="tools-workers-order" rows="3">${esc(fleet.routing.order.join(', '))}</textarea></p><p>Within each group, free slots and recent latency decide. Fetching jobs stay on the host. Draining stops new assignments and lets current jobs finish.</p><button class="ovk-btn ovk-btn--primary" type="submit">Save routing</button></form>
+<form id="tools-workers-create"><h3>Add a worker</h3><label>Label <input name="label" required maxlength="80" pattern="[A-Za-z0-9_ .-]+" placeholder="Dell tower one"></label> <label>Location <select name="location"><option value="home">Home</option><option value="host">Host</option></select></label> <button class="ovk-btn ovk-btn--primary" type="submit">Create one-time credential</button></form>
+<div id="tools-workers-credential" hidden><p>Save this token in a private file on the worker. It is shown only now and cannot be retrieved again.</p><pre id="tools-workers-token"></pre><button type="button" class="ovk-btn ovk-btn--quiet" id="tools-workers-clear">Clear credential</button></div>
+<p id="tools-workers-status" role="status"></p>`;
+ const status = message => { panel.querySelector('#tools-workers-status').textContent = message; };
+ const action = async (button, fn) => { button.disabled = true; try { await fn(); } catch (e) { status(e.message); } finally { button.disabled = false; } };
+ panel.querySelector('#tools-workers-refresh').onclick = () => loadToolsWorkers();
+ panel.querySelector('#tools-workers-clear').onclick = () => { panel.querySelector('#tools-workers-token').textContent = ''; panel.querySelector('#tools-workers-credential').hidden = true; };
+ for (const button of panel.querySelectorAll('[data-worker-drain]')) button.onclick = () => action(button, async () => {
+ await api(`/api/admin/tools-workers/workers/${button.dataset.workerDrain}/drain`, { method: 'PUT', body: { draining: button.dataset.draining === 'true' } });
+ await loadToolsWorkers();
+ });
+ for (const button of panel.querySelectorAll('[data-worker-revoke]')) button.onclick = () => action(button, async () => {
+ await api(`/api/admin/tools-workers/workers/${button.dataset.workerRevoke}`, { method: 'DELETE' });
+ await loadToolsWorkers();
+ });
+ panel.querySelector('#tools-workers-routing').onsubmit = event => {
+ event.preventDefault();
+ return action(event.target.querySelector('button'), async () => {
+ const order = panel.querySelector('#tools-workers-order').value.split(/[\s,]+/).filter(Boolean);
+ await api('/api/admin/tools-workers/routing', { method: 'PUT', body: { enabled: panel.querySelector('#tools-workers-enabled').checked, order } });
+ await loadToolsWorkers();
+ });
+ };
+ panel.querySelector('#tools-workers-create').onsubmit = event => {
+ event.preventDefault();
+ return action(event.target.querySelector('button'), async () => {
+ const fields = new FormData(event.target);
+ const credential = await api('/api/admin/tools-workers/workers', { method: 'POST', body: { label: fields.get('label'), location: fields.get('location') } });
+ // Render immediately: a later fleet fetch must not lose the one-time response.
+ panel.querySelector('#tools-workers-token').textContent = `Worker id: ${credential.id}\nToken: ${credential.token}`;
+ panel.querySelector('#tools-workers-credential').hidden = false;
+ status('Worker created. Save the credential before refreshing.');
+ event.target.reset();
+ });
+ };
+ } catch (e) { panel.innerHTML = `<h2>Tools workers</h2><p>${esc(e.message)}</p><button class="ovk-btn ovk-btn--quiet" type="button" onclick="loadToolsWorkers()">Retry</button>`; }
+}
@@ -66,6 +66,7 @@ function createServiceProxies({ requireAuth, signToken, services }) {
@@ -66,6 +66,7 @@ function createServiceProxies({ requireAuth, signToken, services }) {
const live = () => services.live.internalUrl;
const live = () => services.live.internalUrl;
const chat = () => services.chat.internalUrl;
const chat = () => services.chat.internalUrl;
+ r.use('/tools-workers', require('./tools-workers').createToolsWorkers({ requireAuth, signToken, services }));
r.use('/streamer', requireAuth, admin, to(live, '/api/admin', 'OpenVibe.Live', 'Streamer proxy error'));
r.use('/streamer', requireAuth, admin, to(live, '/api/admin', 'OpenVibe.Live', 'Streamer proxy error'));
r.use('/streamer-mod', requireAuth, staff, to(live, '/api/mod', 'OpenVibe.Live', 'Mod proxy error'));
r.use('/streamer-mod', requireAuth, staff, to(live, '/api/mod', 'OpenVibe.Live', 'Mod proxy error'));
r.use('/streamer-tts', requireAuth, admin, to(chat, '/api/tts', 'OpenVibe.Chat', 'TTS proxy error'));
r.use('/streamer-tts', requireAuth, admin, to(chat, '/api/tts', 'OpenVibe.Chat', 'TTS proxy error'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
+'use strict';
+const express = require('express');
+const { requireOwner } = require('../auth/owner-guard');
+/** Fixed owner-only Tools paths; no caller-selected URL or path is forwarded. */
+function createToolsWorkers({ requireAuth, signToken, services }) {
+ const router = express.Router();
+ router.use((_req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
+ router.use(requireAuth, requireOwner);
+ router.use((req, res, next) => {
+ if (!['GET', 'HEAD'].includes(req.method)) {
+ const expected = new URL(req.app.locals.config?.baseUrl || 'https://openvibe.network').origin;
+ if (req.headers.origin !== expected) return res.status(403).json({ error: 'Origin required' });
+ }
+ next();
+ });
+ const forward = pathFor => async (req, res) => {
+ try {
+ const path = pathFor(req);
+ if (!path) return res.status(400).json({ error: 'Invalid worker id' });
+ const token = req.user && req.app.locals.privateKey && typeof signToken === 'function'
+ ? signToken(req.user, req.app.locals.privateKey, req.app.locals.config, { renew: req.tokenClaims || {} }) : req.token;
+ const target = new URL(path, services.tools.internalUrl);
+ const upstream = await fetch(target, {
+ method: req.method, headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Origin: 'https://openvibe.tools' },
+ body: ['GET', 'HEAD'].includes(req.method) ? undefined : JSON.stringify(req.body || {}),
+ signal: AbortSignal.timeout(10000), redirect: 'error',
+ });
+ res.status(upstream.status).json(await upstream.json());
+ } catch { res.status(502).json({ error: 'Could not reach Tools worker controls' }); }
+ };
+ const worker = suffix => req => /^wrk_[a-f0-9]{24}$/.test(req.params.id) ? `/api/v1/owner/workers/${req.params.id}${suffix}` : null;
+ router.get('/fleet', forward(() => '/api/v1/owner/workers'));
+ router.post('/workers', forward(() => '/api/v1/owner/workers'));
+ router.put('/workers/:id/drain', forward(worker('/drain')));
+ router.delete('/workers/:id', forward(worker('')));
+ router.get('/routing', forward(() => '/api/v1/owner/worker-routing'));
+ router.put('/routing', forward(() => '/api/v1/owner/worker-routing'));
+ return router;
+}
+module.exports = { createToolsWorkers };
@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) as
@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) as
const files = fs.readdirSync(path.join(pub, 'js', 'admin')).filter((f) => f.endsWith('.js')).sort();
const files = fs.readdirSync(path.join(pub, 'js', 'admin')).filter((f) => f.endsWith('.js')).sort();
// The module map, in load order (public/js/admin/…).
// The module map, in load order (public/js/admin/…).
-const modules = ['core.js', 'dashboard.js', 'users.js', 'moderation.js', 'settings.js', 'payments.js', 'glossary.js', 'ai.js', 'inference.js', 'setup.js', 'content.js', 'analytics.js', 'deploy.js', 'boot.js'];
+const modules = ['core.js', 'dashboard.js', 'users.js', 'moderation.js', 'settings.js', 'tools-workers.js', 'payments.js', 'glossary.js', 'ai.js', 'inference.js', 'setup.js', 'content.js', 'analytics.js', 'deploy.js', 'boot.js'];
assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');
assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');
const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/admin\/([^"]+)"([^>]*)><\/script>/g)];
const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/admin\/([^"]+)"([^>]*)><\/script>/g)];
const loaded = tags.map((m) => m[2]);
const loaded = tags.map((m) => m[2]);
@@ -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/admin/tools-workers.js fetch': [1, 'Owner-only fixed Tools admin paths at the operator-configured internal URL; no caller URL; redirects refused.'],
'server/link/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/link/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'],
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
+'use strict';
+const assert = require('node:assert/strict');
+const http = require('node:http');
+const fs = require('node:fs');
+const vm = require('node:vm');
+const express = require('express');
+const { parseHTML } = require('linkedom');
+const { createServiceProxies } = require('../server/admin/service-proxies');
+(async () => {
+ const seen = [];
+ const upstream = http.createServer((req, res) => {
+ let body = ''; req.on('data', d => { body += d; });
+ req.on('end', () => { seen.push({ path: req.url, auth: req.headers.authorization, origin: req.headers.origin, method: req.method, body }); res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ ok: true })); });
+ });
+ await new Promise(r => upstream.listen(0, '127.0.0.1', r));
+ const app = express(); app.use(express.json());
+ app.locals.config = { baseUrl: 'https://openvibe.network' }; app.locals.privateKey = 'fixture';
+ const requireAuth = (req, res, next) => { if (!req.headers['x-test-user']) return res.sendStatus(401); req.user = { id: 'test', username: req.headers['x-test-user'], role: 'admin' }; req.token = 'original-fixture'; next(); };
+ app.use('/api/admin', createServiceProxies({ requireAuth, signToken: () => 'reminted-fixture', services: { tools: { internalUrl: `http://127.0.0.1:${upstream.address().port}` } } }));
+ const server = await new Promise(r => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
+ const base = `http://127.0.0.1:${server.address().port}/api/admin/tools-workers`;
+ const id = 'wrk_' + '1'.repeat(24);
+ const owner = process.env.OWNER_USERNAME || 'goosely';
+ async function call(p, method = 'GET', who = owner, origin = 'https://openvibe.network') {
+ return fetch(base + p, { method, headers: { 'x-test-user': who, origin, 'content-type': 'application/json' }, body: method === 'GET' ? undefined : JSON.stringify({ draining: true }) });
+ }
+ try {
+ assert.equal((await call('/fleet', 'GET', 'other-admin')).status, 403);
+ assert.equal((await call('/workers', 'POST', owner, 'https://elsewhere.example')).status, 403);
+ assert.equal(seen.length, 0);
+ const response = await call('/fleet'); assert.equal(response.status, 200); assert.equal(response.headers.get('cache-control'), 'no-store');
+ assert.deepEqual(seen[0], { path: '/api/v1/owner/workers', method: 'GET', body: '', auth: 'Bearer reminted-fixture', origin: 'https://openvibe.tools' });
+ assert.equal((await call(`/workers/${id}/drain`, 'PUT')).status, 200);
+ assert.equal(seen.at(-1).path, `/api/v1/owner/workers/${id}/drain`);
+ assert.equal((await call('/workers/not-a-worker/drain', 'PUT')).status, 400);
+ assert.equal((await call('/workers/anything/extra', 'POST')).status, 404);
+ assert.equal((await call('/routing', 'PUT')).status, 200);
+ assert.equal(seen.at(-1).path, '/api/v1/owner/worker-routing');
+ const { document } = parseHTML('<html><body><div id="settings-tools"></div></body></html>');
+ const calls = [];
+ const fleet = { day: '2026-11-10', routing: { enabled: false, order: ['home', 'host'] }, workers: [{ id, label: '<img src=x onerror=bad>', location: 'home', online: true, revoked: false, draining: false, slots: 2, free: 1, active_jobs: 1, jobs_today: 7, load: 0.2 }] };
+ const context = vm.createContext({ document, currentUser: { is_owner: true }, esc: s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]),
+ FormData: class { constructor(form) { this.form = form; } get(name) { return this.form.querySelector(`[name="${name}"]`).value; } },
+ api: async (p, options) => { calls.push({ p, options }); return p.endsWith('/fleet') ? fleet : p.endsWith('/workers') ? { id, token: 'one-time-fixture' } : {}; },
+ });
+ vm.runInContext(fs.readFileSync('public/js/admin/tools-workers.js', 'utf8'), context);
+ await context.loadToolsWorkers();
+ assert.equal(document.querySelector('img'), null, 'worker labels are escaped');
+ assert.match(document.querySelector('#tools-workers-panel').textContent, /Jobs today/);
+ await document.querySelector('[data-worker-drain]').onclick();
+ assert.equal(calls.find(c => c.p.endsWith('/drain')).options.body.draining, true);
+ const form = document.querySelector('#tools-workers-create'); form.reset = () => {};
+ form.querySelector('[name="label"]').value = 'Tower';
+ await form.onsubmit({ preventDefault() {}, target: form });
+ assert.match(document.querySelector('#tools-workers-token').textContent, /one-time-fixture/);
+ document.querySelector('#tools-workers-clear').onclick();
+ assert.equal(document.querySelector('#tools-workers-token').textContent, '');
+ const count = calls.length; context.currentUser.is_owner = false;
+ await context.loadToolsWorkers(); assert.equal(calls.length, count, 'nonowners do not request fleet data');
+ console.log('Tools console: owner/origin gates, fixed proxy paths, reminted auth, no-store, fleet rendering, escaping, drain and one-time credential clear passed');
+ } finally { await new Promise(r => server.close(r)); await new Promise(r => upstream.close(r)); }
+})().catch(e => { console.error(e); process.exitCode = 1; });