openvibers/openvibe.network Commits
Serve llms-full.txt and IndexNow key, grant Trade event manage
Changes
@@@ -13,6 +13,10 @@ OV_NETWORK_URL=http://localhost:4000OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000OV_NETWORK_LOGIN_URL=http://localhost:4000++# IndexNow (openvibe-shared/indexnow): 8–128 letters or digits. Set, the key file answers at++# /<key>.txt; unset, it is off and nothing is served. The key goes in /etc/openvibe/network.env only.++INDEXNOW_KEY=++# Per-actor limits on API writes by a person's session (server/auth/actor-limits.js, roadmap WS-R task 4),# per minute and per hour; account, project, theme, avatar, follow and block writes have tighter numbers of their own.#NETWORK_LIMITS_MINUTE=120@@@ -21,9 +21,9 @@"iovalkey": "^0.4.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.90.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.94.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","uuid": "^11.1.0","web-push": "^3.6.7"@@@ -1556,9 +1556,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.90.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.90.0",-- "integrity": "sha512-3rn3499kohy++DZm2nkWHPmO6W6r55GajAJtKlxOVgbEo0qQhni+mf6SvMZDMWpRQrdRQDBtiodIr21uwA9x4Q==",++ "version": "0.94.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.94.0",++ "integrity": "sha512-dh1eAdBUkjjIQuUSZZM0u2o/yzVb/cWOcl2J12dZfTg2q83yhgARSTTX0tGLLKuBCPks7TrYfoErXHZcXtWubA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -1601,9 +1601,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"@@@ -30,9 +30,9 @@"iovalkey": "^0.4.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.90.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.94.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","uuid": "^11.1.0","web-push": "^3.6.7"@@@ -56,6 +56,10 @@ module.exports = {// scripts/subscribe-events.js.eventsWebhookSecrets: process.env.NETWORK_EVENTS_SECRET || '',++ // IndexNow (openvibe-shared/indexnow): when INDEXNOW_KEY is set the key file is served at /<key>.txt.++ // Network publishes no content stream, so nothing pings yet. Unset: off — nothing is mounted.++ indexnow: { key: process.env.INDEXNOW_KEY || '' },++// Database (ADR-035, plan T2): PostgreSQL through PgBouncer in production. `url` (DATABASE_URL) serves// requests; `directUrl` (DATABASE_DIRECT_URL, the owner role on a direct connection) runs migrations.// Without DATABASE_URL, development and tests run an embedded PGlite database in `pgliteDir`@@@ -173,12 +173,32 @@ function sendHome(req, res) {res.send(page.html);}++const llmsSummary = () => `OpenVibe is an open source, community-run network of sites that share one account: ${OPEN.map((site) => site.name.toLowerCase()).join(', ')}.`;++function llmsTxt() {const { catalog } = toolsCatalog.peek();-- return seo.llmsTxt({ name: 'OpenVibe Network', summary: `OpenVibe is an open source, community-run network of sites that share one account: ${OPEN.map((site) => site.name.toLowerCase()).join(', ')}.`,++ return seo.llmsTxt({ name: 'OpenVibe Network', summary: llmsSummary(),sections: [{ title: 'Sites', links: OPEN.map((site) => ({ title: siteName(site), url: `https://${site.host}/`, note: site.what })) },{ title: 'Tool families', links: catalog.families.filter(f => f.url).map(f => ({ title: f.name, url: f.path ? 'https://openvibe.tools' + f.path : f.url, note: f.tagline })) },{ title: 'Machine-readable', links: [{ title: 'Tool catalog (JSON)', url: 'https://openvibe.tools/api/catalog.json' }, { title: 'Tools llms.txt', url: 'https://openvibe.tools/llms.txt' }, { title: 'Tool domains (JSON)', url: 'https://openvibe.network/api/domains' }] }].filter((section) => section.links.length) });}--module.exports = { sendHome, render, llmsTxt, INTENTS };++// /llms-full.txt: the public pages the sitemap lists (server/seo/routes.js PAGES), each with its text.++const LLMS_PAGE_TEXT = {++ '/': () => ['One OpenVibe account signs you in on every site:', ...OPEN.map((site) => `- ${siteName(site)} (https://${site.host}/): ${site.what}`)].join('\n'),++ '/status': () => 'Observed status of each OpenVibe service: readiness, release and when it was last checked.',++ '/updates': () => "Every change deployed to the OpenVibe network, newest first: each site's commits as they ship, and the Patch notes posts that gather them.",++ '/terms': () => 'The rules for using OpenVibe.Network, in plain language.',++ '/privacy': () => 'What OpenVibe.Network collects, why, and the choices you have.',++ '/dmca': () => 'How to report copyright infringement on OpenVibe.Network, and how to respond to a report.',++};++const LLMS_PAGE_TITLE = { '/': 'OpenVibe.Network', '/status': 'Status', '/updates': 'Updates', '/terms': 'Terms of Service', '/privacy': 'Privacy Policy', '/dmca': 'DMCA' };++const LLMS_FULL_MAX_BYTES = 512 * 1024;++++function llmsFullTxt({ baseUrl = 'https://openvibe.network' } = {}) {++ const pages = require('../seo/routes').PAGES.filter((page) => LLMS_PAGE_TEXT[page.path])++ .map((page) => ({ title: LLMS_PAGE_TITLE[page.path], url: page.path, text: LLMS_PAGE_TEXT[page.path]() }));++ return seo.llmsFull({ site: 'OpenVibe Network', summary: llmsSummary(), base: baseUrl, maxBytes: LLMS_FULL_MAX_BYTES, sections: [{ title: 'Pages', pages }] });++}++++module.exports = { sendHome, render, llmsTxt, llmsFullTxt, LLMS_FULL_MAX_BYTES, INTENTS };@@@ -130,6 +130,8 @@ const DEFAULT_GRANTS = [['trade', 'events.event.publish', 'openvibe.events', []],['trade', 'sources.item.read', 'openvibe.sources', []],['trade', 'sources.source.read', 'openvibe.sources', []],++ // Trade's scripts/subscribe.js (Trade #9) manages its own Events subscriptions.++ ['trade', 'events.subscription.manage', 'openvibe.events', []],// Wave 18: Deals and Coupons; Wave 21 Stage B: the Host API publishes deploy/domain events.['deals', 'identity.subject.resolve', SELF_AUDIENCE, []],['deals', 'events.event.publish', 'openvibe.events', []],@@@ -802,9 +802,14 @@ app.get(['/', '/index.html'], (req, res) => {return require('./home/render').sendHome(req, res);});app.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(require('./home/render').llmsTxt()));++app.get('/llms-full.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(require('./home/render').llmsFullTxt({ baseUrl: config.baseUrl })));// The crawl files the front door owes crawlers, built from openvibe-shared/seo (plan T11 lane D), ahead// of express.static below so they answer the generated copy rather than public/.app.use(require('./seo/routes').createSeoRoutes({ release }));++// IndexNow key file (openvibe-shared/indexnow), from INDEXNOW_KEY; unset → off, nothing served. The++// public page set is the fixed seo/routes PAGES list, so no hook pings; the client stays for one that will.++const indexnow = require('openvibe-shared/indexnow').createIndexNow({ host: config.baseUrl, key: config.indexnow.key });++if (indexnow.enabled) app.use(indexnow.keyFile);// Account hub (my.html) — the apex hosts the account hub under /my// plus its client-routed sections.@@@ -34,6 +34,8 @@ for (const [client, cap, aud] of principals.DEFAULT_GRANTS) {assert.ok(c, `default grant ${client} ${cap} is in openvibe-contracts`);assert.strictEqual(aud, `openvibe.${c.owner}`, `default grant ${client} ${cap} → ${aud}`);}++// Trade's scripts/subscribe.js (Trade #9) manages its Events subscriptions like the other publishers.++assert.ok(principals.DEFAULT_GRANTS.some(([client, cap, aud, scopes]) => client === 'trade' && cap === 'events.subscription.manage' && aud === 'openvibe.events' && Array.isArray(scopes) && !scopes.length), 'Trade holds events.subscription.manage on openvibe.events');assert.strictEqual((await events()).length, 0, 'the seed is not announced');// Grant, update, revoke.@@@ -7,6 +7,8 @@ const assert = require('assert');const { bootServer } = require('./helpers/boot-server');const seo = require('openvibe-shared/seo');const seoRoutes = require('../server/seo/routes');++const render = require('../server/home/render');++const cache = require('openvibe-shared/cache-policy');(async () => {const srv = await bootServer();@@@ -54,6 +56,25 @@ const seoRoutes = require('../server/seo/routes');const shipped = seoRoutes.sitemapXml({ release: { full: () => ({ released_at: '2026-01-02T03:04:05Z' }) } });assert.ok(shipped.includes('<lastmod>2026-01-02</lastmod>'), 'lastmod follows the release manifest');++ // ── /llms-full.txt: the public pages only, within maxBytes ──++ const full = await fetch(srv.base + '/llms-full.txt');++ const fullBody = await full.text();++ assert.strictEqual(full.status, 200, '/llms-full.txt is 200');++ assert.ok((full.headers.get('content-type') || '').startsWith('text/plain'), '/llms-full.txt is text/plain');++ assert.strictEqual(full.headers.get('cache-control'), cache.htmlHeaders({ maxAge: 3600 }), '/llms-full.txt is cached like /llms.txt');++ assert.ok(fullBody.startsWith('# OpenVibe Network\n'), 'llms-full.txt opens with the llms.txt header');++ const llms = await get('/llms.txt');++ assert.ok(fullBody.includes(llms.body.split('\n').find(l => l.startsWith('> '))), 'llms-full.txt carries the llms.txt summary');++ const fullUrls = [...fullBody.matchAll(/^URL: (\S+)$/gm)].map(m => m[1]);++ assert.deepStrictEqual(fullUrls, seoRoutes.PAGES.map(p => `${seoRoutes.HOST}${p.path}`), 'llms-full.txt lists exactly the sitemap pages');++ assert.ok(!/\/(admin|my|login|developer|internal|account|settings)\b/.test(fullUrls.join(' ')), 'no private page is listed');++ assert.ok(Buffer.byteLength(fullBody) <= render.LLMS_FULL_MAX_BYTES, 'llms-full.txt stays within maxBytes');++ assert.strictEqual(render.LLMS_FULL_MAX_BYTES, 512 * 1024);++++ // ── IndexNow: no INDEXNOW_KEY → no key file ──++ const KEY = require('crypto').randomBytes(16).toString('hex');++ assert.strictEqual((await get(`/${KEY}.txt`)).status, 404, 'no key file is served without INDEXNOW_KEY');++console.log('seo routes: all checks passed');} catch (err) {console.error(srv.logs());@@@ -61,4 +82,22 @@ const seoRoutes = require('../server/seo/routes');} finally {await srv.stop();}++++ // ── IndexNow: INDEXNOW_KEY set → /<key>.txt answers with the key ──++ const KEY = require('crypto').randomBytes(16).toString('hex');++ const keyed = await bootServer({ child: true, env: { INDEXNOW_KEY: KEY } });++ try {++ const r = await fetch(`${keyed.base}/${KEY}.txt`);++ assert.strictEqual(r.status, 200, 'the IndexNow key file is served');++ assert.ok((r.headers.get('content-type') || '').startsWith('text/plain'), 'the key file is text/plain');++ assert.strictEqual(await r.text(), KEY, 'the key file holds the key');++ assert.strictEqual((await fetch(`${keyed.base}/0000000000000000.txt`)).status, 404, 'another key is not served');++ assert.strictEqual((await fetch(`${keyed.base}/robots.txt`)).status, 200, 'robots.txt still answers');++ } catch (err) {++ console.error(keyed.logs());++ throw err;++ } finally {++ await keyed.stop();++ }++ console.log('seo routes: indexnow key file checks passed');})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
@@ -13,6 +13,10 @@ OV_NETWORK_URL=http://localhost:4000
@@ -13,6 +13,10 @@ OV_NETWORK_URL=http://localhost:4000
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
OV_NETWORK_LOGIN_URL=http://localhost:4000
OV_NETWORK_LOGIN_URL=http://localhost:4000
+# IndexNow (openvibe-shared/indexnow): 8–128 letters or digits. Set, the key file answers at
+# /<key>.txt; unset, it is off and nothing is served. The key goes in /etc/openvibe/network.env only.
+INDEXNOW_KEY=
+
# Per-actor limits on API writes by a person's session (server/auth/actor-limits.js, roadmap WS-R task 4),
# Per-actor limits on API writes by a person's session (server/auth/actor-limits.js, roadmap WS-R task 4),
# per minute and per hour; account, project, theme, avatar, follow and block writes have tighter numbers of their own.
# per minute and per hour; account, project, theme, avatar, follow and block writes have tighter numbers of their own.
#NETWORK_LIMITS_MINUTE=120
#NETWORK_LIMITS_MINUTE=120
@@ -21,9 +21,9 @@
@@ -21,9 +21,9 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"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.90.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.94.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",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
"web-push": "^3.6.7"
@@ -1556,9 +1556,9 @@
@@ -1556,9 +1556,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.90.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.90.0",
- "integrity": "sha512-3rn3499kohy++DZm2nkWHPmO6W6r55GajAJtKlxOVgbEo0qQhni+mf6SvMZDMWpRQrdRQDBtiodIr21uwA9x4Q==",
+ "version": "0.94.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.94.0",
+ "integrity": "sha512-dh1eAdBUkjjIQuUSZZM0u2o/yzVb/cWOcl2J12dZfTg2q83yhgARSTTX0tGLLKuBCPks7TrYfoErXHZcXtWubA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1601,9 +1601,9 @@
@@ -1601,9 +1601,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"
@@ -30,9 +30,9 @@
@@ -30,9 +30,9 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"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.90.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.94.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",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
"web-push": "^3.6.7"
@@ -56,6 +56,10 @@ module.exports = {
@@ -56,6 +56,10 @@ module.exports = {
// scripts/subscribe-events.js.
// scripts/subscribe-events.js.
eventsWebhookSecrets: process.env.NETWORK_EVENTS_SECRET || '',
eventsWebhookSecrets: process.env.NETWORK_EVENTS_SECRET || '',
+ // IndexNow (openvibe-shared/indexnow): when INDEXNOW_KEY is set the key file is served at /<key>.txt.
+ // Network publishes no content stream, so nothing pings yet. Unset: off — nothing is mounted.
+ indexnow: { key: process.env.INDEXNOW_KEY || '' },
+
// Database (ADR-035, plan T2): PostgreSQL through PgBouncer in production. `url` (DATABASE_URL) serves
// Database (ADR-035, plan T2): PostgreSQL through PgBouncer in production. `url` (DATABASE_URL) serves
// requests; `directUrl` (DATABASE_DIRECT_URL, the owner role on a direct connection) runs migrations.
// requests; `directUrl` (DATABASE_DIRECT_URL, the owner role on a direct connection) runs migrations.
// Without DATABASE_URL, development and tests run an embedded PGlite database in `pgliteDir`
// Without DATABASE_URL, development and tests run an embedded PGlite database in `pgliteDir`
@@ -173,12 +173,32 @@ function sendHome(req, res) {
@@ -173,12 +173,32 @@ function sendHome(req, res) {
res.send(page.html);
res.send(page.html);
}
}
+const llmsSummary = () => `OpenVibe is an open source, community-run network of sites that share one account: ${OPEN.map((site) => site.name.toLowerCase()).join(', ')}.`;
+
function llmsTxt() {
function llmsTxt() {
const { catalog } = toolsCatalog.peek();
const { catalog } = toolsCatalog.peek();
- return seo.llmsTxt({ name: 'OpenVibe Network', summary: `OpenVibe is an open source, community-run network of sites that share one account: ${OPEN.map((site) => site.name.toLowerCase()).join(', ')}.`,
+ return seo.llmsTxt({ name: 'OpenVibe Network', summary: llmsSummary(),
sections: [{ title: 'Sites', links: OPEN.map((site) => ({ title: siteName(site), url: `https://${site.host}/`, note: site.what })) },
sections: [{ title: 'Sites', links: OPEN.map((site) => ({ title: siteName(site), url: `https://${site.host}/`, note: site.what })) },
{ title: 'Tool families', links: catalog.families.filter(f => f.url).map(f => ({ title: f.name, url: f.path ? 'https://openvibe.tools' + f.path : f.url, note: f.tagline })) },
{ title: 'Tool families', links: catalog.families.filter(f => f.url).map(f => ({ title: f.name, url: f.path ? 'https://openvibe.tools' + f.path : f.url, note: f.tagline })) },
{ title: 'Machine-readable', links: [{ title: 'Tool catalog (JSON)', url: 'https://openvibe.tools/api/catalog.json' }, { title: 'Tools llms.txt', url: 'https://openvibe.tools/llms.txt' }, { title: 'Tool domains (JSON)', url: 'https://openvibe.network/api/domains' }] }].filter((section) => section.links.length) });
{ title: 'Machine-readable', links: [{ title: 'Tool catalog (JSON)', url: 'https://openvibe.tools/api/catalog.json' }, { title: 'Tools llms.txt', url: 'https://openvibe.tools/llms.txt' }, { title: 'Tool domains (JSON)', url: 'https://openvibe.network/api/domains' }] }].filter((section) => section.links.length) });
}
}
-module.exports = { sendHome, render, llmsTxt, INTENTS };
+// /llms-full.txt: the public pages the sitemap lists (server/seo/routes.js PAGES), each with its text.
+const LLMS_PAGE_TEXT = {
+ '/': () => ['One OpenVibe account signs you in on every site:', ...OPEN.map((site) => `- ${siteName(site)} (https://${site.host}/): ${site.what}`)].join('\n'),
+ '/status': () => 'Observed status of each OpenVibe service: readiness, release and when it was last checked.',
+ '/updates': () => "Every change deployed to the OpenVibe network, newest first: each site's commits as they ship, and the Patch notes posts that gather them.",
+ '/terms': () => 'The rules for using OpenVibe.Network, in plain language.',
+ '/privacy': () => 'What OpenVibe.Network collects, why, and the choices you have.',
+ '/dmca': () => 'How to report copyright infringement on OpenVibe.Network, and how to respond to a report.',
+};
+const LLMS_PAGE_TITLE = { '/': 'OpenVibe.Network', '/status': 'Status', '/updates': 'Updates', '/terms': 'Terms of Service', '/privacy': 'Privacy Policy', '/dmca': 'DMCA' };
+const LLMS_FULL_MAX_BYTES = 512 * 1024;
+
+function llmsFullTxt({ baseUrl = 'https://openvibe.network' } = {}) {
+ const pages = require('../seo/routes').PAGES.filter((page) => LLMS_PAGE_TEXT[page.path])
+ .map((page) => ({ title: LLMS_PAGE_TITLE[page.path], url: page.path, text: LLMS_PAGE_TEXT[page.path]() }));
+ return seo.llmsFull({ site: 'OpenVibe Network', summary: llmsSummary(), base: baseUrl, maxBytes: LLMS_FULL_MAX_BYTES, sections: [{ title: 'Pages', pages }] });
+}
+
+module.exports = { sendHome, render, llmsTxt, llmsFullTxt, LLMS_FULL_MAX_BYTES, INTENTS };
@@ -130,6 +130,8 @@ const DEFAULT_GRANTS = [
@@ -130,6 +130,8 @@ const DEFAULT_GRANTS = [
['trade', 'events.event.publish', 'openvibe.events', []],
['trade', 'events.event.publish', 'openvibe.events', []],
['trade', 'sources.item.read', 'openvibe.sources', []],
['trade', 'sources.item.read', 'openvibe.sources', []],
['trade', 'sources.source.read', 'openvibe.sources', []],
['trade', 'sources.source.read', 'openvibe.sources', []],
+ // Trade's scripts/subscribe.js (Trade #9) manages its own Events subscriptions.
+ ['trade', 'events.subscription.manage', 'openvibe.events', []],
// Wave 18: Deals and Coupons; Wave 21 Stage B: the Host API publishes deploy/domain events.
// Wave 18: Deals and Coupons; Wave 21 Stage B: the Host API publishes deploy/domain events.
['deals', 'identity.subject.resolve', SELF_AUDIENCE, []],
['deals', 'identity.subject.resolve', SELF_AUDIENCE, []],
['deals', 'events.event.publish', 'openvibe.events', []],
['deals', 'events.event.publish', 'openvibe.events', []],
@@ -802,9 +802,14 @@ app.get(['/', '/index.html'], (req, res) => {
@@ -802,9 +802,14 @@ app.get(['/', '/index.html'], (req, res) => {
return require('./home/render').sendHome(req, res);
return require('./home/render').sendHome(req, res);
});
});
app.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(require('./home/render').llmsTxt()));
app.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(require('./home/render').llmsTxt()));
+app.get('/llms-full.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(require('./home/render').llmsFullTxt({ baseUrl: config.baseUrl })));
// The crawl files the front door owes crawlers, built from openvibe-shared/seo (plan T11 lane D), ahead
// The crawl files the front door owes crawlers, built from openvibe-shared/seo (plan T11 lane D), ahead
// of express.static below so they answer the generated copy rather than public/.
// of express.static below so they answer the generated copy rather than public/.
app.use(require('./seo/routes').createSeoRoutes({ release }));
app.use(require('./seo/routes').createSeoRoutes({ release }));
+// IndexNow key file (openvibe-shared/indexnow), from INDEXNOW_KEY; unset → off, nothing served. The
+// public page set is the fixed seo/routes PAGES list, so no hook pings; the client stays for one that will.
+const indexnow = require('openvibe-shared/indexnow').createIndexNow({ host: config.baseUrl, key: config.indexnow.key });
+if (indexnow.enabled) app.use(indexnow.keyFile);
// Account hub (my.html) — the apex hosts the account hub under /my
// Account hub (my.html) — the apex hosts the account hub under /my
// plus its client-routed sections.
// plus its client-routed sections.
@@ -34,6 +34,8 @@ for (const [client, cap, aud] of principals.DEFAULT_GRANTS) {
@@ -34,6 +34,8 @@ for (const [client, cap, aud] of principals.DEFAULT_GRANTS) {
assert.ok(c, `default grant ${client} ${cap} is in openvibe-contracts`);
assert.ok(c, `default grant ${client} ${cap} is in openvibe-contracts`);
assert.strictEqual(aud, `openvibe.${c.owner}`, `default grant ${client} ${cap} → ${aud}`);
assert.strictEqual(aud, `openvibe.${c.owner}`, `default grant ${client} ${cap} → ${aud}`);
}
}
+// Trade's scripts/subscribe.js (Trade #9) manages its Events subscriptions like the other publishers.
+assert.ok(principals.DEFAULT_GRANTS.some(([client, cap, aud, scopes]) => client === 'trade' && cap === 'events.subscription.manage' && aud === 'openvibe.events' && Array.isArray(scopes) && !scopes.length), 'Trade holds events.subscription.manage on openvibe.events');
assert.strictEqual((await events()).length, 0, 'the seed is not announced');
assert.strictEqual((await events()).length, 0, 'the seed is not announced');
// Grant, update, revoke.
// Grant, update, revoke.
@@ -7,6 +7,8 @@ const assert = require('assert');
@@ -7,6 +7,8 @@ const assert = require('assert');
const { bootServer } = require('./helpers/boot-server');
const { bootServer } = require('./helpers/boot-server');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const seoRoutes = require('../server/seo/routes');
const seoRoutes = require('../server/seo/routes');
+const render = require('../server/home/render');
+const cache = require('openvibe-shared/cache-policy');
(async () => {
(async () => {
const srv = await bootServer();
const srv = await bootServer();
@@ -54,6 +56,25 @@ const seoRoutes = require('../server/seo/routes');
@@ -54,6 +56,25 @@ const seoRoutes = require('../server/seo/routes');
const shipped = seoRoutes.sitemapXml({ release: { full: () => ({ released_at: '2026-01-02T03:04:05Z' }) } });
const shipped = seoRoutes.sitemapXml({ release: { full: () => ({ released_at: '2026-01-02T03:04:05Z' }) } });
assert.ok(shipped.includes('<lastmod>2026-01-02</lastmod>'), 'lastmod follows the release manifest');
assert.ok(shipped.includes('<lastmod>2026-01-02</lastmod>'), 'lastmod follows the release manifest');
+ // ── /llms-full.txt: the public pages only, within maxBytes ──
+ const full = await fetch(srv.base + '/llms-full.txt');
+ const fullBody = await full.text();
+ assert.strictEqual(full.status, 200, '/llms-full.txt is 200');
+ assert.ok((full.headers.get('content-type') || '').startsWith('text/plain'), '/llms-full.txt is text/plain');
+ assert.strictEqual(full.headers.get('cache-control'), cache.htmlHeaders({ maxAge: 3600 }), '/llms-full.txt is cached like /llms.txt');
+ assert.ok(fullBody.startsWith('# OpenVibe Network\n'), 'llms-full.txt opens with the llms.txt header');
+ const llms = await get('/llms.txt');
+ assert.ok(fullBody.includes(llms.body.split('\n').find(l => l.startsWith('> '))), 'llms-full.txt carries the llms.txt summary');
+ const fullUrls = [...fullBody.matchAll(/^URL: (\S+)$/gm)].map(m => m[1]);
+ assert.deepStrictEqual(fullUrls, seoRoutes.PAGES.map(p => `${seoRoutes.HOST}${p.path}`), 'llms-full.txt lists exactly the sitemap pages');
+ assert.ok(!/\/(admin|my|login|developer|internal|account|settings)\b/.test(fullUrls.join(' ')), 'no private page is listed');
+ assert.ok(Buffer.byteLength(fullBody) <= render.LLMS_FULL_MAX_BYTES, 'llms-full.txt stays within maxBytes');
+ assert.strictEqual(render.LLMS_FULL_MAX_BYTES, 512 * 1024);
+
+ // ── IndexNow: no INDEXNOW_KEY → no key file ──
+ const KEY = require('crypto').randomBytes(16).toString('hex');
+ assert.strictEqual((await get(`/${KEY}.txt`)).status, 404, 'no key file is served without INDEXNOW_KEY');
+
console.log('seo routes: all checks passed');
console.log('seo routes: all checks passed');
} catch (err) {
} catch (err) {
console.error(srv.logs());
console.error(srv.logs());
@@ -61,4 +82,22 @@ const seoRoutes = require('../server/seo/routes');
@@ -61,4 +82,22 @@ const seoRoutes = require('../server/seo/routes');
} finally {
} finally {
await srv.stop();
await srv.stop();
}
}
+
+ // ── IndexNow: INDEXNOW_KEY set → /<key>.txt answers with the key ──
+ const KEY = require('crypto').randomBytes(16).toString('hex');
+ const keyed = await bootServer({ child: true, env: { INDEXNOW_KEY: KEY } });
+ try {
+ const r = await fetch(`${keyed.base}/${KEY}.txt`);
+ assert.strictEqual(r.status, 200, 'the IndexNow key file is served');
+ assert.ok((r.headers.get('content-type') || '').startsWith('text/plain'), 'the key file is text/plain');
+ assert.strictEqual(await r.text(), KEY, 'the key file holds the key');
+ assert.strictEqual((await fetch(`${keyed.base}/0000000000000000.txt`)).status, 404, 'another key is not served');
+ assert.strictEqual((await fetch(`${keyed.base}/robots.txt`)).status, 200, 'robots.txt still answers');
+ } catch (err) {
+ console.error(keyed.logs());
+ throw err;
+ } finally {
+ await keyed.stop();
+ }
+ console.log('seo routes: indexnow key file checks passed');
})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });