openvibers/openvibe.deals Commits

Route every server cache header through openvibe-shared cache-policy (#2)

OP OpenVibers committed 2 Oct 2026 · 8442b0e935 · 5 files, +52 −12

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

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

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 66125af..3f07f5a 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -45,6 +45,7 @@ const { createDealsReadiness } = require('./observability');
const { createActorLimits } = require('./http/actor-limits');
const { createWorker } = require('./worker');
const { assetVersion } = require('./render/layout');
++const cache = require('openvibe-shared/cache-policy');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
@@@ -162,7 +163,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) }));
},
}));
@@@ -183,7 +184,7 @@ async function createApp(opts = {}) {
app.use((err, req, res, _next) => {
log.error('[Deals]', 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/discovery.js b/server/http/discovery.js
iindex 83abe7f..cfa28e9 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -17,11 +17,12 @@
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, listings }) {
const router = express.Router();
const abs = (p) => seo.canonicalUrl(config.baseUrl, p);
-- 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);
async function offerEntries() {
return Promise.all((await listings.indexable()).map(async (o) => {
@@@ -45,11 +46,11 @@ function createDiscoveryRoutes({ config, store, publication, listings }) {
'# indexability (meta robots / X-Robots-Tag): stale or expired deals are noindex. A Disallow is not a noindex.',
sharedSeo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: ['/submit', '/watches', '/mod', '/search', '/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.Deals',
summary: 'Deals submitted and voted on by the OpenVibe community or imported from registered sources, with the source and observation time of every price.',
details: 'A price on this site is an observation: it has a source and an observed_at time, and it is only claimed "as of" that time. Offers whose latest observation is older than the freshness window are marked stale and are noindex; expired offers are noindex; a price nobody stated is shown as "not stated" and omitted from structured data (never 0). Every deal page has a JSON twin at <deal URL>.json with the full observation history, sources, votes and indexability reasons. Imported and AI-assisted text is noindex until a person reviews it.',
ddiff --git a/server/http/errors.js b/server/http/errors.js
iindex b4d4e01..85a84eb 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');
const { ApiError } = require('../domain/values');
/** Wrap a JSON handler: its return value is the body; errors become problems. */
@@@ -35,7 +36,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/pages.js b/server/http/pages.js
iindex f311664..924bec2 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -24,6 +24,7 @@ const express = require('express');
const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const seo = require('openvibe-publishing/seo');
++const cache = require('openvibe-shared/cache-policy');
const ssr = require('openvibe-publishing/ssr');
const { renderPage } = require('../render/layout');
const views = require('../render/views');
@@@ -48,8 +49,8 @@ function createPages(ctx) {
function cacheHeaders(req, res, { cacheable, robots, status }) {
res.vary('Cookie');
res.vary('Authorization');
-- if (cacheable && req.viewer.kind === 'anonymous' && status === 200) res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=60');
-- else res.set('Cache-Control', 'private, no-store');
++ if (cacheable && req.viewer.kind === 'anonymous' && status === 200) 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);
}
@@@ -197,7 +198,7 @@ function createPages(ctx) {
if (!offer) { notFound(req, res); return null; }
if (offer.merged_into) {
const root = await reads.root(offer);
-- res.set('Cache-Control', 'public, max-age=300');
++ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 300 }));
res.redirect(301, `${publication.offerPath(root)}${json ? '.json' : ''}`);
return null;
}
@@@ -423,12 +424,12 @@ function createPages(ctx) {
}
const channel = () => ({ title: 'OpenVibe.Deals — new deals', link: publication.abs('/new'), description: 'New deals, each with its source and the time its price was observed.', language: 'en' });
-- router.get('/feed.xml', async (_req, res) => res.type('application/rss+xml').set('Cache-Control', 'public, max-age=300').send(seo.rssFeed({ ...channel(), feedUrl: publication.abs('/feed.xml') }, await feedItems())));
++ router.get('/feed.xml', async (_req, res) => res.type('application/rss+xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(seo.rssFeed({ ...channel(), feedUrl: publication.abs('/feed.xml') }, await feedItems())));
router.get('/atom.xml', async (_req, res) => {
const items = await feedItems();
-- res.type('application/atom+xml').set('Cache-Control', 'public, max-age=300').send(seo.atomFeed({ ...channel(), feedUrl: publication.abs('/atom.xml'), ...(items.length ? {} : { updated: store.now() }) }, items));
++ res.type('application/atom+xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(seo.atomFeed({ ...channel(), feedUrl: publication.abs('/atom.xml'), ...(items.length ? {} : { updated: store.now() }) }, items));
});
-- router.get('/feed.json', async (_req, res) => res.type('application/feed+json').set('Cache-Control', 'public, max-age=300').send(JSON.stringify(seo.jsonFeed({ ...channel(), feedUrl: publication.abs('/feed.json') }, await feedItems()))));
++ router.get('/feed.json', async (_req, res) => res.type('application/feed+json').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(JSON.stringify(seo.jsonFeed({ ...channel(), feedUrl: publication.abs('/feed.json') }, await feedItems()))));
return { router, notFound, messagePage };
}
ddiff --git a/test/asset-cache.test.js b/test/asset-cache.test.js
nnew file mode 100644
iindex 0000000..6984830
---- /dev/null
++++ b/test/asset-cache.test.js
@@@ -0,0 +1,36 @@
++'use strict';
++/**
++ * Static-asset caching (openvibe-shared/cache-policy, used by the express.static setHeaders in
++ * server/app.js): a URL whose `?v=` equals the render layout's assetVersion for that file is
++ * content-addressed and immutable for a year; anything else — a wrong hash or no `?v=` at all —
++ * is a short public window with a day of stale-while-revalidate.
++ */
++const assert = require('assert');
++const { assetVersion } = require('../server/render/layout');
++const { boot, check, done } = require('./helpers/boot');
++
++const ASSET = 'css/deals.css';
++const IMMUTABLE = 'public, max-age=31536000, immutable';
++const SHORT = 'public, max-age=300, stale-while-revalidate=86400';
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('the current ?v=<assetVersion> is served immutable for a year', async () => {
++ const r = await t.get(`/${ASSET}?v=${assetVersion(ASSET)}`);
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('cache-control'), IMMUTABLE);
++ });
++
++ await check('a wrong-but-hex ?v= and no ?v= get the short public window', async () => {
++ for (const suffix of ['?v=deadbeefdeadbeef', '']) {
++ const r = await t.get(`/${ASSET}${suffix}`);
++ assert.strictEqual(r.status, 200, suffix || 'no ?v=');
++ assert.strictEqual(r.headers.get('cache-control'), SHORT, suffix || 'no ?v=');
++ }
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();