openvibers/openvibe.search Commits

The OpenVibe Frame on search.openvibe.network: the shared navbar, footer and themes (scripts only from this site or openvibe.network; the init is the same-origin /frame-init.js reading a JSON config, so the CSP still allows no inline script), what shipped on the front page, and /updates as the shared log; nginx serves both new paths; openvibe-shared 1.11.1

OP OpenVibers committed 24 Sept 2026 · b168c70f99 · 6 files, +86 −13


Changes

ddiff --git a/deploy/nginx/search.openvibe.network.conf b/deploy/nginx/search.openvibe.network.conf
iindex 5be1e69..9589215 100644
---- a/deploy/nginx/search.openvibe.network.conf
++++ b/deploy/nginx/search.openvibe.network.conf
@@@ -79,6 +79,13 @@ server {
proxy_pass http://127.0.0.1:4710;
}
location = /robots.txt { 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; }
++ location = /updates {
++ limit_except GET { deny all; }
++ limit_req zone=ovsearch_query burst=40 nodelay;
++ proxy_pass http://127.0.0.1:4710;
++ }
# ── Public query API (GET only) ─────────────────────────
location ~ ^/api/v1/(search|suggest)$ {
ddiff --git a/package-lock.json b/package-lock.json
iindex 3a0b1b2..c12ecd8 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,7 +15,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.7.0"
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.11.1"
},
"engines": {
"node": ">=22.0.0"
@@@ -985,9 +985,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "1.7.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.7.0",
-- "integrity": "sha512-gF1PABMEGHhtu0WMkJQHwJOvbYzWcdfOi3feGYrCUkLXIZhpeSvrSOD7SF0+nveWhfCQMFjqNU8Q0d90vv4s/A==",
++ "version": "1.11.1",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.11.1",
++ "integrity": "sha512-hBaZlZRVZjkGfsPQE16rQIou2lUuusADea3Evx1tVjB84FTHGHS7ZALfcXaNOk7dR53HOVTJrQ0njmScdreUiw==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex c35fcd7..f50c467 100644
---- a/package.json
++++ b/package.json
@@@ -21,7 +21,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.7.0"
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.11.1"
},
"engines": {
"node": ">=22.0.0"
ddiff --git a/server/web/page.js b/server/web/page.js
iindex 3903870..b831e30 100644
---- a/server/web/page.js
++++ b/server/web/page.js
@@@ -14,7 +14,27 @@ const express = require('express');
const { AuthError, ANONYMOUS } = require('../auth');
const { QueryError, one } = require('../api/query');
--const CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
++// 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.
++const NETWORK = 'https://openvibe.network';
++const CSP = `default-src 'none'; script-src 'self' ${NETWORK}; connect-src 'self' ${NETWORK}; style-src 'unsafe-inline' ${NETWORK}; img-src 'self' data: https:; frame-src ${NETWORK}; form-action 'self' ${NETWORK}; base-uri 'none'; frame-ancestors 'none'`;
++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 esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
@@@ -59,6 +79,10 @@ function layout({ title, q, owner, type, body, noindex }) {
<title>${esc(title)}</title>
${noindex ? '<meta name="robots" content="noindex, nofollow">\n' : ''}<meta name="description" content="Search what the OpenVibe network's services have published.">
<meta name="color-scheme" content="light dark">
++<script src="${NETWORK}/shared/theme-loader.js" defer></script>
++<script src="${NETWORK}/shared/navbar.js" defer></script>
++<script src="${NETWORK}/shared/footer.js" defer></script>
++<script src="/frame-init.js" defer></script>
<style>
:root { --bg: #fff; --fg: #1a1a1a; --muted: #5c5c66; --line: #dcdce3; --accent: #2456d6; --mark: #fff2a8; }
@media (prefers-color-scheme: dark) { :root { --bg: #111317; --fg: #e8e8ec; --muted: #a0a0ab; --line: #2c2f36; --accent: #7aa2ff; --mark: #5a4b00; } }
@@@ -79,11 +103,13 @@ li a.t:hover { text-decoration: underline; }
mark { background: var(--mark); color: inherit; }
.empty, .note { color: var(--muted); }
.more { display: inline-block; margin-top: 16px; color: var(--accent); }
--footer { margin-top: 40px; color: var(--muted); font-size: .85rem; }
--footer a { color: inherit; }
++.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' }] })}
<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>
@@@ -92,12 +118,14 @@ footer a { color: inherit; }
${owner ? `<input type="hidden" name="owner" value="${esc(owner)}">` : ''}${type ? `<input type="hidden" name="type" value="${esc(type)}">` : ''}<button type="submit">Search</button>
</form>
${body}
--<footer>
++<section class="page-note">
<p>Private, draft and deleted documents are never shown. A document that is removed or made private leaves these results at once.
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>
--</footer>
++</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>
`;
@@@ -117,9 +145,21 @@ ${r.snippet_html ? `<p class="snip">${r.snippet_html}</p>` : r.summary ? `<p cla
function pageRouter({ searcher, auth }) {
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);
++ });
++
++ // 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.type('html').send(layout({ title: 'What shipped on OpenVibe.Search', q: '', owner: '', type: '', body: frame.updatesBody({ service: 'search', siteName: 'OpenVibe.Search' }) + frame.shippedScript() }));
++ });
++
router.get('/robots.txt', (_req, res) => {
res.type('text/plain').set('Cache-Control', 'public, max-age=3600')
-- .send('User-agent: *\nAllow: /$\nDisallow: /?\nDisallow: /api/\nDisallow: /internal/\n');
++ .send('User-agent: *\nAllow: /$\nAllow: /updates\nDisallow: /?\nDisallow: /api/\nDisallow: /internal/\n');
});
router.get('/', (req, res, next) => {
@@@ -145,7 +185,7 @@ function pageRouter({ searcher, auth }) {
if (!(err instanceof AuthError)) return next(err);
viewer = null; // a browser page never 401s: an unverifiable credential searches as anonymous
}
-- let body = '<p class="note">Type some words to search. Filters: <code>?owner=wiki</code>, <code>?type=page</code>.</p>';
++ let body = '<p class="note">Type some words to search. Filters: <code>?owner=wiki</code>, <code>?type=page</code>.</p>' + frame.shipped({ service: 'search', title: 'Recently shipped on OpenVibe.Search' });
let status = 200;
if (searching) {
try {
ddiff --git a/test/page.test.js b/test/page.test.js
iindex 95af7fa..0faa83e 100644
---- a/test/page.test.js
++++ b/test/page.test.js
@@@ -30,7 +30,31 @@ t('the front page is a search form, indexable, no-store, with a strict CSP', asy
assert.strictEqual(r.headers.get('x-robots-tag'), null);
assert.strictEqual(r.headers.get('cache-control'), 'no-store');
assert.match(r.headers.get('content-security-policy'), /default-src 'none'/);
-- assert.ok(!/<script/i.test(r.text), 'no JavaScript on the page');
++ // 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).
++ const csp = r.headers.get('content-security-policy');
++ assert.match(csp, /script-src 'self' https:\/\/openvibe\.network;/);
++ assert.ok(!/script-src[^;]*unsafe-inline/.test(csp), 'no inline script allowed');
++ const scripts = [...r.text.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)];
++ for (const [, attrs, body] of scripts) {
++ if (/type="application\/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}`);
++ }
++ assert.ok(r.text.includes('<div id="navbar-mount"></div>') && r.text.includes('id="ov-footer"'), 'the OpenVibe Frame');
++ assert.ok(r.text.includes('data-ov-shipped="latest" data-service="search" href="/updates"'), 'what shipped');
++});
++
++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/);
++ 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"'));
++ assert.match(r.headers.get('content-security-policy'), /default-src 'none'/);
});
t('results show public documents only, escaped, linked to their canonical URL, noindex', async () => {
ddiff --git a/test/vhost.test.js b/test/vhost.test.js
iindex 532288f..34ef594 100644
---- a/test/vhost.test.js
++++ b/test/vhost.test.js
@@@ -27,6 +27,8 @@ 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(block('location ^~ /internal/'), loopbackOnly);
assert.match(block('location ^~ /api/v1/owners/'), loopbackOnly);
assert.match(block('location ^~ /api/v1/documents/'), /limit_except GET \{\s*allow 127\.0\.0\.1;\s*allow ::1;\s*deny all;/);