openvibers/openvibe.shared Commits

1.7.0: openvibe-shared/trace, one W3C trace across services: install(app) keeps each request's trace in AsyncLocalStorage and wraps fetch() so calls to loopback or OpenVibe hosts carry traceparent (new span) and X-OpenVibe-Request-Id; provider and user-chosen URLs are untouched; tested against a real loopback server

OP OpenVibers committed 24 Sept 2026 · f62f65336d · 8 files, +155 −6


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 777c8c3..cfb12d9 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,16 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
++## 1.7.0 — 2026-09-24
++
++`openvibe-shared/trace` (Track O): one W3C trace across the services a request touches.
++`trace.install(app)` (right after openvibe-contracts' `http.middleware()`) runs each request inside
++an AsyncLocalStorage holding its trace and request ids, and wraps `fetch()` once so a call to a
++loopback address or an OpenVibe host carries `traceparent` (the request's trace id, a new span) and
++`X-OpenVibe-Request-Id`, unless it sets its own. Calls to anyone else (payment and AI providers,
++user-chosen URLs) are untouched, so trace ids never leave the network; outside a request nothing is
++added. Also `trace.current()`, `trace.outboundHeaders()`, `trace.run(ctx, fn)`. Additive.
++
## 1.6.0 — 2026-09-24
`openvibe-shared/egress`: the one rule for connecting to an address someone else chose. Live
ddiff --git a/README.md b/README.md
iindex fa0fa7a..11c5e23 100644
---- a/README.md
++++ b/README.md
@@@ -24,7 +24,7 @@ were reconciled first.
| Kind | Files |
|---|---|
| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `release-watch.js`, `release-update.js`, `openvibe-sw.js` |
--| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `auth-client`, `brand`, `builtin-themes`, `chrome-ssr`, `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `release`, `release-compat` (tests), `metrics`, `ready`; `footer` and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
++| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `auth-client`, `brand`, `builtin-themes`, `chrome-ssr`, `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`; `footer` and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js` |
ddiff --git a/STATUS.json b/STATUS.json
iindex 8dbb73e..c0f3d7b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "1.6.0",
++ "version": "1.7.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"updated": "2026-09-24"
}
ddiff --git a/package-lock.json b/package-lock.json
iindex 35b19dc..826a6a9 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "1.6.0",
++ "version": "1.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "1.6.0",
++ "version": "1.7.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex b4a8255..1d23794 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "1.6.0",
++ "version": "1.7.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"exports": {
@@@ -46,6 +46,7 @@
"./sso-client": "./sso-client.js",
"./theme-loader": "./theme-loader.js",
"./theme-sync": "./theme-sync.js",
++ "./trace": "./trace.js",
"./tooltip": "./tooltip.js",
"./ui": "./ui.js",
"./url-resolver": "./url-resolver.js",
ddiff --git a/test/package.test.js b/test/package.test.js
iindex e8f55a5..3c0a50d 100644
---- a/test/package.test.js
++++ b/test/package.test.js
@@@ -22,7 +22,7 @@ for (const sub of ['analytics', 'app-icon', 'auth-client', 'brand', 'chrome-ssr'
assert.doesNotThrow(() => require.resolve(`openvibe-shared/${sub}`), `openvibe-shared/${sub}`);
}
// Server modules load in Node; the navbar module loads too (UMD, no DOM touched at require time).
--for (const sub of ['analytics', 'app-icon', 'brand', 'chrome-ssr', 'footer', 'icons', 'legal', 'notifications', 'seo', 'url-resolver', 'files', 'nav-icons', 'metrics', 'ready', 'egress']) require(`openvibe-shared/${sub}`);
++for (const sub of ['analytics', 'app-icon', 'brand', 'chrome-ssr', 'footer', 'icons', 'legal', 'notifications', 'seo', 'url-resolver', 'files', 'nav-icons', 'metrics', 'ready', 'egress', 'trace']) require(`openvibe-shared/${sub}`);
// analytics is dependency-free: its parts require only Node built-ins and each other.
for (const f of fs.readdirSync(path.join(ROOT, 'analytics'))) {
const src = fs.readFileSync(path.join(ROOT, 'analytics', f), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
ddiff --git a/test/trace.test.js b/test/trace.test.js
nnew file mode 100644
iindex 0000000..ca2cc92
---- /dev/null
++++ b/test/trace.test.js
@@@ -0,0 +1,48 @@
++'use strict';
++/** openvibe-shared/trace: a request's W3C trace rides on the fetch() calls made while serving it, only inside the network. */
++const assert = require('assert');
++const http = require('http');
++const trace = require('../trace');
++
++(async () => {
++ const seen = [];
++ const srv = await new Promise((r) => { const s = http.createServer((req, res) => { seen.push({ tp: req.headers.traceparent || null, rid: req.headers['x-openvibe-request-id'] || null }); res.end('ok'); }).listen(0, '127.0.0.1', () => r(s)); });
++ const url = `http://127.0.0.1:${srv.address().port}/x`;
++ const used = [];
++ const mw = trace.install({ use: (fn) => used.push(fn) });
++ assert.strictEqual(used.length, 1, 'install() mounts the middleware');
++ const TRACE = 'a'.repeat(32);
++
++ // Inside a request: loopback calls carry the request's trace id with a fresh span.
++ await new Promise((resolve, reject) => mw({ ov: { traceId: TRACE, requestId: 'req_1' }, headers: {} }, {}, () => {
++ (async () => {
++ assert.deepStrictEqual(trace.current(), { traceId: TRACE, requestId: 'req_1' });
++ await (await fetch(url)).text();
++ await (await fetch(url, { headers: { traceparent: `00-${'b'.repeat(32)}-${'c'.repeat(16)}-01` } })).text();
++ await new Promise((r) => setTimeout(r, 5));
++ await (await fetch(url)).text(); // still inside after an await and a timer
++ })().then(resolve, reject);
++ }));
++ assert.match(seen[0].tp, new RegExp(`^00-${TRACE}-[0-9a-f]{16}-01$`));
++ assert.strictEqual(seen[0].rid, 'req_1');
++ assert.strictEqual(seen[1].tp, `00-${'b'.repeat(32)}-${'c'.repeat(16)}-01`, 'a caller\'s own traceparent is kept');
++ assert.match(seen[2].tp, new RegExp(`^00-${TRACE}-`));
++ assert.notStrictEqual(seen[0].tp, seen[2].tp, 'each call is its own span');
++
++ // Outside a request nothing is added.
++ await (await fetch(url)).text();
++ assert.strictEqual(seen[3].tp, null);
++
++ // Without req.ov the incoming traceparent is used, and a malformed one starts a new trace.
++ await new Promise((resolve) => mw({ headers: { traceparent: `00-${'d'.repeat(32)}-${'e'.repeat(16)}-01` } }, {}, () => { assert.strictEqual(trace.current().traceId, 'd'.repeat(32)); resolve(); }));
++ await new Promise((resolve) => mw({ headers: { traceparent: 'garbage' } }, {}, () => { assert.match(trace.current().traceId, /^[0-9a-f]{32}$/); resolve(); }));
++
++ // Only calls inside the network are traced: a trace id never goes to a provider or a user's URL.
++ for (const u of ['http://127.0.0.1:4000/x', 'http://localhost:3000', 'https://openvibe.live/api', 'https://billing.openvibe.network/x', 'https://openre.stream/', 'http://[::1]:9/']) assert.strictEqual(trace.insideNetwork(u), true, u);
++ for (const u of ['https://api.stripe.com/v1', 'https://example.org', 'https://openvibe.live.evil.com/', 'https://notopenvibe.live/', 'not a url']) assert.strictEqual(trace.insideNetwork(u), false, u);
++ assert.deepStrictEqual(trace.outboundHeaders(null), {});
++ trace.install({ use() {} });
++ assert.strictEqual(globalThis.fetch[Symbol.for('openvibe.trace.fetch')], true, 'fetch is wrapped once');
++ srv.close();
++ console.log('trace: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/trace.js b/trace.js
nnew file mode 100644
iindex 0000000..a2c2dd4
---- /dev/null
++++ b/trace.js
@@@ -0,0 +1,90 @@
++'use strict';
++/**
++ * openvibe-shared/trace — carry the W3C trace of the request being served onto the calls a service
++ * makes while serving it (roadmap Track O): one trace across Live → Chat → VIP → Billing, without
++ * threading a context object through every function.
++ *
++ * const trace = require('openvibe-shared/trace');
++ * app.use(contracts.http.middleware()); // accepts or starts traceparent, sets req.ov
++ * trace.install(app); // right after it: remembers req.ov for the request,
++ * // and outbound fetch() carries it
++ *
++ * install(app) adds a middleware that runs the rest of the request inside an AsyncLocalStorage
++ * holding { traceId, requestId } (from req.ov, or parsed from the incoming traceparent), and wraps
++ * globalThis.fetch once per process: a request to a loopback address or an OpenVibe host
++ * (*.openvibe.network, openvibe.live, …, TRACE_HOSTS) that has no traceparent of its own gets
++ * `traceparent: 00-<traceId>-<new span id>-01` and `X-OpenVibe-Request-Id`. Calls to anyone else
++ * (payment providers, AI providers, user-chosen URLs) are left exactly as they were, so a trace id
++ * never leaves the network. Outside a request (timers, workers) nothing is added.
++ *
++ * trace.current() { traceId, requestId } or null
++ * trace.outboundHeaders() the headers install() would add ({} outside a request)
++ * trace.run(ctx, fn) run fn inside a context (jobs that want one)
++ */
++const crypto = require('crypto');
++const { AsyncLocalStorage } = require('async_hooks');
++
++const als = new AsyncLocalStorage();
++const TRACE_RE = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
++const OPENVIBE_HOST = /(^|\.)(openvibe\.(network|live|tools|media|community|chat|games|tips|vip|codes|wiki|blog|news|reviews|deals|coupons|trade|host)|openre\.stream)$/i;
++const LOOPBACK_HOST = /^(127\.\d+\.\d+\.\d+|localhost|\[?::1\]?)$/i;
++
++function fromHeaders(headers = {}) {
++ const get = (k) => (typeof headers.get === 'function' ? headers.get(k) : headers[k] || headers[k.toLowerCase()]);
++ const m = TRACE_RE.exec(String(get('traceparent') || '').trim().toLowerCase());
++ const traceId = m && !/^0+$/.test(m[1]) ? m[1] : crypto.randomBytes(16).toString('hex');
++ const rid = String(get('x-openvibe-request-id') || '').slice(0, 128);
++ return { traceId, requestId: /^[A-Za-z0-9._:-]{1,128}$/.test(rid) ? rid : `req_${crypto.randomBytes(12).toString('hex')}` };
++}
++
++function current() { return als.getStore() || null; }
++function run(ctx, fn) { return als.run(ctx && ctx.traceId ? { traceId: ctx.traceId, requestId: ctx.requestId || null } : fromHeaders({}), fn); }
++
++function outboundHeaders(ctx = current()) {
++ if (!ctx || !ctx.traceId) return {};
++ const out = { traceparent: `00-${ctx.traceId}-${crypto.randomBytes(8).toString('hex')}-01` };
++ if (ctx.requestId) out['X-OpenVibe-Request-Id'] = ctx.requestId;
++ return out;
++}
++
++function extraHosts() {
++ return String(process.env.TRACE_HOSTS || '').split(',').map((h) => h.trim().toLowerCase()).filter(Boolean);
++}
++/** Does a call to this URL stay inside the network (loopback or an OpenVibe host)? */
++function insideNetwork(url) {
++ let host;
++ try { host = new URL(url).hostname.toLowerCase(); } catch { return false; }
++ host = host.replace(/^\[|\]$/g, '');
++ return LOOPBACK_HOST.test(host) || OPENVIBE_HOST.test(host) || extraHosts().includes(host);
++}
++
++const WRAPPED = Symbol.for('openvibe.trace.fetch');
++function wrapFetch() {
++ const original = globalThis.fetch;
++ if (typeof original !== 'function' || original[WRAPPED]) return;
++ const traced = function tracedFetch(input, init) {
++ const ctx = current();
++ if (!ctx) return original(input, init);
++ const url = typeof input === 'string' ? input : (input && (input.url || input.href)) || '';
++ if (!insideNetwork(url)) return original(input, init);
++ const headers = new Headers((init && init.headers) || (input && typeof input === 'object' && input.headers) || undefined);
++ if (headers.has('traceparent')) return original(input, init);
++ for (const [k, v] of Object.entries(outboundHeaders(ctx))) if (!headers.has(k)) headers.set(k, v);
++ return original(input, { ...(init || {}), headers });
++ };
++ traced[WRAPPED] = true;
++ globalThis.fetch = traced;
++}
++
++/** Express: the request's trace for everything it calls, and fetch() that carries it. */
++function install(app) {
++ wrapFetch();
++ const mw = function openvibeTrace(req, _res, next) {
++ const ctx = req.ov && req.ov.traceId ? { traceId: req.ov.traceId, requestId: req.ov.requestId || null } : fromHeaders(req.headers || {});
++ als.run(ctx, next);
++ };
++ if (app && typeof app.use === 'function') app.use(mw);
++ return mw;
++}
++
++module.exports = { install, current, run, outboundHeaders, insideNetwork, _wrapFetch: wrapFetch };