openvibers/openvibe.network Commits

Verify-email page, sign-in everywhere, cross-site history, linked services for every site, navbar brand + modular menus

OP OpenVibers committed 18 Sept 2026 · d8b7f65f01 · 20 files, +1401 −100

- /verify-email is its own page (public/verify-email.html): the link from the mail opens on any
  device, signed in or not, and shows a real result — before, the account hub bounced to /login
  and dropped the token, so the link "did nothing"
- Sign-in everywhere: after any sign-in (direct or OAuth chooser) the browser runs /sso/fanout —
  one silent prompt=none hop per first-party site (server/auth/sso-targets.js) so one login is a
  login on Live, Tools, Games and Community; sign-out everywhere is the same chain in reverse;
  unreachable sites are probed and skipped, a Skip link and a 20 s cap keep it out of the way
- Linked Services: every OAuth code/refresh exchange records the service (linked_accounts
  .last_used_at) — Tools/Games/Media/Community used to never appear because only Live reported;
  the tab shows last-used, links out, and offers sign-in/out everywhere
- Cross-site history: /api/history (record / list / search / pause / delete), shared history.js,
  History tab on the account page, "Recently used" rows in the shared navbar dropdown
- Shared navbar: brand derived from the hostname as three segments (Pastes · OpenVibe · Tools)
  with compact mode on narrow viewports; per-site links, menu rows (init options + addMenuItem/
  setLinks), history recording, hint-gated silent login; footer spells the same full name
- ov-mark: data-variant per site (live pulses red, tools/media/games/community each their own
  motion) so one mark carries every property
- community OAuth client seeded; landing page: full network grid (every owned domain), JSON-LD,
  og:image/twitter, shared footer; cost-claim wording ("free", "no profit") replaced with the
  open/community-run voice across the landing page, footer and brand constants
- tests: navbar-brand, history + sso targets

Changes

ddiff --git a/package.json b/package.json
iindex cb6da104..d5762844 100644
---- a/package.json
++++ b/package.json
@@@ -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",
++ "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",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
ddiff --git a/packages/openvibe-shared/brand.js b/packages/openvibe-shared/brand.js
iindex 61ab011a..bfa2a1a8 100644
---- a/packages/openvibe-shared/brand.js
++++ b/packages/openvibe-shared/brand.js
@@@ -19,7 +19,7 @@ const BRAND_DEFAULTS = Object.freeze({
gamesServiceName: 'OpenVibe.Games',
mediaServiceName: 'OpenVibe.Media',
tagline: 'One Account. All of OpenVibe.',
-- campTagline: 'Free & Open Live Streaming',
++ campTagline: 'Open Live Streaming, Community Run',
toolsSubdomainBase: 'openvibe.tools',
discord: 'https://discord.gg/M6MuRUaeJj',
github: 'https://github.com/OpenVibe.Live',
@@@ -99,8 +99,8 @@ function buildBrandFromRegistry(registry = {}, env = process.env) {
// Build service list using registry URLs where available
const services = [
-- { id: 'live', name: streamerServiceName, url: liveUrl, description: 'Free & Open Live Streaming' },
-- { id: 'tools', name: toolsServiceName, url: toolsUrl, description: 'Free Online Tools' },
++ { id: 'live', name: streamerServiceName, url: liveUrl, description: 'Open live streaming, community run' },
++ { id: 'tools', name: toolsServiceName, url: toolsUrl, description: 'Online tools for everyone' },
{ id: 'games', name: gamesServiceName, url: gamesUrl, description: 'Games & Community Canvas' },
{ id: 'media', name: mediaServiceName, url: mediaUrl, description: 'VODs, Clips, Pastes & Files' },
{ id: 'network', name: BRAND_DEFAULTS.networkServiceName, url: networkUrl, description: 'Accounts, SSO & OpenCoins' },
@@@ -155,8 +155,8 @@ const BRAND = Object.freeze({
// Primary services in the network (for dashboard display, health checks, etc.)
// Static list with OpenVibe defaults — use buildBrandFromRegistry() for live values.
services: Object.freeze([
-- { id: 'live', name: 'OpenVibe.Live', url: 'https://openvibe.live', description: 'Free & Open Live Streaming' },
-- { id: 'tools', name: 'OpenVibe.Tools', url: 'https://openvibe.tools', description: 'Free Online Tools' },
++ { id: 'live', name: 'OpenVibe.Live', url: 'https://openvibe.live', description: 'Open live streaming, community run' },
++ { id: 'tools', name: 'OpenVibe.Tools', url: 'https://openvibe.tools', description: 'Online tools for everyone' },
{ id: 'games', name: 'OpenVibe.Games', url: 'https://openvibe.games', description: 'Games & Community Canvas' },
{ id: 'media', name: 'OpenVibe.Media', url: 'https://openvibe.media', description: 'VODs, Clips, Pastes & Files' },
{ id: 'network', name: 'OpenVibe.Network', url: 'https://openvibe.network', description: 'Accounts, SSO & OpenCoins' },
ddiff --git a/packages/openvibe-shared/footer.js b/packages/openvibe-shared/footer.js
iindex bccc6a11..ed13bbd1 100644
---- a/packages/openvibe-shared/footer.js
++++ b/packages/openvibe-shared/footer.js
@@@ -34,7 +34,7 @@
// the link, with no edit anywhere else.
const NETWORK = [
{ id: 'live', name: 'Live streaming', url: 'https://openvibe.live', desc: 'Go live, restream and clip' },
-- { id: 'tools', name: 'Free online tools', url: 'https://openvibe.tools', desc: 'The whole toolbox' },
++ { id: 'tools', name: 'Online tools', url: 'https://openvibe.tools', desc: 'The whole toolbox' },
{ id: 'dev', name: 'Developer tools', url: 'https://dev.openvibe.tools', desc: 'Formatters, encoders, validators' },
{ id: 'net', name: 'Network diagnostics', url: 'https://net.openvibe.tools', desc: 'Ping, DNS, Whois, SSL' },
{ id: 'img', name: 'Image converter', url: 'https://img.openvibe.tools', desc: 'Convert, resize, compress' },
@@@ -42,7 +42,11 @@
{ id: 'docs', name: 'PDF & document tools', url: 'https://docs.openvibe.tools', desc: 'Merge, split, compress' },
{ id: 'text', name: 'Text tools', url: 'https://text.openvibe.tools', desc: 'Fancy text, case, ASCII' },
{ id: 'yt', name: 'YouTube downloader', url: 'https://yt.openvibe.tools', desc: 'Video and audio' },
++ { id: 'community', name: 'Community & pastes', url: 'https://openvibe.community', desc: 'Pastes, posts and the people of OpenVibe' },
++ { id: 'games', name: 'Games', url: 'https://openvibe.games', desc: 'Scraplandia and browser games' },
++ { id: 'media', name: 'Media', url: 'https://openvibe.media', desc: 'VODs, clips and files for every site' },
{ id: 'network', name: 'Account & themes', url: NETWORK_URL, desc: 'One account, every site' },
++ { id: 'more', name: 'The whole network →', url: NETWORK_URL + '/#network', desc: 'Every OpenVibe site, live and upcoming' },
];
const LEGAL = [
@@@ -59,7 +63,7 @@
const DEFAULTS = {
service: null, // 'live' | 'tools' | 'dev' | … (auto-detected when omitted)
brandName: null,
-- tagline: 'Free, open source and community run. Built in the open by the people using it.',
++ tagline: 'Open source and community run. Built in the open by the people using it.',
variant: 'full',
mount: '#ov-footer',
links: [], // [{ heading, items: [{ name, url, onclick? }] }]
@@@ -84,9 +88,17 @@
return sub === h ? 'tools' : (sub || 'tools');
}
++ const TLD_LABELS = { live: 'Live', tools: 'Tools', network: 'Network', media: 'Media', games: 'Games', community: 'Community', chat: 'Chat', codes: 'Codes', blog: 'Blog', wiki: 'Wiki', news: 'News', reviews: 'Reviews', tips: 'Tips', vip: 'VIP', trade: 'Trade', host: 'Host', deals: 'Deals', coupons: 'Coupons' };
++ /** The full site name from the hostname — the same spelling the navbar uses (Pastes.OpenVibe.Tools). */
function brandFor(service) {
++ const h = typeof location !== 'undefined' ? location.hostname.toLowerCase() : '';
++ const cap = (w) => w ? w.charAt(0).toUpperCase() + w.slice(1) : '';
++ let m = h.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/);
++ if (m) { const sub = m[1] && m[1] !== 'www' ? m[1] : null; return [sub ? (sub.length <= 4 ? sub.toUpperCase() : cap(sub)) : null, 'OpenVibe', TLD_LABELS[m[2]] || cap(m[2])].filter(Boolean).join('.'); }
++ if ((m = h.match(/^(?:(.+)\.)?openre\.stream$/))) return 'OpenRe.Stream';
const found = NETWORK.find(n => n.id === service);
-- if (found) return found.name === 'Account & themes' ? 'OpenVibe' : found.name;
++ if (service && TLD_LABELS[service]) return `OpenVibe.${TLD_LABELS[service]}`;
++ if (found && found.name !== 'Account & themes' && found.name !== 'The whole network →') return `${cap(service)}.OpenVibe.Tools`;
return 'OpenVibe';
}
ddiff --git a/packages/openvibe-shared/history.js b/packages/openvibe-shared/history.js
nnew file mode 100644
iindex 00000000..028c7f87
---- /dev/null
++++ b/packages/openvibe-shared/history.js
@@@ -0,0 +1,111 @@
++/**
++ * openvibe-shared/history.js — cross-site history for a signed-in account.
++ *
++ * OpenVibeHistory.record({ type, title, url, icon, service, meta }, { token, apiBase })
++ * OpenVibeHistory.recent({ limit, service, type, token, apiBase }) → [{...}]
++ * OpenVibeHistory.clear({ token }) → wipes it
++ *
++ * Every property records what the account touched — a tool used, a stream watched, a paste
++ * opened, a game played — so "Recently used" in the shared navbar and the History tab on the
++ * Network show one timeline across the whole network instead of one per site. Writes are
++ * deduplicated server-side (same URL within ten minutes bumps the entry instead of adding one)
++ * and the browser side throttles too, so a page cannot flood it. Nothing is recorded for
++ * anonymous sessions, and the user can pause or clear the timeline from their account page.
++ *
++ * Loaded lazily by navbar.js from https://openvibe.network/shared/history.js; safe to include
++ * directly. Token resolution matches the navbar: ov_token cookie → localStorage.ov_token.
++ */
++(function (root) {
++ 'use strict';
++ if (root.OpenVibeHistory) return;
++
++ const DEFAULT_API = 'https://openvibe.network';
++ const SENT_KEY = 'ov_history_sent'; // sessionStorage: url → ts, to avoid re-posting on every re-render
++ const THROTTLE_MS = 5 * 60 * 1000;
++
++ function token(opts) {
++ if (opts && opts.token) return opts.token;
++ const m = (typeof document !== 'undefined' ? document.cookie : '').match(/(?:^|;\s*)ov_token=([^;]*)/);
++ if (m && m[1]) { try { return decodeURIComponent(m[1]); } catch { return m[1]; } }
++ try { return localStorage.getItem('ov_token'); } catch { return null; }
++ }
++
++ function api(opts) { return (opts && opts.apiBase) || DEFAULT_API; }
++
++ function sent() { try { return JSON.parse(sessionStorage.getItem(SENT_KEY) || '{}'); } catch { return {}; } }
++ function markSent(url) {
++ try {
++ const m = sent(); m[url] = Date.now();
++ const keys = Object.keys(m); if (keys.length > 60) for (const k of keys.slice(0, keys.length - 60)) delete m[k];
++ sessionStorage.setItem(SENT_KEY, JSON.stringify(m));
++ } catch { /* storage unavailable */ }
++ }
++
++ /** Guess an icon and service label from the URL when the caller gives none. */
++ function inferService(url) {
++ let host = '';
++ try { host = new URL(url, location.href).hostname; } catch { return { service: null }; }
++ const m = host.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/) || host.match(/^(?:(.+)\.)?(openre)\.stream$/);
++ if (!m) return { service: null };
++ return { service: m[2], sub: m[1] || null };
++ }
++
++ const ICONS = { tool: 'fa-screwdriver-wrench', stream: 'fa-tower-broadcast', vod: 'fa-clapperboard', clip: 'fa-scissors', paste: 'fa-paste', game: 'fa-gamepad', page: 'fa-file-lines', post: 'fa-comments', account: 'fa-user', theme: 'fa-palette', download: 'fa-download', chat: 'fa-comment' };
++
++ async function record(entry, opts) {
++ const t = token(opts);
++ if (!t || !entry) return false;
++ const url = String(entry.url || (typeof location !== 'undefined' ? location.href : '')).slice(0, 2000);
++ if (!url) return false;
++ const last = sent()[url] || 0;
++ if (!entry.force && Date.now() - last < THROTTLE_MS) return false;
++ markSent(url);
++ const inf = inferService(url);
++ const body = {
++ type: String(entry.type || 'page').slice(0, 32),
++ title: String(entry.title || (typeof document !== 'undefined' ? document.title : '') || url).slice(0, 200),
++ url,
++ icon: entry.icon || ICONS[entry.type] || null,
++ service: entry.service || inf.service || null,
++ sub: entry.sub || inf.sub || null,
++ meta: entry.meta && typeof entry.meta === 'object' ? entry.meta : null,
++ };
++ try {
++ const res = await fetch(`${api(opts)}/api/history`, {
++ method: 'POST', keepalive: true,
++ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
++ body: JSON.stringify(body),
++ });
++ return res.ok;
++ } catch { return false; }
++ }
++
++ async function recent(opts) {
++ const t = token(opts);
++ if (!t) return [];
++ const q = new URLSearchParams();
++ if (opts && opts.limit) q.set('limit', String(opts.limit));
++ if (opts && opts.service) q.set('service', opts.service);
++ if (opts && opts.type) q.set('type', opts.type);
++ try {
++ const res = await fetch(`${api(opts)}/api/history?${q}`, { headers: { Authorization: `Bearer ${t}` } });
++ if (!res.ok) return [];
++ const data = await res.json();
++ return (data && data.items) || [];
++ } catch { return []; }
++ }
++
++ async function clear(opts) {
++ const t = token(opts);
++ if (!t) return false;
++ try { const res = await fetch(`${api(opts)}/api/history`, { method: 'DELETE', headers: { Authorization: `Bearer ${t}` } }); return res.ok; } catch { return false; }
++ }
++
++ async function remove(id, opts) {
++ const t = token(opts);
++ if (!t) return false;
++ try { const res = await fetch(`${api(opts)}/api/history/${encodeURIComponent(id)}`, { method: 'DELETE', headers: { Authorization: `Bearer ${t}` } }); return res.ok; } catch { return false; }
++ }
++
++ root.OpenVibeHistory = { record, recent, clear, remove, ICONS };
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/packages/openvibe-shared/navbar.js b/packages/openvibe-shared/navbar.js
iindex db9f7c71..47afb984 100644
---- a/packages/openvibe-shared/navbar.js
++++ b/packages/openvibe-shared/navbar.js
@@@ -8,12 +8,39 @@
// onLogin/onLogout callbacks. When no `user` is passed the navbar resolves
// it itself: ov_token cookie → localStorage → token opt → sessionUrl, then
// GET {apiBase}/api/auth/me with `Authorization: Bearer <token>`.
++//
++// Brand: derived from the hostname — `pastes.openvibe.tools` renders as
++// Pastes · OpenVibe · Tools (three segments, the subdomain first so the
++// context reads left-to-right), `openvibe.live` as OpenVibe · Live. Pass
++// brand: { sub, tld, name, icon, variant } to override any part, or the
++// legacy brandName/brandIcon. compact: 'auto' (default — the brand shortens
++// to the subdomain on narrow viewports), 'always', 'never'.
++// Menus are modular: every site keeps the shared account menu and adds its own
++// pieces — links: [{label, href, icon?, active?}] replaces the service's top
++// links; menu: { before: [item], after: [item] } adds dropdown rows
++// ({label, href, icon, onClick, danger, external}); OpenVibeNavbar.addMenuItem()
++// / setLinks() do the same at runtime. Signed-in users also get a
++// "Recently used" row fed by the shared history module when it is loaded.
// ═══════════════════════════════════════════════════════════════
(function (root) {
'use strict';
-- let _config = { service: 'network', token: null, user: null, apiBase: 'https://openvibe.network', onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null };
++ let _config = {
++ service: 'network', token: null, user: null, apiBase: 'https://openvibe.network',
++ onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null,
++ brand: null, brandName: null, brandIcon: null, compact: 'auto',
++ links: null, menu: null, recent: true,
++ // history: { type: 'tool'|'stream'|'paste'|'page'|…, title, url, icon } — recorded for the
++ // signed-in user once auth resolves (cross-site "Recently used" / History on the Network).
++ history: null,
++ // silentLogin: 'https://site/auth/login?silent=1&next={url}' — when nobody is signed in here
++ // but this browser has signed in to the network before (ov_sso_hint), try one silent
++ // prompt=none round trip per tab so a session on one site becomes a session on all.
++ silentLogin: null,
++ };
++ const _runtimeMenu = { before: [], after: [] };
++ let _runtimeLinks = null;
let _navEl = null;
function injectStyles() {
@@@ -29,10 +56,44 @@
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
color: var(--text-primary, #e0e0e0);
}
-- .openvibe-navbar-brand { display: flex; align-items: center; gap: 8px; text-decoration: none; color: inherit; margin-right: 8px; }
-- .openvibe-navbar-brand .flame { font-size: 18px; color: var(--accent, #8b5cf6); }
-- .openvibe-navbar-brand .name { font-size: 15px; font-weight: 700; letter-spacing: -.3px; }
-- .openvibe-navbar-brand .service-name { font-size: 11px; color: var(--accent-light, #a78bfa); font-weight: 500; letter-spacing: .5px; text-transform: uppercase; }
++ .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 .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); }
++ .openvibe-navbar-brand .b-core { color: var(--text-primary, #e0e0e0); }
++ .openvibe-navbar-brand .b-tld { color: var(--accent-light, var(--accent, #a78bfa)); }
++ .openvibe-navbar-brand.has-sub .b-core { color: var(--text-secondary, #b0b0b8); font-weight: 600; }
++ .openvibe-navbar-brand.has-sub .b-tld { color: var(--text-secondary, #b0b0b8); font-weight: 600; }
++ .openvibe-navbar-brand .b-dot { color: var(--accent, #8b5cf6); opacity: .75; margin: 0 1px; font-weight: 800; }
++ .openvibe-navbar-brand .b-sub, .openvibe-navbar-brand .b-core, .openvibe-navbar-brand .b-tld { transition: color .2s, opacity .2s; }
++ .openvibe-navbar-brand:hover .b-tld, .openvibe-navbar-brand:hover .b-sub { color: var(--accent-light, #a78bfa); }
++ .openvibe-navbar-brand:hover .b-dot { opacity: 1; }
++ .openvibe-navbar-brand .b-tag { font-size: 9px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; color: var(--accent-light, #a78bfa); background: var(--accent-glow, rgba(139,92,246,.14)); border-radius: 4px; padding: 2px 5px; margin-left: 6px; align-self: center; }
++ /* Compact: drop the trailing segments on narrow viewports, keep what identifies the page. */
++ .openvibe-navbar[data-compact="always"] .openvibe-navbar-brand.has-sub .b-core,
++ .openvibe-navbar[data-compact="always"] .openvibe-navbar-brand.has-sub .b-tld,
++ .openvibe-navbar[data-compact="always"] .openvibe-navbar-brand.has-sub .b-dot { display: none; }
++ @media (max-width: 860px) {
++ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-core,
++ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-tld,
++ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-dot { display: none; }
++ }
++ @media (max-width: 420px) {
++ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand:not(.has-sub) .b-core,
++ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand:not(.has-sub) .b-dot { display: none; }
++ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand .b-tag { display: none; }
++ }
++ .openvibe-navbar-links a .icon { margin-right: 5px; opacity: .8; }
++ .openvibe-navbar-dropdown-recent { padding: 6px 8px 2px; border-bottom: 1px solid var(--border, #333340); }
++ .openvibe-navbar-dropdown-recent .label { font-size: 10px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; color: var(--text-muted, #707080); padding: 2px 8px 4px; display: flex; justify-content: space-between; align-items: center; }
++ .openvibe-navbar-dropdown-recent .label a { color: var(--accent-light, #a78bfa); text-decoration: none; font-weight: 600; letter-spacing: 0; text-transform: none; }
++ .openvibe-navbar-dropdown-recent .item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; font-size: 12px; color: var(--text-secondary, #b0b0b8); text-decoration: none; transition: background .12s; min-width: 0; }
++ .openvibe-navbar-dropdown-recent .item:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }
++ .openvibe-navbar-dropdown-recent .item .icon { width: 18px; text-align: center; color: var(--accent-light, #a78bfa); flex: none; }
++ .openvibe-navbar-dropdown-recent .item .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++ .openvibe-navbar-dropdown-recent .item .s { margin-left: auto; font-size: 10px; color: var(--text-muted, #707080); flex: none; }
++ .openvibe-navbar-dropdown-menu .sep { height: 1px; background: var(--border, #333340); margin: 4px -8px; }
.openvibe-navbar-links { display: flex; align-items: center; gap: 4px; margin-left: 8px; }
.openvibe-navbar-links a {
@@@ -350,6 +411,113 @@
'colors.openvibe.tools': { name: 'OpenVibeColor', icon: 'fa-palette' },
};
++ // ─── Brand from hostname ───────────────────────────────────
++ // Every property is <sub?>.openvibe.<tld> (plus openre.stream). The navbar spells the
++ // whole name — Pastes.OpenVibe.Tools, not "Paste.OpenVibe" — because the subdomain and
++ // the TLD are what tell a visitor where they are in the network.
++ const TLD_LABELS = {
++ live: 'Live', tools: 'Tools', network: 'Network', media: 'Media', games: 'Games',
++ community: 'Community', 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 SUB_LABELS = {
++ json: 'JSON', yaml: 'YAML', xml: 'XML', csv: 'CSV', sql: 'SQL', html: 'HTML', jwt: 'JWT',
++ uuid: 'UUID', guid: 'GUID', url: 'URL', b64: 'B64', sha256: 'SHA256', og: 'OG', md: 'MD',
++ yt: 'YT', ip: 'IP', myip: 'MyIP', ipv4: 'IPv4', ipv6: 'IPv6', geoip: 'GeoIP', asn: 'ASN',
++ rdns: 'rDNS', dns: 'DNS', mx: 'MX', txt: 'TXT', ns: 'NS', spf: 'SPF', dkim: 'DKIM',
++ dmarc: 'DMARC', mtr: 'MTR', ssl: 'SSL', tls: 'TLS', ptr: 'PTR', smtp: 'SMTP', http: 'HTTP',
++ httpstatus: 'HTTPStatus', rdap: 'RDAP', isp: 'ISP', pdf: 'PDF', mergepdf: 'MergePDF',
++ splitpdf: 'SplitPDF', compresspdf: 'CompressPDF', rotatepdf: 'RotatePDF',
++ reorderpdf: 'ReorderPDF', watermarkpdf: 'WatermarkPDF', protectpdf: 'ProtectPDF',
++ unlockpdf: 'UnlockPDF', image2pdf: 'Image2PDF', jpg2pdf: 'JPG2PDF', png2pdf: 'PNG2PDF',
++ pdf2jpg: 'PDF2JPG', pdf2png: 'PDF2PNG', png: 'PNG', jpg: 'JPG', jpeg: 'JPEG', webp: 'WebP',
++ avif: 'AVIF', heic: 'HEIC', heif: 'HEIF', svg: 'SVG', gif: 'GIF', ico: 'ICO', tiff: 'TIFF',
++ bmp: 'BMP', mp3: 'MP3', wav: 'WAV', flac: 'FLAC', ogg: 'OGG', m4a: 'M4A', aac: 'AAC',
++ opus: 'Opus', wma: 'WMA', aiff: 'AIFF', ac3: 'AC3', eq: 'EQ', equalizer: 'EQ', mxn: 'MXN',
++ ascii: 'ASCII', smallcaps: 'SmallCaps', titlecase: 'TitleCase', textlogo: 'TextLogo',
++ textart: 'TextArt', channelart: 'ChannelArt', lowerthird: 'LowerThird', copypaste: 'CopyPaste',
++ dnspropagation: 'DNSPropagation', opengraph: 'OpenGraph', whip: 'WHIP', ingest: 'Ingest',
++ play: 'Play', my: 'My', auth: 'Auth', api: 'API', admin: 'Admin', docs: 'Docs', dev: 'Dev',
++ net: 'Net', img: 'Img', pastes: 'Pastes', paste: 'Pastes', maps: 'Maps', food: 'Food',
++ text: 'Text', logo: 'Logo', audio: 'Audio', ai: 'AI', cdn: 'CDN', status: 'Status',
++ };
++ const SERVICE_TLD = { live: 'live', tools: 'tools', games: 'games', media: 'media', network: 'network', community: 'community' };
++ const SERVICE_SUB = { net: 'net', dev: 'dev', paste: 'pastes', maps: 'maps', food: 'food', img: 'img', yt: 'yt', audio: 'audio', text: 'text', logo: 'logo', docs: 'docs' };
++
++ function titleCase(w) { return w ? w.charAt(0).toUpperCase() + w.slice(1) : ''; }
++ function subLabel(sub) { return SUB_LABELS[sub] || titleCase(sub); }
++
++ /**
++ * { sub, core, tld, name, short, icon, variant } for the current page.
++ * sub 'Pastes' | null tld 'Tools' core 'OpenVibe'
++ * name 'Pastes.OpenVibe.Tools' short 'Pastes' (what compact mode keeps)
++ * variant the ov-mark flavour: the TLD id ('live', 'tools', …) — every site gets its own twist
++ */
++ function resolveBrand() {
++ const b = Object.assign({}, _config.brand || {});
++ const host = currentHost().toLowerCase();
++ let sub = null, tld = null, core = 'OpenVibe';
++ let m = host.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/);
++ if (m) { sub = m[1] && m[1] !== 'www' ? m[1] : null; tld = m[2]; }
++ else if ((m = host.match(/^(?:(.+)\.)?openre\.stream$/))) { sub = m[1] && m[1] !== 'www' ? m[1] : null; core = 'OpenRe'; tld = 'stream'; }
++ // Off-network hosts (localhost, previews): fall back to the service id.
++ if (!tld) { tld = SERVICE_TLD[_config.service] || (SERVICE_SUB[_config.service] ? 'tools' : 'network'); sub = SERVICE_SUB[_config.service] || null; }
++ // Legacy brandName ("Paste.OpenVibe", "OpenVibe.Live") still steers the segments.
++ if (_config.brandName && !b.sub && !b.tld) {
++ const parts = String(_config.brandName).split('.');
++ if (parts.length >= 2 && /^openvibe$/i.test(parts[0])) tld = parts[1].toLowerCase();
++ else if (parts.length >= 2 && /^openvibe$/i.test(parts[1])) sub = parts[0].toLowerCase();
++ else if (parts.length === 1) sub = parts[0].toLowerCase();
++ }
++ if (b.sub !== undefined) sub = b.sub ? String(b.sub).toLowerCase() : null;
++ if (b.tld) tld = String(b.tld).toLowerCase();
++ const subText = b.subLabel || (sub ? subLabel(sub) : null);
++ const tldText = b.tldLabel || TLD_LABELS[tld] || titleCase(tld);
++ const name = b.name || [subText, core, tldText].filter(Boolean).join('.');
++ const icon = b.icon || _config.brandIcon || null;
++ const variant = b.variant || (core === 'OpenRe' ? 'stream' : tld);
++ return { sub, subText, core, tld, tldText, name, short: subText || `${core}.${tldText}`, icon, variant, tag: b.tag || null, href: b.href || '/' };
++ }
++
++ function brandHTML(brand) {
++ const seg = (cls, text) => `<span class="${cls}">${escapeAttr(text)}</span>`;
++ 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);
++ 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>
++ <span class="name">${text}${brand.tag ? `<span class="b-tag">${escapeAttr(brand.tag)}</span>` : ''}</span>
++ </a>`;
++ }
++
++ function menuItemHTML(item) {
++ if (!item) return '';
++ if (item.sep) return '<div class="sep"></div>';
++ const icon = item.icon ? `<span class="icon"><i class="fa-solid ${escapeAttr(item.icon)}"></i></span>` : '<span class="icon"></span>';
++ const cls = item.danger ? ' class="danger"' : '';
++ const id = item.id ? ` data-menu-id="${escapeAttr(item.id)}"` : '';
++ if (item.href) return `<a href="${escapeAttr(item.href)}"${cls}${id}${item.external ? ' target="_blank" rel="noopener"' : ''}>${icon} ${escapeAttr(item.label)}</a>`;
++ return `<button type="button"${cls}${id}>${icon} ${escapeAttr(item.label)}</button>`;
++ }
++
++ function bindMenuItems(container, items) {
++ for (const item of items) {
++ if (!item || !item.id || typeof item.onClick !== 'function') continue;
++ container.querySelector(`[data-menu-id="${item.id}"]`)?.addEventListener('click', (e) => { if (!item.href) e.preventDefault(); item.onClick(e); });
++ }
++ }
++
++ function currentLinks() {
++ const list = _runtimeLinks || _config.links || SERVICE_LINKS[_config.service] || [];
++ const path = (typeof location !== 'undefined' && location.pathname) || '/';
++ return list.map((l) => Object.assign({}, l, { active: l.active !== undefined ? l.active : (l.href === path && path !== '/') }));
++ }
++
const SERVICE_LINKS = {
live: [
{ label: 'Watch', href: '/' },
@@@ -610,6 +778,45 @@
let _authInFlight = null;
++ function ssoHint() {
++ const m = (typeof document !== 'undefined' ? document.cookie : '').match(/(?:^|;\s*)ov_sso_hint=([^;]*)/);
++ if (m) return m[1];
++ try { return localStorage.getItem('ov_sso_hint'); } catch { return null; }
++ }
++
++ /**
++ * One silent sign-in attempt per tab: only when this browser has signed in to the network
++ * before (the hint survives token expiry), never after an explicit sign-out ('guest'), and
++ * never for bots. The site's login route turns silent=1 into prompt=none and comes straight
++ * back on error=login_required, so a signed-out visitor sees one quick redirect at most.
++ */
++ function maybeSilentLogin() {
++ if (!_config.silentLogin || typeof location === 'undefined') return false;
++ if (ssoHint() !== 'account') return false;
++ if (/bot|crawl|spider|slurp|headless/i.test(navigator.userAgent || '')) return false;
++ try { if (sessionStorage.getItem('ov_silent_sso')) return false; sessionStorage.setItem('ov_silent_sso', '1'); } catch { return false; }
++ if (/[?&]sso=none\b/.test(location.search)) return false;
++ const url = String(_config.silentLogin).replace('{url}', encodeURIComponent(location.href));
++ location.replace(url);
++ return true;
++ }
++
++ function recordHistory() {
++ const h = _config.history;
++ if (!h || !_config.user || _config.user.is_anon) return;
++ const rec = Object.assign({ url: location.href, title: document.title, service: resolveBrand().tld }, h);
++ const H = root.OpenVibeHistory;
++ if (H && typeof H.record === 'function') { H.record(rec, { token: _config.token, apiBase: _config.apiBase }); return; }
++ if (!document.getElementById('ov-history-loader')) {
++ const sc = document.createElement('script'); sc.id = 'ov-history-loader'; sc.async = true;
++ sc.src = `${_config.apiBase}/shared/history.js`;
++ sc.onload = () => { try { root.OpenVibeHistory.record(rec, { token: _config.token, apiBase: _config.apiBase }); } catch { /* */ } };
++ document.head.appendChild(sc);
++ } else {
++ document.getElementById('ov-history-loader').addEventListener('load', () => { try { root.OpenVibeHistory.record(rec, { token: _config.token, apiBase: _config.apiBase }); } catch { /* */ } });
++ }
++ }
++
function refreshAuthState() {
if (_authInFlight) return _authInFlight;
_authInFlight = resolveSessionUser()
@@@ -619,6 +826,9 @@
_config.user = session.user;
if (session.token) _config.token = session.token;
render();
++ recordHistory();
++ } else {
++ maybeSilentLogin();
}
try {
document.dispatchEvent(new CustomEvent('openvibe-navbar-auth', {
@@@ -673,28 +883,37 @@
});
}
++ /**
++ * "Recently used" rows in the dropdown: the last few things this account touched anywhere
++ * on the network, from the shared history module (loaded lazily from the Network).
++ */
++ function renderRecent(el) {
++ if (!el || !_config.user || _config.user.is_anon) return;
++ const draw = (items) => {
++ if (!items || !items.length) { el.hidden = true; return; }
++ el.hidden = false;
++ el.innerHTML = `<div class="label"><span>Recently used</span><a href="https://openvibe.network/my#history">All history</a></div>` +
++ items.slice(0, 4).map(h => `<a class="item" href="${escapeAttr(h.url)}"><span class="icon"><i class="fa-solid ${escapeAttr(h.icon || 'fa-clock-rotate-left')}"></i></span><span class="t">${escapeAttr(h.title || h.url)}</span><span class="s">${escapeAttr(h.service_label || h.service || '')}</span></a>`).join('');
++ };
++ const H = root.OpenVibeHistory;
++ if (H && typeof H.recent === 'function') { H.recent({ limit: 4, token: _config.token, apiBase: _config.apiBase }).then(draw).catch(() => draw(null)); return; }
++ if (document.getElementById('ov-history-loader')) return;
++ const sc = document.createElement('script'); sc.id = 'ov-history-loader'; sc.async = true;
++ sc.src = `${_config.apiBase}/shared/history.js`;
++ sc.onload = () => { try { root.OpenVibeHistory.recent({ limit: 4, token: _config.token, apiBase: _config.apiBase }).then(draw).catch(() => draw(null)); } catch { /* */ } };
++ document.head.appendChild(sc);
++ }
++
function render() {
if (_navEl) _navEl.remove();
const nav = document.createElement('nav');
nav.className = 'openvibe-navbar';
const svc = _config.service;
-- const links = SERVICE_LINKS[svc] || [];
--
-- // Resolve brand name + icon: config override > subdomain lookup > service defaults
-- const host = (typeof location !== 'undefined' && location.hostname) || '';
-- // Exact match first (e.g., hostname.openvibe.tools), then fallback to service name
-- let subBrand = SUBDOMAIN_BRANDS[host];
-- if (!subBrand && host && host.includes('.openvibe.tools')) {
-- // Try matching against service names
-- const parts = host.split('.');
-- const potential = parts[0]; // e.g., 'json' from 'json.openvibe.tools'
-- subBrand = SUBDOMAIN_BRANDS[`${potential}.openvibe.tools`];
-- }
-- const svcName = _config.brandName || (subBrand && subBrand.name) || SERVICE_NAMES[svc] || 'OpenVibe';
-- const svcIcon = _config.brandIcon || (subBrand && subBrand.icon) || SERVICE_ICONS[svc] || 'fa-circle-nodes';
-- const brandHref = '/';
++ const brand = resolveBrand();
++ if (typeof nav.setAttribute === 'function') { nav.setAttribute('data-compact', _config.compact || 'auto'); nav.setAttribute('data-service', svc); }
++ const links = currentLinks();
const u = _config.user;
const accounts = getAccounts();
@@@ -707,14 +926,9 @@
try { const sc = document.createElement('script'); sc.id = 'ov-mark-loader'; sc.src = 'https://openvibe.network/shared/ov-mark.js'; sc.async = true; document.head.appendChild(sc); } catch { /* */ }
}
nav.innerHTML = `
-- <a class="openvibe-navbar-brand" href="${brandHref}">
-- <span class="flame">${svcIcon === 'fa-circle-nodes' ? '<span class="ov-mark" data-size="28"></span>' : `<i class="fa-solid ${svcIcon}"></i>`}</span>
-- <div>
-- <div class="name">${svcName}</div>
-- </div>
-- </a>
++ ${brandHTML(brand)}
<div class="openvibe-navbar-links">
-- ${links.map(l => `<a href="${l.href}">${l.label}</a>`).join('')}
++ ${links.map(l => `<a href="${escapeAttr(l.href)}"${l.active ? ' class="active"' : ''}${l.external ? ' target="_blank" rel="noopener"' : ''}>${l.icon ? `<i class="fa-solid ${escapeAttr(l.icon)} icon"></i>` : ''}${escapeAttr(l.label)}</a>`).join('')}
${u && u.role === 'admin' ? `<a href="https://openvibe.network/admin"><i class="fa-solid fa-shield-halved"></i> Admin</a>` : ''}
</div>
<div class="openvibe-navbar-spacer"></div>
@@@ -733,6 +947,8 @@
dropdown.id = 'openvibe-user-dropdown';
const otherAccounts = accounts.filter(a => isAnon ? !a.is_anon : String(a.id) !== String(u.id));
++ const before = ((_config.menu && _config.menu.before) || []).concat(_runtimeMenu.before);
++ const after = ((_config.menu && _config.menu.after) || []).concat(_runtimeMenu.after);
dropdown.innerHTML = `
<div class="openvibe-navbar-dropdown-header">
@@@ -759,17 +975,23 @@
<span>Add another account</span>
</a>
</div>
++ <div class="openvibe-navbar-dropdown-recent" id="openvibe-recent" hidden></div>
<div class="openvibe-navbar-dropdown-menu">
++ ${before.map(menuItemHTML).join('')}${before.length ? '<div class="sep"></div>' : ''}
<a href="https://openvibe.network/my"><span class="icon"><i class="fa-solid fa-user"></i></span> My Account</a>
-- <a href="https://openvibe.network/my#notifications"><span class="icon"><i class="fa-solid fa-bell"></i></span> Notification Settings</a>
++ <a href="https://openvibe.network/my#history"><span class="icon"><i class="fa-solid fa-clock-rotate-left"></i></span> History</a>
++ <a href="https://openvibe.network/my#notifications"><span class="icon"><i class="fa-solid fa-bell"></i></span> Notifications</a>
<a href="https://openvibe.network/themes"><span class="icon"><i class="fa-solid fa-palette"></i></span> Themes</a>
<a href="https://openvibe.network/my#linked"><span class="icon"><i class="fa-solid fa-link"></i></span> Linked Services</a>
${u.role === 'admin' ? `<a href="https://openvibe.network/admin"><span class="icon"><i class="fa-solid fa-screwdriver-wrench"></i></span> Admin Panel</a>` : ''}
-- <div style="height:1px;background:var(--border,#333340);margin:4px -8px"></div>
++ ${after.length ? '<div class="sep"></div>' : ''}${after.map(menuItemHTML).join('')}
++ <div class="sep"></div>
<button id="openvibe-logout-btn" class="danger"><span class="icon"><i class="fa-solid fa-right-from-bracket"></i></span> Sign Out</button>
</div>
`;
nav.appendChild(dropdown);
++ bindMenuItems(dropdown, before.concat(after));
++ if (_config.recent !== false) renderRecent(dropdown.querySelector('#openvibe-recent'));
// Avatar click toggles dropdown
nav.querySelector('#openvibe-avatar-btn').addEventListener('click', () => {
@@@ -838,9 +1060,13 @@
// the shared SSO state (ov_token cookie / localStorage / optional
// sessionUrl) and the navbar re-renders when it arrives.
if (!_config.user) refreshAuthState();
++ else recordHistory();
return el;
},
++ /** Record something the signed-in user did here ({type, title, url, icon}) in their network history. */
++ record(entry) { const h = _config.history; _config.history = Object.assign({}, h || {}, entry || {}); recordHistory(); _config.history = h; },
++
/** Re-resolve the signed-in user from the shared SSO state. */
refreshAuth() { return refreshAuthState(); },
@@@ -850,6 +1076,26 @@
render();
},
++ /** Replace this site's top links at runtime ([{label, href, icon?, active?, external?}]). */
++ setLinks(links) { _runtimeLinks = Array.isArray(links) ? links : null; if (_navEl) render(); },
++
++ /** Add a row to the account dropdown: {label, href|onClick, icon, danger, external, position:'before'|'after'}. */
++ addMenuItem(item) {
++ if (!item || !item.label) return;
++ const it = Object.assign({ id: item.id || `mi-${Math.random().toString(36).slice(2, 8)}` }, item);
++ (it.position === 'before' ? _runtimeMenu.before : _runtimeMenu.after).push(it);
++ if (_navEl && _config.user) render();
++ return it.id;
++ },
++
++ removeMenuItem(id) {
++ for (const k of ['before', 'after']) _runtimeMenu[k] = _runtimeMenu[k].filter(i => i.id !== id);
++ if (_navEl && _config.user) render();
++ },
++
++ /** The resolved brand for this page ({ sub, core, tld, name, short, variant }). */
++ brand() { return resolveBrand(); },
++
setToken(token) { _config.token = token; },
/** Get the bell mount point for OpenVibeNotifications. */
ddiff --git a/packages/openvibe-shared/ov-mark.js b/packages/openvibe-shared/ov-mark.js
iindex 2c018cf5..5e5bf5be 100644
---- a/packages/openvibe-shared/ov-mark.js
++++ b/packages/openvibe-shared/ov-mark.js
@@@ -2,6 +2,7 @@
* ov-mark.js — the OpenVibe "OV" brand mark as a drop-in.
* <span class="ov-mark"></span> → 32px animated mark
* <span class="ov-mark" data-size="56"> → any size; add data-static="1" for no motion
++ * <span class="ov-mark" data-variant="live"> → the site's own twist on the motion (see VARIANTS)
* Self-contained: injects its own CSS once, works on any page (Live, Network, static pages).
* The O is a ring with a comet arc running around it, the V is drawn inside with a light
* sweep, and a dot orbits the ring. Transform/opacity/stroke motion only.
@@@ -17,7 +18,30 @@
.ov-mark .r{fill:none;stroke:currentColor;stroke-width:4;opacity:.28}
.ov-mark .c{fill:none;stroke-width:4;stroke-linecap:round;stroke-dasharray:34 79;transform-box:fill-box;transform-origin:center;animation:ovmComet 3.6s linear infinite}
.ov-mark .v{fill:none;stroke-width:4.6;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:46;animation:ovmDraw 1.1s cubic-bezier(.2,.8,.2,1) both}
--.ov-mark .d{fill:#fff;transform-box:fill-box;transform-origin:center;animation:ovmDot 2.4s ease-in-out infinite}
++.ov-mark .d{fill:var(--ovm-dot,#fff);transform-box:fill-box;transform-origin:center;animation:ovmDot 2.4s ease-in-out infinite}
++/* Per-site variants: one family, each site with its own motion so the network reads as one brand
++ with many rooms. Colours stay with the theme (currentColor); only the dot and the rhythm change. */
++.ov-mark[data-variant=live]{--ovm-dot:#ef4444}
++.ov-mark[data-variant=live] .d{animation:ovmOnAir 1.4s ease-in-out infinite}
++.ov-mark[data-variant=tools] .c{animation-duration:2.2s;stroke-dasharray:22 91}
++.ov-mark[data-variant=tools] .o2{display:none}
++.ov-mark[data-variant=media] .c{stroke-dasharray:14 14 14 71;animation-duration:4.4s}
++.ov-mark[data-variant=games] svg{animation:ovmBounce 1.6s cubic-bezier(.3,1.4,.4,1) infinite}
++.ov-mark[data-variant=games] .d{animation-duration:1.6s}
++.ov-mark[data-variant=community] .o2{opacity:.9;r:1.8}
++.ov-mark[data-variant=community] .c{animation-direction:reverse}
++.ov-mark[data-variant=network] .g{animation-duration:2.4s}
++.ov-mark[data-variant=chat] .d{animation:ovmDot 1s ease-in-out infinite}
++.ov-mark[data-variant=stream] .c{stroke-dasharray:60 53;animation-duration:2.8s}
++.ov-mark[data-variant=codes] .v{animation:ovmDraw 1.1s cubic-bezier(.2,.8,.2,1) infinite alternate}
++.ov-mark[data-variant=blog] .c,.ov-mark[data-variant=wiki] .c,.ov-mark[data-variant=news] .c{animation-duration:6s}
++.ov-mark[data-variant=vip]{--ovm-dot:#facc15}
++.ov-mark[data-variant=tips]{--ovm-dot:#22c55e}
++.ov-mark[data-variant=deals],.ov-mark[data-variant=coupons]{--ovm-dot:#f59e0b}
++.ov-mark[data-variant=trade] .c{animation-timing-function:cubic-bezier(.4,0,.2,1)}
++.ov-mark[data-variant=host] svg{animation:none}
++@keyframes ovmOnAir{0%,100%{transform:scale(1);opacity:.7}50%{transform:scale(1.7);opacity:1}}
++@keyframes ovmBounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-2.5px)}}
.ov-mark .o{fill:#fff;filter:drop-shadow(0 0 3px currentColor)}
.ov-mark .o2{opacity:.5}
.ov-mark:hover svg,a:hover>.ov-mark svg,button:hover>.ov-mark svg{animation:ovmSpin .9s cubic-bezier(.2,1.5,.3,1) 1}
ddiff --git a/packages/openvibe-shared/test/navbar-brand.test.js b/packages/openvibe-shared/test/navbar-brand.test.js
nnew file mode 100644
iindex 00000000..77156530
---- /dev/null
++++ b/packages/openvibe-shared/test/navbar-brand.test.js
@@@ -0,0 +1,129 @@
++'use strict';
++// navbar.js — brand resolution from the hostname, compact/short form, overrides and the
++// modular menu/links API (plain node with the same stubbed globals as navbar-auth.test.js).
++const assert = require('assert');
++const path = require('path');
++const NAVBAR_PATH = path.join(__dirname, '..', 'navbar.js');
++
++function stubElement() {
++ const el = {
++ style: {}, textContent: '', className: '', id: '', innerHTML: '', dataset: {}, attrs: {}, children: [],
++ classList: { add() {}, remove() {}, toggle() {} },
++ setAttribute(k, v) { this.attrs[k] = String(v); }, getAttribute(k) { return this.attrs[k] ?? null; },
++ appendChild(c) { this.children.push(c); }, prepend(c) { this.children.unshift(c); }, remove() {}, addEventListener() {},
++ querySelector() { return stubElement(); }, querySelectorAll() { return []; },
++ };
++ return el;
++}
++
++function load(hostname, opts = {}) {
++ global.location = { hostname, href: `https://${hostname}/`, pathname: opts.pathname || '/', protocol: 'https:' };
++ global.window = { location: global.location };
++ try { Object.defineProperty(global, 'navigator', { value: { userAgent: 'test' }, configurable: true }); } catch { /* read-only in some runtimes */ }
++ const created = [];
++ global.document = {
++ cookie: opts.cookie || '', head: stubElement(), body: stubElement(), title: 'T',
++ getElementById: () => null, createElement: (tag) => { const e = stubElement(); e.tag = tag; created.push(e); return e; },
++ addEventListener() {}, removeEventListener() {}, dispatchEvent() {},
++ };
++ global.CustomEvent = class { constructor(t, i) { this.type = t; this.detail = i && i.detail; } };
++ const store = {};
++ global.localStorage = { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: (k) => { delete store[k]; } };
++ global.sessionStorage = { getItem: () => null, setItem() {}, removeItem() {} };
++ global.fetch = async () => ({ ok: false, status: 404, json: async () => ({}) });
++ delete require.cache[require.resolve(NAVBAR_PATH)];
++ const navbar = require(NAVBAR_PATH);
++ return { navbar, created };
++}
++
++// ── hostname → segments ──────────────────────────────────────
++{
++ const { navbar } = load('pastes.openvibe.tools');
++ navbar.init({ service: 'paste', user: { id: 1, username: 'x' } });
++ const b = navbar.brand();
++ assert.strictEqual(b.name, 'Pastes.OpenVibe.Tools');
++ assert.strictEqual(b.short, 'Pastes');
++ assert.strictEqual(b.tld, 'tools');
++ assert.strictEqual(b.variant, 'tools');
++}
++{
++ const { navbar } = load('json.openvibe.tools');
++ navbar.init({ service: 'dev' });
++ assert.strictEqual(navbar.brand().name, 'JSON.OpenVibe.Tools', 'known acronyms keep their casing');
++}
++{
++ const { navbar } = load('mergepdf.openvibe.tools');
++ navbar.init({ service: 'docs' });
++ assert.strictEqual(navbar.brand().name, 'MergePDF.OpenVibe.Tools');
++}
++{
++ const { navbar } = load('openvibe.live');
++ navbar.init({ service: 'live' });
++ const b = navbar.brand();
++ assert.strictEqual(b.name, 'OpenVibe.Live');
++ assert.strictEqual(b.short, 'OpenVibe.Live', 'no subdomain: nothing to shorten to');
++ assert.strictEqual(b.variant, 'live');
++}
++{
++ const { navbar } = load('www.openvibe.community');
++ navbar.init({ service: 'community' });
++ assert.strictEqual(navbar.brand().name, 'OpenVibe.Community', 'www is not a subdomain brand');
++}
++{
++ const { navbar } = load('play.openvibe.games');
++ navbar.init({ service: 'games' });
++ assert.strictEqual(navbar.brand().name, 'Play.OpenVibe.Games');
++}
++{
++ const { navbar } = load('ingest.openre.stream');
++ navbar.init({ service: 'live' });
++ const b = navbar.brand();
++ assert.strictEqual(b.name, 'Ingest.OpenRe.Stream');
++ assert.strictEqual(b.variant, 'stream');
++}
++{
++ const { navbar } = load('openvibe.vip');
++ navbar.init({ service: 'network' });
++ assert.strictEqual(navbar.brand().name, 'OpenVibe.VIP');
++}
++// ── off-network hosts fall back to the service id ────────────
++{
++ const { navbar } = load('localhost');
++ navbar.init({ service: 'net' });
++ assert.strictEqual(navbar.brand().name, 'Net.OpenVibe.Tools');
++ const { navbar: n2 } = load('localhost');
++ n2.init({ service: 'live' });
++ assert.strictEqual(n2.brand().name, 'OpenVibe.Live');
++}
++// ── overrides ────────────────────────────────────────────────
++{
++ const { navbar } = load('openvibe.tools');
++ navbar.init({ service: 'tools', brandName: 'Paste.OpenVibe' });
++ assert.strictEqual(navbar.brand().name, 'Pastes.OpenVibe.Tools', 'legacy brandName steers the subdomain segment (paste → Pastes, the host name)');
++ const { navbar: n2 } = load('openvibe.tools');
++ n2.init({ service: 'tools', brand: { sub: 'logo', tag: 'beta' } });
++ assert.strictEqual(n2.brand().name, 'Logo.OpenVibe.Tools');
++ assert.strictEqual(n2.brand().tag, 'beta');
++ const { navbar: n3 } = load('openvibe.tools');
++ n3.init({ service: 'tools', brand: { name: 'Custom Name', variant: 'games' } });
++ assert.strictEqual(n3.brand().name, 'Custom Name');
++ assert.strictEqual(n3.brand().variant, 'games');
++}
++// ── rendered markup carries the segments, compact attr and the variant ────
++{
++ const { navbar, created } = load('pastes.openvibe.tools', { pathname: '/mine' });
++ navbar.init({ service: 'paste', user: { id: 1, username: 'x' }, links: [{ label: 'Mine', href: '/mine' }, { label: 'New', href: '/new', icon: 'fa-plus' }], menu: { after: [{ id: 'mp', label: 'My pastes', href: '/my', icon: 'fa-paste' }] }, compact: 'always' });
++ const nav = created.find(e => e.tag === 'nav');
++ assert.ok(nav, 'nav element created');
++ assert.strictEqual(nav.getAttribute('data-compact'), 'always');
++ assert.ok(nav.innerHTML.includes('class="b-sub">Pastes<') && nav.innerHTML.includes('class="b-tld">Tools<'), 'segments rendered');
++ assert.ok(nav.innerHTML.includes('data-variant="tools"'), 'ov-mark variant');
++ assert.ok(nav.innerHTML.includes('href="/mine" class="active"'), 'current path marked active');
++ assert.ok(nav.innerHTML.includes('fa-plus icon'), 'link icon rendered');
++ const dd = created.find(e => e.tag === 'div' && /openvibe-navbar-dropdown-menu/.test(e.innerHTML));
++ assert.ok(dd && dd.innerHTML.includes('data-menu-id="mp"') && dd.innerHTML.includes('My pastes'), 'custom dropdown row rendered');
++ assert.ok(dd.innerHTML.includes('/my#history'), 'History link present');
++ const id = navbar.addMenuItem({ label: 'Runtime row', href: '/r', position: 'before' });
++ assert.ok(id, 'addMenuItem returns an id');
++}
++console.log('navbar brand: all checks passed');
ddiff --git a/public/assets/favicon.svg b/public/assets/favicon.svg
nnew file mode 100644
iindex 00000000..38638d57
---- /dev/null
++++ b/public/assets/favicon.svg
@@@ -0,0 +1 @@
++<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="12" fill="#0f172a"/><circle cx="24" cy="24" r="16" fill="none" stroke="#3b82f6" stroke-width="4" opacity=".35"/><path d="M8.5 24 A15.5 15.5 0 0 1 24 8.5" fill="none" stroke="#60a5fa" stroke-width="4" stroke-linecap="round"/><path d="M15.5 18 L24 33 L32.5 18" fill="none" stroke="#fff" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="24" cy="33" r="2.4" fill="#fff"/></svg>
ddiff --git a/public/assets/ov-mark.js b/public/assets/ov-mark.js
iindex 2c018cf5..5e5bf5be 100644
---- a/public/assets/ov-mark.js
++++ b/public/assets/ov-mark.js
@@@ -2,6 +2,7 @@
* ov-mark.js — the OpenVibe "OV" brand mark as a drop-in.
* <span class="ov-mark"></span> → 32px animated mark
* <span class="ov-mark" data-size="56"> → any size; add data-static="1" for no motion
++ * <span class="ov-mark" data-variant="live"> → the site's own twist on the motion (see VARIANTS)
* Self-contained: injects its own CSS once, works on any page (Live, Network, static pages).
* The O is a ring with a comet arc running around it, the V is drawn inside with a light
* sweep, and a dot orbits the ring. Transform/opacity/stroke motion only.
@@@ -17,7 +18,30 @@
.ov-mark .r{fill:none;stroke:currentColor;stroke-width:4;opacity:.28}
.ov-mark .c{fill:none;stroke-width:4;stroke-linecap:round;stroke-dasharray:34 79;transform-box:fill-box;transform-origin:center;animation:ovmComet 3.6s linear infinite}
.ov-mark .v{fill:none;stroke-width:4.6;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:46;animation:ovmDraw 1.1s cubic-bezier(.2,.8,.2,1) both}
--.ov-mark .d{fill:#fff;transform-box:fill-box;transform-origin:center;animation:ovmDot 2.4s ease-in-out infinite}
++.ov-mark .d{fill:var(--ovm-dot,#fff);transform-box:fill-box;transform-origin:center;animation:ovmDot 2.4s ease-in-out infinite}
++/* Per-site variants: one family, each site with its own motion so the network reads as one brand
++ with many rooms. Colours stay with the theme (currentColor); only the dot and the rhythm change. */
++.ov-mark[data-variant=live]{--ovm-dot:#ef4444}
++.ov-mark[data-variant=live] .d{animation:ovmOnAir 1.4s ease-in-out infinite}
++.ov-mark[data-variant=tools] .c{animation-duration:2.2s;stroke-dasharray:22 91}
++.ov-mark[data-variant=tools] .o2{display:none}
++.ov-mark[data-variant=media] .c{stroke-dasharray:14 14 14 71;animation-duration:4.4s}
++.ov-mark[data-variant=games] svg{animation:ovmBounce 1.6s cubic-bezier(.3,1.4,.4,1) infinite}
++.ov-mark[data-variant=games] .d{animation-duration:1.6s}
++.ov-mark[data-variant=community] .o2{opacity:.9;r:1.8}
++.ov-mark[data-variant=community] .c{animation-direction:reverse}
++.ov-mark[data-variant=network] .g{animation-duration:2.4s}
++.ov-mark[data-variant=chat] .d{animation:ovmDot 1s ease-in-out infinite}
++.ov-mark[data-variant=stream] .c{stroke-dasharray:60 53;animation-duration:2.8s}
++.ov-mark[data-variant=codes] .v{animation:ovmDraw 1.1s cubic-bezier(.2,.8,.2,1) infinite alternate}
++.ov-mark[data-variant=blog] .c,.ov-mark[data-variant=wiki] .c,.ov-mark[data-variant=news] .c{animation-duration:6s}
++.ov-mark[data-variant=vip]{--ovm-dot:#facc15}
++.ov-mark[data-variant=tips]{--ovm-dot:#22c55e}
++.ov-mark[data-variant=deals],.ov-mark[data-variant=coupons]{--ovm-dot:#f59e0b}
++.ov-mark[data-variant=trade] .c{animation-timing-function:cubic-bezier(.4,0,.2,1)}
++.ov-mark[data-variant=host] svg{animation:none}
++@keyframes ovmOnAir{0%,100%{transform:scale(1);opacity:.7}50%{transform:scale(1.7);opacity:1}}
++@keyframes ovmBounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-2.5px)}}
.ov-mark .o{fill:#fff;filter:drop-shadow(0 0 3px currentColor)}
.ov-mark .o2{opacity:.5}
.ov-mark:hover svg,a:hover>.ov-mark svg,button:hover>.ov-mark svg{animation:ovmSpin .9s cubic-bezier(.2,1.5,.3,1) 1}
ddiff --git a/public/index.html b/public/index.html
iindex 63242af6..4405e15d 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -4,15 +4,21 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OpenVibe — One Account. All of OpenVibe.</title>
--<meta name="description" content="OpenVibe is a free, community-driven network: live streaming, online tools, games, and media — all with one account.">
--<meta name="keywords" content="openvibe, openvibe network, live streaming, free tools, single sign-on">
++<meta name="description" content="OpenVibe is an open, community-run network: live streaming, online tools, games, media, pastes and more — one account for all of it.">
++<meta name="keywords" content="openvibe, openvibe network, live streaming, online tools, pastes, community, single sign-on">
<meta name="theme-color" content="#8b5cf6">
<meta property="og:type" content="website">
<meta property="og:url" content="https://openvibe.network/">
<meta property="og:title" content="OpenVibe — One Account. All of OpenVibe.">
--<meta property="og:description" content="Free, community-driven network: live streaming, tools, games, and media — one account for everything.">
++<meta property="og:description" content="An open, community-run network: live streaming, tools, games, media and community — one account for everything.">
++<meta property="og:image" content="https://openvibe.network/assets/logo-512.png">
++<meta name="twitter:card" content="summary">
++<meta name="twitter:title" content="OpenVibe — One Account. All of OpenVibe.">
++<meta name="twitter:description" content="An open, community-run network: live streaming, tools, games, media and community — one account for everything.">
++<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
<meta property="og:site_name" content="OpenVibe">
<link rel="canonical" href="https://openvibe.network/">
++<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"Organization","@id":"https://openvibe.network/#org","name":"OpenVibe","url":"https://openvibe.network/","logo":"https://openvibe.network/assets/logo-512.png","sameAs":["https://github.com/OpenVibers","https://discord.gg/M6MuRUaeJj","https://openvibe.live","https://openvibe.tools","https://openvibe.games","https://openvibe.media","https://openvibe.community"]},{"@type":"WebSite","@id":"https://openvibe.network/#site","url":"https://openvibe.network/","name":"OpenVibe.Network","description":"One account for the whole OpenVibe network: live streaming, tools, games, media and community.","publisher":{"@id":"https://openvibe.network/#org"}}]}</script>
<link rel="icon" type="image/x-icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect fill='%238b5cf6' width='32' height='32' rx='8'/%3E%3Ctext x='50%25' y='50%25' font-size='20' font-weight='bold' fill='white' text-anchor='middle' dominant-baseline='central'%3EO%3C/text%3E%3C/svg%3E">
<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">
<!-- Critical theme bootstrap (ov-theme-boot) — MUST stay inline and above the stylesheets.
@@@ -98,8 +104,9 @@ a{color:inherit}
.svc-name{font-size:17px;font-weight:700;letter-spacing:-.3px}
.svc-desc{font-size:13px;line-height:1.55;color:var(--text-secondary)}
.svc-go{margin-top:auto;display:inline-flex;align-items:center;gap:6px;font-size:12.5px;font-weight:600;color:var(--card-accent,var(--accent-light))}
--.svc-card.soon{opacity:.65;cursor:default}
--.svc-card.soon:hover{transform:none;border-color:var(--border);box-shadow:none}
++.svc-card.soon{opacity:.72}
++.svc-card.soon:hover{opacity:1}
++.soon-grid .svc-card{padding-bottom:18px}
.soon-tag{
position:absolute;top:14px;right:14px;padding:3px 10px;border-radius:999px;
background:var(--bg-hover);border:1px solid var(--border-light);
@@@ -144,9 +151,9 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
<!-- Hero -->
<section class="hero">
-- <div class="hero-badge"><span class="ov-mark" data-size="20"></span> The OpenVibe Network — Community-Driven, No Profit</div>
++ <div class="hero-badge"><span class="ov-mark" data-size="20" data-variant="network"></span> The OpenVibe Network — open source, community run</div>
<h1>One Account.<br><span class="grad">All of OpenVibe.</span></h1>
-- <p class="sub">Live streaming, free online tools, games, and media hosting — one free account works across the entire network. One login, no nonsense.</p>
++ <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>
<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>
@@@ -164,13 +171,13 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
<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">Free &amp; open live streaming. Go live with OBS or your browser, chat, clip, and tip with Vibes.</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">A huge collection of free online tools — network diagnostics, dev utilities, converters, text, images, PDFs, and more.</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;">
@@@ -185,13 +192,33 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
<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>
-- <div class="svc-card soon" style="--card-accent:#f39c12;">
-- <span class="soon-tag">Coming Soon</span>
++ <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">Forums, events, and community spaces for the whole network. Under construction — stay tuned.</div>
-- <span class="svc-go">openvibe.community</span>
-- </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>
@@@ -200,31 +227,20 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
<div class="sso-inner">
<span class="ov-mark" data-size="44" style="margin-bottom:12px"></span>
<h3>Your identity, everywhere</h3>
-- <p>openvibe.network is the identity service behind it all: single sign-on, profiles, themes that follow you across every app, notifications, and the network-wide OpenCoins wallet.</p>
++ <p>openvibe.network is the identity behind it all: sign in once and every OpenVibe site signs you in too, your themes and notifications follow you, your history across the network lives in one place, and the OpenCoins wallet works everywhere.</p>
</div>
</section>
</div>
--<!-- Footer -->
--<footer>
-- <div class="foot-inner">
-- <div class="foot-brand"><span class="ov-mark" data-size="24"></span> OpenVibe</div>
-- <nav class="foot-links">
-- <a href="https://openvibe.live">Live</a>
-- <a href="https://openvibe.tools">Tools</a>
-- <a href="https://openvibe.games">Games</a>
-- <a href="https://openvibe.media">Media</a>
-- <a href="/my">My Account</a>
-- <a href="/login">Sign In</a>
-- </nav>
-- <div class="foot-note">© OpenVibe Network. Community-driven and free — one account for all of OpenVibe.</div>
-- </div>
--</footer>
++<!-- Footer (shared across the network) -->
++<div id="ov-footer"></div>
<script src="/shared/navbar.js"></script>
++<script src="/shared/footer.js"></script>
<script>
(function () {
++ if (window.OpenVibeFooter) { try { OpenVibeFooter.init({ service: 'network', variant: 'full', links: [{ heading: 'OpenVibe.Network', items: [{ name: 'My account', url: '/my' }, { name: 'History', url: '/my#history' }, { name: 'Themes', url: '/themes' }, { name: 'Linked services', url: '/my#linked' }, { name: 'Sign in', url: '/login' }] }] }); } catch (e) { /* */ } }
function getCookie(name) {
const m = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
return m ? decodeURIComponent(m[1]) : null;
@@@ -234,7 +250,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
function initNavbar(user) {
if (typeof OpenVibeNavbar !== 'undefined') {
-- OpenVibeNavbar.init({ service: 'network', token: token || null, user: user || null });
++ OpenVibeNavbar.init({ service: 'network', token: token || null, user: user || null, links: [{ label: 'Home', href: '/' }, { label: 'My Account', href: '/my' }, { label: 'Themes', href: '/themes' }, { label: 'The network', href: '/#network' }] });
}
if (user) {
document.getElementById('hero-guest-ctas').style.display = 'none';
ddiff --git a/public/login.html b/public/login.html
iindex 7dffbaa1..957adcb8 100644
---- a/public/login.html
++++ b/public/login.html
@@@ -665,7 +665,7 @@ async function selectAccount(account) {
throw new Error('Invalid redirect target returned by OAuth server');
}
toast('Redirecting as ' + (account.display_name || account.username) + '...', 'success');
-- setTimeout(() => window.location.href = data.redirect, 400);
++ setTimeout(() => window.location.href = viaFanout(data.redirect, oauthClientId()), 400);
} catch (err) {
toast(err.message, 'error');
} finally {
@@@ -674,7 +674,7 @@ async function selectAccount(account) {
} else {
// Not OAuth — just switch the active account and go home
toast('Welcome back, ' + (account.display_name || account.username) + '! <span class="ov-mark" data-size="16" data-static="1"></span>', 'success');
-- setTimeout(() => window.location.href = getReturnUrl(), 600);
++ setTimeout(() => window.location.href = viaFanout(getReturnUrl()), 600);
}
}
@@@ -727,6 +727,19 @@ function getReturnUrl() {
return params.get('return') || '/';
}
++// After any successful sign-in the browser runs the sign-in-everywhere chain (/sso/fanout):
++// one silent hop through each first-party site so the account is signed in on all of them,
++// then on to where the user was going. An OAuth client that just got its code is skipped
++// (it is about to sign in with that code).
++function viaFanout(url, skipClient) {
++ if (!url || /[?&]error=/.test(String(url))) return url;
++ const u = new URL('/sso/fanout', window.location.origin);
++ u.searchParams.set('next', String(url));
++ if (skipClient) u.searchParams.set('skip', skipClient);
++ return u.toString();
++}
++function oauthClientId() { return params.get('client_id') || ''; }
++
// ── Login Handler ───────────────────────────────────────────
async function handleLogin(e) {
e.preventDefault();
@@@ -776,10 +789,10 @@ async function handleLogin(e) {
throw new Error('Invalid redirect target returned by OAuth server');
}
toast('Welcome back, ' + (data.user.display_name || data.user.username) + '! <span class="ov-mark" data-size="16" data-static="1"></span>');
-- setTimeout(() => window.location.href = confirmData.redirect, 800);
++ setTimeout(() => window.location.href = viaFanout(confirmData.redirect, oauthClientId()), 800);
} else {
toast('Welcome back, ' + (data.user.display_name || data.user.username) + '! <span class="ov-mark" data-size="16" data-static="1"></span>');
-- setTimeout(() => window.location.href = getReturnUrl(), 800);
++ setTimeout(() => window.location.href = viaFanout(getReturnUrl()), 800);
}
} catch (err) {
showMsg('login-msg', err.message, 'error');
@@@ -855,10 +868,10 @@ async function handleRegister(e) {
throw new Error('Invalid redirect target returned by OAuth server');
}
toast('Account created! Welcome to the network <span class="ov-mark" data-size="16" data-static="1"></span>');
-- setTimeout(() => window.location.href = confirmData.redirect, 800);
++ setTimeout(() => window.location.href = viaFanout(confirmData.redirect, oauthClientId()), 800);
} else {
toast('Account created! Welcome to the network <span class="ov-mark" data-size="16" data-static="1"></span>');
-- setTimeout(() => window.location.href = getReturnUrl(), 800);
++ setTimeout(() => window.location.href = viaFanout(getReturnUrl()), 800);
}
} catch (err) {
showMsg('register-msg', err.message, 'error');
@@@ -950,7 +963,7 @@ async function continueAnonymous() {
saveAnonAccount(data.user, data.token);
toast('Browsing as Anonymous #' + data.user.anon_number);
-- setTimeout(() => window.location.href = getReturnUrl(), 600);
++ setTimeout(() => window.location.href = viaFanout(getReturnUrl()), 600);
} catch (err) {
toast(err.message, 'error');
}
ddiff --git a/public/my.html b/public/my.html
iindex db7e06cf..75d19a97 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -123,7 +123,30 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
.notif-grid .toggle.off{background:var(--border)}
/* Linked services */
--.linked-item{display:flex;align-items:center;gap:12px;padding:12px;border-radius:8px;background:var(--bg-input);margin-bottom:8px}
++.linked-item{display:flex;align-items:center;gap:12px;padding:12px;border-radius:8px;background:var(--bg-input);margin-bottom:8px;text-decoration:none;color:inherit;transition:transform .15s,background .15s}
++a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translateX(2px)}
++.linked-item .svc-meta{margin-left:auto;text-align:right;font-size:11px;color:var(--text-muted);white-space:nowrap}
++.linked-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px;margin-top:14px}
++.linked-grid a{display:flex;align-items:center;gap:8px;padding:10px 12px;border-radius:8px;border:1px dashed var(--border);font-size:12px;color:var(--text-secondary);text-decoration:none;transition:border-color .15s,color .15s}
++.linked-grid a:hover{border-color:var(--accent);color:var(--text)}
++.hist-toolbar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:12px}
++.hist-chips{display:flex;flex-wrap:wrap;gap:6px}
++.hist-chips button{padding:5px 10px;border-radius:999px;border:1px solid var(--border);background:transparent;color:var(--text-secondary);font-size:12px;cursor:pointer;transition:all .15s}
++.hist-chips button.active,.hist-chips button:hover{border-color:var(--accent);color:var(--accent-light);background:rgba(var(--accent-rgb,139,92,246),.08)}
++.hist-search{flex:1;min-width:160px;padding:7px 10px;border-radius:8px;border:1px solid var(--border);background:var(--bg-input);color:var(--text);font-size:13px}
++.hist-pause{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--text-secondary);cursor:pointer}
++.btn-small{padding:6px 10px;font-size:12px}
++.hist-day{font-size:11px;font-weight:700;letter-spacing:.6px;text-transform:uppercase;color:var(--text-muted);margin:14px 0 6px}
++.hist-item{display:flex;align-items:center;gap:12px;padding:9px 10px;border-radius:8px;text-decoration:none;color:inherit;transition:background .12s;animation:sec-in .25s ease both}
++.hist-item:hover{background:var(--bg-input)}
++.hist-item .hi{width:34px;height:34px;border-radius:9px;display:grid;place-items:center;background:rgba(var(--accent-rgb,139,92,246),.12);color:var(--accent-light);flex:none}
++.hist-item .ht{font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
++.hist-item .hs{font-size:11px;color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
++.hist-item .hx{margin-left:auto;display:flex;align-items:center;gap:10px;flex:none;font-size:11px;color:var(--text-muted)}
++.hist-item .hx button{background:none;border:none;color:var(--text-muted);cursor:pointer;padding:4px;opacity:0;transition:opacity .15s}
++.hist-item:hover .hx button{opacity:1}
++.hist-item .hx button:hover{color:var(--danger,#ef4444)}
++.hist-empty{padding:26px;text-align:center;color:var(--text-muted);font-size:13px}
.linked-item .svc-icon{font-size:20px;width:32px;text-align:center}
.linked-item .svc-name{font-size:13px;font-weight:600}
.linked-item .svc-user{font-size:11px;color:var(--text-muted)}
@@@ -241,6 +264,7 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
<button onclick="showSection('themes',this)"><i class="fa-solid fa-palette"></i> Themes</button>
<button onclick="showSection('accounts',this)"><i class="fa-solid fa-arrows-rotate"></i> Accounts</button>
<button onclick="showSection('notifications',this)"><i class="fa-solid fa-bell"></i> Notifications</button>
++ <button onclick="showSection('history',this)"><i class="fa-solid fa-clock-rotate-left"></i> History</button>
<button onclick="showSection('linked',this)"><i class="fa-solid fa-link"></i> Linked Services</button>
<button onclick="showSection('security',this)"><i class="fa-solid fa-lock"></i> Security</button>
<button onclick="showSection('anon',this)"><i class="fa-solid fa-user-secret"></i> Anon Info</button>
@@@ -357,11 +381,31 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
</div>
</div>
++ <!-- History Section -->
++ <div class="section" id="sec-history">
++ <div class="card">
++ <h3><span class="icon"><i class="fa-solid fa-clock-rotate-left"></i></span> History across OpenVibe</h3>
++ <p style="margin-bottom:12px;color:var(--text-secondary);font-size:13px">Everything this account opened on any OpenVibe site — tools, streams, pastes, games — in one timeline. Only you can see it; pause or clear it any time.</p>
++ <div class="hist-toolbar">
++ <div class="hist-chips" id="hist-chips"></div>
++ <input type="search" id="hist-q" class="hist-search" placeholder="Search history…" oninput="histDebounce()">
++ <label class="hist-pause" title="While paused nothing new is recorded"><input type="checkbox" id="hist-pause" onchange="toggleHistoryPause(this)"> Pause</label>
++ <button class="btn btn-small btn-danger" onclick="clearHistory()" title="Delete the whole timeline"><i class="fa-solid fa-trash"></i> Clear</button>
++ </div>
++ <div id="hist-list"><div class="loading-spinner">Loading history…</div></div>
++ <div class="btn-row" id="hist-more-row" style="display:none;margin-top:10px"><button class="btn" onclick="loadHistory(true)">Load more</button></div>
++ </div>
++ </div>
++
<!-- Linked Services Section -->
<div class="section" id="sec-linked">
<div class="card">
<h3><span class="icon"><i class="fa-solid fa-link"></i></span> Linked Services</h3>
-- <p style="margin-bottom:12px">Your OpenVibe account is connected to these services.</p>
++ <p style="margin-bottom:12px;color:var(--text-secondary);font-size:13px">Every OpenVibe site you have signed in to with this account. Sign in once here and the network signs you in everywhere; sign out everywhere with one click.</p>
++ <div class="btn-row" style="margin-bottom:14px">
++ <a class="btn btn-primary" href="/sso/fanout?next=%2Flinked"><i class="fa-solid fa-wand-magic-sparkles"></i> Sign in everywhere</a>
++ <a class="btn" href="/sso/fanout?action=logout&next=%2Flogin"><i class="fa-solid fa-right-from-bracket"></i> Sign out everywhere</a>
++ </div>
<div id="linked-list">
<!-- Populated by JS -->
</div>
@@@ -597,6 +641,7 @@ function showSection(id, btn) {
const targetBtn = btn || [...document.querySelectorAll('.section-tabs button')].find((button) => button.getAttribute('onclick')?.includes(`'${id}'`));
targetBtn?.classList.add('active');
history.replaceState(null, '', id === 'profile' ? window.location.pathname : `${window.location.pathname}#${id}`);
++ if (id === 'history' && typeof loadHistory === 'function') loadHistory();
}
function avatarPlaceholder(user, size = 96) {
@@@ -780,7 +825,7 @@ async function resetCustomColors(btn) {
}
function openInitialSection() {
-- const pathSection = { '/themes': 'themes', '/notifications': 'notifications', '/verify-email': 'notifications', '/linked': 'linked', '/security': 'security', '/profile': 'profile' }[window.location.pathname];
++ const pathSection = { '/themes': 'themes', '/notifications': 'notifications', '/verify-email': 'notifications', '/linked': 'linked', '/security': 'security', '/profile': 'profile', '/history': 'history' }[window.location.pathname];
const routeSection = pathSection || window.location.hash.replace(/^#/, '') || 'profile';
if (window.location.pathname === '/verify-email') setTimeout(handleVerifyEmailToken, 50);
if (isAnonSession() && ['notifications','linked','security'].includes(routeSection)) {
@@@ -1133,30 +1178,86 @@ async function togglePref(btn) {
// Linked services
async function loadLinked() {
if (isAnonSession()) return;
++ const list = document.getElementById('linked-list');
++ const SITES = {
++ live: { name: 'OpenVibe.Live', icon: 'fa-tower-broadcast', url: 'https://openvibe.live', color: '#ef4444' },
++ tools: { name: 'OpenVibe.Tools', icon: 'fa-screwdriver-wrench', url: 'https://openvibe.tools', color: '#8b5cf6' },
++ games: { name: 'OpenVibe.Games', icon: 'fa-gamepad', url: 'https://openvibe.games', color: '#22c55e' },
++ media: { name: 'OpenVibe.Media', icon: 'fa-photo-film', url: 'https://openvibe.media', color: '#22d3ee' },
++ community: { name: 'OpenVibe.Community', icon: 'fa-people-group', url: 'https://openvibe.community', color: '#f59e0b' },
++ discord: { name: 'Discord', icon: 'fa-brands fa-discord', url: 'https://discord.gg/M6MuRUaeJj', color: '#5865f2' },
++ network: { name: 'OpenVibe.Network', icon: 'fa-circle-nodes', url: '/', color: '#3b82f6' },
++ };
++ const ago = (iso) => { if (!iso) return ''; const d = (Date.now() - new Date(String(iso).replace(' ', 'T') + (String(iso).endsWith('Z') ? '' : 'Z')).getTime()) / 1000; if (d < 90) return 'just now'; if (d < 3600) return Math.round(d / 60) + 'm ago'; if (d < 86400) return Math.round(d / 3600) + 'h ago'; return Math.round(d / 86400) + 'd ago'; };
try {
-- const data = await apiFetch('/api/auth/me');
-- // We don't have a direct linked endpoint from auth/me, let's check from card
const card = await apiFetch('/api/auth/users/' + currentUser.id + '/card');
-- const list = document.getElementById('linked-list');
-- const icons = { openvibelive: '<i class="fa-solid fa-tv"></i>', openvibegames: '<i class="fa-solid fa-shield-halved"></i>', ovn: '<i class="fa-solid fa-screwdriver-wrench"></i>', openvibemaps: '<i class="fa-solid fa-map"></i>' };
const linked = card.user?.linked_services || [];
-- if (linked.length === 0) {
-- list.innerHTML = '<p style="color:var(--text-muted);font-size:13px">No services linked yet. Use OpenVibe.Live or Scraplandia with your account to link them automatically.</p>';
-- } else {
-- list.innerHTML = linked.map(l => `
-- <div class="linked-item">
-- <span class="svc-icon">${icons[l.service] || '<i class="fa-solid fa-link"></i>'}</span>
-- <div>
-- <div class="svc-name">${l.service}</div>
-- <div class="svc-user">${l.service_username || l.service_user_id}</div>
-- </div>
-- <span class="svc-status"><i class="fa-solid fa-check"></i> Linked</span>
-- </div>
-- `).join('');
-- }
++ const known = new Set(linked.map(l => l.service));
++ const rows = linked.map(l => {
++ const site = SITES[l.service] || { name: l.service, icon: 'fa-link', url: null };
++ const who = l.service_username && !String(l.service_user_id || '').startsWith('network:') ? '@' + l.service_username : (l.service_username ? '@' + l.service_username : 'this account');
++ const inner = `<span class="svc-icon" style="color:${site.color || 'var(--accent-light)'}"><i class="fa-solid ${site.icon}"></i></span>
++ <div><div class="svc-name">${esc(site.name)}</div><div class="svc-user">${esc(who)}</div></div>
++ <div class="svc-meta">${l.last_used_at ? 'used ' + ago(l.last_used_at) : (l.linked_at ? 'linked ' + ago(l.linked_at) : '')}<br><span class="svc-status" style="color:var(--success)"><i class="fa-solid fa-check"></i> Linked</span></div>`;
++ return site.url ? `<a class="linked-item" href="${site.url}" target="_blank" rel="noopener">${inner}</a>` : `<div class="linked-item">${inner}</div>`;
++ }).join('');
++ const rest = Object.entries(SITES).filter(([id]) => !known.has(id) && id !== 'network' && id !== 'discord');
++ list.innerHTML = (rows || '<p style="color:var(--text-muted);font-size:13px">Nothing linked yet — the first sign-in on any OpenVibe site links it here automatically.</p>') +
++ (rest.length ? `<div class="linked-grid">${rest.map(([id, site]) => `<a href="${site.url}" target="_blank" rel="noopener" title="Open ${site.name}"><i class="fa-solid ${site.icon}" style="color:${site.color}"></i> ${esc(site.name.replace('OpenVibe.', ''))}<span style="margin-left:auto;font-size:10px;opacity:.7">not yet</span></a>`).join('')}</div>` : '');
} catch (err) { console.error('Failed to load linked:', err); }
}
++// ── History across the network ──
++let _histOffset = 0, _histService = '', _histTimer = null, _histLoaded = false;
++function histDebounce() { clearTimeout(_histTimer); _histTimer = setTimeout(() => loadHistory(false), 250); }
++function histFilter(svc, btn) { _histService = svc; document.querySelectorAll('#hist-chips button').forEach(b => b.classList.toggle('active', b === btn)); loadHistory(false); }
++async function loadHistory(more) {
++ if (isAnonSession()) { document.getElementById('hist-list').innerHTML = '<div class="hist-empty">History is only kept for signed-in accounts.</div>'; return; }
++ if (!more) _histOffset = 0;
++ const listEl = document.getElementById('hist-list');
++ if (!more) listEl.innerHTML = '<div class="loading-spinner">Loading history…</div>';
++ try {
++ const q = new URLSearchParams({ limit: '40', offset: String(_histOffset) });
++ if (_histService) q.set('service', _histService);
++ const term = document.getElementById('hist-q').value.trim(); if (term) q.set('q', term);
++ const data = await apiFetch('/api/history?' + q);
++ document.getElementById('hist-pause').checked = !!data.paused;
++ if (!_histLoaded || !more) {
++ const chips = document.getElementById('hist-chips');
++ chips.innerHTML = `<button class="${_histService ? '' : 'active'}" onclick="histFilter('', this)">All</button>` + (data.services || []).map(s => `<button class="${_histService === s.service ? 'active' : ''}" onclick="histFilter('${esc(s.service)}', this)">${esc(s.label || s.service)} <span style="opacity:.6">${s.count}</span></button>`).join('');
++ _histLoaded = true;
++ }
++ const items = data.items || [];
++ if (!more && !items.length) { listEl.innerHTML = `<div class="hist-empty"><i class="fa-solid fa-clock-rotate-left" style="font-size:22px;display:block;margin-bottom:8px;opacity:.5"></i>${term || _histService ? 'Nothing matches.' : 'Nothing yet — open a tool, watch a stream or read a paste and it shows up here.'}</div>`; document.getElementById('hist-more-row').style.display = 'none'; return; }
++ const dayOf = (iso) => { const d = new Date(String(iso).replace(' ', 'T') + 'Z'); const today = new Date(); const y = new Date(); y.setDate(today.getDate() - 1); if (d.toDateString() === today.toDateString()) return 'Today'; if (d.toDateString() === y.toDateString()) return 'Yesterday'; return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); };
++ const timeOf = (iso) => new Date(String(iso).replace(' ', 'T') + 'Z').toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
++ let html = '', lastDay = more ? (listEl.dataset.lastDay || '') : '';
++ for (const h of items) {
++ const day = dayOf(h.updated_at);
++ if (day !== lastDay) { html += `<div class="hist-day">${esc(day)}</div>`; lastDay = day; }
++ let host = ''; try { host = new URL(h.url).hostname; } catch {}
++ html += `<a class="hist-item" href="${esc(h.url)}" data-id="${h.id}"><span class="hi"><i class="fa-solid ${esc(h.icon || 'fa-file-lines')}"></i></span><div style="min-width:0"><div class="ht">${esc(h.title)}</div><div class="hs">${esc(h.service_label || host)} · ${esc(host)}${h.hits > 1 ? ` · ${h.hits}×` : ''}</div></div><div class="hx"><span>${esc(timeOf(h.updated_at))}</span><button title="Remove" onclick="removeHistoryItem(event, ${h.id})"><i class="fa-solid fa-xmark"></i></button></div></a>`;
++ }
++ listEl.dataset.lastDay = lastDay;
++ if (more) listEl.insertAdjacentHTML('beforeend', html); else listEl.innerHTML = html;
++ _histOffset += items.length;
++ document.getElementById('hist-more-row').style.display = _histOffset < (data.total || 0) ? '' : 'none';
++ } catch (err) { listEl.innerHTML = `<div class="hist-empty">Could not load history: ${esc(err.message)}</div>`; }
++}
++async function toggleHistoryPause(cb) {
++ try { await apiFetch('/api/history/settings', { method: 'PUT', body: JSON.stringify({ paused: cb.checked }) }); toast(cb.checked ? 'History paused — nothing new is recorded.' : 'History recording resumed.'); }
++ catch (e) { cb.checked = !cb.checked; toast(e.message, 'error'); }
++}
++async function clearHistory() {
++ if (!confirm('Delete your whole OpenVibe history? This cannot be undone.')) return;
++ try { await apiFetch('/api/history', { method: 'DELETE' }); _histLoaded = false; loadHistory(false); toast('History cleared.', 'success'); } catch (e) { toast(e.message, 'error'); }
++}
++async function removeHistoryItem(ev, id) {
++ ev.preventDefault(); ev.stopPropagation();
++ const el = document.querySelector(`.hist-item[data-id="${id}"]`);
++ try { await apiFetch('/api/history/' + id, { method: 'DELETE' }); if (el) { el.style.transition = 'opacity .2s'; el.style.opacity = '0'; setTimeout(() => el.remove(), 200); } } catch (e) { toast(e.message, 'error'); }
++}
++
// Sessions
async function loadSessions() {
if (isAnonSession()) {
ddiff --git a/public/sso-fanout.html b/public/sso-fanout.html
nnew file mode 100644
iindex 00000000..c033af2b
---- /dev/null
++++ b/public/sso-fanout.html
@@@ -0,0 +1,120 @@
++<!DOCTYPE html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++<title>Signing you in across OpenVibe</title>
++<meta name="robots" content="noindex, nofollow">
++<meta name="theme-color" content="#3b82f6">
++<link rel="icon" href="/assets/favicon.svg" type="image/svg+xml">
++<script>
++(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();
++</script>
++<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
++<style>
++*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
++:root{--bg-primary:#0a0f1c;--bg-secondary:#101828;--bg-card:#131c2e;--border:#1f2d47;--text-primary:#e8ecf5;--text-secondary:#a9b3c9;--text-muted:#6b7590;--accent:#3b82f6;--accent-light:#60a5fa;--accent-rgb:59,130,246;--on-accent:#fff;--success:#22c55e}
++html,body{height:100%}
++body{background:var(--bg-primary);color:var(--text-primary);font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;display:grid;place-items:center;padding:24px 16px}
++.card{width:100%;max-width:440px;background:var(--bg-card);border:1px solid var(--border);border-radius:18px;padding:30px 28px;position:relative;overflow:hidden;box-shadow:0 30px 80px rgba(0,0,0,.35);animation:rise .4s cubic-bezier(.2,.8,.2,1) both}
++.card::before{content:"";position:absolute;inset:-50% -20% auto;height:80%;background:radial-gradient(60% 60% at 50% 40%,rgba(var(--accent-rgb),.2),transparent 70%);pointer-events:none}
++@keyframes rise{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}
++.head{display:flex;align-items:center;gap:14px;margin-bottom:18px;position:relative}
++.head h1{font-size:18px;letter-spacing:-.2px}
++.head p{font-size:13px;color:var(--text-secondary);margin-top:2px}
++.bar{height:4px;border-radius:4px;background:rgba(var(--accent-rgb),.15);overflow:hidden;margin-bottom:16px;position:relative}
++.bar i{position:absolute;inset:0 auto 0 0;width:var(--w,0%);background:linear-gradient(90deg,var(--accent),var(--accent-light));transition:width .45s cubic-bezier(.2,.8,.2,1);border-radius:4px}
++.bar i::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(255,255,255,.35),transparent);animation:sheen 1.2s linear infinite}
++@keyframes sheen{from{transform:translateX(-100%)}to{transform:translateX(100%)}}
++ul{list-style:none;position:relative}
++li{display:flex;align-items:center;gap:12px;padding:10px 12px;border-radius:10px;font-size:14px;color:var(--text-secondary);transition:background .2s,color .2s}
++li .dot{width:26px;height:26px;border-radius:50%;display:grid;place-items:center;border:1px solid var(--border);font-size:12px;color:var(--text-muted);flex:none;transition:all .25s}
++li.done{color:var(--text-primary)}
++li.done .dot{background:var(--success);border-color:var(--success);color:#fff;animation:pop .45s cubic-bezier(.2,1.6,.3,1)}
++li.skip .dot{color:var(--text-muted)}
++li.now{background:rgba(var(--accent-rgb),.08);color:var(--text-primary)}
++li.now .dot{border-color:var(--accent);color:var(--accent-light)}
++li.now .dot i{animation:spin 1s linear infinite}
++@keyframes spin{to{transform:rotate(360deg)}}
++@keyframes pop{0%{transform:scale(.5)}60%{transform:scale(1.15)}100%{transform:scale(1)}}
++.foot{display:flex;justify-content:space-between;align-items:center;margin-top:16px;font-size:12px;color:var(--text-muted);position:relative}
++.foot a{color:var(--accent-light);text-decoration:none;font-weight:600}
++.ov-mark{width:38px;height:38px;flex:none}
++@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
++</style>
++</head>
++<body>
++<section class="card" aria-live="polite">
++ <div class="head"><span class="ov-mark" data-size="38"></span><div><h1 id="h">Signing you in across OpenVibe</h1><p id="p">One account, every site — this takes a moment.</p></div></div>
++ <div class="bar"><i id="bar"></i></div>
++ <ul id="list"></ul>
++ <div class="foot"><span id="note">Each site gets its own session; nothing is shared between them.</span><a id="skip" href="/">Skip</a></div>
++</section>
++<script src="/shared/ov-mark.js" async></script>
++<script>
++(function () {
++ const q = new URLSearchParams(location.search);
++ const action = q.get('action') === 'logout' ? 'logout' : 'login';
++ const skipId = q.get('skip') || '';
++ const step = Math.max(0, parseInt(q.get('step') || '0', 10) || 0);
++ const doneIds = (q.get('done') || '').split(',').filter(Boolean);
++ const skippedIds = (q.get('skipped') || '').split(',').filter(Boolean);
++ const nextRaw = q.get('next') || '/';
++ const safeNext = (s) => { try { if (s.startsWith('/') && !s.startsWith('//')) return s; const u = new URL(s); if (u.protocol !== 'https:' && u.protocol !== 'http:') return '/'; return /(^|\.)(openvibe\.[a-z]+|openre\.stream)$/.test(u.hostname) || /^(localhost|127\.0\.0\.1)$/.test(u.hostname) ? u.toString() : '/'; } catch { return '/'; } };
++ const next = safeNext(nextRaw);
++ document.getElementById('skip').href = next;
++ if (action === 'logout') { document.getElementById('h').textContent = 'Signing you out everywhere'; document.getElementById('p').textContent = 'Closing your session on every OpenVibe site.'; }
++
++ const list = document.getElementById('list'), bar = document.getElementById('bar');
++ const hasNetworkSession = () => /(^|; )ov_token=/.test(document.cookie);
++ const finish = () => {
++ try { localStorage.setItem('ov_sso_hint', action === 'login' ? 'account' : 'guest'); } catch (e) { /* */ }
++ if (action === 'logout') {
++ // The network's own session goes last, after every site had its turn.
++ try { for (const k of ['ov_token', 'openvibe_accounts', 'openvibe_active_account', 'openvibe_anon_token', 'openvibe_anon_tokens']) localStorage.removeItem(k); } catch (e) { /* */ }
++ document.cookie = 'ov_token=;path=/;max-age=0;SameSite=Lax;Secure';
++ }
++ location.replace(next);
++ };
++
++ fetch('/api/sso/targets', { cache: 'no-store' }).then(r => r.json()).then(({ targets }) => {
++ let all = (targets || []).filter(t => t.id !== skipId && (action === 'login' ? t.login : t.logout));
++ if (!all.length || (action === 'login' && !hasNetworkSession())) return finish();
++ const idx = Math.min(step, all.length);
++ list.innerHTML = all.map((t, i) => {
++ const st = doneIds.includes(t.id) ? 'done' : skippedIds.includes(t.id) ? 'skip' : i === idx ? 'now' : '';
++ const ico = st === 'done' ? 'fa-check' : st === 'skip' ? 'fa-minus' : st === 'now' ? 'fa-circle-notch' : 'fa-circle';
++ return `<li class="${st}" data-id="${t.id}"><span class="dot"><i class="fa-solid ${ico}"></i></span><span>${t.name}</span></li>`;
++ }).join('');
++ bar.style.setProperty('--w', Math.round((idx / all.length) * 100) + '%');
++ if (idx >= all.length) { bar.style.setProperty('--w', '100%'); return setTimeout(finish, 450); }
++
++ const t = all[idx];
++ // Mark the previous hop done (the hop that sent us here is idx-1 unless it was skipped).
++ const cont = (extra) => {
++ const u = new URL('/sso/fanout', location.origin);
++ u.searchParams.set('action', action); u.searchParams.set('next', next); if (skipId) u.searchParams.set('skip', skipId);
++ u.searchParams.set('step', String(idx + 1));
++ u.searchParams.set('done', doneIds.concat(extra.done || []).join(','));
++ u.searchParams.set('skipped', skippedIds.concat(extra.skipped || []).join(','));
++ return u.toString();
++ };
++ // A site that is down would strand the browser on its error page: probe it first (an opaque
++ // no-cors response resolves; a connection failure rejects) and skip it when it does not answer.
++ const probe = new Promise((resolve) => {
++ const c = new AbortController(); const tm = setTimeout(() => { c.abort(); resolve(false); }, 2500);
++ fetch((t.origin || new URL(t.login).origin) + '/', { mode: 'no-cors', cache: 'no-store', signal: c.signal }).then(() => { clearTimeout(tm); resolve(true); }).catch(() => { clearTimeout(tm); resolve(false); });
++ });
++ probe.then((up) => {
++ if (!up) { location.replace(cont({ skipped: [t.id] })); return; }
++ const hop = (action === 'login' ? t.login : t.logout).replace('{next}', encodeURIComponent(cont({ done: [t.id] })));
++ setTimeout(() => location.replace(hop), 250);
++ });
++ }).catch(finish);
++
++ // Hard stop: never hold the user more than 20s on this page.
++ setTimeout(finish, 20000);
++})();
++</script>
++</body>
++</html>
ddiff --git a/public/verify-email.html b/public/verify-email.html
nnew file mode 100644
iindex 00000000..960ec364
---- /dev/null
++++ b/public/verify-email.html
@@@ -0,0 +1,139 @@
++<!DOCTYPE html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++<title>Confirm your email — OpenVibe</title>
++<meta name="description" content="Confirming the email address on your OpenVibe account.">
++<meta name="robots" content="noindex, nofollow">
++<meta name="theme-color" content="#3b82f6">
++<link rel="icon" href="/assets/favicon.svg" type="image/svg+xml">
++<script>
++(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();
++</script>
++<script src="/shared/theme-loader.js"></script>
++<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
++<style>
++*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
++:root{--bg-primary:#0a0f1c;--bg-secondary:#101828;--bg-card:#131c2e;--bg-hover:#1c2a44;--border:#1f2d47;--text-primary:#e8ecf5;--text-secondary:#a9b3c9;--text-muted:#6b7590;--accent:#3b82f6;--accent-light:#60a5fa;--accent-rgb:59,130,246;--on-accent:#fff;--success:#22c55e;--danger:#ef4444;--warning:#f59e0b}
++html,body{min-height:100%}
++body{background:var(--bg-primary);color:var(--text-primary);font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;display:flex;flex-direction:column}
++.wrap{flex:1;display:grid;place-items:center;padding:32px 16px}
++.card{width:100%;max-width:480px;background:var(--bg-card);border:1px solid var(--border);border-radius:18px;padding:36px 32px;text-align:center;position:relative;overflow:hidden;box-shadow:0 30px 80px rgba(0,0,0,.35);animation:rise .5s cubic-bezier(.2,.8,.2,1) both}
++.card::before{content:"";position:absolute;inset:-40% -20% auto;height:70%;background:radial-gradient(60% 60% at 50% 40%,rgba(var(--accent-rgb),.22),transparent 70%);pointer-events:none;transition:opacity .6s}
++.card[data-state="ok"]::before{background:radial-gradient(60% 60% at 50% 40%,rgba(34,197,94,.22),transparent 70%)}
++.card[data-state="bad"]::before{background:radial-gradient(60% 60% at 50% 40%,rgba(239,68,68,.18),transparent 70%)}
++@keyframes rise{from{opacity:0;transform:translateY(14px) scale(.98)}to{opacity:1;transform:none}}
++.glyph{position:relative;width:96px;height:96px;margin:0 auto 22px}
++.ring{position:absolute;inset:0;border-radius:50%;border:3px solid rgba(var(--accent-rgb),.18)}
++.ring.spin{border-top-color:var(--accent);animation:spin 1s linear infinite}
++@keyframes spin{to{transform:rotate(360deg)}}
++.glyph .ico{position:absolute;inset:0;display:grid;place-items:center;font-size:38px;color:var(--accent-light);transition:transform .35s cubic-bezier(.2,1.4,.3,1),color .3s}
++.card[data-state="ok"] .ico{color:var(--success);transform:scale(1.12)}
++.card[data-state="bad"] .ico{color:var(--danger)}
++.card[data-state="ok"] .ring{border-color:rgba(34,197,94,.35);animation:pop .6s cubic-bezier(.2,1.6,.3,1) both}
++.card[data-state="bad"] .ring{border-color:rgba(239,68,68,.35)}
++@keyframes pop{0%{transform:scale(.6);opacity:0}60%{transform:scale(1.08)}100%{transform:scale(1);opacity:1}}
++.burst{position:absolute;inset:0;pointer-events:none}
++.burst i{position:absolute;left:50%;top:50%;width:6px;height:6px;border-radius:50%;background:var(--success);opacity:0}
++.card[data-state="ok"] .burst i{animation:fly .9s cubic-bezier(.2,.8,.2,1) both}
++@keyframes fly{0%{opacity:1;transform:translate(-50%,-50%) scale(1)}100%{opacity:0;transform:translate(calc(-50% + var(--dx)),calc(-50% + var(--dy))) scale(.2)}}
++h1{font-size:22px;letter-spacing:-.3px;margin-bottom:8px}
++p{color:var(--text-secondary);font-size:14px;line-height:1.6}
++.email{color:var(--text-primary);font-weight:600}
++.actions{display:flex;flex-wrap:wrap;gap:10px;justify-content:center;margin-top:24px}
++.btn{display:inline-flex;align-items:center;gap:8px;padding:11px 18px;border-radius:10px;font-size:14px;font-weight:600;text-decoration:none;border:1px solid var(--border);color:var(--text-primary);background:var(--bg-secondary);transition:transform .15s,background .15s,border-color .15s}
++.btn:hover{transform:translateY(-1px);border-color:var(--accent)}
++.btn-primary{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}
++.btn-primary:hover{filter:brightness(1.08)}
++.hint{margin-top:18px;font-size:12px;color:var(--text-muted)}
++.hidden{display:none}
++.next{margin-top:22px;text-align:left;background:var(--bg-secondary);border:1px solid var(--border);border-radius:12px;padding:14px 16px;font-size:13px;color:var(--text-secondary);animation:rise .5s .25s both}
++.next b{color:var(--text-primary)}
++.next ul{margin:8px 0 0 18px;line-height:1.7}
++@media (prefers-reduced-motion:reduce){.card,.ring,.ico,.burst i,.next{animation:none!important;transition:none!important}}
++</style>
++</head>
++<body>
++<div id="navbar-mount"></div>
++<main class="wrap">
++ <section class="card" id="card" data-state="busy" aria-live="polite">
++ <div class="glyph">
++ <div class="ring spin" id="ring"></div>
++ <div class="burst" id="burst"></div>
++ <div class="ico" id="ico"><i class="fa-solid fa-envelope"></i></div>
++ </div>
++ <h1 id="title">Confirming your email…</h1>
++ <p id="msg">One moment — we're checking the link from your inbox.</p>
++ <div class="actions" id="actions"></div>
++ <div class="hint" id="hint"></div>
++ <div class="next hidden" id="next">
++ <b>What this unlocks</b>
++ <ul>
++ <li>Go-live alerts when a streamer you follow starts (opt in under Notifications)</li>
++ <li>Account recovery and security emails</li>
++ <li>The occasional network update — never spam, never sold</li>
++ </ul>
++ </div>
++ </section>
++</main>
++<script src="/shared/navbar.js"></script>
++<script>
++(function () {
++ if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'network', apiBase: location.origin }); } catch (e) { /* */ } }
++ const card = document.getElementById('card'), ring = document.getElementById('ring'), ico = document.getElementById('ico');
++ const title = document.getElementById('title'), msg = document.getElementById('msg'), actions = document.getElementById('actions'), hint = document.getElementById('hint');
++ const burst = document.getElementById('burst');
++ for (let i = 0; i < 14; i++) { const s = document.createElement('i'); const a = (i / 14) * Math.PI * 2; const r = 46 + (i % 3) * 10; s.style.setProperty('--dx', Math.cos(a) * r + 'px'); s.style.setProperty('--dy', Math.sin(a) * r + 'px'); s.style.animationDelay = (i * 18) + 'ms'; burst.appendChild(s); }
++
++ const btn = (label, href, primary, icon) => `<a class="btn${primary ? ' btn-primary' : ''}" href="${href}">${icon ? `<i class="fa-solid ${icon}"></i>` : ''}${label}</a>`;
++ const signedIn = () => { try { return !!(localStorage.getItem('ov_token') || /(^|; )ov_token=/.test(document.cookie)); } catch { return /(^|; )ov_token=/.test(document.cookie); } };
++ const accountHref = (path) => signedIn() ? path : `/login?return=${encodeURIComponent(path)}`;
++
++ function show(state, t, m, acts, h) {
++ ring.classList.remove('spin');
++ card.setAttribute('data-state', state);
++ ico.innerHTML = state === 'ok' ? '<i class="fa-solid fa-circle-check"></i>' : state === 'bad' ? '<i class="fa-solid fa-circle-exclamation"></i>' : '<i class="fa-solid fa-envelope"></i>';
++ title.textContent = t; msg.innerHTML = m; actions.innerHTML = acts.join(''); hint.textContent = h || '';
++ document.title = t + ' — OpenVibe';
++ if (state === 'ok') document.getElementById('next').classList.remove('hidden');
++ }
++
++ const token = new URLSearchParams(location.search).get('token');
++ if (!token) {
++ show('bad', 'No verification link', 'This page only works from the link in the email we sent you. Open the message and tap <b>Verify email</b>, or request a new link from your account.',
++ [btn('Request a new link', accountHref('/notifications'), true, 'fa-paper-plane'), btn('My account', accountHref('/my'), false, 'fa-user')]);
++ return;
++ }
++ // Keep the token out of the address bar / history once we have it.
++ try { history.replaceState(null, '', '/verify-email'); } catch (e) { /* */ }
++
++ const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 12000);
++ fetch('/api/auth/email/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }), signal: ctrl.signal })
++ .then(async (r) => { const data = await r.json().catch(() => ({})); return { ok: r.ok, status: r.status, data }; })
++ .then(({ ok, data }) => {
++ clearTimeout(timer);
++ if (ok) {
++ const email = data.email ? `<span class="email">${String(data.email).replace(/[<>&]/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]))}</span>` : 'Your email';
++ show('ok', 'Email confirmed', `${email} is verified. Email alerts you opt into will reach your inbox from now on.`,
++ [btn('Notification settings', accountHref('/notifications'), true, 'fa-bell'), btn('Go to my account', accountHref('/my'), false, 'fa-user')],
++ signedIn() ? '' : 'You are not signed in on this device — sign in to change what OpenVibe emails you.');
++ return;
++ }
++ const err = String(data.error || 'This verification link could not be used.');
++ const expired = /expired/i.test(err), used = /already used/i.test(err), changed = /changed/i.test(err);
++ show('bad', expired ? 'This link has expired' : used ? 'Already confirmed' : changed ? 'Your email changed' : 'Link not valid',
++ used ? 'That link was used before, so there is nothing left to do — your address is already confirmed.' : err,
++ used ? [btn('Notification settings', accountHref('/notifications'), true, 'fa-bell')]
++ : [btn('Send a new link', accountHref('/notifications'), true, 'fa-paper-plane'), btn('My account', accountHref('/my'), false, 'fa-user')],
++ expired ? 'Links work for 60 minutes. Request a new one from Notifications on your account page.' : '');
++ })
++ .catch(() => {
++ clearTimeout(timer);
++ show('bad', 'Could not reach OpenVibe', 'The verification request did not go through. Check your connection and open the link again — it stays valid for 60 minutes.',
++ [btn('Try again', location.pathname + '?token=' + encodeURIComponent(token), true, 'fa-rotate-right'), btn('My account', accountHref('/my'), false, 'fa-user')]);
++ });
++})();
++</script>
++</body>
++</html>
ddiff --git a/server/auth/oauth-routes.js b/server/auth/oauth-routes.js
iindex 019b826c..388f87a8 100644
---- a/server/auth/oauth-routes.js
++++ b/server/auth/oauth-routes.js
@@@ -147,6 +147,26 @@ router.post('/token', (req, res) => {
}
});
++/**
++ * Every successful OAuth exchange is the proof that this account is used on that service, so
++ * it is recorded as a linked service here — the Linked Services tab used to show only the
++ * sites that reported the link themselves (Live), and openvibe.tools never appeared even
++ * right after signing in there. A site that maps the account to its own user id (Live) still
++ * owns service_user_id; the exchange never overwrites it, only bumps last_used_at.
++ */
++function recordLinkedService(db, user, client) {
++ if (!client?.client_id || !user?.id || user.is_anon) return;
++ try {
++ db.prepare(`
++ INSERT INTO linked_accounts (user_id, service, service_user_id, service_username, linked_at, last_used_at)
++ VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
++ ON CONFLICT(user_id, service) DO UPDATE SET
++ service_username = COALESCE(service_username, excluded.service_username),
++ last_used_at = CURRENT_TIMESTAMP
++ `).run(user.id, client.client_id, `network:${user.id}`, user.username || null);
++ } catch (err) { console.warn('[OAuth] linked service record failed:', err.message); }
++}
++
function handleAuthCodeGrant(db, config, req, res, client, code, redirectUri) {
if (!code) return res.status(400).json({ error: 'invalid_request', error_description: 'Missing code' });
@@@ -169,6 +189,7 @@ function handleAuthCodeGrant(db, config, req, res, client, code, redirectUri) {
// Issue tokens
const { accessToken, refreshToken } = issueTokenPair(db, config, req, user, client);
++ recordLinkedService(db, user, client);
// Get preferences for theme sync
const prefs = db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(user.id);
@@@ -206,6 +227,7 @@ function handleRefreshGrant(db, config, req, res, client, refreshToken) {
// Issue new token pair
const { accessToken, refreshToken: newRefresh } = issueTokenPair(db, config, req, user, client);
++ recordLinkedService(db, user, client);
res.json({
access_token: accessToken,
ddiff --git a/server/auth/sso-targets.js b/server/auth/sso-targets.js
nnew file mode 100644
iindex 00000000..961ff25c
---- /dev/null
++++ b/server/auth/sso-targets.js
@@@ -0,0 +1,63 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// "Sign in everywhere": the first-party sites a network sign-in is fanned out to.
++//
++// The sites are separate registrable domains (openvibe.live, openvibe.tools, …), so no cookie
++// can be shared between them and third-party cookies are unreliable for hidden-frame SSO.
++// What always works is a chain of top-level redirects: after signing in here, the browser
++// visits each site's silent login (prompt=none → an authorization code with no chooser), the
++// site sets its own first-party session cookie and hands the browser back to
++// /sso/fanout for the next hop. Each hop is one quick round trip; the fanout page shows
++// progress. The same chain with action=logout signs the browser out of every site.
++//
++// {next} is replaced with the URL-encoded fanout continuation. Sites must accept it when
++// it points at https://openvibe.network/… .
++// ═══════════════════════════════════════════════════════════════
++
++const DEFAULT_TARGETS = [
++ { id: 'live', name: 'OpenVibe.Live', origin: 'https://openvibe.live',
++ login: 'https://openvibe.live/api/auth/sso/login?silent=1&next={next}',
++ logout: 'https://openvibe.live/api/auth/logout?next={next}' },
++ { id: 'tools', name: 'OpenVibe.Tools', origin: 'https://openvibe.tools',
++ login: 'https://openvibe.tools/auth/login?silent=1&next={next}',
++ logout: 'https://openvibe.tools/auth/logout?next={next}' },
++ { id: 'games', name: 'OpenVibe.Games', origin: 'https://openvibe.games',
++ login: 'https://openvibe.games/auth/login?silent=1&next={next}',
++ logout: 'https://openvibe.games/auth/logout?next={next}' },
++ { id: 'community', name: 'OpenVibe.Community', origin: 'https://openvibe.community',
++ login: 'https://openvibe.community/auth/login?silent=1&next={next}',
++ logout: 'https://openvibe.community/auth/logout?next={next}' },
++];
++
++/**
++ * Targets for this deployment. OV_SSO_TARGETS (JSON array, same shape) replaces the list;
++ * OV_SSO_TARGETS_DISABLED ("games,community") removes entries — for a site that is not
++ * deployed yet, so the chain never sends a browser to an error page.
++ */
++function ssoTargets(env = process.env) {
++ let list = DEFAULT_TARGETS;
++ if (env.OV_SSO_TARGETS) {
++ try { const parsed = JSON.parse(env.OV_SSO_TARGETS); if (Array.isArray(parsed)) list = parsed; } catch { /* keep defaults */ }
++ }
++ const disabled = new Set(String(env.OV_SSO_TARGETS_DISABLED || '').split(',').map(s => s.trim()).filter(Boolean));
++ return list.filter(t => t && t.id && t.login && !disabled.has(t.id)).map(t => ({
++ id: String(t.id), name: t.name || t.id, origin: t.origin || null, login: t.login, logout: t.logout || null,
++ }));
++}
++
++/** Is `next` somewhere we are willing to send the browser after the chain? */
++function safeNext(raw, env = process.env) {
++ const s = String(raw || '');
++ if (!s) return '/';
++ if (s.startsWith('/') && !s.startsWith('//')) return s;
++ try {
++ const u = new URL(s);
++ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && /^(localhost|127\.0\.0\.1)$/.test(u.hostname))) return '/';
++ const h = u.hostname.toLowerCase();
++ if (/(^|\.)openvibe\.[a-z]+$/.test(h) || /(^|\.)openre\.stream$/.test(h) || /^(localhost|127\.0\.0\.1)$/.test(h)) return u.toString();
++ for (const t of ssoTargets(env)) { try { if (t.origin && new URL(t.origin).hostname === h) return u.toString(); } catch { /* */ } }
++ } catch { /* not a URL */ }
++ return '/';
++}
++
++module.exports = { ssoTargets, safeNext, DEFAULT_TARGETS };
ddiff --git a/server/db/database.js b/server/db/database.js
iindex f9328525..6869c72b 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -486,6 +486,10 @@ function initDb(dbPath) {
{ table: 'users', column: 'email_verified_at', sql: "ALTER TABLE users ADD COLUMN email_verified_at DATETIME" },
{ table: 'users', column: 'email_bounced_at', sql: "ALTER TABLE users ADD COLUMN email_bounced_at DATETIME" },
{ table: 'users', column: 'email_bounce_reason', sql: "ALTER TABLE users ADD COLUMN email_bounce_reason TEXT" },
++ // When the account was last used on a service (bumped on every OAuth exchange).
++ { table: 'linked_accounts', column: 'last_used_at', sql: "ALTER TABLE linked_accounts ADD COLUMN last_used_at DATETIME" },
++ // Cross-site history can be paused by the user (server/history/routes.js).
++ { table: 'users', column: 'history_paused', sql: "ALTER TABLE users ADD COLUMN history_paused INTEGER DEFAULT 0" },
];
for (const m of migrations) {
const cols = db.prepare(`PRAGMA table_info(${m.table})`).all();
@@@ -566,6 +570,11 @@ function initDb(dbPath) {
name: 'OpenVibe.Media',
redirect_uris: ['https://openvibe.media/auth/callback'],
},
++ {
++ client_id: 'community',
++ name: 'OpenVibe.Community',
++ redirect_uris: ['https://openvibe.community/auth/callback'],
++ },
];
const insert = db.prepare(
'INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, ?, ?, ?, 1)'
ddiff --git a/server/history/routes.js b/server/history/routes.js
nnew file mode 100644
iindex 00000000..625ca5cc
---- /dev/null
++++ b/server/history/routes.js
@@@ -0,0 +1,157 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// Cross-site history — one timeline per account of what it touched anywhere on the network
++// (a tool on openvibe.tools, a stream on openvibe.live, a paste on openvibe.community, a game).
++// Written by the shared history.js browser module through the navbar, read back by the
++// navbar's "Recently used" rows and the History tab on the account page.
++//
++// Privacy: nothing is stored for anonymous sessions; the user can pause recording
++// (users.history_paused) and delete single entries or everything. Entries carry only what
++// the page told us — a type, a title, a URL and an optional small meta object — never
++// request bodies or anything from other accounts.
++// ═══════════════════════════════════════════════════════════════
++const express = require('express');
++
++const MAX_PER_USER = 2000; // oldest rows beyond this are trimmed on write
++const DEDUPE_WINDOW_MIN = 10; // same URL within this window bumps the entry instead of adding one
++const TYPES = new Set(['tool', 'stream', 'vod', 'clip', 'paste', 'game', 'page', 'post', 'account', 'theme', 'download', 'chat']);
++const SERVICE_LABELS = {
++ live: 'Live', tools: 'Tools', games: 'Games', media: 'Media', network: 'Network', community: 'Community',
++ chat: 'Chat', codes: 'Codes', blog: 'Blog', wiki: 'Wiki', news: 'News', reviews: 'Reviews', tips: 'Tips',
++ vip: 'VIP', trade: 'Trade', host: 'Host', deals: 'Deals', coupons: 'Coupons', openre: 'OpenRe.Stream',
++};
++
++function ensureSchema(db) {
++ db.exec(`
++ CREATE TABLE IF NOT EXISTS user_history (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ user_id INTEGER NOT NULL,
++ service TEXT,
++ sub TEXT,
++ type TEXT NOT NULL DEFAULT 'page',
++ title TEXT NOT NULL,
++ url TEXT NOT NULL,
++ icon TEXT,
++ meta TEXT,
++ hits INTEGER NOT NULL DEFAULT 1,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ FOREIGN KEY (user_id) REFERENCES users(id)
++ );
++ CREATE INDEX IF NOT EXISTS idx_user_history_user_time ON user_history(user_id, updated_at DESC);
++ CREATE INDEX IF NOT EXISTS idx_user_history_user_url ON user_history(user_id, url);
++ `);
++ const cols = db.prepare('PRAGMA table_info(users)').all();
++ if (!cols.find(c => c.name === 'history_paused')) {
++ try { db.exec('ALTER TABLE users ADD COLUMN history_paused INTEGER DEFAULT 0'); } catch { /* exists */ }
++ }
++}
++
++/** Only http(s) URLs on the network's own domains are recorded; everything else is dropped. */
++function allowedUrl(raw) {
++ let u;
++ try { u = new URL(String(raw)); } catch { return null; }
++ if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
++ const h = u.hostname.toLowerCase();
++ if (!/(^|\.)openvibe\.[a-z]+$/.test(h) && !/(^|\.)openre\.stream$/.test(h) && h !== 'localhost' && !/^127\./.test(h)) return null;
++ u.hash = '';
++ // Strip tokens / codes that pages sometimes carry in the query string.
++ for (const k of ['token', 'code', 'state', 'access_token', 'ov_token', 'key']) u.searchParams.delete(k);
++ return u.toString().slice(0, 2000);
++}
++
++function serviceFromUrl(url) {
++ try {
++ const h = new URL(url).hostname.toLowerCase();
++ const m = h.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/) || h.match(/^(?:(.+)\.)?(openre)\.stream$/);
++ return m ? { service: m[2], sub: m[1] && m[1] !== 'www' ? m[1] : null } : { service: null, sub: null };
++ } catch { return { service: null, sub: null }; }
++}
++
++function rowOut(r) {
++ let meta = null;
++ try { meta = r.meta ? JSON.parse(r.meta) : null; } catch { meta = null; }
++ return {
++ id: r.id, type: r.type, title: r.title, url: r.url, icon: r.icon, service: r.service, sub: r.sub,
++ service_label: r.sub ? `${r.sub}.${SERVICE_LABELS[r.service] || r.service || ''}` : (SERVICE_LABELS[r.service] || r.service || null),
++ meta, hits: r.hits, created_at: r.created_at, updated_at: r.updated_at,
++ };
++}
++
++function createHistoryRoutes(db, requireAuth) {
++ ensureSchema(db);
++ const router = express.Router();
++ router.use(requireAuth);
++
++ // Anonymous sessions have no history: nothing to read, nothing recorded.
++ router.use((req, res, next) => {
++ if (req.user?.is_anon) return res.status(403).json({ error: 'History is only kept for signed-in accounts' });
++ next();
++ });
++
++ router.get('/', (req, res) => {
++ const limit = Math.min(200, Math.max(1, parseInt(req.query.limit, 10) || 50));
++ const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
++ const where = ['user_id = ?']; const params = [req.user.id];
++ if (req.query.service) { where.push('service = ?'); params.push(String(req.query.service).slice(0, 32)); }
++ if (req.query.type) { where.push('type = ?'); params.push(String(req.query.type).slice(0, 32)); }
++ if (req.query.q) { where.push('(title LIKE ? OR url LIKE ?)'); const q = `%${String(req.query.q).slice(0, 80)}%`; params.push(q, q); }
++ const rows = db.prepare(`SELECT * FROM user_history WHERE ${where.join(' AND ')} ORDER BY updated_at DESC, id DESC LIMIT ? OFFSET ?`).all(...params, limit, offset);
++ const total = db.prepare(`SELECT COUNT(*) AS c FROM user_history WHERE ${where.join(' AND ')}`).get(...params)?.c || 0;
++ const paused = !!db.prepare('SELECT history_paused FROM users WHERE id = ?').get(req.user.id)?.history_paused;
++ const services = db.prepare('SELECT service, COUNT(*) AS c FROM user_history WHERE user_id = ? GROUP BY service ORDER BY c DESC').all(req.user.id)
++ .map(r => ({ service: r.service, label: SERVICE_LABELS[r.service] || r.service, count: r.c }));
++ res.set('Cache-Control', 'no-store');
++ res.json({ items: rows.map(rowOut), total, paused, services });
++ });
++
++ router.post('/', (req, res) => {
++ const u = db.prepare('SELECT history_paused FROM users WHERE id = ?').get(req.user.id);
++ if (u?.history_paused) return res.json({ ok: true, paused: true });
++ const url = allowedUrl(req.body?.url);
++ if (!url) return res.status(400).json({ error: 'A URL on the OpenVibe network is required' });
++ const type = TYPES.has(String(req.body?.type)) ? String(req.body.type) : 'page';
++ const title = String(req.body?.title || '').trim().slice(0, 200) || url;
++ const TYPE_ICONS = { tool: 'fa-screwdriver-wrench', stream: 'fa-tower-broadcast', vod: 'fa-clapperboard', clip: 'fa-scissors', paste: 'fa-paste', game: 'fa-gamepad', page: 'fa-file-lines', post: 'fa-comments', account: 'fa-user', theme: 'fa-palette', download: 'fa-download', chat: 'fa-comment' };
++ const icon = /^fa-[a-z0-9-]{1,40}$/.test(String(req.body?.icon || '')) ? String(req.body.icon) : (TYPE_ICONS[type] || null);
++ const inf = serviceFromUrl(url);
++ const service = String(req.body?.service || inf.service || '').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 32) || null;
++ const sub = String(req.body?.sub || inf.sub || '').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 48) || null;
++ let meta = null;
++ if (req.body?.meta && typeof req.body.meta === 'object') {
++ const s = JSON.stringify(req.body.meta);
++ if (s.length <= 1000) meta = s;
++ }
++ const recent = db.prepare(`SELECT id FROM user_history WHERE user_id = ? AND url = ? AND updated_at > datetime('now', '-${DEDUPE_WINDOW_MIN} minutes')`).get(req.user.id, url);
++ if (recent) {
++ db.prepare('UPDATE user_history SET hits = hits + 1, title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(title, recent.id);
++ return res.json({ ok: true, id: recent.id, merged: true });
++ }
++ const r = db.prepare('INSERT INTO user_history (user_id, service, sub, type, title, url, icon, meta) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
++ .run(req.user.id, service, sub, type, title, url, icon, meta);
++ // Trim: keep the newest MAX_PER_USER rows.
++ db.prepare(`DELETE FROM user_history WHERE user_id = ? AND id NOT IN (SELECT id FROM user_history WHERE user_id = ? ORDER BY updated_at DESC LIMIT ${MAX_PER_USER})`).run(req.user.id, req.user.id);
++ res.status(201).json({ ok: true, id: r.lastInsertRowid });
++ });
++
++ router.put('/settings', (req, res) => {
++ const paused = req.body?.paused ? 1 : 0;
++ db.prepare('UPDATE users SET history_paused = ? WHERE id = ?').run(paused, req.user.id);
++ res.json({ ok: true, paused: !!paused });
++ });
++
++ router.delete('/', (req, res) => {
++ const r = db.prepare('DELETE FROM user_history WHERE user_id = ?').run(req.user.id);
++ res.json({ ok: true, deleted: r.changes });
++ });
++
++ router.delete('/:id', (req, res) => {
++ const r = db.prepare('DELETE FROM user_history WHERE id = ? AND user_id = ?').run(parseInt(req.params.id, 10) || 0, req.user.id);
++ if (!r.changes) return res.status(404).json({ error: 'Not found' });
++ res.json({ ok: true });
++ });
++
++ return router;
++}
++
++module.exports = { createHistoryRoutes, ensureSchema, allowedUrl, serviceFromUrl, SERVICE_LABELS, TYPES };
ddiff --git a/server/index.js b/server/index.js
iindex 2b46be26..a85533e7 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -398,6 +398,16 @@ const pushService = require('./push/push-service');
pushService.initVapid(db);
app.use('/api/push', requireAuth, require('./push/routes'));
++// Cross-site history (what the account touched anywhere on the network)
++app.use('/api/history', rateLimit({ windowMs: 60_000, max: 60 }), require('./history/routes').createHistoryRoutes(db, requireAuth));
++
++// "Sign in everywhere" chain targets (public: the fanout page reads them before hopping)
++app.get('/api/sso/targets', (req, res) => {
++ const { ssoTargets } = require('./auth/sso-targets');
++ res.set('Cache-Control', 'public, max-age=300');
++ res.json({ targets: ssoTargets() });
++});
++
// Discord bot admin API
function requireAdmin(req, res, next) {
if (!req.user || req.user.role !== 'admin') {
@@@ -514,11 +524,27 @@ app.get(['/', '/index.html'], (req, res) => {
// Account hub (my.html) — the apex hosts the account hub under /my
// plus its client-routed sections.
--app.get(['/my', '/my.html', '/themes', '/notifications', '/linked', '/security', '/profile', '/billing', '/preferences', '/verify-email'], (req, res) => {
++app.get(['/my', '/my.html', '/themes', '/notifications', '/linked', '/security', '/profile', '/billing', '/preferences', '/history'], (req, res) => {
if (req.path === '/my.html') return redirectWithoutHtml(req, res, '/my');
return sendMyAccountApp(res);
});
++// Email verification lands here from the message itself — on whatever device the mail was
++// opened, signed in or not — so it is its own page, not the signed-in account hub (which
++// bounced to /login and dropped the token).
++app.get('/verify-email', (req, res) => {
++ res.setHeader('Cache-Control', 'no-store');
++ res.setHeader('X-Robots-Tag', 'noindex');
++ res.sendFile(path.join(__dirname, '..', 'public', 'verify-email.html'));
++});
++
++// Sign-in / sign-out everywhere: the redirect chain through every first-party site.
++app.get(['/sso/fanout', '/sso/fanout.html'], (req, res) => {
++ res.setHeader('Cache-Control', 'no-store');
++ res.setHeader('X-Robots-Tag', 'noindex');
++ res.sendFile(path.join(__dirname, '..', 'public', 'sso-fanout.html'));
++});
++
app.use(express.static(path.join(__dirname, '..', 'public'), {
setHeaders(res, filePath) {
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
ddiff --git a/test/history-sso.test.js b/test/history-sso.test.js
nnew file mode 100644
iindex 00000000..ae310ebe
---- /dev/null
++++ b/test/history-sso.test.js
@@@ -0,0 +1,88 @@
++'use strict';
++// Cross-site history API and the sign-in-everywhere target list.
++// node test/history-sso.test.js
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const Database = require('better-sqlite3');
++const { createHistoryRoutes, allowedUrl, serviceFromUrl } = require('../server/history/routes');
++const { ssoTargets, safeNext } = require('../server/auth/sso-targets');
++
++// ── pure helpers ─────────────────────────────────────────────
++assert.strictEqual(allowedUrl('https://json.openvibe.tools/?x=1&token=abc#frag'), 'https://json.openvibe.tools/?x=1', 'tokens + fragments stripped');
++assert.strictEqual(allowedUrl('https://evil.example.com/'), null, 'off-network URLs are dropped');
++assert.strictEqual(allowedUrl('javascript:alert(1)'), null);
++assert.deepStrictEqual(serviceFromUrl('https://pastes.openvibe.tools/p/x'), { service: 'tools', sub: 'pastes' });
++assert.deepStrictEqual(serviceFromUrl('https://openvibe.live/@someone'), { service: 'live', sub: null });
++assert.deepStrictEqual(serviceFromUrl('https://ingest.openre.stream/'), { service: 'openre', sub: 'ingest' });
++
++const t = ssoTargets({});
++assert.ok(t.find(x => x.id === 'live') && t.find(x => x.id === 'tools') && t.find(x => x.id === 'community'), 'default targets');
++assert.ok(t.every(x => x.login.includes('{next}')), 'every login hop carries the continuation');
++assert.deepStrictEqual(ssoTargets({ OV_SSO_TARGETS_DISABLED: 'games, community' }).map(x => x.id), ['live', 'tools'], 'disabled targets are dropped');
++assert.strictEqual(safeNext('https://openvibe.live/@x?y=1'), 'https://openvibe.live/@x?y=1');
++assert.strictEqual(safeNext('https://phish.example/'), '/', 'foreign hosts fall back to /');
++assert.strictEqual(safeNext('//evil'), '/');
++assert.strictEqual(safeNext('/my#linked'), '/my#linked');
++
++// ── API against an in-memory database ────────────────────────
++const db = new Database(':memory:');
++db.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, is_anon INTEGER DEFAULT 0);
++ INSERT INTO users (id, username) VALUES (1, 'alex'), (2, 'other');`);
++const users = { a: { id: 1, username: 'alex' }, b: { id: 2, username: 'other' }, anon: { id: 3, username: 'anon', is_anon: true } };
++const requireAuth = (req, res, next) => { const u = users[req.headers['x-user']]; if (!u) return res.status(401).json({ error: 'no' }); req.user = u; next(); };
++const app = express();
++app.use(express.json());
++app.use('/api/history', createHistoryRoutes(db, requireAuth));
++const server = http.createServer(app);
++
++(async () => {
++ await new Promise(r => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const call = (method, path, user, body) => fetch(base + path, { method, headers: { 'content-type': 'application/json', ...(user ? { 'x-user': user } : {}) }, body: body ? JSON.stringify(body) : undefined }).then(async r => ({ status: r.status, body: await r.json().catch(() => ({})) }));
++
++ let r = await call('POST', '/api/history', 'a', { type: 'tool', title: 'JSON Formatter', url: 'https://json.openvibe.tools/', icon: 'fa-code' });
++ assert.strictEqual(r.status, 201);
++ r = await call('POST', '/api/history', 'a', { type: 'tool', title: 'JSON Formatter (again)', url: 'https://json.openvibe.tools/' });
++ assert.strictEqual(r.status, 200); assert.strictEqual(r.body.merged, true, 'same URL within the window merges');
++ r = await call('POST', '/api/history', 'a', { type: 'stream', title: 'goosely live', url: 'https://openvibe.live/@goosely' });
++ assert.strictEqual(r.status, 201);
++ r = await call('POST', '/api/history', 'a', { type: 'page', title: 'nope', url: 'https://evil.example/' });
++ assert.strictEqual(r.status, 400, 'off-network URL rejected');
++ r = await call('POST', '/api/history', 'anon', { type: 'page', title: 'x', url: 'https://openvibe.live/' });
++ assert.strictEqual(r.status, 403, 'anonymous sessions keep no history');
++ r = await call('POST', '/api/history', null, { type: 'page', title: 'x', url: 'https://openvibe.live/' });
++ assert.strictEqual(r.status, 401);
++
++ r = await call('GET', '/api/history?limit=10', 'a');
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.body.total, 2);
++ assert.strictEqual(r.body.items[0].title, 'goosely live', 'newest first');
++ assert.strictEqual(r.body.items[1].hits, 2, 'merged entry counted twice');
++ assert.strictEqual(r.body.items[1].service_label, 'json.Tools');
++ assert.deepStrictEqual(r.body.services.map(s => s.service).sort(), ['live', 'tools']);
++ r = await call('GET', '/api/history?service=live', 'a');
++ assert.strictEqual(r.body.items.length, 1);
++ r = await call('GET', '/api/history?q=json', 'a');
++ assert.strictEqual(r.body.items.length, 1);
++ r = await call('GET', '/api/history', 'b');
++ assert.strictEqual(r.body.total, 0, 'other accounts see nothing');
++
++ // pause → nothing recorded
++ r = await call('PUT', '/api/history/settings', 'a', { paused: true });
++ assert.strictEqual(r.body.paused, true);
++ r = await call('POST', '/api/history', 'a', { type: 'page', title: 'paused', url: 'https://openvibe.games/' });
++ assert.strictEqual(r.body.paused, true);
++ r = await call('GET', '/api/history', 'a'); assert.strictEqual(r.body.total, 2); assert.strictEqual(r.body.paused, true);
++ await call('PUT', '/api/history/settings', 'a', { paused: false });
++
++ // delete one / all, ownership enforced
++ const id = r.body.items[0].id;
++ r = await call('DELETE', `/api/history/${id}`, 'b'); assert.strictEqual(r.status, 404, 'cannot delete another account\'s entry');
++ r = await call('DELETE', `/api/history/${id}`, 'a'); assert.strictEqual(r.status, 200);
++ r = await call('DELETE', '/api/history', 'a'); assert.strictEqual(r.body.deleted, 1);
++ r = await call('GET', '/api/history', 'a'); assert.strictEqual(r.body.total, 0);
++
++ server.close();
++ console.log('history + sso targets: all checks passed');
++})().catch((err) => { console.error(err); server.close(); process.exit(1); });