openvibers/openvibe.reviews Commits

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

OP OpenVibers committed 2 Oct 2026 · e69dd198e3 · 7 files, +29 −21

checks run by GitHub CI (test) · CI ✓ 3/3 · review SHIP by DeepSeek harness (QUALITY=high)

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

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 5ef888e..e1aaabf 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -13,6 +13,7 @@ const path = require('path');
const express = require('express');
const cookieParser = require('cookie-parser');
const rateLimit = require('express-rate-limit');
++const cache = require('openvibe-shared/cache-policy');
const { createReadiness } = require('openvibe-shared/ready');
const { http } = require('openvibe-contracts');
const { createSessionRoutes } = require('./auth/session');
@@@ -152,7 +153,7 @@ function createApp({ config, svc, viewers, platform, sources, sync, keys, db, lo
setHeaders(res, filePath) {
const v = res.req && res.req.query && res.req.query.v;
const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
-- res.setHeader('Cache-Control', v && v === require('./render/layout').assetVersion(rel) ? 'public, max-age=31536000, immutable' : 'public, max-age=300');
++ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === require('./render/layout').assetVersion(rel) }));
},
}));
app.use(createMachine({ svc, config }));
@@@ -168,7 +169,7 @@ function createApp({ config, svc, viewers, platform, sources, sync, keys, db, lo
log.error('[Reviews]', err && err.stack ? err.stack : err);
if (res.headersSent) return;
if (req.path.startsWith('/api/')) return http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error' });
-- res.status(500).set('Cache-Control', 'private, no-store').type('text/plain').send('Something went wrong on our side. Please try again.');
++ res.status(500).set('Cache-Control', cache.htmlHeaders({ private: true })).type('text/plain').send('Something went wrong on our side. Please try again.');
});
app.locals.consumer = consumer;
return app;
ddiff --git a/server/auth/session.js b/server/auth/session.js
iindex ee93148..f752b85 100644
---- a/server/auth/session.js
++++ b/server/auth/session.js
@@@ -15,6 +15,7 @@
*/
const crypto = require('crypto');
const express = require('express');
++const cache = require('openvibe-shared/cache-policy');
const ACCESS_COOKIE = 'ov_token';
const REFRESH_COOKIE = 'ov_refresh';
@@@ -180,7 +181,7 @@ function createSessionRoutes({ config, viewers, fetchImpl = globalThis.fetch, lo
router.get('/me', async (req, res) => {
const actor = await viewers.resolve(req, { services: false }).catch(() => null);
if (!actor || actor.kind !== 'user') return res.status(401).json({ error: 'Not authenticated' });
-- res.set('Cache-Control', 'private, no-store').json({ user: actor.user });
++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ user: actor.user });
});
router.post('/refresh', async (req, res) => {
ddiff --git a/server/http/common.js b/server/http/common.js
iindex 2552421..d2e3757 100644
---- a/server/http/common.js
++++ b/server/http/common.js
@@@ -3,6 +3,7 @@
* Shared HTTP helpers: the actor middleware, capability guards and error mapping.
*/
const contracts = require('openvibe-contracts');
++const cache = require('openvibe-shared/cache-policy');
const { checkCapability } = require('../auth/capabilities');
const { AuthError } = require('../auth/viewer');
@@@ -62,7 +63,7 @@ function run(fn, status = 200, log = console) {
try {
const out = await fn(req, res);
if (res.headersSent) return;
-- res.status(typeof status === 'function' ? status(out) : status).set('Cache-Control', 'private, no-store').json(out);
++ res.status(typeof status === 'function' ? status(out) : status).set('Cache-Control', cache.htmlHeaders({ private: true })).json(out);
} catch (err) {
sendError(res, req, err, log);
}
ddiff --git a/server/http/machine.js b/server/http/machine.js
iindex 782b4ab..e21d17f 100644
---- a/server/http/machine.js
++++ b/server/http/machine.js
@@@ -12,16 +12,17 @@
const express = require('express');
const seo = require('openvibe-publishing/seo');
const sharedSeo = require('openvibe-shared/seo');
++const cache = require('openvibe-shared/cache-policy');
const PER_SITEMAP = 45000;
function createMachine({ svc, config }) {
const router = express.Router();
const origin = config.baseUrl;
-- const cache = (res, s = 300) => res.set('Cache-Control', `public, max-age=${s}`);
++ const setCache = (res, s = 300) => res.set('Cache-Control', cache.htmlHeaders({ maxAge: s }));
router.get('/robots.txt', (_req, res) => {
-- cache(res, 3600).type('text/plain').send(seo.robotsTxt({
++ setCache(res, 3600).type('text/plain').send(seo.robotsTxt({
sitemaps: [`${origin}/sitemap.xml`],
disallow: ['/auth/', '/api/', '/internal/', '/editor', '/search', '/e/*/edit', '/e/*/correct'],
}));
@@@ -35,18 +36,18 @@ function createMachine({ svc, config }) {
const pages = seo.sitemap(await entityEntries(), { maxUrls: PER_SITEMAP });
const maps = [{ loc: `${origin}/sitemaps/pages.xml` }];
pages.files.forEach((_f, i) => maps.push({ loc: `${origin}/sitemaps/entities-${i + 1}.xml` }));
-- cache(res).type('application/xml').send(seo.sitemapIndex(maps));
++ setCache(res).type('application/xml').send(seo.sitemapIndex(maps));
});
router.get('/sitemaps/pages.xml', (_req, res) => {
const policy = { minWords: 0, requireSources: false };
const entries = ['/', '/about'].map((p) => ({ loc: seo.canonicalUrl(origin, p), decision: seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: seo.canonicalUrl(origin, p), wordCount: 0 }, { policy, now: Date.now() }) }));
-- cache(res).type('application/xml').send(seo.sitemap(entries).files[0]);
++ setCache(res).type('application/xml').send(seo.sitemap(entries).files[0]);
});
router.get('/sitemaps/entities-:n.xml', async (req, res) => {
const files = seo.sitemap(await entityEntries(), { maxUrls: PER_SITEMAP }).files;
const n = Number(req.params.n);
if (!Number.isInteger(n) || n < 1 || n > files.length) return res.status(404).type('text/plain').send('Not found');
-- cache(res).type('application/xml').send(files[n - 1]);
++ setCache(res).type('application/xml').send(files[n - 1]);
});
async function feedItems() {
@@@ -65,15 +66,15 @@ function createMachine({ svc, config }) {
router.get('/feed.atom', async (_req, res) => {
const items = await feedItems();
-- if (!items.some((i) => i.decision.listable)) return res.status(404).set('Cache-Control', 'public, max-age=60').type('text/plain').send('No summary has been published yet.');
-- cache(res).type('application/atom+xml').send(seo.atomFeed({ title: 'OpenVibe.Reviews: published summaries', link: `${origin}/`, feedUrl: `${origin}/feed.atom`, id: `${origin}/feed.atom` }, items));
++ if (!items.some((i) => i.decision.listable)) return res.status(404).set('Cache-Control', cache.htmlHeaders({ maxAge: 60 })).type('text/plain').send('No summary has been published yet.');
++ setCache(res).type('application/atom+xml').send(seo.atomFeed({ title: 'OpenVibe.Reviews: published summaries', link: `${origin}/`, feedUrl: `${origin}/feed.atom`, id: `${origin}/feed.atom` }, items));
});
router.get('/feed.json', async (_req, res) => {
-- cache(res).type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: 'OpenVibe.Reviews: published summaries', link: `${origin}/`, feedUrl: `${origin}/feed.json`, description: 'Editor-reviewed summaries, by the time their current revision was published.' }, await feedItems())));
++ setCache(res).type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: 'OpenVibe.Reviews: published summaries', link: `${origin}/`, feedUrl: `${origin}/feed.json`, description: 'Editor-reviewed summaries, by the time their current revision was published.' }, await feedItems())));
});
router.get('/llms.txt', (_req, res) => {
-- cache(res, 3600).type('text/plain').send(sharedSeo.llmsTxt({
++ setCache(res, 3600).type('text/plain').send(sharedSeo.llmsTxt({
name: 'OpenVibe.Reviews',
summary: 'Review signals from named sources (via OpenVibe.Sources) resolved to entities, with provenance for every number, aggregates that exist only when signals do, and editor-reviewed summaries.',
details: 'Every entity page has a JSON representation at the same address plus ".json" (same content). Each signal names its source item, retrieval time and licence note; the aggregate lists its inputs, exclusions and computation (method reviews-aggregate@1). Summaries never carry a rating; AI-drafted summaries are labelled and published only after a person approves them. Review text is not republished.',
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 99e3554..00d4a72 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -22,6 +22,7 @@
*/
const express = require('express');
const ovServe = require('openvibe-shared/serve');
++const cache = require('openvibe-shared/cache-policy');
const frame = require('openvibe-shared/frame');
const seo = require('openvibe-publishing/seo');
const { renderPage } = require('../render/layout');
@@@ -93,7 +94,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
// Cross-site form posts are refused (the session cookie is SameSite=Lax as well).
router.use((req, res, next) => {
if (req.method !== 'POST' || !crossSite(req, origin)) return next();
-- res.status(403).type('text/plain').set('Cache-Control', 'private, no-store').send('Cross-site form posts are not accepted.');
++ res.status(403).type('text/plain').set('Cache-Control', cache.htmlHeaders({ private: true })).send('Cross-site form posts are not accepted.');
});
// Per-actor limits (http/actor-limits.js): each form names its budget, shared with the API route
@@@ -102,7 +103,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
const send = (req, res, status, body, o = {}) => {
const cacheable = o.cache === 'public' && req.actor.kind === 'anonymous' && status === 200;
-- res.status(status).set('Cache-Control', cacheable ? 'public, max-age=60' : 'private, no-store').set('Vary', 'Cookie, Authorization').type('html')
++ res.status(status).set('Cache-Control', cacheable ? cache.htmlHeaders({ maxAge: 60 }) : cache.htmlHeaders({ private: true })).set('Vary', 'Cookie, Authorization').type('html')
.send(renderPage({ config, actor: req.actor, path: o.path || req.path, editor: svc.access.isEditor(req.actor), ...o, body }));
};
const errorPage = (req, res, status, title, message) => send(req, res, status, views.errorBody({ status, title, message }), { title, robots: 'noindex, nofollow' });
@@@ -127,7 +128,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
const query = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
if (!e) {
const r = await svc.resolveRedirect(`/e/${slug}`);
-- if (r && r.status === 301) { res.set('Cache-Control', 'public, max-age=300').redirect(301, r.location + suffix + query); return null; }
++ if (r && r.status === 301) { res.set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).redirect(301, r.location + suffix + query); return null; }
if (r && r.status === 410) { gone(req, res); return null; }
notFound(req, res);
return null;
@@@ -135,7 +136,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
if (e.state === 'deleted') { gone(req, res); return null; }
if (e.state === 'merged') {
const c = await svc.entityById(await svc.canonicalOf(e.id));
-- res.set('Cache-Control', 'public, max-age=60').redirect(301, `${svc.entityPath(c)}${suffix}${query}`);
++ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 60 })).redirect(301, `${svc.entityPath(c)}${suffix}${query}`);
return null;
}
return e;
@@@ -161,7 +162,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
const e = await locate(req, res, '.json');
if (!e) return;
const p = await svc.page(e, req.actor);
-- res.status(200).set('Cache-Control', req.actor.kind === 'anonymous' ? 'public, max-age=60' : 'private, no-store').set('Vary', 'Cookie, Authorization').set('X-Robots-Tag', p.decision.robots)
++ res.status(200).set('Cache-Control', req.actor.kind === 'anonymous' ? cache.htmlHeaders({ maxAge: 60 }) : cache.htmlHeaders({ private: true })).set('Vary', 'Cookie, Authorization').set('X-Robots-Tag', p.decision.robots)
.json({ ...p, decision: { indexable: p.decision.indexable, robots: p.decision.robots, reasons: p.decision.codes } });
});
ddiff --git a/test/asset-cache.test.js b/test/asset-cache.test.js
iindex 387b4ed..6fcab33 100644
---- a/test/asset-cache.test.js
++++ b/test/asset-cache.test.js
@@@ -16,9 +16,12 @@ const { assetVersion } = require('../server/render/layout');
assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=31536000, immutable', 'the current hash is immutable');
r = await fetch(`${h.base}/css/reviews.css?v=0123456789`);
assert.strictEqual(r.status, 200);
-- assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300', 'another ?v= is never pinned');
++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300, stale-while-revalidate=86400', 'another ?v= is never pinned');
++ r = await fetch(`${h.base}/css/reviews.css?v=deadbeefdeadbeef`);
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300, stale-while-revalidate=86400', 'a hex but wrong ?v= is not immutable');
r = await fetch(`${h.base}/css/reviews.css`);
-- assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300');
++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300, stale-while-revalidate=86400');
console.log('asset cache: all checks passed');
} finally { await h.stop(); }
})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/nojs.test.js b/test/nojs.test.js
iindex 666923f..9464ba8 100644
---- a/test/nojs.test.js
++++ b/test/nojs.test.js
@@@ -81,7 +81,7 @@ t('every public page reads without JavaScript; sitemaps, feeds and robots follow
} });
const page = await req(h, 'GET', `/e/${e.slug}`);
assert.strictEqual(robots(page.text), 'index, follow');
-- assert.strictEqual(page.headers.get('cache-control'), 'public, max-age=60');
++ assert.strictEqual(page.headers.get('cache-control'), 'public, max-age=60, stale-while-revalidate=3600');
const text = noScripts(page.text);
for (const needle of ['Portal 2', '50%</strong> recommend', 'Half of the sample recommends it', 'Retrieved', 'steam-reviews-portal-2', 'Computation', '/history', '/correct', 'Discussion']) assert.ok(text.includes(needle), needle);
assert.ok(jsonLd(page.text).some((x) => x['@type'] === 'Review'));