openvibers/openvibe.network Commits

Move server Cache-Control values onto openvibe-shared cache-policy (#13)

OP OpenVibers committed 2 Oct 2026 · 2c6b8b5eae · 21 files, +109 −38

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

https://github.com/OpenVibers/OpenVibe.Network/pull/13

Changes

ddiff --git a/server/admin/events-ops.js b/server/admin/events-ops.js
iindex f6a99774..56f23340 100644
---- a/server/admin/events-ops.js
++++ b/server/admin/events-ops.js
@@@ -1,4 +1,5 @@
'use strict';
++const cache = require('openvibe-shared/cache-policy');
/**
* The Events delivery operator view (roadmap WS-F task 3): Network's admin reads OpenVibe.Events'
* dead-letter queue and replays deliveries through Events' own operator API, with a self-signed
@@@ -55,7 +56,7 @@ function createEventsOps({ db, eventsUrl, privateKey, issuer, fetchImpl = global
q.set('limit', String(Math.min(Math.max(parseInt(req.query.limit, 10) || 100, 1), 500)));
try {
const out = await call('GET', `/api/v1/deliveries?${q}`);
-- res.set('Cache-Control', 'private, no-store');
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.status(out.status === 200 ? 200 : 502).json(out.status === 200 ? { ok: true, ...out.data } : { ok: false, error: `Events answered ${out.status}`, detail: out.data && (out.data.detail || out.data.title) });
} catch (err) {
res.status(502).json({ ok: false, error: `Events did not answer: ${err.message}` });
ddiff --git a/server/admin/moderation-audit.js b/server/admin/moderation-audit.js
iindex 9757cb27..777f9cd3 100644
---- a/server/admin/moderation-audit.js
++++ b/server/admin/moderation-audit.js
@@@ -1,4 +1,5 @@
'use strict';
++const cache = require('openvibe-shared/cache-policy');
/**
* The network's moderation audit log (ADR-022): every staff action any service reports as an event,
* in one place staff can read. Enforcement stays with each owner; this only records.
@@@ -118,7 +119,7 @@ function createModerationAudit(db) {
const r = express.Router();
r.get('/', async (req, res) => {
if (!req.user || !staff.can(staffClaims(req.user), 'staff.moderation.logs')) return res.status(403).json({ error: 'forbidden', detail: 'staff.moderation.logs required' });
-- res.set('Cache-Control', 'private, no-store');
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
const filters = { service: req.query.service, action: req.query.action, actor: req.query.actor, target: req.query.target, since: req.query.since, until: req.query.until };
if (req.query.format === 'csv') {
const { items } = await list({ ...filters, limit: CSV_MAX, max: CSV_MAX });
ddiff --git a/server/admin/operator-checklist.js b/server/admin/operator-checklist.js
iindex 43d833ef..3a553cdc 100644
---- a/server/admin/operator-checklist.js
++++ b/server/admin/operator-checklist.js
@@@ -1,4 +1,5 @@
'use strict';
++const cache = require('openvibe-shared/cache-policy');
/**
* Operator parity checklist (roadmap WS-D task 5): every operator job the old single-site admin did,
* and where it is done now. Each item names its places, one of:
@@@ -70,7 +71,7 @@ function router({ statusRows = () => [] } = {}) {
r.get('/', (req, res) => {
let rows = [];
try { rows = statusRows() || []; } catch { rows = []; }
-- res.set('Cache-Control', 'private, no-store').json({ areas: checklist(rows) });
++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ areas: checklist(rows) });
});
return r;
}
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex 9cca4132..d7255dda 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -1,4 +1,5 @@
'use strict';
++const cache = require('openvibe-shared/cache-policy');
// ═══════════════════════════════════════════════════════════════
// Admin Panel — API Routes
@@@ -303,7 +304,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
// Core site settings are owner-only (off-limits to admins).
router.get('/settings', requireOwner, async (req, res) => {
-- res.set('Cache-Control', 'private, no-store'); // the owner sees secrets in clear here
++ res.set('Cache-Control', cache.htmlHeaders({ private: true })); // the owner sees secrets in clear here
try {
const rows = await db.prepare('SELECT * FROM site_settings').all();
const settings = {};
@@@ -399,7 +400,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
// URL Registry
router.get('/url-registry', async (req, res) => {
-- res.set('Cache-Control', 'private, no-store'); // the owner sees secret-typed values in clear here
++ res.set('Cache-Control', cache.htmlHeaders({ private: true })); // the owner sees secret-typed values in clear here
try {
let entries = await urlRegistry.getAllRegistryEntries(db);
// Mask secret-typed registry values (e.g. DEPLOY_CLOUDFLARE_TOKEN) for non-owners.
ddiff --git a/server/admin/staff-api.js b/server/admin/staff-api.js
iindex f1c96e2e..33b3f3c4 100644
---- a/server/admin/staff-api.js
++++ b/server/admin/staff-api.js
@@@ -1,4 +1,5 @@
'use strict';
++const cache = require('openvibe-shared/cache-policy');
/**
* Staff APIs (roadmap WS-D task 4), from the staff capability map in openvibe-contracts:
*
@@@ -33,13 +34,13 @@ function createStaffApi({ db, requireAuth, guard }) {
const r = express.Router();
r.get('/capabilities', requireAuth, (req, res) => {
const claims = staffClaims(req.user);
-- res.set('Cache-Control', 'private, no-store');
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.json({ role: claims.is_owner ? 'owner' : req.user.role || 'user', is_owner: !!claims.is_owner, staff_map: staff.map.version, capabilities: claims.staff_caps || [] });
});
const serviceGuard = guard('network.staff.read');
const list = async (req, res) => {
const service = typeof req.query.service === 'string' && /^[a-z][a-z0-9_-]{0,31}$/.test(req.query.service) ? req.query.service : null;
-- res.set('Cache-Control', 'private, no-store');
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.json({ service, staff: await staffList(db, { service }) });
};
r.get('/moderators', (req, res, next) => {
ddiff --git a/server/analytics/creators.js b/server/analytics/creators.js
iindex 1ad57c16..e13d828b 100644
---- a/server/analytics/creators.js
++++ b/server/analytics/creators.js
@@@ -1,4 +1,5 @@
'use strict';
++const cache = require('openvibe-shared/cache-policy');
/**
* Creator analytics (roadmap WS-E task 6; Contracts 0.68.0 live.stream.ended `stats`,
* network.creator-analytics-result@1, network.analytics.creator.read).
@@@ -107,7 +108,7 @@ function router({ fullGuard }) {
const u = SUBJECT_RE.test(key) ? await db.prepare('SELECT subject_id, is_anon FROM users WHERE subject_id = ?').get(key)
: NAME_RE.test(key) ? await db.prepare('SELECT subject_id, is_anon FROM users WHERE lower(username) = lower(?)').get(key) : null;
if (!u || u.is_anon || !SUBJECT_RE.test(String(u.subject_id || ''))) return http.sendProblem(res, 404, 'analytics.unknown_creator', { detail: 'no such creator', ctx: req.ov });
-- const answer = async (full) => res.set('Cache-Control', full ? 'private, no-store' : 'public, max-age=60').json(await query(db, u.subject_id, { days: req.query.days, full }));
++ const answer = async (full) => res.set('Cache-Control', full ? cache.htmlHeaders({ private: true }) : 'public, max-age=60').json(await query(db, u.subject_id, { days: req.query.days, full }));
const viewer = await viewerOf(req);
if (viewer) return await answer(viewer.subject_id === u.subject_id);
const h = String(req.headers.authorization || '');
ddiff --git a/server/auth/sso-check.js b/server/auth/sso-check.js
iindex 27547287..a190738f 100644
---- a/server/auth/sso-check.js
++++ b/server/auth/sso-check.js
@@@ -1,4 +1,5 @@
'use strict';
++const cache = require('openvibe-shared/cache-policy');
// ═══════════════════════════════════════════════════════════════
// GET /sso/check?origin=https://openvibe.tools — the silent, invisible session check.
//
@@@ -46,7 +47,7 @@ function createSsoCheckRoute(getCtx) {
} catch { /* not signed in */ }
res.removeHeader('X-Frame-Options');
res.setHeader('Content-Security-Policy', `frame-ancestors ${origin}; default-src 'none'; script-src 'unsafe-inline'`);
-- res.setHeader('Cache-Control', 'no-store, private');
++ res.setHeader('Cache-Control', cache.htmlHeaders({ private: true }));
res.setHeader('Vary', 'Cookie');
res.setHeader('X-Robots-Tag', 'noindex');
res.type('html').send(renderCheck(origin, { signedIn, username }));
ddiff --git a/server/developer/routes.js b/server/developer/routes.js
iindex 2907e93d..58d9ec1d 100644
---- a/server/developer/routes.js
++++ b/server/developer/routes.js
@@@ -1,4 +1,5 @@
'use strict';
++const cache = require('openvibe-shared/cache-policy');
/**
* /api/v1/projects — developer projects for signed-in people (roadmap Wave 20 foundation, ADR-014).
* The API Codes (the developer portal) calls with the user's Network access token.
@@@ -53,7 +54,7 @@ function router() {
const r = express.Router();
r.use(http.middleware());
r.use(express.json({ limit: '64kb' }));
-- r.use((req, res, next) => { res.set('Cache-Control', 'private, no-store'); next(); });
++ r.use((req, res, next) => { res.set('Cache-Control', cache.htmlHeaders({ private: true })); next(); });
// Bearer user tokens only. An expired token is refused here even inside the session grace period:
// this API hands out secrets, so it does not slide sessions.
ddiff --git a/server/home/render.js b/server/home/render.js
iindex b40cfe65..0cc2ea14 100644
---- a/server/home/render.js
++++ b/server/home/render.js
@@@ -1,4 +1,5 @@
'use strict';
++const cache = require('openvibe-shared/cache-policy');
// The openvibe.network home page: the static shell (public/index.html) with the network and the
// tool catalog rendered into it on the server, so the page is complete without JavaScript and
// always matches what openvibe.tools actually offers (catalog via server/domains/catalog.js).
@@@ -149,24 +150,24 @@ ${SOON.length ? `<section class="home-sec" aria-labelledby="h-soon"><h2 id="h-so
<div><b>Sign in with OpenVibe</b><p>OAuth 2.0 and FedCM for sites on the network. Source on <a href="https://github.com/OpenVibers" rel="noopener">GitHub</a>.</p></div></div></section>`;
}
--let cache = { key: '', html: '', etag: '' };
++let pageCache = { key: '', html: '', etag: '' };
function render() {
const { catalog } = toolsCatalog.peek();
const act = activity.peek();
const stat = fs.statSync(SHELL);
const key = `${stat.mtimeMs}:${catalog.updated}:${catalog.tools.length}:${act.version}`;
-- if (cache.key === key) return cache;
++ if (pageCache.key === key) return pageCache;
const ld = seo.jsonLdTag(seo.jsonLd.itemList(sitesHeading(), OPEN.map((site) => ({ name: siteName(site), url: `https://${site.host}/`, description: site.what }))));
const html = fs.readFileSync(SHELL, 'utf8').replace('<div id="navbar-mount"></div>', '<div id="navbar-mount"></div>' + require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Sign in', href: '/login' }, { label: 'Themes', href: '/themes' }] })).replace('<!--OV:HOME-->', body(catalog, act)).replace('<!--OV:INTENTS-->', intents(catalog)).replace('<!--OV:CONSTELLATION-->', constellation()).replace('<!--OV:COUNT-->', catalog.tools.length ? String(catalog.tools.length) : 'free').replace('<div id="ov-footer"></div>', require('openvibe-shared/footer').ssr({ service: 'network', variant: 'full' })).replace('</head>', `${ld}\n</head>`);
-- cache = { key, html, etag: '"' + crypto.createHash('sha1').update(html).digest('base64url').slice(0, 20) + '"' };
-- return cache;
++ pageCache = { key, html, etag: '"' + crypto.createHash('sha1').update(html).digest('base64url').slice(0, 20) + '"' };
++ return pageCache;
}
function sendHome(req, res) {
toolsCatalog.getCatalog().catch(() => {}); // refresh in the background; this response uses what is known now
const page = render();
res.set('Content-Type', 'text/html; charset=utf-8');
-- res.set('Cache-Control', 'public, max-age=120, stale-while-revalidate=3600');
++ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 120 }));
res.set('ETag', page.etag);
if (req.headers['if-none-match'] === page.etag) return res.status(304).end();
res.send(page.html);
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 0d56996c..a5df60e0 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -1,4 +1,5 @@
'use strict';
++const cache = require('openvibe-shared/cache-policy');
/**
* Account export and deletion (roadmap WS-B task 7, ADR-033; Contracts 0.71.0).
*
@@@ -436,7 +437,7 @@ function sendError(res, e) {
function routers({ requireAuth, staffClaims, contributeGuard, confirmGuard, dir, notify }) {
const me = express.Router();
me.use(express.json({ limit: '8kb' }));
-- const noStore = (res) => res.set('Cache-Control', 'private, no-store');
++ const noStore = (res) => res.set('Cache-Control', cache.htmlHeaders({ private: true }));
me.post('/export', requireAuth, async (req, res) => {
const db = req.app.locals.db;
try {
ddiff --git a/server/identity/account-merge.js b/server/identity/account-merge.js
iindex f5a4c4b6..12eaa799 100644
---- a/server/identity/account-merge.js
++++ b/server/identity/account-merge.js
@@@ -1,4 +1,5 @@
'use strict';
++const cache = require('openvibe-shared/cache-policy');
/**
* Account merge (roadmap WS-B task 5, ADR-029; Contracts 0.69.0 network.subject.merged@1,
* network.account-merge-result@1, staff.identity.merge).
@@@ -261,7 +262,7 @@ function routers({ requireAuth, staffClaims }) {
me.get('/merges', requireAuth, async (req, res) => {
const db = req.app.locals.db; await ensureSchema(db);
const rows = await db.prepare('SELECT * FROM account_merges WHERE into_user_id = ? ORDER BY merged_at DESC LIMIT 50').all(req.user.id);
-- res.set('Cache-Control', 'private, no-store').json({ merges: rows.map((r) => resultOf(r, false)) });
++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ merges: rows.map((r) => resultOf(r, false)) });
});
const admin = express.Router();
@@@ -282,7 +283,7 @@ function routers({ requireAuth, staffClaims }) {
const db = req.app.locals.db; await ensureSchema(db);
if (!staff.can(staffClaims(req.user), 'staff.identity.merge')) return res.status(403).json({ error: 'forbidden', detail: 'staff.identity.merge required' });
const rows = await db.prepare('SELECT id, from_subject, into_subject, initiated_by, actor_subject, reason, moved, merged_at, split_until, reduced_at FROM account_merges ORDER BY merged_at DESC LIMIT 200').all();
-- res.set('Cache-Control', 'private, no-store').json({ merges: rows });
++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ merges: rows });
});
return { me, admin };
}
ddiff --git a/server/identity/blocks.js b/server/identity/blocks.js
iindex 10fa9957..9aad6abd 100644
---- a/server/identity/blocks.js
++++ b/server/identity/blocks.js
@@@ -1,4 +1,5 @@
'use strict';
++const cache = require('openvibe-shared/cache-policy');
/**
* Platform blocks (roadmap WS-E task 5; Contracts 0.49.0 network.block.changed, network.blocks.read).
*
@@@ -136,7 +137,7 @@ function userRouter(requireAuth) {
const router = express.Router();
router.use(http.middleware());
const send = async (req, res, fn) => {
-- res.set('Cache-Control', 'private, no-store');
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
try { return await fn(req.app.locals.db); } catch (err) {
if (err instanceof BlockError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
console.error('[Blocks]', err.message);
ddiff --git a/server/identity/follows.js b/server/identity/follows.js
iindex 0ca43042..b4ddffec 100644
---- a/server/identity/follows.js
++++ b/server/identity/follows.js
@@@ -1,4 +1,5 @@
'use strict';
++const cache = require('openvibe-shared/cache-policy');
/**
* The follow graph (roadmap WS-E task 4, ADR-030; Contracts 0.65.0 network.follow.created / .deleted,
* network.follow-status-result@1, network.follow-list-result@1, network.follows.read).
@@@ -246,7 +247,7 @@ async function viewerOf(req) {
function routers({ requireAuth, followsGuard }) {
const send = async (req, res, fn) => {
-- res.set('Cache-Control', 'private, no-store');
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
try { return await fn(req.app.locals.db); } catch (err) {
if (err instanceof FollowError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
console.error('[Follows]', err.message);
@@@ -296,7 +297,7 @@ function routers({ requireAuth, followsGuard }) {
const t = await targetOrFail(db, req.params.type, req.params.target);
const viewer = await viewerOf(req);
const body = await status(db, req.params.type, t.subject_id, viewer && !viewer.is_anon ? viewer.subject_id : null);
-- res.set('Cache-Control', viewer ? 'private, no-store' : 'public, max-age=30').set('Vary', 'Authorization, Cookie').json(body);
++ res.set('Cache-Control', viewer ? cache.htmlHeaders({ private: true }) : 'public, max-age=30').set('Vary', 'Authorization, Cookie').json(body);
} catch (err) {
if (err instanceof FollowError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
throw err;
ddiff --git a/server/identity/grants-admin.js b/server/identity/grants-admin.js
iindex 884e40cd..d4ee4bb6 100644
---- a/server/identity/grants-admin.js
++++ b/server/identity/grants-admin.js
@@@ -1,4 +1,5 @@
'use strict';
++const cache = require('openvibe-shared/cache-policy');
/**
* Audited service grants (roadmap WS-D task 3; Contracts 0.48.0 network.principal_grant.changed; a
* developer app's grant is network.grant.changed, from server/developer/store.js).
@@@ -168,8 +169,8 @@ function router(db) {
}
};
const actorOf = (req) => (/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(req.user.subject_id || '')) ? req.user.subject_id : null);
-- r.get('/', async (req, res) => { res.set('Cache-Control', 'private, no-store'); await send(res, async () => ({ grants: await list(db, { client: req.query.client }) })); });
-- r.get('/changes', async (req, res) => { res.set('Cache-Control', 'private, no-store'); await send(res, async () => ({ changes: await changes(db, { client: req.query.client, limit: req.query.limit }) })); });
++ r.get('/', async (req, res) => { res.set('Cache-Control', cache.htmlHeaders({ private: true })); await send(res, async () => ({ grants: await list(db, { client: req.query.client }) })); });
++ r.get('/changes', async (req, res) => { res.set('Cache-Control', cache.htmlHeaders({ private: true })); await send(res, async () => ({ changes: await changes(db, { client: req.query.client, limit: req.query.limit }) })); });
r.post('/', async (req, res) => await send(res, async () => { const out = await grant(db, req.body || {}, actorOf(req)); kick(db); return { grant: out }; }));
r.post('/revoke', async (req, res) => await send(res, async () => { const out = await revoke(db, req.body || {}, actorOf(req)); kick(db); return { grant: out }; }));
return r;
ddiff --git a/server/identity/mod-principals.js b/server/identity/mod-principals.js
iindex 2674e2c1..a1468ee4 100644
---- a/server/identity/mod-principals.js
++++ b/server/identity/mod-principals.js
@@@ -1,4 +1,5 @@
'use strict';
++const cache = require('openvibe-shared/cache-policy');
/**
* Mod principals (roadmap WS-M task 3, ADR-013; Contracts 0.72.0 mods.grant.manage, network.mod-principal@1,
* network.mod.grants_changed@1).
@@@ -169,7 +170,7 @@ function routers({ guard, requireAuth, staffClaims }) {
const audit = async (req, action, detail) => await req.app.locals.db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, action, JSON.stringify(detail));
admin.get('/', requireAuth, staffOnly, async (req, res) => {
const db = req.app.locals.db; ensureSchema(db);
-- res.set('Cache-Control', 'private, no-store').json({ mods: (await Promise.all((await db.prepare('SELECT mod_id FROM mod_principals ORDER BY updated_at DESC LIMIT 500').all()).map(async (r) => await view(db, r.mod_id)))) });
++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ mods: (await Promise.all((await db.prepare('SELECT mod_id FROM mod_principals ORDER BY updated_at DESC LIMIT 500').all()).map(async (r) => await view(db, r.mod_id)))) });
});
admin.post('/:id/grants', requireAuth, staffOnly, async (req, res) => {
const reason = reasoned(req, res); if (!reason) return;
ddiff --git a/server/identity/modules.js b/server/identity/modules.js
iindex 39de473e..aacd8429 100644
---- a/server/identity/modules.js
++++ b/server/identity/modules.js
@@@ -1,4 +1,5 @@
'use strict';
++const cache = require('openvibe-shared/cache-policy');
/**
* Versioned user modules (roadmap Wave 1 item 13, sections 4.3-4.5).
*
@@@ -286,7 +287,7 @@ const revisionHeader = (req) => {
const n = Number(String(h).replace(/^W\//, '').replace(/"/g, ''));
return Number.isInteger(n) && n >= 0 ? n : NaN;
};
--const withEtag = (res, record) => res.set('ETag', `"${record.revision}"`).set('Cache-Control', 'private, no-store');
++const withEtag = (res, record) => res.set('ETag', `"${record.revision}"`).set('Cache-Control', cache.htmlHeaders({ private: true }));
/** User + public routes, mounted at /api/modules. */
function userRouter(requireAuth) {
@@@ -305,7 +306,7 @@ function userRouter(requireAuth) {
const db = req.app.locals.db;
const sid = await subjects.ensureUserSubject(db, req.user);
const rows = await db.prepare('SELECT * FROM user_modules WHERE subject_id = ? ORDER BY namespace').all(sid);
-- res.set('Cache-Control', 'private, no-store').json({ subject: subjectRef(sid), modules: rows.map(toRecord),
++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ subject: subjectRef(sid), modules: rows.map(toRecord),
namespaces: modules.namespaces.map(n => ({ namespace: n.namespace, owner: ownerOf(n.namespace), userWritable: n.writers.includes('user') && !ownerRetired(n.namespace), description: n.description })) });
});
@@@ -372,7 +373,7 @@ function serviceRoutes(router, principals) {
if (Number.isNaN(rev)) return problem(res, { status: 400, code: 'modules.bad_revision' }, req);
const out = await remove(db, sid, req.params.ns, { writer: { type: 'service', id: serviceOf(req) }, expectedRevision: rev, ctx: req.ov });
if (out.status !== 204) return problem(res, out, req);
-- res.set('Cache-Control', 'private, no-store').status(204).end();
++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).status(204).end();
});
}
ddiff --git a/server/index.js b/server/index.js
iindex d20ef425..f680462a 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -1,5 +1,7 @@
'use strict';
++const cache = require('openvibe-shared/cache-policy');
++
// ═══════════════════════════════════════════════════════════════
// openvibe.network — Main Server Entry Point
// Pure identity/account service for the OpenVibe network:
@@@ -764,7 +766,7 @@ app.get(['/', '/index.html'], (req, res) => {
if (req.path === '/index.html') return redirectWithoutHtml(req, res, '/');
return require('./home/render').sendHome(req, res);
});
--app.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(require('./home/render').llmsTxt()));
++app.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(require('./home/render').llmsTxt()));
// The crawl files the front door owes crawlers, built from openvibe-shared/seo (plan T11 lane D), ahead
// of express.static below so they answer the generated copy rather than public/.
app.use(require('./seo/routes').createSeoRoutes({ release }));
@@@ -833,7 +835,7 @@ function serveShared(req, res, next) {
// Override helmet's same-origin policies so other domains can load these scripts
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
res.setHeader('Cross-Origin-Opener-Policy', 'unsafe-none');
-- res.setHeader('Cache-Control', req.query.v === sharedRev.get(name) ? 'public, max-age=31536000, immutable' : 'public, max-age=300, stale-while-revalidate=60');
++ res.setHeader('Cache-Control', cache.assetHeaders(name, { hashed: req.query.v === sharedRev.get(name) }));
res.sendFile(sharedFiles.path(name));
}
app.use('/shared/v1', serveShared);
ddiff --git a/server/seo/routes.js b/server/seo/routes.js
iindex 5e4333d2..de1b0e80 100644
---- a/server/seo/routes.js
++++ b/server/seo/routes.js
@@@ -1,5 +1,6 @@
'use strict';
// Crawl files for the public front door, mounted before express.static.
++const cache = require('openvibe-shared/cache-policy');
const express = require('express');
const fs = require('fs');
const path = require('path');
@@@ -34,10 +35,10 @@ function createSeoRoutes({ release = null } = {}) {
const robots = robotsTxt();
const sitemap = sitemapXml({ release });
r.get('/robots.txt', (_req, res) => {
-- res.set('Content-Type', 'text/plain; charset=utf-8').set('Cache-Control', 'public, max-age=3600').send(robots);
++ res.set('Content-Type', 'text/plain; charset=utf-8').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(robots);
});
r.get('/sitemap.xml', (_req, res) => {
-- res.set('Content-Type', 'application/xml; charset=utf-8').set('Cache-Control', 'public, max-age=3600').send(sitemap);
++ res.set('Content-Type', 'application/xml; charset=utf-8').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(sitemap);
});
return r;
}
ddiff --git a/server/themes/routes.js b/server/themes/routes.js
iindex ad150f89..73e9db27 100644
---- a/server/themes/routes.js
++++ b/server/themes/routes.js
@@@ -1,4 +1,5 @@
'use strict';
++const cache = require('openvibe-shared/cache-policy');
// ═══════════════════════════════════════════════════════════════
// openvibe.network — Theme API Routes
@@@ -129,7 +130,7 @@ router.get('/me/submissions', requireAuth, async (req, res) => {
ensureReview(db);
const userId = req.user.sub || req.user.id;
const themes = (await db.prepare('SELECT id, name, slug, description, mode, variables, tags, review_status, review_note, reviewed_at, created_at FROM themes WHERE author_id = ? AND is_builtin = 0 ORDER BY created_at DESC LIMIT 50').all(userId)).map(parseTheme);
-- res.set('Cache-Control', 'private, no-store').json({ themes });
++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ themes });
});
// ── Get Theme by ID or Slug (approved themes; your own at any status) ──
@@@ -230,7 +231,7 @@ function reviewRouter() {
const status = ['pending', 'approved', 'rejected'].includes(req.query.status) ? req.query.status : 'pending';
const themes = (await db.prepare(`SELECT t.id, t.name, t.slug, t.description, t.mode, t.variables, t.tags, t.review_status, t.review_note, t.reviewed_at, t.created_at, u.username AS author
FROM themes t LEFT JOIN users u ON u.id = t.author_id WHERE t.is_builtin = 0 AND t.review_status = ? ORDER BY t.created_at ASC LIMIT 200`).all(status)).map(parseTheme);
-- res.set('Cache-Control', 'private, no-store').json({ status, themes });
++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ status, themes });
});
r.post('/:id/review', async (req, res) => {
const db = await getDb(req);
ddiff --git a/server/updates/routes.js b/server/updates/routes.js
iindex 0652c43c..0169b441 100644
---- a/server/updates/routes.js
++++ b/server/updates/routes.js
@@@ -1,4 +1,5 @@
'use strict';
++const cache = require('openvibe-shared/cache-policy');
/**
* What shipped, network-wide (roadmap WS-A):
*
@@@ -19,7 +20,7 @@ const CURSOR_RE = /^[A-Za-z0-9_-]{1,120}$/;
function createUpdatesRoutes({ blogUrl = 'http://127.0.0.1:4810', fetchImpl = globalThis.fetch, now = () => Date.now(), log = console } = {}) {
const base = String(blogUrl).replace(/\/+$/, '');
-- const cache = new Map(); // key -> { at, body }
++ const feedCache = new Map(); // key -> { at, body }
function queryOf(q) {
const out = new URLSearchParams();
@@@ -32,14 +33,14 @@ function createUpdatesRoutes({ blogUrl = 'http://127.0.0.1:4810', fetchImpl = gl
/** The feed for a normalised query string: fresh from cache, else Blog, else stale, else null. */
async function feed(qs) {
-- const hit = cache.get(qs);
++ const hit = feedCache.get(qs);
if (hit && now() - hit.at < TTL_MS) return hit.body;
try {
const res = await fetchImpl(`${base}/api/v1/changelog?${qs}`, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(5000) });
if (!res.ok) throw new Error(`blog answered ${res.status}`);
const body = await res.json();
-- if (cache.size >= MAX_KEYS) cache.delete(cache.keys().next().value);
-- cache.set(qs, { at: now(), body });
++ if (feedCache.size >= MAX_KEYS) feedCache.delete(feedCache.keys().next().value);
++ feedCache.set(qs, { at: now(), body });
return body;
} catch (err) {
if (log && log.warn) log.warn(`[updates] changelog unavailable: ${err.message}`);
@@@ -60,7 +61,7 @@ function createUpdatesRoutes({ blogUrl = 'http://127.0.0.1:4810', fetchImpl = gl
const asked = String(req.query.site || '').toLowerCase().trim();
const site = serviceFor(asked);
const body = await feed(queryOf({ service: site, limit: 50 }));
-- res.set('Content-Type', 'text/html; charset=utf-8').set('Cache-Control', 'public, max-age=60');
++ res.set('Content-Type', 'text/html; charset=utf-8').set('Cache-Control', cache.htmlHeaders({ maxAge: 60 }));
// The unfiltered log is the page; a per-site view is a filter of it (followed, not indexed).
if (asked) res.set('X-Robots-Tag', 'noindex, follow');
res.send(renderPage({ site, filtered: Boolean(asked), body }));
ddiff --git a/test/asset-cache.test.js b/test/asset-cache.test.js
nnew file mode 100644
iindex 00000000..cc44abc0
---- /dev/null
++++ b/test/asset-cache.test.js
@@@ -0,0 +1,50 @@
++'use strict';
++// The cache policy comes from openvibe-shared/cache-policy (plan T11 lane D): the /shared/<file> assets are
++// content-addressed with ?v=<sha256 of the file>, so that exact version is immutable for a year while a
++// wrong-but-hex ?v= or none at all gets five minutes plus a day of stale-while-revalidate (server/index.js,
++// serveShared). /robots.txt, /sitemap.xml and /llms.txt are documents and take the HTML policy.
++// node test/asset-cache.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const fs = require('fs');
++const cache = require('openvibe-shared/cache-policy');
++const sharedFiles = require('openvibe-shared/files');
++const { bootServer } = require('./helpers/boot-server');
++
++(async () => {
++ const srv = await bootServer();
++ try {
++ const get = (p) => fetch(srv.base + p).then(async (r) => ({ status: r.status, cache: r.headers.get('cache-control'), body: await r.text() }));
++
++ const file = 'navbar.js';
++ const version = crypto.createHash('sha256').update(fs.readFileSync(sharedFiles.path(file))).digest('hex').slice(0, 12);
++
++ // The version the file's bytes hash to is content-addressed: immutable for a year.
++ const pinned = await get(`/shared/${file}?v=${version}`);
++ assert.strictEqual(pinned.status, 200, '/shared/navbar.js is 200');
++ assert.strictEqual(pinned.cache, 'public, max-age=31536000, immutable');
++ assert.strictEqual(pinned.cache, cache.IMMUTABLE);
++
++ // A wrong-but-hex ?v= and no ?v= at all are not content-addressed: 5 minutes + a day of swr.
++ for (const qs of ['?v=deadbeefdeadbeef', '']) {
++ const r = await get(`/shared/${file}${qs}`);
++ assert.strictEqual(r.status, 200, `/shared/navbar.js${qs} is 200`);
++ assert.strictEqual(r.cache, 'public, max-age=300, stale-while-revalidate=86400');
++ assert.strictEqual(r.cache, cache.assetHeaders(file, { hashed: false }));
++ }
++
++ // Crawl files are documents: the HTML policy at the same max-age.
++ for (const p of ['/robots.txt', '/sitemap.xml', '/llms.txt']) {
++ const r = await get(p);
++ assert.strictEqual(r.status, 200, `${p} is 200`);
++ assert.strictEqual(r.cache, cache.htmlHeaders({ maxAge: 3600 }), `${p} keeps the HTML cache policy`);
++ }
++
++ console.log('asset cache: ?v=<hash> is immutable for a year; a wrong or missing ?v= is 5 min + a day of swr');
++ } catch (err) {
++ console.error(srv.logs());
++ throw err;
++ } finally {
++ await srv.stop();
++ }
++})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });