openvibers/openvibe.blog Commits

Serve Blog cache headers through openvibe-shared/cache-policy (#3)

OP OpenVibers committed 2 Oct 2026 · 9a335d28a8 · 6 files, +50 −11

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

https://github.com/OpenVibers/OpenVibe.Blog/pull/3

Changes

ddiff --git a/server/app.js b/server/app.js
iindex a09dc16..0cc8881 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -32,6 +32,7 @@ const { createMedia } = require('./clients/media');
const { createVip } = require('./clients/vip');
const { createServiceOutbox } = require('openvibe-sdk/events');
const { createIndexNow } = require('openvibe-shared/indexnow');
++const cache = require('openvibe-shared/cache-policy');
const { createPublicRoutes } = require('./http/public');
const { createEditorRoutes } = require('./http/editor');
const { createApi } = require('./http/api');
@@@ -153,7 +154,7 @@ async function createApp(opts = {}) {
// { user: null }), not a 401. The SDK router's own /me answers 401 for an absent session; this route,
// registered first, keeps Blog's contract and marks the answer private / no-store.
app.get('/auth/me', async (req, res) => {
-- res.set('Cache-Control', 'private, no-store');
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.vary('Cookie');
res.vary('Authorization');
const token = auth.extractToken(req);
@@@ -176,7 +177,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) }));
},
}));
@@@ -195,7 +196,7 @@ async function createApp(opts = {}) {
app.use((err, req, res, _next) => {
log.error('[Blog]', 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/')) 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/api.js b/server/http/api.js
iindex e7b2011..e8da776 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -31,6 +31,7 @@
const express = require('express');
const contracts = require('openvibe-contracts');
const seo = require('openvibe-publishing/seo');
++const cache = require('openvibe-shared/cache-policy');
const { run, jsonBody, ApiError, privateNoStore } = require('./errors');
const { guard } = require('../auth/viewer');
const { checkCapability } = require('../auth/capabilities');
@@@ -227,7 +228,7 @@ function createApi(ctx) {
const blog = await mustBlog(req);
const s = await blogs.feedSettings(blog);
const items = await reading.feedItems(blog, { limit: s.item_count, fullContent: Boolean(s.full_content) });
-- res.set('Cache-Control', 'public, max-age=300');
++ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 300 }));
res.removeHeader('X-Robots-Tag');
return seo.jsonFeed({ title: blog.title, link: publication.abs(publication.blogPath(blog)), feedUrl: publication.abs(reading.urls.feed(blog, 'json')), description: blog.description || undefined }, items);
}));
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex e233cf0..a12b172 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, blogs, publication, reading }) {
const router = express.Router();
@@@ -40,7 +41,7 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
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 = [
@@@ -49,12 +50,12 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
'# Pages decide their own indexability (meta robots / X-Robots-Tag); a Disallow is not a noindex.',
sharedSeo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: ['/write', '/auth/', '/api/'] }),
].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', async (_req, res) => {
const official = await blogs.official();
-- 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.Blog',
summary: 'The official OpenVibe blog and a blog for every OpenVibe member: server-rendered posts with feeds, sitemaps and a JSON representation of every post.',
details: 'Only public, published posts are listed, fed or mapped. Every post page has a machine-readable twin at <post URL>.json with the same content, its revision, authorship (human, AI-assisted or AI-generated, and whether a person reviewed it), sources and indexability reasons. AI-generated drafts are never published or indexed before a person reviews them.',
ddiff --git a/server/http/editor.js b/server/http/editor.js
iindex d421a30..c9f7d61 100644
---- a/server/http/editor.js
++++ b/server/http/editor.js
@@@ -23,6 +23,7 @@ const { renderPage } = require('../render/layout');
const editor = require('../render/editor');
const { csrfToken, checkCsrf } = require('../auth/forms');
const { asApiError } = require('./errors');
++const cache = require('openvibe-shared/cache-policy');
/** Checkbox + hidden fallback arrive as ['1','0']; a text field repeated keeps its last value. */
const one = (v) => (Array.isArray(v) ? (v.includes('1') ? '1' : v[v.length - 1]) : v);
@@@ -39,7 +40,7 @@ function createEditorRoutes(ctx) {
const form = express.urlencoded({ extended: false, limit: '600kb' });
router.use(viewers.middleware({ services: false }));
router.use((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/public.js b/server/http/public.js
iindex a5cbfed..193d66e 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -23,6 +23,7 @@ const frame = require('openvibe-shared/frame');
const seo = require('openvibe-publishing/seo');
const ssr = require('openvibe-publishing/ssr');
const authorship = require('openvibe-publishing/authorship');
++const cache = require('openvibe-shared/cache-policy');
const { renderPage } = require('../render/layout');
const pages = require('../render/pages');
const { csrfToken, checkCsrf } = require('../auth/forms');
@@@ -39,8 +40,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);
}
@@@ -173,7 +174,7 @@ function createPublicRoutes(ctx) {
const items = await reading.feedItems(blog, { limit: s.item_count, fullContent: Boolean(s.full_content) });
const link = publication.abs(publication.blogPath(blog));
const feedUrl = publication.abs(reading.urls.feed(blog, kind));
-- 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: blog.title, link, description: blog.description || blog.title, feedUrl, language: blog.language }, items));
if (kind === 'json') return res.type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: blog.title, link, feedUrl, description: blog.description || undefined, language: blog.language }, items)));
ddiff --git a/test/asset-cache.test.js b/test/asset-cache.test.js
nnew file mode 100644
iindex 0000000..28b992f
---- /dev/null
++++ b/test/asset-cache.test.js
@@@ -0,0 +1,34 @@
++'use strict';
++/**
++ * Static assets follow openvibe-shared/cache-policy: the current `?v=<assetVersion>` URL is immutable
++ * for a year (the bytes cannot change under that URL), a wrong `?v=` or none is a short public window
++ * with a long stale-while-revalidate (the URL may change at any moment).
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { assetVersion } = require('../server/render/layout');
++
++const REL = 'css/blog.css';
++const IMMUTABLE = 'public, max-age=31536000, immutable';
++const REVALIDATED = 'public, max-age=300, stale-while-revalidate=86400';
++
++(async () => {
++ const t = await boot();
++ await check('the current ?v=<assetVersion> is immutable for a year', async () => {
++ const r = await t.get(`/${REL}?v=${assetVersion(REL)}`);
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('cache-control'), IMMUTABLE);
++ });
++ await check('a wrong-but-hex ?v= is not content-addressed: short public window', async () => {
++ const r = await t.get(`/${REL}?v=deadbeefdeadbeef`);
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('cache-control'), REVALIDATED);
++ });
++ await check('no ?v= is not content-addressed: short public window', async () => {
++ const r = await t.get(`/${REL}`);
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('cache-control'), REVALIDATED);
++ });
++ await t.close();
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });