openvibers/openvibe.live Commits

/llms.txt for Live (roadmap 32.4/32.5, 33.8): plain text (llmstxt.org shape) saying what OpenVibe.Live is, its public pages and the JSON behind them (/content and /api/content/feed, /moments and /api/content/moments, /@user?page=N, /vod/<id>?t=<s>, /clip/<id>, pastes on OpenVibe.Community), the API docs (/documentation, /docs and the WHIP, broadcasting, API token, chat, VOD/clip and architecture docs that exist), the sitemap, and how people's work is kept apart from AI Moments (labelled, credited to no person, noindex,follow, canonical to the source VOD at the moment, 'ai': true in the JSON). Served by the SEO module before the SPA fallback, cached an hour. test/llms-txt.test.js checks the links resolve to real docs and that it has no free/$0 copy.

OP OpenVibers committed 24 Sept 2026 · faf49f8f87 · 2 files, +142 −3


Changes

ddiff --git a/server/seo/seo.js b/server/seo/seo.js
iindex 9698c462..4dc6d266 100644
---- a/server/seo/seo.js
++++ b/server/seo/seo.js
@@@ -1053,12 +1053,71 @@ async function sitemapHandler(req, res) {
res.send(_sitemap);
}
--// Register: dynamic sitemap first, then the meta middleware. MUST be mounted BEFORE
++// ── /llms.txt: what this site is, for language models and other automated readers ─────────
++// (llmstxt.org). It names the public pages, the JSON behind them, the API docs, and how people's
++// work is kept apart from what the AI derived from it (roadmap 32.4, 33.8).
++const LLMS_DOCS = [
++ ['whip', 'WHIP ingest API: publish to a channel from a browser or any WHIP client'],
++ ['broadcasting', 'Going live: WebRTC, WHIP, RTMP (OBS) and the JSMPEG/CLI path'],
++ ['api-tokens', 'Bot and integration tokens (hbt_...) for the API'],
++ ['chat-system', 'Chat features and moderation'],
++ ['vods-and-clips', 'How VODs and clips are recorded and cut'],
++ ['architecture', 'System design and how Live fits the OpenVibe network'],
++];
++function llmsTxt() {
++ const b = baseUrl();
++ const docs = LLMS_DOCS.filter(([name]) => { try { return fs.existsSync(path.join(__dirname, '../../docs', `${name}.md`)); } catch { return false; } });
++ return [
++ `# ${SITE_NAME}`,
++ '',
++ '> Open-source, community-run live streaming. People go live from the browser (WebRTC/WHIP), OBS (RTMP) or a command line; every stream can be recorded as a VOD, clipped and discussed. Part of the OpenVibe network: one account across openvibe.network, openvibe.media, openvibe.community, openvibe.tools and the other OpenVibe sites.',
++ '',
++ 'People\'s work and AI-made material are kept apart everywhere on this site. The Content pages list what people made; AI Moments list what the platform\'s AI derived from streams. Every AI item is labelled AI-generated, credited to no person, marked noindex,follow and made canonical to the source VOD at the moment it came from (/vod/<id>?t=<seconds>). In the JSON feeds, AI items carry "ai": true and an "ai_label".',
++ '',
++ '## What people made',
++ '',
++ `- [Home](${b}/): who is live now, recent VODs and clips`,
++ `- [Content](${b}/content): VODs, the clips people took, and pastes people wrote. JSON: ${b}/api/content/feed (cursor paging; ?type=vods|clips|pastes, ?sort=new|top)`,
++ `- Channel pages: ${b}/@<username>, with the channel's videos in pages (${b}/@<username>?page=2)`,
++ `- VOD pages: ${b}/vod/<id>; a moment in a VOD: ${b}/vod/<id>?t=<seconds>`,
++ `- Clip pages: ${b}/clip/<id>`,
++ '- Pastes live on OpenVibe.Community: https://openvibe.community/p/<slug> (Live\'s /p/<slug> redirects there)',
++ `- [Chat](${b}/chat) and [the Arena](${b}/arena): mic-judged streamer callouts`,
++ '',
++ '## What the AI made (AI Moments)',
++ '',
++ `- [AI Moments](${b}/moments): auto-clips cut when chat reacted, frames the AI picked from streams, and AI-written after-show recaps. JSON: ${b}/api/content/moments`,
++ '- AI Moments pages are noindex; the /moments collection is their indexable form. They are never listed in the sitemap.',
++ '- An AI clip says "AI clip · from <streamer>\'s stream"; it is never presented as something the streamer or a viewer clipped.',
++ '',
++ '## API and docs',
++ '',
++ `- [API docs](${b}/documentation): every feature has an open API (chat, streams, clips, overlays, robots, sound commands)`,
++ `- [Docs index](${b}/docs)`,
++ ...docs.map(([name, what]) => `- [${name}](${b}/docs/${name}): ${what}`),
++ '- Source code: https://github.com/OpenVibers/OpenVibe.Live',
++ '',
++ '## Discovery',
++ '',
++ `- Sitemap (people's work only): ${b}/sitemap.xml`,
++ `- robots.txt: ${b}/robots.txt`,
++ '- The OpenVibe network\'s platform descriptor: https://openvibe.network/.well-known/openvibe',
++ '',
++ ].join('\n');
++}
++function llmsHandler(req, res) {
++ res.set('Content-Type', 'text/plain; charset=utf-8');
++ res.set('Cache-Control', 'public, max-age=3600');
++ res.send(llmsTxt());
++}
++
++// Register: dynamic sitemap and llms.txt first, then the meta middleware. MUST be mounted BEFORE
// express.static (so it can intercept "/") and before the SPA catch-all.
function register(app) {
app.get('/sitemap.xml', sitemapHandler);
++ app.get('/llms.txt', llmsHandler);
app.use(middleware);
-- console.log('[SEO] per-route meta + dynamic sitemap registered');
++ console.log('[SEO] per-route meta + dynamic sitemap + llms.txt registered');
}
--module.exports = { register, middleware, sitemapHandler, buildSitemap, shellHtml, _pageMeta, render };
++module.exports = { register, middleware, sitemapHandler, buildSitemap, shellHtml, llmsTxt, _pageMeta, render };
ddiff --git a/test/llms-txt.test.js b/test/llms-txt.test.js
nnew file mode 100644
iindex 00000000..aff4dd77
---- /dev/null
++++ b/test/llms-txt.test.js
@@@ -0,0 +1,80 @@
++/**
++ * /llms.txt (server/seo/seo.js, roadmap 32.4/33.8): plain text that tells language models and other
++ * automated readers what OpenVibe.Live is, where its public pages and their JSON are, where the API
++ * docs are, and how people's work is kept apart from what the AI derived. Every docs page it links
++ * must exist, and it must answer before the SPA fallback (never the HTML shell).
++ *
++ * node test/llms-txt.test.js
++ */
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++
++const tmp = path.join(os.tmpdir(), `ov-llms-${process.pid}.db`);
++process.env.DB_PATH = tmp;
++process.env.NODE_ENV = 'test';
++const quiet = console.log;
++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
++console.warn = () => {};
++console.error = () => {};
++
++require('../server/db/database').initDb();
++const express = require('express');
++const seo = require('../server/seo/seo');
++const app = express();
++seo.register(app);
++app.get('*', (req, res) => res.status(404).type('html').send('<html>shell</html>'));
++
++let failures = 0;
++async function check(name, fn) {
++ try { await fn(); quiet(' ✓', name); }
++ catch (e) { failures++; quiet(' ✗', name, '\n ', e.message); }
++}
++
++const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const res = await fetch(`${base}/llms.txt`);
++ const text = await res.text();
++ quiet('llms.txt');
++
++ await check('answers 200 text/plain, before the SPA fallback', () => {
++ assert.strictEqual(res.status, 200);
++ assert.ok(/^text\/plain/.test(res.headers.get('content-type')), res.headers.get('content-type'));
++ assert.ok(text.startsWith('# OpenVibe.Live\n\n> '), 'an llmstxt.org title and summary');
++ assert.ok(!/<html/i.test(text));
++ });
++
++ await check('names the public pages and the JSON behind them', () => {
++ for (const u of ['https://openvibe.live/content', 'https://openvibe.live/moments', 'https://openvibe.live/@<username>',
++ 'https://openvibe.live/vod/<id>?t=<seconds>', 'https://openvibe.live/clip/<id>', 'https://openvibe.live/api/content/feed',
++ 'https://openvibe.live/api/content/moments', 'https://openvibe.community/p/<slug>', 'https://openvibe.live/sitemap.xml']) {
++ assert.ok(text.includes(u), `missing ${u}`);
++ }
++ });
++
++ await check('links the API docs, and every docs page it links exists', () => {
++ assert.ok(text.includes('[API docs](https://openvibe.live/documentation)'));
++ const linked = [...text.matchAll(/https:\/\/openvibe\.live\/docs\/([a-z-]+)\)/g)].map((m) => m[1]);
++ assert.ok(linked.includes('whip') && linked.includes('api-tokens'), linked.join(','));
++ for (const name of linked) assert.ok(fs.existsSync(path.join(__dirname, '../docs', `${name}.md`)), `docs/${name}.md`);
++ });
++
++ await check("explains the separation of people's work and AI Moments", () => {
++ assert.ok(/People's work and AI-made material are kept apart/.test(text));
++ assert.ok(text.includes('noindex,follow') && text.includes('credited to no person') && text.includes('"ai": true'));
++ assert.ok(/## What people made[\s\S]*## What the AI made/.test(text), 'made, then derived');
++ });
++
++ await check('no "free" or "$0" copy (owner rule)', () => {
++ assert.ok(!/\bfree\b|\$0/i.test(text), 'found free/$0');
++ });
++
++ server.close();
++ try { fs.unlinkSync(tmp); } catch { /* */ }
++ for (const ext of ['-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }
++ quiet(failures ? `\n${failures} check(s) failed` : '\nllms.txt: all checks passed');
++ process.exit(failures ? 1 : 0);
++});