openvibers/openvibe.tools Commits
Bump openvibe-shared to v2.6.0; add llms-full.txt and IndexNow key file
Changes
@@@ -22,7 +22,7 @@"multer": "^1.4.5-lts.1","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.92.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0","pg": "^8.23.0","uuid": "^11.1.0"}@@@ -1335,9 +1335,9 @@}},"node_modules/openvibe-shared": {-- "version": "2.3.1",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",-- "integrity": "sha512-K+MXnB4O+8xIFi+NQGC2SDTvDq3RkzfxQ3bTVx76Y/Rbuq7kC/L5kL3c/06xr9sPYcjyzF3Bhpv5yH2YNfR4Zw==",++ "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"@@@ -25,7 +25,7 @@"multer": "^1.4.5-lts.1","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.92.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0","uuid": "^11.1.0"}}@@@ -21,7 +21,7 @@"multer": "^1.4.5-lts.1","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.92.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0","pdf-lib": "^1.17.1","pg": "^8.23.0","sharp": "^0.33.5",@@@ -1412,9 +1412,9 @@}},"node_modules/openvibe-shared": {-- "version": "2.3.1",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",-- "integrity": "sha512-K+MXnB4O+8xIFi+NQGC2SDTvDq3RkzfxQ3bTVx76Y/Rbuq7kC/L5kL3c/06xr9sPYcjyzF3Bhpv5yH2YNfR4Zw==",++ "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"@@@ -24,7 +24,7 @@"multer": "^1.4.5-lts.1","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.92.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0","pdf-lib": "^1.17.1","sharp": "^0.33.5","uuid": "^11.1.0"@@@ -17,7 +17,7 @@"helmet": "^7.1.0","iovalkey": "^0.4.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0","pg": "^8.23.0"}},@@@ -1129,9 +1129,9 @@}},"node_modules/openvibe-shared": {-- "version": "2.3.1",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",-- "integrity": "sha512-K+MXnB4O+8xIFi+NQGC2SDTvDq3RkzfxQ3bTVx76Y/Rbuq7kC/L5kL3c/06xr9sPYcjyzF3Bhpv5yH2YNfR4Zw==",++ "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"@@@ -20,6 +20,6 @@"express-rate-limit": "^7.1.5","helmet": "^7.1.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1"++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0"}}@@@ -23,6 +23,10 @@ OV_OAUTH_REDIRECT_URI=http://localhost:4001/auth/callback#COOKIE_DOMAIN=.openvibe.tools#COOKIE_SECURE=true++# IndexNow (openvibe-shared/indexnow). With a key (8–128 hex/alphanumeric) the key file is served at++# /<key>.txt so engines can verify the site. Unset: off — nothing is mounted and nothing is sent.++#INDEXNOW_KEY=++# OpenVibe.Media — backs Paste.OpenVibe (pastes stored under app_id 'live')MEDIA_URL=http://127.0.0.1:4100MEDIA_PUBLIC_URL=https://openvibe.media@@@ -20,7 +20,7 @@"jsonwebtoken": "^9.0.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.92.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0","pg": "^8.23.0"}},@@@ -1212,9 +1212,9 @@}},"node_modules/openvibe-shared": {-- "version": "2.3.1",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",-- "integrity": "sha512-K+MXnB4O+8xIFi+NQGC2SDTvDq3RkzfxQ3bTVx76Y/Rbuq7kC/L5kL3c/06xr9sPYcjyzF3Bhpv5yH2YNfR4Zw==",++ "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"@@@ -23,6 +23,6 @@"jsonwebtoken": "^9.0.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.92.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1"++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0"}}@@@ -39,4 +39,8 @@ module.exports = {liveUrl: process.env.LIVE_URL || 'http://127.0.0.1:3000',// OpenVibe.Community owns pastes (roadmap Wave 5); the paste front-end's API goes straight there.communityUrl: (process.env.OV_COMMUNITY_INTERNAL_URL || 'http://127.0.0.1:4200').replace(/\/$/, ''),++++ // IndexNow (openvibe-shared/indexnow): with INDEXNOW_KEY set the key file is served at++ // /<key>.txt so engines can verify this site. Unset: off — nothing is mounted and nothing is sent.++ indexnow: { key: process.env.INDEXNOW_KEY || '' },};@@@ -21,6 +21,8 @@ const rateLimit = require('express-rate-limit');const path = require('path');const config = require('./config');++// IndexNow: with INDEXNOW_KEY set, serve its key file; Tools publishes no user-created pages, so nothing pings.++const { createToolsIndexNow } = require('./seo/indexnow');const { BRAND } = require('openvibe-shared/brand');const { createAuthClient, createAuthRoutes, extractToken, fedcmCors } = require('./auth/routes');const createNetRoutes = require('./net/routes');@@@ -466,6 +468,10 @@ app.get('/openvibe-sw.js', (req, res) => {});// ── Static Files ─────────────────────────────────────────────++// IndexNow's key file (openvibe-shared/indexnow) comes first: /<key>.txt must answer its own text,++// not fall through to the SPA/static handlers. Unset INDEXNOW_KEY → off, nothing mounted (404).++const indexnow = createToolsIndexNow(config.baseUrl, config.indexnow.key);++if (indexnow.enabled) app.use(indexnow.keyFile);app.use(express.static(path.join(__dirname, '..', 'public'), {setHeaders(res, filePath) {if (filePath.endsWith('.js') || filePath.endsWith('.css')) {@@@ -6,7 +6,7 @@// /tool/<id> one tool: what it does, where it lives (primary host + mirrors), related// /all-tools A–Z// /search?q= results (works as a plain form; JS only makes it instant)--// /sitemap.xml /robots.txt /llms.txt /api/catalog.json++// /sitemap.xml /robots.txt /llms.txt /llms-full.txt /api/catalog.json// Every page is complete HTML without JavaScript. Pages are rendered once per registry version// and served from memory with an ETag.// ═══════════════════════════════════════════════════════════════@@@ -297,6 +297,35 @@ function sitemapEntries() {].map(u => Object.assign({ lastmod: today }, u));}++// ── llms.txt + llms-full.txt ─────────────────────────────────++// The public pages, once, as the data both artifacts read: /llms.txt lists each with its tagline,++// /llms-full.txt carries the same pages with their full description. One source, so the two can++// never disagree about what is public. The noindex search page and the site's own /tool/*,++// /developers and /updates pages were never in llms.txt and stay out of both.++function llmsSections() {++ const { tools, families } = registry.get();++ return families.filter(f => f.path).map(f => ({++ title: f.name,++ links: tools.filter(t => t.family === f.id).map(t => ({ title: t.name, url: t.url, note: t.tagline, text: t.description || t.tagline })),++ }));++}++function llmsSummary() {++ const { tools } = registry.get();++ return `${tools.length} online tools for files, text, code and networks. Open source and community-run. A machine-readable catalog is at ${SITE}/api/catalog.json.`;++}++function llmsTxtBody() {++ return seo.llmsTxt({ name: NAME, summary: llmsSummary(),++ sections: [{ title: 'API', links: [{ title: 'Developer guide', url: SITE + '/developers', note: 'run any tool from code' }, { title: 'OpenAPI 3.1', url: SITE + '/api/v1/openapi.json' }, { title: 'Tool registry (JSON)', url: SITE + '/api/v1/tools' }, { title: 'Full text (llms-full.txt)', url: SITE + '/llms-full.txt', note: 'the public pages below as one plain-text document' }] }].concat(llmsSections()) });++}++// /llms-full.txt: the same header and summary as /llms.txt, then every public page llms.txt lists++// with its text, under the same sections. The 512 KB ceiling keeps the answer a document, not a++// payload — llmsFull stops before the page that would pass it.++const LLMS_FULL_MAX_BYTES = 512 * 1024;++function llmsFullBody() {++ return seo.llmsFull({ site: { name: NAME, url: SITE }, summary: llmsSummary(), maxBytes: LLMS_FULL_MAX_BYTES,++ sections: llmsSections().map(s => ({ title: s.title, pages: s.links.map(l => ({ title: l.title, url: l.url, text: l.text })) })) });++}++function createSiteRouter() {const router = express.Router();const apexOnly = (req, res, next) => (!req.ovHost || req.ovHost.kind === 'apex' || (req.ovHost.kind === 'unknown' && !req.ovHost.inZone) ? next() : next('router'));@@@ -312,8 +341,9 @@ function createSiteRouter() {router.get('/tool/:id', (req, res, next) => { const t = registry.get().tools.find(x => x.id === req.params.id); return t ? send(req, res, '/tool/' + t.id, () => pageTool(t)) : next(); });router.get('/sitemap.xml', (req, res) => send(req, res, 'sitemap', () => seo.sitemapXml(sitemapEntries()), 'application/xml; charset=utf-8'));router.get('/robots.txt', (req, res) => send(req, res, 'robots', () => seo.robotsTxt({ sitemaps: [SITE + '/sitemap.xml'], disallow: ['/api/', '/auth/'], allow: ['/api/catalog.json'], allowAI: true }), 'text/plain; charset=utf-8'));-- router.get('/llms.txt', (req, res) => send(req, res, 'llms', () => { const { tools, families } = registry.get(); return seo.llmsTxt({ name: NAME, summary: `${tools.length} online tools for files, text, code and networks. Open source and community-run. A machine-readable catalog is at ${SITE}/api/catalog.json.`,-- sections: [{ title: 'API', links: [{ title: 'Developer guide', url: SITE + '/developers', note: 'run any tool from code' }, { title: 'OpenAPI 3.1', url: SITE + '/api/v1/openapi.json' }, { title: 'Tool registry (JSON)', url: SITE + '/api/v1/tools' }] }].concat(families.filter(f => f.path).map(f => ({ title: f.name, links: tools.filter(t => t.family === f.id).map(t => ({ title: t.name, url: t.url, note: t.tagline })) }))) }); }, 'text/plain; charset=utf-8'));++ router.get('/llms.txt', (req, res) => send(req, res, 'llms', llmsTxtBody, 'text/plain; charset=utf-8'));++ // The same document with every public page's text: what /llms.txt lists, readable whole.++ router.get('/llms-full.txt', (req, res) => send(req, res, 'llms-full', llmsFullBody, 'text/plain; charset=utf-8'));router.get('/:slug', (req, res, next) => { const f = registry.get().families.find(x => x.path === '/' + req.params.slug); return f ? send(req, res, f.path, () => pageFamily(f)) : next(); });return router;}nnew file mode 100644@@@ -0,0 +1,20 @@++'use strict';++++// ═══════════════════════════════════════════════════════════════++// IndexNow (openvibe-shared/indexnow): the key file engines fetch to verify this site.++//++// Created once at boot from INDEXNOW_KEY in the gateway config (server/config.js). A blank or++// unset key means off: the module is not mounted, /<key>.txt falls through to the 404, and no++// ping is ever sent. Tools has no user-created public pages — every page is built from the++// tool descriptors at boot — so nothing here calls pingSoon(); only the key file is served.++// ═══════════════════════════════════════════════════════════════++const shared = require('openvibe-shared/indexnow');++++/** @param {string} host the site host, e.g. https://openvibe.tools @param {string} [key] INDEXNOW_KEY */++function createToolsIndexNow(host, key) {++ const trimmed = String(key == null ? '' : key).trim();++ // createIndexNow() itself refuses a key that is not 8–128 hex/alphanumeric; null is the "off" key.++ return shared.createIndexNow({ host, key: trimmed === '' ? null : trimmed });++}++++module.exports = { createToolsIndexNow };nnew file mode 100644@@@ -0,0 +1,78 @@++'use strict';++// Discovery: /llms-full.txt (every public page llms.txt lists, as text) and the IndexNow key file++// (served only when INDEXNOW_KEY is set). Fetched from the real HTTP layer over a stand-in Express++// app, the same wiring server/index.js mounts.++const assert = require('assert');++const http = require('http');++const express = require('express');++const site = require('../server/pages/site');++const registry = require('../server/registry');++const { createToolsIndexNow } = require('../server/seo/indexnow');++++const HOST = 'openvibe.tools';++// Format-valid placeholder (8-128 alphanumeric, what createIndexNow accepts), not a secret.++const KEY = 'testtesttesttest';++++function serve(app) {++ const srv = http.createServer(app);++ return new Promise((r) => srv.listen(0, '127.0.0.1', () => r(srv)));++}++++(async () => {++ // ── /llms-full.txt ──────────────────────────────────────++ const app = express();++ app.use(site.createSiteRouter());++ const srv = await serve(app);++ const base = `http://127.0.0.1:${srv.address().port}`;++ try {++ const r = await fetch(`${base}/llms-full.txt`);++ assert.strictEqual(r.status, 200, 'llms-full is served');++ assert.ok(/^text\/plain/.test(r.headers.get('content-type') || ''), 'llms-full is text/plain');++ const body = await r.text();++ assert.ok(body.startsWith('# OpenVibe.Tools\n'), 'llms-full opens with the llms.txt header');++ // Same summary line as llms.txt (llmsFull builds its header from the same llmsTxt call).++ const idx = await (await fetch(`${base}/llms.txt`)).text();++ const summary = idx.split('\n').find((l) => l.startsWith('> '));++ assert.ok(summary && body.includes(summary), 'the same summary as llms.txt');++ // Every public page llms.txt lists: each tool, under its family heading, with its text.++ const { tools, families } = registry.get();++ for (const f of families.filter((x) => x.path)) {++ assert.ok(body.includes(`## ${f.name}`), `section for ${f.name}`);++ for (const t of tools.filter((x) => x.family === f.id)) {++ assert.ok(body.includes(t.url), `${t.id} URL is listed`);++ assert.ok(body.includes(t.description), `${t.id} carries its description`);++ }++ }++ // Nothing robots keeps out of crawlers leaks into the model-readable document (the catalog URL++ // in the summary is the one path robots explicitly allows, so only /auth/ and /search can appear).++ assert.ok(!body.includes('/auth/') && !body.includes('/search') && !body.includes('Disallow'), 'no private paths');++ // llms.txt points at the full document.++ assert.ok(idx.includes('/llms-full.txt'), 'llms.txt lists llms-full.txt');++ } finally { srv.close(); }++++ // ── IndexNow key file ───────────────────────────────────++ const mount = (key) => {++ const a = express();++ const ix = createToolsIndexNow(`https://${HOST}`, key);++ if (ix.enabled) a.use(ix.keyFile);++ else a.use((_req, res) => res.status(404).end());++ return a;++ };++ const off = await serve(mount(''));++ const offBase = `http://127.0.0.1:${off.address().port}`;++ try {++ assert.strictEqual(createToolsIndexNow(`https://${HOST}`, '').enabled, false, 'unset key is off');++ const r = await fetch(`${offBase}/${KEY}.txt`);++ assert.strictEqual(r.status, 404, 'unset key: the key file is not served');++ } finally { off.close(); }++ const on = await serve(mount(KEY));++ const onBase = `http://127.0.0.1:${on.address().port}`;++ try {++ const r = await fetch(`${onBase}/${KEY}.txt`);++ assert.strictEqual(r.status, 200, 'set key: the key file answers');++ assert.ok(/^text\/plain/.test(r.headers.get('content-type') || ''), 'the key file is text/plain');++ assert.strictEqual((await r.text()).trim(), KEY, 'the key file carries the key');++ } finally { on.close(); }++++ console.log('llms-full + IndexNow: all checks passed');++})().catch((e) => { console.error(e); process.exit(1); });
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.92.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.92.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"uuid": "^11.1.0"
"uuid": "^11.1.0"
}
}
@@ -1335,9 +1335,9 @@
@@ -1335,9 +1335,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "2.3.1",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
- "integrity": "sha512-K+MXnB4O+8xIFi+NQGC2SDTvDq3RkzfxQ3bTVx76Y/Rbuq7kC/L5kL3c/06xr9sPYcjyzF3Bhpv5yH2YNfR4Zw==",
+ "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"
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.92.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.92.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
"uuid": "^11.1.0"
"uuid": "^11.1.0"
}
}
}
}
@@ -21,7 +21,7 @@
@@ -21,7 +21,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.92.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.92.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
"pdf-lib": "^1.17.1",
"pdf-lib": "^1.17.1",
"pg": "^8.23.0",
"pg": "^8.23.0",
"sharp": "^0.33.5",
"sharp": "^0.33.5",
@@ -1412,9 +1412,9 @@
@@ -1412,9 +1412,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "2.3.1",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
- "integrity": "sha512-K+MXnB4O+8xIFi+NQGC2SDTvDq3RkzfxQ3bTVx76Y/Rbuq7kC/L5kL3c/06xr9sPYcjyzF3Bhpv5yH2YNfR4Zw==",
+ "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"
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.92.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.92.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
"pdf-lib": "^1.17.1",
"pdf-lib": "^1.17.1",
"sharp": "^0.33.5",
"sharp": "^0.33.5",
"uuid": "^11.1.0"
"uuid": "^11.1.0"
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"helmet": "^7.1.0",
"helmet": "^7.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
}
}
},
},
@@ -1129,9 +1129,9 @@
@@ -1129,9 +1129,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "2.3.1",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
- "integrity": "sha512-K+MXnB4O+8xIFi+NQGC2SDTvDq3RkzfxQ3bTVx76Y/Rbuq7kC/L5kL3c/06xr9sPYcjyzF3Bhpv5yH2YNfR4Zw==",
+ "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"
@@ -20,6 +20,6 @@
@@ -20,6 +20,6 @@
"express-rate-limit": "^7.1.5",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"helmet": "^7.1.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1"
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0"
}
}
}
}
@@ -23,6 +23,10 @@ OV_OAUTH_REDIRECT_URI=http://localhost:4001/auth/callback
@@ -23,6 +23,10 @@ OV_OAUTH_REDIRECT_URI=http://localhost:4001/auth/callback
#COOKIE_DOMAIN=.openvibe.tools
#COOKIE_DOMAIN=.openvibe.tools
#COOKIE_SECURE=true
#COOKIE_SECURE=true
+# IndexNow (openvibe-shared/indexnow). With a key (8–128 hex/alphanumeric) the key file is served at
+# /<key>.txt so engines can verify the site. Unset: off — nothing is mounted and nothing is sent.
+#INDEXNOW_KEY=
+
# OpenVibe.Media — backs Paste.OpenVibe (pastes stored under app_id 'live')
# OpenVibe.Media — backs Paste.OpenVibe (pastes stored under app_id 'live')
MEDIA_URL=http://127.0.0.1:4100
MEDIA_URL=http://127.0.0.1:4100
MEDIA_PUBLIC_URL=https://openvibe.media
MEDIA_PUBLIC_URL=https://openvibe.media
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.92.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.92.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
}
}
},
},
@@ -1212,9 +1212,9 @@
@@ -1212,9 +1212,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "2.3.1",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
- "integrity": "sha512-K+MXnB4O+8xIFi+NQGC2SDTvDq3RkzfxQ3bTVx76Y/Rbuq7kC/L5kL3c/06xr9sPYcjyzF3Bhpv5yH2YNfR4Zw==",
+ "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"
@@ -23,6 +23,6 @@
@@ -23,6 +23,6 @@
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.92.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.92.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1"
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0"
}
}
}
}
@@ -39,4 +39,8 @@ module.exports = {
@@ -39,4 +39,8 @@ module.exports = {
liveUrl: process.env.LIVE_URL || 'http://127.0.0.1:3000',
liveUrl: process.env.LIVE_URL || 'http://127.0.0.1:3000',
// OpenVibe.Community owns pastes (roadmap Wave 5); the paste front-end's API goes straight there.
// OpenVibe.Community owns pastes (roadmap Wave 5); the paste front-end's API goes straight there.
communityUrl: (process.env.OV_COMMUNITY_INTERNAL_URL || 'http://127.0.0.1:4200').replace(/\/$/, ''),
communityUrl: (process.env.OV_COMMUNITY_INTERNAL_URL || 'http://127.0.0.1:4200').replace(/\/$/, ''),
+
+ // IndexNow (openvibe-shared/indexnow): with INDEXNOW_KEY set the key file is served at
+ // /<key>.txt so engines can verify this site. Unset: off — nothing is mounted and nothing is sent.
+ indexnow: { key: process.env.INDEXNOW_KEY || '' },
};
};
@@ -21,6 +21,8 @@ const rateLimit = require('express-rate-limit');
@@ -21,6 +21,8 @@ const rateLimit = require('express-rate-limit');
const path = require('path');
const path = require('path');
const config = require('./config');
const config = require('./config');
+// IndexNow: with INDEXNOW_KEY set, serve its key file; Tools publishes no user-created pages, so nothing pings.
+const { createToolsIndexNow } = require('./seo/indexnow');
const { BRAND } = require('openvibe-shared/brand');
const { BRAND } = require('openvibe-shared/brand');
const { createAuthClient, createAuthRoutes, extractToken, fedcmCors } = require('./auth/routes');
const { createAuthClient, createAuthRoutes, extractToken, fedcmCors } = require('./auth/routes');
const createNetRoutes = require('./net/routes');
const createNetRoutes = require('./net/routes');
@@ -466,6 +468,10 @@ app.get('/openvibe-sw.js', (req, res) => {
@@ -466,6 +468,10 @@ app.get('/openvibe-sw.js', (req, res) => {
});
});
// ── Static Files ─────────────────────────────────────────────
// ── Static Files ─────────────────────────────────────────────
+// IndexNow's key file (openvibe-shared/indexnow) comes first: /<key>.txt must answer its own text,
+// not fall through to the SPA/static handlers. Unset INDEXNOW_KEY → off, nothing mounted (404).
+const indexnow = createToolsIndexNow(config.baseUrl, config.indexnow.key);
+if (indexnow.enabled) app.use(indexnow.keyFile);
app.use(express.static(path.join(__dirname, '..', 'public'), {
app.use(express.static(path.join(__dirname, '..', 'public'), {
setHeaders(res, filePath) {
setHeaders(res, filePath) {
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
// /tool/<id> one tool: what it does, where it lives (primary host + mirrors), related
// /tool/<id> one tool: what it does, where it lives (primary host + mirrors), related
// /all-tools A–Z
// /all-tools A–Z
// /search?q= results (works as a plain form; JS only makes it instant)
// /search?q= results (works as a plain form; JS only makes it instant)
-// /sitemap.xml /robots.txt /llms.txt /api/catalog.json
+// /sitemap.xml /robots.txt /llms.txt /llms-full.txt /api/catalog.json
// Every page is complete HTML without JavaScript. Pages are rendered once per registry version
// Every page is complete HTML without JavaScript. Pages are rendered once per registry version
// and served from memory with an ETag.
// and served from memory with an ETag.
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
@@ -297,6 +297,35 @@ function sitemapEntries() {
@@ -297,6 +297,35 @@ function sitemapEntries() {
].map(u => Object.assign({ lastmod: today }, u));
].map(u => Object.assign({ lastmod: today }, u));
}
}
+// ── llms.txt + llms-full.txt ─────────────────────────────────
+// The public pages, once, as the data both artifacts read: /llms.txt lists each with its tagline,
+// /llms-full.txt carries the same pages with their full description. One source, so the two can
+// never disagree about what is public. The noindex search page and the site's own /tool/*,
+// /developers and /updates pages were never in llms.txt and stay out of both.
+function llmsSections() {
+ const { tools, families } = registry.get();
+ return families.filter(f => f.path).map(f => ({
+ title: f.name,
+ links: tools.filter(t => t.family === f.id).map(t => ({ title: t.name, url: t.url, note: t.tagline, text: t.description || t.tagline })),
+ }));
+}
+function llmsSummary() {
+ const { tools } = registry.get();
+ return `${tools.length} online tools for files, text, code and networks. Open source and community-run. A machine-readable catalog is at ${SITE}/api/catalog.json.`;
+}
+function llmsTxtBody() {
+ return seo.llmsTxt({ name: NAME, summary: llmsSummary(),
+ sections: [{ title: 'API', links: [{ title: 'Developer guide', url: SITE + '/developers', note: 'run any tool from code' }, { title: 'OpenAPI 3.1', url: SITE + '/api/v1/openapi.json' }, { title: 'Tool registry (JSON)', url: SITE + '/api/v1/tools' }, { title: 'Full text (llms-full.txt)', url: SITE + '/llms-full.txt', note: 'the public pages below as one plain-text document' }] }].concat(llmsSections()) });
+}
+// /llms-full.txt: the same header and summary as /llms.txt, then every public page llms.txt lists
+// with its text, under the same sections. The 512 KB ceiling keeps the answer a document, not a
+// payload — llmsFull stops before the page that would pass it.
+const LLMS_FULL_MAX_BYTES = 512 * 1024;
+function llmsFullBody() {
+ return seo.llmsFull({ site: { name: NAME, url: SITE }, summary: llmsSummary(), maxBytes: LLMS_FULL_MAX_BYTES,
+ sections: llmsSections().map(s => ({ title: s.title, pages: s.links.map(l => ({ title: l.title, url: l.url, text: l.text })) })) });
+}
+
function createSiteRouter() {
function createSiteRouter() {
const router = express.Router();
const router = express.Router();
const apexOnly = (req, res, next) => (!req.ovHost || req.ovHost.kind === 'apex' || (req.ovHost.kind === 'unknown' && !req.ovHost.inZone) ? next() : next('router'));
const apexOnly = (req, res, next) => (!req.ovHost || req.ovHost.kind === 'apex' || (req.ovHost.kind === 'unknown' && !req.ovHost.inZone) ? next() : next('router'));
@@ -312,8 +341,9 @@ function createSiteRouter() {
@@ -312,8 +341,9 @@ function createSiteRouter() {
router.get('/tool/:id', (req, res, next) => { const t = registry.get().tools.find(x => x.id === req.params.id); return t ? send(req, res, '/tool/' + t.id, () => pageTool(t)) : next(); });
router.get('/tool/:id', (req, res, next) => { const t = registry.get().tools.find(x => x.id === req.params.id); return t ? send(req, res, '/tool/' + t.id, () => pageTool(t)) : next(); });
router.get('/sitemap.xml', (req, res) => send(req, res, 'sitemap', () => seo.sitemapXml(sitemapEntries()), 'application/xml; charset=utf-8'));
router.get('/sitemap.xml', (req, res) => send(req, res, 'sitemap', () => seo.sitemapXml(sitemapEntries()), 'application/xml; charset=utf-8'));
router.get('/robots.txt', (req, res) => send(req, res, 'robots', () => seo.robotsTxt({ sitemaps: [SITE + '/sitemap.xml'], disallow: ['/api/', '/auth/'], allow: ['/api/catalog.json'], allowAI: true }), 'text/plain; charset=utf-8'));
router.get('/robots.txt', (req, res) => send(req, res, 'robots', () => seo.robotsTxt({ sitemaps: [SITE + '/sitemap.xml'], disallow: ['/api/', '/auth/'], allow: ['/api/catalog.json'], allowAI: true }), 'text/plain; charset=utf-8'));
- router.get('/llms.txt', (req, res) => send(req, res, 'llms', () => { const { tools, families } = registry.get(); return seo.llmsTxt({ name: NAME, summary: `${tools.length} online tools for files, text, code and networks. Open source and community-run. A machine-readable catalog is at ${SITE}/api/catalog.json.`,
- sections: [{ title: 'API', links: [{ title: 'Developer guide', url: SITE + '/developers', note: 'run any tool from code' }, { title: 'OpenAPI 3.1', url: SITE + '/api/v1/openapi.json' }, { title: 'Tool registry (JSON)', url: SITE + '/api/v1/tools' }] }].concat(families.filter(f => f.path).map(f => ({ title: f.name, links: tools.filter(t => t.family === f.id).map(t => ({ title: t.name, url: t.url, note: t.tagline })) }))) }); }, 'text/plain; charset=utf-8'));
+ router.get('/llms.txt', (req, res) => send(req, res, 'llms', llmsTxtBody, 'text/plain; charset=utf-8'));
+ // The same document with every public page's text: what /llms.txt lists, readable whole.
+ router.get('/llms-full.txt', (req, res) => send(req, res, 'llms-full', llmsFullBody, 'text/plain; charset=utf-8'));
router.get('/:slug', (req, res, next) => { const f = registry.get().families.find(x => x.path === '/' + req.params.slug); return f ? send(req, res, f.path, () => pageFamily(f)) : next(); });
router.get('/:slug', (req, res, next) => { const f = registry.get().families.find(x => x.path === '/' + req.params.slug); return f ? send(req, res, f.path, () => pageFamily(f)) : next(); });
return router;
return router;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+'use strict';
+
+// ═══════════════════════════════════════════════════════════════
+// IndexNow (openvibe-shared/indexnow): the key file engines fetch to verify this site.
+//
+// Created once at boot from INDEXNOW_KEY in the gateway config (server/config.js). A blank or
+// unset key means off: the module is not mounted, /<key>.txt falls through to the 404, and no
+// ping is ever sent. Tools has no user-created public pages — every page is built from the
+// tool descriptors at boot — so nothing here calls pingSoon(); only the key file is served.
+// ═══════════════════════════════════════════════════════════════
+const shared = require('openvibe-shared/indexnow');
+
+/** @param {string} host the site host, e.g. https://openvibe.tools @param {string} [key] INDEXNOW_KEY */
+function createToolsIndexNow(host, key) {
+ const trimmed = String(key == null ? '' : key).trim();
+ // createIndexNow() itself refuses a key that is not 8–128 hex/alphanumeric; null is the "off" key.
+ return shared.createIndexNow({ host, key: trimmed === '' ? null : trimmed });
+}
+
+module.exports = { createToolsIndexNow };
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
+'use strict';
+// Discovery: /llms-full.txt (every public page llms.txt lists, as text) and the IndexNow key file
+// (served only when INDEXNOW_KEY is set). Fetched from the real HTTP layer over a stand-in Express
+// app, the same wiring server/index.js mounts.
+const assert = require('assert');
+const http = require('http');
+const express = require('express');
+const site = require('../server/pages/site');
+const registry = require('../server/registry');
+const { createToolsIndexNow } = require('../server/seo/indexnow');
+
+const HOST = 'openvibe.tools';
+// Format-valid placeholder (8-128 alphanumeric, what createIndexNow accepts), not a secret.
+const KEY = 'testtesttesttest';
+
+function serve(app) {
+ const srv = http.createServer(app);
+ return new Promise((r) => srv.listen(0, '127.0.0.1', () => r(srv)));
+}
+
+(async () => {
+ // ── /llms-full.txt ──────────────────────────────────────
+ const app = express();
+ app.use(site.createSiteRouter());
+ const srv = await serve(app);
+ const base = `http://127.0.0.1:${srv.address().port}`;
+ try {
+ const r = await fetch(`${base}/llms-full.txt`);
+ assert.strictEqual(r.status, 200, 'llms-full is served');
+ assert.ok(/^text\/plain/.test(r.headers.get('content-type') || ''), 'llms-full is text/plain');
+ const body = await r.text();
+ assert.ok(body.startsWith('# OpenVibe.Tools\n'), 'llms-full opens with the llms.txt header');
+ // Same summary line as llms.txt (llmsFull builds its header from the same llmsTxt call).
+ const idx = await (await fetch(`${base}/llms.txt`)).text();
+ const summary = idx.split('\n').find((l) => l.startsWith('> '));
+ assert.ok(summary && body.includes(summary), 'the same summary as llms.txt');
+ // Every public page llms.txt lists: each tool, under its family heading, with its text.
+ const { tools, families } = registry.get();
+ for (const f of families.filter((x) => x.path)) {
+ assert.ok(body.includes(`## ${f.name}`), `section for ${f.name}`);
+ for (const t of tools.filter((x) => x.family === f.id)) {
+ assert.ok(body.includes(t.url), `${t.id} URL is listed`);
+ assert.ok(body.includes(t.description), `${t.id} carries its description`);
+ }
+ }
+ // Nothing robots keeps out of crawlers leaks into the model-readable document (the catalog URL
+ // in the summary is the one path robots explicitly allows, so only /auth/ and /search can appear).
+ assert.ok(!body.includes('/auth/') && !body.includes('/search') && !body.includes('Disallow'), 'no private paths');
+ // llms.txt points at the full document.
+ assert.ok(idx.includes('/llms-full.txt'), 'llms.txt lists llms-full.txt');
+ } finally { srv.close(); }
+
+ // ── IndexNow key file ───────────────────────────────────
+ const mount = (key) => {
+ const a = express();
+ const ix = createToolsIndexNow(`https://${HOST}`, key);
+ if (ix.enabled) a.use(ix.keyFile);
+ else a.use((_req, res) => res.status(404).end());
+ return a;
+ };
+ const off = await serve(mount(''));
+ const offBase = `http://127.0.0.1:${off.address().port}`;
+ try {
+ assert.strictEqual(createToolsIndexNow(`https://${HOST}`, '').enabled, false, 'unset key is off');
+ const r = await fetch(`${offBase}/${KEY}.txt`);
+ assert.strictEqual(r.status, 404, 'unset key: the key file is not served');
+ } finally { off.close(); }
+ const on = await serve(mount(KEY));
+ const onBase = `http://127.0.0.1:${on.address().port}`;
+ try {
+ const r = await fetch(`${onBase}/${KEY}.txt`);
+ assert.strictEqual(r.status, 200, 'set key: the key file answers');
+ assert.ok(/^text\/plain/.test(r.headers.get('content-type') || ''), 'the key file is text/plain');
+ assert.strictEqual((await r.text()).trim(), KEY, 'the key file carries the key');
+ } finally { on.close(); }
+
+ console.log('llms-full + IndexNow: all checks passed');
+})().catch((e) => { console.error(e); process.exit(1); });