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
Changes
@@@ -4,6 +4,16 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking 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@@@ -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` |@@@ -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"}@@@ -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"@@@ -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",@@@ -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, '');nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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 };
@@ -4,6 +4,16 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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).
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
## 1.6.0 — 2026-09-24
`openvibe-shared/egress`: the one rule for connecting to an address someone else chose. Live
`openvibe-shared/egress`: the one rule for connecting to an address someone else chose. Live
@@ -24,7 +24,7 @@ were reconciled first.
@@ -24,7 +24,7 @@ were reconciled first.
| Kind | Files |
| 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` |
| 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`) |
| 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` |
| 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` |
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "1.6.0",
+ "version": "1.7.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"updated": "2026-09-24"
"updated": "2026-09-24"
}
}
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.6.0",
+ "version": "1.7.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.6.0",
+ "version": "1.7.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"exports": {
"exports": {
@@ -46,6 +46,7 @@
@@ -46,6 +46,7 @@
"./sso-client": "./sso-client.js",
"./sso-client": "./sso-client.js",
"./theme-loader": "./theme-loader.js",
"./theme-loader": "./theme-loader.js",
"./theme-sync": "./theme-sync.js",
"./theme-sync": "./theme-sync.js",
+ "./trace": "./trace.js",
"./tooltip": "./tooltip.js",
"./tooltip": "./tooltip.js",
"./ui": "./ui.js",
"./ui": "./ui.js",
"./url-resolver": "./url-resolver.js",
"./url-resolver": "./url-resolver.js",
@@ -22,7 +22,7 @@ for (const sub of ['analytics', 'app-icon', 'auth-client', 'brand', 'chrome-ssr'
@@ -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}`);
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).
// 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.
// analytics is dependency-free: its parts require only Node built-ins and each other.
for (const f of fs.readdirSync(path.join(ROOT, 'analytics'))) {
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, '');
const src = fs.readFileSync(path.join(ROOT, 'analytics', f), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,90 @@
@@ -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 };