openvibers/openvibe.coupons Commits

Serve every Cache-Control through openvibe-shared/cache-policy (#2)

OP OpenVibers committed 2 Oct 2026 · c9f3cb66d6 · 8 files, +65 −13

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

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

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 1b801ac..df50b5d 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -19,6 +19,7 @@ const { createSsoClient } = require('openvibe-sdk/sso');
const { jwksClient } = require('openvibe-sdk/auth');
const { createServiceOutbox } = require('openvibe-sdk/events');
const { createIndexNow } = require('openvibe-shared/indexnow');
++const cache = require('openvibe-shared/cache-policy');
const configLib = require('./config');
const { openStore } = require('./db');
@@@ -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) }));
},
}));
@@@ -181,7 +182,7 @@ async function createApp(opts = {}) {
app.use((err, req, res, _next) => {
log.error('[Coupons]', 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/auth/viewer.js b/server/auth/viewer.js
iindex d9c8216..f6cf7ee 100644
---- a/server/auth/viewer.js
++++ b/server/auth/viewer.js
@@@ -26,6 +26,7 @@
const contracts = require('openvibe-contracts');
const { claimsToUser, decodeJwtPayload } = require('openvibe-sdk/sso');
const { verifyServiceToken } = require('openvibe-sdk/auth');
++const cache = require('openvibe-shared/cache-policy');
const { checkCapability } = require('./capabilities');
const { ApiError } = require('../http/errors');
@@@ -114,7 +115,7 @@ function createViewerResolver({ auth, jwks, config, installs }) {
next();
} catch (err) {
if (!(err instanceof ApiError)) return next(err);
-- res.set('Cache-Control', 'private, no-store');
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
}
};
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 0ddd84d..ee5045c 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -30,6 +30,7 @@ const { guard } = require('../auth/viewer');
const { CAPABILITIES, SCOPES } = require('../auth/capabilities');
const { ApiError, run, jsonBody } = require('./errors');
const { hosts } = require('openvibe-publishing/ingest');
++const cache = require('openvibe-shared/cache-policy');
const MOZ_EXTENSION_RE = /^moz-extension:\/\/[0-9a-f-]{36}$/;
@@@ -43,7 +44,7 @@ function createApi(ctx) {
const moderate = actorLimits.budget('coupons.moderate');
// ── Helpers ─────────────────────────────────────────────
-- const noStore = (res) => res.set('Cache-Control', 'private, no-store');
++ const noStore = (res) => res.set('Cache-Control', cache.htmlHeaders({ private: true }));
function extensionOriginAllowed(origin) {
if (!origin) return false;
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex a6a3f44..8e073e3 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -18,12 +18,13 @@
const express = require('express');
const seo = require('openvibe-publishing/seo');
const sharedSeo = require('openvibe-shared/seo');
++const cache = require('openvibe-shared/cache-policy');
const pages = require('../render/pages');
function createDiscoveryRoutes({ config, store, merchants, coupons, publication }) {
const router = express.Router();
const abs = (p) => seo.canonicalUrl(config.baseUrl, p);
-- const xml = (res, body, type = 'application/xml') => res.type(type).set('Cache-Control', 'public, max-age=300').send(body);
++ const xml = (res, body, type = 'application/xml') => res.type(type).set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(body);
async function activeEntries() {
const byMerchant = new Map();
@@@ -44,11 +45,11 @@ function createDiscoveryRoutes({ config, store, merchants, coupons, publication
'# an expired code stays readable but is noindex; a Disallow is not a noindex.',
sharedSeo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: ['/auth/', '/api/', '/submit', '/connect-extension', '/staff', '/watching'] }),
].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.Coupons',
summary: 'Coupon codes for online shops with merchant/domain matching, restrictions, honest expiry and validity reports by people.',
details: 'A code\'s status is exactly one of unknown, reported_working, reported_failed, expired or disabled. Only recent reports by people can make a code reported_working or reported_failed; a submission, a source or a model cannot. An expiry that nobody stated is "unknown" (null in JSON), and restrictions that were not stated are absent, not "none". Every shop page has a JSON twin at /m/<slug>.json and every code at /c/<id>.json with the same content. Expired and taken-down codes are not in lists, feeds or sitemaps.',
@@@ -115,7 +116,7 @@ function createDiscoveryRoutes({ config, store, merchants, coupons, publication
xml(res, seo.atomFeed({ ...channel, feedUrl: abs('/atom.xml'), ...(items.length ? {} : { updated: bootTime }) }, items), 'application/atom+xml');
});
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: abs('/feed.json') }, feedItems(await recent()))));
++ res.type('application/feed+json').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(JSON.stringify(seo.jsonFeed({ ...channel, feedUrl: abs('/feed.json') }, feedItems(await recent()))));
});
router.get('/m/:slug/feed.xml', async (req, res, next) => {
const m = await merchants.get(req.params.slug);
ddiff --git a/server/http/errors.js b/server/http/errors.js
iindex 23e8dc8..e6dda28 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');
/** A refusal with a stable problem code (e.g. 404 'coupon.not_found'). */
class ApiError extends Error {
@@@ -50,7 +51,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/public.js b/server/http/public.js
iindex 69af93d..2236e06 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -22,6 +22,7 @@
const express = require('express');
const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
++const cache = require('openvibe-shared/cache-policy');
const seo = require('openvibe-publishing/seo');
const { renderPage } = require('../render/layout');
const pages = require('../render/pages');
@@@ -45,7 +46,7 @@ function createPublicRoutes(ctx) {
function cacheHeaders(res, { cacheable, robots }) {
res.vary('Cookie');
res.vary('Authorization');
-- res.set('Cache-Control', cacheable ? 'public, max-age=60, stale-while-revalidate=60' : 'private, no-store');
++ res.set('Cache-Control', cacheable ? cache.htmlHeaders({ maxAge: 60 }) : cache.htmlHeaders({ private: true }));
if (robots && robots !== 'index, follow') res.set('X-Robots-Tag', robots);
}
@@@ -148,7 +149,7 @@ function createPublicRoutes(ctx) {
const data = await merchantData(m);
const decision = publication.merchantDecision(m, data.coupons.length);
res.vary('Cookie'); res.vary('Authorization');
-- res.set('Cache-Control', 'public, max-age=60');
++ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 60 }));
if (!decision.indexable) res.set('X-Robots-Tag', decision.robots);
res.json({ ...data, indexability: { indexable: decision.indexable, reasons: decision.codes } });
});
@@@ -196,7 +197,7 @@ function createPublicRoutes(ctx) {
if (!found) return;
const decision = publication.couponDecision(found.c, found.m);
res.vary('Cookie'); res.vary('Authorization');
-- res.set('Cache-Control', 'public, max-age=60');
++ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 60 }));
if (!decision.indexable) res.set('X-Robots-Tag', decision.robots);
res.json({ coupon: await coupons.view(found.c, { merchant: found.m }), merchant: { id: found.m.id, slug: found.m.slug, name: found.m.name, url: publication.merchantUrl(found.m) }, indexability: { indexable: decision.indexable, reasons: decision.codes } });
});
ddiff --git a/test/asset-cache.test.js b/test/asset-cache.test.js
nnew file mode 100644
iindex 0000000..7e1149e
---- /dev/null
++++ b/test/asset-cache.test.js
@@@ -0,0 +1,46 @@
++'use strict';
++/**
++ * Static asset caching (openvibe-shared/cache-policy, wired in server/app.js): only a ?v= that is
++ * exactly the asset's own assetVersion() is content-addressed and therefore immutable for a year.
++ * A wrong hash, and no hash at all, get the short window with a long stale-while-revalidate.
++ */
++const assert = require('assert');
++const cache = require('openvibe-shared/cache-policy');
++const { assetVersion } = require('../server/render/layout');
++const { boot, check, done } = require('./helpers/boot');
++
++const ASSET = 'css/coupons.css';
++const IMMUTABLE = 'public, max-age=31536000, immutable';
++const REVALIDATE = 'public, max-age=300, stale-while-revalidate=86400';
++
++(async () => {
++ await check('the module: hashed → immutable, otherwise a 5 min window with a day of stale-while-revalidate', async () => {
++ assert.strictEqual(cache.IMMUTABLE, IMMUTABLE);
++ assert.strictEqual(cache.assetHeaders(ASSET, { hashed: true }), IMMUTABLE);
++ assert.strictEqual(cache.assetHeaders(ASSET, { hashed: false }), REVALIDATE);
++ assert.strictEqual(cache.htmlHeaders({ maxAge: 300 }), `public, max-age=300, stale-while-revalidate=3600`);
++ assert.strictEqual(cache.htmlHeaders({ private: true }), 'private, no-store');
++ });
++
++ await check('served assets: the current ?v= is immutable, a wrong hash and no hash are not', async () => {
++ const t = await boot();
++ try {
++ const v = assetVersion(ASSET);
++ assert.ok(/^[0-9a-f]{8,64}$/.test(v), `assetVersion looks like a hash: ${v}`);
++
++ const current = await t.get(`/${ASSET}?v=${v}`);
++ assert.strictEqual(current.status, 200, current.text.slice(0, 200));
++ assert.strictEqual(current.headers.get('cache-control'), IMMUTABLE);
++
++ const wrong = await t.get(`/${ASSET}?v=deadbeefdeadbeef`);
++ assert.strictEqual(wrong.status, 200, wrong.text.slice(0, 200));
++ assert.strictEqual(wrong.headers.get('cache-control'), REVALIDATE);
++
++ const bare = await t.get(`/${ASSET}`);
++ assert.strictEqual(bare.status, 200, bare.text.slice(0, 200));
++ assert.strictEqual(bare.headers.get('cache-control'), REVALIDATE);
++ } finally { await t.close(); }
++ });
++
++ done();
++})();
ddiff --git a/test/pages-discovery.test.js b/test/pages-discovery.test.js
iindex 8d7afc1..7a059c1 100644
---- a/test/pages-discovery.test.js
++++ b/test/pages-discovery.test.js
@@@ -57,7 +57,7 @@ const { boot, check, done } = require('./helpers/boot');
await check('caching: anonymous pages are public for 60 s, signed-in pages are private; both vary on Cookie', async () => {
const anon = await t.get(`/m/${shop.slug}`);
-- assert.match(anon.headers.get('cache-control'), /^public, max-age=60/);
++ assert.strictEqual(anon.headers.get('cache-control'), 'public, max-age=60, stale-while-revalidate=3600');
assert.match(anon.headers.get('vary'), /Cookie/);
const signed = await t.get(`/m/${shop.slug}`, { as: alice });
assert.strictEqual(signed.headers.get('cache-control'), 'private, no-store');