openvibers/openvibe.search Commits

Serve Search cache headers through openvibe-shared/cache-policy (#3)

OP OpenVibers committed 2 Oct 2026 · e304199da8 · 7 files, +75 −15

checks run by GitHub CI (test) · CI ✓ 3/3 · review SHIP by Codex GPT-6 Sol (QUALITY=high) · 2 fix rounds

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

Changes

ddiff --git a/server/api/query.js b/server/api/query.js
iindex 98da735..0443fd6 100644
---- a/server/api/query.js
++++ b/server/api/query.js
@@@ -18,6 +18,7 @@
*/
const crypto = require('crypto');
const express = require('express');
++const cache = require('openvibe-shared/cache-policy');
const { http } = require('openvibe-contracts');
const { toMatch, snippetHtml } = require('../engine/pg');
const { EXPOSURE } = require('../document');
@@@ -179,7 +180,7 @@ function withViewer(auth, handler, limit = null) {
}
// Never cached: a visibility change or deletion must leave results immediately, and a
// personalised answer must never reach a shared cache.
-- res.setHeader('Cache-Control', 'no-store');
++ res.setHeader('Cache-Control', cache.htmlHeaders({ private: true }));
res.setHeader('Vary', 'Authorization, Cookie, X-OV-Subject, X-OV-Groups, X-OV-Entitlements');
const run = async () => {
try {
ddiff --git a/server/web/page.js b/server/web/page.js
iindex 7a8c30a..4fe5b97 100644
---- a/server/web/page.js
++++ b/server/web/page.js
@@@ -9,12 +9,13 @@
* GET /llms.txt what this site is, its public pages and its public endpoints (openvibe-shared/seo)
* GET /sitemap.xml the public pages, with lastmod from the newest public document (never the clock)
*
-- * Every answer is no-store (a document that leaves the index leaves this page at once) and result
++ * Search answers are no-store (a document that leaves the index leaves this page at once) and result
* pages are noindex. Clients that do not ask for HTML get the plain-text route index at /.
*/
const express = require('express');
const ovServe = require('openvibe-shared/serve');
const seo = require('openvibe-shared/seo');
++const cache = require('openvibe-shared/cache-policy');
const { AuthError, ANONYMOUS } = require('../auth');
const { QueryError, one } = require('../api/query');
@@@ -233,25 +234,25 @@ function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.network
const origin = String(baseUrl).replace(/\/+$/, '');
const router = express.Router();
-- router.get('/frame-init.js', (_req, res) => {
-- res.type('application/javascript').set('Cache-Control', 'public, max-age=3600').send(FRAME_INIT);
++ router.get('/frame-init.js', (req, res) => {
++ res.type('application/javascript').set('Cache-Control', cache.assetHeaders(req.path, { hashed: false })).send(FRAME_INIT);
});
// What shipped on OpenVibe.Search: the shared update log every OpenVibe site has.
router.get('/updates', (_req, res) => {
res.setHeader('Content-Security-Policy', CSP);
res.setHeader('X-Frame-Options', 'DENY');
-- res.setHeader('Cache-Control', 'public, max-age=60');
++ res.setHeader('Cache-Control', cache.htmlHeaders({ maxAge: 60 }));
res.type('html').send(layout({ title: 'What shipped on OpenVibe.Search', q: '', owner: '', type: '', body: frame.updatesBody({ service: 'search', siteName: 'OpenVibe.Search' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`, canonical: `${origin}/updates` }));
});
// Crawl files (openvibe-shared/seo): the existing robots rules kept, plus /llms.txt and /sitemap.xml.
router.get('/robots.txt', (_req, res) => {
-- res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(robotsTxt(origin));
++ res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(robotsTxt(origin));
});
router.get('/llms.txt', (_req, res) => {
-- res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(llmsTxt(origin));
++ res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(llmsTxt(origin));
});
router.get('/sitemap.xml', async (_req, res, next) => {
@@@ -261,12 +262,12 @@ function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.network
} catch (err) {
return next(err); // a sitemap that cannot read the index is not a sitemap of this site
}
-- res.type('application/xml').set('Cache-Control', 'public, max-age=3600').send(sitemapXml(origin, { indexLastmod, releasedAt }));
++ res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(sitemapXml(origin, { indexLastmod, releasedAt }));
});
router.get('/', async (req, res, next) => {
const wantsHtml = /\btext\/html\b/.test(String(req.get('accept') || ''));
-- res.setHeader('Cache-Control', 'no-store');
++ res.setHeader('Cache-Control', cache.htmlHeaders({ private: true }));
res.setHeader('Vary', 'Accept, Cookie');
if (!wantsHtml) return res.type('text/plain').send(TEXT_INDEX);
ddiff --git a/test/acl-leak.test.js b/test/acl-leak.test.js
iindex b102e78..5800d9f 100644
---- a/test/acl-leak.test.js
++++ b/test/acl-leak.test.js
@@@ -94,7 +94,7 @@ t('a signed-in stranger sees exactly what anonymous sees in results', async () =
const r = await q(`q=${SECRET}&facets=category`, asUser(mallory));
assert.deepStrictEqual(idsOf(r), ['pub']);
assertNoLeak(r, ['pub']);
-- assert.strictEqual(r.headers.get('cache-control'), 'no-store');
++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
});
t('the ACL subject sees its private and unlisted documents, never drafts, unpublished or deleted', async () => {
ddiff --git a/test/asset-cache.test.js b/test/asset-cache.test.js
nnew file mode 100644
iindex 0000000..e251c43
---- /dev/null
++++ b/test/asset-cache.test.js
@@@ -0,0 +1,58 @@
++'use strict';
++/**
++ * Cache headers come from openvibe-shared/cache-policy (the one cache rule every OpenVibe site uses):
++ * the crawl files and the update log get the estate's short public window with a long
++ * stale-while-revalidate, and the content-addressed Frame files under /shared are immutable for a
++ * year only for the hash of the bytes served — any other ?v=, or none, is never pinned. Search's
++ * Frame files are served by openvibe-shared/serve.js, whose own one-minute revalidation for shared
++ * scripts is kept as it was.
++ */
++const assert = require('assert');
++const serve = require('openvibe-shared/serve');
++const { boot, request, suite } = require('./helpers');
++
++const t = suite('asset-cache');
++let svc;
++
++t('boot', async () => {
++ svc = await boot();
++});
++
++t('the current ?v=<hash> of a shared Frame file is immutable for a year', async () => {
++ const r = await request(svc.base, 'GET', `/shared/navbar.js?v=${serve.hashOf('navbar.js')}`);
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=31536000, immutable');
++});
++
++t('a wrong-but-hex ?v= and no ?v= are a short public window, never pinned', async () => {
++ for (const p of ['/shared/navbar.js?v=deadbeefdeadbeef', '/shared/navbar.js']) {
++ const r = await request(svc.base, 'GET', p);
++ assert.strictEqual(r.status, 200, p);
++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300, stale-while-revalidate=60', p);
++ }
++});
++
++t('the unversioned Frame init uses the shared asset policy even with a version query', async () => {
++ for (const p of ['/frame-init.js', '/frame-init.js?v=deadbeefdeadbeef']) {
++ const r = await request(svc.base, 'GET', p);
++ assert.strictEqual(r.status, 200, p);
++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300, stale-while-revalidate=86400', p);
++ }
++});
++
++t('the crawl files and the update log use the estate HTML policy', async () => {
++ for (const [p, expected] of [
++ ['/robots.txt', 'public, max-age=3600, stale-while-revalidate=3600'],
++ ['/llms.txt', 'public, max-age=3600, stale-while-revalidate=3600'],
++ ['/sitemap.xml', 'public, max-age=3600, stale-while-revalidate=3600'],
++ ['/updates', 'public, max-age=60, stale-while-revalidate=3600'],
++ ]) {
++ const r = await request(svc.base, 'GET', p);
++ assert.strictEqual(r.status, 200, p);
++ assert.strictEqual(r.headers.get('cache-control'), expected, p);
++ }
++});
++
++t('shutdown', async () => { await svc.stop(); });
++
++t.run();
ddiff --git a/test/discovery.test.js b/test/discovery.test.js
iindex d14f626..46f7e59 100644
---- a/test/discovery.test.js
++++ b/test/discovery.test.js
@@@ -26,7 +26,7 @@ t('GET /llms.txt: what the site is, its public pages and its public endpoints, c
const r = await request(svc.base, 'GET', '/llms.txt');
assert.strictEqual(r.status, 200);
assert.match(r.headers.get('content-type'), /text\/plain/);
-- assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=3600');
++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=3600, stale-while-revalidate=3600');
assert.match(r.text, /^# OpenVibe\.Search/m);
assert.ok(r.text.includes(`- [Search](${origin()}/)`), 'the front page');
assert.ok(r.text.includes(`(${origin()}/updates)`), 'the update log');
@@@ -40,7 +40,7 @@ t('GET /robots.txt: every existing Disallow kept, plus the sitemap and the AI/se
const r = await request(svc.base, 'GET', '/robots.txt');
assert.strictEqual(r.status, 200);
assert.match(r.headers.get('content-type'), /text\/plain/);
-- assert.match(r.headers.get('cache-control'), /max-age=3600/);
++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=3600, stale-while-revalidate=3600');
for (const d of ['/?', '/api/', '/internal/']) assert.ok(r.text.includes(`Disallow: ${d}`), `kept Disallow: ${d}`);
assert.ok(r.text.includes('Allow: /$') && r.text.includes('Allow: /updates'), 'the crawlable pages');
assert.ok(r.text.includes(`Sitemap: ${origin()}/sitemap.xml`));
@@@ -51,7 +51,7 @@ t('GET /sitemap.xml: the public pages, lastmod from the newest public document,
const r = await request(svc.base, 'GET', '/sitemap.xml');
assert.strictEqual(r.status, 200);
assert.match(r.headers.get('content-type'), /application\/xml/);
-- assert.match(r.headers.get('cache-control'), /max-age=3600/);
++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=3600, stale-while-revalidate=3600');
assert.ok(r.text.includes(`<loc>${origin()}/</loc>`), 'the front page');
assert.ok(r.text.includes(`<loc>${origin()}/updates</loc>`), 'the update log');
// The front page's lastmod is the newest PUBLIC document's time — not the clock, and not the
ddiff --git a/test/page.test.js b/test/page.test.js
iindex 068e840..2748ce4 100644
---- a/test/page.test.js
++++ b/test/page.test.js
@@@ -32,7 +32,7 @@ t('the front page is a search form, indexable, no-store, with a strict CSP', asy
const canon = r.text.match(/<link rel="canonical" href="([^"]+)">/g) || [];
assert.strictEqual(canon.length, 1);
assert.match(canon[0], /href="https?:\/\/[^"/]+\/">$/);
-- assert.strictEqual(r.headers.get('cache-control'), 'no-store');
++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
assert.match(r.headers.get('content-security-policy'), /default-src 'none'/);
// The OpenVibe Frame (navbar, footer, shipped views) is the only script: from this site or
// openvibe.network, never inline JavaScript (the Frame's config is a JSON data block).
ddiff --git a/test/saved-searches.test.js b/test/saved-searches.test.js
iindex e788bef..6649458 100644
---- a/test/saved-searches.test.js
++++ b/test/saved-searches.test.js
@@@ -59,7 +59,7 @@ t('a person saves a query with filters; the same query again is the same saved s
assert.strictEqual(list.status, 200);
assert.deepStrictEqual(list.body.saved_searches.map(s => s.id), [saved.id]);
assert.strictEqual(list.body.max, 3);
-- assert.strictEqual(list.headers.get('cache-control'), 'no-store');
++ assert.strictEqual(list.headers.get('cache-control'), 'private, no-store');
});
t('running a saved search queries as the person now, with its filters', async () => {