openvibers/openvibe.wiki Commits

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

OP OpenVibers committed 2 Oct 2026 · f088ee1251 · 9 files, +31 −23

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

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

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 9750bfa..c0e2bdd 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -12,6 +12,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 { createSessionRoutes } = require('./auth/session');
const { createApi } = require('./http/api');
@@@ -112,7 +113,7 @@ function createApp({ config, svc, viewers: baseViewers, platform, keys, db, valk
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) }));
},
}));
// GET /<key>.txt — the IndexNow key file, only when a key is configured (server/index.js).
@@@ -131,7 +132,7 @@ function createApp({ config, svc, viewers: baseViewers, platform, keys, db, valk
log.error('[Wiki]', err && err.stack ? err.stack : err);
if (res.headersSent) return;
if (req.path.startsWith('/api/')) return require('openvibe-contracts').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.');
});
return app;
}
ddiff --git a/server/auth/session.js b/server/auth/session.js
iindex 1991e66..0589210 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';
@@@ -178,7 +179,7 @@ function createSessionRoutes({ config, viewers, fetchImpl = globalThis.fetch, lo
});
router.get('/me', async (req, res) => {
-- res.set('Cache-Control', 'private, no-store');
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
// No credential at all (a guest) is signed out, not an error: the shared navbar asks this on every
// page view, and a 401 logged a console error on each (browser check, OpenVibe.Host). A credential
// that is present but invalid or expired still answers 401.
ddiff --git a/server/http/common.js b/server/http/common.js
iindex 352ecd0..4a1802c 100644
---- a/server/http/common.js
++++ b/server/http/common.js
@@@ -3,6 +3,7 @@
* Shared HTTP helpers: the actor middleware, capability guards, error mapping and citation input.
*/
const contracts = require('openvibe-contracts');
++const cache = require('openvibe-shared/cache-policy');
const { checkCapability } = require('../auth/capabilities');
const { AuthError } = require('../auth/viewer');
@@@ -49,7 +50,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 2352a4e..03a8f01 100644
---- a/server/http/machine.js
++++ b/server/http/machine.js
@@@ -13,16 +13,17 @@ const express = require('express');
const seo = require('openvibe-publishing/seo');
const ssr = require('openvibe-publishing/ssr');
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/', '/new-space', '/search', '/proposals/'],
}));
@@@ -49,14 +50,14 @@ function createMachine({ svc, config }) {
const pages = seo.sitemap(await pageEntries(), { maxUrls: PER_SITEMAP });
const maps = [{ loc: `${origin}/sitemaps/spaces.xml` }];
pages.files.forEach((_f, i) => maps.push({ loc: `${origin}/sitemaps/pages-${i + 1}.xml` }));
-- cache(res).type('application/xml').send(seo.sitemapIndex(maps));
++ setCache(res).type('application/xml').send(seo.sitemapIndex(maps));
}));
-- router.get('/sitemaps/spaces.xml', wrap(async (_req, res) => cache(res).type('application/xml').send(seo.sitemap(await spaceEntries()).files[0])));
++ router.get('/sitemaps/spaces.xml', wrap(async (_req, res) => setCache(res).type('application/xml').send(seo.sitemap(await spaceEntries()).files[0])));
router.get('/sitemaps/pages-:n.xml', wrap(async (req, res) => {
const files = seo.sitemap(await pageEntries(), { 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() {
@@@ -86,15 +87,15 @@ function createMachine({ svc, config }) {
router.get('/feed.atom', wrap(async (_req, res) => {
const items = await feedItems();
const updated = items.some((i) => i.decision.listable) ? null : await emptyFeedUpdated();
-- cache(res).type('application/atom+xml').send(seo.atomFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.atom`, id: `${origin}/feed.atom`, ...(updated ? { updated } : {}) }, items));
++ setCache(res).type('application/atom+xml').send(seo.atomFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.atom`, id: `${origin}/feed.atom`, ...(updated ? { updated } : {}) }, items));
}));
router.get('/feed.json', wrap(async (_req, res) => {
-- cache(res).type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.json`, description: 'Public wiki pages by the time their current revision was published.' }, await feedItems())));
++ setCache(res).type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.json`, description: 'Public wiki pages by the time their current revision was published.' }, await feedItems())));
}));
router.get('/llms.txt', wrap(async (_req, res) => {
const spaces = (await svc.listSpaces({ kind: 'anonymous' })).filter((s) => s.visibility === 'public');
-- cache(res, 3600).type('text/plain').send(sharedSeo.llmsTxt({
++ setCache(res, 3600).type('text/plain').send(sharedSeo.llmsTxt({
name: 'OpenVibe.Wiki',
summary: 'Wiki spaces of the OpenVibe network: page trees, immutable revisions, citations attached to the revision that used them, infoboxes and internal links.',
details: 'Every public page has a JSON representation at the same address plus ".json" (same content, same visibility rules). Revision history and diffs are public for public pages. AI-generated revisions are labelled and are published only after a person approves them.',
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex bd0afcc..d6b19d7 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -28,6 +28,7 @@
*/
const express = require('express');
const ovServe = require('openvibe-shared/serve');
++const cache = require('openvibe-shared/cache-policy');
const seo = require('openvibe-publishing/seo');
const ssr = require('openvibe-publishing/ssr');
const { renderPage } = require('../render/layout');
@@@ -64,13 +65,13 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
router.use((req, res, next) => {
if (req.method !== 'POST') return next();
const o = req.get('origin');
-- if (o && o !== origin && o !== 'null') return res.status(403).type('text/plain').set('Cache-Control', 'private, no-store').send('Cross-site form posts are not accepted.');
++ if (o && o !== origin && o !== 'null') return res.status(403).type('text/plain').set('Cache-Control', cache.htmlHeaders({ private: true })).send('Cross-site form posts are not accepted.');
next();
});
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, ...o, body }));
};
const errorPage = (req, res, status, title, message) => send(req, res, status, views.errorBody({ status, title, message }), { title, robots: 'noindex, nofollow' });
@@@ -96,7 +97,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
if (page && page.state === 'deleted') { gone(req, res); return null; }
if (!page) {
const r = await svc.resolveRedirect(`/w/${req.params.space}/${req.params.slug}`);
-- if (r && r.status === 301) { res.set('Cache-Control', 'public, max-age=300').redirect(301, r.location + suffix + (req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '')); return null; }
++ if (r && r.status === 301) { res.set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).redirect(301, r.location + suffix + (req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '')); return null; }
if ((r && r.status === 410) || (space && space.deleted_at)) { gone(req, res); return null; }
notFound(req, res);
return null;
@@@ -319,9 +320,9 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
let v;
try { v = await svc.view(space, page, req.actor, { revision: rev }); } catch (err) { if (err.status === 404) return notFound(req, res); throw err; }
const open = page.state === 'published' && svc.access.effectiveVisibility(space, page) === 'public';
-- const cache = open && v.isPublishedRevision ? 'public' : null;
++ const publicCache = open && v.isPublishedRevision ? 'public' : null;
if (json) {
-- res.status(200).set('Cache-Control', cache && req.actor.kind === 'anonymous' ? 'public, max-age=60' : 'private, no-store').set('Vary', 'Cookie, Authorization')
++ res.status(200).set('Cache-Control', publicCache && req.actor.kind === 'anonymous' ? cache.htmlHeaders({ maxAge: 60 }) : cache.htmlHeaders({ private: true })).set('Vary', 'Cookie, Authorization')
.set('X-Robots-Tag', v.isPublishedRevision ? seo.xRobotsTag(v.decision) : 'noindex, nofollow').json(articleData(v, space, page));
return;
}
@@@ -350,7 +351,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
].filter(Boolean) : null;
const head = seo.metaTags({ decision, title: `${v.revision.fields.title} · OpenVibe.Wiki`, description, siteName: 'OpenVibe.Wiki', type: 'article', jsonLd });
send(req, res, 200, views.articlePage(v, { html: bodyHtml, discussion, actor: req.actor, mediaUrl: (id) => platform.media.publicUrl(id), resolve: (title) => infoboxLinks.get(title) || { href: null, exists: false }, flash: req.query.saved ? 'Saved.' : null }), {
-- head, path: svc.pagePath(space, page), cache,
++ head, path: svc.pagePath(space, page), cache: publicCache,
});
}));
ddiff --git a/test/asset-cache.test.js b/test/asset-cache.test.js
iindex f94a6db..5772ef7 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/wiki.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/wiki.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/wiki.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/citation-inspector.test.js b/test/citation-inspector.test.js
iindex 81fe6f9..5a0167d 100644
---- a/test/citation-inspector.test.js
++++ b/test/citation-inspector.test.js
@@@ -46,7 +46,7 @@ const stripScripts = (html) => html.replace(/<script[\s\S]*?<\/script>/g, '');
// The published revision by default, readable without scripts.
r = await H.req(h, 'GET', '/w/bread/rye/sources');
assert.strictEqual(r.status, 200);
-- assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60, stale-while-revalidate=3600');
assert.ok(r.text.includes('<meta name="robots" content="noindex, follow">'));
let page2 = stripScripts(r.text);
assert.ok(page2.includes('<h1>Sources of Rye, revision 2</h1>'));
ddiff --git a/test/feeds.test.js b/test/feeds.test.js
iindex a53be32..110402a 100644
---- a/test/feeds.test.js
++++ b/test/feeds.test.js
@@@ -50,7 +50,7 @@ function atom(text) {
let r = await H.req(h, 'GET', '/feed.atom');
assert.strictEqual(r.status, 200, 'an empty feed is a feed, not a 404');
assert.match(r.headers.get('content-type'), /^application\/atom\+xml/);
-- 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=3600');
let f = atom(r.text);
assert.ok(f.root, 'root <feed> in the Atom namespace');
assert.strictEqual(f.id, 'http://wiki.test/feed.atom');
ddiff --git a/test/visibility.test.js b/test/visibility.test.js
iindex 5809102..86b62b7 100644
---- a/test/visibility.test.js
++++ b/test/visibility.test.js
@@@ -41,7 +41,7 @@ async function lastIndexEvent(h, pageId) {
assert.strictEqual(ev.payload.indexability.decision, 'index');
r = await H.req(h, 'GET', '/w/notes/open-page');
assert.strictEqual(r.status, 200);
-- assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60, stale-while-revalidate=3600');
assert.ok(r.text.includes('<meta name="robots" content="index, follow">'));
// A signed-in view is personalised: never publicly cacheable.
r = await H.req(h, 'GET', '/w/notes/open-page', { cookie: H.cookieFor(H.userToken({ subject: owner.subject })) });