openvibers/openvibe.shared Commits

README (consuming, releasing, compatibility), vendored-copy divergence audit, consumer migration plan; alpha

OP OpenVibers committed 23 Sept 2026 · 3d873ad1d0 · 4 files, +885 −55

docs/divergence-2026-09-22.md: no consumer held a fix canonical lacked; Media/Tools/Community
ov-icons.js predate the 71be248 centring fix, Community's navbar test is stale, Live's
user-card.js differed only in working-tree line endings. docs/migration-plan.md: per-repo steps
and diffs for Network, Live, Community, Media, Tools and Sites to pin the v1.0.0 tarball.

Changes

ddiff --git a/README.md b/README.md
iindex b450705..42a6614 100644
---- a/README.md
++++ b/README.md
@@@ -2,72 +2,177 @@
> Versioned UI, chrome, SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** placeholder — planning only, no runnable code yet.
--**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.3 and §16.6.
++**Status:** alpha. The `openvibe-shared` package lives here, v1.0.0 (Wave 2). Consumers still
++vendor their own copies until they apply [docs/migration-plan.md](docs/migration-plan.md).
++**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.3 and §16.6.
**License:** AGPL-3.0 (same as every OpenVibe service).
--## Purpose
--
--The canonical home of what is today `OpenVibe.Network/packages/openvibe-shared` (navbar, footer, themes, icons, legal pages, SEO helpers, notifications UI, panels), published as immutable versioned artifacts with a CDN-compatible mirror instead of being vendored/rsynced from Network.
--
--## Owns
--
++This repository **is** the npm package `openvibe-shared`. `package.json` sits at the repository
++root, so GitHub's tarball of a release tag installs as the package with no build step. It was
++imported history-free from `OpenVibe.Network/packages/openvibe-shared` at Network `f337eb6`.
++[docs/divergence-2026-09-22.md](docs/divergence-2026-09-22.md) records how the vendored copies
++were reconciled first.
++
++## What's in it
++
++| 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`, `openvibe-sw.js` |
++| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics`, `app-icon`, `auth-client`, `brand`, `builtin-themes`, `chrome-ssr`, `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`; `footer` and `icons` (= `ov-icons.js`) work on both sides |
++| 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` |
++
++Every module file has an `exports` entry, so `require('openvibe-shared/navbar')`,
++`require.resolve('openvibe-shared/package.json')` and the rest all resolve.
++
++## Consuming it
++
++### Server side: pin a release tarball
++
++```json
++"dependencies": {
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0"
++}
++```
++
++`npm install` writes the resolved URL and its `integrity` into `package-lock.json`, and `npm ci`
++reproduces it exactly. Upgrade by changing the tag in the URL and running `npm install`. Never
++hand-copy files into `node_modules/` or a `vendor/` directory.
++
++```js
++const legal = require('openvibe-shared/legal');
++const seo = require('openvibe-shared/seo');
++const { AnalyticsTracker } = require('openvibe-shared/analytics');
++```
++
++### Browser side: two options
++
++1. **From the Network** (what most pages do today):
++ `<script src="https://openvibe.network/shared/navbar.js" defer></script>`. Network serves the
++ browser files of the version it pins, from its own `node_modules/openvibe-shared`.
++2. **From the site's own origin**: the page's first paint doesn't depend on the Network being
++ up, and the files match the version the site's server pins. Serve exactly the browser files
++ from `node_modules`:
++
++ ```js
++ const shared = require('openvibe-shared/files');
++ app.use('/shared', (req, res, next) => {
++ const name = path.basename(req.path);
++ if (!shared.isBrowserFile(name)) return next();
++ res.setHeader('Access-Control-Allow-Origin', '*');
++ res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
++ res.sendFile(shared.path(name));
++ });
++ // Web Push worker: same origin, scope "/"
++ app.get('/openvibe-sw.js', (req, res) => { res.setHeader('Service-Worker-Allowed', '/'); res.sendFile(shared.path('openvibe-sw.js')); });
++ ```
++
++ Never `express.static` the whole package directory. It also holds server code, tests and
++ scripts.
++
++**navbar.js and `nav-icons.js`.** navbar.js draws the icons for its own rows inline. The rest
++of the Font Awesome glyph set sites name in `links`, `chips` and menu sections lives in
++`nav-icons.js`. navbar.js fetches that file once, after first paint, from **the same directory
++navbar.js was loaded from**, as `nav-icons.js?v=<first 12 hex of its sha256>`. It falls back to
++`https://openvibe.network/shared/` when navbar.js is bundled or inlined.
++
++So wherever you serve navbar.js, serve `nav-icons.js` beside it. The `?v=` matches the file's
++content hash, so it can be cached as immutable.
++
++Until the glyphs arrive, each icon's slot has the right width and nothing moves. If the file
++can't load, those icons fall back to `<i class="fa-solid …">`. Two options change this:
++
++- `OpenVibeNavbar.init({ iconsUrl })` points navbar.js at another URL.
++- Loading `nav-icons.js` *before* navbar.js puts every glyph in the first frame.
++
++## Development
++
++```bash
++npm ci
++npm test # every test/*.test.js (fnm exec --using=22.22.1 npm test)
++npm test -- navbar # only matching files
++npm run size # raw + brotli (q11) size of every browser file
++python3 scripts/build-nav-icons.py --font …/fa-solid-900.woff2 --css …/fontawesome.min.css
++node scripts/build-theme-loader.js
++```
++
++Tests are plain Node with stubbed browser globals. Some tests guard the release:
++
++- **navbar.js size budget**: 29 KB brotli (`test/nav-icons.test.js`).
++- **Built-in icons**: every icon the default navbar renders must be built in and drawn with no
++ fetch.
++- **Generated blocks**: the generated navbar and theme-loader blocks must be up to date.
++- **Browser bundles**: browser files must be free of `require()` and Node globals
++ (`test/browser-bundles.test.js`).
++- **Exports**: every export must resolve.
++
++CI runs on Node 22.22.1 and installs a tag-style tarball into an empty project.
++
++## Releasing
++
++1. Land the change on `main` with tests green. Add an entry under a new version heading in
++ `CHANGELOG.md`, and set the same version in `package.json`.
++2. Commit (`Release vX.Y.Z`), then tag: `git tag -a vX.Y.Z -m "vX.Y.Z"`. Push `main` and the tag.
++ CI runs on the tag.
++3. **Tags are immutable.** Never move or delete a released tag. Consumer lockfiles pin its
++ tarball's integrity hash. A fix is a new patch release.
++4. Upgrade Network first, because it serves `https://openvibe.network/shared/*` to every site.
++ Then upgrade Live, Community, Media, Tools and Sites: change the tag in the URL, run
++ `npm install`, commit the lockfile and deploy.
++
++GitHub builds tag tarballs on demand. In practice they're byte-stable, but GitHub doesn't
++promise it. If `npm ci` ever reports `EINTEGRITY` for this package on an unchanged tag, check
++that the tag wasn't moved, then run `npm install` to refresh the lockfile. The content is the
++tag's tree either way.
++
++## Compatibility policy
++
++- **Semver.** Major releases are for breaking changes: removing or renaming an exported
++ subpath, an export, a browser global (`OpenVibeNavbar`, `OpenVibeFooter`, …) or a served file
++ name, removing an init option, or changing markup, classes or defaults that sites style or
++ script against. Minor releases add modules, options or files. Patch releases are fixes with no
++ API change.
++- **The previous major stays servable.** Network serves each major it supports at
++ `/shared/v<major>/<file>`. It keeps the older major installed side by side
++ (`"openvibe-shared-v1": "<v1 tarball URL>"`) for at least 90 days after the next major ships,
++ and for as long as any consumer or published HTML still references it. That includes the
++ pre-rendered pages in `OpenVibe.Sites/dist/`.
++- **Unversioned paths never jump a major silently.** `/shared/<file>` keeps serving the major it
++ served before. It moves to a new major only after every consumer has either moved to it or
++ switched to an explicit `/shared/v<major>/` path. Because navbar.js loads `nav-icons.js` from
++ its own directory, a versioned navbar always gets the icons of its own release.
++- A new browser file is added to `files.js` in the same release, so sites that serve from
++ `node_modules` pick it up.
++
++## Charter
++
++The canonical home of what was `OpenVibe.Network/packages/openvibe-shared` (navbar, footer,
++themes, icons, legal pages, SEO helpers, notifications UI, panels). It is published as
++immutable versioned artifacts with a CDN-compatible mirror, instead of being vendored or
++rsynced from Network.
++
++**Owns**
- `@openvibe/tokens|ui|chrome|auth-ui|seo|legal|icons|release-client|web-runtime|server-web|testing-web`
++ (today still one package, `openvibe-shared`)
- design tokens and theme rendering
- release manifest client (active-tab update coordinator)
--## Does not own
--
++**Does not own**
- theme *authority* (OpenVibe.Network owns the theme API and preferences)
- identity
--## Planned surfaces
--
--- vanilla-JS-compatible build kept; no framework rewrite required
--- pinned versions, selective entry points, content-addressed browser assets
--- local/mirrored serving so a Network outage does not blank every page
--
--## Data (authority tables / families)
--
--- none (artifact repository)
--
--## Capabilities and events
--
--- package APIs only
++**Planned surfaces**
++- a vanilla-JS-compatible build (no framework rewrite required)
++- pinned versions, selective entry points and content-addressed browser assets
++- local/mirrored serving, so a Network outage doesn't blank every page
--Events: `release manifest notifications (client side)`
--
--## Depends on
--
--- OpenVibe.Contracts (release manifest contract)
--
--## Acceptance (must be true before "done")
++**Data:** none (artifact repository). **Capabilities and events:** package APIs only; release
++manifest notifications (client side). **Depends on:** OpenVibe.Contracts (release manifest
++contract).
++**Acceptance (must be true before "done")**
- Live, Community, Media, Tools and Sites consume pinned versions with no manual copies
- old `openvibe.network/shared/*` URLs keep serving version-pinned mirrors during migration
--## Bootstrap / extraction source
--
--Extract `packages/openvibe-shared` from OpenVibers/OpenVibe.Network; Network then consumes it like every other product.
--
--## Launch rule
--
--This repository does not make the product real, and the domain keeps its placeholder page on
--[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the
--following exist here (plan §12.12):
--
--1. an owning runtime with health/readiness endpoints and observability;
--2. canonical identity/auth integration (OpenVibe.Network subjects, scoped service principals);
--3. server-rendered or static public routes that are useful without JavaScript;
--4. real persistence and end-to-end workflows;
--5. capability and event registration against `OpenVibe.Contracts`;
--6. a migration/seed strategy, a security/threat review, and sitemap/robots/feed behaviour;
--7. acceptance tests proving the advertised functionality.
--
--The launch release removes the domain from `OpenVibe.Sites/sites.json`, switches routing and
--registers maturity in the ecosystem registry atomically. A placeholder is never counted as an
--implemented service.
--
---
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
ddiff --git a/STATUS.json b/STATUS.json
iindex 803f4f3..1c37671 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -1,7 +1,10 @@
{
"repository": "OpenVibers/OpenVibe.Shared",
-- "stage": "placeholder",
-- "code": false,
++ "stage": "alpha",
++ "code": true,
++ "wave": "W2",
++ "package": "openvibe-shared",
++ "version": "1.0.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
-- "updated": "2026-09-21"
++ "updated": "2026-09-22"
}
ddiff --git a/docs/divergence-2026-09-22.md b/docs/divergence-2026-09-22.md
nnew file mode 100644
iindex 0000000..29584e2
---- /dev/null
++++ b/docs/divergence-2026-09-22.md
@@@ -0,0 +1,95 @@
++# Vendored-copy divergence audit — 2026-09-22
++
++Before `openvibe-shared` moved into this repository, every vendored copy was compared with the
++canonical package, `OpenVibe.Network/packages/openvibe-shared` at Network commit **`f337eb6`**.
++Comparisons use the **committed** trees (`git archive HEAD`), not working trees, because some
++working trees carry CRLF line endings that git normalises away (see Live below).
++
++**Result: no consumer holds a fix that canonical lacks.** Every difference is a copy that fell
++behind canonical, or a line-ending artefact. Canonical was imported unchanged (except navbar.js,
++per CHANGELOG 1.0.0) and nothing needed folding back in.
++
++## Method
++
++1. `diff -r` of each consumer's committed `vendor/openvibe-shared` against canonical.
++2. For every divergent file, `git log -p -- vendor/openvibe-shared/<file>` in the consumer, and
++ `git log -- packages/openvibe-shared/<file>` in Network, to date both sides.
++3. To catch a local fix that a later re-sync might have overwritten: every blob ever committed
++ under `vendor/openvibe-shared` in Live, Media, Tools, Community and Sites was looked up among
++ the 116 blobs ever committed under `packages/openvibe-shared` in Network. A blob that never
++ existed in Network is a local edit.
++
++## Consumers at audit time
++
++| Repo | HEAD | Copy | Differences from canonical |
++|---|---|---|---|
++| OpenVibe.Live (seadragon worktree = main) | `e266060` | full, no tests | none in committed content (CRLF artefact below) |
++| OpenVibe.Media | `ddfefcf` | full, no tests | `ov-icons.js` (stale) |
++| OpenVibe.Tools | `fd5d318` | full, no tests | `ov-icons.js` (stale) |
++| OpenVibe.Community | `d7c6e60` | full, partial tests | `ov-icons.js` (stale), `test/navbar-brand.test.js` (stale) |
++| OpenVibe.Sites | `185014f` | 5 files | none: `app-icon`, `chrome-ssr`, `footer`, `legal`, `seo` are byte-identical |
++
++The vendored copies that lack `test/` and `scripts/build-nav-icons.py` are simply packaged
++differently. None of those files ships to a browser or is required at runtime.
++
++## Decisions
++
++### Live: `vendor/openvibe-shared/user-card.js`: canonical (no content change)
++
++- The reported divergence is **line endings only**. `diff --strip-trailing-cr` is empty, and the
++ committed blob in Live equals canonical's.
++- Network's working tree has CRLF in five files (`analytics.js`, `auth-client.js`, `index.js`,
++ `middleware.js`, `user-card.js`), and the seadragon worktree has CRLF in `user-card.js`. Their
++ index copies are LF (`git ls-files --eol` shows `i/lf w/crlf`, `core.autocrlf=input`). A
++ working-tree diff between a CRLF checkout and an LF checkout therefore flags the file.
++- History: Live `e371ed1` (2026-09-18, "…vendor sync") re-synced it. It changed the banner
++ fallback accent from `#8b5cf6` to `#3b82f6`, which canonical already had. Before that, Live had
++ only the initial import `ac0d6a4`.
++- Prevention: this repository ships `.gitattributes` with `* text=auto eol=lf`, and the package
++ is installed from a tarball, so no working-tree line endings are involved.
++
++### Media, Tools, Community: `vendor/openvibe-shared/ov-icons.js`: canonical
++
++- The three copies are identical to each other (md5 `8c50f1d5…`). Each equals canonical as of
++ Network `6ce1ef4` (2026-09-21 09:57, "Ring icons: solid Font Awesome glyphs inside the OV
++ ring"). They were synced one minute later: Media `6114bc6`, Tools `0346edc`, Community `de947ea`.
++- Canonical then got Network **`71be248`** (2026-09-21 11:57, "Ring icons: fix solid glyphs
++ rendering off-centre (transform-origin pivot bug)"). That commit moves the position and scale
++ `transform` from the classed `.ovi-glyph` element onto a plain inner `<g>`. The consumers
++ never re-synced after it.
++- None of the three consumer histories edits the file outside those syncs. `git log -p` shows
++ only `vendor sync` or `…; vendor sync` commits.
++- Canonical is the fix, and the consumers carry the bug. It matters where they
++ `require('openvibe-shared/icons')` on the server. Tools does this in
++ `apps/gateway/server/pages/site.js`, so Tools' server-rendered solid ring glyphs
++ (`icons.use()` / `icons.sprite()`) can render off-centre until Tools moves to v1.0.0.
++ Browsers load `ov-icons.js` from `openvibe.network/shared/`, which is already fixed.
++
++### Community: `vendor/openvibe-shared/test/navbar-brand.test.js`: canonical
++
++- Community's copy comes from `ff4a6b6` (2026-09-17 20:10, "Re-sync vendored openvibe-shared").
++ Canonical changed afterwards in `68597ec` and `959bcb3` (2026-09-18): links became
++ `class="ovnav-link …"`, the mobile drawer arrived and icons became inline SVG.
++- Its old assertions (`class="active"`, `fa-plus icon`) would fail against the navbar Community
++ actually ships. It never runs, because Community's `test/run.js` reads only `test/`. Its copy
++ also lacks `panels.test.js` and `shared-modules.test.js`.
++- The copy is stale, with nothing to fold in. Tests now live here only.
++
++### Local-edit scan (method step 3)
++
++The scan found one local-only blob. Community `66c11ab` (2026-09-17 20:09, "Scaffold the
++Community app…") vendored a `navbar.js` that never existed in Network. It is 7 lines away from
++Network `d8b7f65`, committed at 20:08 the same evening. Community copied the Network working
++tree about a minute before that commit. `d8b7f65` then added `apiBase` to the history calls and
++guarded `nav.setAttribute`.
++
++That snapshot is therefore an older draft of canonical, not a fix. Community's next syncs
++(`f4533f4`, `ee2f49d`, `b9cb474`, `de947ea`) replaced it with Network blobs. No other vendored
++blob in any consumer's history is missing from Network's history.
++
++## Follow-ups
++
++- Tools' server-rendered solid ring glyphs can stay off-centre until Tools installs v1.0.0
++ (`docs/migration-plan.md`).
++- Once every consumer pins this package, the `vendor/openvibe-shared` directories are deleted,
++ and this class of drift can't recur.
ddiff --git a/docs/migration-plan.md b/docs/migration-plan.md
nnew file mode 100644
iindex 0000000..c9888fc
---- /dev/null
++++ b/docs/migration-plan.md
@@@ -0,0 +1,627 @@
++# Migration plan: consumers move to the pinned OpenVibe.Shared release
++
++Status: **prepared, not applied** (2026-09-22). Nothing here has been committed in the consumer
++repos. The lead applies it after tagging `v1.0.0` in this repo.
++
++Every consumer replaces its `file:` dependency on a vendored copy with the release tarball:
++
++```json
++"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0"
++```
++
++It also deletes its `vendor/openvibe-shared` copy. Code that read that directory by path now
++goes through `require('openvibe-shared/files')`, which lists the package's browser files and
++where they are.
++
++## Order and prerequisites
++
++1. **Tag `v1.0.0`** in OpenVibe.Shared and push it. The repo must be public, because codeload
++ serves private repos only with a token. Check that
++ `curl -fsSLI https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0`
++ returns 200.
++2. **Network first.** It serves `https://openvibe.network/shared/*` to Media, Tools, Community
++ and Sites pages. Once it deploys, those pages get the v1.0.0 navbar and footer, plus the new
++ lazily fetched `nav-icons.js` next to them, with no change in those repos.
++3. **Live.** It serves `/shared/*` from its own origin, and its navbar needs `nav-icons.js`
++ served beside `navbar.js`. Until Live's allowlist includes that file, lazy icons 404 and fall
++ back to `<i class="fa-solid">`. Live loads Font Awesome, so the icons still show.
++4. **Community, Media, Tools** in any order. Their browsers already load from the Network. Their
++ servers only `require()` Node modules: legal, footer SSR, app-icon, auth-client, analytics
++ and icons.
++5. **Sites**, optionally. It has no `package.json` today.
++
++Common steps for each npm consumer:
++
++```bash
++rm -rf node_modules/openvibe-shared # today a symlink into vendor/
++git rm -r -q vendor/openvibe-shared # Network: packages/openvibe-shared
++# edit package.json (diff below), then:
++npm install --no-audit --no-fund # rewrites package-lock.json
++npm test
++git grep -n 'vendor/openvibe-shared' # must print nothing but docs you chose to keep
++```
++
++The lockfile then holds a single entry:
++
++```json
++"node_modules/openvibe-shared": {
++ "version": "1.0.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0",
++ "integrity": "sha512-…",
++ "license": "MIT",
++ "dependencies": { "jsonwebtoken": "^9.0.0" }
++}
++```
++
++The `"vendor/openvibe-shared": {…}` workspace entry and the `"link": true` entry disappear.
++
++On the hosts, `npm ci --omit=dev` (or `npm install` where there is no lockfile) replaces any
++stale real-directory copy in `node_modules/openvibe-shared`. The "node_modules is a stale COPY"
++comments stop applying.
++
++The references below come from `git grep -n -E "vendor/openvibe-shared|packages/openvibe-shared|'vendor', 'openvibe-shared'|'packages', 'openvibe-shared'"`
++over tracked files, run at the HEADs listed in `docs/divergence-2026-09-22.md`. Network's
++generated `docs/roadmap-baseline/*` is left out (270 hits in `inventory.json`), because it is
++regenerated.
++
++---
++
++## OpenVibe.Network
++
++### References
++
++```
++README.md:265 docs: "The `packages/openvibe-shared/` directory provides…"
++docs/platform-plan.md:34-35 docs
++docs/shared-contracts.md:3,5,177 docs
++package.json:9 test script runs 7 packages/openvibe-shared/test/* files
++package.json:27 "openvibe-shared": "file:./packages/openvibe-shared",
++package-lock.json:24,1483,2099 lockfile entries
++scripts/roadmap-baseline/generate.js:30,37,97,133,500
++server/index.js:607 res.sendFile(path.resolve(__dirname, '..', 'packages', 'openvibe-shared', 'openvibe-sw.js'))
++server/index.js:612 const sharedPath = path.resolve(__dirname, '..', 'packages', 'openvibe-shared');
++deploy/nginx/openvibe.network.conf:80-86 location /shared/ { … add_header Cache-Control "public, max-age=300, …"; }
++server/deploy/nginx-generator.js:33-36 { match: '/shared/', cacheTime: '1h' }
++```
++
++The `server/…` requires that use `require('openvibe-shared/...')` need no change:
++`server/index.js:22,28,426`, `server/admin/routes.js:16`, `server/db/database.js:698`,
++`server/deploy/routes.js:12`, `server/home/render.js:8,9,80`,
++`server/notifications/notification-service.js:10`, `server/setup/routes.js:6` and
++`server/url-registry.js:3`.
++
++### Steps
++
++1. `git rm -r packages/openvibe-shared`. From now on, changes land in OpenVibe.Shared and ship
++ as a release.
++2. Edit `package.json`:
++
++```diff
++- "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node packages/openvibe-shared/test/shared-modules.test.js && node test/chrome.test.js && node packages/openvibe-shared/test/panels.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/registry-ecosystem.test.js && node test/service-principal.test.js",
+++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/chrome.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/registry-ecosystem.test.js && node test/service-principal.test.js",
++@@
++- "openvibe-shared": "file:./packages/openvibe-shared",
+++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0",
++```
++
++ The package's own tests now run in OpenVibe.Shared's CI.
++
++3. Run `npm install`. It regenerates `package-lock.json`.
++4. Update `server/index.js`, currently lines 601–621. `/shared/*` now serves from
++ `node_modules/openvibe-shared`, through an allowlist, with a versioned alias:
++
++```diff
++-// Web-push service worker: must be served from THIS origin (scope /), so each site
++-// exposes the shared worker at /openvibe-sw.js rather than loading it from Network.
++-app.get('/openvibe-sw.js', (req, res) => {
++- res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
++- res.setHeader('Service-Worker-Allowed', '/');
++- res.setHeader('Cache-Control', 'no-cache');
++- res.sendFile(path.resolve(__dirname, '..', 'packages', 'openvibe-shared', 'openvibe-sw.js'));
++-});
++-
++-// Serve openvibe-shared client-side libs (notification-ui.js, navbar.js, etc.)
++-// Accessible at https://openvibe.network/shared/notification-ui.js etc.
++-const sharedPath = path.resolve(__dirname, '..', 'packages', 'openvibe-shared');
++-app.use('/shared', express.static(sharedPath, {
++- setHeaders(res, filePath) {
++- res.setHeader('Access-Control-Allow-Origin', '*');
++- // Override helmet's same-origin policies so other domains can load these scripts
++- res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
++- res.setHeader('Cross-Origin-Opener-Policy', 'unsafe-none');
++- res.setHeader('Cache-Control', 'public, max-age=300');
++- },
++-}));
+++// openvibe-shared: the OpenVibe.Shared release package.json pins, from node_modules.
+++const sharedFiles = require('openvibe-shared/files');
+++
+++// Web-push service worker: must be served from THIS origin (scope /), so each site
+++// exposes the shared worker at /openvibe-sw.js rather than loading it from Network.
+++app.get('/openvibe-sw.js', (req, res) => {
+++ res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
+++ res.setHeader('Service-Worker-Allowed', '/');
+++ res.setHeader('Cache-Control', 'no-cache');
+++ res.sendFile(sharedFiles.path('openvibe-sw.js'));
+++});
+++
+++// Serve openvibe-shared client-side libs (notification-ui.js, navbar.js, etc.) to every site at
+++// https://openvibe.network/shared/<file>, and the same release at /shared/v1/<file> (OpenVibe.Shared
+++// README, compatibility policy). Only the package's browser files: its server modules, tests and
+++// scripts are not served. A ?v= equal to the file's content hash (navbar.js asks for nav-icons.js
+++// that way) is cacheable forever; anything else gets five minutes.
+++const sharedRev = new Map();
+++function serveShared(req, res, next) {
+++ const name = req.path.slice(1);
+++ if (!sharedFiles.isBrowserFile(name)) return next();
+++ if (!sharedRev.has(name)) sharedRev.set(name, require('crypto').createHash('sha256').update(fs.readFileSync(sharedFiles.path(name))).digest('hex').slice(0, 12));
+++ res.setHeader('Access-Control-Allow-Origin', '*');
+++ // Override helmet's same-origin policies so other domains can load these scripts
+++ res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
+++ res.setHeader('Cross-Origin-Opener-Policy', 'unsafe-none');
+++ res.setHeader('Cache-Control', req.query.v === sharedRev.get(name) ? 'public, max-age=31536000, immutable' : 'public, max-age=300, stale-while-revalidate=60');
+++ res.sendFile(sharedFiles.path(name));
+++}
+++app.use('/shared/v1', serveShared);
+++app.use('/shared', serveShared);
++```
++
++ Behaviour changes here, all intended. `/shared/package.json`, `/shared/test/*`,
++ `/shared/scripts/*` and the Node modules (`/shared/auth-client.js`, `/shared/middleware.js`, …)
++ stop being public. Every file any site loads is in the allowlist: `navbar`, `theme-loader`,
++ `footer`, `notification-ui`, `account-switcher`, `ov-mark`, `ov-icons`, `history`,
++ `sso-client`, `panels`, `ui`, `island`, and `user-card` from the README example.
++
++ The hash map is per process. The package only changes through a deploy, which restarts the
++ process.
++
++5. Update `deploy/nginx/openvibe.network.conf`. Remove the proxy's own `Cache-Control`. With
++ `add_header`, nginx sends a second `Cache-Control` next to the app's, and the immutable one
++ would be ignored:
++
++```diff
++ location /shared/ {
++ proxy_pass http://127.0.0.1:4000;
++ proxy_http_version 1.1;
++ proxy_set_header Host $host;
++- # Shared browser modules are loaded by every OpenVibe site from here. Five minutes at the
++- # edge: a navbar/footer change reaches the whole network within minutes instead of an hour.
++- add_header Cache-Control "public, max-age=300, stale-while-revalidate=60";
+++ # Shared browser modules are loaded by every OpenVibe site from here. The app sets
+++ # Cache-Control: five minutes (+60s stale) by default, a year for a ?v= content-hash URL.
++ }
++```
++
++ Also update `server/deploy/nginx-generator.js:33-36`: drop `cacheTime: '1h'` from
++ `{ match: '/shared/' }`, because `expires 1h` adds a conflicting header as well. Re-run
++ `node test/nginx-generator-live.test.js`, and adjust it if it asserts that `cacheTime`.
++
++6. Update `scripts/roadmap-baseline/generate.js`. The canonical package now lives in the
++ sibling repo, and consumers carry it in `node_modules`:
++
++```diff
++- { name: 'OpenVibe.Network', service: 'network', scan: ['server', 'packages/openvibe-shared'] },
+++ { name: 'OpenVibe.Network', service: 'network', scan: ['server'] },
+++ { name: 'OpenVibe.Shared', service: 'shared', scan: ['.'] },
++@@
++-const CANONICAL_SHARED = path.join(NETWORK_DIR, 'packages/openvibe-shared');
+++const CANONICAL_SHARED = path.join(ROOT, 'OpenVibe.Shared');
++```
++
++ At line 97, "Tables created by the vendored shared package", read
++ `path.join(dir, 'node_modules/openvibe-shared')` when `vendor/openvibe-shared` is absent. At
++ line 133, "Vendored shared-package drift", report each repo's pinned tag, read from
++ `package.json` `dependencies['openvibe-shared']`, instead of hashing vendored files. Then
++ regenerate `docs/roadmap-baseline/` and run `node test/roadmap-baseline.test.js`.
++7. Update the docs. In `README.md:263-282` ("Shared Client Libraries"), change "The
++ `packages/openvibe-shared/` directory provides…" to "The `openvibe-shared` package
++ ([OpenVibers/OpenVibe.Shared](https://github.com/OpenVibers/OpenVibe.Shared), pinned in
++ package.json) provides… served at `/shared/` (and `/shared/v1/`)". Add `nav-icons.js` to the
++ table ("navbar glyphs, loaded by navbar.js"). In `docs/platform-plan.md:34-35` and
++ `docs/shared-contracts.md:3,5`, replace "lives in `OpenVibe.Network/packages/openvibe-shared`
++ … vendored (rsync, never edited)" with "lives in OpenVibe.Shared, consumed as a pinned
++ release tarball". In `docs/shared-contracts.md:177`, the path becomes
++ `node scripts/measure-icons.js` (in OpenVibe.Shared).
++8. Verify:
++ - `curl -sI https://openvibe.network/shared/navbar.js` returns 200 with `max-age=300`.
++ - `curl -sI "https://openvibe.network/shared/nav-icons.js?v=$(node -e "console.log(require('crypto').createHash('sha256').update(require('fs').readFileSync(require('openvibe-shared/files').path('nav-icons.js'))).digest('hex').slice(0,12))")"`
++ returns 200 with `immutable`.
++ - `curl -sI https://openvibe.network/shared/v1/footer.js` returns 200.
++ - `curl -sI https://openvibe.network/shared/auth-client.js` returns 404, or the SPA fallback.
++ - A Media, Tools or Community page shows the navbar icons, and DevTools shows
++ `nav-icons.js?v=…` loaded after first paint.
++
++---
++
++## OpenVibe.Live
++
++### References
++
++```
++package.json:47 "openvibe-shared": "file:./vendor/openvibe-shared",
++package-lock.json:29,2832,3779 lockfile entries
++server/index.js:446 () => path.resolve(__dirname, '../vendor/openvibe-shared'),
++server/index.js:449-450 OpenVibeApp/packages fallbacks
++scripts/perf/check-budgets.js:32,36 budget check reads vendor/ by path
++test/features-registry.test.js:24 fileFor() reads vendor/ by path
++public/js/ov-navbar-live.js:1 comment
++public/js/app.js:1483 comment
++AGENTS.md:59 README.md:21 SETUP.md:14,30 docs/architecture.md:12 SECURITY_AUDIT.md:98 docs
++.gitignore:31-32 leftover "public/shared/" (nothing writes it any more)
++```
++
++`server/index.js:124` (`require('openvibe-shared/analytics')`) and `server/config.js:9`
++(`require('openvibe-shared/url-resolver')`) need no change.
++
++### Steps
++
++1. Run `rm -rf node_modules/openvibe-shared && git rm -r vendor/openvibe-shared`.
++2. Edit `package.json`, then run `npm install`:
++
++```diff
++- "openvibe-shared": "file:./vendor/openvibe-shared",
+++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0",
++```
++
++3. Update `server/index.js`, currently lines 434–505. The package lists its own browser files,
++ and that list includes the new `nav-icons.js`. The server serves them from
++ `node_modules/openvibe-shared` with no candidate search:
++
++```diff
++ // ── Static Files ─────────────────────────────────────────────
++-// Serve openvibe-shared browser assets at /shared/ — serve directly from the
++-// resolved source directory to avoid write operations (the public/ directory
++-// is read-only under systemd ProtectSystem=strict on production).
++-const SHARED_BROWSER_FILES = ['theme-loader.js', 'notification-ui.js', 'account-switcher.js', 'openvibe-sw.js', 'footer.js', 'ov-mark.js', 'tooltip.js', 'history.js', 'navbar.js', 'sso-client.js', 'ov-icons.js', 'island.js', 'ui.js', 'panels.js'];
++-let sharedServePath = null;
++-
++-(function resolveSharedAssets() {
++- const candidates = [
++- // The vendored copy is the source of truth (synced from OpenVibe.Network). On the
++- // server node_modules/openvibe-shared is a stale COPY, not a symlink, so it must
++- // never win over vendor/.
++- () => path.resolve(__dirname, '../vendor/openvibe-shared'),
++- () => path.dirname(require.resolve('openvibe-shared/package.json')),
++- () => path.resolve(__dirname, '../node_modules/openvibe-shared'),
++- () => path.resolve(__dirname, '..', '..', 'OpenVibeApp', 'packages', 'openvibe-shared'),
++- () => path.resolve(__dirname, '..', '..', 'packages', 'openvibe-shared'),
++- ];
++- for (const getPath of candidates) {
++- try {
++- const p = getPath();
++- if (p && fs.existsSync(p)) {
++- // Verify at least one required browser file exists
++- const found = SHARED_BROWSER_FILES.filter(f => fs.existsSync(path.join(p, f)));
++- if (found.length > 0) {
++- sharedServePath = p;
++- assets.setSharedDir(p);
++- console.log(`[Server] /shared: serving ${found.length}/${SHARED_BROWSER_FILES.length} browser file(s) from ${p}`);
++- const missing = SHARED_BROWSER_FILES.filter(f => !found.includes(f));
++- if (missing.length) console.warn(`[Server] /shared: missing files: ${missing.join(', ')}`);
++- return;
++- }
++- }
++- } catch (_) {}
++- }
++- console.error('[Server] /shared: openvibe-shared source not found — shared browser assets will return 404');
++-})();
++-
++-if (sharedServePath) {
+++// Serve openvibe-shared browser assets at /shared/ — straight from node_modules/openvibe-shared,
+++// the OpenVibe.Shared release package.json pins (the public/ directory is read-only under
+++// systemd ProtectSystem=strict on production). The package lists its own browser files.
+++const sharedFiles = require('openvibe-shared/files');
+++const SHARED_BROWSER_FILES = sharedFiles.BROWSER;
+++const sharedServePath = sharedFiles.dir;
+++assets.setSharedDir(sharedServePath);
+++console.log(`[Server] /shared: serving ${SHARED_BROWSER_FILES.length} browser file(s) from ${sharedServePath}`);
+++
+++{
++ // Web-push service worker must be same-origin with scope "/" → expose it at the root.
++ app.get('/openvibe-sw.js', (req, res) => {
++@@ (lines 474-499 unchanged: /openvibe-sw.js, whitelist, ?v= immutable, sendFile)
++-} else {
++- console.error('[Server] /shared: serving unavailable — shared browser assets will return 404');
++- app.use('/shared', (req, res) => {
++- res.status(404).type('text/plain').send('Shared assets unavailable');
++- });
++ }
++```
++
++ The unchanged middle already makes `nav-icons.js?v=<hash>` immutable, because
++ `req.query.v === assets.hashOf('/shared/nav-icons.js')`. navbar.js asks for exactly that
++ hash (`NAV_ICONS_REV`, sha256 truncated to 12 hex characters, the same as `assets.js`).
++ `user-card.js` is now served too. It is public on the Network already.
++
++4. Update `scripts/perf/check-budgets.js`:
++
++```diff
++ const assets = require(path.join(ROOT, 'server/web/assets'));
++-try { assets.setSharedDir(path.join(ROOT, 'vendor/openvibe-shared')); } catch { /* */ }
+++const SHARED_DIR = require('openvibe-shared/files').dir;
+++try { assets.setSharedDir(SHARED_DIR); } catch { /* */ }
++ const html = assets.renderRoute(assets.document('index.html').html, '/');
++ const fileFor = (url) => {
++ const p = url.split('?')[0];
++- return p.startsWith('/shared/') ? path.join(ROOT, 'vendor/openvibe-shared', path.basename(p)) : path.join(ROOT, 'public', p);
+++ return p.startsWith('/shared/') ? path.join(SHARED_DIR, path.basename(p)) : path.join(ROOT, 'public', p);
++ };
++```
++
++ Expected after v1.0.0: `eagerJsBrotliKB` goes from 263.8 (failing) to **252.4 / 260**, and
++ `eagerJsRawKB` from 1147.1 to 1113.9. This was measured with the v1.0.0 `navbar.js`
++ substituted into the budget script.
++
++5. Update `test/features-registry.test.js:24`:
++
++```diff
++-const fileFor = (url) => url.startsWith('/shared/') ? path.join(ROOT, 'vendor/openvibe-shared', path.basename(url)) : path.join(PUB, url);
+++const fileFor = (url) => url.startsWith('/shared/') ? require('openvibe-shared/files').path(path.basename(url)) : path.join(PUB, url);
++```
++
++ `files.path()` throws for a non-browser file. That is stricter than before, and correct,
++ because a feature must not reference one.
++
++6. Update comments and docs:
++ - `public/js/ov-navbar-live.js:1`: "(vendor/openvibe-shared/navbar.js, served at
++ /shared/navbar.js)" becomes "(openvibe-shared/navbar.js from OpenVibe.Shared, served at
++ /shared/navbar.js)".
++ - `public/js/app.js:1483`: "(vendor/openvibe-shared/panels.js" becomes
++ "(openvibe-shared/panels.js".
++ - `AGENTS.md:59`: "**openvibe-shared:** Pinned release of OpenVibers/OpenVibe.Shared
++ (`"openvibe-shared": "https://codeload…/refs/tags/vX.Y.Z"`), served at `/shared/*` from
++ `node_modules`. Change it there and bump the tag; never edit `node_modules`."
++ - `README.md:21`, `SETUP.md:14,30` and `docs/architecture.md:12`: same wording.
++ - `SECURITY_AUDIT.md:98`: `vendor/openvibe-shared/analytics.js` becomes
++ `openvibe-shared/analytics.js`, with remediation "change in OpenVibe.Shared, release, bump
++ the pin".
++ - `.gitignore:31-32`: delete the leftover `public/shared/` lines. This is optional.
++7. Deploy. `deploy/scripts/deploy.sh` treats `package.json` and `package-lock.json` as
++ "deps + server", so the release runs `npm ci --omit=dev`, which installs the tarball, and
++ then restarts. A restart drops live RTMP publishers, so check `/api/streams` first and use
++ `deploy.sh --wait-idle`.
++8. Verify:
++ - `npm test` passes, including `perf:budget`.
++ - `curl -sI https://openvibe.live/shared/nav-icons.js?v=<hash>` returns an immutable 200.
++ - The home page's navbar icons render. `nav-icons.js` loads after first paint.
++
++---
++
++## OpenVibe.Community
++
++### References
++
++```
++package.json:25 "openvibe-shared": "file:./vendor/openvibe-shared"
++package-lock.json:21,1075,1567 lockfile entries
++README.md:203 "vendor/openvibe-shared unmodified copy of OpenVibe.Network/packages/openvibe-shared"
++```
++
++Browsers load `https://openvibe.network/shared/{theme-loader,navbar,footer}.js` absolutely (the
++`NETWORK_URL` constant at `server/render/layout.js:16`, used at lines 120–125). Server requires
++are `server/app.js:82` (legal), `server/auth/routes.js:37` (auth-client) and
++`server/render/layout.js:118,133` (app-icon, footer). Neither needs a change.
++
++The vendored copy's tracked `test/` files never ran and are deleted with it.
++
++### Steps
++
++1. Run `rm -rf node_modules/openvibe-shared && git rm -r vendor/openvibe-shared`.
++2. Edit `package.json`, then run `npm install`:
++
++```diff
++- "openvibe-shared": "file:./vendor/openvibe-shared"
+++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0"
++```
++
++3. `README.md:203`: the line becomes
++ `node_modules/openvibe-shared pinned OpenVibe.Shared release (package.json); never edit`.
++ README line 173 ("serve the current `openvibe-shared`…") still reads correctly.
++4. `npm test`. `test/ssr.test.js:19-21` asserts the absolute Network URLs, which are unchanged.
++ Community's CI (`.github/workflows/ci.yml`, `npm ci`) fetches the tarball from codeload.
++5. There is no deploy script. On the host, `git pull && npm ci --omit=dev && systemctl restart openvibe-community`.
++
++---
++
++## OpenVibe.Media
++
++### References
++
++```
++package.json:22 "openvibe-shared": "file:./vendor/openvibe-shared",
++package-lock.json:20,1973,2587 lockfile entries
++README.md:42 "vendor/openvibe-shared/ vendored shared helpers (do not edit; re-sync from canonical)"
++```
++
++Browsers load `${NETWORK_URL}/shared/{theme-loader,navbar,footer}.js`
++(`server/public/page-chrome.js:14,82,146-147`) and
++`https://openvibe.network/shared/theme-loader.js` (`server/public/browse.js:240`). Server
++requires are `server/index.js:42`, `server/user-auth.js:37`,
++`server/public/browse.js:269,271,276` and `server/public/page-chrome.js:81,187,191`. None of them
++needs a change.
++
++### Steps
++
++1. Run `rm -rf node_modules/openvibe-shared && git rm -r vendor/openvibe-shared`.
++2. Edit `package.json`, then run `npm install`:
++
++```diff
++- "openvibe-shared": "file:./vendor/openvibe-shared",
+++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0",
++```
++
++3. `README.md:42`: the line becomes
++ `node_modules/openvibe-shared/ pinned OpenVibe.Shared release (package.json); never edit`.
++4. `npm test`. `test/public-pages.test.js:40,42` asserts the absolute Network URLs, which are
++ unchanged.
++5. Deploy: pull, `npm ci --omit=dev`, restart. This picks up the ov-icons centring fix from
++ `docs/divergence-2026-09-22.md` for server-side icons.
++
++---
++
++## OpenVibe.Tools
++
++Tools has no root dependency. Each app under `apps/*` has its own `package.json`, and
++`deploy/scripts/deploy.sh` rsyncs `vendor/openvibe-shared` into every app's
++`node_modules/openvibe-shared`, because npm copies `file:` dependencies on the host. After the
++change, each app installs its own copy of the tarball, and npm's cache means it is downloaded
++once. There is no rsync and no symlink.
++
++### References
++
++```
++package.json:7 "install:all": "npm --prefix vendor/openvibe-shared install && npm --prefix apps/gateway install && …"
++deploy/scripts/deploy.sh:2-3,11 header comment + rsync of vendor/openvibe-shared into each app
++apps/gateway/server/index.js:282 res.sendFile(path.resolve(__dirname, '..', '..', '..', 'vendor', 'openvibe-shared', 'openvibe-sw.js'));
++apps/audio/package.json:21 "openvibe-shared": "file:../../vendor/openvibe-shared",
++apps/docs/package.json:20 "openvibe-shared": "file:../../vendor/openvibe-shared",
++apps/food/package.json:19 "openvibe-shared": "file:../../vendor/openvibe-shared"
++apps/gateway/package.json:20 "openvibe-shared": "file:../../vendor/openvibe-shared"
++apps/img/package.json:20 "openvibe-shared": "file:../../vendor/openvibe-shared",
++apps/maps/package.json:14 "openvibe-shared": "file:../../vendor/openvibe-shared",
++apps/text/package.json:19 "openvibe-shared": "file:../../vendor/openvibe-shared"
++apps/yt/package.json:20 "openvibe-shared": "file:../../vendor/openvibe-shared",
++apps/gateway/package-lock.json:19,22,752 lockfile entries
++apps/maps/package-lock.json:22,25,1287 lockfile entries
++apps/text/package-lock.json:19,22,873 lockfile entries
++README.md:26 "└── openvibe-shared # vendored shared package (canonical copy lives in OpenVibe.Network)"
++apps/audio/README.md:34,52 "openvibe-shared package (vendored at `vendor/openvibe-shared`)"
++```
++
++Browser pages load `https://openvibe.network/shared/*` absolutely: `apps/*/public/index.html`,
++gateway `public/{audio,img,dev,net}.html`, `server/pages/site.js:120`, and 31
++`apps/text/public/*.html` pages. They need no change. Server requires such as
++`openvibe-shared/analytics` in every app, and gateway's brand, legal, auth-client, app-icon,
++icons, seo and footer, need no change either.
++
++### Steps
++
++1. `git rm -r vendor/openvibe-shared`. The untracked `vendor/openvibe-shared/node_modules` left
++ by `install:all` goes too: `rm -rf vendor`.
++2. In each of the 8 app `package.json` files, keep the trailing comma where the file has one:
++
++```diff
++- "openvibe-shared": "file:../../vendor/openvibe-shared",
+++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0",
++```
++
++ Then run
++ `for a in apps/*/; do [ -f "$a/package.json" ] && (cd "$a" && rm -rf node_modules/openvibe-shared && npm install --no-audit --no-fund); done`.
++ That regenerates the three tracked lockfiles (gateway, maps and text).
++3. Update the root `package.json:7`:
++
++```diff
++- "install:all": "npm --prefix vendor/openvibe-shared install && npm --prefix apps/gateway install && npm --prefix apps/maps install && …",
+++ "install:all": "npm --prefix apps/gateway install && npm --prefix apps/maps install && …",
++```
++
++ Only the leading `npm --prefix vendor/openvibe-shared install && ` is removed.
++
++4. Update `deploy/scripts/deploy.sh`:
++
++```diff
++ #!/usr/bin/env bash
++-# Deploy OpenVibe.Tools on the host: pull, refresh the vendored shared package inside each app's
++-# node_modules (npm copies file: dependencies on this host, so a pull alone leaves them stale),
++-# install new dependencies when a package.json changed, restart the units, check health.
+++# Deploy OpenVibe.Tools on the host: pull, install dependencies in each app whose package.json
+++# changed (that includes a new openvibe-shared release tag), restart the units, check health.
++ set -euo pipefail
++ cd "$(dirname "$0")/../.."
++ BEFORE=$(git rev-parse HEAD); git pull -q --ff-only; AFTER=$(git rev-parse HEAD)
++ for app in apps/*/; do
++ [ -f "$app/package.json" ] || continue
++ if git diff --name-only "$BEFORE" "$AFTER" -- "$app/package.json" | grep -q . || [ ! -d "$app/node_modules" ]; then (cd "$app" && npm install --omit=dev --no-audit --no-fund --silent); fi
++- d="$app/node_modules/openvibe-shared"; [ -L "$d" ] || { mkdir -p "$d"; rsync -a --delete --exclude node_modules vendor/openvibe-shared/ "$d/"; }
++ done
++```
++
++ The first deploy after this change touches every app's `package.json`, so every app runs
++ `npm install`. That replaces the rsynced real-directory copy with the tarball. If an app's
++ `node_modules/openvibe-shared` is a leftover symlink into `vendor/`, delete it before that
++ deploy.
++
++5. Update `apps/gateway/server/index.js:282`:
++
++```diff
++- res.sendFile(path.resolve(__dirname, '..', '..', '..', 'vendor', 'openvibe-shared', 'openvibe-sw.js'));
+++ res.sendFile(require('openvibe-shared/files').path('openvibe-sw.js'));
++```
++
++6. Update the docs. `README.md:26` becomes `(openvibe-shared is a pinned OpenVibe.Shared
++ release in each app's package.json; no vendor/ copy)`, and `apps/audio/README.md:34,52`
++ becomes "openvibe-shared (pinned OpenVibe.Shared release)". `README.md:116` ("Browser pages
++ load shared JS absolutely from `https://openvibe.network/shared/`") stays true.
++7. Verify with `npm --prefix apps/gateway test` and `npm --prefix apps/yt test`. Then run
++ `node -e "require('openvibe-shared/icons')"` in each app directory. After deploy, check that
++ `/api/health` is healthy and that gateway ring icons are centred. That comes from the
++ ov-icons fix Tools lacked.
++
++---
++
++## OpenVibe.Sites (optional, no package.json today)
++
++### References
++
++```
++build.js:16 comment "Vendored from OpenVibe.Network/packages/openvibe-shared (copy, never edit)"
++build.js:17 const legal = require('./vendor/openvibe-shared/legal');
++build.js:63-64 require('./vendor/openvibe-shared/app-icon').headTags(…) / .CRITICAL
++build.js:193 require('./vendor/openvibe-shared/footer').ssr(…)
++build.js:210 require('./vendor/openvibe-shared/app-icon').manifest(…)
++vendor/openvibe-shared/{app-icon,chrome-ssr,footer,legal,seo}.js (5-file subset, byte-identical to v1.0.0)
++```
++
++### Steps
++
++1. Add `package.json`:
++
++```json
++{
++ "name": "openvibe-sites",
++ "private": true,
++ "description": "Placeholder and hub pages for the planned OpenVibe domains (static build)",
++ "scripts": { "build": "node build.js" },
++ "dependencies": {
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0"
++ }
++}
++```
++
++ Run `npm install` and commit `package-lock.json`. `.gitignore` already lists `node_modules/`.
++
++2. Update `build.js`:
++
++```diff
++-// Vendored from OpenVibe.Network/packages/openvibe-shared (copy, never edit): the network's legal documents.
++-const legal = require('./vendor/openvibe-shared/legal');
+++// The network's legal documents, from the pinned OpenVibe.Shared release (package.json).
+++const legal = require('openvibe-shared/legal');
++@@
++-${require('./vendor/openvibe-shared/app-icon').headTags({ site: 'network' }).split('\n')[0]}
++-${require('./vendor/openvibe-shared/app-icon').CRITICAL}
+++${require('openvibe-shared/app-icon').headTags({ site: 'network' }).split('\n')[0]}
+++${require('openvibe-shared/app-icon').CRITICAL}
++@@
++-${require('./vendor/openvibe-shared/footer').ssr({ service: 'network', variant: 'full' })}
+++${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'full' })}
++@@
++-function manifest(site) { return JSON.stringify(require('./vendor/openvibe-shared/app-icon').manifest({ …
+++function manifest(site) { return JSON.stringify(require('openvibe-shared/app-icon').manifest({ …
++```
++
++3. Run `git rm -r vendor/openvibe-shared`, then `node build.js`. `git status dist/` must show no
++ changes, because the five modules are byte-identical to v1.0.0.
++4. Update `deploy/scripts/deploy.sh`: add `npm ci --omit=dev --no-audit --no-fund` before
++ `node build.js`. The host needs outbound HTTPS to codeload.github.com.
++
++The published `dist/` HTML hard-codes `https://openvibe.network/shared/navbar.js`. That is one
++reason the Network keeps `/shared/<file>` on the v1 line (README, compatibility policy).
++
++---
++
++## After all consumers moved
++
++- `git grep -n 'vendor/openvibe-shared'` is empty in every repo, apart from historical docs.
++ `find ~/OpenVibers -path '*/vendor/openvibe-shared' -type d` finds nothing.
++- Every future shared change follows the same path: a PR to OpenVibe.Shared, a `vX.Y.Z` tag,
++ then a pin bump in Network first and in the other consumers after.