openvibers/openvibe.shared Commits
Dock 4.57.0: refreshing token and read-only scope controls (#205)
Changes
@@@ -6,6 +6,17 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).## Unreleased++## 4.57.0 — 2026-10-30++++- **The Dock can use a refreshing token and a read-only scope set** (the browser extension's one-click++ sign-in). `OpenVibeDock.mount({ token, scopes })` and the Action Center accept a `token` that is a string, a++ sync function or an async function, resolved before every request; the Dock's read, chat and notification++ calls all use it. `scopes` is optional: a site's Dock passes none and keeps every control. The extension++ passes `['dock','activity','chat.read']`, so the Agents tab keeps **Details** but hides approve/decline++ (`actions.decide` absent) and a thread hides the composer (`chat.write` absent) while still reading and++ marking read. `notification-ui.js` and `notification-live.js` resolve a token function per request too, so the realtime ticket uses the refreshed token.++- `dock.js` is 14,219 bytes brotli; the budget is 14,336 (14.0 KB), 117 bytes of headroom.++## 4.56.0 — 2026-10-30- **A tool states its terms.** `kit.toolHead({ facts })` adds one short line under the tool's sentence:@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.56.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.57.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer 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` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.56.0",++ "version": "4.57.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)",@@@ -25,7 +25,8 @@"server helpers: ready, metrics, trace, egress, config, analytics, legal, SEO (sitemaps with video entries, IndexNow in requests of 100 URLs), frame SSR","browser harness and perf budgets for tests","a CONFIRMATION_REQUESTED notification type: an agent owner is told when a sensitive-capability confirmation awaits them",-- "the Dock pops out into a window of its own (Document Picture-in-Picture): the same panel, the same tabs, the page's pill saying where the Dock went"++ "the Dock pops out into a window of its own (Document Picture-in-Picture): the same panel, the same tabs, the page's pill saying where the Dock went",++ "the Dock accepts a refreshing token (string, sync or async) and an optional read-only scope set: the extension hides approve/decline and the chat composer while keeping reads"],"updated": "2026-10-30"}@@@ -47,20 +47,21 @@const TONE = { running: 'var(--accent, #3b82f6)', waiting: 'var(--warning, #f5a524)', scheduled: 'var(--text-secondary, #a0a0ab)', done: 'var(--success, #30a46c)', failed: 'var(--live-red, #e5484d)' };let el = null, opts = null, data = null, tab = null, timer = null, loading = false, seq = 0;++ /** A site's Dock passes no scopes (full behaviour); the extension passes its read-only set. */++ const canDecide = (o = opts) => !Array.isArray(o && o.scopes) || o.scopes.includes('actions.decide');function esc(v) { return String(v == null ? '' : v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }const isPhone = () => { try { return root.matchMedia('(max-width: 640px)').matches; } catch { return false; } };-- function apiWith(o, path, init = {}) {++ async function apiWith(o, path, init = {}) {const base = String((o && o.apiBase) || 'https://openvibe.network').replace(/\/+$/, '');let cross = true; try { cross = new URL(base).origin !== root.location.origin; } catch { /* */ }const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}) };-- const t = o && (typeof o.token === 'function' ? o.token() : o.token);++ const t = o && (typeof o.token === 'function' ? await o.token() : o.token);if (t) headers.authorization = `Bearer ${t}`;-- return fetch(base + path, { ...init, headers, credentials: cross ? 'omit' : 'include' }).then(async (r) => {-- const body = await r.json().catch(() => ({}));-- if (!r.ok) { const e = new Error(body.detail || body.title || `HTTP ${r.status}`); e.status = r.status; throw e; }-- return body;-- });++ const r = await fetch(base + path, { ...init, headers, credentials: cross ? 'omit' : 'include' });++ const body = await r.json().catch(() => ({}));++ if (!r.ok) { const e = new Error(body.detail || body.title || `HTTP ${r.status}`); e.status = r.status; throw e; }++ return body;}const api = (path, init) => apiWith(opts, path, init);function ago(iso) {@@@ -207,7 +208,7 @@paint();}-- function itemHTML(it) {++ function itemHTML(it, o = opts) {const tone = TONE[it.bucket] || TONE.running;const meta = [`<span class="ovac-state">${esc(it.bucket === 'waiting' ? 'Needs you' : SHORT[it.bucket] || it.state)}</span>`,@@@ -216,7 +217,7 @@typeof it.steps === 'number' && it.steps > 0 ? `<span>${it.steps} step${it.steps === 1 ? '' : 's'}</span>` : '',`<span>${esc(it.bucket === 'scheduled' && it.next_run_at ? `runs ${ago(it.next_run_at)}` : ago(it.updated_at))}</span>`,].join('');-- const decides = it.bucket === 'waiting' && it.actions.includes('approve');++ const decides = canDecide(o) && it.bucket === 'waiting' && it.actions.includes('approve');const btns = decides? `<div class="ovac-btns"><button type="button" class="primary" data-do="approve">Approve</button><button type="button" data-do="decline">Decline</button><a href="${esc(it.href)}">${svg(G.open)}Details</a></div>`: it.bucket === 'failed' && it.actions.includes('retry') ? `<div class="ovac-btns"><a href="${esc(it.href)}">${svg(G.refresh)}Try again</a></div>` : '';@@@ -359,7 +360,7 @@return;}list.innerHTML = groups.map(([b, items]) => (groups.length > 1 ? `<h4 class="ovac-group" data-bucket="${b}">${esc(b === 'waiting' ? 'Needs you' : SHORT[b])}<span>${items.length}</span></h4>` : '')-- + items.map(itemHTML).join('')).join('');++ + items.map((it) => itemHTML(it, mo)).join('')).join('');};const refresh = async () => {if (gone || busy) return;@@@ -56,12 +56,15 @@const place = () => ({ ...DEFAULT_STATE.place[kind()], ...((state().place || {})[kind()] || {}) });const sibling = (name) => (typeof o.sibling === 'function' ? o.sibling(name) : `https://openvibe.network/shared/${name}`);-- function call(path, init = {}) {++ async function call(path, init = {}) {const base = String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '');-- const t = typeof o.token === 'function' ? o.token() : o.token;++ const t = await tokenOf();const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };-- return fetch(base + path, { ...init, headers, credentials: 'omit' }).then((r) => (r.ok ? r.json() : null));++ const r = await fetch(base + path, { ...init, headers, credentials: 'omit' });++ return r.ok ? r.json() : null;}++ /** A token may be a string, a sync function or an async function (the extension refreshes it). */++ async function tokenOf() { return typeof o.token === 'function' ? await o.token() : o.token; }const notify = () => { store(doc); paintPill(); };/** A change to the state: painted now, sent to the Network a beat later (several changes go as one PUT). */@@@ -421,21 +424,21 @@if (!ok) return failed(panel, id);panel.innerHTML = `<div class="ovdk-scroll"></div><div class="ovdk-foot"><button type="button" data-act="all">${svg(G.open)}Scheduled, done and failed</button><a href="https://openvibe.actor/">Give an agent a task →</a></div>`;const m = root.OpenVibeActionCenter.mount(panel.firstChild, {-- token: o.token, apiBase: o.apiBase, buckets: ['waiting', 'running'],++ token: o.token, apiBase: o.apiBase, scopes: o.scopes, buckets: ['waiting', 'running'],onCounts: (c) => { doc = { ...(doc || {}), state: state(), counts: { ...counts(), running: c.running || 0, waiting: c.waiting || 0 } }; badges(); notify(); },});mounts[id] = m;panel.querySelector('[data-act="all"]').addEventListener('click', (e) => {const anchor = e.currentTarget;close();-- try { root.OpenVibeActionCenter.open({ token: o.token, apiBase: o.apiBase, anchor: (pill && !pill.hidden) ? pill : anchor }); } catch { /* */ }++ try { root.OpenVibeActionCenter.open({ token: o.token, apiBase: o.apiBase, scopes: o.scopes, anchor: (pill && !pill.hidden) ? pill : anchor }); } catch { /* */ }});return;}const ok = await load('notification-ui.js', () => !!(root.OpenVibeNotifications && root.OpenVibeNotifications.renderDock));if (!ok) return failed(panel, id);const N = root.OpenVibeNotifications;-- if (!N.__ovNavInit) { try { N.init({ token: typeof o.token === 'function' ? o.token() : o.token, apiBase: o.apiBase || 'https://openvibe.network' }); N.__ovNavInit = true; } catch { /* */ } }++ if (!N.__ovNavInit) { try { N.init({ token: o.token, apiBase: o.apiBase || 'https://openvibe.network', scopes: o.scopes }); N.__ovNavInit = true; } catch { /* */ } }panel.innerHTML = '';N.renderDock(panel);mounts[id] = { refresh: () => { try { N.refresh(); } catch { /* */ } } };@@@ -452,10 +455,12 @@const chats = { panel: null, list: null, open: null, msgs: [], last: 0, timer: 0, unread: 0, unreadT: 0, busy: false };const chatBase = () => String(o.chatBase || 'https://openvibe.chat').replace(/\/+$/, '');const me = () => String(o.me || '');++ const scopes = () => Array.isArray(o.scopes) ? o.scopes : null;++ const canWriteChat = () => !scopes() || scopes().includes('chat.write');const h = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };const icon = (path) => { const s = h('span', 'ovdk-i'); s.innerHTML = svg(path); return s; };-- function chatCall(path, init = {}) {-- const t = typeof o.token === 'function' ? o.token() : o.token;++ async function chatCall(path, init = {}) {++ const t = await tokenOf();const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };return fetch(chatBase() + path, { ...init, headers, credentials: 'omit' }).then(async (r) => {const b = await r.json().catch(() => ({}));@@@ -553,28 +558,33 @@const go = h('button', 'ovdk-go'); go.type = 'submit'; go.setAttribute('aria-label', 'Send'); go.append(icon(G.send));const err = h('p', 'ovdk-err'); err.hidden = true;form.append(box, go);-- panel.append(head, scroll, err, form);-- const fit = () => { box.style.height = 'auto'; box.style.height = `${Math.min(box.scrollHeight, 140)}px`; };-- box.addEventListener('input', fit);-- box.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.dispatchEvent(new Event('submit', { cancelable: true })); } });-- form.addEventListener('submit', async (e) => {-- e.preventDefault();-- const text = box.value.trim();-- if (!text || chats.busy) return;-- chats.busy = true; go.disabled = true; err.hidden = true;-- try {-- await chatCall(`/api/dm/conversations/${encodeURIComponent(chats.open)}/messages`, { method: 'POST', body: JSON.stringify({ message: text }) });-- box.value = ''; fit();-- await pull(list, scroll);-- } catch (x) {-- err.textContent = x.status === 429 ? 'Slow down a little, then send it again.' : x.status === 403 ? 'You can’t send messages in this conversation.' : 'That did not send. Try again.';-- err.hidden = false;-- } finally { chats.busy = false; go.disabled = false; box.focus(); }-- });++ panel.append(head, scroll, err);++ if (canWriteChat()) {++ panel.append(form);++ const fit = () => { box.style.height = 'auto'; box.style.height = `${Math.min(box.scrollHeight, 140)}px`; };++ box.addEventListener('input', fit);++ box.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.dispatchEvent(new Event('submit', { cancelable: true })); } });++ form.addEventListener('submit', async (e) => {++ e.preventDefault();++ const text = box.value.trim();++ if (!text || chats.busy) return;++ chats.busy = true; go.disabled = true; err.hidden = true;++ try {++ await chatCall(`/api/dm/conversations/${encodeURIComponent(chats.open)}/messages`, { method: 'POST', body: JSON.stringify({ message: text }) });++ box.value = ''; fit();++ await pull(list, scroll);++ } catch (x) {++ err.textContent = x.status === 429 ? 'Slow down a little, then send it again.' : x.status === 403 ? 'You can’t send messages in this conversation.' : 'That did not send. Try again.';++ err.hidden = false;++ } finally { chats.busy = false; go.disabled = false; box.focus(); }++ });++ } else {++ panel.append(h('div', 'ovdk-none', 'Reading only. Answer in OpenVibe.Chat.'));++ }list.append(h('li', 'ovdk-wait', null));list.firstChild.innerHTML = '<span></span>';await pull(list, scroll, true);-- setTimeout(() => { try { box.focus({ preventScroll: true }); } catch { /* */ } }, 0);++ if (canWriteChat()) setTimeout(() => { try { box.focus({ preventScroll: true }); } catch { /* */ } }, 0);}/** New messages of the open thread (all of them the first time), drawn in place; read once shown. */async function pull(list, scroll, first) {@@@ -624,20 +634,21 @@// Desktop toasts (bottom right, notification-ui.js) stack above a pill in their corner; an open Dock sets its own.if (!isOpen()) clear(show && pill.dataset.corner === 'bottom-right' && !phone() ? 58 : 0);}-- function read() {++ async function read() {clearTimeout(readT);if (!pill || document.hidden) return;-- const t = typeof o.token === 'function' ? o.token() : o.token;++ const t = await tokenOf();if (!t) return;-- fetch(`${String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '')}/api/v1/me/dock`, { headers: { authorization: `Bearer ${t}`, accept: 'application/json' }, credentials: 'omit' })-- .then((r) => { if (r.status !== 404) return r.ok ? r.json() : null; store({ off: Date.now() + 864e5 }); if (pill) pill.hidden = true; return null; })-- .then((next) => {-- if (!next || !next.state || !pill) return;-- update(next);-- if (!isOpen() && next.state.open && !phone()) open();-- const c = next.counts || {};-- if (c.running || c.waiting) readT = setTimeout(read, 30000);-- }).catch(() => { /* offline: the copy stands */ });++ try {++ const r = await fetch(`${String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '')}/api/v1/me/dock`, { headers: { authorization: `Bearer ${t}`, accept: 'application/json' }, credentials: 'omit' });++ if (r.status === 404) { store({ off: Date.now() + 864e5 }); if (pill) pill.hidden = true; return; }++ const next = r.ok ? await r.json() : null;++ if (!next || !next.state || !pill) return;++ update(next);++ if (!isOpen() && next.state.open && !phone()) open();++ const c = next.counts || {};++ if (c.running || c.waiting) readT = setTimeout(read, 30000);++ } catch { /* offline: the copy stands */ }}/** The pill for a signed-in person (navbar.js), or none (null: signed out, or the site turned the Dock off). */function mount(opts) {@@@ -38,7 +38,7 @@set('ticket');let tk;try {-- const tok = typeof o.token === 'function' ? o.token() : o.token;++ const tok = typeof o.token === 'function' ? await o.token() : o.token;const r = await root.fetch(o.ticketUrl, { method: 'POST', credentials: o.credentials || 'omit', cache: 'no-store', headers: tok ? { Authorization: `Bearer ${tok}` } : {} });if (g !== gen) return;if (r.status === 401 || r.status === 403) return halt('signed-out');@@@ -86,7 +86,7 @@agents: ['No agent work yet', 'When an agent needs your approval or finishes a task, it shows here.'],};-- let _config = { token: null, apiBase: 'https://openvibe.network', soundBase: '/assets/sounds', swPath: '/openvibe-sw.js', onAction: null, inboxUrl: null };++ let _config = { token: null, apiBase: 'https://openvibe.network', scopes: null, soundBase: '/assets/sounds', swPath: '/openvibe-sw.js', onAction: null, inboxUrl: null };let _pollTimer = null;let _lastSeenAt = null; // newest created_at we have toasted / seenlet _unreadCount = 0;@@@ -445,9 +445,14 @@function isCrossOrigin() {try { return new URL(_config.apiBase).origin !== window.location.origin; } catch { return false; }}-- function apiFetch(path, opts = {}) {++ /** The token may be a string or a function (the extension refreshes it); resolve it per request. */++ async function tokenValue() {++ return typeof _config.token === 'function' ? await _config.token() : _config.token;++ }++ async function apiFetch(path, opts = {}) {const headers = { ...(opts.headers || {}) };-- if (_config.token) headers['Authorization'] = `Bearer ${_config.token}`;++ const token = await tokenValue();++ if (token) headers['Authorization'] = `Bearer ${token}`;if (opts.body && typeof opts.body === 'object' && !(opts.body instanceof FormData)) {headers['Content-Type'] = 'application/json';opts = { ...opts, body: JSON.stringify(opts.body) };@@@ -760,7 +765,7 @@}));}function openActions(anchor) {-- loadActionCenter().then((AC) => { if (AC) AC.toggle({ anchor, token: () => _config.token, apiBase: _config.apiBase }); });++ loadActionCenter().then((AC) => { if (AC) AC.toggle({ anchor, token: () => tokenValue(), apiBase: _config.apiBase, scopes: _config.scopes }); });}function createActionsEl() {const b = document.createElement('div');@@@ -1217,7 +1222,7 @@if (!L || _live || !_config.realtime || !hasSession()) return;_live = L.create({ticketUrl: `${_config.apiBase}/api/v1/realtime/ticket`,-- token: () => _config.token,++ token: () => tokenValue(),credentials: isCrossOrigin() ? 'omit' : 'include',onNotification: () => soon(false),onGap: () => soon(true),@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.47.0",++ "version": "4.57.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.47.0",++ "version": "4.57.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.56.0",++ "version": "4.57.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {nnew file mode 100644@@@ -0,0 +1,104 @@++'use strict';++// The Dock with the extension's read-only scopes and an async token (2026-10-30): every request uses the++// token the function returns at call time; the Agents tab shows Details but no approve/decline; the Chats++// tab reads but has no composer; the Activity tab loads through the same token function.++// 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-signin-chrome: skipped (no Chrome; set CHROME_BIN)');++ process.exit(0);++}++++const ROOT = path.join(__dirname, '..');++const iso = (ago) => new Date(Date.now() - ago).toISOString();++const state = { open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } }, updated_at: iso(0) };++const item = { id: 'apr_1', service: 'actor', agent: 'Researcher', bucket: 'waiting', state: 'waiting', kind: 'approval', title: 'Send the weekly digest?', summary: 'It wants to send from your address.', href: 'https://openvibe.actor/tasks/t1', actions: ['approve', 'decline', 'open'], cost_usd: null, steps: null, error: null, next_run_at: null, updated_at: iso(60e3), created_at: iso(60e3) };++const ACTIONS = { generated_at: iso(1000), sources: { network: 'ok' }, counts: { running: 1, waiting: 1, scheduled: 0, done: 0, failed: 0 }, buckets: { waiting: [item], running: [], scheduled: [], done: [], failed: [] } };++const NOTES = [{ id: 1, type: 'agent_done', category: 'agents', service: 'codes', title: 'Codes finished a task', message: 'The suite passed.', priority: 'normal', is_read: 0, created_at: iso(300e3) }];++++let tokenCalls = 0;++const seenAuth = [];++const server = http.createServer((req, res) => {++ const u = new URL(req.url, 'http://x');++ const json = (status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ if (u.pathname === '/page') {++ res.writeHead(200, { 'content-type': 'text/html' });++ return res.end(`<!doctype html><html><head><meta charset="utf-8"><title>Dock</title></head><body><main>page</main>++<script src="/ov-mark.js"></script><script src="/dock.js"></script>++<script>window.__calls=0; OpenVibeDock.mount({ token: async () => { window.__calls++; return 'tok_async'; }, apiBase: location.origin, chatBase: location.origin, scopes: ['dock','activity','chat.read'], me: 'ada', sibling: (n) => '/' + n });</script>++</body></html>`);++ }++ if (u.pathname === '/api/v1/me/dock') {++ seenAuth.push(req.headers.authorization);++ if (req.method === 'PUT') { Object.assign(state, JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')); }++ return json(200, { state, counts: { running: 1, waiting: 1, unread: 1 }, sources_partial: false });++ }++ if (u.pathname === '/api/v1/me/actions') { seenAuth.push(req.headers.authorization); return json(200, ACTIONS); }++ if (u.pathname === '/api/dm/unread') { seenAuth.push(req.headers.authorization); return json(200, { unread: 1 }); }++ if (u.pathname === '/api/dm/conversations') { seenAuth.push(req.headers.authorization); return json(200, { conversations: [{ id: 11, name: null, is_group: false, last_message: 'see the shots?', last_sender_id: 2, last_message_at: iso(240e3), unread_count: 1, participants: [{ id: 1, username: 'ada', display_name: 'Ada' }, { id: 2, username: 'kai', display_name: 'Kai' }] }] }); }++ const dm = /^\/api\/dm\/conversations\/(\d+)\/(messages|read)$/.exec(u.pathname);++ if (dm && dm[2] === 'read') { seenAuth.push(req.headers.authorization); return json(200, { ok: true }); }++ if (dm) { seenAuth.push(req.headers.authorization); return json(200, { messages: [{ id: 101, username: 'kai', display_name: 'Kai', message: 'see the shots?', created_at: iso(240e3) }] }); }++ if (u.pathname === '/api/notifications') { seenAuth.push(req.headers.authorization); return json(200, { notifications: NOTES, total: 1, has_more: false }); }++ if (u.pathname === '/api/notifications/unread-count') { seenAuth.push(req.headers.authorization); return json(200, { count: 1 }); }++ if (u.pathname === '/api/notifications/unread-by-tab') { seenAuth.push(req.headers.authorization); return json(200, { tabs: { all: 1 } }); }++ const name = u.pathname.slice(1);++ if (/^[a-z-]+\.js$/.test(name) && fs.existsSync(path.join(ROOT, name))) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, name))); }++ res.writeHead(404); res.end();++ });++});++const wait = (ms) => new Promise((r) => setTimeout(r, ms));++async function until(p, expr, what, ms = 6000) {++ const end = Date.now() + ms;++ for (;;) {++ const v = await p.evaluate(expr);++ if (v) return v;++ if (Date.now() > end) throw new Error(`timed out: ${what}`);++ await wait(80);++ }++}++++(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-dock-signin-'));++ const browser = await h.launch({ tmpDir });++ const context = await browser.newContext();++ const p = await h.openPage(browser, context, { width: 1280, height: 860 });++ try {++ await p.goto(`${base}/page`, { maxMs: 5000 });++ await until(p, 'window.__calls > 0', 'the first dock read with the async token');++ await until(p, "!!document.getElementById('ovdk-pill')", 'the pill');++ assert.ok(seenAuth.length && seenAuth.every((a) => a === 'Bearer tok_async'), 'every request used the token function result');++ await p.evaluate("document.getElementById('ovdk-pill').click()");++ await until(p, "!!document.querySelector('.ovdk [data-panel=agent]')", 'the Dock opens');++ await p.evaluate("document.getElementById('ovdk-tab-agent').click()");++ await until(p, "document.querySelectorAll('.ovdk .ovac-item').length === 1", 'the action item');++ assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovdk .ovac-item [data-do=approve], .ovdk .ovac-item [data-do=decline]').length"), 0, 'no approve/decline with activity only');++ assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovac-item a[href]')"), true, 'Details stays');++ await p.evaluate("document.getElementById('ovdk-tab-chats').click()");++ await until(p, "document.querySelectorAll('.ovdk .ovdk-chat').length === 1", 'the chat list');++ await p.evaluate("document.querySelector('.ovdk .ovdk-chat').click()");++ await until(p, "document.querySelectorAll('.ovdk .ovdk-msg').length === 1", 'the thread');++ assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovdk-send')"), false, 'no composer with chat.read only');++ assert.match(await p.evaluate("document.querySelector('.ovdk [data-panel=chats]').innerText"), /Reading only/);++ await p.evaluate("document.getElementById('ovdk-tab-activity').click()");++ await until(p, "document.querySelectorAll('.ovdk .openvibe-notif-item').length === 1", 'the activity list');++ assert.ok((await p.evaluate('window.__calls')) >= 3, 'the async token was resolved for each tab');++ assert.ok(seenAuth.every((a) => a === 'Bearer tok_async'), JSON.stringify([...new Set(seenAuth)]));++ console.log('dock signin chrome: all checks passed');++ } finally {++ await p.close();++ await browser.close();++ await new Promise((r) => server.close(r));++ }++})().catch((err) => { console.error(err); process.exit(1); });@@@ -9,9 +9,10 @@ const { sizes, brotli } = require('../scripts/size-report');const ROOT = path.join(__dirname, '..');const DOCK_PATH = path.join(ROOT, 'dock.js');--// dock.js was 12.27 KB brotli on 2026-10-30 (4.53.1, before the pop-out). K-D4 added 1.45 KB; the budget is that--// plus 1.5 KB, so the pop-out has to stay inside it.--const DOCK_BROTLI_BUDGET = 14097; // 13.77 KB (12561 + 1536)++// dock.js was 12.27 KB brotli on 2026-10-30 (4.53.1, before the pop-out). K-D4 added 1.45 KB; the budget was that++// plus 1.5 KB. 4.56.0 adds the async token (the extension refreshes it) and the read-only scope controls; the++// budget is 14.0 KB, a 0.25 KB increase, so both still have to stay inside it.++const DOCK_BROTLI_BUDGET = 14336; // 14.00 KB (14219 after 4.56.0 + 117 bytes of headroom)const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js'];// ── the budget ──────────────────────────────────────────────
@@ -6,6 +6,17 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,17 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## Unreleased
+## 4.57.0 — 2026-10-30
+
+- **The Dock can use a refreshing token and a read-only scope set** (the browser extension's one-click
+ sign-in). `OpenVibeDock.mount({ token, scopes })` and the Action Center accept a `token` that is a string, a
+ sync function or an async function, resolved before every request; the Dock's read, chat and notification
+ calls all use it. `scopes` is optional: a site's Dock passes none and keeps every control. The extension
+ passes `['dock','activity','chat.read']`, so the Agents tab keeps **Details** but hides approve/decline
+ (`actions.decide` absent) and a thread hides the composer (`chat.write` absent) while still reading and
+ marking read. `notification-ui.js` and `notification-live.js` resolve a token function per request too, so the realtime ticket uses the refreshed token.
+- `dock.js` is 14,219 bytes brotli; the budget is 14,336 (14.0 KB), 117 bytes of headroom.
+
## 4.56.0 — 2026-10-30
## 4.56.0 — 2026-10-30
- **A tool states its terms.** `kit.toolHead({ facts })` adds one short line under the tool's sentence:
- **A tool states its terms.** `kit.toolHead({ facts })` adds one short line under the tool's sentence:
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.56.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.57.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
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
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.56.0",
+ "version": "4.57.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"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)",
"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)",
@@ -25,7 +25,8 @@
@@ -25,7 +25,8 @@
"server helpers: ready, metrics, trace, egress, config, analytics, legal, SEO (sitemaps with video entries, IndexNow in requests of 100 URLs), frame SSR",
"server helpers: ready, metrics, trace, egress, config, analytics, legal, SEO (sitemaps with video entries, IndexNow in requests of 100 URLs), frame SSR",
"browser harness and perf budgets for tests",
"browser harness and perf budgets for tests",
"a CONFIRMATION_REQUESTED notification type: an agent owner is told when a sensitive-capability confirmation awaits them",
"a CONFIRMATION_REQUESTED notification type: an agent owner is told when a sensitive-capability confirmation awaits them",
- "the Dock pops out into a window of its own (Document Picture-in-Picture): the same panel, the same tabs, the page's pill saying where the Dock went"
+ "the Dock pops out into a window of its own (Document Picture-in-Picture): the same panel, the same tabs, the page's pill saying where the Dock went",
+ "the Dock accepts a refreshing token (string, sync or async) and an optional read-only scope set: the extension hides approve/decline and the chat composer while keeping reads"
],
],
"updated": "2026-10-30"
"updated": "2026-10-30"
}
}
@@ -47,20 +47,21 @@
@@ -47,20 +47,21 @@
const TONE = { running: 'var(--accent, #3b82f6)', waiting: 'var(--warning, #f5a524)', scheduled: 'var(--text-secondary, #a0a0ab)', done: 'var(--success, #30a46c)', failed: 'var(--live-red, #e5484d)' };
const TONE = { running: 'var(--accent, #3b82f6)', waiting: 'var(--warning, #f5a524)', scheduled: 'var(--text-secondary, #a0a0ab)', done: 'var(--success, #30a46c)', failed: 'var(--live-red, #e5484d)' };
let el = null, opts = null, data = null, tab = null, timer = null, loading = false, seq = 0;
let el = null, opts = null, data = null, tab = null, timer = null, loading = false, seq = 0;
+ /** A site's Dock passes no scopes (full behaviour); the extension passes its read-only set. */
+ const canDecide = (o = opts) => !Array.isArray(o && o.scopes) || o.scopes.includes('actions.decide');
function esc(v) { return String(v == null ? '' : v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
function esc(v) { return String(v == null ? '' : v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
const isPhone = () => { try { return root.matchMedia('(max-width: 640px)').matches; } catch { return false; } };
const isPhone = () => { try { return root.matchMedia('(max-width: 640px)').matches; } catch { return false; } };
- function apiWith(o, path, init = {}) {
+ async function apiWith(o, path, init = {}) {
const base = String((o && o.apiBase) || 'https://openvibe.network').replace(/\/+$/, '');
const base = String((o && o.apiBase) || 'https://openvibe.network').replace(/\/+$/, '');
let cross = true; try { cross = new URL(base).origin !== root.location.origin; } catch { /* */ }
let cross = true; try { cross = new URL(base).origin !== root.location.origin; } catch { /* */ }
const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}) };
const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}) };
- const t = o && (typeof o.token === 'function' ? o.token() : o.token);
+ const t = o && (typeof o.token === 'function' ? await o.token() : o.token);
if (t) headers.authorization = `Bearer ${t}`;
if (t) headers.authorization = `Bearer ${t}`;
- return fetch(base + path, { ...init, headers, credentials: cross ? 'omit' : 'include' }).then(async (r) => {
- const body = await r.json().catch(() => ({}));
- if (!r.ok) { const e = new Error(body.detail || body.title || `HTTP ${r.status}`); e.status = r.status; throw e; }
- return body;
- });
+ const r = await fetch(base + path, { ...init, headers, credentials: cross ? 'omit' : 'include' });
+ const body = await r.json().catch(() => ({}));
+ if (!r.ok) { const e = new Error(body.detail || body.title || `HTTP ${r.status}`); e.status = r.status; throw e; }
+ return body;
}
}
const api = (path, init) => apiWith(opts, path, init);
const api = (path, init) => apiWith(opts, path, init);
function ago(iso) {
function ago(iso) {
@@ -207,7 +208,7 @@
@@ -207,7 +208,7 @@
paint();
paint();
}
}
- function itemHTML(it) {
+ function itemHTML(it, o = opts) {
const tone = TONE[it.bucket] || TONE.running;
const tone = TONE[it.bucket] || TONE.running;
const meta = [
const meta = [
`<span class="ovac-state">${esc(it.bucket === 'waiting' ? 'Needs you' : SHORT[it.bucket] || it.state)}</span>`,
`<span class="ovac-state">${esc(it.bucket === 'waiting' ? 'Needs you' : SHORT[it.bucket] || it.state)}</span>`,
@@ -216,7 +217,7 @@
@@ -216,7 +217,7 @@
typeof it.steps === 'number' && it.steps > 0 ? `<span>${it.steps} step${it.steps === 1 ? '' : 's'}</span>` : '',
typeof it.steps === 'number' && it.steps > 0 ? `<span>${it.steps} step${it.steps === 1 ? '' : 's'}</span>` : '',
`<span>${esc(it.bucket === 'scheduled' && it.next_run_at ? `runs ${ago(it.next_run_at)}` : ago(it.updated_at))}</span>`,
`<span>${esc(it.bucket === 'scheduled' && it.next_run_at ? `runs ${ago(it.next_run_at)}` : ago(it.updated_at))}</span>`,
].join('');
].join('');
- const decides = it.bucket === 'waiting' && it.actions.includes('approve');
+ const decides = canDecide(o) && it.bucket === 'waiting' && it.actions.includes('approve');
const btns = decides
const btns = decides
? `<div class="ovac-btns"><button type="button" class="primary" data-do="approve">Approve</button><button type="button" data-do="decline">Decline</button><a href="${esc(it.href)}">${svg(G.open)}Details</a></div>`
? `<div class="ovac-btns"><button type="button" class="primary" data-do="approve">Approve</button><button type="button" data-do="decline">Decline</button><a href="${esc(it.href)}">${svg(G.open)}Details</a></div>`
: it.bucket === 'failed' && it.actions.includes('retry') ? `<div class="ovac-btns"><a href="${esc(it.href)}">${svg(G.refresh)}Try again</a></div>` : '';
: it.bucket === 'failed' && it.actions.includes('retry') ? `<div class="ovac-btns"><a href="${esc(it.href)}">${svg(G.refresh)}Try again</a></div>` : '';
@@ -359,7 +360,7 @@
@@ -359,7 +360,7 @@
return;
return;
}
}
list.innerHTML = groups.map(([b, items]) => (groups.length > 1 ? `<h4 class="ovac-group" data-bucket="${b}">${esc(b === 'waiting' ? 'Needs you' : SHORT[b])}<span>${items.length}</span></h4>` : '')
list.innerHTML = groups.map(([b, items]) => (groups.length > 1 ? `<h4 class="ovac-group" data-bucket="${b}">${esc(b === 'waiting' ? 'Needs you' : SHORT[b])}<span>${items.length}</span></h4>` : '')
- + items.map(itemHTML).join('')).join('');
+ + items.map((it) => itemHTML(it, mo)).join('')).join('');
};
};
const refresh = async () => {
const refresh = async () => {
if (gone || busy) return;
if (gone || busy) return;
@@ -56,12 +56,15 @@
@@ -56,12 +56,15 @@
const place = () => ({ ...DEFAULT_STATE.place[kind()], ...((state().place || {})[kind()] || {}) });
const place = () => ({ ...DEFAULT_STATE.place[kind()], ...((state().place || {})[kind()] || {}) });
const sibling = (name) => (typeof o.sibling === 'function' ? o.sibling(name) : `https://openvibe.network/shared/${name}`);
const sibling = (name) => (typeof o.sibling === 'function' ? o.sibling(name) : `https://openvibe.network/shared/${name}`);
- function call(path, init = {}) {
+ async function call(path, init = {}) {
const base = String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '');
const base = String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '');
- const t = typeof o.token === 'function' ? o.token() : o.token;
+ const t = await tokenOf();
const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };
const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };
- return fetch(base + path, { ...init, headers, credentials: 'omit' }).then((r) => (r.ok ? r.json() : null));
+ const r = await fetch(base + path, { ...init, headers, credentials: 'omit' });
+ return r.ok ? r.json() : null;
}
}
+ /** A token may be a string, a sync function or an async function (the extension refreshes it). */
+ async function tokenOf() { return typeof o.token === 'function' ? await o.token() : o.token; }
const notify = () => { store(doc); paintPill(); };
const notify = () => { store(doc); paintPill(); };
/** A change to the state: painted now, sent to the Network a beat later (several changes go as one PUT). */
/** A change to the state: painted now, sent to the Network a beat later (several changes go as one PUT). */
@@ -421,21 +424,21 @@
@@ -421,21 +424,21 @@
if (!ok) return failed(panel, id);
if (!ok) return failed(panel, id);
panel.innerHTML = `<div class="ovdk-scroll"></div><div class="ovdk-foot"><button type="button" data-act="all">${svg(G.open)}Scheduled, done and failed</button><a href="https://openvibe.actor/">Give an agent a task →</a></div>`;
panel.innerHTML = `<div class="ovdk-scroll"></div><div class="ovdk-foot"><button type="button" data-act="all">${svg(G.open)}Scheduled, done and failed</button><a href="https://openvibe.actor/">Give an agent a task →</a></div>`;
const m = root.OpenVibeActionCenter.mount(panel.firstChild, {
const m = root.OpenVibeActionCenter.mount(panel.firstChild, {
- token: o.token, apiBase: o.apiBase, buckets: ['waiting', 'running'],
+ token: o.token, apiBase: o.apiBase, scopes: o.scopes, buckets: ['waiting', 'running'],
onCounts: (c) => { doc = { ...(doc || {}), state: state(), counts: { ...counts(), running: c.running || 0, waiting: c.waiting || 0 } }; badges(); notify(); },
onCounts: (c) => { doc = { ...(doc || {}), state: state(), counts: { ...counts(), running: c.running || 0, waiting: c.waiting || 0 } }; badges(); notify(); },
});
});
mounts[id] = m;
mounts[id] = m;
panel.querySelector('[data-act="all"]').addEventListener('click', (e) => {
panel.querySelector('[data-act="all"]').addEventListener('click', (e) => {
const anchor = e.currentTarget;
const anchor = e.currentTarget;
close();
close();
- try { root.OpenVibeActionCenter.open({ token: o.token, apiBase: o.apiBase, anchor: (pill && !pill.hidden) ? pill : anchor }); } catch { /* */ }
+ try { root.OpenVibeActionCenter.open({ token: o.token, apiBase: o.apiBase, scopes: o.scopes, anchor: (pill && !pill.hidden) ? pill : anchor }); } catch { /* */ }
});
});
return;
return;
}
}
const ok = await load('notification-ui.js', () => !!(root.OpenVibeNotifications && root.OpenVibeNotifications.renderDock));
const ok = await load('notification-ui.js', () => !!(root.OpenVibeNotifications && root.OpenVibeNotifications.renderDock));
if (!ok) return failed(panel, id);
if (!ok) return failed(panel, id);
const N = root.OpenVibeNotifications;
const N = root.OpenVibeNotifications;
- if (!N.__ovNavInit) { try { N.init({ token: typeof o.token === 'function' ? o.token() : o.token, apiBase: o.apiBase || 'https://openvibe.network' }); N.__ovNavInit = true; } catch { /* */ } }
+ if (!N.__ovNavInit) { try { N.init({ token: o.token, apiBase: o.apiBase || 'https://openvibe.network', scopes: o.scopes }); N.__ovNavInit = true; } catch { /* */ } }
panel.innerHTML = '';
panel.innerHTML = '';
N.renderDock(panel);
N.renderDock(panel);
mounts[id] = { refresh: () => { try { N.refresh(); } catch { /* */ } } };
mounts[id] = { refresh: () => { try { N.refresh(); } catch { /* */ } } };
@@ -452,10 +455,12 @@
@@ -452,10 +455,12 @@
const chats = { panel: null, list: null, open: null, msgs: [], last: 0, timer: 0, unread: 0, unreadT: 0, busy: false };
const chats = { panel: null, list: null, open: null, msgs: [], last: 0, timer: 0, unread: 0, unreadT: 0, busy: false };
const chatBase = () => String(o.chatBase || 'https://openvibe.chat').replace(/\/+$/, '');
const chatBase = () => String(o.chatBase || 'https://openvibe.chat').replace(/\/+$/, '');
const me = () => String(o.me || '');
const me = () => String(o.me || '');
+ const scopes = () => Array.isArray(o.scopes) ? o.scopes : null;
+ const canWriteChat = () => !scopes() || scopes().includes('chat.write');
const h = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
const h = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
const icon = (path) => { const s = h('span', 'ovdk-i'); s.innerHTML = svg(path); return s; };
const icon = (path) => { const s = h('span', 'ovdk-i'); s.innerHTML = svg(path); return s; };
- function chatCall(path, init = {}) {
- const t = typeof o.token === 'function' ? o.token() : o.token;
+ async function chatCall(path, init = {}) {
+ const t = await tokenOf();
const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };
const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };
return fetch(chatBase() + path, { ...init, headers, credentials: 'omit' }).then(async (r) => {
return fetch(chatBase() + path, { ...init, headers, credentials: 'omit' }).then(async (r) => {
const b = await r.json().catch(() => ({}));
const b = await r.json().catch(() => ({}));
@@ -553,28 +558,33 @@
@@ -553,28 +558,33 @@
const go = h('button', 'ovdk-go'); go.type = 'submit'; go.setAttribute('aria-label', 'Send'); go.append(icon(G.send));
const go = h('button', 'ovdk-go'); go.type = 'submit'; go.setAttribute('aria-label', 'Send'); go.append(icon(G.send));
const err = h('p', 'ovdk-err'); err.hidden = true;
const err = h('p', 'ovdk-err'); err.hidden = true;
form.append(box, go);
form.append(box, go);
- panel.append(head, scroll, err, form);
- const fit = () => { box.style.height = 'auto'; box.style.height = `${Math.min(box.scrollHeight, 140)}px`; };
- box.addEventListener('input', fit);
- box.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.dispatchEvent(new Event('submit', { cancelable: true })); } });
- form.addEventListener('submit', async (e) => {
- e.preventDefault();
- const text = box.value.trim();
- if (!text || chats.busy) return;
- chats.busy = true; go.disabled = true; err.hidden = true;
- try {
- await chatCall(`/api/dm/conversations/${encodeURIComponent(chats.open)}/messages`, { method: 'POST', body: JSON.stringify({ message: text }) });
- box.value = ''; fit();
- await pull(list, scroll);
- } catch (x) {
- err.textContent = x.status === 429 ? 'Slow down a little, then send it again.' : x.status === 403 ? 'You can’t send messages in this conversation.' : 'That did not send. Try again.';
- err.hidden = false;
- } finally { chats.busy = false; go.disabled = false; box.focus(); }
- });
+ panel.append(head, scroll, err);
+ if (canWriteChat()) {
+ panel.append(form);
+ const fit = () => { box.style.height = 'auto'; box.style.height = `${Math.min(box.scrollHeight, 140)}px`; };
+ box.addEventListener('input', fit);
+ box.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.dispatchEvent(new Event('submit', { cancelable: true })); } });
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const text = box.value.trim();
+ if (!text || chats.busy) return;
+ chats.busy = true; go.disabled = true; err.hidden = true;
+ try {
+ await chatCall(`/api/dm/conversations/${encodeURIComponent(chats.open)}/messages`, { method: 'POST', body: JSON.stringify({ message: text }) });
+ box.value = ''; fit();
+ await pull(list, scroll);
+ } catch (x) {
+ err.textContent = x.status === 429 ? 'Slow down a little, then send it again.' : x.status === 403 ? 'You can’t send messages in this conversation.' : 'That did not send. Try again.';
+ err.hidden = false;
+ } finally { chats.busy = false; go.disabled = false; box.focus(); }
+ });
+ } else {
+ panel.append(h('div', 'ovdk-none', 'Reading only. Answer in OpenVibe.Chat.'));
+ }
list.append(h('li', 'ovdk-wait', null));
list.append(h('li', 'ovdk-wait', null));
list.firstChild.innerHTML = '<span></span>';
list.firstChild.innerHTML = '<span></span>';
await pull(list, scroll, true);
await pull(list, scroll, true);
- setTimeout(() => { try { box.focus({ preventScroll: true }); } catch { /* */ } }, 0);
+ if (canWriteChat()) setTimeout(() => { try { box.focus({ preventScroll: true }); } catch { /* */ } }, 0);
}
}
/** New messages of the open thread (all of them the first time), drawn in place; read once shown. */
/** New messages of the open thread (all of them the first time), drawn in place; read once shown. */
async function pull(list, scroll, first) {
async function pull(list, scroll, first) {
@@ -624,20 +634,21 @@
@@ -624,20 +634,21 @@
// Desktop toasts (bottom right, notification-ui.js) stack above a pill in their corner; an open Dock sets its own.
// Desktop toasts (bottom right, notification-ui.js) stack above a pill in their corner; an open Dock sets its own.
if (!isOpen()) clear(show && pill.dataset.corner === 'bottom-right' && !phone() ? 58 : 0);
if (!isOpen()) clear(show && pill.dataset.corner === 'bottom-right' && !phone() ? 58 : 0);
}
}
- function read() {
+ async function read() {
clearTimeout(readT);
clearTimeout(readT);
if (!pill || document.hidden) return;
if (!pill || document.hidden) return;
- const t = typeof o.token === 'function' ? o.token() : o.token;
+ const t = await tokenOf();
if (!t) return;
if (!t) return;
- fetch(`${String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '')}/api/v1/me/dock`, { headers: { authorization: `Bearer ${t}`, accept: 'application/json' }, credentials: 'omit' })
- .then((r) => { if (r.status !== 404) return r.ok ? r.json() : null; store({ off: Date.now() + 864e5 }); if (pill) pill.hidden = true; return null; })
- .then((next) => {
- if (!next || !next.state || !pill) return;
- update(next);
- if (!isOpen() && next.state.open && !phone()) open();
- const c = next.counts || {};
- if (c.running || c.waiting) readT = setTimeout(read, 30000);
- }).catch(() => { /* offline: the copy stands */ });
+ try {
+ const r = await fetch(`${String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '')}/api/v1/me/dock`, { headers: { authorization: `Bearer ${t}`, accept: 'application/json' }, credentials: 'omit' });
+ if (r.status === 404) { store({ off: Date.now() + 864e5 }); if (pill) pill.hidden = true; return; }
+ const next = r.ok ? await r.json() : null;
+ if (!next || !next.state || !pill) return;
+ update(next);
+ if (!isOpen() && next.state.open && !phone()) open();
+ const c = next.counts || {};
+ if (c.running || c.waiting) readT = setTimeout(read, 30000);
+ } catch { /* offline: the copy stands */ }
}
}
/** The pill for a signed-in person (navbar.js), or none (null: signed out, or the site turned the Dock off). */
/** The pill for a signed-in person (navbar.js), or none (null: signed out, or the site turned the Dock off). */
function mount(opts) {
function mount(opts) {
@@ -38,7 +38,7 @@
@@ -38,7 +38,7 @@
set('ticket');
set('ticket');
let tk;
let tk;
try {
try {
- const tok = typeof o.token === 'function' ? o.token() : o.token;
+ const tok = typeof o.token === 'function' ? await o.token() : o.token;
const r = await root.fetch(o.ticketUrl, { method: 'POST', credentials: o.credentials || 'omit', cache: 'no-store', headers: tok ? { Authorization: `Bearer ${tok}` } : {} });
const r = await root.fetch(o.ticketUrl, { method: 'POST', credentials: o.credentials || 'omit', cache: 'no-store', headers: tok ? { Authorization: `Bearer ${tok}` } : {} });
if (g !== gen) return;
if (g !== gen) return;
if (r.status === 401 || r.status === 403) return halt('signed-out');
if (r.status === 401 || r.status === 403) return halt('signed-out');
@@ -86,7 +86,7 @@
@@ -86,7 +86,7 @@
agents: ['No agent work yet', 'When an agent needs your approval or finishes a task, it shows here.'],
agents: ['No agent work yet', 'When an agent needs your approval or finishes a task, it shows here.'],
};
};
- let _config = { token: null, apiBase: 'https://openvibe.network', soundBase: '/assets/sounds', swPath: '/openvibe-sw.js', onAction: null, inboxUrl: null };
+ let _config = { token: null, apiBase: 'https://openvibe.network', scopes: null, soundBase: '/assets/sounds', swPath: '/openvibe-sw.js', onAction: null, inboxUrl: null };
let _pollTimer = null;
let _pollTimer = null;
let _lastSeenAt = null; // newest created_at we have toasted / seen
let _lastSeenAt = null; // newest created_at we have toasted / seen
let _unreadCount = 0;
let _unreadCount = 0;
@@ -445,9 +445,14 @@
@@ -445,9 +445,14 @@
function isCrossOrigin() {
function isCrossOrigin() {
try { return new URL(_config.apiBase).origin !== window.location.origin; } catch { return false; }
try { return new URL(_config.apiBase).origin !== window.location.origin; } catch { return false; }
}
}
- function apiFetch(path, opts = {}) {
+ /** The token may be a string or a function (the extension refreshes it); resolve it per request. */
+ async function tokenValue() {
+ return typeof _config.token === 'function' ? await _config.token() : _config.token;
+ }
+ async function apiFetch(path, opts = {}) {
const headers = { ...(opts.headers || {}) };
const headers = { ...(opts.headers || {}) };
- if (_config.token) headers['Authorization'] = `Bearer ${_config.token}`;
+ const token = await tokenValue();
+ if (token) headers['Authorization'] = `Bearer ${token}`;
if (opts.body && typeof opts.body === 'object' && !(opts.body instanceof FormData)) {
if (opts.body && typeof opts.body === 'object' && !(opts.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
headers['Content-Type'] = 'application/json';
opts = { ...opts, body: JSON.stringify(opts.body) };
opts = { ...opts, body: JSON.stringify(opts.body) };
@@ -760,7 +765,7 @@
@@ -760,7 +765,7 @@
}));
}));
}
}
function openActions(anchor) {
function openActions(anchor) {
- loadActionCenter().then((AC) => { if (AC) AC.toggle({ anchor, token: () => _config.token, apiBase: _config.apiBase }); });
+ loadActionCenter().then((AC) => { if (AC) AC.toggle({ anchor, token: () => tokenValue(), apiBase: _config.apiBase, scopes: _config.scopes }); });
}
}
function createActionsEl() {
function createActionsEl() {
const b = document.createElement('div');
const b = document.createElement('div');
@@ -1217,7 +1222,7 @@
@@ -1217,7 +1222,7 @@
if (!L || _live || !_config.realtime || !hasSession()) return;
if (!L || _live || !_config.realtime || !hasSession()) return;
_live = L.create({
_live = L.create({
ticketUrl: `${_config.apiBase}/api/v1/realtime/ticket`,
ticketUrl: `${_config.apiBase}/api/v1/realtime/ticket`,
- token: () => _config.token,
+ token: () => tokenValue(),
credentials: isCrossOrigin() ? 'omit' : 'include',
credentials: isCrossOrigin() ? 'omit' : 'include',
onNotification: () => soon(false),
onNotification: () => soon(false),
onGap: () => soon(true),
onGap: () => soon(true),
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.47.0",
+ "version": "4.57.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.47.0",
+ "version": "4.57.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.56.0",
+ "version": "4.57.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
+'use strict';
+// The Dock with the extension's read-only scopes and an async token (2026-10-30): every request uses the
+// token the function returns at call time; the Agents tab shows Details but no approve/decline; the Chats
+// tab reads but has no composer; the Activity tab loads through the same token function.
+// 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-signin-chrome: skipped (no Chrome; set CHROME_BIN)');
+ process.exit(0);
+}
+
+const ROOT = path.join(__dirname, '..');
+const iso = (ago) => new Date(Date.now() - ago).toISOString();
+const state = { open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } }, updated_at: iso(0) };
+const item = { id: 'apr_1', service: 'actor', agent: 'Researcher', bucket: 'waiting', state: 'waiting', kind: 'approval', title: 'Send the weekly digest?', summary: 'It wants to send from your address.', href: 'https://openvibe.actor/tasks/t1', actions: ['approve', 'decline', 'open'], cost_usd: null, steps: null, error: null, next_run_at: null, updated_at: iso(60e3), created_at: iso(60e3) };
+const ACTIONS = { generated_at: iso(1000), sources: { network: 'ok' }, counts: { running: 1, waiting: 1, scheduled: 0, done: 0, failed: 0 }, buckets: { waiting: [item], running: [], scheduled: [], done: [], failed: [] } };
+const NOTES = [{ id: 1, type: 'agent_done', category: 'agents', service: 'codes', title: 'Codes finished a task', message: 'The suite passed.', priority: 'normal', is_read: 0, created_at: iso(300e3) }];
+
+let tokenCalls = 0;
+const seenAuth = [];
+const server = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ const json = (status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ if (u.pathname === '/page') {
+ res.writeHead(200, { 'content-type': 'text/html' });
+ return res.end(`<!doctype html><html><head><meta charset="utf-8"><title>Dock</title></head><body><main>page</main>
+<script src="/ov-mark.js"></script><script src="/dock.js"></script>
+<script>window.__calls=0; OpenVibeDock.mount({ token: async () => { window.__calls++; return 'tok_async'; }, apiBase: location.origin, chatBase: location.origin, scopes: ['dock','activity','chat.read'], me: 'ada', sibling: (n) => '/' + n });</script>
+</body></html>`);
+ }
+ if (u.pathname === '/api/v1/me/dock') {
+ seenAuth.push(req.headers.authorization);
+ if (req.method === 'PUT') { Object.assign(state, JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')); }
+ return json(200, { state, counts: { running: 1, waiting: 1, unread: 1 }, sources_partial: false });
+ }
+ if (u.pathname === '/api/v1/me/actions') { seenAuth.push(req.headers.authorization); return json(200, ACTIONS); }
+ if (u.pathname === '/api/dm/unread') { seenAuth.push(req.headers.authorization); return json(200, { unread: 1 }); }
+ if (u.pathname === '/api/dm/conversations') { seenAuth.push(req.headers.authorization); return json(200, { conversations: [{ id: 11, name: null, is_group: false, last_message: 'see the shots?', last_sender_id: 2, last_message_at: iso(240e3), unread_count: 1, participants: [{ id: 1, username: 'ada', display_name: 'Ada' }, { id: 2, username: 'kai', display_name: 'Kai' }] }] }); }
+ const dm = /^\/api\/dm\/conversations\/(\d+)\/(messages|read)$/.exec(u.pathname);
+ if (dm && dm[2] === 'read') { seenAuth.push(req.headers.authorization); return json(200, { ok: true }); }
+ if (dm) { seenAuth.push(req.headers.authorization); return json(200, { messages: [{ id: 101, username: 'kai', display_name: 'Kai', message: 'see the shots?', created_at: iso(240e3) }] }); }
+ if (u.pathname === '/api/notifications') { seenAuth.push(req.headers.authorization); return json(200, { notifications: NOTES, total: 1, has_more: false }); }
+ if (u.pathname === '/api/notifications/unread-count') { seenAuth.push(req.headers.authorization); return json(200, { count: 1 }); }
+ if (u.pathname === '/api/notifications/unread-by-tab') { seenAuth.push(req.headers.authorization); return json(200, { tabs: { all: 1 } }); }
+ const name = u.pathname.slice(1);
+ if (/^[a-z-]+\.js$/.test(name) && fs.existsSync(path.join(ROOT, name))) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, name))); }
+ res.writeHead(404); res.end();
+ });
+});
+const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+async function until(p, expr, what, ms = 6000) {
+ const end = Date.now() + ms;
+ for (;;) {
+ const v = await p.evaluate(expr);
+ if (v) return v;
+ if (Date.now() > end) throw new Error(`timed out: ${what}`);
+ await wait(80);
+ }
+}
+
+(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-dock-signin-'));
+ const browser = await h.launch({ tmpDir });
+ const context = await browser.newContext();
+ const p = await h.openPage(browser, context, { width: 1280, height: 860 });
+ try {
+ await p.goto(`${base}/page`, { maxMs: 5000 });
+ await until(p, 'window.__calls > 0', 'the first dock read with the async token');
+ await until(p, "!!document.getElementById('ovdk-pill')", 'the pill');
+ assert.ok(seenAuth.length && seenAuth.every((a) => a === 'Bearer tok_async'), 'every request used the token function result');
+ await p.evaluate("document.getElementById('ovdk-pill').click()");
+ await until(p, "!!document.querySelector('.ovdk [data-panel=agent]')", 'the Dock opens');
+ await p.evaluate("document.getElementById('ovdk-tab-agent').click()");
+ await until(p, "document.querySelectorAll('.ovdk .ovac-item').length === 1", 'the action item');
+ assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovdk .ovac-item [data-do=approve], .ovdk .ovac-item [data-do=decline]').length"), 0, 'no approve/decline with activity only');
+ assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovac-item a[href]')"), true, 'Details stays');
+ await p.evaluate("document.getElementById('ovdk-tab-chats').click()");
+ await until(p, "document.querySelectorAll('.ovdk .ovdk-chat').length === 1", 'the chat list');
+ await p.evaluate("document.querySelector('.ovdk .ovdk-chat').click()");
+ await until(p, "document.querySelectorAll('.ovdk .ovdk-msg').length === 1", 'the thread');
+ assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovdk-send')"), false, 'no composer with chat.read only');
+ assert.match(await p.evaluate("document.querySelector('.ovdk [data-panel=chats]').innerText"), /Reading only/);
+ await p.evaluate("document.getElementById('ovdk-tab-activity').click()");
+ await until(p, "document.querySelectorAll('.ovdk .openvibe-notif-item').length === 1", 'the activity list');
+ assert.ok((await p.evaluate('window.__calls')) >= 3, 'the async token was resolved for each tab');
+ assert.ok(seenAuth.every((a) => a === 'Bearer tok_async'), JSON.stringify([...new Set(seenAuth)]));
+ console.log('dock signin chrome: all checks passed');
+ } finally {
+ await p.close();
+ await browser.close();
+ await new Promise((r) => server.close(r));
+ }
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -9,9 +9,10 @@ const { sizes, brotli } = require('../scripts/size-report');
@@ -9,9 +9,10 @@ const { sizes, brotli } = require('../scripts/size-report');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
const DOCK_PATH = path.join(ROOT, 'dock.js');
const DOCK_PATH = path.join(ROOT, 'dock.js');
-// dock.js was 12.27 KB brotli on 2026-10-30 (4.53.1, before the pop-out). K-D4 added 1.45 KB; the budget is that
-// plus 1.5 KB, so the pop-out has to stay inside it.
-const DOCK_BROTLI_BUDGET = 14097; // 13.77 KB (12561 + 1536)
+// dock.js was 12.27 KB brotli on 2026-10-30 (4.53.1, before the pop-out). K-D4 added 1.45 KB; the budget was that
+// plus 1.5 KB. 4.56.0 adds the async token (the extension refreshes it) and the read-only scope controls; the
+// budget is 14.0 KB, a 0.25 KB increase, so both still have to stay inside it.
+const DOCK_BROTLI_BUDGET = 14336; // 14.00 KB (14219 after 4.56.0 + 117 bytes of headroom)
const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js'];
const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js'];
// ── the budget ──────────────────────────────────────────────
// ── the budget ──────────────────────────────────────────────