openvibers/openvibe.network Commits
Move server Cache-Control values onto openvibe-shared cache-policy (#13)
Changes
@@@ -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 = globalq.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}` });@@@ -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 });@@@ -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;}@@@ -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 heretry {const rows = await db.prepare('SELECT * FROM site_settings').all();const settings = {};@@@ -399,7 +400,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {// URL Registryrouter.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 heretry {let entries = await urlRegistry.getAllRegistryEntries(db);// Mask secret-typed registry values (e.g. DEPLOY_CLOUDFLARE_TOKEN) for non-owners.@@@ -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) => {@@@ -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 || '');@@@ -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 }));@@@ -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.@@@ -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 nowconst 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);@@@ -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 {@@@ -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 };}@@@ -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);@@@ -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;@@@ -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;@@@ -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;@@@ -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();});}@@@ -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 scriptsres.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);@@@ -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;}@@@ -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 authorFROM 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);@@@ -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 = glconst 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 }));nnew file mode 100644@@@ -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); });
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
'use strict';
'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'
* 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
* 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
@@ -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)));
q.set('limit', String(Math.min(Math.max(parseInt(req.query.limit, 10) || 100, 1), 500)));
try {
try {
const out = await call('GET', `/api/v1/deliveries?${q}`);
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) });
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) {
} catch (err) {
res.status(502).json({ ok: false, error: `Events did not answer: ${err.message}` });
res.status(502).json({ ok: false, error: `Events did not answer: ${err.message}` });
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
'use strict';
'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,
* 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.
* in one place staff can read. Enforcement stays with each owner; this only records.
@@ -118,7 +119,7 @@ function createModerationAudit(db) {
@@ -118,7 +119,7 @@ function createModerationAudit(db) {
const r = express.Router();
const r = express.Router();
r.get('/', async (req, res) => {
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' });
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 };
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') {
if (req.query.format === 'csv') {
const { items } = await list({ ...filters, limit: CSV_MAX, max: CSV_MAX });
const { items } = await list({ ...filters, limit: CSV_MAX, max: CSV_MAX });
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
'use strict';
'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,
* 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:
* and where it is done now. Each item names its places, one of:
@@ -70,7 +71,7 @@ function router({ statusRows = () => [] } = {}) {
@@ -70,7 +71,7 @@ function router({ statusRows = () => [] } = {}) {
r.get('/', (req, res) => {
r.get('/', (req, res) => {
let rows = [];
let rows = [];
try { rows = statusRows() || []; } catch { 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;
return r;
}
}
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
'use strict';
'use strict';
+const cache = require('openvibe-shared/cache-policy');
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
// Admin Panel — API Routes
// Admin Panel — API Routes
@@ -303,7 +304,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -303,7 +304,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
// Core site settings are owner-only (off-limits to admins).
// Core site settings are owner-only (off-limits to admins).
router.get('/settings', requireOwner, async (req, res) => {
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 {
try {
const rows = await db.prepare('SELECT * FROM site_settings').all();
const rows = await db.prepare('SELECT * FROM site_settings').all();
const settings = {};
const settings = {};
@@ -399,7 +400,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -399,7 +400,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
// URL Registry
// URL Registry
router.get('/url-registry', async (req, res) => {
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 {
try {
let entries = await urlRegistry.getAllRegistryEntries(db);
let entries = await urlRegistry.getAllRegistryEntries(db);
// Mask secret-typed registry values (e.g. DEPLOY_CLOUDFLARE_TOKEN) for non-owners.
// Mask secret-typed registry values (e.g. DEPLOY_CLOUDFLARE_TOKEN) for non-owners.
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
'use strict';
'use strict';
+const cache = require('openvibe-shared/cache-policy');
/**
/**
* Staff APIs (roadmap WS-D task 4), from the staff capability map in openvibe-contracts:
* Staff APIs (roadmap WS-D task 4), from the staff capability map in openvibe-contracts:
*
*
@@ -33,13 +34,13 @@ function createStaffApi({ db, requireAuth, guard }) {
@@ -33,13 +34,13 @@ function createStaffApi({ db, requireAuth, guard }) {
const r = express.Router();
const r = express.Router();
r.get('/capabilities', requireAuth, (req, res) => {
r.get('/capabilities', requireAuth, (req, res) => {
const claims = staffClaims(req.user);
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 || [] });
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 serviceGuard = guard('network.staff.read');
const list = async (req, res) => {
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;
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 }) });
res.json({ service, staff: await staffList(db, { service }) });
};
};
r.get('/moderators', (req, res, next) => {
r.get('/moderators', (req, res, next) => {
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
'use strict';
'use strict';
+const cache = require('openvibe-shared/cache-policy');
/**
/**
* Creator analytics (roadmap WS-E task 6; Contracts 0.68.0 live.stream.ended `stats`,
* Creator analytics (roadmap WS-E task 6; Contracts 0.68.0 live.stream.ended `stats`,
* network.creator-analytics-result@1, network.analytics.creator.read).
* network.creator-analytics-result@1, network.analytics.creator.read).
@@ -107,7 +108,7 @@ function router({ fullGuard }) {
@@ -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)
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;
: 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 });
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);
const viewer = await viewerOf(req);
if (viewer) return await answer(viewer.subject_id === u.subject_id);
if (viewer) return await answer(viewer.subject_id === u.subject_id);
const h = String(req.headers.authorization || '');
const h = String(req.headers.authorization || '');
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
'use strict';
'use strict';
+const cache = require('openvibe-shared/cache-policy');
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
// GET /sso/check?origin=https://openvibe.tools — the silent, invisible session check.
// GET /sso/check?origin=https://openvibe.tools — the silent, invisible session check.
//
//
@@ -46,7 +47,7 @@ function createSsoCheckRoute(getCtx) {
@@ -46,7 +47,7 @@ function createSsoCheckRoute(getCtx) {
} catch { /* not signed in */ }
} catch { /* not signed in */ }
res.removeHeader('X-Frame-Options');
res.removeHeader('X-Frame-Options');
res.setHeader('Content-Security-Policy', `frame-ancestors ${origin}; default-src 'none'; script-src 'unsafe-inline'`);
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('Vary', 'Cookie');
res.setHeader('X-Robots-Tag', 'noindex');
res.setHeader('X-Robots-Tag', 'noindex');
res.type('html').send(renderCheck(origin, { signedIn, username }));
res.type('html').send(renderCheck(origin, { signedIn, username }));
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
'use strict';
'use strict';
+const cache = require('openvibe-shared/cache-policy');
/**
/**
* /api/v1/projects — developer projects for signed-in people (roadmap Wave 20 foundation, ADR-014).
* /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.
* The API Codes (the developer portal) calls with the user's Network access token.
@@ -53,7 +54,7 @@ function router() {
@@ -53,7 +54,7 @@ function router() {
const r = express.Router();
const r = express.Router();
r.use(http.middleware());
r.use(http.middleware());
r.use(express.json({ limit: '64kb' }));
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:
// 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.
// this API hands out secrets, so it does not slide sessions.
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
'use strict';
'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
// 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
// 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).
// 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
@@ -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>`;
<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() {
function render() {
const { catalog } = toolsCatalog.peek();
const { catalog } = toolsCatalog.peek();
const act = activity.peek();
const act = activity.peek();
const stat = fs.statSync(SHELL);
const stat = fs.statSync(SHELL);
const key = `${stat.mtimeMs}:${catalog.updated}:${catalog.tools.length}:${act.version}`;
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 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>`);
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) {
function sendHome(req, res) {
toolsCatalog.getCatalog().catch(() => {}); // refresh in the background; this response uses what is known now
toolsCatalog.getCatalog().catch(() => {}); // refresh in the background; this response uses what is known now
const page = render();
const page = render();
res.set('Content-Type', 'text/html; charset=utf-8');
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);
res.set('ETag', page.etag);
if (req.headers['if-none-match'] === page.etag) return res.status(304).end();
if (req.headers['if-none-match'] === page.etag) return res.status(304).end();
res.send(page.html);
res.send(page.html);
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
'use strict';
'use strict';
+const cache = require('openvibe-shared/cache-policy');
/**
/**
* Account export and deletion (roadmap WS-B task 7, ADR-033; Contracts 0.71.0).
* Account export and deletion (roadmap WS-B task 7, ADR-033; Contracts 0.71.0).
*
*
@@ -436,7 +437,7 @@ function sendError(res, e) {
@@ -436,7 +437,7 @@ function sendError(res, e) {
function routers({ requireAuth, staffClaims, contributeGuard, confirmGuard, dir, notify }) {
function routers({ requireAuth, staffClaims, contributeGuard, confirmGuard, dir, notify }) {
const me = express.Router();
const me = express.Router();
me.use(express.json({ limit: '8kb' }));
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) => {
me.post('/export', requireAuth, async (req, res) => {
const db = req.app.locals.db;
const db = req.app.locals.db;
try {
try {
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
'use strict';
'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,
* 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).
* network.account-merge-result@1, staff.identity.merge).
@@ -261,7 +262,7 @@ function routers({ requireAuth, staffClaims }) {
@@ -261,7 +262,7 @@ function routers({ requireAuth, staffClaims }) {
me.get('/merges', requireAuth, async (req, res) => {
me.get('/merges', requireAuth, async (req, res) => {
const db = req.app.locals.db; await ensureSchema(db);
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);
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();
const admin = express.Router();
@@ -282,7 +283,7 @@ function routers({ requireAuth, staffClaims }) {
@@ -282,7 +283,7 @@ function routers({ requireAuth, staffClaims }) {
const db = req.app.locals.db; await ensureSchema(db);
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' });
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();
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 };
return { me, admin };
}
}
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
'use strict';
'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).
* Platform blocks (roadmap WS-E task 5; Contracts 0.49.0 network.block.changed, network.blocks.read).
*
*
@@ -136,7 +137,7 @@ function userRouter(requireAuth) {
@@ -136,7 +137,7 @@ function userRouter(requireAuth) {
const router = express.Router();
const router = express.Router();
router.use(http.middleware());
router.use(http.middleware());
const send = async (req, res, fn) => {
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) {
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 });
if (err instanceof BlockError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
console.error('[Blocks]', err.message);
console.error('[Blocks]', err.message);
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
'use strict';
'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,
* 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).
* network.follow-status-result@1, network.follow-list-result@1, network.follows.read).
@@ -246,7 +247,7 @@ async function viewerOf(req) {
@@ -246,7 +247,7 @@ async function viewerOf(req) {
function routers({ requireAuth, followsGuard }) {
function routers({ requireAuth, followsGuard }) {
const send = async (req, res, fn) => {
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) {
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 });
if (err instanceof FollowError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
console.error('[Follows]', err.message);
console.error('[Follows]', err.message);
@@ -296,7 +297,7 @@ function routers({ requireAuth, followsGuard }) {
@@ -296,7 +297,7 @@ function routers({ requireAuth, followsGuard }) {
const t = await targetOrFail(db, req.params.type, req.params.target);
const t = await targetOrFail(db, req.params.type, req.params.target);
const viewer = await viewerOf(req);
const viewer = await viewerOf(req);
const body = await status(db, req.params.type, t.subject_id, viewer && !viewer.is_anon ? viewer.subject_id : null);
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) {
} catch (err) {
if (err instanceof FollowError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
if (err instanceof FollowError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
throw err;
throw err;
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
'use strict';
'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
* 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).
* developer app's grant is network.grant.changed, from server/developer/store.js).
@@ -168,8 +169,8 @@ function router(db) {
@@ -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);
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('/', 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 }; }));
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;
return r;
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
'use strict';
'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,
* 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).
* network.mod.grants_changed@1).
@@ -169,7 +170,7 @@ function routers({ guard, requireAuth, staffClaims }) {
@@ -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));
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) => {
admin.get('/', requireAuth, staffOnly, async (req, res) => {
const db = req.app.locals.db; ensureSchema(db);
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) => {
admin.post('/:id/grants', requireAuth, staffOnly, async (req, res) => {
const reason = reasoned(req, res); if (!reason) return;
const reason = reasoned(req, res); if (!reason) return;
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
'use strict';
'use strict';
+const cache = require('openvibe-shared/cache-policy');
/**
/**
* Versioned user modules (roadmap Wave 1 item 13, sections 4.3-4.5).
* Versioned user modules (roadmap Wave 1 item 13, sections 4.3-4.5).
*
*
@@ -286,7 +287,7 @@ const revisionHeader = (req) => {
@@ -286,7 +287,7 @@ const revisionHeader = (req) => {
const n = Number(String(h).replace(/^W\//, '').replace(/"/g, ''));
const n = Number(String(h).replace(/^W\//, '').replace(/"/g, ''));
return Number.isInteger(n) && n >= 0 ? n : NaN;
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. */
/** User + public routes, mounted at /api/modules. */
function userRouter(requireAuth) {
function userRouter(requireAuth) {
@@ -305,7 +306,7 @@ function userRouter(requireAuth) {
@@ -305,7 +306,7 @@ function userRouter(requireAuth) {
const db = req.app.locals.db;
const db = req.app.locals.db;
const sid = await subjects.ensureUserSubject(db, req.user);
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);
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 })) });
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) {
@@ -372,7 +373,7 @@ function serviceRoutes(router, principals) {
if (Number.isNaN(rev)) return problem(res, { status: 400, code: 'modules.bad_revision' }, req);
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 });
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);
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();
});
});
}
}
@@ -1,5 +1,7 @@
@@ -1,5 +1,7 @@
'use strict';
'use strict';
+const cache = require('openvibe-shared/cache-policy');
+
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
// openvibe.network — Main Server Entry Point
// openvibe.network — Main Server Entry Point
// Pure identity/account service for the OpenVibe network:
// Pure identity/account service for the OpenVibe network:
@@ -764,7 +766,7 @@ app.get(['/', '/index.html'], (req, res) => {
@@ -764,7 +766,7 @@ app.get(['/', '/index.html'], (req, res) => {
if (req.path === '/index.html') return redirectWithoutHtml(req, res, '/');
if (req.path === '/index.html') return redirectWithoutHtml(req, res, '/');
return require('./home/render').sendHome(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
// 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/.
// of express.static below so they answer the generated copy rather than public/.
app.use(require('./seo/routes').createSeoRoutes({ release }));
app.use(require('./seo/routes').createSeoRoutes({ release }));
@@ -833,7 +835,7 @@ function serveShared(req, res, next) {
@@ -833,7 +835,7 @@ function serveShared(req, res, next) {
// Override helmet's same-origin policies so other domains can load these scripts
// 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-Resource-Policy', 'cross-origin');
res.setHeader('Cross-Origin-Opener-Policy', 'unsafe-none');
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));
res.sendFile(sharedFiles.path(name));
}
}
app.use('/shared/v1', serveShared);
app.use('/shared/v1', serveShared);
@@ -1,5 +1,6 @@
@@ -1,5 +1,6 @@
'use strict';
'use strict';
// Crawl files for the public front door, mounted before express.static.
// Crawl files for the public front door, mounted before express.static.
+const cache = require('openvibe-shared/cache-policy');
const express = require('express');
const express = require('express');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
@@ -34,10 +35,10 @@ function createSeoRoutes({ release = null } = {}) {
@@ -34,10 +35,10 @@ function createSeoRoutes({ release = null } = {}) {
const robots = robotsTxt();
const robots = robotsTxt();
const sitemap = sitemapXml({ release });
const sitemap = sitemapXml({ release });
r.get('/robots.txt', (_req, res) => {
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) => {
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;
return r;
}
}
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
'use strict';
'use strict';
+const cache = require('openvibe-shared/cache-policy');
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
// openvibe.network — Theme API Routes
// openvibe.network — Theme API Routes
@@ -129,7 +130,7 @@ router.get('/me/submissions', requireAuth, async (req, res) => {
@@ -129,7 +130,7 @@ router.get('/me/submissions', requireAuth, async (req, res) => {
ensureReview(db);
ensureReview(db);
const userId = req.user.sub || req.user.id;
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);
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) ──
// ── Get Theme by ID or Slug (approved themes; your own at any status) ──
@@ -230,7 +231,7 @@ function reviewRouter() {
@@ -230,7 +231,7 @@ function reviewRouter() {
const status = ['pending', 'approved', 'rejected'].includes(req.query.status) ? req.query.status : 'pending';
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
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);
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) => {
r.post('/:id/review', async (req, res) => {
const db = await getDb(req);
const db = await getDb(req);
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
'use strict';
'use strict';
+const cache = require('openvibe-shared/cache-policy');
/**
/**
* What shipped, network-wide (roadmap WS-A):
* What shipped, network-wide (roadmap WS-A):
*
*
@@ -19,7 +20,7 @@ const CURSOR_RE = /^[A-Za-z0-9_-]{1,120}$/;
@@ -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 } = {}) {
function createUpdatesRoutes({ blogUrl = 'http://127.0.0.1:4810', fetchImpl = globalThis.fetch, now = () => Date.now(), log = console } = {}) {
const base = String(blogUrl).replace(/\/+$/, '');
const base = String(blogUrl).replace(/\/+$/, '');
- const cache = new Map(); // key -> { at, body }
+ const feedCache = new Map(); // key -> { at, body }
function queryOf(q) {
function queryOf(q) {
const out = new URLSearchParams();
const out = new URLSearchParams();
@@ -32,14 +33,14 @@ function createUpdatesRoutes({ blogUrl = 'http://127.0.0.1:4810', fetchImpl = gl
@@ -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. */
/** The feed for a normalised query string: fresh from cache, else Blog, else stale, else null. */
async function feed(qs) {
async function feed(qs) {
- const hit = cache.get(qs);
+ const hit = feedCache.get(qs);
if (hit && now() - hit.at < TTL_MS) return hit.body;
if (hit && now() - hit.at < TTL_MS) return hit.body;
try {
try {
const res = await fetchImpl(`${base}/api/v1/changelog?${qs}`, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(5000) });
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}`);
if (!res.ok) throw new Error(`blog answered ${res.status}`);
const body = await res.json();
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;
return body;
} catch (err) {
} catch (err) {
if (log && log.warn) log.warn(`[updates] changelog unavailable: ${err.message}`);
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
@@ -60,7 +61,7 @@ function createUpdatesRoutes({ blogUrl = 'http://127.0.0.1:4810', fetchImpl = gl
const asked = String(req.query.site || '').toLowerCase().trim();
const asked = String(req.query.site || '').toLowerCase().trim();
const site = serviceFor(asked);
const site = serviceFor(asked);
const body = await feed(queryOf({ service: site, limit: 50 }));
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).
// 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');
if (asked) res.set('X-Robots-Tag', 'noindex, follow');
res.send(renderPage({ site, filtered: Boolean(asked), body }));
res.send(renderPage({ site, filtered: Boolean(asked), body }));
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -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); });