openvibers/openvibe.network Commits

Serve llms-full.txt and IndexNow key, grant Trade event manage

OP OpenVibers committed 4 Oct 2026 · e82af326c8 · 9 files, +88 −12

Asked: Branch agent/network-t11-step-4-c-d-for-221022-muudjscx (worktree /mnt/120gb/openvibe-worktrees/OpenVibe.Network-agent-network-t11-step-4-c-d-for-221022-muudjscx) holds a finished change (llms-full.txt, IndexNow key file, events.subscription.manage grant, shared v2.6.0 / contracts v0.94.0 bump) but…
Harness job network-branch-agent-network-t11-step-4--222739.

Changes

ddiff --git a/.env.example b/.env.example
iindex 14480470..b52b520d 100644
---- a/.env.example
++++ b/.env.example
@@@ -13,6 +13,10 @@ OV_NETWORK_URL=http://localhost:4000
OV_NETWORK_INTERNAL_URL=http://127.0.0.1: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 minute and per hour; account, project, theme, avatar, follow and block writes have tighter numbers of their own.
#NETWORK_LIMITS_MINUTE=120
ddiff --git a/package-lock.json b/package-lock.json
iindex f6599d8c..fd1b1f7d 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex 385e54d2..b87c83a2 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/server/config.js b/server/config.js
iindex 8d4b4f3a..7017dad9 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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`
ddiff --git a/server/home/render.js b/server/home/render.js
iindex 0cc2ea14..50e06cbf 100644
---- a/server/home/render.js
++++ b/server/home/render.js
@@@ -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 };
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex cdb327dc..407a1800 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -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', []],
ddiff --git a/server/index.js b/server/index.js
iindex fc082ab0..c075a312 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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.
ddiff --git a/test/grants-admin.test.js b/test/grants-admin.test.js
iindex 8f7d37e7..b9ba0ad9 100644
---- a/test/grants-admin.test.js
++++ b/test/grants-admin.test.js
@@@ -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.
ddiff --git a/test/seo-routes.test.js b/test/seo-routes.test.js
iindex 0a42032b..f06c01f2 100644
---- a/test/seo-routes.test.js
++++ b/test/seo-routes.test.js
@@@ -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); });