openvibers/openvibe.search Commits
Render Search pages through openvibe-shared/shell with a hashed inline…
Changes
@@@ -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;@@@ -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"@@@ -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": {@@@ -27,32 +27,31 @@ const SITE_DESCRIPTION = "Search what the OpenVibe network's services have publilet 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 thedefaults 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.networkconst 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 }));});@@@ -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'],@@@ -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', asyassert.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'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', asyassert.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, nassert.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 <script>alert(1)</script> nesting'));assert.ok(!r.text.includes('<script>alert(1)'));assert.ok(r.text.includes('href="https://openvibe.wiki/p/kestrel?a=1&b=2"'));@@@ -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);
@@ -82,10 +82,9 @@ server {
@@ -82,10 +82,9 @@ server {
location = /robots.txt { proxy_pass http://127.0.0.1:4710; }
location = /robots.txt { proxy_pass http://127.0.0.1:4710; }
location = /llms.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; }
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).
# 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; }
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 {
location = /updates {
limit_except GET { deny all; }
limit_except GET { deny all; }
limit_req zone=ovsearch_query burst=40 nodelay;
limit_req zone=ovsearch_query burst=40 nodelay;
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
"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-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"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -1069,9 +1069,9 @@
@@ -1069,9 +1069,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
"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-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"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
@@ -27,32 +27,31 @@ const SITE_DESCRIPTION = "Search what the OpenVibe network's services have publi
@@ -27,32 +27,31 @@ const SITE_DESCRIPTION = "Search what the OpenVibe network's services have publi
let RELEASE = 'dev';
let RELEASE = 'dev';
function setRelease(id) { if (id) RELEASE = String(id); }
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';
const NETWORK = 'https://openvibe.network';
// Cloudflare Web Analytics: Cloudflare injects its beacon at the edge and the privacy text says it may measure
// 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.
// performance; script-src loads the beacon, connect-src is where it reports.
const CF_BEACON = 'https://static.cloudflareinsights.com', CF_REPORT = 'https://cloudflareinsights.com';
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).
// The Events realtime stream: release notifications (release-watch's EventSource, openvibe-shared 1.17).
const EVENTS = 'https://events.openvibe.network';
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 = 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
@@ -149,22 +148,20 @@ function sitemapXml(origin, { indexLastmod = null, releasedAt = null } = {}) {
@@ -149,22 +148,20 @@ function sitemapXml(origin, { indexLastmod = null, releasedAt = null } = {}) {
}
}
function layout({ title, q, owner, type, body, noindex, canonical, jsonLd }) {
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
/* 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
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. */
(shipped.js, the navbar) always agree on background and text. */
@@ -190,11 +187,9 @@ mark { background: var(--mark); color: inherit; }
@@ -190,11 +187,9 @@ mark { background: var(--mark); color: inherit; }
.more { display: inline-block; margin-top: 16px; color: var(--accent); }
.more { display: inline-block; margin-top: 16px; color: var(--accent); }
.page-note { margin-top: 40px; color: var(--muted); font-size: .85rem; }
.page-note { margin-top: 40px; color: var(--muted); font-size: .85rem; }
.page-note a { color: inherit; }
.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">
<main id="main">
<h1><a href="/" style="color:inherit;text-decoration:none">OpenVibe.Search</a></h1>
<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>
<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}
@@ -208,12 +203,9 @@ ${body}
JSON API: <a href="/api/v1/search?q=${encodeURIComponent(q || '')}">/api/v1/search</a>.
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>
Source: <a href="https://github.com/OpenVibers/OpenVibe.Search">OpenVibers/OpenVibe.Search</a>.</p>
</section>
</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) {
function resultsHtml(results) {
@@ -234,10 +226,6 @@ function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.network
@@ -234,10 +226,6 @@ function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.network
const origin = String(baseUrl).replace(/\/+$/, '');
const origin = String(baseUrl).replace(/\/+$/, '');
const router = express.Router();
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.
// What shipped on OpenVibe.Search: the shared update log every OpenVibe site has.
router.get('/updates', (_req, res) => {
router.get('/updates', (_req, res) => {
res.setHeader('Content-Security-Policy', CSP);
res.setHeader('Content-Security-Policy', CSP);
@@ -311,7 +299,7 @@ function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.network
@@ -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.
// 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 }));
res.status(status).type('html').send(layout({ title: q ? `${q} · OpenVibe.Search` : 'OpenVibe.Search', q, owner, type, body, noindex: searching, canonical: `${origin}/`, jsonLd }));
});
});
@@ -32,14 +32,6 @@ t('a wrong-but-hex ?v= and no ?v= are a short public window, never pinned', asyn
@@ -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 () => {
t('the crawl files and the update log use the estate HTML policy', async () => {
for (const [p, expected] of [
for (const [p, expected] of [
['/robots.txt', 'public, max-age=3600, stale-while-revalidate=3600'],
['/robots.txt', 'public, max-age=3600, stale-while-revalidate=3600'],
@@ -5,6 +5,7 @@
@@ -5,6 +5,7 @@
* search engines. Non-HTML clients still get the text route index.
* search engines. Non-HTML clients still get the text route index.
*/
*/
const assert = require('assert');
const assert = require('assert');
+const crypto = require('crypto');
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
const { boot, request, userToken, doc, suite } = require('./helpers');
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
@@ -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.match(canon[0], /href="https?:\/\/[^"/]+\/">$/);
assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
assert.match(r.headers.get('content-security-policy'), /default-src 'none'/);
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');
const csp = r.headers.get('content-security-policy');
// Plus Cloudflare Web Analytics, which Cloudflare injects at the edge (the privacy text discloses it).
// 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).
// 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.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)];
const scripts = [...r.text.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)];
+ let inline = 0;
for (const [, attrs, body] of scripts) {
for (const [, attrs, body] of scripts) {
if (/type="application\/(json|ld\+json)"/.test(attrs)) continue;
if (/type="application\/(json|ld\+json)"/.test(attrs)) continue;
- assert.strictEqual(body.trim(), '', 'no inline JavaScript');
const src = (/src="([^"]+)"/.exec(attrs) || [])[1];
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'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.
// 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]));
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.strictEqual(ld[0].potentialAction.target.urlTemplate.endsWith('/?q={search_term_string}'), true);
assert.ok(ld[0].url.endsWith('/') && ld[1].url.endsWith('/'));
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');
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
@@ -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, /<meta name="ov-boost" content="search@[^"]+">/);
assert.match(r.text, /<script src="\/shared\/boost\.js\?v=[^"]+" data-main="#main" defer><\/script>/);
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');
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');
const r = await html('/updates');
assert.strictEqual(r.status, 200);
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.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
@@ -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.strictEqual(r.headers.get('x-robots-tag'), 'noindex, nofollow');
assert.match(r.text, /<meta name="robots" content="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(!/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 <script>alert(1)</script> nesting'));
assert.ok(r.text.includes('Kestrel <script>alert(1)</script> nesting'));
assert.ok(!r.text.includes('<script>alert(1)'));
assert.ok(!r.text.includes('<script>alert(1)'));
assert.ok(r.text.includes('href="https://openvibe.wiki/p/kestrel?a=1&b=2"'));
assert.ok(r.text.includes('href="https://openvibe.wiki/p/kestrel?a=1&b=2"'));
@@ -27,7 +27,6 @@ t('wildcard certificate and client address from $remote_addr only', () => {
@@ -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', () => {
t('/metrics is 404; owner API, webhook and document writes are loopback-only', () => {
assert.match(conf, /location = \/metrics \{ return 404; \}/);
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 = /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(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);
assert.match(block('location ^~ /internal/'), loopbackOnly);