openvibers/openvibe.run Commits

Serve llms.txt, llms-full.txt and an IndexNow key file when keyed

OP OpenVibers committed 14 Oct 2026 · 59aeef6f68 · 8 files, +155 −4

Asked: Branch agent/run-t11-step-4-run-slice-012408-mv7ffn2w (worktree /mnt/120gb/openvibe-worktrees/OpenVibe.Run-agent-run-t11-step-4-run-slice-012408-mv7ffn2w) adds /llms.txt, /llms-full.txt, the IndexNow key file and a boot ping when INDEXNOW_KEY is set. `ov push` refused: gitleaks found secrets in the…
Harness job run-branch-agent-run-t11-step-4-run-slic-013229.

Changes

ddiff --git a/.env.example b/.env.example
iindex 0aa061a..237db72 100644
---- a/.env.example
++++ b/.env.example
@@@ -90,3 +90,7 @@ OV_OAUTH_CLIENT_SECRET=
#RUN_BILLING_AUDIENCE=openvibe.billing
#RUN_BILLING_INTERVAL_MS=5000
#RUN_BILLING_TIMEOUT_MS=8000
++
++# ── IndexNow (search engines are told when the public pages change) ──
++# 8-128 letters and digits. Unset: nothing is sent and GET /<key>.txt is 404. When set, the key file is served there.
++#INDEXNOW_KEY=
ddiff --git a/README.md b/README.md
iindex e2df66b..23fd1e2 100644
---- a/README.md
++++ b/README.md
@@@ -44,7 +44,7 @@ placed from the Fabric's offers and actually runs — the poller in `server/jobs
- The public site at openvibe.run (`server/web/home.js`, `server/app.js`): the front page in the OpenVibe Frame (what a
job is and how it runs, the API in brief), the legal pages (`openvibe-shared/legal`), `robots.txt`, the sitemap,
-- the icon and the web manifest. It says only what works today; `test/site.test.js` checks it.
++ `/llms.txt` and `/llms-full.txt`, IndexNow (only with `INDEXNOW_KEY`: the key file and one ping at boot), the icon and the web manifest. It says only what works today; `test/site.test.js` checks it.
- The job API (`server/api/v1.js`, one capability per route — the table below) and the job's lifecycle record
(`run_jobs`, `migrations/0001_jobs.sql`): state, placement, timings, exit, result and the metered wall clock.
- The project scope: a job's `project_id` and `requester` come from the caller's Network token
ddiff --git a/STATUS.json b/STATUS.json
iindex 2b646ca..5ccd4ce 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -49,7 +49,8 @@
"run_jobs (migrations/0001_jobs.sql) holds every job, its state, placement, timings, exit, result and the metered wall clock",
"Bot owns the node link and the per-second metering of the jobs it carries (plan T14 L1); Run holds a Billing token of its own (billing.usage.record, audience openvibe.billing) used only for the external managed-browser provider's jobs",
"plan T14 R4a built, not deployed: the managed-browser provider adapter (server/dispatch/browser-provider.js; RUN_BROWSER_PROVIDER_URL / _API_KEY / _TIMEOUT_MS, off while unset) places class browser jobs on kind=provider offers named browser-provider, validates every answer against browser-provider.job-create-result@1 / job-state@1 and never adopts a malformed one; Run meters those jobs itself, the provider's claimed wall time clamped to Run's observed duration + 5 s, and relays the readings from run_usage_outbox (migrations/0003_usage_outbox.sql) to Billing (RUN_BILLING_URL; unset, they wait and the jobs still run)",
-- "truthful readiness (GET /api/ready: db required; valkey, Network JWKS, Events, Bot reachability and the poller's own status reported, never claimed) and the loopback-only /metrics gauges (jobs queued, active, ended, outbox backlog, streams)"
++ "truthful readiness (GET /api/ready: db required; valkey, Network JWKS, Events, Bot reachability and the poller's own status reported, never claimed) and the loopback-only /metrics gauges (jobs queued, active, ended, outbox backlog, streams)",
++ "plan T11 step 4 built, not deployed: /llms.txt and /llms-full.txt (server/web/discovery.js, public pages and docs only, never the API, admin, a job or a stream) and IndexNow (INDEXNOW_KEY: serves GET /<key>.txt and pings the public pages once at boot; unset, the key route is 404 and nothing is sent)"
],
"updated": "2026-10-13"
}
ddiff --git a/server/app.js b/server/app.js
iindex bb01f12..30cf3ce 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -99,6 +99,16 @@ function createApp(opts = {}) {
const home = require('./web/home');
const appIcon = require('openvibe-shared/app-icon');
app.use('/shared', require('openvibe-shared/serve').handler());
++ // IndexNow (openvibe-shared/indexnow): on only with INDEXNOW_KEY, which also mounts GET /<key>.txt. A malformed
++ // key (8–128 alphanumerics) turns it off instead of stopping the service.
++ const discovery = require('./web/discovery');
++ let indexnow = opts.indexnow;
++ if (!indexnow) {
++ const { createIndexNow } = require('openvibe-shared/indexnow');
++ try { indexnow = createIndexNow({ host: config.baseUrl, key: config.indexnow.key, fetch: fetchImpl }); }
++ catch (err) { log.warn(`[Run] IndexNow disabled: ${err.message}`); indexnow = createIndexNow({ host: config.baseUrl, key: '' }); }
++ }
++ if (indexnow.enabled) app.use(indexnow.keyFile);
app.get('/', (req, res) => {
res.setHeader('Content-Security-Policy', home.HOME_CSP);
res.setHeader('Cache-Control', 'public, max-age=300');
@@@ -106,6 +116,8 @@ function createApp(opts = {}) {
});
{ const legal = require('openvibe-shared/legal');
app.get(legal.PATHS, (req, res, next) => { res.setHeader('Content-Security-Policy', home.HOME_CSP); next(); }, legal.handler({ id: 'run', service: 'run', host: 'openvibe.run', name: home.SITE_NAME, profile: 'hosting' })); }
++ app.get('/llms.txt', (req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(discovery.llmsTxt(config)));
++ app.get('/llms-full.txt', (req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(discovery.llmsFull(config)));
app.get('/robots.txt', (req, res) => res.type('text/plain').send(['User-agent: *', 'Allow: /$', 'Disallow: /api/', 'Disallow: /internal/', `Sitemap: ${config.baseUrl}/sitemap.xml`, ''].join('\n')));
app.get('/sitemap.xml', (req, res) => res.type('application/xml').send(require('openvibe-shared/seo').sitemapXml(['/', '/terms', '/privacy', '/dmca'].map((p) => ({ loc: `${config.baseUrl}${p}` })))));
app.get('/favicon.ico', (req, res) => res.type('image/svg+xml').set('Cache-Control', 'public, max-age=86400').send(appIcon.favicon({ site: 'run' })));
@@@ -128,7 +140,7 @@ function createApp(opts = {}) {
return res.status(500).type('text/plain').send('Something went wrong\n');
});
-- Object.assign(app.locals, { config, db, valkey, keys, outbox, events, stream, tickets, dispatch, apiAuth, metrics });
++ Object.assign(app.locals, { config, db, indexnow, valkey, keys, outbox, events, stream, tickets, dispatch, apiAuth, metrics });
return app;
}
ddiff --git a/server/config.js b/server/config.js
iindex e2f0988..2443183 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -67,6 +67,8 @@ function loadConfig(env = process.env) {
clientId: env.OV_OAUTH_CLIENT_ID || 'run',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
},
++ // IndexNow (openvibe-shared/indexnow): unset, nothing is sent and the key file is not served.
++ indexnow: { key: (env.INDEXNOW_KEY || '').trim() },
cookies: { secure: env.COOKIE_SECURE != null ? bool(env.COOKIE_SECURE) : isProduction },
events: {
ddiff --git a/server/index.js b/server/index.js
iindex b70acbe..3062768 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -33,7 +33,7 @@ async function main() {
const valkey = createValkey({ url: config.valkey.url, prefix: config.valkey.prefix });
const app = createApp({ config, db, valkey, registry });
-- const { keys, outbox, stream, tickets, dispatch } = app.locals;
++ const { keys, outbox, stream, tickets, dispatch, indexnow } = app.locals;
keys.start();
const ticketKey = tickets.start();
@@@ -50,6 +50,8 @@ async function main() {
console.log(`[Run] jobs ${config.jobs.enabled ? 'on' : 'off (RUN_JOBS=off)'}; events relay ${outbox.enabled ? `→ ${config.events.url}` : 'off (run.job.* events wait in the outbox)'}; stream tickets ${ticketKey.enabled ? `on (${config.stream.keyId}${ticketKey.ephemeral ? ', ephemeral key' : ''})` : 'off (no stream key)'}; dispatcher ${dispatch.enabled ? `on (poll ${config.dispatch.pollMs} ms): ${dispatch.note}` : `off: ${dispatch.note}`}; ${dispatch.status().usage_relay.note}`);
});
server.keepAliveTimeout = 65_000;
++ // The public pages change with a deploy: tell the engines once, and only with INDEXNOW_KEY set.
++ require('./web/discovery').notify(indexnow, config);
// systemd sends SIGTERM (SIGINT by hand); openvibe-sdk/service's gracefulStop takes the signal, runs the
// stop steps in order (nothing new starts), drains the HTTP server — event streams are destroyed, so a
ddiff --git a/server/web/discovery.js b/server/web/discovery.js
nnew file mode 100644
iindex 0000000..44bd0f6
---- /dev/null
++++ b/server/web/discovery.js
@@@ -0,0 +1,61 @@
++'use strict';
++/**
++ * What openvibe.run tells crawlers about itself: /llms.txt and /llms-full.txt (openvibe-shared/seo) and the
++ * IndexNow notifier. Only the public pages are listed — never the API, the admin routes, a job or its stream.
++ */
++const seo = require('openvibe-shared/seo');
++const home = require('./home');
++
++const MAX_BYTES = 512 * 1024;
++const REPO = 'https://github.com/OpenVibers/OpenVibe.Run';
++const NODE_REPO = 'https://github.com/OpenVibers/OpenVibe.Node';
++
++// The public pages, in the order the sitemap lists them.
++const PAGES = ['/', '/terms', '/privacy', '/dmca'];
++
++function sections(base) {
++ return [
++ { title: 'Pages', links: [
++ { title: 'OpenVibe.Run', url: `${base}/`, note: 'what a job is, how it runs, and the API in brief' },
++ { title: 'Terms', url: `${base}/terms` },
++ { title: 'Privacy', url: `${base}/privacy` },
++ { title: 'DMCA', url: `${base}/dmca` },
++ ] },
++ { title: 'Docs', links: [
++ { title: 'OpenVibe.Run on GitHub', url: REPO, note: 'the source and the README: the job API, its states and its capabilities' },
++ { title: 'OpenVibe Node', url: NODE_REPO, note: 'pair your own machine to run jobs' },
++ ] },
++ ];
++}
++
++function llmsTxt(config) {
++ return seo.llmsTxt({ name: home.SITE_NAME, summary: home.DESCRIPTION, sections: sections(config.baseUrl) });
++}
++
++function llmsFull(config) {
++ const page = home.renderHome({ config });
++ const main = /<main id="main">([\s\S]*)<\/main>/.exec(page);
++ return seo.llmsFull({
++ site: home.SITE_NAME,
++ summary: home.DESCRIPTION,
++ base: config.baseUrl,
++ maxBytes: MAX_BYTES,
++ sections: [{ title: 'Pages', pages: [{ title: home.SITE_NAME, url: '/', html: main ? main[1] : '' }] }],
++ });
++}
++
++/**
++ * Tells search engines the public pages changed (once at boot; a deploy is what changes them). Sends only
++ * when IndexNow is enabled, i.e. INDEXNOW_KEY is set; returns whether anything was queued. Never throws.
++ */
++function notify(indexnow, config) {
++ try {
++ if (!indexnow || !indexnow.enabled) return false;
++ return indexnow.pingSoon([...PAGES, '/llms.txt', '/sitemap.xml'].map((p) => `${config.baseUrl}${p}`)) > 0;
++ } catch (err) {
++ console.warn('[IndexNow] notify failed:', err.message);
++ return false;
++ }
++}
++
++module.exports = { llmsTxt, llmsFull, notify, sections, MAX_BYTES, PAGES };
ddiff --git a/test/discovery.test.js b/test/discovery.test.js
nnew file mode 100644
iindex 0000000..705f5aa
---- /dev/null
++++ b/test/discovery.test.js
@@@ -0,0 +1,69 @@
++'use strict';
++/**
++ * What openvibe.run tells crawlers: /llms.txt, /llms-full.txt and IndexNow (the key file and the boot ping,
++ * both only with INDEXNOW_KEY).
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++const { createIndexNow } = require('openvibe-shared/indexnow');
++const discovery = require('../server/web/discovery');
++
++const KEY = require('crypto').randomBytes(16).toString('hex');
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('/llms.txt answers 200 text/plain with the site name, and lists no API, admin, job or stream URL', async () => {
++ const r = await t.call('GET', '/llms.txt', { token: null });
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('content-type'), /^text\/plain/);
++ assert.match(r.text, /^# OpenVibe\.Run\n/);
++ assert.ok(r.text.includes('http://run.test/terms'));
++ assert.ok(!/\/api\/|\/internal\/|\/admin|\/jobs|\/stream/.test(r.text), 'no machine route is listed');
++ });
++ await check('/llms-full.txt answers 200 text/plain within maxBytes and carries the front page text', async () => {
++ const r = await t.call('GET', '/llms-full.txt', { token: null });
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('content-type'), /^text\/plain/);
++ assert.ok(Buffer.byteLength(r.text) <= discovery.MAX_BYTES);
++ assert.match(r.text, /^# OpenVibe\.Run\n/);
++ assert.match(r.text, /How a job runs/);
++ assert.ok(!/<[a-z]+[ >]/.test(r.text), 'plain text, no markup');
++ assert.ok(!/\/internal\/|\/admin/.test(r.text));
++ });
++ await check('without INDEXNOW_KEY the key route is 404 and no notify call is made', async () => {
++ assert.strictEqual(t.app.locals.indexnow.enabled, false);
++ assert.strictEqual((await t.call('GET', `/${KEY}.txt`, { token: null })).status, 404);
++ const sent = [];
++ const off = createIndexNow({ host: 'https://run.test', key: '', fetch: async (...a) => { sent.push(a); return { status: 200 }; } });
++ assert.strictEqual(discovery.notify(off, { baseUrl: 'https://run.test' }), false);
++ await off.flush();
++ await off.ping(['https://run.test/']);
++ assert.strictEqual(sent.length, 0);
++ });
++ } finally {
++ await t.close();
++ }
++
++ const sent = [];
++ const indexnow = createIndexNow({ host: 'https://run.test', key: KEY, fetch: async (url, init) => { sent.push(JSON.parse(init.body)); return { status: 200 }; } });
++ const k = await boot({ env: { INDEXNOW_KEY: KEY, BASE_URL: 'https://run.test' }, appOpts: { indexnow } });
++ try {
++ await check('with INDEXNOW_KEY the key route serves the key, and notify sends the public pages in one batch', async () => {
++ const r = await k.call('GET', `/${KEY}.txt`, { token: null });
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.text, KEY);
++ assert.strictEqual((await k.call('GET', '/other-key.txt', { token: null })).status, 404);
++ assert.strictEqual(discovery.notify(k.app.locals.indexnow, k.app.locals.config), true);
++ await indexnow.flush();
++ assert.strictEqual(sent.length, 1);
++ assert.strictEqual(sent[0].key, KEY);
++ assert.strictEqual(sent[0].keyLocation, `https://run.test/${KEY}.txt`);
++ assert.deepStrictEqual(sent[0].urlList.slice(0, 2), ['https://run.test/', 'https://run.test/terms']);
++ assert.ok(sent[0].urlList.every((u) => !/\/api\/|\/internal\//.test(u)));
++ });
++ } finally {
++ await k.close();
++ }
++ done();
++})();