openvibers/openvibe.network Commits
The Dock's agent view, timeline and Actor through Network's frame relay (#744)
Changes
@@@ -595,6 +595,72 @@ async function createFrameService(db, config, analytics, { privateKey = null, is(req) => (ITEM.test(String(req.params.id || '')) ? `/api/v1/store/${req.params.id}/buy` : null)));router.get('/wallet/api/v1/kinds', walletRelay('inventory', 'GET', '/api/v1/kinds'));++ // ── The Dock's Agents tab (openvibe-shared dock-agent.js, dock-timeline.js and dock-actor.js) ───────────────++ // The Dock runs on every OpenVibe site, and every site's connect-src allows openvibe.network and refuses++ // openvibe.codes and openvibe.actor (2026-11-09: 49 sites logged 1-4 refusals at 1280). These fixed paths are++ // the Dock's three reads and its controls: the person's Bearer token goes upstream and never a cookie, the++ // bodies are JSON of at most 8 KB, redirects are not followed, and Codes' and Actor's status and answer come++ // back as they are. Nothing is kept: every answer is the person's own.++ const dockAuth = (req) => (/^Bearer [A-Za-z0-9._~+/=-]{10,4096}$/.test(String(req.get('authorization') || '')) ? req.get('authorization') : '');++ const DOCK_ID = /^[A-Za-z0-9._~:-]{1,160}$/;++ const dockId = (v) => (DOCK_ID.test(String(v || '')) ? String(v) : '');++ function dockRelay(service, method, upstream, { image = false } = {}) {++ const upstreamPath = typeof upstream === 'function' ? upstream : () => upstream;++ const fallback = service === 'codes' ? 'http://127.0.0.1:4900' : 'http://127.0.0.1:4950';++ return async (req, res) => {++ res.set({ 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'private, no-store' });++ const auth = dockAuth(req);++ if (!auth) return res.status(401).json({ error: 'sign in to use the Dock' });++ const path = upstreamPath(req);++ if (!path) return res.status(404).json({ error: 'not found' });++ const headers = { Accept: image ? 'image/jpeg, image/webp' : 'application/json', Authorization: auth };++ const init = { method, headers, redirect: 'manual', signal: AbortSignal.timeout(15000) };++ if (method !== 'GET') { headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(req.body || {}); }++ try {++ const r = await fetch(new URL(path, svcUrl(service, fallback)).href, init);++ if (image) {++ const bytes = Buffer.from(await r.arrayBuffer());++ res.set('Content-Type', String(r.headers.get('content-type') || 'application/octet-stream').slice(0, 80));++ return res.status(r.status).send(bytes);++ }++ const text = await r.text();++ res.set('Content-Type', 'application/json; charset=utf-8');++ return res.status(r.status).send(text.slice(0, 64 * 1024));++ } catch { return res.status(502).json({ error: 'The Dock service did not answer' }); }++ };++ }++ const dockJson = express.json({ limit: '8kb' });++ router.options(['/agent-view', '/agent-view/*', '/agent/*', '/tasks/*', '/actor', '/actor/*'], (req, res) => {++ res.set({ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Authorization, Content-Type', 'Access-Control-Allow-Methods': 'GET, POST', 'Access-Control-Max-Age': '600' });++ res.status(204).end();++ });++ // OpenVibe.Codes: the person's agent stream, its last still, and the four controls.++ router.get('/agent-view', dockRelay('codes', 'GET', '/api/v1/me/agent-view'));++ router.get('/agent-view/frame', dockRelay('codes', 'GET', '/api/v1/me/agent-view/frame', { image: true }));++ for (const what of ['pause', 'curtain', 'mute', 'end']) router.post(`/agent-view/${what}`, dockJson, dockRelay('codes', 'POST', `/api/v1/me/agent-view/${what}`));++ // OpenVibe.Actor: the one timeline behind the Dock, the approvals answered in the mode that owns them, the++ // kill switch, and the task hand-off the timeline's Take over / Give it back buttons use.++ router.get('/agent/timeline', dockRelay('actor', 'GET', (req) => {++ const q = new URLSearchParams();++ const mode = String(req.query.mode || '');++ if (/^[a-z]{1,20}$/.test(mode)) q.set('mode', mode);++ if (/^(1|true)$/.test(String(req.query.needs_you || ''))) q.set('needs_you', '1');++ const cursor = String(req.query.cursor || '');++ if (/^[A-Za-z0-9._~=-]{1,300}$/.test(cursor)) q.set('cursor', cursor);++ if (String(req.query.scope || '') === 'shared') q.set('scope', 'shared');++ return '/api/v1/agent/timeline' + (q.toString() ? '?' + q.toString() : '');++ }));++ router.post('/agent/approvals/:product/:id/decide', dockJson, dockRelay('actor', 'POST', (req) => {++ const product = String(req.params.product || ''), id = dockId(req.params.id);++ return /^[a-z][a-z0-9_-]{0,39}$/.test(product) && id ? `/api/v1/agent/approvals/${encodeURIComponent(product)}/${encodeURIComponent(id)}/decide` : null;++ }));++ router.post('/agent/kill-switch', dockJson, dockRelay('actor', 'POST', '/api/v1/agent/kill-switch'));++ for (const what of ['takeover', 'giveback']) router.post(`/tasks/:id/${what}`, dockJson, dockRelay('actor', 'POST', (req) => (dockId(req.params.id) ? `/api/v1/tasks/${encodeURIComponent(req.params.id)}/${what}` : null)));++ // OpenVibe.Actor's block at the top of the tab: your Actor, its first ask, and one line to tell it something.++ router.get('/actor', dockRelay('actor', 'GET', '/api/v1/me/actor'));++ for (const what of ['pause', 'resume', 'tell']) router.post(`/actor/${what}`, dockJson, dockRelay('actor', 'POST', `/api/v1/me/actor/${what}`));++ for (const what of ['answer', 'decline']) router.post(`/actor/asks/:id/${what}`, dockJson, dockRelay('actor', 'POST', (req) => (dockId(req.params.id) ? `/api/v1/me/actor/asks/${encodeURIComponent(req.params.id)}/${what}` : null)));++// GET /api/frame/suggest?q=&owner=&limit= — the navbar's magnifier (openvibe-shared search.js): OpenVibe.Search's// suggestions, asked through Network because every site's connect-src already lets the navbar reach// openvibe.network, and 33 of them refused search.openvibe.network (2026-10-28). Anonymous, as the browser's own callnnew file mode 100644@@@ -0,0 +1,179 @@++'use strict';++// /api/frame/agent-view, /api/frame/agent/timeline and /api/frame/actor: the shared Dock's OpenVibe.Codes and++// OpenVibe.Actor calls on every site, relayed through Network because every site's connect-src lets the Dock reach++// openvibe.network and refuses openvibe.codes and openvibe.actor (2026-11-09: 49 sites logged the refusals). Only++// the person's Bearer token goes upstream, never a cookie; fixed paths; JSON bodies at most 8 KB; upstream status++// and answer pass back; nothing is cached, and a signed-out page asks upstream nothing.++const assert = require('assert');++const http = require('http');++const express = require('express');++const { getDb } = require('../server/db/database');++const { createFrameService } = require('../server/frame/service');++++const TOKEN = 'Bearer abcdefghijklmnop.qrstu';++// A one-pixel JPEG: the still the Dock shows when nothing is live.++const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0xff, 0xd9]);++++(async () => {++ const asked = [];++ let down = false;++ const stub = (name, handler) => {++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ asked.push({ svc: name, method: req.method, url: req.url, auth: req.headers.authorization || '', cookie: req.headers.cookie || '', body: Buffer.concat(chunks).toString('utf8') });++ if (down) { res.statusCode = 503; return res.end('{"error":"down"}'); }++ return handler(req, res, new URL(req.url, 'http://x'));++ });++ });++ return server;++ };++ const codes = stub('codes', (req, res, u) => {++ if (u.pathname === '/api/v1/me/agent-view' && req.method === 'GET') {++ res.setHeader('content-type', 'application/json');++ return res.end(JSON.stringify({ agent: { id: 'ags_1', state: 'live', title: 'Ship it' } }));++ }++ if (u.pathname === '/api/v1/me/agent-view/frame' && req.method === 'GET') {++ res.writeHead(200, { 'content-type': 'image/jpeg', 'cache-control': 'private, max-age=10' });++ return res.end(JPEG);++ }++ if (/^\/api\/v1\/me\/agent-view\/(pause|curtain|mute|end)$/.test(u.pathname) && req.method === 'POST') {++ res.setHeader('content-type', 'application/json');++ return res.end(JSON.stringify({ agent: { id: 'ags_1', state: 'live', control: u.pathname.split('/').pop() } }));++ }++ res.writeHead(404, { 'content-type': 'application/json' });++ return res.end('{"error":"no route"}');++ });++ const actor = stub('actor', (req, res, u) => {++ res.setHeader('content-type', 'application/json');++ if (u.pathname === '/api/v1/agent/timeline' && req.method === 'GET') return res.end(JSON.stringify({ now: null, needs_you: [], entries: [], counts: { mode: u.searchParams.get('mode') } }));++ if (/^\/api\/v1\/agent\/approvals\/[a-z]+\/[A-Za-z0-9._~-]+\/decide$/.test(u.pathname) && req.method === 'POST') { res.statusCode = 201; return res.end(JSON.stringify({ product: 'actor', state: 'approved' })); }++ if (u.pathname === '/api/v1/agent/kill-switch' && req.method === 'POST') return res.end(JSON.stringify({ ok: true }));++ if (/^\/api\/v1\/tasks\/[A-Za-z0-9._~-]+\/(takeover|giveback)$/.test(u.pathname) && req.method === 'POST') return res.end(JSON.stringify({ ok: true }));++ if (u.pathname === '/api/v1/me/actor' && req.method === 'GET') return res.end(JSON.stringify({ id: 'act_1', name: 'My Actor', status: 'resting', asks: [] }));++ if (/^\/api\/v1\/me\/actor\/(pause|resume|tell)$/.test(u.pathname) && req.method === 'POST') return res.end(JSON.stringify({ ok: true }));++ if (/^\/api\/v1\/me\/actor\/asks\/[A-Za-z0-9._~-]+\/(answer|decline)$/.test(u.pathname) && req.method === 'POST') return res.end(JSON.stringify({ ok: true }));++ res.statusCode = 404;++ return res.end('{"error":"no route"}');++ });++ await Promise.all([codes, actor].map((s) => new Promise((r) => s.listen(0, '127.0.0.1', r))));++ const svc = await createFrameService(getDb(), { services: {++ codes: { internalUrl: `http://127.0.0.1:${codes.address().port}` },++ actor: { internalUrl: `http://127.0.0.1:${actor.address().port}` },++ } }, null);++ const app = express();++ app.set('trust proxy', 'loopback');++ app.use('/api/frame', svc.router);++ const server = app.listen(0, '127.0.0.1');++ await new Promise((r) => server.once('listening', r));++ const base = `http://127.0.0.1:${server.address().port}/api/frame`;++ const get = async (p, headers) => { const r = await fetch(`${base}${p}`, { headers }); return { r, d: await r.json().catch(() => null) }; };++ const send = async (p, body = {}) => {++ const r = await fetch(`${base}${p}`, { method: 'POST', headers: { Authorization: TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });++ return { r, d: await r.json().catch(() => null) };++ };++++ // The agent view: the person's token upstream, never their cookie, and never kept.++ let { r, d } = await get('/agent-view', { Authorization: TOKEN, Cookie: 'ov_token=secret' });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(d.agent.title, 'Ship it');++ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*', 'any site may ask');++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store', 'a personal answer is not kept');++ let hit = asked.find((x) => x.url === '/api/v1/me/agent-view' && x.method === 'GET');++ assert.strictEqual(hit.auth, TOKEN, "the person's token goes along");++ assert.strictEqual(hit.cookie, '', 'a cookie never does');++++ // Signed out: a 401 and nothing asked upstream.++ const before = asked.length;++ ({ r } = await get('/agent-view'));++ assert.strictEqual(r.status, 401, 'no token, no agent view');++ ({ r } = await get('/agent/timeline'));++ assert.strictEqual(r.status, 401, 'no token, no timeline');++ ({ r } = await get('/actor'));++ assert.strictEqual(r.status, 401, 'no token, no Actor');++ assert.strictEqual(asked.length, before, 'none of the signed-out calls asked upstream');++++ // The still is the picture itself, with Codes' type.++ const still = await fetch(`${base}/agent-view/frame`, { headers: { Authorization: TOKEN } });++ assert.strictEqual(still.status, 200);++ assert.strictEqual(still.headers.get('content-type'), 'image/jpeg');++ assert.deepStrictEqual(Buffer.from(await still.arrayBuffer()), JPEG, 'the bytes pass through');++ hit = asked.find((x) => x.url === '/api/v1/me/agent-view/frame');++ assert.strictEqual(hit.auth, TOKEN, 'the still is asked with the token');++++ // The four controls post the Dock's body and pass Codes' answer back.++ for (const what of ['pause', 'curtain', 'mute', 'end']) {++ ({ r, d } = await send(`/agent-view/${what}`, { paused: true }));++ assert.strictEqual(r.status, 200);++ assert.strictEqual(d.agent.control, what);++ hit = asked.find((x) => x.url === `/api/v1/me/agent-view/${what}`);++ assert.strictEqual(hit.method, 'POST');++ assert.deepStrictEqual(JSON.parse(hit.body), { paused: true }, 'the body passes');++ assert.strictEqual(hit.auth, TOKEN, 'and the token');++ }++++ // The timeline passes its four filters and drops anything else.++ ({ d } = await get('/agent/timeline?mode=code&needs_you=1&cursor=c2&scope=shared&subject=usr_other&limit=500', { Authorization: TOKEN }));++ assert.strictEqual(d.counts.mode, 'code');++ hit = new URL(asked.filter((x) => x.url.startsWith('/api/v1/agent/timeline')).at(-1).url, 'http://x');++ assert.deepStrictEqual([...hit.searchParams.keys()].sort(), ['cursor', 'mode', 'needs_you', 'scope'], 'only the Dock\'s filters go upstream');++ assert.strictEqual(hit.searchParams.get('needs_you'), '1');++++ // An approval keeps its product and id fixed; a bad one asks upstream nothing.++ ({ r, d } = await send('/agent/approvals/actor/apr_01JZ/decide', { decision: 'approve' }));++ assert.strictEqual(r.status, 201, 'the upstream status passes');++ assert.strictEqual(d.state, 'approved');++ hit = asked.find((x) => x.url === '/api/v1/agent/approvals/actor/apr_01JZ/decide');++ assert.deepStrictEqual(JSON.parse(hit.body), { decision: 'approve' });++ const askedBefore = asked.length;++ ({ r } = await send('/agent/approvals/../../etc/apr_1/decide', { decision: 'approve' }));++ assert.ok(r.status === 404 || r.status === 401, 'a bad product is refused');++ ({ r } = await send('/agent/approvals/actor/bad id/decide', { decision: 'approve' }));++ assert.strictEqual(r.status, 404, 'a bad id is refused');++ assert.strictEqual(asked.length, askedBefore, 'neither asked upstream');++++ // The kill switch and the task hand-off.++ ({ r } = await send('/agent/kill-switch', { action: 'stop' }));++ assert.strictEqual(r.status, 200);++ ({ r } = await send('/tasks/tsk_01JZ/takeover'));++ assert.strictEqual(r.status, 200);++ assert.ok(asked.some((x) => x.url === '/api/v1/tasks/tsk_01JZ/takeover' && x.method === 'POST'));++++ // The Actor block and its ask.++ ({ r, d } = await get('/actor', { Authorization: TOKEN }));++ assert.strictEqual(r.status, 200);++ assert.strictEqual(d.name, 'My Actor');++ for (const what of ['pause', 'resume', 'tell']) {++ ({ r } = await send(`/actor/${what}`, { text: 'hello' }));++ assert.strictEqual(r.status, 200);++ }++ for (const what of ['answer', 'decline']) {++ ({ r } = await send(`/actor/asks/ask_1/${what}`, { option: 'yes' }));++ assert.strictEqual(r.status, 200);++ assert.ok(asked.some((x) => x.url === `/api/v1/me/actor/asks/ask_1/${what}` && x.method === 'POST'));++ }++++ // A preflight is answered for every path; an upstream status passes back, and a service that is not there is++ // a 502, never a hang.++ const opt = await fetch(`${base}/agent/timeline`, { method: 'OPTIONS' });++ assert.strictEqual(opt.status, 204);++ assert.strictEqual(opt.headers.get('access-control-allow-origin'), '*');++ assert.match(opt.headers.get('access-control-allow-headers'), /Authorization/);++ down = true;++ ({ r } = await get('/agent-view', { Authorization: TOKEN }));++ assert.strictEqual(r.status, 503, "Codes' own status passes back");++ down = false;++ await new Promise((resolve) => actor.close(resolve));++ ({ r } = await get('/actor', { Authorization: TOKEN }));++ assert.strictEqual(r.status, 502, 'Actor not answering is a 502');++++ // Bodies over 8 KB are refused before they reach a service.++ const big = await fetch(`${base}/actor/tell`, { method: 'POST', headers: { Authorization: TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ text: 'x'.repeat(9000) }) });++ assert.strictEqual(big.status, 413, 'an 8 KB ceiling');++++ server.close();++ console.log('frame dock relay: the Dock\'s Codes and Actor calls through Network');++ console.log('all passed');++ process.exit(0);++})().catch((e) => { console.error(e.stack || e); process.exit(1); });@@@ -127,7 +127,7 @@ const REVIEWED = {'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],'server/today/receipts.js fetch': [1, 'Mail\'s internal URL from config (OV_MAIL_INTERNAL_URL, loopback 5250): the person\'s own token to the fixed path /api/v1/me/mail/receipts, 2.5 s timeout; never a request value names a host'],'server/wallet/routes.js fetch': [2, 'Billing internal URL from config (OV_BILLING_INTERNAL_URL, loopback 4600) only: the person\'s own token for /api/me/activity and every /api/me read and write, and — only while /api/me/activity is missing — the marked temporary journal fallback, a billing.balance.read service read scoped to the signed-in person\'s own usr_ id; the paths are fixed, a charge id must be a vch_ ULID and is percent-encoded, and no request value ever names a host. The second call site is Network\'s own loopback (config.internalUrl, else 127.0.0.1:<port>): the fixed path POST /internal/subject-assertions, which signs the person\'s own token into a 60-second subject assertion; the person\'s token is in the body, never in the URL, and no request value names a host'],-- 'server/frame/service.js fetch': [6, 'The composer\'s pictures and posts go to OpenVibe.Community\'s fixed paths (POST /api/v1/spaces/status/attachments, POST /api/v1/posts) on its internal URL. Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links); Community internal URL from config for the menu composer relay (POST /api/frame/status, fixed path, the person\'s Bearer only); Billing and Inventory internal URLs from config for the wallet relays (/api/frame/wallet/…, fixed paths, a usr_/itd_ id must match its pattern, the person\'s Bearer only, no redirects, 8 s)'],++ 'server/frame/service.js fetch': [7, 'The composer\'s pictures and posts go to OpenVibe.Community\'s fixed paths (POST /api/v1/spaces/status/attachments, POST /api/v1/posts) on its internal URL. Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links); Community internal URL from config for the menu composer relay (POST /api/frame/status, fixed path, the person\'s Bearer only); Billing and Inventory internal URLs from config for the wallet relays (/api/frame/wallet/…, fixed paths, a usr_/itd_ id must match its pattern, the person\'s Bearer only, no redirects, 8 s); Codes and Actor internal URLs from config for the Dock relays (/api/frame/agent-view…, /agent/timeline, /agent/approvals/…, /agent/kill-switch, /tasks/…, /actor…, fixed paths, product and id matched against patterns and percent-encoded, the person\'s Bearer only, no cookie, no redirects, 15 s)'],'server/frame/menu-cards.js fetch': [2, 'https://<open site host>/.well-known/openvibe-menu.json, hosts from the site registry only, no redirects, 5 s, 64 KB (menu cards)'],'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],
@@ -595,6 +595,72 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -595,6 +595,72 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
(req) => (ITEM.test(String(req.params.id || '')) ? `/api/v1/store/${req.params.id}/buy` : null)));
(req) => (ITEM.test(String(req.params.id || '')) ? `/api/v1/store/${req.params.id}/buy` : null)));
router.get('/wallet/api/v1/kinds', walletRelay('inventory', 'GET', '/api/v1/kinds'));
router.get('/wallet/api/v1/kinds', walletRelay('inventory', 'GET', '/api/v1/kinds'));
+ // ── The Dock's Agents tab (openvibe-shared dock-agent.js, dock-timeline.js and dock-actor.js) ───────────────
+ // The Dock runs on every OpenVibe site, and every site's connect-src allows openvibe.network and refuses
+ // openvibe.codes and openvibe.actor (2026-11-09: 49 sites logged 1-4 refusals at 1280). These fixed paths are
+ // the Dock's three reads and its controls: the person's Bearer token goes upstream and never a cookie, the
+ // bodies are JSON of at most 8 KB, redirects are not followed, and Codes' and Actor's status and answer come
+ // back as they are. Nothing is kept: every answer is the person's own.
+ const dockAuth = (req) => (/^Bearer [A-Za-z0-9._~+/=-]{10,4096}$/.test(String(req.get('authorization') || '')) ? req.get('authorization') : '');
+ const DOCK_ID = /^[A-Za-z0-9._~:-]{1,160}$/;
+ const dockId = (v) => (DOCK_ID.test(String(v || '')) ? String(v) : '');
+ function dockRelay(service, method, upstream, { image = false } = {}) {
+ const upstreamPath = typeof upstream === 'function' ? upstream : () => upstream;
+ const fallback = service === 'codes' ? 'http://127.0.0.1:4900' : 'http://127.0.0.1:4950';
+ return async (req, res) => {
+ res.set({ 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'private, no-store' });
+ const auth = dockAuth(req);
+ if (!auth) return res.status(401).json({ error: 'sign in to use the Dock' });
+ const path = upstreamPath(req);
+ if (!path) return res.status(404).json({ error: 'not found' });
+ const headers = { Accept: image ? 'image/jpeg, image/webp' : 'application/json', Authorization: auth };
+ const init = { method, headers, redirect: 'manual', signal: AbortSignal.timeout(15000) };
+ if (method !== 'GET') { headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(req.body || {}); }
+ try {
+ const r = await fetch(new URL(path, svcUrl(service, fallback)).href, init);
+ if (image) {
+ const bytes = Buffer.from(await r.arrayBuffer());
+ res.set('Content-Type', String(r.headers.get('content-type') || 'application/octet-stream').slice(0, 80));
+ return res.status(r.status).send(bytes);
+ }
+ const text = await r.text();
+ res.set('Content-Type', 'application/json; charset=utf-8');
+ return res.status(r.status).send(text.slice(0, 64 * 1024));
+ } catch { return res.status(502).json({ error: 'The Dock service did not answer' }); }
+ };
+ }
+ const dockJson = express.json({ limit: '8kb' });
+ router.options(['/agent-view', '/agent-view/*', '/agent/*', '/tasks/*', '/actor', '/actor/*'], (req, res) => {
+ res.set({ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Authorization, Content-Type', 'Access-Control-Allow-Methods': 'GET, POST', 'Access-Control-Max-Age': '600' });
+ res.status(204).end();
+ });
+ // OpenVibe.Codes: the person's agent stream, its last still, and the four controls.
+ router.get('/agent-view', dockRelay('codes', 'GET', '/api/v1/me/agent-view'));
+ router.get('/agent-view/frame', dockRelay('codes', 'GET', '/api/v1/me/agent-view/frame', { image: true }));
+ for (const what of ['pause', 'curtain', 'mute', 'end']) router.post(`/agent-view/${what}`, dockJson, dockRelay('codes', 'POST', `/api/v1/me/agent-view/${what}`));
+ // OpenVibe.Actor: the one timeline behind the Dock, the approvals answered in the mode that owns them, the
+ // kill switch, and the task hand-off the timeline's Take over / Give it back buttons use.
+ router.get('/agent/timeline', dockRelay('actor', 'GET', (req) => {
+ const q = new URLSearchParams();
+ const mode = String(req.query.mode || '');
+ if (/^[a-z]{1,20}$/.test(mode)) q.set('mode', mode);
+ if (/^(1|true)$/.test(String(req.query.needs_you || ''))) q.set('needs_you', '1');
+ const cursor = String(req.query.cursor || '');
+ if (/^[A-Za-z0-9._~=-]{1,300}$/.test(cursor)) q.set('cursor', cursor);
+ if (String(req.query.scope || '') === 'shared') q.set('scope', 'shared');
+ return '/api/v1/agent/timeline' + (q.toString() ? '?' + q.toString() : '');
+ }));
+ router.post('/agent/approvals/:product/:id/decide', dockJson, dockRelay('actor', 'POST', (req) => {
+ const product = String(req.params.product || ''), id = dockId(req.params.id);
+ return /^[a-z][a-z0-9_-]{0,39}$/.test(product) && id ? `/api/v1/agent/approvals/${encodeURIComponent(product)}/${encodeURIComponent(id)}/decide` : null;
+ }));
+ router.post('/agent/kill-switch', dockJson, dockRelay('actor', 'POST', '/api/v1/agent/kill-switch'));
+ for (const what of ['takeover', 'giveback']) router.post(`/tasks/:id/${what}`, dockJson, dockRelay('actor', 'POST', (req) => (dockId(req.params.id) ? `/api/v1/tasks/${encodeURIComponent(req.params.id)}/${what}` : null)));
+ // OpenVibe.Actor's block at the top of the tab: your Actor, its first ask, and one line to tell it something.
+ router.get('/actor', dockRelay('actor', 'GET', '/api/v1/me/actor'));
+ for (const what of ['pause', 'resume', 'tell']) router.post(`/actor/${what}`, dockJson, dockRelay('actor', 'POST', `/api/v1/me/actor/${what}`));
+ for (const what of ['answer', 'decline']) router.post(`/actor/asks/:id/${what}`, dockJson, dockRelay('actor', 'POST', (req) => (dockId(req.params.id) ? `/api/v1/me/actor/asks/${encodeURIComponent(req.params.id)}/${what}` : null)));
+
// GET /api/frame/suggest?q=&owner=&limit= — the navbar's magnifier (openvibe-shared search.js): OpenVibe.Search's
// GET /api/frame/suggest?q=&owner=&limit= — the navbar's magnifier (openvibe-shared search.js): OpenVibe.Search's
// suggestions, asked through Network because every site's connect-src already lets the navbar reach
// suggestions, asked through Network because every site's connect-src already lets the navbar reach
// openvibe.network, and 33 of them refused search.openvibe.network (2026-10-28). Anonymous, as the browser's own call
// openvibe.network, and 33 of them refused search.openvibe.network (2026-10-28). Anonymous, as the browser's own call
new file mode 100644
new file mode 100644
@@ -0,0 +1,179 @@
@@ -0,0 +1,179 @@
+'use strict';
+// /api/frame/agent-view, /api/frame/agent/timeline and /api/frame/actor: the shared Dock's OpenVibe.Codes and
+// OpenVibe.Actor calls on every site, relayed through Network because every site's connect-src lets the Dock reach
+// openvibe.network and refuses openvibe.codes and openvibe.actor (2026-11-09: 49 sites logged the refusals). Only
+// the person's Bearer token goes upstream, never a cookie; fixed paths; JSON bodies at most 8 KB; upstream status
+// and answer pass back; nothing is cached, and a signed-out page asks upstream nothing.
+const assert = require('assert');
+const http = require('http');
+const express = require('express');
+const { getDb } = require('../server/db/database');
+const { createFrameService } = require('../server/frame/service');
+
+const TOKEN = 'Bearer abcdefghijklmnop.qrstu';
+// A one-pixel JPEG: the still the Dock shows when nothing is live.
+const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0xff, 0xd9]);
+
+(async () => {
+ const asked = [];
+ let down = false;
+ const stub = (name, handler) => {
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ asked.push({ svc: name, method: req.method, url: req.url, auth: req.headers.authorization || '', cookie: req.headers.cookie || '', body: Buffer.concat(chunks).toString('utf8') });
+ if (down) { res.statusCode = 503; return res.end('{"error":"down"}'); }
+ return handler(req, res, new URL(req.url, 'http://x'));
+ });
+ });
+ return server;
+ };
+ const codes = stub('codes', (req, res, u) => {
+ if (u.pathname === '/api/v1/me/agent-view' && req.method === 'GET') {
+ res.setHeader('content-type', 'application/json');
+ return res.end(JSON.stringify({ agent: { id: 'ags_1', state: 'live', title: 'Ship it' } }));
+ }
+ if (u.pathname === '/api/v1/me/agent-view/frame' && req.method === 'GET') {
+ res.writeHead(200, { 'content-type': 'image/jpeg', 'cache-control': 'private, max-age=10' });
+ return res.end(JPEG);
+ }
+ if (/^\/api\/v1\/me\/agent-view\/(pause|curtain|mute|end)$/.test(u.pathname) && req.method === 'POST') {
+ res.setHeader('content-type', 'application/json');
+ return res.end(JSON.stringify({ agent: { id: 'ags_1', state: 'live', control: u.pathname.split('/').pop() } }));
+ }
+ res.writeHead(404, { 'content-type': 'application/json' });
+ return res.end('{"error":"no route"}');
+ });
+ const actor = stub('actor', (req, res, u) => {
+ res.setHeader('content-type', 'application/json');
+ if (u.pathname === '/api/v1/agent/timeline' && req.method === 'GET') return res.end(JSON.stringify({ now: null, needs_you: [], entries: [], counts: { mode: u.searchParams.get('mode') } }));
+ if (/^\/api\/v1\/agent\/approvals\/[a-z]+\/[A-Za-z0-9._~-]+\/decide$/.test(u.pathname) && req.method === 'POST') { res.statusCode = 201; return res.end(JSON.stringify({ product: 'actor', state: 'approved' })); }
+ if (u.pathname === '/api/v1/agent/kill-switch' && req.method === 'POST') return res.end(JSON.stringify({ ok: true }));
+ if (/^\/api\/v1\/tasks\/[A-Za-z0-9._~-]+\/(takeover|giveback)$/.test(u.pathname) && req.method === 'POST') return res.end(JSON.stringify({ ok: true }));
+ if (u.pathname === '/api/v1/me/actor' && req.method === 'GET') return res.end(JSON.stringify({ id: 'act_1', name: 'My Actor', status: 'resting', asks: [] }));
+ if (/^\/api\/v1\/me\/actor\/(pause|resume|tell)$/.test(u.pathname) && req.method === 'POST') return res.end(JSON.stringify({ ok: true }));
+ if (/^\/api\/v1\/me\/actor\/asks\/[A-Za-z0-9._~-]+\/(answer|decline)$/.test(u.pathname) && req.method === 'POST') return res.end(JSON.stringify({ ok: true }));
+ res.statusCode = 404;
+ return res.end('{"error":"no route"}');
+ });
+ await Promise.all([codes, actor].map((s) => new Promise((r) => s.listen(0, '127.0.0.1', r))));
+ const svc = await createFrameService(getDb(), { services: {
+ codes: { internalUrl: `http://127.0.0.1:${codes.address().port}` },
+ actor: { internalUrl: `http://127.0.0.1:${actor.address().port}` },
+ } }, null);
+ const app = express();
+ app.set('trust proxy', 'loopback');
+ app.use('/api/frame', svc.router);
+ const server = app.listen(0, '127.0.0.1');
+ await new Promise((r) => server.once('listening', r));
+ const base = `http://127.0.0.1:${server.address().port}/api/frame`;
+ const get = async (p, headers) => { const r = await fetch(`${base}${p}`, { headers }); return { r, d: await r.json().catch(() => null) }; };
+ const send = async (p, body = {}) => {
+ const r = await fetch(`${base}${p}`, { method: 'POST', headers: { Authorization: TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
+ return { r, d: await r.json().catch(() => null) };
+ };
+
+ // The agent view: the person's token upstream, never their cookie, and never kept.
+ let { r, d } = await get('/agent-view', { Authorization: TOKEN, Cookie: 'ov_token=secret' });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(d.agent.title, 'Ship it');
+ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*', 'any site may ask');
+ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store', 'a personal answer is not kept');
+ let hit = asked.find((x) => x.url === '/api/v1/me/agent-view' && x.method === 'GET');
+ assert.strictEqual(hit.auth, TOKEN, "the person's token goes along");
+ assert.strictEqual(hit.cookie, '', 'a cookie never does');
+
+ // Signed out: a 401 and nothing asked upstream.
+ const before = asked.length;
+ ({ r } = await get('/agent-view'));
+ assert.strictEqual(r.status, 401, 'no token, no agent view');
+ ({ r } = await get('/agent/timeline'));
+ assert.strictEqual(r.status, 401, 'no token, no timeline');
+ ({ r } = await get('/actor'));
+ assert.strictEqual(r.status, 401, 'no token, no Actor');
+ assert.strictEqual(asked.length, before, 'none of the signed-out calls asked upstream');
+
+ // The still is the picture itself, with Codes' type.
+ const still = await fetch(`${base}/agent-view/frame`, { headers: { Authorization: TOKEN } });
+ assert.strictEqual(still.status, 200);
+ assert.strictEqual(still.headers.get('content-type'), 'image/jpeg');
+ assert.deepStrictEqual(Buffer.from(await still.arrayBuffer()), JPEG, 'the bytes pass through');
+ hit = asked.find((x) => x.url === '/api/v1/me/agent-view/frame');
+ assert.strictEqual(hit.auth, TOKEN, 'the still is asked with the token');
+
+ // The four controls post the Dock's body and pass Codes' answer back.
+ for (const what of ['pause', 'curtain', 'mute', 'end']) {
+ ({ r, d } = await send(`/agent-view/${what}`, { paused: true }));
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(d.agent.control, what);
+ hit = asked.find((x) => x.url === `/api/v1/me/agent-view/${what}`);
+ assert.strictEqual(hit.method, 'POST');
+ assert.deepStrictEqual(JSON.parse(hit.body), { paused: true }, 'the body passes');
+ assert.strictEqual(hit.auth, TOKEN, 'and the token');
+ }
+
+ // The timeline passes its four filters and drops anything else.
+ ({ d } = await get('/agent/timeline?mode=code&needs_you=1&cursor=c2&scope=shared&subject=usr_other&limit=500', { Authorization: TOKEN }));
+ assert.strictEqual(d.counts.mode, 'code');
+ hit = new URL(asked.filter((x) => x.url.startsWith('/api/v1/agent/timeline')).at(-1).url, 'http://x');
+ assert.deepStrictEqual([...hit.searchParams.keys()].sort(), ['cursor', 'mode', 'needs_you', 'scope'], 'only the Dock\'s filters go upstream');
+ assert.strictEqual(hit.searchParams.get('needs_you'), '1');
+
+ // An approval keeps its product and id fixed; a bad one asks upstream nothing.
+ ({ r, d } = await send('/agent/approvals/actor/apr_01JZ/decide', { decision: 'approve' }));
+ assert.strictEqual(r.status, 201, 'the upstream status passes');
+ assert.strictEqual(d.state, 'approved');
+ hit = asked.find((x) => x.url === '/api/v1/agent/approvals/actor/apr_01JZ/decide');
+ assert.deepStrictEqual(JSON.parse(hit.body), { decision: 'approve' });
+ const askedBefore = asked.length;
+ ({ r } = await send('/agent/approvals/../../etc/apr_1/decide', { decision: 'approve' }));
+ assert.ok(r.status === 404 || r.status === 401, 'a bad product is refused');
+ ({ r } = await send('/agent/approvals/actor/bad id/decide', { decision: 'approve' }));
+ assert.strictEqual(r.status, 404, 'a bad id is refused');
+ assert.strictEqual(asked.length, askedBefore, 'neither asked upstream');
+
+ // The kill switch and the task hand-off.
+ ({ r } = await send('/agent/kill-switch', { action: 'stop' }));
+ assert.strictEqual(r.status, 200);
+ ({ r } = await send('/tasks/tsk_01JZ/takeover'));
+ assert.strictEqual(r.status, 200);
+ assert.ok(asked.some((x) => x.url === '/api/v1/tasks/tsk_01JZ/takeover' && x.method === 'POST'));
+
+ // The Actor block and its ask.
+ ({ r, d } = await get('/actor', { Authorization: TOKEN }));
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(d.name, 'My Actor');
+ for (const what of ['pause', 'resume', 'tell']) {
+ ({ r } = await send(`/actor/${what}`, { text: 'hello' }));
+ assert.strictEqual(r.status, 200);
+ }
+ for (const what of ['answer', 'decline']) {
+ ({ r } = await send(`/actor/asks/ask_1/${what}`, { option: 'yes' }));
+ assert.strictEqual(r.status, 200);
+ assert.ok(asked.some((x) => x.url === `/api/v1/me/actor/asks/ask_1/${what}` && x.method === 'POST'));
+ }
+
+ // A preflight is answered for every path; an upstream status passes back, and a service that is not there is
+ // a 502, never a hang.
+ const opt = await fetch(`${base}/agent/timeline`, { method: 'OPTIONS' });
+ assert.strictEqual(opt.status, 204);
+ assert.strictEqual(opt.headers.get('access-control-allow-origin'), '*');
+ assert.match(opt.headers.get('access-control-allow-headers'), /Authorization/);
+ down = true;
+ ({ r } = await get('/agent-view', { Authorization: TOKEN }));
+ assert.strictEqual(r.status, 503, "Codes' own status passes back");
+ down = false;
+ await new Promise((resolve) => actor.close(resolve));
+ ({ r } = await get('/actor', { Authorization: TOKEN }));
+ assert.strictEqual(r.status, 502, 'Actor not answering is a 502');
+
+ // Bodies over 8 KB are refused before they reach a service.
+ const big = await fetch(`${base}/actor/tell`, { method: 'POST', headers: { Authorization: TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ text: 'x'.repeat(9000) }) });
+ assert.strictEqual(big.status, 413, 'an 8 KB ceiling');
+
+ server.close();
+ console.log('frame dock relay: the Dock\'s Codes and Actor calls through Network');
+ console.log('all passed');
+ process.exit(0);
+})().catch((e) => { console.error(e.stack || e); process.exit(1); });
@@ -127,7 +127,7 @@ const REVIEWED = {
@@ -127,7 +127,7 @@ const REVIEWED = {
'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],
'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],
'server/today/receipts.js fetch': [1, 'Mail\'s internal URL from config (OV_MAIL_INTERNAL_URL, loopback 5250): the person\'s own token to the fixed path /api/v1/me/mail/receipts, 2.5 s timeout; never a request value names a host'],
'server/today/receipts.js fetch': [1, 'Mail\'s internal URL from config (OV_MAIL_INTERNAL_URL, loopback 5250): the person\'s own token to the fixed path /api/v1/me/mail/receipts, 2.5 s timeout; never a request value names a host'],
'server/wallet/routes.js fetch': [2, 'Billing internal URL from config (OV_BILLING_INTERNAL_URL, loopback 4600) only: the person\'s own token for /api/me/activity and every /api/me read and write, and — only while /api/me/activity is missing — the marked temporary journal fallback, a billing.balance.read service read scoped to the signed-in person\'s own usr_ id; the paths are fixed, a charge id must be a vch_ ULID and is percent-encoded, and no request value ever names a host. The second call site is Network\'s own loopback (config.internalUrl, else 127.0.0.1:<port>): the fixed path POST /internal/subject-assertions, which signs the person\'s own token into a 60-second subject assertion; the person\'s token is in the body, never in the URL, and no request value names a host'],
'server/wallet/routes.js fetch': [2, 'Billing internal URL from config (OV_BILLING_INTERNAL_URL, loopback 4600) only: the person\'s own token for /api/me/activity and every /api/me read and write, and — only while /api/me/activity is missing — the marked temporary journal fallback, a billing.balance.read service read scoped to the signed-in person\'s own usr_ id; the paths are fixed, a charge id must be a vch_ ULID and is percent-encoded, and no request value ever names a host. The second call site is Network\'s own loopback (config.internalUrl, else 127.0.0.1:<port>): the fixed path POST /internal/subject-assertions, which signs the person\'s own token into a 60-second subject assertion; the person\'s token is in the body, never in the URL, and no request value names a host'],
- 'server/frame/service.js fetch': [6, 'The composer\'s pictures and posts go to OpenVibe.Community\'s fixed paths (POST /api/v1/spaces/status/attachments, POST /api/v1/posts) on its internal URL. Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links); Community internal URL from config for the menu composer relay (POST /api/frame/status, fixed path, the person\'s Bearer only); Billing and Inventory internal URLs from config for the wallet relays (/api/frame/wallet/…, fixed paths, a usr_/itd_ id must match its pattern, the person\'s Bearer only, no redirects, 8 s)'],
+ 'server/frame/service.js fetch': [7, 'The composer\'s pictures and posts go to OpenVibe.Community\'s fixed paths (POST /api/v1/spaces/status/attachments, POST /api/v1/posts) on its internal URL. Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links); Community internal URL from config for the menu composer relay (POST /api/frame/status, fixed path, the person\'s Bearer only); Billing and Inventory internal URLs from config for the wallet relays (/api/frame/wallet/…, fixed paths, a usr_/itd_ id must match its pattern, the person\'s Bearer only, no redirects, 8 s); Codes and Actor internal URLs from config for the Dock relays (/api/frame/agent-view…, /agent/timeline, /agent/approvals/…, /agent/kill-switch, /tasks/…, /actor…, fixed paths, product and id matched against patterns and percent-encoded, the person\'s Bearer only, no cookie, no redirects, 15 s)'],
'server/frame/menu-cards.js fetch': [2, 'https://<open site host>/.well-known/openvibe-menu.json, hosts from the site registry only, no redirects, 5 s, 64 KB (menu cards)'],
'server/frame/menu-cards.js fetch': [2, 'https://<open site host>/.well-known/openvibe-menu.json, hosts from the site registry only, no redirects, 5 s, 64 KB (menu cards)'],
'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],
'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],
'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],
'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],