openvibers/openvibe.chat Commits

openvibe.chat, the site (WS-I task 8): global chat (server-rendered, live over /ws/chat), messages (inbox, conversations for participants only, start one by username) and settings (the chat.preferences record Live's chat reads), /updates, robots and sitemap, served by Chat itself so it works with Live down. Sign in with OpenVibe.Network (client chat, CHAT_WEB_URL/auth/callback); the Frame from this site's own /shared/ (openvibe-shared 1.12.0). Forms post without JavaScript through the same API handlers (one set of checks), only from this origin. test/web.test.js

OP OpenVibers committed 24 Sept 2026 · 49379468a5 · 10 files, +955 −6


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 2e76118..1b0ddea 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -18,7 +18,7 @@
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.7.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"ws": "^8.18.0"
},
"engines": {
@@@ -1087,9 +1087,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "1.7.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.7.0",
-- "integrity": "sha512-gF1PABMEGHhtu0WMkJQHwJOvbYzWcdfOi3feGYrCUkLXIZhpeSvrSOD7SF0+nveWhfCQMFjqNU8Q0d90vv4s/A==",
++ "version": "1.12.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
++ "integrity": "sha512-Qpy4YVoPMs1q/0/08/5ZODI0+7ev+Krk75nsRPsTjNCm7Eu91tt1LPnUNLt4kYfRj6duS+N9icm02gksnlMiyA==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex 01c8c2b..dfc1530 100644
---- a/package.json
++++ b/package.json
@@@ -29,7 +29,7 @@
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.7.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"ws": "^8.18.0"
},
"engines": {
ddiff --git a/public/web/chat.css b/public/web/chat.css
nnew file mode 100644
iindex 0000000..8d4a69b
---- /dev/null
++++ b/public/web/chat.css
@@@ -0,0 +1,73 @@
++/* openvibe.chat. Colours follow the network theme variables (theme-loader.js) with dark defaults that
++ match the critical canvas; everything reads and posts without JavaScript. */
++:root {
++ --oc-bg: var(--bg-primary, #0a0f1c);
++ --oc-panel: var(--bg-secondary, #111a2c);
++ --oc-panel-2: var(--bg-tertiary, #16213a);
++ --oc-text: var(--text-primary, #e6edf7);
++ --oc-muted: var(--text-secondary, #9fb0c8);
++ --oc-line: var(--border-color, rgba(255, 255, 255, .12));
++ --oc-accent: var(--accent, #60a5fa);
++ --oc-on-accent: var(--on-accent, #0a0f1c);
++ --oc-red: #f87171;
++ --oc-ok: #34d399;
++ --oc-scale: 1;
++}
++* { box-sizing: border-box; }
++html { -webkit-text-size-adjust: 100%; }
++body { margin: 0; background: var(--oc-bg); color: var(--oc-text); font: calc(16px * var(--oc-scale))/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
++a { color: var(--oc-accent); }
++a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 2px; }
++.oc-skip { position: absolute; left: -9999px; }
++.oc-skip:focus { left: 16px; top: 8px; background: var(--oc-panel); padding: 6px 10px; z-index: 10; }
++.oc-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
++.oc-bar { display: flex; flex-wrap: wrap; gap: 8px 18px; align-items: center; max-width: 960px; margin: 0 auto; padding: 12px 16px; border-bottom: 1px solid var(--oc-line); }
++.oc-brand { font-weight: 800; color: var(--oc-text); text-decoration: none; letter-spacing: -.01em; }
++.oc-tabs { display: flex; gap: 4px; flex-wrap: wrap; }
++.oc-tabs a { color: var(--oc-muted); text-decoration: none; padding: 4px 10px; border-radius: 999px; }
++.oc-tabs a[aria-current="page"], .oc-tabs a:hover { color: var(--oc-text); background: var(--oc-panel); }
++.oc-account { font-size: 14px; color: var(--oc-muted); margin-left: auto; }
++.oc-main { max-width: 960px; margin: 0 auto; padding: 16px 16px 48px; overflow-wrap: anywhere; }
++h1 { font-size: 1.6rem; line-height: 1.25; margin: .3em 0 .2em; }
++.oc-muted { color: var(--oc-muted); }
++.oc-live { color: var(--oc-ok); font-size: .85em; margin-left: 6px; }
++.oc-notice { padding: 8px 12px; border-left: 4px solid var(--oc-accent); background: var(--oc-panel); border-radius: 0 8px 8px 0; }
++.oc-notice.oc-error { border-color: var(--oc-red); }
++.oc-notice.oc-ok { border-color: var(--oc-ok); }
++.oc-feed { list-style: none; margin: 12px 0; padding: 8px 4px; background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 12px; max-height: min(64vh, 720px); overflow-y: auto; overscroll-behavior: contain; }
++.oc-msg { padding: 5px 10px; border-radius: 8px; line-height: 1.45; }
++.oc-msg:hover { background: var(--oc-panel-2); }
++.oc-compact .oc-msg { padding: 1px 10px; }
++.oc-time { display: none; color: var(--oc-muted); font-size: .8em; font-variant-numeric: tabular-nums; margin-right: 4px; }
++.oc-times .oc-time { display: inline; }
++.oc-name { font-weight: 700; text-decoration: none; }
++.oc-anon { color: var(--oc-muted); font-weight: 600; }
++.oc-where { color: var(--oc-muted); font-size: .8em; text-decoration: none; margin-left: 4px; }
++.oc-text { white-space: pre-wrap; }
++.oc-badge { display: inline-block; font-size: .7em; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; padding: 0 6px; margin-left: 6px; border-radius: 999px; border: 1px solid var(--oc-line); color: var(--oc-muted); vertical-align: 1px; }
++.oc-badge-staff { color: #fbbf24; border-color: rgba(251, 191, 36, .45); }
++.oc-badge-mod { color: var(--oc-ok); border-color: rgba(52, 211, 153, .45); }
++.oc-nobadges .oc-badge-staff, .oc-nobadges .oc-badge-mod { display: none; }
++.oc-mine .oc-name { color: var(--oc-accent); }
++.oc-empty { padding: 16px; text-align: center; }
++.oc-compose { display: flex; gap: 8px; align-items: flex-end; }
++.oc-compose textarea { flex: 1; min-height: 44px; resize: vertical; }
++input, select, textarea { font: inherit; color: var(--oc-text); background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 10px; padding: 8px 10px; max-width: 100%; }
++button, .oc-button { display: inline-block; background: var(--oc-accent); color: var(--oc-on-accent); border: 0; border-radius: 10px; padding: 9px 16px; font: inherit; font-weight: 700; cursor: pointer; text-decoration: none; }
++button[disabled] { opacity: .6; cursor: default; }
++.oc-signin { margin: 12px 0; }
++.oc-new { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 12px 0 16px; }
++.oc-new input { flex: 1 1 180px; }
++.oc-convs { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
++.oc-conv { display: flex; gap: 12px; align-items: center; background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 12px; padding: 10px 14px; }
++.oc-conv a { flex: 1; min-width: 0; color: var(--oc-text); text-decoration: none; display: flex; flex-direction: column; }
++.oc-conv .oc-last { font-size: .9em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
++.oc-conv.oc-unread { border-color: var(--oc-accent); }
++.oc-form { display: grid; gap: 10px; max-width: 520px; }
++.oc-check { display: flex; gap: 8px; align-items: center; }
++.oc-form select { margin-left: 8px; }
++@media (max-width: 560px) {
++ .oc-main { padding: 12px 12px 40px; }
++ .oc-feed { max-height: 60vh; }
++ .oc-account { margin-left: 0; }
++}
ddiff --git a/public/web/chat.js b/public/web/chat.js
nnew file mode 100644
iindex 0000000..3e443ec
---- /dev/null
++++ b/public/web/chat.js
@@@ -0,0 +1,170 @@
++/* openvibe.chat client: the live global feed over /ws/chat and a quicker composer; DMs refresh every few
++ seconds. Pages work without this file (server-rendered, forms post); it only adds liveness. User text
++ is always set with textContent, never parsed as HTML. */
++(function () {
++ 'use strict';
++ var cfg = (window.__OV_PAGE && window.__OV_PAGE.chat) || null;
++ if (!cfg) return;
++ var feed = document.getElementById('oc-feed');
++ var form = document.getElementById('oc-compose');
++ var input = document.getElementById('oc-input');
++ var latest = Number(cfg.latest) || 0;
++ var LIVE = 'https://openvibe.live';
++
++ function nearBottom() { return !feed || feed.scrollHeight - feed.scrollTop - feed.clientHeight < 120; }
++ function toBottom() { if (feed) feed.scrollTop = feed.scrollHeight; }
++ function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
++ function clock(ts) {
++ var d = new Date(ts && String(ts).indexOf('T') < 0 ? String(ts).replace(' ', 'T') + 'Z' : ts);
++ var t = el('time', 'oc-time', isNaN(d) ? '' : d.toISOString().slice(11, 16));
++ if (!isNaN(d)) t.dateTime = d.toISOString();
++ return t;
++ }
++ function textWithLinks(parent, text) {
++ var re = /https?:\/\/[^\s<>"']+/g, last = 0, m;
++ text = String(text || '');
++ while ((m = re.exec(text))) {
++ if (m.index > last) parent.appendChild(document.createTextNode(text.slice(last, m.index)));
++ var a = el('a', null, m[0]); a.href = m[0]; a.rel = 'nofollow ugc noopener'; a.target = '_blank';
++ parent.appendChild(a);
++ last = m.index + m[0].length;
++ }
++ if (last < text.length) parent.appendChild(document.createTextNode(text.slice(last)));
++ }
++ // Display only (the server decides who may do what): the same badges the page renders.
++ var BADGES = { admin: ['oc-badge oc-badge-staff', 'staff'], global_mod: ['oc-badge oc-badge-mod', 'mod'] };
++ function badge(role) { var b = BADGES[role]; return b ? el('span', b[0], b[1]) : null; }
++ function removeEmpty() { var e = feed && feed.querySelector('.oc-empty'); if (e) e.remove(); }
++ function append(li, id) {
++ if (!feed) return;
++ if (id && feed.querySelector('[data-id="' + id + '"]')) return;
++ var stick = nearBottom();
++ removeEmpty();
++ feed.appendChild(li);
++ while (feed.children.length > 300) feed.removeChild(feed.firstChild);
++ if (stick) toBottom();
++ }
++ function showError(text) {
++ var p = document.querySelector('.oc-live-error');
++ if (!p) { p = el('p', 'oc-notice oc-error oc-live-error'); p.setAttribute('role', 'alert'); (form || feed).insertAdjacentElement('beforebegin', p); }
++ p.textContent = text;
++ clearTimeout(showError.t); showError.t = setTimeout(function () { p.remove(); }, 6000);
++ }
++
++ // ── Global chat ──
++ function globalItem(m) {
++ var li = el('li', 'oc-msg'); li.dataset.id = m.id || '';
++ li.appendChild(clock(m.timestamp)); li.appendChild(document.createTextNode(' '));
++ var handle = m.core_username || (m.user_id ? m.username : null);
++ var name = m.display_name || m.username || m.anon_id || 'someone';
++ var who;
++ if (handle) { who = el('a', 'oc-name', name); who.href = LIVE + '/@' + encodeURIComponent(handle); if (/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '')) who.style.color = m.profile_color; }
++ else who = el('span', 'oc-name oc-anon', name);
++ li.appendChild(who);
++ var b = badge(m.role); if (b) li.appendChild(b);
++ li.appendChild(document.createTextNode(' '));
++ var t = el('span', 'oc-text'); textWithLinks(t, m.message); li.appendChild(t);
++ return li;
++ }
++ function catchUp() {
++ return fetch('/api/chat/global/history?after_id=' + latest + '&limit=200', { credentials: 'same-origin' })
++ .then(function (r) { return r.ok ? r.json() : null; })
++ .then(function (d) {
++ if (!d || !d.messages) return;
++ d.messages.forEach(function (m) { if ((m.message_type || 'chat') === 'chat' && !m.is_deleted) append(globalItem(m), m.id); });
++ if (d.latest_id) latest = Math.max(latest, d.latest_id);
++ }).catch(function () {});
++ }
++ function startGlobal() {
++ var live = document.getElementById('oc-live');
++ var ws = null, delay = 1000, opened = false;
++ function connect() {
++ ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');
++ ws.onopen = function () {
++ delay = 1000;
++ ws.send(JSON.stringify({ type: 'join' }));
++ if (live) live.hidden = false;
++ if (opened) catchUp();
++ opened = true;
++ };
++ ws.onmessage = function (ev) {
++ var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
++ if (m.type === 'chat' && m.is_global) {
++ if (m.id) latest = Math.max(latest, Number(m.id));
++ append(globalItem(m), m.id);
++ } else if (m.type === 'delete-messages' && Array.isArray(m.ids || m.message_ids)) {
++ (m.ids || m.message_ids).forEach(function (id) { var x = feed.querySelector('[data-id="' + id + '"]'); if (x) x.remove(); });
++ } else if (m.type === 'error' && m.message) {
++ showError(m.message);
++ } else if (m.type === 'auth_revoked') {
++ showError('You were signed out. Sign in again to keep chatting.');
++ }
++ };
++ ws.onclose = function () {
++ if (live) live.hidden = true;
++ setTimeout(connect, delay); delay = Math.min(delay * 2, 30000);
++ };
++ }
++ connect();
++ if (form && input) {
++ form.addEventListener('submit', function (e) {
++ var text = input.value.trim();
++ if (!text) { e.preventDefault(); return; }
++ if (!ws || ws.readyState !== 1) return; // not connected: the form posts as without JavaScript
++ e.preventDefault();
++ ws.send(JSON.stringify({ type: 'chat', message: text }));
++ input.value = '';
++ input.focus();
++ });
++ }
++ }
++
++ // ── A conversation ──
++ function dmItem(m) {
++ var li = el('li', 'oc-msg' + (m.sender_id === cfg.me ? ' oc-mine' : '')); li.dataset.id = m.id || '';
++ li.appendChild(clock(m.created_at)); li.appendChild(document.createTextNode(' '));
++ li.appendChild(el('span', 'oc-name', m.display_name || m.username));
++ li.appendChild(document.createTextNode(' '));
++ var t = el('span', 'oc-text'); textWithLinks(t, m.message); li.appendChild(t);
++ return li;
++ }
++ function startDm() {
++ var base = '/api/dm/conversations/' + encodeURIComponent(cfg.conversation);
++ function poll() {
++ if (document.hidden) return;
++ fetch(base + '/messages?after=' + latest + '&limit=100', { credentials: 'same-origin' })
++ .then(function (r) { return r.ok ? r.json() : null; })
++ .then(function (d) {
++ if (!d || !d.messages || !d.messages.length) return;
++ d.messages.forEach(function (m) { latest = Math.max(latest, Number(m.id) || 0); append(dmItem(m), m.id); });
++ fetch(base + '/read', { method: 'POST', credentials: 'same-origin' }).catch(function () {});
++ }).catch(function () {});
++ }
++ setInterval(poll, 4000);
++ document.addEventListener('visibilitychange', poll);
++ if (form && input) {
++ form.addEventListener('submit', function (e) {
++ var text = input.value.trim();
++ if (!text) { e.preventDefault(); return; }
++ e.preventDefault();
++ var btn = form.querySelector('button'); if (btn) btn.disabled = true;
++ fetch(base + '/messages', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: text }) })
++ .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
++ .then(function (x) {
++ if (!x.ok) { showError((x.d && x.d.error) || 'Could not send'); return; }
++ if (x.d && x.d.message) { latest = Math.max(latest, Number(x.d.message.id) || 0); append(dmItem(x.d.message), x.d.message.id); }
++ input.value = ''; input.focus();
++ })
++ .catch(function () { form.submit(); })
++ .then(function () { if (btn) btn.disabled = false; });
++ });
++ }
++ }
++
++ if (input) input.addEventListener('keydown', function (e) {
++ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.submit(); }
++ });
++ toBottom();
++ if (cfg.view === 'global') startGlobal();
++ else if (cfg.view === 'dm') startDm();
++})();
ddiff --git a/server/app.js b/server/app.js
iindex 404e1d6..9556383 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -48,6 +48,7 @@ function getAllowedOrigins() {
} catch { /* */ }
}
allowed.add(normalizeOrigin(config.networkUrl) || 'https://openvibe.network');
++ allowed.add(normalizeOrigin(config.web.baseUrl) || 'https://openvibe.chat');
allowed.add('https://openvibe.games');
allowed.add('https://openvibe.tools');
if (!config.isProduction) {
@@@ -206,6 +207,9 @@ function createApp({ chatServer, bridge, mirror, relay, events = null }) {
app.use('/api/tts', require('./chat/tts-routes'));
app.use('/api/sounds', require('./chat/sounds-routes'));
++ // openvibe.chat, the site: pages, sign-in, the Frame's /shared/ files (server/web/).
++ app.use(require('./web/pages').createWebRoutes({ config }));
++
app.use((req, res) => res.status(404).json({ error: 'Not found' }));
app.use((err, req, res, _next) => {
if (err.name === 'MulterError' || (err.message && err.message.includes('file'))) {
ddiff --git a/server/config.js b/server/config.js
iindex 18bb5c6..41f5ae8 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -39,12 +39,21 @@ module.exports = {
// PEM with the Network's RS256 public key. When unset or unreadable the key is fetched from
// ${networkInternalUrl}/api/.well-known/jwks.
networkPublicKeyPath: process.env.OV_NETWORK_PUBLIC_KEY || '',
-- // Chat's service principal (client `chat` in the Network's oauth_clients).
++ // Chat's service principal (client `chat` in the Network's oauth_clients). The same client signs
++ // people in to openvibe.chat (redirect <web.baseUrl>/auth/callback), as Wiki and Blog do.
oauth: {
clientId: process.env.OV_OAUTH_CLIENT_ID || 'chat',
clientSecret: process.env.OV_OAUTH_CLIENT_SECRET || '',
++ redirectUri: process.env.CHAT_OAUTH_REDIRECT_URI || `${strip(process.env.CHAT_WEB_URL || (isProduction ? 'https://openvibe.chat' : 'http://localhost:4400'))}/auth/callback`,
++ scope: 'profile theme',
},
++ // openvibe.chat, the site (server/web/): global chat, messages and settings, served by Chat itself.
++ web: {
++ baseUrl: strip(process.env.CHAT_WEB_URL || (isProduction ? 'https://openvibe.chat' : 'http://localhost:4400')),
++ },
++ cookies: { secure: process.env.COOKIE_SECURE ? process.env.COOKIE_SECURE === 'true' : isProduction },
++
// OpenVibe.Live — users, streams, channels, bans and every side effect chat triggers.
live: {
internalUrl: strip(process.env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000'),
ddiff --git a/server/web/layout.js b/server/web/layout.js
nnew file mode 100644
iindex 0000000..6d80eea
---- /dev/null
++++ b/server/web/layout.js
@@@ -0,0 +1,102 @@
++'use strict';
++/**
++ * openvibe.chat page shell (server/web/). Every page is server-rendered and readable without
++ * JavaScript; the OpenVibe Frame (navbar, footer, themes) comes from this site's own /shared/ copy of
++ * openvibe-shared, and public/web/chat.js adds the live feed and the composer.
++ */
++const crypto = require('crypto');
++const fs = require('fs');
++const path = require('path');
++const ovServe = require('openvibe-shared/serve');
++const sharedSeo = require('openvibe-shared/seo');
++const appIcon = require('openvibe-shared/app-icon');
++const frame = require('openvibe-shared/frame');
++
++const SITE_NAME = 'OpenVibe.Chat';
++const NETWORK_URL = 'https://openvibe.network';
++const DEFAULT_DESCRIPTION = 'OpenVibe.Chat: the network-wide chat room and your direct messages, one account across every OpenVibe site.';
++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public', 'web');
++const LINKS = [
++ { key: 'global', label: 'Global chat', href: '/' },
++ { key: 'messages', label: 'Messages', href: '/messages' },
++ { key: 'settings', label: 'Settings', href: '/settings' },
++];
++
++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++
++const hashes = new Map();
++function asset(rel) {
++ if (!hashes.has(rel)) {
++ let v = 'dev';
++ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }
++ hashes.set(rel, v);
++ }
++ return `/web/${rel}?v=${hashes.get(rel)}`;
++}
++
++function navConfig(o, config) {
++ return {
++ service: 'chat',
++ apiBase: NETWORK_URL,
++ links: LINKS.map((l) => ({ label: l.label, href: l.href, active: o.active === l.key })),
++ history: { type: 'page', title: o.title || SITE_NAME },
++ silentLogin: `${config.web.baseUrl}/auth/login?silent=1&next={url}`,
++ sessionUrl: '/auth/me',
++ loginUrl: `/auth/login?next=${encodeURIComponent(o.path || '/')}`,
++ logoutUrl: '/auth/logout?next={path}',
++ };
++}
++
++/**
++ * o: title, description, path, robots (required), body, active, actor, config, page (JSON for chat.js),
++ * prefs (the person's chat preferences: timestamps, compact, font_scale, show_badges)
++ */
++function renderPage(o) {
++ const { config } = o;
++ if (!o.robots) throw new TypeError('renderPage needs explicit robots');
++ const title = o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME;
++ const head = sharedSeo.headTags({
++ title, description: o.description || DEFAULT_DESCRIPTION, canonical: `${config.web.baseUrl}${o.path || '/'}`,
++ robots: o.robots, siteName: SITE_NAME, type: 'website',
++ });
++ const actor = o.actor || { kind: 'anonymous' };
++ const who = actor.kind === 'user'
++ ? `<span>Signed in as ${esc(actor.user.display_name || actor.user.username)}</span> · <a href="/auth/logout?next=${encodeURIComponent(o.path || '/')}">Sign out</a>`
++ : `<a href="/auth/login?next=${encodeURIComponent(o.path || '/')}">Sign in with OpenVibe</a>`;
++ const p = o.prefs || {};
++ const cls = [p.compact ? 'oc-compact' : '', p.timestamps ? 'oc-times' : '', p.show_badges === false ? 'oc-nobadges' : ''].filter(Boolean).join(' ');
++ const scale = typeof p.font_scale === 'number' ? ` style="--oc-scale:${Math.min(1.4, Math.max(0.8, p.font_scale))}"` : '';
++ return `<!DOCTYPE html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++${head}
++${appIcon.headTags({ site: 'network' })}
++<link rel="stylesheet" href="${asset('chat.css')}">
++<script src="${ovServe.url('theme-loader.js')}" defer></script>
++<script src="${ovServe.url('navbar.js')}" defer></script>
++<script src="${ovServe.url('footer.js')}" defer></script>
++${o.script ? `<script src="${asset('chat.js')}" defer></script>` : ''}
++</head>
++<body class="${cls}"${scale}>
++<a class="oc-skip" href="#main">Skip to content</a>
++<div id="navbar-mount"></div>
++${frame.noscriptNav({ name: SITE_NAME, home: '/', links: LINKS.map(({ label, href }) => ({ label, href })) })}
++<header class="oc-bar"><a class="oc-brand" href="/">${SITE_NAME}</a><nav class="oc-tabs" aria-label="Chat">${LINKS.map((l) => `<a href="${l.href}"${o.active === l.key ? ' aria-current="page"' : ''}>${l.label}</a>`).join('')}</nav><noscript><span class="oc-account">${who}</span></noscript></header>
++<main id="main" class="oc-main">
++${o.body || ''}
++</main>
++${frame.footer({ service: 'chat', variant: 'full', updates: '/updates' })}
++<script>
++window.__OV_PAGE = ${JSON.stringify({ navbar: navConfig(o, config), footer: { service: 'chat', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' }, chat: o.page || null }).replace(/</g, '\\u003c')};
++document.addEventListener('DOMContentLoaded', function () {
++ try { if (window.OpenVibeNavbar) OpenVibeNavbar.init(window.__OV_PAGE.navbar); } catch (e) { /* the Frame is optional */ }
++ try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* */ }
++});
++</script>
++</body>
++</html>`;
++}
++
++module.exports = { renderPage, asset, esc, SITE_NAME, DEFAULT_DESCRIPTION, NETWORK_URL };
ddiff --git a/server/web/pages.js b/server/web/pages.js
nnew file mode 100644
iindex 0000000..0d4763b
---- /dev/null
++++ b/server/web/pages.js
@@@ -0,0 +1,283 @@
++'use strict';
++/**
++ * openvibe.chat, the site (roadmap WS-I task 8): the network-wide chat room, your direct messages and
++ * your chat settings, served by OpenVibe.Chat itself, so it keeps working while OpenVibe.Live is down.
++ *
++ * GET / global chat: the last messages server-rendered (readable without
++ * JavaScript); public/web/chat.js adds the live feed over /ws/chat
++ * POST /send the composer without JavaScript (the same handler as POST /api/chat/send)
++ * GET /messages your conversations; POST /messages/new starts one by username
++ * GET /messages/:id one conversation (participants only); POST /messages/:id replies
++ * GET /settings your portable chat settings (Network module chat.preferences); POST saves
++ * GET /updates what shipped on openvibe.chat (the shared changelog)
++ * GET /robots.txt, /sitemap.xml
++ * /auth/* sign in with OpenVibe.Network (./session.js), cookies host-only
++ *
++ * Forms go through the same API handlers the JavaScript client calls (apiBridge), so rate limits,
++ * bans, blocks, spam checks and DM participation are enforced in one place. Posts must come from this
++ * site (Origin check; the session cookie is SameSite=Lax).
++ */
++const path = require('path');
++const express = require('express');
++const rateLimit = require('express-rate-limit');
++const ovServe = require('openvibe-shared/serve');
++const frame = require('openvibe-shared/frame');
++const { renderPage, esc, SITE_NAME } = require('./layout');
++const { createSessionRoutes } = require('./session');
++const session = require('../auth/network-session');
++const historyStore = require('../chat/history-store');
++const dm = require('../chat/dm');
++const prefs = require('../prefs/chat-preferences');
++const ctx = require('../live-context');
++const permissions = require('../auth/permissions');
++
++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public', 'web');
++const ANON = Object.freeze({ kind: 'anonymous' });
++const LIVE = 'https://openvibe.live';
++const FONT_SCALES = { small: 0.88, default: null, large: 1.18 };
++
++async function viewerOf(req) {
++ const token = req.cookies && req.cookies.ov_token;
++ if (!token) return ANON;
++ const u = await session.authenticate(token).catch(() => null);
++ if (!u || u.auth_source === 'api_token') return ANON;
++ return { kind: 'user', user: u, token };
++}
++
++async function prefsOf(actor) {
++ if (actor.kind !== 'user' || !actor.user.subject_id) return {};
++ try { return (await prefs.get(actor.user.subject_id)).preferences || {}; } catch { return {}; }
++}
++
++const hhmm = (ts) => {
++ const d = new Date(String(ts || '').includes('T') ? ts : `${String(ts || '').replace(' ', 'T')}Z`);
++ return Number.isNaN(d.getTime()) ? { iso: '', text: '' } : { iso: d.toISOString(), text: d.toISOString().slice(11, 16) };
++};
++/** Plain text with http(s) links, escaped. */
++function linkify(text) {
++ return esc(text).replace(/https?:\/\/[^\s<>"']+/g, (u) => `<a href="${u}" rel="nofollow ugc noopener" target="_blank">${u}</a>`);
++}
++/** The author's badge, from the staff map (ADR-022): staff run the site, moderators moderate chat. */
++function roleBadge(role) {
++ const who = { role: String(role || 'user') };
++ if (permissions.can(who, 'staff.site.configure')) return '<span class="oc-badge oc-badge-staff" title="OpenVibe staff">staff</span>';
++ if (permissions.can(who, 'staff.moderation.chat')) return '<span class="oc-badge oc-badge-mod" title="Moderator">mod</span>';
++ return '';
++}
++function globalMessage(m) {
++ const t = hhmm(m.timestamp);
++ const handle = m.core_username || (m.user_id ? m.username : null);
++ const name = esc(m.display_name || m.username || m.anon_id || 'someone');
++ const who = handle ? `<a class="oc-name" href="${LIVE}/@${encodeURIComponent(handle)}" style="color:${esc(/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? m.profile_color : 'inherit')}">${name}</a>` : `<span class="oc-name oc-anon">${name}</span>`;
++ const where = m.stream_channel ? ` <a class="oc-where" href="${LIVE}/@${encodeURIComponent(m.stream_channel)}" title="Sent from ${esc(m.stream_channel)}'s channel">@${esc(m.stream_channel)}</a>` : '';
++ return `<li class="oc-msg" data-id="${Number(m.id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who}${roleBadge(m.role)}${where} <span class="oc-text">${linkify(m.message)}</span></li>`;
++}
++function dmMessage(m, me) {
++ const t = hhmm(m.created_at);
++ const mine = me && m.sender_id === me.id;
++ return `<li class="oc-msg${mine ? ' oc-mine' : ''}" data-id="${Number(m.id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> <span class="oc-name">${esc(m.display_name || m.username)}</span> <span class="oc-text">${linkify(m.message)}</span></li>`;
++}
++const notice = (kind, text) => `<p class="oc-notice oc-${kind}" role="${kind === 'error' ? 'alert' : 'status'}">${esc(text)}</p>`;
++
++/**
++ * Run a form through an existing JSON API handler: the route is re-dispatched under `apiPath`, and its
++ * JSON answer becomes a redirect (success) or an error page. One set of checks for both clients.
++ */
++function apiBridge(apiRouter, apiPath, { body, ok, fail }) {
++ return (req, res, next) => {
++ const original = req.url;
++ req.url = typeof apiPath === 'function' ? apiPath(req) : apiPath;
++ if (body) req.body = body(req);
++ res.json = (payload) => {
++ req.url = original;
++ if (res.statusCode < 400) return ok(req, res, payload || {});
++ return fail(req, res, res.statusCode, (payload && payload.error) || 'That did not work');
++ };
++ apiRouter(req, res, (err) => { req.url = original; next(err); });
++ };
++}
++
++function createWebRoutes({ config }) {
++ const router = express.Router();
++ const site = config.web.baseUrl;
++ const siteOrigin = (() => { try { return new URL(site).origin; } catch { return null; } })();
++ const webConfig = { ...config, baseUrl: site };
++ const viewers = { resolve: async (req) => viewerOf(req) };
++ const formLimit = rateLimit({ windowMs: 60_000, max: 30, standardHeaders: true, legacyHeaders: false });
++ const sameSite = (req, res, next) => {
++ const o = req.get('origin');
++ if (o && o !== siteOrigin && o !== 'null') return res.status(403).type('text').send('Forms are only accepted from this site.');
++ next();
++ };
++ const page = async (req, res, status, o) => {
++ const actor = o.actor || await viewerOf(req);
++ res.status(status).set('Cache-Control', o.cache || 'private, no-store').type('html')
++ .send(renderPage({ config, actor, prefs: await prefsOf(actor), ...o }));
++ };
++ const needUser = async (req, res) => {
++ const actor = await viewerOf(req);
++ if (actor.kind === 'user') return actor;
++ res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
++ return null;
++ };
++
++ router.use('/auth', createSessionRoutes({ config: webConfig, viewers }));
++ router.use('/shared', ovServe.handler());
++ router.use('/web', express.static(PUBLIC_DIR, { maxAge: '365d', immutable: true, index: false }));
++
++ // ── Global chat ──
++ router.get('/', async (req, res) => {
++ const actor = await viewerOf(req);
++ const { messages, latest_id } = historyStore.page('global', { limit: 60 });
++ const shown = messages.filter((m) => !m.is_deleted && (m.message_type || 'chat') === 'chat');
++ const composer = actor.kind === 'user'
++ ? (actor.user.is_banned ? notice('error', 'Your account is banned from chat.') : `<form class="oc-compose" method="post" action="/send" id="oc-compose">
++<label for="oc-input" class="oc-sr">Message to everyone</label>
++<textarea id="oc-input" name="message" maxlength="500" rows="2" required placeholder="Say something to everyone on OpenVibe"></textarea>
++<button type="submit">Send</button>
++</form>`)
++ : `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=%2F">Sign in with OpenVibe</a> to chat. Anyone can read along.</p>`;
++ const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
++ await page(req, res, 200, {
++ actor, title: 'Global chat', path: '/', active: 'global', robots: 'index, follow', script: true,
++ description: 'The network-wide chat room of OpenVibe: everyone on every OpenVibe site, in one conversation.',
++ page: { view: 'global', latest: latest_id, signedIn: actor.kind === 'user', me: actor.kind === 'user' ? actor.user.username : null },
++ body: `<section class="oc-room" aria-labelledby="oc-h">
++<h1 id="oc-h">Global chat</h1>
++<p class="oc-muted">One room for everyone on OpenVibe: streamers, viewers and players, from every site. <span class="oc-live" id="oc-live" hidden>● live</span></p>
++${error}
++<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${shown.map(globalMessage).join('\n') || '<li class="oc-empty oc-muted">No messages yet. Say hello.</li>'}</ol>
++${composer}
++</section>`,
++ });
++ });
++
++ const chatApi = require('../chat/routes');
++ router.post('/send', formLimit, sameSite, async (req, res, next) => {
++ if (!(await needUser(req, res))) return;
++ req.headers.authorization = `Bearer ${req.cookies.ov_token}`;
++ return apiBridge(chatApi, '/send', {
++ body: (r) => ({ message: String((r.body && r.body.message) || '').slice(0, 6000) }),
++ ok: (r, s) => s.redirect(303, '/#oc-compose'),
++ fail: (r, s, status, error) => s.redirect(303, `/?error=${encodeURIComponent(error)}#oc-compose`),
++ })(req, res, next);
++ });
++
++ // ── Direct messages ──
++ const dmApi = require('../chat/dm-routes');
++ router.get('/messages', async (req, res) => {
++ const actor = await needUser(req, res); if (!actor) return;
++ const me = actor.user;
++ const convs = dm.getConversations(me.id);
++ const rows = convs.map((c) => {
++ const others = dm.getParticipants(c.id).filter((p) => p.id !== me.id);
++ const title = c.name || others.map((p) => p.display_name || p.username).join(', ') || 'Just you';
++ const t = hhmm(c.last_message_at);
++ return `<li class="oc-conv${c.unread_count ? ' oc-unread' : ''}"><a href="/messages/${c.id}"><strong>${esc(title)}</strong>${c.unread_count ? ` <span class="oc-badge">${c.unread_count} new</span>` : ''}<span class="oc-muted oc-last">${c.last_message ? esc(String(c.last_message).slice(0, 120)) : 'No messages yet'}</span></a>${t.text ? `<time class="oc-time" datetime="${t.iso}">${t.iso.slice(0, 10)}</time>` : ''}</li>`;
++ });
++ const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
++ await page(req, res, 200, {
++ actor, title: 'Messages', path: '/messages', active: 'messages', robots: 'noindex, nofollow',
++ body: `<h1>Messages</h1>${error}
++<form class="oc-new" method="post" action="/messages/new"><label for="oc-to">Message someone</label> <input id="oc-to" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="their username" autocomplete="off"> <button type="submit">Start</button></form>
++<ul class="oc-convs">${rows.join('\n') || '<li class="oc-muted">No conversations yet.</li>'}</ul>`,
++ });
++ });
++
++ router.post('/messages/new', formLimit, sameSite, async (req, res, next) => {
++ const actor = await needUser(req, res); if (!actor) return;
++ const name = String((req.body && req.body.username) || '').trim();
++ let target = /^[A-Za-z0-9_]{3,24}$/.test(name) ? ctx.getUserByUsername(name) : null;
++ if (!target && /^[A-Za-z0-9_]{3,24}$/.test(name)) target = await ctx.ensureUserByUsername(name).catch(() => null);
++ if (!target) return res.redirect(303, `/messages?error=${encodeURIComponent(`Nobody called ${name.slice(0, 24)} on OpenVibe`)}`);
++ req.headers.authorization = `Bearer ${req.cookies.ov_token}`;
++ return apiBridge(dmApi, '/conversations', {
++ body: () => ({ user_ids: [target.id] }),
++ ok: (r, s, p) => { const id = (p.conversation && p.conversation.id) || p.id; return s.redirect(303, id ? `/messages/${Number(id)}` : '/messages'); },
++ fail: (r, s, status, error) => s.redirect(303, `/messages?error=${encodeURIComponent(error)}`),
++ })(req, res, next);
++ });
++
++ router.get('/messages/:id', async (req, res) => {
++ const actor = await needUser(req, res); if (!actor) return;
++ const me = actor.user;
++ const id = parseInt(req.params.id, 10);
++ if (!Number.isInteger(id) || !dm.isParticipant(id, me.id)) return page(req, res, 404, { actor, title: 'Not found', path: req.path, robots: 'noindex', body: '<h1>No such conversation</h1><p><a href="/messages">Back to your messages</a></p>' });
++ const others = dm.getParticipants(id).filter((p) => p.id !== me.id);
++ const conv = dm.getConversation(id);
++ const title = (conv && conv.name) || others.map((p) => p.display_name || p.username).join(', ') || 'Conversation';
++ const msgs = dm.getMessages(id, 60).slice().sort((a, b) => a.id - b.id);
++ try { dm.markRead(id, me.id); } catch { /* best effort */ }
++ const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
++ await page(req, res, 200, {
++ actor, title, path: `/messages/${id}`, active: 'messages', robots: 'noindex, nofollow', script: true,
++ page: { view: 'dm', conversation: id, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me.id },
++ body: `<p><a href="/messages">← Messages</a></p><h1>${esc(title)}</h1>${error}
++<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => dmMessage(m, me)).join('\n') || '<li class="oc-empty oc-muted">No messages yet.</li>'}</ol>
++<form class="oc-compose" method="post" action="/messages/${id}" id="oc-compose"><label for="oc-input" class="oc-sr">Reply</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="Write a message"></textarea><button type="submit">Send</button></form>`,
++ });
++ });
++
++ router.post('/messages/:id', formLimit, sameSite, async (req, res, next) => {
++ if (!(await needUser(req, res))) return;
++ const id = parseInt(req.params.id, 10);
++ if (!Number.isInteger(id)) return res.redirect(303, '/messages');
++ req.headers.authorization = `Bearer ${req.cookies.ov_token}`;
++ return apiBridge(dmApi, `/conversations/${id}/messages`, {
++ body: (r) => ({ message: String((r.body && r.body.message) || '').slice(0, 2000) }),
++ ok: (r, s) => s.redirect(303, `/messages/${id}#oc-compose`),
++ fail: (r, s, status, error) => s.redirect(303, `/messages/${id}?error=${encodeURIComponent(error)}#oc-compose`),
++ })(req, res, next);
++ });
++
++ // ── Settings (chat.preferences, the same record Live's chat reads) ──
++ router.get('/settings', async (req, res) => {
++ const actor = await needUser(req, res); if (!actor) return;
++ const p = await prefsOf(actor);
++ const size = p.font_scale == null ? 'default' : p.font_scale < 0.95 ? 'small' : p.font_scale > 1.05 ? 'large' : 'default';
++ const box = (name, label, on) => `<label class="oc-check"><input type="checkbox" name="${name}" value="1"${on ? ' checked' : ''}> ${label}</label>`;
++ const saved = req.query.saved ? notice('ok', 'Saved. These settings follow you to Live chat and every device.') : '';
++ const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
++ await page(req, res, 200, {
++ actor, title: 'Chat settings', path: '/settings', active: 'settings', robots: 'noindex, nofollow',
++ body: `<h1>Chat settings</h1>${saved}${error}
++<p class="oc-muted">Saved to your OpenVibe account, so they apply here, in Live chat and on every device you sign in to.</p>
++<form class="oc-form" method="post" action="/settings">
++${box('timestamps', 'Show the time next to each message', p.timestamps === true)}
++${box('compact', 'Compact messages', p.compact === true)}
++${box('show_badges', 'Show staff and moderator badges', p.show_badges !== false)}
++${box('hide_emotes', 'Hide emotes', p.hide_emotes === true)}
++<label>Text size <select name="font_size">${['small', 'default', 'large'].map((s) => `<option value="${s}"${s === size ? ' selected' : ''}>${s[0].toUpperCase()}${s.slice(1)}</option>`).join('')}</select></label>
++<p><button type="submit">Save</button></p>
++</form>`,
++ });
++ });
++
++ router.post('/settings', formLimit, sameSite, async (req, res) => {
++ const actor = await needUser(req, res); if (!actor) return;
++ const b = req.body || {};
++ const patch = {
++ timestamps: b.timestamps === '1' ? true : null,
++ compact: b.compact === '1' ? true : null,
++ show_badges: b.show_badges === '1' ? null : false,
++ hide_emotes: b.hide_emotes === '1' ? true : null,
++ font_scale: Object.prototype.hasOwnProperty.call(FONT_SCALES, b.font_size) ? FONT_SCALES[b.font_size] : null,
++ };
++ try { await prefs.update(actor.user.subject_id, patch); return res.redirect(303, '/settings?saved=1'); }
++ catch (err) { return res.redirect(303, `/settings?error=${encodeURIComponent(err.message || 'Could not save right now')}`); }
++ });
++
++ // ── What shipped, robots, sitemap ──
++ router.get('/updates', (req, res) => page(req, res, 200, {
++ title: `What shipped on ${SITE_NAME}`, path: '/updates', robots: 'index, follow', cache: 'public, max-age=300',
++ body: frame.updatesBody({ service: 'chat', siteName: SITE_NAME }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,
++ }));
++ router.get('/robots.txt', (req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600')
++ .send(`User-agent: *\nAllow: /$\nAllow: /updates\nDisallow: /messages\nDisallow: /settings\nDisallow: /auth/\nDisallow: /api/\nSitemap: ${site}/sitemap.xml\n`));
++ router.get('/sitemap.xml', (req, res) => res.type('application/xml').set('Cache-Control', 'public, max-age=3600')
++ .send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${['/', '/updates'].map((p) => `<url><loc>${site}${p}</loc></url>`).join('')}</urlset>\n`));
++
++ return router;
++}
++
++module.exports = { createWebRoutes, apiBridge, globalMessage, linkify };
ddiff --git a/server/web/session.js b/server/web/session.js
nnew file mode 100644
iindex 0000000..d43ada6
---- /dev/null
++++ b/server/web/session.js
@@@ -0,0 +1,199 @@
++'use strict';
++/**
++ * openvibe.chat as an OAuth2 client of OpenVibe.Network (client `chat`, redirect
++ * <CHAT_WEB_URL>/auth/callback): the session layer OpenVibe.Wiki and OpenVibe.Community use.
++ *
++ * GET /auth/login → Network /oauth/authorize (?silent=1 adds prompt=none; ?next= same-site path)
++ * GET /auth/callback → server-side code exchange, sets the cookies
++ * POST /auth/fedcm → the shared navbar's FedCM assertion (nonce checked here, signature by the Network)
++ * GET /auth/logout → clears the cookies (best-effort refresh revoke)
++ * GET /auth/me → the signed-in profile (offline JWT verification)
++ * POST /auth/refresh → rotates tokens with the refresh token
++ *
++ * Cookies (host-only): ov_token (access JWT, JS-readable for the navbar), ov_refresh (httpOnly,
++ * Path=/auth), ov_sso_hint ('account' | 'guest', tells the navbar whether a silent login is worth it).
++ */
++const crypto = require('crypto');
++const express = require('express');
++
++const ACCESS_COOKIE = 'ov_token';
++const REFRESH_COOKIE = 'ov_refresh';
++const HINT_COOKIE = 'ov_sso_hint';
++const STATE_COOKIE = 'ov_oauth_state';
++const NEXT_COOKIE = 'ov_oauth_next';
++const SILENT_COOKIE = 'ov_oauth_silent';
++
++function sanitizeNext(next, config) {
++ if (!next || typeof next !== 'string') return '/';
++ if (/^\/(?!\/|\\)/.test(next)) return next;
++ try {
++ const u = new URL(next);
++ if (u.protocol !== 'https:') return '/';
++ const allowed = [config.baseUrl, config.networkUrl].map((b) => { try { return new URL(b).hostname; } catch { return null; } }).filter(Boolean);
++ if (allowed.includes(u.hostname)) return u.toString();
++ } catch { /* fall through */ }
++ return '/';
++}
++
++function withParam(target, key, value) {
++ const at = target.indexOf('#');
++ const hash = at >= 0 ? target.slice(at) : '';
++ const base = at >= 0 ? target.slice(0, at) : target;
++ return `${base}${base.includes('?') ? '&' : '?'}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;
++}
++
++function authorizeUrl(config, { silent = false } = {}) {
++ const state = crypto.randomBytes(16).toString('hex');
++ const u = new URL(`${config.networkUrl}/oauth/authorize`);
++ u.searchParams.set('client_id', config.oauth.clientId);
++ u.searchParams.set('redirect_uri', config.oauth.redirectUri);
++ u.searchParams.set('response_type', 'code');
++ u.searchParams.set('scope', config.oauth.scope);
++ u.searchParams.set('state', state);
++ if (silent) u.searchParams.set('prompt', 'none');
++ return { url: u.toString(), state };
++}
++
++function fedcmNonceMatches(token, nonce) {
++ if (typeof nonce !== 'string' || !nonce || nonce.length > 256) return false;
++ const parts = String(token || '').split('.');
++ if (parts.length !== 3) return false;
++ let claims;
++ try { claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return false; }
++ if (!claims || typeof claims.nonce !== 'string') return false;
++ const a = Buffer.from(claims.nonce), b = Buffer.from(nonce);
++ return a.length === b.length && crypto.timingSafeEqual(a, b);
++}
++
++function createSessionRoutes({ config, viewers, fetchImpl = globalThis.fetch, log = console }) {
++ const router = express.Router();
++ const secure = config.cookies.secure;
++ const access = () => ({ sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: 24 * 3600 * 1000 });
++ const refresh = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 30 * 24 * 3600 * 1000 });
++ const flow = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 10 * 60 * 1000 });
++ const hint = () => ({ sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 3600 * 1000 });
++
++ function setSession(res, accessToken, refreshToken) {
++ res.cookie(ACCESS_COOKIE, accessToken, access());
++ if (refreshToken) res.cookie(REFRESH_COOKIE, refreshToken, refresh());
++ res.cookie(HINT_COOKIE, 'account', hint());
++ }
++ function clearSession(res) {
++ res.clearCookie(ACCESS_COOKIE, { ...access(), maxAge: undefined });
++ res.clearCookie(REFRESH_COOKIE, { ...refresh(), maxAge: undefined });
++ }
++ function clearFlow(res) { for (const c of [STATE_COOKIE, NEXT_COOKIE, SILENT_COOKIE]) res.clearCookie(c, { path: '/auth' }); }
++
++ async function tokenGrant(body) {
++ let lastErr = null;
++ for (const base of [config.networkInternalUrl, config.networkUrl]) {
++ if (!base) continue;
++ try {
++ const res = await fetchImpl(`${base}/oauth/token`, {
++ method: 'POST', headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...body }),
++ signal: AbortSignal.timeout(10000),
++ });
++ const data = await res.json().catch(() => ({}));
++ if (!res.ok) throw Object.assign(new Error(data.error_description || data.error || `token grant failed (${res.status})`), { status: res.status, error: data.error || 'invalid_grant' });
++ return data;
++ } catch (err) {
++ lastErr = err;
++ if (err.status && err.status < 500) throw err;
++ }
++ }
++ throw lastErr || new Error('Network unreachable');
++ }
++
++ router.get('/login', async (req, res) => {
++ const silent = !!req.query.silent && req.query.silent !== '0';
++ const next = sanitizeNext(req.query.next, config);
++ if (silent) {
++ const actor = await viewers.resolve(req, { services: false }).catch(() => null);
++ if (actor && actor.kind === 'user') { clearFlow(res); return res.redirect(next); }
++ }
++ const { url, state } = authorizeUrl(config, { silent });
++ res.cookie(STATE_COOKIE, state, flow());
++ if (next !== '/') res.cookie(NEXT_COOKIE, next, flow()); else res.clearCookie(NEXT_COOKIE, { path: '/auth' });
++ if (silent) res.cookie(SILENT_COOKIE, '1', flow()); else res.clearCookie(SILENT_COOKIE, { path: '/auth' });
++ res.redirect(url);
++ });
++
++ router.get('/callback', async (req, res) => {
++ const { code, state, error } = req.query;
++ const next = sanitizeNext(req.cookies && req.cookies[NEXT_COOKIE], config);
++ const silent = req.cookies && req.cookies[SILENT_COOKIE] === '1';
++ if (error) {
++ clearFlow(res);
++ if (silent || error === 'login_required') return res.redirect(withParam(next, 'sso', 'none'));
++ return res.redirect(withParam('/', 'auth_error', String(error)));
++ }
++ if (!code) return res.status(400).type('text/plain').send('Missing authorization code');
++ const expected = req.cookies && req.cookies[STATE_COOKIE];
++ clearFlow(res);
++ if (!expected || !state || !crypto.timingSafeEqual(Buffer.from(String(state).padEnd(64).slice(0, 64)), Buffer.from(String(expected).padEnd(64).slice(0, 64)))) {
++ return res.status(400).type('text/plain').send('OAuth state mismatch — please try signing in again.');
++ }
++ try {
++ const data = await tokenGrant({ grant_type: 'authorization_code', redirect_uri: config.oauth.redirectUri, code });
++ setSession(res, data.access_token, data.refresh_token);
++ return res.redirect(next);
++ } catch (err) {
++ log.error(`[Wiki] code exchange failed: ${err.message}`);
++ return res.status(502).type('text/plain').send('Sign-in failed — could not reach OpenVibe.Network. Please try again.');
++ }
++ });
++
++ router.post('/fedcm', express.json({ limit: '16kb' }), async (req, res) => {
++ const { token, nonce } = req.body || {};
++ if (typeof token !== 'string' || !token || !fedcmNonceMatches(token, nonce)) return res.status(400).json({ error: 'invalid_request', error_description: 'token and a matching nonce are required' });
++ try {
++ const data = await tokenGrant({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: token });
++ if (!data.access_token) throw Object.assign(new Error('no token'), { status: 401 });
++ setSession(res, data.access_token, data.refresh_token);
++ return res.json({ ok: true, user: data.user || null });
++ } catch (err) {
++ if (err.status && err.status < 500) return res.status(401).json({ error: 'invalid_grant' });
++ return res.status(502).json({ error: 'server_error', error_description: 'Could not reach OpenVibe.Network' });
++ }
++ });
++
++ router.get('/logout', async (req, res) => {
++ const token = req.cookies && req.cookies[REFRESH_COOKIE];
++ if (token) {
++ try {
++ await fetchImpl(`${config.networkInternalUrl || config.networkUrl}/oauth/revoke`, {
++ method: 'POST', headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, token }),
++ signal: AbortSignal.timeout(3000),
++ });
++ } catch { /* optional */ }
++ }
++ clearSession(res);
++ res.cookie(HINT_COOKIE, 'guest', hint());
++ res.redirect(sanitizeNext(req.query.next, config));
++ });
++
++ router.get('/me', async (req, res) => {
++ const actor = await viewers.resolve(req, { services: false }).catch(() => null);
++ if (!actor || actor.kind !== 'user') return res.status(401).json({ error: 'Not authenticated' });
++ res.set('Cache-Control', 'private, no-store').json({ user: actor.user });
++ });
++
++ router.post('/refresh', async (req, res) => {
++ const token = req.cookies && req.cookies[REFRESH_COOKIE];
++ if (!token) return res.status(401).json({ error: 'No refresh token' });
++ try {
++ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: token });
++ setSession(res, data.access_token, data.refresh_token);
++ return res.json({ token: data.access_token });
++ } catch (err) {
++ if (err.status && err.status < 500) { clearSession(res); return res.status(401).json({ error: 'Refresh token rejected — please sign in again' }); }
++ return res.status(502).json({ error: 'Could not reach OpenVibe.Network' });
++ }
++ });
++
++ return router;
++}
++
++module.exports = { createSessionRoutes, sanitizeNext, authorizeUrl, fedcmNonceMatches };
ddiff --git a/test/web.test.js b/test/web.test.js
nnew file mode 100644
iindex 0000000..99156b4
---- /dev/null
++++ b/test/web.test.js
@@@ -0,0 +1,109 @@
++'use strict';
++/**
++ * openvibe.chat, the site (server/web/): global chat reads without JavaScript and posts through the same
++ * handler as the API; messages, a conversation (participants only) and settings need a signed-in
++ * person; forms from another origin are refused; user text is escaped; sign-in goes to the Network with
++ * the site's callback; the Frame's files, robots, sitemap and /updates are served.
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++
++const t = suite('web');
++let h, alice, bob, carol;
++const SITE = 'https://openvibe.chat';
++const cookie = (u) => ({ cookie: `ov_token=${u.token}` });
++// Like h.http, but redirects are answers here, not followed.
++const req = async (method, path, { headers = {}, raw } = {}) => {
++ const r = await fetch(`${h.base}${path}`, { method, headers, body: raw, redirect: 'manual' });
++ return { status: r.status, headers: r.headers, text: await r.text() };
++};
++const form = (u, fields, extra = {}) => ({ headers: { ...cookie(u), 'content-type': 'application/x-www-form-urlencoded', origin: SITE, ...extra }, raw: new URLSearchParams(fields).toString() });
++
++t('boot', async () => {
++ h = await boot({ env: { CHAT_WEB_URL: SITE } });
++ alice = h.addUser('alice', { subject: ids.newId('user') });
++ bob = h.addUser('bob', { subject: ids.newId('user') });
++ carol = h.addUser('carol', { subject: ids.newId('user') });
++ for (const u of [alice, bob, carol]) { h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }
++});
++
++t('global chat reads without JavaScript; signing in shows the composer', async () => {
++ const r = await req('GET', '/');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /<h1 id="oc-h">Global chat<\/h1>/);
++ assert.match(r.text, /Sign in with OpenVibe/);
++ assert.match(r.text, /<meta name="robots" content="index, follow">/);
++ assert.match(r.text, /\/shared\/navbar\.js\?v=/, 'the Frame from this site\'s own /shared/');
++ assert.ok(!/id="oc-compose"/.test(r.text), 'no composer for a guest');
++ const me = await req('GET', '/', { headers: cookie(alice) });
++ assert.match(me.text, /id="oc-compose"/);
++});
++
++t('the composer posts without JavaScript, through the API\'s checks; other origins are refused', async () => {
++ let r = await req('POST', '/send', form(alice, { message: 'hello <script>alert(1)</script> https://example.org/x' }));
++ assert.strictEqual(r.status, 303);
++ assert.strictEqual(r.headers.get('location'), '/#oc-compose');
++ const page = (await req('GET', '/')).text;
++ assert.ok(page.includes('hello &lt;script&gt;alert(1)&lt;/script&gt;'), 'escaped');
++ assert.ok(page.includes('<a href="https://example.org/x" rel="nofollow ugc noopener"'), 'links are links');
++ assert.ok(!page.includes('<script>alert(1)</script>'));
++ r = await req('POST', '/send', form(alice, { message: 'from elsewhere' }, { origin: 'https://evil.example' }));
++ assert.strictEqual(r.status, 403);
++ r = await req('POST', '/send', form(alice, { message: '' }));
++ assert.strictEqual(r.status, 303);
++ assert.match(r.headers.get('location'), /^\/\?error=/, 'the API\'s refusal comes back as a notice');
++ r = await req('POST', '/send', { headers: { 'content-type': 'application/x-www-form-urlencoded', origin: SITE }, raw: 'message=hi' });
++ assert.strictEqual(r.status, 303);
++ assert.match(r.headers.get('location'), /^\/auth\/login\?next=/, 'signed out: sign in first');
++});
++
++t('messages: start a conversation by username, reply without JavaScript, participants only', async () => {
++ assert.match((await req('GET', '/messages')).headers.get('location'), /^\/auth\/login\?next=%2Fmessages/);
++ let r = await req('POST', '/messages/new', form(alice, { username: 'bob' }));
++ assert.strictEqual(r.status, 303);
++ const m = /^\/messages\/(\d+)$/.exec(r.headers.get('location'));
++ assert.ok(m, r.headers.get('location'));
++ r = await req('POST', `/messages/${m[1]}`, form(alice, { message: 'hi bob' }));
++ assert.strictEqual(r.status, 303);
++ const thread = await req('GET', `/messages/${m[1]}`, { headers: cookie(bob) });
++ assert.strictEqual(thread.status, 200);
++ assert.ok(thread.text.includes('hi bob'));
++ assert.match(thread.text, /noindex, nofollow/);
++ const inbox = await req('GET', '/messages', { headers: cookie(bob) });
++ assert.ok(inbox.text.includes(`/messages/${m[1]}`));
++ assert.strictEqual((await req('GET', `/messages/${m[1]}`, { headers: cookie(carol) })).status, 404, 'not a participant');
++ r = await req('POST', `/messages/${m[1]}`, form(carol, { message: 'let me in' }));
++ assert.match(r.headers.get('location'), /error=/, 'the API refuses a non-participant');
++ r = await req('POST', '/messages/new', form(alice, { username: 'nobody_like_this' }));
++ assert.match(r.headers.get('location'), /^\/messages\?error=/);
++});
++
++t('settings save to the chat.preferences record', async () => {
++ let r = await req('GET', '/settings', { headers: cookie(alice) });
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /name="timestamps"/);
++ r = await req('POST', '/settings', form(alice, { timestamps: '1', compact: '1', font_size: 'large' }));
++ assert.strictEqual(r.headers.get('location'), '/settings?saved=1');
++ const rec = h.netModules.records.get(h.netModules.key('chat.preferences', alice.subject_id));
++ assert.deepStrictEqual(rec && rec.data, { timestamps: true, compact: true, show_badges: false, font_scale: 1.18 });
++ const page = await req('GET', '/', { headers: cookie(alice) });
++ assert.match(page.text, /<body class="oc-compact oc-times oc-nobadges" style="--oc-scale:1.18">/, 'the site follows them too');
++});
++
++t('sign-in, the Frame, robots, sitemap and /updates', async () => {
++ const r = await req('GET', '/auth/login?next=%2Fmessages');
++ assert.strictEqual(r.status, 302);
++ const u = new URL(r.headers.get('location'));
++ assert.deepStrictEqual([u.pathname, u.searchParams.get('client_id'), u.searchParams.get('redirect_uri')], ['/oauth/authorize', 'chat', `${SITE}/auth/callback`]);
++ assert.strictEqual((await req('GET', '/shared/navbar.js')).status, 200);
++ assert.strictEqual((await req('GET', '/web/chat.css')).status, 200);
++ assert.match((await req('GET', '/robots.txt')).text, /Disallow: \/messages/);
++ assert.match((await req('GET', '/sitemap.xml')).text, /<loc>https:\/\/openvibe\.chat\/updates<\/loc>/);
++ const up = await req('GET', '/updates');
++ assert.strictEqual(up.status, 200);
++ assert.match(up.text, /data-ov-shipped/);
++ assert.strictEqual((await req('GET', '/api/nope')).status, 404, 'the API keeps its JSON 404');
++});
++
++t.run(async () => { if (h && h.close) await h.close(); });