openvibers/openvibe.network Commits

Add an Approvals section to the account hub for agent confirmations (#48)

OP OpenVibers committed 5 Oct 2026 · f83dbdaf06 · 7 files, +181 −5

* Add an Approvals section to the account hub for agent confirmations

The owner API existed (GET /api/v1/confirmations, POST /:id/approve and /:id/deny) but nothing in the hub used it, so the
"Approval needed" notification linked to a page where nothing could be approved. The hub now has an Approvals tab: each
pending request shows its summary, the agent and host asking, the capability, what it touches and how long is left, with
Approve once and Deny. The tab shows how many wait; a request decided elsewhere or lapsed reloads the list. Rows are built
from DOM nodes (agent text is never parsed as HTML). The notification links to /my#approvals. Plan T2 WS-Z2.

* Name the agent's host by its id, and keep anonymous sessions out of the Approvals API

The host is a SubjectRef object ({ type, id }), so rows read "on [object Object]". An anonymous session could open the tab and call the authenticated API; it now says approvals need a full account, and the tab is faded and disabled like Notifications and Security.

Changes

ddiff --git a/public/js/my/approvals.js b/public/js/my/approvals.js
nnew file mode 100644
iindex 00000000..42eab65f
---- /dev/null
++++ b/public/js/my/approvals.js
@@@ -0,0 +1,130 @@
++/* ═══════════════════════════════════════════════════════════════
++ OpenVibe.Network — account hub (/my): Approvals (plan T2 WS-Z2).
++ The owner's confirmation inbox: an agent acting for this person asks before
++ a sensitive capability runs (server/developer/confirmations.js). Lists the
++ pending requests from GET /api/v1/confirmations; Approve and Deny post to
++ /:id/approve and /:id/deny. Built from DOM nodes: summaries, names and
++ resources come from agents, so nothing here is parsed as HTML.
++ Classic script, global scope; relies on my/core.js (apiFetch, toast).
++ ═══════════════════════════════════════════════════════════════ */
++let approvalsState = { loading: false, items: [], agents: {} };
++
++function approvalsEl(tag, attrs, ...kids) {
++ const n = document.createElement(tag);
++ for (const [k, v] of Object.entries(attrs || {})) {
++ if (v == null) continue;
++ if (k === 'class') n.className = v; else if (k === 'text') n.textContent = v; else if (k.startsWith('on')) n.addEventListener(k.slice(2), v); else n.setAttribute(k, v);
++ }
++ for (const kid of kids) if (kid != null) n.append(kid);
++ return n;
++}
++
++// "in 4 min", "in 1 h 5 min", "expired": the request lapses by itself (expires_at), so the owner sees how long is left.
++function approvalsLeft(iso) {
++ const ms = Date.parse(iso) - Date.now();
++ if (!Number.isFinite(ms) || ms <= 0) return 'expired';
++ const m = Math.ceil(ms / 60000);
++ return m < 60 ? `${m} min left` : `${Math.floor(m / 60)} h ${m % 60} min left`;
++}
++
++// The agent's host is a SubjectRef ({ type: 'app', id: 'app_…' }) from agentContext; a string is taken as is.
++function approvalsHost(agent) {
++ const h = agent && agent.host;
++ if (!h) return '';
++ return typeof h === 'string' ? h : String(h.id || h.type || '');
++}
++
++function setApprovalsBadge(n) {
++ const btn = [...document.querySelectorAll('.section-tabs button')].find((b) => b.getAttribute('onclick')?.includes("'approvals'"));
++ if (!btn) return;
++ let badge = btn.querySelector('.tab-badge');
++ if (!n) { badge?.remove(); return; }
++ if (!badge) { badge = approvalsEl('span', { class: 'tab-badge', 'aria-label': 'waiting for you' }); btn.append(' ', badge); }
++ badge.textContent = String(n);
++}
++
++function renderApprovals(error) {
++ const list = document.getElementById('approvals-list');
++ if (!list) return;
++ list.replaceChildren();
++ if (error) {
++ list.append(approvalsEl('div', { class: 'approvals-empty', role: 'alert' },
++ approvalsEl('p', { text: `Could not load your approvals: ${error}` }),
++ approvalsEl('button', { class: 'btn', type: 'button', text: 'Try again', onclick: () => loadApprovals() })));
++ return;
++ }
++ const items = approvalsState.items.filter((c) => c.state === 'pending');
++ setApprovalsBadge(items.length);
++ if (!items.length) {
++ list.append(approvalsEl('p', { class: 'approvals-empty', text: 'Nothing is waiting for you. When an agent asks to do something sensitive on your behalf, it shows up here.' }));
++ return;
++ }
++ for (const c of items) list.append(approvalRow(c));
++}
++
++function approvalRow(c) {
++ const agent = approvalsState.agents[c.requested_by && c.requested_by.id] || null;
++ const who = agent ? agent.name : (c.requested_by && c.requested_by.id) || 'An agent';
++ const row = approvalsEl('div', { class: 'approval', 'data-id': c.id });
++ const meta = approvalsEl('div', { class: 'approval-meta' },
++ approvalsEl('span', { class: 'approval-who', text: who }),
++ approvalsHost(agent) ? approvalsEl('span', { class: 'approval-host', text: ` on ${approvalsHost(agent)}` }) : null,
++ approvalsEl('span', { class: 'approval-sep', text: ' · ' }),
++ approvalsEl('code', { class: 'approval-cap', text: c.capability }),
++ approvalsEl('span', { class: 'approval-sep', text: ' · ' }),
++ approvalsEl('span', { class: 'approval-left', text: approvalsLeft(c.expires_at) }));
++ const resources = Array.isArray(c.resources) && c.resources.length
++ ? approvalsEl('ul', { class: 'approval-resources', 'aria-label': 'What it touches' },
++ ...c.resources.slice(0, 8).map((r) => approvalsEl('li', { text: typeof r === 'string' ? r : [r.type, r.id || r.name].filter(Boolean).join(' ') })))
++ : null;
++ const approve = approvalsEl('button', { class: 'btn btn-primary', type: 'button', onclick: () => decideApproval(c.id, 'approve', row) },
++ approvalsEl('i', { class: 'fa-solid fa-check', 'aria-hidden': 'true' }), ' Approve once');
++ const deny = approvalsEl('button', { class: 'btn', type: 'button', onclick: () => decideApproval(c.id, 'deny', row) },
++ approvalsEl('i', { class: 'fa-solid fa-xmark', 'aria-hidden': 'true' }), ' Deny');
++ row.append(approvalsEl('p', { class: 'approval-summary', text: c.summary || c.capability }), meta, resources,
++ approvalsEl('div', { class: 'btn-row' }, approve, deny));
++ return row;
++}
++
++async function decideApproval(id, action, row) {
++ for (const b of row.querySelectorAll('button')) b.disabled = true;
++ try {
++ await apiFetch(`/api/v1/confirmations/${encodeURIComponent(id)}/${action}`, { method: 'POST', body: JSON.stringify(action === 'approve' ? { standing_rule: 'once' } : {}) });
++ approvalsState.items = approvalsState.items.filter((c) => c.id !== id);
++ renderApprovals();
++ toast(action === 'approve' ? 'Approved: the agent can go ahead once' : 'Denied: the agent was told no', 'success');
++ } catch (e) {
++ for (const b of row.querySelectorAll('button')) b.disabled = false;
++ // 409: it was decided elsewhere or lapsed meanwhile; reload so the list says what is true.
++ if (e.status === 409) { toast('That request is no longer pending', 'info'); return loadApprovals(); }
++ toast(`Could not ${action}: ${e.message}`, 'error');
++ }
++}
++
++async function loadApprovals() {
++ if (approvalsState.loading) return;
++ // An anonymous session has no confirmations to read (the API needs a full account): say so, call nothing.
++ if (typeof isAnonSession === 'function' && isAnonSession()) {
++ document.getElementById('approvals-list')?.replaceChildren(approvalsEl('p', { class: 'approvals-empty', text: 'Approvals are only available once you sign in with a full OpenVibe account.' }));
++ return;
++ }
++ approvalsState.loading = true;
++ const list = document.getElementById('approvals-list');
++ if (list && !approvalsState.items.length) list.replaceChildren(approvalsEl('p', { class: 'approvals-empty', text: 'Loading…' }));
++ try {
++ const r = await apiFetch('/api/v1/confirmations?state=pending&limit=100');
++ approvalsState.items = r.confirmations || [];
++ approvalsState.agents = r.agents || {};
++ renderApprovals();
++ } catch (e) {
++ renderApprovals(e.message);
++ } finally {
++ approvalsState.loading = false;
++ }
++}
++
++// The tab shows how many wait even before it is opened (a quiet read; a failure leaves the badge off).
++async function refreshApprovalsBadge() {
++ if (typeof isAnonSession === 'function' && isAnonSession()) return;
++ try { const r = await apiFetch('/api/v1/confirmations?state=pending&limit=100'); setApprovalsBadge((r.confirmations || []).filter((c) => c.state === 'pending').length); } catch { /* signed out or offline: no badge */ }
++}
ddiff --git a/public/js/my/boot.js b/public/js/my/boot.js
iindex d749a3b9..319abcbb 100644
---- a/public/js/my/boot.js
++++ b/public/js/my/boot.js
@@@ -9,3 +9,4 @@ OpenVibeAccountSwitcher.init({ apiBase: API });
loadUser();
buildDashboardShowcase();
openInitialSection();
++refreshApprovalsBadge();
ddiff --git a/public/js/my/core.js b/public/js/my/core.js
iindex 1a252bb4..8c6971e5 100644
---- a/public/js/my/core.js
++++ b/public/js/my/core.js
@@@ -158,13 +158,14 @@ function showSection(id, btn) {
history.replaceState(null, '', id === 'profile' ? window.location.pathname : `${window.location.pathname}#${id}`);
if (id === 'history' && typeof loadHistory === 'function') loadHistory();
if (id === 'data') loadDataSection();
++ if (id === 'approvals' && typeof loadApprovals === 'function') loadApprovals();
}
function openInitialSection() {
const pathSection = { '/themes': 'themes', '/notifications': 'notifications', '/verify-email': 'notifications', '/linked': 'linked', '/security': 'security', '/profile': 'profile', '/history': 'history' }[window.location.pathname];
const routeSection = pathSection || window.location.hash.replace(/^#/, '') || 'profile';
if (window.location.pathname === '/verify-email') setTimeout(handleVerifyEmailToken, 50);
-- if (isAnonSession() && ['notifications','linked','security','data'].includes(routeSection)) {
++ if (isAnonSession() && ['notifications','linked','security','data','approvals'].includes(routeSection)) {
return showSection('anon');
}
if (document.getElementById('sec-' + routeSection)) {
@@@ -199,7 +200,8 @@ function applyAnonModeUI(user) {
const notifTab = [...document.querySelectorAll('.section-tabs button')].find((button) => button.getAttribute('onclick')?.includes("'notifications'"));
const linkedTab = [...document.querySelectorAll('.section-tabs button')].find((button) => button.getAttribute('onclick')?.includes("'linked'"));
const securityTab = [...document.querySelectorAll('.section-tabs button')].find((button) => button.getAttribute('onclick')?.includes("'security'"));
-- [notifTab, linkedTab, securityTab].forEach((button) => {
++ const approvalsTab = [...document.querySelectorAll('.section-tabs button')].find((button) => button.getAttribute('onclick')?.includes("'approvals'"));
++ [notifTab, linkedTab, securityTab, approvalsTab].forEach((button) => {
if (!button) return;
button.style.opacity = '.45';
button.title = 'Unavailable in anonymous mode';
@@@ -208,6 +210,7 @@ function applyAnonModeUI(user) {
setSectionDisabled('notifications', 'Notification inbox, email preferences, and alert delivery are only available for full accounts.');
setSectionDisabled('linked', 'Linked services are only available once you sign in with a full OpenVibe account.');
setSectionDisabled('security', 'Anonymous identities do not have passwords or device sessions to manage.');
++ setSectionDisabled('approvals', 'Approvals for your agents are only available for full accounts.');
const themeGrid = document.getElementById('theme-grid');
if (themeGrid) {
ddiff --git a/public/my.html b/public/my.html
iindex ac825a71..89c28c4a 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -68,6 +68,18 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
.section-tabs button.active{background:var(--bg-card);color:var(--accent-light)}
.section-tabs button:hover:not(.active){color:var(--text)}
++/* Approvals (my/approvals.js): one row per waiting request, and the count on its tab */
++.section-tabs .tab-badge{display:inline-block;min-width:18px;padding:0 6px;border-radius:9px;background:var(--accent);color:#fff;font-size:11px;line-height:18px;text-align:center}
++.approval{border:1px solid var(--border);border-radius:10px;padding:14px 16px;margin-bottom:10px;background:var(--bg-secondary)}
++.approval-summary{font-weight:600;color:var(--text);margin-bottom:6px;overflow-wrap:anywhere}
++.approval-meta{font-size:12px;color:var(--text-secondary);margin-bottom:10px;overflow-wrap:anywhere}
++.approval-who{color:var(--text);font-weight:600}
++.approval-cap{font-size:11px;padding:1px 6px;border-radius:5px;background:var(--bg-input);border:1px solid var(--border)}
++.approval-resources{list-style:none;display:flex;flex-wrap:wrap;gap:6px;margin:0 0 10px;padding:0}
++.approval-resources li{font-size:11px;padding:2px 8px;border-radius:6px;background:var(--bg-input);color:var(--text-secondary);overflow-wrap:anywhere}
++.approval .btn-row{display:flex;gap:8px;flex-wrap:wrap}
++.approvals-empty{color:var(--text-secondary);font-size:13px}
++
/* Section */
.section{display:none;animation:sec-in .2s ease}
.section.active{display:block}
@@@ -304,6 +316,7 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
<button onclick="showSection('notifications',this)"><i class="fa-solid fa-bell"></i> Notifications</button>
<button onclick="showSection('history',this)"><i class="fa-solid fa-clock-rotate-left"></i> History</button>
<button onclick="showSection('data',this)"><i class="fa-solid fa-database"></i> AI &amp; Data</button>
++ <button onclick="showSection('approvals',this)"><i class="fa-solid fa-user-check"></i> Approvals</button>
<button onclick="showSection('linked',this)"><i class="fa-solid fa-link"></i> Linked Services</button>
<button onclick="showSection('security',this)"><i class="fa-solid fa-lock"></i> Security</button>
<button onclick="showSection('anon',this)"><i class="fa-solid fa-user-secret"></i> Anon Info</button>
@@@ -501,6 +514,16 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
</div>
<!-- Linked Services Section -->
++ <div class="section" id="sec-approvals">
++ <div class="card">
++ <h3><span class="icon"><i class="fa-solid fa-user-check"></i></span> Approvals</h3>
++ <p style="margin-bottom:12px;color:var(--text-secondary);font-size:13px">Agents you run ask here before they use a sensitive permission on your behalf. Approve lets it go ahead once; Deny tells it no. A request you leave alone expires by itself.</p>
++ <div id="approvals-list" aria-live="polite">
++ <!-- Populated by JS (my/approvals.js) -->
++ </div>
++ </div>
++ </div>
++
<div class="section" id="sec-linked">
<div class="card">
<h3><span class="icon"><i class="fa-solid fa-link"></i></span> Linked Services</h3>
@@@ -586,6 +609,7 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
<script src="/js/my/themes.js" defer></script>
<script src="/js/my/profile.js" defer></script>
<script src="/js/my/security.js" defer></script>
++<script src="/js/my/approvals.js" defer></script>
<script src="/js/my/boot.js" defer></script>
<script src="https://openvibe.live/live-notify.js" async></script>
</body>
ddiff --git a/server/developer/confirmations.js b/server/developer/confirmations.js
iindex 4c4838ef..186a3e20 100644
---- a/server/developer/confirmations.js
++++ b/server/developer/confirmations.js
@@@ -184,7 +184,7 @@ async function notifyOwner(db, { owner, name }, summary) {
const u = await db.prepare('SELECT id FROM users WHERE subject_id = ?').get(owner);
if (!u) return;
const message = `${String(name || 'An agent').slice(0, 80)}: ${summary}`.slice(0, 200);
-- await notifier.create({ user_id: u.id, type: 'CONFIRMATION_REQUESTED', title: 'Approval needed', message, url: 'https://openvibe.network/my', service: 'network' });
++ await notifier.create({ user_id: u.id, type: 'CONFIRMATION_REQUESTED', title: 'Approval needed', message, url: 'https://openvibe.network/my#approvals', service: 'network' });
} catch (err) {
console.warn('[Confirmations] owner notify failed:', err.message);
}
ddiff --git a/test/confirmations.test.js b/test/confirmations.test.js
iindex 35a1bc45..d4aac6ae 100644
---- a/test/confirmations.test.js
++++ b/test/confirmations.test.js
@@@ -428,7 +428,7 @@ for (const [id, name] of PEOPLE) T[name] = jwt.sign({ sub: id, id }, keys.privat
assert.strictEqual(n1.state, 'pending');
assert.strictEqual(calls.length, 1);
const { message, ...rest } = calls[0];
-- assert.deepStrictEqual(rest, { user_id: 10, type: 'CONFIRMATION_REQUESTED', title: 'Approval needed', url: 'https://openvibe.network/my', service: 'network' });
++ assert.deepStrictEqual(rest, { user_id: 10, type: 'CONFIRMATION_REQUESTED', title: 'Approval needed', url: 'https://openvibe.network/my#approvals', service: 'network' });
assert.strictEqual(message, `Bot: ${SUMMARY}`);
assert.ok(!message.includes('private-detail-text'));
await approve(n1, 'owner', { standing_rule: 'always' });
ddiff --git a/test/my-modules.test.js b/test/my-modules.test.js
iindex 6c5f96aa..e2c128ce 100644
---- a/test/my-modules.test.js
++++ b/test/my-modules.test.js
@@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) as
const files = fs.readdirSync(path.join(pub, 'js', 'my')).filter((f) => f.endsWith('.js')).sort();
// The module map, in load order (public/js/my/…).
--const modules = ['core.js', 'tools.js', 'themes.js', 'profile.js', 'security.js', 'boot.js'];
++const modules = ['core.js', 'tools.js', 'themes.js', 'profile.js', 'security.js', 'approvals.js', 'boot.js'];
assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');
const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/my\/([^"]+)"([^>]*)><\/script>/g)];
const loaded = tags.map((m) => m[2]);
@@@ -32,4 +32,22 @@ assert.ok(owner.size > 70, 'the modules declare the hub functions');
// Every section calls these, so they load first.
for (const name of ['_bootstrapFromCookie', 'getAuthToken', 'apiFetch', 'showSection']) assert.strictEqual(owner.get(name), 'core.js', `${name} lives in core.js`);
++// The Approvals section (T2 WS-Z2): its tab, its section and its list, and the module builds rows from DOM nodes, never
++// innerHTML (summaries, names and resources come from agents).
++assert.ok(html.includes('id="sec-approvals"') && html.includes('id="approvals-list"') && html.includes("showSection('approvals',this)"), 'the Approvals tab and section are in my.html');
++const approvals = fs.readFileSync(path.join(pub, 'js', 'my', 'approvals.js'), 'utf8');
++assert.ok(!/innerHTML|insertAdjacentHTML/.test(approvals), 'approvals.js never parses agent text as HTML');
++for (const name of ['loadApprovals', 'decideApproval', 'refreshApprovalsBadge']) assert.strictEqual(owner.get(name), 'approvals.js', `${name} lives in approvals.js`);
++assert.ok(fs.readFileSync(path.join(pub, 'js', 'my', 'boot.js'), 'utf8').includes('refreshApprovalsBadge()'), 'boot.js shows the waiting count on the tab');
++// The host is a SubjectRef object: the row names its id, never "[object Object]"; an anonymous session calls no API.
++{
++ const vm = require('vm');
++ const sandbox = { document: { createElement: () => ({}) }, console };
++ vm.createContext(sandbox); vm.runInContext(approvals, sandbox);
++ assert.strictEqual(sandbox.approvalsHost({ host: { type: 'app', id: 'app_01' } }), 'app_01');
++ assert.strictEqual(sandbox.approvalsHost({ host: 'node-1' }), 'node-1');
++ assert.strictEqual(sandbox.approvalsHost({}), '');
++ assert.ok(/isAnonSession\(\)/.test(approvals.slice(approvals.indexOf('async function loadApprovals'))), 'loadApprovals checks for an anonymous session first');
++}
++
console.log('my-modules: all checks passed');