openvibers/openvibe.network Commits
Changelog proxy: carry latest_devblog for the What's new card (#737)
Changes
@@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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());@@@ -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.jsconst 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'));
@@ -9,6 +9,9 @@ const cache = require('openvibe-shared/cache-policy');
@@ -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
* 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
* (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.
* 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
* 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,
* first page (useful without JavaScript), then openvibe-shared/shipped.js's log view (days,
* "Load more", a filter per site, the Patch notes posts).
* "Load more", a filter per site, the Patch notes posts).
@@ -22,43 +25,87 @@ const MAX_KEYS = 300;
@@ -22,43 +25,87 @@ const MAX_KEYS = 300;
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;
const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;
const CURSOR_RE = /^[A-Za-z0-9_-]{1,120}$/;
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 } = {}) {
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 base = String(blogUrl).replace(/\/+$/, '');
const feedCache = new Map(); // key -> { at, body }
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) {
function queryOf(q) {
const out = new URLSearchParams();
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);
const n = parseInt(q.limit, 10);
out.set('limit', String(Number.isFinite(n) ? Math.min(Math.max(n, 1), 100) : 20));
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));
if (q.before && CURSOR_RE.test(String(q.before))) out.set('before', String(q.before));
return out.toString();
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;
if (hit && now() - hit.at < TTL_MS) return hit.body;
try {
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}`);
if (!res.ok) throw new Error(`blog answered ${res.status}`);
const body = await res.json();
const body = await res.json();
if (feedCache.size >= MAX_KEYS) feedCache.delete(feedCache.keys().next().value);
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;
return body;
} catch (err) {
} 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();
const r = express.Router();
r.get('/api/v1/changelog', async (req, res) => {
r.get('/api/v1/changelog', async (req, res) => {
res.set('Access-Control-Allow-Origin', '*');
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.' });
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());
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());
@@ -1,6 +1,7 @@
@@ -1,6 +1,7 @@
'use strict';
'use strict';
// What shipped network-wide: /api/v1/changelog proxies OpenVibe.Blog's changelog (cached, stale on
// 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
// node test/updates.test.js
const assert = require('assert');
const assert = require('assert');
const http = require('http');
const http = require('http');
@@ -11,9 +12,16 @@ const publicCors = require('../server/public-cors');
@@ -11,9 +12,16 @@ const publicCors = require('../server/public-cors');
(async () => {
(async () => {
let calls = [];
let calls = [];
let up = true;
let up = true;
+ let devblogsUp = true;
let t = 1000;
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 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 u = createUpdatesRoutes({ blogUrl: 'http://blog.test', fetchImpl, now: () => t, log: null });
const app = express();
const app = express();
app.use(u.router);
app.use(u.router);
@@ -25,14 +33,36 @@ const publicCors = require('../server/public-cors');
@@ -25,14 +33,36 @@ const publicCors = require('../server/public-cors');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');
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[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`);
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;
t += 61 * 1000; up = false;
r = await fetch(`${base}/api/v1/changelog?service=wiki&limit=500`);
r = await fetch(`${base}/api/v1/changelog?service=wiki&limit=500`);
assert.strictEqual(r.status, 200, 'stale copy while Blog is down');
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`);
r = await fetch(`${base}/api/v1/changelog?service=news`);
assert.strictEqual(r.status, 502, 'nothing cached and Blog down: an honest error');
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();
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('data-ov-shipped="log"') && page.includes('What shipped across OpenVibe'));