openvibers/openvibe.network Commits
What shipped, network-wide: /api/v1/changelog proxies OpenVibe.Blog's changelog (60 s cache, stale on failure, public CORS) so every site's shared widget reads it from openvibe.network; /updates renders the whole network's log (server-side first page, then openvibe-shared shipped.js: days, Load more, a filter per site, Patch notes). openvibe-shared 1.9.0 (footer 'shipped X ago' line and Updates link on every site, navbar stale-token fallback to the site session, footer empty-bar fix); home links All updates
Changes
@@@ -22,7 +22,7 @@"multer": "^1.4.5-lts.1","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.8.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.9.0","uuid": "^11.1.0","web-push": "^3.6.7"}@@@ -1490,9 +1490,9 @@}},"node_modules/openvibe-shared": {-- "version": "1.8.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.8.0",-- "integrity": "sha512-4e8V/csFWSDJTgV+9sI5sqXYWwCslLhYUquAnQdmAalEb06h/EJQVZxHtmkvXyhcaPv4/Xi9/GemSzPhDLe2nw==",++ "version": "1.9.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.9.0",++ "integrity": "sha512-6OP9RKfTN8NGE/oa5mr+WFNx+Po8ORqwjhzY+Cfmo9KRkp5fNnX+OI/R+oBqcDEntNPMPQFtP81Mmj4hTgcu4g==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -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 test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/chrome.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js",++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/chrome.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js","reset-db": "node server/reset-db.js","grant-admin": "node server/grant-admin.js"},@@@ -25,7 +25,7 @@"multer": "^1.4.5-lts.1","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.8.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.9.0","uuid": "^11.1.0","web-push": "^3.6.7"}@@@ -193,7 +193,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}<script src="/shared/navbar.js"></script><script src="/shared/footer.js"></script><script src="/shared/shipped.js" defer></script>--<script>document.addEventListener('DOMContentLoaded', function () { if (window.OpenVibeShipped) OpenVibeShipped.mount('#ov-shipped', { limit: 8, title: 'Recently shipped across OpenVibe' }); });</script>++<script>document.addEventListener('DOMContentLoaded', function () { if (window.OpenVibeShipped) OpenVibeShipped.mount('#ov-shipped', { limit: 8, title: 'Recently shipped across OpenVibe', more: '/updates' }); });</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) { /* */ } }@@@ -395,6 +395,8 @@ app.use(ecosystem.router());ecosystem.start();// Operator status: GET /status (server-rendered, noindex), /api/v1/status, /api/v1/status/slo.app.use(require('./status/routes').createStatusRoutes({ ecosystem }));++// What shipped network-wide: the changelog proxy every site's widget reads, and /updates.++app.use(require('./updates/routes').createUpdatesRoutes({ blogUrl: process.env.OV_BLOG_INTERNAL_URL || 'http://127.0.0.1:4810' }).router);app.get('/api/.well-known/jwks', (_req, res) => {const out = { public_key: publicKey, algorithm: privateKey === publicKey ? 'HS256' : 'RS256' };@@@ -25,6 +25,7 @@ const CONTRACT_RE = /^\/contracts\/[a-z0-9-]+(?:\/[a-z0-9-]+)?\/[a-z0-9_.-]+\.v\function isPublicDiscoveryPath(p) {const path = String(p || '');if (path === '/.well-known/openvibe') return true;++ if (path === '/api/v1/changelog') return true; // the network changelog every site's "shipped" widget readsif (CONTRACT_RE.test(path)) return true;if (path === '/api/v1/registry' || path === '/api/v1/registry/') return true;if (!path.startsWith('/api/v1/registry/')) return false;nnew file mode 100644@@@ -0,0 +1,148 @@++'use strict';++/**++ * What shipped, network-wide (roadmap WS-A):++ *++ * GET /api/v1/changelog?service=&limit=&before= cached proxy of OpenVibe.Blog's network changelog++ * (its public /api/v1/changelog), so every OpenVibe site's shared "shipped" widget reads it from++ * openvibe.network, which each site's CSP already allows. CORS *, 60 s cache, stale on failure.++ * GET /updates[?site=<id|host>] the whole network's update log: server-rendered++ * first page (useful without JavaScript), then openvibe-shared/shipped.js's log view (days,++ * "Load more", a filter per site, the Patch notes posts).++ */++const express = require('express');++++const TTL_MS = 60 * 1000;++const MAX_KEYS = 300;++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;++const CURSOR_RE = /^[A-Za-z0-9_-]{1,120}$/;++++function createUpdatesRoutes({ blogUrl = 'http://127.0.0.1:4810', fetchImpl = globalThis.fetch, now = () => Date.now(), log = console } = {}) {++ const base = String(blogUrl).replace(/\/+$/, '');++ const cache = new Map(); // key -> { at, body }++++ function queryOf(q) {++ const out = new URLSearchParams();++ if (q.service && SERVICE_RE.test(String(q.service))) out.set('service', String(q.service));++ const n = parseInt(q.limit, 10);++ out.set('limit', String(Number.isFinite(n) ? Math.min(Math.max(n, 1), 100) : 20));++ if (q.before && CURSOR_RE.test(String(q.before))) out.set('before', String(q.before));++ return out.toString();++ }++++ /** The feed for a normalised query string: fresh from cache, else Blog, else stale, else null. */++ async function feed(qs) {++ const hit = cache.get(qs);++ if (hit && now() - hit.at < TTL_MS) return hit.body;++ try {++ const res = await fetchImpl(`${base}/api/v1/changelog?${qs}`, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(5000) });++ if (!res.ok) throw new Error(`blog answered ${res.status}`);++ const body = await res.json();++ if (cache.size >= MAX_KEYS) cache.delete(cache.keys().next().value);++ cache.set(qs, { at: now(), body });++ return body;++ } catch (err) {++ if (log && log.warn) log.warn(`[updates] changelog unavailable: ${err.message}`);++ return hit ? hit.body : null;++ }++ }++++ const r = express.Router();++ r.get('/api/v1/changelog', async (req, res) => {++ res.set('Access-Control-Allow-Origin', '*');++ const body = await feed(queryOf(req.query));++ if (!body) return res.status(502).set('Cache-Control', 'no-store').json({ error: 'changelog_unavailable', detail: 'The network changelog could not be read just now.' });++ res.set('Cache-Control', 'public, max-age=60').json(body);++ });++ r.options('/api/v1/changelog', (_req, res) => res.set('Access-Control-Allow-Origin', '*').set('Access-Control-Allow-Methods', 'GET').set('Access-Control-Max-Age', '86400').status(204).end());++++ r.get('/updates', async (req, res) => {++ const asked = String(req.query.site || '').toLowerCase().trim();++ const site = serviceFor(asked);++ const body = await feed(queryOf({ service: site, limit: 50 }));++ res.set('Content-Type', 'text/html; charset=utf-8').set('Cache-Control', 'public, max-age=60');++ // The unfiltered log is the page; a per-site view is a filter of it (followed, not indexed).++ if (asked) res.set('X-Robots-Tag', 'noindex, follow');++ res.send(renderPage({ site, filtered: Boolean(asked), body }));++ });++ return { router: r, feed, queryOf };++}++++const SITES = ['live', 'network', 'tools', 'media', 'community', 'chat', 'games', 'blog', 'wiki', 'news', 'reviews', 'deals', 'coupons', 'trade', 'codes', 'host', 'ai', 'search', 'sources', 'events', 'billing', 'tips', 'vip', 'openre', 'sites'];++const NAMES = { ai: 'AI', vip: 'VIP', openre: 'OpenRe' };++const nameOf = (id) => NAMES[id] || (id ? id.charAt(0).toUpperCase() + id.slice(1) : '');++++/** A registry id from an id or a hostname (openvibe.wiki → wiki, pdf.openvibe.tools → tools). */++function serviceFor(v) {++ const s = String(v || '').toLowerCase().replace(/:\d+$/, '').replace(/^www\./, '');++ if (!s) return null;++ if (SITES.includes(s)) return s;++ if (/(^|\.)openre\.stream$/.test(s)) return 'openre';++ if (/(^|\.)openvibe\.tools$/.test(s)) return 'tools';++ if (/(^|\.)openvibe\.network$/.test(s)) { const sub = s.replace(/\.?openvibe\.network$/, ''); return SITES.includes(sub) ? sub : 'network'; }++ const m = /(?:^|\.)openvibe\.([a-z]+)$/.exec(s);++ return m && SITES.includes(m[1]) ? m[1] : null;++}++++function renderPage({ site, filtered, body }) {++ const entries = (body && Array.isArray(body.entries)) ? body.entries : [];++ const posts = (body && Array.isArray(body.posts)) ? body.posts.filter((p) => /^https:\/\//.test(p.url || '')) : [];++ const title = site ? `What shipped on OpenVibe.${nameOf(site)}` : 'What shipped across OpenVibe';++ const days = [];++ for (const e of entries) {++ const d = String(e.deployed_at || '').slice(0, 10);++ if (!days.length || days[days.length - 1].day !== d) days.push({ day: d, list: [] });++ days[days.length - 1].list.push(e);++ }++ const dayHtml = days.map((g) => `<section class="ov-shipped-day"><h3>${esc(g.day)}</h3><div class="ov-shipped-entries">${g.list.map((e) => `<div class="ov-shipped-entry">${/^https:\/\//.test(e.url || '') ? `<a class="ov-shipped-hash" href="${esc(e.url)}" rel="noopener">${esc(e.short || String(e.sha || '').slice(0, 7))}</a>` : ''}${site ? '' : `<span class="ov-shipped-site">${esc(nameOf(e.service))}</span>`}<span class="ov-shipped-text">${esc(e.subject)}</span><span class="ov-shipped-meta">${esc(e.author ? `${e.author} · ` : '')}${esc(String(e.deployed_at || '').slice(11, 16))} UTC</span></div>`).join('')}</div></section>`).join('\n');++ const postsHtml = posts.length ? `<aside class="ov-shipped-posts"><h3>Patch notes</h3><ul>${posts.map((p) => `<li><a href="${esc(p.url)}">${esc(p.title || 'Patch notes')}</a>${p.published_at ? ` <small>${esc(String(p.published_at).slice(0, 10))}</small>` : ''}</li>`).join('')}</ul></aside>` : '';++ const chips = `<nav class="up-sites" aria-label="Sites"><a href="/updates"${site ? '' : ' aria-current="page"'}>Everything</a>${SITES.filter((s) => s !== 'sites').map((s) => `<a href="/updates?site=${s}"${site === s ? ' aria-current="page"' : ''}>${esc(nameOf(s))}</a>`).join('')}</nav>`;++ return `<!DOCTYPE html>++<html lang="en">++<head>++<meta charset="utf-8">++<meta name="viewport" content="width=device-width, initial-scale=1">++<title>${esc(title)} · OpenVibe.Network</title>++<meta name="description" content="Every change deployed to the OpenVibe network, newest first: each site's commits as they ship, and the Patch notes posts that gather them.">++<link rel="canonical" href="https://openvibe.network/updates${filtered && site ? `?site=${esc(site)}` : ''}">++${filtered ? '<meta name="robots" content="noindex, follow">' : ''}++<link rel="alternate" type="application/json" href="/api/v1/changelog${site ? `?service=${esc(site)}` : ''}">++${require('openvibe-shared/app-icon').headTags({ site: 'network', iconBase: '/assets' })}++<meta name="color-scheme" content="dark light">++<script src="/shared/theme-loader.js" defer></script>++<style>++*,*::before,*::after{box-sizing:border-box}++:root{--bg-primary:#0a0f1c;--bg-secondary:#101828;--border:#1f2d47;--accent:#3b82f6;--text-primary:#e6edf7;--text-secondary:#96a7c2;--text-muted:#7386a3}++html,body{margin:0;background:var(--bg-primary);color:var(--text-primary);font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}++a{color:var(--accent)}++.up{max-width:960px;margin:0 auto;padding:24px 16px 40px}++.up h1{font-size:clamp(1.5rem,4vw,2rem);margin:.2em 0 .3em}.up .lede{color:var(--text-secondary);margin:0 0 18px;line-height:1.55}++.up-sites{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 18px}.up-sites a{font-size:.8rem;padding:4px 12px;border-radius:999px;border:1px solid var(--border);color:var(--text-secondary);text-decoration:none}++.up-sites a[aria-current]{background:var(--accent);border-color:var(--accent);color:#fff}++.ov-shipped-day h3{font-size:.95rem;color:var(--text-secondary);border-bottom:1px solid var(--border);padding-bottom:8px;margin:18px 0 4px}++.ov-shipped-entry{display:flex;gap:10px;align-items:baseline;padding:8px 12px;flex-wrap:wrap}.ov-shipped-text{flex:1;min-width:0;overflow-wrap:anywhere}++.ov-shipped-meta{font-size:.75rem;color:var(--text-secondary)}.ov-shipped-hash{font-family:ui-monospace,monospace;font-size:.8rem}++.ov-shipped-site{font-size:.72rem;font-weight:700;padding:.05rem .45rem;border-radius:999px;background:rgba(59,130,246,.16)}++</style>++</head>++<body>++<div id="navbar-mount"></div>++${require('openvibe-shared/chrome-ssr').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Updates', href: '/updates' }, { label: 'Status', href: '/status' }] })}++<main class="up" id="main">++<h1>${esc(title)}</h1>++<p class="lede">Every change deployed to OpenVibe, newest first. Each line is a commit from the OpenVibers repositories, linked to the change itself. When enough have gathered, or a large feature lands, they are written up as <a href="https://openvibe.blog/@openvibe">Patch notes on openvibe.blog</a>. JSON: <a href="/api/v1/changelog"><code>/api/v1/changelog</code></a>.</p>++${chips}++<div data-ov-shipped="log" data-service="${esc(site || '')}" data-limit="50">++${postsHtml}++${dayHtml || '<p class="ov-shipped-empty">The update history could not be loaded just now.</p>'}++</div>++</main>++${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact', updates: '/updates' })}++<script src="/shared/navbar.js" defer></script>++<script src="/shared/shipped.js" defer></script>++<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin});}catch(e){}});</script>++</body>++</html>`;++}++++module.exports = { createUpdatesRoutes, serviceFor, renderPage };nnew file mode 100644@@@ -0,0 +1,52 @@++'use strict';++// What shipped network-wide: /api/v1/changelog proxies OpenVibe.Blog's changelog (cached, stale on++// failure, public CORS), and /updates server-renders the log before shipped.js takes over.++// node test/updates.test.js++const assert = require('assert');++const http = require('http');++const express = require('express');++const { createUpdatesRoutes, serviceFor } = require('../server/updates/routes');++const publicCors = require('../server/public-cors');++++(async () => {++ let calls = [];++ let up = true;++ let t = 1000;++ const feed = { entries: [{ service: 'wiki', sha: 'a'.repeat(40), short: 'aaaaaaa', subject: 'Fix <script>x</script> in pages', author: 'OpenVibers', deployed_at: '2026-09-24T07:00:00.000Z', url: 'https://github.com/OpenVibers/OpenVibe.Wiki/commit/aaa' }], next: null, posts: [{ title: 'Patch notes: x', url: 'https://openvibe.blog/@openvibe/x', published_at: '2026-09-24T06:00:00.000Z' }], latest_post: null };++ const fetchImpl = async (url) => { calls.push(url); if (!up) throw new Error('down'); return { ok: true, json: async () => feed }; };++ const u = createUpdatesRoutes({ blogUrl: 'http://blog.test', fetchImpl, now: () => t, log: null });++ const app = express();++ app.use(u.router);++ const srv = http.createServer(app);++ await new Promise((r) => srv.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${srv.address().port}`;++++ let r = await fetch(`${base}/api/v1/changelog?service=wiki&limit=500&before=../../x`);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');++ assert.strictEqual(calls[0], 'http://blog.test/api/v1/changelog?service=wiki&limit=100', 'limit clamped, a bad cursor dropped');++ await fetch(`${base}/api/v1/changelog?service=wiki&limit=500`);++ assert.strictEqual(calls.length, 1, 'cached for 60 s');++ t += 61 * 1000; up = false;++ r = await fetch(`${base}/api/v1/changelog?service=wiki&limit=500`);++ assert.strictEqual(r.status, 200, 'stale copy while Blog is down');++ r = await fetch(`${base}/api/v1/changelog?service=news`);++ assert.strictEqual(r.status, 502, 'nothing cached and Blog down: an honest error');++ up = true;++++ const page = await (await fetch(`${base}/updates`)).text();++ assert.ok(page.includes('data-ov-shipped="log"') && page.includes('What shipped across OpenVibe'));++ assert.ok(page.includes('Fix <script>x</script> in pages') && !page.includes('<script>x</script>'), 'commit text escaped in the SSR log');++ assert.ok(page.includes('https://openvibe.blog/@openvibe/x'), 'patch notes listed');++ assert.ok(page.includes('<link rel="canonical" href="https://openvibe.network/updates">'));++ const one = await fetch(`${base}/updates?site=openvibe.wiki`);++ assert.strictEqual(one.headers.get('x-robots-tag'), 'noindex, follow');++ assert.ok((await one.text()).includes('What shipped on OpenVibe.Wiki'));++++ assert.strictEqual(serviceFor('pdf.openvibe.tools'), 'tools');++ assert.strictEqual(serviceFor('search.openvibe.network'), 'search');++ assert.strictEqual(serviceFor('evil.example'), null);++ assert.ok(publicCors.isPublicDiscoveryPath('/api/v1/changelog'), 'readable from any origin');++ srv.close();++ console.log('updates: all checks passed');++})().catch((e) => { console.error(e); process.exit(1); });
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.8.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.9.0",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
"web-push": "^3.6.7"
}
}
@@ -1490,9 +1490,9 @@
@@ -1490,9 +1490,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "1.8.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.8.0",
- "integrity": "sha512-4e8V/csFWSDJTgV+9sI5sqXYWwCslLhYUquAnQdmAalEb06h/EJQVZxHtmkvXyhcaPv4/Xi9/GemSzPhDLe2nw==",
+ "version": "1.9.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.9.0",
+ "integrity": "sha512-6OP9RKfTN8NGE/oa5mr+WFNx+Po8ORqwjhzY+Cfmo9KRkp5fNnX+OI/R+oBqcDEntNPMPQFtP81Mmj4hTgcu4g==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/chrome.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js",
+ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/chrome.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js",
"reset-db": "node server/reset-db.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.8.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.9.0",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
"web-push": "^3.6.7"
}
}
@@ -193,7 +193,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
@@ -193,7 +193,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
<script src="/shared/navbar.js"></script>
<script src="/shared/navbar.js"></script>
<script src="/shared/footer.js"></script>
<script src="/shared/footer.js"></script>
<script src="/shared/shipped.js" defer></script>
<script src="/shared/shipped.js" defer></script>
-<script>document.addEventListener('DOMContentLoaded', function () { if (window.OpenVibeShipped) OpenVibeShipped.mount('#ov-shipped', { limit: 8, title: 'Recently shipped across OpenVibe' }); });</script>
+<script>document.addEventListener('DOMContentLoaded', function () { if (window.OpenVibeShipped) OpenVibeShipped.mount('#ov-shipped', { limit: 8, title: 'Recently shipped across OpenVibe', more: '/updates' }); });</script>
<script>
<script>
(function () {
(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) { /* */ } }
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) { /* */ } }
@@ -395,6 +395,8 @@ app.use(ecosystem.router());
@@ -395,6 +395,8 @@ app.use(ecosystem.router());
ecosystem.start();
ecosystem.start();
// Operator status: GET /status (server-rendered, noindex), /api/v1/status, /api/v1/status/slo.
// Operator status: GET /status (server-rendered, noindex), /api/v1/status, /api/v1/status/slo.
app.use(require('./status/routes').createStatusRoutes({ ecosystem }));
app.use(require('./status/routes').createStatusRoutes({ ecosystem }));
+// What shipped network-wide: the changelog proxy every site's widget reads, and /updates.
+app.use(require('./updates/routes').createUpdatesRoutes({ blogUrl: process.env.OV_BLOG_INTERNAL_URL || 'http://127.0.0.1:4810' }).router);
app.get('/api/.well-known/jwks', (_req, res) => {
app.get('/api/.well-known/jwks', (_req, res) => {
const out = { public_key: publicKey, algorithm: privateKey === publicKey ? 'HS256' : 'RS256' };
const out = { public_key: publicKey, algorithm: privateKey === publicKey ? 'HS256' : 'RS256' };
@@ -25,6 +25,7 @@ const CONTRACT_RE = /^\/contracts\/[a-z0-9-]+(?:\/[a-z0-9-]+)?\/[a-z0-9_.-]+\.v\
@@ -25,6 +25,7 @@ const CONTRACT_RE = /^\/contracts\/[a-z0-9-]+(?:\/[a-z0-9-]+)?\/[a-z0-9_.-]+\.v\
function isPublicDiscoveryPath(p) {
function isPublicDiscoveryPath(p) {
const path = String(p || '');
const path = String(p || '');
if (path === '/.well-known/openvibe') return true;
if (path === '/.well-known/openvibe') return true;
+ if (path === '/api/v1/changelog') return true; // the network changelog every site's "shipped" widget reads
if (CONTRACT_RE.test(path)) return true;
if (CONTRACT_RE.test(path)) return true;
if (path === '/api/v1/registry' || path === '/api/v1/registry/') return true;
if (path === '/api/v1/registry' || path === '/api/v1/registry/') return true;
if (!path.startsWith('/api/v1/registry/')) return false;
if (!path.startsWith('/api/v1/registry/')) return false;
new file mode 100644
new file mode 100644
@@ -0,0 +1,148 @@
@@ -0,0 +1,148 @@
+'use strict';
+/**
+ * What shipped, network-wide (roadmap WS-A):
+ *
+ * GET /api/v1/changelog?service=&limit=&before= cached proxy of OpenVibe.Blog's network changelog
+ * (its public /api/v1/changelog), so every OpenVibe site's shared "shipped" widget reads it from
+ * openvibe.network, which each site's CSP already allows. CORS *, 60 s cache, stale on failure.
+ * GET /updates[?site=<id|host>] the whole network's update log: server-rendered
+ * first page (useful without JavaScript), then openvibe-shared/shipped.js's log view (days,
+ * "Load more", a filter per site, the Patch notes posts).
+ */
+const express = require('express');
+
+const TTL_MS = 60 * 1000;
+const MAX_KEYS = 300;
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;
+const CURSOR_RE = /^[A-Za-z0-9_-]{1,120}$/;
+
+function createUpdatesRoutes({ blogUrl = 'http://127.0.0.1:4810', fetchImpl = globalThis.fetch, now = () => Date.now(), log = console } = {}) {
+ const base = String(blogUrl).replace(/\/+$/, '');
+ const cache = new Map(); // key -> { at, body }
+
+ function queryOf(q) {
+ const out = new URLSearchParams();
+ if (q.service && SERVICE_RE.test(String(q.service))) out.set('service', String(q.service));
+ const n = parseInt(q.limit, 10);
+ out.set('limit', String(Number.isFinite(n) ? Math.min(Math.max(n, 1), 100) : 20));
+ if (q.before && CURSOR_RE.test(String(q.before))) out.set('before', String(q.before));
+ return out.toString();
+ }
+
+ /** The feed for a normalised query string: fresh from cache, else Blog, else stale, else null. */
+ async function feed(qs) {
+ const hit = cache.get(qs);
+ if (hit && now() - hit.at < TTL_MS) return hit.body;
+ try {
+ const res = await fetchImpl(`${base}/api/v1/changelog?${qs}`, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(5000) });
+ if (!res.ok) throw new Error(`blog answered ${res.status}`);
+ const body = await res.json();
+ if (cache.size >= MAX_KEYS) cache.delete(cache.keys().next().value);
+ cache.set(qs, { at: now(), body });
+ return body;
+ } catch (err) {
+ if (log && log.warn) log.warn(`[updates] changelog unavailable: ${err.message}`);
+ return hit ? hit.body : null;
+ }
+ }
+
+ const r = express.Router();
+ r.get('/api/v1/changelog', async (req, res) => {
+ res.set('Access-Control-Allow-Origin', '*');
+ const body = await feed(queryOf(req.query));
+ if (!body) return res.status(502).set('Cache-Control', 'no-store').json({ error: 'changelog_unavailable', detail: 'The network changelog could not be read just now.' });
+ res.set('Cache-Control', 'public, max-age=60').json(body);
+ });
+ r.options('/api/v1/changelog', (_req, res) => res.set('Access-Control-Allow-Origin', '*').set('Access-Control-Allow-Methods', 'GET').set('Access-Control-Max-Age', '86400').status(204).end());
+
+ r.get('/updates', async (req, res) => {
+ const asked = String(req.query.site || '').toLowerCase().trim();
+ const site = serviceFor(asked);
+ const body = await feed(queryOf({ service: site, limit: 50 }));
+ res.set('Content-Type', 'text/html; charset=utf-8').set('Cache-Control', 'public, max-age=60');
+ // The unfiltered log is the page; a per-site view is a filter of it (followed, not indexed).
+ if (asked) res.set('X-Robots-Tag', 'noindex, follow');
+ res.send(renderPage({ site, filtered: Boolean(asked), body }));
+ });
+ return { router: r, feed, queryOf };
+}
+
+const SITES = ['live', 'network', 'tools', 'media', 'community', 'chat', 'games', 'blog', 'wiki', 'news', 'reviews', 'deals', 'coupons', 'trade', 'codes', 'host', 'ai', 'search', 'sources', 'events', 'billing', 'tips', 'vip', 'openre', 'sites'];
+const NAMES = { ai: 'AI', vip: 'VIP', openre: 'OpenRe' };
+const nameOf = (id) => NAMES[id] || (id ? id.charAt(0).toUpperCase() + id.slice(1) : '');
+
+/** A registry id from an id or a hostname (openvibe.wiki → wiki, pdf.openvibe.tools → tools). */
+function serviceFor(v) {
+ const s = String(v || '').toLowerCase().replace(/:\d+$/, '').replace(/^www\./, '');
+ if (!s) return null;
+ if (SITES.includes(s)) return s;
+ if (/(^|\.)openre\.stream$/.test(s)) return 'openre';
+ if (/(^|\.)openvibe\.tools$/.test(s)) return 'tools';
+ if (/(^|\.)openvibe\.network$/.test(s)) { const sub = s.replace(/\.?openvibe\.network$/, ''); return SITES.includes(sub) ? sub : 'network'; }
+ const m = /(?:^|\.)openvibe\.([a-z]+)$/.exec(s);
+ return m && SITES.includes(m[1]) ? m[1] : null;
+}
+
+function renderPage({ site, filtered, body }) {
+ const entries = (body && Array.isArray(body.entries)) ? body.entries : [];
+ const posts = (body && Array.isArray(body.posts)) ? body.posts.filter((p) => /^https:\/\//.test(p.url || '')) : [];
+ const title = site ? `What shipped on OpenVibe.${nameOf(site)}` : 'What shipped across OpenVibe';
+ const days = [];
+ for (const e of entries) {
+ const d = String(e.deployed_at || '').slice(0, 10);
+ if (!days.length || days[days.length - 1].day !== d) days.push({ day: d, list: [] });
+ days[days.length - 1].list.push(e);
+ }
+ const dayHtml = days.map((g) => `<section class="ov-shipped-day"><h3>${esc(g.day)}</h3><div class="ov-shipped-entries">${g.list.map((e) => `<div class="ov-shipped-entry">${/^https:\/\//.test(e.url || '') ? `<a class="ov-shipped-hash" href="${esc(e.url)}" rel="noopener">${esc(e.short || String(e.sha || '').slice(0, 7))}</a>` : ''}${site ? '' : `<span class="ov-shipped-site">${esc(nameOf(e.service))}</span>`}<span class="ov-shipped-text">${esc(e.subject)}</span><span class="ov-shipped-meta">${esc(e.author ? `${e.author} · ` : '')}${esc(String(e.deployed_at || '').slice(11, 16))} UTC</span></div>`).join('')}</div></section>`).join('\n');
+ const postsHtml = posts.length ? `<aside class="ov-shipped-posts"><h3>Patch notes</h3><ul>${posts.map((p) => `<li><a href="${esc(p.url)}">${esc(p.title || 'Patch notes')}</a>${p.published_at ? ` <small>${esc(String(p.published_at).slice(0, 10))}</small>` : ''}</li>`).join('')}</ul></aside>` : '';
+ const chips = `<nav class="up-sites" aria-label="Sites"><a href="/updates"${site ? '' : ' aria-current="page"'}>Everything</a>${SITES.filter((s) => s !== 'sites').map((s) => `<a href="/updates?site=${s}"${site === s ? ' aria-current="page"' : ''}>${esc(nameOf(s))}</a>`).join('')}</nav>`;
+ return `<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>${esc(title)} · OpenVibe.Network</title>
+<meta name="description" content="Every change deployed to the OpenVibe network, newest first: each site's commits as they ship, and the Patch notes posts that gather them.">
+<link rel="canonical" href="https://openvibe.network/updates${filtered && site ? `?site=${esc(site)}` : ''}">
+${filtered ? '<meta name="robots" content="noindex, follow">' : ''}
+<link rel="alternate" type="application/json" href="/api/v1/changelog${site ? `?service=${esc(site)}` : ''}">
+${require('openvibe-shared/app-icon').headTags({ site: 'network', iconBase: '/assets' })}
+<meta name="color-scheme" content="dark light">
+<script src="/shared/theme-loader.js" defer></script>
+<style>
+*,*::before,*::after{box-sizing:border-box}
+:root{--bg-primary:#0a0f1c;--bg-secondary:#101828;--border:#1f2d47;--accent:#3b82f6;--text-primary:#e6edf7;--text-secondary:#96a7c2;--text-muted:#7386a3}
+html,body{margin:0;background:var(--bg-primary);color:var(--text-primary);font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
+a{color:var(--accent)}
+.up{max-width:960px;margin:0 auto;padding:24px 16px 40px}
+.up h1{font-size:clamp(1.5rem,4vw,2rem);margin:.2em 0 .3em}.up .lede{color:var(--text-secondary);margin:0 0 18px;line-height:1.55}
+.up-sites{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 18px}.up-sites a{font-size:.8rem;padding:4px 12px;border-radius:999px;border:1px solid var(--border);color:var(--text-secondary);text-decoration:none}
+.up-sites a[aria-current]{background:var(--accent);border-color:var(--accent);color:#fff}
+.ov-shipped-day h3{font-size:.95rem;color:var(--text-secondary);border-bottom:1px solid var(--border);padding-bottom:8px;margin:18px 0 4px}
+.ov-shipped-entry{display:flex;gap:10px;align-items:baseline;padding:8px 12px;flex-wrap:wrap}.ov-shipped-text{flex:1;min-width:0;overflow-wrap:anywhere}
+.ov-shipped-meta{font-size:.75rem;color:var(--text-secondary)}.ov-shipped-hash{font-family:ui-monospace,monospace;font-size:.8rem}
+.ov-shipped-site{font-size:.72rem;font-weight:700;padding:.05rem .45rem;border-radius:999px;background:rgba(59,130,246,.16)}
+</style>
+</head>
+<body>
+<div id="navbar-mount"></div>
+${require('openvibe-shared/chrome-ssr').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Updates', href: '/updates' }, { label: 'Status', href: '/status' }] })}
+<main class="up" id="main">
+<h1>${esc(title)}</h1>
+<p class="lede">Every change deployed to OpenVibe, newest first. Each line is a commit from the OpenVibers repositories, linked to the change itself. When enough have gathered, or a large feature lands, they are written up as <a href="https://openvibe.blog/@openvibe">Patch notes on openvibe.blog</a>. JSON: <a href="/api/v1/changelog"><code>/api/v1/changelog</code></a>.</p>
+${chips}
+<div data-ov-shipped="log" data-service="${esc(site || '')}" data-limit="50">
+${postsHtml}
+${dayHtml || '<p class="ov-shipped-empty">The update history could not be loaded just now.</p>'}
+</div>
+</main>
+${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact', updates: '/updates' })}
+<script src="/shared/navbar.js" defer></script>
+<script src="/shared/shipped.js" defer></script>
+<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin});}catch(e){}});</script>
+</body>
+</html>`;
+}
+
+module.exports = { createUpdatesRoutes, serviceFor, renderPage };
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
+'use strict';
+// What shipped network-wide: /api/v1/changelog proxies OpenVibe.Blog's changelog (cached, stale on
+// failure, public CORS), and /updates server-renders the log before shipped.js takes over.
+// node test/updates.test.js
+const assert = require('assert');
+const http = require('http');
+const express = require('express');
+const { createUpdatesRoutes, serviceFor } = require('../server/updates/routes');
+const publicCors = require('../server/public-cors');
+
+(async () => {
+ let calls = [];
+ let up = true;
+ let t = 1000;
+ const feed = { entries: [{ service: 'wiki', sha: 'a'.repeat(40), short: 'aaaaaaa', subject: 'Fix <script>x</script> in pages', author: 'OpenVibers', deployed_at: '2026-09-24T07:00:00.000Z', url: 'https://github.com/OpenVibers/OpenVibe.Wiki/commit/aaa' }], next: null, posts: [{ title: 'Patch notes: x', url: 'https://openvibe.blog/@openvibe/x', published_at: '2026-09-24T06:00:00.000Z' }], latest_post: null };
+ const fetchImpl = async (url) => { calls.push(url); if (!up) throw new Error('down'); return { ok: true, json: async () => feed }; };
+ const u = createUpdatesRoutes({ blogUrl: 'http://blog.test', fetchImpl, now: () => t, log: null });
+ const app = express();
+ app.use(u.router);
+ const srv = http.createServer(app);
+ await new Promise((r) => srv.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${srv.address().port}`;
+
+ let r = await fetch(`${base}/api/v1/changelog?service=wiki&limit=500&before=../../x`);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');
+ assert.strictEqual(calls[0], 'http://blog.test/api/v1/changelog?service=wiki&limit=100', 'limit clamped, a bad cursor dropped');
+ await fetch(`${base}/api/v1/changelog?service=wiki&limit=500`);
+ assert.strictEqual(calls.length, 1, 'cached for 60 s');
+ t += 61 * 1000; up = false;
+ r = await fetch(`${base}/api/v1/changelog?service=wiki&limit=500`);
+ assert.strictEqual(r.status, 200, 'stale copy while Blog is down');
+ r = await fetch(`${base}/api/v1/changelog?service=news`);
+ assert.strictEqual(r.status, 502, 'nothing cached and Blog down: an honest error');
+ up = true;
+
+ const page = await (await fetch(`${base}/updates`)).text();
+ assert.ok(page.includes('data-ov-shipped="log"') && page.includes('What shipped across OpenVibe'));
+ assert.ok(page.includes('Fix <script>x</script> in pages') && !page.includes('<script>x</script>'), 'commit text escaped in the SSR log');
+ assert.ok(page.includes('https://openvibe.blog/@openvibe/x'), 'patch notes listed');
+ assert.ok(page.includes('<link rel="canonical" href="https://openvibe.network/updates">'));
+ const one = await fetch(`${base}/updates?site=openvibe.wiki`);
+ assert.strictEqual(one.headers.get('x-robots-tag'), 'noindex, follow');
+ assert.ok((await one.text()).includes('What shipped on OpenVibe.Wiki'));
+
+ assert.strictEqual(serviceFor('pdf.openvibe.tools'), 'tools');
+ assert.strictEqual(serviceFor('search.openvibe.network'), 'search');
+ assert.strictEqual(serviceFor('evil.example'), null);
+ assert.ok(publicCors.isPublicDiscoveryPath('/api/v1/changelog'), 'readable from any origin');
+ srv.close();
+ console.log('updates: all checks passed');
+})().catch((e) => { console.error(e); process.exit(1); });