openvibers/openvibe.chat Commits

Serve Chat's cache headers through openvibe-shared/cache-policy (#3)

OP OpenVibers committed 2 Oct 2026 · 02eb132299 · 11 files, +84 −28

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

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

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex dbaaf3a..f316fe3 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -18,7 +18,7 @@
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.2",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.0.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.4.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
},
@@@ -1338,9 +1338,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "2.0.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.0.0",
-- "integrity": "sha512-n5CHC0hfxOo0jo/3kS4O3KiwhirhZDlx5zZTIuPyIf3Dq0yI/R6R64btx7NYwZe5dI0NjvqzEtnv19m8unwLMg==",
++ "version": "2.4.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.4.0",
++ "integrity": "sha512-DmFlDcVlT/WRza0M4h0xf+WiwM4FsiJ0WarIesOYByE0v/JYZcoIdjYUvVB5GzshJ7QmFHJLnJsNmdJqj7L1kg==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex 954b952..d242df0 100644
---- a/package.json
++++ b/package.json
@@@ -31,7 +31,7 @@
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.2",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.0.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.4.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
},
ddiff --git a/server/chat/routes.js b/server/chat/routes.js
iindex 5c544da..ee6004f 100644
---- a/server/chat/routes.js
++++ b/server/chat/routes.js
@@@ -12,6 +12,7 @@
* GET /api/chat/user/:username/profile - Get user profile card data
*/
const express = require('express');
++const cache = require('openvibe-shared/cache-policy');
const db = require('../db/database');
const ctx = require('../live-context');
const { optionalAuth, requireAuth } = require('../auth/auth');
@@@ -27,7 +28,7 @@ const { limits } = require('../net/actor-limits');
*/
async function withoutBlocked(req, res, rows) {
if (!req.user) return rows;
-- res.set('Cache-Control', 'private, no-store');
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
const hidden = await networkBlocks.blockedUserIds(req.user);
if (!hidden.size) return rows;
return rows.filter((m) => !(m.user_id && hidden.has(Number(m.user_id))));
@@@ -91,15 +92,15 @@ function enrichMessagesWithCosmetics(messages) {
const cosmetics = { getCosmeticProfile: (id) => ctx.getCosmeticProfile(id) };
const tags = { getTagProfile: (id) => ctx.getTagProfile(id) };
if (!Array.isArray(messages)) return messages;
-- const cache = new Map();
++ const profileCache = new Map();
for (const m of messages) {
if (!m || !m.user_id) continue;
-- let prof = cache.get(m.user_id);
++ let prof = profileCache.get(m.user_id);
if (!prof) {
prof = {};
try { if (cosmetics) Object.assign(prof, cosmetics.getCosmeticProfile(m.user_id) || {}); } catch { /* */ }
try { if (tags) prof.tag = tags.getTagProfile(m.user_id) || null; } catch { /* */ }
-- cache.set(m.user_id, prof);
++ profileCache.set(m.user_id, prof);
}
if (prof.nameFX) m.nameFX = prof.nameFX;
if (prof.particleFX) m.particleFX = prof.particleFX;
@@@ -895,7 +896,7 @@ router.get('/me/export', requireAuth, exportLimit, async (req, res) => {
}
const lines = rows.slice(0, MAX).map((m) => ({ id: m.id, timestamp: m.timestamp, message: m.message, message_type: m.message_type || 'chat', stream_id: m.stream_id || null, stream_title: m.stream_title || null, is_global: !!m.is_global }));
const stamp = new Date().toISOString().slice(0, 10);
-- res.set('Cache-Control', 'private, no-store');
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
if (req.query.format === 'csv') {
const header = 'id,timestamp,message,message_type,stream_id,stream_title,is_global\n';
res.set('Content-Disposition', `attachment; filename="my-chat-${stamp}.csv"`);
ddiff --git a/server/net/turn.js b/server/net/turn.js
iindex d11f714..c95d183 100644
---- a/server/net/turn.js
++++ b/server/net/turn.js
@@@ -13,6 +13,7 @@
*/
const crypto = require('crypto');
const express = require('express');
++const cache = require('openvibe-shared/cache-policy');
const TTL_SECONDS = 3600;
const STUN = { urls: 'stun:stun.l.google.com:19302' };
@@@ -64,7 +65,7 @@ function createIceRoutes() {
const router = express.Router();
router.get('/', optionalAuth, (req, res) => {
const { iceServers: list, ephemeral } = iceServers(req.user ? `u${req.user.id}` : 'anon');
-- res.set('Cache-Control', ephemeral ? 'private, max-age=300' : 'private, no-store').json({ iceServers: list });
++ res.set('Cache-Control', ephemeral ? 'private, max-age=300' : cache.htmlHeaders({ private: true })).json({ iceServers: list });
});
return router;
}
ddiff --git a/server/prefs/routes.js b/server/prefs/routes.js
iindex 334a6df..406eee5 100644
---- a/server/prefs/routes.js
++++ b/server/prefs/routes.js
@@@ -17,6 +17,7 @@
'use strict';
const express = require('express');
++const cache = require('openvibe-shared/cache-policy');
const { requireAuth } = require('../auth/auth');
const { limits } = require('../net/actor-limits');
const { PrefsError } = require('./module-store');
@@@ -33,7 +34,7 @@ function fail(res, err) {
return res.status(500).json({ error: 'Internal server error' });
}
--const reply = (res, out) => res.set('Cache-Control', 'private, no-store').set('ETag', `"${out.revision}"`).json(out);
++const reply = (res, out) => res.set('Cache-Control', cache.htmlHeaders({ private: true })).set('ETag', `"${out.revision}"`).json(out);
function routesFor(store, key) {
const router = express.Router();
ddiff --git a/server/rooms/routes.js b/server/rooms/routes.js
iindex 4d83b82..bb17e1a 100644
---- a/server/rooms/routes.js
++++ b/server/rooms/routes.js
@@@ -23,6 +23,7 @@
* may talk, a demoted one is muted, a blocked person is dropped.
*/
const express = require('express');
++const cache = require('openvibe-shared/cache-policy');
const { requireAuth, optionalAuth } = require('../auth/auth');
const { limits } = require('../net/actor-limits');
const rooms = require('./rooms');
@@@ -78,7 +79,7 @@ async function readable(req, res) {
}
router.get('/', optionalAuth, async (req, res) => {
-- try { res.set('Cache-Control', 'private, no-store').json(await rooms.list(req.user || null, { limit: req.query.limit })); } catch (err) { send(res, err); }
++ try { res.set('Cache-Control', cache.htmlHeaders({ private: true })).json(await rooms.list(req.user || null, { limit: req.query.limit })); } catch (err) { send(res, err); }
});
router.post('/', requireAuth, createLimit, async (req, res) => {
@@@ -88,7 +89,7 @@ router.post('/', requireAuth, createLimit, async (req, res) => {
router.get('/:slug', optionalAuth, async (req, res) => {
const room = await readable(req, res); if (!room) return;
const a = await rooms.access(room, req.user || null);
-- res.set('Cache-Control', 'private, no-store').json({
++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({
room: await rooms.publicRoom(room, { role: a.role }),
can: { read: a.read, post: a.post, join: a.join, talk: a.talk, moderate: a.moderate, manage: a.manage },
...(room.kind === 'call' ? { call: callOf(room) } : {}),
@@@ -104,7 +105,7 @@ router.get('/:slug/messages', optionalAuth, async (req, res) => {
const room = await readable(req, res); if (!room) return;
try {
const messages = await rooms.history(room, { before: req.query.before ?? null, after: req.query.after ?? null, limit: req.query.limit });
-- res.set('Cache-Control', 'private, no-store').json({ messages, latest_id: messages.length ? messages[messages.length - 1].id : null });
++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ messages, latest_id: messages.length ? messages[messages.length - 1].id : null });
} catch (err) { send(res, err); }
});
@@@ -150,7 +151,7 @@ router.post('/:slug/read', requireAuth, readLimit, async (req, res) => {
router.get('/:slug/members', optionalAuth, async (req, res) => {
const room = await readable(req, res); if (!room) return;
const moderate = (await rooms.access(room, req.user || null)).moderate;
-- res.set('Cache-Control', 'private, no-store').json({ members: (await rooms.members(room, { seen: moderate })).filter((m) => m.role !== 'blocked' || moderate) });
++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ members: (await rooms.members(room, { seen: moderate })).filter((m) => m.role !== 'blocked' || moderate) });
});
router.post('/:slug/members', requireAuth, manageLimit, async (req, res) => {
@@@ -170,7 +171,7 @@ router.post('/:slug/members', requireAuth, manageLimit, async (req, res) => {
router.get('/:slug/attachments', requireAuth, async (req, res) => {
const room = await readable(req, res); if (!room) return;
if (!(await rooms.access(room, req.user)).manage) return res.status(403).json({ error: 'Only the room\'s owner sees where it is attached', code: 'rooms.not_owner' });
-- res.set('Cache-Control', 'private, no-store').json({ attachments: await rooms.attachments(room) });
++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ attachments: await rooms.attachments(room) });
});
router.post('/:slug/attachments', requireAuth, personOnly, attachLimit, async (req, res) => {
ddiff --git a/server/web/discovery.js b/server/web/discovery.js
iindex d53179e..469a921 100644
---- a/server/web/discovery.js
++++ b/server/web/discovery.js
@@@ -12,6 +12,7 @@
*/
const express = require('express');
const seo = require('openvibe-shared/seo');
++const cache = require('openvibe-shared/cache-policy');
const historyStore = require('../chat/history-store');
const rooms = require('../rooms/rooms');
@@@ -75,7 +76,7 @@ function createDiscoveryRoutes({ config }) {
router.get('/robots.txt', (req, res) => {
// The same rules as before, plus the search and AI crawlers the shared kit names by name and
// the sitemap. Every previous Disallow is kept.
-- res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(seo.robotsTxt({
++ res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.robotsTxt({
sitemaps: [abs('/sitemap.xml')],
allow: ['/', '/updates', '/rooms', '/r/'],
disallow: ['/r/*/settings', '/messages', '/settings', '/auth/', '/api/'],
@@@ -83,7 +84,7 @@ function createDiscoveryRoutes({ config }) {
});
router.get('/llms.txt', (req, res) => {
-- res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(seo.llmsTxt({
++ res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.llmsTxt({
name: 'OpenVibe.Chat',
summary: 'OpenVibe.Chat: the network-wide chat room of OpenVibe, plus its rooms and the direct messages of its members.',
details: 'One OpenVibe account works here and on every other OpenVibe site. Anyone can read the global chat and the public rooms without an account; posting needs a signed-in person. The global chat and the public room pages are readable as plain HTML without JavaScript and update live over the site\'s own WebSocket. Direct messages, room settings and the API are per-person and are never listed here.',
@@@ -107,7 +108,7 @@ function createDiscoveryRoutes({ config }) {
const lastmod = p.lastmod ? await p.lastmod() : null;
urls.push({ loc: abs(p.path), ...(lastmod ? { lastmod } : {}), changefreq: p.changefreq, priority: p.priority });
}
-- res.type('application/xml').set('Cache-Control', 'public, max-age=3600').send(seo.sitemapXml(urls));
++ res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.sitemapXml(urls));
});
return router;
ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex 45e87dd..7547c50 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -26,13 +26,17 @@ const LINKS = [
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const hashes = new Map();
--function asset(rel) {
++/** The content hash in a /web asset's URL: what `?v=` must equal for the bytes to be immutable. */
++function assetVersion(rel) {
if (!hashes.has(rel)) {
let v = 'dev';
try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }
hashes.set(rel, v);
}
-- return `/web/${rel}?v=${hashes.get(rel)}`;
++ return hashes.get(rel);
++}
++function asset(rel) {
++ return `/web/${rel}?v=${assetVersion(rel)}`;
}
/** The site's links, with the signed-in person's unread counts (o.counts: { rooms, messages }). */
@@@ -109,4 +113,4 @@ document.addEventListener('DOMContentLoaded', function () {
</html>`;
}
--module.exports = { renderPage, asset, esc, SITE_NAME, DEFAULT_DESCRIPTION, NETWORK_URL };
++module.exports = { renderPage, asset, assetVersion, esc, SITE_NAME, DEFAULT_DESCRIPTION, NETWORK_URL };
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 8e4855a..3622a72 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -31,7 +31,8 @@ const express = require('express');
const rateLimit = require('express-rate-limit');
const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
--const { renderPage, esc, SITE_NAME } = require('./layout');
++const cache = require('openvibe-shared/cache-policy');
++const { renderPage, assetVersion, esc, SITE_NAME } = require('./layout');
const { createSessionRoutes } = require('./session');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const session = require('../auth/network-session');
@@@ -127,7 +128,7 @@ function createWebRoutes({ config }) {
};
const page = async (req, res, status, o) => {
const actor = o.actor || await viewerOf(req);
-- res.status(status).set('Cache-Control', o.cache || 'private, no-store').type('html')
++ res.status(status).set('Cache-Control', o.cache || cache.htmlHeaders({ private: true })).type('html')
.send(renderPage({ config, actor, prefs: await prefsOf(actor), counts: await countsOf(actor), ...o }));
};
const needUser = async (req, res) => {
@@@ -139,7 +140,16 @@ function createWebRoutes({ config }) {
router.use('/auth', createSessionRoutes({ config: webConfig, viewers }));
router.use('/shared', ovServe.handler());
-- router.use('/web', express.static(PUBLIC_DIR, { maxAge: '365d', immutable: true, index: false }));
++ // A /web asset is content-addressed by layout.asset()'s ?v=: only that exact version is immutable,
++ // anything else (a stale link, a hand-typed URL) gets the short public window.
++ router.use('/web', express.static(PUBLIC_DIR, {
++ index: false,
++ 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', cache.assetHeaders(rel, { hashed: !!v && v === assetVersion(rel) }));
++ },
++ }));
// ── Global chat ──
router.get('/', async (req, res) => {
@@@ -594,7 +604,7 @@ ${attachList}`,
// ── What shipped, robots, sitemap ──
router.get('/updates', async (req, res) => await page(req, res, 200, {
-- title: `What shipped on ${SITE_NAME}`, path: '/updates', robots: 'index, follow', cache: 'public, max-age=300',
++ title: `What shipped on ${SITE_NAME}`, path: '/updates', robots: 'index, follow', cache: cache.htmlHeaders({ maxAge: 300 }),
body: frame.updatesBody({ service: 'chat', siteName: SITE_NAME }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,
}));
// ── robots.txt, llms.txt, sitemap.xml: server/web/discovery.js, the shared SEO kit ──
ddiff --git a/server/web/session.js b/server/web/session.js
iindex 134728e..e529f52 100644
---- a/server/web/session.js
++++ b/server/web/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/test/asset-cache.test.js b/test/asset-cache.test.js
nnew file mode 100644
iindex 0000000..3e68d31
---- /dev/null
++++ b/test/asset-cache.test.js
@@@ -0,0 +1,36 @@
++'use strict';
++/**
++ * openvibe.chat's Cache-Control values (openvibe-shared/cache-policy): a /web asset is immutable
++ * only at the content hash its own URL carries (layout.asset()'s ?v=) and gets the module's short
++ * public window otherwise; robots.txt and the public pages carry the module's HTML policy, and a
++ * personalised answer is never cached by anybody.
++ */
++const assert = require('assert');
++const { boot, suite } = require('./helpers');
++const { assetVersion } = require('../server/web/layout');
++
++const t = suite('asset cache');
++let h;
++const SITE = 'https://openvibe.chat';
++const cacheOf = async (path) => (await fetch(`${h.base}${path}`)).headers.get('cache-control');
++
++t('boot', async () => { h = await boot({ env: { CHAT_WEB_URL: SITE } }); });
++
++t('/web asset: immutable only at its own ?v=, the module window otherwise', async () => {
++ const v = assetVersion('chat.css');
++ assert.strictEqual(v, assetVersion('chat.css'), 'the version is the same every time');
++ assert.strictEqual(await cacheOf(`/web/chat.css?v=${v}`), 'public, max-age=31536000, immutable', 'the current hash is immutable');
++ assert.strictEqual(await cacheOf('/web/chat.css?v=deadbeefdeadbeef'), 'public, max-age=300, stale-while-revalidate=86400', 'a hex but wrong ?v= is not immutable');
++ assert.strictEqual(await cacheOf('/web/chat.css'), 'public, max-age=300, stale-while-revalidate=86400', 'no ?v= is not immutable');
++});
++
++t('robots.txt and a public page carry the module\'s HTML policy', async () => {
++ assert.strictEqual(await cacheOf('/robots.txt'), 'public, max-age=3600, stale-while-revalidate=3600');
++ assert.strictEqual(await cacheOf('/updates'), 'public, max-age=300, stale-while-revalidate=3600');
++});
++
++t('a personalised answer is private, no-store', async () => {
++ assert.strictEqual(await cacheOf('/auth/me'), 'private, no-store');
++});
++
++t.run(async () => { if (h && h.close) await h.close(); });