openvibers/openvibe.search Commits

Render Search pages through openvibe-shared/shell with a hashed inline…

OP OpenVibers committed 4 Oct 2026 · a732124c29 · 7 files, +77 −81

Asked: Part of a cross-repository initiative (init-t11-step-2-first-batch-every-1004T0752): T11 step 2 (first batch): every server-rendered page in OpenVibe.Tips, OpenVibe.VIP, OpenVibe.Codes, OpenVibe.Search and OpenVibe.Community renders its document through openvibe-shared/shell (shell.page), released …
Harness job init-t11-step-2-first-0752-s4-083501 (plan T11).

Changes

ddiff --git a/deploy/nginx/search.openvibe.network.conf b/deploy/nginx/search.openvibe.network.conf
iindex c20329b..de136a2 100644
---- a/deploy/nginx/search.openvibe.network.conf
++++ b/deploy/nginx/search.openvibe.network.conf
@@@ -82,10 +82,9 @@ server {
location = /robots.txt { proxy_pass http://127.0.0.1:4710; }
location = /llms.txt { proxy_pass http://127.0.0.1:4710; }
location = /sitemap.xml { proxy_pass http://127.0.0.1:4710; }
-- # The OpenVibe Frame's same-origin init script, and what shipped on this site (the shared log).
-- location = /frame-init.js { limit_except GET { deny all; } proxy_pass http://127.0.0.1:4710; }
# This site's own pinned copy of the Frame's browser files (openvibe-shared/serve).
location ^~ /shared/ { limit_except GET { deny all; } proxy_pass http://127.0.0.1:4710; }
++ # What shipped on this site (the shared log).
location = /updates {
limit_except GET { deny all; }
limit_req zone=ovsearch_query burst=40 nodelay;
ddiff --git a/package-lock.json b/package-lock.json
iindex 883ed6b..5d2988b 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,7 +15,7 @@
"express": "^4.21.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
"pg": "^8.23.0"
},
"devDependencies": {
@@@ -1069,9 +1069,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "2.5.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
-- "integrity": "sha512-fHuUS6SXM7ezatfAyqLm1V8B6puV9YXSnz6aG6XQbWx28qghRCCHeUF3lr4nxiCGdn9SyikYtIyLGL5r1ycp9Q==",
++ "version": "2.6.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
++ "integrity": "sha512-IKKvWTM/9UonQ4msrzM4yir0QcTUNbhyqeN1r5ywWavU4VTsL1sI5QdHQlxge7BAsMrQ4OvMEPlHjuAfUJM3rg==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex aa0dbab..29929aa 100644
---- a/package.json
++++ b/package.json
@@@ -27,7 +27,7 @@
"express": "^4.21.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
"pg": "^8.23.0"
},
"engines": {
ddiff --git a/server/web/page.js b/server/web/page.js
iindex 4fe5b97..9b88a5f 100644
---- a/server/web/page.js
++++ b/server/web/page.js
@@@ -27,32 +27,31 @@ const SITE_DESCRIPTION = "Search what the OpenVibe network's services have publi
let RELEASE = 'dev';
function setRelease(id) { if (id) RELEASE = String(id); }
--// The OpenVibe Frame (navbar, footer, "shipped" views, themes) comes from openvibe.network; its init is
--// /frame-init.js (same origin, no inline script), reading the JSON config in #ov-frame-config.
++// The OpenVibe Frame (navbar, footer, "shipped" views, themes) comes from openvibe.network; openvibe-shared/shell
++// renders the document and boots the navbar from one inline script, and FOOTER_INIT upgrades the footer.
const NETWORK = 'https://openvibe.network';
// Cloudflare Web Analytics: Cloudflare injects its beacon at the edge and the privacy text says it may measure
// performance; script-src loads the beacon, connect-src is where it reports.
const CF_BEACON = 'https://static.cloudflareinsights.com', CF_REPORT = 'https://cloudflareinsights.com';
// The Events realtime stream: release notifications (release-watch's EventSource, openvibe-shared 1.17).
const EVENTS = 'https://events.openvibe.network';
--const CSP = `default-src 'none'; script-src 'self' ${NETWORK} ${CF_BEACON}; connect-src 'self' ${NETWORK} ${CF_REPORT} ${EVENTS}; style-src 'unsafe-inline' ${NETWORK}; img-src 'self' data: https:; frame-src ${NETWORK}; form-action 'self' ${NETWORK}; base-uri 'none'; frame-ancestors 'none'`;
++const shell = require('openvibe-shared/shell');
const frame = require('openvibe-shared/frame');
--const FRAME_INIT = `(function () {
-- var tries = 0;
-- function boot() {
-- if (!window.OpenVibeNavbar || !window.OpenVibeFooter) { if (++tries < 60) setTimeout(boot, 100); return; }
-- var el = document.getElementById('ov-frame-config'); var cfg = {};
-- try { cfg = JSON.parse(el ? el.textContent : '{}'); } catch (e) { /* */ }
-- try { if (cfg.navbar) OpenVibeNavbar.init(cfg.navbar); } catch (e) { /* the Frame is optional */ }
-- try { if (cfg.footer) OpenVibeFooter.init(cfg.footer); } catch (e) { /* */ }
-- }
-- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
--})();
--`;
--const FRAME_CONFIG = {
-- navbar: { service: 'search', apiBase: NETWORK, links: [{ label: 'Search', href: '/' }, { label: 'Updates', href: '/updates' }] },
-- footer: { service: 'search', variant: 'compact', mount: '#ov-footer', brandName: 'OpenVibe.Search', updates: '/updates' },
++const LINKS = [{ label: 'Search', href: '/' }, { label: 'Updates', href: '/updates' }];
++// The shell options every page shares: the navbar it boots and the noscript nav and footer it renders.
++const FRAME = {
++ name: SITE_NAME, lang: 'en', navLinks: LINKS,
++ navbar: { service: 'search', apiBase: NETWORK, links: LINKS },
++ footer: { service: 'search', variant: 'compact', updates: '/updates' },
};
++const FOOTER = { service: 'search', variant: 'compact', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' };
++const FOOTER_INIT = `window.addEventListener('DOMContentLoaded', function () { try { OpenVibeFooter.init(${JSON.stringify(FOOTER).replace(/</g, '\\u003c')}); } catch (e) { /* the Frame is optional */ } });`;
++// The two inline scripts are constant (the shell's boot depends only on FRAME), so script-src allows exactly
++// them by hash and still has no 'unsafe-inline'.
++const sha256 = (js) => `'sha256-${require('crypto').createHash('sha256').update(js, 'utf8').digest('base64')}'`;
++const INLINE = [...shell.scripts(FRAME).matchAll(/<script>([\s\S]*?)<\/script>/g)].map((m) => m[1]).concat(FOOTER_INIT);
++const CSP = `default-src 'none'; script-src 'self' ${NETWORK} ${CF_BEACON} ${INLINE.map(sha256).join(' ')}; connect-src 'self' ${NETWORK} ${CF_REPORT} ${EVENTS}; style-src 'unsafe-inline' ${NETWORK}; img-src 'self' data: https:; frame-src ${NETWORK}; form-action 'self' ${NETWORK}; base-uri 'none'; frame-ancestors 'none'`;
++const ICON_LINKS = require('openvibe-shared/app-icon').headTags({ site: 'network', iconBase: `${NETWORK}/assets` }).split('\n').filter((l) => l.startsWith('<link') && !/rel="manifest"/.test(l)).join('\n');
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
@@@ -149,22 +148,20 @@ function sitemapXml(origin, { indexLastmod = null, releasedAt = null } = {}) {
}
function layout({ title, q, owner, type, body, noindex, canonical, jsonLd }) {
-- return `<!doctype html>
--<html lang="en">
--<head>
--<meta charset="utf-8">
--<meta name="viewport" content="width=device-width, initial-scale=1">
--<title>${esc(title)}</title>
--${noindex ? '<meta name="robots" content="noindex, nofollow">\n' : ''}${canonical && !noindex ? `<link rel="canonical" href="${esc(canonical)}">\n` : ''}<meta name="description" content="Search what the OpenVibe network's services have published.">
--<meta name="color-scheme" content="light dark">
--${jsonLd ? jsonLd + '\n' : ''}${require('openvibe-shared/app-icon').headTags({ site: 'network', iconBase: `${NETWORK}/assets` }).split('\n').filter((l) => l.startsWith('<link') && !/rel="manifest"/.test(l)).join('\n')}
--<script src="${ovServe.url('theme-loader.js')}" defer></script>
--<script src="${ovServe.url('navbar.js')}" defer></script>
--<script src="${ovServe.url('footer.js')}" defer></script>
--<script src="/frame-init.js" defer></script>
--<meta name="ov-boost" content="search@${esc(RELEASE)}">
--<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>
--<style>
++ const url = noindex ? undefined : canonical;
++ return shell.page({
++ ...FRAME,
++ title, description: SITE_DESCRIPTION, canonical: url, jsonLd,
++ robots: noindex ? 'noindex, nofollow' : 'index, follow, max-image-preview:large, max-snippet:-1',
++ // The AI summary (and its WebPage JSON-LD) only where the page may be indexed, like the rest of the JSON-LD.
++ ...(noindex ? {} : { summary: SITE_DESCRIPTION, url }),
++ head: [
++ '<meta name="color-scheme" content="light dark">',
++ ICON_LINKS,
++ `<meta name="ov-boost" content="search@${esc(RELEASE)}">`,
++ `<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
++ `<script>${FOOTER_INIT}</script>`,
++ `<style>
/* The network theme (the Frame's theme loader sets these tokens on <html>) wins; the values here are the
defaults when it does not load. Search's own names follow them, so the page and the shared widgets
(shipped.js, the navbar) always agree on background and text. */
@@@ -190,11 +187,9 @@ mark { background: var(--mark); color: inherit; }
.more { display: inline-block; margin-top: 16px; color: var(--accent); }
.page-note { margin-top: 40px; color: var(--muted); font-size: .85rem; }
.page-note a { color: inherit; }
--</style>
--</head>
--<body>
--<div id="navbar-mount"></div>
--${frame.noscriptNav({ name: 'OpenVibe.Search', links: [{ label: 'Search', href: '/' }, { label: 'Updates', href: '/updates' }] })}
++</style>`,
++ ].join('\n'),
++ body: `<div id="navbar-mount"></div>
<main id="main">
<h1><a href="/" style="color:inherit;text-decoration:none">OpenVibe.Search</a></h1>
<p class="lede">Search what the OpenVibe network's services have published. Alpha: the index holds only what they have sent so far.</p>
@@@ -208,12 +203,9 @@ ${body}
JSON API: <a href="/api/v1/search?q=${encodeURIComponent(q || '')}">/api/v1/search</a>.
Source: <a href="https://github.com/OpenVibers/OpenVibe.Search">OpenVibers/OpenVibe.Search</a>.</p>
</section>
--</main>
--${frame.footer({ service: 'search', variant: 'compact', updates: '/updates' })}
--<script type="application/json" id="ov-frame-config">${JSON.stringify(FRAME_CONFIG).replace(/</g, '\\u003c')}</script>
--</body>
--</html>
--`;
++</main>`,
++ bodyAttributes: { 'data-page': 'search' },
++ });
}
function resultsHtml(results) {
@@@ -234,10 +226,6 @@ 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', 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);
@@@ -311,7 +299,7 @@ function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.network
}
}
// JSON-LD only where the page may be indexed: the plain front page, never a result page.
-- const jsonLd = searching || status !== 200 ? null : homeJsonLd(origin).map(seo.jsonLdTag).join('\n');
++ const jsonLd = searching || status !== 200 ? null : homeJsonLd(origin);
res.status(status).type('html').send(layout({ title: q ? `${q} · OpenVibe.Search` : 'OpenVibe.Search', q, owner, type, body, noindex: searching, canonical: `${origin}/`, jsonLd }));
});
ddiff --git a/test/asset-cache.test.js b/test/asset-cache.test.js
iindex e251c43..a0ce0f1 100644
---- a/test/asset-cache.test.js
++++ b/test/asset-cache.test.js
@@@ -32,14 +32,6 @@ t('a wrong-but-hex ?v= and no ?v= are a short public window, never pinned', asyn
}
});
--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'],
ddiff --git a/test/page.test.js b/test/page.test.js
iindex 2748ce4..0fe40ce 100644
---- a/test/page.test.js
++++ b/test/page.test.js
@@@ -5,6 +5,7 @@
* search engines. Non-HTML clients still get the text route index.
*/
const assert = require('assert');
++const crypto = require('crypto');
const { ids } = require('openvibe-contracts');
const { boot, request, userToken, doc, suite } = require('./helpers');
@@@ -34,24 +35,46 @@ t('the front page is a search form, indexable, no-store, with a strict CSP', asy
assert.match(canon[0], /href="https?:\/\/[^"/]+\/">$/);
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).
++ // Scripts come from this site or openvibe.network; the only inline JavaScript is openvibe-shared/shell's
++ // navbar/runtime boot and the footer init, each allowed by its own sha256 (never 'unsafe-inline').
const csp = r.headers.get('content-security-policy');
// Plus Cloudflare Web Analytics, which Cloudflare injects at the edge (the privacy text discloses it).
-- assert.match(csp, /script-src 'self' https:\/\/openvibe\.network https:\/\/static\.cloudflareinsights\.com;/);
++ assert.match(csp, /script-src 'self' https:\/\/openvibe\.network https:\/\/static\.cloudflareinsights\.com 'sha256-[^']+'/);
// And the Events realtime stream, for release notifications (release-watch, openvibe-shared 1.17).
assert.match(csp, /connect-src 'self' https:\/\/openvibe\.network https:\/\/cloudflareinsights\.com https:\/\/events\.openvibe\.network;/);
-- assert.ok(!/script-src[^;]*unsafe-inline/.test(csp), 'no inline script allowed');
++ assert.ok(!/script-src[^;]*unsafe-inline/.test(csp), 'no inline script allowed by default');
++ const allowed = [...(/script-src ([^;]*)/.exec(csp)[1]).matchAll(/'sha256-([^']+)'/g)].map((m) => m[1]);
const scripts = [...r.text.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)];
++ let inline = 0;
for (const [, attrs, body] of scripts) {
if (/type="application\/(json|ld\+json)"/.test(attrs)) continue;
-- assert.strictEqual(body.trim(), '', 'no inline JavaScript');
const src = (/src="([^"]+)"/.exec(attrs) || [])[1];
-- assert.ok(src && (src.startsWith('/') || src.startsWith('https://openvibe.network/')), `script from this site or openvibe.network: ${src}`);
++ if (src) {
++ assert.strictEqual(body.trim(), '', 'a script with src has no body');
++ assert.ok(src.startsWith('/') || src.startsWith('https://openvibe.network/'), `script from this site or openvibe.network: ${src}`);
++ continue;
++ }
++ inline++;
++ assert.ok(allowed.includes(crypto.createHash('sha256').update(body, 'utf8').digest('base64')), `inline script allowed by its hash: ${body.slice(0, 60)}`);
}
++ assert.strictEqual(inline, 2, 'the shell boot and the footer init');
++ assert.match(r.text, /OpenVibeNavbar\.init\(\{"service":"search"/, 'the shell boots the navbar');
++ assert.match(r.text, /OpenVibeFooter\.init\(\{"service":"search"[^)]*"mount":"#ov-footer"/, 'the footer is upgraded in place');
++ // The rendered head (openvibe-shared/shell): one title, the canonical, robots, the AI summary and JSON-LD.
++ const head = r.text.slice(0, r.text.indexOf('</head>'));
++ assert.strictEqual((head.match(/<title>/g) || []).length, 1);
++ assert.match(head, /<title>OpenVibe\.Search<\/title>/);
++ assert.match(head, /<link rel="canonical" href="https?:\/\/[^"/]+\/">/);
++ assert.match(head, /<meta name="robots" content="index, follow[^"]*">/);
++ assert.match(head, /<meta name="description" content="Search what the OpenVibe network&#39;s services have published\.">/);
++ assert.match(head, /<meta name="ai-summary" content="[^"]+">/);
++ assert.match(head, /<script type="application\/ld\+json">/);
++ assert.ok(head.includes('data-ov-icon="network"') && head.includes('<link rel="apple-touch-icon" href="https://openvibe.network/assets/'), 'the network app icons');
++ assert.ok(head.includes('<meta name="color-scheme" content="light dark">') && head.includes('<style>'), 'the page\'s own head');
// JSON-LD on the front page only: the WebSite (with its SearchAction) and the search application.
const ld = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
-- assert.deepStrictEqual(ld.map((o) => o['@type']), ['WebSite', 'WebApplication']);
++ // (and the WebPage of the AI summary).
++ assert.deepStrictEqual(ld.map((o) => o['@type']), ['WebSite', 'WebApplication', 'WebPage']);
assert.strictEqual(ld[0].potentialAction.target.urlTemplate.endsWith('/?q={search_term_string}'), true);
assert.ok(ld[0].url.endsWith('/') && ld[1].url.endsWith('/'));
assert.ok(r.text.includes('<div id="navbar-mount"></div>') && r.text.includes('id="ov-footer"'), 'the OpenVibe Frame');
@@@ -61,16 +84,10 @@ t('the front page is a search form, indexable, no-store, with a strict CSP', asy
assert.match(r.text, /<meta name="ov-boost" content="search@[^"]+">/);
assert.match(r.text, /<script src="\/shared\/boost\.js\?v=[^"]+" data-main="#main" defer><\/script>/);
assert.ok(r.text.includes('<main id="main">'), 'the changing part');
-- // Sign-in must not bake the current path in: a {path} template returns to whatever page is showing.
-- const frameCfg = JSON.parse(/<script type="application\/json" id="ov-frame-config">([\s\S]*?)<\/script>/.exec(r.text)[1]);
-- assert.ok(!frameCfg.navbar.loginUrl || frameCfg.navbar.loginUrl.includes('{path}'), 'sign-in follows the current page');
});
--t('the Frame init is a same-origin script and /updates is the shared log', async () => {
-- const init = await fetch(`${svc.base}/frame-init.js`);
-- assert.strictEqual(init.status, 200);
-- assert.match(init.headers.get('content-type'), /javascript/);
-- assert.match(await init.text(), /OpenVibeNavbar\.init/);
++t('/updates is the shared log; the old Frame init script is gone', async () => {
++ assert.strictEqual((await fetch(`${svc.base}/frame-init.js`)).status, 404);
const r = await html('/updates');
assert.strictEqual(r.status, 200);
assert.ok(r.text.includes('What shipped on OpenVibe.Search') && r.text.includes('data-ov-shipped="log" data-service="search"'));
@@@ -85,6 +102,7 @@ t('results show public documents only, escaped, linked to their canonical URL, n
assert.strictEqual(r.headers.get('x-robots-tag'), 'noindex, nofollow');
assert.match(r.text, /<meta name="robots" content="noindex, nofollow">/);
assert.ok(!/rel="canonical"/.test(r.text), 'a result page is noindex: no canonical');
++ assert.ok(!/application\/ld\+json|ai-summary/.test(r.text), 'no JSON-LD or AI summary on a result page');
assert.ok(r.text.includes('Kestrel &lt;script&gt;alert(1)&lt;/script&gt; nesting'));
assert.ok(!r.text.includes('<script>alert(1)'));
assert.ok(r.text.includes('href="https://openvibe.wiki/p/kestrel?a=1&amp;b=2"'));
ddiff --git a/test/vhost.test.js b/test/vhost.test.js
iindex ef149a7..d7d1a5d 100644
---- a/test/vhost.test.js
++++ b/test/vhost.test.js
@@@ -27,7 +27,6 @@ t('wildcard certificate and client address from $remote_addr only', () => {
t('/metrics is 404; owner API, webhook and document writes are loopback-only', () => {
assert.match(conf, /location = \/metrics \{ return 404; \}/);
-- assert.match(conf, /location = \/frame-init\.js \{ limit_except GET \{ deny all; \} proxy_pass http:\/\/127\.0\.0\.1:4710; \}/, 'the Frame init script');
assert.match(block('location = /updates'), /limit_except GET \{ deny all; \}/, 'the update log, GET only');
assert.match(conf, /location \^~ \/shared\/ \{ limit_except GET \{ deny all; \} proxy_pass http:\/\/127\.0\.0\.1:4710; \}/, 'the site\'s own Frame files');
assert.match(block('location ^~ /internal/'), loopbackOnly);