openvibers/openvibe.news Commits

Serve News cache headers through openvibe-shared cache-policy (#2)

OP OpenVibers committed 2 Oct 2026 · e52c812f65 · 7 files, +47 −11

checks run by GitHub CI (test, test-pg) · CI ✓ 3/3 · review SHIP by DeepSeek harness

https://github.com/OpenVibers/OpenVibe.News/pull/2

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 69f0086..aa72305 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -19,6 +19,7 @@ const { createSsoClient } = require('openvibe-sdk/sso');
const { createServiceOutbox } = require('openvibe-sdk/events');
const { createSourcesClient } = require('openvibe-publishing/ingest');
const { createIndexNow } = require('openvibe-shared/indexnow');
++const cache = require('openvibe-shared/cache-policy');
const configLib = require('./config');
const { openStore } = require('./db');
@@@ -164,7 +165,7 @@ async function createApp(opts = {}) {
setHeaders(res, filePath) {
const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
const v = res.req && res.req.query && res.req.query.v;
-- res.setHeader('Cache-Control', v && v === assetVersion(rel) ? 'public, max-age=31536000, immutable' : 'public, max-age=300');
++ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === assetVersion(rel) }));
},
}));
@@@ -184,7 +185,7 @@ async function createApp(opts = {}) {
app.use((err, req, res, _next) => {
log.error('[News]', err && err.stack ? err.stack : err);
if (res.headersSent) return;
-- res.set('Cache-Control', 'private, no-store');
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');
});
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 9f36b0e..a304bca 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -16,6 +16,7 @@
const express = require('express');
const seo = require('openvibe-publishing/seo');
const sharedSeo = require('openvibe-shared/seo');
++const cache = require('openvibe-shared/cache-policy');
function createDiscoveryRoutes({ config, store, publication, topics }) {
const router = express.Router();
@@@ -31,7 +32,7 @@ function createDiscoveryRoutes({ config, store, publication, topics }) {
return out;
}
-- const xml = (res, body) => res.type('application/xml').set('Cache-Control', 'public, max-age=300').send(body);
++ const xml = (res, body) => res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(body);
router.get('/robots.txt', (_req, res) => {
const body = [
@@@ -42,11 +43,11 @@ function createDiscoveryRoutes({ config, store, publication, topics }) {
'# X-Robots-Tag): retracted stories stay readable but are noindex. A Disallow is not a noindex.',
sharedSeo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: ['/edit', '/clusters/', '/auth/', '/api/', '/internal/'] }),
].join('\n');
-- res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(body);
++ res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(body);
});
router.get('/llms.txt', (_req, res) => {
-- res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(sharedSeo.llmsTxt({
++ res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(sharedSeo.llmsTxt({
name: 'OpenVibe.News',
summary: 'A source-backed publication: stories written by OpenVibe.News editors from source items collected by OpenVibe.Sources. Every paragraph cites the sources it rests on.',
details: 'OpenVibe.News is not an automatic headline generator. Each story page lists its sources (headline, outlet, author and date as the source published them, with links), a timeline, editor-assigned perspective groupings and its full correction history. Full article text from sources is never republished; at most a short summary where the source’s terms allow it. AI-assisted drafts are never published or indexed before a person reviews them. Retracted stories stay at their URL with a retraction notice and are noindex. Append .json to any story URL for the same content as data, including every claim’s source numbers and the Sources item ids.',
ddiff --git a/server/http/editor.js b/server/http/editor.js
iindex ca2ff84..f7a633e 100644
---- a/server/http/editor.js
++++ b/server/http/editor.js
@@@ -22,6 +22,7 @@ const editor = require('../render/editor');
const { csrfToken, checkCsrf } = require('../auth/forms');
const { asApiError } = require('./errors');
const access = require('../domain/access');
++const cache = require('openvibe-shared/cache-policy');
/** Checkbox + hidden fallback arrive as ['1','0']; a repeated text field keeps its last value. */
const one = (v) => (Array.isArray(v) ? (v.includes('1') ? '1' : v[v.length - 1]) : v);
@@@ -31,7 +32,7 @@ function createEditorRoutes(ctx) {
const router = express.Router();
const form = express.urlencoded({ extended: false, limit: '300kb' });
router.use(['/edit', '/clusters'], viewers.middleware({ services: false }), (req, res, next) => {
-- res.set('Cache-Control', 'private, no-store');
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.set('X-Robots-Tag', 'noindex, nofollow');
res.vary('Cookie');
if (req.viewer.kind !== 'user') {
ddiff --git a/server/http/errors.js b/server/http/errors.js
iindex d46b8d3..5f687f0 100644
---- a/server/http/errors.js
++++ b/server/http/errors.js
@@@ -6,6 +6,7 @@
*/
const express = require('express');
const contracts = require('openvibe-contracts');
++const cache = require('openvibe-shared/cache-policy');
/** A refusal with a stable problem code (e.g. 404 'post.not_found'). */
class ApiError extends Error {
@@@ -56,7 +57,7 @@ function jsonBody(req, res, next) {
/** Private, per-viewer responses: never stored by a shared cache, never indexed. */
function privateNoStore(res) {
-- res.set('Cache-Control', 'private, no-store');
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.vary('Cookie');
res.vary('Authorization');
}
ddiff --git a/server/http/public.js b/server/http/public.js
iindex 79a7e1a..932dbad 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -27,6 +27,7 @@ const { renderPage } = require('../render/layout');
const pages = require('../render/pages');
const access = require('../domain/access');
const { csrfToken, checkCsrf } = require('../auth/forms');
++const cache = require('openvibe-shared/cache-policy');
const PER_PAGE = 20;
@@@ -38,8 +39,8 @@ function createPublicRoutes(ctx) {
function cacheHeaders(res, { cacheable, robots }) {
res.vary('Cookie');
res.vary('Authorization');
-- if (cacheable) res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=60');
-- else res.set('Cache-Control', 'private, no-store');
++ if (cacheable) res.set('Cache-Control', cache.htmlHeaders({ maxAge: 60 }));
++ else res.set('Cache-Control', cache.htmlHeaders({ private: true }));
if (robots && robots !== 'index, follow') res.set('X-Robots-Tag', robots);
}
@@@ -117,7 +118,7 @@ function createPublicRoutes(ctx) {
const description = topic ? (topic.description || `Stories on ${topic.name}`) : 'Source-backed stories from OpenVibe.News';
const file = FEEDS.find((f) => f.type === kind).file;
const feedUrl = publication.abs(`${base === '/' ? '' : base}/${file}`);
-- res.set('Cache-Control', 'public, max-age=300');
++ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 300 }));
res.vary('Accept-Encoding');
if (kind === 'rss') return res.type('application/rss+xml').send(seo.rssFeed({ title, link, description, feedUrl, language: 'en' }, items));
if (kind === 'json') return res.type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title, link, feedUrl, description, language: 'en' }, items)));
ddiff --git a/test/asset-cache.test.js b/test/asset-cache.test.js
nnew file mode 100644
iindex 0000000..acdf81d
---- /dev/null
++++ b/test/asset-cache.test.js
@@@ -0,0 +1,31 @@
++'use strict';
++/**
++ * Static assets take their Cache-Control from openvibe-shared/cache-policy (plan T11): only the exact
++ * content-hashed ?v= the page links (assetVersion) is immutable for a year; a wrong or missing ?v= is
++ * the short public window with stale-while-revalidate the module hands out — a hex-looking value the
++ * render never produced must never be pinned as immutable.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const cssVersion = require('../server/render/layout').assetVersion('css/news.css');
++
++ await check('the current ?v=<assetVersion> is immutable for a year', async () => {
++ const r = await t.get(`/css/news.css?v=${cssVersion}`);
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=31536000, immutable');
++ });
++
++ await check('a wrong-but-hex ?v= and no ?v= get the short public window, never immutable', async () => {
++ for (const path of ['/css/news.css?v=deadbeefdeadbeef', '/css/news.css']) {
++ const r = await t.get(path);
++ assert.strictEqual(r.status, 200, `${path} → ${r.status}`);
++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300, stale-while-revalidate=86400', path);
++ }
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/stories.test.js b/test/stories.test.js
iindex ce764ef..79dba65 100644
---- a/test/stories.test.js
++++ b/test/stories.test.js
@@@ -100,7 +100,7 @@ const BODY = [
assert.match(page.text, /Wire A/);
assert.match(page.text, /Ana Reporter/);
assert.match(page.text, /<meta name="robots" content="index, follow">/);
-- assert.strictEqual(page.headers.get('cache-control'), 'public, max-age=60, stale-while-revalidate=60');
++ assert.strictEqual(page.headers.get('cache-control'), 'public, max-age=60, stale-while-revalidate=3600');
assert.match(page.text, /"@type":"NewsArticle"/);
assert.match(page.text, /Mission announcements/);
assert.match(page.text, /2026-09-22<\/time> — Europa Clipper lifts off/);