openvibers/openvibe.tools Commits

Tools anywhere 1: outbound worker runtime and authenticated channel (#284)

OP OpenVibers committed 2 days ago · e8a997564b · 15 files, +390 −4

* Add outbound Tools worker channel and bounded file job runtime

* Verify worker signatures with the presented credential, not its stored hash

* Acknowledge worker heartbeats to preserve idle proxy connections

* Record the fixed gateway egress boundary in the SSRF audit

Changes

ddiff --git a/apps/gateway/deploy/nginx/openvibe.tools.conf b/apps/gateway/deploy/nginx/openvibe.tools.conf
iindex 5ffa8b5..0ef45ee 100644
---- a/apps/gateway/deploy/nginx/openvibe.tools.conf
++++ b/apps/gateway/deploy/nginx/openvibe.tools.conf
@@@ -78,6 +78,17 @@ server {
}
# ── API (moderate rate limit) ───────────────────────────
++ location = /api/v1/workers/connect {
++ proxy_pass http://127.0.0.1:4001;
++ proxy_http_version 1.1;
++ proxy_set_header Upgrade $http_upgrade;
++ proxy_set_header Connection "upgrade";
++ proxy_set_header Host $host;
++ proxy_read_timeout 60s;
++ proxy_send_timeout 60s;
++ proxy_buffering off;
++ }
++
location /api/ {
limit_req zone=ovtools_api burst=30 nodelay;
proxy_pass http://127.0.0.1:4001;
ddiff --git a/apps/gateway/package-lock.json b/apps/gateway/package-lock.json
iindex 5473f42..9a146ea 100644
---- a/apps/gateway/package-lock.json
++++ b/apps/gateway/package-lock.json
@@@ -23,7 +23,8 @@
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.197.0",
"pg": "^8.23.0",
-- "zxing-wasm": "3.1.5"
++ "zxing-wasm": "3.1.5",
++ "ws": "8.18.3"
}
},
"node_modules/@acemir/cssom": {
@@@ -1734,6 +1735,27 @@
"node": ">= 0.8"
}
},
++ "node_modules/ws": {
++ "version": "8.18.3",
++ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
++ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=10.0.0"
++ },
++ "peerDependencies": {
++ "bufferutil": "^4.0.1",
++ "utf-8-validate": ">=5.0.2"
++ },
++ "peerDependenciesMeta": {
++ "bufferutil": {
++ "optional": true
++ },
++ "utf-8-validate": {
++ "optional": true
++ }
++ }
++ },
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
ddiff --git a/apps/gateway/package.json b/apps/gateway/package.json
iindex 7d27345..081b818 100644
---- a/apps/gateway/package.json
++++ b/apps/gateway/package.json
@@@ -2,7 +2,7 @@
"name": "openvibe-tools-gateway",
"version": "2.0.0",
"private": true,
-- "description": "openvibe.tools gateway — apex directory, Net.OpenVibe, Dev.OpenVibe, Paste.OpenVibe (Host-header routing)",
++ "description": "openvibe.tools gateway \u2014 apex directory, Net.OpenVibe, Dev.OpenVibe, Paste.OpenVibe (Host-header routing)",
"main": "server/index.js",
"scripts": {
"start": "node server/index.js",
@@@ -25,6 +25,7 @@
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.146.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.199.0",
"bwip-js": "4.11.4",
-- "zxing-wasm": "3.1.5"
++ "zxing-wasm": "3.1.5",
++ "ws": "8.18.3"
}
}
ddiff --git a/apps/gateway/server/index.js b/apps/gateway/server/index.js
iindex f6d383c..1d3657b 100644
---- a/apps/gateway/server/index.js
++++ b/apps/gateway/server/index.js
@@@ -65,6 +65,9 @@ let auth = null; // created below; the key check reads it at request time
// what it would refuse.
// The one `tools` database and the shared Valkey (plan T8, decisions 3 and 4).
const toolsDb = require('../../_shared/db').openToolsDb({ createDb: require('openvibe-sdk/db').createDb, service: 'tools-gateway' });
++const workerStore = require('./workers/store').createStore(toolsDb.db);
++const workerHub = require('./workers/hub').createHub({ WebSocketServer: require('ws').WebSocketServer, store: workerStore });
++app.get('/api/v1/workers/status', (_req, res) => res.json({ protocol: 1, dry_run: true, transport: 'outbound-websocket', accepts: ['img.process', 'docs.process', 'audio.process'] }));
const toolsValkey = require('../../_shared/db').openToolsValkey({ createValkey: require('openvibe-sdk/valkey').createValkey });
// The account export and deletion (ADR-033, ./account-data.js): what Tools holds about a person, and the erasure
// of it. The network sender posts Tools' part of an export back to Network with Tools' own token.
@@@ -312,6 +315,12 @@ app.locals.auth = auth;
app.locals.config = config;
app.use('/auth', createAuthRoutes(config, auth));
++const workerOwner = require('./workers/owner').ownerGuard({ verify: token => auth.verify(token), extractToken, origin: new URL(config.baseUrl).origin });
++app.post('/api/v1/owner/workers', workerOwner, express.json({ limit: '4kb' }), async (req, res) => {
++ try { res.status(201).json(await workerStore.mint(req.body || {})); }
++ catch { res.status(400).json({ error: 'Could not create worker' }); }
++});
++
/** 401 unless a valid ov_token is presented. */
async function requireAuth(req, res, next) {
@@@ -574,6 +583,8 @@ const server = app.listen(...(socketActivated ? [{ fd: 3 }] : [config.port, conf
console.log(`║ Pastes: ${config.communityUrl.padEnd(32)}║`);
console.log(`╚════════════════════════════════════════════╝\n`);
});
++server.on('upgrade', (req, socket, head) => { workerHub.upgrade(req, socket, head).then(handled => { if (!handled) socket.destroy(); }, () => socket.destroy()); });
++
// ── Stop (roadmap WS-P lifecycle; apps/_shared/graceful.js) ──
// SIGTERM: the catalog, services and satellite pollers and the search indexer stop; the server stops
@@@ -595,6 +606,7 @@ require('../../_shared/graceful').gracefulStop({
close: [
() => require('../../_shared/usage').stopRecorder(800),
() => runApi.pool.close(),
++ () => workerHub.close(),
() => guard.close(),
async () => { await telemetryRetention.stop(); await telemetry.stop(); },
() => toolOpens.stop(),
ddiff --git a/apps/gateway/server/workers/hub.js b/apps/gateway/server/workers/hub.js
nnew file mode 100644
iindex 0000000..53202c0
---- /dev/null
++++ b/apps/gateway/server/workers/hub.js
@@@ -0,0 +1,74 @@
++'use strict';
++const crypto = require('node:crypto');
++const { MAX_FRAME, TYPES, verify } = require('../../../worker/server/protocol');
++function createHub({ WebSocketServer, store, now = Date.now }) {
++ const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_FRAME, perMessageDeflate: false });
++ const connections = new Map(), nonces = new Map();
++ async function upgrade(req, socket, head) {
++ if (req.url !== '/api/v1/workers/connect') return false;
++ const deny = () => { socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); };
++ try {
++ const id = req.headers['x-worker-id'];
++ if (!/^wrk_[a-f0-9]{24}$/.test(id || '')) { deny(); return true; }
++ const worker = await store.lookup(id);
++ for (const [n, expiry] of nonces) if (expiry < now()) nonces.delete(n);
++ const nonce = `${id}:${req.headers['x-worker-nonce']}`;
++ if (!worker || !verify(worker.token_hash, req.headers, now()) || nonces.has(nonce)) { deny(); return true; }
++ nonces.set(nonce, now() + 60000);
++ wss.handleUpgrade(req, socket, head, ws => {
++ const old = connections.get(id);
++ if (old) old.ws.terminate();
++ const c = { id, location: worker.location, draining: worker.draining, ws, last: now(), pending: new Map(), capabilities: [], slots: 0, free: 0, load: 0, latency_ms: null };
++ connections.set(id, c);
++ ws.on('error', () => {});
++ ws.on('message', raw => {
++ let m;
++ try { m = JSON.parse(String(raw)); } catch { ws.close(1008); return; }
++ if (m.type === 'status') {
++ if (!Array.isArray(m.capabilities) || m.capabilities.some(x => !TYPES.includes(x)) || !Number.isInteger(m.slots) || m.slots < 1 || m.slots > 8 || !Number.isInteger(m.free) || m.free < 0 || m.free > m.slots || !Number.isFinite(m.load) || m.load < 0) { ws.close(1008); return; }
++ Object.assign(c, { capabilities: m.dry ? [] : [...new Set(m.capabilities)], slots: m.slots, free: m.dry ? 0 : m.free, load: m.load, last: now() });
++ ws.send(JSON.stringify({ type: 'heartbeat' })); // Also keeps the reverse proxy's upstream read deadline alive.
++ } else if (m.type === 'result') {
++ const p = c.pending.get(m.id);
++ if (!p) return;
++ c.pending.delete(m.id); clearTimeout(p.timer);
++ c.latency_ms = now() - p.started;
++ p.resolve(m);
++ } else ws.close(1008);
++ });
++ ws.on('close', () => {
++ if (connections.get(id) === c) connections.delete(id);
++ for (const p of c.pending.values()) { clearTimeout(p.timer); p.reject(Object.assign(new Error('worker disconnected'), { code: 'worker.disconnected' })); }
++ c.pending.clear();
++ });
++ ws.send(JSON.stringify({ type: 'ready', protocol: 1 }));
++ });
++ } catch { deny(); }
++ return true;
++ }
++ const sweep = setInterval(async () => {
++ for (const c of connections.values()) {
++ try {
++ const row = await store.lookup(c.id);
++ if (!row || now() - c.last > 45000) c.ws.terminate();
++ else c.draining = row.draining;
++ } catch { c.ws.terminate(); }
++ }
++ }, 15000);
++ sweep.unref();
++ function dispatch(c, job, signal) {
++ return new Promise((resolve, reject) => {
++ if (signal?.aborted) return reject(new Error('cancelled'));
++ const id = crypto.randomBytes(16).toString('hex');
++ const cancel = () => { c.ws.send(JSON.stringify({ type: 'cancel', id })); finish(new Error('cancelled')); };
++ const cleanup = () => { const p = c.pending.get(id); clearTimeout(p?.timer); c.pending.delete(id); signal?.removeEventListener('abort', cancel); };
++ const finish = err => { cleanup(); reject(err); };
++ const timer = setTimeout(() => { c.ws.terminate(); finish(new Error('worker timeout')); }, Math.min(job.timeoutMs || 900000, 900000));
++ c.pending.set(id, { started: now(), timer, resolve: m => { signal?.removeEventListener('abort', cancel); resolve(m); }, reject: err => { signal?.removeEventListener('abort', cancel); reject(err); } });
++ signal?.addEventListener('abort', cancel, { once: true });
++ c.ws.send(JSON.stringify({ type: 'job', id, jobType: job.type, input: job.input, files: job.files }));
++ });
++ }
++ return { upgrade, connections, dispatch, stats: () => [...connections.values()].map(({ id, location, draining, capabilities, slots, free, load, latency_ms }) => ({ id, location, draining, capabilities, slots, free, load, latency_ms })), close() { clearInterval(sweep); for (const c of connections.values()) c.ws.terminate(); wss.close(); } };
++}
++module.exports = { createHub };
ddiff --git a/apps/gateway/server/workers/owner.js b/apps/gateway/server/workers/owner.js
nnew file mode 100644
iindex 0000000..b5eeedc
---- /dev/null
++++ b/apps/gateway/server/workers/owner.js
@@@ -0,0 +1,13 @@
++'use strict';
++function ownerGuard({ verify, extractToken, origin, username = process.env.OWNER_USERNAME || 'goosely' }) {
++ return async (req, res, next) => {
++ res.set('Cache-Control', 'no-store');
++ try {
++ const claims = await verify(extractToken(req));
++ if (!claims || String(claims.username || '').toLowerCase() !== username.toLowerCase()) return res.status(403).json({ error: 'Owner access required' });
++ if (!['GET', 'HEAD'].includes(req.method) && req.headers.origin !== origin) return res.status(403).json({ error: 'Origin required' });
++ next();
++ } catch { res.status(403).json({ error: 'Owner access required' }); }
++ };
++}
++module.exports = { ownerGuard };
ddiff --git a/apps/gateway/server/workers/store.js b/apps/gateway/server/workers/store.js
nnew file mode 100644
iindex 0000000..0c37320
---- /dev/null
++++ b/apps/gateway/server/workers/store.js
@@@ -0,0 +1,18 @@
++'use strict';
++const crypto = require('node:crypto');
++const { hash } = require('../../../worker/server/protocol');
++function createStore(db) {
++ return {
++ async mint({ label, location = 'home' }) {
++ if (typeof label !== 'string' || !/^[\w .-]{1,80}$/.test(label) || !['home', 'host'].includes(location)) throw new Error('invalid worker label or location');
++ const id = `wrk_${crypto.randomBytes(12).toString('hex')}`;
++ const token = crypto.randomBytes(32).toString('base64url');
++ await db.prepare('INSERT INTO tools_workers (id,label,location,token_hash,created_at) VALUES (?,?,?,?,?)').run(id, label, location, hash(token), Date.now());
++ return { id, token }; // One response only; listing never selects token_hash.
++ },
++ lookup: id => db.prepare('SELECT id,token_hash,location,draining FROM tools_workers WHERE id=? AND revoked=FALSE').get(id),
++ list: () => db.prepare('SELECT id,label,location,draining,revoked,created_at FROM tools_workers ORDER BY created_at').all(),
++ routing: () => db.prepare('SELECT enabled,worker_order FROM tools_worker_routing WHERE id=1').get(),
++ };
++}
++module.exports = { createStore };
ddiff --git a/apps/gateway/test/security-ssrf.test.js b/apps/gateway/test/security-ssrf.test.js
iindex dc602c9..1bb3c6d 100644
---- a/apps/gateway/test/security-ssrf.test.js
++++ b/apps/gateway/test/security-ssrf.test.js
@@@ -76,6 +76,7 @@ function rawGet(port, p, headers = {}) {
await check('ratchet: every file that makes an outbound request itself is reviewed', () => {
const REVIEWED = {
++ 'worker/server/index.js': 'operator-configured HTTPS gateway only; fixed status/connect paths, never job input',
'gateway/server/auth/routes.js': 'Network JWKS, OAuth token and revoke (configured)',
'gateway/server/index.js': 'OpenVibe.Community paste API (configured; paths held to /api/pastes) and the satellites on loopback',
'gateway/server/net/routes.js': 'visitor-chosen hosts, through the egress guard',
ddiff --git a/apps/worker/README.md b/apps/worker/README.md
nnew file mode 100644
iindex 0000000..6bd5557
---- /dev/null
++++ b/apps/worker/README.md
@@@ -0,0 +1,9 @@
++# Tools worker protocol 1
++
++A single Node 22 process reuses the image, PDF and audio satellite job handlers and their validation, bounded worker threads and hardened ffmpeg execution. Jobs arrive as bytes over an outbound TLS WebSocket; no HTTP listener is opened. The gateway stores only a SHA-256 credential hash, verifies timestamped HMAC signatures, refuses replayed handshakes and drops stale connections. Worker credentials are independent of person credentials.
++
++`node apps/worker/server/index.js --dry-run` checks the production protocol without credentials, registering a worker or accepting any job. Real workers require `TOOLS_WORKER_ID`, `TOOLS_WORKER_TOKEN_FILE` (a private file), and optionally `TOOLS_WORKER_SLOTS` (1–8, default 2). `TOOLS_GATEWAY` defaults to `https://openvibe.tools` and must be an HTTPS origin.
++
++Only img.process, docs.process and audio.process are supported. Each job is capped at 16 MiB of input and output; larger jobs remain on the host. Input types are sniffed again on the worker. SVG and URL-fetching jobs are refused. Tool options are validated by the same satellite definitions. The gateway retains job ownership, quotas and result storage. No caller token crosses the worker channel.
++
++Worker routing is default-off. This first part installs the channel and execution runtime; routing and owner controls follow separately.
ddiff --git a/apps/worker/server/index.js b/apps/worker/server/index.js
nnew file mode 100644
iindex 0000000..ff06ff9
---- /dev/null
++++ b/apps/worker/server/index.js
@@@ -0,0 +1,47 @@
++'use strict';
++const fs = require('node:fs');
++const { createRuntime } = require('./runtime');
++const { sign, MAX_FRAME } = require('./protocol');
++async function main() {
++ const dry = process.argv.includes('--dry-run');
++ const gateway = new URL(process.env.TOOLS_GATEWAY || 'https://openvibe.tools');
++ if (gateway.protocol !== 'https:' || gateway.username || gateway.password || gateway.pathname !== '/' || gateway.search || gateway.hash) throw new Error('gateway must be an HTTPS origin');
++ if (dry) {
++ const r = await fetch(new URL('/api/v1/workers/status', gateway), { signal: AbortSignal.timeout(15000), redirect: 'error' });
++ const body = await r.json();
++ if (!r.ok || body.protocol !== 1 || body.dry_run !== true) throw new Error('worker protocol unavailable');
++ console.log('dry-run: production protocol 1 reachable; accepts_real_jobs=false');
++ return;
++ }
++ const token = fs.readFileSync(process.env.TOOLS_WORKER_TOKEN_FILE, 'utf8').trim();
++ const id = process.env.TOOLS_WORKER_ID;
++ if (!/^wrk_[a-f0-9]{24}$/.test(id || '') || !/^[A-Za-z0-9_-]{43}$/.test(token)) throw new Error('invalid worker credentials');
++ const WebSocket = require('../../gateway/node_modules/ws');
++ const runtime = createRuntime({ slots: Number(process.env.TOOLS_WORKER_SLOTS || 2) });
++ let ws, timer, retry, stopping = false;
++ const send = m => { if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(m)); };
++ const status = () => send({ type: 'status', ...runtime.status() });
++ function connect() {
++ const endpoint = new URL('/api/v1/workers/connect', gateway); endpoint.protocol = 'wss:';
++ ws = new WebSocket(endpoint, { headers: sign(token, id), maxPayload: MAX_FRAME, perMessageDeflate: false, handshakeTimeout: 15000 });
++ ws.on('open', () => { status(); timer = setInterval(status, 15000); });
++ ws.on('message', async raw => {
++ let m; try { m = JSON.parse(String(raw)); } catch { ws.close(); return; }
++ if (m.type === 'cancel') return runtime.cancel(m.id);
++ if (m.type !== 'job') return;
++ const connection = ws;
++ try {
++ const result = await runtime.run({ id: m.id, type: m.jobType, input: m.input, files: m.files });
++ if (ws === connection) send({ type: 'result', id: m.id, ok: true, result });
++ } catch { if (ws === connection) send({ type: 'result', id: m.id, ok: false, error: 'worker job failed' }); }
++ status();
++ });
++ ws.on('error', () => {}); // Never log a credential or server-supplied error.
++ ws.on('close', () => { runtime.cancelAll(); clearInterval(timer); if (!stopping) retry = setTimeout(connect, 5000); });
++ }
++ async function stop() { stopping = true; clearTimeout(retry); clearInterval(timer); ws?.terminate(); await runtime.close(); }
++ process.once('SIGTERM', stop); process.once('SIGINT', stop);
++ connect();
++}
++if (require.main === module) main().catch(() => { console.error('Tools worker could not start; check configuration and gateway availability.'); process.exitCode = 1; });
++module.exports = { main };
ddiff --git a/apps/worker/server/protocol.js b/apps/worker/server/protocol.js
nnew file mode 100644
iindex 0000000..4777369
---- /dev/null
++++ b/apps/worker/server/protocol.js
@@@ -0,0 +1,25 @@
++'use strict';
++const crypto = require('node:crypto');
++const MAX_BYTES = 16 * 1024 * 1024;
++const MAX_FRAME = 48 * 1024 * 1024;
++const TYPES = ['img.process', 'docs.process', 'audio.process'];
++const hash = token => crypto.createHash('sha256').update(token).digest('hex');
++const signature = (key, id, timestamp, nonce) => crypto.createHmac('sha256', key).update(`${id}\n${timestamp}\n${nonce}`).digest('hex');
++function sign(token, id) {
++ const timestamp = String(Date.now()), nonce = crypto.randomBytes(16).toString('hex');
++ return { authorization: `Bearer ${token}`, 'x-worker-id': id, 'x-worker-time': timestamp, 'x-worker-nonce': nonce, 'x-worker-signature': signature(token, id, timestamp, nonce) };
++}
++function verify(key, headers, now = Date.now()) {
++ const id = headers['x-worker-id'], ts = headers['x-worker-time'], nonce = headers['x-worker-nonce'], sig = headers['x-worker-signature'];
++ if (!/^wrk_[a-f0-9]{24}$/.test(id || '') || !/^\d{13}$/.test(ts || '') || Math.abs(now - Number(ts)) > 30000 || !/^[a-f0-9]{32}$/.test(nonce || '') || !/^[a-f0-9]{64}$/.test(sig || '')) return false;
++ const token = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(headers.authorization || '')?.[1];
++ if (!token || !crypto.timingSafeEqual(Buffer.from(hash(token), 'hex'), Buffer.from(key, 'hex'))) return false;
++ return crypto.timingSafeEqual(Buffer.from(signature(token, id, ts, nonce), 'hex'), Buffer.from(sig, 'hex'));
++}
++function bytes(file) {
++ if (!file || typeof file.bytes !== 'string' || file.bytes.length > Math.ceil(MAX_BYTES / 3) * 4) throw new Error('invalid file bytes');
++ const b = Buffer.from(file.bytes, 'base64');
++ if (!b.length || b.length > MAX_BYTES || b.toString('base64') !== file.bytes) throw new Error('file exceeds worker transfer limit');
++ return b;
++}
++module.exports = { MAX_BYTES, MAX_FRAME, TYPES, hash, sign, verify, bytes };
ddiff --git a/apps/worker/server/runtime.js b/apps/worker/server/runtime.js
nnew file mode 100644
iindex 0000000..ec8f2f8
---- /dev/null
++++ b/apps/worker/server/runtime.js
@@@ -0,0 +1,74 @@
++'use strict';
++// Only local byte transforms are advertised. No caller token, URL, path or executable is accepted.
++const fs = require('node:fs/promises');
++const os = require('node:os');
++const path = require('node:path');
++const { MAX_BYTES, TYPES, bytes } = require('./protocol');
++const { detect } = require('../../_shared/guard/sniff');
++const { poolFromEnv } = require('../../_shared/jobs/pool');
++function definitions() {
++ const defs = new Map(), pools = [];
++ for (const app of ['img', 'docs', 'audio']) {
++ try {
++ const process = require(`../../${app}/server/process`);
++ const pool = app === 'audio' ? null : poolFromEnv(app, path.resolve(__dirname, `../../${app}/server/worker.js`), { size: 1, maxQueue: 0 });
++ if (pool) pools.push(pool);
++ process.defineJobs({ define: def => defs.set(def.type, def), JobError: Error }, pool);
++ } catch { /* A missing dependency removes this capability, rather than accepting work it cannot run. */ }
++ }
++ return { defs, close: () => Promise.all(pools.map(p => p.close())) };
++}
++function createRuntime({ slots = 2, dry = false, registry = definitions(), root = os.tmpdir() } = {}) {
++ if (!Number.isInteger(slots) || slots < 1 || slots > 8) throw new Error('slots must be 1..8');
++ const active = new Map();
++ function status() { return { capabilities: dry ? [] : [...registry.defs.keys()].filter(k => TYPES.includes(k)), slots, free: dry ? 0 : slots - active.size, load: os.loadavg()[0], dry }; }
++ async function run(job) {
++ const def = registry.defs.get(job.type);
++ if (dry || !TYPES.includes(job.type) || !def || active.size >= slots || active.has(job.id) || !/^[a-f0-9]{32}$/.test(job.id || '')) throw new Error('job refused');
++ if (!job.input || typeof job.input !== 'object' || Array.isArray(job.input) || JSON.stringify(job.input).length > 65536) throw new Error('invalid input');
++ if (!Array.isArray(job.files) || job.files.length < def.minFiles || job.files.length > def.maxFiles) throw new Error('invalid files');
++ const ctrl = new AbortController();
++ active.set(job.id, ctrl);
++ let dir, result;
++ const timer = setTimeout(() => ctrl.abort(), def.timeoutMs);
++ try {
++ dir = await fs.mkdtemp(path.join(root, 'ov-tools-job-'));
++ const outDir = path.join(dir, 'out');
++ await fs.mkdir(outDir);
++ let total = 0;
++ const files = [];
++ for (const f of job.files) {
++ const b = bytes(f); total += b.length;
++ if (total > MAX_BYTES) throw new Error('job exceeds worker transfer limit');
++ const detected = detect(b);
++ const allowed = job.type === 'img.process' ? /^image\// : job.type === 'docs.process' ? /^(application\/pdf|image\/)/ : /^(audio|video)\//;
++ // SVG can contain external resource references. Keep it on the host.
++ if (!detected || !allowed.test(detected.mime) || detected.mime === 'image/svg+xml') throw new Error('unsupported file');
++ const local = path.join(dir, `${files.length}.${detected.ext}`);
++ await fs.writeFile(local, b, { mode: 0o600 });
++ files.push({ path: local, name: `${files.length}.${detected.ext}`, mime: detected.mime, size: b.length });
++ }
++ const invalid = def.validate(job.input, files);
++ if (invalid) throw new Error('invalid tool options');
++ if (ctrl.signal.aborted) throw new Error('cancelled');
++ result = await def.run({ input: job.input, files, outDir, signal: ctrl.signal, progress() {} });
++ if (ctrl.signal.aborted) throw new Error('cancelled');
++ const output = [];
++ total = 0;
++ for (const f of result.files || []) {
++ const size = (await fs.stat(f.path)).size; total += size;
++ if (total > MAX_BYTES) throw new Error('result exceeds worker transfer limit');
++ output.push({ name: path.basename(f.name), mime: f.mime, bytes: (await fs.readFile(f.path)).toString('base64') });
++ }
++ return { files: output, data: result.data || {} };
++ } finally {
++ clearTimeout(timer);
++ active.delete(job.id);
++ // Audio tools write into their own configured temp directory.
++ for (const f of result?.files || []) await fs.rm(f.path, { force: true }).catch(() => {});
++ if (dir) await fs.rm(dir, { recursive: true, force: true });
++ }
++ }
++ return { status, run, cancel: id => active.get(id)?.abort(), cancelAll: () => { for (const c of active.values()) c.abort(); }, close: async () => { for (const c of active.values()) c.abort(); await registry.close(); } };
++}
++module.exports = { createRuntime, definitions };
ddiff --git a/apps/worker/test/worker.test.js b/apps/worker/test/worker.test.js
nnew file mode 100644
iindex 0000000..7f1ff3a
---- /dev/null
++++ b/apps/worker/test/worker.test.js
@@@ -0,0 +1,60 @@
++'use strict';
++const assert = require('node:assert/strict');
++const crypto = require('node:crypto');
++const http = require('node:http');
++const { once } = require('node:events');
++const WebSocket = require('../../gateway/node_modules/ws');
++const { hash, sign, verify, bytes } = require('../server/protocol');
++const { createRuntime } = require('../server/runtime');
++const { createHub } = require('../../gateway/server/workers/hub');
++(async () => {
++ const { ownerGuard } = require('../../gateway/server/workers/owner');
++ let admitted = false, status;
++ const response = { set() {}, status(n) { status = n; return this; }, json() {} };
++ const guard = ownerGuard({ verify: async token => token === 'owner-test' ? { username: 'owner' } : null, extractToken: r => r.token, origin: 'https://openvibe.tools', username: 'owner' });
++ await guard({ token: 'owner-test', method: 'POST', headers: {} }, response, () => { admitted = true; });
++ assert.equal(status, 403); assert.equal(admitted, false);
++ await guard({ token: 'owner-test', method: 'POST', headers: { origin: 'https://openvibe.tools' } }, response, () => { admitted = true; });
++ assert.equal(admitted, true);
++ const token = crypto.randomBytes(32).toString('base64url'), id = `wrk_${crypto.randomBytes(12).toString('hex')}`;
++ const headers = sign(token, id);
++ assert(verify(hash(token), headers));
++ assert(!verify(hash(token), { ...headers, 'x-worker-id': `wrk_${'0'.repeat(24)}` }));
++ assert(!verify(hash(token), headers, Date.now() + 61000));
++ assert.throws(() => bytes({ bytes: 'not base64' }));
++ const registry = { defs: new Map([['img.process', { minFiles: 1, maxFiles: 1, timeoutMs: 1000, validate: () => null, run: async () => ({ files: [], data: { done: true } }) }]]), close: async () => {} };
++ const runtime = createRuntime({ registry, slots: 1 });
++ const job = { id: crypto.randomBytes(16).toString('hex'), type: 'img.process', input: {}, files: [{ bytes: Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"/>').toString('base64') }] };
++ await assert.rejects(runtime.run(job), /unsupported file/);
++ await assert.rejects(runtime.run({ ...job, type: 'yt.download' }), /refused/);
++ const dry = createRuntime({ registry, dry: true });
++ assert.deepEqual(dry.status().capabilities, []);
++ await assert.rejects(dry.run(job), /refused/);
++ assert.equal(runtime.status().free, 1);
++ const real = createRuntime({ slots: 1 });
++ const sharp = require('../../img/node_modules/sharp');
++ const png = await sharp({ create: { width: 2, height: 2, channels: 3, background: 'red' } }).png().toBuffer();
++ const converted = await real.run({ ...job, input: { tool: 'convert', format: 'png' }, files: [{ bytes: png.toString('base64') }] });
++ assert.equal(converted.files[0].mime, 'image/png');
++ assert.equal((await sharp(Buffer.from(converted.files[0].bytes, 'base64')).metadata()).width, 2);
++ await real.close();
++ const store = { lookup: async key => key === id ? { token_hash: hash(token), location: 'home', draining: false } : null };
++ const hub = createHub({ WebSocketServer: WebSocket.WebSocketServer, store });
++ const server = http.createServer();
++ server.on('upgrade', (req, sock, head) => hub.upgrade(req, sock, head));
++ server.listen(0, '127.0.0.1'); await once(server, 'listening');
++ const url = `ws://127.0.0.1:${server.address().port}/api/v1/workers/connect`;
++ const ws = new WebSocket(url, { headers });
++ await once(ws, 'open');
++ ws.send(JSON.stringify({ type: 'status', capabilities: ['img.process'], slots: 1, free: 1, load: 0 }));
++ await new Promise(r => setTimeout(r, 30));
++ assert.equal(hub.stats()[0].free, 1);
++ const replay = new WebSocket(url, { headers });
++ await new Promise(resolve => replay.on('unexpected-response', (_req, res) => { assert.equal(res.statusCode, 401); res.resume(); replay.terminate(); resolve(); }).on('error', () => {}));
++ const c = hub.connections.get(id);
++ const pending = hub.dispatch(c, { type: 'img.process', input: {}, files: [], timeoutMs: 1000 });
++ ws.terminate();
++ await assert.rejects(pending, /disconnected/);
++ hub.close(); await new Promise(r => server.close(r)); await runtime.close(); await dry.close();
++ console.log('worker: signed channel, replay refusal, disconnect, dry mode, fetch/SVG refusal passed');
++})().catch(e => { console.error(e); process.exitCode = 1; });
ddiff --git a/migrations/0013_tools_workers.sql b/migrations/0013_tools_workers.sql
nnew file mode 100644
iindex 0000000..b74d5cc
---- /dev/null
++++ b/migrations/0013_tools_workers.sql
@@@ -0,0 +1,16 @@
++-- phase: expand
++CREATE TABLE IF NOT EXISTS tools_workers (
++ id TEXT PRIMARY KEY,
++ label TEXT NOT NULL,
++ location TEXT NOT NULL DEFAULT 'home',
++ token_hash TEXT NOT NULL,
++ draining BOOLEAN NOT NULL DEFAULT FALSE,
++ revoked BOOLEAN NOT NULL DEFAULT FALSE,
++ created_at BIGINT NOT NULL
++);
++CREATE TABLE IF NOT EXISTS tools_worker_routing (
++ id INTEGER PRIMARY KEY CHECK (id = 1),
++ enabled BOOLEAN NOT NULL DEFAULT FALSE,
++ worker_order TEXT NOT NULL DEFAULT '["home","host"]'
++);
++INSERT INTO tools_worker_routing (id) VALUES (1) ON CONFLICT DO NOTHING;
ddiff --git a/package.json b/package.json
iindex 9087386..0019a7b 100644
---- a/package.json
++++ b/package.json
@@@ -15,7 +15,10 @@
"start:audio": "npm --prefix apps/audio start",
"start:text": "npm --prefix apps/text start",
"start:docs": "npm --prefix apps/docs start",
-- "test:discovery": "node test/run.js gateway/test/home-jobs gateway/test/tool-opens gateway/test/discovery gateway/test/run-api _shared/test/job-usage gateway/test/perf-budget"
++ "test:discovery": "node test/run.js gateway/test/home-jobs gateway/test/tool-opens gateway/test/discovery gateway/test/run-api _shared/test/job-usage gateway/test/perf-budget",
++ "test:worker": "node apps/worker/test/worker.test.js",
++ "test:suite": "node --unhandled-rejections=strict test/run.js",
++ "test:worker-security": "node apps/gateway/test/security-ssrf.test.js"
},
"license": "MIT",
"engines": {