openvibers/openvibe.shared README.md
README.md
| 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 |
| 9 | consumer 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 |
| 11 | the 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 | |
| 15 | This repository **is** the npm package `openvibe-shared`. `package.json` sits at the repository |
| 16 | root, so GitHub's tarball of a release tag installs as the package with no build step. It was |
| 17 | imported 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 |
| 19 | were 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 | |
| 33 | Every 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 |
| 35 | subpath 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 |
| 43 | npm run create-site -- my-site |
| 44 | ``` |
| 45 | |
| 46 | The command creates `./my-site` with a pinned Shared dependency, a small Express server, |
| 47 | discovery 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 | |
| 49 | The generated server renders each page with `openvibe-shared/shell`, so the doctype, SEO head and the |
| 50 | whole OpenVibe Frame come from one call instead of a hand-written layout: |
| 51 | |
| 52 | ```js |
| 53 | const shell = require('openvibe-shared/shell'); |
| 54 | res.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 | |
| 63 | See "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 |
| 68 | the 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` |
| 79 | reproduces it exactly. Upgrade by changing the tag in the URL and running `npm install`. Never |
| 80 | hand-copy files into `node_modules/` or a `vendor/` directory. |
| 81 | |
| 82 | ```js |
| 83 | const legal = require('openvibe-shared/legal'); |
| 84 | const seo = require('openvibe-shared/seo'); |
| 85 | const { 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 |
| 91 | the 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` |
| 95 | header, then `## <section>` and, for each page, `### <title>`, `URL: <absolute url>` and the page's |
| 96 | full plain text — `text` as given (newlines kept) or `html` stripped to text; a page with neither is |
| 97 | listed with its URL only. Input order is kept and bodies are never clipped. With `maxBytes` it stops |
| 98 | before 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 |
| 100 | 2.4.0 shape, `{ title, url, body }` with `maxChars`/`maxTotal`, still render as before.) |
| 101 | |
| 102 | ```js |
| 103 | const seo = require('openvibe-shared/seo'); |
| 104 | const 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 |
| 111 | string 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 |
| 115 | escaped. `.head` is the meta and JSON-LD on their own and `.body` the noscript block, for pages that |
| 116 | put facts in `<body>`; `.meta`, `.jsonLd` and `.html` are the 2.4.0 values. |
| 117 | |
| 118 | ```js |
| 119 | const 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 |
| 126 | const site = { name: 'Example', url: 'https://example.com/' }; |
| 127 | const 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' }] }; |
| 130 | seo.feedXml(feed, { format: 'rss' }); // serve as application/rss+xml |
| 131 | seo.feedXml(feed, { format: 'atom' }); // serve as application/atom+xml |
| 132 | seo.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 |
| 136 | array) and `videoObject` (the same VideoObject shape as `video`). Feed URLs should be absolute, |
| 137 | or 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 |
| 146 | renders the `<picture>` around it, without the page hand-writing a srcset: |
| 147 | |
| 148 | ```js |
| 149 | const fs = require('fs'); |
| 150 | const assets = require('openvibe-shared/assets'); |
| 151 | const hash = assets.hash(fs.readFileSync('public/img/hero.png')); // 12 hex |
| 152 | assets.url('/img/hero.png', hash); // /img/hero.png?v=<hash> |
| 153 | const 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, |
| 167 | hash)` appends `?v=`, uses `&` after an existing query, replaces an old `?v=` instead of stacking it, |
| 168 | and **returns the path unchanged when there is no hash** — so a site with no build step keeps its |
| 169 | existing 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 |
| 172 | order kept inside a format — and always puts the fallback `<img>` last. With no usable source it |
| 173 | renders 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` |
| 175 | all pass through escaped, and a fixed attribute order means the same input renders the same bytes. |
| 176 | |
| 177 | For 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 |
| 179 | original 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 | |
| 182 | To copy a directory of static files to hashed filenames, run: |
| 183 | |
| 184 | ```sh |
| 185 | openvibe-build-assets public/img dist/img |
| 186 | ``` |
| 187 | |
| 188 | The 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 |
| 190 | names as immutable. Shared does not transcode images: generate AVIF and WebP variants in the |
| 191 | site 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 |
| 196 | scripts and stylesheets, raw and brotli) with no browser. `measure({ ..., browser: true })` then |
| 197 | opens 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 |
| 199 | test workflow needs no extra step for this, because `ubuntu-latest` has `/usr/bin/google-chrome`. |
| 200 | Run the check from `extra`: |
| 201 | |
| 202 | ```yaml |
| 203 | jobs: |
| 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. |
| 212 | const { measure, check, format } = require('openvibe-shared/perf-budget'); |
| 213 | const 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 |
| 225 | const release = require('openvibe-shared/release').createRelease({ service: 'community' }); |
| 226 | const 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) |
| 229 | m.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. |
| 231 | m.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 | |
| 235 | const 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 | }); |
| 242 | app.get('/api/ready', ready.handler); // 503 only when a required check fails; optional → degraded |
| 243 | ``` |
| 244 | |
| 245 | A 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 |
| 247 | without it. |
| 248 | |
| 249 | A check that verified nothing (its feature is not configured, its dependency is switched off) |
| 250 | returns `skip('not configured')` (`require('openvibe-shared/ready').skip`, or `{ skipped: reason }`), |
| 251 | never `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. |
| 259 | Several namespaces share the `config_snapshots` and `config_keys` tables, created by the service's |
| 260 | migrations from `config.configSchema()`. `createConfigStore` returns a promise of the ready store. |
| 261 | Its database methods are async; `get()` and `revision()` stay in memory. |
| 262 | |
| 263 | ```js |
| 264 | const config = require('openvibe-shared/config'); |
| 265 | const 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 | }); |
| 278 | settings.get(); // defaults overlaid with the active values: frozen, in memory |
| 279 | settings.get('max_bitrate_kbps'); // one value; never a database read |
| 280 | settings.revision(); // the active revision, or null |
| 281 | await settings.propose(values, { actor, reason, merge, unset }); // validated, stored as proposed (redacted snapshot) |
| 282 | await settings.activate(revision, { actor }); |
| 283 | await settings.apply(values, { actor, reason, merge, unset }); // propose + activate |
| 284 | await settings.rollback({ actor, reason, to }); // a new revision copying the previous good one (or `to`) |
| 285 | await settings.lastKnownGood(); await settings.history({ limit, before }); await settings.snapshot(revision); await settings.summary(); |
| 286 | await settings.import(legacy); // revision 1 from a legacy source when the namespace has none |
| 287 | await settings.reload(); // another process changed it: re-read the active revision |
| 288 | await settings.setSecret(key, value, { actor, reason }); // the write-only path; returns the marker |
| 289 | await settings.deleteSecret(key, { actor, reason }); // removes it as a new revision |
| 290 | await settings.secretMarker(key); // fingerprint, hint, set_at, set_by, bound_to (or null) |
| 291 | await settings.reseal(); // plaintext and old-KEK rows to the current KEK |
| 292 | settings.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 |
| 383 | config.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 | |
| 404 | Errors are `application/problem+json` (`errors.problem@1`). Mount it after a JSON body parser, or |
| 405 | let it read the body itself. A normal POST cannot write a secret-class value: the two `/secrets` |
| 406 | routes are the only write path, and they answer a marker, never the value. |
| 407 | |
| 408 | The test hook is how the Setup page's **Test** button reaches a provider. `test({ namespace, key, |
| 409 | value, values, store, req })` gets the stored value (a secret opened) on the server, calls the |
| 410 | provider, and answers `{ ok, message }`, or null when it does not test that key. The route answers |
| 411 | that in one line of at most 300 characters, scrubbed of every secret value of the namespace (a |
| 412 | client id's tester may hold the client secret too), so it never echoes a key; a tester that throws |
| 413 | is a failed test with its scrubbed message, and one that runs past `testTimeoutMs` (15 s) is a failed |
| 414 | test as well. `authorize` sees the action `'test'`, and the route needs an actor like a write. It |
| 415 | changes 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 |
| 420 | const 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 | }); |
| 431 | const m = require('openvibe-shared/metrics').instrument(app, { service: 'community', release: release.release }); |
| 432 | release.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 | |
| 510 | Mixed-version test: capture the previous release's `/release.json` (its `full()` form) and the |
| 511 | calls its pages make as fixtures, then: |
| 512 | |
| 513 | ```js |
| 514 | const compat = require('openvibe-shared/release-compat'); |
| 515 | await 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 | |
| 521 | It fails when adjacent releases' ranges don't overlap both ways, when a pair the manifests call |
| 522 | compatible fails against the real server, and when an incompatible pair would not make the tab |
| 523 | reload. `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, |
| 525 | such as a fake `EventSource`, and `timeouts()`, `fireTimeouts(filter)` and `dispatch(type)` drive the |
| 526 | page's timers and events. |
| 527 | |
| 528 | ### Analytics (server, ADR-021) |
| 529 | |
| 530 | ```js |
| 531 | const { AnalyticsTrackerPg, analyticsSchema, pruneRawEventsPg } = require('openvibe-shared/analytics/pg'); |
| 532 | // Put analyticsSchema() in the service's database migrations. |
| 533 | const 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 | }); |
| 539 | app.use(analytics.middleware()); |
| 540 | // A service may call pruneRawEventsPg(db, { days: 30 }) in its own job runner. |
| 541 | ``` |
| 542 | |
| 543 | A raw event has no IP, user or subject id, or city. It has a route template (never a raw URL), a |
| 544 | referer origin, a user-agent class, a rotating session id and a country. Raw events are kept for |
| 545 | 30 days, and the rollups for longer. A request with `Sec-GPC: 1` or `DNT: 1` is not recorded at |
| 546 | all. 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 |
| 550 | call increments `analytics_bot_api_hourly` and `analytics_bot_api_daily` by endpoint group (the |
| 551 | first three path segments of the route template), status class (`2xx`/`3xx`/`4xx`/`5xx`/`other`) and |
| 552 | bot family (the row's `bot_type`: `known_crawler`, `no_useragent`, `rate_limit`, `honeypot_hit`). |
| 553 | The hour's and day's rollups add those counters to raw rows when they are computed, so a day closes |
| 554 | to the same `api_calls`, `bot_hits` and `error_count` a raw-row rollup gave; the counters are totals, |
| 555 | so recomputing a day never counts a call twice. Page views by bots always keep their raw row, and a |
| 556 | call that cannot be counted (the bounded in-memory map is full) falls back to a raw row. The bot |
| 557 | breakdown and status classes in `getStats` and `getBotAnalysis` include the counters. |
| 558 | |
| 559 | ### Browser side: two options |
| 560 | |
| 561 | 1. **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`. |
| 564 | 2. **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 |
| 593 | one in a page, `require('openvibe-shared/glyphs').markup('live')` writes one on a server. Font Awesome markup is still |
| 594 | drawn 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 |
| 596 | class strings, CSS rules, animations). |
| 597 | |
| 598 | **navbar.js and `nav-icons.js`.** navbar.js draws the icons for its own rows inline. The rest |
| 599 | of 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 |
| 601 | navbar.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 | |
| 604 | So wherever you serve navbar.js, serve `nav-icons.js` beside it. The `?v=` matches the file's |
| 605 | content hash, so it can be cached as immutable. |
| 606 | |
| 607 | Until the glyphs arrive, each icon's slot has the right width and nothing moves. If the file |
| 608 | can'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, |
| 625 | a × 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 |
| 636 | every agent task in one place: Running (re-read every 15 s while open), Waiting for you (Approve and Decline right |
| 637 | there), 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 |
| 641 | from 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 |
| 644 | conversations (**Chat**: OpenVibe.Chat's `/api/dm/conversations`, read and answered in place, Edit as a pencil at the |
| 645 | bubble's corner) and **Vibe** (Vibe's conversations, the person's coding tasks and their agents, by a Chat · Code · |
| 646 | Agents 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` |
| 649 | from beside itself (it is in `files.js`); it reads the person's state from the Network so it follows them from site to |
| 650 | site: |
| 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 |
| 659 | open). `dock: false` leaves Chat and Vibe out of the menu; `dockApiBase` and `dockChatBase` override the Network and |
| 660 | Chat 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 |
| 663 | minutes), 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 | |
| 667 | Every page stays a normal page (it works without JavaScript and crawlers read the same HTML); boost only makes a same-site |
| 668 | move 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 | |
| 677 | A click on a same-site link (no modifier key, no target, no download, not `data-no-boost`, not `/auth/`, `/api/` or a |
| 678 | file) fetches the page (already prefetched on hover, focus or touch), loads its new stylesheets first, swaps `<main>` in |
| 679 | place with `route-transition.js`'s bar and fade, updates the title, description, canonical, Open Graph, Twitter and |
| 680 | JSON-LD tags, runs the new main's inline scripts once, pushes history, restores scroll on back and forward, and focuses |
| 681 | the new content. Another release marker, other external scripts, a non-HTML answer, a redirect off the site, an error |
| 682 | or 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 |
| 684 | prefixes), `timeout` (8000 ms), `prefetch` (true), `fx` (true). |
| 685 | |
| 686 | ### Soft pages (`soft.js`): prefetch on intent and cross-document view transitions |
| 687 | |
| 688 | The small, safe part of the "instant pages" work, for a server-rendered site that does not want a full soft-navigation |
| 689 | contract yet. Load it in `<head>`, after `theme-loader.js`, as a plain script (not deferred — the arriving page must |
| 690 | have its `@view-transition` opt-in before it first renders): |
| 691 | |
| 692 | ```html |
| 693 | <script src="/shared/soft.js"></script> |
| 694 | ``` |
| 695 | |
| 696 | It prefetches a same-origin link on intent — pointer down, or 80 ms of hover on a computer — and never on Save-Data or a |
| 697 | 2G/3G connection, never sign-in/out, auth/API, a download, a query URL, an in-page anchor or `data-ov-no-prefetch`, at |
| 698 | most 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 |
| 700 | navbar'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 |
| 705 | research 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 |
| 710 | openvibe-shared/web-runtime.js — a site's feature loader and route lifecycle (roadmap WS-P task 6), taken out of |
| 711 | OpenVibe.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 | |
| 733 | Asset groups are transactional: a feature counts as loaded, runs its hook and fires `<prefix>:feature` only when |
| 734 | every script loaded. When one fails, the stylesheets that attempt added (and no other feature wants) are taken out |
| 735 | again and the failed script's tag is removed, so a retry fetches exactly what is missing. Scripts that did run are |
| 736 | kept, so nothing runs twice. |
| 737 | Tags the server already put in the document count as loaded. Global stubs (installStubs) keep inline onclick |
| 738 | handlers working before their feature has loaded. |
| 739 | ``` |
| 740 | |
| 741 | A server-rendered page puts these tags in its `<head>` itself, with the SEO head and the OpenVibe |
| 742 | Frame around them: `openvibe-shared/shell` (`shell.page(o)`) composes exactly that — see "The page |
| 743 | shell" below. |
| 744 | |
| 745 | ### Installable sites |
| 746 | |
| 747 | Every page `shell.page()` renders is also an installable web app, with no per-site code. When the page's |
| 748 | service has a pre-rendered icon set (`openvibe-shared/app-icons`; `icons/<variant>[-maskable]-192|512.png`, one set per tint variant), |
| 749 | the 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 |
| 751 | manifest is built from the service's Contracts `site` block (name and tagline) with the hashed icon URLs — |
| 752 | so a home-screen install never depends on another host. `manifest: false` opts out for sites that serve |
| 753 | their 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 |
| 758 | openvibe-shared/shell — compose a whole server-rendered page from the pieces a site already ships, |
| 759 | so a repo's layout.js becomes one call instead of a hand-written <!doctype html> document (the T11 |
| 760 | sweep). 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 | |
| 777 | page(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. |
| 785 | scripts(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 | |
| 789 | Every interpolated value is escaped: the head through `seo`, `lang` and the body attributes here, and |
| 790 | the runtime boot with `JSON.stringify(…).replace(/</g, '\\u003c')` so a feature name can never close |
| 791 | the `<script>`. |
| 792 | |
| 793 | Each repo's `layout.js` moves onto `shell.page()` in the T11 sweep; until then the shell and the |
| 794 | hand-written layouts produce the same document. `templates/site/server/app.js` (the scaffold every |
| 795 | new site starts from) already calls it. See `docs/migration-plan.md`, "T11: layout.js onto |
| 796 | openvibe-shared/shell". |
| 797 | |
| 798 | ### The OpenVibe Kit (`kit.js`, `kit.css`, `appbar.js`) |
| 799 | |
| 800 | The pieces an app page is made of, one look on every site, phone first: a sticky head with its tabs, lists of rows, |
| 801 | cards 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 |
| 805 | piece, 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, |
| 810 | Tailwind, Jetpack Compose, SwiftUI, Flutter and Figma variables, so native apps wear the same theme as the web, without a |
| 811 | web 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, |
| 814 | accessibility) with Compose and SwiftUI reference views, plus kit documents: a screen as validated JSON that the web |
| 815 | renders through kit.js and a native app with its own views ([docs/components.md](docs/components.md)). |
| 816 | |
| 817 | ```js |
| 818 | const kit = require('openvibe-shared/kit'); |
| 819 | kit.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' }); |
| 822 | OpenVibeNavbar.init({ …, appBar: true }); // phones: the links with icons as a bottom tab bar |
| 823 | ``` |
| 824 | |
| 825 | ### The navbar (`navbar.js`) |
| 826 | |
| 827 | ```text |
| 828 | Consistent top bar across all services with logo, navigation, |
| 829 | notification bell, account switcher, and theme-aware styling. |
| 830 | Usage: 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>`. |
| 839 | Brand: 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'. |
| 845 | Menus 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. |
| 853 | Sign-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. |
| 863 | One 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. |
| 867 | New 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. |
| 897 | The 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. |
| 900 | Balances: 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. |
| 909 | Re-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 | |
| 914 | Dropdowns 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 | |
| 918 | The owner, 2026-11-09: *"how come when you change sites it doesn't load more asynchronously and dynamically, so it's faster |
| 919 | across sites? … better caching on the client, cross-site and smarter, intelligent, so it's faster, while staying SEO friendly |
| 920 | and crawlable?"* |
| 921 | |
| 922 | One worker, every site, at `/openvibe-sw.js` (same origin, scope "/"), from this package. It is the file the notification |
| 923 | bell 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 |
| 927 | OpenVibeNavbar.init({ service: 'live', sw: true }); // or sw: { path: '/my-sw.js', scope: '/' } |
| 928 | ``` |
| 929 | |
| 930 | The 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 | |
| 940 | Nothing moves behind JavaScript: the HTML the server writes is the HTML the browser shows. The worker only decides *when* a |
| 941 | file is asked for, never *what* it says, and it never serves a cached page while the network answers. |
| 942 | |
| 943 | The **kill switch**: the worker asks `https://openvibe.network/api/v1/sw-config` (cached 5 minutes by the browser; a worker |
| 944 | that cannot reach it keeps working — a hiccup must not take the cache away). The answer `{ "enabled": false }` makes every |
| 945 | worker on every site unregister itself and drop its caches. A release that needs to be undone does not need a deploy on |
| 946 | every site. |
| 947 | |
| 948 | The server's half is `openvibe-shared/sw`: |
| 949 | |
| 950 | ```js |
| 951 | const sw = require('openvibe-shared/sw'); |
| 952 | app.get('/openvibe-sw.js', (req, res) => res.sendFile(sw.workerPath())); // the worker |
| 953 | app.get('/ov-sw-manifest.json', (req, res) => res.json(sw.manifest())); // the shell, by content hash |
| 954 | app.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 |
| 958 | fetching them while the server is still writing the page. The same links ride in the `Link` header of the page itself, so a |
| 959 | proxy that drops the 103 still hands the browser the hint. Cloudflare's Early Hints has to be on for the zone (the origin's |
| 960 | 103 is passed through when it is). |
| 961 | |
| 962 | ### The rules the browser gets before the click |
| 963 | |
| 964 | The 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 | |
| 973 | The 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 | |
| 977 | The 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 | |
| 984 | The button binds itself: the Vibes green (`#4ade80`, dark ink), the label "Pay with Vibes", a 44 px target, and a |
| 985 | focus ring; it reads on light and dark themes and on every theme's accent. A click opens the sheet in a 520 px popup |
| 986 | on 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. |
| 987 | The sheet posts `{ type: 'ov-vibes-pay', status, charge_id }` to the page only from `https://openvibe.network`; the |
| 988 | promise 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 |
| 992 | const { status } = await OpenVibe.vibes.pay(payUrl); // 'paid', 'cancelled', … |
| 993 | ``` |
| 994 | |
| 995 | Or call `OpenVibe.vibes.bind(document)` after adding buttons to the DOM yourself. The server side creates the charge |
| 996 | with `openvibe-sdk/vibes`; see [docs/vibes-pay.md](docs/vibes-pay.md). |
| 997 | |
| 998 | ### The video player (`player.js`, `player.css`) |
| 999 | |
| 1000 | One look for every `<video>` across the network. Mark the element and load both files; without the script the |
| 1001 | browser'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 | |
| 1009 | Each `video[data-ov-player]` is wrapped in `.ov-player` with a scrubber (buffered, played, the time under the |
| 1010 | pointer), play, volume (remembered), time, captions (when the video has a captions track), speed, picture-in-picture |
| 1011 | and 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 |
| 1013 | only the media element and listens to its events, so a page's own script (HLS, chapters, next-up, view counts) keeps |
| 1014 | working. Colours follow the site's `--accent`. `OpenVibePlayer.enhance(video)` for a video added later. |
| 1015 | |
| 1016 | ### What people wear (`items.js`, `items.css`) |
| 1017 | |
| 1018 | The items a person has equipped in OpenVibe.Inventory, drawn the same on every site (plan T21). Mark each name with |
| 1019 | its 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, |
| 1029 | no credentials, each answer kept 60 s) and gives each name its renderers' classes; call it again after adding names |
| 1030 | and only the new ones are read. Renderers: `live.name_effect.css@1` → `ov-fx` + the item's token (Live's 13 name |
| 1031 | effects, `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 |
| 1033 | someone points at or focuses the name. Hats and particles read Live's definitions once per page. `network.badge.image@1` → a |
| 1034 | community 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 |
| 1035 | Calm setting and `prefers-reduced-motion` stop the animation. `window.OpenVibeItemsConfig = { api }` points it at |
| 1036 | another Inventory (development). |
| 1037 | |
| 1038 | ### A person's stuff on any site (`profile-panel.js`, `profile-panel.css`) |
| 1039 | |
| 1040 | Steam-style: what someone wears, how many items, badges and achievements they have, their games with the latest |
| 1041 | unlock, and links to their full profile and inventory, on any OpenVibe site (the owner, 2026-10-25: "you should be |
| 1042 | able 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 | |
| 1050 | Every `[data-ov-profile]` on the page is drawn once loaded; `OpenVibeProfilePanel.render(el, username)` draws one added |
| 1051 | later. `data-ov-profile="me"` is whoever is signed in on that site (its own `/auth/me`, the navbar's session lookup), so a |
| 1052 | page cached for everyone can still show "your stuff": give the element `hidden` and it appears only once drawn. A |
| 1053 | hidden 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 |
| 1054 | open, no credentials), which gathers OpenVibe.Inventory, OpenVibe.Quest and OpenVibe.Play. Values are text, links only |
| 1055 | to OpenVibe's own https hosts, pictures only from openvibe.media and openvibe.network; a private profile shows its name |
| 1056 | and picture; no answer leaves the fallback link. `window.OpenVibeProfilePanelConfig = { api }` points it at another |
| 1057 | Network (development). |
| 1058 | |
| 1059 | ### Forms (`ov-forms.js`, `ov-forms.css`) |
| 1060 | |
| 1061 | OpenVibe'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, |
| 1063 | so 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 |
| 1065 | switch; `.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 | |
| 1070 | End-to-end encryption in the browser (plan Q2): a locked item's card (the link's key, a password, or your locker), |
| 1071 | making your locker (a passphrase, then a 24-word recovery kit), and unlocking it on a new device. Nothing readable |
| 1072 | leaves the page. `ov-locker-core.js` is `openvibe-sdk/locker`, generated (`node scripts/build-locker-core.js`), and |
| 1073 | reproduces the SDK's test vectors exactly. See [docs/locker.md](docs/locker.md). |
| 1074 | |
| 1075 | ### Share (`ov-share.js`, `ov-share.css`) |
| 1076 | |
| 1077 | One 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 | |
| 1084 | Put `<button data-ov-share-sheet data-title="…">Share</button>` on a page: the navbar loads the sheet on the first |
| 1085 | press. 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 |
| 1091 | npm ci |
| 1092 | npm test # every test/*.test.js (fnm exec --using=22.22.1 npm test) |
| 1093 | npm test -- navbar # only matching files |
| 1094 | npm run size # raw + brotli (q11) size of every browser file |
| 1095 | node scripts/build-navbar-icons.js # the bar's icons and the ring solids, from glyphs.js |
| 1096 | node scripts/build-theme-loader.js |
| 1097 | ``` |
| 1098 | |
| 1099 | Tests 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 | |
| 1117 | CI runs on Node 22.22.1 and installs a tag-style tarball into an empty project. |
| 1118 | |
| 1119 | ## Releasing |
| 1120 | |
| 1121 | 1. 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`. |
| 1123 | 2. 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. |
| 1125 | 3. **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. |
| 1127 | 4. 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 | |
| 1131 | GitHub builds tag tarballs on demand. In practice they're byte-stable, but GitHub doesn't |
| 1132 | promise it. If `npm ci` ever reports `EINTEGRITY` for this package on an unchanged tag, check |
| 1133 | that the tag wasn't moved, then run `npm install` to refresh the lockfile. The content is the |
| 1134 | tag'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 |
| 1158 | const 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 | }); |
| 1168 | rt.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 | |
| 1178 | It 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 | |
| 1182 | The canonical home of what was `OpenVibe.Network/packages/openvibe-shared` (navbar, footer, |
| 1183 | themes, icons, legal pages, SEO helpers, notifications UI, panels): the OpenVibe Frame and the |
| 1184 | server helpers every site uses, published as immutable versioned artifacts instead of being |
| 1185 | vendored 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 | |
| 1213 | None: Shared implements no capability and holds no grant. Browser files call Network and Events as the |
| 1214 | signed-in person (their session cookie or a Network realtime ticket), and server modules call whatever |
| 1215 | their 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 |
| 1220 | need Chrome). What the release-guarding tests prove is listed under [Development](#development): size |
| 1221 | budgets, releases and the mixed-version matrix, built-in icons, generated blocks, browser bundles free of |
| 1222 | Node code, every export resolving, analytics privacy. The charter's two acceptance points hold: Live, |
| 1223 | Community, 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 | |
| 1228 | Reporting 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 | |
| 1239 | Nothing runs from this repository. A release is a git tag ([Releasing](#releasing)). OpenVibe.Network |
| 1240 | serves `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 |
| 1242 | can instead serve its own pinned copy (`openvibe-shared/serve`); either way each consumer pins a tag and |
| 1243 | ships it with its own deploy. |
| 1244 | A bad release is undone by pinning the previous tag; a released tag is never moved. |
| 1245 | |
| 1246 | --- |
| 1247 | |
| 1248 | Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers). |
| 1249 | |
| 1250 | The 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 |