openvibers/openvibe.news Commits
Serve News cache headers through openvibe-shared cache-policy (#2)
Changes
@@@ -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.');});@@@ -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.',@@@ -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') {@@@ -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');}@@@ -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)));nnew file mode 100644@@@ -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();++})();@@@ -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/);
@@ -19,6 +19,7 @@ const { createSsoClient } = require('openvibe-sdk/sso');
@@ -19,6 +19,7 @@ const { createSsoClient } = require('openvibe-sdk/sso');
const { createServiceOutbox } = require('openvibe-sdk/events');
const { createServiceOutbox } = require('openvibe-sdk/events');
const { createSourcesClient } = require('openvibe-publishing/ingest');
const { createSourcesClient } = require('openvibe-publishing/ingest');
const { createIndexNow } = require('openvibe-shared/indexnow');
const { createIndexNow } = require('openvibe-shared/indexnow');
+const cache = require('openvibe-shared/cache-policy');
const configLib = require('./config');
const configLib = require('./config');
const { openStore } = require('./db');
const { openStore } = require('./db');
@@ -164,7 +165,7 @@ async function createApp(opts = {}) {
@@ -164,7 +165,7 @@ async function createApp(opts = {}) {
setHeaders(res, filePath) {
setHeaders(res, filePath) {
const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
const v = res.req && res.req.query && res.req.query.v;
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 = {}) {
@@ -184,7 +185,7 @@ async function createApp(opts = {}) {
app.use((err, req, res, _next) => {
app.use((err, req, res, _next) => {
log.error('[News]', err && err.stack ? err.stack : err);
log.error('[News]', err && err.stack ? err.stack : err);
if (res.headersSent) return;
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 });
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.');
res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');
});
});
@@ -16,6 +16,7 @@
@@ -16,6 +16,7 @@
const express = require('express');
const express = require('express');
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
const sharedSeo = require('openvibe-shared/seo');
const sharedSeo = require('openvibe-shared/seo');
+const cache = require('openvibe-shared/cache-policy');
function createDiscoveryRoutes({ config, store, publication, topics }) {
function createDiscoveryRoutes({ config, store, publication, topics }) {
const router = express.Router();
const router = express.Router();
@@ -31,7 +32,7 @@ function createDiscoveryRoutes({ config, store, publication, topics }) {
@@ -31,7 +32,7 @@ function createDiscoveryRoutes({ config, store, publication, topics }) {
return out;
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) => {
router.get('/robots.txt', (_req, res) => {
const body = [
const body = [
@@ -42,11 +43,11 @@ function createDiscoveryRoutes({ config, store, publication, topics }) {
@@ -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.',
'# 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/'] }),
sharedSeo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: ['/edit', '/clusters/', '/auth/', '/api/', '/internal/'] }),
].join('\n');
].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) => {
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',
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.',
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.',
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.',
@@ -22,6 +22,7 @@ const editor = require('../render/editor');
@@ -22,6 +22,7 @@ const editor = require('../render/editor');
const { csrfToken, checkCsrf } = require('../auth/forms');
const { csrfToken, checkCsrf } = require('../auth/forms');
const { asApiError } = require('./errors');
const { asApiError } = require('./errors');
const access = require('../domain/access');
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. */
/** 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);
const one = (v) => (Array.isArray(v) ? (v.includes('1') ? '1' : v[v.length - 1]) : v);
@@ -31,7 +32,7 @@ function createEditorRoutes(ctx) {
@@ -31,7 +32,7 @@ function createEditorRoutes(ctx) {
const router = express.Router();
const router = express.Router();
const form = express.urlencoded({ extended: false, limit: '300kb' });
const form = express.urlencoded({ extended: false, limit: '300kb' });
router.use(['/edit', '/clusters'], viewers.middleware({ services: false }), (req, res, next) => {
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.set('X-Robots-Tag', 'noindex, nofollow');
res.vary('Cookie');
res.vary('Cookie');
if (req.viewer.kind !== 'user') {
if (req.viewer.kind !== 'user') {
@@ -6,6 +6,7 @@
@@ -6,6 +6,7 @@
*/
*/
const express = require('express');
const express = require('express');
const contracts = require('openvibe-contracts');
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'). */
/** A refusal with a stable problem code (e.g. 404 'post.not_found'). */
class ApiError extends Error {
class ApiError extends Error {
@@ -56,7 +57,7 @@ function jsonBody(req, res, next) {
@@ -56,7 +57,7 @@ function jsonBody(req, res, next) {
/** Private, per-viewer responses: never stored by a shared cache, never indexed. */
/** Private, per-viewer responses: never stored by a shared cache, never indexed. */
function privateNoStore(res) {
function privateNoStore(res) {
- res.set('Cache-Control', 'private, no-store');
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.vary('Cookie');
res.vary('Cookie');
res.vary('Authorization');
res.vary('Authorization');
}
}
@@ -27,6 +27,7 @@ const { renderPage } = require('../render/layout');
@@ -27,6 +27,7 @@ const { renderPage } = require('../render/layout');
const pages = require('../render/pages');
const pages = require('../render/pages');
const access = require('../domain/access');
const access = require('../domain/access');
const { csrfToken, checkCsrf } = require('../auth/forms');
const { csrfToken, checkCsrf } = require('../auth/forms');
+const cache = require('openvibe-shared/cache-policy');
const PER_PAGE = 20;
const PER_PAGE = 20;
@@ -38,8 +39,8 @@ function createPublicRoutes(ctx) {
@@ -38,8 +39,8 @@ function createPublicRoutes(ctx) {
function cacheHeaders(res, { cacheable, robots }) {
function cacheHeaders(res, { cacheable, robots }) {
res.vary('Cookie');
res.vary('Cookie');
res.vary('Authorization');
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);
if (robots && robots !== 'index, follow') res.set('X-Robots-Tag', robots);
}
}
@@ -117,7 +118,7 @@ function createPublicRoutes(ctx) {
@@ -117,7 +118,7 @@ function createPublicRoutes(ctx) {
const description = topic ? (topic.description || `Stories on ${topic.name}`) : 'Source-backed stories from OpenVibe.News';
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 file = FEEDS.find((f) => f.type === kind).file;
const feedUrl = publication.abs(`${base === '/' ? '' : base}/${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');
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 === '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)));
if (kind === 'json') return res.type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title, link, feedUrl, description, language: 'en' }, items)));
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -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();
+})();
@@ -100,7 +100,7 @@ const BODY = [
@@ -100,7 +100,7 @@ const BODY = [
assert.match(page.text, /Wire A/);
assert.match(page.text, /Wire A/);
assert.match(page.text, /Ana Reporter/);
assert.match(page.text, /Ana Reporter/);
assert.match(page.text, /<meta name="robots" content="index, follow">/);
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, /"@type":"NewsArticle"/);
assert.match(page.text, /Mission announcements/);
assert.match(page.text, /Mission announcements/);
assert.match(page.text, /2026-09-22<\/time> — Europa Clipper lifts off/);
assert.match(page.text, /2026-09-22<\/time> — Europa Clipper lifts off/);