openvibers/openvibe.sdk Commits

0.8.0: openvibe-sdk/chrome, the network's shared chrome for any app: mountChrome() loads and mounts the universal navbar (account menu, notifications, site switcher, sign in/out through the app's own sessionUrl/loginUrl/logoutUrl), the shared footer (network and legal links, 'shipped X ago', Updates) and themes from openvibe.network; chromeTags() gives server-rendered pages the same markup with the config escaped; never throws for a network failure; https bases only

OP OpenVibers committed 24 Sept 2026 · 4ecf0f4512 · 12 files, +335 −8


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 9d33490..56fe928 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -3,6 +3,18 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
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:
++
++- 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;
++- the theme loader.
++
++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.
++
## 0.7.0 (2026-09-24)
`openvibe-sdk/vip`: the consumer seam for products that honour OpenVibe.VIP memberships
ddiff --git a/README.md b/README.md
iindex 41f1f5c..7cae6f0 100644
---- a/README.md
++++ b/README.md
@@@ -237,6 +237,28 @@ 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
++
++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({
++ service: 'myapp',
++ brand: { name: 'My App' },
++ links: [{ label: 'Home', href: '/' }, { label: 'Docs', href: '/docs' }],
++ menu: { before: [{ label: 'My projects', href: '/projects' }] }, // rows in the account menu
++ sessionUrl: '/auth/me', // your server session: { user }
++ loginUrl: '/auth/login?next={path}',
++ logoutUrl: '/auth/logout?next={path}', // Sign out ends your session too
++ footer: { updates: '/updates' }, // or false for no footer
++});
++```
++
++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`.
++
++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`).
++
### Tools
`openvibe-sdk/tools` wraps the OpenVibe.Tools platform API (ADR-027, openvibe-contracts v0.33.0) on the gateway, `https://openvibe.tools`: every tool has a descriptor (`tools.tool@1`), and every tool with `api: true` runs through one route. **Status:** the registry routes come with Tools S3, the run API and the gateway's `/api/v1/jobs` facade with Tools S6 (the capabilities `tools.tool.read`, `tools.tool.run` and `tools.net.probe` stay `planned` until then). Until they are deployed, this client is tested against the contracts and the mock only.
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 8ff990e..e069e8a 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.7.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.8.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, registry, modules, realtime, media, community, jobs, tools, projects). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -37,7 +37,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.7.0';
++const SDK_VERSION = '0.8.0';
module.exports = {
createClient,
ddiff --git a/esm/chrome.mjs b/esm/chrome.mjs
nnew file mode 100644
iindex 0000000..4202095
---- /dev/null
++++ b/esm/chrome.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-lock.json b/package-lock.json
iindex eef1b47..6239263 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.7.0",
++ "version": "0.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.7.0",
++ "version": "0.8.0",
"license": "MIT",
"devDependencies": {
"better-sqlite3": "^11.10.0",
ddiff --git a/package.json b/package.json
iindex 42b2dd0..d8d5f15 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.7.0",
++ "version": "0.8.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Tools (registry, run API and jobs), VIP memberships and developer projects, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
@@@ -80,6 +80,11 @@
"import": "./esm/projects.mjs",
"default": "./src/projects.js"
},
++ "./chrome": {
++ "types": "./types/chrome.d.ts",
++ "import": "./esm/chrome.mjs",
++ "default": "./src/chrome.js"
++ },
"./vip": {
"types": "./types/vip.d.ts",
"browser": null,
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex 543fe3b..f0ef272 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -27,6 +27,7 @@ const ENTRIES = {
'tools.mjs': '../src/tools.js',
'projects.mjs': '../src/projects.js',
'vip.mjs': '../src/vip.js',
++ 'chrome.mjs': '../src/chrome.js',
'testing.mjs': '../src/testing/index.js',
};
ddiff --git a/src/chrome.js b/src/chrome.js
nnew file mode 100644
iindex 0000000..42cc8ef
---- /dev/null
++++ b/src/chrome.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/src/core/index.js b/src/core/index.js
iindex 12f181d..b010d03 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.7.0';
++const SDK_VERSION = '0.8.0';
module.exports = {
createClient,
ddiff --git a/test/chrome.test.js b/test/chrome.test.js
nnew file mode 100644
iindex 0000000..ba4725a
---- /dev/null
++++ b/test/chrome.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 4169c23..d365dc6 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', '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/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']) {
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');
@@@ -66,7 +66,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
-- assert.equal(subpaths.length, 16);
++ assert.equal(subpaths.length, 17);
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
ddiff --git a/types/chrome.d.ts b/types/chrome.d.ts
nnew file mode 100644
iindex 0000000..15b0cd3
---- /dev/null
++++ b/types/chrome.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 }>;