openvibers/openvibe.shared README.md

README.md

main 92.6 KB · 1256 lines · markdown

1# OpenVibe.Shared
2
3> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
4
5**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
6**Install and test:** `npm ci && npm test`
7
8**Status:** alpha, v4.241.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
9consumer installs a tagged release and none keeps a vendored copy
10([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
11the tag it pins.
12**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.3 and §16.6.
13**License:** MIT, as the package has always been: it is a client library other sites and developers embed (the OpenVibe services themselves are AGPL-3.0).
14
15This repository **is** the npm package `openvibe-shared`. `package.json` sits at the repository
16root, so GitHub's tarball of a release tag installs as the package with no build step. It was
17imported history-free from `OpenVibe.Network/packages/openvibe-shared` at Network `f337eb6`.
18[docs/divergence-2026-09-22.md](docs/divergence-2026-09-22.md) records how the vendored copies
19were reconciled first.
20
21## What's in it
22
23| Kind | Files |
24|---|---|
25| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `notification-live.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`, `player.js`, `ov-float.js` (the move engine behind a PiP or a panel: dock/float, tuck, size and the kept place), `vibes-pay.js`, `release-watch.js`, `release-update.js`, `web-runtime.js`, `boost.js`, `soft.js`, `openvibe-sw.js` |
26| Fonts, served at `/shared/<file>` (`files.FONTS`) | `ov-brand.woff2`: the brand face the names are set in (Nunito, SIL OFL 1.1, `OFL-Nunito.txt`; Latin, 700–900), declared by `navbar.js` |
27| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,pg,retention,event}`), `app-icon`, `glyphs` (the network's icons, one registry: `NAMES`, `path(name)`, `svg(name, { size, title })`, `fromFa(name)`, `FA`; the browser drop-in `ov-glyphs.js` shows them in place of Font Awesome's and keeps Font Awesome's classes, animations and `data-fa-transform`; also as fonts, `/shared/ov-glyphs.woff2` with `ov-glyphs-font.css` (`ovi ovi-<name>`) or `ov-glyphs-fa.css` (Font Awesome's names), `glyph-fonts/ov-glyphs.{ttf,otf}` and `glyph-codepoints.json`, and as the sprite `/shared/ov-glyphs.svg#ovg-<name>`), `mark` (the OpenVibe mark, the Pair, as static SVG: `svg({ form, tone, wide, size, title })`, `art()`, `geo()`, `shapes()`, `COLORS`; the browser drop-in `ov-mark.js` draws the same numbers), `app-icons` (every site's installable app icon set: `has(service)`, `url(service, size, { maskable })`, `isIcon(name)`, `path(name)`; the pre-rendered PNGs are served at `/shared/icons/<service>[-maskable]-<size>.png` and named by the web app manifest `serve.js` serves at `/shared/manifest/<service>.webmanifest`), `assets` (content-addressed image/asset URLs and responsive `<picture>` markup: `hash(bytes)`, `url(src, hash)`, `srcset(entries)`, `picture({ src, hash?, alt, width, height, sizes, sources })` — AVIF before WebP before the fallback `<img>`, an unhashed path kept exactly as given), `auth-client`, `brand`, `builtin-themes`, `cache-policy` (the one cache rule: a content-addressed asset — `?v=<hash>` or a hashed filename — is `public, max-age=31536000, immutable`, anything else is `public, max-age=300, stale-while-revalidate=86400`, HTML is `public, max-age=120, stale-while-revalidate=3600` and a personalised page is `private, no-store`; `assetHeaders`/`htmlHeaders`/`isHashed` plus an `applyHtml()` middleware, used by `serve.js`), `indexnow` (tell engines a page changed: `createIndexNow({ host, key, keyLocation?, endpoint?, fetch, log })` → `keyFile` for `/<key>.txt`, `ping(urls)` for one POST of a deduped, https-only, own-host batch of ≤10,000 URLs that never throws, and `pingSoon(urls)` debounced into one batch in 30s; an unset key sends nothing), `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `social` (every site's 1200×630 share card: `url(service)`, served by `serve.js` at `/shared/social/<service>.jpg` and used by `shell.page()` for `og:image` when a page names none), `shell` (the server-rendered page shell: `page(o)` composes the doctype and `<head>` from `seo.headTags`/`seo.pageSummary`, the theme-loader before the first paint, `web-runtime`/`navbar`/`footer` through `serve.url`, the `frame.noscriptNav` no-JS nav, the body and `frame.footer`; `scripts(o)` is just the head scripts and the escaped runtime boot JSON), `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`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Link its pure `plan()` |
28| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
29| Generators | `scripts/build-glyphs.js` (`ov-glyphs.js` and `ov-glyphs.svg` from `glyphs.js`), `scripts/build-glyph-fonts.py` (the glyph fonts and their CSS; Python with picosvg, fonttools and brotli), `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`, `scripts/build-icons.js`, `scripts/build-social-cards.js` (share cards from the Contracts manifests; Chrome + ImageMagick) |
30| Pin bot | `scripts/pin-bump.js` (`node scripts/pin-bump.js <library> <tag> [--repos A,B] [--apply]`): after a release, one PR per repository that pins the library below the tag, with the lockfile refreshed and the docs-status fields rewritten; a dry run unless `--apply`; CI tests each PR |
31| Checks (CI) | `scripts/pin-drift.js` (every openvibe-* pin is a published tag and not more than one minor behind, and openvibe-shared is installed once), `scripts/docs-status.js` (`node scripts/docs-status.js <dir> [--check]`: STATUS.json's contracts/sdk/shared fields and the README `<!-- versions:start/end -->` block must name the pinned releases; `--check` exits 1 on drift) and `scripts/floating-promises.js` (`node scripts/floating-promises.js <dir> [--json]`: an async call whose result nobody awaits — a failure is an unhandled rejection and a transaction commits without it — unless awaited, returned, assigned, `void`-ed, chained with `.then(`/`.catch(`/`.finally(` or opted out with `// floating-ok: <reason>`; exit 1 when found). The native code is compiled too: `scripts/native-check.js` writes the generated Compose and SwiftUI sources out, and `.github/workflows/native.yml` compiles the Kotlin against the Compose artifacts and type-checks the Swift against SwiftUI |
32
33Every module file has an explicit `exports` entry (no wildcards), so `require('openvibe-shared/navbar')`,
34`require.resolve('openvibe-shared/package.json')` and the rest all resolve. It stays one package with
35subpath exports rather than a set of `@openvibe/*` packages:
36[docs/adr/0001-one-package-subpath-exports.md](docs/adr/0001-one-package-subpath-exports.md).
37
38## Consuming it
39
40### Start a new site
41
42```sh
43npm run create-site -- my-site
44```
45
46The command creates `./my-site` with a pinned Shared dependency, a small Express server,
47discovery routes (`/robots.txt`, `/sitemap.xml`, `/llms.txt`, `/llms-full.txt`), and a smoke test. Set `SITE_URL` to the site's public origin before serving it.
48
49The generated server renders each page with `openvibe-shared/shell`, so the doctype, SEO head and the
50whole OpenVibe Frame come from one call instead of a hand-written layout:
51
52```js
53const shell = require('openvibe-shared/shell');
54res.type('html').send(shell.page({
55 name: 'example', title: 'Example', description: '…', canonical: 'https://example.com/',
56 summary: 'One line an AI can read',
57 body: '<main>…</main>',
58 features: { chat: { scripts: ['/js/chat.js'] } },
59 routes: [{ path: '^/chat', features: ['chat'] }],
60}));
61```
62
63See "The page shell" below for the rest of the options.
64
65### Agent discovery and Markdown twins
66
67`openvibe-shared/agent-layer` mounts the agent manifest and public Markdown twins from
68the same server renderer as HTML. See [the three-line mount and service descriptor guide](docs/agent-layer.md).
69
70### Server side: pin a release tarball
71
72```json
73"dependencies": {
74 "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0"
75}
76```
77
78`npm install` writes the resolved URL and its `integrity` into `package-lock.json`, and `npm ci`
79reproduces it exactly. Upgrade by changing the tag in the URL and running `npm install`. Never
80hand-copy files into `node_modules/` or a `vendor/` directory.
81
82```js
83const legal = require('openvibe-shared/legal');
84const seo = require('openvibe-shared/seo');
85const { AnalyticsTrackerPg } = require('openvibe-shared/analytics/pg');
86```
87
88### SEO kit
89
90`openvibe-shared/seo` exports the page head, sitemap, robots and `llms.txt` helpers, plus
91the discovery formats below. The package root also exports them as `seo`. A site serves
92`/robots.txt`, `/sitemap.xml`, `/llms.txt` and `/llms-full.txt` (`templates/site` has all four).
93
94`llmsFull({ site, summary, base, sections, maxBytes })` renders `/llms-full.txt`: the `llms.txt`
95header, then `## <section>` and, for each page, `### <title>`, `URL: <absolute url>` and the page's
96full plain text — `text` as given (newlines kept) or `html` stripped to text; a page with neither is
97listed with its URL only. Input order is kept and bodies are never clipped. With `maxBytes` it stops
98before the first page that would pass the limit and ends with
99`(truncated: N more pages at <base>/llms.txt)`. Sections with no pages are left out. (Sections in the
1002.4.0 shape, `{ title, url, body }` with `maxChars`/`maxTotal`, still render as before.)
101
102```js
103const seo = require('openvibe-shared/seo');
104const full = seo.llmsFull({ site: 'Example', summary: 'What Example publishes', base: 'https://example.com/',
105 sections: [{ title: 'Guides', pages: [{ title: 'Start', url: '/start', text: 'Step one.\nStep two.' },
106 { title: 'API', url: '/api', html: '<h1>API</h1><p>Call get().</p>' }]}],
107 maxBytes: 500000 }); // serve as text/plain at /llms-full.txt
108```
109
110`pageSummary({ title, summary, url, updated, facts })` is the AI-readable summary of a page. As a
111string it is `<meta name="ai-summary">` (the summary clipped to 160 like the description, which
112`headTags` still owns), a WebPage JSON-LD tag (`name`, `description`, `url`, `dateModified` as ISO,
113`abstract`; written with `jsonLdTag`) and, when `facts` (strings) are given, a
114`<noscript><section data-ai-summary>` block with the title, summary and facts. Everything is
115escaped. `.head` is the meta and JSON-LD on their own and `.body` the noscript block, for pages that
116put facts in `<body>`; `.meta`, `.jsonLd` and `.html` are the 2.4.0 values.
117
118```js
119const summary = seo.pageSummary({ title: 'Start', summary: 'How to set up Example in five minutes',
120 url: 'https://example.com/start', updated: '2026-10-01', facts: ['Takes five minutes', 'No account needed'] });
121`<head>${seo.headTags({ title: 'Start', description: 'How to set up Example', canonical: 'https://example.com/start' })}
122${summary.head}</head><body>${summary.body}…`; // or `${summary}` in <head> when there are no facts
123```
124
125```js
126const site = { name: 'Example', url: 'https://example.com/' };
127const feed = { title: 'Example', link: site.url, description: 'Recent guides', language: 'en',
128 selfUrl: '/feed.xml', items: [{ title: 'Guide', link: '/guide', guid: 'guide-1',
129 published: '2026-10-01T12:00:00Z', description: 'A short guide', content: '<p>Full text</p>', author: 'Example' }] };
130seo.feedXml(feed, { format: 'rss' }); // serve as application/rss+xml
131seo.feedXml(feed, { format: 'atom' }); // serve as application/atom+xml
132seo.feedLinkTags({ rss: 'https://example.com/feed.xml', atom: 'https://example.com/atom.xml', title: 'Example' });
133```
134
135`jsonLd` also builds `product`, `review`, `aggregateRating`, `imageGallery` (an ImageObject
136array) and `videoObject` (the same VideoObject shape as `video`). Feed URLs should be absolute,
137or relative to the absolute `link`. `feedXml` throws on a format other than `rss` or `atom`. Without
138`updated`, the feed uses its newest item date, or the current time when there are no items. Without a
139`guid`, an item's link is its RSS guid (`isPermaLink="true"`) and its Atom id. The 2.4.0 item names `url`,
140`id` and `summary` still work. `llmsFull` resolves page URLs against `base` (or `site.url`).
141
142### Images and responsive pictures (`assets.js`)
143
144`openvibe-shared/assets` gives an image the same content-addressed URL the rest of the estate uses —
145`?v=<first 12 hex of sha256>`, which `cache-policy` and every CDN already treat as immutable — and
146renders the `<picture>` around it, without the page hand-writing a srcset:
147
148```js
149const fs = require('fs');
150const assets = require('openvibe-shared/assets');
151const hash = assets.hash(fs.readFileSync('public/img/hero.png')); // 12 hex
152assets.url('/img/hero.png', hash); // /img/hero.png?v=<hash>
153const html = assets.picture({
154 src: '/img/hero.png', hash, alt: 'The dashboard', width: 1600, height: 900,
155 sizes: '(max-width: 800px) 100vw, 800px',
156 sources: [
157 { type: 'image/avif', entries: [{ src: '/img/hero-800.avif', hash: h800, w: 800 },
158 { src: '/img/hero-1600.avif', hash: h1600, w: 1600 }] },
159 { type: 'image/webp', entries: [{ src: '/img/hero-800.webp', hash: w800, w: 800 },
160 { src: '/img/hero-1600.webp', hash: w1600, w: 1600 }] },
161 ],
162});
163// <picture><source type="image/avif" …><source type="image/webp" …><img src="/img/hero.png?v=…" alt="…" …></picture>
164```
165
166`hash(data)` is sha256 over bytes (`Buffer`/`string`), truncated to the estate's 12 hex. `url(src,
167hash)` appends `?v=`, uses `&` after an existing query, replaces an old `?v=` instead of stacking it,
168and **returns the path unchanged when there is no hash** — so a site with no build step keeps its
169existing asset paths. `srcset(entries)` builds one from `[{ src, hash?, w? | x? }]`.
170
171`picture(o)` orders the `<source>`s itself — AVIF, then WebP, then any other type, with the caller's
172order kept inside a format — and always puts the fallback `<img>` last. With no usable source it
173renders a plain `<img>`, never an empty `<picture>`; with no `src` it renders nothing. `alt`, `type`,
174`srcset`, `sizes`, `media`, `class`, `id`, `loading` (`lazy`), `decoding` (`async`) and `fetchpriority`
175all pass through escaped, and a fixed attribute order means the same input renders the same bytes.
176
177For variants named `hero-400.avif`, `hero-800.avif`, and their WebP equivalents, pass
178`widths: [400, 800]` with `src: '/img/hero.jpg'`. `picture()` derives both srcsets and keeps the
179original JPEG as the `<img>` fallback. Supply widths only for variants the site has generated.
180`hashAsset(bytes)` is the same 12-hex content hash used by `serve.hashOf()`; `hash()` remains an alias.
181
182To copy a directory of static files to hashed filenames, run:
183
184```sh
185openvibe-build-assets public/img dist/img
186```
187
188The command writes `dist/img/manifest.json`, mapping paths such as `hero.jpg` to
189`hero.<hash>.jpg` (and preserving subdirectories). `cache-policy.isHashed()` recognises these
190names as immutable. Shared does not transcode images: generate AVIF and WebP variants in the
191site build before using `widths`, or pass existing variants through `sources`.
192
193### Performance budgets (`perf-budget.js`)
194
195`measure({ base, path })` weighs a page's first load from the running server (HTML, same-origin
196scripts and stylesheets, raw and brotli) with no browser. `measure({ ..., browser: true })` then
197opens the page in Chrome through `browser-harness`, clicks once at the viewport centre and adds
198`cwv: { lcpMs, inpMs, cls }`, so `check()` takes Core Web Vitals budgets as well. The shared
199test workflow needs no extra step for this, because `ubuntu-latest` has `/usr/bin/google-chrome`.
200Run the check from `extra`:
201
202```yaml
203jobs:
204 test:
205 uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@<sha>
206 with:
207 extra: node scripts/perf/cwv.js
208```
209
210```js
211// scripts/perf/cwv.js: start the site, measure the home page in Chrome, fail over budget.
212const { measure, check, format } = require('openvibe-shared/perf-budget');
213const server = require('../../server/app').listen(0, '127.0.0.1', async () => {
214 const m = await measure({ base: `http://127.0.0.1:${server.address().port}`, path: '/', browser: true });
215 const over = check(m, { htmlBrotliKB: 30, jsBrotliKB: 90, lcpMs: 2500, inpMs: 200, cls: 0.1 });
216 console.log(format(m, over));
217 server.close();
218 process.exitCode = over.length ? 1 : 0;
219});
220```
221
222### Metrics and readiness (server)
223
224```js
225const release = require('openvibe-shared/release').createRelease({ service: 'community' });
226const m = require('openvibe-shared/metrics').instrument(app, { service: 'community', release: release.release });
227// before any route: HTTP golden signals by route template, process metrics, release_info,
228// and GET /metrics for direct loopback callers only (404 through a proxy)
229m.registry.gauge({ name: 'jobs', help: 'Jobs by state', labelNames: ['state'], collect: () => rows });
230// collect may be async (a database read): /metrics awaits it, up to 2 s, and leaves out one that fails.
231m.registry.gauge({ name: 'docs', help: 'Documents', collect: async () => (await db.one(sql`SELECT count(*)::int AS n FROM docs`)).n });
232// instrument() also routes an async handler's rejection to next(err) (openvibe-shared/express-async): Express 4
233// would leave it unhandled, and Node 22 ends the process on an unhandled rejection.
234
235const ready = require('openvibe-shared/ready').createReadiness({
236 service: 'community', release: release.release,
237 checks: [
238 { name: 'db', required: true, check: () => db.prepare('SELECT 1 AS ok').get().ok === 1 },
239 { name: 'media', required: false, cacheMs: 30000, check: async () => (await ping()) || 'unreachable' },
240 ],
241});
242app.get('/api/ready', ready.handler); // 503 only when a required check fails; optional → degraded
243```
244
245A check fails when it throws, times out, returns `false`, a string (the reason) or
246`{ ok: false, error }`. Name a dependency required only when the service really cannot serve
247without it.
248
249A check that verified nothing (its feature is not configured, its dependency is switched off)
250returns `skip('not configured')` (`require('openvibe-shared/ready').skip`, or `{ skipped: reason }`),
251never `true`: it reports status `skipped` with the reason and is listed in `skipped`. No fake green
252(roadmap WS-Q task 7): a skipped required check makes the service `degraded`, and a service is
253`ready` only when at least one check really passed.
254
255### Configuration (server, WS-C task 7)
256
257`openvibe-shared/config` keeps one namespace of a service's configuration as immutable revisions
258(`common.config-snapshot@1`) in the service's PostgreSQL database. Pass an openvibe-sdk/db handle.
259Several namespaces share the `config_snapshots` and `config_keys` tables, created by the service's
260migrations from `config.configSchema()`. `createConfigStore` returns a promise of the ready store.
261Its database methods are async; `get()` and `revision()` stay in memory.
262
263```js
264const config = require('openvibe-shared/config');
265const settings = await config.createConfigStore({
266 db, service: 'live', namespace: 'live.site_settings',
267 schema, // JSON Schema subset: type, enum, const, required, properties,
268 // additionalProperties, min/max*, pattern, items, uniqueItems, multipleOf
269 validate: (values) => true, // or false, a message, a list, { valid, errors }; synchronous
270 classify: (key) => (/api_key|secret|token/.test(key) ? 'secret' : undefined), // or { key: class }; unknown = internal
271 defaults, // revision 1 of a new namespace, and under every revision
272 legacy: async () => config.fromRows(await db.all('SELECT * FROM site_settings')), // revision 1 instead
273 onActivate: async (values, previous, { restoring }) => { /* apply it; throw to refuse */ },
274 keks: { current: 'k2', keys: { k1, k2 } }, // 32-byte Buffers; or OV_CONFIG_KEKS / OV_CONFIG_KEK_CURRENT
275 onChange: async ({ namespace, keys, actor, revision }) => { /* the operator alert */ },
276 keep: 50, log: console, now: () => new Date(),
277});
278settings.get(); // defaults overlaid with the active values: frozen, in memory
279settings.get('max_bitrate_kbps'); // one value; never a database read
280settings.revision(); // the active revision, or null
281await settings.propose(values, { actor, reason, merge, unset }); // validated, stored as proposed (redacted snapshot)
282await settings.activate(revision, { actor });
283await settings.apply(values, { actor, reason, merge, unset }); // propose + activate
284await settings.rollback({ actor, reason, to }); // a new revision copying the previous good one (or `to`)
285await settings.lastKnownGood(); await settings.history({ limit, before }); await settings.snapshot(revision); await settings.summary();
286await settings.import(legacy); // revision 1 from a legacy source when the namespace has none
287await settings.reload(); // another process changed it: re-read the active revision
288await settings.setSecret(key, value, { actor, reason }); // the write-only path; returns the marker
289await settings.deleteSecret(key, { actor, reason }); // removes it as a new revision
290await settings.secretMarker(key); // fingerprint, hint, set_at, set_by, bound_to (or null)
291await settings.reseal(); // plaintext and old-KEK rows to the current KEK
292settings.sealed; settings.kek; // false / null without a KEK
293```
294
295- **Values** are a complete snapshot; `merge: true` starts from the active values and `unset` removes
296 keys (a key with a default falls back to it). A redaction marker sent back (`{ redacted: true,
297 fingerprint }` as the routes show it) keeps the value it stands for, so a form can round-trip
298 secrets; a marker whose fingerprint is not the current value's is refused (422).
299- **Activation** is serialized per store. The switch (new revision active, old superseded) is one
300 transaction; then `onActivate(values, previous)` runs. If it throws, the previous revision is active
301 again in memory and in the database, the new one is `rejected` with the error (scrubbed of secrets),
302 `onActivate` runs once more with the restored values (`{ restoring: true }`), and a
303 `ConfigError` (`config.activation_failed`, 422) is thrown with the original as `cause`. A proposal
304 whose base is no longer active is `config.stale` (409). A process that stopped mid-activation boots
305 on the last-known-good.
306- **Last-known-good** is the newest revision that activated successfully. Pruning keeps the newest
307 `keep` revisions and never the active one or the last-known-good.
308- **Secrets** leave only redacted (history, summary, routes, the return values) and never reach the
309 log or a stored error. A redacted value is `{ redacted: true, fingerprint }`: HMAC-SHA256 of its
310 canonical JSON under a random 32-byte key made for the namespace on first use and kept in
311 `config_keys`. The key is never logged; with a KEK it is stored there wrapped, so a database dump
312 has the fingerprint but no key that reproduces it. Equal fingerprints mean an unchanged value. A
313 snapshot's `checksum` is the sha256 of its values as shown. Losing the key row only means a new key:
314 older markers stop round-tripping. A key classified secret later is redacted in older revisions too.
315 Errors name the rule, never the value.
316- **Sealed secrets (D11).** With a KEK, a secret-class value never enters `values_json`: it is stored
317 as a versioned envelope (`v1:<kek_id>:<fingerprint>:<hint>:<bind>:…`) that a random 256-bit
318 data key sealed with AES-256-GCM, itself sealed by the current KEK (AES-256-GCM), with the AAD
319 `service|namespace|key|bind` on both. `get()` opens the envelope for the service (the plaintext
320 lives in memory exactly as before); a summary, snapshot, history entry or rollback shows a marker.
321 - The KEKs come from `opts.keks` (`{ current, keys: { k1: Buffer } }`) or, by default, from
322 `OV_CONFIG_KEKS=k1:<base64 32 bytes>[,k2:…]` and `OV_CONFIG_KEK_CURRENT=k2`. A value that does not
323 decode to exactly 32 bytes refuses startup with an error that names the variable and the KEK id,
324 never a byte of the key. Only `node:crypto` is used.
325 - No KEK means today's behaviour, untouched and unflagged: `sealed: false`, `kek: null`, the value
326 stays in the row. `reseal()` moves plaintext secrets and envelopes under an older KEK to the
327 current KEK revision by revision (it never resurrects one whose bind changed) and reports
328 `{ resealed, current, plaintext, locked, errors }`.
329 - **The fingerprint key.** With no KEK, `config_keys.hmac_key` is today's plain 32-byte namespace
330 key. With a KEK it is the same key sealed under the current KEK there (AES-256-GCM, AAD
331 `openvibe-config-fingerprint|<service>|<namespace>`), never the key itself; the store unwraps it
332 in memory and rewraps it under a new current KEK on boot. The key itself never changes, so markers
333 and checksums do not move across a KEK rotation, or when a KEK is first enabled on an existing
334 namespace. This is the wrapped-key alternative to deriving it from `k1`: a wrapped key does not
335 depend on `k1` surviving a rotation, and enabling a KEK preserves every marker written before it.
336 `reseal()` recomputes each resealed envelope's fingerprint with this stable key.
337 - **Rotation.** Add the new KEK next to the old one and set it current. The first boot rewraps the
338 fingerprint key (the key is unchanged); `reseal()` moves the envelopes to the new KEK and
339 recomputes their fingerprints; then the old KEK can be removed.
340 - A `v1:…` envelope string is reserved for a stored secret: a value that already parses as an
341 envelope is carried as one (that is also why a copied envelope never opens under another key).
342- **Bind, pins and the summary schema.** A secret property's `x-bind: "<key>"` puts that key's value
343 (e.g. a provider's `base_url`) into the AAD: changing it locks the secret (`get()` gives undefined)
344 until the owner enters it again for the new value. `x-env: "NAME"` pins a key to the process
345 environment: the summary shows the env value with `pinned: true`, and a write to it is 409
346 `config.pinned_in_env`. `summary()`'s `classification` names every key of `values` and, since
347 4.125.0, every property of the schema, set or not, so a form knows a key is a secret before it is
348 entered. `summary()` now carries `sealed`, `kek`, `pins`, `secrets` (each secret's
349 marker with its four-character `hint`, `set_at`, `set_by`, `bound_to`) and `schema`, the JSON
350 Schema to draw the form from; a secret property's `default`, `examples`, `enum` and `const` are
351 removed from that copy. The annotations are `title`, `description`, `x-group`, `x-env`, `x-danger`,
352 `x-bind` and `x-unit` (`nano_usd`, `ms`, `bps`, `seconds`).
353- **The owner's Setup page and clear settings (4.125.0).** Network's admin panel draws every namespace
354 from its summary schema, and lists on its Setup tab every field only the owner can give. These
355 annotations are checked when the store is created (a wrong shape refuses it, naming the rule) and pass
356 through to the summary as written:
357
358 | Annotation | Shape | What the panel does with it |
359 |---|---|---|
360 | `x-owner-input` | `{ group, unlocks, get_it_at?, get_it_how?, test? }` | A row on Setup. `group` is one of `config.OWNER_INPUT_GROUPS` (`money`, `ai-providers`, `gpus`, `voices`, `chat-apps`, `sign-in-apps`, `data-sources`, `design-publishing`, `domains`, `legal`); `unlocks` is the sentence "what it turns on"; `get_it_at` the https page at the provider; `get_it_how` one line on what to choose there; `test: true` when the service's tester (below) tests this key. |
361 | `x-required-for` | `"<feature>"` | Setup says **Needed** while the key is unset, naming what waits on it. Without it an unset key is **Optional**. |
362 | `x-enum-labels` | `{ <value>: { label, description? } }` | Every option of a choice in words and a line, never a raw value. Keys are the enum values (a non-string as its JSON: `"null"`, `"5"`); a label for a value the enum lacks is refused. |
363 | `x-advanced` | `true` | The field folds under a counted "Advanced settings (N)". |
364 | `x-example` | a string, number or boolean | The input's placeholder. Never a real value. |
365
366 A secret-class property loses `x-example` and `x-enum-labels` in the summary, with `default`,
367 `examples`, `enum` and `const` (say a key's shape in `get_it_how` instead: "It starts with sk-").
368- **`config.lintSchema(schema)`** returns `[{ path, key, message }]`, empty when every field reads
369 clearly: a field without a `title`, with a `description` under 30 characters (what it does, what
370 the default means, an example), or a choice (`enum`, or `items.enum`) without an `x-enum-labels`
371 label for every option is one entry. Fields are the top-level properties and the properties of an
372 object field; a summary is read through its `schema`. Run it in the service's own test on each
373 namespace it publishes: `assert.deepStrictEqual(config.lintSchema(SCHEMA), [])`.
374- **Hooks.** `onChange({ namespace, keys, actor, revision })` runs after every successful activation
375 (never for a proposal or the seed), and a throwing hook is logged and never refuses the revision.
376 `requireFresh(req, { namespace, keys })`, an `adminRoutes` option, runs before a secret write, a
377 rollback that changes a secret or an `x-danger` key, and a danger write; a false answer is 403
378 `config.fresh_required`.
379- `config.fromRows(rows, { prefix, type })` reads typed key-value rows (`number`, `boolean`, `json`,
380 anything else a string), as Live's and Network's `site_settings` and Media's `media_settings` keep them.
381
382```js
383config.adminRoutes([settings, other], {
384 requireAdmin, // a middleware or a list; guards every handler
385 actor: (req) => ({ type: 'user', id: req.user.subject_id }), // default: req.actor, req.subject, req.user.subject(_id)
386 authorize: (req, { action, namespace, keys }) => action === 'read' || isOwner(req.user) || !keys.some(isSecret),
387 requireFresh: (req, { namespace, keys }) => isFreshSignIn(req), // secrets and x-danger keys; false → 403
388 test: async ({ namespace, key, value }) => ({ ok, message }), // optional: the Setup page's Test button
389 basePath: '/api/admin/config',
390}).mount(app); // or { router }, the handlers, or handle(req, res, next) on plain http
391```
392
393| Route | Answer |
394|---|---|
395| `GET /api/admin/config` | `{ namespaces: [{ service, namespace, revision, values, classification, sealed, kek, pins, secrets, schema, active, last_known_good }] }`, values redacted |
396| `GET /api/admin/config/:namespace` | one of them |
397| `GET /api/admin/config/:namespace/history?limit&before` | `{ namespace, snapshots, next_before }`, newest first |
398| `POST /api/admin/config/:namespace` `{ values, reason, merge?, unset? }` | the new active snapshot; 422 `config.invalid` with `errors[]`, 422 `config.activation_failed`; a secret-class value or unset is 422 `config.secret_write_only`, an env-pinned key 409 `config.pinned_in_env` |
399| `POST /api/admin/config/:namespace/rollback` `{ to?, reason }` | the new active snapshot; 409 `config.no_previous`, `config.not_good` |
400| `PUT /api/admin/config/:namespace/secrets/:key` `{ value, reason }` | the marker `{ redacted, fingerprint, hint, set_at, set_by, bound_to }`; write-only |
401| `DELETE /api/admin/config/:namespace/secrets/:key` `{ reason }` | the empty marker; a new revision without the key |
402| `POST /api/admin/config/:namespace/test/:key` | `{ ok, message }`: the service's `test` hook tried the stored value; 409 `config.not_set` when the key has no value, 404 `config.test_not_available` when there is no tester or it answers null for that key |
403
404Errors are `application/problem+json` (`errors.problem@1`). Mount it after a JSON body parser, or
405let it read the body itself. A normal POST cannot write a secret-class value: the two `/secrets`
406routes are the only write path, and they answer a marker, never the value.
407
408The test hook is how the Setup page's **Test** button reaches a provider. `test({ namespace, key,
409value, values, store, req })` gets the stored value (a secret opened) on the server, calls the
410provider, and answers `{ ok, message }`, or null when it does not test that key. The route answers
411that in one line of at most 300 characters, scrubbed of every secret value of the namespace (a
412client id's tester may hold the client secret too), so it never echoes a key; a tester that throws
413is a failed test with its scrubbed message, and one that runs past `testTimeoutMs` (15 s) is a failed
414test as well. `authorize` sees the action `'test'`, and the route needs an actor like a write. It
415changes nothing and keeps nothing: the caller (Network's proxy) records the result.
416
417### Releases: `/release.json`, open tabs and the mixed-version test (ADR-016, Track R)
418
419```js
420const release = require('openvibe-shared/release').createRelease({
421 service: 'community',
422 root: path.join(__dirname, '..'),
423 components: {
424 styles: { kind: 'style', assets: ['/css/app.css', '/css/pages.css'] }, // swapped in place
425 pages: { kind: 'content', files: ['server/web/pages'] }, // regions re-fetched in place
426 shell: { kind: 'script', assets: ['/js/app.js'], files: ['server/web/layout.js', 'public/index.html'] },
427 },
428 contracts: { 'community.web-api': { version: '1.4.0', accepts: '^1.0.0' } }, // what the pages call
429 schemaGeneration: () => migrations.generation(), // optional
430});
431const m = require('openvibe-shared/metrics').instrument(app, { service: 'community', release: release.release });
432release.mount(app, { registry: m.registry }); // GET /release.json, POST /release-metrics → /metrics
433```
434
435- **Components.** `style` (stylesheets, `assets` are URL paths under `publicDir`, default
436 `root/public`), `content` (server-rendered regions), `script` (code the page runs) and `server`
437 (code only the server runs). A component's version is the hash of its files. `shell` is the
438 catch-all `script` component: list in it **every client-facing file no other component lists**
439 (layout templates, page scripts). An undeclared shell is versioned by the release, so every
440 release reloads, which is the 1.4.0 behaviour. The asset map's URLs are `<path>?v=<12 hex of
441 sha256>`, which matches Live's `server/web/assets.js`; pass `assetUrl(path, hash)` for another
442 scheme.
443- **Contract ranges.** `version` is what this release speaks. `accepts` is the range it still
444 serves from the other side (`^1.0.0` → `>=1.0.0 <2.0.0`); keep it wide enough for the previous
445 release, which is the 24-hour window. `role: 'consumes'` marks another service's contract that
446 this one calls.
447- **What is served** is limited to the fields your installed openvibe-contracts
448 `registry.release-manifest` schema declares: 1.0.0 until you pin openvibe-contracts ≥ v0.31.0.
449 `release.validate()` checks the served manifest with your contracts (put it in a test), and
450 `release.full()` is everything. A static-only release switch without a restart: pass
451 `recheckMs`, or call `release.refresh()`.
452- **Markup for in-place content**: `<main data-ov-content="pages" data-ov-rev="<revision>">…</main>`.
453 The tab re-fetches the page URL (or `data-ov-src`) and takes the element with the same
454 `data-ov-content`. Scripts and inline handlers are dropped, so rebind on `ov:content-updated`.
455 A stylesheet whose URL path is not the asset's logical path needs `data-ov-asset="/css/app.css"`.
456 Mark anything that must not be replaced with `data-ov-protected`.
457- **Client generations and the shell (manifest 1.2.0, openvibe-contracts ≥ 0.60.0).** Pass
458 `createRelease({ clientGeneration, minClientGeneration, shell })`. `clientGeneration` (an integer, or a
459 function returning one) goes up only when an older client can no longer work against this server; the
460 meta tag then carries `data-generation`. `minClientGeneration` (or env `MIN_CLIENT_GENERATION`) makes
461 every tab below it reload at the next safe moment (`reloaded: required`), without waiting for the
462 window. `shell` names the components that form the page shell (`['shell', 'nav']`); the manifest serves
463 `{ version, components }`, and a tab whose shell version differs is prompted, never updated in place.
464- **The update matrix (WS-P task 8)**, what an open tab does with each kind of change, each row tested
465 (test/release-update.test.js, test/release.test.js):
466
467 | Change | What the tab does | Events / counts |
468 |---|---|---|
469 | Stylesheet or theme (`style`) | loads the new `<link>` beside the old, swaps when every one loaded; a failure or 15 s timeout keeps the old | `ov:styles-updated`; `applied` or `failed: style/style-timeout` |
470 | Content (`content`) | re-fetches the region, skips an unchanged `data-ov-rev`, keeps its scroll and what the reader sees above it | `ov:content-updated`; `applied` |
471 | A widget inside a region | told to let go before its region is replaced, then to mount again | `ov:content-dispose`, then `ov:content-updated` |
472 | A busy region (focus, unsent form, playing media, live camera/mic, `data-ov-protected`) | waits, and is replaced once it is not busy | `deferred` with the reason, once per release |
473 | An account switched in place (the page's `openvibe-auth-changed` window event) while a region waits or is being fetched | the region is fetched again with the new session; what was fetched for the previous account is never committed (test/release-account-switch.test.js) | `deferred: account` |
474 | Shell, router or any script (`script` and anything not in place) | prompts once; reloads only when it must | `prompted: optional` |
475 | Security minimum (`min_client_release`), mixed-version window over, contracts out of range | reloads when safe (hidden or idle, nothing busy), prompting meanwhile | `prompted` and `reloaded: required/window/contract` |
476 | Server only (`server`) | nothing to do in the tab | `applied: server` |
477- **Release notifications (1.17.0).** When OpenVibe.Host announces a release, it publishes
478 `host.release.published` (public, `ovhost deploy` / `ovhost announce`). release-watch opens one
479 EventSource per tab, without credentials, on
480 `https://openvibe.events/realtime/stream?topics=host.release.published`.
481 - **Which events count.** Only events whose `payload.service` is the page's service: `OVReleaseConfig.service`,
482 the meta tag's `data-service`, else the `service` of `/release.json`. The tab ignores the release it runs
483 or already knows (a hex prefix counts as the same release) and repeats of an event, and runs its usual
484 check once, after a random 0–20 s delay.
485 - **Bursts.** At most one check per 30 s; anything that arrives in between collapses into one more.
486 - **Account changes.** The events are public, so an account switch changes nothing and never opens a
487 second stream. `/release.json` is read without credentials, so whether a tab updates never depends on
488 who is signed in.
489 - **Hidden tabs.** A tab hidden for 5 minutes closes its stream, and reopens it on return with
490 `last_event_id`.
491 - **Failures.** Errors back off from 30 s to 15 minutes. After 6 failures in a row only the poll is left,
492 until the browser goes back online. Back online (the `online` event), the stream reconnects at once from
493 its cursor, whether it had given up or was still backing off, and one check runs
494 (test/release-offline-resume.test.js).
495 - **Where to connect.** The default applies on https pages only. `OVReleaseConfig.eventsUrl` or the meta
496 tag's `data-events` sets another URL, and `false` or `"off"` turns it off. Polling (focus, visibility,
497 every 10 minutes) is unchanged.
498 - **Origins.** Events answers `https://*.openvibe.*` origins, plus those in its `REALTIME_CORS_ORIGINS`.
499 - **CSP.** The site's CSP `connect-src` must allow `https://openvibe.events`. Otherwise the
500 browser refuses the stream once, and release-watch stops (state `blocked`) and keeps polling.
501 - **State.** `OVRelease.state().realtime` reports `{ state, service, url, events, ignored, checks, failures, lastSeq }`.
502- **Metrics (D46)**: `release_client_updates_total{outcome,reason}`, where outcome is `prompted`
503 (reason `optional`, `required`, `window` or `contract`), `applied`, `reloaded`, `deferred` or
504 `failed`. `release_client_sessions{generation}` counts open tabs heard from in the last 12
505 minutes, `current` when they run the release this process serves and `older` otherwise: each tab
506 beats every 5 minutes with a random id it keeps in memory only (never tied to an account) and
507 says when it ends. The tab finds the endpoint in the manifest's `metrics_url` (set by `mount`),
508 the meta tag's `data-metrics`, or `OpenVibeNavbar.init({ releaseWatch: { metricsUrl } })`.
509
510Mixed-version test: capture the previous release's `/release.json` (its `full()` form) and the
511calls its pages make as fixtures, then:
512
513```js
514const compat = require('openvibe-shared/release-compat');
515await compat.assertMixedVersion({ releases: [
516 { name: 'N-1', manifest: require('./fixtures/release-prev.json'), client: compat.replay(require('./fixtures/calls-prev.json')) },
517 { name: 'N', manifest: release.full(), server: async () => ({ url, close }), client: compat.replay(calls) },
518] });
519```
520
521It fails when adjacent releases' ranges don't overlap both ways, when a pair the manifests call
522compatible fails against the real server, and when an incompatible pair would not make the tab
523reload. `compat.openPage({ html, serve })` runs release-watch in a linkedom page (install
524`linkedom` as a devDependency) for in-place tests of your own markup. `globals` adds window properties,
525such as a fake `EventSource`, and `timeouts()`, `fireTimeouts(filter)` and `dispatch(type)` drive the
526page's timers and events.
527
528### Analytics (server, ADR-021)
529
530```js
531const { AnalyticsTrackerPg, analyticsSchema, pruneRawEventsPg } = require('openvibe-shared/analytics/pg');
532// Put analyticsSchema() in the service's database migrations.
533const analytics = new AnalyticsTrackerPg(db, 'live', {
534 retention: { days: 30 },
535 paramPrefixes: ['avatar'],
536 pathRules: [[/\/by-username\/[^/?#]+/gi, '/by-username/:username']],
537 countBotApiAtIngest: true, // optional: bot API calls go into the rollups, not one raw row each
538});
539app.use(analytics.middleware());
540// A service may call pruneRawEventsPg(db, { days: 30 }) in its own job runner.
541```
542
543A raw event has no IP, user or subject id, or city. It has a route template (never a raw URL), a
544referer origin, a user-agent class, a rotating session id and a country. Raw events are kept for
54530 days, and the rollups for longer. A request with `Sec-GPC: 1` or `DNT: 1` is not recorded at
546all. The row shape is `analytics/event.v1` (`docs/schemas/analytics-event.v1.json`), and
547`require('openvibe-shared/analytics/event').checkRow(row)` checks a stored row against it. PostgreSQL retention uses `pruneRawEventsPg(db, { days })`; `retention.MAX_DAYS` is 30.
548
549`countBotApiAtIngest` (off by default) stops writing a raw row for a bot's API call. At flush the
550call increments `analytics_bot_api_hourly` and `analytics_bot_api_daily` by endpoint group (the
551first three path segments of the route template), status class (`2xx`/`3xx`/`4xx`/`5xx`/`other`) and
552bot family (the row's `bot_type`: `known_crawler`, `no_useragent`, `rate_limit`, `honeypot_hit`).
553The hour's and day's rollups add those counters to raw rows when they are computed, so a day closes
554to the same `api_calls`, `bot_hits` and `error_count` a raw-row rollup gave; the counters are totals,
555so recomputing a day never counts a call twice. Page views by bots always keep their raw row, and a
556call that cannot be counted (the bounded in-memory map is full) falls back to a raw row. The bot
557breakdown and status classes in `getStats` and `getBotAnalysis` include the counters.
558
559### Browser side: two options
560
5611. **From the Network** (what most pages do today):
562 `<script src="https://openvibe.network/shared/navbar.js" defer></script>`. Network serves the
563 browser files of the version it pins, from its own `node_modules/openvibe-shared`.
5642. **From the site's own origin**: the page's first paint doesn't depend on the Network being
565 up, and the files match the version the site's server pins. Serve exactly the browser files
566 from `node_modules`:
567
568 ```js
569 const shared = require('openvibe-shared/files');
570 app.use('/shared', (req, res, next) => {
571 const name = path.basename(req.path);
572 if (!shared.isBrowserFile(name)) return next();
573 res.setHeader('Access-Control-Allow-Origin', '*');
574 res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
575 res.sendFile(shared.path(name));
576 });
577 // Web Push worker: same origin, scope "/"
578 app.get('/openvibe-sw.js', (req, res) => { res.setHeader('Service-Worker-Allowed', '/'); res.sendFile(shared.path('openvibe-sw.js')); });
579 ```
580
581 Never `express.static` the whole package directory. It also holds server code, tests and
582 scripts.
583
584**Icons: OpenVibe's own markup (4.231.0).** Every icon is an OpenVibe glyph (openvibe.design/icons):
585
586```html
587<i data-ov-icon="live"></i> <!-- any glyph by name; a title names it to screen readers -->
588<i data-ov-icon="eye" class="ovg-lg ovg-fw"></i> <!-- ovg-xs … ovg-3x, ovg-fw, ovg-rotate-*, ovg-flip-h/-v, ovg-fill -->
589<i data-ov-brand="github"></i> <!-- a brand logo (brands.js, Simple Icons) -->
590```
591
592`ov-glyphs.js` draws them (the navbar loads it on any page that shows one); `OVGlyphs.icon('live', { title })` makes
593one in a page, `require('openvibe-shared/glyphs').markup('live')` writes one on a server. Font Awesome markup is still
594drawn with the glyph its name maps to while sites move: `node node_modules/openvibe-shared/scripts/fa-to-ov.js
595--write` rewrites a repository's static markup and lists what a person has to change (names built at run time, JS
596class strings, CSS rules, animations).
597
598**navbar.js and `nav-icons.js`.** navbar.js draws the icons for its own rows inline. The rest
599of the bar's set (OpenVibe glyphs, keyed by the names sites pass in `links`, `chips` and menu sections) lives in
600`nav-icons.js`; a glyph name the set lacks is drawn as `<i data-ov-icon>` by `ov-glyphs.js`. navbar.js fetches that file once, after first paint, from **the same directory
601navbar.js was loaded from**, as `nav-icons.js?v=<first 12 hex of its sha256>`. It falls back to
602`https://openvibe.network/shared/` when navbar.js is bundled or inlined.
603
604So wherever you serve navbar.js, serve `nav-icons.js` beside it. The `?v=` matches the file's
605content hash, so it can be cached as immutable.
606
607Until the glyphs arrive, each icon's slot has the right width and nothing moves. If the file
608can't load, those icons fall back to `<i class="fa-solid …">`. Two options change this:
609
610- `OpenVibeNavbar.init({ iconsUrl })` points navbar.js at another URL.
611- Loading `nav-icons.js` *before* navbar.js puts every glyph in the first frame.
612
613**The notification bell in realtime (1.22.0, off by default).** `OpenVibeNavbar.init({ notificationsRealtime: true })`, or `OpenVibeNotifications.init({ realtime: true })` where a site mounts the bell itself, makes the bell hear the person's new notifications as they happen. It uses Network's `network.notification.created` over OpenVibe.Events' realtime stream (ADR-005 amendment 2).
614- **Loading.** notification-ui.js then loads `notification-live.js` from its own directory, so serve it beside notification-ui.js (it is in `files.js`).
615- **Each (re)connect.** It asks Network for a two-minute ticket (`POST https://openvibe.network/api/v1/realtime/ticket`) with the page's Bearer token, or with the session cookie on openvibe.network itself. It then opens the ticket's stream URL (now `https://openvibe.events/realtime/stream?topics=network.notification.*&ticket=…`) without credentials, resuming with `last_event_id`.
616- **What it reacts to.** On an event whose subject is the person, it re-reads the unread count and toasts what is new: one request per burst. On `event: gap` it also reloads the open lists.
617- **Polling stays:** every 15 s without a stream, and every 2 min while the stream is open, as a safety net for reads made on other sites.
618- **Retries and giving up.**
619 - Errors back off from 2 s, with jitter. After ten failures in a row the bell polls only, until the browser comes back `online`.
620 - A tab hidden 5 minutes closes the stream and resumes it when shown.
621 - A Content-Security-Policy refusal, a signed-out 401/403, or Network's `REALTIME_TICKETS=off` (503) stop the stream, and the bell polls.
622- **The site's CSP** needs `connect-src https://openvibe.events` (and `https://openvibe.network` for the ticket, which the bell already calls). `OpenVibeNotifications.realtimeState()` reports the feed's state.
623
624**The bell center (4.30.0).** The bell opens a 420 px popover on desktop and a full-height sheet on phones (a drag handle,
625a × and a scrim; the page underneath does not scroll).
626- **Tabs:** All · Mentions · Live · Deals & alerts · System · Agents. Network defines what each holds
627 (`GET /api/notifications?tab=`) and counts them (`GET /api/notifications/unread-by-tab`). A Network without the
628 counts shows the tabs without numbers. Arrow keys, Home and End move between tabs.
629- **Unread** filters any tab; search still searches.
630- **Rows:** an unread dot on the left; the sender's avatar or the category's line glyph; a thumbnail on the right from
631 `rich_content.image`, `thumbnail_url` or `thumbnail` (https, and not the avatar again); grouped repeats with ×N.
632- **Touch:** a row swipes right to mark it read and left to clear it (Network's dismiss); the buttons on the time line
633 do the same with a mouse or keyboard.
634
635**The Action Center (4.31.0).** The ✦ beside the bell (desktop; on phones, the bell's Agents tab links to it) opens
636every agent task in one place: Running (re-read every 15 s while open), Waiting for you (Approve and Decline right
637there), Scheduled, Done (cost, steps, the page) and Failed (why). It reads Network's `GET /api/v1/me/actions`
638(`network.me-actions-result@1`, contracts 0.229.0) and acts through `POST /api/v1/me/actions/:service/:id/approve|decline`.
639`action-center.js` loads from beside `notification-ui.js` on the first click (serve it there; it is in `files.js`).
640`OpenVibeNotifications.init({ actions: false })` leaves the ✦ out; `OpenVibeNotifications.openActions(anchor)` opens it
641from a site's own control.
642
643**The menu's Chat and Vibe (dock.js; 4.51.0, one UI since 4.220.0).** The OpenVibe menu holds the person's
644conversations (**Chat**: OpenVibe.Chat's `/api/dm/conversations`, read and answered in place, Edit as a pencil at the
645bubble's corner) and **Vibe** (Vibe's conversations, the person's coding tasks and their agents, by a Chat · Code ·
646Agents switch, with a timer button at its end). The panels are `dock.js`'s, placed in the menu with
647`OpenVibeDock.mountInto(host, { tab })` on every screen. There is no floating Dock, no pill and no window of its own
648(the owner, 2026-11-12: "there should only be the one unified UI"). For a signed-in person `navbar.js` loads `dock.js`
649from beside itself (it is in `files.js`); it reads the person's state from the Network so it follows them from site to
650site:
651- reads: `GET /api/v1/me/dock` (`network.dock-result@1`); writes: one debounced `PUT` (`network.dock-state@1`): the
652 place last open, the open chat, the Vibe conversation and the timer;
653- what needs saying is a status on the bar's menu button: a breathing dot while agents work, an amber one when one
654 needs you (the button then opens on Agents), a timer's minutes in a small label under it;
655- a 404 from a Network without the route keeps the status away for a day.
656
657`OpenVibeNavbar.openDock('chats' | 'vibe' | 'code' | 'agent')`, `OpenVibeDock.open({ tab })`, `ask({ text })` and
658`share({ text, title })` open the menu there (`OpenVibeSearch.open({ door })` or `OpenVibeSearch.door(id)` when it is
659open). `dock: false` leaves Chat and Vibe out of the menu; `dockApiBase` and `dockChatBase` override the Network and
660Chat for tests and staging.
661
662**Quiet hours (4.30.0).** During the person's window (`GET /api/notifications/quiet-hours`, read at start and every ten
663minutes), toasts and sounds wait; the bell and the badge still fill, and a critical notice still shows.
664
665### Boost (`boost.js`): smooth page moves for a server-rendered site
666
667Every page stays a normal page (it works without JavaScript and crawlers read the same HTML); boost only makes a same-site
668move feel like an app. Mark the page and its changing part, then start it:
669
670```html
671<meta name="ov-boost" content="<site>@<release>"> <!-- the release id: a different one means a normal page load -->
672<main id="main">…</main>
673<script src="/shared/boost.js" defer></script>
674<script>addEventListener('DOMContentLoaded', () => OVBoost.start({ main: '#main' }));</script>
675```
676
677A click on a same-site link (no modifier key, no target, no download, not `data-no-boost`, not `/auth/`, `/api/` or a
678file) fetches the page (already prefetched on hover, focus or touch), loads its new stylesheets first, swaps `<main>` in
679place with `route-transition.js`'s bar and fade, updates the title, description, canonical, Open Graph, Twitter and
680JSON-LD tags, runs the new main's inline scripts once, pushes history, restores scroll on back and forward, and focuses
681the new content. Another release marker, other external scripts, a non-HTML answer, a redirect off the site, an error
682or a timeout is a normal load. Listen for `ov:boost:before` and `ov:boost:load` to tear down and wire widgets.
683`OVBoost.start(opts)` returns `{ go(url), stop(), stats() }`; options: `main`, `prefix` (`ov`), `exclude` (path
684prefixes), `timeout` (8000 ms), `prefetch` (true), `fx` (true).
685
686### Soft pages (`soft.js`): prefetch on intent and cross-document view transitions
687
688The small, safe part of the "instant pages" work, for a server-rendered site that does not want a full soft-navigation
689contract yet. Load it in `<head>`, after `theme-loader.js`, as a plain script (not deferred — the arriving page must
690have its `@view-transition` opt-in before it first renders):
691
692```html
693<script src="/shared/soft.js"></script>
694```
695
696It prefetches a same-origin link on intent — pointer down, or 80 ms of hover on a computer — and never on Save-Data or a
6972G/3G connection, never sign-in/out, auth/API, a download, a query URL, an in-page anchor or `data-ov-no-prefetch`, at
698most four at a time. Chrome/Edge get a `conservative` Speculation Rule for the press (the navbar's cross-site
699`[data-ov-net-link]` warm-up is kept); other browsers use `<link rel=prefetch>`, and no prerender is ever added. The
700navbar's mark starts drawing at the press (`OVMark.navStart('page')`) and finishes on the next page's first paint
701(`navEnd('page')`). Cross-document view transitions hold the shared bar still and cross-fade the rest of the page
702(180 ms; Calm 120 ms; motion Off has none). A page with its own `script[type="speculationrules"]` keeps it.
703
704`window.OpenVibeSoft.on()`, `.off()` and `.exclude(selector)` turn it on, off, or add links to the exclusion. The
705research behind it is in the network's agent research notes, `agents/research/soft-pages.md`.
706
707### The web runtime (`web-runtime.js`, `route-transition.js`)
708
709```text
710openvibe-shared/web-runtime.js — a site's feature loader and route lifecycle (roadmap WS-P task 6), taken out of
711OpenVibe.Live's public/js/ov-loader.js so every OpenVibe site loads its code the same way.
712
713 const rt = OVWebRuntime.create({ features, routes, versions, ... }); // registry: see README "Web runtime"
714 rt.load('broadcast') → the feature's stylesheets (its dependencies' too) and markup fragment together, the markup
715 inserted once the styles are in (never unstyled), then its dependencies and scripts (in
716 order) and its `after` hook; once, cached; a failed load can be retried.
717 rt.route(path) → every feature the path's routes name, as one promise.
718 rt.enter(section, work, { label })
719 → a page while its route loads: hidden (keeping its space) until its styles and code are in;
720 route-transition.js (loaded on the first move) adds the top bar, a "Loading <label>…" line
721 after 150 ms with the phase, and the fade-in. rt.status(text) changes that line.
722 rt.prefetch('channel') → download only (link rel=prefetch), never execute; skipped on Save-Data, 2g and < 2 GB
723 of memory, and within a budget (count, and bytes where sizes are known).
724 rt.nextRoute() / rt.gen() / rt.isCurrent(gen)
725 → route generations: a loader that awaited something can tell whether the visitor moved on.
726 rt.scope() → timers, listeners, observers, fetches (AbortSignal) and child scopes owned by the current
727 route, released by the next nextRoute(). Anything registered on a scope whose route has
728 ended is refused (and counted), never left running.
729 rt.diagnostics(), rt.leaks()
730 → loaded, failed and rolled-back features, duplicate tags, late registrations, what the
731 current route holds, and the prefetch budget spent.
732
733Asset groups are transactional: a feature counts as loaded, runs its hook and fires `<prefix>:feature` only when
734every script loaded. When one fails, the stylesheets that attempt added (and no other feature wants) are taken out
735again and the failed script's tag is removed, so a retry fetches exactly what is missing. Scripts that did run are
736kept, so nothing runs twice.
737Tags the server already put in the document count as loaded. Global stubs (installStubs) keep inline onclick
738handlers working before their feature has loaded.
739```
740
741A server-rendered page puts these tags in its `<head>` itself, with the SEO head and the OpenVibe
742Frame around them: `openvibe-shared/shell` (`shell.page(o)`) composes exactly that — see "The page
743shell" below.
744
745### Installable sites
746
747Every page `shell.page()` renders is also an installable web app, with no per-site code. When the page's
748service has a pre-rendered icon set (`openvibe-shared/app-icons`; `icons/<variant>[-maskable]-192|512.png`, one set per tint variant),
749the shell adds `<link rel="manifest" href="/shared/manifest/<service>.webmanifest?v=<hash>">` and an
750`<link rel="apple-touch-icon">` to its `<head>`. `serve.js` answers both from the site's pinned copy — the
751manifest is built from the service's Contracts `site` block (name and tagline) with the hashed icon URLs —
752so a home-screen install never depends on another host. `manifest: false` opts out for sites that serve
753their own `/manifest.webmanifest`, and a page whose `head` already has a manifest link keeps it.
754
755### The page shell (`shell.js`)
756
757```text
758openvibe-shared/shell — compose a whole server-rendered page from the pieces a site already ships,
759so a repo's layout.js becomes one call instead of a hand-written <!doctype html> document (the T11
760sweep). It adds no markup of its own.
761
762 const shell = require('openvibe-shared/shell');
763 res.type('html').send(shell.page({
764 name: 'example', service: 'example',
765 title: 'Example', siteName: 'OpenVibe.Example', description: '…',
766 canonical: 'https://example.com/', image: '…', imageAlt: '…', type: 'website',
767 summary: 'One line an AI can read', facts: [['Sites', 12]], url: 'https://example.com/',
768 jsonLd: [seo.jsonLd.website({ … })],
769 body: '<main>…</main>', // the page's main content (trusted, already escaped)
770 bodyClass: 'home', bodyAttributes: { 'data-ov-content': 'pages' },
771 navLinks: [{ label: 'Docs', href: '/docs' }], home: '/',
772 footer: { service: 'example', variant: 'compact' },
773 features: { chat: { scripts: ['/js/chat.js'] } },
774 routes: [{ path: '^/chat', features: ['chat'] }],
775 }));
776
777page(o) → the whole document: doctype, <head>, <body>. The head is seo.headTags (title,
778 description, canonical, robots, Open Graph, Twitter, any jsonLd); when `summary` is
779 given, seo.pageSummary adds its <meta name="ai-summary"> and WebPage JSON-LD to the
780 head and its <noscript> block at the top of the body. Then theme-loader.js eagerly (it
781 applies the theme before the first paint), web-runtime.js/navbar.js/footer.js deferred
782 through serve.url, the frame.noscriptNav no-JavaScript nav, the body and frame.footer
783 (footer.ssr). The runtime boot is inline JSON handed to
784 OVWebRuntime.create(…).boot(), with the navbar init next to it.
785scripts(o) → just that <head> script block (theme-loader, the deferred runtime, the boot JSON and
786 the inline boot call). `routes[].path` is a string, not a RegExp.
787```
788
789Every interpolated value is escaped: the head through `seo`, `lang` and the body attributes here, and
790the runtime boot with `JSON.stringify(…).replace(/</g, '\\u003c')` so a feature name can never close
791the `<script>`.
792
793Each repo's `layout.js` moves onto `shell.page()` in the T11 sweep; until then the shell and the
794hand-written layouts produce the same document. `templates/site/server/app.js` (the scaffold every
795new site starts from) already calls it. See `docs/migration-plan.md`, "T11: layout.js onto
796openvibe-shared/shell".
797
798### The OpenVibe Kit (`kit.js`, `kit.css`, `appbar.js`)
799
800The pieces an app page is made of, one look on every site, phone first: a sticky head with its tabs, lists of rows,
801cards that fold, settings rows, a search row, a composer pinned at the bottom, and a bottom tab bar on phones
802(`appBar` on the navbar). The same builders run on the server (`require('openvibe-shared/kit')`) and in the page
803(`window.OpenVibeKit`); text is escaped, and every piece works without JavaScript. `kit-web.js` is the behaviour alone
804(about 7 KB), all a server-rendered page loads. [docs/kit.md](docs/kit.md) has every
805piece, the sizes, the colours, and how to use it in an OpenVibe site or anyone's project;
806`node scripts/kit-gallery.js --shots <dir>` shows them all at 390, 768 and 1280 px. Every navbar also has search across OpenVibe
807(`search.js`, loaded on first use), and `loading.js` is the one loading look (docs/kit.md#loading).
808
809**OpenVibe Tokens** (`openvibe-shared/tokens`, 4.40.0) is any theme as a W3C Design Tokens document, compiled to CSS,
810Tailwind, Jetpack Compose, SwiftUI, Flutter and Figma variables, so native apps wear the same theme as the web, without a
811web view ([docs/tokens.md](docs/tokens.md)).
812
813**OpenVibe components** (`openvibe-shared/components`, 4.42.0) is the kit's components as a contract (anatomy, tokens, states,
814accessibility) with Compose and SwiftUI reference views, plus kit documents: a screen as validated JSON that the web
815renders through kit.js and a native app with its own views ([docs/components.md](docs/components.md)).
816
817```js
818const kit = require('openvibe-shared/kit');
819kit.head({ title: 'Settings', back: { href: '/', label: 'Home' }, tabs: kit.tabs({ base: '/settings', items, active }) })
820 + kit.list(rooms.map((r) => kit.row({ href: `/r/${r.slug}`, mark: kit.mark({ name: r.name }), title: r.name, sub: r.topic })))
821 + kit.dock({ action: '/r/x', placeholder: 'Message #general' });
822OpenVibeNavbar.init({ …, appBar: true }); // phones: the links with icons as a bottom tab bar
823```
824
825### The navbar (`navbar.js`)
826
827```text
828Consistent top bar across all services with logo, navigation,
829notification bell, account switcher, and theme-aware styling.
830Usage: OpenVibeNavbar.init({ service, token, user, apiBase })
831 Optional: loginUrl (override the Sign In href), sessionUrl (same-origin
832 endpoint returning { user } — asked when no usable ov_token exists, including
833 when a stored token turned out stale), logoutUrl (where Sign out goes, e.g. the
834 site's '/auth/logout?next={path}', so a server-side session ends too; {url} is
835 the full return URL, {path} its local path — loginUrl takes both too),
836 onLogin/onLogout callbacks. When no `user` is passed the navbar resolves
837 it itself: ov_token cookie → localStorage → token opt → sessionUrl, then
838 GET {apiBase}/api/auth/me with `Authorization: Bearer <token>`.
839Brand: derived from the hostname — `pastes.openvibe.tools` renders as
840 Pastes · OpenVibe · Tools (three segments, the subdomain first so the
841 context reads left-to-right), `openvibe.live` as OpenVibe · Live. Pass
842 brand: { sub, tld, name, icon, variant } to override any part, or the
843 legacy brandName/brandIcon. compact: 'auto' (default — the brand shortens
844 to the subdomain on narrow viewports), 'always', 'never'.
845Menus are modular: every site keeps the shared account menu and adds its own
846 pieces — links: [{label, href, icon?, active?, featured?}] replaces the service's
847 top links; featured: true draws that one link as a quiet accent pill with its
848 glyph, and puts it first in the phone's menu (4.117.0); menu: { before: [item],
849 after: [item] } adds dropdown rows
850 ({label, href, icon, onClick, danger, external}); OpenVibeNavbar.addMenuItem()
851 / setLinks() do the same at runtime. Signed-in users also get a
852 "Recently used" row fed by the shared history module when it is loaded.
853Sign-in and history: history: { type: 'tool'|'stream'|'video'|'clip'|'paste'|'thread'|'conversation'|'document'|'page'|…,
854 title, url, icon } is recorded for the signed-in user once auth resolves
855 (cross-site "Recently used" / History on the Network). silentLogin:
856 'https://site/auth/login?silent=1&next={url}' — when nobody is signed in
857 here, one top-level prompt=none round trip per tab per ten minutes picks up
858 the Network session, including a first visit with third-party cookies blocked.
859 Guests return with sso=none; an explicit local sign-out (ov_sso_hint=guest),
860 bots and unavailable sessionStorage suppress the attempt. The site's login
861 handler must return silent failures without showing a login form (openvibe-sdk/sso).
862 OpenVibeSSO.fedcm remains available for browser-native sign-in flows.
863One menu (4.187): the OpenVibe mark is a button that opens the OpenVibe menu
864 on its first tab, as the menu button does (there is no separate site
865 switcher). The site's name stays the link home; switcher: false keeps the
866 mark as a home link.
867New in 4.190: `ov-drop.js` is the one dropdown (`OVDrop.open(anchor, items,
868 opts)`, `.attach(button, items)`, `.pages(anchor, links, icon)`): it opens out
869 of what was pressed, under it or above, stays on screen, and one is open at a
870 time; the bar's site name opens the site's pages with it. `purse.js` gives any
871 site the currencies' effects: `OpenVibePurse.burst(el, id, delta)` plays the
872 gain or spend at an element (inside a dialog too), `.count(el, from, to,
873 format)` counts a number, `data-purse="coins|vibes|balance"` gives a holder
874 the currency's colour as `--pc`. `machines.js` is the one picker of a person's
875 Link computers (`[data-ov-machines]` or `OpenVibeMachines.mount`). `OVUpload`
876 takes `checkDuplicate(file, sha256)` and tells a person they already have a
877 file. The footer strip reads `/api/frame/online`.
878 The balances (4.189): one pill, `#openvibe-chips-all`, at every width, with
879 one `.ovnav-chip-mini` per balance the person has (OpenCoins, Vibes, the money
880 balance; only those above zero, the largest first) and one of them showing.
881 `purse.js`, loaded beside the navbar, draws each currency's icon
882 (`OpenVibePurse.icon(id, size)` gives the same drawing to any surface), turns
883 the pill through the balances on the first page of a visit and now and then,
884 and on `setChip(id, value)` turns to that balance, counts the number and
885 plays the gain or spend effect (`OpenVibePurse.play(id, delta)`). A site's
886 single counter that is not a balance stays an ordinary chip.
887 The menu (4.189, one UI since 4.220): the menu button always opens this
888 site's tab (no tab is remembered; the picture still asks for You); Chat and
889 Vibe are places in the menu itself, on every screen (`dock: false` leaves
890 them out).
891 The account's things (4.188): the person's picture opens the OpenVibe menu
892 on You, where the account's block is (who you are, add account, settings,
893 sign out, your other accounts, this site's own items from addMenuItem,
894 Display). There is no dropdown under the picture; the block rests in the bar,
895 out of sight, while the menu is closed, so #openvibe-logout-btn and a
896 site's own items are always in the page.
897The bar's order (4.187): balances, the bell, the menu button, then the
898 person's picture (or Sign In), last. New messages are counted on the bell
899 (setMessages(count)); there is no Messages button.
900Balances: chips: [{ id, icon, value, label, tone, title, onClick, hidden }]
901 are status chips beside the bell (Live's OpenCoins and Vibes); setChip(id,
902 value) updates one. Two or more fold into one chip at 560 px and narrower.
903 wallet: true (4.17) puts OpenCoins and Vibes there on any site without
904 writing them: the navbar asks the Network and Billing, keeps the last
905 balances per person so the next page paints them at once, refreshes after a
906 minute away, and hides Vibes while Billing is not the ledger (409). A site's
907 own chips with the ids coins/vibes win. Every site's account menu shows both
908 balances in its header either way.
909Re-renders (each openvibe-shared/boost page move re-renders the bar) end the
910 previous render's document and window listeners and its panels.js
911 registrations, so the old bar can be collected.
912```
913
914Dropdowns under a top link (`children`) are fixed-positioned under their link when they show (hover, keyboard focus, a tap), because the links row scrolls sideways and would clip them.
915
916### The shell's cache: the worker (`openvibe-sw.js`, `sw.js`, 4.164.0)
917
918The owner, 2026-11-09: *"how come when you change sites it doesn't load more asynchronously and dynamically, so it's faster
919across sites? … better caching on the client, cross-site and smarter, intelligent, so it's faster, while staying SEO friendly
920and crawlable?"*
921
922One worker, every site, at `/openvibe-sw.js` (same origin, scope "/"), from this package. It is the file the notification
923bell already registered for Web Push, so there is one registration and nothing is unregistered by accident. A site opts in:
924
925```js
926// in the site's navbar options
927OpenVibeNavbar.init({ service: 'live', sw: true }); // or sw: { path: '/my-sw.js', scope: '/' }
928```
929
930The rules it keeps, and why:
931
932| Request | What happens | Why |
933|---|---|---|
934| A content-addressed file (`?v=<hash>`, or a hash in the name) | the cache first, for good | the bytes cannot change under that URL |
935| Any other file (a script, a stylesheet, a font, an image) | the cached copy now, the fresh one in the background | a visit never waits twice |
936| A page (a navigation) | the network first (3 s), the cached copy only when the network does not answer | a crawler and a person read the HTML the server just wrote |
937| `/api/`, anything that is not a GET, a range request | untouched | the API is never the cache's business |
938| A response with `Set-Cookie`, `Cache-Control: private` or `no-store`, or a `Vary` on Cookie or Authorization | passed through, never stored | nothing private is ever written down |
939
940Nothing moves behind JavaScript: the HTML the server writes is the HTML the browser shows. The worker only decides *when* a
941file is asked for, never *what* it says, and it never serves a cached page while the network answers.
942
943The **kill switch**: the worker asks `https://openvibe.network/api/v1/sw-config` (cached 5 minutes by the browser; a worker
944that cannot reach it keeps working — a hiccup must not take the cache away). The answer `{ "enabled": false }` makes every
945worker on every site unregister itself and drop its caches. A release that needs to be undone does not need a deploy on
946every site.
947
948The server's half is `openvibe-shared/sw`:
949
950```js
951const sw = require('openvibe-shared/sw');
952app.get('/openvibe-sw.js', (req, res) => res.sendFile(sw.workerPath())); // the worker
953app.get('/ov-sw-manifest.json', (req, res) => res.json(sw.manifest())); // the shell, by content hash
954app.use(sw.hints({ links: (req) => [ { href: assets.url('/shared/navbar.js'), as: 'script' } ] }));
955```
956
957`sw.hints()` sends the **103 Early Hints** (RFC 8297): the browser gets the shell's URLs before the HTML arrives and starts
958fetching them while the server is still writing the page. The same links ride in the `Link` header of the page itself, so a
959proxy that drops the 103 still hands the browser the hint. Cloudflare's Early Hints has to be on for the zone (the origin's
960103 is passed through when it is).
961
962### The rules the browser gets before the click
963
964The navbar hands the browser **speculation rules** (Chrome 103 and later):
965
966- a **same-site** page is prefetched on hover or touch, and — where the browser has prerendering — rendered in the
967 background before the click lands (`prerender: false` turns that off for a site whose own router draws its pages; a link
968 marked `data-no-prefetch` is left alone);
969- a link to **another OpenVibe site** (the ones marked `data-ov-net-link`) is prefetched too, so the next site is already
970 warm when the person arrives. Chrome leaves a site alone when the person has cookies on it, which is right: that page is
971 theirs, not a public copy.
972
973The sign-in, the sign-out, the API, the internal pages and the downloads are never fetched ahead of the click.
974
975### Vibes Pay (`vibes-pay.js`, 4.111.0)
976
977The browser half of Vibes Pay (MONEY.md, D12): a page on any site can sell something for Vibes.
978
979```html
980<script src="https://openvibe.network/shared/vibes-pay.js" defer></script>
981<button data-ov-vibes-pay="https://openvibe.network/pay/vch_…">Pay with Vibes</button>
982```
983
984The button binds itself: the Vibes green (`#4ade80`, dark ink), the label "Pay with Vibes", a 44 px target, and a
985focus ring; it reads on light and dark themes and on every theme's accent. A click opens the sheet in a 520 px popup
986on a computer, or as the full page in a new tab on a phone; if the browser blocks it, the page navigates to the sheet.
987The sheet posts `{ type: 'ov-vibes-pay', status, charge_id }` to the page only from `https://openvibe.network`; the
988promise resolves `{ status }` (also on the bubbling `ov-vibes-pay` event), and a popup closed without paying resolves
989`{ status: 'closed' }`. A pay URL that is not `https://openvibe.network/pay/…` throws.
990
991```js
992const { status } = await OpenVibe.vibes.pay(payUrl); // 'paid', 'cancelled', …
993```
994
995Or call `OpenVibe.vibes.bind(document)` after adding buttons to the DOM yourself. The server side creates the charge
996with `openvibe-sdk/vibes`; see [docs/vibes-pay.md](docs/vibes-pay.md).
997
998### The video player (`player.js`, `player.css`)
999
1000One look for every `<video>` across the network. Mark the element and load both files; without the script the
1001browser's own controls stay:
1002
1003```html
1004<link rel="stylesheet" href="/shared/player.css">
1005<video data-ov-player controls src="…" poster="…" preload="metadata" playsinline></video>
1006<script src="/shared/player.js" defer></script>
1007```
1008
1009Each `video[data-ov-player]` is wrapped in `.ov-player` with a scrubber (buffered, played, the time under the
1010pointer), play, volume (remembered), time, captions (when the video has a captions track), speed, picture-in-picture
1011and fullscreen; the controls hide while playing and idle. Keys while the player has focus: Space/K, J/L ±10 s, ←/→ ±5 s,
1012↑/↓ volume, M, F, C, 0–9. On touch, a tap shows the controls and a double tap on either side seeks ±10 s. It drives
1013only the media element and listens to its events, so a page's own script (HLS, chapters, next-up, view counts) keeps
1014working. Colours follow the site's `--accent`. `OpenVibePlayer.enhance(video)` for a video added later.
1015
1016### What people wear (`items.js`, `items.css`)
1017
1018The items a person has equipped in OpenVibe.Inventory, drawn the same on every site (plan T21). Mark each name with
1019its owner's subject and load both files:
1020
1021```html
1022<link rel="stylesheet" href="/shared/items.css">
1023<a href="https://openvibe.network/@ana" data-ov-subject="usr_01…">Ana</a>
1024<script src="/shared/items.js" defer></script>
1025<script>addEventListener('DOMContentLoaded', () => OpenVibeItems.decorate(document));</script>
1026```
1027
1028`OpenVibeItems.decorate(node)` reads Inventory's public `GET /api/v1/equipped?subjects=…` (100 people per request,
1029no credentials, each answer kept 60 s) and gives each name its renderers' classes; call it again after adding names
1030and only the new ones are read. Renderers: `live.name_effect.css@1` → `ov-fx` + the item's token (Live's 13 name
1031effects, `name-fx-rainbow` … `name-fx-divine`); `live.hat.glyph@1` → the hat's emoji just before the name
1032(`ov-hat`, with its motion); `live.particle.css@1` → `ov-px` + the token, and a burst of the item's characters when
1033someone points at or focuses the name. Hats and particles read Live's definitions once per page. `network.badge.image@1` → a
1034community badge's image (`img.ov-badge`) before the name, from the equipped read's `media_id`. A server that already knows the token writes the class itself. The
1035Calm setting and `prefers-reduced-motion` stop the animation. `window.OpenVibeItemsConfig = { api }` points it at
1036another Inventory (development).
1037
1038### A person's stuff on any site (`profile-panel.js`, `profile-panel.css`)
1039
1040Steam-style: what someone wears, how many items, badges and achievements they have, their games with the latest
1041unlock, and links to their full profile and inventory, on any OpenVibe site (the owner, 2026-10-25: "you should be
1042able to see your inventory anywhere"). Put a fallback link in an element and load both files:
1043
1044```html
1045<link rel="stylesheet" href="/shared/profile-panel.css">
1046<div data-ov-profile="ana"><a href="https://openvibe.network/@ana">Ana's profile</a></div>
1047<script src="/shared/profile-panel.js" defer></script>
1048```
1049
1050Every `[data-ov-profile]` on the page is drawn once loaded; `OpenVibeProfilePanel.render(el, username)` draws one added
1051later. `data-ov-profile="me"` is whoever is signed in on that site (its own `/auth/me`, the navbar's session lookup), so a
1052page cached for everyone can still show "your stuff": give the element `hidden` and it appears only once drawn. A
1053hidden ancestor marked `data-ov-profile-section` (a heading and a line around the panel) is shown with it. It reads OpenVibe.Network's public profile (`GET https://openvibe.network/api/v1/profiles/:username`, CORS
1054open, no credentials), which gathers OpenVibe.Inventory, OpenVibe.Quest and OpenVibe.Play. Values are text, links only
1055to OpenVibe's own https hosts, pictures only from openvibe.media and openvibe.network; a private profile shows its name
1056and picture; no answer leaves the fallback link. `window.OpenVibeProfilePanelConfig = { api }` points it at another
1057Network (development).
1058
1059### Forms (`ov-forms.js`, `ov-forms.css`)
1060
1061OpenVibe's own select and form controls on every site: the navbar loads them on a page with a form control. Every
1062`<select>` becomes a button that opens a menu on a computer and a sheet on a phone. The hidden select keeps the value,
1063so forms, `select.value` and `change` listeners work as before. An option can say more: `data-desc`, `data-badge`,
1064`data-state` and a `data-href` setup link. Checkboxes, radios and sliders take the site's accent; `role="switch"` is a
1065switch; `.ov-field`, `.ov-input` and `.ov-choice` are the full field look. Turn it off with `data-ov-native` or
1066`<html data-ov-forms="off">`. See [docs/forms.md](docs/forms.md).
1067
1068### Locker (`ov-locker.js`, `ov-locker.css`, `ov-locker-core.js`)
1069
1070End-to-end encryption in the browser (plan Q2): a locked item's card (the link's key, a password, or your locker),
1071making your locker (a passphrase, then a 24-word recovery kit), and unlocking it on a new device. Nothing readable
1072leaves the page. `ov-locker-core.js` is `openvibe-sdk/locker`, generated (`node scripts/build-locker-core.js`), and
1073reproduces the SDK's test vectors exactly. See [docs/locker.md](docs/locker.md).
1074
1075### Share (`ov-share.js`, `ov-share.css`)
1076
1077One Share sheet on every site (plan track Q): a sheet from the bottom on a phone, a dialog on a computer.
1078- **Send it, for everyone:** the link and Copy; the phone's own share sheet (contacts, messages, apps); X, Bluesky,
1079 WhatsApp, Reddit, Discord, Telegram, Threads, Mastodon, Facebook, email, Messages, LinkedIn, Pinterest, Tumblr,
1080 Hacker News and LINE; a QR code made on the device; Start at 1:23; embed code.
1081- **Who can open it, for the owner:** Everyone, Anyone with the link, Specific people by @name (Can view or Can edit,
1082 with an end date) and Only me; access requests; transfer of ownership with the recipient's say-so.
1083
1084Put `<button data-ov-share-sheet data-title="…">Share</button>` on a page: the navbar loads the sheet on the first
1085press. Add `data-kind` and `data-id` where the service mounts `openvibe-sdk/share`'s router. See
1086[docs/share.md](docs/share.md).
1087
1088## Development
1089
1090```bash
1091npm ci
1092npm test # every test/*.test.js (fnm exec --using=22.22.1 npm test)
1093npm test -- navbar # only matching files
1094npm run size # raw + brotli (q11) size of every browser file
1095node scripts/build-navbar-icons.js # the bar's icons and the ring solids, from glyphs.js
1096node scripts/build-theme-loader.js
1097```
1098
1099Tests are plain Node with stubbed browser globals. Some tests guard the release:
1100
1101- **navbar.js size budget**: 29 KB brotli (`test/nav-icons.test.js`). release-watch.js and
1102 release-update.js: 5 KB and 3.5 KB (`test/release-update.test.js`).
1103- **Releases (ADR-016)**: the manifest validates against the installed openvibe-contracts (and
1104 the 1.1.0 schema), in-place updates are transactional and never replace a region in use, the
1105 reload rules hold, and the mixed-version matrix runs against real servers
1106 (`test/release*.test.js`).
1107- **Built-in icons**: every icon the default navbar renders must be built in and drawn with no
1108 fetch.
1109- **Generated blocks**: the generated navbar and theme-loader blocks must be up to date.
1110- **Browser bundles**: browser files must be free of `require()` and Node globals
1111 (`test/browser-bundles.test.js`).
1112- **Exports**: every export must resolve, and every public module file must
1113 have one. Internal `config-core.js` and `analytics/core.js` are private.
1114- **Analytics (ADR-021)**: PostgreSQL tests cover request privacy, opt-out, rollups, and raw-event retention;
1115 pure privacy and event helpers have their own tests.
1116
1117CI runs on Node 22.22.1 and installs a tag-style tarball into an empty project.
1118
1119## Releasing
1120
11211. Land the change on `main` with tests green. Add an entry under a new version heading in
1122 `CHANGELOG.md`, and set the same version in `package.json`.
11232. Commit (`Release vX.Y.Z`), then tag: `git tag -a vX.Y.Z -m "vX.Y.Z"`. Push `main` and the tag.
1124 CI runs on the tag.
11253. **Tags are immutable.** Never move or delete a released tag. Consumer lockfiles pin its
1126 tarball's integrity hash. A fix is a new patch release.
11274. Upgrade Network first, because it serves `https://openvibe.network/shared/*` to every site.
1128 Then upgrade Live, Community, Media, Tools and Sites: change the tag in the URL, run
1129 `npm install`, commit the lockfile and deploy.
1130
1131GitHub builds tag tarballs on demand. In practice they're byte-stable, but GitHub doesn't
1132promise it. If `npm ci` ever reports `EINTEGRITY` for this package on an unchanged tag, check
1133that the tag wasn't moved, then run `npm install` to refresh the lockfile. The content is the
1134tag's tree either way.
1135
1136## Compatibility policy
1137
1138- **Semver.** Major releases are for breaking changes: removing or renaming an exported
1139 subpath, an export, a browser global (`OpenVibeNavbar`, `OpenVibeFooter`, …) or a served file
1140 name, removing an init option, or changing markup, classes or defaults that sites style or
1141 script against. Minor releases add modules, options or files. Patch releases are fixes with no
1142 API change.
1143- **The previous major stays servable.** Network serves each major it supports at
1144 `/shared/v<major>/<file>`. It keeps the older major installed side by side
1145 (`"openvibe-shared-v1": "<v1 tarball URL>"`) for at least 90 days after the next major ships,
1146 and for as long as any consumer or published HTML still references it. That includes the
1147 pre-rendered pages in `OpenVibe.Sites/dist/`.
1148- **Unversioned paths never jump a major silently.** `/shared/<file>` keeps serving the major it
1149 served before. It moves to a new major only after every consumer has either moved to it or
1150 switched to an explicit `/shared/v<major>/` path. Because navbar.js loads `nav-icons.js` from
1151 its own directory, a versioned navbar always gets the icons of its own release.
1152- A new browser file is added to `files.js` in the same release, so sites that serve from
1153 `node_modules` pick it up.
1154
1155**Web runtime: a site's feature loader (1.23.0).** `web-runtime.js` (`OVWebRuntime`) is OpenVibe.Live's route loader, made shareable (roadmap WS-P task 6). A site describes its features once and loads each when it is needed:
1156
1157```js
1158const rt = OVWebRuntime.create({
1159 features: { player: { fragment: 'player', deps: ['base'], css: ['/css/player.css'], js: ['/js/p1.js', '/js/p2.js'], after: 'initPlayer', stubs: ['openPlayer'], idle: ['ops'] } },
1160 routes: [{ path: '^/watch/', features: ['player'] }], // path: a RegExp or its source
1161 versions: { '/js/p1.js': '<hash>' }, // → /js/p1.js?v=<hash>; or url: (p) => …
1162 styleSlot: (href) => node, // insert a stylesheet before this node (cascade order); default: last in <head>
1163 fragmentPath: (name) => `/fragments/${name}.html`, // loaded into #page-<name> (or `section`) once
1164 prefetch: { count: 60, bytes: 3 * 1024 * 1024, sizes: { '/js/p1.js': 12345 } },
1165 onStubError: (err, feature) => toast('That part of the site could not load.'),
1166 routeError: { icon: 'fa-solid fa-plug-circle-xmark' }, debug: false, eventPrefix: 'ov',
1167});
1168rt.boot({ initial: ['player'] }); // adopt the server's tags, load these, prefetch on link intent and when idle
1169```
1170
1171- **Loading.** `load(name)` puts in the feature's fragment first, then its dependencies, then its stylesheets and scripts (injected together, run in order), then calls `after` once. It fires `ov:feature` (and `ov:fragment` on the section). One promise per feature; tags already in the document count as loaded. `route(path)` loads every feature its routes name. `loadScript(src, { integrity, crossOrigin })` and `loadStyle(href)` load single files once, another origin's included.
1172- **Transactional groups.** A feature is loaded only when every script loaded. When one fails, the attempt's own stylesheets leave the document (unless another feature wants them), the failed tag is removed, no hook runs, and a retry fetches only what is missing. A script that ran is never fetched or run again. A missing stylesheet never blocks: it waits at most 4 s.
1173- **Route scopes.** `nextRoute()` starts a generation (`gen()`, `isCurrent(gen)`) and ends the previous route's scope. `scope()` owns `interval`, `timeout`, `listen`, `observe`, `onDispose`, `fetch` (its `signal` aborts when the route ends) and `child()` scopes for one component. Anything registered on an ended scope is refused and counted, never left running (`debug: true` also warns).
1174- **Prefetch.** `prefetch(name)` and `prefetchRoute(path)` download without running (`link rel=prefetch`). Hover, focus and touch on in-site links prefetch their route. They skip Save-Data, 2g and devices under 2 GB of memory, and stop at the budget: a count, and bytes where `sizes` are known.
1175- **Diagnostics.** `diagnostics()` lists loaded, loading and failed features, failed and rolled-back assets, duplicate tags in the document, late registrations, what the current scope holds and the prefetch budget spent. `leaks()` returns the problems as sentences, or `[]`. Browser smoke tests can compare them across navigations.
1176- **Also:** `installStubs()` (global functions that load their feature, then call the real one, for inline handlers) and `showRouteError(pageId, err, retry)` (a retry box built from DOM nodes).
1177
1178It is 6.1 KB brotli (budget 6.5 KB). Serve it beside the other browser files and load it before the site's own loader. `test/web-runtime.test.js` (linkedom) and `test/web-runtime-chrome.test.js` (headless Chrome: real 404s, ordering, scopes) cover it.
1179
1180## Purpose
1181
1182The canonical home of what was `OpenVibe.Network/packages/openvibe-shared` (navbar, footer,
1183themes, icons, legal pages, SEO helpers, notifications UI, panels): the OpenVibe Frame and the
1184server helpers every site uses, published as immutable versioned artifacts instead of being
1185vendored or rsynced from Network.
1186
1187## Owns
1188
1189- the `openvibe-shared` package (one package with subpath exports; the charter's
1190 `@openvibe/tokens|ui|frame|auth-ui|seo|legal|icons|release-client|web-runtime|server-web|testing-web`
1191 split was decided against in [docs/adr/0001-one-package-subpath-exports.md](docs/adr/0001-one-package-subpath-exports.md))
1192- design tokens and theme rendering, the navbar, footer and panels of the OpenVibe Frame
1193- the release manifest client (active-tab update coordinator) and the web runtime (feature loader)
1194- server helpers: readiness, metrics, trace, egress, config, analytics, legal, SEO
1195
1196## Does not own
1197
1198- theme *authority* (OpenVibe.Network owns the theme API and preferences)
1199- identity, notifications and the changelog (OpenVibe.Network, OpenVibe.Blog); the browser files
1200 only display them
1201- any service's data: it has none (artifact repository)
1202
1203## Depends on
1204
1205- `jsonwebtoken` (the only runtime dependency, for `auth-client`)
1206- OpenVibe.Contracts (the release manifest contract; `openvibe-contracts` v0.419.0 is a devDependency
1207 the tests validate against)
1208- in the browser, at run time: OpenVibe.Network (sessions, notifications and realtime tickets, the
1209 changelog feed) and OpenVibe.Events (the realtime stream for the notification bell)
1210
1211## Capabilities
1212
1213None: Shared implements no capability and holds no grant. Browser files call Network and Events as the
1214signed-in person (their session cookie or a Network realtime ticket), and server modules call whatever
1215their consumer configures with the consumer's own credentials.
1216
1217## Acceptance
1218
1219`npm test` runs every `test/*.test.js` (plain Node with stubbed browser globals; the `*-chrome` tests
1220need Chrome). What the release-guarding tests prove is listed under [Development](#development): size
1221budgets, releases and the mixed-version matrix, built-in icons, generated blocks, browser bundles free of
1222Node code, every export resolving, analytics privacy. The charter's two acceptance points hold: Live,
1223Community, Media, Tools and Sites consume pinned versions with no manual copies, and
1224`openvibe.network/shared/*` keeps serving version-pinned files.
1225
1226## Security
1227
1228Reporting a vulnerability: [SECURITY.md](SECURITY.md). Shared's code runs on every site, so:
1229
1230- browser files carry no secrets and no Node code (`test/browser-bundles.test.js`);
1231- `egress` gives services SSRF-safe addresses and connect-time DNS checks for user-chosen hosts
1232 (`test/egress.test.js`); `trace` forwards the W3C trace only to loopback and OpenVibe hosts;
1233- analytics stores no personal data and honours opt-out (`test/analytics-privacy.test.js`);
1234- `/metrics` from `openvibe-shared/metrics` answers direct loopback callers only;
1235- tags are immutable, so a consumer's lockfile integrity hash always names the same code.
1236
1237## Deploy
1238
1239Nothing runs from this repository. A release is a git tag ([Releasing](#releasing)). OpenVibe.Network
1240serves `https://openvibe.network/shared/*` from the tag it pins, so a Network deploy
1241(`sudo ovhost deploy network`) publishes a release to every site that loads the Frame from there. A site
1242can instead serve its own pinned copy (`openvibe-shared/serve`); either way each consumer pins a tag and
1243ships it with its own deploy.
1244A bad release is undone by pinning the previous tag; a released tag is never moved.
1245
1246---
1247
1248Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
1249
1250The Locker browser core currently uses the immutable SDK review commit `80f212323869347385e34545d6ee0a911017f5af` (OpenVibe.SDK #141). Replace it with the reviewed release pin before the next library release.
1251
1252<!-- versions:start -->
1253- openvibe-contracts: v0.419.0
1254- openvibe-sdk: v0.160.0
1255<!-- versions:end -->
1256