openvibers/openvibe.shared Commits

Dock 4.57.0: refreshing token and read-only scope controls (#205)

OP OpenVibers committed 13 days ago · c71fbec6f0 · 11 files, +197 −63

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex d3ea755..975f8fb 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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:
ddiff --git a/README.md b/README.md
iindex 0afa6d2..28fb7e1 100644
---- a/README.md
++++ b/README.md
@@@ -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 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 7f1d429..780a7c5 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
}
ddiff --git a/action-center.js b/action-center.js
iindex f085963..4f765ba 100644
---- a/action-center.js
++++ b/action-center.js
@@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }
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;
ddiff --git a/dock.js b/dock.js
iindex e20e648..b4b80b8 100644
---- a/dock.js
++++ b/dock.js
@@@ -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) {
ddiff --git a/notification-live.js b/notification-live.js
iindex 1e95d0e..118466d 100644
---- a/notification-live.js
++++ b/notification-live.js
@@@ -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');
ddiff --git a/notification-ui.js b/notification-ui.js
iindex 94c3cdb..4b41fb3 100644
---- a/notification-ui.js
++++ b/notification-ui.js
@@@ -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 / seen
let _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),
ddiff --git a/package-lock.json b/package-lock.json
iindex 9485807..a96ccb4 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 00854cb..c8e6266 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/test/dock-signin-chrome.test.js b/test/dock-signin-chrome.test.js
nnew file mode 100644
iindex 0000000..04b6a24
---- /dev/null
++++ b/test/dock-signin-chrome.test.js
@@@ -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); });
ddiff --git a/test/dock-size.test.js b/test/dock-size.test.js
iindex 12acac3..6b94769 100644
---- a/test/dock-size.test.js
++++ b/test/dock-size.test.js
@@@ -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 ──────────────────────────────────────────────