openvibers/openvibe.network Commits
Shared icons, activity island, UI toasts/dialogs; navbar launcher, linked brand segments and auto bell; owner-only tool domains; server-rendered home with the tool catalog; llms.txt
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",++ "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","reset-db": "node server/reset-db.js","grant-admin": "node server/grant-admin.js"},nnew file mode 100644@@@ -0,0 +1,161 @@++/**++ * openvibe-shared/island.js — the activity island.++ *++ * A page tells the navbar what it is doing. The brand mark becomes the indicator (progress ring,++ * state colour) and a pill grows out of the brand; hover, focus or tap expands it into a rich card++ * (image, title, subtitle, progress bar, detail line, actions).++ *++ * OpenVibeIsland.start({ id, title, subtitle, icon, image, progress, state, detail, actions })++ * OpenVibeIsland.update(id, patch) progress: 0–1 | null (indeterminate)++ * OpenVibeIsland.finish(id, { state: 'ok', title, actions, ttl })++ * OpenVibeIsland.fail(id, { title, detail })++ * OpenVibeIsland.remove(id) · .list()++ *++ * Works without the navbar (fixed pill, top centre). All text goes through textContent; links are++ * http(s) or same-origin paths only. Motion respects prefers-reduced-motion.++ */++(function (root) {++ 'use strict';++ if (root.OpenVibeIsland) return;++ if (typeof document === 'undefined') { const n = () => {}; const stub = { start: () => ({ update: n, finish: n, fail: n, remove: n }), update: n, finish: n, fail: n, remove: n, list: () => [] }; if (typeof module !== 'undefined') module.exports = stub; return; }++++ const CSS = `++.ov-island{position:relative;display:inline-flex;align-items:center;margin-left:10px;z-index:60;font:500 12.5px/1.2 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:var(--text-primary,#e6edf7)}++.ov-island.is-floating{position:fixed;top:10px;left:50%;transform:translateX(-50%);margin:0;z-index:2147482000}++.ov-island[hidden]{display:none}++.ovi-pill{appearance:none;border:1px solid color-mix(in srgb,var(--ovis-c) 45%,transparent);background:color-mix(in srgb,var(--ovis-c) 12%,var(--bg-elevated,var(--bg-secondary,#0d131d)));color:inherit;font:inherit;display:inline-flex;align-items:center;gap:8px;height:30px;padding:0 12px 0 5px;border-radius:999px;cursor:pointer;max-width:min(300px,46vw);animation:ovisGrow .4s cubic-bezier(.2,1.3,.3,1);box-shadow:0 4px 18px rgba(0,0,0,.28)}++.ov-island{--ovis-c:var(--accent,#3b82f6)}++.ov-island[data-state=ok]{--ovis-c:var(--success,#22c55e)}++.ov-island[data-state=error]{--ovis-c:var(--danger,#ef4444)}++.ovi-pill .ov-icon{--ovi-size:22px;--ovi-accent:var(--ovis-c)}++.ovi-pill-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:650}++.ovi-pill-pct{font-variant-numeric:tabular-nums;color:var(--text-secondary,#a8b3c4)}++.ovi-count{background:var(--ovis-c);color:var(--on-accent,#fff);border-radius:999px;font-size:10.5px;font-weight:800;padding:1px 6px}++.ovi-card{position:absolute;top:calc(100% + 8px);left:0;width:min(340px,calc(100vw - 24px));background:var(--bg-elevated,var(--bg-secondary,#111826));border:1px solid var(--border,rgba(255,255,255,.12));border-radius:18px;box-shadow:0 24px 60px rgba(0,0,0,.5);padding:8px;opacity:0;transform:translateY(-6px) scale(.97);transform-origin:top left;pointer-events:none;transition:opacity .18s,transform .22s cubic-bezier(.2,1.2,.3,1)}++.ov-island.is-floating .ovi-card{left:50%;translate:-50% 0;transform-origin:top center}++.ov-island.is-open .ovi-card{opacity:1;transform:none;pointer-events:auto}++.ovi-item{display:grid;grid-template-columns:auto 1fr;gap:10px;padding:8px;border-radius:12px;--ovis-c:var(--accent,#3b82f6)}++.ovi-item[data-state=ok]{--ovis-c:var(--success,#22c55e)}++.ovi-item[data-state=error]{--ovis-c:var(--danger,#ef4444)}++.ovi-item+.ovi-item{border-top:1px solid var(--border,rgba(255,255,255,.08));border-radius:0}++.ovi-thumb{width:64px;height:44px;border-radius:8px;object-fit:cover;background:#0006}++.ovi-item .ov-icon{--ovi-size:40px;--ovi-accent:var(--ovis-c)}++.ovi-t{font-weight:700;font-size:13.5px}++.ovi-s{color:var(--text-secondary,#a8b3c4);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}++.ovi-body{min-width:0}++.ovi-bar{height:4px;border-radius:4px;background:color-mix(in srgb,var(--ovis-c) 18%,transparent);margin-top:8px;overflow:hidden}++.ovi-bar i{display:block;height:100%;width:0;background:var(--ovis-c);border-radius:4px;transition:width .35s cubic-bezier(.2,.8,.2,1)}++.ovi-bar.is-ind i{width:35%;animation:ovisInd 1.2s ease-in-out infinite}++.ovi-d{color:var(--text-muted,#7d8aa0);font-size:11.5px;margin-top:6px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}++.ovi-acts{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}++.ovi-act{appearance:none;border:0;cursor:pointer;text-decoration:none;font:700 12px/1 system-ui,sans-serif;padding:7px 11px;border-radius:8px;color:var(--ovis-c);background:color-mix(in srgb,var(--ovis-c) 14%,transparent)}++.ovi-act:first-child{background:var(--ovis-c);color:var(--on-accent,#fff)}++.ovi-act:focus-visible,.ovi-pill:focus-visible{outline:2px solid var(--ovis-c);outline-offset:2px}++@keyframes ovisGrow{from{max-width:30px;opacity:0}to{opacity:1}}++@keyframes ovisInd{0%{transform:translateX(-110%)}100%{transform:translateX(300%)}}++@media (max-width:640px){.ov-island{margin-left:6px}.ovi-pill{max-width:40vw;padding-right:9px}.ovi-pill-pct{display:none}.ovi-card{position:fixed;top:56px;left:12px;right:12px;width:auto}}++@media (prefers-reduced-motion:reduce){.ovi-pill,.ovi-bar.is-ind i{animation:none}.ovi-card,.ovi-bar i{transition:none}}`;++++ const items = new Map(); // id → activity (insertion order = age)++ const timers = new Map();++ let host = null, pill = null, card = null, seq = 0;++++ const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = String(text); return e; };++ const safeUrl = (u) => { try { const x = new URL(u, location.href); return /^(https?|blob):$/.test(x.protocol) ? x.href : null; } catch { return null; } };++ const clamp = (p) => (typeof p === 'number' && isFinite(p) ? Math.max(0, Math.min(1, p)) : null);++++ function ensure() {++ if (host && host.isConnected) return;++ if (!document.getElementById('ov-island-css')) { const s = el('style'); s.id = 'ov-island-css'; s.textContent = CSS; document.head.appendChild(s); }++ if (!root.OpenVibeIcons && !document.getElementById('ov-icons-loader')) { const sc = el('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; sc.async = true; document.head.appendChild(sc); }++ host = el('div', 'ov-island'); host.hidden = true;++ pill = el('button', 'ovi-pill'); pill.type = 'button'; pill.setAttribute('aria-haspopup', 'true'); pill.setAttribute('aria-expanded', 'false');++ card = el('div', 'ovi-card'); card.setAttribute('role', 'region'); card.setAttribute('aria-label', 'Activity');++ host.append(pill, card);++ const brand = document.querySelector('.openvibe-navbar .ovnav-brand, .openvibe-navbar .openvibe-brand, .navbar .nav-brand, .navbar .logo');++ if (brand && brand.parentNode) brand.parentNode.insertBefore(host, brand.nextSibling);++ else { host.classList.add('is-floating'); document.body.appendChild(host); }++ const open = (v) => { host.classList.toggle('is-open', v); pill.setAttribute('aria-expanded', String(v)); };++ pill.addEventListener('click', () => open(!host.classList.contains('is-open')));++ if (matchMedia('(hover:hover)').matches) { host.addEventListener('mouseenter', () => open(true)); host.addEventListener('mouseleave', () => open(false)); }++ document.addEventListener('click', (e) => { if (!host.contains(e.target)) open(false); });++ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') open(false); });++ }++++ function top() { let t = null; for (const a of items.values()) t = a; return t; }++++ function brandMark(a) {++ // The navbar's own mark mirrors the top activity: progress ring + state colour.++ document.querySelectorAll('.openvibe-navbar .ov-mark, .navbar .ov-mark').forEach((m) => {++ if (m.closest('.openvibe-network-badge')) return;++ if (!a) { m.removeAttribute('data-state'); m.removeAttribute('data-progress'); m.style.removeProperty('--ovm-p'); return; }++ m.setAttribute('data-state', a.state === 'info' ? 'idle' : a.state);++ if (a.progress == null || a.state !== 'busy') m.removeAttribute('data-progress'); else { m.setAttribute('data-progress', String(a.progress)); m.style.setProperty('--ovm-p', String(a.progress)); }++ });++ try { root.dispatchEvent(new CustomEvent('ov:activity', { detail: a ? { id: a.id, state: a.state, progress: a.progress, title: a.title } : null })); } catch { /* */ }++ }++++ function iconEl(a, size) { const i = el('span', 'ov-icon'); i.dataset.icon = a.icon || 'ov'; i.dataset.size = String(size); i.dataset.state = a.state === 'info' ? 'idle' : a.state; if (a.progress != null && a.state === 'busy') i.dataset.progress = String(a.progress); i.dataset.fx = a.state === 'busy' ? 'orbit' : 'none'; return i; }++++ function render() {++ ensure();++ const a = top();++ brandMark(a);++ if (!a) { host.hidden = true; host.classList.remove('is-open'); return; }++ host.hidden = false; host.dataset.state = a.state;++ pill.replaceChildren(iconEl(a, 22), el('span', 'ovi-pill-text', a.title || 'Working'));++ if (a.state === 'busy' && a.progress != null) pill.appendChild(el('span', 'ovi-pill-pct', Math.round(a.progress * 100) + '%'));++ if (items.size > 1) pill.appendChild(el('span', 'ovi-count', items.size));++ pill.setAttribute('aria-label', `${a.title || 'Activity'}${a.progress != null ? ', ' + Math.round(a.progress * 100) + ' percent' : ''}`);++++ card.replaceChildren(...[...items.values()].reverse().map((it) => {++ const row = el('div', 'ovi-item'); row.dataset.state = it.state;++ const img = it.image && safeUrl(it.image);++ if (img) { const im = el('img', 'ovi-thumb'); im.src = img; im.alt = ''; im.loading = 'lazy'; im.referrerPolicy = 'no-referrer'; row.appendChild(im); } else row.appendChild(iconEl(it, 40));++ const body = el('div', 'ovi-body'); body.appendChild(el('div', 'ovi-t', it.title || ''));++ if (it.subtitle) body.appendChild(el('div', 'ovi-s', it.subtitle));++ if (it.state === 'busy') { const bar = el('div', 'ovi-bar' + (it.progress == null ? ' is-ind' : '')); const fill = el('i'); if (it.progress != null) fill.style.width = (it.progress * 100).toFixed(1) + '%'; bar.appendChild(fill); bar.setAttribute('role', 'progressbar'); if (it.progress != null) bar.setAttribute('aria-valuenow', String(Math.round(it.progress * 100))); body.appendChild(bar); }++ if (it.detail) body.appendChild(el('div', 'ovi-d', it.detail));++ if (it.actions && it.actions.length) {++ const acts = el('div', 'ovi-acts');++ it.actions.slice(0, 3).forEach((ac) => {++ if (!ac || !ac.label) return;++ const href = ac.href && safeUrl(ac.href);++ const b = el(href ? 'a' : 'button', 'ovi-act', ac.label);++ if (href) { b.href = href; if (ac.download) b.setAttribute('download', typeof ac.download === 'string' ? ac.download : ''); } else b.type = 'button';++ if (ac.onClick) b.addEventListener('click', (ev) => { try { ac.onClick(ev); } catch { /* */ } });++ acts.appendChild(b);++ });++ body.appendChild(acts);++ }++ row.appendChild(body);++ return row;++ }));++ }++++ function put(id, patch, defaults) {++ const prev = items.get(id) || Object.assign({ id, title: '', subtitle: '', icon: 'ov', image: '', progress: null, state: 'busy', detail: '', actions: [] }, defaults);++ const next = Object.assign({}, prev, patch || {});++ if (patch && 'progress' in patch) next.progress = clamp(patch.progress);++ if (!['busy', 'ok', 'error', 'info'].includes(next.state)) next.state = 'busy';++ items.set(id, next);++ clearTimeout(timers.get(id));++ if (next.ttl) timers.set(id, setTimeout(() => remove(id), next.ttl));++ render();++ return next;++ }++++ function handle(id) { return { id, update: (p) => update(id, p), finish: (p) => finish(id, p), fail: (p) => fail(id, p), remove: () => remove(id) }; }++ function start(o) { const id = (o && o.id) || 'act-' + (++seq); items.delete(id); put(id, Object.assign({ state: 'busy' }, o, { id })); return handle(id); }++ function update(id, patch) { if (!items.has(id)) return; const p = Object.assign({}, patch); delete p.id; put(id, p); }++ function finish(id, patch) { if (!items.has(id)) return; put(id, Object.assign({ state: 'ok', progress: 1, actions: [], ttl: 8000 }, patch)); if (host && top() && top().id === id && (patch && patch.actions && patch.actions.length)) { host.classList.add('is-open'); pill.setAttribute('aria-expanded', 'true'); setTimeout(() => { if (host && !host.matches(':hover')) host.classList.remove('is-open'); }, 5000); } }++ function fail(id, patch) { if (!items.has(id)) return; put(id, Object.assign({ state: 'error', progress: null, actions: [], ttl: 12000 }, patch)); }++ function remove(id) { clearTimeout(timers.get(id)); timers.delete(id); if (items.delete(id)) render(); }++++ const api = { start, update, finish, fail, remove, list: () => [...items.values()] };++ root.OpenVibeIsland = api;++ // The navbar exposes the same thing as OpenVibeNavbar.activity, whichever loads first.++ const bind = () => { if (root.OpenVibeNavbar && !root.OpenVibeNavbar.activity) { try { root.OpenVibeNavbar.activity = api; } catch { /* */ } } };++ bind(); root.addEventListener('load', bind);++ if (typeof module !== 'undefined' && module.exports) module.exports = api;++})(typeof window !== 'undefined' ? window : globalThis);@@@ -61,7 +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 .flame { font-size: 18px; color: var(--accent, #8b5cf6); display: inline-grid; place-items: center; width: 28px; height: 28px; flex: none; }++ .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)); }++ .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; }++ .ovnav-launcher .ovl-q:focus { border-color: var(--accent, #3b82f6); }++ .ovnav-launcher .ovl-h { display: flex; justify-content: space-between; font-size: 11px; font-weight: 700; letter-spacing: .7px; text-transform: uppercase; color: var(--text-muted, #7d8aa0); margin: 12px 4px 6px; }++ .ovnav-launcher .ovl-h a { color: var(--accent-light, var(--accent, #60a5fa)); text-decoration: none; text-transform: none; letter-spacing: 0; }++ .ovnav-launcher .ovl-sites { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }++ .ovnav-launcher .ovl-fams { display: grid; grid-template-columns: repeat(2, 1fr); gap: 2px; }++ .ovnav-launcher .ovl-site, .ovnav-launcher .ovl-fam { display: flex; align-items: center; gap: 9px; padding: 8px; border-radius: 10px; color: var(--text-primary, #e6edf7); text-decoration: none; min-width: 0; }++ .ovnav-launcher .ovl-site { flex-direction: column; text-align: center; gap: 6px; padding: 12px 6px; }++ .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-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; } }++ .openvibe-navbar-brand .flame { font-size: 18px; text-decoration: none; color: var(--accent, #8b5cf6); display: inline-grid; place-items: center; width: 28px; height: 28px; flex: none; }.openvibe-navbar-brand .name { display: inline-flex; align-items: baseline; font-size: 15px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; line-height: 1; }/* Segments: subdomain · OpenVibe · TLD. The part that names *this* site is the loud one. */.openvibe-navbar-brand .b-sub { color: var(--text-primary, #e0e0e0); }@@@ -484,19 +505,117 @@return { sub, subText, core, tld, tldText, name, short: subText || `${core}.${tldText}`, icon, variant, tag: b.tag || null, href: b.href || '/' };}++ /** Where each brand segment goes: sub → this tool, OpenVibe → the network, TLD → the site's apex. */++ function brandLinks(brand) {++ const apex = brand.core === 'OpenRe' ? 'https://openre.stream/' : `https://openvibe.${brand.tld}/`;++ if (!brand.subText) return { sub: brand.href, core: brand.href, tld: brand.href, apex };++ return { sub: brand.href, core: 'https://openvibe.network/', tld: apex, apex };++ }++function brandHTML(brand) {-- const seg = (cls, text) => `<span class="${cls}">${escapeAttr(text)}</span>`;++ const to = brandLinks(brand);++ const seg = (cls, text, href, title) => `<a href="${escapeAttr(href)}"${title ? ` title="${escapeAttr(title)}"` : ''} class="${cls}">${escapeAttr(text)}</a>`;const dot = '<span class="b-dot">.</span>';const text = brand.subText-- ? seg('b-sub', brand.subText) + dot + seg('b-core', brand.core) + dot + seg('b-tld', brand.tldText)-- : seg('b-core', brand.core) + dot + seg('b-tld', brand.tldText);++ ? seg('b-sub', brand.subText, to.sub, brand.name) + dot + seg('b-core', brand.core, to.core, 'OpenVibe Network') + dot + seg('b-tld', brand.tldText, to.tld, `All of ${brand.core}.${brand.tldText}`)++ : seg('b-core', brand.core, to.core, brand.name) + dot + seg('b-tld', brand.tldText, to.tld, brand.name);const mark = brand.icon? `<i class="fa-solid ${escapeAttr(brand.icon)}"></i>`: `<span class="ov-mark" data-size="28" data-variant="${escapeAttr(brand.variant)}"></span>`;-- return `<a class="openvibe-navbar-brand${brand.subText ? ' has-sub' : ''}" href="${escapeAttr(brand.href)}" title="${escapeAttr(brand.name)}" aria-label="${escapeAttr(brand.name)}">-- <span class="flame">${mark}</span>++ return `<div class="openvibe-navbar-brand${brand.subText ? ' has-sub' : ''}">++ <a class="flame" href="${escapeAttr(brand.href)}" aria-label="${escapeAttr(brand.name)} home">${mark}</a><span class="name">${text}${brand.tag ? `<span class="b-tag">${escapeAttr(brand.tag)}</span>` : ''}</span>-- </a>`;++ </div>++ ${_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>'}`;++ }++++ // ── Network launcher ─────────────────────────────────────++ const LAUNCHER_SITES = [++ { name: 'Live', desc: 'Streams, clips and chat', icon: 'live', url: 'https://openvibe.live/' },++ { name: 'Tools', desc: 'Every online tool', icon: 'tools', url: 'https://openvibe.tools/' },++ { name: 'Community', desc: 'Pastes and posts', icon: 'community', url: 'https://openvibe.community/' },++ { name: 'Games', desc: 'Browser games', icon: 'games', url: 'https://openvibe.games/' },++ { name: 'Media', desc: 'VODs, clips, files', icon: 'media', url: 'https://openvibe.media/' },++ { name: 'Network', desc: 'Account and themes', icon: 'network', url: 'https://openvibe.network/' },++ ];++ const LAUNCHER_FAMILIES = [++ { name: 'Media Tools', icon: 'youtube', url: 'https://yt.openvibe.tools/' },++ { name: 'Image Tools', icon: 'image', url: 'https://img.openvibe.tools/' },++ { name: 'Audio Tools', icon: 'audio', url: 'https://audio.openvibe.tools/' },++ { name: 'PDF & Documents', icon: 'pdf', url: 'https://docs.openvibe.tools/' },++ { name: 'Text Tools', icon: 'text', url: 'https://text.openvibe.tools/' },++ { name: 'Developer Tools', icon: 'code', url: 'https://dev.openvibe.tools/' },++ { name: 'Network Tools', icon: 'dns', url: 'https://net.openvibe.tools/' },++ ];++ const CATALOG_URL = 'https://openvibe.tools/api/catalog.json';++ const okUrl = (u) => { try { const x = new URL(u); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };++++ async function launcherCatalog() {++ try { const c = JSON.parse(sessionStorage.getItem('ov_catalog') || 'null'); if (c && Date.now() - c.at < 30 * 60000) return c.data; } catch { /* */ }++ try {++ const r = await fetch(CATALOG_URL, { credentials: 'omit' }); if (!r.ok) return null;++ const j = await r.json();++ const data = { families: (j.families || []).slice(0, 12).map(f => ({ name: String(f.name || ''), icon: String(f.icon || 'tools'), url: okUrl(f.url) })).filter(f => f.name && f.url),++ tools: (j.tools || []).slice(0, 400).map(t => ({ name: String(t.name || ''), icon: String(t.icon || 'tools'), url: okUrl(t.url), k: [t.name, t.tagline].concat(t.keywords || []).join(' ').toLowerCase().slice(0, 400) })).filter(t => t.name && t.url) };++ try { sessionStorage.setItem('ov_catalog', JSON.stringify({ at: Date.now(), data })); } catch { /* */ }++ return data;++ } catch { return null; }++ }++++ function bindLauncher(nav) {++ const btn = nav.querySelector('#openvibe-launcher-btn'); if (!btn) return;++ let panel = null;++ const tile = (it, cls) => `<a class="${cls}" href="${escapeAttr(it.url)}"><span class="ov-icon" data-icon="${escapeAttr(it.icon)}" data-size="${cls === 'ovl-site' ? 34 : 24}" data-fx="none"></span><span><b>${escapeAttr(it.name)}</b>${it.desc ? `<small>${escapeAttr(it.desc)}</small>` : ''}</span></a>`;++ const close = () => { if (panel) panel.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); };++ function paint(cat, q) {++ const fams = (cat && cat.families.length ? cat.families : LAUNCHER_FAMILIES);++ const body = panel.querySelector('.ovl-body');++ if (q && cat) {++ const hits = cat.tools.filter(t => t.k.includes(q)).slice(0, 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>`;++ }++ btn.addEventListener('click', async (e) => {++ e.stopPropagation();++ if (panel && panel.classList.contains('open')) return close();++ if (!panel) {++ panel = document.createElement('div'); panel.className = 'ovnav-launcher'; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'OpenVibe sites and tools');++ panel.innerHTML = '<input type="search" class="ovl-q" placeholder="Find a tool or site" aria-label="Find a tool or site"><div class="ovl-body"></div>';++ nav.appendChild(panel);++ if (!root.OpenVibeIcons && !document.getElementById('ov-icons-loader')) { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; sc.async = true; document.head.appendChild(sc); }++ let cat = null; paint(null, '');++ const input = panel.querySelector('.ovl-q');++ input.addEventListener('input', () => paint(cat, input.value.trim().toLowerCase()));++ input.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { const first = panel.querySelector('.ovl-body a'); if (first) location.href = first.href; } if (ev.key === 'ArrowDown') { const first = panel.querySelector('.ovl-body a'); if (first) { ev.preventDefault(); first.focus(); } } });++ panel.addEventListener('keydown', (ev) => {++ if (ev.key === 'Escape') { close(); btn.focus(); return; }++ if (ev.key !== 'ArrowDown' && ev.key !== 'ArrowUp') return;++ const links = [...panel.querySelectorAll('.ovl-body a')]; const i = links.indexOf(document.activeElement); if (i < 0) return;++ ev.preventDefault(); const n = i + (ev.key === 'ArrowDown' ? 1 : -1); (links[n] || (n < 0 ? input : links[0])).focus();++ });++ panel.addEventListener('click', (ev) => ev.stopPropagation());++ document.addEventListener('click', close);++ launcherCatalog().then((c) => { if (c) { cat = c; paint(cat, input.value.trim().toLowerCase()); } });++ }++ panel.classList.add('open'); btn.setAttribute('aria-expanded', 'true');++ try { panel.querySelector('.ovl-q').focus({ preventScroll: true }); } catch { /* */ }++ });++ }++++ // ── Notification bell (mounted by the navbar itself) ─────++ function mountBell(nav, u) {++ if (_config.notifications === false || !u || u.is_anon || !_config.token) return;++ const mount = nav.querySelector('#openvibe-bell-mount'); if (!mount || mount.childElementCount) return;++ const go = () => {++ const N = root.OpenVibeNotifications; if (!N || mount.childElementCount) return;++ try { if (!N.__ovNavInit) { N.init({ token: _config.token, apiBase: 'https://openvibe.network' }); N.__ovNavInit = true; } N.createBell(mount); } catch { /* */ }++ };++ if (root.OpenVibeNotifications) return go();++ if (document.getElementById('ov-notify-loader')) return;++ const sc = document.createElement('script'); sc.id = 'ov-notify-loader'; sc.src = 'https://openvibe.network/shared/notification-ui.js'; sc.async = true; sc.onload = () => setTimeout(go, 0); document.head.appendChild(sc);}function menuItemHTML(item) {@@@ -1013,6 +1132,9 @@`<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}">Sign In</a>`}</div>`;++ bindLauncher(nav);++ // Pages that wire the bell themselves run right after init(); give them the first go.++ setTimeout(() => mountBell(nav, u), 0);// Dropdownif (u) {@@@ -1175,6 +1297,10 @@/** The resolved brand for this page ({ sub, core, tld, name, short, variant }). */brand() { return resolveBrand(); },++ /** The activity island (island.js), loaded on first use: OpenVibeNavbar.activity.start({...}). */++ get activity() { return root.OpenVibeIsland || null; },++ set activity(_v) { /* island.js binds itself; nothing to store */ },++setToken(token) { _config.token = token; },/** Get the bell mount point for OpenVibeNotifications. */nnew file mode 100644@@@ -0,0 +1,184 @@++/**++ * openvibe-shared/ov-icons.js — the OpenVibe animated icon family.++ *++ * <span class="ov-icon" data-icon="youtube" data-size="40"></span>++ * <span class="ov-icon" data-icon="pdf" data-state="busy" data-progress="0.4" data-fx="orbit"></span>++ *++ * One construction for every icon so the network reads as one brand: the OV ring (with its comet)++ * around a glyph drawn in the theme accent. The ring is also a progress meter (data-progress 0–1)++ * and a status light (data-state idle|busy|ok|error), so the same icon that names a tool can show++ * what the tool is doing. Glyphs are 24×24 stroke paths centred in the ring.++ *++ * OpenVibeIcons.names() every registered icon++ * OpenVibeIcons.register(name, { glyph, fx?, accent? })++ * OpenVibeIcons.mount(root?) (re)scan; new nodes are picked up automatically++ * OpenVibeIcons.set(el, { progress, state, icon })++ * OpenVibeIcons.svg(name, size) markup string, for server-side / templates++ *++ * Effects (data-fx): orbit (default) · pulse · draw · bounce · none. Motion is transform/opacity/++ * stroke only and stops under prefers-reduced-motion. Unknown names fall back to the OV mark "V".++ */++(function (root) {++ 'use strict';++ if (root.OpenVibeIcons) return;++++ const P = (d) => `<path d="${d}"/>`;++ // Glyphs: stroke-only, 24×24 box, designed to sit inside the ring.++ const GLYPHS = {++ ov: P('M6.5 7.5 12 17.5l5.5-10'),++ network: '<circle cx="12" cy="6" r="2"/><circle cx="5.5" cy="17" r="2"/><circle cx="18.5" cy="17" r="2"/>' + P('M12 8v3.5M12 11.5 6.8 15.4M12 11.5l5.2 3.9'),++ live: '<circle cx="12" cy="12" r="2.2"/>' + P('M7.5 7.5a6.4 6.4 0 0 0 0 9M16.5 7.5a6.4 6.4 0 0 1 0 9M4.8 5a10 10 0 0 0 0 14M19.2 5a10 10 0 0 1 0 14'),++ tools: P('M14.5 5.5a4 4 0 0 0-5.2 5.2L4.5 15.5a1.8 1.8 0 0 0 2.5 2.5l4.8-4.8a4 4 0 0 0 5.2-5.2l-2.6 2.6-2.1-.5-.5-2.1z'),++ media: '<rect x="4" y="6" width="16" height="12" rx="2"/>' + P('M10 9.5v5l4.5-2.5z'),++ games: P('M7 9h10a4 4 0 0 1 3.9 4.9l-.6 2.6a2 2 0 0 1-3.5.8L15.5 16h-7l-1.3 1.3a2 2 0 0 1-3.5-.8l-.6-2.6A4 4 0 0 1 7 9zM8 11.5v3M6.5 13h3') + '<circle cx="15.5" cy="12" r=".6"/><circle cx="17.5" cy="14" r=".6"/>',++ community: '<circle cx="9" cy="9" r="2.6"/><circle cx="16.5" cy="10" r="2"/>' + P('M3.8 18.5a5.2 5.2 0 0 1 10.4 0M14.6 14.2a4 4 0 0 1 5.6 3.8'),++ chat: P('M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v7A1.5 1.5 0 0 1 19 16.5h-7l-4 3v-3H5A1.5 1.5 0 0 1 3.5 15V8A1.5 1.5 0 0 1 5 6.5zM8 10.5h8M8 13h5'),++ codes: P('M9 8 4.5 12 9 16M15 8l4.5 4-4.5 4M13.2 6l-2.4 12'),++ code: P('M9 8 4.5 12 9 16M15 8l4.5 4-4.5 4M13.2 6l-2.4 12'),++ blog: P('M5 19l1.2-4.2L16 5l3 3-9.8 9.8zM14 7l3 3'),++ wiki: P('M5 5.5h6a2 2 0 0 1 2 2V19a2 2 0 0 0-2-2H5zM19 5.5h-4a2 2 0 0 0-2 2V19a2 2 0 0 1 2-2h4z'),++ news: '<rect x="4" y="5.5" width="13" height="13" rx="1.5"/>' + P('M17 9h3v8a1.5 1.5 0 0 1-3 0M7 9h7M7 12h7M7 15h4'),++ reviews: P('M12 4.5l2.2 4.6 5 .7-3.6 3.5.9 5-4.5-2.4-4.5 2.4.9-5L4.8 9.8l5-.7z'),++ tips: P('M12 19s-6.5-3.8-6.5-8.6A3.6 3.6 0 0 1 12 8a3.6 3.6 0 0 1 6.5 2.4C18.5 15.2 12 19 12 19z'),++ vip: P('M4.5 16.5 6 8l3.8 3.5L12 6.5l2.2 5L18 8l1.5 8.5zM5 19h14'),++ trade: P('M4.5 18.5h15M6.5 15l3.5-4 3 2.5 4.5-6M17.5 7.5h-3M17.5 7.5v3'),++ host: '<rect x="4.5" y="5" width="15" height="5.5" rx="1.2"/><rect x="4.5" y="13.5" width="15" height="5.5" rx="1.2"/><circle cx="7.5" cy="7.75" r=".6"/><circle cx="7.5" cy="16.25" r=".6"/>',++ deals: P('M4.5 12.5V5.5h7l8 8-7 7zM8.2 9.2h.01'),++ coupons: P('M4 8.5h16v2.5a1.5 1.5 0 0 0 0 3v2.5H4V14a1.5 1.5 0 0 0 0-3zM10 8.5v8') ,++ stream: P('M5 17.5 12 6l7 11.5M8 13h8') + '<circle cx="12" cy="6" r="1.4"/>',++ youtube: '<rect x="3.5" y="6.5" width="17" height="11" rx="3"/>' + P('M10.5 9.8v4.4l3.8-2.2z'),++ video: '<rect x="3.5" y="6.5" width="17" height="11" rx="3"/>' + P('M10.5 9.8v4.4l3.8-2.2z'),++ download: P('M12 4.5v10M8 11l4 4 4-4M5 19h14'),++ upload: P('M12 15.5v-10M8 9l4-4 4 4M5 19h14'),++ image: '<rect x="4" y="5" width="16" height="14" rx="2"/><circle cx="9" cy="10" r="1.5"/>' + P('M4.5 17l4.5-4.5 3.5 3.5 2.5-2.5 4.5 4.5'),++ audio: P('M9 17V7.5l9-2V15') + '<circle cx="6.8" cy="17" r="2.2"/><circle cx="15.8" cy="15" r="2.2"/>',++ pdf: P('M7 4.5h7l4 4V19a.5.5 0 0 1-.5.5h-10.5A.5.5 0 0 1 6.5 19V5a.5.5 0 0 1 .5-.5zM14 4.5v4h4M9 13h6M9 16h4'),++ docs: P('M7 4.5h7l4 4V19a.5.5 0 0 1-.5.5h-10.5A.5.5 0 0 1 6.5 19V5a.5.5 0 0 1 .5-.5zM14 4.5v4h4M9 13h6M9 16h4'),++ text: P('M5.5 7V5.5h13V7M12 5.5v13M9.5 18.5h5'),++ logo: P('M12 4.5l1.8 4.2 4.2 1.8-4.2 1.8L12 16.5l-1.8-4.2L6 10.5l4.2-1.8zM18 15.5l.8 1.7 1.7.8-1.7.8-.8 1.7-.8-1.7-1.7-.8 1.7-.8z'),++ json: P('M9 5.5c-2 0-2 1.5-2 3s0 3.5-2 3.5c2 0 2 2 2 3.5s0 3 2 3M15 5.5c2 0 2 1.5 2 3s0 3.5 2 3.5c-2 0-2 2-2 3.5s0 3-2 3'),++ dns: '<circle cx="12" cy="12" r="7.5"/>' + P('M4.5 12h15M12 4.5c2.5 2 2.5 13 0 15M12 4.5c-2.5 2-2.5 13 0 15'),++ ip: P('M12 20s-6-5.2-6-10a6 6 0 0 1 12 0c0 4.8-6 10-6 10z') + '<circle cx="12" cy="10" r="2.2"/>',++ ssl: '<rect x="6" y="11" width="12" height="8.5" rx="1.8"/>' + P('M8.5 11V8.5a3.5 3.5 0 0 1 7 0V11M12 14.5v2'),++ ping: P('M3.5 12h4l2-5 3.5 10 2.5-7 1.5 2h3.5'),++ whois: '<circle cx="11" cy="11" r="5.5"/>' + P('M15.2 15.2 19.5 19.5M9 10a2 2 0 0 1 4 0c0 1.5-2 1.6-2 3M11 15h.01'),++ search: '<circle cx="11" cy="11" r="5.5"/>' + P('M15.2 15.2 19.5 19.5'),++ map: P('M4.5 7l5-2 5 2 5-2v12l-5 2-5-2-5 2zM9.5 5v12M14.5 7v12'),++ food: P('M7 4.5v6a2 2 0 0 0 4 0v-6M9 4.5v15M16.5 4.5c-2 1.5-2.5 4-2.5 6.5h2.5zM16.5 11v8.5'),++ paste: '<rect x="6" y="6" width="12" height="14" rx="1.8"/>' + P('M9.5 6V5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1M9 11h6M9 14h6M9 17h3'),++ account: '<circle cx="12" cy="9" r="3.2"/>' + P('M5.5 19a6.5 6.5 0 0 1 13 0'),++ bell: P('M6.5 16.5h11l-1.2-2V11a4.3 4.3 0 0 0-8.6 0v3.5zM10.5 19a1.6 1.6 0 0 0 3 0'),++ history: '<circle cx="12" cy="12" r="7.5"/>' + P('M12 8v4.2l2.8 1.8'),++ theme: P('M12 4.5a7.5 7.5 0 1 0 0 15c1 0 1.6-.8 1.2-1.7-.5-1.1.3-2.3 1.5-2.3h1.6a3.2 3.2 0 0 0 3.2-3.2C19.5 8 16.2 4.5 12 4.5z') + '<circle cx="8.5" cy="11" r=".7"/><circle cx="11.5" cy="8" r=".7"/><circle cx="15" cy="9" r=".7"/>',++ check: P('M6 12.5l4 4 8-9'),++ error: P('M12 7v6M12 16.5h.01'),++ clip: P('M6 6l12 12M6 18L18 6') ,++ };++ const ALIASES = { yt: 'youtube', net: 'dns', dev: 'code', img: 'image', sound: 'audio', document: 'pdf', pastes: 'paste', openre: 'stream', maps: 'map', user: 'account', notifications: 'bell' };++ const registry = Object.create(null);++ for (const [k, g] of Object.entries(GLYPHS)) registry[k] = { glyph: g };++++ const CSS = `++.ov-icon{--ovi-size:32px;display:inline-grid;place-items:center;width:var(--ovi-size);height:var(--ovi-size);flex:none;vertical-align:middle;line-height:0;color:var(--ovi-accent,var(--accent,#3b82f6));position:relative}++.ov-icon svg{width:100%;height:100%;overflow:visible;display:block}++.ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s}++.ov-icon .ovi-ring{fill:none;stroke:currentColor;stroke-width:2.4;opacity:.26}++.ov-icon .ovi-comet{fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-dasharray:26 100;transform-origin:24px 24px;animation:oviSpin 3.6s linear infinite}++.ov-icon .ovi-prog{fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;transform:rotate(-90deg);transform-origin:24px 24px;stroke-dasharray:125.7;stroke-dashoffset:125.7;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s;opacity:0}++.ov-icon .ovi-glyph{fill:none;stroke:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;transform-origin:24px 24px;transition:transform .25s cubic-bezier(.2,1.4,.3,1),stroke .2s}++.ov-icon .ovi-glyph circle[r=".6"],.ov-icon .ovi-glyph circle[r=".7"]{fill:currentColor;stroke:none}++.ov-icon:hover .ovi-glyph,a:hover>.ov-icon .ovi-glyph,button:hover>.ov-icon .ovi-glyph{transform:scale(1.1)}++.ov-icon:hover .ovi-bg,a:hover>.ov-icon .ovi-bg{opacity:.18}++.ov-icon[data-fx=none] .ovi-comet{display:none}++.ov-icon[data-fx=pulse] .ovi-comet{display:none}++.ov-icon[data-fx=pulse] .ovi-bg{animation:oviPulse 2s ease-in-out infinite;transform-origin:24px 24px}++.ov-icon[data-fx=draw] .ovi-glyph path{stroke-dasharray:80;animation:oviDraw 1.2s cubic-bezier(.2,.8,.2,1) both}++.ov-icon[data-fx=bounce] svg{animation:oviBounce 1.8s cubic-bezier(.3,1.4,.4,1) infinite}++.ov-icon[data-progress] .ovi-prog{opacity:1}++.ov-icon[data-progress] .ovi-comet{opacity:0}++.ov-icon[data-state=busy] .ovi-comet{animation-duration:1.1s;opacity:1;stroke-dasharray:40 86}++.ov-icon[data-state=busy][data-progress] .ovi-comet{opacity:.35}++.ov-icon[data-state=ok]{--ovi-accent:var(--success,#22c55e)}++.ov-icon[data-state=error]{--ovi-accent:var(--danger,#ef4444)}++.ov-icon[data-state=ok] .ovi-comet,.ov-icon[data-state=error] .ovi-comet{display:none}++.ov-icon[data-state=ok] .ovi-ring,.ov-icon[data-state=error] .ovi-ring{opacity:.9}++.ov-icon[data-state=ok] svg{animation:oviPop .5s cubic-bezier(.2,1.6,.3,1)}++.ov-icon[data-state=error] svg{animation:oviShake .4s ease}++@keyframes oviSpin{to{transform:rotate(360deg)}}++@keyframes oviPulse{0%,100%{transform:scale(.9);opacity:.08}50%{transform:scale(1.06);opacity:.22}}++@keyframes oviDraw{from{stroke-dashoffset:80}to{stroke-dashoffset:0}}++@keyframes oviBounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-6%)}}++@keyframes oviPop{0%{transform:scale(.8)}60%{transform:scale(1.12)}100%{transform:scale(1)}}++@keyframes oviShake{0%,100%{transform:translateX(0)}25%{transform:translateX(-6%)}75%{transform:translateX(6%)}}++@media (prefers-reduced-motion:reduce){.ov-icon *, .ov-icon svg{animation:none!important;transition:none!important}.ov-icon .ovi-comet{display:none}}`;++++ function resolve(name) {++ const n = String(name || 'ov').toLowerCase();++ return registry[n] ? n : (registry[ALIASES[n]] ? ALIASES[n] : 'ov');++ }++++ /** Markup for one icon (usable server-side: no DOM needed). Glyph is scaled 24→22 and centred in the 48 box. */++ function svg(name, size) {++ const def = registry[resolve(name)];++ const s = size ? ` width="${size}" height="${size}"` : '';++ return `<svg viewBox="0 0 48 48"${s} aria-hidden="true" focusable="false"><circle class="ovi-bg" cx="24" cy="24" r="21"/><circle class="ovi-ring" cx="24" cy="24" r="20"/><circle class="ovi-comet" cx="24" cy="24" r="20"/><circle class="ovi-prog" cx="24" cy="24" r="20"/><g class="ovi-glyph" transform="translate(13 13) scale(.9167)">${def.glyph}</g></svg>`;++ }++++ function applyProgress(el) {++ const raw = el.getAttribute('data-progress');++ const prog = el.querySelector('.ovi-prog');++ if (!prog) return;++ if (raw == null || raw === '') { prog.style.strokeDashoffset = ''; return; }++ const p = Math.max(0, Math.min(1, parseFloat(raw) || 0));++ prog.style.strokeDashoffset = String(125.7 * (1 - p));++ }++++ function mountOne(el) {++ const name = resolve(el.getAttribute('data-icon'));++ if (el.getAttribute('data-ovi') === name) { applyProgress(el); return; }++ el.setAttribute('data-ovi', name);++ const size = parseInt(el.getAttribute('data-size'), 10);++ if (size) el.style.setProperty('--ovi-size', size + 'px');++ const def = registry[name];++ if (def.accent) el.style.setProperty('--ovi-accent', def.accent);++ if (def.fx && !el.hasAttribute('data-fx')) el.setAttribute('data-fx', def.fx);++ el.innerHTML = svg(name);++ if (!el.hasAttribute('role')) el.setAttribute('aria-hidden', 'true');++ applyProgress(el);++ }++++ function mount(rootEl) { (rootEl || document).querySelectorAll('.ov-icon').forEach(mountOne); }++++ function set(el, patch) {++ if (!el || !patch) return;++ if (patch.icon) el.setAttribute('data-icon', patch.icon);++ if ('state' in patch) { if (patch.state) el.setAttribute('data-state', patch.state); else el.removeAttribute('data-state'); }++ if ('progress' in patch) { if (patch.progress == null) el.removeAttribute('data-progress'); else el.setAttribute('data-progress', String(patch.progress)); }++ mountOne(el);++ }++++ function register(name, def) {++ if (!name || !def || !def.glyph) return;++ registry[String(name).toLowerCase()] = { glyph: def.glyph, fx: def.fx || null, accent: def.accent || null };++ if (typeof document !== 'undefined') document.querySelectorAll(`.ov-icon[data-icon="${name}"]`).forEach((el) => { el.removeAttribute('data-ovi'); mountOne(el); });++ }++++ const api = { names: () => Object.keys(registry), register, mount, set, svg, resolve, CSS };++ root.OpenVibeIcons = api;++ if (typeof module !== 'undefined' && module.exports) module.exports = api;++ if (typeof document === 'undefined') return;++++ function init() {++ if (!document.getElementById('ov-icons-css')) { const st = document.createElement('style'); st.id = 'ov-icons-css'; st.textContent = CSS; document.head.appendChild(st); }++ mount();++ try {++ new MutationObserver((muts) => {++ for (const m of muts) {++ if (m.type === 'attributes') { if (m.target.classList && m.target.classList.contains('ov-icon')) mountOne(m.target); continue; }++ m.addedNodes.forEach((n) => { if (n.nodeType !== 1) return; if (n.classList.contains('ov-icon')) mountOne(n); else if (n.querySelector) n.querySelectorAll('.ov-icon').forEach(mountOne); });++ }++ }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-icon', 'data-progress'] });++ } catch { /* */ }++ }++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();++})(typeof window !== 'undefined' ? window : globalThis);@@@ -48,6 +48,13 @@.ov-mark:hover .c{animation-duration:.9s}.ov-mark[data-static] svg,.ov-mark[data-static] .g,.ov-mark[data-static] .c,.ov-mark[data-static] .v,.ov-mark[data-static] .d{animation:none!important}.ov-mark[data-static] .o{display:none}++.ov-mark .p{fill:none;stroke:currentColor;stroke-width:4.4;stroke-linecap:round;transform:rotate(-90deg);transform-box:fill-box;transform-origin:center;stroke-dasharray:113.1;stroke-dashoffset:calc(113.1px * (1 - var(--ovm-p,0)));opacity:0;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s}++.ov-mark[data-progress] .p{opacity:1}++.ov-mark[data-progress] .c,.ov-mark[data-progress] .o{opacity:.25}++.ov-mark[data-state=busy] .c{animation-duration:1.1s}++.ov-mark[data-state=ok]{color:var(--success,#22c55e)!important}++.ov-mark[data-state=error]{color:var(--danger,#ef4444)!important}++.ov-mark[data-state=ok] svg{animation:ovmSpin .9s cubic-bezier(.2,1.5,.3,1) 1}@keyframes ovmFloat{0%,100%{transform:translateY(0) rotate(0deg)}50%{transform:translateY(-1px) rotate(2deg)}}@keyframes ovmGlow{0%,100%{opacity:.08;transform:scale(.92)}50%{opacity:.2;transform:scale(1.06)}}@keyframes ovmComet{to{transform:rotate(360deg)}}@@@ -61,6 +68,7 @@<linearGradient id="${id}v" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fff"/><stop offset="0.5" stop-color="currentColor"/><stop offset="1" stop-color="#fff" stop-opacity="0.9"/><animateTransform attributeName="gradientTransform" type="translate" from="-1 0" to="1 0" dur="3.2s" repeatCount="indefinite"/></linearGradient><path id="${id}p" d="M24,6 A18,18 0 1,1 23.99,6 Z"/></defs><circle class="g" cx="24" cy="24" r="21"/><circle class="r" cx="24" cy="24" r="18"/><circle class="c" cx="24" cy="24" r="18" stroke="url(#${id}g)"/>++<circle class="p" cx="24" cy="24" r="18"/><path class="v" d="M14.5,17 L24,34 L33.5,17" stroke="url(#${id}v)"/><circle class="d" cx="24" cy="34" r="2.6"/><circle class="o" r="2"><animateMotion dur="3.6s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle><circle class="o o2" r="1.4"><animateMotion dur="3.6s" begin="0.14s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle></svg>`;@@@ -12,7 +12,8 @@"./middleware": "./middleware.js","./notifications": "./notifications.js","./url-resolver": "./url-resolver.js",-- "./seo": "./seo.js"++ "./seo": "./seo.js",++ "./icons": "./ov-icons.js"},"license": "MIT","dependencies": {nnew file mode 100644@@@ -0,0 +1,18 @@++'use strict';++const assert = require('assert');++const seo = require('../seo');++const icons = require('../ov-icons');++++const head = seo.headTags({ title: 'A very long title that certainly goes past the sixty character limit for titles', description: 'd', canonical: 'https://openvibe.tools/', jsonLd: [{ '@type': 'Thing', name: '</script><script>alert(1)' }] });++assert.ok(/<title>[^<]{1,62}<\/title>/.test(head), 'title is clipped');++assert.ok(!head.includes('</script><script>alert'), 'JSON-LD cannot close its script tag');++assert.ok(seo.robotsTxt({ sitemaps: ['https://x.test/sitemap.xml'] }).includes('GPTBot'), 'AI crawlers are named');++assert.ok(!seo.sitemapXml([{ loc: '/relative' }, { loc: 'https://ok.test/' }]).includes('/relative'), 'sitemaps hold absolute URLs only');++assert.ok(seo.llmsTxt({ name: 'X', summary: 's', sections: [] }).startsWith('# X'));++++assert.ok(icons.names().length >= 40);++for (const n of icons.names()) assert.ok(/<(path|circle|rect)/.test(icons.svg(n)), `glyph: ${n}`);++assert.equal(icons.resolve('yt'), 'youtube');++assert.equal(icons.resolve('<img onerror=1>'), 'ov', 'unknown names fall back to the mark');++for (const f of ['../island.js', '../ui.js']) { const m = require(f); assert.equal(typeof (m.start || m.toast), 'function', `${f} loads without a DOM`); }++console.log('shared modules: all checks passed');nnew file mode 100644@@@ -0,0 +1,124 @@++/**++ * openvibe-shared/ui.js — one toast / confirm / alert implementation for every OpenVibe site.++ *++ * OpenVibeUI.toast('Saved', { type: 'success' })++ * OpenVibeUI.toast('Upload failed', { type: 'error', title: 'Images', action: { label: 'Retry', onClick } })++ * if (await OpenVibeUI.confirm({ title: 'Delete paste?', message: 'This cannot be undone.', danger: true })) …++ * await OpenVibeUI.alert({ title: 'Heads up', message: '…' })++ *++ * Themed from the site's CSS variables, stacked bottom-right (bottom-centre on phones), announced++ * to assistive tech (role=status, role=alert for errors), pause-on-hover, Escape closes dialogs,++ * focus is trapped in dialogs and restored afterwards. Text is always set with textContent.++ */++(function (root) {++ 'use strict';++ if (root.OpenVibeUI) return;++ if (typeof document === 'undefined') { const noop = { toast() { return { close() {} }; }, confirm: async () => false, alert: async () => {} }; if (typeof module !== 'undefined') module.exports = noop; return; }++++ const CSS = `++.ovui-toasts{position:fixed;right:16px;bottom:16px;z-index:2147483000;display:flex;flex-direction:column;gap:8px;max-width:min(380px,calc(100vw - 24px));pointer-events:none}++.ovui-toast{pointer-events:auto;display:grid;grid-template-columns:auto 1fr auto;gap:10px;align-items:start;padding:11px 12px;border-radius:12px;background:var(--bg-elevated,var(--bg-secondary,#131a26));color:var(--text-primary,#e6edf7);border:1px solid var(--border,rgba(255,255,255,.1));box-shadow:0 10px 30px rgba(0,0,0,.35);font:500 13.5px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;animation:ovuiIn .28s cubic-bezier(.2,1.2,.3,1);border-left:3px solid var(--ovui-c,var(--accent,#3b82f6))}++.ovui-toast[data-type=success]{--ovui-c:var(--success,#22c55e)}++.ovui-toast[data-type=error]{--ovui-c:var(--danger,#ef4444)}++.ovui-toast[data-type=warning]{--ovui-c:var(--warning,#f59e0b)}++.ovui-toast.is-out{animation:ovuiOut .2s ease forwards}++.ovui-dot{width:8px;height:8px;border-radius:50%;background:var(--ovui-c,var(--accent,#3b82f6));margin-top:6px}++.ovui-title{font-weight:700;margin-bottom:1px}++.ovui-msg{color:var(--text-secondary,#a8b3c4);overflow-wrap:anywhere}++.ovui-title+.ovui-msg{font-weight:400}++.ovui-toast .ovui-msg:first-child{color:inherit}++.ovui-x,.ovui-act{appearance:none;border:0;background:none;color:inherit;font:inherit;cursor:pointer;border-radius:8px}++.ovui-x{opacity:.55;padding:2px 6px;line-height:1;font-size:16px}++.ovui-x:hover{opacity:1}++.ovui-act{margin-top:6px;padding:4px 10px;font-weight:700;color:var(--accent,#3b82f6);background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent);text-decoration:none;display:inline-block}++.ovui-back{position:fixed;inset:0;z-index:2147483100;background:rgba(3,6,12,.6);backdrop-filter:blur(3px);display:grid;place-items:center;padding:16px;animation:ovuiFade .15s ease}++.ovui-dialog{width:min(420px,100%);background:var(--bg-elevated,var(--bg-secondary,#131a26));color:var(--text-primary,#e6edf7);border:1px solid var(--border,rgba(255,255,255,.1));border-radius:16px;padding:20px;box-shadow:0 24px 60px rgba(0,0,0,.5);font:400 14.5px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;animation:ovuiIn .22s cubic-bezier(.2,1.2,.3,1)}++.ovui-dialog h2{margin:0 0 6px;font-size:17px;font-weight:700}++.ovui-dialog p{margin:0;color:var(--text-secondary,#a8b3c4);white-space:pre-wrap;overflow-wrap:anywhere}++.ovui-btns{display:flex;justify-content:flex-end;gap:8px;margin-top:18px}++.ovui-btn{appearance:none;border:1px solid var(--border,rgba(255,255,255,.14));background:transparent;color:inherit;font:600 14px/1 system-ui,sans-serif;padding:10px 16px;border-radius:10px;cursor:pointer}++.ovui-btn:hover{background:rgba(127,127,127,.12)}++.ovui-btn.is-primary{background:var(--accent,#3b82f6);border-color:transparent;color:var(--on-accent,#fff)}++.ovui-btn.is-danger{background:var(--danger,#ef4444);border-color:transparent;color:#fff}++.ovui-btn:focus-visible,.ovui-x:focus-visible,.ovui-act:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}++@keyframes ovuiIn{from{opacity:0;transform:translateY(10px) scale(.97)}to{opacity:1;transform:none}}++@keyframes ovuiOut{to{opacity:0;transform:translateX(16px)}}++@keyframes ovuiFade{from{opacity:0}to{opacity:1}}++@media (max-width:560px){.ovui-toasts{left:12px;right:12px;bottom:calc(12px + env(safe-area-inset-bottom,0px));max-width:none}}++@media (prefers-reduced-motion:reduce){.ovui-toast,.ovui-dialog,.ovui-back{animation:none!important}}`;++++ function css() { if (document.getElementById('ovui-css')) return; const s = document.createElement('style'); s.id = 'ovui-css'; s.textContent = CSS; document.head.appendChild(s); }++ function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = String(text); return e; }++ function safeHref(h) { try { const u = new URL(h, location.href); return /^https?:$/.test(u.protocol) ? u.href : null; } catch { return null; } }++++ let stack = null;++ const MAX = 4;++++ function toast(message, opts) {++ const o = opts || {};++ css();++ if (!stack || !stack.isConnected) { stack = el('div', 'ovui-toasts'); stack.setAttribute('aria-live', 'polite'); document.body.appendChild(stack); }++ const type = ['info', 'success', 'error', 'warning'].includes(o.type) ? o.type : 'info';++ const t = el('div', 'ovui-toast'); t.dataset.type = type; t.setAttribute('role', type === 'error' ? 'alert' : 'status');++ t.appendChild(el('span', 'ovui-dot'));++ const body = el('div');++ if (o.title) body.appendChild(el('div', 'ovui-title', o.title));++ body.appendChild(el('div', 'ovui-msg', message));++ if (o.action && o.action.label) {++ const href = o.action.href && safeHref(o.action.href);++ const a = el(href ? 'a' : 'button', 'ovui-act', o.action.label);++ if (href) a.href = href; else a.type = 'button';++ a.addEventListener('click', (ev) => { try { o.action.onClick && o.action.onClick(ev); } finally { close(); } });++ body.appendChild(a);++ }++ t.appendChild(body);++ const x = el('button', 'ovui-x', '×'); x.type = 'button'; x.setAttribute('aria-label', 'Dismiss'); x.addEventListener('click', () => close()); t.appendChild(x);++ stack.appendChild(t);++ while (stack.children.length > MAX) stack.firstChild.remove();++++ const ttl = o.ttl === 0 ? 0 : (o.ttl || (type === 'error' ? 8000 : 4500));++ let timer = null, closed = false;++ const arm = () => { if (ttl) timer = setTimeout(close, ttl); };++ function close() { if (closed) return; closed = true; clearTimeout(timer); t.classList.add('is-out'); setTimeout(() => t.remove(), 220); }++ t.addEventListener('mouseenter', () => clearTimeout(timer)); t.addEventListener('mouseleave', arm);++ arm();++ return { close, el: t };++ }++++ function dialog(o, withCancel) {++ css();++ return new Promise((resolve) => {++ const prev = document.activeElement;++ const back = el('div', 'ovui-back');++ const d = el('div', 'ovui-dialog'); d.setAttribute('role', withCancel ? 'alertdialog' : 'dialog'); d.setAttribute('aria-modal', 'true');++ const h = el('h2', null, o.title || (withCancel ? 'Are you sure?' : 'Notice')); h.id = 'ovui-h-' + Date.now(); d.setAttribute('aria-labelledby', h.id); d.appendChild(h);++ if (o.message) d.appendChild(el('p', null, o.message));++ const btns = el('div', 'ovui-btns');++ let cancel = null;++ if (withCancel) { cancel = el('button', 'ovui-btn', o.cancelLabel || 'Cancel'); cancel.type = 'button'; btns.appendChild(cancel); }++ const ok = el('button', 'ovui-btn ' + (o.danger ? 'is-danger' : 'is-primary'), o.confirmLabel || (withCancel ? 'Confirm' : 'OK')); ok.type = 'button'; btns.appendChild(ok);++ d.appendChild(btns); back.appendChild(d); document.body.appendChild(back);++ function done(v) { document.removeEventListener('keydown', onKey, true); back.remove(); try { prev && prev.focus && prev.focus(); } catch { /* */ } resolve(v); }++ function onKey(e) {++ if (e.key === 'Escape') { e.preventDefault(); done(false); }++ else if (e.key === 'Tab') { const f = [cancel, ok].filter(Boolean); const i = f.indexOf(document.activeElement); e.preventDefault(); f[(i + (e.shiftKey ? f.length - 1 : 1)) % f.length].focus(); }++ }++ document.addEventListener('keydown', onKey, true);++ back.addEventListener('mousedown', (e) => { if (e.target === back) done(false); });++ ok.addEventListener('click', () => done(true));++ if (cancel) cancel.addEventListener('click', () => done(false));++ (o.danger && cancel ? cancel : ok).focus();++ });++ }++++ const api = {++ toast,++ success: (m, o) => toast(m, Object.assign({}, o, { type: 'success' })),++ error: (m, o) => toast(m, Object.assign({}, o, { type: 'error' })),++ warning: (m, o) => toast(m, Object.assign({}, o, { type: 'warning' })),++ confirm: (o) => dialog(typeof o === 'string' ? { message: o } : (o || {}), true),++ alert: (o) => dialog(typeof o === 'string' ? { message: o } : (o || {}), false).then(() => undefined),++ };++ root.OpenVibeUI = api;++ if (typeof module !== 'undefined' && module.exports) module.exports = api;++})(typeof window !== 'undefined' ? window : globalThis);@@@ -208,6 +208,7 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa<button class="admin-tab-btn" onclick="showTab('vkeys',this)"><i class="fa-solid fa-key"></i> Keys</button><button class="admin-tab-btn" onclick="showTab('audit',this)"><i class="fa-solid fa-clipboard-list"></i> Audit</button><button class="admin-tab-btn" onclick="showTab('analytics',this)"><i class="fa-solid fa-chart-pie"></i> Analytics</button>++ <button class="admin-tab-btn owner-only" onclick="showTab('domains',this)"><i class="fa-solid fa-globe"></i> Domains</button><button class="admin-tab-btn owner-only" onclick="showTab('deploy',this)"><i class="fa-solid fa-server"></i> Deploy</button><button class="admin-tab-btn" onclick="showTab('ssh',this)"><i class="fa-solid fa-terminal"></i> SSH</button></div>@@@ -681,6 +682,9 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa</div><!-- SSH access provisioning -->++ <div class="section" id="sec-domains">++ <div id="domains-content"><div class="loading">Loading...</div></div>++ </div><div class="section" id="sec-ssh"><div id="ssh-content"><div class="loading">Loading...</div></div></div>@@@ -769,7 +773,7 @@ let currentTab = 'dashboard';// Tabs restricted to the network owner (API keys / money / infrastructure / core settings).// NOTE: 'ai' is NOT here — admins may use its analytics/explorer tools; only the// AI configuration form inside it is owner-gated (see loadAi).--const OWNER_ONLY_TABS = new Set(['deploy', 'payments', 'cashouts', 'storage', 'email', 'settings']);++const OWNER_ONLY_TABS = new Set(['deploy', 'domains', 'payments', 'cashouts', 'storage', 'email', 'settings']);const tabLoaders = {dashboard: loadDashboard,users: loadUsers,@@@ -791,9 +795,60 @@ const tabLoaders = {audit: loadAudit,analytics: loadAnalytics,deploy: loadDeployOverview,++ domains: loadDomains,ssh: loadSSH,};++++// ── Tool domains (owner only; the server enforces it) ───────────────++let _domCatalog = null;++async function loadDomains() {++ const el = document.getElementById('domains-content');++ try {++ const [list, cat] = await Promise.all([api('/api/admin/domains'), _domCatalog ? Promise.resolve(_domCatalog) : api('/api/admin/domains/catalog')]);++ _domCatalog = cat;++ const byTool = {}; list.domains.forEach(d => { (byTool[d.tool_id] = byTool[d.tool_id] || []).push(d); });++ const opts = [...cat.families.map(f => ({ id: f.id, name: f.name + ' (family)' })), ...cat.tools.map(t => ({ id: t.id, name: t.name }))];++ const roleBadge = r => `<span class="badge ${r === 'canonical' ? 'badge-success' : r === 'short' ? 'badge-info' : ''}">${esc(r)}</span>`;++ el.innerHTML = `++ <div class="card"><h3><i class="fa-solid fa-globe"></i> Tool domains</h3>++ <p class="muted">Every tool has a <b>canonical</b> host (used in links, sitemaps and canonical tags), a <b>short</b> host people type, and any number of <b>aliases</b> that redirect to the short one. Custom domains work the same way: point DNS at the server, add the host here, then run <code>add-custom-domain.sh</code> on the host for nginx and TLS. Tools picks changes up within a minute.${cat.source !== 'live' ? ' <br><span class="badge badge-warning">Tools catalog unreachable: showing the built-in list</span>' : ''}</p>++ <div class="form-group" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;align-items:end">++ <div class="form-field"><label for="dom-tool">Tool</label><select id="dom-tool">${opts.map(o => `<option value="${esc(o.id)}">${esc(o.name)}</option>`).join('')}</select></div>++ <div class="form-field"><label for="dom-host">Host</label><input id="dom-host" placeholder="youtubedownloadonline.com" autocomplete="off" spellcheck="false"></div>++ <div class="form-field"><label for="dom-role">Role</label><select id="dom-role"><option value="alias">alias (redirects)</option><option value="short">short (what people type)</option><option value="canonical">canonical (SEO host)</option></select></div>++ <div class="form-field"><label for="dom-note">Note</label><input id="dom-note" maxlength="500"></div>++ <div><button class="btn btn-outline" onclick="checkDomainDns()">Check DNS</button> <button class="btn btn-primary" onclick="addDomain()">Add</button></div>++ </div>++ <div id="dom-check" class="muted" style="margin-top:8px"></div>++ </div>++ <div class="card"><h3>Overrides</h3>++ ${list.domains.length ? `<div style="overflow-x:auto"><table class="table" style="width:100%"><thead><tr><th>Tool</th><th>Host</th><th>Role</th><th>On</th><th>Note</th><th></th></tr></thead><tbody>++ ${list.domains.map(d => `<tr><td>${esc(d.tool_id)}${d.tool_known === false ? ' <span class="badge badge-warning">unknown tool</span>' : ''}</td>++ <td><a href="https://${esc(d.host)}/" target="_blank" rel="noopener">${esc(d.host)}</a></td><td>${roleBadge(d.role)}</td>++ <td><input type="checkbox" ${d.enabled ? 'checked' : ''} onchange="updateDomain(${d.id},{enabled:this.checked})" aria-label="Enabled"></td>++ <td>${esc(d.note || '')}</td>++ <td style="white-space:nowrap"><select onchange="updateDomain(${d.id},{role:this.value})" aria-label="Role">${['canonical','short','alias'].map(r => `<option ${r === d.role ? 'selected' : ''}>${r}</option>`).join('')}</select>++ <button class="btn btn-sm btn-danger" onclick="deleteDomain(${d.id},'${esc(d.host)}')">Remove</button></td></tr>`).join('')}++ </tbody></table></div>` : '<p class="muted">No overrides yet. Every tool is using its built-in hosts.</p>'}++ </div>++ <div class="card"><h3>Built-in hosts</h3><div style="overflow-x:auto"><table class="table" style="width:100%"><thead><tr><th>Tool</th><th>Canonical</th><th>Short</th><th>Aliases</th></tr></thead><tbody>++ ${cat.tools.map(t => `<tr><td>${esc(t.name)} <small class="muted">${esc(t.id)}</small></td><td>${esc(t.hosts?.canonical || '')}</td><td>${esc(t.hosts?.short || '')}</td><td>${esc((t.hosts?.aliases || []).join(', '))}</td></tr>`).join('')}++ </tbody></table></div></div>`;++ } catch (e) { el.innerHTML = `<div class="card"><p class="muted">Could not load domains: ${esc(e.message)}</p></div>`; }++}++async function addDomain() {++ const body = { tool_id: document.getElementById('dom-tool').value, host: document.getElementById('dom-host').value, role: document.getElementById('dom-role').value, note: document.getElementById('dom-note').value };++ try { const r = await api('/api/admin/domains', { method: 'POST', body }); toast('Domain added', 'success'); (r.warnings || []).forEach(w => toast(w, 'warning')); loadDomains(); } catch (e) { toast(e.message, 'error'); }++}++async function updateDomain(id, patch) { try { const r = await api('/api/admin/domains/' + id, { method: 'PUT', body: patch }); (r.warnings || []).forEach(w => toast(w, 'warning')); toast('Saved', 'success'); } catch (e) { toast(e.message, 'error'); } loadDomains(); }++async function deleteDomain(id, host) { if (!confirm(`Remove ${host}?`)) return; try { await api('/api/admin/domains/' + id, { method: 'DELETE' }); toast('Removed', 'success'); } catch (e) { toast(e.message, 'error'); } loadDomains(); }++async function checkDomainDns() {++ const out = document.getElementById('dom-check'); out.textContent = 'Checking…';++ try { const r = await api('/api/admin/domains/check', { method: 'POST', body: { host: document.getElementById('dom-host').value } }); const c = r.check || {};++ out.textContent = (c.status === 'ready' ? 'Ready. ' : '') + (c.message || c.status || ''); } catch (e) { out.textContent = e.message; }++}++function showTab(id, btn) {if (!tabLoaders[id]) id = 'dashboard';// Owner-only tabs (secrets / money / infra) — bounce non-owners to dashboard.@@@ -153,7 +153,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}<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, online tools, games, media, pastes and community — one account works across the entire network. Sign in once and every OpenVibe site knows you.</p>++ <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><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>@@@ -163,64 +163,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}</div></section>-- <!-- Services -->-- <section class="services">-- <h2>The Network</h2>-- <p class="lead">Every service, one identity. Sign in once, vibe everywhere.</p>-- <div class="card-grid">-- <a class="svc-card" href="https://openvibe.live" style="--card-accent:#e74c3c;">-- <div class="svc-icon"><i class="fa-solid fa-tower-broadcast"></i></div>-- <div class="svc-name">OpenVibe.Live</div>-- <div class="svc-desc">Open live streaming. Go live with OBS or your browser, chat, clip, restream, and tip with Vibes.</div>-- <span class="svc-go">openvibe.live <i class="fa-solid fa-arrow-right"></i></span>-- </a>-- <a class="svc-card" href="https://openvibe.tools" style="--card-accent:#8b5cf6;">-- <div class="svc-icon"><i class="fa-solid fa-screwdriver-wrench"></i></div>-- <div class="svc-name">OpenVibe.Tools</div>-- <div class="svc-desc">Hundreds of online tools — network diagnostics, dev utilities, converters, text, images, PDFs, and more.</div>-- <span class="svc-go">openvibe.tools <i class="fa-solid fa-arrow-right"></i></span>-- </a>-- <a class="svc-card" href="https://openvibe.games" style="--card-accent:#2ecc71;">-- <div class="svc-icon"><i class="fa-solid fa-gamepad"></i></div>-- <div class="svc-name">OpenVibe.Games</div>-- <div class="svc-desc">Play Scraplandia and community games, paint the shared canvas, climb the leaderboards, spend your OpenCoins.</div>-- <span class="svc-go">openvibe.games <i class="fa-solid fa-arrow-right"></i></span>-- </a>-- <a class="svc-card" href="https://openvibe.media" style="--card-accent:#22d3ee;">-- <div class="svc-icon"><i class="fa-solid fa-photo-film"></i></div>-- <div class="svc-name">OpenVibe.Media</div>-- <div class="svc-desc">The network's media backbone — VODs, clips, pastes, thumbnails, and file hosting for every OpenVibe app.</div>-- <span class="svc-go">openvibe.media <i class="fa-solid fa-arrow-right"></i></span>-- </a>-- <a class="svc-card" href="https://openvibe.community" style="--card-accent:#f59e0b;">-- <div class="svc-icon"><i class="fa-solid fa-people-group"></i></div>-- <div class="svc-name">OpenVibe.Community</div>-- <div class="svc-desc">Pastes, posts and the people of the network — the home of user-made things, with spaces and threads on the way.</div>-- <span class="svc-go">openvibe.community <i class="fa-solid fa-arrow-right"></i></span>-- </a>-- </div>-- </section>---- <!-- The rest of the network — rooms that exist as domains today and open as they are built -->-- <section class="services" id="network">-- <h2>More rooms in the network</h2>-- <p class="lead">Each one is a domain OpenVibe already owns, opening as it is built — same account, same themes, same navbar.</p>-- <div class="card-grid soon-grid">-- <a class="svc-card soon" href="https://openvibe.chat" style="--card-accent:#a78bfa;"><span class="soon-tag">Coming</span><div class="svc-icon"><i class="fa-solid fa-comments"></i></div><div class="svc-name">OpenVibe.Chat</div><div class="svc-desc">Global rooms, DMs, calls and TTS — one chat for every site.</div></a>-- <a class="svc-card soon" href="https://openvibe.codes" style="--card-accent:#22d3ee;"><span class="soon-tag">Coming</span><div class="svc-icon"><i class="fa-solid fa-code"></i></div><div class="svc-name">OpenVibe.Codes</div><div class="svc-desc">Developer portal: APIs, SDKs, mods and vibe-coding sessions.</div></a>-- <a class="svc-card soon" href="https://openvibe.blog" style="--card-accent:#f472b6;"><span class="soon-tag">Coming</span><div class="svc-icon"><i class="fa-solid fa-pen-nib"></i></div><div class="svc-name">OpenVibe.Blog</div><div class="svc-desc">The network's blog and yours — long-form, media-rich, yours to keep.</div></a>-- <a class="svc-card soon" href="https://openvibe.wiki" style="--card-accent:#34d399;"><span class="soon-tag">Coming</span><div class="svc-icon"><i class="fa-solid fa-book"></i></div><div class="svc-name">OpenVibe.Wiki</div><div class="svc-desc">Source-backed knowledge spaces you can build and edit together.</div></a>-- <a class="svc-card soon" href="https://openvibe.news" style="--card-accent:#fb923c;"><span class="soon-tag">Coming</span><div class="svc-icon"><i class="fa-solid fa-newspaper"></i></div><div class="svc-name">OpenVibe.News</div><div class="svc-desc">Sourced, summarised, many angles — you pick the framing.</div></a>-- <a class="svc-card soon" href="https://openvibe.reviews" style="--card-accent:#facc15;"><span class="soon-tag">Coming</span><div class="svc-icon"><i class="fa-solid fa-star-half-stroke"></i></div><div class="svc-name">OpenVibe.Reviews</div><div class="svc-desc">What people actually say about a thing, across sources, with provenance.</div></a>-- <a class="svc-card soon" href="https://openvibe.tips" style="--card-accent:#4ade80;"><span class="soon-tag">Coming</span><div class="svc-icon"><i class="fa-solid fa-hand-holding-heart"></i></div><div class="svc-name">OpenVibe.Tips</div><div class="svc-desc">Tips, goals, alerts and paid interactions for creators.</div></a>-- <a class="svc-card soon" href="https://openvibe.vip" style="--card-accent:#e879f9;"><span class="soon-tag">Coming</span><div class="svc-icon"><i class="fa-solid fa-crown"></i></div><div class="svc-name">OpenVibe.VIP</div><div class="svc-desc">Memberships and perks — support the creators and the network.</div></a>-- <a class="svc-card soon" href="https://openvibe.trade" style="--card-accent:#60a5fa;"><span class="soon-tag">Coming</span><div class="svc-icon"><i class="fa-solid fa-chart-line"></i></div><div class="svc-name">OpenVibe.Trade</div><div class="svc-desc">Watchlists, signals and community market intelligence.</div></a>-- <a class="svc-card soon" href="https://openvibe.deals" style="--card-accent:#f97316;"><span class="soon-tag">Coming</span><div class="svc-icon"><i class="fa-solid fa-tags"></i></div><div class="svc-name">OpenVibe.Deals</div><div class="svc-desc">Deals worth seeing, voted on by people, with alerts.</div></a>-- <a class="svc-card soon" href="https://openvibe.coupons" style="--card-accent:#fbbf24;"><span class="soon-tag">Coming</span><div class="svc-icon"><i class="fa-solid fa-ticket"></i></div><div class="svc-name">OpenVibe.Coupons</div><div class="svc-desc">Codes that actually work, checked by the community.</div></a>-- <a class="svc-card soon" href="https://openvibe.host" style="--card-accent:#94a3b8;"><span class="soon-tag">Coming</span><div class="svc-icon"><i class="fa-solid fa-server"></i></div><div class="svc-name">OpenVibe.Host</div><div class="svc-desc">Hosting and deployment for OpenVibe services, bots and mods.</div></a>-- <a class="svc-card soon" href="https://openre.stream" style="--card-accent:#ef4444;"><span class="soon-tag">Coming</span><div class="svc-icon"><i class="fa-solid fa-satellite-dish"></i></div><div class="svc-name">OpenRe.Stream</div><div class="svc-desc">Ingest and restream infrastructure — RTMP, WHIP, SRT — behind OpenVibe.Live.</div></a>-- </div>-- </section>++ <!--OV:HOME--><!-- SSO strip --><section class="sso-strip">nnew file mode 100644@@@ -0,0 +1,88 @@++'use strict';++// Server-side client for the Tools catalog (docs/shared-contracts.md §1).++// One cached copy per process: refreshed every 5 minutes, the last good copy kept when Tools is++// unreachable, and a built-in family list when there has never been a good copy.++const FALLBACK = require('./fallback-catalog');++++const TTL_MS = 5 * 60_000;++const RETRY_MS = 30_000; // after a failure, do not hammer Tools on every request++const TIMEOUT_MS = 4000;++++let state = { catalog: null, fetchedAt: 0, failedAt: 0, error: null };++let inflight = null;++let fetchImpl = (...a) => fetch(...a);++const listeners = new Set();++++function catalogUrl() {++ return `${(process.env.OV_TOOLS_INTERNAL_URL || 'http://127.0.0.1:4001').replace(/\/+$/, '')}/api/catalog.json`;++}++++function normalise(raw) {++ if (!raw || !Array.isArray(raw.families) || !Array.isArray(raw.tools)) throw new Error('Catalog has no families/tools');++ const str = (v, max) => (typeof v === 'string' ? v.slice(0, max) : '');++ const families = raw.families.filter(f => f && typeof f.id === 'string' && f.name).map(f => ({++ id: str(f.id, 40), name: str(f.name, 80), tagline: str(f.tagline, 160), description: str(f.description, 600),++ icon: str(f.icon, 40), url: str(f.url, 300), path: str(f.path, 120),++ }));++ const tools = raw.tools.filter(t => t && typeof t.id === 'string' && t.name).map(t => ({++ id: str(t.id, 40), family: str(t.family, 40), name: str(t.name, 80), tagline: str(t.tagline, 160),++ description: str(t.description, 600), icon: str(t.icon, 40),++ keywords: Array.isArray(t.keywords) ? t.keywords.filter(k => typeof k === 'string').slice(0, 20).map(k => k.slice(0, 60)) : [],++ hosts: {++ canonical: str(t.hosts && t.hosts.canonical, 253), short: str(t.hosts && t.hosts.short, 253),++ aliases: Array.isArray(t.hosts && t.hosts.aliases) ? t.hosts.aliases.filter(h => typeof h === 'string').slice(0, 20) : [],++ },++ url: str(t.url, 300),++ }));++ if (!families.length) throw new Error('Catalog has no families');++ return { updated: str(raw.updated, 40) || new Date().toISOString(), families, tools };++}++++async function refresh() {++ if (inflight) return inflight;++ inflight = (async () => {++ try {++ const res = await fetchImpl(catalogUrl(), { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(TIMEOUT_MS) });++ if (!res.ok) throw new Error(`Tools catalog answered ${res.status}`);++ const next = normalise(await res.json());++ const changed = !state.catalog || state.catalog.updated !== next.updated++ || state.catalog.tools.length !== next.tools.length || state.catalog.families.length !== next.families.length;++ state = { catalog: next, fetchedAt: Date.now(), failedAt: 0, error: null };++ if (changed) for (const fn of listeners) { try { fn(next); } catch { /* listener's problem */ } }++ } catch (err) {++ state = { ...state, failedAt: Date.now(), error: err.message };++ } finally { inflight = null; }++ return state;++ })();++ return inflight;++}++++/**++ * The catalog to use right now.++ * @returns {Promise<{ catalog: object, source: 'live'|'stale'|'fallback', error: string|null }>}++ */++async function getCatalog() {++ const now = Date.now();++ const fresh = state.catalog && now - state.fetchedAt < TTL_MS;++ const backingOff = state.failedAt && now - state.failedAt < RETRY_MS;++ if (!fresh && !backingOff) await refresh();++ return peek();++}++++/** Same answer without touching the network. */++function peek() {++ if (state.catalog) {++ const live = !state.failedAt && Date.now() - state.fetchedAt < TTL_MS * 2;++ return { catalog: state.catalog, source: live ? 'live' : 'stale', error: state.error };++ }++ return { catalog: FALLBACK, source: 'fallback', error: state.error };++}++++/** fn(catalog) runs whenever a refresh brings a different catalog. Returns an unsubscribe. */++function onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); }++++// Tests swap the transport and reset the cache.++function _setFetch(fn) { fetchImpl = fn || ((...a) => fetch(...a)); }++function _reset() { state = { catalog: null, fetchedAt: 0, failedAt: 0, error: null }; inflight = null; }++++module.exports = { getCatalog, peek, refresh, onChange, catalogUrl, normalise, FALLBACK, _setFetch, _reset };nnew file mode 100644@@@ -0,0 +1,73 @@++'use strict';++// "Is this custom domain ready?" — resolves the domain and compares it with where this server++// answers. The expected addresses come from PUBLIC_IP / MEDIASOUP_ANNOUNCED_IP, or from whatever++// openvibe.tools resolves to when neither is set.++const dns = require('dns').promises;++const net = require('net');++++const TIMEOUT_MS = 5000;++// Cloudflare's published IPv4 ranges: a proxied domain resolves to these, not to the origin.++const CF_V4 = ['173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22', '141.101.64.0/18', '108.162.192.0/18',++ '190.93.240.0/20', '188.114.96.0/20', '197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13', '104.24.0.0/14',++ '172.64.0.0/13', '131.0.72.0/22'];++const CF_V6 = ['2400:cb00:', '2606:4700:', '2803:f800:', '2405:b500:', '2405:8100:', '2a06:98c', '2c0f:f248:'];++++function v4ToInt(ip) { return ip.split('.').reduce((n, o) => (n * 256) + Number(o), 0); }++function isCloudflare(ip) {++ if (net.isIPv4(ip)) {++ const n = v4ToInt(ip);++ return CF_V4.some(cidr => { const [base, bits] = cidr.split('/'); const size = 2 ** (32 - Number(bits)); const b = v4ToInt(base); return n >= b && n < b + size; });++ }++ const low = String(ip).toLowerCase();++ return CF_V6.some(p => low.startsWith(p));++}++++function withTimeout(promise) {++ let timer;++ return Promise.race([++ promise,++ new Promise((_, reject) => { timer = setTimeout(() => reject(Object.assign(new Error('DNS lookup timed out'), { code: 'ETIMEOUT' })), TIMEOUT_MS); }),++ ]).finally(() => clearTimeout(timer));++}++++async function resolveAll(host, resolver = dns) {++ const grab = async (fn) => {++ try { return { records: await withTimeout(fn()), error: null }; }++ catch (err) { return { records: [], error: ['ENODATA', 'ENOTFOUND', 'ENODOMAIN'].includes(err.code) ? null : (err.code || err.message) }; }++ };++ const [a, aaaa, cname] = await Promise.all([grab(() => resolver.resolve4(host)), grab(() => resolver.resolve6(host)), grab(() => resolver.resolveCname(host))]);++ return { a: a.records, aaaa: aaaa.records, cname: cname.records, error: a.error || aaaa.error || null };++}++++async function expectedAddresses(env = process.env, resolver = dns) {++ const fromEnv = [env.PUBLIC_IP, env.MEDIASOUP_ANNOUNCED_IP].map(v => String(v || '').trim()).filter(v => net.isIP(v));++ if (fromEnv.length) return { addresses: [...new Set(fromEnv)], source: 'env' };++ const ref = await resolveAll('openvibe.tools', resolver);++ return { addresses: [...ref.a, ...ref.aaaa], source: 'openvibe.tools' };++}++++/**++ * @returns {Promise<{ host, status: 'ready'|'proxied'|'mismatch'|'no_records'|'error', points_here, a, aaaa, cname, expected, expected_source, message }>}++ */++async function checkDomain(host, { env = process.env, resolver = dns } = {}) {++ const [found, expected] = await Promise.all([resolveAll(host, resolver), expectedAddresses(env, resolver)]);++ const addrs = [...found.a, ...found.aaaa];++ const base = { host, a: found.a, aaaa: found.aaaa, cname: found.cname, expected: expected.addresses, expected_source: expected.source };++ if (!addrs.length) {++ if (found.error) return { ...base, status: 'error', points_here: false, message: `DNS lookup failed (${found.error})` };++ return { ...base, status: 'no_records', points_here: false, message: 'No A or AAAA record yet. Add one pointing at this server, then check again.' };++ }++ const want = new Set(expected.addresses);++ const hit = addrs.filter(ip => want.has(ip));++ // Every address family the domain publishes has to land here: an A record that is right next++ // to an AAAA that points elsewhere still sends half the visitors to the wrong machine.++ const v4ok = !found.a.length || found.a.some(ip => want.has(ip));++ const v6ok = !found.aaaa.length || found.aaaa.some(ip => want.has(ip)) || !expected.addresses.some(net.isIPv6);++ if (hit.length && v4ok && v6ok) return { ...base, status: 'ready', points_here: true, message: `Points at this server (${hit.join(', ')}).` };++ if (addrs.some(isCloudflare)) {++ return { ...base, status: 'proxied', points_here: null, message: 'Resolves to Cloudflare. The proxy hides the origin, so this check cannot see where it lands — make sure the Cloudflare DNS record targets this server.' };++ }++ return { ...base, status: 'mismatch', points_here: false, message: `Resolves to ${addrs.join(', ')}, not to this server (${expected.addresses.join(', ') || 'address unknown'}).` };++}++++module.exports = { checkDomain, expectedAddresses, resolveAll, isCloudflare };nnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++// Built-in copy of the tool families, used only when the Tools catalog has never been reachable++// since this process started. Deliberately short: every family and its best-known tools, all on++// hosts that exist today. The live catalog (openvibe.tools/api/catalog.json) replaces it.++const Z = 'openvibe.tools';++const tool = (family, id, name, tagline, icon, host) => ({++ id, family, name, tagline, description: '', keywords: [], icon,++ hosts: { canonical: `${host || id}.${Z}`, short: '', aliases: [] },++ url: `https://${host || id}.${Z}/`,++});++const family = (id, name, tagline, icon, host, path) => ({++ id, name, tagline, description: '', icon, url: host ? `https://${host}.${Z}/` : `https://${Z}${path}`, path,++});++++module.exports = {++ updated: '2026-09-18T00:00:00Z',++ builtin: true,++ families: [++ family('net', 'Network Tools', 'Look up, test and debug anything on the internet', 'network', 'net', '/network-tools'),++ family('dev', 'Developer Tools', 'Formatters, encoders, validators and generators', 'code', 'dev', '/developer-tools'),++ family('img', 'Image Tools', 'Convert, resize, compress and clean up images', 'image', 'img', '/image-tools'),++ family('audio', 'Audio Tools', 'Convert, trim and reshape audio', 'audio', 'audio', '/audio-tools'),++ family('docs', 'PDF & Document Tools', 'Merge, split, protect and convert PDFs and documents', 'pdf', 'docs', '/pdf-tools'),++ family('text', 'Text & Logo Tools', 'Fancy text, case and count tools, wordmarks and badges', 'text', 'text', '/text-tools'),++ family('media', 'Video Tools', 'Save and convert video', 'youtube', null, '/video-tools'),++ family('maps', 'Maps & Food', 'Maps, places and what to eat', 'map', 'maps', '/maps-and-food'),++ ],++ tools: [++ tool('net', 'dns', 'DNS Lookup', 'Every record type for any domain', 'dns'),++ tool('net', 'whois', 'Whois Lookup', 'Who registered a domain, and when', 'whois'),++ tool('net', 'myip', 'What Is My IP', 'Your public address and what it reveals', 'ip'),++ tool('net', 'ssl', 'SSL Certificate Checker', 'Expiry, issuer and chain of any certificate', 'ssl'),++ tool('net', 'ping', 'Ping Test', 'Reachability and round-trip time', 'ping'),++ tool('net', 'traceroute', 'Traceroute', 'Every hop between us and a host', 'network'),++ tool('net', 'port', 'Port Checker', 'Is a port open from the outside', 'network'),++ tool('net', 'headers', 'HTTP Header Checker', 'The response headers a URL sends', 'network'),++++ tool('dev', 'jsonfmt', 'JSON Formatter', 'Format, validate and minify JSON', 'json'),++ tool('dev', 'base64', 'Base64 Encoder & Decoder', 'Text to Base64 and back', 'code'),++ tool('dev', 'jwt', 'JWT Decoder', 'Read a token\'s header, claims and expiry', 'code'),++ tool('dev', 'regex', 'Regex Tester', 'Try a pattern against real text', 'code'),++ tool('dev', 'uuid', 'UUID Generator', 'Random v4 identifiers, one or many', 'code'),++ tool('dev', 'hash', 'Hash Generator', 'SHA-1, SHA-256 and SHA-512 of any text', 'code'),++ tool('dev', 'timestamp', 'Unix Timestamp Converter', 'Epoch seconds to dates and back', 'code'),++ tool('dev', 'pastes', 'Pastes', 'Share code and text with a link', 'paste'),++++ tool('img', 'convert', 'Image Converter', 'Between PNG, JPG, WebP, HEIC, TIFF and SVG', 'image'),++ tool('img', 'resize', 'Image Resizer', 'Exact pixels or a percentage', 'image'),++ tool('img', 'compress', 'Image Compressor', 'Smaller files that look the same', 'image'),++ tool('img', 'crop', 'Image Cropper', 'Cut to a shape or an aspect ratio', 'image'),++ tool('img', 'png', 'PNG Converter', 'Any image to PNG', 'image'),++ tool('img', 'webp', 'WebP Converter', 'To and from WebP', 'image'),++ tool('img', 'heic', 'HEIC Converter', 'iPhone photos to JPG or PNG', 'image'),++ tool('img', 'favicon', 'Favicon Generator', 'Every icon size a site needs', 'image'),++++ tool('audio', 'mp3', 'MP3 Converter', 'Any audio file to MP3', 'audio'),++ tool('audio', 'wav', 'WAV Converter', 'Any audio file to WAV', 'audio'),++ tool('audio', 'trim', 'Audio Trimmer', 'Cut a clip out of a recording', 'audio'),++ tool('audio', 'speed', 'Audio Speed Changer', 'Play a track faster or slower', 'audio'),++ tool('audio', 'pitch', 'Pitch Shifter', 'Move a track up or down in key', 'audio'),++ tool('audio', 'vocal', 'Vocal Remover', 'Split vocals from the instrumental', 'audio'),++ tool('audio', 'normalize', 'Audio Normalizer', 'Even out the loudness', 'audio'),++++ tool('docs', 'mergepdf', 'Merge PDF', 'Several PDFs into one', 'pdf'),++ tool('docs', 'splitpdf', 'Split PDF', 'Pull pages out of a PDF', 'pdf'),++ tool('docs', 'rotatepdf', 'Rotate PDF', 'Turn pages the right way up', 'pdf'),++ tool('docs', 'protectpdf', 'Protect PDF', 'Put a password on a PDF', 'pdf'),++ tool('docs', 'watermarkpdf', 'Watermark PDF', 'Stamp every page', 'pdf'),++ tool('docs', 'pdf2jpg', 'PDF to JPG', 'Every page as an image', 'pdf'),++++ tool('text', 'fancy', 'Fancy Text', 'Unicode styles you can paste anywhere', 'text'),++ tool('text', 'case', 'Case Converter', 'UPPER, lower, Title and more', 'text'),++ tool('text', 'count', 'Word Counter', 'Words, characters and reading time', 'text'),++ tool('text', 'symbols', 'Symbols', 'Copy-and-paste symbols and arrows', 'text'),++ tool('text', 'kaomoji', 'Kaomoji', 'Japanese emoticons, ready to copy', 'text'),++ tool('text', 'wordmark', 'Wordmark Maker', 'A text logo in a minute', 'logo'),++ tool('text', 'logo', 'Logo Maker', 'A mark and a name, exported clean', 'logo'),++ tool('text', 'badge', 'Badge Maker', 'Badges and stickers from text', 'logo'),++ tool('text', 'thumbnail', 'Thumbnail Maker', 'Covers and channel art from text', 'logo'),++++ tool('media', 'yt', 'YouTube Downloader', 'Save videos and audio from YouTube', 'youtube'),++++ tool('maps', 'maps', 'Maps', 'Find places and get around', 'map'),++ tool('maps', 'food', 'Food', 'What to eat, and where', 'food'),++ ],++};nnew file mode 100644@@@ -0,0 +1,258 @@++'use strict';++// ═══════════════════════════════════════════════════════════════++// Tool domain registry (docs/shared-contracts.md §1).++//++// OpenVibe.Tools owns the catalog of tools and each tool's default hosts. This table holds the++// owner's overrides: "this host is the canonical / short / alias host of that tool" — including++// custom domains that are not under openvibe.tools at all. Tools reads the public list++// server-side and merges it over its code defaults.++//++// GET /api/domains public, CORS *, max-age=60 — enabled rows only++// GET /api/admin/domains owner — every row + catalog status++// POST /api/admin/domains owner — { tool_id, host, role, enabled?, note? }++// PUT /api/admin/domains/:id owner — any of the same fields++// DELETE /api/admin/domains/:id owner++// GET /api/admin/domains/catalog owner — tools + families for the admin pickers++// POST /api/admin/domains/check owner — { host } → DNS readiness++//++// Invariants (enforced here, inside a transaction): a host belongs to exactly one tool; a tool has++// at most one enabled canonical and one enabled short — setting a new one demotes the previous to++// alias. Every change lands in audit_log.++// ═══════════════════════════════════════════════════════════════++const express = require('express');++const { isOwner } = require('../auth/owner-guard');++const { ROLES, validateHost, validToolIdSyntax } = require('./validate');++const toolsCatalog = require('./catalog');++const dnsCheck = require('./dns-check');++++const NOTE_MAX = 500;++++function ensureSchema(db) {++ db.exec(`++ CREATE TABLE IF NOT EXISTS tool_domains (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ tool_id TEXT NOT NULL,++ host TEXT NOT NULL UNIQUE,++ role TEXT NOT NULL DEFAULT 'alias' CHECK (role IN ('canonical', 'short', 'alias')),++ enabled INTEGER NOT NULL DEFAULT 1,++ note TEXT,++ created_by INTEGER,++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP++ );++ CREATE INDEX IF NOT EXISTS idx_tool_domains_tool ON tool_domains(tool_id, role, enabled);++ `);++}++++function iso(sqliteTime) {++ if (!sqliteTime) return null;++ const d = new Date(String(sqliteTime).replace(' ', 'T') + (/[zZ]|[+-]\d\d:?\d\d$/.test(sqliteTime) ? '' : 'Z'));++ return isNaN(d) ? null : d.toISOString();++}++++/** Which catalog tool already answers on this host by default (so it cannot be given to another). */++function catalogOwnerOf(host, catalog) {++ for (const t of catalog.tools) {++ const hosts = [t.hosts.canonical, t.hosts.short, ...(t.hosts.aliases || [])];++ try { if (t.url) hosts.push(new URL(t.url).hostname); } catch { /* */ }++ if (hosts.includes(host)) return t.id;++ }++ return null;++}++++function createDomainRoutes(db, requireAuth, opts = {}) {++ ensureSchema(db);++ const catalogSource = opts.catalog || toolsCatalog;++ const checkDomain = opts.checkDomain || dnsCheck.checkDomain;++ const onChange = typeof opts.onChange === 'function' ? opts.onChange : () => {};++++ const q = {++ publicList: db.prepare(`SELECT tool_id, host, role FROM tool_domains WHERE enabled = 1++ ORDER BY tool_id, CASE role WHEN 'canonical' THEN 0 WHEN 'short' THEN 1 ELSE 2 END, host`),++ lastChange: db.prepare('SELECT MAX(updated_at) AS t FROM tool_domains'),++ all: db.prepare(`SELECT d.*, u.username AS created_by_username FROM tool_domains d LEFT JOIN users u ON u.id = d.created_by++ ORDER BY d.tool_id, CASE d.role WHEN 'canonical' THEN 0 WHEN 'short' THEN 1 ELSE 2 END, d.host`),++ byId: db.prepare('SELECT * FROM tool_domains WHERE id = ?'),++ byHost: db.prepare('SELECT * FROM tool_domains WHERE host = ?'),++ holders: db.prepare('SELECT * FROM tool_domains WHERE tool_id = ? AND role = ? AND enabled = 1 AND id != ?'),++ demote: db.prepare("UPDATE tool_domains SET role = 'alias', updated_at = CURRENT_TIMESTAMP WHERE id = ?"),++ insert: db.prepare('INSERT INTO tool_domains (tool_id, host, role, enabled, note, created_by) VALUES (?, ?, ?, ?, ?, ?)'),++ update: db.prepare('UPDATE tool_domains SET tool_id = ?, host = ?, role = ?, enabled = ?, note = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'),++ remove: db.prepare('DELETE FROM tool_domains WHERE id = ?'),++ };++++ function audit(req, action, details) {++ try {++ db.prepare('INSERT INTO audit_log (user_id, action, details, ip) VALUES (?, ?, ?, ?)').run(req.user.id, action, JSON.stringify(details), req.ip || null);++ } catch (err) {++ console.error('[Domains] Audit log error:', err.message);++ }++ }++++ const present = (row) => row && ({++ id: row.id, tool_id: row.tool_id, host: row.host, role: row.role, enabled: !!row.enabled, note: row.note || '',++ created_by: row.created_by, created_by_username: row.created_by_username || null,++ created_at: iso(row.created_at), updated_at: iso(row.updated_at),++ });++++ /**++ * Validate a proposed row. `current` is the row being edited (null on create).++ * @returns {Promise<{ error?: string, status?: number, value?: object, warnings: string[] }>}++ */++ async function validate(body, current) {++ const warnings = [];++ const next = {++ tool_id: body.tool_id !== undefined ? String(body.tool_id).trim().toLowerCase() : current && current.tool_id,++ host: body.host !== undefined ? body.host : current && current.host,++ role: body.role !== undefined ? String(body.role).trim().toLowerCase() : (current ? current.role : 'alias'),++ enabled: body.enabled !== undefined ? (body.enabled === true || body.enabled === 1 || body.enabled === '1' || body.enabled === 'true' ? 1 : 0) : (current ? current.enabled : 1),++ note: body.note !== undefined ? String(body.note == null ? '' : body.note).trim().slice(0, NOTE_MAX) : (current ? current.note : ''),++ };++ if (!ROLES.includes(next.role)) return { status: 400, error: `role must be one of ${ROLES.join(', ')}`, warnings };++ if (!validToolIdSyntax(next.tool_id)) return { status: 400, error: 'tool_id must be 1–40 characters of a–z, 0–9 and hyphen', warnings };++++ const h = validateHost(next.host);++ if (!h.ok) return { status: 400, error: h.error, warnings };++ next.host = h.host;++++ const { catalog, source } = await catalogSource.getCatalog();++ let toolVerified = false;++ if (source === 'fallback') {++ warnings.push('The Tools catalog is unreachable, so the tool id could not be verified. Check it again once Tools is back.');++ } else {++ const known = catalog.tools.some(t => t.id === next.tool_id) || catalog.families.some(f => f.id === next.tool_id);++ if (!known) return { status: 400, error: `"${next.tool_id}" is not a tool in the Tools catalog`, warnings };++ toolVerified = true;++ const owner = catalogOwnerOf(next.host, catalog);++ if (owner && owner !== next.tool_id) return { status: 409, error: `${next.host} already belongs to the tool "${owner}" in the Tools catalog`, warnings };++ }++++ const taken = q.byHost.get(next.host);++ if (taken && (!current || taken.id !== current.id)) {++ return { status: 409, error: `${next.host} is already registered for the tool "${taken.tool_id}" — a host belongs to one tool`, warnings };++ }++ return { value: next, toolVerified, warnings };++ }++++ /** Write inside one transaction; returns the row plus whatever was demoted to make room. */++ const write = db.transaction((next, current, userId) => {++ const demoted = [];++ if (next.enabled && next.role !== 'alias') {++ for (const row of q.holders.all(next.tool_id, next.role, current ? current.id : 0)) {++ q.demote.run(row.id);++ demoted.push({ id: row.id, host: row.host, from: row.role });++ }++ }++ let id;++ if (current) { q.update.run(next.tool_id, next.host, next.role, next.enabled, next.note, current.id); id = current.id; }++ else id = q.insert.run(next.tool_id, next.host, next.role, next.enabled, next.note, userId).lastInsertRowid;++ return { row: q.byId.get(id), demoted };++ });++++ // ── public ───────────────────────────────────────────────++ const publicRouter = express.Router();++ publicRouter.use((req, res, next) => {++ res.set('Access-Control-Allow-Origin', '*');++ res.set('Cross-Origin-Resource-Policy', 'cross-origin');++ if (req.method === 'OPTIONS') {++ res.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');++ res.set('Access-Control-Max-Age', '86400');++ return res.status(204).end();++ }++ next();++ });++ publicRouter.get('/', (_req, res) => {++ try {++ const domains = q.publicList.all();++ res.set('Cache-Control', 'public, max-age=60');++ res.json({ updated: iso(q.lastChange.get().t) || new Date(0).toISOString(), domains });++ } catch (err) {++ console.error('[Domains] public list:', err.message);++ res.status(500).json({ error: 'Could not read the domain registry' });++ }++ });++++ // ── owner-only admin ─────────────────────────────────────++ const adminRouter = express.Router();++ adminRouter.use(requireAuth, (req, res, next) => {++ if (!req.user || req.user.role !== 'admin' || !isOwner(req.user)) {++ return res.status(403).json({ ok: false, error: 'Owner access required' });++ }++ res.set('Cache-Control', 'no-store');++ next();++ });++++ adminRouter.get('/', async (_req, res) => {++ try {++ const { catalog, source } = await catalogSource.getCatalog();++ const known = new Set([...catalog.tools.map(t => t.id), ...catalog.families.map(f => f.id)]);++ const rows = q.all.all().map(r => ({ ...present(r), tool_known: source === 'fallback' ? null : known.has(r.tool_id) }));++ res.json({ ok: true, domains: rows, catalog_source: source });++ } catch (err) { res.status(500).json({ ok: false, error: err.message }); }++ });++++ adminRouter.get('/catalog', async (_req, res) => {++ try {++ const { catalog, source, error } = await catalogSource.getCatalog();++ res.json({++ ok: true, source, error: source === 'live' ? null : error, updated: catalog.updated,++ families: catalog.families.map(f => ({ id: f.id, name: f.name, icon: f.icon, url: f.url })),++ tools: catalog.tools.map(t => ({ id: t.id, family: t.family, name: t.name, icon: t.icon, hosts: t.hosts, url: t.url })),++ });++ } catch (err) { res.status(500).json({ ok: false, error: err.message }); }++ });++++ adminRouter.post('/check', async (req, res) => {++ const h = validateHost(req.body && req.body.host);++ if (!h.ok) return res.status(400).json({ ok: false, error: h.error });++ try { res.json({ ok: true, check: await checkDomain(h.host) }); }++ catch (err) { res.status(502).json({ ok: false, error: `DNS check failed: ${err.message}` }); }++ });++++ adminRouter.post('/', async (req, res) => {++ try {++ const v = await validate(req.body || {}, null);++ if (v.error) return res.status(v.status).json({ ok: false, error: v.error, warnings: v.warnings });++ const { row, demoted } = write(v.value, null, req.user.id);++ audit(req, 'tool_domain_create', { id: row.id, tool_id: row.tool_id, host: row.host, role: row.role, enabled: !!row.enabled, demoted, tool_unverified: !v.toolVerified || undefined });++ onChange();++ res.status(201).json({ ok: true, domain: present(row), demoted, tool_verified: v.toolVerified, warnings: v.warnings });++ } catch (err) {++ if (/UNIQUE/i.test(err.message)) return res.status(409).json({ ok: false, error: 'That host is already registered' });++ res.status(500).json({ ok: false, error: err.message });++ }++ });++++ adminRouter.put('/:id', async (req, res) => {++ try {++ const current = q.byId.get(Number(req.params.id));++ if (!current) return res.status(404).json({ ok: false, error: 'Domain not found' });++ const v = await validate(req.body || {}, current);++ if (v.error) return res.status(v.status).json({ ok: false, error: v.error, warnings: v.warnings });++ const { row, demoted } = write(v.value, current, req.user.id);++ const changed = {};++ for (const k of ['tool_id', 'host', 'role', 'enabled', 'note']) if (String(current[k] ?? '') !== String(row[k] ?? '')) changed[k] = { from: current[k], to: row[k] };++ audit(req, 'tool_domain_update', { id: row.id, host: row.host, tool_id: row.tool_id, changed, demoted, tool_unverified: !v.toolVerified || undefined });++ onChange();++ res.json({ ok: true, domain: present(row), demoted, tool_verified: v.toolVerified, warnings: v.warnings });++ } catch (err) {++ if (/UNIQUE/i.test(err.message)) return res.status(409).json({ ok: false, error: 'That host is already registered' });++ res.status(500).json({ ok: false, error: err.message });++ }++ });++++ adminRouter.delete('/:id', (req, res) => {++ try {++ const current = q.byId.get(Number(req.params.id));++ if (!current) return res.status(404).json({ ok: false, error: 'Domain not found' });++ q.remove.run(current.id);++ audit(req, 'tool_domain_delete', { id: current.id, tool_id: current.tool_id, host: current.host, role: current.role });++ onChange();++ res.json({ ok: true });++ } catch (err) { res.status(500).json({ ok: false, error: err.message }); }++ });++++ return { publicRouter, adminRouter };++}++++module.exports = { createDomainRoutes, ensureSchema, catalogOwnerOf };nnew file mode 100644@@@ -0,0 +1,57 @@++'use strict';++// Hostname rules for the tool-domain registry (docs/shared-contracts.md §1).++const net = require('net');++const { OWNED_ZONES } = require('../auth/sso-owned');++++const ROLES = ['canonical', 'short', 'alias'];++const TOOLS_ZONE = 'openvibe.tools';++// Hosts under openvibe.tools that are the platform itself, never a tool.++const RESERVED_TOOLS_SUBS = new Set(['www', 'api', 'admin', 'static', 'cdn', 'mail']);++++/**++ * Normalise and validate a hostname. Input is what the owner typed: it is trimmed, lowercased++ * and a trailing dot dropped, but a scheme, path, port, wildcard or IP is an error rather than++ * something silently stripped — the row is what nginx and the gateway will match on.++ * @returns {{ ok: true, host: string } | { ok: false, error: string }}++ */++function validateHost(raw) {++ let host = String(raw == null ? '' : raw).trim().toLowerCase();++ if (!host) return { ok: false, error: 'Hostname is required' };++ if (/^[a-z][a-z0-9+.-]*:\/\//.test(host)) return { ok: false, error: 'Enter the hostname only, without http:// or https://' };++ if (/[/?#@\s]/.test(host)) return { ok: false, error: 'Enter the hostname only, without a path, query or spaces' };++ if (host.endsWith('.')) host = host.slice(0, -1);++ if (net.isIP(host) || net.isIP(host.replace(/^\[|\]$/g, '')) || /^\d+(\.\d+){3}$/.test(host)) return { ok: false, error: 'An IP address is not a domain' };++ if (host.includes(':')) return { ok: false, error: 'Enter the hostname only, without a port' };++ if (host.length > 253) return { ok: false, error: 'Hostname is longer than 253 characters' };++ const labels = host.split('.');++ if (labels.length < 2) return { ok: false, error: 'Hostname needs a domain and a TLD (example.com)' };++ for (const label of labels) {++ if (!label) return { ok: false, error: 'Hostname has an empty label (two dots in a row)' };++ if (label.length > 63) return { ok: false, error: 'A hostname label is longer than 63 characters' };++ if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(label)) return { ok: false, error: `"${label}" is not a valid hostname label (letters, digits and inner hyphens only)` };++ }++ const tld = labels[labels.length - 1];++ if (/^\d+$/.test(tld)) return { ok: false, error: 'The top-level domain cannot be numeric' };++ if (tld === 'localhost' || tld === 'local' || tld === 'internal' || tld === 'invalid' || tld === 'test') return { ok: false, error: 'That is not a public domain' };++++ const core = coreHostReason(host);++ if (core) return { ok: false, error: core };++ return { ok: true, host };++}++++/** Why a host is one of the network's own, or null when it is free to be a tool host. */++function coreHostReason(host) {++ for (const zone of OWNED_ZONES) {++ if (host === zone || host === 'www.' + zone) return `${host} is one of the network's own sites and cannot be a tool domain`;++ if (host.endsWith('.' + zone)) {++ if (zone !== TOOLS_ZONE) return `${host} is inside ${zone}, which belongs to another OpenVibe site`;++ const sub = host.slice(0, -(zone.length + 1));++ if (RESERVED_TOOLS_SUBS.has(sub)) return `${host} is reserved by the Tools platform`;++ }++ }++ return null;++}++++function validToolIdSyntax(id) { return /^[a-z0-9-]{1,40}$/.test(String(id || '')); }++++module.exports = { ROLES, validateHost, coreHostReason, validToolIdSyntax };nnew file mode 100644@@@ -0,0 +1,101 @@++'use strict';++// The openvibe.network home page: the static shell (public/index.html) with the network and the++// tool catalog rendered into it on the server, so the page is complete without JavaScript and++// always matches what openvibe.tools actually offers (catalog via server/domains/catalog.js).++const fs = require('fs');++const path = require('path');++const crypto = require('crypto');++const seo = require('openvibe-shared/seo');++const icons = require('openvibe-shared/icons');++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 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.'],++ ['tools', 'OpenVibe.Tools', 'https://openvibe.tools/', 'Online tools', 'Converters, downloaders, PDF and image tools, developer utilities and network diagnostics, each on its own easy address.'],++ ['community', 'OpenVibe.Community', 'https://openvibe.community/', 'Pastes and posts', 'Share text and code with a link, comment, and follow what the people of OpenVibe are making.'],++ ['games', 'OpenVibe.Games', 'https://openvibe.games/', 'Browser games', 'Scraplandia and other games that run in a tab and know your OpenVibe account.'],++ ['media', 'OpenVibe.Media', 'https://openvibe.media/', 'VODs, clips and files', 'The media library behind every site: recorded streams, clips and uploads.'],++];++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 = [++ ['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'],++ ['bell', 'One notification inbox', 'Alerts from every site in one bell, with optional push.', '/notifications'],++];++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-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}++.home-card b{display:block;font-size:16px}.home-card em{display:block;font-style:normal;font-size:12px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:var(--accent-light,var(--accent,#60a5fa));margin:1px 0 4px}++.home-card small{display:block;color:var(--text-secondary,#a8b3c4);font-size:13.5px;line-height:1.45}++.home-fams{display:grid;gap:10px;grid-template-columns:repeat(auto-fill,minmax(220px,1fr))}++.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-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}}++${icons.CSS}`;++++function body(catalog) {++ const byId = new Map(catalog.tools.map(t => [t.id, t]));++ const popular = POPULAR.map(id => byId.get(id)).filter(Boolean);++ 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>++<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>++<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>++<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>++<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>++<div><b>llms.txt</b><p>A plain-text map of the network for AI assistants: <a href="/llms.txt"><code>/llms.txt</code></a> here and on <a href="https://openvibe.tools/llms.txt">openvibe.tools</a>.</p></div>++<div><b>Sign in with OpenVibe</b><p>OAuth 2.0 and FedCM for sites on the network. Source on <a href="https://github.com/OpenVibers" rel="noopener">GitHub</a>.</p></div></div></section>`;++}++++let cache = { key: '', html: '', etag: '' };++function render() {++ const { catalog } = toolsCatalog.peek();++ const stat = fs.statSync(SHELL);++ const key = `${stat.mtimeMs}:${catalog.updated}:${catalog.tools.length}`;++ if (cache.key === key) return cache;++ const ld = seo.jsonLdTag(seo.jsonLd.itemList('OpenVibe sites', SITES.map(([, n, u, , d]) => ({ name: n, url: u, description: d }))));++ const html = fs.readFileSync(SHELL, 'utf8').replace('<!--OV:HOME-->', body(catalog)).replace('<!--OV:COUNT-->', String(catalog.tools.length)).replace('</head>', `${ld}\n</head>`);++ cache = { key, html, etag: '"' + crypto.createHash('sha1').update(html).digest('base64url').slice(0, 20) + '"' };++ return cache;++}++++function sendHome(req, res) {++ toolsCatalog.getCatalog().catch(() => {}); // refresh in the background; this response uses what is known now++ const page = render();++ res.set('Content-Type', 'text/html; charset=utf-8');++ res.set('Cache-Control', 'public, max-age=120, stale-while-revalidate=3600');++ res.set('ETag', page.etag);++ if (req.headers['if-none-match'] === page.etag) return res.status(304).end();++ res.send(page.html);++}++++function llmsTxt() {++ const { catalog } = toolsCatalog.peek();++ return seo.llmsTxt({ name: 'OpenVibe Network', summary: 'OpenVibe is an open source, community-run network of sites that share one account: live streaming, online tools, community pastes, games and media.',++ sections: [{ title: 'Sites', links: SITES.map(([, n, u, , d]) => ({ title: n, url: u, note: d })) },++ { title: 'Tool families', links: catalog.families.filter(f => f.url).map(f => ({ title: f.name, url: f.path ? 'https://openvibe.tools' + f.path : f.url, note: f.tagline })) },++ { title: 'Machine-readable', links: [{ title: 'Tool catalog (JSON)', url: 'https://openvibe.tools/api/catalog.json' }, { title: 'Tools llms.txt', url: 'https://openvibe.tools/llms.txt' }, { title: 'Tool domains (JSON)', url: 'https://openvibe.network/api/domains' }] }] });++}++++module.exports = { sendHome, render, llmsTxt };@@@ -415,6 +415,10 @@ function requireAdmin(req, res, next) {}next();}++// 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);++app.use('/api/admin/domains', toolDomains.adminRouter);app.use('/api/admin/discord', createDiscordRoutes(db, discordService, requireAuth, requireAdmin));// Deploy (TLS / Nginx / Infrastructure) admin API@@@ -519,8 +523,9 @@ app.get(['/login.html', '/admin.html'], (req, res) => {// Landing page at the apex rootapp.get(['/', '/index.html'], (req, res) => {if (req.path === '/index.html') return redirectWithoutHtml(req, res, '/');-- return sendLandingPage(res);++ return require('./home/render').sendHome(req, res);});++app.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(require('./home/render').llmsTxt()));// Account hub (my.html) — the apex hosts the account hub under /my// plus its client-routed sections.nnew file mode 100644@@@ -0,0 +1,21 @@++const Database=require('better-sqlite3');const express=require('express');const assert=require('assert');++const db=new Database(':memory:');db.exec("CREATE TABLE users(id INTEGER PRIMARY KEY, username TEXT);CREATE TABLE audit_log(id INTEGER PRIMARY KEY, user_id INT, action TEXT, details TEXT, ip TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);INSERT INTO users VALUES(1,'alex'),(2,'mod')");++process.env.OWNER_USERNAME=process.env.OWNER_USERNAME||'alex';++const {createDomainRoutes}=require('../server/domains/routes');++const {ownerName}=require('../server/auth/owner-guard');++const owner=ownerName();++const auth=(req,res,next)=>{const w=req.headers['x-as'];if(!w)return res.status(401).json({error:'auth'});req.user=w==='owner'?{id:1,username:owner,role:'admin'}:{id:2,username:'mod',role:'admin'};next();};++const cat={getCatalog:async()=>({source:'live',catalog:{tools:[{id:'yt',hosts:{canonical:'youtube-downloader.openvibe.tools',short:'yt.openvibe.tools',aliases:[]}}],families:[]}})};++const r=createDomainRoutes(db,auth,{catalog:cat,checkDomain:async()=>({ok:true})});++const app=express();app.use(express.json());app.use('/api/domains',r.publicRouter);app.use('/api/admin/domains',r.adminRouter);++const s=app.listen(0,async()=>{const b='http://127.0.0.1:'+s.address().port;const j=async(m,p,as,body)=>{const x=await fetch(b+p,{method:m,headers:{'content-type':'application/json',...(as?{'x-as':as}:{})},body:body?JSON.stringify(body):undefined});return{s:x.status,j:await x.json().catch(()=>null)}};++let x=await j('GET','/api/admin/domains');assert.equal(x.s,401);++x=await j('GET','/api/admin/domains','admin');assert.equal(x.s,403,'admin non-owner refused');++x=await j('POST','/api/admin/domains','admin',{tool_id:'yt',host:'evil.com',role:'canonical'});assert.equal(x.s,403);++x=await j('POST','/api/admin/domains','owner',{tool_id:'yt',host:'YoutubeDownloadOnline.com',role:'canonical'});assert.equal(x.s<300,true,JSON.stringify(x.j));++x=await j('POST','/api/admin/domains','owner',{tool_id:'yt',host:'other.com',role:'canonical'});assert.ok(x.s<300);++x=await j('POST','/api/admin/domains','owner',{tool_id:'nope',host:'a.com',role:'alias'});assert.equal(x.s,400);++x=await j('POST','/api/admin/domains','owner',{tool_id:'yt',host:'bad host/../',role:'alias'});assert.equal(x.s,400);++x=await j('GET','/api/domains');console.log(x.s,JSON.stringify(x.j.domains));++assert.equal(x.j.domains.filter(d=>d.role==='canonical').length,1);++console.log('audit rows',db.prepare('select count(*) c from audit_log').get().c);console.log('domains: all checks passed');s.close();});
@@ -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",
+ "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",
"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"
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,161 @@
@@ -0,0 +1,161 @@
+/**
+ * openvibe-shared/island.js — the activity island.
+ *
+ * A page tells the navbar what it is doing. The brand mark becomes the indicator (progress ring,
+ * state colour) and a pill grows out of the brand; hover, focus or tap expands it into a rich card
+ * (image, title, subtitle, progress bar, detail line, actions).
+ *
+ * OpenVibeIsland.start({ id, title, subtitle, icon, image, progress, state, detail, actions })
+ * OpenVibeIsland.update(id, patch) progress: 0–1 | null (indeterminate)
+ * OpenVibeIsland.finish(id, { state: 'ok', title, actions, ttl })
+ * OpenVibeIsland.fail(id, { title, detail })
+ * OpenVibeIsland.remove(id) · .list()
+ *
+ * Works without the navbar (fixed pill, top centre). All text goes through textContent; links are
+ * http(s) or same-origin paths only. Motion respects prefers-reduced-motion.
+ */
+(function (root) {
+ 'use strict';
+ if (root.OpenVibeIsland) return;
+ if (typeof document === 'undefined') { const n = () => {}; const stub = { start: () => ({ update: n, finish: n, fail: n, remove: n }), update: n, finish: n, fail: n, remove: n, list: () => [] }; if (typeof module !== 'undefined') module.exports = stub; return; }
+
+ const CSS = `
+.ov-island{position:relative;display:inline-flex;align-items:center;margin-left:10px;z-index:60;font:500 12.5px/1.2 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:var(--text-primary,#e6edf7)}
+.ov-island.is-floating{position:fixed;top:10px;left:50%;transform:translateX(-50%);margin:0;z-index:2147482000}
+.ov-island[hidden]{display:none}
+.ovi-pill{appearance:none;border:1px solid color-mix(in srgb,var(--ovis-c) 45%,transparent);background:color-mix(in srgb,var(--ovis-c) 12%,var(--bg-elevated,var(--bg-secondary,#0d131d)));color:inherit;font:inherit;display:inline-flex;align-items:center;gap:8px;height:30px;padding:0 12px 0 5px;border-radius:999px;cursor:pointer;max-width:min(300px,46vw);animation:ovisGrow .4s cubic-bezier(.2,1.3,.3,1);box-shadow:0 4px 18px rgba(0,0,0,.28)}
+.ov-island{--ovis-c:var(--accent,#3b82f6)}
+.ov-island[data-state=ok]{--ovis-c:var(--success,#22c55e)}
+.ov-island[data-state=error]{--ovis-c:var(--danger,#ef4444)}
+.ovi-pill .ov-icon{--ovi-size:22px;--ovi-accent:var(--ovis-c)}
+.ovi-pill-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:650}
+.ovi-pill-pct{font-variant-numeric:tabular-nums;color:var(--text-secondary,#a8b3c4)}
+.ovi-count{background:var(--ovis-c);color:var(--on-accent,#fff);border-radius:999px;font-size:10.5px;font-weight:800;padding:1px 6px}
+.ovi-card{position:absolute;top:calc(100% + 8px);left:0;width:min(340px,calc(100vw - 24px));background:var(--bg-elevated,var(--bg-secondary,#111826));border:1px solid var(--border,rgba(255,255,255,.12));border-radius:18px;box-shadow:0 24px 60px rgba(0,0,0,.5);padding:8px;opacity:0;transform:translateY(-6px) scale(.97);transform-origin:top left;pointer-events:none;transition:opacity .18s,transform .22s cubic-bezier(.2,1.2,.3,1)}
+.ov-island.is-floating .ovi-card{left:50%;translate:-50% 0;transform-origin:top center}
+.ov-island.is-open .ovi-card{opacity:1;transform:none;pointer-events:auto}
+.ovi-item{display:grid;grid-template-columns:auto 1fr;gap:10px;padding:8px;border-radius:12px;--ovis-c:var(--accent,#3b82f6)}
+.ovi-item[data-state=ok]{--ovis-c:var(--success,#22c55e)}
+.ovi-item[data-state=error]{--ovis-c:var(--danger,#ef4444)}
+.ovi-item+.ovi-item{border-top:1px solid var(--border,rgba(255,255,255,.08));border-radius:0}
+.ovi-thumb{width:64px;height:44px;border-radius:8px;object-fit:cover;background:#0006}
+.ovi-item .ov-icon{--ovi-size:40px;--ovi-accent:var(--ovis-c)}
+.ovi-t{font-weight:700;font-size:13.5px}
+.ovi-s{color:var(--text-secondary,#a8b3c4);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.ovi-body{min-width:0}
+.ovi-bar{height:4px;border-radius:4px;background:color-mix(in srgb,var(--ovis-c) 18%,transparent);margin-top:8px;overflow:hidden}
+.ovi-bar i{display:block;height:100%;width:0;background:var(--ovis-c);border-radius:4px;transition:width .35s cubic-bezier(.2,.8,.2,1)}
+.ovi-bar.is-ind i{width:35%;animation:ovisInd 1.2s ease-in-out infinite}
+.ovi-d{color:var(--text-muted,#7d8aa0);font-size:11.5px;margin-top:6px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}
+.ovi-acts{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}
+.ovi-act{appearance:none;border:0;cursor:pointer;text-decoration:none;font:700 12px/1 system-ui,sans-serif;padding:7px 11px;border-radius:8px;color:var(--ovis-c);background:color-mix(in srgb,var(--ovis-c) 14%,transparent)}
+.ovi-act:first-child{background:var(--ovis-c);color:var(--on-accent,#fff)}
+.ovi-act:focus-visible,.ovi-pill:focus-visible{outline:2px solid var(--ovis-c);outline-offset:2px}
+@keyframes ovisGrow{from{max-width:30px;opacity:0}to{opacity:1}}
+@keyframes ovisInd{0%{transform:translateX(-110%)}100%{transform:translateX(300%)}}
+@media (max-width:640px){.ov-island{margin-left:6px}.ovi-pill{max-width:40vw;padding-right:9px}.ovi-pill-pct{display:none}.ovi-card{position:fixed;top:56px;left:12px;right:12px;width:auto}}
+@media (prefers-reduced-motion:reduce){.ovi-pill,.ovi-bar.is-ind i{animation:none}.ovi-card,.ovi-bar i{transition:none}}`;
+
+ const items = new Map(); // id → activity (insertion order = age)
+ const timers = new Map();
+ let host = null, pill = null, card = null, seq = 0;
+
+ const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = String(text); return e; };
+ const safeUrl = (u) => { try { const x = new URL(u, location.href); return /^(https?|blob):$/.test(x.protocol) ? x.href : null; } catch { return null; } };
+ const clamp = (p) => (typeof p === 'number' && isFinite(p) ? Math.max(0, Math.min(1, p)) : null);
+
+ function ensure() {
+ if (host && host.isConnected) return;
+ if (!document.getElementById('ov-island-css')) { const s = el('style'); s.id = 'ov-island-css'; s.textContent = CSS; document.head.appendChild(s); }
+ if (!root.OpenVibeIcons && !document.getElementById('ov-icons-loader')) { const sc = el('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; sc.async = true; document.head.appendChild(sc); }
+ host = el('div', 'ov-island'); host.hidden = true;
+ pill = el('button', 'ovi-pill'); pill.type = 'button'; pill.setAttribute('aria-haspopup', 'true'); pill.setAttribute('aria-expanded', 'false');
+ card = el('div', 'ovi-card'); card.setAttribute('role', 'region'); card.setAttribute('aria-label', 'Activity');
+ host.append(pill, card);
+ const brand = document.querySelector('.openvibe-navbar .ovnav-brand, .openvibe-navbar .openvibe-brand, .navbar .nav-brand, .navbar .logo');
+ if (brand && brand.parentNode) brand.parentNode.insertBefore(host, brand.nextSibling);
+ else { host.classList.add('is-floating'); document.body.appendChild(host); }
+ const open = (v) => { host.classList.toggle('is-open', v); pill.setAttribute('aria-expanded', String(v)); };
+ pill.addEventListener('click', () => open(!host.classList.contains('is-open')));
+ if (matchMedia('(hover:hover)').matches) { host.addEventListener('mouseenter', () => open(true)); host.addEventListener('mouseleave', () => open(false)); }
+ document.addEventListener('click', (e) => { if (!host.contains(e.target)) open(false); });
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') open(false); });
+ }
+
+ function top() { let t = null; for (const a of items.values()) t = a; return t; }
+
+ function brandMark(a) {
+ // The navbar's own mark mirrors the top activity: progress ring + state colour.
+ document.querySelectorAll('.openvibe-navbar .ov-mark, .navbar .ov-mark').forEach((m) => {
+ if (m.closest('.openvibe-network-badge')) return;
+ if (!a) { m.removeAttribute('data-state'); m.removeAttribute('data-progress'); m.style.removeProperty('--ovm-p'); return; }
+ m.setAttribute('data-state', a.state === 'info' ? 'idle' : a.state);
+ if (a.progress == null || a.state !== 'busy') m.removeAttribute('data-progress'); else { m.setAttribute('data-progress', String(a.progress)); m.style.setProperty('--ovm-p', String(a.progress)); }
+ });
+ try { root.dispatchEvent(new CustomEvent('ov:activity', { detail: a ? { id: a.id, state: a.state, progress: a.progress, title: a.title } : null })); } catch { /* */ }
+ }
+
+ function iconEl(a, size) { const i = el('span', 'ov-icon'); i.dataset.icon = a.icon || 'ov'; i.dataset.size = String(size); i.dataset.state = a.state === 'info' ? 'idle' : a.state; if (a.progress != null && a.state === 'busy') i.dataset.progress = String(a.progress); i.dataset.fx = a.state === 'busy' ? 'orbit' : 'none'; return i; }
+
+ function render() {
+ ensure();
+ const a = top();
+ brandMark(a);
+ if (!a) { host.hidden = true; host.classList.remove('is-open'); return; }
+ host.hidden = false; host.dataset.state = a.state;
+ pill.replaceChildren(iconEl(a, 22), el('span', 'ovi-pill-text', a.title || 'Working'));
+ if (a.state === 'busy' && a.progress != null) pill.appendChild(el('span', 'ovi-pill-pct', Math.round(a.progress * 100) + '%'));
+ if (items.size > 1) pill.appendChild(el('span', 'ovi-count', items.size));
+ pill.setAttribute('aria-label', `${a.title || 'Activity'}${a.progress != null ? ', ' + Math.round(a.progress * 100) + ' percent' : ''}`);
+
+ card.replaceChildren(...[...items.values()].reverse().map((it) => {
+ const row = el('div', 'ovi-item'); row.dataset.state = it.state;
+ const img = it.image && safeUrl(it.image);
+ if (img) { const im = el('img', 'ovi-thumb'); im.src = img; im.alt = ''; im.loading = 'lazy'; im.referrerPolicy = 'no-referrer'; row.appendChild(im); } else row.appendChild(iconEl(it, 40));
+ const body = el('div', 'ovi-body'); body.appendChild(el('div', 'ovi-t', it.title || ''));
+ if (it.subtitle) body.appendChild(el('div', 'ovi-s', it.subtitle));
+ if (it.state === 'busy') { const bar = el('div', 'ovi-bar' + (it.progress == null ? ' is-ind' : '')); const fill = el('i'); if (it.progress != null) fill.style.width = (it.progress * 100).toFixed(1) + '%'; bar.appendChild(fill); bar.setAttribute('role', 'progressbar'); if (it.progress != null) bar.setAttribute('aria-valuenow', String(Math.round(it.progress * 100))); body.appendChild(bar); }
+ if (it.detail) body.appendChild(el('div', 'ovi-d', it.detail));
+ if (it.actions && it.actions.length) {
+ const acts = el('div', 'ovi-acts');
+ it.actions.slice(0, 3).forEach((ac) => {
+ if (!ac || !ac.label) return;
+ const href = ac.href && safeUrl(ac.href);
+ const b = el(href ? 'a' : 'button', 'ovi-act', ac.label);
+ if (href) { b.href = href; if (ac.download) b.setAttribute('download', typeof ac.download === 'string' ? ac.download : ''); } else b.type = 'button';
+ if (ac.onClick) b.addEventListener('click', (ev) => { try { ac.onClick(ev); } catch { /* */ } });
+ acts.appendChild(b);
+ });
+ body.appendChild(acts);
+ }
+ row.appendChild(body);
+ return row;
+ }));
+ }
+
+ function put(id, patch, defaults) {
+ const prev = items.get(id) || Object.assign({ id, title: '', subtitle: '', icon: 'ov', image: '', progress: null, state: 'busy', detail: '', actions: [] }, defaults);
+ const next = Object.assign({}, prev, patch || {});
+ if (patch && 'progress' in patch) next.progress = clamp(patch.progress);
+ if (!['busy', 'ok', 'error', 'info'].includes(next.state)) next.state = 'busy';
+ items.set(id, next);
+ clearTimeout(timers.get(id));
+ if (next.ttl) timers.set(id, setTimeout(() => remove(id), next.ttl));
+ render();
+ return next;
+ }
+
+ function handle(id) { return { id, update: (p) => update(id, p), finish: (p) => finish(id, p), fail: (p) => fail(id, p), remove: () => remove(id) }; }
+ function start(o) { const id = (o && o.id) || 'act-' + (++seq); items.delete(id); put(id, Object.assign({ state: 'busy' }, o, { id })); return handle(id); }
+ function update(id, patch) { if (!items.has(id)) return; const p = Object.assign({}, patch); delete p.id; put(id, p); }
+ function finish(id, patch) { if (!items.has(id)) return; put(id, Object.assign({ state: 'ok', progress: 1, actions: [], ttl: 8000 }, patch)); if (host && top() && top().id === id && (patch && patch.actions && patch.actions.length)) { host.classList.add('is-open'); pill.setAttribute('aria-expanded', 'true'); setTimeout(() => { if (host && !host.matches(':hover')) host.classList.remove('is-open'); }, 5000); } }
+ function fail(id, patch) { if (!items.has(id)) return; put(id, Object.assign({ state: 'error', progress: null, actions: [], ttl: 12000 }, patch)); }
+ function remove(id) { clearTimeout(timers.get(id)); timers.delete(id); if (items.delete(id)) render(); }
+
+ const api = { start, update, finish, fail, remove, list: () => [...items.values()] };
+ root.OpenVibeIsland = api;
+ // The navbar exposes the same thing as OpenVibeNavbar.activity, whichever loads first.
+ const bind = () => { if (root.OpenVibeNavbar && !root.OpenVibeNavbar.activity) { try { root.OpenVibeNavbar.activity = api; } catch { /* */ } } };
+ bind(); root.addEventListener('load', bind);
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
+})(typeof window !== 'undefined' ? window : globalThis);
@@ -61,7 +61,28 @@
@@ -61,7 +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 .flame { font-size: 18px; color: var(--accent, #8b5cf6); display: inline-grid; place-items: center; width: 28px; height: 28px; flex: none; }
+ .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)); }
+ .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; }
+ .ovnav-launcher .ovl-q:focus { border-color: var(--accent, #3b82f6); }
+ .ovnav-launcher .ovl-h { display: flex; justify-content: space-between; font-size: 11px; font-weight: 700; letter-spacing: .7px; text-transform: uppercase; color: var(--text-muted, #7d8aa0); margin: 12px 4px 6px; }
+ .ovnav-launcher .ovl-h a { color: var(--accent-light, var(--accent, #60a5fa)); text-decoration: none; text-transform: none; letter-spacing: 0; }
+ .ovnav-launcher .ovl-sites { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
+ .ovnav-launcher .ovl-fams { display: grid; grid-template-columns: repeat(2, 1fr); gap: 2px; }
+ .ovnav-launcher .ovl-site, .ovnav-launcher .ovl-fam { display: flex; align-items: center; gap: 9px; padding: 8px; border-radius: 10px; color: var(--text-primary, #e6edf7); text-decoration: none; min-width: 0; }
+ .ovnav-launcher .ovl-site { flex-direction: column; text-align: center; gap: 6px; padding: 12px 6px; }
+ .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-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; } }
+ .openvibe-navbar-brand .flame { font-size: 18px; text-decoration: none; color: var(--accent, #8b5cf6); display: inline-grid; place-items: center; width: 28px; height: 28px; flex: none; }
.openvibe-navbar-brand .name { display: inline-flex; align-items: baseline; font-size: 15px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; line-height: 1; }
.openvibe-navbar-brand .name { display: inline-flex; align-items: baseline; font-size: 15px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; line-height: 1; }
/* Segments: subdomain · OpenVibe · TLD. The part that names *this* site is the loud one. */
/* Segments: subdomain · OpenVibe · TLD. The part that names *this* site is the loud one. */
.openvibe-navbar-brand .b-sub { color: var(--text-primary, #e0e0e0); }
.openvibe-navbar-brand .b-sub { color: var(--text-primary, #e0e0e0); }
@@ -484,19 +505,117 @@
@@ -484,19 +505,117 @@
return { sub, subText, core, tld, tldText, name, short: subText || `${core}.${tldText}`, icon, variant, tag: b.tag || null, href: b.href || '/' };
return { sub, subText, core, tld, tldText, name, short: subText || `${core}.${tldText}`, icon, variant, tag: b.tag || null, href: b.href || '/' };
}
}
+ /** Where each brand segment goes: sub → this tool, OpenVibe → the network, TLD → the site's apex. */
+ function brandLinks(brand) {
+ const apex = brand.core === 'OpenRe' ? 'https://openre.stream/' : `https://openvibe.${brand.tld}/`;
+ if (!brand.subText) return { sub: brand.href, core: brand.href, tld: brand.href, apex };
+ return { sub: brand.href, core: 'https://openvibe.network/', tld: apex, apex };
+ }
+
function brandHTML(brand) {
function brandHTML(brand) {
- const seg = (cls, text) => `<span class="${cls}">${escapeAttr(text)}</span>`;
+ const to = brandLinks(brand);
+ const seg = (cls, text, href, title) => `<a href="${escapeAttr(href)}"${title ? ` title="${escapeAttr(title)}"` : ''} class="${cls}">${escapeAttr(text)}</a>`;
const dot = '<span class="b-dot">.</span>';
const dot = '<span class="b-dot">.</span>';
const text = brand.subText
const text = brand.subText
- ? seg('b-sub', brand.subText) + dot + seg('b-core', brand.core) + dot + seg('b-tld', brand.tldText)
- : seg('b-core', brand.core) + dot + seg('b-tld', brand.tldText);
+ ? seg('b-sub', brand.subText, to.sub, brand.name) + dot + seg('b-core', brand.core, to.core, 'OpenVibe Network') + dot + seg('b-tld', brand.tldText, to.tld, `All of ${brand.core}.${brand.tldText}`)
+ : seg('b-core', brand.core, to.core, brand.name) + dot + seg('b-tld', brand.tldText, to.tld, brand.name);
const mark = brand.icon
const mark = brand.icon
? `<i class="fa-solid ${escapeAttr(brand.icon)}"></i>`
? `<i class="fa-solid ${escapeAttr(brand.icon)}"></i>`
: `<span class="ov-mark" data-size="28" data-variant="${escapeAttr(brand.variant)}"></span>`;
: `<span class="ov-mark" data-size="28" data-variant="${escapeAttr(brand.variant)}"></span>`;
- return `<a class="openvibe-navbar-brand${brand.subText ? ' has-sub' : ''}" href="${escapeAttr(brand.href)}" title="${escapeAttr(brand.name)}" aria-label="${escapeAttr(brand.name)}">
- <span class="flame">${mark}</span>
+ return `<div class="openvibe-navbar-brand${brand.subText ? ' has-sub' : ''}">
+ <a class="flame" href="${escapeAttr(brand.href)}" aria-label="${escapeAttr(brand.name)} home">${mark}</a>
<span class="name">${text}${brand.tag ? `<span class="b-tag">${escapeAttr(brand.tag)}</span>` : ''}</span>
<span class="name">${text}${brand.tag ? `<span class="b-tag">${escapeAttr(brand.tag)}</span>` : ''}</span>
- </a>`;
+ </div>
+ ${_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>'}`;
+ }
+
+ // ── Network launcher ─────────────────────────────────────
+ const LAUNCHER_SITES = [
+ { name: 'Live', desc: 'Streams, clips and chat', icon: 'live', url: 'https://openvibe.live/' },
+ { name: 'Tools', desc: 'Every online tool', icon: 'tools', url: 'https://openvibe.tools/' },
+ { name: 'Community', desc: 'Pastes and posts', icon: 'community', url: 'https://openvibe.community/' },
+ { name: 'Games', desc: 'Browser games', icon: 'games', url: 'https://openvibe.games/' },
+ { name: 'Media', desc: 'VODs, clips, files', icon: 'media', url: 'https://openvibe.media/' },
+ { name: 'Network', desc: 'Account and themes', icon: 'network', url: 'https://openvibe.network/' },
+ ];
+ const LAUNCHER_FAMILIES = [
+ { name: 'Media Tools', icon: 'youtube', url: 'https://yt.openvibe.tools/' },
+ { name: 'Image Tools', icon: 'image', url: 'https://img.openvibe.tools/' },
+ { name: 'Audio Tools', icon: 'audio', url: 'https://audio.openvibe.tools/' },
+ { name: 'PDF & Documents', icon: 'pdf', url: 'https://docs.openvibe.tools/' },
+ { name: 'Text Tools', icon: 'text', url: 'https://text.openvibe.tools/' },
+ { name: 'Developer Tools', icon: 'code', url: 'https://dev.openvibe.tools/' },
+ { name: 'Network Tools', icon: 'dns', url: 'https://net.openvibe.tools/' },
+ ];
+ const CATALOG_URL = 'https://openvibe.tools/api/catalog.json';
+ const okUrl = (u) => { try { const x = new URL(u); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };
+
+ async function launcherCatalog() {
+ try { const c = JSON.parse(sessionStorage.getItem('ov_catalog') || 'null'); if (c && Date.now() - c.at < 30 * 60000) return c.data; } catch { /* */ }
+ try {
+ const r = await fetch(CATALOG_URL, { credentials: 'omit' }); if (!r.ok) return null;
+ const j = await r.json();
+ const data = { families: (j.families || []).slice(0, 12).map(f => ({ name: String(f.name || ''), icon: String(f.icon || 'tools'), url: okUrl(f.url) })).filter(f => f.name && f.url),
+ tools: (j.tools || []).slice(0, 400).map(t => ({ name: String(t.name || ''), icon: String(t.icon || 'tools'), url: okUrl(t.url), k: [t.name, t.tagline].concat(t.keywords || []).join(' ').toLowerCase().slice(0, 400) })).filter(t => t.name && t.url) };
+ try { sessionStorage.setItem('ov_catalog', JSON.stringify({ at: Date.now(), data })); } catch { /* */ }
+ return data;
+ } catch { return null; }
+ }
+
+ function bindLauncher(nav) {
+ const btn = nav.querySelector('#openvibe-launcher-btn'); if (!btn) return;
+ let panel = null;
+ const tile = (it, cls) => `<a class="${cls}" href="${escapeAttr(it.url)}"><span class="ov-icon" data-icon="${escapeAttr(it.icon)}" data-size="${cls === 'ovl-site' ? 34 : 24}" data-fx="none"></span><span><b>${escapeAttr(it.name)}</b>${it.desc ? `<small>${escapeAttr(it.desc)}</small>` : ''}</span></a>`;
+ const close = () => { if (panel) panel.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); };
+ function paint(cat, q) {
+ const fams = (cat && cat.families.length ? cat.families : LAUNCHER_FAMILIES);
+ const body = panel.querySelector('.ovl-body');
+ if (q && cat) {
+ const hits = cat.tools.filter(t => t.k.includes(q)).slice(0, 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>`;
+ }
+ btn.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ if (panel && panel.classList.contains('open')) return close();
+ if (!panel) {
+ panel = document.createElement('div'); panel.className = 'ovnav-launcher'; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'OpenVibe sites and tools');
+ panel.innerHTML = '<input type="search" class="ovl-q" placeholder="Find a tool or site" aria-label="Find a tool or site"><div class="ovl-body"></div>';
+ nav.appendChild(panel);
+ if (!root.OpenVibeIcons && !document.getElementById('ov-icons-loader')) { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; sc.async = true; document.head.appendChild(sc); }
+ let cat = null; paint(null, '');
+ const input = panel.querySelector('.ovl-q');
+ input.addEventListener('input', () => paint(cat, input.value.trim().toLowerCase()));
+ input.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { const first = panel.querySelector('.ovl-body a'); if (first) location.href = first.href; } if (ev.key === 'ArrowDown') { const first = panel.querySelector('.ovl-body a'); if (first) { ev.preventDefault(); first.focus(); } } });
+ panel.addEventListener('keydown', (ev) => {
+ if (ev.key === 'Escape') { close(); btn.focus(); return; }
+ if (ev.key !== 'ArrowDown' && ev.key !== 'ArrowUp') return;
+ const links = [...panel.querySelectorAll('.ovl-body a')]; const i = links.indexOf(document.activeElement); if (i < 0) return;
+ ev.preventDefault(); const n = i + (ev.key === 'ArrowDown' ? 1 : -1); (links[n] || (n < 0 ? input : links[0])).focus();
+ });
+ panel.addEventListener('click', (ev) => ev.stopPropagation());
+ document.addEventListener('click', close);
+ launcherCatalog().then((c) => { if (c) { cat = c; paint(cat, input.value.trim().toLowerCase()); } });
+ }
+ panel.classList.add('open'); btn.setAttribute('aria-expanded', 'true');
+ try { panel.querySelector('.ovl-q').focus({ preventScroll: true }); } catch { /* */ }
+ });
+ }
+
+ // ── Notification bell (mounted by the navbar itself) ─────
+ function mountBell(nav, u) {
+ if (_config.notifications === false || !u || u.is_anon || !_config.token) return;
+ const mount = nav.querySelector('#openvibe-bell-mount'); if (!mount || mount.childElementCount) return;
+ const go = () => {
+ const N = root.OpenVibeNotifications; if (!N || mount.childElementCount) return;
+ try { if (!N.__ovNavInit) { N.init({ token: _config.token, apiBase: 'https://openvibe.network' }); N.__ovNavInit = true; } N.createBell(mount); } catch { /* */ }
+ };
+ if (root.OpenVibeNotifications) return go();
+ if (document.getElementById('ov-notify-loader')) return;
+ const sc = document.createElement('script'); sc.id = 'ov-notify-loader'; sc.src = 'https://openvibe.network/shared/notification-ui.js'; sc.async = true; sc.onload = () => setTimeout(go, 0); document.head.appendChild(sc);
}
}
function menuItemHTML(item) {
function menuItemHTML(item) {
@@ -1013,6 +1132,9 @@
@@ -1013,6 +1132,9 @@
`<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}">Sign In</a>`}
`<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}">Sign In</a>`}
</div>
</div>
`;
`;
+ bindLauncher(nav);
+ // Pages that wire the bell themselves run right after init(); give them the first go.
+ setTimeout(() => mountBell(nav, u), 0);
// Dropdown
// Dropdown
if (u) {
if (u) {
@@ -1175,6 +1297,10 @@
@@ -1175,6 +1297,10 @@
/** The resolved brand for this page ({ sub, core, tld, name, short, variant }). */
/** The resolved brand for this page ({ sub, core, tld, name, short, variant }). */
brand() { return resolveBrand(); },
brand() { return resolveBrand(); },
+ /** The activity island (island.js), loaded on first use: OpenVibeNavbar.activity.start({...}). */
+ get activity() { return root.OpenVibeIsland || null; },
+ set activity(_v) { /* island.js binds itself; nothing to store */ },
+
setToken(token) { _config.token = token; },
setToken(token) { _config.token = token; },
/** Get the bell mount point for OpenVibeNotifications. */
/** Get the bell mount point for OpenVibeNotifications. */
new file mode 100644
new file mode 100644
@@ -0,0 +1,184 @@
@@ -0,0 +1,184 @@
+/**
+ * openvibe-shared/ov-icons.js — the OpenVibe animated icon family.
+ *
+ * <span class="ov-icon" data-icon="youtube" data-size="40"></span>
+ * <span class="ov-icon" data-icon="pdf" data-state="busy" data-progress="0.4" data-fx="orbit"></span>
+ *
+ * One construction for every icon so the network reads as one brand: the OV ring (with its comet)
+ * around a glyph drawn in the theme accent. The ring is also a progress meter (data-progress 0–1)
+ * and a status light (data-state idle|busy|ok|error), so the same icon that names a tool can show
+ * what the tool is doing. Glyphs are 24×24 stroke paths centred in the ring.
+ *
+ * OpenVibeIcons.names() every registered icon
+ * OpenVibeIcons.register(name, { glyph, fx?, accent? })
+ * OpenVibeIcons.mount(root?) (re)scan; new nodes are picked up automatically
+ * OpenVibeIcons.set(el, { progress, state, icon })
+ * OpenVibeIcons.svg(name, size) markup string, for server-side / templates
+ *
+ * Effects (data-fx): orbit (default) · pulse · draw · bounce · none. Motion is transform/opacity/
+ * stroke only and stops under prefers-reduced-motion. Unknown names fall back to the OV mark "V".
+ */
+(function (root) {
+ 'use strict';
+ if (root.OpenVibeIcons) return;
+
+ const P = (d) => `<path d="${d}"/>`;
+ // Glyphs: stroke-only, 24×24 box, designed to sit inside the ring.
+ const GLYPHS = {
+ ov: P('M6.5 7.5 12 17.5l5.5-10'),
+ network: '<circle cx="12" cy="6" r="2"/><circle cx="5.5" cy="17" r="2"/><circle cx="18.5" cy="17" r="2"/>' + P('M12 8v3.5M12 11.5 6.8 15.4M12 11.5l5.2 3.9'),
+ live: '<circle cx="12" cy="12" r="2.2"/>' + P('M7.5 7.5a6.4 6.4 0 0 0 0 9M16.5 7.5a6.4 6.4 0 0 1 0 9M4.8 5a10 10 0 0 0 0 14M19.2 5a10 10 0 0 1 0 14'),
+ tools: P('M14.5 5.5a4 4 0 0 0-5.2 5.2L4.5 15.5a1.8 1.8 0 0 0 2.5 2.5l4.8-4.8a4 4 0 0 0 5.2-5.2l-2.6 2.6-2.1-.5-.5-2.1z'),
+ media: '<rect x="4" y="6" width="16" height="12" rx="2"/>' + P('M10 9.5v5l4.5-2.5z'),
+ games: P('M7 9h10a4 4 0 0 1 3.9 4.9l-.6 2.6a2 2 0 0 1-3.5.8L15.5 16h-7l-1.3 1.3a2 2 0 0 1-3.5-.8l-.6-2.6A4 4 0 0 1 7 9zM8 11.5v3M6.5 13h3') + '<circle cx="15.5" cy="12" r=".6"/><circle cx="17.5" cy="14" r=".6"/>',
+ community: '<circle cx="9" cy="9" r="2.6"/><circle cx="16.5" cy="10" r="2"/>' + P('M3.8 18.5a5.2 5.2 0 0 1 10.4 0M14.6 14.2a4 4 0 0 1 5.6 3.8'),
+ chat: P('M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v7A1.5 1.5 0 0 1 19 16.5h-7l-4 3v-3H5A1.5 1.5 0 0 1 3.5 15V8A1.5 1.5 0 0 1 5 6.5zM8 10.5h8M8 13h5'),
+ codes: P('M9 8 4.5 12 9 16M15 8l4.5 4-4.5 4M13.2 6l-2.4 12'),
+ code: P('M9 8 4.5 12 9 16M15 8l4.5 4-4.5 4M13.2 6l-2.4 12'),
+ blog: P('M5 19l1.2-4.2L16 5l3 3-9.8 9.8zM14 7l3 3'),
+ wiki: P('M5 5.5h6a2 2 0 0 1 2 2V19a2 2 0 0 0-2-2H5zM19 5.5h-4a2 2 0 0 0-2 2V19a2 2 0 0 1 2-2h4z'),
+ news: '<rect x="4" y="5.5" width="13" height="13" rx="1.5"/>' + P('M17 9h3v8a1.5 1.5 0 0 1-3 0M7 9h7M7 12h7M7 15h4'),
+ reviews: P('M12 4.5l2.2 4.6 5 .7-3.6 3.5.9 5-4.5-2.4-4.5 2.4.9-5L4.8 9.8l5-.7z'),
+ tips: P('M12 19s-6.5-3.8-6.5-8.6A3.6 3.6 0 0 1 12 8a3.6 3.6 0 0 1 6.5 2.4C18.5 15.2 12 19 12 19z'),
+ vip: P('M4.5 16.5 6 8l3.8 3.5L12 6.5l2.2 5L18 8l1.5 8.5zM5 19h14'),
+ trade: P('M4.5 18.5h15M6.5 15l3.5-4 3 2.5 4.5-6M17.5 7.5h-3M17.5 7.5v3'),
+ host: '<rect x="4.5" y="5" width="15" height="5.5" rx="1.2"/><rect x="4.5" y="13.5" width="15" height="5.5" rx="1.2"/><circle cx="7.5" cy="7.75" r=".6"/><circle cx="7.5" cy="16.25" r=".6"/>',
+ deals: P('M4.5 12.5V5.5h7l8 8-7 7zM8.2 9.2h.01'),
+ coupons: P('M4 8.5h16v2.5a1.5 1.5 0 0 0 0 3v2.5H4V14a1.5 1.5 0 0 0 0-3zM10 8.5v8') ,
+ stream: P('M5 17.5 12 6l7 11.5M8 13h8') + '<circle cx="12" cy="6" r="1.4"/>',
+ youtube: '<rect x="3.5" y="6.5" width="17" height="11" rx="3"/>' + P('M10.5 9.8v4.4l3.8-2.2z'),
+ video: '<rect x="3.5" y="6.5" width="17" height="11" rx="3"/>' + P('M10.5 9.8v4.4l3.8-2.2z'),
+ download: P('M12 4.5v10M8 11l4 4 4-4M5 19h14'),
+ upload: P('M12 15.5v-10M8 9l4-4 4 4M5 19h14'),
+ image: '<rect x="4" y="5" width="16" height="14" rx="2"/><circle cx="9" cy="10" r="1.5"/>' + P('M4.5 17l4.5-4.5 3.5 3.5 2.5-2.5 4.5 4.5'),
+ audio: P('M9 17V7.5l9-2V15') + '<circle cx="6.8" cy="17" r="2.2"/><circle cx="15.8" cy="15" r="2.2"/>',
+ pdf: P('M7 4.5h7l4 4V19a.5.5 0 0 1-.5.5h-10.5A.5.5 0 0 1 6.5 19V5a.5.5 0 0 1 .5-.5zM14 4.5v4h4M9 13h6M9 16h4'),
+ docs: P('M7 4.5h7l4 4V19a.5.5 0 0 1-.5.5h-10.5A.5.5 0 0 1 6.5 19V5a.5.5 0 0 1 .5-.5zM14 4.5v4h4M9 13h6M9 16h4'),
+ text: P('M5.5 7V5.5h13V7M12 5.5v13M9.5 18.5h5'),
+ logo: P('M12 4.5l1.8 4.2 4.2 1.8-4.2 1.8L12 16.5l-1.8-4.2L6 10.5l4.2-1.8zM18 15.5l.8 1.7 1.7.8-1.7.8-.8 1.7-.8-1.7-1.7-.8 1.7-.8z'),
+ json: P('M9 5.5c-2 0-2 1.5-2 3s0 3.5-2 3.5c2 0 2 2 2 3.5s0 3 2 3M15 5.5c2 0 2 1.5 2 3s0 3.5 2 3.5c-2 0-2 2-2 3.5s0 3-2 3'),
+ dns: '<circle cx="12" cy="12" r="7.5"/>' + P('M4.5 12h15M12 4.5c2.5 2 2.5 13 0 15M12 4.5c-2.5 2-2.5 13 0 15'),
+ ip: P('M12 20s-6-5.2-6-10a6 6 0 0 1 12 0c0 4.8-6 10-6 10z') + '<circle cx="12" cy="10" r="2.2"/>',
+ ssl: '<rect x="6" y="11" width="12" height="8.5" rx="1.8"/>' + P('M8.5 11V8.5a3.5 3.5 0 0 1 7 0V11M12 14.5v2'),
+ ping: P('M3.5 12h4l2-5 3.5 10 2.5-7 1.5 2h3.5'),
+ whois: '<circle cx="11" cy="11" r="5.5"/>' + P('M15.2 15.2 19.5 19.5M9 10a2 2 0 0 1 4 0c0 1.5-2 1.6-2 3M11 15h.01'),
+ search: '<circle cx="11" cy="11" r="5.5"/>' + P('M15.2 15.2 19.5 19.5'),
+ map: P('M4.5 7l5-2 5 2 5-2v12l-5 2-5-2-5 2zM9.5 5v12M14.5 7v12'),
+ food: P('M7 4.5v6a2 2 0 0 0 4 0v-6M9 4.5v15M16.5 4.5c-2 1.5-2.5 4-2.5 6.5h2.5zM16.5 11v8.5'),
+ paste: '<rect x="6" y="6" width="12" height="14" rx="1.8"/>' + P('M9.5 6V5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1M9 11h6M9 14h6M9 17h3'),
+ account: '<circle cx="12" cy="9" r="3.2"/>' + P('M5.5 19a6.5 6.5 0 0 1 13 0'),
+ bell: P('M6.5 16.5h11l-1.2-2V11a4.3 4.3 0 0 0-8.6 0v3.5zM10.5 19a1.6 1.6 0 0 0 3 0'),
+ history: '<circle cx="12" cy="12" r="7.5"/>' + P('M12 8v4.2l2.8 1.8'),
+ theme: P('M12 4.5a7.5 7.5 0 1 0 0 15c1 0 1.6-.8 1.2-1.7-.5-1.1.3-2.3 1.5-2.3h1.6a3.2 3.2 0 0 0 3.2-3.2C19.5 8 16.2 4.5 12 4.5z') + '<circle cx="8.5" cy="11" r=".7"/><circle cx="11.5" cy="8" r=".7"/><circle cx="15" cy="9" r=".7"/>',
+ check: P('M6 12.5l4 4 8-9'),
+ error: P('M12 7v6M12 16.5h.01'),
+ clip: P('M6 6l12 12M6 18L18 6') ,
+ };
+ const ALIASES = { yt: 'youtube', net: 'dns', dev: 'code', img: 'image', sound: 'audio', document: 'pdf', pastes: 'paste', openre: 'stream', maps: 'map', user: 'account', notifications: 'bell' };
+ const registry = Object.create(null);
+ for (const [k, g] of Object.entries(GLYPHS)) registry[k] = { glyph: g };
+
+ const CSS = `
+.ov-icon{--ovi-size:32px;display:inline-grid;place-items:center;width:var(--ovi-size);height:var(--ovi-size);flex:none;vertical-align:middle;line-height:0;color:var(--ovi-accent,var(--accent,#3b82f6));position:relative}
+.ov-icon svg{width:100%;height:100%;overflow:visible;display:block}
+.ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s}
+.ov-icon .ovi-ring{fill:none;stroke:currentColor;stroke-width:2.4;opacity:.26}
+.ov-icon .ovi-comet{fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-dasharray:26 100;transform-origin:24px 24px;animation:oviSpin 3.6s linear infinite}
+.ov-icon .ovi-prog{fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;transform:rotate(-90deg);transform-origin:24px 24px;stroke-dasharray:125.7;stroke-dashoffset:125.7;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s;opacity:0}
+.ov-icon .ovi-glyph{fill:none;stroke:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;transform-origin:24px 24px;transition:transform .25s cubic-bezier(.2,1.4,.3,1),stroke .2s}
+.ov-icon .ovi-glyph circle[r=".6"],.ov-icon .ovi-glyph circle[r=".7"]{fill:currentColor;stroke:none}
+.ov-icon:hover .ovi-glyph,a:hover>.ov-icon .ovi-glyph,button:hover>.ov-icon .ovi-glyph{transform:scale(1.1)}
+.ov-icon:hover .ovi-bg,a:hover>.ov-icon .ovi-bg{opacity:.18}
+.ov-icon[data-fx=none] .ovi-comet{display:none}
+.ov-icon[data-fx=pulse] .ovi-comet{display:none}
+.ov-icon[data-fx=pulse] .ovi-bg{animation:oviPulse 2s ease-in-out infinite;transform-origin:24px 24px}
+.ov-icon[data-fx=draw] .ovi-glyph path{stroke-dasharray:80;animation:oviDraw 1.2s cubic-bezier(.2,.8,.2,1) both}
+.ov-icon[data-fx=bounce] svg{animation:oviBounce 1.8s cubic-bezier(.3,1.4,.4,1) infinite}
+.ov-icon[data-progress] .ovi-prog{opacity:1}
+.ov-icon[data-progress] .ovi-comet{opacity:0}
+.ov-icon[data-state=busy] .ovi-comet{animation-duration:1.1s;opacity:1;stroke-dasharray:40 86}
+.ov-icon[data-state=busy][data-progress] .ovi-comet{opacity:.35}
+.ov-icon[data-state=ok]{--ovi-accent:var(--success,#22c55e)}
+.ov-icon[data-state=error]{--ovi-accent:var(--danger,#ef4444)}
+.ov-icon[data-state=ok] .ovi-comet,.ov-icon[data-state=error] .ovi-comet{display:none}
+.ov-icon[data-state=ok] .ovi-ring,.ov-icon[data-state=error] .ovi-ring{opacity:.9}
+.ov-icon[data-state=ok] svg{animation:oviPop .5s cubic-bezier(.2,1.6,.3,1)}
+.ov-icon[data-state=error] svg{animation:oviShake .4s ease}
+@keyframes oviSpin{to{transform:rotate(360deg)}}
+@keyframes oviPulse{0%,100%{transform:scale(.9);opacity:.08}50%{transform:scale(1.06);opacity:.22}}
+@keyframes oviDraw{from{stroke-dashoffset:80}to{stroke-dashoffset:0}}
+@keyframes oviBounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-6%)}}
+@keyframes oviPop{0%{transform:scale(.8)}60%{transform:scale(1.12)}100%{transform:scale(1)}}
+@keyframes oviShake{0%,100%{transform:translateX(0)}25%{transform:translateX(-6%)}75%{transform:translateX(6%)}}
+@media (prefers-reduced-motion:reduce){.ov-icon *, .ov-icon svg{animation:none!important;transition:none!important}.ov-icon .ovi-comet{display:none}}`;
+
+ function resolve(name) {
+ const n = String(name || 'ov').toLowerCase();
+ return registry[n] ? n : (registry[ALIASES[n]] ? ALIASES[n] : 'ov');
+ }
+
+ /** Markup for one icon (usable server-side: no DOM needed). Glyph is scaled 24→22 and centred in the 48 box. */
+ function svg(name, size) {
+ const def = registry[resolve(name)];
+ const s = size ? ` width="${size}" height="${size}"` : '';
+ return `<svg viewBox="0 0 48 48"${s} aria-hidden="true" focusable="false"><circle class="ovi-bg" cx="24" cy="24" r="21"/><circle class="ovi-ring" cx="24" cy="24" r="20"/><circle class="ovi-comet" cx="24" cy="24" r="20"/><circle class="ovi-prog" cx="24" cy="24" r="20"/><g class="ovi-glyph" transform="translate(13 13) scale(.9167)">${def.glyph}</g></svg>`;
+ }
+
+ function applyProgress(el) {
+ const raw = el.getAttribute('data-progress');
+ const prog = el.querySelector('.ovi-prog');
+ if (!prog) return;
+ if (raw == null || raw === '') { prog.style.strokeDashoffset = ''; return; }
+ const p = Math.max(0, Math.min(1, parseFloat(raw) || 0));
+ prog.style.strokeDashoffset = String(125.7 * (1 - p));
+ }
+
+ function mountOne(el) {
+ const name = resolve(el.getAttribute('data-icon'));
+ if (el.getAttribute('data-ovi') === name) { applyProgress(el); return; }
+ el.setAttribute('data-ovi', name);
+ const size = parseInt(el.getAttribute('data-size'), 10);
+ if (size) el.style.setProperty('--ovi-size', size + 'px');
+ const def = registry[name];
+ if (def.accent) el.style.setProperty('--ovi-accent', def.accent);
+ if (def.fx && !el.hasAttribute('data-fx')) el.setAttribute('data-fx', def.fx);
+ el.innerHTML = svg(name);
+ if (!el.hasAttribute('role')) el.setAttribute('aria-hidden', 'true');
+ applyProgress(el);
+ }
+
+ function mount(rootEl) { (rootEl || document).querySelectorAll('.ov-icon').forEach(mountOne); }
+
+ function set(el, patch) {
+ if (!el || !patch) return;
+ if (patch.icon) el.setAttribute('data-icon', patch.icon);
+ if ('state' in patch) { if (patch.state) el.setAttribute('data-state', patch.state); else el.removeAttribute('data-state'); }
+ if ('progress' in patch) { if (patch.progress == null) el.removeAttribute('data-progress'); else el.setAttribute('data-progress', String(patch.progress)); }
+ mountOne(el);
+ }
+
+ function register(name, def) {
+ if (!name || !def || !def.glyph) return;
+ registry[String(name).toLowerCase()] = { glyph: def.glyph, fx: def.fx || null, accent: def.accent || null };
+ if (typeof document !== 'undefined') document.querySelectorAll(`.ov-icon[data-icon="${name}"]`).forEach((el) => { el.removeAttribute('data-ovi'); mountOne(el); });
+ }
+
+ const api = { names: () => Object.keys(registry), register, mount, set, svg, resolve, CSS };
+ root.OpenVibeIcons = api;
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
+ if (typeof document === 'undefined') return;
+
+ function init() {
+ if (!document.getElementById('ov-icons-css')) { const st = document.createElement('style'); st.id = 'ov-icons-css'; st.textContent = CSS; document.head.appendChild(st); }
+ mount();
+ try {
+ new MutationObserver((muts) => {
+ for (const m of muts) {
+ if (m.type === 'attributes') { if (m.target.classList && m.target.classList.contains('ov-icon')) mountOne(m.target); continue; }
+ m.addedNodes.forEach((n) => { if (n.nodeType !== 1) return; if (n.classList.contains('ov-icon')) mountOne(n); else if (n.querySelector) n.querySelectorAll('.ov-icon').forEach(mountOne); });
+ }
+ }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-icon', 'data-progress'] });
+ } catch { /* */ }
+ }
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
+})(typeof window !== 'undefined' ? window : globalThis);
@@ -48,6 +48,13 @@
@@ -48,6 +48,13 @@
.ov-mark:hover .c{animation-duration:.9s}
.ov-mark:hover .c{animation-duration:.9s}
.ov-mark[data-static] svg,.ov-mark[data-static] .g,.ov-mark[data-static] .c,.ov-mark[data-static] .v,.ov-mark[data-static] .d{animation:none!important}
.ov-mark[data-static] svg,.ov-mark[data-static] .g,.ov-mark[data-static] .c,.ov-mark[data-static] .v,.ov-mark[data-static] .d{animation:none!important}
.ov-mark[data-static] .o{display:none}
.ov-mark[data-static] .o{display:none}
+.ov-mark .p{fill:none;stroke:currentColor;stroke-width:4.4;stroke-linecap:round;transform:rotate(-90deg);transform-box:fill-box;transform-origin:center;stroke-dasharray:113.1;stroke-dashoffset:calc(113.1px * (1 - var(--ovm-p,0)));opacity:0;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s}
+.ov-mark[data-progress] .p{opacity:1}
+.ov-mark[data-progress] .c,.ov-mark[data-progress] .o{opacity:.25}
+.ov-mark[data-state=busy] .c{animation-duration:1.1s}
+.ov-mark[data-state=ok]{color:var(--success,#22c55e)!important}
+.ov-mark[data-state=error]{color:var(--danger,#ef4444)!important}
+.ov-mark[data-state=ok] svg{animation:ovmSpin .9s cubic-bezier(.2,1.5,.3,1) 1}
@keyframes ovmFloat{0%,100%{transform:translateY(0) rotate(0deg)}50%{transform:translateY(-1px) rotate(2deg)}}
@keyframes ovmFloat{0%,100%{transform:translateY(0) rotate(0deg)}50%{transform:translateY(-1px) rotate(2deg)}}
@keyframes ovmGlow{0%,100%{opacity:.08;transform:scale(.92)}50%{opacity:.2;transform:scale(1.06)}}
@keyframes ovmGlow{0%,100%{opacity:.08;transform:scale(.92)}50%{opacity:.2;transform:scale(1.06)}}
@keyframes ovmComet{to{transform:rotate(360deg)}}
@keyframes ovmComet{to{transform:rotate(360deg)}}
@@ -61,6 +68,7 @@
@@ -61,6 +68,7 @@
<linearGradient id="${id}v" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fff"/><stop offset="0.5" stop-color="currentColor"/><stop offset="1" stop-color="#fff" stop-opacity="0.9"/><animateTransform attributeName="gradientTransform" type="translate" from="-1 0" to="1 0" dur="3.2s" repeatCount="indefinite"/></linearGradient>
<linearGradient id="${id}v" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fff"/><stop offset="0.5" stop-color="currentColor"/><stop offset="1" stop-color="#fff" stop-opacity="0.9"/><animateTransform attributeName="gradientTransform" type="translate" from="-1 0" to="1 0" dur="3.2s" repeatCount="indefinite"/></linearGradient>
<path id="${id}p" d="M24,6 A18,18 0 1,1 23.99,6 Z"/></defs>
<path id="${id}p" d="M24,6 A18,18 0 1,1 23.99,6 Z"/></defs>
<circle class="g" cx="24" cy="24" r="21"/><circle class="r" cx="24" cy="24" r="18"/><circle class="c" cx="24" cy="24" r="18" stroke="url(#${id}g)"/>
<circle class="g" cx="24" cy="24" r="21"/><circle class="r" cx="24" cy="24" r="18"/><circle class="c" cx="24" cy="24" r="18" stroke="url(#${id}g)"/>
+<circle class="p" cx="24" cy="24" r="18"/>
<path class="v" d="M14.5,17 L24,34 L33.5,17" stroke="url(#${id}v)"/><circle class="d" cx="24" cy="34" r="2.6"/>
<path class="v" d="M14.5,17 L24,34 L33.5,17" stroke="url(#${id}v)"/><circle class="d" cx="24" cy="34" r="2.6"/>
<circle class="o" r="2"><animateMotion dur="3.6s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle>
<circle class="o" r="2"><animateMotion dur="3.6s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle>
<circle class="o o2" r="1.4"><animateMotion dur="3.6s" begin="0.14s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle></svg>`;
<circle class="o o2" r="1.4"><animateMotion dur="3.6s" begin="0.14s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle></svg>`;
@@ -12,7 +12,8 @@
@@ -12,7 +12,8 @@
"./middleware": "./middleware.js",
"./middleware": "./middleware.js",
"./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"
},
},
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+'use strict';
+const assert = require('assert');
+const seo = require('../seo');
+const icons = require('../ov-icons');
+
+const head = seo.headTags({ title: 'A very long title that certainly goes past the sixty character limit for titles', description: 'd', canonical: 'https://openvibe.tools/', jsonLd: [{ '@type': 'Thing', name: '</script><script>alert(1)' }] });
+assert.ok(/<title>[^<]{1,62}<\/title>/.test(head), 'title is clipped');
+assert.ok(!head.includes('</script><script>alert'), 'JSON-LD cannot close its script tag');
+assert.ok(seo.robotsTxt({ sitemaps: ['https://x.test/sitemap.xml'] }).includes('GPTBot'), 'AI crawlers are named');
+assert.ok(!seo.sitemapXml([{ loc: '/relative' }, { loc: 'https://ok.test/' }]).includes('/relative'), 'sitemaps hold absolute URLs only');
+assert.ok(seo.llmsTxt({ name: 'X', summary: 's', sections: [] }).startsWith('# X'));
+
+assert.ok(icons.names().length >= 40);
+for (const n of icons.names()) assert.ok(/<(path|circle|rect)/.test(icons.svg(n)), `glyph: ${n}`);
+assert.equal(icons.resolve('yt'), 'youtube');
+assert.equal(icons.resolve('<img onerror=1>'), 'ov', 'unknown names fall back to the mark');
+for (const f of ['../island.js', '../ui.js']) { const m = require(f); assert.equal(typeof (m.start || m.toast), 'function', `${f} loads without a DOM`); }
+console.log('shared modules: all checks passed');
new file mode 100644
new file mode 100644
@@ -0,0 +1,124 @@
@@ -0,0 +1,124 @@
+/**
+ * openvibe-shared/ui.js — one toast / confirm / alert implementation for every OpenVibe site.
+ *
+ * OpenVibeUI.toast('Saved', { type: 'success' })
+ * OpenVibeUI.toast('Upload failed', { type: 'error', title: 'Images', action: { label: 'Retry', onClick } })
+ * if (await OpenVibeUI.confirm({ title: 'Delete paste?', message: 'This cannot be undone.', danger: true })) …
+ * await OpenVibeUI.alert({ title: 'Heads up', message: '…' })
+ *
+ * Themed from the site's CSS variables, stacked bottom-right (bottom-centre on phones), announced
+ * to assistive tech (role=status, role=alert for errors), pause-on-hover, Escape closes dialogs,
+ * focus is trapped in dialogs and restored afterwards. Text is always set with textContent.
+ */
+(function (root) {
+ 'use strict';
+ if (root.OpenVibeUI) return;
+ if (typeof document === 'undefined') { const noop = { toast() { return { close() {} }; }, confirm: async () => false, alert: async () => {} }; if (typeof module !== 'undefined') module.exports = noop; return; }
+
+ const CSS = `
+.ovui-toasts{position:fixed;right:16px;bottom:16px;z-index:2147483000;display:flex;flex-direction:column;gap:8px;max-width:min(380px,calc(100vw - 24px));pointer-events:none}
+.ovui-toast{pointer-events:auto;display:grid;grid-template-columns:auto 1fr auto;gap:10px;align-items:start;padding:11px 12px;border-radius:12px;background:var(--bg-elevated,var(--bg-secondary,#131a26));color:var(--text-primary,#e6edf7);border:1px solid var(--border,rgba(255,255,255,.1));box-shadow:0 10px 30px rgba(0,0,0,.35);font:500 13.5px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;animation:ovuiIn .28s cubic-bezier(.2,1.2,.3,1);border-left:3px solid var(--ovui-c,var(--accent,#3b82f6))}
+.ovui-toast[data-type=success]{--ovui-c:var(--success,#22c55e)}
+.ovui-toast[data-type=error]{--ovui-c:var(--danger,#ef4444)}
+.ovui-toast[data-type=warning]{--ovui-c:var(--warning,#f59e0b)}
+.ovui-toast.is-out{animation:ovuiOut .2s ease forwards}
+.ovui-dot{width:8px;height:8px;border-radius:50%;background:var(--ovui-c,var(--accent,#3b82f6));margin-top:6px}
+.ovui-title{font-weight:700;margin-bottom:1px}
+.ovui-msg{color:var(--text-secondary,#a8b3c4);overflow-wrap:anywhere}
+.ovui-title+.ovui-msg{font-weight:400}
+.ovui-toast .ovui-msg:first-child{color:inherit}
+.ovui-x,.ovui-act{appearance:none;border:0;background:none;color:inherit;font:inherit;cursor:pointer;border-radius:8px}
+.ovui-x{opacity:.55;padding:2px 6px;line-height:1;font-size:16px}
+.ovui-x:hover{opacity:1}
+.ovui-act{margin-top:6px;padding:4px 10px;font-weight:700;color:var(--accent,#3b82f6);background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent);text-decoration:none;display:inline-block}
+.ovui-back{position:fixed;inset:0;z-index:2147483100;background:rgba(3,6,12,.6);backdrop-filter:blur(3px);display:grid;place-items:center;padding:16px;animation:ovuiFade .15s ease}
+.ovui-dialog{width:min(420px,100%);background:var(--bg-elevated,var(--bg-secondary,#131a26));color:var(--text-primary,#e6edf7);border:1px solid var(--border,rgba(255,255,255,.1));border-radius:16px;padding:20px;box-shadow:0 24px 60px rgba(0,0,0,.5);font:400 14.5px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;animation:ovuiIn .22s cubic-bezier(.2,1.2,.3,1)}
+.ovui-dialog h2{margin:0 0 6px;font-size:17px;font-weight:700}
+.ovui-dialog p{margin:0;color:var(--text-secondary,#a8b3c4);white-space:pre-wrap;overflow-wrap:anywhere}
+.ovui-btns{display:flex;justify-content:flex-end;gap:8px;margin-top:18px}
+.ovui-btn{appearance:none;border:1px solid var(--border,rgba(255,255,255,.14));background:transparent;color:inherit;font:600 14px/1 system-ui,sans-serif;padding:10px 16px;border-radius:10px;cursor:pointer}
+.ovui-btn:hover{background:rgba(127,127,127,.12)}
+.ovui-btn.is-primary{background:var(--accent,#3b82f6);border-color:transparent;color:var(--on-accent,#fff)}
+.ovui-btn.is-danger{background:var(--danger,#ef4444);border-color:transparent;color:#fff}
+.ovui-btn:focus-visible,.ovui-x:focus-visible,.ovui-act:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}
+@keyframes ovuiIn{from{opacity:0;transform:translateY(10px) scale(.97)}to{opacity:1;transform:none}}
+@keyframes ovuiOut{to{opacity:0;transform:translateX(16px)}}
+@keyframes ovuiFade{from{opacity:0}to{opacity:1}}
+@media (max-width:560px){.ovui-toasts{left:12px;right:12px;bottom:calc(12px + env(safe-area-inset-bottom,0px));max-width:none}}
+@media (prefers-reduced-motion:reduce){.ovui-toast,.ovui-dialog,.ovui-back{animation:none!important}}`;
+
+ function css() { if (document.getElementById('ovui-css')) return; const s = document.createElement('style'); s.id = 'ovui-css'; s.textContent = CSS; document.head.appendChild(s); }
+ function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = String(text); return e; }
+ function safeHref(h) { try { const u = new URL(h, location.href); return /^https?:$/.test(u.protocol) ? u.href : null; } catch { return null; } }
+
+ let stack = null;
+ const MAX = 4;
+
+ function toast(message, opts) {
+ const o = opts || {};
+ css();
+ if (!stack || !stack.isConnected) { stack = el('div', 'ovui-toasts'); stack.setAttribute('aria-live', 'polite'); document.body.appendChild(stack); }
+ const type = ['info', 'success', 'error', 'warning'].includes(o.type) ? o.type : 'info';
+ const t = el('div', 'ovui-toast'); t.dataset.type = type; t.setAttribute('role', type === 'error' ? 'alert' : 'status');
+ t.appendChild(el('span', 'ovui-dot'));
+ const body = el('div');
+ if (o.title) body.appendChild(el('div', 'ovui-title', o.title));
+ body.appendChild(el('div', 'ovui-msg', message));
+ if (o.action && o.action.label) {
+ const href = o.action.href && safeHref(o.action.href);
+ const a = el(href ? 'a' : 'button', 'ovui-act', o.action.label);
+ if (href) a.href = href; else a.type = 'button';
+ a.addEventListener('click', (ev) => { try { o.action.onClick && o.action.onClick(ev); } finally { close(); } });
+ body.appendChild(a);
+ }
+ t.appendChild(body);
+ const x = el('button', 'ovui-x', '×'); x.type = 'button'; x.setAttribute('aria-label', 'Dismiss'); x.addEventListener('click', () => close()); t.appendChild(x);
+ stack.appendChild(t);
+ while (stack.children.length > MAX) stack.firstChild.remove();
+
+ const ttl = o.ttl === 0 ? 0 : (o.ttl || (type === 'error' ? 8000 : 4500));
+ let timer = null, closed = false;
+ const arm = () => { if (ttl) timer = setTimeout(close, ttl); };
+ function close() { if (closed) return; closed = true; clearTimeout(timer); t.classList.add('is-out'); setTimeout(() => t.remove(), 220); }
+ t.addEventListener('mouseenter', () => clearTimeout(timer)); t.addEventListener('mouseleave', arm);
+ arm();
+ return { close, el: t };
+ }
+
+ function dialog(o, withCancel) {
+ css();
+ return new Promise((resolve) => {
+ const prev = document.activeElement;
+ const back = el('div', 'ovui-back');
+ const d = el('div', 'ovui-dialog'); d.setAttribute('role', withCancel ? 'alertdialog' : 'dialog'); d.setAttribute('aria-modal', 'true');
+ const h = el('h2', null, o.title || (withCancel ? 'Are you sure?' : 'Notice')); h.id = 'ovui-h-' + Date.now(); d.setAttribute('aria-labelledby', h.id); d.appendChild(h);
+ if (o.message) d.appendChild(el('p', null, o.message));
+ const btns = el('div', 'ovui-btns');
+ let cancel = null;
+ if (withCancel) { cancel = el('button', 'ovui-btn', o.cancelLabel || 'Cancel'); cancel.type = 'button'; btns.appendChild(cancel); }
+ const ok = el('button', 'ovui-btn ' + (o.danger ? 'is-danger' : 'is-primary'), o.confirmLabel || (withCancel ? 'Confirm' : 'OK')); ok.type = 'button'; btns.appendChild(ok);
+ d.appendChild(btns); back.appendChild(d); document.body.appendChild(back);
+ function done(v) { document.removeEventListener('keydown', onKey, true); back.remove(); try { prev && prev.focus && prev.focus(); } catch { /* */ } resolve(v); }
+ function onKey(e) {
+ if (e.key === 'Escape') { e.preventDefault(); done(false); }
+ else if (e.key === 'Tab') { const f = [cancel, ok].filter(Boolean); const i = f.indexOf(document.activeElement); e.preventDefault(); f[(i + (e.shiftKey ? f.length - 1 : 1)) % f.length].focus(); }
+ }
+ document.addEventListener('keydown', onKey, true);
+ back.addEventListener('mousedown', (e) => { if (e.target === back) done(false); });
+ ok.addEventListener('click', () => done(true));
+ if (cancel) cancel.addEventListener('click', () => done(false));
+ (o.danger && cancel ? cancel : ok).focus();
+ });
+ }
+
+ const api = {
+ toast,
+ success: (m, o) => toast(m, Object.assign({}, o, { type: 'success' })),
+ error: (m, o) => toast(m, Object.assign({}, o, { type: 'error' })),
+ warning: (m, o) => toast(m, Object.assign({}, o, { type: 'warning' })),
+ confirm: (o) => dialog(typeof o === 'string' ? { message: o } : (o || {}), true),
+ alert: (o) => dialog(typeof o === 'string' ? { message: o } : (o || {}), false).then(() => undefined),
+ };
+ root.OpenVibeUI = api;
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
+})(typeof window !== 'undefined' ? window : globalThis);
@@ -208,6 +208,7 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
@@ -208,6 +208,7 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
<button class="admin-tab-btn" onclick="showTab('vkeys',this)"><i class="fa-solid fa-key"></i> Keys</button>
<button class="admin-tab-btn" onclick="showTab('vkeys',this)"><i class="fa-solid fa-key"></i> Keys</button>
<button class="admin-tab-btn" onclick="showTab('audit',this)"><i class="fa-solid fa-clipboard-list"></i> Audit</button>
<button class="admin-tab-btn" onclick="showTab('audit',this)"><i class="fa-solid fa-clipboard-list"></i> Audit</button>
<button class="admin-tab-btn" onclick="showTab('analytics',this)"><i class="fa-solid fa-chart-pie"></i> Analytics</button>
<button class="admin-tab-btn" onclick="showTab('analytics',this)"><i class="fa-solid fa-chart-pie"></i> Analytics</button>
+ <button class="admin-tab-btn owner-only" onclick="showTab('domains',this)"><i class="fa-solid fa-globe"></i> Domains</button>
<button class="admin-tab-btn owner-only" onclick="showTab('deploy',this)"><i class="fa-solid fa-server"></i> Deploy</button>
<button class="admin-tab-btn owner-only" onclick="showTab('deploy',this)"><i class="fa-solid fa-server"></i> Deploy</button>
<button class="admin-tab-btn" onclick="showTab('ssh',this)"><i class="fa-solid fa-terminal"></i> SSH</button>
<button class="admin-tab-btn" onclick="showTab('ssh',this)"><i class="fa-solid fa-terminal"></i> SSH</button>
</div>
</div>
@@ -681,6 +682,9 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
@@ -681,6 +682,9 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
</div>
</div>
<!-- SSH access provisioning -->
<!-- SSH access provisioning -->
+ <div class="section" id="sec-domains">
+ <div id="domains-content"><div class="loading">Loading...</div></div>
+ </div>
<div class="section" id="sec-ssh">
<div class="section" id="sec-ssh">
<div id="ssh-content"><div class="loading">Loading...</div></div>
<div id="ssh-content"><div class="loading">Loading...</div></div>
</div>
</div>
@@ -769,7 +773,7 @@ let currentTab = 'dashboard';
@@ -769,7 +773,7 @@ let currentTab = 'dashboard';
// Tabs restricted to the network owner (API keys / money / infrastructure / core settings).
// Tabs restricted to the network owner (API keys / money / infrastructure / core settings).
// NOTE: 'ai' is NOT here — admins may use its analytics/explorer tools; only the
// NOTE: 'ai' is NOT here — admins may use its analytics/explorer tools; only the
// AI configuration form inside it is owner-gated (see loadAi).
// AI configuration form inside it is owner-gated (see loadAi).
-const OWNER_ONLY_TABS = new Set(['deploy', 'payments', 'cashouts', 'storage', 'email', 'settings']);
+const OWNER_ONLY_TABS = new Set(['deploy', 'domains', 'payments', 'cashouts', 'storage', 'email', 'settings']);
const tabLoaders = {
const tabLoaders = {
dashboard: loadDashboard,
dashboard: loadDashboard,
users: loadUsers,
users: loadUsers,
@@ -791,9 +795,60 @@ const tabLoaders = {
@@ -791,9 +795,60 @@ const tabLoaders = {
audit: loadAudit,
audit: loadAudit,
analytics: loadAnalytics,
analytics: loadAnalytics,
deploy: loadDeployOverview,
deploy: loadDeployOverview,
+ domains: loadDomains,
ssh: loadSSH,
ssh: loadSSH,
};
};
+
+// ── Tool domains (owner only; the server enforces it) ───────────────
+let _domCatalog = null;
+async function loadDomains() {
+ const el = document.getElementById('domains-content');
+ try {
+ const [list, cat] = await Promise.all([api('/api/admin/domains'), _domCatalog ? Promise.resolve(_domCatalog) : api('/api/admin/domains/catalog')]);
+ _domCatalog = cat;
+ const byTool = {}; list.domains.forEach(d => { (byTool[d.tool_id] = byTool[d.tool_id] || []).push(d); });
+ const opts = [...cat.families.map(f => ({ id: f.id, name: f.name + ' (family)' })), ...cat.tools.map(t => ({ id: t.id, name: t.name }))];
+ const roleBadge = r => `<span class="badge ${r === 'canonical' ? 'badge-success' : r === 'short' ? 'badge-info' : ''}">${esc(r)}</span>`;
+ el.innerHTML = `
+ <div class="card"><h3><i class="fa-solid fa-globe"></i> Tool domains</h3>
+ <p class="muted">Every tool has a <b>canonical</b> host (used in links, sitemaps and canonical tags), a <b>short</b> host people type, and any number of <b>aliases</b> that redirect to the short one. Custom domains work the same way: point DNS at the server, add the host here, then run <code>add-custom-domain.sh</code> on the host for nginx and TLS. Tools picks changes up within a minute.${cat.source !== 'live' ? ' <br><span class="badge badge-warning">Tools catalog unreachable: showing the built-in list</span>' : ''}</p>
+ <div class="form-group" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;align-items:end">
+ <div class="form-field"><label for="dom-tool">Tool</label><select id="dom-tool">${opts.map(o => `<option value="${esc(o.id)}">${esc(o.name)}</option>`).join('')}</select></div>
+ <div class="form-field"><label for="dom-host">Host</label><input id="dom-host" placeholder="youtubedownloadonline.com" autocomplete="off" spellcheck="false"></div>
+ <div class="form-field"><label for="dom-role">Role</label><select id="dom-role"><option value="alias">alias (redirects)</option><option value="short">short (what people type)</option><option value="canonical">canonical (SEO host)</option></select></div>
+ <div class="form-field"><label for="dom-note">Note</label><input id="dom-note" maxlength="500"></div>
+ <div><button class="btn btn-outline" onclick="checkDomainDns()">Check DNS</button> <button class="btn btn-primary" onclick="addDomain()">Add</button></div>
+ </div>
+ <div id="dom-check" class="muted" style="margin-top:8px"></div>
+ </div>
+ <div class="card"><h3>Overrides</h3>
+ ${list.domains.length ? `<div style="overflow-x:auto"><table class="table" style="width:100%"><thead><tr><th>Tool</th><th>Host</th><th>Role</th><th>On</th><th>Note</th><th></th></tr></thead><tbody>
+ ${list.domains.map(d => `<tr><td>${esc(d.tool_id)}${d.tool_known === false ? ' <span class="badge badge-warning">unknown tool</span>' : ''}</td>
+ <td><a href="https://${esc(d.host)}/" target="_blank" rel="noopener">${esc(d.host)}</a></td><td>${roleBadge(d.role)}</td>
+ <td><input type="checkbox" ${d.enabled ? 'checked' : ''} onchange="updateDomain(${d.id},{enabled:this.checked})" aria-label="Enabled"></td>
+ <td>${esc(d.note || '')}</td>
+ <td style="white-space:nowrap"><select onchange="updateDomain(${d.id},{role:this.value})" aria-label="Role">${['canonical','short','alias'].map(r => `<option ${r === d.role ? 'selected' : ''}>${r}</option>`).join('')}</select>
+ <button class="btn btn-sm btn-danger" onclick="deleteDomain(${d.id},'${esc(d.host)}')">Remove</button></td></tr>`).join('')}
+ </tbody></table></div>` : '<p class="muted">No overrides yet. Every tool is using its built-in hosts.</p>'}
+ </div>
+ <div class="card"><h3>Built-in hosts</h3><div style="overflow-x:auto"><table class="table" style="width:100%"><thead><tr><th>Tool</th><th>Canonical</th><th>Short</th><th>Aliases</th></tr></thead><tbody>
+ ${cat.tools.map(t => `<tr><td>${esc(t.name)} <small class="muted">${esc(t.id)}</small></td><td>${esc(t.hosts?.canonical || '')}</td><td>${esc(t.hosts?.short || '')}</td><td>${esc((t.hosts?.aliases || []).join(', '))}</td></tr>`).join('')}
+ </tbody></table></div></div>`;
+ } catch (e) { el.innerHTML = `<div class="card"><p class="muted">Could not load domains: ${esc(e.message)}</p></div>`; }
+}
+async function addDomain() {
+ const body = { tool_id: document.getElementById('dom-tool').value, host: document.getElementById('dom-host').value, role: document.getElementById('dom-role').value, note: document.getElementById('dom-note').value };
+ try { const r = await api('/api/admin/domains', { method: 'POST', body }); toast('Domain added', 'success'); (r.warnings || []).forEach(w => toast(w, 'warning')); loadDomains(); } catch (e) { toast(e.message, 'error'); }
+}
+async function updateDomain(id, patch) { try { const r = await api('/api/admin/domains/' + id, { method: 'PUT', body: patch }); (r.warnings || []).forEach(w => toast(w, 'warning')); toast('Saved', 'success'); } catch (e) { toast(e.message, 'error'); } loadDomains(); }
+async function deleteDomain(id, host) { if (!confirm(`Remove ${host}?`)) return; try { await api('/api/admin/domains/' + id, { method: 'DELETE' }); toast('Removed', 'success'); } catch (e) { toast(e.message, 'error'); } loadDomains(); }
+async function checkDomainDns() {
+ const out = document.getElementById('dom-check'); out.textContent = 'Checking…';
+ try { const r = await api('/api/admin/domains/check', { method: 'POST', body: { host: document.getElementById('dom-host').value } }); const c = r.check || {};
+ out.textContent = (c.status === 'ready' ? 'Ready. ' : '') + (c.message || c.status || ''); } catch (e) { out.textContent = e.message; }
+}
+
function showTab(id, btn) {
function showTab(id, btn) {
if (!tabLoaders[id]) id = 'dashboard';
if (!tabLoaders[id]) id = 'dashboard';
// Owner-only tabs (secrets / money / infra) — bounce non-owners to dashboard.
// Owner-only tabs (secrets / money / infra) — bounce non-owners to dashboard.
@@ -153,7 +153,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
@@ -153,7 +153,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
<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>
<h1>One Account.<br><span class="grad">All of OpenVibe.</span></h1>
- <p class="sub">Live streaming, online tools, games, media, pastes and community — one account works across the entire network. Sign in once and every OpenVibe site knows you.</p>
+ <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>
<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>
@@ -163,64 +163,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
@@ -163,64 +163,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
</div>
</div>
</section>
</section>
- <!-- Services -->
- <section class="services">
- <h2>The Network</h2>
- <p class="lead">Every service, one identity. Sign in once, vibe everywhere.</p>
- <div class="card-grid">
- <a class="svc-card" href="https://openvibe.live" style="--card-accent:#e74c3c;">
- <div class="svc-icon"><i class="fa-solid fa-tower-broadcast"></i></div>
- <div class="svc-name">OpenVibe.Live</div>
- <div class="svc-desc">Open live streaming. Go live with OBS or your browser, chat, clip, restream, and tip with Vibes.</div>
- <span class="svc-go">openvibe.live <i class="fa-solid fa-arrow-right"></i></span>
- </a>
- <a class="svc-card" href="https://openvibe.tools" style="--card-accent:#8b5cf6;">
- <div class="svc-icon"><i class="fa-solid fa-screwdriver-wrench"></i></div>
- <div class="svc-name">OpenVibe.Tools</div>
- <div class="svc-desc">Hundreds of online tools — network diagnostics, dev utilities, converters, text, images, PDFs, and more.</div>
- <span class="svc-go">openvibe.tools <i class="fa-solid fa-arrow-right"></i></span>
- </a>
- <a class="svc-card" href="https://openvibe.games" style="--card-accent:#2ecc71;">
- <div class="svc-icon"><i class="fa-solid fa-gamepad"></i></div>
- <div class="svc-name">OpenVibe.Games</div>
- <div class="svc-desc">Play Scraplandia and community games, paint the shared canvas, climb the leaderboards, spend your OpenCoins.</div>
- <span class="svc-go">openvibe.games <i class="fa-solid fa-arrow-right"></i></span>
- </a>
- <a class="svc-card" href="https://openvibe.media" style="--card-accent:#22d3ee;">
- <div class="svc-icon"><i class="fa-solid fa-photo-film"></i></div>
- <div class="svc-name">OpenVibe.Media</div>
- <div class="svc-desc">The network's media backbone — VODs, clips, pastes, thumbnails, and file hosting for every OpenVibe app.</div>
- <span class="svc-go">openvibe.media <i class="fa-solid fa-arrow-right"></i></span>
- </a>
- <a class="svc-card" href="https://openvibe.community" style="--card-accent:#f59e0b;">
- <div class="svc-icon"><i class="fa-solid fa-people-group"></i></div>
- <div class="svc-name">OpenVibe.Community</div>
- <div class="svc-desc">Pastes, posts and the people of the network — the home of user-made things, with spaces and threads on the way.</div>
- <span class="svc-go">openvibe.community <i class="fa-solid fa-arrow-right"></i></span>
- </a>
- </div>
- </section>
-
- <!-- The rest of the network — rooms that exist as domains today and open as they are built -->
- <section class="services" id="network">
- <h2>More rooms in the network</h2>
- <p class="lead">Each one is a domain OpenVibe already owns, opening as it is built — same account, same themes, same navbar.</p>
- <div class="card-grid soon-grid">
- <a class="svc-card soon" href="https://openvibe.chat" style="--card-accent:#a78bfa;"><span class="soon-tag">Coming</span><div class="svc-icon"><i class="fa-solid fa-comments"></i></div><div class="svc-name">OpenVibe.Chat</div><div class="svc-desc">Global rooms, DMs, calls and TTS — one chat for every site.</div></a>
- <a class="svc-card soon" href="https://openvibe.codes" style="--card-accent:#22d3ee;"><span class="soon-tag">Coming</span><div class="svc-icon"><i class="fa-solid fa-code"></i></div><div class="svc-name">OpenVibe.Codes</div><div class="svc-desc">Developer portal: APIs, SDKs, mods and vibe-coding sessions.</div></a>
- <a class="svc-card soon" href="https://openvibe.blog" style="--card-accent:#f472b6;"><span class="soon-tag">Coming</span><div class="svc-icon"><i class="fa-solid fa-pen-nib"></i></div><div class="svc-name">OpenVibe.Blog</div><div class="svc-desc">The network's blog and yours — long-form, media-rich, yours to keep.</div></a>
- <a class="svc-card soon" href="https://openvibe.wiki" style="--card-accent:#34d399;"><span class="soon-tag">Coming</span><div class="svc-icon"><i class="fa-solid fa-book"></i></div><div class="svc-name">OpenVibe.Wiki</div><div class="svc-desc">Source-backed knowledge spaces you can build and edit together.</div></a>
- <a class="svc-card soon" href="https://openvibe.news" style="--card-accent:#fb923c;"><span class="soon-tag">Coming</span><div class="svc-icon"><i class="fa-solid fa-newspaper"></i></div><div class="svc-name">OpenVibe.News</div><div class="svc-desc">Sourced, summarised, many angles — you pick the framing.</div></a>
- <a class="svc-card soon" href="https://openvibe.reviews" style="--card-accent:#facc15;"><span class="soon-tag">Coming</span><div class="svc-icon"><i class="fa-solid fa-star-half-stroke"></i></div><div class="svc-name">OpenVibe.Reviews</div><div class="svc-desc">What people actually say about a thing, across sources, with provenance.</div></a>
- <a class="svc-card soon" href="https://openvibe.tips" style="--card-accent:#4ade80;"><span class="soon-tag">Coming</span><div class="svc-icon"><i class="fa-solid fa-hand-holding-heart"></i></div><div class="svc-name">OpenVibe.Tips</div><div class="svc-desc">Tips, goals, alerts and paid interactions for creators.</div></a>
- <a class="svc-card soon" href="https://openvibe.vip" style="--card-accent:#e879f9;"><span class="soon-tag">Coming</span><div class="svc-icon"><i class="fa-solid fa-crown"></i></div><div class="svc-name">OpenVibe.VIP</div><div class="svc-desc">Memberships and perks — support the creators and the network.</div></a>
- <a class="svc-card soon" href="https://openvibe.trade" style="--card-accent:#60a5fa;"><span class="soon-tag">Coming</span><div class="svc-icon"><i class="fa-solid fa-chart-line"></i></div><div class="svc-name">OpenVibe.Trade</div><div class="svc-desc">Watchlists, signals and community market intelligence.</div></a>
- <a class="svc-card soon" href="https://openvibe.deals" style="--card-accent:#f97316;"><span class="soon-tag">Coming</span><div class="svc-icon"><i class="fa-solid fa-tags"></i></div><div class="svc-name">OpenVibe.Deals</div><div class="svc-desc">Deals worth seeing, voted on by people, with alerts.</div></a>
- <a class="svc-card soon" href="https://openvibe.coupons" style="--card-accent:#fbbf24;"><span class="soon-tag">Coming</span><div class="svc-icon"><i class="fa-solid fa-ticket"></i></div><div class="svc-name">OpenVibe.Coupons</div><div class="svc-desc">Codes that actually work, checked by the community.</div></a>
- <a class="svc-card soon" href="https://openvibe.host" style="--card-accent:#94a3b8;"><span class="soon-tag">Coming</span><div class="svc-icon"><i class="fa-solid fa-server"></i></div><div class="svc-name">OpenVibe.Host</div><div class="svc-desc">Hosting and deployment for OpenVibe services, bots and mods.</div></a>
- <a class="svc-card soon" href="https://openre.stream" style="--card-accent:#ef4444;"><span class="soon-tag">Coming</span><div class="svc-icon"><i class="fa-solid fa-satellite-dish"></i></div><div class="svc-name">OpenRe.Stream</div><div class="svc-desc">Ingest and restream infrastructure — RTMP, WHIP, SRT — behind OpenVibe.Live.</div></a>
- </div>
- </section>
+ <!--OV:HOME-->
<!-- SSO strip -->
<!-- SSO strip -->
<section class="sso-strip">
<section class="sso-strip">
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
+'use strict';
+// Server-side client for the Tools catalog (docs/shared-contracts.md §1).
+// One cached copy per process: refreshed every 5 minutes, the last good copy kept when Tools is
+// unreachable, and a built-in family list when there has never been a good copy.
+const FALLBACK = require('./fallback-catalog');
+
+const TTL_MS = 5 * 60_000;
+const RETRY_MS = 30_000; // after a failure, do not hammer Tools on every request
+const TIMEOUT_MS = 4000;
+
+let state = { catalog: null, fetchedAt: 0, failedAt: 0, error: null };
+let inflight = null;
+let fetchImpl = (...a) => fetch(...a);
+const listeners = new Set();
+
+function catalogUrl() {
+ return `${(process.env.OV_TOOLS_INTERNAL_URL || 'http://127.0.0.1:4001').replace(/\/+$/, '')}/api/catalog.json`;
+}
+
+function normalise(raw) {
+ if (!raw || !Array.isArray(raw.families) || !Array.isArray(raw.tools)) throw new Error('Catalog has no families/tools');
+ const str = (v, max) => (typeof v === 'string' ? v.slice(0, max) : '');
+ const families = raw.families.filter(f => f && typeof f.id === 'string' && f.name).map(f => ({
+ id: str(f.id, 40), name: str(f.name, 80), tagline: str(f.tagline, 160), description: str(f.description, 600),
+ icon: str(f.icon, 40), url: str(f.url, 300), path: str(f.path, 120),
+ }));
+ const tools = raw.tools.filter(t => t && typeof t.id === 'string' && t.name).map(t => ({
+ id: str(t.id, 40), family: str(t.family, 40), name: str(t.name, 80), tagline: str(t.tagline, 160),
+ description: str(t.description, 600), icon: str(t.icon, 40),
+ keywords: Array.isArray(t.keywords) ? t.keywords.filter(k => typeof k === 'string').slice(0, 20).map(k => k.slice(0, 60)) : [],
+ hosts: {
+ canonical: str(t.hosts && t.hosts.canonical, 253), short: str(t.hosts && t.hosts.short, 253),
+ aliases: Array.isArray(t.hosts && t.hosts.aliases) ? t.hosts.aliases.filter(h => typeof h === 'string').slice(0, 20) : [],
+ },
+ url: str(t.url, 300),
+ }));
+ if (!families.length) throw new Error('Catalog has no families');
+ return { updated: str(raw.updated, 40) || new Date().toISOString(), families, tools };
+}
+
+async function refresh() {
+ if (inflight) return inflight;
+ inflight = (async () => {
+ try {
+ const res = await fetchImpl(catalogUrl(), { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(TIMEOUT_MS) });
+ if (!res.ok) throw new Error(`Tools catalog answered ${res.status}`);
+ const next = normalise(await res.json());
+ const changed = !state.catalog || state.catalog.updated !== next.updated
+ || state.catalog.tools.length !== next.tools.length || state.catalog.families.length !== next.families.length;
+ state = { catalog: next, fetchedAt: Date.now(), failedAt: 0, error: null };
+ if (changed) for (const fn of listeners) { try { fn(next); } catch { /* listener's problem */ } }
+ } catch (err) {
+ state = { ...state, failedAt: Date.now(), error: err.message };
+ } finally { inflight = null; }
+ return state;
+ })();
+ return inflight;
+}
+
+/**
+ * The catalog to use right now.
+ * @returns {Promise<{ catalog: object, source: 'live'|'stale'|'fallback', error: string|null }>}
+ */
+async function getCatalog() {
+ const now = Date.now();
+ const fresh = state.catalog && now - state.fetchedAt < TTL_MS;
+ const backingOff = state.failedAt && now - state.failedAt < RETRY_MS;
+ if (!fresh && !backingOff) await refresh();
+ return peek();
+}
+
+/** Same answer without touching the network. */
+function peek() {
+ if (state.catalog) {
+ const live = !state.failedAt && Date.now() - state.fetchedAt < TTL_MS * 2;
+ return { catalog: state.catalog, source: live ? 'live' : 'stale', error: state.error };
+ }
+ return { catalog: FALLBACK, source: 'fallback', error: state.error };
+}
+
+/** fn(catalog) runs whenever a refresh brings a different catalog. Returns an unsubscribe. */
+function onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); }
+
+// Tests swap the transport and reset the cache.
+function _setFetch(fn) { fetchImpl = fn || ((...a) => fetch(...a)); }
+function _reset() { state = { catalog: null, fetchedAt: 0, failedAt: 0, error: null }; inflight = null; }
+
+module.exports = { getCatalog, peek, refresh, onChange, catalogUrl, normalise, FALLBACK, _setFetch, _reset };
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -0,0 +1,73 @@
+'use strict';
+// "Is this custom domain ready?" — resolves the domain and compares it with where this server
+// answers. The expected addresses come from PUBLIC_IP / MEDIASOUP_ANNOUNCED_IP, or from whatever
+// openvibe.tools resolves to when neither is set.
+const dns = require('dns').promises;
+const net = require('net');
+
+const TIMEOUT_MS = 5000;
+// Cloudflare's published IPv4 ranges: a proxied domain resolves to these, not to the origin.
+const CF_V4 = ['173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22', '141.101.64.0/18', '108.162.192.0/18',
+ '190.93.240.0/20', '188.114.96.0/20', '197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13', '104.24.0.0/14',
+ '172.64.0.0/13', '131.0.72.0/22'];
+const CF_V6 = ['2400:cb00:', '2606:4700:', '2803:f800:', '2405:b500:', '2405:8100:', '2a06:98c', '2c0f:f248:'];
+
+function v4ToInt(ip) { return ip.split('.').reduce((n, o) => (n * 256) + Number(o), 0); }
+function isCloudflare(ip) {
+ if (net.isIPv4(ip)) {
+ const n = v4ToInt(ip);
+ return CF_V4.some(cidr => { const [base, bits] = cidr.split('/'); const size = 2 ** (32 - Number(bits)); const b = v4ToInt(base); return n >= b && n < b + size; });
+ }
+ const low = String(ip).toLowerCase();
+ return CF_V6.some(p => low.startsWith(p));
+}
+
+function withTimeout(promise) {
+ let timer;
+ return Promise.race([
+ promise,
+ new Promise((_, reject) => { timer = setTimeout(() => reject(Object.assign(new Error('DNS lookup timed out'), { code: 'ETIMEOUT' })), TIMEOUT_MS); }),
+ ]).finally(() => clearTimeout(timer));
+}
+
+async function resolveAll(host, resolver = dns) {
+ const grab = async (fn) => {
+ try { return { records: await withTimeout(fn()), error: null }; }
+ catch (err) { return { records: [], error: ['ENODATA', 'ENOTFOUND', 'ENODOMAIN'].includes(err.code) ? null : (err.code || err.message) }; }
+ };
+ const [a, aaaa, cname] = await Promise.all([grab(() => resolver.resolve4(host)), grab(() => resolver.resolve6(host)), grab(() => resolver.resolveCname(host))]);
+ return { a: a.records, aaaa: aaaa.records, cname: cname.records, error: a.error || aaaa.error || null };
+}
+
+async function expectedAddresses(env = process.env, resolver = dns) {
+ const fromEnv = [env.PUBLIC_IP, env.MEDIASOUP_ANNOUNCED_IP].map(v => String(v || '').trim()).filter(v => net.isIP(v));
+ if (fromEnv.length) return { addresses: [...new Set(fromEnv)], source: 'env' };
+ const ref = await resolveAll('openvibe.tools', resolver);
+ return { addresses: [...ref.a, ...ref.aaaa], source: 'openvibe.tools' };
+}
+
+/**
+ * @returns {Promise<{ host, status: 'ready'|'proxied'|'mismatch'|'no_records'|'error', points_here, a, aaaa, cname, expected, expected_source, message }>}
+ */
+async function checkDomain(host, { env = process.env, resolver = dns } = {}) {
+ const [found, expected] = await Promise.all([resolveAll(host, resolver), expectedAddresses(env, resolver)]);
+ const addrs = [...found.a, ...found.aaaa];
+ const base = { host, a: found.a, aaaa: found.aaaa, cname: found.cname, expected: expected.addresses, expected_source: expected.source };
+ if (!addrs.length) {
+ if (found.error) return { ...base, status: 'error', points_here: false, message: `DNS lookup failed (${found.error})` };
+ return { ...base, status: 'no_records', points_here: false, message: 'No A or AAAA record yet. Add one pointing at this server, then check again.' };
+ }
+ const want = new Set(expected.addresses);
+ const hit = addrs.filter(ip => want.has(ip));
+ // Every address family the domain publishes has to land here: an A record that is right next
+ // to an AAAA that points elsewhere still sends half the visitors to the wrong machine.
+ const v4ok = !found.a.length || found.a.some(ip => want.has(ip));
+ const v6ok = !found.aaaa.length || found.aaaa.some(ip => want.has(ip)) || !expected.addresses.some(net.isIPv6);
+ if (hit.length && v4ok && v6ok) return { ...base, status: 'ready', points_here: true, message: `Points at this server (${hit.join(', ')}).` };
+ if (addrs.some(isCloudflare)) {
+ return { ...base, status: 'proxied', points_here: null, message: 'Resolves to Cloudflare. The proxy hides the origin, so this check cannot see where it lands — make sure the Cloudflare DNS record targets this server.' };
+ }
+ return { ...base, status: 'mismatch', points_here: false, message: `Resolves to ${addrs.join(', ')}, not to this server (${expected.addresses.join(', ') || 'address unknown'}).` };
+}
+
+module.exports = { checkDomain, expectedAddresses, resolveAll, isCloudflare };
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+// Built-in copy of the tool families, used only when the Tools catalog has never been reachable
+// since this process started. Deliberately short: every family and its best-known tools, all on
+// hosts that exist today. The live catalog (openvibe.tools/api/catalog.json) replaces it.
+const Z = 'openvibe.tools';
+const tool = (family, id, name, tagline, icon, host) => ({
+ id, family, name, tagline, description: '', keywords: [], icon,
+ hosts: { canonical: `${host || id}.${Z}`, short: '', aliases: [] },
+ url: `https://${host || id}.${Z}/`,
+});
+const family = (id, name, tagline, icon, host, path) => ({
+ id, name, tagline, description: '', icon, url: host ? `https://${host}.${Z}/` : `https://${Z}${path}`, path,
+});
+
+module.exports = {
+ updated: '2026-09-18T00:00:00Z',
+ builtin: true,
+ families: [
+ family('net', 'Network Tools', 'Look up, test and debug anything on the internet', 'network', 'net', '/network-tools'),
+ family('dev', 'Developer Tools', 'Formatters, encoders, validators and generators', 'code', 'dev', '/developer-tools'),
+ family('img', 'Image Tools', 'Convert, resize, compress and clean up images', 'image', 'img', '/image-tools'),
+ family('audio', 'Audio Tools', 'Convert, trim and reshape audio', 'audio', 'audio', '/audio-tools'),
+ family('docs', 'PDF & Document Tools', 'Merge, split, protect and convert PDFs and documents', 'pdf', 'docs', '/pdf-tools'),
+ family('text', 'Text & Logo Tools', 'Fancy text, case and count tools, wordmarks and badges', 'text', 'text', '/text-tools'),
+ family('media', 'Video Tools', 'Save and convert video', 'youtube', null, '/video-tools'),
+ family('maps', 'Maps & Food', 'Maps, places and what to eat', 'map', 'maps', '/maps-and-food'),
+ ],
+ tools: [
+ tool('net', 'dns', 'DNS Lookup', 'Every record type for any domain', 'dns'),
+ tool('net', 'whois', 'Whois Lookup', 'Who registered a domain, and when', 'whois'),
+ tool('net', 'myip', 'What Is My IP', 'Your public address and what it reveals', 'ip'),
+ tool('net', 'ssl', 'SSL Certificate Checker', 'Expiry, issuer and chain of any certificate', 'ssl'),
+ tool('net', 'ping', 'Ping Test', 'Reachability and round-trip time', 'ping'),
+ tool('net', 'traceroute', 'Traceroute', 'Every hop between us and a host', 'network'),
+ tool('net', 'port', 'Port Checker', 'Is a port open from the outside', 'network'),
+ tool('net', 'headers', 'HTTP Header Checker', 'The response headers a URL sends', 'network'),
+
+ tool('dev', 'jsonfmt', 'JSON Formatter', 'Format, validate and minify JSON', 'json'),
+ tool('dev', 'base64', 'Base64 Encoder & Decoder', 'Text to Base64 and back', 'code'),
+ tool('dev', 'jwt', 'JWT Decoder', 'Read a token\'s header, claims and expiry', 'code'),
+ tool('dev', 'regex', 'Regex Tester', 'Try a pattern against real text', 'code'),
+ tool('dev', 'uuid', 'UUID Generator', 'Random v4 identifiers, one or many', 'code'),
+ tool('dev', 'hash', 'Hash Generator', 'SHA-1, SHA-256 and SHA-512 of any text', 'code'),
+ tool('dev', 'timestamp', 'Unix Timestamp Converter', 'Epoch seconds to dates and back', 'code'),
+ tool('dev', 'pastes', 'Pastes', 'Share code and text with a link', 'paste'),
+
+ tool('img', 'convert', 'Image Converter', 'Between PNG, JPG, WebP, HEIC, TIFF and SVG', 'image'),
+ tool('img', 'resize', 'Image Resizer', 'Exact pixels or a percentage', 'image'),
+ tool('img', 'compress', 'Image Compressor', 'Smaller files that look the same', 'image'),
+ tool('img', 'crop', 'Image Cropper', 'Cut to a shape or an aspect ratio', 'image'),
+ tool('img', 'png', 'PNG Converter', 'Any image to PNG', 'image'),
+ tool('img', 'webp', 'WebP Converter', 'To and from WebP', 'image'),
+ tool('img', 'heic', 'HEIC Converter', 'iPhone photos to JPG or PNG', 'image'),
+ tool('img', 'favicon', 'Favicon Generator', 'Every icon size a site needs', 'image'),
+
+ tool('audio', 'mp3', 'MP3 Converter', 'Any audio file to MP3', 'audio'),
+ tool('audio', 'wav', 'WAV Converter', 'Any audio file to WAV', 'audio'),
+ tool('audio', 'trim', 'Audio Trimmer', 'Cut a clip out of a recording', 'audio'),
+ tool('audio', 'speed', 'Audio Speed Changer', 'Play a track faster or slower', 'audio'),
+ tool('audio', 'pitch', 'Pitch Shifter', 'Move a track up or down in key', 'audio'),
+ tool('audio', 'vocal', 'Vocal Remover', 'Split vocals from the instrumental', 'audio'),
+ tool('audio', 'normalize', 'Audio Normalizer', 'Even out the loudness', 'audio'),
+
+ tool('docs', 'mergepdf', 'Merge PDF', 'Several PDFs into one', 'pdf'),
+ tool('docs', 'splitpdf', 'Split PDF', 'Pull pages out of a PDF', 'pdf'),
+ tool('docs', 'rotatepdf', 'Rotate PDF', 'Turn pages the right way up', 'pdf'),
+ tool('docs', 'protectpdf', 'Protect PDF', 'Put a password on a PDF', 'pdf'),
+ tool('docs', 'watermarkpdf', 'Watermark PDF', 'Stamp every page', 'pdf'),
+ tool('docs', 'pdf2jpg', 'PDF to JPG', 'Every page as an image', 'pdf'),
+
+ tool('text', 'fancy', 'Fancy Text', 'Unicode styles you can paste anywhere', 'text'),
+ tool('text', 'case', 'Case Converter', 'UPPER, lower, Title and more', 'text'),
+ tool('text', 'count', 'Word Counter', 'Words, characters and reading time', 'text'),
+ tool('text', 'symbols', 'Symbols', 'Copy-and-paste symbols and arrows', 'text'),
+ tool('text', 'kaomoji', 'Kaomoji', 'Japanese emoticons, ready to copy', 'text'),
+ tool('text', 'wordmark', 'Wordmark Maker', 'A text logo in a minute', 'logo'),
+ tool('text', 'logo', 'Logo Maker', 'A mark and a name, exported clean', 'logo'),
+ tool('text', 'badge', 'Badge Maker', 'Badges and stickers from text', 'logo'),
+ tool('text', 'thumbnail', 'Thumbnail Maker', 'Covers and channel art from text', 'logo'),
+
+ tool('media', 'yt', 'YouTube Downloader', 'Save videos and audio from YouTube', 'youtube'),
+
+ tool('maps', 'maps', 'Maps', 'Find places and get around', 'map'),
+ tool('maps', 'food', 'Food', 'What to eat, and where', 'food'),
+ ],
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,258 @@
@@ -0,0 +1,258 @@
+'use strict';
+// ═══════════════════════════════════════════════════════════════
+// Tool domain registry (docs/shared-contracts.md §1).
+//
+// OpenVibe.Tools owns the catalog of tools and each tool's default hosts. This table holds the
+// owner's overrides: "this host is the canonical / short / alias host of that tool" — including
+// custom domains that are not under openvibe.tools at all. Tools reads the public list
+// server-side and merges it over its code defaults.
+//
+// GET /api/domains public, CORS *, max-age=60 — enabled rows only
+// GET /api/admin/domains owner — every row + catalog status
+// POST /api/admin/domains owner — { tool_id, host, role, enabled?, note? }
+// PUT /api/admin/domains/:id owner — any of the same fields
+// DELETE /api/admin/domains/:id owner
+// GET /api/admin/domains/catalog owner — tools + families for the admin pickers
+// POST /api/admin/domains/check owner — { host } → DNS readiness
+//
+// Invariants (enforced here, inside a transaction): a host belongs to exactly one tool; a tool has
+// at most one enabled canonical and one enabled short — setting a new one demotes the previous to
+// alias. Every change lands in audit_log.
+// ═══════════════════════════════════════════════════════════════
+const express = require('express');
+const { isOwner } = require('../auth/owner-guard');
+const { ROLES, validateHost, validToolIdSyntax } = require('./validate');
+const toolsCatalog = require('./catalog');
+const dnsCheck = require('./dns-check');
+
+const NOTE_MAX = 500;
+
+function ensureSchema(db) {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS tool_domains (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ tool_id TEXT NOT NULL,
+ host TEXT NOT NULL UNIQUE,
+ role TEXT NOT NULL DEFAULT 'alias' CHECK (role IN ('canonical', 'short', 'alias')),
+ enabled INTEGER NOT NULL DEFAULT 1,
+ note TEXT,
+ created_by INTEGER,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+ CREATE INDEX IF NOT EXISTS idx_tool_domains_tool ON tool_domains(tool_id, role, enabled);
+ `);
+}
+
+function iso(sqliteTime) {
+ if (!sqliteTime) return null;
+ const d = new Date(String(sqliteTime).replace(' ', 'T') + (/[zZ]|[+-]\d\d:?\d\d$/.test(sqliteTime) ? '' : 'Z'));
+ return isNaN(d) ? null : d.toISOString();
+}
+
+/** Which catalog tool already answers on this host by default (so it cannot be given to another). */
+function catalogOwnerOf(host, catalog) {
+ for (const t of catalog.tools) {
+ const hosts = [t.hosts.canonical, t.hosts.short, ...(t.hosts.aliases || [])];
+ try { if (t.url) hosts.push(new URL(t.url).hostname); } catch { /* */ }
+ if (hosts.includes(host)) return t.id;
+ }
+ return null;
+}
+
+function createDomainRoutes(db, requireAuth, opts = {}) {
+ ensureSchema(db);
+ const catalogSource = opts.catalog || toolsCatalog;
+ const checkDomain = opts.checkDomain || dnsCheck.checkDomain;
+ const onChange = typeof opts.onChange === 'function' ? opts.onChange : () => {};
+
+ const q = {
+ publicList: db.prepare(`SELECT tool_id, host, role FROM tool_domains WHERE enabled = 1
+ ORDER BY tool_id, CASE role WHEN 'canonical' THEN 0 WHEN 'short' THEN 1 ELSE 2 END, host`),
+ lastChange: db.prepare('SELECT MAX(updated_at) AS t FROM tool_domains'),
+ all: db.prepare(`SELECT d.*, u.username AS created_by_username FROM tool_domains d LEFT JOIN users u ON u.id = d.created_by
+ ORDER BY d.tool_id, CASE d.role WHEN 'canonical' THEN 0 WHEN 'short' THEN 1 ELSE 2 END, d.host`),
+ byId: db.prepare('SELECT * FROM tool_domains WHERE id = ?'),
+ byHost: db.prepare('SELECT * FROM tool_domains WHERE host = ?'),
+ holders: db.prepare('SELECT * FROM tool_domains WHERE tool_id = ? AND role = ? AND enabled = 1 AND id != ?'),
+ demote: db.prepare("UPDATE tool_domains SET role = 'alias', updated_at = CURRENT_TIMESTAMP WHERE id = ?"),
+ insert: db.prepare('INSERT INTO tool_domains (tool_id, host, role, enabled, note, created_by) VALUES (?, ?, ?, ?, ?, ?)'),
+ update: db.prepare('UPDATE tool_domains SET tool_id = ?, host = ?, role = ?, enabled = ?, note = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'),
+ remove: db.prepare('DELETE FROM tool_domains WHERE id = ?'),
+ };
+
+ function audit(req, action, details) {
+ try {
+ db.prepare('INSERT INTO audit_log (user_id, action, details, ip) VALUES (?, ?, ?, ?)').run(req.user.id, action, JSON.stringify(details), req.ip || null);
+ } catch (err) {
+ console.error('[Domains] Audit log error:', err.message);
+ }
+ }
+
+ const present = (row) => row && ({
+ id: row.id, tool_id: row.tool_id, host: row.host, role: row.role, enabled: !!row.enabled, note: row.note || '',
+ created_by: row.created_by, created_by_username: row.created_by_username || null,
+ created_at: iso(row.created_at), updated_at: iso(row.updated_at),
+ });
+
+ /**
+ * Validate a proposed row. `current` is the row being edited (null on create).
+ * @returns {Promise<{ error?: string, status?: number, value?: object, warnings: string[] }>}
+ */
+ async function validate(body, current) {
+ const warnings = [];
+ const next = {
+ tool_id: body.tool_id !== undefined ? String(body.tool_id).trim().toLowerCase() : current && current.tool_id,
+ host: body.host !== undefined ? body.host : current && current.host,
+ role: body.role !== undefined ? String(body.role).trim().toLowerCase() : (current ? current.role : 'alias'),
+ enabled: body.enabled !== undefined ? (body.enabled === true || body.enabled === 1 || body.enabled === '1' || body.enabled === 'true' ? 1 : 0) : (current ? current.enabled : 1),
+ note: body.note !== undefined ? String(body.note == null ? '' : body.note).trim().slice(0, NOTE_MAX) : (current ? current.note : ''),
+ };
+ if (!ROLES.includes(next.role)) return { status: 400, error: `role must be one of ${ROLES.join(', ')}`, warnings };
+ if (!validToolIdSyntax(next.tool_id)) return { status: 400, error: 'tool_id must be 1–40 characters of a–z, 0–9 and hyphen', warnings };
+
+ const h = validateHost(next.host);
+ if (!h.ok) return { status: 400, error: h.error, warnings };
+ next.host = h.host;
+
+ const { catalog, source } = await catalogSource.getCatalog();
+ let toolVerified = false;
+ if (source === 'fallback') {
+ warnings.push('The Tools catalog is unreachable, so the tool id could not be verified. Check it again once Tools is back.');
+ } else {
+ const known = catalog.tools.some(t => t.id === next.tool_id) || catalog.families.some(f => f.id === next.tool_id);
+ if (!known) return { status: 400, error: `"${next.tool_id}" is not a tool in the Tools catalog`, warnings };
+ toolVerified = true;
+ const owner = catalogOwnerOf(next.host, catalog);
+ if (owner && owner !== next.tool_id) return { status: 409, error: `${next.host} already belongs to the tool "${owner}" in the Tools catalog`, warnings };
+ }
+
+ const taken = q.byHost.get(next.host);
+ if (taken && (!current || taken.id !== current.id)) {
+ return { status: 409, error: `${next.host} is already registered for the tool "${taken.tool_id}" — a host belongs to one tool`, warnings };
+ }
+ return { value: next, toolVerified, warnings };
+ }
+
+ /** Write inside one transaction; returns the row plus whatever was demoted to make room. */
+ const write = db.transaction((next, current, userId) => {
+ const demoted = [];
+ if (next.enabled && next.role !== 'alias') {
+ for (const row of q.holders.all(next.tool_id, next.role, current ? current.id : 0)) {
+ q.demote.run(row.id);
+ demoted.push({ id: row.id, host: row.host, from: row.role });
+ }
+ }
+ let id;
+ if (current) { q.update.run(next.tool_id, next.host, next.role, next.enabled, next.note, current.id); id = current.id; }
+ else id = q.insert.run(next.tool_id, next.host, next.role, next.enabled, next.note, userId).lastInsertRowid;
+ return { row: q.byId.get(id), demoted };
+ });
+
+ // ── public ───────────────────────────────────────────────
+ const publicRouter = express.Router();
+ publicRouter.use((req, res, next) => {
+ res.set('Access-Control-Allow-Origin', '*');
+ res.set('Cross-Origin-Resource-Policy', 'cross-origin');
+ if (req.method === 'OPTIONS') {
+ res.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
+ res.set('Access-Control-Max-Age', '86400');
+ return res.status(204).end();
+ }
+ next();
+ });
+ publicRouter.get('/', (_req, res) => {
+ try {
+ const domains = q.publicList.all();
+ res.set('Cache-Control', 'public, max-age=60');
+ res.json({ updated: iso(q.lastChange.get().t) || new Date(0).toISOString(), domains });
+ } catch (err) {
+ console.error('[Domains] public list:', err.message);
+ res.status(500).json({ error: 'Could not read the domain registry' });
+ }
+ });
+
+ // ── owner-only admin ─────────────────────────────────────
+ const adminRouter = express.Router();
+ adminRouter.use(requireAuth, (req, res, next) => {
+ if (!req.user || req.user.role !== 'admin' || !isOwner(req.user)) {
+ return res.status(403).json({ ok: false, error: 'Owner access required' });
+ }
+ res.set('Cache-Control', 'no-store');
+ next();
+ });
+
+ adminRouter.get('/', async (_req, res) => {
+ try {
+ const { catalog, source } = await catalogSource.getCatalog();
+ const known = new Set([...catalog.tools.map(t => t.id), ...catalog.families.map(f => f.id)]);
+ const rows = q.all.all().map(r => ({ ...present(r), tool_known: source === 'fallback' ? null : known.has(r.tool_id) }));
+ res.json({ ok: true, domains: rows, catalog_source: source });
+ } catch (err) { res.status(500).json({ ok: false, error: err.message }); }
+ });
+
+ adminRouter.get('/catalog', async (_req, res) => {
+ try {
+ const { catalog, source, error } = await catalogSource.getCatalog();
+ res.json({
+ ok: true, source, error: source === 'live' ? null : error, updated: catalog.updated,
+ families: catalog.families.map(f => ({ id: f.id, name: f.name, icon: f.icon, url: f.url })),
+ tools: catalog.tools.map(t => ({ id: t.id, family: t.family, name: t.name, icon: t.icon, hosts: t.hosts, url: t.url })),
+ });
+ } catch (err) { res.status(500).json({ ok: false, error: err.message }); }
+ });
+
+ adminRouter.post('/check', async (req, res) => {
+ const h = validateHost(req.body && req.body.host);
+ if (!h.ok) return res.status(400).json({ ok: false, error: h.error });
+ try { res.json({ ok: true, check: await checkDomain(h.host) }); }
+ catch (err) { res.status(502).json({ ok: false, error: `DNS check failed: ${err.message}` }); }
+ });
+
+ adminRouter.post('/', async (req, res) => {
+ try {
+ const v = await validate(req.body || {}, null);
+ if (v.error) return res.status(v.status).json({ ok: false, error: v.error, warnings: v.warnings });
+ const { row, demoted } = write(v.value, null, req.user.id);
+ audit(req, 'tool_domain_create', { id: row.id, tool_id: row.tool_id, host: row.host, role: row.role, enabled: !!row.enabled, demoted, tool_unverified: !v.toolVerified || undefined });
+ onChange();
+ res.status(201).json({ ok: true, domain: present(row), demoted, tool_verified: v.toolVerified, warnings: v.warnings });
+ } catch (err) {
+ if (/UNIQUE/i.test(err.message)) return res.status(409).json({ ok: false, error: 'That host is already registered' });
+ res.status(500).json({ ok: false, error: err.message });
+ }
+ });
+
+ adminRouter.put('/:id', async (req, res) => {
+ try {
+ const current = q.byId.get(Number(req.params.id));
+ if (!current) return res.status(404).json({ ok: false, error: 'Domain not found' });
+ const v = await validate(req.body || {}, current);
+ if (v.error) return res.status(v.status).json({ ok: false, error: v.error, warnings: v.warnings });
+ const { row, demoted } = write(v.value, current, req.user.id);
+ const changed = {};
+ for (const k of ['tool_id', 'host', 'role', 'enabled', 'note']) if (String(current[k] ?? '') !== String(row[k] ?? '')) changed[k] = { from: current[k], to: row[k] };
+ audit(req, 'tool_domain_update', { id: row.id, host: row.host, tool_id: row.tool_id, changed, demoted, tool_unverified: !v.toolVerified || undefined });
+ onChange();
+ res.json({ ok: true, domain: present(row), demoted, tool_verified: v.toolVerified, warnings: v.warnings });
+ } catch (err) {
+ if (/UNIQUE/i.test(err.message)) return res.status(409).json({ ok: false, error: 'That host is already registered' });
+ res.status(500).json({ ok: false, error: err.message });
+ }
+ });
+
+ adminRouter.delete('/:id', (req, res) => {
+ try {
+ const current = q.byId.get(Number(req.params.id));
+ if (!current) return res.status(404).json({ ok: false, error: 'Domain not found' });
+ q.remove.run(current.id);
+ audit(req, 'tool_domain_delete', { id: current.id, tool_id: current.tool_id, host: current.host, role: current.role });
+ onChange();
+ res.json({ ok: true });
+ } catch (err) { res.status(500).json({ ok: false, error: err.message }); }
+ });
+
+ return { publicRouter, adminRouter };
+}
+
+module.exports = { createDomainRoutes, ensureSchema, catalogOwnerOf };
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+'use strict';
+// Hostname rules for the tool-domain registry (docs/shared-contracts.md §1).
+const net = require('net');
+const { OWNED_ZONES } = require('../auth/sso-owned');
+
+const ROLES = ['canonical', 'short', 'alias'];
+const TOOLS_ZONE = 'openvibe.tools';
+// Hosts under openvibe.tools that are the platform itself, never a tool.
+const RESERVED_TOOLS_SUBS = new Set(['www', 'api', 'admin', 'static', 'cdn', 'mail']);
+
+/**
+ * Normalise and validate a hostname. Input is what the owner typed: it is trimmed, lowercased
+ * and a trailing dot dropped, but a scheme, path, port, wildcard or IP is an error rather than
+ * something silently stripped — the row is what nginx and the gateway will match on.
+ * @returns {{ ok: true, host: string } | { ok: false, error: string }}
+ */
+function validateHost(raw) {
+ let host = String(raw == null ? '' : raw).trim().toLowerCase();
+ if (!host) return { ok: false, error: 'Hostname is required' };
+ if (/^[a-z][a-z0-9+.-]*:\/\//.test(host)) return { ok: false, error: 'Enter the hostname only, without http:// or https://' };
+ if (/[/?#@\s]/.test(host)) return { ok: false, error: 'Enter the hostname only, without a path, query or spaces' };
+ if (host.endsWith('.')) host = host.slice(0, -1);
+ if (net.isIP(host) || net.isIP(host.replace(/^\[|\]$/g, '')) || /^\d+(\.\d+){3}$/.test(host)) return { ok: false, error: 'An IP address is not a domain' };
+ if (host.includes(':')) return { ok: false, error: 'Enter the hostname only, without a port' };
+ if (host.length > 253) return { ok: false, error: 'Hostname is longer than 253 characters' };
+ const labels = host.split('.');
+ if (labels.length < 2) return { ok: false, error: 'Hostname needs a domain and a TLD (example.com)' };
+ for (const label of labels) {
+ if (!label) return { ok: false, error: 'Hostname has an empty label (two dots in a row)' };
+ if (label.length > 63) return { ok: false, error: 'A hostname label is longer than 63 characters' };
+ if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(label)) return { ok: false, error: `"${label}" is not a valid hostname label (letters, digits and inner hyphens only)` };
+ }
+ const tld = labels[labels.length - 1];
+ if (/^\d+$/.test(tld)) return { ok: false, error: 'The top-level domain cannot be numeric' };
+ if (tld === 'localhost' || tld === 'local' || tld === 'internal' || tld === 'invalid' || tld === 'test') return { ok: false, error: 'That is not a public domain' };
+
+ const core = coreHostReason(host);
+ if (core) return { ok: false, error: core };
+ return { ok: true, host };
+}
+
+/** Why a host is one of the network's own, or null when it is free to be a tool host. */
+function coreHostReason(host) {
+ for (const zone of OWNED_ZONES) {
+ if (host === zone || host === 'www.' + zone) return `${host} is one of the network's own sites and cannot be a tool domain`;
+ if (host.endsWith('.' + zone)) {
+ if (zone !== TOOLS_ZONE) return `${host} is inside ${zone}, which belongs to another OpenVibe site`;
+ const sub = host.slice(0, -(zone.length + 1));
+ if (RESERVED_TOOLS_SUBS.has(sub)) return `${host} is reserved by the Tools platform`;
+ }
+ }
+ return null;
+}
+
+function validToolIdSyntax(id) { return /^[a-z0-9-]{1,40}$/.test(String(id || '')); }
+
+module.exports = { ROLES, validateHost, coreHostReason, validToolIdSyntax };
new file mode 100644
new file mode 100644
@@ -0,0 +1,101 @@
@@ -0,0 +1,101 @@
+'use strict';
+// The openvibe.network home page: the static shell (public/index.html) with the network and the
+// tool catalog rendered into it on the server, so the page is complete without JavaScript and
+// always matches what openvibe.tools actually offers (catalog via server/domains/catalog.js).
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const seo = require('openvibe-shared/seo');
+const icons = require('openvibe-shared/icons');
+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 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.'],
+ ['tools', 'OpenVibe.Tools', 'https://openvibe.tools/', 'Online tools', 'Converters, downloaders, PDF and image tools, developer utilities and network diagnostics, each on its own easy address.'],
+ ['community', 'OpenVibe.Community', 'https://openvibe.community/', 'Pastes and posts', 'Share text and code with a link, comment, and follow what the people of OpenVibe are making.'],
+ ['games', 'OpenVibe.Games', 'https://openvibe.games/', 'Browser games', 'Scraplandia and other games that run in a tab and know your OpenVibe account.'],
+ ['media', 'OpenVibe.Media', 'https://openvibe.media/', 'VODs, clips and files', 'The media library behind every site: recorded streams, clips and uploads.'],
+];
+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 = [
+ ['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'],
+ ['bell', 'One notification inbox', 'Alerts from every site in one bell, with optional push.', '/notifications'],
+];
+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-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}
+.home-card b{display:block;font-size:16px}.home-card em{display:block;font-style:normal;font-size:12px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:var(--accent-light,var(--accent,#60a5fa));margin:1px 0 4px}
+.home-card small{display:block;color:var(--text-secondary,#a8b3c4);font-size:13.5px;line-height:1.45}
+.home-fams{display:grid;gap:10px;grid-template-columns:repeat(auto-fill,minmax(220px,1fr))}
+.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-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}}
+${icons.CSS}`;
+
+function body(catalog) {
+ const byId = new Map(catalog.tools.map(t => [t.id, t]));
+ const popular = POPULAR.map(id => byId.get(id)).filter(Boolean);
+ 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>
+<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>
+<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>
+<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>
+<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>
+<div><b>llms.txt</b><p>A plain-text map of the network for AI assistants: <a href="/llms.txt"><code>/llms.txt</code></a> here and on <a href="https://openvibe.tools/llms.txt">openvibe.tools</a>.</p></div>
+<div><b>Sign in with OpenVibe</b><p>OAuth 2.0 and FedCM for sites on the network. Source on <a href="https://github.com/OpenVibers" rel="noopener">GitHub</a>.</p></div></div></section>`;
+}
+
+let cache = { key: '', html: '', etag: '' };
+function render() {
+ const { catalog } = toolsCatalog.peek();
+ const stat = fs.statSync(SHELL);
+ const key = `${stat.mtimeMs}:${catalog.updated}:${catalog.tools.length}`;
+ if (cache.key === key) return cache;
+ const ld = seo.jsonLdTag(seo.jsonLd.itemList('OpenVibe sites', SITES.map(([, n, u, , d]) => ({ name: n, url: u, description: d }))));
+ const html = fs.readFileSync(SHELL, 'utf8').replace('<!--OV:HOME-->', body(catalog)).replace('<!--OV:COUNT-->', String(catalog.tools.length)).replace('</head>', `${ld}\n</head>`);
+ cache = { key, html, etag: '"' + crypto.createHash('sha1').update(html).digest('base64url').slice(0, 20) + '"' };
+ return cache;
+}
+
+function sendHome(req, res) {
+ toolsCatalog.getCatalog().catch(() => {}); // refresh in the background; this response uses what is known now
+ const page = render();
+ res.set('Content-Type', 'text/html; charset=utf-8');
+ res.set('Cache-Control', 'public, max-age=120, stale-while-revalidate=3600');
+ res.set('ETag', page.etag);
+ if (req.headers['if-none-match'] === page.etag) return res.status(304).end();
+ res.send(page.html);
+}
+
+function llmsTxt() {
+ const { catalog } = toolsCatalog.peek();
+ return seo.llmsTxt({ name: 'OpenVibe Network', summary: 'OpenVibe is an open source, community-run network of sites that share one account: live streaming, online tools, community pastes, games and media.',
+ sections: [{ title: 'Sites', links: SITES.map(([, n, u, , d]) => ({ title: n, url: u, note: d })) },
+ { title: 'Tool families', links: catalog.families.filter(f => f.url).map(f => ({ title: f.name, url: f.path ? 'https://openvibe.tools' + f.path : f.url, note: f.tagline })) },
+ { title: 'Machine-readable', links: [{ title: 'Tool catalog (JSON)', url: 'https://openvibe.tools/api/catalog.json' }, { title: 'Tools llms.txt', url: 'https://openvibe.tools/llms.txt' }, { title: 'Tool domains (JSON)', url: 'https://openvibe.network/api/domains' }] }] });
+}
+
+module.exports = { sendHome, render, llmsTxt };
@@ -415,6 +415,10 @@ function requireAdmin(req, res, next) {
@@ -415,6 +415,10 @@ function requireAdmin(req, res, next) {
}
}
next();
next();
}
}
+// 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);
+app.use('/api/admin/domains', toolDomains.adminRouter);
app.use('/api/admin/discord', createDiscordRoutes(db, discordService, requireAuth, requireAdmin));
app.use('/api/admin/discord', createDiscordRoutes(db, discordService, requireAuth, requireAdmin));
// Deploy (TLS / Nginx / Infrastructure) admin API
// Deploy (TLS / Nginx / Infrastructure) admin API
@@ -519,8 +523,9 @@ app.get(['/login.html', '/admin.html'], (req, res) => {
@@ -519,8 +523,9 @@ app.get(['/login.html', '/admin.html'], (req, res) => {
// Landing page at the apex root
// Landing page at the apex root
app.get(['/', '/index.html'], (req, res) => {
app.get(['/', '/index.html'], (req, res) => {
if (req.path === '/index.html') return redirectWithoutHtml(req, res, '/');
if (req.path === '/index.html') return redirectWithoutHtml(req, res, '/');
- return sendLandingPage(res);
+ return require('./home/render').sendHome(req, res);
});
});
+app.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(require('./home/render').llmsTxt()));
// Account hub (my.html) — the apex hosts the account hub under /my
// Account hub (my.html) — the apex hosts the account hub under /my
// plus its client-routed sections.
// plus its client-routed sections.
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+const Database=require('better-sqlite3');const express=require('express');const assert=require('assert');
+const db=new Database(':memory:');db.exec("CREATE TABLE users(id INTEGER PRIMARY KEY, username TEXT);CREATE TABLE audit_log(id INTEGER PRIMARY KEY, user_id INT, action TEXT, details TEXT, ip TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);INSERT INTO users VALUES(1,'alex'),(2,'mod')");
+process.env.OWNER_USERNAME=process.env.OWNER_USERNAME||'alex';
+const {createDomainRoutes}=require('../server/domains/routes');
+const {ownerName}=require('../server/auth/owner-guard');
+const owner=ownerName();
+const auth=(req,res,next)=>{const w=req.headers['x-as'];if(!w)return res.status(401).json({error:'auth'});req.user=w==='owner'?{id:1,username:owner,role:'admin'}:{id:2,username:'mod',role:'admin'};next();};
+const cat={getCatalog:async()=>({source:'live',catalog:{tools:[{id:'yt',hosts:{canonical:'youtube-downloader.openvibe.tools',short:'yt.openvibe.tools',aliases:[]}}],families:[]}})};
+const r=createDomainRoutes(db,auth,{catalog:cat,checkDomain:async()=>({ok:true})});
+const app=express();app.use(express.json());app.use('/api/domains',r.publicRouter);app.use('/api/admin/domains',r.adminRouter);
+const s=app.listen(0,async()=>{const b='http://127.0.0.1:'+s.address().port;const j=async(m,p,as,body)=>{const x=await fetch(b+p,{method:m,headers:{'content-type':'application/json',...(as?{'x-as':as}:{})},body:body?JSON.stringify(body):undefined});return{s:x.status,j:await x.json().catch(()=>null)}};
+let x=await j('GET','/api/admin/domains');assert.equal(x.s,401);
+x=await j('GET','/api/admin/domains','admin');assert.equal(x.s,403,'admin non-owner refused');
+x=await j('POST','/api/admin/domains','admin',{tool_id:'yt',host:'evil.com',role:'canonical'});assert.equal(x.s,403);
+x=await j('POST','/api/admin/domains','owner',{tool_id:'yt',host:'YoutubeDownloadOnline.com',role:'canonical'});assert.equal(x.s<300,true,JSON.stringify(x.j));
+x=await j('POST','/api/admin/domains','owner',{tool_id:'yt',host:'other.com',role:'canonical'});assert.ok(x.s<300);
+x=await j('POST','/api/admin/domains','owner',{tool_id:'nope',host:'a.com',role:'alias'});assert.equal(x.s,400);
+x=await j('POST','/api/admin/domains','owner',{tool_id:'yt',host:'bad host/../',role:'alias'});assert.equal(x.s,400);
+x=await j('GET','/api/domains');console.log(x.s,JSON.stringify(x.j.domains));
+assert.equal(x.j.domains.filter(d=>d.role==='canonical').length,1);
+console.log('audit rows',db.prepare('select count(*) c from audit_log').get().c);console.log('domains: all checks passed');s.close();});