openvibers/openvibe.shared README.md

README.md

main 92.6 KB · 1256 lines · markdown

OpenVibe.Shared

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.

Docs: https://openvibe.services · For AI: https://openvibe.network/llms.txt
Install and test: npm ci && npm test

Status: alpha, v4.237.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's package.json names
the tag it pins.
Plan: OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.3 and §16.6.
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).

This repository is the npm package openvibe-shared. package.json sits at the repository
root, so GitHub's tarball of a release tag installs as the package with no build step. It was
imported history-free from OpenVibe.Network/packages/openvibe-shared at Network f337eb6.
[docs/divergence-2026-09-22.md](docs/divergence-2026-09-22.md) records how the vendored copies
were reconciled first.

What's in it

| Kind | Files |
|---|---|
| Browser scripts, served at /shared/<file> (listed in files.js) | navbar.js, nav-icons.js, theme-loader.js, footer.js, notification-ui.js, 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 |
| 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 |
| 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() |
| Schemas | docs/schemas/analytics-event.v1.json (analytics/event.v1, exported as openvibe-shared/analytics/event.v1.json) |
| 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) |
| 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 |
| 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 |

Every module file has an explicit exports entry (no wildcards), so require('openvibe-shared/navbar'),
require.resolve('openvibe-shared/package.json') and the rest all resolve. It stays one package with
subpath exports rather than a set of @openvibe/* packages:
[docs/adr/0001-one-package-subpath-exports.md](docs/adr/0001-one-package-subpath-exports.md).

Consuming it

Start a new site
npm run create-site -- my-site

The command creates ./my-site with a pinned Shared dependency, a small Express server,
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.

The generated server renders each page with openvibe-shared/shell, so the doctype, SEO head and the
whole OpenVibe Frame come from one call instead of a hand-written layout:

const shell = require('openvibe-shared/shell');
res.type('html').send(shell.page({
    name: 'example', title: 'Example', description: '…', canonical: 'https://example.com/',
    summary: 'One line an AI can read',
    body: '<main>…</main>',
    features: { chat: { scripts: ['/js/chat.js'] } },
    routes: [{ path: '^/chat', features: ['chat'] }],
}));

See "The page shell" below for the rest of the options.

Agent discovery and Markdown twins

openvibe-shared/agent-layer mounts the agent manifest and public Markdown twins from
the same server renderer as HTML. See [the three-line mount and service descriptor guide](docs/agent-layer.md).

Server side: pin a release tarball
"dependencies": {
    "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0"
}

npm install writes the resolved URL and its integrity into package-lock.json, and npm ci
reproduces it exactly. Upgrade by changing the tag in the URL and running npm install. Never
hand-copy files into node_modules/ or a vendor/ directory.

const legal = require('openvibe-shared/legal');
const seo = require('openvibe-shared/seo');
const { AnalyticsTrackerPg } = require('openvibe-shared/analytics/pg');
SEO kit

openvibe-shared/seo exports the page head, sitemap, robots and llms.txt helpers, plus
the discovery formats below. The package root also exports them as seo. A site serves
/robots.txt, /sitemap.xml, /llms.txt and /llms-full.txt (templates/site has all four).

llmsFull({ site, summary, base, sections, maxBytes }) renders /llms-full.txt: the llms.txt
header, then ## <section> and, for each page, ### <title>, URL: <absolute url> and the page's
full plain text — text as given (newlines kept) or html stripped to text; a page with neither is
listed with its URL only. Input order is kept and bodies are never clipped. With maxBytes it stops
before the first page that would pass the limit and ends with
(truncated: N more pages at <base>/llms.txt). Sections with no pages are left out. (Sections in the
2.4.0 shape, { title, url, body } with maxChars/maxTotal, still render as before.)

const seo = require('openvibe-shared/seo');
const full = seo.llmsFull({ site: 'Example', summary: 'What Example publishes', base: 'https://example.com/',
    sections: [{ title: 'Guides', pages: [{ title: 'Start', url: '/start', text: 'Step one.\nStep two.' },
        { title: 'API', url: '/api', html: '<h1>API</h1><p>Call get().</p>' }]}],
    maxBytes: 500000 }); // serve as text/plain at /llms-full.txt

pageSummary({ title, summary, url, updated, facts }) is the AI-readable summary of a page. As a
string it is <meta name="ai-summary"> (the summary clipped to 160 like the description, which
headTags still owns), a WebPage JSON-LD tag (name, description, url, dateModified as ISO,
abstract; written with jsonLdTag) and, when facts (strings) are given, a
<noscript><section data-ai-summary> block with the title, summary and facts. Everything is
escaped. .head is the meta and JSON-LD on their own and .body the noscript block, for pages that
put facts in <body>; .meta, .jsonLd and .html are the 2.4.0 values.

const summary = seo.pageSummary({ title: 'Start', summary: 'How to set up Example in five minutes',
    url: 'https://example.com/start', updated: '2026-10-01', facts: ['Takes five minutes', 'No account needed'] });
`<head>${seo.headTags({ title: 'Start', description: 'How to set up Example', canonical: 'https://example.com/start' })}
${summary.head}</head><body>${summary.body}…`; // or `${summary}` in <head> when there are no facts
const site = { name: 'Example', url: 'https://example.com/' };
const feed = { title: 'Example', link: site.url, description: 'Recent guides', language: 'en',
    selfUrl: '/feed.xml', items: [{ title: 'Guide', link: '/guide', guid: 'guide-1',
        published: '2026-10-01T12:00:00Z', description: 'A short guide', content: '<p>Full text</p>', author: 'Example' }] };
seo.feedXml(feed, { format: 'rss' });  // serve as application/rss+xml
seo.feedXml(feed, { format: 'atom' }); // serve as application/atom+xml
seo.feedLinkTags({ rss: 'https://example.com/feed.xml', atom: 'https://example.com/atom.xml', title: 'Example' });

jsonLd also builds product, review, aggregateRating, imageGallery (an ImageObject
array) and videoObject (the same VideoObject shape as video). Feed URLs should be absolute,
or relative to the absolute link. feedXml throws on a format other than rss or atom. Without
updated, the feed uses its newest item date, or the current time when there are no items. Without a
guid, an item's link is its RSS guid (isPermaLink="true") and its Atom id. The 2.4.0 item names url,
id and summary still work. llmsFull resolves page URLs against base (or site.url).

Images and responsive pictures (assets.js)

openvibe-shared/assets gives an image the same content-addressed URL the rest of the estate uses —
?v=<first 12 hex of sha256>, which cache-policy and every CDN already treat as immutable — and
renders the <picture> around it, without the page hand-writing a srcset:

const fs = require('fs');
const assets = require('openvibe-shared/assets');
const hash = assets.hash(fs.readFileSync('public/img/hero.png'));   // 12 hex
assets.url('/img/hero.png', hash);                                  // /img/hero.png?v=<hash>
const html = assets.picture({
    src: '/img/hero.png', hash, alt: 'The dashboard', width: 1600, height: 900,
    sizes: '(max-width: 800px) 100vw, 800px',
    sources: [
        { type: 'image/avif', entries: [{ src: '/img/hero-800.avif', hash: h800, w: 800 },
                                        { src: '/img/hero-1600.avif', hash: h1600, w: 1600 }] },
        { type: 'image/webp', entries: [{ src: '/img/hero-800.webp', hash: w800, w: 800 },
                                        { src: '/img/hero-1600.webp', hash: w1600, w: 1600 }] },
    ],
});
// <picture><source type="image/avif" …><source type="image/webp" …><img src="/img/hero.png?v=…" alt="…" …></picture>

hash(data) is sha256 over bytes (Buffer/string), truncated to the estate's 12 hex. url(src, hash) appends ?v=, uses & after an existing query, replaces an old ?v= instead of stacking it,
and returns the path unchanged when there is no hash — so a site with no build step keeps its
existing asset paths. srcset(entries) builds one from [{ src, hash?, w? | x? }].

picture(o) orders the <source>s itself — AVIF, then WebP, then any other type, with the caller's
order kept inside a format — and always puts the fallback <img> last. With no usable source it
renders a plain <img>, never an empty <picture>; with no src it renders nothing. alt, type,
srcset, sizes, media, class, id, loading (lazy), decoding (async) and fetchpriority
all pass through escaped, and a fixed attribute order means the same input renders the same bytes.

For variants named hero-400.avif, hero-800.avif, and their WebP equivalents, pass
widths: [400, 800] with src: '/img/hero.jpg'. picture() derives both srcsets and keeps the
original JPEG as the <img> fallback. Supply widths only for variants the site has generated.
hashAsset(bytes) is the same 12-hex content hash used by serve.hashOf(); hash() remains an alias.

To copy a directory of static files to hashed filenames, run:

openvibe-build-assets public/img dist/img

The command writes dist/img/manifest.json, mapping paths such as hero.jpg to
hero.<hash>.jpg (and preserving subdirectories). cache-policy.isHashed() recognises these
names as immutable. Shared does not transcode images: generate AVIF and WebP variants in the
site build before using widths, or pass existing variants through sources.

Performance budgets (perf-budget.js)

measure({ base, path }) weighs a page's first load from the running server (HTML, same-origin
scripts and stylesheets, raw and brotli) with no browser. measure({ ..., browser: true }) then
opens the page in Chrome through browser-harness, clicks once at the viewport centre and adds
cwv: { lcpMs, inpMs, cls }, so check() takes Core Web Vitals budgets as well. The shared
test workflow needs no extra step for this, because ubuntu-latest has /usr/bin/google-chrome.
Run the check from extra:

jobs:
  test:
    uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@<sha>
    with:
      extra: node scripts/perf/cwv.js
// scripts/perf/cwv.js: start the site, measure the home page in Chrome, fail over budget.
const { measure, check, format } = require('openvibe-shared/perf-budget');
const server = require('../../server/app').listen(0, '127.0.0.1', async () => {
    const m = await measure({ base: `http://127.0.0.1:${server.address().port}`, path: '/', browser: true });
    const over = check(m, { htmlBrotliKB: 30, jsBrotliKB: 90, lcpMs: 2500, inpMs: 200, cls: 0.1 });
    console.log(format(m, over));
    server.close();
    process.exitCode = over.length ? 1 : 0;
});
Metrics and readiness (server)
const release = require('openvibe-shared/release').createRelease({ service: 'community' });
const m = require('openvibe-shared/metrics').instrument(app, { service: 'community', release: release.release });
// before any route: HTTP golden signals by route template, process metrics, release_info,
// and GET /metrics for direct loopback callers only (404 through a proxy)
m.registry.gauge({ name: 'jobs', help: 'Jobs by state', labelNames: ['state'], collect: () => rows });
// collect may be async (a database read): /metrics awaits it, up to 2 s, and leaves out one that fails.
m.registry.gauge({ name: 'docs', help: 'Documents', collect: async () => (await db.one(sql`SELECT count(*)::int AS n FROM docs`)).n });
// instrument() also routes an async handler's rejection to next(err) (openvibe-shared/express-async): Express 4
// would leave it unhandled, and Node 22 ends the process on an unhandled rejection.

const ready = require('openvibe-shared/ready').createReadiness({
    service: 'community', release: release.release,
    checks: [
        { name: 'db', required: true, check: () => db.prepare('SELECT 1 AS ok').get().ok === 1 },
        { name: 'media', required: false, cacheMs: 30000, check: async () => (await ping()) || 'unreachable' },
    ],
});
app.get('/api/ready', ready.handler);   // 503 only when a required check fails; optional → degraded

A check fails when it throws, times out, returns false, a string (the reason) or
{ ok: false, error }. Name a dependency required only when the service really cannot serve
without it.

A check that verified nothing (its feature is not configured, its dependency is switched off)
returns skip('not configured') (require('openvibe-shared/ready').skip, or { skipped: reason }),
never true: it reports status skipped with the reason and is listed in skipped. No fake green
(roadmap WS-Q task 7): a skipped required check makes the service degraded, and a service is
ready only when at least one check really passed.

Configuration (server, WS-C task 7)

openvibe-shared/config keeps one namespace of a service's configuration as immutable revisions
(common.config-snapshot@1) in the service's PostgreSQL database. Pass an openvibe-sdk/db handle.
Several namespaces share the config_snapshots and config_keys tables, created by the service's
migrations from config.configSchema(). createConfigStore returns a promise of the ready store.
Its database methods are async; get() and revision() stay in memory.

const config = require('openvibe-shared/config');
const settings = await config.createConfigStore({
    db, service: 'live', namespace: 'live.site_settings',
    schema,                        // JSON Schema subset: type, enum, const, required, properties,
                                   // additionalProperties, min/max*, pattern, items, uniqueItems, multipleOf
    validate: (values) => true,    // or false, a message, a list, { valid, errors }; synchronous
    classify: (key) => (/api_key|secret|token/.test(key) ? 'secret' : undefined),  // or { key: class }; unknown = internal
    defaults,                      // revision 1 of a new namespace, and under every revision
    legacy: async () => config.fromRows(await db.all('SELECT * FROM site_settings')),  // revision 1 instead
    onActivate: async (values, previous, { restoring }) => { /* apply it; throw to refuse */ },
    keks: { current: 'k2', keys: { k1, k2 } },   // 32-byte Buffers; or OV_CONFIG_KEKS / OV_CONFIG_KEK_CURRENT
    onChange: async ({ namespace, keys, actor, revision }) => { /* the operator alert */ },
    keep: 50, log: console, now: () => new Date(),
});
settings.get();                    // defaults overlaid with the active values: frozen, in memory
settings.get('max_bitrate_kbps');  // one value; never a database read
settings.revision();               // the active revision, or null
await settings.propose(values, { actor, reason, merge, unset });   // validated, stored as proposed (redacted snapshot)
await settings.activate(revision, { actor });
await settings.apply(values, { actor, reason, merge, unset }); // propose + activate
await settings.rollback({ actor, reason, to });                 // a new revision copying the previous good one (or `to`)
await settings.lastKnownGood(); await settings.history({ limit, before }); await settings.snapshot(revision); await settings.summary();
await settings.import(legacy);           // revision 1 from a legacy source when the namespace has none
await settings.reload();                 // another process changed it: re-read the active revision
await settings.setSecret(key, value, { actor, reason });   // the write-only path; returns the marker
await settings.deleteSecret(key, { actor, reason });       // removes it as a new revision
await settings.secretMarker(key);        // fingerprint, hint, set_at, set_by, bound_to (or null)
await settings.reseal();                 // plaintext and old-KEK rows to the current KEK
settings.sealed; settings.kek;           // false / null without a KEK
  • Values are a complete snapshot; merge: true starts from the active values and unset removes
    keys (a key with a default falls back to it). A redaction marker sent back ({ redacted: true, fingerprint } as the routes show it) keeps the value it stands for, so a form can round-trip
    secrets; a marker whose fingerprint is not the current value's is refused (422).
  • Activation is serialized per store. The switch (new revision active, old superseded) is one
    transaction; then onActivate(values, previous) runs. If it throws, the previous revision is active
    again in memory and in the database, the new one is rejected with the error (scrubbed of secrets),
    onActivate runs once more with the restored values ({ restoring: true }), and a
    ConfigError (config.activation_failed, 422) is thrown with the original as cause. A proposal
    whose base is no longer active is config.stale (409). A process that stopped mid-activation boots
    on the last-known-good.
  • Last-known-good is the newest revision that activated successfully. Pruning keeps the newest
    keep revisions and never the active one or the last-known-good.
  • Secrets leave only redacted (history, summary, routes, the return values) and never reach the
    log or a stored error. A redacted value is { redacted: true, fingerprint }: HMAC-SHA256 of its
    canonical JSON under a random 32-byte key made for the namespace on first use and kept in
    config_keys. The key is never logged; with a KEK it is stored there wrapped, so a database dump
    has the fingerprint but no key that reproduces it. Equal fingerprints mean an unchanged value. A
    snapshot's checksum is the sha256 of its values as shown. Losing the key row only means a new key:
    older markers stop round-tripping. A key classified secret later is redacted in older revisions too.
    Errors name the rule, never the value.
  • Sealed secrets (D11). With a KEK, a secret-class value never enters values_json: it is stored
    as a versioned envelope (v1:<kek_id>:<fingerprint>:<hint>:<bind>:…) that a random 256-bit
    data key sealed with AES-256-GCM, itself sealed by the current KEK (AES-256-GCM), with the AAD
    service|namespace|key|bind on both. get() opens the envelope for the service (the plaintext
    lives in memory exactly as before); a summary, snapshot, history entry or rollback shows a marker.
  • The KEKs come from opts.keks ({ current, keys: { k1: Buffer } }) or, by default, from
    OV_CONFIG_KEKS=k1:<base64 32 bytes>[,k2:…] and OV_CONFIG_KEK_CURRENT=k2. A value that does not
    decode to exactly 32 bytes refuses startup with an error that names the variable and the KEK id,
    never a byte of the key. Only node:crypto is used.
  • No KEK means today's behaviour, untouched and unflagged: sealed: false, kek: null, the value
    stays in the row. reseal() moves plaintext secrets and envelopes under an older KEK to the
    current KEK revision by revision (it never resurrects one whose bind changed) and reports
    { resealed, current, plaintext, locked, errors }.
  • The fingerprint key. With no KEK, config_keys.hmac_key is today's plain 32-byte namespace
    key. With a KEK it is the same key sealed under the current KEK there (AES-256-GCM, AAD
    openvibe-config-fingerprint|<service>|<namespace>), never the key itself; the store unwraps it
    in memory and rewraps it under a new current KEK on boot. The key itself never changes, so markers
    and checksums do not move across a KEK rotation, or when a KEK is first enabled on an existing
    namespace. This is the wrapped-key alternative to deriving it from k1: a wrapped key does not
    depend on k1 surviving a rotation, and enabling a KEK preserves every marker written before it.
    reseal() recomputes each resealed envelope's fingerprint with this stable key.
  • Rotation. Add the new KEK next to the old one and set it current. The first boot rewraps the
    fingerprint key (the key is unchanged); reseal() moves the envelopes to the new KEK and
    recomputes their fingerprints; then the old KEK can be removed.
  • A v1:… envelope string is reserved for a stored secret: a value that already parses as an
    envelope is carried as one (that is also why a copied envelope never opens under another key).
  • Bind, pins and the summary schema. A secret property's x-bind: "<key>" puts that key's value
    (e.g. a provider's base_url) into the AAD: changing it locks the secret (get() gives undefined)
    until the owner enters it again for the new value. x-env: "NAME" pins a key to the process
    environment: the summary shows the env value with pinned: true, and a write to it is 409
    config.pinned_in_env. summary()'s classification names every key of values and, since
    4.125.0, every property of the schema, set or not, so a form knows a key is a secret before it is
    entered. summary() now carries sealed, kek, pins, secrets (each secret's
    marker with its four-character hint, set_at, set_by, bound_to) and schema, the JSON
    Schema to draw the form from; a secret property's default, examples, enum and const are
    removed from that copy. The annotations are title, description, x-group, x-env, x-danger,
    x-bind and x-unit (nano_usd, ms, bps, seconds).
  • The owner's Setup page and clear settings (4.125.0). Network's admin panel draws every namespace
    from its summary schema, and lists on its Setup tab every field only the owner can give. These
    annotations are checked when the store is created (a wrong shape refuses it, naming the rule) and pass
    through to the summary as written:

| Annotation | Shape | What the panel does with it |
|---|---|---|
| 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. |
| x-required-for | "<feature>" | Setup says Needed while the key is unset, naming what waits on it. Without it an unset key is Optional. |
| 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. |
| x-advanced | true | The field folds under a counted "Advanced settings (N)". |
| x-example | a string, number or boolean | The input's placeholder. Never a real value. |

A secret-class property loses x-example and x-enum-labels in the summary, with default,
examples, enum and const (say a key's shape in get_it_how instead: "It starts with sk-").

  • config.lintSchema(schema) returns [{ path, key, message }], empty when every field reads
    clearly: a field without a title, with a description under 30 characters (what it does, what
    the default means, an example), or a choice (enum, or items.enum) without an x-enum-labels
    label for every option is one entry. Fields are the top-level properties and the properties of an
    object field; a summary is read through its schema. Run it in the service's own test on each
    namespace it publishes: assert.deepStrictEqual(config.lintSchema(SCHEMA), []).
  • Hooks. onChange({ namespace, keys, actor, revision }) runs after every successful activation
    (never for a proposal or the seed), and a throwing hook is logged and never refuses the revision.
    requireFresh(req, { namespace, keys }), an adminRoutes option, runs before a secret write, a
    rollback that changes a secret or an x-danger key, and a danger write; a false answer is 403
    config.fresh_required.
  • config.fromRows(rows, { prefix, type }) reads typed key-value rows (number, boolean, json,
    anything else a string), as Live's and Network's site_settings and Media's media_settings keep them.
config.adminRoutes([settings, other], {
    requireAdmin,                             // a middleware or a list; guards every handler
    actor: (req) => ({ type: 'user', id: req.user.subject_id }),  // default: req.actor, req.subject, req.user.subject(_id)
    authorize: (req, { action, namespace, keys }) => action === 'read' || isOwner(req.user) || !keys.some(isSecret),
    requireFresh: (req, { namespace, keys }) => isFreshSignIn(req),  // secrets and x-danger keys; false → 403
    test: async ({ namespace, key, value }) => ({ ok, message }),    // optional: the Setup page's Test button
    basePath: '/api/admin/config',
}).mount(app);                                // or { router }, the handlers, or handle(req, res, next) on plain http

| Route | Answer |
|---|---|
| GET /api/admin/config | { namespaces: [{ service, namespace, revision, values, classification, sealed, kek, pins, secrets, schema, active, last_known_good }] }, values redacted |
| GET /api/admin/config/:namespace | one of them |
| GET /api/admin/config/:namespace/history?limit&before | { namespace, snapshots, next_before }, newest first |
| 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 |
| POST /api/admin/config/:namespace/rollback { to?, reason } | the new active snapshot; 409 config.no_previous, config.not_good |
| PUT /api/admin/config/:namespace/secrets/:key { value, reason } | the marker { redacted, fingerprint, hint, set_at, set_by, bound_to }; write-only |
| DELETE /api/admin/config/:namespace/secrets/:key { reason } | the empty marker; a new revision without the key |
| 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 |

Errors are application/problem+json (errors.problem@1). Mount it after a JSON body parser, or
let it read the body itself. A normal POST cannot write a secret-class value: the two /secrets
routes are the only write path, and they answer a marker, never the value.

The test hook is how the Setup page's Test button reaches a provider. test({ namespace, key, value, values, store, req }) gets the stored value (a secret opened) on the server, calls the
provider, and answers { ok, message }, or null when it does not test that key. The route answers
that in one line of at most 300 characters, scrubbed of every secret value of the namespace (a
client id's tester may hold the client secret too), so it never echoes a key; a tester that throws
is a failed test with its scrubbed message, and one that runs past testTimeoutMs (15 s) is a failed
test as well. authorize sees the action 'test', and the route needs an actor like a write. It
changes nothing and keeps nothing: the caller (Network's proxy) records the result.

Releases: /release.json, open tabs and the mixed-version test (ADR-016, Track R)
const release = require('openvibe-shared/release').createRelease({
    service: 'community',
    root: path.join(__dirname, '..'),
    components: {
        styles: { kind: 'style', assets: ['/css/app.css', '/css/pages.css'] },   // swapped in place
        pages: { kind: 'content', files: ['server/web/pages'] },                // regions re-fetched in place
        shell: { kind: 'script', assets: ['/js/app.js'], files: ['server/web/layout.js', 'public/index.html'] },
    },
    contracts: { 'community.web-api': { version: '1.4.0', accepts: '^1.0.0' } },   // what the pages call
    schemaGeneration: () => migrations.generation(),                            // optional
});
const m = require('openvibe-shared/metrics').instrument(app, { service: 'community', release: release.release });
release.mount(app, { registry: m.registry });   // GET /release.json, POST /release-metrics → /metrics
  • Components. style (stylesheets, assets are URL paths under publicDir, default
    root/public), content (server-rendered regions), script (code the page runs) and server
    (code only the server runs). A component's version is the hash of its files. shell is the
    catch-all script component: list in it every client-facing file no other component lists
    (layout templates, page scripts). An undeclared shell is versioned by the release, so every
    release reloads, which is the 1.4.0 behaviour. The asset map's URLs are <path>?v=<12 hex of sha256>, which matches Live's server/web/assets.js; pass assetUrl(path, hash) for another
    scheme.
  • Contract ranges. version is what this release speaks. accepts is the range it still
    serves from the other side (^1.0.0 → >=1.0.0 <2.0.0); keep it wide enough for the previous
    release, which is the 24-hour window. role: 'consumes' marks another service's contract that
    this one calls.
  • What is served is limited to the fields your installed openvibe-contracts
    registry.release-manifest schema declares: 1.0.0 until you pin openvibe-contracts ≥ v0.31.0.
    release.validate() checks the served manifest with your contracts (put it in a test), and
    release.full() is everything. A static-only release switch without a restart: pass
    recheckMs, or call release.refresh().
  • Markup for in-place content: <main data-ov-content="pages" data-ov-rev="<revision>">…</main>.
    The tab re-fetches the page URL (or data-ov-src) and takes the element with the same
    data-ov-content. Scripts and inline handlers are dropped, so rebind on ov:content-updated.
    A stylesheet whose URL path is not the asset's logical path needs data-ov-asset="/css/app.css".
    Mark anything that must not be replaced with data-ov-protected.
  • Client generations and the shell (manifest 1.2.0, openvibe-contracts ≥ 0.60.0). Pass
    createRelease({ clientGeneration, minClientGeneration, shell }). clientGeneration (an integer, or a
    function returning one) goes up only when an older client can no longer work against this server; the
    meta tag then carries data-generation. minClientGeneration (or env MIN_CLIENT_GENERATION) makes
    every tab below it reload at the next safe moment (reloaded: required), without waiting for the
    window. shell names the components that form the page shell (['shell', 'nav']); the manifest serves
    { version, components }, and a tab whose shell version differs is prompted, never updated in place.
  • The update matrix (WS-P task 8), what an open tab does with each kind of change, each row tested
    (test/release-update.test.js, test/release.test.js):

| Change | What the tab does | Events / counts |
|---|---|---|
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| Shell, router or any script (script and anything not in place) | prompts once; reloads only when it must | prompted: optional |
| 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 |
| Server only (server) | nothing to do in the tab | applied: server |

  • Release notifications (1.17.0). When OpenVibe.Host announces a release, it publishes
    host.release.published (public, ovhost deploy / ovhost announce). release-watch opens one
    EventSource per tab, without credentials, on
    https://openvibe.events/realtime/stream?topics=host.release.published.
  • Which events count. Only events whose payload.service is the page's service: OVReleaseConfig.service,
    the meta tag's data-service, else the service of /release.json. The tab ignores the release it runs
    or already knows (a hex prefix counts as the same release) and repeats of an event, and runs its usual
    check once, after a random 0–20 s delay.
  • Bursts. At most one check per 30 s; anything that arrives in between collapses into one more.
  • Account changes. The events are public, so an account switch changes nothing and never opens a
    second stream. /release.json is read without credentials, so whether a tab updates never depends on
    who is signed in.
  • Hidden tabs. A tab hidden for 5 minutes closes its stream, and reopens it on return with
    last_event_id.
  • Failures. Errors back off from 30 s to 15 minutes. After 6 failures in a row only the poll is left,
    until the browser goes back online. Back online (the online event), the stream reconnects at once from
    its cursor, whether it had given up or was still backing off, and one check runs
    (test/release-offline-resume.test.js).
  • Where to connect. The default applies on https pages only. OVReleaseConfig.eventsUrl or the meta
    tag's data-events sets another URL, and false or "off" turns it off. Polling (focus, visibility,
    every 10 minutes) is unchanged.
  • Origins. Events answers https://*.openvibe.* origins, plus those in its REALTIME_CORS_ORIGINS.
  • CSP. The site's CSP connect-src must allow https://openvibe.events. Otherwise the
    browser refuses the stream once, and release-watch stops (state blocked) and keeps polling.
  • State. OVRelease.state().realtime reports { state, service, url, events, ignored, checks, failures, lastSeq }.
  • Metrics (D46): release_client_updates_total{outcome,reason}, where outcome is prompted
    (reason optional, required, window or contract), applied, reloaded, deferred or
    failed. release_client_sessions{generation} counts open tabs heard from in the last 12
    minutes, current when they run the release this process serves and older otherwise: each tab
    beats every 5 minutes with a random id it keeps in memory only (never tied to an account) and
    says when it ends. The tab finds the endpoint in the manifest's metrics_url (set by mount),
    the meta tag's data-metrics, or OpenVibeNavbar.init({ releaseWatch: { metricsUrl } }).

Mixed-version test: capture the previous release's /release.json (its full() form) and the
calls its pages make as fixtures, then:

const compat = require('openvibe-shared/release-compat');
await compat.assertMixedVersion({ releases: [
    { name: 'N-1', manifest: require('./fixtures/release-prev.json'), client: compat.replay(require('./fixtures/calls-prev.json')) },
    { name: 'N', manifest: release.full(), server: async () => ({ url, close }), client: compat.replay(calls) },
] });

It fails when adjacent releases' ranges don't overlap both ways, when a pair the manifests call
compatible fails against the real server, and when an incompatible pair would not make the tab
reload. compat.openPage({ html, serve }) runs release-watch in a linkedom page (install
linkedom as a devDependency) for in-place tests of your own markup. globals adds window properties,
such as a fake EventSource, and timeouts(), fireTimeouts(filter) and dispatch(type) drive the
page's timers and events.

Analytics (server, ADR-021)
const { AnalyticsTrackerPg, analyticsSchema, pruneRawEventsPg } = require('openvibe-shared/analytics/pg');
// Put analyticsSchema() in the service's database migrations.
const analytics = new AnalyticsTrackerPg(db, 'live', {
    retention: { days: 30 },
    paramPrefixes: ['avatar'],
    pathRules: [[/\/by-username\/[^/?#]+/gi, '/by-username/:username']],
    countBotApiAtIngest: true,   // optional: bot API calls go into the rollups, not one raw row each
});
app.use(analytics.middleware());
// A service may call pruneRawEventsPg(db, { days: 30 }) in its own job runner.

A raw event has no IP, user or subject id, or city. It has a route template (never a raw URL), a
referer origin, a user-agent class, a rotating session id and a country. Raw events are kept for
30 days, and the rollups for longer. A request with Sec-GPC: 1 or DNT: 1 is not recorded at
all. The row shape is analytics/event.v1 (docs/schemas/analytics-event.v1.json), and
require('openvibe-shared/analytics/event').checkRow(row) checks a stored row against it. PostgreSQL retention uses pruneRawEventsPg(db, { days }); retention.MAX_DAYS is 30.

countBotApiAtIngest (off by default) stops writing a raw row for a bot's API call. At flush the
call increments analytics_bot_api_hourly and analytics_bot_api_daily by endpoint group (the
first three path segments of the route template), status class (2xx/3xx/4xx/5xx/other) and
bot family (the row's bot_type: known_crawler, no_useragent, rate_limit, honeypot_hit).
The hour's and day's rollups add those counters to raw rows when they are computed, so a day closes
to the same api_calls, bot_hits and error_count a raw-row rollup gave; the counters are totals,
so recomputing a day never counts a call twice. Page views by bots always keep their raw row, and a
call that cannot be counted (the bounded in-memory map is full) falls back to a raw row. The bot
breakdown and status classes in getStats and getBotAnalysis include the counters.

Browser side: two options
  1. From the Network (what most pages do today):
    <script src="https://openvibe.network/shared/navbar.js" defer></script>. Network serves the
    browser files of the version it pins, from its own node_modules/openvibe-shared.
  2. From the site's own origin: the page's first paint doesn't depend on the Network being
    up, and the files match the version the site's server pins. Serve exactly the browser files
    from node_modules:
   const shared = require('openvibe-shared/files');
   app.use('/shared', (req, res, next) => {
       const name = path.basename(req.path);
       if (!shared.isBrowserFile(name)) return next();
       res.setHeader('Access-Control-Allow-Origin', '*');
       res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
       res.sendFile(shared.path(name));
   });
   // Web Push worker: same origin, scope "/"
   app.get('/openvibe-sw.js', (req, res) => { res.setHeader('Service-Worker-Allowed', '/'); res.sendFile(shared.path('openvibe-sw.js')); });

Never express.static the whole package directory. It also holds server code, tests and
scripts.

Icons: OpenVibe's own markup (4.231.0). Every icon is an OpenVibe glyph (openvibe.design/icons):

<i data-ov-icon="live"></i>                     <!-- any glyph by name; a title names it to screen readers -->
<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 -->
<i data-ov-brand="github"></i>                  <!-- a brand logo (brands.js, Simple Icons) -->

ov-glyphs.js draws them (the navbar loads it on any page that shows one); OVGlyphs.icon('live', { title }) makes
one in a page, require('openvibe-shared/glyphs').markup('live') writes one on a server. Font Awesome markup is still
drawn with the glyph its name maps to while sites move: node node_modules/openvibe-shared/scripts/fa-to-ov.js --write rewrites a repository's static markup and lists what a person has to change (names built at run time, JS
class strings, CSS rules, animations).

navbar.js and nav-icons.js. navbar.js draws the icons for its own rows inline. The rest
of the bar's set (OpenVibe glyphs, keyed by the names sites pass in links, chips and menu sections) lives in
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
navbar.js was loaded from
, as nav-icons.js?v=<first 12 hex of its sha256>. It falls back to
https://openvibe.network/shared/ when navbar.js is bundled or inlined.

So wherever you serve navbar.js, serve nav-icons.js beside it. The ?v= matches the file's
content hash, so it can be cached as immutable.

Until the glyphs arrive, each icon's slot has the right width and nothing moves. If the file
can't load, those icons fall back to <i class="fa-solid …">. Two options change this:

  • OpenVibeNavbar.init({ iconsUrl }) points navbar.js at another URL.
  • Loading nav-icons.js before navbar.js puts every glyph in the first frame.

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).

  • 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).
  • 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.
  • 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.
  • 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.
  • Retries and giving up.
  • Errors back off from 2 s, with jitter. After ten failures in a row the bell polls only, until the browser comes back online.
  • A tab hidden 5 minutes closes the stream and resumes it when shown.
  • A Content-Security-Policy refusal, a signed-out 401/403, or Network's REALTIME_TICKETS=off (503) stop the stream, and the bell polls.
  • 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.

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,
a × and a scrim; the page underneath does not scroll).

  • Tabs: All · Mentions · Live · Deals & alerts · System · Agents. Network defines what each holds
    (GET /api/notifications?tab=) and counts them (GET /api/notifications/unread-by-tab). A Network without the
    counts shows the tabs without numbers. Arrow keys, Home and End move between tabs.
  • Unread filters any tab; search still searches.
  • Rows: an unread dot on the left; the sender's avatar or the category's line glyph; a thumbnail on the right from
    rich_content.image, thumbnail_url or thumbnail (https, and not the avatar again); grouped repeats with ×N.
  • Touch: a row swipes right to mark it read and left to clear it (Network's dismiss); the buttons on the time line
    do the same with a mouse or keyboard.

The Action Center (4.31.0). The ✦ beside the bell (desktop; on phones, the bell's Agents tab links to it) opens
every agent task in one place: Running (re-read every 15 s while open), Waiting for you (Approve and Decline right
there), Scheduled, Done (cost, steps, the page) and Failed (why). It reads Network's GET /api/v1/me/actions
(network.me-actions-result@1, contracts 0.229.0) and acts through POST /api/v1/me/actions/:service/:id/approve|decline.
action-center.js loads from beside notification-ui.js on the first click (serve it there; it is in files.js).
OpenVibeNotifications.init({ actions: false }) leaves the ✦ out; OpenVibeNotifications.openActions(anchor) opens it
from a site's own control.

The menu's Chat and Vibe (dock.js; 4.51.0, one UI since 4.220.0). The OpenVibe menu holds the person's
conversations (Chat: OpenVibe.Chat's /api/dm/conversations, read and answered in place, Edit as a pencil at the
bubble's corner) and Vibe (Vibe's conversations, the person's coding tasks and their agents, by a Chat · Code ·
Agents switch, with a timer button at its end). The panels are dock.js's, placed in the menu with
OpenVibeDock.mountInto(host, { tab }) on every screen. There is no floating Dock, no pill and no window of its own
(the owner, 2026-11-12: "there should only be the one unified UI"). For a signed-in person navbar.js loads dock.js
from beside itself (it is in files.js); it reads the person's state from the Network so it follows them from site to
site:

  • reads: GET /api/v1/me/dock (network.dock-result@1); writes: one debounced PUT (network.dock-state@1): the
    place last open, the open chat, the Vibe conversation and the timer;
  • what needs saying is a status on the bar's menu button: a breathing dot while agents work, an amber one when one
    needs you (the button then opens on Agents), a timer's minutes in a small label under it;
  • a 404 from a Network without the route keeps the status away for a day.

OpenVibeNavbar.openDock('chats' | 'vibe' | 'code' | 'agent'), OpenVibeDock.open({ tab }), ask({ text }) and
share({ text, title }) open the menu there (OpenVibeSearch.open({ door }) or OpenVibeSearch.door(id) when it is
open). dock: false leaves Chat and Vibe out of the menu; dockApiBase and dockChatBase override the Network and
Chat for tests and staging.

Quiet hours (4.30.0). During the person's window (GET /api/notifications/quiet-hours, read at start and every ten
minutes), toasts and sounds wait; the bell and the badge still fill, and a critical notice still shows.

Boost (boost.js): smooth page moves for a server-rendered site

Every page stays a normal page (it works without JavaScript and crawlers read the same HTML); boost only makes a same-site
move feel like an app. Mark the page and its changing part, then start it:

<meta name="ov-boost" content="<site>@<release>">   <!-- the release id: a different one means a normal page load -->
<main id="main">…</main>
<script src="/shared/boost.js" defer></script>
<script>addEventListener('DOMContentLoaded', () => OVBoost.start({ main: '#main' }));</script>

A click on a same-site link (no modifier key, no target, no download, not data-no-boost, not /auth/, /api/ or a
file) fetches the page (already prefetched on hover, focus or touch), loads its new stylesheets first, swaps <main> in
place with route-transition.js's bar and fade, updates the title, description, canonical, Open Graph, Twitter and
JSON-LD tags, runs the new main's inline scripts once, pushes history, restores scroll on back and forward, and focuses
the new content. Another release marker, other external scripts, a non-HTML answer, a redirect off the site, an error
or a timeout is a normal load. Listen for ov:boost:before and ov:boost:load to tear down and wire widgets.
OVBoost.start(opts) returns { go(url), stop(), stats() }; options: main, prefix (ov), exclude (path
prefixes), timeout (8000 ms), prefetch (true), fx (true).

Soft pages (soft.js): prefetch on intent and cross-document view transitions

The small, safe part of the "instant pages" work, for a server-rendered site that does not want a full soft-navigation
contract yet. Load it in <head>, after theme-loader.js, as a plain script (not deferred — the arriving page must
have its @view-transition opt-in before it first renders):

<script src="/shared/soft.js"></script>

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
2G/3G connection, never sign-in/out, auth/API, a download, a query URL, an in-page anchor or data-ov-no-prefetch, at
most four at a time. Chrome/Edge get a conservative Speculation Rule for the press (the navbar's cross-site
[data-ov-net-link] warm-up is kept); other browsers use <link rel=prefetch>, and no prerender is ever added. The
navbar's mark starts drawing at the press (OVMark.navStart('page')) and finishes on the next page's first paint
(navEnd('page')). Cross-document view transitions hold the shared bar still and cross-fade the rest of the page
(180 ms; Calm 120 ms; motion Off has none). A page with its own script[type="speculationrules"] keeps it.

window.OpenVibeSoft.on(), .off() and .exclude(selector) turn it on, off, or add links to the exclusion. The
research behind it is in the network's agent research notes, agents/research/soft-pages.md.

The web runtime (web-runtime.js, route-transition.js)
openvibe-shared/web-runtime.js — a site's feature loader and route lifecycle (roadmap WS-P task 6), taken out of
OpenVibe.Live's public/js/ov-loader.js so every OpenVibe site loads its code the same way.

  const rt = OVWebRuntime.create({ features, routes, versions, ... });   // registry: see README "Web runtime"
  rt.load('broadcast')      → the feature's stylesheets (its dependencies' too) and markup fragment together, the markup
                              inserted once the styles are in (never unstyled), then its dependencies and scripts (in
                              order) and its `after` hook; once, cached; a failed load can be retried.
  rt.route(path)            → every feature the path's routes name, as one promise.
  rt.enter(section, work, { label })
                            → a page while its route loads: hidden (keeping its space) until its styles and code are in;
                              route-transition.js (loaded on the first move) adds the top bar, a "Loading <label>…" line
                              after 150 ms with the phase, and the fade-in. rt.status(text) changes that line.
  rt.prefetch('channel')    → download only (link rel=prefetch), never execute; skipped on Save-Data, 2g and < 2 GB
                              of memory, and within a budget (count, and bytes where sizes are known).
  rt.nextRoute() / rt.gen() / rt.isCurrent(gen)
                            → route generations: a loader that awaited something can tell whether the visitor moved on.
  rt.scope()                → timers, listeners, observers, fetches (AbortSignal) and child scopes owned by the current
                              route, released by the next nextRoute(). Anything registered on a scope whose route has
                              ended is refused (and counted), never left running.
  rt.diagnostics(), rt.leaks()
                            → loaded, failed and rolled-back features, duplicate tags, late registrations, what the
                              current route holds, and the prefetch budget spent.

Asset groups are transactional: a feature counts as loaded, runs its hook and fires `<prefix>:feature` only when
every script loaded. When one fails, the stylesheets that attempt added (and no other feature wants) are taken out
again and the failed script's tag is removed, so a retry fetches exactly what is missing. Scripts that did run are
kept, so nothing runs twice.
Tags the server already put in the document count as loaded. Global stubs (installStubs) keep inline onclick
handlers working before their feature has loaded.

A server-rendered page puts these tags in its <head> itself, with the SEO head and the OpenVibe
Frame around them: openvibe-shared/shell (shell.page(o)) composes exactly that — see "The page
shell" below.

Installable sites

Every page shell.page() renders is also an installable web app, with no per-site code. When the page's
service has a pre-rendered icon set (openvibe-shared/app-icons; icons/<variant>[-maskable]-192|512.png, one set per tint variant),
the shell adds <link rel="manifest" href="/shared/manifest/<service>.webmanifest?v=<hash>"> and an
<link rel="apple-touch-icon"> to its <head>. serve.js answers both from the site's pinned copy — the
manifest is built from the service's Contracts site block (name and tagline) with the hashed icon URLs —
so a home-screen install never depends on another host. manifest: false opts out for sites that serve
their own /manifest.webmanifest, and a page whose head already has a manifest link keeps it.

The page shell (shell.js)
openvibe-shared/shell — compose a whole server-rendered page from the pieces a site already ships,
so a repo's layout.js becomes one call instead of a hand-written <!doctype html> document (the T11
sweep). It adds no markup of its own.

  const shell = require('openvibe-shared/shell');
  res.type('html').send(shell.page({
    name: 'example', service: 'example',
    title: 'Example', siteName: 'OpenVibe.Example', description: '…',
    canonical: 'https://example.com/', image: '…', imageAlt: '…', type: 'website',
    summary: 'One line an AI can read', facts: [['Sites', 12]], url: 'https://example.com/',
    jsonLd: [seo.jsonLd.website({ … })],
    body: '<main>…</main>',                 // the page's main content (trusted, already escaped)
    bodyClass: 'home', bodyAttributes: { 'data-ov-content': 'pages' },
    navLinks: [{ label: 'Docs', href: '/docs' }], home: '/',
    footer: { service: 'example', variant: 'compact' },
    features: { chat: { scripts: ['/js/chat.js'] } },
    routes: [{ path: '^/chat', features: ['chat'] }],
  }));

page(o)     → the whole document: doctype, <head>, <body>. The head is seo.headTags (title,
              description, canonical, robots, Open Graph, Twitter, any jsonLd); when `summary` is
              given, seo.pageSummary adds its <meta name="ai-summary"> and WebPage JSON-LD to the
              head and its <noscript> block at the top of the body. Then theme-loader.js eagerly (it
              applies the theme before the first paint), web-runtime.js/navbar.js/footer.js deferred
              through serve.url, the frame.noscriptNav no-JavaScript nav, the body and frame.footer
              (footer.ssr). The runtime boot is inline JSON handed to
              OVWebRuntime.create(…).boot(), with the navbar init next to it.
scripts(o)  → just that <head> script block (theme-loader, the deferred runtime, the boot JSON and
              the inline boot call). `routes[].path` is a string, not a RegExp.

Every interpolated value is escaped: the head through seo, lang and the body attributes here, and
the runtime boot with JSON.stringify(…).replace(/</g, '\\u003c') so a feature name can never close
the <script>.

Each repo's layout.js moves onto shell.page() in the T11 sweep; until then the shell and the
hand-written layouts produce the same document. templates/site/server/app.js (the scaffold every
new site starts from) already calls it. See docs/migration-plan.md, "T11: layout.js onto
openvibe-shared/shell".

The OpenVibe Kit (kit.js, kit.css, appbar.js)

The pieces an app page is made of, one look on every site, phone first: a sticky head with its tabs, lists of rows,
cards that fold, settings rows, a search row, a composer pinned at the bottom, and a bottom tab bar on phones
(appBar on the navbar). The same builders run on the server (require('openvibe-shared/kit')) and in the page
(window.OpenVibeKit); text is escaped, and every piece works without JavaScript. kit-web.js is the behaviour alone
(about 7 KB), all a server-rendered page loads. [docs/kit.md](docs/kit.md) has every
piece, the sizes, the colours, and how to use it in an OpenVibe site or anyone's project;
node scripts/kit-gallery.js --shots <dir> shows them all at 390, 768 and 1280 px. Every navbar also has search across OpenVibe
(search.js, loaded on first use), and loading.js is the one loading look (docs/kit.md#loading).

OpenVibe Tokens (openvibe-shared/tokens, 4.40.0) is any theme as a W3C Design Tokens document, compiled to CSS,
Tailwind, Jetpack Compose, SwiftUI, Flutter and Figma variables, so native apps wear the same theme as the web, without a
web view ([docs/tokens.md](docs/tokens.md)).

OpenVibe components (openvibe-shared/components, 4.42.0) is the kit's components as a contract (anatomy, tokens, states,
accessibility) with Compose and SwiftUI reference views, plus kit documents: a screen as validated JSON that the web
renders through kit.js and a native app with its own views ([docs/components.md](docs/components.md)).

const kit = require('openvibe-shared/kit');
kit.head({ title: 'Settings', back: { href: '/', label: 'Home' }, tabs: kit.tabs({ base: '/settings', items, active }) })
  + kit.list(rooms.map((r) => kit.row({ href: `/r/${r.slug}`, mark: kit.mark({ name: r.name }), title: r.name, sub: r.topic })))
  + kit.dock({ action: '/r/x', placeholder: 'Message #general' });
OpenVibeNavbar.init({ …, appBar: true });   // phones: the links with icons as a bottom tab bar
The navbar (navbar.js)
Consistent top bar across all services with logo, navigation,
notification bell, account switcher, and theme-aware styling.
Usage: OpenVibeNavbar.init({ service, token, user, apiBase })
  Optional: loginUrl (override the Sign In href), sessionUrl (same-origin
  endpoint returning { user } — asked when no usable ov_token exists, including
  when a stored token turned out stale), logoutUrl (where Sign out goes, e.g. the
  site's '/auth/logout?next={path}', so a server-side session ends too; {url} is
  the full return URL, {path} its local path — loginUrl takes both too),
  onLogin/onLogout callbacks. When no `user` is passed the navbar resolves
  it itself: ov_token cookie → localStorage → token opt → sessionUrl, then
  GET {apiBase}/api/auth/me with `Authorization: Bearer <token>`.
Brand: derived from the hostname — `pastes.openvibe.tools` renders as
  Pastes · OpenVibe · Tools (three segments, the subdomain first so the
  context reads left-to-right), `openvibe.live` as OpenVibe · Live. Pass
  brand: { sub, tld, name, icon, variant } to override any part, or the
  legacy brandName/brandIcon. compact: 'auto' (default — the brand shortens
  to the subdomain on narrow viewports), 'always', 'never'.
Menus are modular: every site keeps the shared account menu and adds its own
  pieces — links: [{label, href, icon?, active?, featured?}] replaces the service's
  top links; featured: true draws that one link as a quiet accent pill with its
  glyph, and puts it first in the phone's menu (4.117.0); menu: { before: [item],
  after: [item] } adds dropdown rows
  ({label, href, icon, onClick, danger, external}); OpenVibeNavbar.addMenuItem()
  / setLinks() do the same at runtime. Signed-in users also get a
  "Recently used" row fed by the shared history module when it is loaded.
Sign-in and history: history: { type: 'tool'|'stream'|'video'|'clip'|'paste'|'thread'|'conversation'|'document'|'page'|…,
  title, url, icon } is recorded for the signed-in user once auth resolves
  (cross-site "Recently used" / History on the Network). silentLogin:
  'https://site/auth/login?silent=1&next={url}' — when nobody is signed in
  here, one top-level prompt=none round trip per tab per ten minutes picks up
  the Network session, including a first visit with third-party cookies blocked.
  Guests return with sso=none; an explicit local sign-out (ov_sso_hint=guest),
  bots and unavailable sessionStorage suppress the attempt. The site's login
  handler must return silent failures without showing a login form (openvibe-sdk/sso).
  OpenVibeSSO.fedcm remains available for browser-native sign-in flows.
One menu (4.187): the OpenVibe mark is a button that opens the OpenVibe menu
  on its first tab, as the menu button does (there is no separate site
  switcher). The site's name stays the link home; switcher: false keeps the
  mark as a home link.
New in 4.190: `ov-drop.js` is the one dropdown (`OVDrop.open(anchor, items,
  opts)`, `.attach(button, items)`, `.pages(anchor, links, icon)`): it opens out
  of what was pressed, under it or above, stays on screen, and one is open at a
  time; the bar's site name opens the site's pages with it. `purse.js` gives any
  site the currencies' effects: `OpenVibePurse.burst(el, id, delta)` plays the
  gain or spend at an element (inside a dialog too), `.count(el, from, to,
  format)` counts a number, `data-purse="coins|vibes|balance"` gives a holder
  the currency's colour as `--pc`. `machines.js` is the one picker of a person's
  Link computers (`[data-ov-machines]` or `OpenVibeMachines.mount`). `OVUpload`
  takes `checkDuplicate(file, sha256)` and tells a person they already have a
  file. The footer strip reads `/api/frame/online`.
  The balances (4.189): one pill, `#openvibe-chips-all`, at every width, with
  one `.ovnav-chip-mini` per balance the person has (OpenCoins, Vibes, the money
  balance; only those above zero, the largest first) and one of them showing.
  `purse.js`, loaded beside the navbar, draws each currency's icon
  (`OpenVibePurse.icon(id, size)` gives the same drawing to any surface), turns
  the pill through the balances on the first page of a visit and now and then,
  and on `setChip(id, value)` turns to that balance, counts the number and
  plays the gain or spend effect (`OpenVibePurse.play(id, delta)`). A site's
  single counter that is not a balance stays an ordinary chip.
  The menu (4.189, one UI since 4.220): the menu button always opens this
  site's tab (no tab is remembered; the picture still asks for You); Chat and
  Vibe are places in the menu itself, on every screen (`dock: false` leaves
  them out).
  The account's things (4.188): the person's picture opens the OpenVibe menu
  on You, where the account's block is (who you are, add account, settings,
  sign out, your other accounts, this site's own items from addMenuItem,
  Display). There is no dropdown under the picture; the block rests in the bar,
  out of sight, while the menu is closed, so #openvibe-logout-btn and a
  site's own items are always in the page.
The bar's order (4.187): balances, the bell, the menu button, then the
  person's picture (or Sign In), last. New messages are counted on the bell
  (setMessages(count)); there is no Messages button.
Balances: chips: [{ id, icon, value, label, tone, title, onClick, hidden }]
  are status chips beside the bell (Live's OpenCoins and Vibes); setChip(id,
  value) updates one. Two or more fold into one chip at 560 px and narrower.
  wallet: true (4.17) puts OpenCoins and Vibes there on any site without
  writing them: the navbar asks the Network and Billing, keeps the last
  balances per person so the next page paints them at once, refreshes after a
  minute away, and hides Vibes while Billing is not the ledger (409). A site's
  own chips with the ids coins/vibes win. Every site's account menu shows both
  balances in its header either way.
Re-renders (each openvibe-shared/boost page move re-renders the bar) end the
  previous render's document and window listeners and its panels.js
  registrations, so the old bar can be collected.

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.

The shell's cache: the worker (openvibe-sw.js, sw.js, 4.164.0)

The owner, 2026-11-09: *"how come when you change sites it doesn't load more asynchronously and dynamically, so it's faster
across sites? … better caching on the client, cross-site and smarter, intelligent, so it's faster, while staying SEO friendly
and crawlable?"*

One worker, every site, at /openvibe-sw.js (same origin, scope "/"), from this package. It is the file the notification
bell already registered for Web Push, so there is one registration and nothing is unregistered by accident. A site opts in:

// in the site's navbar options
OpenVibeNavbar.init({ service: 'live', sw: true });            // or sw: { path: '/my-sw.js', scope: '/' }

The rules it keeps, and why:

| Request | What happens | Why |
|---|---|---|
| A content-addressed file (?v=<hash>, or a hash in the name) | the cache first, for good | the bytes cannot change under that URL |
| 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 |
| 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 |
| /api/, anything that is not a GET, a range request | untouched | the API is never the cache's business |
| 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 |

Nothing moves behind JavaScript: the HTML the server writes is the HTML the browser shows. The worker only decides when a
file is asked for, never what it says, and it never serves a cached page while the network answers.

The kill switch: the worker asks https://openvibe.network/api/v1/sw-config (cached 5 minutes by the browser; a worker
that cannot reach it keeps working — a hiccup must not take the cache away). The answer { "enabled": false } makes every
worker on every site unregister itself and drop its caches. A release that needs to be undone does not need a deploy on
every site.

The server's half is openvibe-shared/sw:

const sw = require('openvibe-shared/sw');
app.get('/openvibe-sw.js', (req, res) => res.sendFile(sw.workerPath()));       // the worker
app.get('/ov-sw-manifest.json', (req, res) => res.json(sw.manifest()));       // the shell, by content hash
app.use(sw.hints({ links: (req) => [ { href: assets.url('/shared/navbar.js'), as: 'script' } ] }));

sw.hints() sends the 103 Early Hints (RFC 8297): the browser gets the shell's URLs before the HTML arrives and starts
fetching them while the server is still writing the page. The same links ride in the Link header of the page itself, so a
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
103 is passed through when it is).

The rules the browser gets before the click

The navbar hands the browser speculation rules (Chrome 103 and later):

  • a same-site page is prefetched on hover or touch, and — where the browser has prerendering — rendered in the
    background before the click lands (prerender: false turns that off for a site whose own router draws its pages; a link
    marked data-no-prefetch is left alone);
  • a link to another OpenVibe site (the ones marked data-ov-net-link) is prefetched too, so the next site is already
    warm when the person arrives. Chrome leaves a site alone when the person has cookies on it, which is right: that page is
    theirs, not a public copy.

The sign-in, the sign-out, the API, the internal pages and the downloads are never fetched ahead of the click.

Vibes Pay (vibes-pay.js, 4.111.0)

The browser half of Vibes Pay (MONEY.md, D12): a page on any site can sell something for Vibes.

<script src="https://openvibe.network/shared/vibes-pay.js" defer></script>
<button data-ov-vibes-pay="https://openvibe.network/pay/vch_…">Pay with Vibes</button>

The button binds itself: the Vibes green (#4ade80, dark ink), the label "Pay with Vibes", a 44 px target, and a
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
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.
The sheet posts { type: 'ov-vibes-pay', status, charge_id } to the page only from https://openvibe.network; the
promise resolves { status } (also on the bubbling ov-vibes-pay event), and a popup closed without paying resolves
{ status: 'closed' }. A pay URL that is not https://openvibe.network/pay/… throws.

const { status } = await OpenVibe.vibes.pay(payUrl);   // 'paid', 'cancelled', …

Or call OpenVibe.vibes.bind(document) after adding buttons to the DOM yourself. The server side creates the charge
with openvibe-sdk/vibes; see [docs/vibes-pay.md](docs/vibes-pay.md).

The video player (player.js, player.css)

One look for every <video> across the network. Mark the element and load both files; without the script the
browser's own controls stay:

<link rel="stylesheet" href="/shared/player.css">
<video data-ov-player controls src="…" poster="…" preload="metadata" playsinline></video>
<script src="/shared/player.js" defer></script>

Each video[data-ov-player] is wrapped in .ov-player with a scrubber (buffered, played, the time under the
pointer), play, volume (remembered), time, captions (when the video has a captions track), speed, picture-in-picture
and fullscreen; the controls hide while playing and idle. Keys while the player has focus: Space/K, J/L ±10 s, ←/→ ±5 s,
↑/↓ 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
only the media element and listens to its events, so a page's own script (HLS, chapters, next-up, view counts) keeps
working. Colours follow the site's --accent. OpenVibePlayer.enhance(video) for a video added later.

What people wear (items.js, items.css)

The items a person has equipped in OpenVibe.Inventory, drawn the same on every site (plan T21). Mark each name with
its owner's subject and load both files:

<link rel="stylesheet" href="/shared/items.css">
<a href="https://openvibe.network/@ana" data-ov-subject="usr_01…">Ana</a>
<script src="/shared/items.js" defer></script>
<script>addEventListener('DOMContentLoaded', () => OpenVibeItems.decorate(document));</script>

OpenVibeItems.decorate(node) reads Inventory's public GET /api/v1/equipped?subjects=… (100 people per request,
no credentials, each answer kept 60 s) and gives each name its renderers' classes; call it again after adding names
and only the new ones are read. Renderers: live.name_effect.css@1 → ov-fx + the item's token (Live's 13 name
effects, name-fx-rainbow … name-fx-divine); live.hat.glyph@1 → the hat's emoji just before the name
(ov-hat, with its motion); live.particle.css@1 → ov-px + the token, and a burst of the item's characters when
someone points at or focuses the name. Hats and particles read Live's definitions once per page. network.badge.image@1 → a
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
Calm setting and prefers-reduced-motion stop the animation. window.OpenVibeItemsConfig = { api } points it at
another Inventory (development).

A person's stuff on any site (profile-panel.js, profile-panel.css)

Steam-style: what someone wears, how many items, badges and achievements they have, their games with the latest
unlock, and links to their full profile and inventory, on any OpenVibe site (the owner, 2026-10-25: "you should be
able to see your inventory anywhere"). Put a fallback link in an element and load both files:

<link rel="stylesheet" href="/shared/profile-panel.css">
<div data-ov-profile="ana"><a href="https://openvibe.network/@ana">Ana's profile</a></div>
<script src="/shared/profile-panel.js" defer></script>

Every [data-ov-profile] on the page is drawn once loaded; OpenVibeProfilePanel.render(el, username) draws one added
later. data-ov-profile="me" is whoever is signed in on that site (its own /auth/me, the navbar's session lookup), so a
page cached for everyone can still show "your stuff": give the element hidden and it appears only once drawn. A
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
open, no credentials), which gathers OpenVibe.Inventory, OpenVibe.Quest and OpenVibe.Play. Values are text, links only
to OpenVibe's own https hosts, pictures only from openvibe.media and openvibe.network; a private profile shows its name
and picture; no answer leaves the fallback link. window.OpenVibeProfilePanelConfig = { api } points it at another
Network (development).

Forms (ov-forms.js, ov-forms.css)

OpenVibe's own select and form controls on every site: the navbar loads them on a page with a form control. Every
<select> becomes a button that opens a menu on a computer and a sheet on a phone. The hidden select keeps the value,
so forms, select.value and change listeners work as before. An option can say more: data-desc, data-badge,
data-state and a data-href setup link. Checkboxes, radios and sliders take the site's accent; role="switch" is a
switch; .ov-field, .ov-input and .ov-choice are the full field look. Turn it off with data-ov-native or
<html data-ov-forms="off">. See [docs/forms.md](docs/forms.md).

Locker (ov-locker.js, ov-locker.css, ov-locker-core.js)

End-to-end encryption in the browser (plan Q2): a locked item's card (the link's key, a password, or your locker),
making your locker (a passphrase, then a 24-word recovery kit), and unlocking it on a new device. Nothing readable
leaves the page. ov-locker-core.js is openvibe-sdk/locker, generated (node scripts/build-locker-core.js), and
reproduces the SDK's test vectors exactly. See [docs/locker.md](docs/locker.md).

Share (ov-share.js, ov-share.css)

One Share sheet on every site (plan track Q): a sheet from the bottom on a phone, a dialog on a computer.

  • Send it, for everyone: the link and Copy; the phone's own share sheet (contacts, messages, apps); X, Bluesky,
    WhatsApp, Reddit, Discord, Telegram, Threads, Mastodon, Facebook, email, Messages, LinkedIn, Pinterest, Tumblr,
    Hacker News and LINE; a QR code made on the device; Start at 1:23; embed code.
  • Who can open it, for the owner: Everyone, Anyone with the link, Specific people by @name (Can view or Can edit,
    with an end date) and Only me; access requests; transfer of ownership with the recipient's say-so.

Put <button data-ov-share-sheet data-title="…">Share</button> on a page: the navbar loads the sheet on the first
press. Add data-kind and data-id where the service mounts openvibe-sdk/share's router. See
[docs/share.md](docs/share.md).

Development

npm ci
npm test                         # every test/*.test.js (fnm exec --using=22.22.1 npm test)
npm test -- navbar               # only matching files
npm run size                     # raw + brotli (q11) size of every browser file
node scripts/build-navbar-icons.js   # the bar's icons and the ring solids, from glyphs.js
node scripts/build-theme-loader.js

Tests are plain Node with stubbed browser globals. Some tests guard the release:

  • navbar.js size budget: 29 KB brotli (test/nav-icons.test.js). release-watch.js and
    release-update.js: 5 KB and 3.5 KB (test/release-update.test.js).
  • Releases (ADR-016): the manifest validates against the installed openvibe-contracts (and
    the 1.1.0 schema), in-place updates are transactional and never replace a region in use, the
    reload rules hold, and the mixed-version matrix runs against real servers
    (test/release*.test.js).
  • Built-in icons: every icon the default navbar renders must be built in and drawn with no
    fetch.
  • Generated blocks: the generated navbar and theme-loader blocks must be up to date.
  • Browser bundles: browser files must be free of require() and Node globals
    (test/browser-bundles.test.js).
  • Exports: every export must resolve, and every public module file must
    have one. Internal config-core.js and analytics/core.js are private.
  • Analytics (ADR-021): PostgreSQL tests cover request privacy, opt-out, rollups, and raw-event retention;
    pure privacy and event helpers have their own tests.

CI runs on Node 22.22.1 and installs a tag-style tarball into an empty project.

Releasing

  1. Land the change on main with tests green. Add an entry under a new version heading in
    CHANGELOG.md, and set the same version in package.json.
  2. Commit (Release vX.Y.Z), then tag: git tag -a vX.Y.Z -m "vX.Y.Z". Push main and the tag.
    CI runs on the tag.
  3. Tags are immutable. Never move or delete a released tag. Consumer lockfiles pin its
    tarball's integrity hash. A fix is a new patch release.
  4. Upgrade Network first, because it serves https://openvibe.network/shared/* to every site.
    Then upgrade Live, Community, Media, Tools and Sites: change the tag in the URL, run
    npm install, commit the lockfile and deploy.

GitHub builds tag tarballs on demand. In practice they're byte-stable, but GitHub doesn't
promise it. If npm ci ever reports EINTEGRITY for this package on an unchanged tag, check
that the tag wasn't moved, then run npm install to refresh the lockfile. The content is the
tag's tree either way.

Compatibility policy

  • Semver. Major releases are for breaking changes: removing or renaming an exported
    subpath, an export, a browser global (OpenVibeNavbar, OpenVibeFooter, …) or a served file
    name, removing an init option, or changing markup, classes or defaults that sites style or
    script against. Minor releases add modules, options or files. Patch releases are fixes with no
    API change.
  • The previous major stays servable. Network serves each major it supports at
    /shared/v<major>/<file>. It keeps the older major installed side by side
    ("openvibe-shared-v1": "<v1 tarball URL>") for at least 90 days after the next major ships,
    and for as long as any consumer or published HTML still references it. That includes the
    pre-rendered pages in OpenVibe.Sites/dist/.
  • Unversioned paths never jump a major silently. /shared/<file> keeps serving the major it
    served before. It moves to a new major only after every consumer has either moved to it or
    switched to an explicit /shared/v<major>/ path. Because navbar.js loads nav-icons.js from
    its own directory, a versioned navbar always gets the icons of its own release.
  • A new browser file is added to files.js in the same release, so sites that serve from
    node_modules pick it up.

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:

const rt = OVWebRuntime.create({
    features: { player: { fragment: 'player', deps: ['base'], css: ['/css/player.css'], js: ['/js/p1.js', '/js/p2.js'], after: 'initPlayer', stubs: ['openPlayer'], idle: ['ops'] } },
    routes: [{ path: '^/watch/', features: ['player'] }],   // path: a RegExp or its source
    versions: { '/js/p1.js': '<hash>' },                     // → /js/p1.js?v=<hash>; or url: (p) => …
    styleSlot: (href) => node,        // insert a stylesheet before this node (cascade order); default: last in <head>
    fragmentPath: (name) => `/fragments/${name}.html`,       // loaded into #page-<name> (or `section`) once
    prefetch: { count: 60, bytes: 3 * 1024 * 1024, sizes: { '/js/p1.js': 12345 } },
    onStubError: (err, feature) => toast('That part of the site could not load.'),
    routeError: { icon: 'fa-solid fa-plug-circle-xmark' }, debug: false, eventPrefix: 'ov',
});
rt.boot({ initial: ['player'] });     // adopt the server's tags, load these, prefetch on link intent and when idle
  • 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.
  • 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.
  • 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).
  • 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.
  • 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.
  • 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).

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.

Purpose

The canonical home of what was OpenVibe.Network/packages/openvibe-shared (navbar, footer,
themes, icons, legal pages, SEO helpers, notifications UI, panels): the OpenVibe Frame and the
server helpers every site uses, published as immutable versioned artifacts instead of being
vendored or rsynced from Network.

Owns

  • the openvibe-shared package (one package with subpath exports; the charter's
    @openvibe/tokens|ui|frame|auth-ui|seo|legal|icons|release-client|web-runtime|server-web|testing-web
    split was decided against in [docs/adr/0001-one-package-subpath-exports.md](docs/adr/0001-one-package-subpath-exports.md))
  • design tokens and theme rendering, the navbar, footer and panels of the OpenVibe Frame
  • the release manifest client (active-tab update coordinator) and the web runtime (feature loader)
  • server helpers: readiness, metrics, trace, egress, config, analytics, legal, SEO

Does not own

  • theme authority (OpenVibe.Network owns the theme API and preferences)
  • identity, notifications and the changelog (OpenVibe.Network, OpenVibe.Blog); the browser files
    only display them
  • any service's data: it has none (artifact repository)

Depends on

  • jsonwebtoken (the only runtime dependency, for auth-client)
  • OpenVibe.Contracts (the release manifest contract; openvibe-contracts v0.419.0 is a devDependency
    the tests validate against)
  • in the browser, at run time: OpenVibe.Network (sessions, notifications and realtime tickets, the
    changelog feed) and OpenVibe.Events (the realtime stream for the notification bell)

Capabilities

None: Shared implements no capability and holds no grant. Browser files call Network and Events as the
signed-in person (their session cookie or a Network realtime ticket), and server modules call whatever
their consumer configures with the consumer's own credentials.

Acceptance

npm test runs every test/*.test.js (plain Node with stubbed browser globals; the *-chrome tests
need Chrome). What the release-guarding tests prove is listed under Development: size
budgets, releases and the mixed-version matrix, built-in icons, generated blocks, browser bundles free of
Node code, every export resolving, analytics privacy. The charter's two acceptance points hold: Live,
Community, Media, Tools and Sites consume pinned versions with no manual copies, and
openvibe.network/shared/* keeps serving version-pinned files.

Security

Reporting a vulnerability: [SECURITY.md](SECURITY.md). Shared's code runs on every site, so:

  • browser files carry no secrets and no Node code (test/browser-bundles.test.js);
  • egress gives services SSRF-safe addresses and connect-time DNS checks for user-chosen hosts
    (test/egress.test.js); trace forwards the W3C trace only to loopback and OpenVibe hosts;
  • analytics stores no personal data and honours opt-out (test/analytics-privacy.test.js);
  • /metrics from openvibe-shared/metrics answers direct loopback callers only;
  • tags are immutable, so a consumer's lockfile integrity hash always names the same code.

Deploy

Nothing runs from this repository. A release is a git tag (Releasing). OpenVibe.Network
serves https://openvibe.network/shared/* from the tag it pins, so a Network deploy
(sudo ovhost deploy network) publishes a release to every site that loads the Frame from there. A site
can instead serve its own pinned copy (openvibe-shared/serve); either way each consumer pins a tag and
ships it with its own deploy.
A bad release is undone by pinning the previous tag; a released tag is never moved.


Part of the OpenVibe network. Built in the open by OpenVibers.

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.

<!-- versions:start -->

  • openvibe-contracts: v0.419.0
  • openvibe-sdk: v0.160.0

<!-- versions:end -->