openvibers/openvibe.network Commits

Changelog proxy: carry latest_devblog for the What's new card (#737)

OP OpenVibers committed 4 days ago · 65f31c13c0 · 2 files, +91 −14

Changes

ddiff --git a/server/updates/routes.js b/server/updates/routes.js
iindex 425e9093..78f212de 100644
---- a/server/updates/routes.js
++++ b/server/updates/routes.js
@@@ -9,6 +9,9 @@ const cache = require('openvibe-shared/cache-policy');
* 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.
++ * It also carries latest_devblog: the newest devblog for the requested service (network-wide
++ * without one) as the "What's new" card's lead — null when Blog cannot answer, never failing the
++ * changelog itself.
* 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).
@@@ -22,43 +25,87 @@ const MAX_KEYS = 300;
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;
const CURSOR_RE = /^[A-Za-z0-9_-]{1,120}$/;
++const text = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null);
++const https = (v) => (typeof v === 'string' && /^https:\/\//.test(v) ? v : null);
++
++/** Blog's devblog item → the "What's new" card's lead: text capped, only https URLs. Blog sends the figure as
++ * its art ({ mediaId, url }), or already as a URL. */
++function devblogLead(post) {
++ if (!post || typeof post !== 'object') return null;
++ const figure = post.figure && typeof post.figure === 'object' ? post.figure.url : post.figure;
++ return {
++ title: text(post.title, 200),
++ summary: text(post.summary, 400),
++ figure: https(figure),
++ url: https(post.url),
++ published_at: typeof post.published_at === 'string' && post.published_at ? post.published_at : null,
++ };
++}
function createUpdatesRoutes({ blogUrl = 'http://127.0.0.1:4810', release = null, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console } = {}) {
const base = String(blogUrl).replace(/\/+$/, '');
const feedCache = new Map(); // key -> { at, body }
++ function serviceOf(q) {
++ return q && q.service && SERVICE_RE.test(String(q.service)) ? String(q.service) : null;
++ }
++
function queryOf(q) {
const out = new URLSearchParams();
-- if (q.service && SERVICE_RE.test(String(q.service))) out.set('service', String(q.service));
++ const service = serviceOf(q);
++ if (service) out.set('service', 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 = feedCache.get(qs);
++ /**
++ * One Blog call, cached for TTL_MS like the changelog. A failure never rejects: the changelog falls back
++ * to its stale copy (its widget keeps the list), while the devblog lead is null — a card, not the log, so
++ * sites never show a stale "What's new".
++ */
++ async function readBlog(key, path, { label, staleOnFailure }) {
++ const hit = feedCache.get(key);
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) });
++ const res = await fetchImpl(`${base}${path}`, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(5000) });
if (!res.ok) throw new Error(`blog answered ${res.status}`);
const body = await res.json();
if (feedCache.size >= MAX_KEYS) feedCache.delete(feedCache.keys().next().value);
-- feedCache.set(qs, { at: now(), body });
++ feedCache.set(key, { at: now(), body });
return body;
} catch (err) {
-- if (log && log.warn) log.warn(`[updates] changelog unavailable: ${err.message}`);
-- return hit ? hit.body : null;
++ if (log && log.warn) log.warn(`[updates] ${label} unavailable: ${err.message}`);
++ return staleOnFailure && hit ? hit.body : null;
++ }
++ }
++
++ /** The feed for a normalised query string: fresh from cache, else Blog, else stale, else null. */
++ function feed(qs) {
++ return readBlog(qs, `/api/v1/changelog?${qs}`, { label: 'changelog', staleOnFailure: true });
++ }
++
++ /** The newest published devblog for a service (network-wide without one): null when Blog cannot answer. */
++ async function latestDevblog(service) {
++ try {
++ const q = new URLSearchParams();
++ if (service) q.set('service', service);
++ q.set('limit', '1');
++ const body = await readBlog(`devblogs:${service || ''}`, `/api/v1/devblogs?${q}`, { label: 'devblog list', staleOnFailure: false });
++ return devblogLead(body && Array.isArray(body.posts) ? body.posts[0] : null);
++ } catch (err) {
++ if (log && log.warn) log.warn(`[updates] devblog list unavailable: ${err.message}`);
++ return 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));
++ const [body, latest] = await Promise.all([feed(queryOf(req.query)), latestDevblog(serviceOf(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);
++ res.set('Cache-Control', 'public, max-age=60').json({ ...body, latest_devblog: latest });
});
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());
ddiff --git a/test/updates.test.js b/test/updates.test.js
iindex c13fc515..88ad9e87 100644
---- a/test/updates.test.js
++++ b/test/updates.test.js
@@@ -1,6 +1,7 @@
'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.
++// failure, public CORS) plus the newest devblog for the card's lead, and /updates server-renders the
++// log before shipped.js takes over.
// node test/updates.test.js
const assert = require('assert');
const http = require('http');
@@@ -11,9 +12,16 @@ const publicCors = require('../server/public-cors');
(async () => {
let calls = [];
let up = true;
++ let devblogsUp = 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 }; };
++ let devblogs = { total: 1, posts: [{ id: 'p1', title: 'What shipped on OpenVibe.Wiki', summary: 'A week of pages.', figure: { mediaId: 'm1', url: 'https://openvibe.media/o/m1' }, url: 'https://openvibe.blog/@openvibe/what-shipped-wiki', blog: 'openvibe', published_at: '2026-09-24T06:00:00.000Z' }] };
++ const fetchImpl = async (url) => {
++ calls.push(url);
++ if (!up) throw new Error('down');
++ if (url.includes('/api/v1/devblogs')) return devblogsUp ? { ok: true, json: async () => devblogs } : { ok: false, status: 404, json: async () => ({}) };
++ 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);
@@@ -25,14 +33,36 @@ const publicCors = require('../server/public-cors');
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');
++ assert.strictEqual(calls[1], 'http://blog.test/api/v1/devblogs?service=wiki&limit=1', 'the lead is the newest devblog for the requested service');
++ let body = await r.json();
++ assert.deepStrictEqual(body.latest_devblog, {
++ title: 'What shipped on OpenVibe.Wiki', summary: 'A week of pages.',
++ figure: 'https://openvibe.media/o/m1', url: 'https://openvibe.blog/@openvibe/what-shipped-wiki', published_at: '2026-09-24T06:00:00.000Z',
++ }, 'the devblog as the card lead, its art as an https URL');
await fetch(`${base}/api/v1/changelog?service=wiki&limit=500`);
-- assert.strictEqual(calls.length, 1, 'cached for 60 s');
++ assert.strictEqual(calls.length, 2, 'changelog and devblog cached for 60 s');
++ t += 61 * 1000;
++ devblogs = { total: 1, posts: [{ id: 'p2', title: 'T'.repeat(260), summary: 'S'.repeat(500), figure: 'http://insecure.test/a.png', url: 'https://openvibe.blog/@openvibe/second', published_at: '2026-09-25T06:00:00.000Z' }] };
++ body = await (await fetch(`${base}/api/v1/changelog?service=wiki`)).json();
++ assert.strictEqual(body.latest_devblog.figure, null, 'a non-https figure is dropped');
++ assert.strictEqual(body.latest_devblog.title.length, 200, 'the title is capped');
++ assert.strictEqual(body.latest_devblog.summary.length, 400, 'the summary is capped');
++ assert.strictEqual(body.latest_devblog.url, 'https://openvibe.blog/@openvibe/second');
++ body = await (await fetch(`${base}/api/v1/changelog`)).json();
++ assert.ok(calls.includes('http://blog.test/api/v1/devblogs?limit=1'), 'no service: the network-wide lead');
++ assert.strictEqual(body.latest_devblog.title.length, 200);
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');
++ assert.strictEqual((await r.json()).latest_devblog, null, 'a devblog failure is null, never a stale card');
r = await fetch(`${base}/api/v1/changelog?service=news`);
assert.strictEqual(r.status, 502, 'nothing cached and Blog down: an honest error');
-- up = true;
++ up = true; devblogsUp = false; t += 61 * 1000;
++ r = await fetch(`${base}/api/v1/changelog?service=wiki`);
++ assert.strictEqual(r.status, 200, 'a Blog without the devblog endpoint still answers the changelog');
++ assert.strictEqual((await r.json()).latest_devblog, null, 'the field is null until the endpoint ships');
++ devblogsUp = true; t += 61 * 1000;
++ assert.strictEqual((await (await fetch(`${base}/api/v1/changelog?service=wiki`)).json()).latest_devblog.title, 'T'.repeat(200), 'the field returns when Blog does');
const page = await (await fetch(`${base}/updates`)).text();
assert.ok(page.includes('data-ov-shipped="log"') && page.includes('What shipped across OpenVibe'));