openvibers/openvibe.shared Commits

4.170.0: the wallet, one sheet on every site (through Network) (#365)

OP OpenVibers committed 4 days ago · 93d0bbee43 · 20 files, +472 −121

* Add the universal wallet sheet and shared balance chips

* Wallet: ask through Network's relays, follow only named checkout hosts

The sheet and the bar's chips no longer call billing./inventory.openvibe.network from the
browser (no site's connect-src allows them): every request goes to Network's
/api/frame/wallet/… relays, configured by frameBase (default
https://openvibe.network/api/frame). Network forwards only the person's Bearer token, never a
cookie.

A hosted checkout is followed only when its URL is https with no credentials and its host is
one Billing names for the chosen provider (checkout.providers: [{ name, hosts }]) or is
billing.openvibe.network; otherwise the sheet shows "Checkout is unavailable." and stops.

The modal's scroll lock is left to surfaces.js when present, so its saved page state is not
"hidden".

* Wallet: no eyebrow, makers by name, the close ring for the keyboard only

* Merge main

* 4.170.0: the wallet

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 7d85c42..6f91766 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,19 @@
# Changelog
++## 4.170.0 — 2026-11-09
++
++- **The wallet, one sheet on every site** (`ov-wallet.js`, opened from the navbar's balance chips): OpenCoins, Vibes
++ and Balance with how to earn, buy or top up; the store (items travel with the account, priced in Vibes or
++ OpenCoins); your items with Equip and Unequip.
++- **Through Network**: every call goes to `https://openvibe.network/api/frame/wallet/…` (Network #718, nine fixed
++ Billing and Inventory paths, the person's token only). Every site's CSP refuses billing. and inventory. hosts.
++- **Checkout only on a named host**: the sheet follows a checkout URL only when it is https, carries no credentials,
++ and its host is one Billing names for its providers (Billing #228) or billing.openvibe.network. Anything else is
++ "Checkout is unavailable."
++- Each purchase carries its own idempotency key and keeps it across a retry, so a double tap never charges twice.
++- Designed here: no eyebrow, makers by name ("By Live"), the close ring for the keyboard only, 44 px controls,
++ phone sheet from the bottom.
++
## 4.169.0 — 2026-11-09
- **kit.switchRow, redrawn** (ask 44, the paste form: "polish and align it: switches, sizes"): an icon tile, a title and
ddiff --git a/README.md b/README.md
iindex 692faf8..d0e7219 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.169.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.170.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 94dc3fa..32e743d 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.169.0",
++ "version": "4.170.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)",
ddiff --git a/docs/screenshots/wallet-1280-dark.png b/docs/screenshots/wallet-1280-dark.png
nnew file mode 100644
iindex 0000000..de86c9e
BBinary files /dev/null and b/docs/screenshots/wallet-1280-dark.png differ
ddiff --git a/docs/screenshots/wallet-1280-light.png b/docs/screenshots/wallet-1280-light.png
nnew file mode 100644
iindex 0000000..d4fbe17
BBinary files /dev/null and b/docs/screenshots/wallet-1280-light.png differ
ddiff --git a/docs/screenshots/wallet-390-dark.png b/docs/screenshots/wallet-390-dark.png
nnew file mode 100644
iindex 0000000..61de240
BBinary files /dev/null and b/docs/screenshots/wallet-390-dark.png differ
ddiff --git a/docs/screenshots/wallet-390-light.png b/docs/screenshots/wallet-390-light.png
nnew file mode 100644
iindex 0000000..77812e5
BBinary files /dev/null and b/docs/screenshots/wallet-390-light.png differ
ddiff --git a/docs/wallet.md b/docs/wallet.md
nnew file mode 100644
iindex 0000000..27b89c7
---- /dev/null
++++ b/docs/wallet.md
@@@ -0,0 +1,18 @@
++# Shared Wallet
++
++The signed-in navbar reads GET /api/v1/me/wallet once per minute and shows two balances, keeping the third in Wallet. Ranking compares Vibes and Balance at Billing’s USD value rate; OpenCoins uses its spendable count because it has no cash exchange rate. A zero Balance is shown first when the person used AI this month. Unavailable balances show an ellipsis, never a false zero. Account changes discard old responses and close private content.
++
++The first chip tap loads ov-wallet.js and ov-wallet.css. Native dialog focus containment, Escape, outside-click dismissal, a locked page and an internal scroller work at phone and desktop widths. All three balances include zeros and an action. The sheet reads the reviewed Inventory catalogue and the person's items, marks owned items, buys via Billing-backed orders, equips/unequips, and shows five recent events with their source. Checkout opens the provider in the same tab. Failed or uncertain actions keep their idempotency key while retrying.
++
++OpenVibeNavbar.openWallet() opens the shared sheet from a site action. OpenVibeNavbar.refreshWallet() refreshes balances after a site completes a money action. frameBase (default https://openvibe.network/api/frame) configures where the sheet asks; every request goes through Network's /api/frame/wallet/… relays, because each site's connect-src allows openvibe.network and refuses billing./inventory.openvibe.network. Network forwards only the person's Bearer token (never a cookie), fixed paths, JSON bodies of at most 8 KB, and passes Billing's/Inventory's status and body back.
++
++A hosted checkout is followed only when its URL is https with no credentials and its host is one Billing names for the chosen provider (checkout.providers: [{ name, hosts }]) or is billing.openvibe.network; otherwise the sheet shows "Checkout is unavailable." and stays put.
++
++Rollout requires the companion Billing/Inventory APIs, reviewed listings, enabled checkout providers (with their hosts in Billing's offers), and the Network wallet relays. The Inventory API serves bearer CORS without cookies. Release/pin alongside the companion PRs; no deployment is performed here.
++
++Screenshots use deterministic local sample balances and items, not a production account. The Chrome test verifies both sizes/themes, overflow, 44px controls, first-tap loading, equip, checkout, Escape, account switching and console/CSP errors. Capture with WALLET_SCREENSHOT_DIR=docs/screenshots ov test test/wallet-chrome.test.js.
++
++![390 dark](screenshots/wallet-390-dark.png)
++![390 light](screenshots/wallet-390-light.png)
++![1280 dark](screenshots/wallet-1280-dark.png)
++![1280 light](screenshots/wallet-1280-light.png)
ddiff --git a/files.js b/files.js
iindex 4ff403a..a7bf942 100644
---- a/files.js
++++ b/files.js
@@@ -46,6 +46,7 @@ const BROWSER = Object.freeze([
'ov-qr.js', // a QR code from a link, on the device: the Share sheet's QR code (ov-share.js loads it on first use)
'ov-locker-core.js', // openvibe-sdk/locker for the browser, generated (scripts/build-locker-core.js): WebCrypto only, the SDK's own vectors
'ov-locker.js', // Locker in the browser (plan Q2): a locked item's card, making and unlocking your locker; crypto in ov-locker-core.js
++ 'ov-wallet.js',
'ov-share.js', // the one Share sheet (plan track Q): who can open a thing, people by @name, transfer; navbar.js loads it on the first press of a [data-ov-share-sheet] button
'ov-upload.js', // shared file picker, upload queue and ov-progress
'messages.js', // Chat's two views: room lines and texting bubbles (openvibe-shared/messages)
@@@ -92,6 +93,7 @@ const STYLES = Object.freeze([
'kit.css', // the OpenVibe Kit's look (kit.js / openvibe-shared/kit build the markup; docs/kit.md)
'ov-forms.css', // the look of ov-forms.js's select, the site-coloured checkboxes, switches and sliders, .ov-field (docs/forms.md)
'ov-locker.css', // ov-locker.js's look: the locked card, the locker sheets, the recovery kit (docs/locker.md)
++ 'ov-wallet.css',
'ov-share.css', // ov-share.js's look: the sheet, the recipient's offer banner, the not-on-the-list gate (docs/share.md)
'ov-upload.css', // picker, queue and ov-progress states
'messages.css', // the room and bubble views' look
ddiff --git a/navbar.js b/navbar.js
iindex c672fe1..620d91b 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -4,7 +4,7 @@
'use strict';
let _config = {
-- service: 'network', token: null, user: null, apiBase: 'https://openvibe.network', billingBase: null,
++ service: 'network', token: null, user: null, apiBase: 'https://openvibe.network', billingBase: null, frameBase: null,
onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null, logoutUrl: null,
brand: null, brandName: null, brandIcon: null, compact: 'auto',
links: null, menu: null, recent: true,
@@@ -1119,6 +1119,7 @@
const close = () => { if (pop) pop.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); };
btn.addEventListener('click', (e) => {
e.stopPropagation();
++ if (_config.wallet) { close(); openWallet(); return; }
if (pop && pop.classList.contains('open')) return close();
if (!pop) { pop = document.createElement('div'); pop.className = 'ovnav-chips-pop'; pop.setAttribute('role', 'menu'); nav.appendChild(pop); regPanel(pop, 'chips', close, btn); }
const list = (_config.chips || []).filter((c) => c && !c.hidden);
@@@ -2022,58 +2023,63 @@
* Network and Billing in the background and when the tab returns after a minute; on a phone they fold into one.
*/
const WALLET_CHIPS = [
-- { id: 'coins', icon: 'fa-coins', tone: 'gold', label: 'OpenCoins', title: 'OpenCoins', href: 'https://openvibe.network/my#coins' },
-- { id: 'vibes', icon: 'fa-wallet', tone: 'green', label: 'Vibes', title: 'Your Vibes', href: 'https://openvibe.live/#vibes' },
++ { id: 'coins', icon: 'fa-coins', tone: 'gold', label: 'OpenCoins', title: 'Open Wallet' },
++ { id: 'vibes', icon: 'fa-wallet', tone: 'green', label: 'Vibes', title: 'Open Wallet' },
++ { id: 'balance', icon: 'fa-wallet', label: 'Balance', title: 'Open Wallet' },
];
-- let _walletFor = null, _walletAt = 0, _walletWatch = false;
-- const walletKey = (u) => `ov-wallet:${u.subject || u.id || u.username || ''}`;
-- function savedWallet(u) { try { return JSON.parse(localStorage.getItem(walletKey(u)) || '{}') || {}; } catch { return {}; } }
++ let _walletFor = null, _walletAt = 0, _walletWatch = false, _walletData = null, _walletScript = null;
++ const walletKey = (u) => `${u.subject || u.id || u.username || ''}:${_config.token || ''}`;
++ function walletAmount(b) {
++ const n = Number(b.amount) / Math.pow(10, b.scale || 0);
++ return b.currency === 'USD' ? new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' }).format(n) : n.toLocaleString();
++ }
++ function walletOrder(data) {
++ const order = ['coins', 'vibes', 'balance'];
++ if (!data) return order.slice(0, 2);
++ const value = (id) => Number(data.balances[id].amount) / Math.pow(10, data.balances[id].scale) / (id === 'vibes' ? data.bits_per_usd || 100 : 1);
++ order.sort((a, b) => value(b) - value(a));
++ if (data.used_ai_this_month && BigInt(data.balances.balance.amount) === 0n) return ['balance', ...order.filter((id) => id !== 'balance')].slice(0, 2);
++ return order.slice(0, 2);
++ }
function syncWalletChips() {
if (!_config.wallet) return;
const u = _config.user;
-- let chips = (_config.chips || []).filter((c) => !(c && c.__wallet));
++ let chips = (_config.chips || []).filter((c) => !(c && (c.__wallet || ['coins', 'vibes', 'balance'].includes(c.id))));
if (u && !u.is_anon) {
-- const saved = savedWallet(u), have = new Set(chips.map((c) => c && c.id));
-- chips = WALLET_CHIPS.filter((w) => !have.has(w.id)).map((w) => Object.assign({}, w, {
-- __wallet: true, value: saved[w.id] == null ? '' : saved[w.id], hidden: saved[w.id] == null,
-- onClick: () => { try { root.location.href = w.href; } catch { /* */ } },
++ const data = _walletFor === walletKey(u) ? _walletData : null;
++ chips = walletOrder(data).map((id) => Object.assign({}, WALLET_CHIPS.find((w) => w.id === id), {
++ __wallet: true, value: data ? walletAmount(data.balances[id]) : '…', hidden: false, onClick: openWallet,
})).concat(chips);
}
_config.chips = chips;
}
++ async function openWallet() {
++ if (!_config.token) return;
++ if (!root.OpenVibeWallet) {
++ if (!_walletScript) _walletScript = new Promise((resolve, reject) => {
++ const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = siblingV('ov-wallet.css'); document.head.appendChild(css);
++ const js = document.createElement('script'); js.src = siblingV('ov-wallet.js'); js.onload = resolve;
++ js.onerror = () => { _walletScript = null; css.remove(); js.remove(); reject(new Error('Wallet could not load')); }; document.head.appendChild(js);
++ });
++ try { await _walletScript; } catch { if (_walletScript === null) root.location.assign('https://openvibe.network/wallet'); return; }
++ }
++ return root.OpenVibeWallet.open({ getToken: () => _config.token, frameBase: _config.frameBase || 'https://openvibe.network/api/frame',
++ onWallet: (data) => { if (!_config.user) return; _walletFor = walletKey(_config.user); _walletData = data; _walletAt = Date.now(); if (_navEl) render(); } });
++ }
function loadWalletChips() {
const u = _config.user;
if (!_config.wallet || !u || u.is_anon || !_config.token || typeof fetch !== 'function') return;
-- const key = walletKey(u);
++ const key = walletKey(u), token = _config.token;
if (_walletFor === key && Date.now() - _walletAt < 60000) return;
++ if (_walletFor !== key) _walletData = null;
_walletFor = key; _walletAt = Date.now();
if (!_walletWatch && typeof document !== 'undefined' && document.addEventListener) {
_walletWatch = true;
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { loadWalletChips(); loadUnread(); } });
}
-- const auth = { Authorization: `Bearer ${_config.token}` };
-- const saved = savedWallet(u);
-- let shown = false;
-- const put = (id, n) => {
-- const mine = (_config.chips || []).find((c) => c && c.id === id && c.__wallet);
-- if (!mine) return;
-- if (n == null) { delete saved[id]; if (!mine.hidden) { mine.hidden = true; shown = true; } return; }
-- saved[id] = n.toLocaleString();
-- if (mine.hidden) shown = true;
-- OpenVibeNavbar.setChip(id, saved[id], { hidden: false });
-- };
-- const num = (v) => (isFinite(v) ? v : null);
-- // Both from the Network (it asks Billing); an older Network without vibes: Billing itself (409 = not the ledger).
-- let vibesKnown = false;
-- const coins = fetch(`${_config.apiBase}/api/coins/me?with=vibes`, { headers: auth, credentials: 'include' })
-- .then(async (r) => { if (!r.ok) return; const j = await r.json(); put('coins', num(Number(j.balance ?? (j.wallet && j.wallet.balance)))); if ('vibes' in j && j.vibes != null) { vibesKnown = true; put('vibes', num(Number(j.vibes))); } });
-- const vibes = coins.catch(() => {}).then(() => (vibesKnown ? null : fetch(`${_config.billingBase || 'https://billing.openvibe.network'}/api/me/vibes`, { headers: auth, credentials: 'omit' })
-- .then(async (r) => { if (r.ok) put('vibes', num(Number((await r.json()).credit))); else if (r.status === 409) put('vibes', null); })));
-- Promise.all([coins.catch(() => {}), vibes.catch(() => {})]).then(() => {
-- try { localStorage.setItem(walletKey(u), JSON.stringify(saved)); } catch { /* */ }
-- // A chip that appeared or went away changes the folded phone chip: draw the bar once more.
-- if (shown && _config.user === u && _navEl) render();
-- });
++ fetch(`${(_config.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '')}/wallet/api/v1/me/wallet`, { headers: { Authorization: `Bearer ${token}` }, credentials: 'omit' })
++ .then(async (r) => { if (!r.ok) return; const data = await r.json(); if (_config.user !== u || _config.token !== token) return;
++ _walletData = data; if (_navEl) render(); }).catch(() => {});
}
function render() {
@@@ -2630,7 +2636,9 @@
brand() { return resolveBrand(); },
/** The signed-in user changed (sites with auth: 'external'). Pass null for signed out. */
-- setUser(user, token) { _config.user = user || null; if (token !== undefined) _config.token = token; _walletLoaded = false; return render(); },
++ openWallet,
++ refreshWallet() { _walletAt = 0; loadWalletChips(); },
++ setUser(user, token) { if (root.OpenVibeWallet && (_config.user !== user || (token !== undefined && token !== _config.token))) root.OpenVibeWallet.close(); _config.user = user || null; if (token !== undefined) _config.token = token; _walletLoaded = false; return render(); },
/** Single-page apps: mark the link whose `page` matches as active. */
setActive(page) { _activePage = page || null; renderAppBar(); if (!_navEl) return; _navEl.querySelectorAll('.ovnav-link[data-page]').forEach(a => a.classList.toggle('active', a.getAttribute('data-page') === _activePage)); },
/** Change a status chip's text (navbar and menu header): setChip('coins', '1,234'). */
@@@ -2718,7 +2726,7 @@
fillUrl,
},
_ask: { selectedText, askVibeHref },
-- _wallet: { loadWallet, configure: (c) => { Object.assign(_config, c); _walletLoaded = false; } },
++ _wallet: { loadWallet, walletOrder, syncWalletChips, loadWalletChips, configure: (c) => { Object.assign(_config, c); _walletLoaded = false; } },
};
if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNavbar;
ddiff --git a/ov-wallet.css b/ov-wallet.css
nnew file mode 100644
iindex 0000000..0532ceb
---- /dev/null
++++ b/ov-wallet.css
@@@ -0,0 +1,8 @@
++.ov-wallet{--wallet-bg:var(--bg-primary,#171b20);--wallet-card:var(--bg-secondary,#20262c);--wallet-text:var(--text-primary,#f4f5f6);--wallet-muted:var(--text-secondary,#a9b3bc);--wallet-line:var(--border-color,#38414b);box-sizing:border-box;width:min(860px,calc(100% - 32px));max-height:calc(100dvh - 48px);padding:0;border:1px solid var(--wallet-line);border-radius:24px;background:var(--wallet-bg);color:var(--wallet-text);font:400 15px/1.5 var(--font-family,system-ui,sans-serif);box-shadow:0 30px 100px #0006;overflow:hidden}
++.ov-wallet::backdrop{background:#070b14a6;backdrop-filter:blur(5px)}
++.ov-wallet *{box-sizing:border-box}.ov-wallet h2,.ov-wallet h3,.ov-wallet h4,.ov-wallet p{margin:0}.ov-wallet h2{font-size:28px;letter-spacing:-.7px;line-height:1.2}.ov-wallet h3{font-size:19px;line-height:1.4}.ov-wallet h4{font-size:15px;overflow-wrap:anywhere}.ov-wallet-head{display:flex;align-items:center;justify-content:space-between;padding:24px 28px;border-bottom:1px solid var(--wallet-line);gap:16px}.ov-wallet-eyebrow{font-size:11px;letter-spacing:1.5px;font-weight:700;color:var(--wallet-muted);margin-bottom:5px!important}.ov-wallet-close{width:44px;height:44px;flex-shrink:0;border:1px solid var(--wallet-line);border-radius:50%;background:transparent;color:inherit;font:28px/1 system-ui;cursor:pointer}
++.ov-wallet-body{max-height:calc(100dvh - 168px);overflow:auto;overscroll-behavior:contain;padding:24px 28px 32px;scrollbar-gutter:stable}.ov-wallet-status:empty{display:none}.ov-wallet-status{padding:12px;margin-bottom:16px!important;border:1px solid var(--wallet-line);border-radius:10px;font-size:14px}.ov-wallet-status.is-error{color:var(--wallet-text);border-color:#c77e51}.ov-wallet-balances{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.ov-wallet-balance{border:1px solid var(--wallet-line);border-radius:16px;padding:16px;display:flex;flex-direction:column;gap:12px;background:var(--wallet-card)}.ov-wallet-balance-title{display:flex;align-items:center;gap:8px}.ov-wallet-balance-title h3{font-size:14px}.ov-wallet-symbol{display:grid;place-items:center;width:26px;height:26px;border-radius:50%;font-weight:800;color:#171b20;background:#dcb85d}.is-vibes .ov-wallet-symbol{background:#8ddac5}.is-balance .ov-wallet-symbol{background:#a3bafa}.ov-wallet-value{font-size:29px;font-weight:750;letter-spacing:-1px;line-height:1.1;overflow-wrap:anywhere}.ov-wallet-purpose{font-size:13px;color:var(--wallet-muted);flex:1}.ov-wallet-button{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:9px 12px;border:1px solid var(--wallet-line);border-radius:10px;background:transparent;color:var(--wallet-text);font-family:inherit;font-size:13px;font-weight:600;line-height:1.35;text-align:center;text-decoration:none;cursor:pointer;white-space:normal;gap:6px}.ov-wallet-button:hover{background:var(--wallet-card);border-color:var(--wallet-muted)}.ov-wallet-button.is-primary{background:#8ddac5;color:#112c24;border-color:#8ddac5}.ov-wallet button:disabled{opacity:.5;cursor:default}.ov-wallet :focus-visible{outline:3px solid #8ddac5;outline-offset:3px}.ov-wallet-section{margin-top:30px}.ov-wallet-muted{color:var(--wallet-muted);font-size:13px}.ov-wallet-section>p{margin-top:4px;margin-bottom:14px}.ov-wallet-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.ov-wallet-item{border:1px solid var(--wallet-line);border-radius:14px;padding:10px;display:flex;flex-direction:column;gap:8px;min-width:0}.ov-wallet-preview{height:100px;display:flex;align-items:center;justify-content:center;border-radius:9px;background:var(--wallet-card);font-size:38px;overflow:hidden;text-align:center;padding:8px}.ov-wallet-preview img{width:100%;height:100%;object-fit:contain}.ov-wallet-sample{font-size:21px;font-weight:800;letter-spacing:-.4px;line-height:1.2;overflow-wrap:anywhere}.ov-wallet-preview.is-rainbow .ov-wallet-sample{background:linear-gradient(110deg,#f393b5,#b0adff,#8ddac5);background-clip:text;color:transparent}.ov-wallet-owned{padding:12px 0;font-size:13px;color:var(--wallet-muted)}.ov-wallet-owned-row{display:flex;gap:12px;align-items:center;padding:12px 0;border-bottom:1px solid var(--wallet-line)}.ov-wallet-owned-row .ov-wallet-preview{height:48px;width:48px;flex-shrink:0;font-size:24px}.ov-wallet-owned-row .ov-wallet-sample{font-size:12px}.ov-wallet-owned-row>div:nth-child(2){flex:1;min-width:0}.ov-wallet-owned-row .ov-wallet-button{flex-shrink:0}.ov-wallet-event{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:12px 0;border-bottom:1px solid var(--wallet-line);font-size:13px}.ov-wallet-event small{display:block;color:var(--wallet-muted);font-size:12px}.ov-wallet-event>span{flex-shrink:0}.ov-wallet-checkout{padding:18px;border:1px solid #8ddac5;border-radius:14px;margin-bottom:20px;display:flex;flex-wrap:wrap;gap:12px}.ov-wallet-checkout h3,.ov-wallet-checkout p{width:100%}.ov-wallet-checkout label{flex:1;min-width:130px;font-size:13px}.ov-wallet-checkout input,.ov-wallet-checkout select{display:block;margin-top:5px;min-height:44px;width:100%;padding:10px;color:inherit;background:var(--wallet-bg);border:1px solid var(--wallet-line);border-radius:8px;font:inherit}
++@media(prefers-color-scheme:light){.ov-wallet{--wallet-bg:var(--bg-primary,#fafbfc);--wallet-card:var(--bg-secondary,#f0f3f5);--wallet-text:var(--text-primary,#20262c);--wallet-muted:var(--text-secondary,#5b6774);--wallet-line:var(--border-color,#d7dee4)}}
++@media(max-width:600px){.ov-wallet{width:100%;max-width:100%;max-height:94dvh;margin:auto 0 0;border-radius:22px 22px 0 0;border-bottom:0}.ov-wallet-head{padding:18px 18px 14px}.ov-wallet-body{padding:16px 18px 28px;max-height:calc(94dvh - 94px);scrollbar-gutter:auto}.ov-wallet-eyebrow{font-size:12px;letter-spacing:1px}.ov-wallet-balances{grid-template-columns:1fr;gap:9px}.ov-wallet-balance{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;padding:12px 14px}.ov-wallet-value{grid-column:1;grid-row:2;font-size:26px}.ov-wallet-purpose{grid-column:1/-1;grid-row:3;font-size:12px}.ov-wallet-balance>.ov-wallet-button{grid-column:2;grid-row:1/3;align-self:center;max-width:132px}.ov-wallet-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.ov-wallet-preview{height:92px}.ov-wallet-item .ov-wallet-button{padding:9px 6px;font-size:12px}.ov-wallet-section{margin-top:24px}.ov-wallet-event{gap:8px}.ov-wallet-event>span{max-width:44%;text-align:right;overflow-wrap:anywhere}}
++/* The close ring only for the keyboard (a tap that opens the sheet shows no ring), in the accent like every focus. */
++.ov-wallet-close:focus{outline:none}.ov-wallet-close:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}
ddiff --git a/ov-wallet.js b/ov-wallet.js
nnew file mode 100644
iindex 0000000..753cb1a
---- /dev/null
++++ b/ov-wallet.js
@@@ -0,0 +1,219 @@
++/* The universal Wallet. Loaded by the navbar on first tap; bearer requests never send cookies. */
++(function (root) {
++ 'use strict';
++ let active = null;
++ const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
++ function amount(b) {
++ const value = Number(b.amount) / Math.pow(10, b.scale || 0);
++ return b.currency === 'USD' ? new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' }).format(value) : value.toLocaleString();
++ }
++ function close() { if (active) active.close(); }
++ /**
++ * A hosted checkout is followed only to a host Billing names for the chosen provider (checkout.providers:
++ * [{ name, hosts }]) or to Billing itself. Anything else — http, a URL with a username, a foreign host — is refused.
++ */
++ const maker = (issuer) => { const n = String(issuer || '').replace(/^(service|app):/, '').replace(/[-_]+/g, ' ').trim(); return n ? n.charAt(0).toUpperCase() + n.slice(1) : 'OpenVibe'; };
++ function checkoutAllowed(value, providers) {
++ let url;
++ try { url = new URL(String(value)); } catch { return false; }
++ if (url.protocol !== 'https:' || url.username || url.password) return false;
++ const hosts = new Set(['billing.openvibe.network']);
++ for (const provider of providers || []) for (const host of (provider && provider.hosts) || []) {
++ if (typeof host === 'string' && host) hosts.add(host.toLowerCase());
++ }
++ return hosts.has(url.hostname.toLowerCase());
++ }
++ async function open(options) {
++ close();
++ const token = options.getToken ? options.getToken() : options.token;
++ if (!token) throw new Error('Sign in to open your wallet');
++ // Every site's connect-src allows openvibe.network and refuses billing./inventory.openvibe.network, so the
++ // sheet asks Network's relays (/api/frame/wallet/…); Network forwards the bearer and never a cookie.
++ const walletApi = `${(options.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '')}/wallet`;
++ const controller = new AbortController();
++ const dialog = el('dialog', 'ov-wallet');
++ dialog.setAttribute('aria-labelledby', 'ov-wallet-title');
++ const header = el('header', 'ov-wallet-head'), heading = el('div');
++ heading.append(el('h2', '', 'Wallet'));
++ heading.lastChild.id = 'ov-wallet-title';
++ const exit = el('button', 'ov-wallet-close', '×'); exit.type = 'button'; exit.setAttribute('aria-label', 'Close wallet');
++ header.append(heading, exit);
++ const body = el('div', 'ov-wallet-body'), status = el('p', 'ov-wallet-status', 'Opening your wallet…');
++ status.setAttribute('role', 'status'); body.append(status); dialog.append(header, body);
++ const previousFocus = document.activeElement, previousOverflow = document.documentElement.style.overflow;
++ // A site's surfaces.js locks the page for any modal dialog and restores it on close; locking here as well would
++ // make its saved state "hidden". Only a page without it needs the wallet's own lock.
++ const ownLock = !root.OVSurfaces;
++ let disposed = false, data = null, allItems = null, equipped = {}, kinds = [], storeItems = [];
++ const pending = new Map();
++ const session = { close() {
++ if (disposed) return; disposed = true; controller.abort(); dialog.close(); dialog.remove();
++ if (ownLock) document.documentElement.style.overflow = previousOverflow;
++ document.removeEventListener('openvibe-auth-changed', session.close);
++ if (active === session) active = null;
++ if (previousFocus && previousFocus.isConnected) previousFocus.focus();
++ } };
++ active = session;
++ const identity = () => {
++ if (disposed || (options.getToken && options.getToken() !== token)) { session.close(); throw new Error('Your account changed. Open Wallet again.'); }
++ };
++ async function request(path, method = 'GET', payload, publicRead = false) {
++ identity();
++ const r = await fetch(walletApi + path, { method, credentials: 'omit', signal: controller.signal,
++ headers: { ...(publicRead ? {} : { Authorization: `Bearer ${token}` }), ...(payload ? { 'Content-Type': 'application/json' } : {}) },
++ ...(payload ? { body: JSON.stringify(payload) } : {}) });
++ const value = await r.json(); identity();
++ if (!r.ok) throw new Error(value.detail || 'This is temporarily unavailable. Please try again.');
++ return value;
++ }
++ function message(error) { if (!disposed) { status.textContent = error.message || String(error); status.classList.add('is-error'); status.scrollIntoView({ block: 'nearest' }); } }
++ function button(label, action, cls) {
++ const b = el('button', cls || 'ov-wallet-button', label); b.type = 'button';
++ b.addEventListener('click', async () => {
++ if (b.disabled) return; b.disabled = true;
++ try { identity(); await action(); } catch (e) { message(e); } finally { if (b.isConnected) b.disabled = false; }
++ }); return b;
++ }
++ function section(title, description) {
++ const s = el('section', 'ov-wallet-section'); s.append(el('h3', '', title));
++ if (description) s.append(el('p', 'ov-wallet-muted', description)); body.append(s); return s;
++ }
++ function preview(art, name) {
++ const p = el('div', 'ov-wallet-preview');
++ if (art && /^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(art.media_id || '')) {
++ const img = el('img'); img.src = `https://openvibe.media/o/${art.media_id}`; img.alt = name; img.loading = 'lazy'; p.append(img);
++ } else if (art && art.emoji) p.textContent = String(art.emoji).slice(0, 12);
++ else { p.append(el('span', 'ov-wallet-sample', name)); if (art && /rainbow|neon|galaxy/.test(art.token || '')) p.classList.add('is-rainbow'); }
++ return p;
++ }
++ async function refreshWallet() {
++ data = await request('/api/v1/me/wallet');
++ if (options.onWallet) options.onWallet(data);
++ }
++ async function refreshItems() {
++ [allItems, equipped] = await Promise.all([
++ request('/api/v1/me/items?limit=100'),
++ request(`/api/v1/people/${encodeURIComponent(data.subject.id)}/equipped`, 'GET', null, true),
++ ]);
++ }
++ function checkout(kind) {
++ const old = body.querySelector('.ov-wallet-checkout'); if (old) old.remove();
++ const form = el('form', 'ov-wallet-checkout');
++ form.append(el('h3', '', kind === 'vibes' ? 'Buy Vibes' : 'Top up Balance'));
++ const offers = data.checkout || {}, topup = offers.topup || {};
++ // Billing names each provider and the hosts its hosted checkout may use; an older answer (plain names)
++ // still lists the provider but allows no checkout host, so the sheet fails closed rather than guess.
++ const providers = (offers.providers || []).map((p) => (typeof p === 'string' ? { name: p, hosts: [] } : p)).filter((p) => p && p.name);
++ if (!providers.length || (kind === 'topup' && !topup.enabled)) {
++ form.append(el('p', '', 'Checkout is not available right now. Please try again later.')); body.prepend(form); return;
++ }
++ const providerLabel = el('label', '', 'Pay with'), provider = el('select');
++ for (const p of providers) { const option = el('option', '', p.name === 'stripe' ? 'Card' : p.name); option.value = p.name; provider.append(option); }
++ providerLabel.append(provider);
++ const amountLabel = el('label', '', kind === 'vibes' ? 'Vibes' : 'Amount to add');
++ const input = el(kind === 'vibes' ? 'input' : 'select');
++ if (kind === 'vibes') { input.type = 'number'; input.min = offers.vibes.minimum; input.max = offers.vibes.maximum; input.step = '1'; input.value = offers.vibes.minimum; input.required = true; }
++ else for (const cents of topup.amounts_cents || []) { const opt = el('option', '', amount({ amount: cents, scale: 2, currency: 'USD' })); opt.value = cents; input.append(opt); }
++ amountLabel.append(input);
++ const cost = el('p', 'ov-wallet-muted');
++ function quote() {
++ const quantity = Number(input.value);
++ const tier = kind === 'vibes' && [...offers.vibes.price_tiers].sort((a, b) => b.min - a.min).find((t) => quantity >= t.min);
++ const cents = kind === 'vibes' ? (tier ? Math.round(quantity * tier.usd_per_bit * 100) : null) : quantity + Math.ceil(quantity * topup.fee_bps / 10000);
++ cost.textContent = cents == null ? 'Choose a valid amount.' : `Total ${amount({ amount: cents, scale: 2, currency: 'USD' })}${kind === 'topup' && topup.fee_bps ? ' including the top-up fee' : ''}. Confirm the final total at checkout.`;
++ }
++ input.addEventListener('input', quote); quote();
++ const submit = el('button', 'ov-wallet-button is-primary', 'Continue to checkout'); submit.type = 'submit';
++ form.append(amountLabel, providerLabel, cost, submit, button('Cancel', () => form.remove(), 'ov-wallet-button'));
++ let attempt = null;
++ form.addEventListener('submit', async (event) => {
++ event.preventDefault(); if (submit.disabled || !form.reportValidity()) return; submit.disabled = true;
++ try {
++ if (!attempt) attempt = { idempotency_key: root.crypto.randomUUID(), provider: provider.value,
++ ...(kind === 'vibes' ? { vibes: Number(input.value) } : { amount_cents: Number(input.value) }) };
++ input.disabled = true; provider.disabled = true;
++ const result = await request(kind === 'vibes' ? '/api/v1/me/vibes/buy' : '/api/v1/me/wallet/topups', 'POST', attempt);
++ if (!checkoutAllowed(result.checkout_url, providers)) throw new Error('Checkout is unavailable.');
++ root.location.assign(result.checkout_url);
++ } catch (e) { message(e); submit.disabled = false; }
++ });
++ body.prepend(form); form.scrollIntoView({ block: 'start' }); input.focus();
++ }
++ function render() {
++ body.replaceChildren(status); status.textContent = data.money_writes_frozen ? 'Purchases and top-ups are paused. Your balances and items are still here.' : '';
++ status.classList.remove('is-error');
++ const balances = el('div', 'ov-wallet-balances'); body.append(balances);
++ for (const id of ['coins', 'vibes', 'balance']) {
++ const b = data.balances[id], card = el('article', `ov-wallet-balance is-${id}`);
++ const title = el('div', 'ov-wallet-balance-title'); title.append(el('span', 'ov-wallet-symbol', { coins: 'O', vibes: 'V', balance: '$' }[id]), el('h3', '', b.label));
++ card.append(title, el('p', 'ov-wallet-value', amount(b)), el('p', 'ov-wallet-purpose', b.purpose));
++ if (id === 'coins') { const link = el('a', 'ov-wallet-button', b.action); link.href = 'https://openvibe.network/my#coins'; card.append(link); }
++ else { const action = button(b.action, () => checkout(id === 'vibes' ? 'vibes' : 'topup')); action.disabled = data.money_writes_frozen; card.append(action); }
++ balances.append(card);
++ }
++ const store = section('Store', 'A little more you. Items travel with your OpenVibe account.');
++ const grid = el('div', 'ov-wallet-grid'); store.append(grid);
++ if (!storeItems.length) grid.append(el('p', 'ov-wallet-muted', 'New items will appear here after review.'));
++ for (const item of storeItems) {
++ const card = el('article', 'ov-wallet-item'); card.append(preview(item.preview, item.name), el('h4', '', item.name), el('p', 'ov-wallet-muted', `By ${maker(item.issuer)}`));
++ const owned = item.owned || (allItems && allItems.instances.some((i) => i.definition_id === item.id && i.state === 'owned'));
++ if (owned) card.append(el('span', 'ov-wallet-owned', '✓ Owned'));
++ else for (const [currency, price] of Object.entries(item.prices)) {
++ const buy = button(`Buy · ${price.toLocaleString()} ${currency === 'vibes' ? 'Vibes' : 'OpenCoins'}`, async () => {
++ let attempt = pending.get(item.id);
++ if (!attempt) { attempt = { currency, idempotency_key: root.crypto.randomUUID() }; pending.set(item.id, attempt); }
++ if (attempt.currency !== currency) throw new Error('Retry this item in the currency you chose first.');
++ await request(`/api/v1/store/${encodeURIComponent(item.id)}/buy`, 'POST', attempt);
++ await Promise.all([refreshWallet(), refreshItems()]); render();
++ }); buy.disabled = data.money_writes_frozen || !item.available; card.append(buy);
++ }
++ grid.append(card);
++ }
++ const mine = section('Your items', 'Wear them wherever their kind is supported.');
++ const owned = allItems ? allItems.instances.filter((i) => i.state === 'owned') : [];
++ if (!owned.length) mine.append(el('p', 'ov-wallet-muted', 'Your collection starts here.'));
++ for (const item of owned) {
++ const def = allItems.definitions[item.definition_id]; if (!def) continue;
++ const kind = kinds.find((k) => k.id === def.kind), row = el('article', 'ov-wallet-owned-row'), copy = el('div');
++ row.append(preview(def.art, def.name)); copy.append(el('h4', '', def.name));
++ copy.append(el('p', 'ov-wallet-muted', `Across OpenVibe · ${(kind && kind.surfaces || []).map((s) => s.surface).join(', ') || 'supported sites'}`)); row.append(copy);
++ for (const slot of kind && kind.slots || []) {
++ const worn = equipped.slots && equipped.slots[`${def.kind}:${slot}`];
++ const isWorn = worn && worn.instance_id === item.id;
++ row.append(button(isWorn ? 'Unequip' : 'Equip', async () => {
++ equipped = await request('/api/v1/me/equipped', 'PUT', { kind: def.kind, slot, instance_id: isWorn ? null : item.id });
++ document.dispatchEvent(new CustomEvent('openvibe-items-changed')); render();
++ }));
++ }
++ mine.append(row);
++ }
++ if (allItems && allItems.next_cursor) mine.append(button('More items', async () => {
++ const page = await request('/api/v1/me/items?limit=100&cursor=' + encodeURIComponent(allItems.next_cursor));
++ allItems = { ...page, instances: allItems.instances.concat(page.instances), definitions: { ...allItems.definitions, ...page.definitions } }; render();
++ }));
++ const recent = section('Recent activity', 'Your last five money events.');
++ if (!data.recent_activity.length) recent.append(el('p', 'ov-wallet-muted', 'No activity yet.'));
++ for (const event of data.recent_activity) {
++ const row = el('div', 'ov-wallet-event'), info = el('div'); info.append(el('strong', '', event.label), el('small', '', `${event.site} · ${new Date(event.at).toLocaleDateString()}`));
++ const units = event.kind === 'balance' ? { currency: 'USD', scale: 9 } : { currency: event.kind, scale: 0 };
++ row.append(info, el('span', '', `${Number(event.amount) > 0 ? '+' : ''}${amount({ ...units, amount: event.amount })}${event.kind === 'balance' ? '' : event.kind === 'vibes' ? ' Vibes' : ' OpenCoins'}`)); recent.append(row);
++ }
++ }
++ exit.addEventListener('click', session.close);
++ dialog.addEventListener('cancel', (e) => { e.preventDefault(); session.close(); });
++ dialog.addEventListener('click', (e) => { if (e.target !== dialog) return; const r = dialog.getBoundingClientRect(); if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) session.close(); });
++ document.addEventListener('openvibe-auth-changed', session.close);
++ document.body.append(dialog); dialog.showModal(); if (ownLock) document.documentElement.style.overflow = 'hidden'; exit.focus();
++ try {
++ await refreshWallet();
++ const results = await Promise.allSettled([request('/api/v1/store'), refreshItems(), request('/api/v1/kinds', 'GET', null, true)]);
++ identity(); storeItems = results[0].status === 'fulfilled' ? results[0].value.items : [];
++ kinds = results[2].status === 'fulfilled' ? results[2].value.kinds : [];
++ render(); if (results.some((r) => r.status === 'rejected')) message(new Error('Your balances are ready. The store or your items could not load. Close and reopen Wallet to retry.'));
++ } catch (error) { message(error); }
++ return session;
++ }
++ const api = { open, close, amount, checkoutAllowed };
++ root.OpenVibeWallet = api;
++ if (typeof module === 'object' && module.exports) module.exports = api;
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/package-lock.json b/package-lock.json
iindex 16ea286..793d305 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.169.0",
++ "version": "4.170.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.169.0",
++ "version": "4.170.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 2111012..f42c516 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.169.0",
++ "version": "4.170.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -126,6 +126,7 @@
"./thread": "./thread.js",
"./messages": "./messages.js",
"./ov-thread": "./ov-thread.js",
++ "./ov-wallet": "./ov-wallet.js",
"./sw": "./sw.js"
},
"files": [
ddiff --git a/test/helpers/wallet-fixture.js b/test/helpers/wallet-fixture.js
nnew file mode 100644
iindex 0000000..44328e2
---- /dev/null
++++ b/test/helpers/wallet-fixture.js
@@@ -0,0 +1,59 @@
++'use strict';
++const fs = require('fs');
++const path = require('path');
++const http = require('http');
++const root = path.resolve(__dirname, '../..');
++const id = (prefix, n) => `${prefix}_${String(n).padStart(26, '0')}`;
++const subject = id('usr', 1);
++const definitions = [
++ { id: id('itd', 1), name: 'Rainbow Name', kind: 'live.name_effect', art: { token: 'name-fx-rainbow' }, issuer: 'service:live' },
++ { id: id('itd', 2), name: 'Golden Crown', kind: 'live.hat', art: { emoji: '👑' }, issuer: 'service:live' },
++ { id: id('itd', 3), name: 'Stardust', kind: 'live.particle', art: { emoji: '✦' }, issuer: 'service:live' },
++];
++const wallet = {
++ subject: { type: 'user', id: subject }, currency: 'USD', money_writes_frozen: false, used_ai_this_month: true, bits_per_usd: 100,
++ balances: {
++ coins: { label: 'OpenCoins', amount: '160799', currency: 'opencoins', scale: 0, purpose: 'Earned by taking part; spend on items across OpenVibe.', action: 'How to earn OpenCoins' },
++ vibes: { label: 'Vibes', amount: '400', currency: 'vibes-bits', scale: 0, purpose: 'Support creators and buy items across OpenVibe.', action: 'Buy Vibes' },
++ balance: { label: 'Balance', amount: '0', currency: 'USD', scale: 9, held: '0', purpose: 'Real money for AI and paid features.', action: 'Top up' },
++ },
++ checkout: { providers: [{ name: 'stripe', hosts: ['checkout.stripe.com'] }], vibes: { minimum: 100, maximum: 100000, price_tiers: [{ min: 100, usd_per_bit: 0.012 }] }, topup: { enabled: true, amounts_cents: [1000, 2500], fee_bps: 0 } },
++ recent_activity: [
++ { id: 'a', at: '2026-11-08T12:00:00Z', kind: 'coins', amount: '50', label: 'Daily quest completed', site: 'quest', detail: '' },
++ { id: 'b', at: '2026-11-08T10:00:00Z', kind: 'vibes', amount: '-25', label: 'Tipped a creator', site: 'live', detail: '' },
++ ],
++};
++function fixture() {
++ const calls = [], owned = new Set([definitions[1].id]);
++ let slots = {}, base;
++ const server = http.createServer(async (req, res) => {
++ const url = new URL(req.url, 'http://localhost');
++ // The sheet asks Network's relay (/api/frame/wallet/…); the fixture answers as Billing and Inventory would.
++ const pathname = url.pathname.startsWith('/api/frame/wallet/') ? url.pathname.slice('/api/frame/wallet'.length) : url.pathname;
++ const json = (x, status = 200) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(x)); };
++ if (pathname.startsWith('/api/')) {
++ const chunks = []; for await (const chunk of req) chunks.push(chunk);
++ const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : null;
++ calls.push({ path: pathname, method: req.method, body, authorization: req.headers.authorization, cookie: req.headers.cookie });
++ if (pathname === '/api/v1/me/wallet') return json(wallet);
++ if (pathname === '/api/v1/store') return json({ items: definitions.map((d, i) => ({ ...d, preview: d.art, prices: { vibes: (i + 1) * 50, coins: (i + 1) * 250 }, available: true, owned: owned.has(d.id), slots: [d.kind.split('.')[1]], surfaces: [{ surface: 'chat' }, { surface: 'profile' }] })) });
++ if (pathname === '/api/v1/me/items') return json({ subject, instances: definitions.filter((d) => owned.has(d.id)).map((d) => ({ id: d.id.replace('itd_', 'inv_'), definition_id: d.id, state: 'owned' })), definitions: Object.fromEntries(definitions.map((d) => [d.id, d])), next_cursor: null });
++ if (pathname.endsWith('/equipped')) { if (body) slots = { ...slots, [`${body.kind}:${body.slot}`]: body.instance_id ? { instance_id: body.instance_id } : null }; return json({ subject, slots }); }
++ if (pathname === '/api/v1/kinds') return json({ kinds: definitions.map((d) => ({ id: d.kind, slots: [d.kind.split('.')[1]], surfaces: [{ surface: 'chat' }, { surface: 'profile' }] })) });
++ if (pathname.endsWith('/buy')) { owned.add(pathname.split('/')[4]); return json({ status: 'fulfilled' }); }
++ return json({ unread: 0, notifications: [], items: [] });
++ }
++ if (pathname === '/') {
++ res.writeHead(200, { 'Content-Type': 'text/html', 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'" });
++ return res.end(`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Wallet preview</title><link rel="icon" href="data:,"><style>body{margin:0;background:#111820;color:#e9edf2;font:16px system-ui;min-height:150vh}main{padding:90px 7%}h1{font-size:50px;letter-spacing:-2px}p{max-width:470px;line-height:1.6}@media(prefers-color-scheme:light){body{background:#f1f4f6;color:#25303d}}</style><script>const originalFetch=window.fetch.bind(window);window.fetch=(u,o)=>new URL(u,location.href).origin===location.origin?originalFetch(u,o):Promise.resolve(new Response(JSON.stringify({items:[],notifications:[],unread:0}),{headers:{'Content-Type':'application/json'}}));</script><script src="/navbar.js"></script><main><p>OPENVIBE LIVE</p><h1>Good company.<br>Anywhere.</h1><p>A local fixture for the shared Wallet. Balances, store items and activity below are sample data.</p></main><script>OpenVibeNavbar.init({service:'live',auth:'external',apiBase:${JSON.stringify(base)},frameBase:${JSON.stringify(base + '/api/frame')},user:{id:1,subject:${JSON.stringify(subject)},display_name:'Maya'},token:'wallet-fixture',wallet:true,search:false,presence:false,dock:false,networkBase:${JSON.stringify(base)}});</script></html>`);
++ }
++ const name = pathname.slice(1);
++ if (/^[a-z0-9-]+\.(js|css|woff2)$/.test(name) && fs.existsSync(path.join(root, name))) {
++ res.writeHead(200, { 'Content-Type': name.endsWith('.css') ? 'text/css' : name.endsWith('.woff2') ? 'font/woff2' : 'application/javascript' });
++ return res.end(fs.readFileSync(path.join(root, name)));
++ }
++ res.writeHead(204); res.end();
++ });
++ return { calls, wallet, server, start: async () => { await new Promise((r) => server.listen(0, '127.0.0.1', r)); base = `http://127.0.0.1:${server.address().port}`; return base; }, close: () => new Promise((r) => server.close(r)) };
++}
++module.exports = { fixture };
ddiff --git a/test/navbar-brand.test.js b/test/navbar-brand.test.js
iindex d3e3fb4..433c745 100644
---- a/test/navbar-brand.test.js
++++ b/test/navbar-brand.test.js
@@@ -217,7 +217,7 @@ function load(hostname, opts = {}) {
// ── balance chips fold into one on a phone (owner, 2026-10-27): the folded chip, its short values, and setChip ──
{
const { navbar, created } = load('openvibe.live');
-- navbar.init({ service: 'live', user: { id: 1, username: 'x' }, chips: [
++ navbar.init({ service: 'live', wallet: false, user: { id: 1, username: 'x' }, chips: [
{ id: 'coins', icon: 'fa-coins', tone: 'gold', value: '160,774', label: 'OpenCoins', onClick() {} },
{ id: 'vibes', icon: 'fa-wallet', tone: 'green', value: '400', title: 'Vibes balance', onClick() {} },
] });
@@@ -229,7 +229,7 @@ function load(hostname, opts = {}) {
assert.ok(/@media \(max-width: 560px\) \{\s*\.openvibe-navbar-right\.has-chips-all \.ovnav-chip\[data-chip-id\] \{ display: none; \}/.test(require('fs').readFileSync(NAVBAR_PATH, 'utf8')), 'the chips fold on a phone');
// One chip: nothing to fold.
const one = load('openvibe.live');
-- one.navbar.init({ service: 'live', user: { id: 1, username: 'x' }, chips: [{ id: 'coins', icon: 'fa-coins', value: '5' }] });
++ one.navbar.init({ service: 'live', wallet: false, user: { id: 1, username: 'x' }, chips: [{ id: 'coins', icon: 'fa-coins', value: '5' }] });
assert.ok(!/ovnav-chip--all/.test(one.created.find(e => e.tag === 'nav').innerHTML));
}
// ── "Across OpenVibe" in the drawer, and the icon slots that have their size before ov-icons.js arrives ──
ddiff --git a/test/navbar-wallet-chips.test.js b/test/navbar-wallet-chips.test.js
iindex 1711adc..bf94df1 100644
---- a/test/navbar-wallet-chips.test.js
++++ b/test/navbar-wallet-chips.test.js
@@@ -36,7 +36,7 @@ function load(store, answers) {
const calls = [];
global.fetch = async (url) => {
calls.push(url);
-- const a = /\/api\/me\/vibes$/.test(url) ? answers.vibes : /\/api\/coins\/me(\?|$)/.test(url) ? answers.coins : { status: 404, body: {} };
++ const a = url.endsWith('/api/v1/me/wallet') ? answers.wallet : { status: 404, body: {} };
return { ok: a.status >= 200 && a.status < 300, status: a.status, json: async () => a.body };
};
delete require.cache[require.resolve(NAVBAR_PATH)];
@@@ -49,77 +49,20 @@ const user = { id: 7, subject: 'usr_7', username: 'maya' };
const base = { service: 'games', auth: 'external', apiBase: 'https://openvibe.network', user, token: 'tok', wallet: true };
(async () => {
-- const store = {};
-- const answers = { coins: { status: 200, body: { balance: 160774 } }, vibes: { status: 200, body: { credit: 2500 } } };
--
-- // First visit: no balances known yet, so the chips wait (hidden), then appear with the numbers and fold on a phone.
-- let { navbar, navs, calls } = load(store, answers);
++ const data = { balances: { coins: { amount: '160799', scale: 0, currency: 'opencoins' }, vibes: { amount: '400', scale: 0, currency: 'vibes-bits' }, balance: { amount: '0', scale: 9, currency: 'USD' } }, used_ai_this_month: false };
++ const { navbar, navs, calls } = load({}, { wallet: { status: 200, body: data } });
navbar.init(base);
-- assert.match(chip(navs[0].innerHTML, 'coins'), /hidden/, 'nothing to show before the balances arrive');
-- await settle();
-- // An older Network answers the coins only: Vibes come from Billing itself (4.25.0 asks the Network for both first).
-- assert.deepStrictEqual(wallet(calls).sort(), ['https://billing.openvibe.network/api/me/vibes', 'https://openvibe.network/api/coins/me?with=vibes']);
-- const last = navs[navs.length - 1].innerHTML;
-- assert.ok(navs.length >= 2, 'drawn again once the chips appeared');
-- assert.doesNotMatch(chip(last, 'coins'), /hidden/);
-- assert.match(last, /data-chip-id="coins"[^>]*>[\s\S]*?160,774/);
-- assert.match(last, /data-chip-id="vibes"[^>]*>[\s\S]*?2,500/);
-- assert.match(last, /id="openvibe-chips-all"/, 'one folded chip for phones');
-- assert.match(last, /aria-label="Your balances: OpenCoins 160,774, Vibes 2,500"/);
-- assert.deepStrictEqual(JSON.parse(store['ov-wallet:usr_7']), { coins: '160,774', vibes: '2,500' });
--
-- // A Network that answers both (4.25.0): one request, Billing never asked (no site's connect-src allowed it).
-- {
-- const store2 = {};
-- const both = { coins: { status: 200, body: { balance: 160774, vibes: 2500 } }, vibes: { status: 500, body: {} } };
-- const r = load(store2, both);
-- r.navbar.init(base);
-- await settle();
-- assert.deepStrictEqual(wallet(r.calls), ['https://openvibe.network/api/coins/me?with=vibes'], 'one request for both balances');
-- assert.match(r.navs[r.navs.length - 1].innerHTML, /data-chip-id="vibes"[^>]*>[\s\S]*?2,500/);
-- }
-- ({ navbar, navs, calls } = load(store, answers));
-- navbar.init(base);
-- await settle();
--
-- // Drawn again on the same page within a minute: no second request.
-- const before = wallet(calls).length;
-- navbar.init(base);
-- await settle();
-- assert.strictEqual(wallet(calls).length, before, 'at most once a minute');
--
-- // Next page: the kept balances paint at once.
-- ({ navbar, navs, calls } = load(store, answers));
-- navbar.init(base);
-- assert.doesNotMatch(chip(navs[0].innerHTML, 'coins'), /hidden/);
-- assert.match(navs[0].innerHTML, /data-chip-id="coins"[^>]*>[\s\S]*?160,774/, 'painted from the kept balance');
-- await settle();
--
-- // Billing not the ledger yet (409): the Vibes chip goes away and is forgotten; OpenCoins stay.
-- answers.vibes = { status: 409, body: { code: 'billing.not_authoritative' } };
-- ({ navbar, navs, calls } = load(store, answers));
-- navbar.init(base);
-- await settle();
-- const after409 = navs[navs.length - 1].innerHTML;
-- assert.match(chip(after409, 'vibes'), /hidden/);
-- assert.doesNotMatch(chip(after409, 'coins'), /hidden/);
-- assert.doesNotMatch(after409, /id="openvibe-chips-all"/, 'one chip left: nothing to fold');
-- assert.deepStrictEqual(JSON.parse(store['ov-wallet:usr_7']), { coins: '160,774' });
--
-- // Signed out, or a site that did not ask: no chips, no requests.
-- ({ navbar, navs, calls } = load({}, answers));
-- navbar.init({ ...base, user: null, token: null });
-- navbar.init({ ...base, wallet: false });
-- await settle();
-- assert.strictEqual(wallet(calls).length, 0);
-- assert.doesNotMatch(navs.map((n) => n.innerHTML).join(''), /data-chip-id="coins"/);
--
-- // Live sets its own coins and vibes chips: they stay, with no second pair.
-- ({ navbar, navs, calls } = load({}, answers));
-- navbar.init({ ...base, chips: [{ id: 'coins', icon: 'fa-coins', value: '9' }, { id: 'vibes', icon: 'fa-wallet', value: '1' }] });
await settle();
-- const live = navs[navs.length - 1].innerHTML;
-- assert.strictEqual((live.match(/data-chip-id="coins"/g) || []).length, 1);
-- assert.match(live, /data-chip-id="coins"[^>]*>[\s\S]*?>9</);
-- console.log('navbar-wallet-chips: all checks passed');
++ assert.strictEqual(calls.filter((u) => u.endsWith('/api/v1/me/wallet')).length, 1);
++ assert(!calls.some((u) => u.includes('/api/coins/me') || u.includes('/api/me/vibes')));
++ const html = navs[navs.length - 1].innerHTML;
++ assert.match(html, /160,799/); assert.match(html, /400/);
++ assert.deepStrictEqual(navbar._wallet.walletOrder(data), ['coins', 'vibes']);
++ data.used_ai_this_month = true;
++ assert.deepStrictEqual(navbar._wallet.walletOrder(data), ['balance', 'coins']);
++ data.balances.coins.amount = '0'; data.balances.vibes.amount = '0';
++ assert.strictEqual(navbar._wallet.walletOrder(data)[0], 'balance');
++ navbar.setUser(null, null); await settle();
++ assert(!navs[navs.length - 1].innerHTML.includes('data-chip-id="coins"'));
++ console.log('wallet chips: one Billing read, all zeros supported, Balance promoted for AI users, signout clears chips');
})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/navbar-yours-everywhere.test.js b/test/navbar-yours-everywhere.test.js
iindex 62be264..606307e 100644
---- a/test/navbar-yours-everywhere.test.js
++++ b/test/navbar-yours-everywhere.test.js
@@@ -43,7 +43,7 @@ function load(answers) {
const a = url === '/auth/navbar-token' ? answers.sessionToken
: url === '/auth/me' ? answers.session
: /\/api\/dm\/unread$/.test(url) ? answers.unread
-- : /\/api\/coins\/me(\?|$)/.test(url) ? answers.coins
++ : /\/api\/frame\/wallet\/api\/v1\/me\/wallet$|\/api\/coins\/me(\?|$)/.test(url) ? answers.coins
: { status: 404, body: {} };
return { ok: a.status >= 200 && a.status < 300, status: a.status, json: async () => a.body };
};
@@@ -53,7 +53,7 @@ function load(answers) {
const settle = () => new Promise((r) => setTimeout(r, 20));
const user = { id: 7, subject: 'usr_7', username: 'maya' };
const base = { service: 'games', auth: 'external', apiBase: 'https://openvibe.network', user, token: 'tok' };
--const answers = { coins: { status: 200, body: { balance: 1200, vibes: 300 } }, unread: { status: 200, body: { unread: 3 } } };
++const answers = { coins: { status: 200, body: { balances: { coins: { amount: '1200', scale: 0, currency: 'opencoins' }, vibes: { amount: '300', scale: 0, currency: 'vibes-bits' }, balance: { amount: '0', scale: 9, currency: 'USD' } }, used_ai_this_month: false } }, unread: { status: 200, body: { unread: 3 } } };
const asked = (calls, re) => calls.filter((u) => re.test(u));
(async () => {
@@@ -73,7 +73,7 @@ const asked = (calls, re) => calls.filter((u) => re.test(u));
assert.match(bar, /data-chip-id="vibes"/, 'the Vibes chip');
await settle();
assert.deepStrictEqual(asked(calls, /\/api\/dm\/unread$/), ['https://openvibe.chat/api/dm/unread'], 'Chat is asked for the count');
-- assert.strictEqual(asked(calls, /\/api\/coins\/me/).length, 1, 'and the Network for the balances');
++ assert.strictEqual(asked(calls, /\/api\/frame\/wallet\/api\/v1\/me\/wallet$/).length, 1, 'and the Network (its wallet relay) for the balances');
assert.strictEqual(badge.textContent, '3', 'the count shows on the button');
navbar.init(base);
await settle();
@@@ -110,14 +110,14 @@ const asked = (calls, re) => calls.filter((u) => re.test(u));
await settle();
bar = navs[navs.length - 1].innerHTML;
assert.doesNotMatch(bar, /openvibe-messages-btn|data-chip-id="coins"/);
-- assert.strictEqual(asked(calls, /\/api\/(dm\/unread|coins\/me)/).length, 0);
++ assert.strictEqual(asked(calls, /\/api\/(dm\/unread|coins\/me|frame\/wallet)/).length, 0);
// Signed out: nothing shown, nothing asked.
({ navbar, navs, calls } = load(answers));
navbar.init({ ...base, user: null, token: null });
await settle();
assert.doesNotMatch(navs.map((n) => n.innerHTML).join(''), /openvibe-messages-btn|data-chip-id="coins"/);
-- assert.strictEqual(asked(calls, /\/api\/(dm\/unread|coins\/me)/).length, 0);
++ assert.strictEqual(asked(calls, /\/api\/(dm\/unread|coins\/me|frame\/wallet)/).length, 0);
// A site with its own Messages (Live, Chat) keeps its own count: Chat is not asked by the navbar.
({ navbar, navs, calls } = load(answers));
ddiff --git a/test/wallet-checkout-host.test.js b/test/wallet-checkout-host.test.js
nnew file mode 100644
iindex 0000000..5662939
---- /dev/null
++++ b/test/wallet-checkout-host.test.js
@@@ -0,0 +1,31 @@
++'use strict';
++// The sheet follows a hosted checkout only to a host Billing names for the chosen provider, or to Billing itself.
++// Anything else is refused before the browser navigates (a provider answer is never trusted to pick a host).
++const assert = require('assert');
++const { checkoutAllowed } = require('../ov-wallet');
++
++const providers = [
++ { name: 'stripe', hosts: ['checkout.stripe.com'] },
++ { name: 'paypal', hosts: ['www.paypal.com', 'www.sandbox.paypal.com'] },
++];
++
++assert.strictEqual(checkoutAllowed('https://checkout.stripe.com/c/pay/cs_test_1', providers), true, "Stripe's own checkout host is followed");
++assert.strictEqual(checkoutAllowed('https://www.paypal.com/checkoutnow?token=1', providers), true, 'PayPal live is followed');
++assert.strictEqual(checkoutAllowed('https://www.sandbox.paypal.com/checkoutnow?token=1', providers), true, 'PayPal sandbox is followed');
++assert.strictEqual(checkoutAllowed('https://billing.openvibe.network/checkout', providers), true, "Billing's own host is followed");
++assert.strictEqual(checkoutAllowed('https://CHECKOUT.STRIPE.COM/c/pay/x', providers), true, 'the host compares case-insensitively');
++
++assert.strictEqual(checkoutAllowed('https://evil.example.test/pay', providers), false, 'a foreign host is refused');
++assert.strictEqual(checkoutAllowed('https://checkout.stripe.com.evil.test/pay', providers), false, 'a suffix lookalike is refused');
++assert.strictEqual(checkoutAllowed('https://billing.openvibe.network.evil.test/', providers), false, 'a Billing lookalike is refused');
++assert.strictEqual(checkoutAllowed('http://checkout.stripe.com/c/pay/x', providers), false, 'http is refused');
++assert.strictEqual(checkoutAllowed('https://user:[email protected]/c/pay/x', providers), false, 'credentials in the URL are refused');
++assert.strictEqual(checkoutAllowed('javascript:alert(1)', providers), false, 'a non-URL is refused');
++assert.strictEqual(checkoutAllowed(null, providers), false, 'no checkout URL is refused');
++
++// An older Billing answer lists plain provider names and no hosts: the sheet fails closed rather than guessing.
++assert.strictEqual(checkoutAllowed('https://checkout.stripe.com/c/pay/x', ['stripe']), false, 'a provider without hosts allows nothing');
++assert.strictEqual(checkoutAllowed('https://checkout.stripe.com/c/pay/x', []), false, 'no providers allows only Billing');
++assert.strictEqual(checkoutAllowed('https://billing.openvibe.network/checkout', []), true, "with no providers only Billing's host remains");
++
++console.log('wallet checkout host: provider hosts followed, foreign/lookalike/http/credentialed URLs refused, old shape fails closed');
ddiff --git a/test/wallet-chrome.test.js b/test/wallet-chrome.test.js
nnew file mode 100644
iindex 0000000..8ebae4f
---- /dev/null
++++ b/test/wallet-chrome.test.js
@@@ -0,0 +1,48 @@
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const h = require('../browser-harness');
++const { fixture } = require('./helpers/wallet-fixture');
++if (!h.findChrome()) { console.log('wallet-chrome: skipped (Chrome unavailable)'); process.exit(0); }
++const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++(async () => {
++ const f = fixture(), base = await f.start();
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-wallet-test-'));
++ const browser = await h.launch({ tmpDir });
++ try {
++ for (const width of [390, 1280]) for (const scheme of ['dark', 'light']) {
++ const context = await browser.newContext(), p = await h.openPage(browser, context, { width, height: 900 });
++ await p.send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: scheme }] });
++ await p.goto(base, { maxMs: 5000 });
++ assert(!p.state.urls.some((u) => u.includes('ov-wallet.js')), 'sheet is lazy');
++ await p.evaluate(`document.querySelector('[data-chip-id]').click()`); await wait(500);
++ const metrics = await p.evaluate(`(() => { const d=document.querySelector('.ov-wallet'); return {open:!!(d&&d.open), width:document.documentElement.scrollWidth, inner:d&&d.scrollWidth, box:d&&d.clientWidth, lock:document.documentElement.style.overflow, text:d&&d.innerText, buttons:[...d.querySelectorAll('button,a')].map(b=>b.getBoundingClientRect().height)} })()`);
++ assert(metrics.open, 'first chip tap opens wallet'); assert.strictEqual(metrics.width, width); assert(metrics.inner <= metrics.box + 1);
++ assert.strictEqual(metrics.lock, 'hidden'); assert(metrics.text.includes('$0.00')); assert(metrics.text.includes('Buy Vibes')); assert(metrics.text.includes('Top up'));
++ assert(metrics.buttons.every((height) => height >= 44), '44px controls');
++ if (process.env.WALLET_SCREENSHOT_DIR) {
++ fs.mkdirSync(process.env.WALLET_SCREENSHOT_DIR, { recursive: true });
++ const shot = await p.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false });
++ fs.writeFileSync(path.join(process.env.WALLET_SCREENSHOT_DIR, `wallet-${width}-${scheme}.png`), Buffer.from(shot.data, 'base64'));
++ }
++ const wasWorn = await p.evaluate(`document.querySelector('.ov-wallet').innerText.includes('Unequip')`);
++ await p.evaluate(`[...document.querySelectorAll('.ov-wallet button')].find(b=>/^(Equip|Unequip)$/.test(b.textContent)).click()`); await wait(150);
++ assert.strictEqual(await p.evaluate(`document.querySelector('.ov-wallet').innerText.includes('Unequip')`), !wasWorn);
++ await p.evaluate(`[...document.querySelectorAll('.ov-wallet button')].find(b=>b.textContent==='Buy Vibes').click()`);
++ assert(await p.evaluate(`document.querySelector('.ov-wallet-checkout').innerText.includes('Continue to checkout')`));
++ await p.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 }); await wait(50);
++ assert.strictEqual(await p.evaluate(`!!document.querySelector('.ov-wallet')`), false);
++ assert.strictEqual(await p.evaluate(`document.documentElement.style.overflow`), '');
++ await p.evaluate(`OpenVibeNavbar.openWallet()`); await wait(150);
++ await p.evaluate(`OpenVibeNavbar.setUser(null,null)`);
++ assert.strictEqual(await p.evaluate(`!!document.querySelector('.ov-wallet')`), false, 'signout closes private content');
++ const exceptions = p.state.errors || [];
++ assert.strictEqual(exceptions.length, 0, JSON.stringify(exceptions));
++ await p.close(); await context.dispose();
++ }
++ assert(f.calls.filter((c) => c.path.includes('/me/')).every((c) => c.authorization === 'Bearer wallet-fixture' && !c.cookie));
++ console.log('wallet Chrome: phone/desktop, both themes, lazy loading, zeros, equip, checkout, Escape, account change and no overflow');
++ } finally { await browser.close(); await f.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }
++})().catch((e) => { console.error(e); process.exitCode = 1; });