openvibers/openvibe.network Commits
Chrome service: analytics-ranked navigation and AI-assisted footer copy, cached; navbar brand group restyled and hardened against host CSS; network links and ranked launcher; shared legal documents; home layout, icons and copy
Changes
@@@ -6,7 +6,7 @@"scripts": {"start": "node server/index.js","dev": "NODE_ENV=development node server/index.js",-- "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node packages/openvibe-shared/test/shared-modules.test.js",++ "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node packages/openvibe-shared/test/shared-modules.test.js && node test/chrome.test.js","reset-db": "node server/reset-db.js","grant-admin": "node server/grant-admin.js"},@@@ -27,6 +27,41 @@})(typeof globalThis !== 'undefined' ? globalThis : this, function () {'use strict';++ // ── Shared chrome data (https://openvibe.network/api/chrome) ─────────────────────────────++ // Sites ordered by real use, footer copy and per-site legal links. Cached per host for 30++ // minutes in localStorage and refreshed in the background, so pages paint from cache and the++ // order never jumps while someone is looking at it. Defined once, shared by navbar + footer.++ const OVChrome = root.OpenVibeChrome || (root.OpenVibeChrome = (function () {++ const KEY = 'ov_chrome_v1', TTL = 30 * 60000;++ let inflight = null;++ const host = () => (typeof location !== 'undefined' ? location.hostname : '');++ const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } };++ function clean(d) {++ if (!d || !Array.isArray(d.nav)) return null;++ const link = (l) => (l && typeof l.name === 'string' && https(l.url) ? { id: String(l.id || ''), name: l.name.slice(0, 60), url: l.url, icon: String(l.icon || ''), tagline: String(l.tagline || '').slice(0, 80) } : null);++ const f = d.footer || {}, lg = f.legal || {};++ return { nav: d.nav.map(link).filter(Boolean).slice(0, 12), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24),++ footer: { blurb: String(f.blurb || '').slice(0, 200), discover: (f.discover || []).map(link).filter(Boolean).slice(0, 6), popular: (f.popular || []).map(link).filter(Boolean).slice(0, 10),++ legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null } };++ }++ function cached() { try { const c = JSON.parse(localStorage.getItem(KEY) || 'null'); return c && c.host === host() && c.data ? c : null; } catch { return null; } }++ function refresh() {++ if (inflight || typeof fetch === 'undefined') return inflight || Promise.resolve(null);++ inflight = fetch('https://openvibe.network/api/chrome?host=' + encodeURIComponent(host()), { credentials: 'omit' })++ .then(r => (r.ok ? r.json() : null)).then(clean)++ .then(d => { if (d) { try { localStorage.setItem(KEY, JSON.stringify({ at: Date.now(), host: host(), data: d })); } catch { /* */ } } return d; })++ .catch(() => null);++ return inflight;++ }++ /** Cached data now (or null); `onFirst` fires once when a first-ever fetch lands. */++ function get(onFirst) {++ const c = cached();++ if (!c || Date.now() - c.at > TTL) { const p = refresh(); if (!c && onFirst) p.then(d => { if (d) onFirst(d); }); }++ return c ? c.data : null;++ }++ return { get, refresh };++ })());++const NETWORK_URL = 'https://openvibe.network';const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));@@@ -50,9 +85,9 @@];const LEGAL = [-- { name: 'Terms of Service', path: '/tos', fallback: 'https://openvibe.live/tos' },-- { name: 'Privacy Policy', path: '/privacy', fallback: 'https://openvibe.live/privacy' },-- { name: 'DMCA', path: '/dmca', fallback: 'https://openvibe.live/dmca' },++ { key: 'terms', name: 'Terms of Service', path: '/tos', fallback: 'https://openvibe.live/tos' },++ { key: 'privacy', name: 'Privacy Policy', path: '/privacy', fallback: 'https://openvibe.live/privacy' },++ { key: 'dmca', name: 'DMCA', path: '/dmca', fallback: 'https://openvibe.live/dmca' },];const SOCIAL = [@@@ -106,6 +141,9 @@}function legalHref(item, cfg) {++ // Each site answers for itself: the chrome service names this domain's own documents.++ const chrome = cfg._chrome;++ if (chrome && chrome.footer.legal && !cfg.legalBase) return chrome.footer.legal[item.key];// Policies live on whichever site owns them; a tool subdomain links out rather than 404ing.if (cfg.legalBase) return cfg.legalBase.replace(/\/$/, '') + item.path;if (typeof location !== 'undefined' && location.hostname.endsWith('openvibe.live')) return item.path;@@@ -114,7 +152,9 @@// ── Markup ─────────────────────────────────────────────────function linkTag(item) {-- const rel = /^https?:\/\//.test(item.url) ? ' target="_blank" rel="noopener"' : '';++ // Our own sites open in this tab (the sign-in hand-off follows the click); the rest of the web in a new one.++ const own = /^https:\/\/([a-z0-9-]+\.)*(openvibe\.[a-z]+|openre\.stream)(\/|$)/i.test(item.url);++ const rel = /^https?:\/\//.test(item.url) && !own ? ' target="_blank" rel="noopener"' : '';const onclick = item.onclick ? ` onclick="${esc(item.onclick)}"` : '';const title = item.desc ? ` title="${esc(item.desc)}"` : '';return `<a href="${esc(item.url)}"${rel}${onclick}${title}>${esc(item.name)}</a>`;@@@ -124,7 +164,14 @@const c = { ...DEFAULTS, ...(cfg || {}) };const service = c.service || detectService();const brand = c.brandName || brandFor(service);-- const network = NETWORK.filter(n => n.id !== service);++ const chrome = c._chrome = OVChrome.get(() => { try { render(); } catch { /* */ } });++ // Most used sites first when the chrome service has spoken; the built-in list otherwise.++ const network = chrome && chrome.nav.length++ ? chrome.nav.filter(n => n.id !== service && !(typeof location !== 'undefined' && new URL(n.url).hostname === location.hostname)).map(n => ({ name: 'OpenVibe.' + n.name, url: n.url, desc: n.tagline }))++ : NETWORK.filter(n => n.id !== service);++ const tagline = (cfg && cfg.tagline) || (chrome && chrome.footer.blurb) || c.tagline;++ const popular = chrome ? chrome.footer.popular : [];++ const discover = chrome ? chrome.footer.discover.filter(d => !network.slice(0, 6).some(n => n.url === d.url)) : [];const year = new Date().getFullYear();if (c.variant === 'compact') {@@@ -149,7 +196,7 @@<div class="ovf-cols"><div class="ovf-col ovf-col--brand"><a class="ovf-brand" href="/"><span class="ov-mark" data-size="26"></span><span>${esc(brand)}</span></a>-- <p class="ovf-tagline">${esc(c.tagline)}</p>++ <p class="ovf-tagline">${esc(tagline)}</p><div class="ovf-social">${SOCIAL.map(s => `<a href="${s.url}" target="_blank" rel="noopener" aria-label="${s.name}" title="${s.name}"><i class="${s.icon}"></i></a>`).join('')}<a href="${esc(c.sitemap)}" aria-label="Sitemap" title="Sitemap"><i class="fa-solid fa-sitemap"></i></a>@@@ -161,6 +208,11 @@${network.slice(0, 6).map(linkTag).join('')}<a class="ovf-more" href="${NETWORK_URL}" target="_blank" rel="noopener">Everything else <i class="fa-solid fa-arrow-right"></i></a></nav>` : ''}++ ${popular.length ? `<nav class="ovf-col" aria-label="Popular tools">++ <h3>Popular tools</h3>++ ${popular.slice(0, 6).concat(discover.slice(0, 2)).map(linkTag).join('')}++ <a class="ovf-more" href="https://openvibe.tools/all-tools">All tools <i class="fa-solid fa-arrow-right"></i></a>++ </nav>` : ''}<nav class="ovf-col" aria-label="Legal"><h3>Legal</h3>${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name)}</a>`).join('')}nnew file mode 100644@@@ -0,0 +1,145 @@++'use strict';++// ═══════════════════════════════════════════════════════════════++// openvibe-shared/legal — Terms, Privacy and DMCA for every OpenVibe site.++//++// Each domain answers for itself: /terms, /privacy and /dmca live on the site's own apex and say++// what THAT site does. One template per document, with clauses switched by the site's profile:++// streaming live video, chat, payments tools files processed, then deleted++// ugc posts, pastes, comments games accounts and game state++// hosting stored user media account the identity provider++// info read-only / placeholder site++//++// const legal = require('openvibe-shared/legal');++// app.get(legal.PATHS, legal.handler({ host: 'openvibe.tools', name: 'OpenVibe.Tools', profile: 'tools' }));++// legal.page('privacy', site) → complete HTML document (static generators use this)++//++// Plain language on purpose. These are templates maintained by the project, not legal advice;++// the owner reviews them before relying on them. Facts stated here (what is stored, for how++// long) must match the code — change both together.++// ═══════════════════════════════════════════════════════════════++const UPDATED = '2026-09-18';++const UPDATED_TEXT = 'September 18, 2026';++const CONTACT = '[email protected]';++const PATHS = ['/terms', '/privacy', '/dmca'];++const TITLES = { terms: 'Terms of Service', privacy: 'Privacy Policy', dmca: 'DMCA & Copyright' };++++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++const has = (site, ...profiles) => profiles.includes(site.profile);++const userContent = (site) => has(site, 'streaming', 'ugc', 'hosting', 'games');++++function terms(site) {++ const n = esc(site.name);++ const S = [];++ S.push(['The short version', `<p>${n} is part of OpenVibe, an open source, community-run network. Use it for lawful things, be decent to other people, and do not try to break it. We can remove content or accounts that break these terms. The software is provided as it is, without warranties.</p>`]);++ S.push(['Who can use it', `<p>You must be at least 13 years old, or the minimum age of digital consent where you live. If you are under 18, a parent or guardian must agree to these terms for you. You may not use ${n} if you were previously banned from the network.</p>`]);++ if (!has(site, 'info')) S.push(['Your account', `<p>Most of ${n} works without signing in. An OpenVibe account, managed at <a href="https://openvibe.network/">openvibe.network</a>, adds things that need to remember you. You are responsible for what happens under your account; keep your password to yourself and tell us if you think someone else has it.</p>`]);++ if (has(site, 'tools')) S.push(['Files and links you give the tools', `<p>The tools process what you upload or paste and hand you the result. Uploads and results are temporary: they are deleted automatically, normally within one hour for visitors and within 24 hours for signed-in users. Do not rely on ${n} to store anything.</p><p>Only process material you have the right to use. Downloaders are for your own uploads and for content you are allowed to keep; respecting the terms of the site the content comes from is your responsibility. Lookup tools (DNS, WHOIS, ping, port checks) may only be pointed at systems you are allowed to test.</p>`]);++ if (userContent(site)) S.push(['What you post', `<p>You keep ownership of what you ${has(site, 'streaming') ? 'stream, upload, clip or write' : 'upload or write'}. You give OpenVibe a worldwide, non-exclusive, royalty-free licence to host, display, transcode and distribute it so the service can work, including on other OpenVibe sites where your content is meant to appear. The licence ends when you delete the content, except for copies other people made while it was public and backups that expire on their own.</p><p>You are responsible for having the rights to everything you post.</p>`]);++ S.push(['Rules', `<p>OpenVibe leans toward open expression. These limits apply everywhere:</p><ul><li>Nothing illegal where you are or where our servers are (United States).</li><li>No sexual content involving minors, and no sexualisation of minors, ever.</li><li>No credible threats of violence, and no publishing of someone's private information to harm them.</li><li>No content you do not have the rights to.</li><li>No malware, phishing, spam, scraping that ignores robots.txt rate limits, or attempts to disrupt or gain unauthorised access to the service or to other people's systems.</li>${has(site, 'streaming') ? '<li>Streams must be labelled honestly; adult or graphic content belongs behind the mature flag.</li>' : ''}</ul>`]);++ if (has(site, 'streaming')) S.push(['Money', `<p>Donations, tips and purchases of virtual currency are voluntary and final except where the law requires a refund. Virtual currencies have no cash value outside the payout rules shown on the site. Streamers are responsible for their own taxes.</p>`]);++ if (has(site, 'games')) S.push(['Game items and progress', `<p>Items, currency and progress in games have no cash value and may be changed, reset or removed as the games evolve. Cheating, automation that gives an unfair advantage and exploiting bugs can lead to a reset or a ban.</p>`]);++ S.push(['Moderation and ending accounts', `<p>We may remove content, limit features, or suspend accounts that break these terms or put other people or the service at risk. You can stop using ${n} at any time and delete your account from your account settings.</p>`]);++ S.push(['Open source', `<p>OpenVibe's code is public. The code is licensed under the terms in each repository; those licences, not this page, govern your use of the code. These terms cover your use of the hosted service at ${esc(site.host)}.</p>`]);++ S.push(['No warranty, limited liability', `<p>The service is provided "as is" and "as available". To the extent the law allows, OpenVibe and the people who run it are not liable for indirect or consequential losses, lost data, or lost profits arising from your use of ${n}. Nothing here limits liability that cannot be limited by law.</p>`]);++ S.push(['Governing law', `<p>These terms are governed by the laws of the United States and the State of Washington, without regard to conflicts of law. Disputes are handled in the federal or state courts located in Washington State. If part of these terms cannot be enforced, the rest still applies.</p>`]);++ S.push(['Changes and contact', `<p>We may update these terms. Material changes are announced on the site before they take effect. Questions: <a href="mailto:${CONTACT}">${CONTACT}</a>.</p>`]);++ return S;++}++++function privacy(site) {++ const n = esc(site.name);++ const S = [];++ S.push(['The short version', `<p>${n} collects what it needs to work and to keep abuse out, and no more. We do not sell personal information and we do not run advertising trackers.</p>`]);++ const collect = [++ '<li><b>Request logs.</b> Like every web server, ours record your IP address, browser type, the page requested and the time. We use them for security, rate limiting and aggregate traffic statistics.</li>',++ '<li><b>Usage statistics.</b> Page views and a random session identifier, so we can see which parts of the network people use. These are first-party statistics kept on our own servers.</li>',++ ];++ if (!has(site, 'info')) collect.push(`<li><b>Account information, if you sign in.</b> Your OpenVibe account (username, email address, password hash, profile and preferences) is held by <a href="https://openvibe.network/privacy">openvibe.network</a>. ${n} receives your user id, username, avatar and role so it can show you as signed in.</li>`);++ if (has(site, 'tools')) collect.push('<li><b>What you give a tool.</b> Files, text, links, domain names and IP addresses you submit are processed to produce your result. Uploaded files and generated results are deleted automatically, normally within one hour for visitors and 24 hours for signed-in users. Many text and developer tools run entirely in your browser and send nothing to us.</li>');++ if (has(site, 'streaming')) collect.push('<li><b>Streams, clips, VODs and chat.</b> What you broadcast and write is stored so it can be shown, and is public unless you choose otherwise.</li><li><b>Payments.</b> Payment processors handle card details; we receive a record of the transaction, not your card number.</li><li><b>AI features.</b> Public streams and chat may be transcribed and summarised by AI services to create captions, highlights and overviews.</li>');++ if (has(site, 'ugc')) collect.push('<li><b>What you post.</b> Pastes, posts and comments, with the account that made them. Public items are visible to everyone and may be indexed by search engines.</li>');++ if (has(site, 'hosting')) collect.push('<li><b>Media you upload or record.</b> Videos, clips, images and files, with their metadata, stored so the sites that use them can show them.</li>');++ if (has(site, 'games')) collect.push('<li><b>Game state.</b> Your characters, items, progress and in-game chat.</li>');++ if (has(site, 'account')) collect.push('<li><b>Your account.</b> Username, email address, a salted hash of your password, profile, theme and notification preferences, linked services, sign-in history and, if you keep it switched on, your cross-site history.</li>');++ S.push(['What we collect', `<ul>${collect.join('')}</ul>`]);++ S.push(['Cookies and local storage', `<p>We use a small number of first-party cookies: a sign-in token (<code>ov_token</code>), a refresh token, and a hint that remembers whether you were signed in so other OpenVibe sites can sign you in quietly. Your theme, recent pages and similar preferences are kept in your browser's local storage. There are no third-party advertising cookies. Our sites sit behind Cloudflare, which may set its own security cookies.</p>`]);++ S.push(['Who we share with', `<p>Only the services needed to run the network: our hosting and network providers (OVH, Cloudflare), storage providers for media${has(site, 'streaming') ? ', payment processors, AI providers used for transcription and summaries' : ''}, and email delivery for account messages. We disclose information when the law requires it, and we tell you when we are allowed to.</p>`]);++ S.push(['Across OpenVibe sites', `<p>OpenVibe sites share one account. When you are signed in, the site you visit learns who you are from openvibe.network; it does not receive your password or email address. You can see and clear your cross-site history, and switch it off, at <a href="https://openvibe.network/history">openvibe.network/history</a>.</p>`]);++ S.push(['How long we keep things', `<p>Request logs and raw usage events are kept for a limited period and then reduced to totals. ${has(site, 'tools') ? 'Tool uploads and results are deleted within hours. ' : ''}${userContent(site) ? 'Content you post stays until you delete it or your account. ' : ''}Account data stays until you delete your account; backups expire on their own shortly afterwards.</p>`]);++ S.push(['Your choices and rights', `<p>You can use most of ${n} without an account. With an account you can view, change, export and delete your data from your account settings, or ask us to do it. Depending on where you live (for example the EEA, the UK or California) you may have additional rights to access, correct, delete or restrict the use of your data; write to us and we will honour them.</p>`]);++ S.push(['Children', `<p>${n} is not for children under 13, and we do not knowingly collect their information. If you believe a child has given us personal information, contact us and we will delete it.</p>`]);++ S.push(['Changes and contact', `<p>We will post changes here and update the date above. Questions and requests: <a href="mailto:${CONTACT}">${CONTACT}</a>.</p>`]);++ return S;++}++++function dmca(site) {++ const n = esc(site.name);++ const S = [];++ S.push(['Our position', `<p>${n} respects copyright and responds to clear notices of infringement under the Digital Millennium Copyright Act (17 U.S.C. § 512).${has(site, 'tools') ? ` The tools here process material at a user's request and do not keep a library of it: results are deleted automatically within hours.` : ''}${has(site, 'info') ? ` ${n} publishes its own material and does not host uploads from the public.` : ''}</p>`]);++ S.push(['Send a takedown notice', `<p>Email <a href="mailto:${CONTACT}">${CONTACT}</a> with all of the following:</p><ol><li>Your name, address, phone number and email address, and whom you represent.</li><li>The copyrighted work you say was infringed.</li><li>The exact URL on ${esc(site.host)} of the material you want removed. General descriptions are not enough to find it.</li><li>A statement that you have a good-faith belief the use is not authorised by the copyright owner, its agent or the law.</li><li>A statement, under penalty of perjury, that the information is accurate and that you are the owner or authorised to act for the owner.</li><li>Your physical or electronic signature.</li></ol>`]);++ S.push(['What happens next', `<p>We remove or disable access to the material, tell the person who posted it, and give them your notice. We act on complete notices as quickly as we can, normally within a few business days.</p>`]);++ if (!has(site, 'info')) S.push(['Counter-notice', `<p>If your material was removed by mistake or misidentification, send a counter-notice to the same address with: your contact details, the material and where it appeared, a statement under penalty of perjury that you believe it was removed by mistake, your consent to the jurisdiction of the federal court for your district (or for Washington State if you are outside the United States), and your signature. We forward it to the complainant and may restore the material after 10 to 14 business days unless they tell us they have filed a court action.</p>`]);++ S.push(['Repeat infringers and false claims', `<p>Accounts that repeatedly infringe are terminated. Knowingly false notices and counter-notices carry legal liability under § 512(f); we also refuse notices that are clearly abusive.</p>`]);++ return S;++}++++const BUILDERS = { terms, privacy, dmca };++++/** { title, description, sections: [[heading, html]], updated } for one document of one site. */++function build(kind, site) {++ if (!BUILDERS[kind]) throw new Error('Unknown legal document: ' + kind);++ const s = Object.assign({ profile: 'info' }, site);++ return { kind, title: `${TITLES[kind]} — ${s.name}`, heading: TITLES[kind],++ description: { terms: `The rules for using ${s.name}, in plain language.`, privacy: `What ${s.name} collects, why, and the choices you have.`, dmca: `How to report copyright infringement on ${s.name}, and how to respond to a report.` }[kind],++ updated: UPDATED, sections: BUILDERS[kind](s) };++}++++/** Article markup only, for sites that wrap it in their own layout. */++function article(kind, site) {++ const d = build(kind, site);++ const others = Object.keys(TITLES).filter(k => k !== kind);++ return `<article class="ov-legal"><p class="ov-legal-kicker">${esc(site.name)}</p><h1>${esc(d.heading)}</h1><p class="ov-legal-date">Last updated <time datetime="${d.updated}">${UPDATED_TEXT}</time></p>++${d.sections.map(([h, html], i) => `<section><h2 id="s${i + 1}">${esc(h)}</h2>${html}</section>`).join('\n')}++<nav class="ov-legal-more" aria-label="Other policies">${others.map(k => `<a href="/${k}">${esc(TITLES[k])}</a>`).join('')}<a href="https://openvibe.network/">About OpenVibe</a></nav></article>`;++}++++const CSS = `*{box-sizing:border-box}body{margin:0;background:var(--bg-primary,#0a0f18);color:var(--text-primary,#e6edf7);font:400 16px/1.65 Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif}++.ov-legal{max-width:760px;margin:0 auto;padding:40px 20px 24px}.ov-legal-kicker{margin:0;font-size:13px;font-weight:700;letter-spacing:.6px;text-transform:uppercase;color:var(--accent-light,var(--accent,#60a5fa))}++.ov-legal h1{font-size:clamp(28px,4.5vw,40px);letter-spacing:-.03em;line-height:1.1;margin:6px 0 4px}.ov-legal-date{color:var(--text-muted,#7d8aa0);font-size:14px;margin:0 0 28px}++.ov-legal h2{font-size:19px;margin:30px 0 8px;letter-spacing:-.01em}.ov-legal p,.ov-legal li{color:var(--text-secondary,#a8b3c4)}.ov-legal li{margin:6px 0}.ov-legal b{color:var(--text-primary,#e6edf7)}++.ov-legal a{color:var(--accent-light,var(--accent,#60a5fa))}.ov-legal code{font-size:13.5px;background:var(--bg-secondary,#111826);padding:1px 6px;border-radius:5px}++.ov-legal-more{display:flex;flex-wrap:wrap;gap:10px;margin-top:40px;padding-top:20px;border-top:1px solid var(--border,rgba(255,255,255,.1))}.ov-legal-more a{padding:8px 14px;border:1px solid var(--border,rgba(255,255,255,.12));border-radius:10px;text-decoration:none;font-weight:600;font-size:14px}`;++++/** A complete page: shared theme, navbar and footer, readable without JavaScript. */++function page(kind, site) {++ const d = build(kind, site);++ const url = `https://${site.host}/${kind}`;++ let head;++ try { head = require('./seo').headTags({ title: d.title, description: d.description, canonical: url, siteName: site.name, type: 'article' }); }++ catch { head = `<title>${esc(d.title)}</title><meta name="description" content="${esc(d.description)}"><link rel="canonical" href="${url}">`; }++ return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">++${head}++<script src="https://openvibe.network/shared/theme-loader.js"></script><style>${CSS}</style></head><body>++<noscript><p style="padding:12px 20px;margin:0"><a href="/">${esc(site.name)}</a></p></noscript>++<main>${article(kind, site)}</main><div id="ov-footer"></div>++<script src="https://openvibe.network/shared/navbar.js" defer></script><script src="https://openvibe.network/shared/footer.js" defer></script>++<script>addEventListener('DOMContentLoaded',function(){var t=null;try{t=(document.cookie.match(/(?:^|; )ov_token=([^;]*)/)||[])[1]||localStorage.getItem('ov_token')}catch(e){}++try{OpenVibeNavbar.init({service:${JSON.stringify(site.service || site.id || 'network')},apiBase:'https://openvibe.network',token:t?decodeURIComponent(t):null})}catch(e){}++try{OpenVibeFooter.init({service:${JSON.stringify(site.service || site.id || 'network')},variant:'compact',mount:'#ov-footer',apiBase:'https://openvibe.network'})}catch(e){}});</script>++</body></html>`;++}++++/** Express handler for legal.PATHS. */++function handler(site) {++ const cache = new Map();++ return function ovLegal(req, res, next) {++ const kind = String(req.path || '').replace(/^\/+|\/+$/g, '');++ if (!BUILDERS[kind]) return next();++ if (!cache.has(kind)) cache.set(kind, page(kind, site));++ res.set('Content-Type', 'text/html; charset=utf-8');++ res.set('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400');++ res.send(cache.get(kind));++ };++}++++module.exports = { PATHS, TITLES, UPDATED, CONTACT, build, article, page, handler, CSS };@@@ -61,11 +61,28 @@color: var(--text-primary, #e0e0e0);}.openvibe-navbar-brand { display: flex; align-items: center; gap: 9px; text-decoration: none; color: inherit; margin-right: 8px; min-width: 0; }-- .openvibe-navbar-brand a { color: inherit; text-decoration: none; }-- .openvibe-navbar-brand a.b-core:hover, .openvibe-navbar-brand a.b-tld:hover, .openvibe-navbar-brand a.b-sub:hover { color: var(--accent-light, var(--accent, #60a5fa)); text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1.5px; }-- .openvibe-navbar-brand a:focus-visible, .ovnav-launch:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; border-radius: 6px; }-- .ovnav-launch { appearance: none; border: 0; background: transparent; color: var(--text-secondary, #a8b3c4); width: 32px; height: 32px; border-radius: 9px; display: inline-grid; place-items: center; cursor: pointer; flex: none; margin-right: 6px; transition: background .15s, color .15s; }-- .ovnav-launch:hover, .ovnav-launch[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.14)); color: var(--accent-light, var(--accent, #60a5fa)); }++ /* Brand group: mark + linked wordmark + launcher read as one control. Every property a host++ page might set on bare "nav a" / "a" is reset here, so the brand looks the same everywhere. */++ .openvibe-navbar .openvibe-navbar-brand a, .openvibe-navbar .openvibe-navbar-brand a:hover { all: unset; cursor: pointer; color: inherit; font: inherit; }++ .openvibe-navbar .openvibe-navbar-brand { gap: 8px; padding: 3px 4px 3px 3px; border-radius: 12px; margin-right: 2px; }++ .openvibe-navbar .openvibe-navbar-brand a.flame { display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; transition: background .15s; }++ .openvibe-navbar .openvibe-navbar-brand a.flame:hover { background: var(--accent-glow, rgba(59,130,246,.14)); }++ .openvibe-navbar .openvibe-navbar-brand .name { align-items: center; gap: 0; font-size: 15.5px; letter-spacing: -.25px; }++ .openvibe-navbar .openvibe-navbar-brand .name a { padding: 3px 2px; border-radius: 6px; transition: color .15s, background .15s; }++ .openvibe-navbar .openvibe-navbar-brand .name a.b-sub { color: var(--text-primary, #e6edf7); font-weight: 750; }++ .openvibe-navbar .openvibe-navbar-brand .name a.b-core { color: var(--text-primary, #e6edf7); font-weight: 700; }++ .openvibe-navbar .openvibe-navbar-brand .name a.b-tld { color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 700; }++ .openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-core, .openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-tld { color: var(--text-secondary, #a8b3c4); font-weight: 600; }++ .openvibe-navbar .openvibe-navbar-brand .name a:hover { color: var(--accent-light, var(--accent, #60a5fa)); background: var(--accent-glow, rgba(59,130,246,.12)); }++ .openvibe-navbar .openvibe-navbar-brand .b-dot { margin: 0; padding: 0 .5px; opacity: .55; }++ .openvibe-navbar .openvibe-navbar-brand a:focus-visible, .ovnav-launch:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }++ .ovnav-launch { all: unset; box-sizing: border-box; cursor: pointer; color: var(--text-secondary, #a8b3c4); width: 32px; height: 32px; border-radius: 10px; display: inline-grid; place-items: center; flex: none; margin-right: 10px; border: 1px solid transparent; transition: background .15s, color .15s, border-color .15s; }++ .ovnav-launch:hover, .ovnav-launch[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.14)); color: var(--accent-light, var(--accent, #60a5fa)); border-color: color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); }++ .ovnav-launch svg { display: block; transition: transform .25s cubic-bezier(.2,1.4,.3,1); }++ .ovnav-launch[aria-expanded="true"] svg { transform: rotate(45deg) scale(.92); }++ .ovnav-launcher a { all: unset; cursor: pointer; box-sizing: border-box; }++ .openvibe-navbar .ovnav-sep { width: 1px; height: 18px; background: var(--border, rgba(255,255,255,.12)); margin: 0 6px; flex: none; align-self: center; }++ @media (max-width: 1180px) { .openvibe-navbar .ovnav-net, .openvibe-navbar .ovnav-sep { display: none; } }.ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; width: min(440px, calc(100vw - 24px)); max-height: min(560px, calc(100vh - 80px)); overflow: auto; background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 12px; z-index: 1000; opacity: 0; transform: translateY(-6px) scale(.98); transform-origin: top left; pointer-events: none; transition: opacity .16s, transform .2s cubic-bezier(.2,1.2,.3,1); }.ovnav-launcher.open { opacity: 1; transform: none; pointer-events: auto; }.ovnav-launcher .ovl-q { width: 100%; box-sizing: border-box; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-primary, #0a0f18); color: var(--text-primary, #e6edf7); font: 500 14px/1.2 inherit; outline: none; }@@@ -79,6 +96,9 @@.ovnav-launcher .ovl-site:hover, .ovnav-launcher .ovl-fam:hover, .ovnav-launcher a:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); outline: none; }.ovnav-launcher b { display: block; font-size: 13px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ovnav-launcher small { display: block; font-size: 11px; color: var(--text-muted, #7d8aa0); margin-top: 1px; }++ .ovnav-launcher .ovl-soon { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 4px 4px; }++ .ovnav-launcher .ovl-soon a { font-size: 12px; font-weight: 600; padding: 4px 9px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.1)); color: var(--text-secondary, #a8b3c4); }++ .ovnav-launcher .ovl-soon a:hover { border-color: var(--accent, #3b82f6); color: var(--text-primary, #e6edf7); background: none; }.ovnav-launcher .ovl-empty { padding: 18px 8px; color: var(--text-secondary, #a8b3c4); font-size: 13px; }@media (max-width: 480px) { .ovnav-launcher { left: 8px; } .ovnav-launcher .ovl-fams { grid-template-columns: 1fr; } }@media (prefers-reduced-motion: reduce) { .ovnav-launcher { transition: none; } }@@@ -529,6 +549,42 @@${_config.launcher === false ? '' : '<button type="button" class="ovnav-launch" id="openvibe-launcher-btn" aria-label="All OpenVibe sites and tools" aria-haspopup="true" aria-expanded="false" title="All OpenVibe sites and tools"><svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><g fill="currentColor"><circle cx="5" cy="5" r="2"/><circle cx="12" cy="5" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="12" cy="19" r="2"/><circle cx="19" cy="19" r="2"/></g></svg></button>'}`;}++++ // ── Shared chrome data (https://openvibe.network/api/chrome) ─────────────────────────────++ // Sites ordered by real use, footer copy and per-site legal links. Cached per host for 30++ // minutes in localStorage and refreshed in the background, so pages paint from cache and the++ // order never jumps while someone is looking at it. Defined once, shared by navbar + footer.++ const OVChrome = root.OpenVibeChrome || (root.OpenVibeChrome = (function () {++ const KEY = 'ov_chrome_v1', TTL = 30 * 60000;++ let inflight = null;++ const host = () => (typeof location !== 'undefined' ? location.hostname : '');++ const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } };++ function clean(d) {++ if (!d || !Array.isArray(d.nav)) return null;++ const link = (l) => (l && typeof l.name === 'string' && https(l.url) ? { id: String(l.id || ''), name: l.name.slice(0, 60), url: l.url, icon: String(l.icon || ''), tagline: String(l.tagline || '').slice(0, 80) } : null);++ const f = d.footer || {}, lg = f.legal || {};++ return { nav: d.nav.map(link).filter(Boolean).slice(0, 12), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24),++ footer: { blurb: String(f.blurb || '').slice(0, 200), discover: (f.discover || []).map(link).filter(Boolean).slice(0, 6), popular: (f.popular || []).map(link).filter(Boolean).slice(0, 10),++ legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null } };++ }++ function cached() { try { const c = JSON.parse(localStorage.getItem(KEY) || 'null'); return c && c.host === host() && c.data ? c : null; } catch { return null; } }++ function refresh() {++ if (inflight || typeof fetch === 'undefined') return inflight || Promise.resolve(null);++ inflight = fetch('https://openvibe.network/api/chrome?host=' + encodeURIComponent(host()), { credentials: 'omit' })++ .then(r => (r.ok ? r.json() : null)).then(clean)++ .then(d => { if (d) { try { localStorage.setItem(KEY, JSON.stringify({ at: Date.now(), host: host(), data: d })); } catch { /* */ } } return d; })++ .catch(() => null);++ return inflight;++ }++ /** Cached data now (or null); `onFirst` fires once when a first-ever fetch lands. */++ function get(onFirst) {++ const c = cached();++ if (!c || Date.now() - c.at > TTL) { const p = refresh(); if (!c && onFirst) p.then(d => { if (d) onFirst(d); }); }++ return c ? c.data : null;++ }++ return { get, refresh };++ })());++// ── Network launcher ─────────────────────────────────────const LAUNCHER_SITES = [{ name: 'Live', desc: 'Streams, clips and chat', icon: 'live', url: 'https://openvibe.live/' },@@@ -575,8 +631,12 @@body.innerHTML = hits.length ? `<div class="ovl-h">Tools</div><div class="ovl-fams">${hits.map(t => tile(t, 'ovl-fam')).join('')}</div>` : '<div class="ovl-empty">No tool matches. <a href="https://openvibe.tools/">Browse them all</a></div>';return;}-- body.innerHTML = `<div class="ovl-h">Sites</div><div class="ovl-sites">${LAUNCHER_SITES.map(x => tile(x, 'ovl-site')).join('')}</div>-- <div class="ovl-h">Tools <a href="https://openvibe.tools/">See all</a></div><div class="ovl-fams">${fams.map(x => tile(x, 'ovl-fam')).join('')}</div>`;++ const chrome = OVChrome.get();++ const sites = chrome && chrome.nav.length ? chrome.nav.map(n => ({ name: n.name, desc: n.tagline, icon: n.icon || n.id, url: n.url })) : LAUNCHER_SITES;++ const soon = chrome ? chrome.soon : [];++ body.innerHTML = `<div class="ovl-h">Sites</div><div class="ovl-sites">${sites.map(x => tile(x, 'ovl-site')).join('')}</div>++ <div class="ovl-h">Tools <a href="https://openvibe.tools/">See all</a></div><div class="ovl-fams">${fams.map(x => tile(x, 'ovl-fam')).join('')}</div>++ ${soon.length ? `<div class="ovl-h">Opening soon</div><div class="ovl-soon">${soon.map(x => `<a href="${escapeAttr(x.url)}">${escapeAttr(x.name)}</a>`).join('')}</div>` : ''}`;}btn.addEventListener('click', async (e) => {e.stopPropagation();@@@ -605,6 +665,19 @@});}++ /** The network's most used sites, after the page's own links (networkLinks: false turns it off). */++ function networkLinksHTML(pageLinks) {++ if (_config.networkLinks === false) return '';++ const chrome = OVChrome.get((d) => { if (d && _navEl && !_navEl.querySelector('.ovnav-net')) { try { render(); } catch { /* */ } } });++ if (!chrome) return '';++ const here = currentHost().toLowerCase();++ const taken = new Set((pageLinks || []).map(l => { try { return new URL(l.href, location.href).hostname; } catch { return ''; } }));++ const max = typeof _config.networkLinks === 'number' ? _config.networkLinks : 4;++ const pick = chrome.nav.filter(n => { try { const h = new URL(n.url).hostname; return h !== here && !here.endsWith('.' + h) && !taken.has(h); } catch { return false; } }).slice(0, max);++ if (!pick.length) return '';++ return `<span class="ovnav-sep" aria-hidden="true"></span>` + pick.map(n => `<a class="ovnav-net" href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}">${escapeAttr(n.name)}</a>`).join('');++ }++// ── Notification bell (mounted by the navbar itself) ─────function mountBell(nav, u) {if (_config.notifications === false || !u || u.is_anon || !_config.token) return;@@@ -775,7 +848,7 @@/** Where "Sign In" goes on this host. */function resolveLoginHref(host, returnUrl) {-- if (_config.loginUrl) return _config.loginUrl;++ if (_config.loginUrl) return String(_config.loginUrl).replace('{url}', encodeURIComponent(returnUrl || '/'));if (onToolsDomain(host)) {// Every *.openvibe.tools host signs in through the gateway apex: it// holds the OAuth state cookie + redirect_uri (both apex-host-only)@@@ -1122,6 +1195,7 @@${brandHTML(brand)}<div class="openvibe-navbar-links">${links.map(l => `<a href="${escapeAttr(l.href)}"${l.active ? ' class="active"' : ''}${l.external ? ' target="_blank" rel="noopener"' : ''}>${l.icon ? `<i class="fa-solid ${escapeAttr(l.icon)} icon"></i>` : ''}${escapeAttr(l.label)}</a>`).join('')}++ ${networkLinksHTML(links)}${u && u.role === 'admin' ? `<a href="https://openvibe.network/admin"><i class="fa-solid fa-shield-halved"></i> Admin</a>` : ''}</div><div class="openvibe-navbar-spacer"></div>@@@ -13,7 +13,8 @@"./notifications": "./notifications.js","./url-resolver": "./url-resolver.js","./seo": "./seo.js",-- "./icons": "./ov-icons.js"++ "./icons": "./ov-icons.js",++ "./legal": "./legal.js"},"license": "MIT","dependencies": {@@@ -152,8 +152,8 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}<!-- Hero --><section class="hero"><div class="hero-badge"><span class="ov-mark" data-size="20" data-variant="network"></span> The OpenVibe Network — open source, community run</div>-- <h1>One Account.<br><span class="grad">All of OpenVibe.</span></h1>-- <p class="sub">Live streaming, <!--OV:COUNT--> online tools, games, media, pastes and community. One account works across the whole network: sign in once and every OpenVibe site knows you.</p>++ <h1>The open network<br><span class="grad">for what you do online.</span></h1>++ <p class="sub">Live streaming, <!--OV:COUNT--> online tools, games, community pastes and a shared media library. Open source, run by the people who use it, and open to everyone without an account. Sign in once and every site already knows you.</p><div class="hero-ctas" id="hero-guest-ctas"><a class="btn btn-primary" href="/login"><i class="fa-solid fa-right-to-bracket"></i> Sign In</a><a class="btn btn-ghost" href="/login?tab=register"><i class="fa-solid fa-user-plus"></i> Create Account</a>@@@ -193,7 +193,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}function initNavbar(user) {if (typeof OpenVibeNavbar !== 'undefined') {-- OpenVibeNavbar.init({ service: 'network', token: token || null, user: user || null, links: [{ label: 'Home', href: '/' }, { label: 'My Account', href: '/my' }, { label: 'Themes', href: '/themes' }, { label: 'The network', href: '/#network' }] });++ OpenVibeNavbar.init({ service: 'network', token: token || null, user: user || null, links: [{ label: 'My Account', href: '/my' }, { label: 'Themes', href: '/themes' }], networkLinks: 5 });}if (user) {document.getElementById('hero-guest-ctas').style.display = 'none';nnew file mode 100644@@@ -0,0 +1,147 @@++'use strict';++// ═══════════════════════════════════════════════════════════════++// Shared chrome data: what the navbar and footer of every site show.++//++// GET /api/chrome?host=<hostname> public, CORS *, max-age=600, ETag++// → { updated, nav: [{ id, name, url, icon, tagline }], open sites, most used first++// soon: [{ id, name, url, icon }],++// footer: { blurb, discover: [{ name, url }], popular: [{ name, url }], legal: { terms, privacy, dmca } } }++//++// Ranking: page views and visitors from each service's own analytics (last 7 days) plus signed-in++// cross-site history, refreshed every 30 minutes and kept in the database so a restart or an++// unreachable service never reorders the network at random. Only totals cross the wire.++// Copy: once a day the configured AI (through OpenVibe.Live's internal API) may write a blurb per++// site and pick "discover" links BY ID from a list we supply; ids resolve to our own URLs here, text++// is stripped, length-capped and screened. No AI, or a bad answer → the hand-written copy stays.++// ═══════════════════════════════════════════════════════════════++const express = require('express');++const crypto = require('crypto');++const { SITES, siteForHost } = require('./sites');++const toolsCatalog = require('../domains/catalog');++++const RANK_MS = 30 * 60_000, COPY_MS = 24 * 60 * 60_000, INTERNAL_SECRET = 'openvibe-internal-2026';++const BANNED = /\b(free|\$0|no ads|ad[- ]free|no cost|gratis)\b|https?:|www\.|[<>{}]/i;++const BASE_WEIGHT = { live: 6, tools: 5, community: 4, games: 3, media: 2, network: 1 }; // cold-start order only++++function createChromeService(db, config, analytics) {++ db.exec('CREATE TABLE IF NOT EXISTS chrome_cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP)');++ const load = (k) => { try { const r = db.prepare('SELECT value, updated_at FROM chrome_cache WHERE key = ?').get(k); return r ? { value: JSON.parse(r.value), at: Date.parse(r.updated_at + 'Z') || 0 } : null; } catch { return null; } };++ const save = (k, v) => db.prepare('INSERT INTO chrome_cache (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP').run(k, JSON.stringify(v));++++ let rank = (load('rank') || {}).value || { scores: {}, tools: [] };++ let copy = (load('copy') || {}).value || { sites: {} };++ let version = Date.now();++++ const svcUrl = (name, fallback) => (config.services && config.services[name] && config.services[name].internalUrl) || fallback;++ async function getJson(url, headers) {++ try { const r = await fetch(url, { headers, signal: AbortSignal.timeout(6000) }); return r.ok ? await r.json() : null; } catch { return null; }++ }++++ async function refreshRank() {++ const sources = await Promise.all([++ getJson(`${svcUrl('live', 'http://127.0.0.1:3000')}/internal/analytics-summary?days=7`, { 'X-Internal-Key': config.internalKey }).then(j => ['live', j && j.summary]),++ ...[['tools', 'http://127.0.0.1:4001'], ['games', 'http://127.0.0.1:8000'], ['media', 'http://127.0.0.1:4100']].map(([n, u]) =>++ getJson(`${svcUrl(n, u)}/api/internal/analytics?days=7`, { 'X-Internal-Secret': INTERNAL_SECRET }).then(j => [n, j && j.analytics && j.analytics.summary])),++ ]);++ const scores = Object.assign({}, rank.scores);++ try { const own = analytics && analytics.getStats({ days: 7 }); if (own && own.summary) sources.push(['network', own.summary]); } catch { /* */ }++ for (const [name, sum] of sources) {++ if (!sum) continue; // unreachable: keep the last known score++ scores[name] = Math.round((Number(sum.total_pageviews) || 0) + 4 * (Number(sum.unique_visitors) || Number(sum.total_unique_visitors) || 0));++ }++ // Signed-in history covers every site (community has no analytics endpoint) and names the tools people use.++ let tools = rank.tools || [];++ try {++ const bySvc = db.prepare("SELECT service, COUNT(*) AS n, COUNT(DISTINCT user_id) AS u FROM user_history WHERE created_at > datetime('now', '-14 days') GROUP BY service").all();++ for (const r of bySvc) { const id = r.service === 'pastes' ? 'community' : r.service; if (!id) continue; scores[id + ':history'] = r.n + 5 * r.u; }++ tools = db.prepare("SELECT sub, COUNT(*) AS n FROM user_history WHERE service = 'tools' AND sub IS NOT NULL AND sub != '' AND created_at > datetime('now', '-30 days') GROUP BY sub ORDER BY n DESC LIMIT 12").all().map(r => r.sub);++ } catch { /* table appears on first history write */ }++ rank = { scores, tools };++ save('rank', rank); version = Date.now();++ }++++ const scoreOf = (id) => (rank.scores[id] || 0) + 3 * (rank.scores[id + ':history'] || 0);++ function orderedOpen() {++ const open = SITES.filter(s => s.status === 'open');++ const max = Math.max(1, ...open.map(s => scoreOf(s.id)));++ // Real use decides; the base weight only breaks ties and orders a cold start.++ return open.map(s => ({ s, v: scoreOf(s.id) / max + (BASE_WEIGHT[s.id] || 0) / 100 })).sort((a, b) => b.v - a.v).map(x => x.s);++ }++++ function linkPool() {++ const { catalog } = toolsCatalog.peek();++ const pool = new Map();++ for (const s of SITES.filter(x => x.status === 'open')) pool.set('site:' + s.id, { name: 'OpenVibe.' + s.name, url: `https://${s.host}/`, about: s.tagline });++ for (const f of catalog.families) if (f.path) pool.set('family:' + f.id, { name: f.name, url: 'https://openvibe.tools' + f.path, about: f.tagline });++ for (const t of catalog.tools) pool.set('tool:' + t.id, { name: t.name, url: t.url, about: t.tagline });++ return pool;++ }++ function popularTools(limit) {++ const { catalog } = toolsCatalog.peek();++ const byId = new Map(catalog.tools.map(t => [t.id, t]));++ const seed = ['yt', 'convert', 'mergepdf', 'jsonfmt', 'mp3', 'dns', 'whois', 'compress'];++ return [...new Set([...(rank.tools || []), ...seed])].map(id => byId.get(id)).filter(Boolean).slice(0, limit).map(t => ({ name: t.name, url: t.url }));++ }++++ async function refreshCopy() {++ if (!config.internalKey || config.internalKey === 'change-me-in-production') return;++ const pool = linkPool();++ const popular = new Set(popularTools(12).map(t => t.url));++ const links = [...pool.entries()].filter(([id, l]) => !id.startsWith('tool:') || popular.has(l.url)).map(([id, l]) => ({ id, name: l.name, about: l.about }));++ const body = { sites: SITES.filter(s => s.status === 'open').map(s => ({ id: s.id, name: 'OpenVibe.' + s.name, what: s.what, popular: s.id === 'tools' ? popularTools(6).map(t => t.name) : [] })), links };++ let j = null;++ try {++ const r = await fetch(`${svcUrl('live', 'http://127.0.0.1:3000')}/internal/ai/site-copy`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Internal-Key': config.internalKey }, body: JSON.stringify(body), signal: AbortSignal.timeout(90_000) });++ j = r.ok ? await r.json() : null;++ } catch { j = null; }++ if (!j || !Array.isArray(j.sites)) return;++ const next = {};++ for (const row of j.sites) {++ const site = SITES.find(s => s.id === row.id); if (!site) continue;++ const blurb = String(row.blurb || '').replace(/\s+/g, ' ').trim();++ const picks = (Array.isArray(row.picks) ? row.picks : []).filter(id => pool.has(id) && id !== 'site:' + site.id).slice(0, 4);++ if (blurb.length < 20 || blurb.length > 170 || BANNED.test(blurb)) continue; // screened out: the hand-written copy stays++ next[site.id] = { blurb, picks };++ }++ if (Object.keys(next).length) { copy = { sites: next, model: String(j.model || '').slice(0, 60) }; save('copy', copy); version = Date.now(); }++ }++++ function payloadFor(hostname) {++ const site = siteForHost(hostname) || SITES.find(s => s.id === 'network');++ const open = orderedOpen(); const pool = linkPool();++ const ai = copy.sites[site.id];++ const discover = (ai ? ai.picks.map(id => pool.get(id)) : open.filter(s => s.id !== site.id).slice(0, 4).map(s => pool.get('site:' + s.id))).filter(Boolean).map(l => ({ name: l.name, url: l.url }));++ // Legal documents live on the site's own apex, so each domain answers for itself.++ const legalBase = `https://${site.host}`;++ return {++ updated: new Date(version).toISOString(), site: { id: site.id, name: site.name, host: site.host, status: site.status },++ nav: open.map(s => ({ id: s.id, name: s.name, url: `https://${s.host}/`, icon: s.icon, tagline: s.tagline })),++ soon: SITES.filter(s => s.status === 'soon').map(s => ({ id: s.id, name: s.name, url: `https://${s.host}/`, icon: s.icon })),++ footer: { blurb: (ai && ai.blurb) || site.what, ai: !!ai, discover, popular: popularTools(8), legal: { terms: legalBase + '/terms', privacy: legalBase + '/privacy', dmca: legalBase + '/dmca' } },++ };++ }++++ const router = express.Router();++ const cache = new Map();++ router.get('/', (req, res) => {++ const host = String(req.query.host || '').toLowerCase().slice(0, 255);++ const site = siteForHost(host) || { id: 'network' };++ let e = cache.get(site.id);++ if (!e || e.version !== version) { const body = JSON.stringify(payloadFor(host)); e = { version, body, etag: '"' + crypto.createHash('sha1').update(body).digest('base64url').slice(0, 18) + '"' }; cache.set(site.id, e); }++ res.set({ 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'public, max-age=600, stale-while-revalidate=86400', ETag: e.etag, 'Content-Type': 'application/json; charset=utf-8' });++ if (req.headers['if-none-match'] === e.etag) return res.status(304).end();++ res.send(e.body);++ });++++ function start() {++ const rankAge = Date.now() - ((load('rank') || {}).at || 0), copyAge = Date.now() - ((load('copy') || {}).at || 0);++ const t1 = setTimeout(() => refreshRank().catch(() => {}), rankAge > RANK_MS ? 8000 : RANK_MS - rankAge); t1.unref();++ const i1 = setInterval(() => refreshRank().catch(() => {}), RANK_MS); i1.unref();++ const t2 = setTimeout(() => refreshCopy().catch(() => {}), copyAge > COPY_MS ? 60_000 : COPY_MS - copyAge); t2.unref();++ const i2 = setInterval(() => refreshCopy().catch(() => {}), COPY_MS); i2.unref();++ }++++ return { router, start, refreshRank, refreshCopy, payloadFor, _state: () => ({ rank, copy }) };++}++++module.exports = { createChromeService, BANNED };nnew file mode 100644@@@ -0,0 +1,39 @@++'use strict';++// Every OpenVibe property: what it is, whether it is open, and which legal documents apply.++// legal.profile decides the wording of /terms, /privacy and /dmca on that site (openvibe-shared/legal):++// streaming user video + chat + payments tools files processed and discarded++// ugc user posts/pastes games accounts + game state++// hosting stored user media account identity provider++// info read-only placeholder/editorial site++const SITES = [++ { id: 'live', name: 'Live', host: 'openvibe.live', icon: 'live', status: 'open', service: 'live', profile: 'streaming', tagline: 'Streams, clips and chat', what: 'Live streaming from a browser, OBS or a phone, with restreaming, clips, VODs, chat games and channel points.' },++ { id: 'tools', name: 'Tools', host: 'openvibe.tools', icon: 'tools', status: 'open', service: 'tools', profile: 'tools', tagline: 'Every online tool', what: 'More than 160 online tools: converters, downloaders, PDF and image tools, developer utilities and network diagnostics, each on its own address.' },++ { id: 'community', name: 'Community', host: 'openvibe.community', icon: 'community', status: 'open', service: 'community', profile: 'ugc', tagline: 'Pastes and posts', what: 'Share text and code with a link, comment, and follow what people on OpenVibe are making.' },++ { id: 'games', name: 'Games', host: 'openvibe.games', icon: 'games', status: 'open', service: 'games', profile: 'games', tagline: 'Browser games', what: 'Scraplandia, a shared pixel canvas and other games that run in a tab and know your OpenVibe account.' },++ { id: 'media', name: 'Media', host: 'openvibe.media', icon: 'media', status: 'open', service: 'media', profile: 'hosting', tagline: 'VODs, clips, files', what: 'The public media library behind every site: recorded streams, clips, images and uploads.' },++ { id: 'network', name: 'Network', host: 'openvibe.network', icon: 'network', status: 'open', service: 'network', profile: 'account', tagline: 'Account and themes', what: 'The account behind every site: one sign-in, themes, notifications, history and linked services.' },++ { id: 'chat', name: 'Chat', host: 'openvibe.chat', icon: 'chat', status: 'soon', profile: 'ugc', tagline: 'Rooms and direct messages', what: 'Community chat rooms and direct messages across the network.' },++ { id: 'codes', name: 'Codes', host: 'openvibe.codes', icon: 'codes', status: 'soon', profile: 'ugc', tagline: 'Snippets and open source', what: 'Code snippets, gists and the source of OpenVibe itself.' },++ { id: 'blog', name: 'Blog', host: 'openvibe.blog', icon: 'blog', status: 'soon', profile: 'info', tagline: 'Updates from the builders', what: 'Release notes and stories from the people building OpenVibe.' },++ { id: 'wiki', name: 'Wiki', host: 'openvibe.wiki', icon: 'wiki', status: 'soon', profile: 'ugc', tagline: 'Guides and how-tos', what: 'Guides for streaming, tools and everything else on the network.' },++ { id: 'news', name: 'News', host: 'openvibe.news', icon: 'news', status: 'soon', profile: 'info', tagline: 'What is happening', what: 'News from the network and the communities on it.' },++ { id: 'reviews', name: 'Reviews', host: 'openvibe.reviews', icon: 'reviews', status: 'soon', profile: 'ugc', tagline: 'Honest reviews', what: 'Community reviews of gear, software and services.' },++ { id: 'tips', name: 'Tips', host: 'openvibe.tips', icon: 'tips', status: 'soon', profile: 'streaming', tagline: 'Support creators', what: 'Send support to the streamers and makers you like.' },++ { id: 'vip', name: 'VIP', host: 'openvibe.vip', icon: 'vip', status: 'soon', profile: 'account', tagline: 'Supporter perks', what: 'Perks for people who support the network.' },++ { id: 'trade', name: 'Trade', host: 'openvibe.trade', icon: 'trade', status: 'soon', profile: 'ugc', tagline: 'Swap and sell', what: 'A community marketplace for trades and sales.' },++ { id: 'host', name: 'Host', host: 'openvibe.host', icon: 'host', status: 'soon', profile: 'hosting', tagline: 'Hosting for projects', what: 'Simple hosting for community projects.' },++ { id: 'deals', name: 'Deals', host: 'openvibe.deals', icon: 'deals', status: 'soon', profile: 'info', tagline: 'Deals worth sharing', what: 'Deals found and vetted by the community.' },++ { id: 'coupons', name: 'Coupons', host: 'openvibe.coupons', icon: 'coupons', status: 'soon', profile: 'info', tagline: 'Codes that work', what: 'Coupon codes checked by real people.' },++ { id: 'stream', name: 'OpenRe.Stream', host: 'openre.stream', icon: 'stream', status: 'soon', profile: 'streaming', tagline: 'Restream anywhere', what: 'Send one stream to every platform at once.' },++];++const byHost = new Map(SITES.map(s => [s.host, s]));++++/** The site a hostname belongs to (subdomains included), or null. */++function siteForHost(hostname) {++ const h = String(hostname || '').toLowerCase().replace(/:\d+$/, '').replace(/^www\./, '');++ if (byHost.has(h)) return byHost.get(h);++ for (const s of SITES) if (h.endsWith('.' + s.host)) return s;++ return null;++}++++module.exports = { SITES, siteForHost };@@@ -11,7 +11,7 @@ const toolsCatalog = require('../domains/catalog');const SHELL = path.join(__dirname, '..', '..', 'public', 'index.html');const esc = seo.esc;--const icon = (name, size) => `<span class="ov-icon" data-icon="${esc(name)}" data-fx="none" style="--ovi-size:${size}px" aria-hidden="true">${icons.svg(name)}</span>`;++const icon = (name, size) => `<span class="ov-icon" data-icon="${esc(name)}" data-ovi="${esc(icons.resolve(name))}" data-fx="none" style="--ovi-size:${size}px" aria-hidden="true">${icons.svg(name)}</span>`;const SITES = [['live', 'OpenVibe.Live', 'https://openvibe.live/', 'Live streaming', 'Go live from a browser, OBS or a phone. Restream to other platforms, clip moments, run chat games and keep your VODs.'],@@@ -22,6 +22,8 @@ const SITES = [];const SOON = [['chat', 'Chat'], ['codes', 'Codes'], ['blog', 'Blog'], ['wiki', 'Wiki'], ['news', 'News'], ['reviews', 'Reviews'], ['tips', 'Tips'], ['vip', 'VIP'], ['trade', 'Trade'], ['host', 'Host'], ['deals', 'Deals'], ['coupons', 'Coupons']];const ACCOUNT = [++ ['live', 'Go live and keep your VODs', 'Stream from a browser tab, clip the good parts, earn channel points and restream to other platforms.', 'https://openvibe.live/'],++ ['tools', 'Tool results that stick around', 'Guests keep results for an hour. Signed in, they stay for a day and your recent tools follow you.', 'https://openvibe.tools/'],['account', 'One sign-in', 'Sign in once. Every OpenVibe site recognises you, including the ones you have not visited yet.', '/login'],['theme', 'Themes that follow you', 'Pick or build a theme and every site wears it.', '/themes'],['history', 'History across sites', 'Streams you watched, tools you used and pastes you opened, in one list you control.', '/history'],@@@ -30,7 +32,7 @@ const ACCOUNT = [const POPULAR = ['yt', 'convert', 'mergepdf', 'jsonfmt', 'mp3', 'dns', 'whois', 'compress', 'fancy', 'ssl', 'logo', 'regex'];const CSS = `--.home-sec{margin:56px 0 0}.home-sec>h2{font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em;margin:0 0 6px}.home-sec>p.lede{color:var(--text-secondary,#a8b3c4);margin:0 0 18px;max-width:760px}++.home-sec{max-width:1080px;margin:56px auto 0;padding:0 24px}.home-sec>h2{font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em;margin:0 0 6px}.home-sec>p.lede{color:var(--text-secondary,#a8b3c4);margin:0 0 18px;max-width:760px}.home-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(260px,1fr))}.home-card{display:flex;gap:14px;align-items:flex-start;padding:16px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#111826);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}.home-card:hover,.home-card:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}@@@ -40,8 +42,8 @@ const CSS = `.home-fam{display:flex;gap:12px;align-items:center;padding:12px 14px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));color:inherit;text-decoration:none}.home-fam:hover{border-color:var(--accent,#3b82f6)}.home-fam b{display:block;font-size:14.5px}.home-fam small{color:var(--text-muted,#7d8aa0);font-size:12.5px}.home-chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px;padding:0;list-style:none}--.home-chips a,.home-chips span{display:inline-flex;align-items:center;gap:7px;padding:6px 12px 6px 7px;border-radius:999px;border:1px solid var(--border,rgba(255,255,255,.1));font-size:13.5px;font-weight:600;color:inherit;text-decoration:none}--.home-chips a:hover{border-color:var(--accent,#3b82f6)}.home-chips span{color:var(--text-secondary,#a8b3c4)}++.home-chips>li>a{display:inline-flex;align-items:center;gap:7px;padding:6px 12px 6px 7px;border-radius:999px;border:1px solid var(--border,rgba(255,255,255,.1));font-size:13.5px;font-weight:600;color:inherit;text-decoration:none}++.home-chips>li>a:hover{border-color:var(--accent,#3b82f6)}.home-dev{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(240px,1fr))}.home-dev div{padding:16px;border-radius:16px;border:1px dashed var(--border,rgba(255,255,255,.14))}.home-dev b{display:block;margin-bottom:4px}.home-dev p{margin:0;color:var(--text-secondary,#a8b3c4);font-size:13.5px}.home-dev code{font-size:12.5px}@media (prefers-reduced-motion:reduce){.home-card{transition:none}}@@@ -53,14 +55,14 @@ function body(catalog) {const fams = catalog.families.filter(f => f.url);const count = (f) => f.count || catalog.tools.filter(t => t.family === f.id).length;return `<style>${CSS}</style>--<section class="home-sec" id="network" aria-labelledby="h-sites"><h2 id="h-sites">The network</h2><p class="lede">Five sites are open today. Your account, theme and notifications work on all of them.</p>++<section class="home-sec" id="network" aria-labelledby="h-sites"><h2 id="h-sites">Five sites, one front door</h2><p class="lede">Stream, build, share, play and store. Everything is open to visitors; signing in once carries your name, theme and notifications to all of it.</p><div class="home-grid">${SITES.map(([ic, n, u, tag, d]) => `<a class="home-card" href="${u}">${icon(ic, 48)}<span><b>${n}</b><em>${tag}</em><small>${d}</small></span></a>`).join('')}</div></section>--<section class="home-sec" id="tools" aria-labelledby="h-tools"><h2 id="h-tools">${catalog.tools.length} tools, one toolbox</h2><p class="lede">Every tool has its own short address, so <a href="https://yt.openvibe.tools/">yt.openvibe.tools</a> or <a href="https://dns.openvibe.tools/">dns.openvibe.tools</a> takes you straight there. Browse them all at <a href="https://openvibe.tools/">openvibe.tools</a>.</p>++<section class="home-sec" id="tools" aria-labelledby="h-tools"><h2 id="h-tools">${catalog.tools.length} tools that just open</h2><p class="lede">No installs and no sign-up wall. Every tool has its own short address, so <a href="https://yt.openvibe.tools/">yt.openvibe.tools</a> or <a href="https://dns.openvibe.tools/">dns.openvibe.tools</a> takes you straight there. Browse them all at <a href="https://openvibe.tools/">openvibe.tools</a>.</p><div class="home-fams">${fams.map(f => `<a class="home-fam" href="${esc(f.path ? 'https://openvibe.tools' + f.path : f.url)}">${icon(f.icon, 38)}<span><b>${esc(f.name)}</b><small>${count(f)} tools · ${esc(f.tagline || '')}</small></span></a>`).join('')}</div><ul class="home-chips">${popular.map(t => `<li><a href="${esc(t.url)}" title="${esc(t.tagline || '')}">${icon(t.icon, 22)}${esc(t.name)}</a></li>`).join('')}</ul></section>--<section class="home-sec" aria-labelledby="h-acct"><h2 id="h-acct">What your account does</h2><p class="lede">openvibe.network is the identity behind every site. It holds your sign-in and your preferences, and nothing else.</p>++<section class="home-sec" aria-labelledby="h-acct"><h2 id="h-acct">What signing in adds</h2><p class="lede">You can use almost everything as a guest. An account is for the things that need to remember you, and it works on every OpenVibe site the moment you arrive.</p><div class="home-grid">${ACCOUNT.map(([ic, n, d, u]) => `<a class="home-card" href="${u}">${icon(ic, 44)}<span><b>${n}</b><small>${d}</small></span></a>`).join('')}</div></section>--<section class="home-sec" aria-labelledby="h-soon"><h2 id="h-soon">More rooms being built</h2><p class="lede">These addresses are reserved and open as each one is ready.</p>++<section class="home-sec" aria-labelledby="h-soon"><h2 id="h-soon">Opening next</h2><p class="lede">Thirteen more addresses are staked out. Each one opens when it is good enough to use daily, and your account will already work there.</p><ul class="home-chips">${SOON.map(([id, n]) => `<li><a href="https://openvibe.${id}/">${icon(id, 22)}OpenVibe.${n}</a></li>`).join('')}<li><a href="https://openre.stream/">${icon('stream', 22)}OpenRe.Stream</a></li></ul></section><section class="home-sec" aria-labelledby="h-dev"><h2 id="h-dev">For developers and crawlers</h2><p class="lede">OpenVibe is open source and community-run. Everything public is meant to be read by machines too.</p><div class="home-dev"><div><b>Tool catalog</b><p>Every tool with its description, keywords and addresses as JSON: <a href="https://openvibe.tools/api/catalog.json"><code>openvibe.tools/api/catalog.json</code></a></p></div>@@@ -415,6 +415,14 @@ function requireAdmin(req, res, next) {}next();}++// Terms, Privacy and DMCA for this domain (openvibe-shared/legal; every site serves its own).++{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'network', service: 'network', host: 'openvibe.network', name: 'OpenVibe.Network', profile: 'account' })); app.get('/tos', (_req, res) => res.redirect(301, '/terms')); }++++// Shared chrome: analytics-ranked navigation + footer copy for every site (server/chrome).++const chromeService = require('./chrome/service').createChromeService(db, config, analytics);++app.use('/api/chrome', rateLimit({ windowMs: 60_000, max: 240 }), chromeService.router);++chromeService.start();++// Tool domains: public list for the Tools gateway, owner-only management (docs/shared-contracts.md §1).const toolDomains = require('./domains/routes').createDomainRoutes(db, requireAuth);app.use('/api/domains', rateLimit({ windowMs: 60_000, max: 120 }), toolDomains.publicRouter);nnew file mode 100644@@@ -0,0 +1,31 @@++'use strict';++const assert = require('assert');++const Database = require('better-sqlite3');++const { createChromeService, BANNED } = require('../server/chrome/service');++const { siteForHost } = require('../server/chrome/sites');++++const db = new Database(':memory:');++db.exec("CREATE TABLE user_history (id INTEGER PRIMARY KEY, user_id INT, service TEXT, sub TEXT, type TEXT, title TEXT, url TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)");++const svc = createChromeService(db, { internalKey: 'change-me-in-production', services: {} }, null);++++assert.equal(siteForHost('yt.openvibe.tools').id, 'tools');++assert.equal(siteForHost('evil.example'), null);++let p = svc.payloadFor('openvibe.media');++assert.deepEqual(p.nav.map(n => n.id), ['live', 'tools', 'community', 'games', 'media', 'network'], 'cold start follows the base order');++assert.equal(p.footer.legal.dmca, 'https://openvibe.media/dmca', 'legal links stay on the site\'s own domain');++assert.ok(!p.footer.discover.some(l => l.url === 'https://openvibe.media/'), 'a site never recommends itself');++assert.ok(p.soon.length >= 10);++++// Real use reorders: community gets the history, so it rises above the cold-start order.++const ins = db.prepare("INSERT INTO user_history (user_id, service, sub, type, title, url) VALUES (?, ?, ?, 'page', 't', 'u')");++for (let i = 0; i < 40; i++) ins.run(i % 9, 'community', null);++for (let i = 0; i < 6; i++) ins.run(1, 'tools', 'dns');++(async () => {++ const realFetch = global.fetch; global.fetch = async () => ({ ok: false });++ await svc.refreshRank(); global.fetch = realFetch;++ p = svc.payloadFor('openvibe.network');++ assert.equal(p.nav[0].id, 'community', 'most used site comes first');++ assert.ok(BANNED.test('Totally free tools') && BANNED.test('see https://x.y') && BANNED.test('<b>hi</b>') && !BANNED.test('Go live from a browser in seconds.'), 'AI copy screen');++ assert.equal(p.footer.ai, false);++ console.log('chrome service: all checks passed');++})().catch(e => { console.error(e); process.exit(1); });
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
- "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node packages/openvibe-shared/test/shared-modules.test.js",
+ "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node packages/openvibe-shared/test/shared-modules.test.js && node test/chrome.test.js",
"reset-db": "node server/reset-db.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -27,6 +27,41 @@
@@ -27,6 +27,41 @@
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
'use strict';
+ // ── Shared chrome data (https://openvibe.network/api/chrome) ─────────────────────────────
+ // Sites ordered by real use, footer copy and per-site legal links. Cached per host for 30
+ // minutes in localStorage and refreshed in the background, so pages paint from cache and the
+ // order never jumps while someone is looking at it. Defined once, shared by navbar + footer.
+ const OVChrome = root.OpenVibeChrome || (root.OpenVibeChrome = (function () {
+ const KEY = 'ov_chrome_v1', TTL = 30 * 60000;
+ let inflight = null;
+ const host = () => (typeof location !== 'undefined' ? location.hostname : '');
+ const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } };
+ function clean(d) {
+ if (!d || !Array.isArray(d.nav)) return null;
+ const link = (l) => (l && typeof l.name === 'string' && https(l.url) ? { id: String(l.id || ''), name: l.name.slice(0, 60), url: l.url, icon: String(l.icon || ''), tagline: String(l.tagline || '').slice(0, 80) } : null);
+ const f = d.footer || {}, lg = f.legal || {};
+ return { nav: d.nav.map(link).filter(Boolean).slice(0, 12), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24),
+ footer: { blurb: String(f.blurb || '').slice(0, 200), discover: (f.discover || []).map(link).filter(Boolean).slice(0, 6), popular: (f.popular || []).map(link).filter(Boolean).slice(0, 10),
+ legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null } };
+ }
+ function cached() { try { const c = JSON.parse(localStorage.getItem(KEY) || 'null'); return c && c.host === host() && c.data ? c : null; } catch { return null; } }
+ function refresh() {
+ if (inflight || typeof fetch === 'undefined') return inflight || Promise.resolve(null);
+ inflight = fetch('https://openvibe.network/api/chrome?host=' + encodeURIComponent(host()), { credentials: 'omit' })
+ .then(r => (r.ok ? r.json() : null)).then(clean)
+ .then(d => { if (d) { try { localStorage.setItem(KEY, JSON.stringify({ at: Date.now(), host: host(), data: d })); } catch { /* */ } } return d; })
+ .catch(() => null);
+ return inflight;
+ }
+ /** Cached data now (or null); `onFirst` fires once when a first-ever fetch lands. */
+ function get(onFirst) {
+ const c = cached();
+ if (!c || Date.now() - c.at > TTL) { const p = refresh(); if (!c && onFirst) p.then(d => { if (d) onFirst(d); }); }
+ return c ? c.data : null;
+ }
+ return { get, refresh };
+ })());
+
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
@@ -50,9 +85,9 @@
@@ -50,9 +85,9 @@
];
];
const LEGAL = [
const LEGAL = [
- { name: 'Terms of Service', path: '/tos', fallback: 'https://openvibe.live/tos' },
- { name: 'Privacy Policy', path: '/privacy', fallback: 'https://openvibe.live/privacy' },
- { name: 'DMCA', path: '/dmca', fallback: 'https://openvibe.live/dmca' },
+ { key: 'terms', name: 'Terms of Service', path: '/tos', fallback: 'https://openvibe.live/tos' },
+ { key: 'privacy', name: 'Privacy Policy', path: '/privacy', fallback: 'https://openvibe.live/privacy' },
+ { key: 'dmca', name: 'DMCA', path: '/dmca', fallback: 'https://openvibe.live/dmca' },
];
];
const SOCIAL = [
const SOCIAL = [
@@ -106,6 +141,9 @@
@@ -106,6 +141,9 @@
}
}
function legalHref(item, cfg) {
function legalHref(item, cfg) {
+ // Each site answers for itself: the chrome service names this domain's own documents.
+ const chrome = cfg._chrome;
+ if (chrome && chrome.footer.legal && !cfg.legalBase) return chrome.footer.legal[item.key];
// Policies live on whichever site owns them; a tool subdomain links out rather than 404ing.
// Policies live on whichever site owns them; a tool subdomain links out rather than 404ing.
if (cfg.legalBase) return cfg.legalBase.replace(/\/$/, '') + item.path;
if (cfg.legalBase) return cfg.legalBase.replace(/\/$/, '') + item.path;
if (typeof location !== 'undefined' && location.hostname.endsWith('openvibe.live')) return item.path;
if (typeof location !== 'undefined' && location.hostname.endsWith('openvibe.live')) return item.path;
@@ -114,7 +152,9 @@
@@ -114,7 +152,9 @@
// ── Markup ─────────────────────────────────────────────────
// ── Markup ─────────────────────────────────────────────────
function linkTag(item) {
function linkTag(item) {
- const rel = /^https?:\/\//.test(item.url) ? ' target="_blank" rel="noopener"' : '';
+ // Our own sites open in this tab (the sign-in hand-off follows the click); the rest of the web in a new one.
+ const own = /^https:\/\/([a-z0-9-]+\.)*(openvibe\.[a-z]+|openre\.stream)(\/|$)/i.test(item.url);
+ const rel = /^https?:\/\//.test(item.url) && !own ? ' target="_blank" rel="noopener"' : '';
const onclick = item.onclick ? ` onclick="${esc(item.onclick)}"` : '';
const onclick = item.onclick ? ` onclick="${esc(item.onclick)}"` : '';
const title = item.desc ? ` title="${esc(item.desc)}"` : '';
const title = item.desc ? ` title="${esc(item.desc)}"` : '';
return `<a href="${esc(item.url)}"${rel}${onclick}${title}>${esc(item.name)}</a>`;
return `<a href="${esc(item.url)}"${rel}${onclick}${title}>${esc(item.name)}</a>`;
@@ -124,7 +164,14 @@
@@ -124,7 +164,14 @@
const c = { ...DEFAULTS, ...(cfg || {}) };
const c = { ...DEFAULTS, ...(cfg || {}) };
const service = c.service || detectService();
const service = c.service || detectService();
const brand = c.brandName || brandFor(service);
const brand = c.brandName || brandFor(service);
- const network = NETWORK.filter(n => n.id !== service);
+ const chrome = c._chrome = OVChrome.get(() => { try { render(); } catch { /* */ } });
+ // Most used sites first when the chrome service has spoken; the built-in list otherwise.
+ const network = chrome && chrome.nav.length
+ ? chrome.nav.filter(n => n.id !== service && !(typeof location !== 'undefined' && new URL(n.url).hostname === location.hostname)).map(n => ({ name: 'OpenVibe.' + n.name, url: n.url, desc: n.tagline }))
+ : NETWORK.filter(n => n.id !== service);
+ const tagline = (cfg && cfg.tagline) || (chrome && chrome.footer.blurb) || c.tagline;
+ const popular = chrome ? chrome.footer.popular : [];
+ const discover = chrome ? chrome.footer.discover.filter(d => !network.slice(0, 6).some(n => n.url === d.url)) : [];
const year = new Date().getFullYear();
const year = new Date().getFullYear();
if (c.variant === 'compact') {
if (c.variant === 'compact') {
@@ -149,7 +196,7 @@
@@ -149,7 +196,7 @@
<div class="ovf-cols">
<div class="ovf-cols">
<div class="ovf-col ovf-col--brand">
<div class="ovf-col ovf-col--brand">
<a class="ovf-brand" href="/"><span class="ov-mark" data-size="26"></span><span>${esc(brand)}</span></a>
<a class="ovf-brand" href="/"><span class="ov-mark" data-size="26"></span><span>${esc(brand)}</span></a>
- <p class="ovf-tagline">${esc(c.tagline)}</p>
+ <p class="ovf-tagline">${esc(tagline)}</p>
<div class="ovf-social">
<div class="ovf-social">
${SOCIAL.map(s => `<a href="${s.url}" target="_blank" rel="noopener" aria-label="${s.name}" title="${s.name}"><i class="${s.icon}"></i></a>`).join('')}
${SOCIAL.map(s => `<a href="${s.url}" target="_blank" rel="noopener" aria-label="${s.name}" title="${s.name}"><i class="${s.icon}"></i></a>`).join('')}
<a href="${esc(c.sitemap)}" aria-label="Sitemap" title="Sitemap"><i class="fa-solid fa-sitemap"></i></a>
<a href="${esc(c.sitemap)}" aria-label="Sitemap" title="Sitemap"><i class="fa-solid fa-sitemap"></i></a>
@@ -161,6 +208,11 @@
@@ -161,6 +208,11 @@
${network.slice(0, 6).map(linkTag).join('')}
${network.slice(0, 6).map(linkTag).join('')}
<a class="ovf-more" href="${NETWORK_URL}" target="_blank" rel="noopener">Everything else <i class="fa-solid fa-arrow-right"></i></a>
<a class="ovf-more" href="${NETWORK_URL}" target="_blank" rel="noopener">Everything else <i class="fa-solid fa-arrow-right"></i></a>
</nav>` : ''}
</nav>` : ''}
+ ${popular.length ? `<nav class="ovf-col" aria-label="Popular tools">
+ <h3>Popular tools</h3>
+ ${popular.slice(0, 6).concat(discover.slice(0, 2)).map(linkTag).join('')}
+ <a class="ovf-more" href="https://openvibe.tools/all-tools">All tools <i class="fa-solid fa-arrow-right"></i></a>
+ </nav>` : ''}
<nav class="ovf-col" aria-label="Legal">
<nav class="ovf-col" aria-label="Legal">
<h3>Legal</h3>
<h3>Legal</h3>
${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name)}</a>`).join('')}
${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name)}</a>`).join('')}
new file mode 100644
new file mode 100644
@@ -0,0 +1,145 @@
@@ -0,0 +1,145 @@
+'use strict';
+// ═══════════════════════════════════════════════════════════════
+// openvibe-shared/legal — Terms, Privacy and DMCA for every OpenVibe site.
+//
+// Each domain answers for itself: /terms, /privacy and /dmca live on the site's own apex and say
+// what THAT site does. One template per document, with clauses switched by the site's profile:
+// streaming live video, chat, payments tools files processed, then deleted
+// ugc posts, pastes, comments games accounts and game state
+// hosting stored user media account the identity provider
+// info read-only / placeholder site
+//
+// const legal = require('openvibe-shared/legal');
+// app.get(legal.PATHS, legal.handler({ host: 'openvibe.tools', name: 'OpenVibe.Tools', profile: 'tools' }));
+// legal.page('privacy', site) → complete HTML document (static generators use this)
+//
+// Plain language on purpose. These are templates maintained by the project, not legal advice;
+// the owner reviews them before relying on them. Facts stated here (what is stored, for how
+// long) must match the code — change both together.
+// ═══════════════════════════════════════════════════════════════
+const UPDATED = '2026-09-18';
+const UPDATED_TEXT = 'September 18, 2026';
+const CONTACT = '[email protected]';
+const PATHS = ['/terms', '/privacy', '/dmca'];
+const TITLES = { terms: 'Terms of Service', privacy: 'Privacy Policy', dmca: 'DMCA & Copyright' };
+
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+const has = (site, ...profiles) => profiles.includes(site.profile);
+const userContent = (site) => has(site, 'streaming', 'ugc', 'hosting', 'games');
+
+function terms(site) {
+ const n = esc(site.name);
+ const S = [];
+ S.push(['The short version', `<p>${n} is part of OpenVibe, an open source, community-run network. Use it for lawful things, be decent to other people, and do not try to break it. We can remove content or accounts that break these terms. The software is provided as it is, without warranties.</p>`]);
+ S.push(['Who can use it', `<p>You must be at least 13 years old, or the minimum age of digital consent where you live. If you are under 18, a parent or guardian must agree to these terms for you. You may not use ${n} if you were previously banned from the network.</p>`]);
+ if (!has(site, 'info')) S.push(['Your account', `<p>Most of ${n} works without signing in. An OpenVibe account, managed at <a href="https://openvibe.network/">openvibe.network</a>, adds things that need to remember you. You are responsible for what happens under your account; keep your password to yourself and tell us if you think someone else has it.</p>`]);
+ if (has(site, 'tools')) S.push(['Files and links you give the tools', `<p>The tools process what you upload or paste and hand you the result. Uploads and results are temporary: they are deleted automatically, normally within one hour for visitors and within 24 hours for signed-in users. Do not rely on ${n} to store anything.</p><p>Only process material you have the right to use. Downloaders are for your own uploads and for content you are allowed to keep; respecting the terms of the site the content comes from is your responsibility. Lookup tools (DNS, WHOIS, ping, port checks) may only be pointed at systems you are allowed to test.</p>`]);
+ if (userContent(site)) S.push(['What you post', `<p>You keep ownership of what you ${has(site, 'streaming') ? 'stream, upload, clip or write' : 'upload or write'}. You give OpenVibe a worldwide, non-exclusive, royalty-free licence to host, display, transcode and distribute it so the service can work, including on other OpenVibe sites where your content is meant to appear. The licence ends when you delete the content, except for copies other people made while it was public and backups that expire on their own.</p><p>You are responsible for having the rights to everything you post.</p>`]);
+ S.push(['Rules', `<p>OpenVibe leans toward open expression. These limits apply everywhere:</p><ul><li>Nothing illegal where you are or where our servers are (United States).</li><li>No sexual content involving minors, and no sexualisation of minors, ever.</li><li>No credible threats of violence, and no publishing of someone's private information to harm them.</li><li>No content you do not have the rights to.</li><li>No malware, phishing, spam, scraping that ignores robots.txt rate limits, or attempts to disrupt or gain unauthorised access to the service or to other people's systems.</li>${has(site, 'streaming') ? '<li>Streams must be labelled honestly; adult or graphic content belongs behind the mature flag.</li>' : ''}</ul>`]);
+ if (has(site, 'streaming')) S.push(['Money', `<p>Donations, tips and purchases of virtual currency are voluntary and final except where the law requires a refund. Virtual currencies have no cash value outside the payout rules shown on the site. Streamers are responsible for their own taxes.</p>`]);
+ if (has(site, 'games')) S.push(['Game items and progress', `<p>Items, currency and progress in games have no cash value and may be changed, reset or removed as the games evolve. Cheating, automation that gives an unfair advantage and exploiting bugs can lead to a reset or a ban.</p>`]);
+ S.push(['Moderation and ending accounts', `<p>We may remove content, limit features, or suspend accounts that break these terms or put other people or the service at risk. You can stop using ${n} at any time and delete your account from your account settings.</p>`]);
+ S.push(['Open source', `<p>OpenVibe's code is public. The code is licensed under the terms in each repository; those licences, not this page, govern your use of the code. These terms cover your use of the hosted service at ${esc(site.host)}.</p>`]);
+ S.push(['No warranty, limited liability', `<p>The service is provided "as is" and "as available". To the extent the law allows, OpenVibe and the people who run it are not liable for indirect or consequential losses, lost data, or lost profits arising from your use of ${n}. Nothing here limits liability that cannot be limited by law.</p>`]);
+ S.push(['Governing law', `<p>These terms are governed by the laws of the United States and the State of Washington, without regard to conflicts of law. Disputes are handled in the federal or state courts located in Washington State. If part of these terms cannot be enforced, the rest still applies.</p>`]);
+ S.push(['Changes and contact', `<p>We may update these terms. Material changes are announced on the site before they take effect. Questions: <a href="mailto:${CONTACT}">${CONTACT}</a>.</p>`]);
+ return S;
+}
+
+function privacy(site) {
+ const n = esc(site.name);
+ const S = [];
+ S.push(['The short version', `<p>${n} collects what it needs to work and to keep abuse out, and no more. We do not sell personal information and we do not run advertising trackers.</p>`]);
+ const collect = [
+ '<li><b>Request logs.</b> Like every web server, ours record your IP address, browser type, the page requested and the time. We use them for security, rate limiting and aggregate traffic statistics.</li>',
+ '<li><b>Usage statistics.</b> Page views and a random session identifier, so we can see which parts of the network people use. These are first-party statistics kept on our own servers.</li>',
+ ];
+ if (!has(site, 'info')) collect.push(`<li><b>Account information, if you sign in.</b> Your OpenVibe account (username, email address, password hash, profile and preferences) is held by <a href="https://openvibe.network/privacy">openvibe.network</a>. ${n} receives your user id, username, avatar and role so it can show you as signed in.</li>`);
+ if (has(site, 'tools')) collect.push('<li><b>What you give a tool.</b> Files, text, links, domain names and IP addresses you submit are processed to produce your result. Uploaded files and generated results are deleted automatically, normally within one hour for visitors and 24 hours for signed-in users. Many text and developer tools run entirely in your browser and send nothing to us.</li>');
+ if (has(site, 'streaming')) collect.push('<li><b>Streams, clips, VODs and chat.</b> What you broadcast and write is stored so it can be shown, and is public unless you choose otherwise.</li><li><b>Payments.</b> Payment processors handle card details; we receive a record of the transaction, not your card number.</li><li><b>AI features.</b> Public streams and chat may be transcribed and summarised by AI services to create captions, highlights and overviews.</li>');
+ if (has(site, 'ugc')) collect.push('<li><b>What you post.</b> Pastes, posts and comments, with the account that made them. Public items are visible to everyone and may be indexed by search engines.</li>');
+ if (has(site, 'hosting')) collect.push('<li><b>Media you upload or record.</b> Videos, clips, images and files, with their metadata, stored so the sites that use them can show them.</li>');
+ if (has(site, 'games')) collect.push('<li><b>Game state.</b> Your characters, items, progress and in-game chat.</li>');
+ if (has(site, 'account')) collect.push('<li><b>Your account.</b> Username, email address, a salted hash of your password, profile, theme and notification preferences, linked services, sign-in history and, if you keep it switched on, your cross-site history.</li>');
+ S.push(['What we collect', `<ul>${collect.join('')}</ul>`]);
+ S.push(['Cookies and local storage', `<p>We use a small number of first-party cookies: a sign-in token (<code>ov_token</code>), a refresh token, and a hint that remembers whether you were signed in so other OpenVibe sites can sign you in quietly. Your theme, recent pages and similar preferences are kept in your browser's local storage. There are no third-party advertising cookies. Our sites sit behind Cloudflare, which may set its own security cookies.</p>`]);
+ S.push(['Who we share with', `<p>Only the services needed to run the network: our hosting and network providers (OVH, Cloudflare), storage providers for media${has(site, 'streaming') ? ', payment processors, AI providers used for transcription and summaries' : ''}, and email delivery for account messages. We disclose information when the law requires it, and we tell you when we are allowed to.</p>`]);
+ S.push(['Across OpenVibe sites', `<p>OpenVibe sites share one account. When you are signed in, the site you visit learns who you are from openvibe.network; it does not receive your password or email address. You can see and clear your cross-site history, and switch it off, at <a href="https://openvibe.network/history">openvibe.network/history</a>.</p>`]);
+ S.push(['How long we keep things', `<p>Request logs and raw usage events are kept for a limited period and then reduced to totals. ${has(site, 'tools') ? 'Tool uploads and results are deleted within hours. ' : ''}${userContent(site) ? 'Content you post stays until you delete it or your account. ' : ''}Account data stays until you delete your account; backups expire on their own shortly afterwards.</p>`]);
+ S.push(['Your choices and rights', `<p>You can use most of ${n} without an account. With an account you can view, change, export and delete your data from your account settings, or ask us to do it. Depending on where you live (for example the EEA, the UK or California) you may have additional rights to access, correct, delete or restrict the use of your data; write to us and we will honour them.</p>`]);
+ S.push(['Children', `<p>${n} is not for children under 13, and we do not knowingly collect their information. If you believe a child has given us personal information, contact us and we will delete it.</p>`]);
+ S.push(['Changes and contact', `<p>We will post changes here and update the date above. Questions and requests: <a href="mailto:${CONTACT}">${CONTACT}</a>.</p>`]);
+ return S;
+}
+
+function dmca(site) {
+ const n = esc(site.name);
+ const S = [];
+ S.push(['Our position', `<p>${n} respects copyright and responds to clear notices of infringement under the Digital Millennium Copyright Act (17 U.S.C. § 512).${has(site, 'tools') ? ` The tools here process material at a user's request and do not keep a library of it: results are deleted automatically within hours.` : ''}${has(site, 'info') ? ` ${n} publishes its own material and does not host uploads from the public.` : ''}</p>`]);
+ S.push(['Send a takedown notice', `<p>Email <a href="mailto:${CONTACT}">${CONTACT}</a> with all of the following:</p><ol><li>Your name, address, phone number and email address, and whom you represent.</li><li>The copyrighted work you say was infringed.</li><li>The exact URL on ${esc(site.host)} of the material you want removed. General descriptions are not enough to find it.</li><li>A statement that you have a good-faith belief the use is not authorised by the copyright owner, its agent or the law.</li><li>A statement, under penalty of perjury, that the information is accurate and that you are the owner or authorised to act for the owner.</li><li>Your physical or electronic signature.</li></ol>`]);
+ S.push(['What happens next', `<p>We remove or disable access to the material, tell the person who posted it, and give them your notice. We act on complete notices as quickly as we can, normally within a few business days.</p>`]);
+ if (!has(site, 'info')) S.push(['Counter-notice', `<p>If your material was removed by mistake or misidentification, send a counter-notice to the same address with: your contact details, the material and where it appeared, a statement under penalty of perjury that you believe it was removed by mistake, your consent to the jurisdiction of the federal court for your district (or for Washington State if you are outside the United States), and your signature. We forward it to the complainant and may restore the material after 10 to 14 business days unless they tell us they have filed a court action.</p>`]);
+ S.push(['Repeat infringers and false claims', `<p>Accounts that repeatedly infringe are terminated. Knowingly false notices and counter-notices carry legal liability under § 512(f); we also refuse notices that are clearly abusive.</p>`]);
+ return S;
+}
+
+const BUILDERS = { terms, privacy, dmca };
+
+/** { title, description, sections: [[heading, html]], updated } for one document of one site. */
+function build(kind, site) {
+ if (!BUILDERS[kind]) throw new Error('Unknown legal document: ' + kind);
+ const s = Object.assign({ profile: 'info' }, site);
+ return { kind, title: `${TITLES[kind]} — ${s.name}`, heading: TITLES[kind],
+ description: { terms: `The rules for using ${s.name}, in plain language.`, privacy: `What ${s.name} collects, why, and the choices you have.`, dmca: `How to report copyright infringement on ${s.name}, and how to respond to a report.` }[kind],
+ updated: UPDATED, sections: BUILDERS[kind](s) };
+}
+
+/** Article markup only, for sites that wrap it in their own layout. */
+function article(kind, site) {
+ const d = build(kind, site);
+ const others = Object.keys(TITLES).filter(k => k !== kind);
+ return `<article class="ov-legal"><p class="ov-legal-kicker">${esc(site.name)}</p><h1>${esc(d.heading)}</h1><p class="ov-legal-date">Last updated <time datetime="${d.updated}">${UPDATED_TEXT}</time></p>
+${d.sections.map(([h, html], i) => `<section><h2 id="s${i + 1}">${esc(h)}</h2>${html}</section>`).join('\n')}
+<nav class="ov-legal-more" aria-label="Other policies">${others.map(k => `<a href="/${k}">${esc(TITLES[k])}</a>`).join('')}<a href="https://openvibe.network/">About OpenVibe</a></nav></article>`;
+}
+
+const CSS = `*{box-sizing:border-box}body{margin:0;background:var(--bg-primary,#0a0f18);color:var(--text-primary,#e6edf7);font:400 16px/1.65 Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
+.ov-legal{max-width:760px;margin:0 auto;padding:40px 20px 24px}.ov-legal-kicker{margin:0;font-size:13px;font-weight:700;letter-spacing:.6px;text-transform:uppercase;color:var(--accent-light,var(--accent,#60a5fa))}
+.ov-legal h1{font-size:clamp(28px,4.5vw,40px);letter-spacing:-.03em;line-height:1.1;margin:6px 0 4px}.ov-legal-date{color:var(--text-muted,#7d8aa0);font-size:14px;margin:0 0 28px}
+.ov-legal h2{font-size:19px;margin:30px 0 8px;letter-spacing:-.01em}.ov-legal p,.ov-legal li{color:var(--text-secondary,#a8b3c4)}.ov-legal li{margin:6px 0}.ov-legal b{color:var(--text-primary,#e6edf7)}
+.ov-legal a{color:var(--accent-light,var(--accent,#60a5fa))}.ov-legal code{font-size:13.5px;background:var(--bg-secondary,#111826);padding:1px 6px;border-radius:5px}
+.ov-legal-more{display:flex;flex-wrap:wrap;gap:10px;margin-top:40px;padding-top:20px;border-top:1px solid var(--border,rgba(255,255,255,.1))}.ov-legal-more a{padding:8px 14px;border:1px solid var(--border,rgba(255,255,255,.12));border-radius:10px;text-decoration:none;font-weight:600;font-size:14px}`;
+
+/** A complete page: shared theme, navbar and footer, readable without JavaScript. */
+function page(kind, site) {
+ const d = build(kind, site);
+ const url = `https://${site.host}/${kind}`;
+ let head;
+ try { head = require('./seo').headTags({ title: d.title, description: d.description, canonical: url, siteName: site.name, type: 'article' }); }
+ catch { head = `<title>${esc(d.title)}</title><meta name="description" content="${esc(d.description)}"><link rel="canonical" href="${url}">`; }
+ return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+${head}
+<script src="https://openvibe.network/shared/theme-loader.js"></script><style>${CSS}</style></head><body>
+<noscript><p style="padding:12px 20px;margin:0"><a href="/">${esc(site.name)}</a></p></noscript>
+<main>${article(kind, site)}</main><div id="ov-footer"></div>
+<script src="https://openvibe.network/shared/navbar.js" defer></script><script src="https://openvibe.network/shared/footer.js" defer></script>
+<script>addEventListener('DOMContentLoaded',function(){var t=null;try{t=(document.cookie.match(/(?:^|; )ov_token=([^;]*)/)||[])[1]||localStorage.getItem('ov_token')}catch(e){}
+try{OpenVibeNavbar.init({service:${JSON.stringify(site.service || site.id || 'network')},apiBase:'https://openvibe.network',token:t?decodeURIComponent(t):null})}catch(e){}
+try{OpenVibeFooter.init({service:${JSON.stringify(site.service || site.id || 'network')},variant:'compact',mount:'#ov-footer',apiBase:'https://openvibe.network'})}catch(e){}});</script>
+</body></html>`;
+}
+
+/** Express handler for legal.PATHS. */
+function handler(site) {
+ const cache = new Map();
+ return function ovLegal(req, res, next) {
+ const kind = String(req.path || '').replace(/^\/+|\/+$/g, '');
+ if (!BUILDERS[kind]) return next();
+ if (!cache.has(kind)) cache.set(kind, page(kind, site));
+ res.set('Content-Type', 'text/html; charset=utf-8');
+ res.set('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400');
+ res.send(cache.get(kind));
+ };
+}
+
+module.exports = { PATHS, TITLES, UPDATED, CONTACT, build, article, page, handler, CSS };
@@ -61,11 +61,28 @@
@@ -61,11 +61,28 @@
color: var(--text-primary, #e0e0e0);
color: var(--text-primary, #e0e0e0);
}
}
.openvibe-navbar-brand { display: flex; align-items: center; gap: 9px; text-decoration: none; color: inherit; margin-right: 8px; min-width: 0; }
.openvibe-navbar-brand { display: flex; align-items: center; gap: 9px; text-decoration: none; color: inherit; margin-right: 8px; min-width: 0; }
- .openvibe-navbar-brand a { color: inherit; text-decoration: none; }
- .openvibe-navbar-brand a.b-core:hover, .openvibe-navbar-brand a.b-tld:hover, .openvibe-navbar-brand a.b-sub:hover { color: var(--accent-light, var(--accent, #60a5fa)); text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1.5px; }
- .openvibe-navbar-brand a:focus-visible, .ovnav-launch:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; border-radius: 6px; }
- .ovnav-launch { appearance: none; border: 0; background: transparent; color: var(--text-secondary, #a8b3c4); width: 32px; height: 32px; border-radius: 9px; display: inline-grid; place-items: center; cursor: pointer; flex: none; margin-right: 6px; transition: background .15s, color .15s; }
- .ovnav-launch:hover, .ovnav-launch[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.14)); color: var(--accent-light, var(--accent, #60a5fa)); }
+ /* Brand group: mark + linked wordmark + launcher read as one control. Every property a host
+ page might set on bare "nav a" / "a" is reset here, so the brand looks the same everywhere. */
+ .openvibe-navbar .openvibe-navbar-brand a, .openvibe-navbar .openvibe-navbar-brand a:hover { all: unset; cursor: pointer; color: inherit; font: inherit; }
+ .openvibe-navbar .openvibe-navbar-brand { gap: 8px; padding: 3px 4px 3px 3px; border-radius: 12px; margin-right: 2px; }
+ .openvibe-navbar .openvibe-navbar-brand a.flame { display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; transition: background .15s; }
+ .openvibe-navbar .openvibe-navbar-brand a.flame:hover { background: var(--accent-glow, rgba(59,130,246,.14)); }
+ .openvibe-navbar .openvibe-navbar-brand .name { align-items: center; gap: 0; font-size: 15.5px; letter-spacing: -.25px; }
+ .openvibe-navbar .openvibe-navbar-brand .name a { padding: 3px 2px; border-radius: 6px; transition: color .15s, background .15s; }
+ .openvibe-navbar .openvibe-navbar-brand .name a.b-sub { color: var(--text-primary, #e6edf7); font-weight: 750; }
+ .openvibe-navbar .openvibe-navbar-brand .name a.b-core { color: var(--text-primary, #e6edf7); font-weight: 700; }
+ .openvibe-navbar .openvibe-navbar-brand .name a.b-tld { color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 700; }
+ .openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-core, .openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-tld { color: var(--text-secondary, #a8b3c4); font-weight: 600; }
+ .openvibe-navbar .openvibe-navbar-brand .name a:hover { color: var(--accent-light, var(--accent, #60a5fa)); background: var(--accent-glow, rgba(59,130,246,.12)); }
+ .openvibe-navbar .openvibe-navbar-brand .b-dot { margin: 0; padding: 0 .5px; opacity: .55; }
+ .openvibe-navbar .openvibe-navbar-brand a:focus-visible, .ovnav-launch:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
+ .ovnav-launch { all: unset; box-sizing: border-box; cursor: pointer; color: var(--text-secondary, #a8b3c4); width: 32px; height: 32px; border-radius: 10px; display: inline-grid; place-items: center; flex: none; margin-right: 10px; border: 1px solid transparent; transition: background .15s, color .15s, border-color .15s; }
+ .ovnav-launch:hover, .ovnav-launch[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.14)); color: var(--accent-light, var(--accent, #60a5fa)); border-color: color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); }
+ .ovnav-launch svg { display: block; transition: transform .25s cubic-bezier(.2,1.4,.3,1); }
+ .ovnav-launch[aria-expanded="true"] svg { transform: rotate(45deg) scale(.92); }
+ .ovnav-launcher a { all: unset; cursor: pointer; box-sizing: border-box; }
+ .openvibe-navbar .ovnav-sep { width: 1px; height: 18px; background: var(--border, rgba(255,255,255,.12)); margin: 0 6px; flex: none; align-self: center; }
+ @media (max-width: 1180px) { .openvibe-navbar .ovnav-net, .openvibe-navbar .ovnav-sep { display: none; } }
.ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; width: min(440px, calc(100vw - 24px)); max-height: min(560px, calc(100vh - 80px)); overflow: auto; background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 12px; z-index: 1000; opacity: 0; transform: translateY(-6px) scale(.98); transform-origin: top left; pointer-events: none; transition: opacity .16s, transform .2s cubic-bezier(.2,1.2,.3,1); }
.ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; width: min(440px, calc(100vw - 24px)); max-height: min(560px, calc(100vh - 80px)); overflow: auto; background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 12px; z-index: 1000; opacity: 0; transform: translateY(-6px) scale(.98); transform-origin: top left; pointer-events: none; transition: opacity .16s, transform .2s cubic-bezier(.2,1.2,.3,1); }
.ovnav-launcher.open { opacity: 1; transform: none; pointer-events: auto; }
.ovnav-launcher.open { opacity: 1; transform: none; pointer-events: auto; }
.ovnav-launcher .ovl-q { width: 100%; box-sizing: border-box; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-primary, #0a0f18); color: var(--text-primary, #e6edf7); font: 500 14px/1.2 inherit; outline: none; }
.ovnav-launcher .ovl-q { width: 100%; box-sizing: border-box; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-primary, #0a0f18); color: var(--text-primary, #e6edf7); font: 500 14px/1.2 inherit; outline: none; }
@@ -79,6 +96,9 @@
@@ -79,6 +96,9 @@
.ovnav-launcher .ovl-site:hover, .ovnav-launcher .ovl-fam:hover, .ovnav-launcher a:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); outline: none; }
.ovnav-launcher .ovl-site:hover, .ovnav-launcher .ovl-fam:hover, .ovnav-launcher a:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); outline: none; }
.ovnav-launcher b { display: block; font-size: 13px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ovnav-launcher b { display: block; font-size: 13px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ovnav-launcher small { display: block; font-size: 11px; color: var(--text-muted, #7d8aa0); margin-top: 1px; }
.ovnav-launcher small { display: block; font-size: 11px; color: var(--text-muted, #7d8aa0); margin-top: 1px; }
+ .ovnav-launcher .ovl-soon { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 4px 4px; }
+ .ovnav-launcher .ovl-soon a { font-size: 12px; font-weight: 600; padding: 4px 9px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.1)); color: var(--text-secondary, #a8b3c4); }
+ .ovnav-launcher .ovl-soon a:hover { border-color: var(--accent, #3b82f6); color: var(--text-primary, #e6edf7); background: none; }
.ovnav-launcher .ovl-empty { padding: 18px 8px; color: var(--text-secondary, #a8b3c4); font-size: 13px; }
.ovnav-launcher .ovl-empty { padding: 18px 8px; color: var(--text-secondary, #a8b3c4); font-size: 13px; }
@media (max-width: 480px) { .ovnav-launcher { left: 8px; } .ovnav-launcher .ovl-fams { grid-template-columns: 1fr; } }
@media (max-width: 480px) { .ovnav-launcher { left: 8px; } .ovnav-launcher .ovl-fams { grid-template-columns: 1fr; } }
@media (prefers-reduced-motion: reduce) { .ovnav-launcher { transition: none; } }
@media (prefers-reduced-motion: reduce) { .ovnav-launcher { transition: none; } }
@@ -529,6 +549,42 @@
@@ -529,6 +549,42 @@
${_config.launcher === false ? '' : '<button type="button" class="ovnav-launch" id="openvibe-launcher-btn" aria-label="All OpenVibe sites and tools" aria-haspopup="true" aria-expanded="false" title="All OpenVibe sites and tools"><svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><g fill="currentColor"><circle cx="5" cy="5" r="2"/><circle cx="12" cy="5" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="12" cy="19" r="2"/><circle cx="19" cy="19" r="2"/></g></svg></button>'}`;
${_config.launcher === false ? '' : '<button type="button" class="ovnav-launch" id="openvibe-launcher-btn" aria-label="All OpenVibe sites and tools" aria-haspopup="true" aria-expanded="false" title="All OpenVibe sites and tools"><svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><g fill="currentColor"><circle cx="5" cy="5" r="2"/><circle cx="12" cy="5" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="12" cy="19" r="2"/><circle cx="19" cy="19" r="2"/></g></svg></button>'}`;
}
}
+
+ // ── Shared chrome data (https://openvibe.network/api/chrome) ─────────────────────────────
+ // Sites ordered by real use, footer copy and per-site legal links. Cached per host for 30
+ // minutes in localStorage and refreshed in the background, so pages paint from cache and the
+ // order never jumps while someone is looking at it. Defined once, shared by navbar + footer.
+ const OVChrome = root.OpenVibeChrome || (root.OpenVibeChrome = (function () {
+ const KEY = 'ov_chrome_v1', TTL = 30 * 60000;
+ let inflight = null;
+ const host = () => (typeof location !== 'undefined' ? location.hostname : '');
+ const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } };
+ function clean(d) {
+ if (!d || !Array.isArray(d.nav)) return null;
+ const link = (l) => (l && typeof l.name === 'string' && https(l.url) ? { id: String(l.id || ''), name: l.name.slice(0, 60), url: l.url, icon: String(l.icon || ''), tagline: String(l.tagline || '').slice(0, 80) } : null);
+ const f = d.footer || {}, lg = f.legal || {};
+ return { nav: d.nav.map(link).filter(Boolean).slice(0, 12), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24),
+ footer: { blurb: String(f.blurb || '').slice(0, 200), discover: (f.discover || []).map(link).filter(Boolean).slice(0, 6), popular: (f.popular || []).map(link).filter(Boolean).slice(0, 10),
+ legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null } };
+ }
+ function cached() { try { const c = JSON.parse(localStorage.getItem(KEY) || 'null'); return c && c.host === host() && c.data ? c : null; } catch { return null; } }
+ function refresh() {
+ if (inflight || typeof fetch === 'undefined') return inflight || Promise.resolve(null);
+ inflight = fetch('https://openvibe.network/api/chrome?host=' + encodeURIComponent(host()), { credentials: 'omit' })
+ .then(r => (r.ok ? r.json() : null)).then(clean)
+ .then(d => { if (d) { try { localStorage.setItem(KEY, JSON.stringify({ at: Date.now(), host: host(), data: d })); } catch { /* */ } } return d; })
+ .catch(() => null);
+ return inflight;
+ }
+ /** Cached data now (or null); `onFirst` fires once when a first-ever fetch lands. */
+ function get(onFirst) {
+ const c = cached();
+ if (!c || Date.now() - c.at > TTL) { const p = refresh(); if (!c && onFirst) p.then(d => { if (d) onFirst(d); }); }
+ return c ? c.data : null;
+ }
+ return { get, refresh };
+ })());
+
// ── Network launcher ─────────────────────────────────────
// ── Network launcher ─────────────────────────────────────
const LAUNCHER_SITES = [
const LAUNCHER_SITES = [
{ name: 'Live', desc: 'Streams, clips and chat', icon: 'live', url: 'https://openvibe.live/' },
{ name: 'Live', desc: 'Streams, clips and chat', icon: 'live', url: 'https://openvibe.live/' },
@@ -575,8 +631,12 @@
@@ -575,8 +631,12 @@
body.innerHTML = hits.length ? `<div class="ovl-h">Tools</div><div class="ovl-fams">${hits.map(t => tile(t, 'ovl-fam')).join('')}</div>` : '<div class="ovl-empty">No tool matches. <a href="https://openvibe.tools/">Browse them all</a></div>';
body.innerHTML = hits.length ? `<div class="ovl-h">Tools</div><div class="ovl-fams">${hits.map(t => tile(t, 'ovl-fam')).join('')}</div>` : '<div class="ovl-empty">No tool matches. <a href="https://openvibe.tools/">Browse them all</a></div>';
return;
return;
}
}
- body.innerHTML = `<div class="ovl-h">Sites</div><div class="ovl-sites">${LAUNCHER_SITES.map(x => tile(x, 'ovl-site')).join('')}</div>
- <div class="ovl-h">Tools <a href="https://openvibe.tools/">See all</a></div><div class="ovl-fams">${fams.map(x => tile(x, 'ovl-fam')).join('')}</div>`;
+ const chrome = OVChrome.get();
+ const sites = chrome && chrome.nav.length ? chrome.nav.map(n => ({ name: n.name, desc: n.tagline, icon: n.icon || n.id, url: n.url })) : LAUNCHER_SITES;
+ const soon = chrome ? chrome.soon : [];
+ body.innerHTML = `<div class="ovl-h">Sites</div><div class="ovl-sites">${sites.map(x => tile(x, 'ovl-site')).join('')}</div>
+ <div class="ovl-h">Tools <a href="https://openvibe.tools/">See all</a></div><div class="ovl-fams">${fams.map(x => tile(x, 'ovl-fam')).join('')}</div>
+ ${soon.length ? `<div class="ovl-h">Opening soon</div><div class="ovl-soon">${soon.map(x => `<a href="${escapeAttr(x.url)}">${escapeAttr(x.name)}</a>`).join('')}</div>` : ''}`;
}
}
btn.addEventListener('click', async (e) => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
e.stopPropagation();
@@ -605,6 +665,19 @@
@@ -605,6 +665,19 @@
});
});
}
}
+ /** The network's most used sites, after the page's own links (networkLinks: false turns it off). */
+ function networkLinksHTML(pageLinks) {
+ if (_config.networkLinks === false) return '';
+ const chrome = OVChrome.get((d) => { if (d && _navEl && !_navEl.querySelector('.ovnav-net')) { try { render(); } catch { /* */ } } });
+ if (!chrome) return '';
+ const here = currentHost().toLowerCase();
+ const taken = new Set((pageLinks || []).map(l => { try { return new URL(l.href, location.href).hostname; } catch { return ''; } }));
+ const max = typeof _config.networkLinks === 'number' ? _config.networkLinks : 4;
+ const pick = chrome.nav.filter(n => { try { const h = new URL(n.url).hostname; return h !== here && !here.endsWith('.' + h) && !taken.has(h); } catch { return false; } }).slice(0, max);
+ if (!pick.length) return '';
+ return `<span class="ovnav-sep" aria-hidden="true"></span>` + pick.map(n => `<a class="ovnav-net" href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}">${escapeAttr(n.name)}</a>`).join('');
+ }
+
// ── Notification bell (mounted by the navbar itself) ─────
// ── Notification bell (mounted by the navbar itself) ─────
function mountBell(nav, u) {
function mountBell(nav, u) {
if (_config.notifications === false || !u || u.is_anon || !_config.token) return;
if (_config.notifications === false || !u || u.is_anon || !_config.token) return;
@@ -775,7 +848,7 @@
@@ -775,7 +848,7 @@
/** Where "Sign In" goes on this host. */
/** Where "Sign In" goes on this host. */
function resolveLoginHref(host, returnUrl) {
function resolveLoginHref(host, returnUrl) {
- if (_config.loginUrl) return _config.loginUrl;
+ if (_config.loginUrl) return String(_config.loginUrl).replace('{url}', encodeURIComponent(returnUrl || '/'));
if (onToolsDomain(host)) {
if (onToolsDomain(host)) {
// Every *.openvibe.tools host signs in through the gateway apex: it
// Every *.openvibe.tools host signs in through the gateway apex: it
// holds the OAuth state cookie + redirect_uri (both apex-host-only)
// holds the OAuth state cookie + redirect_uri (both apex-host-only)
@@ -1122,6 +1195,7 @@
@@ -1122,6 +1195,7 @@
${brandHTML(brand)}
${brandHTML(brand)}
<div class="openvibe-navbar-links">
<div class="openvibe-navbar-links">
${links.map(l => `<a href="${escapeAttr(l.href)}"${l.active ? ' class="active"' : ''}${l.external ? ' target="_blank" rel="noopener"' : ''}>${l.icon ? `<i class="fa-solid ${escapeAttr(l.icon)} icon"></i>` : ''}${escapeAttr(l.label)}</a>`).join('')}
${links.map(l => `<a href="${escapeAttr(l.href)}"${l.active ? ' class="active"' : ''}${l.external ? ' target="_blank" rel="noopener"' : ''}>${l.icon ? `<i class="fa-solid ${escapeAttr(l.icon)} icon"></i>` : ''}${escapeAttr(l.label)}</a>`).join('')}
+ ${networkLinksHTML(links)}
${u && u.role === 'admin' ? `<a href="https://openvibe.network/admin"><i class="fa-solid fa-shield-halved"></i> Admin</a>` : ''}
${u && u.role === 'admin' ? `<a href="https://openvibe.network/admin"><i class="fa-solid fa-shield-halved"></i> Admin</a>` : ''}
</div>
</div>
<div class="openvibe-navbar-spacer"></div>
<div class="openvibe-navbar-spacer"></div>
@@ -13,7 +13,8 @@
@@ -13,7 +13,8 @@
"./notifications": "./notifications.js",
"./notifications": "./notifications.js",
"./url-resolver": "./url-resolver.js",
"./url-resolver": "./url-resolver.js",
"./seo": "./seo.js",
"./seo": "./seo.js",
- "./icons": "./ov-icons.js"
+ "./icons": "./ov-icons.js",
+ "./legal": "./legal.js"
},
},
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
@@ -152,8 +152,8 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
@@ -152,8 +152,8 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
<!-- Hero -->
<!-- Hero -->
<section class="hero">
<section class="hero">
<div class="hero-badge"><span class="ov-mark" data-size="20" data-variant="network"></span> The OpenVibe Network — open source, community run</div>
<div class="hero-badge"><span class="ov-mark" data-size="20" data-variant="network"></span> The OpenVibe Network — open source, community run</div>
- <h1>One Account.<br><span class="grad">All of OpenVibe.</span></h1>
- <p class="sub">Live streaming, <!--OV:COUNT--> online tools, games, media, pastes and community. One account works across the whole network: sign in once and every OpenVibe site knows you.</p>
+ <h1>The open network<br><span class="grad">for what you do online.</span></h1>
+ <p class="sub">Live streaming, <!--OV:COUNT--> online tools, games, community pastes and a shared media library. Open source, run by the people who use it, and open to everyone without an account. Sign in once and every site already knows you.</p>
<div class="hero-ctas" id="hero-guest-ctas">
<div class="hero-ctas" id="hero-guest-ctas">
<a class="btn btn-primary" href="/login"><i class="fa-solid fa-right-to-bracket"></i> Sign In</a>
<a class="btn btn-primary" href="/login"><i class="fa-solid fa-right-to-bracket"></i> Sign In</a>
<a class="btn btn-ghost" href="/login?tab=register"><i class="fa-solid fa-user-plus"></i> Create Account</a>
<a class="btn btn-ghost" href="/login?tab=register"><i class="fa-solid fa-user-plus"></i> Create Account</a>
@@ -193,7 +193,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
@@ -193,7 +193,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
function initNavbar(user) {
function initNavbar(user) {
if (typeof OpenVibeNavbar !== 'undefined') {
if (typeof OpenVibeNavbar !== 'undefined') {
- OpenVibeNavbar.init({ service: 'network', token: token || null, user: user || null, links: [{ label: 'Home', href: '/' }, { label: 'My Account', href: '/my' }, { label: 'Themes', href: '/themes' }, { label: 'The network', href: '/#network' }] });
+ OpenVibeNavbar.init({ service: 'network', token: token || null, user: user || null, links: [{ label: 'My Account', href: '/my' }, { label: 'Themes', href: '/themes' }], networkLinks: 5 });
}
}
if (user) {
if (user) {
document.getElementById('hero-guest-ctas').style.display = 'none';
document.getElementById('hero-guest-ctas').style.display = 'none';
new file mode 100644
new file mode 100644
@@ -0,0 +1,147 @@
@@ -0,0 +1,147 @@
+'use strict';
+// ═══════════════════════════════════════════════════════════════
+// Shared chrome data: what the navbar and footer of every site show.
+//
+// GET /api/chrome?host=<hostname> public, CORS *, max-age=600, ETag
+// → { updated, nav: [{ id, name, url, icon, tagline }], open sites, most used first
+// soon: [{ id, name, url, icon }],
+// footer: { blurb, discover: [{ name, url }], popular: [{ name, url }], legal: { terms, privacy, dmca } } }
+//
+// Ranking: page views and visitors from each service's own analytics (last 7 days) plus signed-in
+// cross-site history, refreshed every 30 minutes and kept in the database so a restart or an
+// unreachable service never reorders the network at random. Only totals cross the wire.
+// Copy: once a day the configured AI (through OpenVibe.Live's internal API) may write a blurb per
+// site and pick "discover" links BY ID from a list we supply; ids resolve to our own URLs here, text
+// is stripped, length-capped and screened. No AI, or a bad answer → the hand-written copy stays.
+// ═══════════════════════════════════════════════════════════════
+const express = require('express');
+const crypto = require('crypto');
+const { SITES, siteForHost } = require('./sites');
+const toolsCatalog = require('../domains/catalog');
+
+const RANK_MS = 30 * 60_000, COPY_MS = 24 * 60 * 60_000, INTERNAL_SECRET = 'openvibe-internal-2026';
+const BANNED = /\b(free|\$0|no ads|ad[- ]free|no cost|gratis)\b|https?:|www\.|[<>{}]/i;
+const BASE_WEIGHT = { live: 6, tools: 5, community: 4, games: 3, media: 2, network: 1 }; // cold-start order only
+
+function createChromeService(db, config, analytics) {
+ db.exec('CREATE TABLE IF NOT EXISTS chrome_cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP)');
+ const load = (k) => { try { const r = db.prepare('SELECT value, updated_at FROM chrome_cache WHERE key = ?').get(k); return r ? { value: JSON.parse(r.value), at: Date.parse(r.updated_at + 'Z') || 0 } : null; } catch { return null; } };
+ const save = (k, v) => db.prepare('INSERT INTO chrome_cache (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP').run(k, JSON.stringify(v));
+
+ let rank = (load('rank') || {}).value || { scores: {}, tools: [] };
+ let copy = (load('copy') || {}).value || { sites: {} };
+ let version = Date.now();
+
+ const svcUrl = (name, fallback) => (config.services && config.services[name] && config.services[name].internalUrl) || fallback;
+ async function getJson(url, headers) {
+ try { const r = await fetch(url, { headers, signal: AbortSignal.timeout(6000) }); return r.ok ? await r.json() : null; } catch { return null; }
+ }
+
+ async function refreshRank() {
+ const sources = await Promise.all([
+ getJson(`${svcUrl('live', 'http://127.0.0.1:3000')}/internal/analytics-summary?days=7`, { 'X-Internal-Key': config.internalKey }).then(j => ['live', j && j.summary]),
+ ...[['tools', 'http://127.0.0.1:4001'], ['games', 'http://127.0.0.1:8000'], ['media', 'http://127.0.0.1:4100']].map(([n, u]) =>
+ getJson(`${svcUrl(n, u)}/api/internal/analytics?days=7`, { 'X-Internal-Secret': INTERNAL_SECRET }).then(j => [n, j && j.analytics && j.analytics.summary])),
+ ]);
+ const scores = Object.assign({}, rank.scores);
+ try { const own = analytics && analytics.getStats({ days: 7 }); if (own && own.summary) sources.push(['network', own.summary]); } catch { /* */ }
+ for (const [name, sum] of sources) {
+ if (!sum) continue; // unreachable: keep the last known score
+ scores[name] = Math.round((Number(sum.total_pageviews) || 0) + 4 * (Number(sum.unique_visitors) || Number(sum.total_unique_visitors) || 0));
+ }
+ // Signed-in history covers every site (community has no analytics endpoint) and names the tools people use.
+ let tools = rank.tools || [];
+ try {
+ const bySvc = db.prepare("SELECT service, COUNT(*) AS n, COUNT(DISTINCT user_id) AS u FROM user_history WHERE created_at > datetime('now', '-14 days') GROUP BY service").all();
+ for (const r of bySvc) { const id = r.service === 'pastes' ? 'community' : r.service; if (!id) continue; scores[id + ':history'] = r.n + 5 * r.u; }
+ tools = db.prepare("SELECT sub, COUNT(*) AS n FROM user_history WHERE service = 'tools' AND sub IS NOT NULL AND sub != '' AND created_at > datetime('now', '-30 days') GROUP BY sub ORDER BY n DESC LIMIT 12").all().map(r => r.sub);
+ } catch { /* table appears on first history write */ }
+ rank = { scores, tools };
+ save('rank', rank); version = Date.now();
+ }
+
+ const scoreOf = (id) => (rank.scores[id] || 0) + 3 * (rank.scores[id + ':history'] || 0);
+ function orderedOpen() {
+ const open = SITES.filter(s => s.status === 'open');
+ const max = Math.max(1, ...open.map(s => scoreOf(s.id)));
+ // Real use decides; the base weight only breaks ties and orders a cold start.
+ return open.map(s => ({ s, v: scoreOf(s.id) / max + (BASE_WEIGHT[s.id] || 0) / 100 })).sort((a, b) => b.v - a.v).map(x => x.s);
+ }
+
+ function linkPool() {
+ const { catalog } = toolsCatalog.peek();
+ const pool = new Map();
+ for (const s of SITES.filter(x => x.status === 'open')) pool.set('site:' + s.id, { name: 'OpenVibe.' + s.name, url: `https://${s.host}/`, about: s.tagline });
+ for (const f of catalog.families) if (f.path) pool.set('family:' + f.id, { name: f.name, url: 'https://openvibe.tools' + f.path, about: f.tagline });
+ for (const t of catalog.tools) pool.set('tool:' + t.id, { name: t.name, url: t.url, about: t.tagline });
+ return pool;
+ }
+ function popularTools(limit) {
+ const { catalog } = toolsCatalog.peek();
+ const byId = new Map(catalog.tools.map(t => [t.id, t]));
+ const seed = ['yt', 'convert', 'mergepdf', 'jsonfmt', 'mp3', 'dns', 'whois', 'compress'];
+ return [...new Set([...(rank.tools || []), ...seed])].map(id => byId.get(id)).filter(Boolean).slice(0, limit).map(t => ({ name: t.name, url: t.url }));
+ }
+
+ async function refreshCopy() {
+ if (!config.internalKey || config.internalKey === 'change-me-in-production') return;
+ const pool = linkPool();
+ const popular = new Set(popularTools(12).map(t => t.url));
+ const links = [...pool.entries()].filter(([id, l]) => !id.startsWith('tool:') || popular.has(l.url)).map(([id, l]) => ({ id, name: l.name, about: l.about }));
+ const body = { sites: SITES.filter(s => s.status === 'open').map(s => ({ id: s.id, name: 'OpenVibe.' + s.name, what: s.what, popular: s.id === 'tools' ? popularTools(6).map(t => t.name) : [] })), links };
+ let j = null;
+ try {
+ const r = await fetch(`${svcUrl('live', 'http://127.0.0.1:3000')}/internal/ai/site-copy`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Internal-Key': config.internalKey }, body: JSON.stringify(body), signal: AbortSignal.timeout(90_000) });
+ j = r.ok ? await r.json() : null;
+ } catch { j = null; }
+ if (!j || !Array.isArray(j.sites)) return;
+ const next = {};
+ for (const row of j.sites) {
+ const site = SITES.find(s => s.id === row.id); if (!site) continue;
+ const blurb = String(row.blurb || '').replace(/\s+/g, ' ').trim();
+ const picks = (Array.isArray(row.picks) ? row.picks : []).filter(id => pool.has(id) && id !== 'site:' + site.id).slice(0, 4);
+ if (blurb.length < 20 || blurb.length > 170 || BANNED.test(blurb)) continue; // screened out: the hand-written copy stays
+ next[site.id] = { blurb, picks };
+ }
+ if (Object.keys(next).length) { copy = { sites: next, model: String(j.model || '').slice(0, 60) }; save('copy', copy); version = Date.now(); }
+ }
+
+ function payloadFor(hostname) {
+ const site = siteForHost(hostname) || SITES.find(s => s.id === 'network');
+ const open = orderedOpen(); const pool = linkPool();
+ const ai = copy.sites[site.id];
+ const discover = (ai ? ai.picks.map(id => pool.get(id)) : open.filter(s => s.id !== site.id).slice(0, 4).map(s => pool.get('site:' + s.id))).filter(Boolean).map(l => ({ name: l.name, url: l.url }));
+ // Legal documents live on the site's own apex, so each domain answers for itself.
+ const legalBase = `https://${site.host}`;
+ return {
+ updated: new Date(version).toISOString(), site: { id: site.id, name: site.name, host: site.host, status: site.status },
+ nav: open.map(s => ({ id: s.id, name: s.name, url: `https://${s.host}/`, icon: s.icon, tagline: s.tagline })),
+ soon: SITES.filter(s => s.status === 'soon').map(s => ({ id: s.id, name: s.name, url: `https://${s.host}/`, icon: s.icon })),
+ footer: { blurb: (ai && ai.blurb) || site.what, ai: !!ai, discover, popular: popularTools(8), legal: { terms: legalBase + '/terms', privacy: legalBase + '/privacy', dmca: legalBase + '/dmca' } },
+ };
+ }
+
+ const router = express.Router();
+ const cache = new Map();
+ router.get('/', (req, res) => {
+ const host = String(req.query.host || '').toLowerCase().slice(0, 255);
+ const site = siteForHost(host) || { id: 'network' };
+ let e = cache.get(site.id);
+ if (!e || e.version !== version) { const body = JSON.stringify(payloadFor(host)); e = { version, body, etag: '"' + crypto.createHash('sha1').update(body).digest('base64url').slice(0, 18) + '"' }; cache.set(site.id, e); }
+ res.set({ 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'public, max-age=600, stale-while-revalidate=86400', ETag: e.etag, 'Content-Type': 'application/json; charset=utf-8' });
+ if (req.headers['if-none-match'] === e.etag) return res.status(304).end();
+ res.send(e.body);
+ });
+
+ function start() {
+ const rankAge = Date.now() - ((load('rank') || {}).at || 0), copyAge = Date.now() - ((load('copy') || {}).at || 0);
+ const t1 = setTimeout(() => refreshRank().catch(() => {}), rankAge > RANK_MS ? 8000 : RANK_MS - rankAge); t1.unref();
+ const i1 = setInterval(() => refreshRank().catch(() => {}), RANK_MS); i1.unref();
+ const t2 = setTimeout(() => refreshCopy().catch(() => {}), copyAge > COPY_MS ? 60_000 : COPY_MS - copyAge); t2.unref();
+ const i2 = setInterval(() => refreshCopy().catch(() => {}), COPY_MS); i2.unref();
+ }
+
+ return { router, start, refreshRank, refreshCopy, payloadFor, _state: () => ({ rank, copy }) };
+}
+
+module.exports = { createChromeService, BANNED };
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
+'use strict';
+// Every OpenVibe property: what it is, whether it is open, and which legal documents apply.
+// legal.profile decides the wording of /terms, /privacy and /dmca on that site (openvibe-shared/legal):
+// streaming user video + chat + payments tools files processed and discarded
+// ugc user posts/pastes games accounts + game state
+// hosting stored user media account identity provider
+// info read-only placeholder/editorial site
+const SITES = [
+ { id: 'live', name: 'Live', host: 'openvibe.live', icon: 'live', status: 'open', service: 'live', profile: 'streaming', tagline: 'Streams, clips and chat', what: 'Live streaming from a browser, OBS or a phone, with restreaming, clips, VODs, chat games and channel points.' },
+ { id: 'tools', name: 'Tools', host: 'openvibe.tools', icon: 'tools', status: 'open', service: 'tools', profile: 'tools', tagline: 'Every online tool', what: 'More than 160 online tools: converters, downloaders, PDF and image tools, developer utilities and network diagnostics, each on its own address.' },
+ { id: 'community', name: 'Community', host: 'openvibe.community', icon: 'community', status: 'open', service: 'community', profile: 'ugc', tagline: 'Pastes and posts', what: 'Share text and code with a link, comment, and follow what people on OpenVibe are making.' },
+ { id: 'games', name: 'Games', host: 'openvibe.games', icon: 'games', status: 'open', service: 'games', profile: 'games', tagline: 'Browser games', what: 'Scraplandia, a shared pixel canvas and other games that run in a tab and know your OpenVibe account.' },
+ { id: 'media', name: 'Media', host: 'openvibe.media', icon: 'media', status: 'open', service: 'media', profile: 'hosting', tagline: 'VODs, clips, files', what: 'The public media library behind every site: recorded streams, clips, images and uploads.' },
+ { id: 'network', name: 'Network', host: 'openvibe.network', icon: 'network', status: 'open', service: 'network', profile: 'account', tagline: 'Account and themes', what: 'The account behind every site: one sign-in, themes, notifications, history and linked services.' },
+ { id: 'chat', name: 'Chat', host: 'openvibe.chat', icon: 'chat', status: 'soon', profile: 'ugc', tagline: 'Rooms and direct messages', what: 'Community chat rooms and direct messages across the network.' },
+ { id: 'codes', name: 'Codes', host: 'openvibe.codes', icon: 'codes', status: 'soon', profile: 'ugc', tagline: 'Snippets and open source', what: 'Code snippets, gists and the source of OpenVibe itself.' },
+ { id: 'blog', name: 'Blog', host: 'openvibe.blog', icon: 'blog', status: 'soon', profile: 'info', tagline: 'Updates from the builders', what: 'Release notes and stories from the people building OpenVibe.' },
+ { id: 'wiki', name: 'Wiki', host: 'openvibe.wiki', icon: 'wiki', status: 'soon', profile: 'ugc', tagline: 'Guides and how-tos', what: 'Guides for streaming, tools and everything else on the network.' },
+ { id: 'news', name: 'News', host: 'openvibe.news', icon: 'news', status: 'soon', profile: 'info', tagline: 'What is happening', what: 'News from the network and the communities on it.' },
+ { id: 'reviews', name: 'Reviews', host: 'openvibe.reviews', icon: 'reviews', status: 'soon', profile: 'ugc', tagline: 'Honest reviews', what: 'Community reviews of gear, software and services.' },
+ { id: 'tips', name: 'Tips', host: 'openvibe.tips', icon: 'tips', status: 'soon', profile: 'streaming', tagline: 'Support creators', what: 'Send support to the streamers and makers you like.' },
+ { id: 'vip', name: 'VIP', host: 'openvibe.vip', icon: 'vip', status: 'soon', profile: 'account', tagline: 'Supporter perks', what: 'Perks for people who support the network.' },
+ { id: 'trade', name: 'Trade', host: 'openvibe.trade', icon: 'trade', status: 'soon', profile: 'ugc', tagline: 'Swap and sell', what: 'A community marketplace for trades and sales.' },
+ { id: 'host', name: 'Host', host: 'openvibe.host', icon: 'host', status: 'soon', profile: 'hosting', tagline: 'Hosting for projects', what: 'Simple hosting for community projects.' },
+ { id: 'deals', name: 'Deals', host: 'openvibe.deals', icon: 'deals', status: 'soon', profile: 'info', tagline: 'Deals worth sharing', what: 'Deals found and vetted by the community.' },
+ { id: 'coupons', name: 'Coupons', host: 'openvibe.coupons', icon: 'coupons', status: 'soon', profile: 'info', tagline: 'Codes that work', what: 'Coupon codes checked by real people.' },
+ { id: 'stream', name: 'OpenRe.Stream', host: 'openre.stream', icon: 'stream', status: 'soon', profile: 'streaming', tagline: 'Restream anywhere', what: 'Send one stream to every platform at once.' },
+];
+const byHost = new Map(SITES.map(s => [s.host, s]));
+
+/** The site a hostname belongs to (subdomains included), or null. */
+function siteForHost(hostname) {
+ const h = String(hostname || '').toLowerCase().replace(/:\d+$/, '').replace(/^www\./, '');
+ if (byHost.has(h)) return byHost.get(h);
+ for (const s of SITES) if (h.endsWith('.' + s.host)) return s;
+ return null;
+}
+
+module.exports = { SITES, siteForHost };
@@ -11,7 +11,7 @@ const toolsCatalog = require('../domains/catalog');
@@ -11,7 +11,7 @@ const toolsCatalog = require('../domains/catalog');
const SHELL = path.join(__dirname, '..', '..', 'public', 'index.html');
const SHELL = path.join(__dirname, '..', '..', 'public', 'index.html');
const esc = seo.esc;
const esc = seo.esc;
-const icon = (name, size) => `<span class="ov-icon" data-icon="${esc(name)}" data-fx="none" style="--ovi-size:${size}px" aria-hidden="true">${icons.svg(name)}</span>`;
+const icon = (name, size) => `<span class="ov-icon" data-icon="${esc(name)}" data-ovi="${esc(icons.resolve(name))}" data-fx="none" style="--ovi-size:${size}px" aria-hidden="true">${icons.svg(name)}</span>`;
const SITES = [
const SITES = [
['live', 'OpenVibe.Live', 'https://openvibe.live/', 'Live streaming', 'Go live from a browser, OBS or a phone. Restream to other platforms, clip moments, run chat games and keep your VODs.'],
['live', 'OpenVibe.Live', 'https://openvibe.live/', 'Live streaming', 'Go live from a browser, OBS or a phone. Restream to other platforms, clip moments, run chat games and keep your VODs.'],
@@ -22,6 +22,8 @@ const SITES = [
@@ -22,6 +22,8 @@ const SITES = [
];
];
const SOON = [['chat', 'Chat'], ['codes', 'Codes'], ['blog', 'Blog'], ['wiki', 'Wiki'], ['news', 'News'], ['reviews', 'Reviews'], ['tips', 'Tips'], ['vip', 'VIP'], ['trade', 'Trade'], ['host', 'Host'], ['deals', 'Deals'], ['coupons', 'Coupons']];
const SOON = [['chat', 'Chat'], ['codes', 'Codes'], ['blog', 'Blog'], ['wiki', 'Wiki'], ['news', 'News'], ['reviews', 'Reviews'], ['tips', 'Tips'], ['vip', 'VIP'], ['trade', 'Trade'], ['host', 'Host'], ['deals', 'Deals'], ['coupons', 'Coupons']];
const ACCOUNT = [
const ACCOUNT = [
+ ['live', 'Go live and keep your VODs', 'Stream from a browser tab, clip the good parts, earn channel points and restream to other platforms.', 'https://openvibe.live/'],
+ ['tools', 'Tool results that stick around', 'Guests keep results for an hour. Signed in, they stay for a day and your recent tools follow you.', 'https://openvibe.tools/'],
['account', 'One sign-in', 'Sign in once. Every OpenVibe site recognises you, including the ones you have not visited yet.', '/login'],
['account', 'One sign-in', 'Sign in once. Every OpenVibe site recognises you, including the ones you have not visited yet.', '/login'],
['theme', 'Themes that follow you', 'Pick or build a theme and every site wears it.', '/themes'],
['theme', 'Themes that follow you', 'Pick or build a theme and every site wears it.', '/themes'],
['history', 'History across sites', 'Streams you watched, tools you used and pastes you opened, in one list you control.', '/history'],
['history', 'History across sites', 'Streams you watched, tools you used and pastes you opened, in one list you control.', '/history'],
@@ -30,7 +32,7 @@ const ACCOUNT = [
@@ -30,7 +32,7 @@ const ACCOUNT = [
const POPULAR = ['yt', 'convert', 'mergepdf', 'jsonfmt', 'mp3', 'dns', 'whois', 'compress', 'fancy', 'ssl', 'logo', 'regex'];
const POPULAR = ['yt', 'convert', 'mergepdf', 'jsonfmt', 'mp3', 'dns', 'whois', 'compress', 'fancy', 'ssl', 'logo', 'regex'];
const CSS = `
const CSS = `
-.home-sec{margin:56px 0 0}.home-sec>h2{font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em;margin:0 0 6px}.home-sec>p.lede{color:var(--text-secondary,#a8b3c4);margin:0 0 18px;max-width:760px}
+.home-sec{max-width:1080px;margin:56px auto 0;padding:0 24px}.home-sec>h2{font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em;margin:0 0 6px}.home-sec>p.lede{color:var(--text-secondary,#a8b3c4);margin:0 0 18px;max-width:760px}
.home-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(260px,1fr))}
.home-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(260px,1fr))}
.home-card{display:flex;gap:14px;align-items:flex-start;padding:16px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#111826);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}
.home-card{display:flex;gap:14px;align-items:flex-start;padding:16px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#111826);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}
.home-card:hover,.home-card:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}
.home-card:hover,.home-card:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}
@@ -40,8 +42,8 @@ const CSS = `
@@ -40,8 +42,8 @@ const CSS = `
.home-fam{display:flex;gap:12px;align-items:center;padding:12px 14px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));color:inherit;text-decoration:none}
.home-fam{display:flex;gap:12px;align-items:center;padding:12px 14px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));color:inherit;text-decoration:none}
.home-fam:hover{border-color:var(--accent,#3b82f6)}.home-fam b{display:block;font-size:14.5px}.home-fam small{color:var(--text-muted,#7d8aa0);font-size:12.5px}
.home-fam:hover{border-color:var(--accent,#3b82f6)}.home-fam b{display:block;font-size:14.5px}.home-fam small{color:var(--text-muted,#7d8aa0);font-size:12.5px}
.home-chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px;padding:0;list-style:none}
.home-chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px;padding:0;list-style:none}
-.home-chips a,.home-chips span{display:inline-flex;align-items:center;gap:7px;padding:6px 12px 6px 7px;border-radius:999px;border:1px solid var(--border,rgba(255,255,255,.1));font-size:13.5px;font-weight:600;color:inherit;text-decoration:none}
-.home-chips a:hover{border-color:var(--accent,#3b82f6)}.home-chips span{color:var(--text-secondary,#a8b3c4)}
+.home-chips>li>a{display:inline-flex;align-items:center;gap:7px;padding:6px 12px 6px 7px;border-radius:999px;border:1px solid var(--border,rgba(255,255,255,.1));font-size:13.5px;font-weight:600;color:inherit;text-decoration:none}
+.home-chips>li>a:hover{border-color:var(--accent,#3b82f6)}
.home-dev{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(240px,1fr))}
.home-dev{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(240px,1fr))}
.home-dev div{padding:16px;border-radius:16px;border:1px dashed var(--border,rgba(255,255,255,.14))}.home-dev b{display:block;margin-bottom:4px}.home-dev p{margin:0;color:var(--text-secondary,#a8b3c4);font-size:13.5px}.home-dev code{font-size:12.5px}
.home-dev div{padding:16px;border-radius:16px;border:1px dashed var(--border,rgba(255,255,255,.14))}.home-dev b{display:block;margin-bottom:4px}.home-dev p{margin:0;color:var(--text-secondary,#a8b3c4);font-size:13.5px}.home-dev code{font-size:12.5px}
@media (prefers-reduced-motion:reduce){.home-card{transition:none}}
@media (prefers-reduced-motion:reduce){.home-card{transition:none}}
@@ -53,14 +55,14 @@ function body(catalog) {
@@ -53,14 +55,14 @@ function body(catalog) {
const fams = catalog.families.filter(f => f.url);
const fams = catalog.families.filter(f => f.url);
const count = (f) => f.count || catalog.tools.filter(t => t.family === f.id).length;
const count = (f) => f.count || catalog.tools.filter(t => t.family === f.id).length;
return `<style>${CSS}</style>
return `<style>${CSS}</style>
-<section class="home-sec" id="network" aria-labelledby="h-sites"><h2 id="h-sites">The network</h2><p class="lede">Five sites are open today. Your account, theme and notifications work on all of them.</p>
+<section class="home-sec" id="network" aria-labelledby="h-sites"><h2 id="h-sites">Five sites, one front door</h2><p class="lede">Stream, build, share, play and store. Everything is open to visitors; signing in once carries your name, theme and notifications to all of it.</p>
<div class="home-grid">${SITES.map(([ic, n, u, tag, d]) => `<a class="home-card" href="${u}">${icon(ic, 48)}<span><b>${n}</b><em>${tag}</em><small>${d}</small></span></a>`).join('')}</div></section>
<div class="home-grid">${SITES.map(([ic, n, u, tag, d]) => `<a class="home-card" href="${u}">${icon(ic, 48)}<span><b>${n}</b><em>${tag}</em><small>${d}</small></span></a>`).join('')}</div></section>
-<section class="home-sec" id="tools" aria-labelledby="h-tools"><h2 id="h-tools">${catalog.tools.length} tools, one toolbox</h2><p class="lede">Every tool has its own short address, so <a href="https://yt.openvibe.tools/">yt.openvibe.tools</a> or <a href="https://dns.openvibe.tools/">dns.openvibe.tools</a> takes you straight there. Browse them all at <a href="https://openvibe.tools/">openvibe.tools</a>.</p>
+<section class="home-sec" id="tools" aria-labelledby="h-tools"><h2 id="h-tools">${catalog.tools.length} tools that just open</h2><p class="lede">No installs and no sign-up wall. Every tool has its own short address, so <a href="https://yt.openvibe.tools/">yt.openvibe.tools</a> or <a href="https://dns.openvibe.tools/">dns.openvibe.tools</a> takes you straight there. Browse them all at <a href="https://openvibe.tools/">openvibe.tools</a>.</p>
<div class="home-fams">${fams.map(f => `<a class="home-fam" href="${esc(f.path ? 'https://openvibe.tools' + f.path : f.url)}">${icon(f.icon, 38)}<span><b>${esc(f.name)}</b><small>${count(f)} tools · ${esc(f.tagline || '')}</small></span></a>`).join('')}</div>
<div class="home-fams">${fams.map(f => `<a class="home-fam" href="${esc(f.path ? 'https://openvibe.tools' + f.path : f.url)}">${icon(f.icon, 38)}<span><b>${esc(f.name)}</b><small>${count(f)} tools · ${esc(f.tagline || '')}</small></span></a>`).join('')}</div>
<ul class="home-chips">${popular.map(t => `<li><a href="${esc(t.url)}" title="${esc(t.tagline || '')}">${icon(t.icon, 22)}${esc(t.name)}</a></li>`).join('')}</ul></section>
<ul class="home-chips">${popular.map(t => `<li><a href="${esc(t.url)}" title="${esc(t.tagline || '')}">${icon(t.icon, 22)}${esc(t.name)}</a></li>`).join('')}</ul></section>
-<section class="home-sec" aria-labelledby="h-acct"><h2 id="h-acct">What your account does</h2><p class="lede">openvibe.network is the identity behind every site. It holds your sign-in and your preferences, and nothing else.</p>
+<section class="home-sec" aria-labelledby="h-acct"><h2 id="h-acct">What signing in adds</h2><p class="lede">You can use almost everything as a guest. An account is for the things that need to remember you, and it works on every OpenVibe site the moment you arrive.</p>
<div class="home-grid">${ACCOUNT.map(([ic, n, d, u]) => `<a class="home-card" href="${u}">${icon(ic, 44)}<span><b>${n}</b><small>${d}</small></span></a>`).join('')}</div></section>
<div class="home-grid">${ACCOUNT.map(([ic, n, d, u]) => `<a class="home-card" href="${u}">${icon(ic, 44)}<span><b>${n}</b><small>${d}</small></span></a>`).join('')}</div></section>
-<section class="home-sec" aria-labelledby="h-soon"><h2 id="h-soon">More rooms being built</h2><p class="lede">These addresses are reserved and open as each one is ready.</p>
+<section class="home-sec" aria-labelledby="h-soon"><h2 id="h-soon">Opening next</h2><p class="lede">Thirteen more addresses are staked out. Each one opens when it is good enough to use daily, and your account will already work there.</p>
<ul class="home-chips">${SOON.map(([id, n]) => `<li><a href="https://openvibe.${id}/">${icon(id, 22)}OpenVibe.${n}</a></li>`).join('')}<li><a href="https://openre.stream/">${icon('stream', 22)}OpenRe.Stream</a></li></ul></section>
<ul class="home-chips">${SOON.map(([id, n]) => `<li><a href="https://openvibe.${id}/">${icon(id, 22)}OpenVibe.${n}</a></li>`).join('')}<li><a href="https://openre.stream/">${icon('stream', 22)}OpenRe.Stream</a></li></ul></section>
<section class="home-sec" aria-labelledby="h-dev"><h2 id="h-dev">For developers and crawlers</h2><p class="lede">OpenVibe is open source and community-run. Everything public is meant to be read by machines too.</p>
<section class="home-sec" aria-labelledby="h-dev"><h2 id="h-dev">For developers and crawlers</h2><p class="lede">OpenVibe is open source and community-run. Everything public is meant to be read by machines too.</p>
<div class="home-dev"><div><b>Tool catalog</b><p>Every tool with its description, keywords and addresses as JSON: <a href="https://openvibe.tools/api/catalog.json"><code>openvibe.tools/api/catalog.json</code></a></p></div>
<div class="home-dev"><div><b>Tool catalog</b><p>Every tool with its description, keywords and addresses as JSON: <a href="https://openvibe.tools/api/catalog.json"><code>openvibe.tools/api/catalog.json</code></a></p></div>
@@ -415,6 +415,14 @@ function requireAdmin(req, res, next) {
@@ -415,6 +415,14 @@ function requireAdmin(req, res, next) {
}
}
next();
next();
}
}
+// Terms, Privacy and DMCA for this domain (openvibe-shared/legal; every site serves its own).
+{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'network', service: 'network', host: 'openvibe.network', name: 'OpenVibe.Network', profile: 'account' })); app.get('/tos', (_req, res) => res.redirect(301, '/terms')); }
+
+// Shared chrome: analytics-ranked navigation + footer copy for every site (server/chrome).
+const chromeService = require('./chrome/service').createChromeService(db, config, analytics);
+app.use('/api/chrome', rateLimit({ windowMs: 60_000, max: 240 }), chromeService.router);
+chromeService.start();
+
// Tool domains: public list for the Tools gateway, owner-only management (docs/shared-contracts.md §1).
// Tool domains: public list for the Tools gateway, owner-only management (docs/shared-contracts.md §1).
const toolDomains = require('./domains/routes').createDomainRoutes(db, requireAuth);
const toolDomains = require('./domains/routes').createDomainRoutes(db, requireAuth);
app.use('/api/domains', rateLimit({ windowMs: 60_000, max: 120 }), toolDomains.publicRouter);
app.use('/api/domains', rateLimit({ windowMs: 60_000, max: 120 }), toolDomains.publicRouter);
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+'use strict';
+const assert = require('assert');
+const Database = require('better-sqlite3');
+const { createChromeService, BANNED } = require('../server/chrome/service');
+const { siteForHost } = require('../server/chrome/sites');
+
+const db = new Database(':memory:');
+db.exec("CREATE TABLE user_history (id INTEGER PRIMARY KEY, user_id INT, service TEXT, sub TEXT, type TEXT, title TEXT, url TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)");
+const svc = createChromeService(db, { internalKey: 'change-me-in-production', services: {} }, null);
+
+assert.equal(siteForHost('yt.openvibe.tools').id, 'tools');
+assert.equal(siteForHost('evil.example'), null);
+let p = svc.payloadFor('openvibe.media');
+assert.deepEqual(p.nav.map(n => n.id), ['live', 'tools', 'community', 'games', 'media', 'network'], 'cold start follows the base order');
+assert.equal(p.footer.legal.dmca, 'https://openvibe.media/dmca', 'legal links stay on the site\'s own domain');
+assert.ok(!p.footer.discover.some(l => l.url === 'https://openvibe.media/'), 'a site never recommends itself');
+assert.ok(p.soon.length >= 10);
+
+// Real use reorders: community gets the history, so it rises above the cold-start order.
+const ins = db.prepare("INSERT INTO user_history (user_id, service, sub, type, title, url) VALUES (?, ?, ?, 'page', 't', 'u')");
+for (let i = 0; i < 40; i++) ins.run(i % 9, 'community', null);
+for (let i = 0; i < 6; i++) ins.run(1, 'tools', 'dns');
+(async () => {
+ const realFetch = global.fetch; global.fetch = async () => ({ ok: false });
+ await svc.refreshRank(); global.fetch = realFetch;
+ p = svc.payloadFor('openvibe.network');
+ assert.equal(p.nav[0].id, 'community', 'most used site comes first');
+ assert.ok(BANNED.test('Totally free tools') && BANNED.test('see https://x.y') && BANNED.test('<b>hi</b>') && !BANNED.test('Go live from a browser in seconds.'), 'AI copy screen');
+ assert.equal(p.footer.ai, false);
+ console.log('chrome service: all checks passed');
+})().catch(e => { console.error(e); process.exit(1); });