openvibers/openvibe.sdk Commits

The OpenVibe Frame: openvibe-sdk/chrome is now openvibe-sdk/frame (mountFrame, frameTags, frameConfig) — the navbar, footer and themes every OpenVibe site sits in; 'chrome' was borrowed UI jargon easily mistaken for the browser. Not yet tagged, so no alias

OP OpenVibers committed 24 Sept 2026 · 9ea504e829 · 13 files, +300 −300


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 56fe928..d39233c 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -5,7 +5,7 @@ release may change an API and says so here.
## 0.8.0 (2026-09-24)
--`openvibe-sdk/chrome`: the OpenVibe network's shared chrome for any app, first-party or not. It covers:
++`openvibe-sdk/frame`: **the OpenVibe Frame**, the navbar, footer and themes every OpenVibe site sits in, for any app, first-party or not. It covers:
- the universal navbar: one account menu, notifications, the site switcher, and sign in and out through your own session endpoints (`sessionUrl`, `loginUrl`, `logoutUrl` with `{path}`/`{url}`);
- the shared footer, with network and legal links, the "shipped X ago" line and an Updates link;
@@@ -13,7 +13,7 @@ release may change an API and says so here.
These are the same files every OpenVibe site runs, served by OpenVibe.Network from the published openvibe-shared release, so nothing is bundled into your app.
--`mountChrome(opts)` loads and mounts them in the browser. It never throws for a network failure; a part that cannot load resolves to `null`. `chromeTags(opts)` returns the same markup as strings for server-rendered pages, with the configuration escaped for its `<script>`. `chromeConfig` and `scriptUrls` are exported for custom setups. The base must be an https origin (or `http://localhost` in development). Browser-safe; no dependencies.
++`mountFrame(opts)` loads and mounts them in the browser. It never throws for a network failure; a part that cannot load resolves to `null`. `frameTags(opts)` returns the same markup as strings for server-rendered pages, with the configuration escaped for its `<script>`. `frameConfig` and `scriptUrls` are exported for custom setups. The base must be an https origin (or `http://localhost` in development). Browser-safe; no dependencies.
## 0.7.0 (2026-09-24)
ddiff --git a/README.md b/README.md
iindex 7cae6f0..b8319e1 100644
---- a/README.md
++++ b/README.md
@@@ -237,13 +237,13 @@ app.post('/internal/events', express.raw({ type: 'application/json' }), (req, re
Turn `requireV2` on once Events sends v2 to you: a replayed v1-only delivery then fails. The lower-level checks are `verifyDeliveryV2(raw, headers, secret, { toleranceSec, now })` (constant-time; also refuses a `X-OpenVibe-Timestamp` that differs from `t`) and `verifyDelivery(raw, signatureHeader, secret)` (v1, unchanged). Keep your clock in sync (NTP): the window is ±300 s both ways. In tests, `signDeliveryHeaders(raw, secret, { now })` returns the three headers as Events sends them.
--### The shared chrome
++### The OpenVibe Frame
Every OpenVibe site uses the same navbar and footer. Your app can use them too, so people move between it and the network without a jump:
```js
--// Browser (CommonJS or ESM: openvibe-sdk/chrome)
--const { navbar } = await mountChrome({
++// Browser (CommonJS or ESM: openvibe-sdk/frame)
++const { navbar } = await mountFrame({
service: 'myapp',
brand: { name: 'My App' },
links: [{ label: 'Home', href: '/' }, { label: 'Docs', href: '/docs' }],
@@@ -255,9 +255,9 @@ const { navbar } = await mountChrome({
});
```
--For server-rendered pages, `chromeTags(opts)` returns `{ head, bodyStart, bodyEnd }`: put `head` in `<head>`, `bodyStart` first in `<body>` and `bodyEnd` last. Your CSP needs `script-src https://openvibe.network` and `connect-src https://openvibe.network`.
++For server-rendered pages, `frameTags(opts)` returns `{ head, bodyStart, bodyEnd }`: put `head` in `<head>`, `bodyStart` first in `<body>` and `bodyEnd` last. Your CSP needs `script-src https://openvibe.network` and `connect-src https://openvibe.network`.
--The chrome is progressive: if openvibe.network cannot be reached, your page still renders, and the part that failed resolves to `null`. Signed-in state comes from the shared `ov_token`, and otherwise from your `sessionUrl`. To have your users recognised across the network, sign them in with OpenVibe (`openvibe-sdk/auth`).
++The Frame is progressive: if openvibe.network cannot be reached, your page still renders, and the part that failed resolves to `null`. Signed-in state comes from the shared `ov_token`, and otherwise from your `sessionUrl`. To have your users recognised across the network, sign them in with OpenVibe (`openvibe-sdk/auth`).
### Tools
ddiff --git a/esm/chrome.mjs b/esm/chrome.mjs
ddeleted file mode 100644
iindex 4202095..0000000
---- a/esm/chrome.mjs
++++ /dev/null
@@@ -1,7 +0,0 @@
--// Generated by scripts/esm.js from src/chrome.js. Do not edit.
--import cjs from '../src/chrome.js';
--
--export const {
-- mountChrome, chromeTags, chromeConfig, scriptUrls, DEFAULT_BASE,
--} = cjs;
--export default cjs;
ddiff --git a/esm/frame.mjs b/esm/frame.mjs
nnew file mode 100644
iindex 0000000..4202095
---- /dev/null
++++ b/esm/frame.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/chrome.js. Do not edit.
++import cjs from '../src/chrome.js';
++
++export const {
++ mountChrome, chromeTags, chromeConfig, scriptUrls, DEFAULT_BASE,
++} = cjs;
++export default cjs;
ddiff --git a/package.json b/package.json
iindex d8d5f15..066be60 100644
---- a/package.json
++++ b/package.json
@@@ -80,10 +80,10 @@
"import": "./esm/projects.mjs",
"default": "./src/projects.js"
},
-- "./chrome": {
-- "types": "./types/chrome.d.ts",
-- "import": "./esm/chrome.mjs",
-- "default": "./src/chrome.js"
++ "./frame": {
++ "types": "./types/frame.d.ts",
++ "import": "./esm/frame.mjs",
++ "default": "./src/frame.js"
},
"./vip": {
"types": "./types/vip.d.ts",
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex f0ef272..e51dd56 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -27,7 +27,7 @@ const ENTRIES = {
'tools.mjs': '../src/tools.js',
'projects.mjs': '../src/projects.js',
'vip.mjs': '../src/vip.js',
-- 'chrome.mjs': '../src/chrome.js',
++ 'frame.mjs': '../src/frame.js',
'testing.mjs': '../src/testing/index.js',
};
ddiff --git a/src/chrome.js b/src/chrome.js
ddeleted file mode 100644
iindex 42cc8ef..0000000
---- a/src/chrome.js
++++ /dev/null
@@@ -1,150 +0,0 @@
--'use strict';
--
--/**
-- * openvibe-sdk/chrome — the OpenVibe network's shared chrome for any app: the universal navbar (one
-- * account menu, notifications, the site switcher, sign in and out), the shared footer (network
-- * links, legal links, the "shipped X ago" line and an Updates link), the "shipped" views and the
-- * theme loader. These are the same files every OpenVibe site runs, served by OpenVibe.Network from
-- * the published openvibe-shared release. No dependencies; nothing is bundled into your app.
-- *
-- * In the browser:
-- *
-- * // mountChrome from the openvibe-sdk/chrome entry (CommonJS or ESM)
-- * const { navbar, footer } = await mountChrome({
-- * service: 'myapp', // your app's id (brand + analytics)
-- * brand: { name: 'My App' }, // optional brand override
-- * links: [{ label: 'Home', href: '/' }, { label: 'Docs', href: '/docs' }],
-- * menu: { before: [{ label: 'My projects', href: '/projects', icon: 'fa-folder' }] },
-- * sessionUrl: '/auth/me', // your server session ({ user }), optional
-- * loginUrl: '/auth/login?next={path}', // {url} full return URL, {path} local path
-- * logoutUrl: '/auth/logout?next={path}', // Sign out ends your session too
-- * footer: { links: [{ heading: 'My App', items: [{ name: 'About', url: '/about' }] }], updates: '/updates' },
-- * });
-- *
-- * Server-rendered apps put chromeTags(opts) in <head>/<body> instead (the same markup this module
-- * would inject, as a string; the JSON config is escaped for a <script> element):
-- *
-- * // chromeTags from the openvibe-sdk/chrome entry
-- * const t = chromeTags({ service: 'myapp', links: [...] });
-- * html = `<head>${t.head}</head><body>${t.bodyStart}…${t.bodyEnd}</body>`;
-- *
-- * Your CSP must allow `script-src https://openvibe.network` and `connect-src https://openvibe.network`
-- * (the navbar asks /api/auth/me and the notifications there; the shipped line reads
-- * /api/v1/changelog). Signed-in state comes from the shared ov_token, else from sessionUrl. For
-- * your own users to be recognised across the network, sign them in with OpenVibe (openvibe-sdk/auth).
-- * The chrome is progressive: if openvibe.network is unreachable your page still renders.
-- */
--
--const DEFAULT_BASE = 'https://openvibe.network';
--const FILES = { theme: 'theme-loader.js', navbar: 'navbar.js', footer: 'footer.js', shipped: 'shipped.js' };
--
--function baseOf(base) {
-- const b = String(base || DEFAULT_BASE).replace(/\/+$/, '');
-- const ok = /^https:\/\/[a-z0-9.-]+(:\d+)?$/i.test(b) || /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(b);
-- if (!ok) throw new TypeError(`openvibe-sdk/chrome: base must be an https origin (or http://localhost for development), got ${base}`);
-- return b;
--}
--
--/** The script URLs on a base: the release OpenVibe.Network currently serves at /shared/. */
--function scriptUrls({ base } = {}) {
-- const b = baseOf(base);
-- const out = {};
-- for (const [k, f] of Object.entries(FILES)) out[k] = `${b}/shared/${f}`;
-- return out;
--}
--
--/** The navbar and footer configurations mountChrome passes to OpenVibeNavbar.init / OpenVibeFooter.init. */
--function chromeConfig(opts = {}) {
-- const o = opts || {};
-- const b = baseOf(o.base);
-- const navbar = {
-- service: o.service || 'app',
-- apiBase: b,
-- ...(o.brand ? { brand: o.brand } : {}),
-- ...(Array.isArray(o.links) ? { links: o.links } : {}),
-- ...(o.menu ? { menu: o.menu } : {}),
-- ...(o.sessionUrl ? { sessionUrl: o.sessionUrl } : {}),
-- ...(o.loginUrl ? { loginUrl: o.loginUrl } : {}),
-- ...(o.logoutUrl ? { logoutUrl: o.logoutUrl } : {}),
-- ...(o.silentLogin ? { silentLogin: o.silentLogin } : {}),
-- ...(o.navbar && typeof o.navbar === 'object' ? o.navbar : {}),
-- };
-- const footer = o.footer === false ? null : {
-- service: o.service || 'app',
-- mount: '#ov-footer',
-- variant: 'full',
-- apiBase: b,
-- ...(o.brand && o.brand.name ? { brandName: o.brand.name } : {}),
-- ...(o.shipped === false ? { shipped: false } : {}),
-- ...(o.footer && typeof o.footer === 'object' ? o.footer : {}),
-- };
-- return { navbar, footer };
--}
--
--const jsonForScript = (v) => JSON.stringify(v).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
--const attr = (s) => String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
--
--/** Markup for server-rendered pages: { head, bodyStart, bodyEnd } strings. */
--function chromeTags(opts = {}) {
-- const urls = scriptUrls(opts);
-- const cfg = chromeConfig(opts);
-- const head = [
-- opts.theme === false ? '' : `<script src="${attr(urls.theme)}" defer></script>`,
-- `<script src="${attr(urls.navbar)}" defer></script>`,
-- cfg.footer ? `<script src="${attr(urls.footer)}" defer></script>` : '',
-- ].filter(Boolean).join('\n');
-- const bodyStart = '<div id="navbar-mount"></div>';
-- const init = `document.addEventListener('DOMContentLoaded',function(){var c=${jsonForScript(cfg)};`
-- + `try{if(window.OpenVibeNavbar)OpenVibeNavbar.init(c.navbar);}catch(e){}`
-- + `try{if(c.footer&&window.OpenVibeFooter)OpenVibeFooter.init(c.footer);}catch(e){}});`;
-- const bodyEnd = `${cfg.footer ? '<footer id="ov-footer"></footer>\n' : ''}<script>${init}</script>`;
-- return { head, bodyStart, bodyEnd, config: cfg, urls };
--}
--
--function loadScript(doc, src) {
-- return new Promise((resolve) => {
-- const existing = doc.querySelector(`script[data-ov-chrome="${src}"]`);
-- if (existing) { if (existing.dataset.loaded) resolve(true); else existing.addEventListener('load', () => resolve(true)); return; }
-- const s = doc.createElement('script');
-- s.src = src;
-- s.async = true;
-- s.setAttribute('data-ov-chrome', src);
-- s.addEventListener('load', () => { s.dataset.loaded = '1'; resolve(true); });
-- s.addEventListener('error', () => resolve(false));
-- (doc.head || doc.documentElement).appendChild(s);
-- });
--}
--
--/**
-- * Browser: load the shared chrome and mount it. Resolves { navbar, footer } (the initialised
-- * globals, or null for a part that could not load). Never throws for a network failure.
-- */
--async function mountChrome(opts = {}) {
-- const doc = (opts && opts.document) || (typeof document !== 'undefined' ? document : null);
-- const win = (opts && opts.window) || (typeof window !== 'undefined' ? window : null);
-- if (!doc || !win) throw new Error('openvibe-sdk/chrome: mountChrome runs in a browser; use chromeTags() on the server');
-- const urls = scriptUrls(opts);
-- const cfg = chromeConfig(opts);
-- if (!doc.getElementById('navbar-mount')) {
-- const m = doc.createElement('div');
-- m.id = 'navbar-mount';
-- doc.body.insertBefore(m, doc.body.firstChild);
-- }
-- if (cfg.footer && !doc.querySelector(cfg.footer.mount)) {
-- const f = doc.createElement('footer');
-- f.id = String(cfg.footer.mount).replace(/^#/, '');
-- doc.body.appendChild(f);
-- }
-- const loads = [];
-- if (opts.theme !== false) loads.push(loadScript(doc, urls.theme));
-- const navOk = loadScript(doc, urls.navbar);
-- const footOk = cfg.footer ? loadScript(doc, urls.footer) : Promise.resolve(false);
-- await Promise.all([...loads, navOk, footOk]);
-- let navbar = null;
-- let footer = null;
-- try { if (win.OpenVibeNavbar) { win.OpenVibeNavbar.init(cfg.navbar); navbar = win.OpenVibeNavbar; } } catch { navbar = null; }
-- try { if (cfg.footer && win.OpenVibeFooter) { win.OpenVibeFooter.init(cfg.footer); footer = win.OpenVibeFooter; } } catch { footer = null; }
-- return { navbar, footer };
--}
--
--module.exports = { mountChrome, chromeTags, chromeConfig, scriptUrls, DEFAULT_BASE };
ddiff --git a/src/frame.js b/src/frame.js
nnew file mode 100644
iindex 0000000..42cc8ef
---- /dev/null
++++ b/src/frame.js
@@@ -0,0 +1,150 @@
++'use strict';
++
++/**
++ * openvibe-sdk/chrome — the OpenVibe network's shared chrome for any app: the universal navbar (one
++ * account menu, notifications, the site switcher, sign in and out), the shared footer (network
++ * links, legal links, the "shipped X ago" line and an Updates link), the "shipped" views and the
++ * theme loader. These are the same files every OpenVibe site runs, served by OpenVibe.Network from
++ * the published openvibe-shared release. No dependencies; nothing is bundled into your app.
++ *
++ * In the browser:
++ *
++ * // mountChrome from the openvibe-sdk/chrome entry (CommonJS or ESM)
++ * const { navbar, footer } = await mountChrome({
++ * service: 'myapp', // your app's id (brand + analytics)
++ * brand: { name: 'My App' }, // optional brand override
++ * links: [{ label: 'Home', href: '/' }, { label: 'Docs', href: '/docs' }],
++ * menu: { before: [{ label: 'My projects', href: '/projects', icon: 'fa-folder' }] },
++ * sessionUrl: '/auth/me', // your server session ({ user }), optional
++ * loginUrl: '/auth/login?next={path}', // {url} full return URL, {path} local path
++ * logoutUrl: '/auth/logout?next={path}', // Sign out ends your session too
++ * footer: { links: [{ heading: 'My App', items: [{ name: 'About', url: '/about' }] }], updates: '/updates' },
++ * });
++ *
++ * Server-rendered apps put chromeTags(opts) in <head>/<body> instead (the same markup this module
++ * would inject, as a string; the JSON config is escaped for a <script> element):
++ *
++ * // chromeTags from the openvibe-sdk/chrome entry
++ * const t = chromeTags({ service: 'myapp', links: [...] });
++ * html = `<head>${t.head}</head><body>${t.bodyStart}…${t.bodyEnd}</body>`;
++ *
++ * Your CSP must allow `script-src https://openvibe.network` and `connect-src https://openvibe.network`
++ * (the navbar asks /api/auth/me and the notifications there; the shipped line reads
++ * /api/v1/changelog). Signed-in state comes from the shared ov_token, else from sessionUrl. For
++ * your own users to be recognised across the network, sign them in with OpenVibe (openvibe-sdk/auth).
++ * The chrome is progressive: if openvibe.network is unreachable your page still renders.
++ */
++
++const DEFAULT_BASE = 'https://openvibe.network';
++const FILES = { theme: 'theme-loader.js', navbar: 'navbar.js', footer: 'footer.js', shipped: 'shipped.js' };
++
++function baseOf(base) {
++ const b = String(base || DEFAULT_BASE).replace(/\/+$/, '');
++ const ok = /^https:\/\/[a-z0-9.-]+(:\d+)?$/i.test(b) || /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(b);
++ if (!ok) throw new TypeError(`openvibe-sdk/chrome: base must be an https origin (or http://localhost for development), got ${base}`);
++ return b;
++}
++
++/** The script URLs on a base: the release OpenVibe.Network currently serves at /shared/. */
++function scriptUrls({ base } = {}) {
++ const b = baseOf(base);
++ const out = {};
++ for (const [k, f] of Object.entries(FILES)) out[k] = `${b}/shared/${f}`;
++ return out;
++}
++
++/** The navbar and footer configurations mountChrome passes to OpenVibeNavbar.init / OpenVibeFooter.init. */
++function chromeConfig(opts = {}) {
++ const o = opts || {};
++ const b = baseOf(o.base);
++ const navbar = {
++ service: o.service || 'app',
++ apiBase: b,
++ ...(o.brand ? { brand: o.brand } : {}),
++ ...(Array.isArray(o.links) ? { links: o.links } : {}),
++ ...(o.menu ? { menu: o.menu } : {}),
++ ...(o.sessionUrl ? { sessionUrl: o.sessionUrl } : {}),
++ ...(o.loginUrl ? { loginUrl: o.loginUrl } : {}),
++ ...(o.logoutUrl ? { logoutUrl: o.logoutUrl } : {}),
++ ...(o.silentLogin ? { silentLogin: o.silentLogin } : {}),
++ ...(o.navbar && typeof o.navbar === 'object' ? o.navbar : {}),
++ };
++ const footer = o.footer === false ? null : {
++ service: o.service || 'app',
++ mount: '#ov-footer',
++ variant: 'full',
++ apiBase: b,
++ ...(o.brand && o.brand.name ? { brandName: o.brand.name } : {}),
++ ...(o.shipped === false ? { shipped: false } : {}),
++ ...(o.footer && typeof o.footer === 'object' ? o.footer : {}),
++ };
++ return { navbar, footer };
++}
++
++const jsonForScript = (v) => JSON.stringify(v).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
++const attr = (s) => String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
++
++/** Markup for server-rendered pages: { head, bodyStart, bodyEnd } strings. */
++function chromeTags(opts = {}) {
++ const urls = scriptUrls(opts);
++ const cfg = chromeConfig(opts);
++ const head = [
++ opts.theme === false ? '' : `<script src="${attr(urls.theme)}" defer></script>`,
++ `<script src="${attr(urls.navbar)}" defer></script>`,
++ cfg.footer ? `<script src="${attr(urls.footer)}" defer></script>` : '',
++ ].filter(Boolean).join('\n');
++ const bodyStart = '<div id="navbar-mount"></div>';
++ const init = `document.addEventListener('DOMContentLoaded',function(){var c=${jsonForScript(cfg)};`
++ + `try{if(window.OpenVibeNavbar)OpenVibeNavbar.init(c.navbar);}catch(e){}`
++ + `try{if(c.footer&&window.OpenVibeFooter)OpenVibeFooter.init(c.footer);}catch(e){}});`;
++ const bodyEnd = `${cfg.footer ? '<footer id="ov-footer"></footer>\n' : ''}<script>${init}</script>`;
++ return { head, bodyStart, bodyEnd, config: cfg, urls };
++}
++
++function loadScript(doc, src) {
++ return new Promise((resolve) => {
++ const existing = doc.querySelector(`script[data-ov-chrome="${src}"]`);
++ if (existing) { if (existing.dataset.loaded) resolve(true); else existing.addEventListener('load', () => resolve(true)); return; }
++ const s = doc.createElement('script');
++ s.src = src;
++ s.async = true;
++ s.setAttribute('data-ov-chrome', src);
++ s.addEventListener('load', () => { s.dataset.loaded = '1'; resolve(true); });
++ s.addEventListener('error', () => resolve(false));
++ (doc.head || doc.documentElement).appendChild(s);
++ });
++}
++
++/**
++ * Browser: load the shared chrome and mount it. Resolves { navbar, footer } (the initialised
++ * globals, or null for a part that could not load). Never throws for a network failure.
++ */
++async function mountChrome(opts = {}) {
++ const doc = (opts && opts.document) || (typeof document !== 'undefined' ? document : null);
++ const win = (opts && opts.window) || (typeof window !== 'undefined' ? window : null);
++ if (!doc || !win) throw new Error('openvibe-sdk/chrome: mountChrome runs in a browser; use chromeTags() on the server');
++ const urls = scriptUrls(opts);
++ const cfg = chromeConfig(opts);
++ if (!doc.getElementById('navbar-mount')) {
++ const m = doc.createElement('div');
++ m.id = 'navbar-mount';
++ doc.body.insertBefore(m, doc.body.firstChild);
++ }
++ if (cfg.footer && !doc.querySelector(cfg.footer.mount)) {
++ const f = doc.createElement('footer');
++ f.id = String(cfg.footer.mount).replace(/^#/, '');
++ doc.body.appendChild(f);
++ }
++ const loads = [];
++ if (opts.theme !== false) loads.push(loadScript(doc, urls.theme));
++ const navOk = loadScript(doc, urls.navbar);
++ const footOk = cfg.footer ? loadScript(doc, urls.footer) : Promise.resolve(false);
++ await Promise.all([...loads, navOk, footOk]);
++ let navbar = null;
++ let footer = null;
++ try { if (win.OpenVibeNavbar) { win.OpenVibeNavbar.init(cfg.navbar); navbar = win.OpenVibeNavbar; } } catch { navbar = null; }
++ try { if (cfg.footer && win.OpenVibeFooter) { win.OpenVibeFooter.init(cfg.footer); footer = win.OpenVibeFooter; } } catch { footer = null; }
++ return { navbar, footer };
++}
++
++module.exports = { mountChrome, chromeTags, chromeConfig, scriptUrls, DEFAULT_BASE };
ddiff --git a/test/chrome.test.js b/test/chrome.test.js
ddeleted file mode 100644
iindex ba4725a..0000000
---- a/test/chrome.test.js
++++ /dev/null
@@@ -1,84 +0,0 @@
--'use strict';
--// openvibe-sdk/chrome: any app mounts the network's shared navbar, footer, shipped line and themes,
--// with its own session endpoints; server-rendered apps get the same markup as strings.
--const assert = require('node:assert/strict');
--const { mountChrome, chromeTags, chromeConfig, scriptUrls } = require('../src/chrome');
--
--const opts = {
-- service: 'myapp',
-- brand: { name: 'My App' },
-- links: [{ label: 'Docs </script><script>alert(1)</script>', href: '/docs' }],
-- menu: { before: [{ label: 'My projects', href: '/projects' }] },
-- sessionUrl: '/auth/me', loginUrl: '/auth/login?next={path}', logoutUrl: '/auth/logout?next={path}',
-- footer: { updates: '/updates' },
--};
--
--// Config mapping
--const cfg = chromeConfig(opts);
--assert.equal(cfg.navbar.service, 'myapp');
--assert.equal(cfg.navbar.apiBase, 'https://openvibe.network');
--assert.equal(cfg.navbar.logoutUrl, '/auth/logout?next={path}');
--assert.deepEqual(cfg.navbar.menu.before[0], { label: 'My projects', href: '/projects' });
--assert.equal(cfg.footer.updates, '/updates');
--assert.equal(cfg.footer.brandName, 'My App');
--assert.equal(chromeConfig({ footer: false }).footer, null);
--assert.equal(chromeConfig({ shipped: false }).footer.shipped, false);
--
--// URLs and base validation
--assert.equal(scriptUrls().navbar, 'https://openvibe.network/shared/navbar.js');
--assert.equal(scriptUrls({ base: 'http://localhost:4000/' }).footer, 'http://localhost:4000/shared/footer.js');
--assert.throws(() => scriptUrls({ base: 'http://evil.example' }), /https origin/);
--assert.throws(() => scriptUrls({ base: 'javascript:alert(1)' }), /https origin/);
--
--// SSR tags: the config cannot break out of its <script>
--const t = chromeTags(opts);
--assert.ok(t.head.includes('https://openvibe.network/shared/navbar.js') && t.head.includes('theme-loader.js') && t.head.includes('footer.js'));
--assert.equal(t.bodyStart, '<div id="navbar-mount"></div>');
--assert.ok(t.bodyEnd.includes('<footer id="ov-footer"></footer>'));
--assert.ok(!/<\/script><script>alert/.test(t.bodyEnd), 'a label cannot close the init script');
--assert.equal((t.bodyEnd.match(/<\/script>/g) || []).length, 1);
--assert.ok(!chromeTags({ theme: false }).head.includes('theme-loader'));
--
--// Browser mount with a minimal DOM: scripts "load", globals appear, init receives the config.
--function fakeDom() {
-- const inits = {};
-- const win = {};
-- const nodes = [];
-- const el = (tag) => {
-- const n = { tagName: tag.toUpperCase(), id: '', dataset: {}, attrs: {}, listeners: {}, children: [],
-- setAttribute(k, v) { this.attrs[k] = v; }, addEventListener(ev, fn) { (this.listeners[ev] = this.listeners[ev] || []).push(fn); },
-- appendChild(c) { this.children.push(c); nodes.push(c); if (c.tagName === 'SCRIPT') setImmediate(() => {
-- if (c.src.endsWith('navbar.js')) win.OpenVibeNavbar = { init: (x) => { inits.navbar = x; } };
-- if (c.src.endsWith('footer.js')) win.OpenVibeFooter = { init: (x) => { inits.footer = x; } };
-- (c.listeners.load || []).forEach((f) => f());
-- }); return c; },
-- insertBefore(c) { nodes.push(c); return c; } };
-- return n;
-- };
-- const doc = {
-- head: el('head'), body: el('body'), documentElement: el('html'),
-- createElement: el,
-- getElementById: (id) => nodes.find((n) => n.id === id) || null,
-- querySelector: (sel) => sel.startsWith('#') ? nodes.find((n) => n.id === sel.slice(1)) || null : nodes.find((n) => n.tagName === 'SCRIPT' && sel.includes(n.src)) || null,
-- };
-- doc.body.firstChild = null;
-- return { doc, win, inits, nodes };
--}
--
--(async () => {
-- const d = fakeDom();
-- const r = await mountChrome({ ...opts, document: d.doc, window: d.win });
-- assert.ok(r.navbar && r.footer);
-- assert.equal(d.inits.navbar.sessionUrl, '/auth/me');
-- assert.equal(d.inits.footer.mount, '#ov-footer');
-- assert.ok(d.doc.getElementById('navbar-mount') && d.doc.getElementById('ov-footer'), 'mount points created');
-- assert.deepEqual(d.nodes.filter((n) => n.tagName === 'SCRIPT').map((n) => n.src.split('/').pop()).sort(), ['footer.js', 'navbar.js', 'theme-loader.js']);
--
-- // A failed load leaves that part null and never throws.
-- const d2 = fakeDom();
-- d2.doc.head.appendChild = function (c) { d2.nodes.push(c); setImmediate(() => (c.listeners.error || []).forEach((f) => f())); return c; };
-- const r2 = await mountChrome({ document: d2.doc, window: d2.win });
-- assert.deepEqual(r2, { navbar: null, footer: null });
-- await assert.rejects(() => mountChrome({ document: null, window: null }), /browser/);
-- console.log('chrome: all checks passed');
--})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/frame.test.js b/test/frame.test.js
nnew file mode 100644
iindex 0000000..ba4725a
---- /dev/null
++++ b/test/frame.test.js
@@@ -0,0 +1,84 @@
++'use strict';
++// openvibe-sdk/chrome: any app mounts the network's shared navbar, footer, shipped line and themes,
++// with its own session endpoints; server-rendered apps get the same markup as strings.
++const assert = require('node:assert/strict');
++const { mountChrome, chromeTags, chromeConfig, scriptUrls } = require('../src/chrome');
++
++const opts = {
++ service: 'myapp',
++ brand: { name: 'My App' },
++ links: [{ label: 'Docs </script><script>alert(1)</script>', href: '/docs' }],
++ menu: { before: [{ label: 'My projects', href: '/projects' }] },
++ sessionUrl: '/auth/me', loginUrl: '/auth/login?next={path}', logoutUrl: '/auth/logout?next={path}',
++ footer: { updates: '/updates' },
++};
++
++// Config mapping
++const cfg = chromeConfig(opts);
++assert.equal(cfg.navbar.service, 'myapp');
++assert.equal(cfg.navbar.apiBase, 'https://openvibe.network');
++assert.equal(cfg.navbar.logoutUrl, '/auth/logout?next={path}');
++assert.deepEqual(cfg.navbar.menu.before[0], { label: 'My projects', href: '/projects' });
++assert.equal(cfg.footer.updates, '/updates');
++assert.equal(cfg.footer.brandName, 'My App');
++assert.equal(chromeConfig({ footer: false }).footer, null);
++assert.equal(chromeConfig({ shipped: false }).footer.shipped, false);
++
++// URLs and base validation
++assert.equal(scriptUrls().navbar, 'https://openvibe.network/shared/navbar.js');
++assert.equal(scriptUrls({ base: 'http://localhost:4000/' }).footer, 'http://localhost:4000/shared/footer.js');
++assert.throws(() => scriptUrls({ base: 'http://evil.example' }), /https origin/);
++assert.throws(() => scriptUrls({ base: 'javascript:alert(1)' }), /https origin/);
++
++// SSR tags: the config cannot break out of its <script>
++const t = chromeTags(opts);
++assert.ok(t.head.includes('https://openvibe.network/shared/navbar.js') && t.head.includes('theme-loader.js') && t.head.includes('footer.js'));
++assert.equal(t.bodyStart, '<div id="navbar-mount"></div>');
++assert.ok(t.bodyEnd.includes('<footer id="ov-footer"></footer>'));
++assert.ok(!/<\/script><script>alert/.test(t.bodyEnd), 'a label cannot close the init script');
++assert.equal((t.bodyEnd.match(/<\/script>/g) || []).length, 1);
++assert.ok(!chromeTags({ theme: false }).head.includes('theme-loader'));
++
++// Browser mount with a minimal DOM: scripts "load", globals appear, init receives the config.
++function fakeDom() {
++ const inits = {};
++ const win = {};
++ const nodes = [];
++ const el = (tag) => {
++ const n = { tagName: tag.toUpperCase(), id: '', dataset: {}, attrs: {}, listeners: {}, children: [],
++ setAttribute(k, v) { this.attrs[k] = v; }, addEventListener(ev, fn) { (this.listeners[ev] = this.listeners[ev] || []).push(fn); },
++ appendChild(c) { this.children.push(c); nodes.push(c); if (c.tagName === 'SCRIPT') setImmediate(() => {
++ if (c.src.endsWith('navbar.js')) win.OpenVibeNavbar = { init: (x) => { inits.navbar = x; } };
++ if (c.src.endsWith('footer.js')) win.OpenVibeFooter = { init: (x) => { inits.footer = x; } };
++ (c.listeners.load || []).forEach((f) => f());
++ }); return c; },
++ insertBefore(c) { nodes.push(c); return c; } };
++ return n;
++ };
++ const doc = {
++ head: el('head'), body: el('body'), documentElement: el('html'),
++ createElement: el,
++ getElementById: (id) => nodes.find((n) => n.id === id) || null,
++ querySelector: (sel) => sel.startsWith('#') ? nodes.find((n) => n.id === sel.slice(1)) || null : nodes.find((n) => n.tagName === 'SCRIPT' && sel.includes(n.src)) || null,
++ };
++ doc.body.firstChild = null;
++ return { doc, win, inits, nodes };
++}
++
++(async () => {
++ const d = fakeDom();
++ const r = await mountChrome({ ...opts, document: d.doc, window: d.win });
++ assert.ok(r.navbar && r.footer);
++ assert.equal(d.inits.navbar.sessionUrl, '/auth/me');
++ assert.equal(d.inits.footer.mount, '#ov-footer');
++ assert.ok(d.doc.getElementById('navbar-mount') && d.doc.getElementById('ov-footer'), 'mount points created');
++ assert.deepEqual(d.nodes.filter((n) => n.tagName === 'SCRIPT').map((n) => n.src.split('/').pop()).sort(), ['footer.js', 'navbar.js', 'theme-loader.js']);
++
++ // A failed load leaves that part null and never throws.
++ const d2 = fakeDom();
++ d2.doc.head.appendChild = function (c) { d2.nodes.push(c); setImmediate(() => (c.listeners.error || []).forEach((f) => f())); return c; };
++ const r2 = await mountChrome({ document: d2.doc, window: d2.win });
++ assert.deepEqual(r2, { navbar: null, footer: null });
++ await assert.rejects(() => mountChrome({ document: null, window: null }), /browser/);
++ console.log('chrome: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex d365dc6..a16257f 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -22,7 +22,7 @@ run([
const out = execFileSync(npm, ['pack', '--json', '--pack-destination', tmp], { cwd: ROOT, env, encoding: 'utf8' });
const info = JSON.parse(out)[0];
const files = info.files.map((f) => f.path);
-- for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'src/vip.js', 'esm/vip.mjs', 'types/vip.d.ts', 'src/chrome.js', 'esm/chrome.mjs', 'types/chrome.d.ts', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'browser/openvibe-sdk.mjs']) {
++ for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'src/vip.js', 'esm/vip.mjs', 'types/vip.d.ts', 'src/frame.js', 'esm/frame.mjs', 'types/frame.d.ts', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'browser/openvibe-sdk.mjs']) {
assert.ok(files.includes(must), `tarball contains ${must}`);
}
assert.ok(!files.some((f) => f.startsWith('test/') || f.startsWith('scripts/') || f.startsWith('.github/')), 'no tests or tooling in the tarball');
ddiff --git a/types/chrome.d.ts b/types/chrome.d.ts
ddeleted file mode 100644
iindex 15b0cd3..0000000
---- a/types/chrome.d.ts
++++ /dev/null
@@@ -1,46 +0,0 @@
--// openvibe-sdk/chrome — the OpenVibe network's shared navbar, footer, "shipped" views and theme loader for any app.
--
--export interface ChromeLink { label: string; href: string; icon?: string; active?: boolean }
--export interface ChromeMenuItem { label: string; href?: string; icon?: string; onClick?: () => void; danger?: boolean; external?: boolean }
--export interface ChromeFooterConfig {
-- links?: Array<{ heading: string; items: Array<{ name: string; url: string }> }>;
-- variant?: 'full' | 'compact';
-- /** This app's update log; default: https://openvibe.network/updates?site=<host>. */
-- updates?: string;
-- mount?: string;
-- [key: string]: unknown;
--}
--export interface ChromeOptions {
-- /** Your app's id (brand and analytics). */
-- service?: string;
-- brand?: { name?: string; sub?: string; tld?: string; icon?: string; variant?: string };
-- links?: ChromeLink[];
-- menu?: { before?: ChromeMenuItem[]; after?: ChromeMenuItem[] };
-- /** Same-origin endpoint answering { user } for your server session. */
-- sessionUrl?: string;
-- /** Sign-in URL; {url} = full return URL, {path} = its local path. */
-- loginUrl?: string;
-- /** Sign-out URL, so your server session ends too; {url} / {path} as for loginUrl. */
-- logoutUrl?: string;
-- silentLogin?: string;
-- /** Footer options, or false for no footer. */
-- footer?: ChromeFooterConfig | false;
-- /** false: no "shipped X ago" line in the footer. */
-- shipped?: boolean;
-- /** false: do not load the shared theme loader. */
-- theme?: boolean;
-- /** Where the shared chrome is served (default https://openvibe.network). */
-- base?: string;
-- /** Extra navbar options passed through to OpenVibeNavbar.init. */
-- navbar?: Record<string, unknown>;
-- document?: unknown;
-- window?: unknown;
--}
--export interface ChromeConfig { navbar: Record<string, unknown>; footer: Record<string, unknown> | null }
--export interface ChromeUrls { theme: string; navbar: string; footer: string; shipped: string }
--
--export const DEFAULT_BASE: string;
--export function scriptUrls(opts?: { base?: string }): ChromeUrls;
--export function chromeConfig(opts?: ChromeOptions): ChromeConfig;
--export function chromeTags(opts?: ChromeOptions): { head: string; bodyStart: string; bodyEnd: string; config: ChromeConfig; urls: ChromeUrls };
--export function mountChrome(opts?: ChromeOptions): Promise<{ navbar: unknown | null; footer: unknown | null }>;
ddiff --git a/types/frame.d.ts b/types/frame.d.ts
nnew file mode 100644
iindex 0000000..15b0cd3
---- /dev/null
++++ b/types/frame.d.ts
@@@ -0,0 +1,46 @@
++// openvibe-sdk/chrome — the OpenVibe network's shared navbar, footer, "shipped" views and theme loader for any app.
++
++export interface ChromeLink { label: string; href: string; icon?: string; active?: boolean }
++export interface ChromeMenuItem { label: string; href?: string; icon?: string; onClick?: () => void; danger?: boolean; external?: boolean }
++export interface ChromeFooterConfig {
++ links?: Array<{ heading: string; items: Array<{ name: string; url: string }> }>;
++ variant?: 'full' | 'compact';
++ /** This app's update log; default: https://openvibe.network/updates?site=<host>. */
++ updates?: string;
++ mount?: string;
++ [key: string]: unknown;
++}
++export interface ChromeOptions {
++ /** Your app's id (brand and analytics). */
++ service?: string;
++ brand?: { name?: string; sub?: string; tld?: string; icon?: string; variant?: string };
++ links?: ChromeLink[];
++ menu?: { before?: ChromeMenuItem[]; after?: ChromeMenuItem[] };
++ /** Same-origin endpoint answering { user } for your server session. */
++ sessionUrl?: string;
++ /** Sign-in URL; {url} = full return URL, {path} = its local path. */
++ loginUrl?: string;
++ /** Sign-out URL, so your server session ends too; {url} / {path} as for loginUrl. */
++ logoutUrl?: string;
++ silentLogin?: string;
++ /** Footer options, or false for no footer. */
++ footer?: ChromeFooterConfig | false;
++ /** false: no "shipped X ago" line in the footer. */
++ shipped?: boolean;
++ /** false: do not load the shared theme loader. */
++ theme?: boolean;
++ /** Where the shared chrome is served (default https://openvibe.network). */
++ base?: string;
++ /** Extra navbar options passed through to OpenVibeNavbar.init. */
++ navbar?: Record<string, unknown>;
++ document?: unknown;
++ window?: unknown;
++}
++export interface ChromeConfig { navbar: Record<string, unknown>; footer: Record<string, unknown> | null }
++export interface ChromeUrls { theme: string; navbar: string; footer: string; shipped: string }
++
++export const DEFAULT_BASE: string;
++export function scriptUrls(opts?: { base?: string }): ChromeUrls;
++export function chromeConfig(opts?: ChromeOptions): ChromeConfig;
++export function chromeTags(opts?: ChromeOptions): { head: string; bodyStart: string; bodyEnd: string; config: ChromeConfig; urls: ChromeUrls };
++export function mountChrome(opts?: ChromeOptions): Promise<{ navbar: unknown | null; footer: unknown | null }>;