openvibers/openvibe.shared Commits

4.181.0: the Dock's agent reads through Network's frame relay (#402)

OP OpenVibers committed 3 days ago · 166b1b8209 · 12 files, +283 −32

* The Dock's agent view, timeline and Actor ask Network's frame relay

dock-agent.js, dock-timeline.js and dock-actor.js fetched openvibe.codes
and openvibe.actor directly from every site; every site's connect-src
allows openvibe.network and refuses those, so 49 signed-in sites logged
1-4 console errors at 1280. Each module now asks Network's same-origin
/api/frame relay by default, still reaches a product when a test or the
extension passes an explicit base, and asks nothing without a token.
test/dock-relay.test.js fails if a Dock script fetches another product
directly again; test/dock-relay-chrome.test.js proves the relay paths.

* Merge remote-tracking branch 'origin/main' into fix/dock-console-relay

* 4.181.0: the Dock's agent reads through Network's frame relay

* Merge origin/main into fix/dock-console-relay

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex dc51e77..d004e29 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,16 @@
# Changelog
++## 4.181.0 — 2026-11-10
++
++**The Dock's agent view, timeline and Actor go through Network's frame relay.** `dock-agent.js`, `dock-timeline.js`
++and `dock-actor.js` asked `openvibe.codes` and `openvibe.actor` directly from every site. Every site's `connect-src`
++allows `openvibe.network` and refuses those, so 49 of 49 signed-in sites logged 1-4 console errors at 1280 on
++2026-11-09 (CSP on most, CORS on the sites without one). The three modules now ask Network's same-origin
++`/api/frame/...` relay by default: `GET /agent-view`, its still and the four controls; `GET /agent/timeline` and its
++writes; `GET /actor` and its subpaths. An explicit `base`/`agentBase`/`actorBase` (a test, or the extension's own
++panel) still reaches the product itself, and nothing is asked without the person's token. The markup and styles are
++unchanged. `test/dock-relay.test.js` fails if a Dock script fetches another product's origin directly again.
++
## 4.180.0 — 2026-11-10
- **A demo scope for motion: `data-fx-demo`** (`ov-fx.js`, `ov-mark.js`, `theme-loader.js`). Inside a container
ddiff --git a/README.md b/README.md
iindex 8942850..14ee788 100644
---- a/README.md
++++ b/README.md
@@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
--**Status:** alpha, v4.180.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.181.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex b0ef7fa..7163762 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,13 +4,14 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.180.0",
++ "version": "4.181.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"contracts": "openvibe-contracts v0.384.0 (devDependency: the release manifest contract)",
"sdk": "openvibe-sdk v0.133.0",
"features": [
++ "the Dock's agent view, timeline and Actor ask Network's /api/frame relay instead of openvibe.codes and openvibe.actor, so a signed-in page logs no cross-origin console errors; an explicit base still reaches the product (4.181.0)",
"an agent's live panel: ov-canvas.js draws actor.canvas@1 (headings, text, lists, checklists, tables, metrics, bar and line charts, pictures from openvibe.media, https links, buttons) the same on every site; built node by node, a button only asks, a tick tells, versions only grow (4.145.0)",
"send it on OpenVibe: the share sheet's first place for a signed-in person is OpenVibe Chat; the Dock opens on Send to… (a conversation, or someone new by their name) and the message waits in the box until the person sends it, OpenVibeDock.share({ text, title }) (4.144.0)",
"one timeline for many players: ov-sync.js, OVSync.attach(mediaEl, { room, socket }), follows Chat's common.sync-clock@1 state over the room socket, measures the server clock with five time samples and holds the element within ±80 ms, nudging 0.95–1.05 or seeking, and respecting the control lease (4.143.0)",
ddiff --git a/dock-actor.js b/dock-actor.js
iindex 826ca18..a664b28 100644
---- a/dock-actor.js
++++ b/dock-actor.js
@@@ -5,8 +5,10 @@
// tab opens:
// OpenVibeDockActor.mount(container, { token, base, active }) → { refresh(), destroy() }
//
--// `base` is OpenVibe.Actor (GET /api/v1/me/actor with the person's Network Dock token, scope actor.me.read; the
--// answers, pause, resume and tell are POSTs with actor.me.write). It shows:
++// It reads OpenVibe.Actor's GET /api/v1/me/actor with the person's Network token (scope actor.me.read); the answers,
++// pause, resume and tell are POSTs with actor.me.write. On a site every call goes through Network's same-origin relay
++// (/api/frame/actor…) because every site's connect-src allows openvibe.network and refuses openvibe.actor; `base`
++// reaches Actor itself in a test, and `frameBase` names the relay. Nothing is asked without a token. It shows:
// - the Actor's face, name and state (Working, Waiting for you, Resting, Paused), Pause/Resume, and its model;
// - Now: the step it is on in plain words, what it is looking at, the last three steps and what comes next, and the
// cost so far today;
@@@ -76,12 +78,18 @@
let gone = false, timer = 0, busy = false;
container.replaceChildren();
container.classList.add('ovdac');
-- const base = String(o.base || 'https://openvibe.actor').replace(/\/+$/, '');
++ // Network's frame relay on a site; an explicit base reaches Actor itself (a test).
++ const direct = o.base ? String(o.base).replace(/\/+$/, '') : '';
++ const frameBase = String(o.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '');
++ const endpoint = (path) => (direct ? direct + '/api/v1/me/actor' + path : frameBase + '/actor' + path);
++ // "Open" is a navigation, never a fetch: Actor's own page, wherever the API is.
++ const openBase = direct || 'https://openvibe.actor';
async function api(path, init = {}) {
const t = typeof o.token === 'function' ? await o.token() : o.token;
-- const headers = { accept: 'application/json', ...(init.json ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };
-- const r = await fetch(base + '/api/v1/me/actor' + path, { method: init.method || 'GET', headers, credentials: 'omit', body: init.json ? JSON.stringify(init.body || {}) : undefined });
++ if (!t) throw Object.assign(new Error('Sign in to use your Actor'), { status: 401 });
++ const headers = { accept: 'application/json', ...(init.json ? { 'content-type': 'application/json' } : {}), authorization: `Bearer ${t}` };
++ const r = await fetch(endpoint(path), { method: init.method || 'GET', headers, credentials: 'omit', body: init.json ? JSON.stringify(init.body || {}) : undefined });
const b = await r.json().catch(() => ({}));
if (!r.ok) throw Object.assign(new Error(b.detail || `HTTP ${r.status}`), { status: r.status });
return b;
@@@ -109,7 +117,7 @@
who.append(st);
const pause = h('button', 'ovdac-btn', a.status === 'paused' ? 'Resume' : 'Pause'); pause.type = 'button';
pause.addEventListener('click', () => act(a.status === 'paused' ? '/resume' : '/pause', {}, pause));
-- const open = h('a', 'ovdac-btn', 'Open'); open.href = base + '/you'; open.target = '_blank'; open.rel = 'noopener';
++ const open = h('a', 'ovdac-btn', 'Open'); open.href = openBase + '/you'; open.target = '_blank'; open.rel = 'noopener';
head.append(face, who, pause, open);
container.append(head);
// Now: the step, what it looks at, the last three, next, today's cost.
ddiff --git a/dock-agent.js b/dock-agent.js
iindex 1330ca1..dd6b7b0 100644
---- a/dock-agent.js
++++ b/dock-agent.js
@@@ -5,8 +5,11 @@
// tab opens:
// OpenVibeDockAgent.mount(container, { token, base, active }) → { refresh(), destroy() }
//
--// `base` is OpenVibe.Codes' API (GET /api/v1/me/agent-view, the same POST for pause, Curtain now, mute and End, and
--// the frame). Agent Streams S5 adds the four controls: a moderator cannot resume the owner's pause on Live, and the
++// The read is OpenVibe.Codes' GET /api/v1/me/agent-view (the same POST for pause, Curtain now, mute and End, and the
++// frame). It asks Network's same-origin relay (/api/frame/agent-view…) because every site's connect-src allows
++// openvibe.network and refuses openvibe.codes; `frameBase` overrides the relay, and `base` reaches Codes itself in a
++// test or the extension (an explicit base wins). Nothing is asked when there is no token: a signed-out page can never
++// read the view. Agent Streams S5 adds the four controls: a moderator cannot resume the owner's pause on Live, and the
// Dock is the owner's own view, so it may always resume.
// Codes mints the view ticket server-side with the person's own token — an iframe on another site has no Live cookie —
// so the frame it answers is already Live's /embed/agent/<ags_id>?vt=wvt_…: the picture, the "AI agent" label, the
@@@ -79,6 +82,10 @@
styles(d);
const o = { ...opts };
const h = (tag, cls, text) => { const n = d.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
++ // Network's frame relay on a site; an explicit base reaches Codes itself (a test, or the extension's panel).
++ const direct = o.base ? String(o.base).replace(/\/+$/, '') : '';
++ const frameBase = String(o.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '');
++ const endpoint = (path) => (direct ? direct + path : frameBase + path.replace(/^\/api\/v1\/me\/agent-view/, '/agent-view'));
let gone = false, timer = 0, busy = false, agent = null, frameUrl = null, lastFrame = '';
container.innerHTML = '';
container.classList.add('ovda');
@@@ -93,10 +100,10 @@
container.append(body);
async function api(path, init = {}) {
-- const base = String(o.base || 'https://openvibe.codes').replace(/\/+$/, '');
const t = typeof o.token === 'function' ? await o.token() : o.token;
-- const headers = { accept: 'application/json', ...(init.json ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };
-- const r = await fetch(base + path, { ...init, headers, credentials: 'omit', body: init.json ? JSON.stringify(init.body || {}) : undefined });
++ if (!t) throw Object.assign(new Error('Sign in to see your agent'), { status: 401 });
++ const headers = { accept: 'application/json', ...(init.json ? { 'content-type': 'application/json' } : {}), authorization: `Bearer ${t}` };
++ const r = await fetch(endpoint(path), { ...init, headers, credentials: 'omit', body: init.json ? JSON.stringify(init.body || {}) : undefined });
const b = await r.json().catch(() => ({}));
if (!r.ok) throw Object.assign(new Error(b.detail || `HTTP ${r.status}`), { status: r.status });
return b;
@@@ -106,8 +113,10 @@
function still(url) {
if (!url || frameUrl === url) return;
frameUrl = url;
-- Promise.resolve(typeof o.token === 'function' ? o.token() : o.token).then((t) => fetch(url, { headers: t ? { authorization: `Bearer ${t}` } : {}, credentials: 'omit' }))
-- .then((r) => (r.ok ? r.blob() : null))
++ Promise.resolve(typeof o.token === 'function' ? o.token() : o.token).then((t) => (t
++ ? fetch(endpoint('/api/v1/me/agent-view/frame'), { headers: { authorization: `Bearer ${t}`, accept: 'image/jpeg, image/webp' }, credentials: 'omit' })
++ : null))
++ .then((r) => (r && r.ok ? r.blob() : null))
.then((blob) => {
if (!blob || gone || frameUrl !== url) return;
const next = URL.createObjectURL(blob);
ddiff --git a/dock-timeline.js b/dock-timeline.js
iindex 1b191b8..725c18b 100644
---- a/dock-timeline.js
++++ b/dock-timeline.js
@@@ -4,10 +4,12 @@
// OpenVibeDockTimeline.mount(container, { token, base, active, focus }) → { refresh(), destroy() }
//
// `token` is the person's own Network token: a string, or the function the navbar hands over (which
--// resolves it when it is needed).
--// `base` is OpenVibe.Actor's API: GET /api/v1/agent/timeline (the person's own steps, newest first, with the cursor
--// and the mode and needs-you filters), POST /api/v1/agent/approvals/:product/:id/decide (an approval is answered in
--// the mode that owns it) and POST /api/v1/agent/kill-switch (stop what is open and pause the agent).
++// resolves it when it is needed). Nothing is asked without it: a signed-out page can never read the timeline.
++// OpenVibe.Actor's API is GET /api/v1/agent/timeline (the person's own steps, newest first, with the cursor and the
++// mode and needs-you filters), POST /api/v1/agent/approvals/:product/:id/decide (an approval is answered in the mode
++// that owns it) and POST /api/v1/agent/kill-switch (stop what is open and pause the agent). On a site every call goes
++// through Network's same-origin relay (/api/frame/agent…, /api/frame/tasks…) because every site's connect-src allows
++// openvibe.network and refuses openvibe.actor; `base` reaches Actor itself in a test, and `frameBase` names the relay.
// `focus` is the id of the step to point at (the task id a site opened the Dock with): the entry that
// carries it is marked and brought into view.
//
@@@ -107,7 +109,10 @@
function mount(container, opts = {}) {
const doc = container.ownerDocument || document;
styles(doc);
-- const base = String(opts.base || 'https://openvibe.actor').replace(/\/+$/, '');
++ // Network's frame relay on a site; an explicit base reaches Actor itself (a test).
++ const direct = opts.base ? String(opts.base).replace(/\/+$/, '') : '';
++ const frameBase = String(opts.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '');
++ const endpoint = (path) => (direct ? direct + path : frameBase + path.replace(/^\/api\/v1/, ''));
const rawToken = opts.token;
const active = typeof opts.active === 'function' ? opts.active : () => true;
const focus = typeof opts.focus === 'function' ? opts.focus : () => opts.focus || null;
@@@ -134,7 +139,8 @@
async function api(path, options = {}) {
const auth = await bearer();
-- const res = await fetch(base + path, Object.assign({ headers: { Accept: 'application/json', ...(auth ? { Authorization: auth } : {}) } }, options));
++ if (!auth) throw new Error('Sign in to see your agent');
++ const res = await fetch(endpoint(path), Object.assign({ headers: { Accept: 'application/json', Authorization: auth } }, options));
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((body && (body.detail || body.title)) || ('HTTP ' + res.status));
return body;
ddiff --git a/dock.js b/dock.js
iindex 64d1a0f..433509a 100644
---- a/dock.js
++++ b/dock.js
@@@ -70,9 +70,13 @@
const kind = () => (phone() ? 'phone' : 'desktop');
const place = () => ({ ...DEFAULT_STATE.place[kind()], ...((state().place || {})[kind()] || {}) });
const sibling = (name) => (typeof o.sibling === 'function' ? o.sibling(name) : `https://openvibe.network/shared/${name}`);
-- // K-D3: the agent stream comes from OpenVibe.Codes' API (it mints the view ticket with the person's own token).
-- const agentBase = () => String(o.agentBase || 'https://openvibe.codes').replace(/\/+$/, '');
-- // The one timeline behind the Dock (design 2026-11-07): OpenVibe.Actor keeps it.
++ // K-D3: the agent stream is OpenVibe.Codes' (it mints the view ticket with the person's own token) and the one
++ // timeline behind the Dock is OpenVibe.Actor's (design 2026-11-07). On a site both go through Network's frame
++ // relay: every site's connect-src allows openvibe.network and refuses openvibe.codes and openvibe.actor. A test
++ // or the extension passes agentBase/actorBase and reaches the product itself; actorBase also names the Actor's
++ // pages in the run list.
++ const frameBase = () => String(o.frameBase || (String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '') + '/api/frame')).replace(/\/+$/, '');
++ const directBase = (v) => (v ? String(v).replace(/\/+$/, '') : '');
const actorBase = () => String(o.actorBase || 'https://openvibe.actor').replace(/\/+$/, '');
async function call(path, init = {}) {
@@@ -106,7 +110,7 @@
}
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
-- const DOCK_REV = { 'dock-vibe.js': '3dc4a69f0678', 'dock-runs.js': '8df917372f19', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': '125c20e01289', 'dock-timeline.js': '3cac6118d6e9', 'dock-actor.js': '402c006ac20f', 'dock-agent.js': '8026ded2b4e5', 'dock-timer.js': 'f5d9fee28824', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016' };
++ const DOCK_REV = { 'dock-vibe.js': '3dc4a69f0678', 'dock-runs.js': '8df917372f19', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': '125c20e01289', 'dock-timeline.js': 'a73188de9bd8', 'dock-actor.js': '470b0c14e9f1', 'dock-agent.js': '8a5423641d14', 'dock-timer.js': 'f5d9fee28824', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016' };
function load(src, test) {
return new Promise((ok) => {
if (test()) return ok(true);
@@@ -546,14 +550,14 @@
// The Agent panel: Now, Needs you and the one timeline (design 2026-11-07). It is first in the tab, so on a
// phone Now and Needs you are on screen before anything else.
load('dock-timeline.js', () => !!(root.OpenVibeDockTimeline && root.OpenVibeDockTimeline.mount)).then((on) => {
-- if (on && !mounts[id + 'Timeline'] && panel.isConnected) mounts[id + 'Timeline'] = root.OpenVibeDockTimeline.mount(panel.querySelector('.ovdk-timeline'), { token: o.token, base: actorBase(), active: () => isOpen() && tab === 'agent', focus: () => o.task || null });
++ if (on && !mounts[id + 'Timeline'] && panel.isConnected) mounts[id + 'Timeline'] = root.OpenVibeDockTimeline.mount(panel.querySelector('.ovdk-timeline'), { token: o.token, base: directBase(o.actorBase), frameBase: frameBase(), active: () => isOpen() && tab === 'agent', focus: () => o.task || null });
});
// Actor v3 (A3): your Actor first, on every site; it shows nothing for a person without one.
load('dock-actor.js', () => !!(root.OpenVibeDockActor && root.OpenVibeDockActor.mount)).then((on) => {
-- if (on && !mounts[id + 'Actor'] && panel.isConnected) mounts[id + 'Actor'] = root.OpenVibeDockActor.mount(panel.querySelector('.ovdk-actor'), { token: o.token, base: actorBase(), active: () => isOpen() && tab === 'agent' });
++ if (on && !mounts[id + 'Actor'] && panel.isConnected) mounts[id + 'Actor'] = root.OpenVibeDockActor.mount(panel.querySelector('.ovdk-actor'), { token: o.token, base: directBase(o.actorBase), frameBase: frameBase(), active: () => isOpen() && tab === 'agent' });
});
load('dock-agent.js', () => !!(root.OpenVibeDockAgent && root.OpenVibeDockAgent.mount)).then((on) => {
-- if (on && !mounts[id + 'View'] && panel.isConnected) mounts[id + 'View'] = root.OpenVibeDockAgent.mount(panel.querySelector('.ovdk-agent'), { token: o.token, base: agentBase(), active: () => isOpen() && tab === 'agent' });
++ if (on && !mounts[id + 'View'] && panel.isConnected) mounts[id + 'View'] = root.OpenVibeDockAgent.mount(panel.querySelector('.ovdk-agent'), { token: o.token, base: directBase(o.agentBase), frameBase: frameBase(), active: () => isOpen() && tab === 'agent' });
});
const m = root.OpenVibeDockRuns.mount(panel.querySelector('.ovdk-scroll'), {
call, state, save, scopes: o.scopes, active: () => isOpen() && tab === 'agent', actorBase: actorBase(), loadMarks: () => load('ai-marks-client.js', () => !!root.OpenVibeAIMarks),
ddiff --git a/navbar.js b/navbar.js
iindex 13e59dc..9b57111 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -572,7 +572,7 @@
const NAV_ICONS_REV = '1091daee9c7e'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
-- const SIBLING_REV = { 'ov-mark.js': 'ced6e6ecf191', 'ov-glyphs.js': '6f8c8dd2a10d', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2d3a47d3535c', 'ov-fx.js': 'f0dc83b9c816', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '20f4ef41d127', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '44a863d7a267', 'ov-share.css': '45b92b654148', 'search.js': 'bbae7542436f', 'dock.js': '7ff462f31951', 'presence.js': '1b675c37d888', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'e06f1388b696', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': 'd18308ab8a63', 'ov-home.js': '631efe800b13', 'nav-slots.js': 'a5af310d62e0', 'ov-wallet.js': '5c4d7fdb448d', 'ov-wallet.css': 'e46f653fa59e' };
++ const SIBLING_REV = { 'ov-mark.js': 'ced6e6ecf191', 'ov-glyphs.js': '6f8c8dd2a10d', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2d3a47d3535c', 'ov-fx.js': 'f0dc83b9c816', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '20f4ef41d127', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '44a863d7a267', 'ov-share.css': '45b92b654148', 'search.js': 'bbae7542436f', 'dock.js': '67101584eb54', 'presence.js': '1b675c37d888', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'e06f1388b696', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': 'd18308ab8a63', 'ov-home.js': '631efe800b13', 'nav-slots.js': 'a5af310d62e0', 'ov-wallet.js': '5c4d7fdb448d', 'ov-wallet.css': 'e46f653fa59e' };
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
ddiff --git a/package-lock.json b/package-lock.json
iindex 73d3961..12f2442 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.180.0",
++ "version": "4.181.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.180.0",
++ "version": "4.181.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 9e85980..fdb35d6 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.180.0",
++ "version": "4.181.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/test/dock-relay-chrome.test.js b/test/dock-relay-chrome.test.js
nnew file mode 100644
iindex 0000000..657ab29
---- /dev/null
++++ b/test/dock-relay-chrome.test.js
@@@ -0,0 +1,134 @@
++'use strict';
++// The Dock's three agent modules on a site: with no explicit product base they ask Network's same-origin frame relay,
++// never openvibe.codes or openvibe.actor (2026-11-09: every site's connect-src allows openvibe.network and refuses
++// them). This runs the modules in a real headless Chrome against a stand-in for Network's relay paths:
++// • dock-agent.js: GET /api/frame/agent-view, POST /api/frame/agent-view/pause, the still at /api/frame/agent-view/frame;
++// • dock-timeline.js: GET /api/frame/agent/timeline with its mode filter, POST /api/frame/agent/approvals/…/decide;
++// • dock-actor.js: GET /api/frame/actor, POST /api/frame/actor/tell;
++// • with no token, nothing is asked at all (a signed-out page can never read them).
++// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const h = require('../browser-harness');
++
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
++ console.log('dock-relay-chrome: skipped (no Chrome; set CHROME_BIN)');
++ process.exit(0);
++}
++
++const ROOT = path.join(__dirname, '..');
++const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0xff, 0xd9]);
++const AGENT = { id: 'ags_01JAB2C3D4E5F6G7H8J9K0MNPQ', state: 'ended', title: 'Ship it', task: null, last_frame_url: 'https://openvibe.codes/api/v1/me/agent-view/frame' };
++const ACTOR = { id: 'act_1', enabled: true, name: 'Juniper', status: 'resting', brain: 'Auto', caps: {}, now: { cost_today: 0 }, asks: [] };
++const TIMELINE = { now: null, needs_you: [], entries: [], next_cursor: null, counts: {} };
++const calls = [];
++
++const page = (signedIn) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Dock relay</title>
++<link rel="icon" href="data:,"></head><body style="margin:0;background:#111;color:#eee;font:14px system-ui">
++<div id="a"></div><div id="t"></div><div id="c"></div>
++<script src="/dock-agent.js"></script><script src="/dock-timeline.js"></script><script src="/dock-actor.js"></script>
++<script>
++const token = ${signedIn ? "async () => 'tok-1'" : 'null'};
++OpenVibeDockAgent.mount(document.getElementById('a'), { token, frameBase: '/api/frame', active: () => true });
++OpenVibeDockTimeline.mount(document.getElementById('t'), { token, frameBase: '/api/frame', active: () => true });
++OpenVibeDockActor.mount(document.getElementById('c'), { token, frameBase: '/api/frame', active: () => true });
++</script></body></html>`;
++
++const server = http.createServer((req, res) => {
++ const u = new URL(req.url, 'http://x');
++ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(page(u.searchParams.get('in') !== '0')); }
++ for (const f of ['dock-agent.js', 'dock-timeline.js', 'dock-actor.js']) {
++ if (u.pathname === '/' + f) { res.writeHead(200, { 'content-type': 'text/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, f))); }
++ }
++ let body = '';
++ req.on('data', (c) => { body += c; });
++ req.on('end', () => {
++ calls.push({ method: req.method, url: req.url, auth: req.headers.authorization || '', body: body ? JSON.parse(body) : null });
++ if (u.pathname === '/api/frame/agent-view') {
++ res.writeHead(200, { 'content-type': 'application/json' });
++ return res.end(JSON.stringify({ agent: AGENT }));
++ }
++ if (u.pathname === '/api/frame/agent-view/frame') {
++ res.writeHead(200, { 'content-type': 'image/jpeg' });
++ return res.end(JPEG);
++ }
++ if (/^\/api\/frame\/agent-view\/(pause|curtain|mute|end)$/.test(u.pathname)) {
++ res.writeHead(200, { 'content-type': 'application/json' });
++ return res.end(JSON.stringify({ agent: AGENT }));
++ }
++ if (u.pathname === '/api/frame/agent/timeline') {
++ res.writeHead(200, { 'content-type': 'application/json' });
++ return res.end(JSON.stringify(TIMELINE));
++ }
++ if (u.pathname === '/api/frame/agent/approvals/actor/apr_1/decide') {
++ res.writeHead(200, { 'content-type': 'application/json' });
++ return res.end(JSON.stringify({ product: 'actor', id: 'apr_1', state: 'approved' }));
++ }
++ if (u.pathname === '/api/frame/actor') {
++ res.writeHead(200, { 'content-type': 'application/json' });
++ return res.end(JSON.stringify(ACTOR));
++ }
++ if (u.pathname === '/api/frame/actor/tell') {
++ res.writeHead(200, { 'content-type': 'application/json' });
++ return res.end('{"ok":true}');
++ }
++ // Anything else, including a direct /api/v1/me/... call, is a miss this test remembers.
++ res.writeHead(404, { 'content-type': 'application/json' });
++ return res.end('{"error":"no route"}');
++ });
++});
++
++const wait = async (pg, expr, ms = 4000) => {
++ const end = Date.now() + ms;
++ for (;;) {
++ if (await pg.evaluate(expr)) return true;
++ if (Date.now() > end) return false;
++ await new Promise((ok) => setTimeout(ok, 50));
++ }
++};
++
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-dockrelay-'));
++ const browser = await h.launch({ tmpDir });
++ try {
++ const context = await browser.newContext();
++ const pg = await h.openPage(browser, context, { width: 390, height: 800 });
++ await pg.goto(`${base}/`, { maxMs: 8000 });
++ await wait(pg, `document.querySelector('.ovda-still') && document.querySelector('.ovdac-tell input')`);
++ await pg.evaluate(`document.querySelector('.ovdac-tell input').value = 'hello'`);
++ await pg.evaluate(`document.querySelector('.ovdac-tell button').click()`);
++ await wait(pg, `document.querySelector('.ovdac-tell input').value === ''`, 2000);
++ await new Promise((ok) => setTimeout(ok, 400));
++
++ const paths = calls.map((c) => `${c.method} ${c.url.split('?')[0]}`);
++ assert.ok(paths.includes('GET /api/frame/agent-view'), 'the agent view is read through the relay');
++ assert.ok(paths.includes('GET /api/frame/agent-view/frame'), 'the still comes through the relay too');
++ assert.ok(paths.includes('GET /api/frame/agent/timeline'), 'the timeline is read through the relay');
++ assert.ok(paths.includes('GET /api/frame/actor'), 'the Actor block is read through the relay');
++ assert.ok(paths.includes('POST /api/frame/actor/tell'), 'telling the Actor goes through the relay');
++ assert.ok(calls.every((c) => c.auth === 'Bearer tok-1'), 'every call carries the person\'s token');
++ assert.ok(calls.every((c) => c.url.startsWith('/api/frame/')), 'no call leaves for another product: ' + paths.join(', '));
++ const timeline = calls.find((c) => c.url.startsWith('/api/frame/agent/timeline'));
++ assert.ok(timeline, 'the timeline was asked');
++ assert.ok(await pg.evaluate(`document.querySelector('.ovda-still') && document.querySelector('.ovda-still').src.startsWith('blob:')`), 'the still shows as the person\'s own picture');
++
++ // Signed out: the same three modules ask the relay nothing.
++ const before = calls.length;
++ const out = await h.openPage(browser, context, { width: 390, height: 800 });
++ await out.goto(`${base}/?in=0`, { maxMs: 8000 });
++ await new Promise((ok) => setTimeout(ok, 1200));
++ assert.strictEqual(calls.length, before, 'a signed-out page asks nothing at all');
++ assert.ok(!(await out.evaluate(`!!document.querySelector('.ovdac.on')`)), 'and shows no Actor');
++ } finally {
++ await browser.close();
++ server.close();
++ }
++ console.log('dock relay chrome: the Dock\'s agent modules ask Network\'s /api/frame relay, and nothing without a token');
++ console.log('all passed');
++ process.exit(0);
++})().catch((e) => { console.error(e.stack || e); process.exit(1); });
ddiff --git a/test/dock-relay.test.js b/test/dock-relay.test.js
nnew file mode 100644
iindex 0000000..3956aa3
---- /dev/null
++++ b/test/dock-relay.test.js
@@@ -0,0 +1,78 @@
++'use strict';
++// The Dock loads on every OpenVibe site, so its scripts may not fetch another product's origin directly: each site's
++// connect-src allows openvibe.network and refuses openvibe.codes and openvibe.actor (2026-11-09: 49 sites logged 1-4
++// console errors at 1280). The three reads and their controls go through Network's same-origin frame relay
++// (/api/frame/agent-view…, /api/frame/agent…, /api/frame/tasks…, /api/frame/actor…); an explicit base (a test, the
++// extension's own panel) still reaches the product. This check fails when a Dock script takes the direct road again,
++// and when one of the three modules loses either the relay default or its no-token guard.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const ROOT = path.join(__dirname, '..');
++const scripts = fs.readdirSync(ROOT).filter((f) => /^dock.*\.js$/.test(f)).sort();
++assert.ok(scripts.includes('dock.js') && scripts.includes('dock-agent.js') && scripts.includes('dock-timeline.js') && scripts.includes('dock-actor.js'), 'the Dock and its three agent modules are here');
++const src = (f) => fs.readFileSync(path.join(ROOT, f), 'utf8');
++
++// Another product's origin: anything under openvibe.* other than openvibe.network (the Frame's own host).
++const OTHER = /https:\/\/openvibe\.(?!network\b)[a-z0-9-]+/i;
++
++/** Every fetch()'s first argument, with its parentheses and quotes balanced (so a call inside a call is read whole). */
++function fetchArgs(code) {
++ const out = [];
++ const re = /(?:^|[^.\w$])fetch\s*\(/g;
++ let m;
++ while ((m = re.exec(code))) {
++ let i = re.lastIndex, depth = 1, quote = null, arg = '';
++ for (; i < code.length && depth > 0; i += 1) {
++ const c = code[i];
++ if (quote) {
++ if (c === '\\') { arg += c + (code[i + 1] || ''); i += 1; continue; }
++ if (c === quote) quote = null;
++ } else if (c === '"' || c === "'" || c === '`') quote = c;
++ else if (c === '(') depth += 1;
++ else if (c === ')') { depth -= 1; if (depth === 0) break; }
++ arg += c;
++ }
++ out.push(arg.trim());
++ }
++ return out;
++}
++
++// 1. No fetch names another product's origin, as a literal or through a variable set to one.
++const offenders = [];
++for (const file of scripts) {
++ const code = src(file);
++ const args = fetchArgs(code);
++ for (const arg of args) if (OTHER.test(arg)) offenders.push(`${file}: fetch(${arg.slice(0, 100)})`);
++ for (const m of code.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:String\(\s*)?['"`](https:\/\/openvibe\.(?!network\b)[a-z0-9-]+)/g)) {
++ if (args.some((arg) => new RegExp(`(^|[^\\w$])${m[1]}([^\\w$]|$)`).test(arg))) offenders.push(`${file}: fetch uses ${m[1]}, which is ${m[2]}`);
++ }
++}
++assert.deepStrictEqual(offenders, [], 'the Dock must not fetch another product\'s origin directly; it goes through Network\'s /api/frame relay:\n ' + offenders.join('\n '));
++
++// 2. Each agent module defaults to the relay and asks nothing without the person's token.
++for (const [file, guard] of [['dock-agent.js', /if \(!t\) throw/], ['dock-timeline.js', /if \(!auth\) throw/], ['dock-actor.js', /if \(!t\) throw/]]) {
++ const code = src(file);
++ assert.ok(/frameBase[^;]*openvibe\.network\/api\/frame/.test(code), `${file} defaults to Network's frame relay`);
++ assert.ok(/endpoint = \(path\)/.test(code), `${file} builds its calls from that one endpoint`);
++ assert.ok(guard.test(code), `${file} asks nothing when the person is not signed in`);
++}
++
++// 3. The relay's paths are the ones Network serves, and the Dock hands every module the same relay.
++const agent = src('dock-agent.js');
++assert.ok(agent.includes("path.replace(/^\\/api\\/v1\\/me\\/agent-view/, '/agent-view')"), 'dock-agent.js maps Codes\' view to the relay');
++assert.ok(agent.includes("endpoint('/api/v1/me/agent-view/frame')"), 'the still comes through the relay too');
++const timeline = src('dock-timeline.js');
++assert.ok(timeline.includes("/api/v1/agent/timeline"), 'the timeline still asks for its own steps');
++assert.ok(timeline.includes("/api/v1/agent/approvals/") && timeline.includes('/api/v1/agent/kill-switch'), 'its two writes still go to Actor through the relay');
++const actor = src('dock-actor.js');
++assert.ok(actor.includes("frameBase + '/actor' + path"), 'dock-actor.js maps /actor and its subpaths to the relay');
++const dock = src('dock.js');
++assert.ok(/const frameBase = \(\) => String\(o\.frameBase \|\|/.test(dock), 'the Dock derives the relay from its Network base');
++assert.ok(/base: directBase\(o\.actorBase\), frameBase: frameBase\(\)/.test(dock), 'the timeline and the Actor block get both bases');
++assert.ok(/base: directBase\(o\.agentBase\), frameBase: frameBase\(\)/.test(dock), 'so does the agent view');
++assert.ok(!/base: agentBase\(\)|base: actorBase\(\)/.test(dock), 'no module is handed a product origin as its default');
++
++console.log('dock relay: the Dock\'s scripts fetch Network\'s /api/frame relay, never another product directly');
++console.log('all passed');