openvibers/openvibe.search Commits
Crawl files through openvibe-shared/seo (plan T11): /llms.txt (what Search is, its public pages and machine-readable endpoints), /robots.txt from the shared builder with every existing rule kept, /sitemap.xml whose lastmods come from the newest public document and the deployed release (never the clock), and WebSite JSON-LD with a SearchAction on the home page. Public data only. nginx proxies the two new paths. test/discovery.test.js fetches each. npm test green (verified).
Changes
@@@ -6,7 +6,8 @@#### The Node service (port 4710, systemd unit openvibe-search) serves:## / the search page (HTML for browsers, a text route index--## otherwise) and /robots.txt. Public, no-store, result pages noindex.++## otherwise) and the crawl files: /robots.txt, /llms.txt,++## /sitemap.xml. Public, no-store, result pages noindex.## /api/v1/search, /api/v1/suggest, GET /api/v1/documents/... public query API (anonymous =## public documents only; the ov_token cookie or a Bearer adds## the caller's ACL matches). Responses are Cache-Control: no-store.@@@ -79,6 +80,8 @@ server {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 = /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).@@@ -57,8 +57,9 @@ function createApp({ config, db, store, engine, auth, outbox, relay, purges, purapp.use(documentsRouter({ store, auth, db, relay, purges, limits }));app.use(queryRouter({ config, store, engine, auth, searcher, limits }));app.use(savedRouter({ config, saved, searcher, auth, limits }));-- // GET / (HTML search page for browsers, the text route index otherwise) and /robots.txt.-- app.use(pageRouter({ searcher, auth, baseUrl: config.baseUrl }));++ // GET / (HTML search page for browsers, the text route index otherwise), /robots.txt, /llms.txt and++ // /sitemap.xml (whose lastmods come from the index and the deployed release, never from the clock).++ app.use(pageRouter({ searcher, auth, baseUrl: config.baseUrl, newestPublic: () => store.newestPublic(), releasedAt: release.full().released_at }));app.use((req, res) => http.sendProblem(res, 404, 'search.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov }));@@@ -38,6 +38,7 @@ function createStore({ db, engine, outbox, purges = null, now = () => Date.now()SUM(CASE WHEN exposure = 3 THEN 1 ELSE 0 END) AS public_listed,SUM(CASE WHEN exposure = 2 THEN 1 ELSE 0 END) AS public_unlisted,SUM(CASE WHEN exposure = 1 THEN 1 ELSE 0 END) AS restricted FROM documents`),++ newestPublic: db.prepare(`SELECT MAX(NULLIF(sort_at, '')) AS lastmod FROM documents WHERE deleted = 0 AND exposure = 3`),};function rowValues(doc, hash, { via, eventId }) {@@@ -197,7 +198,14 @@ function createStore({ db, engine, outbox, purges = null, now = () => Date.now()};}-- return { apply, remove, get, byRid, ownerPage, counts, EXPOSURE_NAME };++ /** The newest content time among the public, indexable documents, ISO 8601, or null on an empty++ * index. This is what the sitemap's front-page lastmod is: a real time from the data, not now(). */++ async function newestPublic() {++ const row = await st.newestPublic.get();++ return row && row.lastmod ? String(row.lastmod) : null;++ }++++ return { apply, remove, get, byRid, ownerPage, counts, newestPublic, EXPOSURE_NAME };}module.exports = { createStore, EXPOSURE_NAME };@@@ -5,16 +5,22 @@* indexable documents only; an ov_token cookie for this host adds its person's ACL matches).** GET / search form; ?q= results, ?owner= / ?type= filters, ?cursor= next page-- * GET /robots.txt the front page may be crawled, result pages and the API not++ * GET /robots.txt the front page and /updates may be crawled, result pages and the API not++ * 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* 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 { AuthError, ANONYMOUS } = require('../auth');const { QueryError, one } = require('../api/query');++const SITE_NAME = 'OpenVibe.Search';++const SITE_DESCRIPTION = "Search what the OpenVibe network's services have published.";++// 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';@@@ -76,7 +82,67 @@ const TEXT_INDEX = ['',].join('\n');--function layout({ title, q, owner, type, body, noindex, canonical }) {++/**++ * The home page's JSON-LD: the WebSite (with the sitelinks SearchAction that is this site's whole++ * purpose) and OpenVibe.Search as the web application it is. Nothing private: both nodes describe++ * the public front page only.++ */++function homeJsonLd(origin) {++ return [++ seo.jsonLd.website({ name: SITE_NAME, url: `${origin}/`, description: SITE_DESCRIPTION, searchUrl: `${origin}/?q={q}` }),++ seo.jsonLd.softwareApp({ name: SITE_NAME, url: `${origin}/`, description: SITE_DESCRIPTION, category: 'SearchApplication' }),++ ];++}++++/** robots.txt: the same Disallows this site already had, plus the sitemap and the AI/search bots by name. */++function robotsTxt(origin) {++ return [++ '# openvibe.search automated-consumer policy: search engines and AI crawlers may read the front',++ '# page and the update log. Result pages carry their own X-Robots-Tag: noindex, and the API, the',++ '# Events webhook and owner writes are not for crawling.',++ seo.robotsTxt({ sitemaps: [`${origin}/sitemap.xml`], allow: ['/$', '/updates'], disallow: ['/?', '/api/', '/internal/'] }),++ ].join('\n');++}++++/** /llms.txt: what this site is, its public pages and its public machine-readable endpoints. */++function llmsTxt(origin) {++ return seo.llmsTxt({++ name: SITE_NAME,++ summary: `The search service of the OpenVibe network: one permission-aware index over what the network's services have published. ${SITE_DESCRIPTION}`,++ details: 'The index holds only what the services have sent so far (Alpha: it is young). Anonymous callers see public, published, indexable documents only; a browser with an ov_token cookie also gets that person\'s own restricted matches. Private, draft and deleted documents are never listed or returned, and a document that is removed or made private leaves the results at once. Result pages are noindex; this file lists pages and public endpoints only, never a person\'s saved searches or the owner API.',++ sections: [++ { title: 'Pages', links: [++ { title: 'Search', url: `${origin}/`, note: 'the search page; ?q= and ?owner=/?type= search, results are noindex' },++ { title: 'What shipped', url: `${origin}/updates`, note: "the site's update log" },++ ] },++ { title: 'Machine-readable', links: [++ { title: 'Search API', url: `${origin}/api/v1/search?q=`, note: 'JSON results; anonymous = public documents only' },++ { title: 'Suggest API', url: `${origin}/api/v1/suggest?q=`, note: 'title suggestions (JSON)' },++ { title: 'Sitemap', url: `${origin}/sitemap.xml` },++ { title: 'robots.txt', url: `${origin}/robots.txt` },++ { title: 'Release', url: `${origin}/release.json`, note: 'the deployed release manifest (JSON)' },++ ] },++ { title: 'The network', links: [++ { title: 'OpenVibe.Network', url: 'https://openvibe.network/', note: 'accounts and the network directory' },++ { title: 'Source', url: 'https://github.com/OpenVibers/OpenVibe.Search' },++ ] },++ ],++ });++}++++/**++ * /sitemap.xml over the public pages. lastmod comes from the data, never from the clock: the front++ * page carries the newest content time among the public, indexable documents (so an empty index++ * leaves it off), and /updates carries the deployed release's date.++ */++function sitemapXml(origin, { indexLastmod = null, releasedAt = null } = {}) {++ const entries = [{ loc: `${origin}/`, ...(indexLastmod ? { lastmod: indexLastmod } : {}), changefreq: 'hourly', priority: 1.0 }];++ if (releasedAt) entries.push({ loc: `${origin}/updates`, lastmod: releasedAt, changefreq: 'weekly', priority: 0.5 });++ else entries.push({ loc: `${origin}/updates` });++ return seo.sitemapXml(entries);++}++++function layout({ title, q, owner, type, body, noindex, canonical, jsonLd }) {return `<!doctype html><html lang="en"><head>@@@ -85,7 +151,7 @@ function layout({ title, q, owner, type, body, noindex, canonical }) {<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">--${require('openvibe-shared/app-icon').headTags({ site: 'network', iconBase: `${NETWORK}/assets` }).split('\n').filter((l) => l.startsWith('<link') && !/rel="manifest"/.test(l)).join('\n')}++${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>@@@ -153,8 +219,10 @@ ${r.snippet_html ? `<p class="snip">${r.snippet_html}</p>` : r.summary ? `<p cla}).join('\n')}</ol>`;}--/** baseUrl (config.baseUrl) names the canonical URL of the pages that may be indexed: the front page and /updates. */--function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.network' }) {++/** baseUrl (config.baseUrl) names the canonical URL of the pages that may be indexed: the front page and /updates.++ * newestPublic() reads the newest content time among the public indexable documents (the sitemap's real++ * lastmod); releasedAt is the deployed release's date (/updates' lastmod). */++function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.network', newestPublic = null, releasedAt = null }) {const origin = String(baseUrl).replace(/\/+$/, '');const router = express.Router();@@@ -170,9 +238,23 @@ function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.networkres.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('User-agent: *\nAllow: /$\nAllow: /updates\nDisallow: /?\nDisallow: /api/\nDisallow: /internal/\n');++ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(robotsTxt(origin));++ });++++ router.get('/llms.txt', (_req, res) => {++ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(llmsTxt(origin));++ });++++ router.get('/sitemap.xml', async (_req, res, next) => {++ let indexLastmod = null;++ try {++ indexLastmod = newestPublic ? await newestPublic() : null;++ } 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 }));});router.get('/', async (req, res, next) => {@@@ -220,10 +302,12 @@ function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.networkbody = `<p class="empty">${esc(err.message)}.</p>`;}}-- res.status(status).type('html').send(layout({ title: q ? `${q} · OpenVibe.Search` : 'OpenVibe.Search', q, owner, type, body, noindex: searching, canonical: `${origin}/` }));++ // 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');++ res.status(status).type('html').send(layout({ title: q ? `${q} · OpenVibe.Search` : 'OpenVibe.Search', q, owner, type, body, noindex: searching, canonical: `${origin}/`, jsonLd }));});return router;}--module.exports = { pageRouter, TEXT_INDEX };++module.exports = { pageRouter, TEXT_INDEX, robotsTxt, llmsTxt, sitemapXml, homeJsonLd };nnew file mode 100644@@@ -0,0 +1,69 @@++'use strict';++/**++ * The crawl files at the origin (server/web/page.js + openvibe-shared/seo): /llms.txt, /robots.txt++ * and /sitemap.xml are served from the booted app, cached like the site's other public files, list++ * the public pages and the newest public document's real time (never the clock), keep the robots++ * rules the site already had, and never name a private path or a private document.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, request, doc, suite } = require('./helpers');++++const t = suite('discovery');++let svc;++const alice = ids.newId('user');++const origin = () => svc.config.baseUrl;++++t('boot', async () => {++ svc = await boot();++ // The public document is the newest content the sitemap may use; the private one is NEWER and++ // must be ignored by every file.++ await svc.store.apply(doc({ id: 'crawl-pub', title: 'Published page', canonical_url: 'https://openvibe.wiki/p/pub', updated_at: '2026-09-10T08:30:00Z' }));++ await svc.store.apply(doc({ id: 'crawl-prv', visibility: 'private', acl: { subjects: [alice] }, title: 'Private diary', canonical_url: 'https://openvibe.wiki/p/secret', updated_at: '2026-09-20T08:30:00Z' }));++});++++t('GET /llms.txt: what the site is, its public pages and its public endpoints, cached', async () => {++ 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.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');++ assert.ok(r.text.includes(`(${origin()}/api/v1/search?q=)`), 'the public query API');++ assert.ok(r.text.includes(`(${origin()}/sitemap.xml)`));++ // Nothing private or per-user: no saved searches, no owner API, no Events webhook.++ assert.ok(!/saved-searches|\/api\/v1\/owners|\/internal\/events/.test(r.text));++});++++t('GET /robots.txt: every existing Disallow kept, plus the sitemap and the AI/search bots', async () => {++ 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/);++ 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`));++ for (const bot of ['GPTBot', 'ClaudeBot', 'Googlebot']) assert.ok(r.text.includes(`User-agent: ${bot}`), `names ${bot}`);++});++++t('GET /sitemap.xml: the public pages, lastmod from the newest public document, no private path', async () => {++ 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.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++ // newer private document (2026-09-20), which never appears anywhere in the file.++ const front = /<url><loc>([^<]+)<\/loc><lastmod>([^<]+)<\/lastmod>/.exec(r.text);++ assert.ok(front, 'the front page has a lastmod');++ assert.strictEqual(front[1], `${origin()}/`);++ assert.strictEqual(front[2], '2026-09-10');++ assert.ok(!r.text.includes('2026-09-20'), 'the private document never moves the sitemap');++ for (const p of ['/api/', '/internal/', '/?', 'openvibe.wiki']) assert.ok(!r.text.includes(p), `no ${p}`);++});++++t('shutdown', async () => { await svc.stop(); });++++t.run();@@@ -44,11 +44,16 @@ t('the front page is a search form, indexable, no-store, with a strict CSP', asyassert.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;++ 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}`);}++ // 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']);++ 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');assert.ok(r.text.includes('data-ov-shipped="latest" data-service="search" href="/updates"'), 'what shipped');});
@@ -6,7 +6,8 @@
@@ -6,7 +6,8 @@
##
##
## The Node service (port 4710, systemd unit openvibe-search) serves:
## The Node service (port 4710, systemd unit openvibe-search) serves:
## / the search page (HTML for browsers, a text route index
## / the search page (HTML for browsers, a text route index
-## otherwise) and /robots.txt. Public, no-store, result pages noindex.
+## otherwise) and the crawl files: /robots.txt, /llms.txt,
+## /sitemap.xml. Public, no-store, result pages noindex.
## /api/v1/search, /api/v1/suggest, GET /api/v1/documents/... public query API (anonymous =
## /api/v1/search, /api/v1/suggest, GET /api/v1/documents/... public query API (anonymous =
## public documents only; the ov_token cookie or a Bearer adds
## public documents only; the ov_token cookie or a Bearer adds
## the caller's ACL matches). Responses are Cache-Control: no-store.
## the caller's ACL matches). Responses are Cache-Control: no-store.
@@ -79,6 +80,8 @@ server {
@@ -79,6 +80,8 @@ server {
proxy_pass http://127.0.0.1:4710;
proxy_pass http://127.0.0.1:4710;
}
}
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 = /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).
# 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 = /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).
@@ -57,8 +57,9 @@ function createApp({ config, db, store, engine, auth, outbox, relay, purges, pur
@@ -57,8 +57,9 @@ function createApp({ config, db, store, engine, auth, outbox, relay, purges, pur
app.use(documentsRouter({ store, auth, db, relay, purges, limits }));
app.use(documentsRouter({ store, auth, db, relay, purges, limits }));
app.use(queryRouter({ config, store, engine, auth, searcher, limits }));
app.use(queryRouter({ config, store, engine, auth, searcher, limits }));
app.use(savedRouter({ config, saved, searcher, auth, limits }));
app.use(savedRouter({ config, saved, searcher, auth, limits }));
- // GET / (HTML search page for browsers, the text route index otherwise) and /robots.txt.
- app.use(pageRouter({ searcher, auth, baseUrl: config.baseUrl }));
+ // GET / (HTML search page for browsers, the text route index otherwise), /robots.txt, /llms.txt and
+ // /sitemap.xml (whose lastmods come from the index and the deployed release, never from the clock).
+ app.use(pageRouter({ searcher, auth, baseUrl: config.baseUrl, newestPublic: () => store.newestPublic(), releasedAt: release.full().released_at }));
app.use((req, res) => http.sendProblem(res, 404, 'search.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov }));
app.use((req, res) => http.sendProblem(res, 404, 'search.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov }));
@@ -38,6 +38,7 @@ function createStore({ db, engine, outbox, purges = null, now = () => Date.now()
@@ -38,6 +38,7 @@ function createStore({ db, engine, outbox, purges = null, now = () => Date.now()
SUM(CASE WHEN exposure = 3 THEN 1 ELSE 0 END) AS public_listed,
SUM(CASE WHEN exposure = 3 THEN 1 ELSE 0 END) AS public_listed,
SUM(CASE WHEN exposure = 2 THEN 1 ELSE 0 END) AS public_unlisted,
SUM(CASE WHEN exposure = 2 THEN 1 ELSE 0 END) AS public_unlisted,
SUM(CASE WHEN exposure = 1 THEN 1 ELSE 0 END) AS restricted FROM documents`),
SUM(CASE WHEN exposure = 1 THEN 1 ELSE 0 END) AS restricted FROM documents`),
+ newestPublic: db.prepare(`SELECT MAX(NULLIF(sort_at, '')) AS lastmod FROM documents WHERE deleted = 0 AND exposure = 3`),
};
};
function rowValues(doc, hash, { via, eventId }) {
function rowValues(doc, hash, { via, eventId }) {
@@ -197,7 +198,14 @@ function createStore({ db, engine, outbox, purges = null, now = () => Date.now()
@@ -197,7 +198,14 @@ function createStore({ db, engine, outbox, purges = null, now = () => Date.now()
};
};
}
}
- return { apply, remove, get, byRid, ownerPage, counts, EXPOSURE_NAME };
+ /** The newest content time among the public, indexable documents, ISO 8601, or null on an empty
+ * index. This is what the sitemap's front-page lastmod is: a real time from the data, not now(). */
+ async function newestPublic() {
+ const row = await st.newestPublic.get();
+ return row && row.lastmod ? String(row.lastmod) : null;
+ }
+
+ return { apply, remove, get, byRid, ownerPage, counts, newestPublic, EXPOSURE_NAME };
}
}
module.exports = { createStore, EXPOSURE_NAME };
module.exports = { createStore, EXPOSURE_NAME };
@@ -5,16 +5,22 @@
@@ -5,16 +5,22 @@
* indexable documents only; an ov_token cookie for this host adds its person's ACL matches).
* indexable documents only; an ov_token cookie for this host adds its person's ACL matches).
*
*
* GET / search form; ?q= results, ?owner= / ?type= filters, ?cursor= next page
* GET / search form; ?q= results, ?owner= / ?type= filters, ?cursor= next page
- * GET /robots.txt the front page may be crawled, result pages and the API not
+ * GET /robots.txt the front page and /updates may be crawled, result pages and the API not
+ * 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
* Every answer is 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 /.
* pages are noindex. Clients that do not ask for HTML get the plain-text route index at /.
*/
*/
const express = require('express');
const express = require('express');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
+const seo = require('openvibe-shared/seo');
const { AuthError, ANONYMOUS } = require('../auth');
const { AuthError, ANONYMOUS } = require('../auth');
const { QueryError, one } = require('../api/query');
const { QueryError, one } = require('../api/query');
+const SITE_NAME = 'OpenVibe.Search';
+const SITE_DESCRIPTION = "Search what the OpenVibe network's services have published.";
+
// The OpenVibe Frame (navbar, footer, "shipped" views, themes) comes from openvibe.network; its init is
// 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.
// /frame-init.js (same origin, no inline script), reading the JSON config in #ov-frame-config.
const NETWORK = 'https://openvibe.network';
const NETWORK = 'https://openvibe.network';
@@ -76,7 +82,67 @@ const TEXT_INDEX = [
@@ -76,7 +82,67 @@ const TEXT_INDEX = [
'',
'',
].join('\n');
].join('\n');
-function layout({ title, q, owner, type, body, noindex, canonical }) {
+/**
+ * The home page's JSON-LD: the WebSite (with the sitelinks SearchAction that is this site's whole
+ * purpose) and OpenVibe.Search as the web application it is. Nothing private: both nodes describe
+ * the public front page only.
+ */
+function homeJsonLd(origin) {
+ return [
+ seo.jsonLd.website({ name: SITE_NAME, url: `${origin}/`, description: SITE_DESCRIPTION, searchUrl: `${origin}/?q={q}` }),
+ seo.jsonLd.softwareApp({ name: SITE_NAME, url: `${origin}/`, description: SITE_DESCRIPTION, category: 'SearchApplication' }),
+ ];
+}
+
+/** robots.txt: the same Disallows this site already had, plus the sitemap and the AI/search bots by name. */
+function robotsTxt(origin) {
+ return [
+ '# openvibe.search automated-consumer policy: search engines and AI crawlers may read the front',
+ '# page and the update log. Result pages carry their own X-Robots-Tag: noindex, and the API, the',
+ '# Events webhook and owner writes are not for crawling.',
+ seo.robotsTxt({ sitemaps: [`${origin}/sitemap.xml`], allow: ['/$', '/updates'], disallow: ['/?', '/api/', '/internal/'] }),
+ ].join('\n');
+}
+
+/** /llms.txt: what this site is, its public pages and its public machine-readable endpoints. */
+function llmsTxt(origin) {
+ return seo.llmsTxt({
+ name: SITE_NAME,
+ summary: `The search service of the OpenVibe network: one permission-aware index over what the network's services have published. ${SITE_DESCRIPTION}`,
+ details: 'The index holds only what the services have sent so far (Alpha: it is young). Anonymous callers see public, published, indexable documents only; a browser with an ov_token cookie also gets that person\'s own restricted matches. Private, draft and deleted documents are never listed or returned, and a document that is removed or made private leaves the results at once. Result pages are noindex; this file lists pages and public endpoints only, never a person\'s saved searches or the owner API.',
+ sections: [
+ { title: 'Pages', links: [
+ { title: 'Search', url: `${origin}/`, note: 'the search page; ?q= and ?owner=/?type= search, results are noindex' },
+ { title: 'What shipped', url: `${origin}/updates`, note: "the site's update log" },
+ ] },
+ { title: 'Machine-readable', links: [
+ { title: 'Search API', url: `${origin}/api/v1/search?q=`, note: 'JSON results; anonymous = public documents only' },
+ { title: 'Suggest API', url: `${origin}/api/v1/suggest?q=`, note: 'title suggestions (JSON)' },
+ { title: 'Sitemap', url: `${origin}/sitemap.xml` },
+ { title: 'robots.txt', url: `${origin}/robots.txt` },
+ { title: 'Release', url: `${origin}/release.json`, note: 'the deployed release manifest (JSON)' },
+ ] },
+ { title: 'The network', links: [
+ { title: 'OpenVibe.Network', url: 'https://openvibe.network/', note: 'accounts and the network directory' },
+ { title: 'Source', url: 'https://github.com/OpenVibers/OpenVibe.Search' },
+ ] },
+ ],
+ });
+}
+
+/**
+ * /sitemap.xml over the public pages. lastmod comes from the data, never from the clock: the front
+ * page carries the newest content time among the public, indexable documents (so an empty index
+ * leaves it off), and /updates carries the deployed release's date.
+ */
+function sitemapXml(origin, { indexLastmod = null, releasedAt = null } = {}) {
+ const entries = [{ loc: `${origin}/`, ...(indexLastmod ? { lastmod: indexLastmod } : {}), changefreq: 'hourly', priority: 1.0 }];
+ if (releasedAt) entries.push({ loc: `${origin}/updates`, lastmod: releasedAt, changefreq: 'weekly', priority: 0.5 });
+ else entries.push({ loc: `${origin}/updates` });
+ return seo.sitemapXml(entries);
+}
+
+function layout({ title, q, owner, type, body, noindex, canonical, jsonLd }) {
return `<!doctype html>
return `<!doctype html>
<html lang="en">
<html lang="en">
<head>
<head>
@@ -85,7 +151,7 @@ function layout({ title, q, owner, type, body, noindex, canonical }) {
@@ -85,7 +151,7 @@ function layout({ title, q, owner, type, body, noindex, canonical }) {
<title>${esc(title)}</title>
<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.">
${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">
<meta name="color-scheme" content="light dark">
-${require('openvibe-shared/app-icon').headTags({ site: 'network', iconBase: `${NETWORK}/assets` }).split('\n').filter((l) => l.startsWith('<link') && !/rel="manifest"/.test(l)).join('\n')}
+${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('theme-loader.js')}" defer></script>
<script src="${ovServe.url('navbar.js')}" defer></script>
<script src="${ovServe.url('navbar.js')}" defer></script>
<script src="${ovServe.url('footer.js')}" defer></script>
<script src="${ovServe.url('footer.js')}" defer></script>
@@ -153,8 +219,10 @@ ${r.snippet_html ? `<p class="snip">${r.snippet_html}</p>` : r.summary ? `<p cla
@@ -153,8 +219,10 @@ ${r.snippet_html ? `<p class="snip">${r.snippet_html}</p>` : r.summary ? `<p cla
}).join('\n')}</ol>`;
}).join('\n')}</ol>`;
}
}
-/** baseUrl (config.baseUrl) names the canonical URL of the pages that may be indexed: the front page and /updates. */
-function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.network' }) {
+/** baseUrl (config.baseUrl) names the canonical URL of the pages that may be indexed: the front page and /updates.
+ * newestPublic() reads the newest content time among the public indexable documents (the sitemap's real
+ * lastmod); releasedAt is the deployed release's date (/updates' lastmod). */
+function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.network', newestPublic = null, releasedAt = null }) {
const origin = String(baseUrl).replace(/\/+$/, '');
const origin = String(baseUrl).replace(/\/+$/, '');
const router = express.Router();
const router = express.Router();
@@ -170,9 +238,23 @@ function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.network
@@ -170,9 +238,23 @@ function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.network
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` }));
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) => {
router.get('/robots.txt', (_req, res) => {
- res.type('text/plain').set('Cache-Control', 'public, max-age=3600')
- .send('User-agent: *\nAllow: /$\nAllow: /updates\nDisallow: /?\nDisallow: /api/\nDisallow: /internal/\n');
+ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(robotsTxt(origin));
+ });
+
+ router.get('/llms.txt', (_req, res) => {
+ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(llmsTxt(origin));
+ });
+
+ router.get('/sitemap.xml', async (_req, res, next) => {
+ let indexLastmod = null;
+ try {
+ indexLastmod = newestPublic ? await newestPublic() : null;
+ } 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 }));
});
});
router.get('/', async (req, res, next) => {
router.get('/', async (req, res, next) => {
@@ -220,10 +302,12 @@ function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.network
@@ -220,10 +302,12 @@ function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.network
body = `<p class="empty">${esc(err.message)}.</p>`;
body = `<p class="empty">${esc(err.message)}.</p>`;
}
}
}
}
- res.status(status).type('html').send(layout({ title: q ? `${q} · OpenVibe.Search` : 'OpenVibe.Search', q, owner, type, body, noindex: searching, canonical: `${origin}/` }));
+ // 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');
+ res.status(status).type('html').send(layout({ title: q ? `${q} · OpenVibe.Search` : 'OpenVibe.Search', q, owner, type, body, noindex: searching, canonical: `${origin}/`, jsonLd }));
});
});
return router;
return router;
}
}
-module.exports = { pageRouter, TEXT_INDEX };
+module.exports = { pageRouter, TEXT_INDEX, robotsTxt, llmsTxt, sitemapXml, homeJsonLd };
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+/**
+ * The crawl files at the origin (server/web/page.js + openvibe-shared/seo): /llms.txt, /robots.txt
+ * and /sitemap.xml are served from the booted app, cached like the site's other public files, list
+ * the public pages and the newest public document's real time (never the clock), keep the robots
+ * rules the site already had, and never name a private path or a private document.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, request, doc, suite } = require('./helpers');
+
+const t = suite('discovery');
+let svc;
+const alice = ids.newId('user');
+const origin = () => svc.config.baseUrl;
+
+t('boot', async () => {
+ svc = await boot();
+ // The public document is the newest content the sitemap may use; the private one is NEWER and
+ // must be ignored by every file.
+ await svc.store.apply(doc({ id: 'crawl-pub', title: 'Published page', canonical_url: 'https://openvibe.wiki/p/pub', updated_at: '2026-09-10T08:30:00Z' }));
+ await svc.store.apply(doc({ id: 'crawl-prv', visibility: 'private', acl: { subjects: [alice] }, title: 'Private diary', canonical_url: 'https://openvibe.wiki/p/secret', updated_at: '2026-09-20T08:30:00Z' }));
+});
+
+t('GET /llms.txt: what the site is, its public pages and its public endpoints, cached', async () => {
+ 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.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');
+ assert.ok(r.text.includes(`(${origin()}/api/v1/search?q=)`), 'the public query API');
+ assert.ok(r.text.includes(`(${origin()}/sitemap.xml)`));
+ // Nothing private or per-user: no saved searches, no owner API, no Events webhook.
+ assert.ok(!/saved-searches|\/api\/v1\/owners|\/internal\/events/.test(r.text));
+});
+
+t('GET /robots.txt: every existing Disallow kept, plus the sitemap and the AI/search bots', async () => {
+ 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/);
+ 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`));
+ for (const bot of ['GPTBot', 'ClaudeBot', 'Googlebot']) assert.ok(r.text.includes(`User-agent: ${bot}`), `names ${bot}`);
+});
+
+t('GET /sitemap.xml: the public pages, lastmod from the newest public document, no private path', async () => {
+ 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.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
+ // newer private document (2026-09-20), which never appears anywhere in the file.
+ const front = /<url><loc>([^<]+)<\/loc><lastmod>([^<]+)<\/lastmod>/.exec(r.text);
+ assert.ok(front, 'the front page has a lastmod');
+ assert.strictEqual(front[1], `${origin()}/`);
+ assert.strictEqual(front[2], '2026-09-10');
+ assert.ok(!r.text.includes('2026-09-20'), 'the private document never moves the sitemap');
+ for (const p of ['/api/', '/internal/', '/?', 'openvibe.wiki']) assert.ok(!r.text.includes(p), `no ${p}`);
+});
+
+t('shutdown', async () => { await svc.stop(); });
+
+t.run();
@@ -44,11 +44,16 @@ t('the front page is a search form, indexable, no-store, with a strict CSP', asy
@@ -44,11 +44,16 @@ t('the front page is a search form, indexable, no-store, with a strict CSP', asy
assert.ok(!/script-src[^;]*unsafe-inline/.test(csp), 'no inline script allowed');
assert.ok(!/script-src[^;]*unsafe-inline/.test(csp), 'no inline script allowed');
const scripts = [...r.text.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)];
const scripts = [...r.text.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)];
for (const [, attrs, body] of scripts) {
for (const [, attrs, body] of scripts) {
- if (/type="application\/json"/.test(attrs)) continue;
+ if (/type="application\/(json|ld\+json)"/.test(attrs)) continue;
assert.strictEqual(body.trim(), '', 'no inline JavaScript');
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}`);
assert.ok(src && (src.startsWith('/') || src.startsWith('https://openvibe.network/')), `script from this site or openvibe.network: ${src}`);
}
}
+ // 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']);
+ 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');
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');
assert.ok(r.text.includes('data-ov-shipped="latest" data-service="search" href="/updates"'), 'what shipped');
});
});