openvibers/openvibe.chat Commits
robots.txt, llms.txt and sitemap.xml come from openvibe-shared/seo (plan T11, server/web/discovery.js): public data only, lastmod from the data itself (the newest public message, each room's last activity), never today; every previous Disallow kept. The home page carries WebSite + WebApplication + WebPage JSON-LD. test/discovery.test.js covers the artifacts. audio-queue's restart case now waits for all three durable rows before reading them (the announce comes before the insert, the race the last verify hit).
Changes
nnew file mode 100644@@@ -0,0 +1,117 @@++'use strict';++/**++ * Crawl and machine-readability artifacts for openvibe.chat (plan T11): robots.txt, llms.txt and++ * sitemap.xml, built from openvibe-shared/seo — the same toolkit the other OpenVibe sites use.++ *++ * Every answer comes from public data only and never from the viewer: the global chat's last public++ * message, the rooms that are public and unarchived, and the site's own fixed pages. Direct++ * messages, your conversations, room settings, sign-in and the API are in no artifact.++ *++ * lastmod is the data's own timestamp (the newest public message, the room's last_message_at or its++ * created_at) — never "today", which would tell crawlers the whole site changed on every fetch.++ */++const express = require('express');++const seo = require('openvibe-shared/seo');++const historyStore = require('../chat/history-store');++const rooms = require('../rooms/rooms');++++/** A SQLite `DATETIME` ("YYYY-MM-DD HH:MM:SS", UTC) or ISO string as YYYY-MM-DD; null when unusable. */++function dayOf(ts) {++ const s = String(ts == null ? '' : ts);++ const m = s.match(/^(\d{4}-\d{2}-\d{2})/);++ return m ? m[1] : null;++}++++/** Public, indexable pages: the fixed ones plus every public room, each with a real lastmod. */++function publicPages() {++ const pages = rooms.list(null, { limit: 100 }).public;++ const out = [++ { path: '/', changefreq: 'hourly', priority: 1.0, lastmod: () => newestGlobalMessage() },++ { path: '/rooms', changefreq: 'daily', priority: 0.8, lastmod: () => newestRoomActivity(pages) },++ { path: '/updates', changefreq: 'daily', priority: 0.5 },++ ];++ for (const r of pages) {++ out.push({ path: `/r/${r.slug}`, changefreq: 'daily', priority: 0.6, lastmod: () => dayOf(r.last_message_at || r.created_at) });++ }++ return out;++}++++function newestGlobalMessage() {++ try {++ const row = historyStore.page('global', { limit: 1 }).messages[0];++ return row ? dayOf(row.timestamp) : null;++ } catch { return null; }++}++++function newestRoomActivity(pages) {++ const days = pages.map((r) => dayOf(r.last_message_at || r.created_at)).filter(Boolean).sort();++ return days.length ? days[days.length - 1] : null;++}++++/**++ * The home page's JSON-LD: the site as a WebSite, the chat itself as the site's primary type (the++ * shared kit's WebApplication) and the page that carries it. Nothing about the reader or their++ * messages: the same three nodes for every crawler.++ */++function homeJsonLd(config) {++ const site = String(config.web.baseUrl).replace(/\/+$/, '');++ const name = 'OpenVibe.Chat';++ const description = 'The network-wide chat room of OpenVibe: everyone on every OpenVibe site, in one conversation, with rooms and direct messages.';++ return [++ seo.jsonLd.website({ name, url: site, description }),++ seo.jsonLd.softwareApp({++ name, url: site, description, category: 'SocialNetworkingApplication',++ keywords: 'chat, rooms, direct messages, openvibe',++ }),++ seo.jsonLd.webPage({ name: 'Global chat', url: `${site}/`, description, siteUrl: site }),++ ];++}++++function createDiscoveryRoutes({ config }) {++ const router = express.Router();++ const site = String(config.web.baseUrl).replace(/\/+$/, '');++ const abs = (p) => `${site}${p}`;++++ router.get('/robots.txt', (req, res) => {++ // The same rules as before, plus the search and AI crawlers the shared kit names by name and++ // the sitemap. Every previous Disallow is kept.++ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(seo.robotsTxt({++ sitemaps: [abs('/sitemap.xml')],++ allow: ['/', '/updates', '/rooms', '/r/'],++ disallow: ['/r/*/settings', '/messages', '/settings', '/auth/', '/api/'],++ }));++ });++++ router.get('/llms.txt', (req, res) => {++ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(seo.llmsTxt({++ name: 'OpenVibe.Chat',++ summary: 'OpenVibe.Chat: the network-wide chat room of OpenVibe, plus its rooms and the direct messages of its members.',++ details: 'One OpenVibe account works here and on every other OpenVibe site. Anyone can read the global chat and the public rooms without an account; posting needs a signed-in person. The global chat and the public room pages are readable as plain HTML without JavaScript and update live over the site\'s own WebSocket. Direct messages, room settings and the API are per-person and are never listed here.',++ sections: [++ { title: 'Start here', links: [++ { title: 'Global chat', url: abs('/'), note: 'one room for everyone on OpenVibe, newest messages at the end' },++ { title: 'Rooms', url: abs('/rooms'), note: 'every public room, with its topic and activity' },++ { title: 'What shipped on OpenVibe.Chat', url: abs('/updates') },++ ] },++ { title: 'Machine-readable', links: [++ { title: 'Sitemap', url: abs('/sitemap.xml'), note: 'the public pages above' },++ { title: 'robots.txt', url: abs('/robots.txt') },++ ] },++ ],++ }));++ });++++ router.get('/sitemap.xml', (req, res) => {++ const urls = publicPages().map((p) => ({++ loc: abs(p.path),++ ...(p.lastmod && p.lastmod() ? { lastmod: p.lastmod() } : {}),++ changefreq: p.changefreq,++ priority: p.priority,++ }));++ res.type('application/xml').set('Cache-Control', 'public, max-age=3600').send(seo.sitemapXml(urls));++ });++++ return router;++}++++module.exports = { createDiscoveryRoutes, publicPages, dayOf, homeJsonLd };@@@ -66,7 +66,7 @@ function renderPage(o) {const title = o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME;const head = sharedSeo.headTags({title, description: o.description || DEFAULT_DESCRIPTION, canonical: `${config.web.baseUrl}${o.path || '/'}`,-- robots: o.robots, siteName: SITE_NAME, type: 'website',++ robots: o.robots, siteName: SITE_NAME, type: 'website', jsonLd: o.jsonLd,});const actor = o.actor || { kind: 'anonymous' };const who = actor.kind === 'user'@@@ -33,6 +33,7 @@ const ovServe = require('openvibe-shared/serve');const frame = require('openvibe-shared/frame');const { renderPage, esc, SITE_NAME } = require('./layout');const { createSessionRoutes } = require('./session');++const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');const session = require('../auth/network-session');const historyStore = require('../chat/history-store');const dm = require('../chat/dm');@@@ -158,6 +159,7 @@ function createWebRoutes({ config }) {await page(req, res, 200, {actor, title: 'Global chat', path: '/', active: 'global', robots: 'index, follow', script: true,description: 'The network-wide chat room of OpenVibe: everyone on every OpenVibe site, in one conversation.',++ jsonLd: homeJsonLd(config),page: { view: 'global', latest: latest_id, signedIn: actor.kind === 'user', me: actor.kind === 'user' ? actor.user.username : null },body: `<section class="oc-room" aria-labelledby="oc-h"><h1 id="oc-h">Global chat</h1>@@@ -595,10 +597,8 @@ ${attachList}`,title: `What shipped on ${SITE_NAME}`, path: '/updates', robots: 'index, follow', cache: 'public, max-age=300',body: frame.updatesBody({ service: 'chat', siteName: SITE_NAME }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,}));-- router.get('/robots.txt', (req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600')-- .send(`User-agent: *\nAllow: /$\nAllow: /updates\nAllow: /rooms\nAllow: /r/\nDisallow: /r/*/settings\nDisallow: /messages\nDisallow: /settings\nDisallow: /auth/\nDisallow: /api/\nSitemap: ${site}/sitemap.xml\n`));-- router.get('/sitemap.xml', (req, res) => res.type('application/xml').set('Cache-Control', 'public, max-age=3600')-- .send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${['/', '/rooms', '/updates', ...rooms.list(null, { limit: 100 }).public.map((r) => `/r/${r.slug}`)].map((p) => `<url><loc>${site}${p}</loc></url>`).join('')}</urlset>\n`));++ // ── robots.txt, llms.txt, sitemap.xml: server/web/discovery.js, the shared SEO kit ──++ router.use(createDiscoveryRoutes({ config }));// ── Nothing here: a browser asking for a page gets the site's 404 page (the browser check found// JSON). The API, /internal/, /ws/ and any client not asking for HTML keep the JSON 404 that@@@ -267,6 +267,8 @@ t('restart: what was playing is finished, what waited plays in order once, stalews.close();}const firstClip = await ears.next((m) => m.type === 'soundboard-audio' && m.title === '!long');++ // The announce comes before the durable insert: wait for all three rows, not just the first clip.++ await until(() => raw((d) => d.prepare("SELECT COUNT(*) AS n FROM audio_requests WHERE label = '!long'").get().n) === 3, 3000);const queued = raw((d) => d.prepare("SELECT id, state FROM audio_requests WHERE label = '!long' ORDER BY id").all());assert.deepStrictEqual(queued.map((x) => x.state), ['playing', 'queued', 'queued']);assert.strictEqual(queued[0].id, firstClip.request_id);nnew file mode 100644@@@ -0,0 +1,108 @@++'use strict';++/**++ * openvibe.chat's crawl artifacts (server/web/discovery.js): llms.txt, robots.txt and sitemap.xml,++ * plus the home page's JSON-LD. Everything they carry is public — a private room, a direct message++ * or a per-person path must never appear in any of them.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++++const t = suite('discovery');++let h, alice;++const SITE = 'https://openvibe.chat';++const req = async (path) => {++ const r = await fetch(`${h.base}${path}`, { redirect: 'manual' });++ return { status: r.status, type: r.headers.get('content-type') || '', cache: r.headers.get('cache-control') || '', text: await r.text() };++};++// Anything that would only exist for one person, or that a crawler must not follow.++const PRIVATE = [/\/messages/, /\/settings/, /\/auth\//, /\/api\//, /\/internal\//, /\/ws\//, /private-room/, /token/, /secret/i];++++t('boot', async () => {++ h = await boot({ env: { CHAT_WEB_URL: SITE } });++ alice = h.addUser('alice', { subject: ids.newId('user') });++ h.ctx.upsertUser(h.live.users.get(alice.id));++ const rooms = require('../server/rooms/rooms');++ rooms.create(alice, { name: 'Night Owls', topic: 'Late chats' });++ rooms.create(alice, { name: 'Closed Room', slug: 'private-room', visibility: 'private' });++ await h.http('POST', '/api/chat/send', { token: alice.token, body: { message: 'hello from the test' } });++ // Backdate the message so "lastmod is the data's own day" is checkable against "not today".++ const db = require('../server/db/database');++ db.run("UPDATE chat_messages SET timestamp = '2026-01-02 03:04:05' WHERE id = (SELECT MAX(id) FROM chat_messages)");++ h.db.run("UPDATE rooms SET last_message_at = '2026-02-03 04:05:06' WHERE slug = 'night-owls'");++ require('../server/chat/history-store').invalidate('global');++});++++t('/llms.txt describes the site, its public pages and its machine-readable endpoints', async () => {++ const r = await req('/llms.txt');++ assert.strictEqual(r.status, 200);++ assert.match(r.type, /^text\/plain/);++ assert.match(r.cache, /public/, 'cached like the site\'s other public answers');++ assert.match(r.text, /^# OpenVibe\.Chat/, 'llmstxt.org: the name first');++ for (const of_ of [`${SITE}/`, `${SITE}/rooms`, `${SITE}/updates`, `${SITE}/sitemap.xml`]) {++ assert.ok(r.text.includes(of_), `mentions ${of_}`);++ }++ for (const re of PRIVATE) assert.ok(!re.test(r.text), `no private path in llms.txt (${re})`);++});++++t('/robots.txt keeps every Disallow and welcomes the search and AI crawlers', async () => {++ const r = await req('/robots.txt');++ assert.strictEqual(r.status, 200);++ assert.match(r.type, /^text\/plain/);++ for (const d of ['/r/*/settings', '/messages', '/settings', '/auth/', '/api/']) {++ assert.ok(r.text.includes(`Disallow: ${d}`), `still disallows ${d}`);++ }++ for (const a of ['/', '/updates', '/rooms', '/r/']) assert.ok(r.text.includes(`Allow: ${a}`), `allows ${a}`);++ assert.match(r.text, /Sitemap: https:\/\/openvibe\.chat\/sitemap\.xml/);++ assert.match(r.text, /User-agent: GPTBot/);++ assert.match(r.text, /User-agent: ClaudeBot/);++});++++t('/sitemap.xml is the public pages with a real lastmod, never today', async () => {++ const r = await req('/sitemap.xml');++ assert.strictEqual(r.status, 200);++ assert.match(r.type, /^application\/xml/);++ assert.match(r.text, /^<\?xml version="1\.0" encoding="UTF-8"\?>/);++ for (const p of ['/', '/rooms', '/updates', '/r/night-owls']) {++ assert.ok(r.text.includes(`<loc>${SITE}${p === '/' ? '/' : p}</loc>`), `lists ${p}`);++ }++ assert.ok(!r.text.includes('private-room'), 'a private room is not in the sitemap');++ for (const re of PRIVATE) assert.ok(!re.test(r.text), `no private path in the sitemap (${re})`);++ const today = new Date().toISOString().slice(0, 10);++ const mods = [...r.text.matchAll(/<lastmod>([\d-]+)<\/lastmod>/g)].map((m) => m[1]);++ assert.ok(mods.length >= 2, 'at least the room and the global chat carry a lastmod');++ for (const m of mods) {++ assert.match(m, /^\d{4}-\d{2}-\d{2}$/);++ assert.notStrictEqual(m, today, 'lastmod is never the day the sitemap was fetched');++ }++ // The global chat's lastmod is the newest public message's own day, not the fetch day.++ const home = r.text.split('<url>').find((u) => u.includes(`<loc>${SITE}/</loc>`));++ assert.ok(home.includes('<lastmod>2026-01-02</lastmod>'), 'the home page lastmod is the message\'s own day');++ const room = r.text.split('<url>').find((u) => u.includes(`<loc>${SITE}/r/night-owls</loc>`));++ assert.ok(room.includes('<lastmod>2026-02-03</lastmod>'), 'the room lastmod is its last_message_at');++});++++t('the home page carries JSON-LD: WebSite plus the site\'s primary type', async () => {++ const r = await req('/');++ assert.strictEqual(r.status, 200);++ const blocks = [...r.text.matchAll(/<script type="application\/ld\+json">(.*?)<\/script>/gs)].map((m) => JSON.parse(m[1]));++ const types = blocks.flatMap((b) => [b['@type'], ...(b['@type'] === 'WebSite' ? [] : [])]).filter(Boolean);++ assert.ok(types.includes('WebSite'), 'WebSite');++ assert.ok(types.includes('WebApplication'), 'the chat itself (softwareApp)');++ const site = blocks.find((b) => b['@type'] === 'WebSite');++ assert.strictEqual(site.url, SITE);++ assert.strictEqual(site.name, 'OpenVibe.Chat');++ const app = blocks.find((b) => b['@type'] === 'WebApplication');++ assert.strictEqual(app.url, SITE);++ const ldOnly = blocks.map((b) => JSON.stringify(b)).join('\n');++ for (const re of PRIVATE) assert.ok(!re.test(ldOnly), `no private path in the JSON-LD (${re})`);++ assert.ok(!/alice|\/r\/night-owls/.test(ldOnly), 'no person and no room in the JSON-LD');++ // The reader's own identity never reaches the head: the same markup signed in or out.++ const me = await req('/');++ const asUser = await fetch(`${h.base}/`, { headers: { cookie: `ov_token=${alice.token}` } }).then((r) => r.text());++ assert.strictEqual([...asUser.matchAll(/<script type="application\/ld\+json">(.*?)<\/script>/gs)].map((m) => m[1]).join(''),++ [...me.text.matchAll(/<script type="application\/ld\+json">(.*?)<\/script>/gs)].map((m) => m[1]).join(''),++ 'the JSON-LD is the same for every reader');++});++++t.run(async () => { if (h && h.close) await h.close(); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -0,0 +1,117 @@
+'use strict';
+/**
+ * Crawl and machine-readability artifacts for openvibe.chat (plan T11): robots.txt, llms.txt and
+ * sitemap.xml, built from openvibe-shared/seo — the same toolkit the other OpenVibe sites use.
+ *
+ * Every answer comes from public data only and never from the viewer: the global chat's last public
+ * message, the rooms that are public and unarchived, and the site's own fixed pages. Direct
+ * messages, your conversations, room settings, sign-in and the API are in no artifact.
+ *
+ * lastmod is the data's own timestamp (the newest public message, the room's last_message_at or its
+ * created_at) — never "today", which would tell crawlers the whole site changed on every fetch.
+ */
+const express = require('express');
+const seo = require('openvibe-shared/seo');
+const historyStore = require('../chat/history-store');
+const rooms = require('../rooms/rooms');
+
+/** A SQLite `DATETIME` ("YYYY-MM-DD HH:MM:SS", UTC) or ISO string as YYYY-MM-DD; null when unusable. */
+function dayOf(ts) {
+ const s = String(ts == null ? '' : ts);
+ const m = s.match(/^(\d{4}-\d{2}-\d{2})/);
+ return m ? m[1] : null;
+}
+
+/** Public, indexable pages: the fixed ones plus every public room, each with a real lastmod. */
+function publicPages() {
+ const pages = rooms.list(null, { limit: 100 }).public;
+ const out = [
+ { path: '/', changefreq: 'hourly', priority: 1.0, lastmod: () => newestGlobalMessage() },
+ { path: '/rooms', changefreq: 'daily', priority: 0.8, lastmod: () => newestRoomActivity(pages) },
+ { path: '/updates', changefreq: 'daily', priority: 0.5 },
+ ];
+ for (const r of pages) {
+ out.push({ path: `/r/${r.slug}`, changefreq: 'daily', priority: 0.6, lastmod: () => dayOf(r.last_message_at || r.created_at) });
+ }
+ return out;
+}
+
+function newestGlobalMessage() {
+ try {
+ const row = historyStore.page('global', { limit: 1 }).messages[0];
+ return row ? dayOf(row.timestamp) : null;
+ } catch { return null; }
+}
+
+function newestRoomActivity(pages) {
+ const days = pages.map((r) => dayOf(r.last_message_at || r.created_at)).filter(Boolean).sort();
+ return days.length ? days[days.length - 1] : null;
+}
+
+/**
+ * The home page's JSON-LD: the site as a WebSite, the chat itself as the site's primary type (the
+ * shared kit's WebApplication) and the page that carries it. Nothing about the reader or their
+ * messages: the same three nodes for every crawler.
+ */
+function homeJsonLd(config) {
+ const site = String(config.web.baseUrl).replace(/\/+$/, '');
+ const name = 'OpenVibe.Chat';
+ const description = 'The network-wide chat room of OpenVibe: everyone on every OpenVibe site, in one conversation, with rooms and direct messages.';
+ return [
+ seo.jsonLd.website({ name, url: site, description }),
+ seo.jsonLd.softwareApp({
+ name, url: site, description, category: 'SocialNetworkingApplication',
+ keywords: 'chat, rooms, direct messages, openvibe',
+ }),
+ seo.jsonLd.webPage({ name: 'Global chat', url: `${site}/`, description, siteUrl: site }),
+ ];
+}
+
+function createDiscoveryRoutes({ config }) {
+ const router = express.Router();
+ const site = String(config.web.baseUrl).replace(/\/+$/, '');
+ const abs = (p) => `${site}${p}`;
+
+ router.get('/robots.txt', (req, res) => {
+ // The same rules as before, plus the search and AI crawlers the shared kit names by name and
+ // the sitemap. Every previous Disallow is kept.
+ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(seo.robotsTxt({
+ sitemaps: [abs('/sitemap.xml')],
+ allow: ['/', '/updates', '/rooms', '/r/'],
+ disallow: ['/r/*/settings', '/messages', '/settings', '/auth/', '/api/'],
+ }));
+ });
+
+ router.get('/llms.txt', (req, res) => {
+ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(seo.llmsTxt({
+ name: 'OpenVibe.Chat',
+ summary: 'OpenVibe.Chat: the network-wide chat room of OpenVibe, plus its rooms and the direct messages of its members.',
+ details: 'One OpenVibe account works here and on every other OpenVibe site. Anyone can read the global chat and the public rooms without an account; posting needs a signed-in person. The global chat and the public room pages are readable as plain HTML without JavaScript and update live over the site\'s own WebSocket. Direct messages, room settings and the API are per-person and are never listed here.',
+ sections: [
+ { title: 'Start here', links: [
+ { title: 'Global chat', url: abs('/'), note: 'one room for everyone on OpenVibe, newest messages at the end' },
+ { title: 'Rooms', url: abs('/rooms'), note: 'every public room, with its topic and activity' },
+ { title: 'What shipped on OpenVibe.Chat', url: abs('/updates') },
+ ] },
+ { title: 'Machine-readable', links: [
+ { title: 'Sitemap', url: abs('/sitemap.xml'), note: 'the public pages above' },
+ { title: 'robots.txt', url: abs('/robots.txt') },
+ ] },
+ ],
+ }));
+ });
+
+ router.get('/sitemap.xml', (req, res) => {
+ const urls = publicPages().map((p) => ({
+ loc: abs(p.path),
+ ...(p.lastmod && p.lastmod() ? { lastmod: p.lastmod() } : {}),
+ changefreq: p.changefreq,
+ priority: p.priority,
+ }));
+ res.type('application/xml').set('Cache-Control', 'public, max-age=3600').send(seo.sitemapXml(urls));
+ });
+
+ return router;
+}
+
+module.exports = { createDiscoveryRoutes, publicPages, dayOf, homeJsonLd };
@@ -66,7 +66,7 @@ function renderPage(o) {
@@ -66,7 +66,7 @@ function renderPage(o) {
const title = o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME;
const title = o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME;
const head = sharedSeo.headTags({
const head = sharedSeo.headTags({
title, description: o.description || DEFAULT_DESCRIPTION, canonical: `${config.web.baseUrl}${o.path || '/'}`,
title, description: o.description || DEFAULT_DESCRIPTION, canonical: `${config.web.baseUrl}${o.path || '/'}`,
- robots: o.robots, siteName: SITE_NAME, type: 'website',
+ robots: o.robots, siteName: SITE_NAME, type: 'website', jsonLd: o.jsonLd,
});
});
const actor = o.actor || { kind: 'anonymous' };
const actor = o.actor || { kind: 'anonymous' };
const who = actor.kind === 'user'
const who = actor.kind === 'user'
@@ -33,6 +33,7 @@ const ovServe = require('openvibe-shared/serve');
@@ -33,6 +33,7 @@ const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const { renderPage, esc, SITE_NAME } = require('./layout');
const { renderPage, esc, SITE_NAME } = require('./layout');
const { createSessionRoutes } = require('./session');
const { createSessionRoutes } = require('./session');
+const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const session = require('../auth/network-session');
const session = require('../auth/network-session');
const historyStore = require('../chat/history-store');
const historyStore = require('../chat/history-store');
const dm = require('../chat/dm');
const dm = require('../chat/dm');
@@ -158,6 +159,7 @@ function createWebRoutes({ config }) {
@@ -158,6 +159,7 @@ function createWebRoutes({ config }) {
await page(req, res, 200, {
await page(req, res, 200, {
actor, title: 'Global chat', path: '/', active: 'global', robots: 'index, follow', script: true,
actor, title: 'Global chat', path: '/', active: 'global', robots: 'index, follow', script: true,
description: 'The network-wide chat room of OpenVibe: everyone on every OpenVibe site, in one conversation.',
description: 'The network-wide chat room of OpenVibe: everyone on every OpenVibe site, in one conversation.',
+ jsonLd: homeJsonLd(config),
page: { view: 'global', latest: latest_id, signedIn: actor.kind === 'user', me: actor.kind === 'user' ? actor.user.username : null },
page: { view: 'global', latest: latest_id, signedIn: actor.kind === 'user', me: actor.kind === 'user' ? actor.user.username : null },
body: `<section class="oc-room" aria-labelledby="oc-h">
body: `<section class="oc-room" aria-labelledby="oc-h">
<h1 id="oc-h">Global chat</h1>
<h1 id="oc-h">Global chat</h1>
@@ -595,10 +597,8 @@ ${attachList}`,
@@ -595,10 +597,8 @@ ${attachList}`,
title: `What shipped on ${SITE_NAME}`, path: '/updates', robots: 'index, follow', cache: 'public, max-age=300',
title: `What shipped on ${SITE_NAME}`, path: '/updates', robots: 'index, follow', cache: 'public, max-age=300',
body: frame.updatesBody({ service: 'chat', siteName: SITE_NAME }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,
body: frame.updatesBody({ service: 'chat', siteName: SITE_NAME }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,
}));
}));
- router.get('/robots.txt', (req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600')
- .send(`User-agent: *\nAllow: /$\nAllow: /updates\nAllow: /rooms\nAllow: /r/\nDisallow: /r/*/settings\nDisallow: /messages\nDisallow: /settings\nDisallow: /auth/\nDisallow: /api/\nSitemap: ${site}/sitemap.xml\n`));
- router.get('/sitemap.xml', (req, res) => res.type('application/xml').set('Cache-Control', 'public, max-age=3600')
- .send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${['/', '/rooms', '/updates', ...rooms.list(null, { limit: 100 }).public.map((r) => `/r/${r.slug}`)].map((p) => `<url><loc>${site}${p}</loc></url>`).join('')}</urlset>\n`));
+ // ── robots.txt, llms.txt, sitemap.xml: server/web/discovery.js, the shared SEO kit ──
+ router.use(createDiscoveryRoutes({ config }));
// ── Nothing here: a browser asking for a page gets the site's 404 page (the browser check found
// ── Nothing here: a browser asking for a page gets the site's 404 page (the browser check found
// JSON). The API, /internal/, /ws/ and any client not asking for HTML keep the JSON 404 that
// JSON). The API, /internal/, /ws/ and any client not asking for HTML keep the JSON 404 that
@@ -267,6 +267,8 @@ t('restart: what was playing is finished, what waited plays in order once, stale
@@ -267,6 +267,8 @@ t('restart: what was playing is finished, what waited plays in order once, stale
ws.close();
ws.close();
}
}
const firstClip = await ears.next((m) => m.type === 'soundboard-audio' && m.title === '!long');
const firstClip = await ears.next((m) => m.type === 'soundboard-audio' && m.title === '!long');
+ // The announce comes before the durable insert: wait for all three rows, not just the first clip.
+ await until(() => raw((d) => d.prepare("SELECT COUNT(*) AS n FROM audio_requests WHERE label = '!long'").get().n) === 3, 3000);
const queued = raw((d) => d.prepare("SELECT id, state FROM audio_requests WHERE label = '!long' ORDER BY id").all());
const queued = raw((d) => d.prepare("SELECT id, state FROM audio_requests WHERE label = '!long' ORDER BY id").all());
assert.deepStrictEqual(queued.map((x) => x.state), ['playing', 'queued', 'queued']);
assert.deepStrictEqual(queued.map((x) => x.state), ['playing', 'queued', 'queued']);
assert.strictEqual(queued[0].id, firstClip.request_id);
assert.strictEqual(queued[0].id, firstClip.request_id);
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+'use strict';
+/**
+ * openvibe.chat's crawl artifacts (server/web/discovery.js): llms.txt, robots.txt and sitemap.xml,
+ * plus the home page's JSON-LD. Everything they carry is public — a private room, a direct message
+ * or a per-person path must never appear in any of them.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+
+const t = suite('discovery');
+let h, alice;
+const SITE = 'https://openvibe.chat';
+const req = async (path) => {
+ const r = await fetch(`${h.base}${path}`, { redirect: 'manual' });
+ return { status: r.status, type: r.headers.get('content-type') || '', cache: r.headers.get('cache-control') || '', text: await r.text() };
+};
+// Anything that would only exist for one person, or that a crawler must not follow.
+const PRIVATE = [/\/messages/, /\/settings/, /\/auth\//, /\/api\//, /\/internal\//, /\/ws\//, /private-room/, /token/, /secret/i];
+
+t('boot', async () => {
+ h = await boot({ env: { CHAT_WEB_URL: SITE } });
+ alice = h.addUser('alice', { subject: ids.newId('user') });
+ h.ctx.upsertUser(h.live.users.get(alice.id));
+ const rooms = require('../server/rooms/rooms');
+ rooms.create(alice, { name: 'Night Owls', topic: 'Late chats' });
+ rooms.create(alice, { name: 'Closed Room', slug: 'private-room', visibility: 'private' });
+ await h.http('POST', '/api/chat/send', { token: alice.token, body: { message: 'hello from the test' } });
+ // Backdate the message so "lastmod is the data's own day" is checkable against "not today".
+ const db = require('../server/db/database');
+ db.run("UPDATE chat_messages SET timestamp = '2026-01-02 03:04:05' WHERE id = (SELECT MAX(id) FROM chat_messages)");
+ h.db.run("UPDATE rooms SET last_message_at = '2026-02-03 04:05:06' WHERE slug = 'night-owls'");
+ require('../server/chat/history-store').invalidate('global');
+});
+
+t('/llms.txt describes the site, its public pages and its machine-readable endpoints', async () => {
+ const r = await req('/llms.txt');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.type, /^text\/plain/);
+ assert.match(r.cache, /public/, 'cached like the site\'s other public answers');
+ assert.match(r.text, /^# OpenVibe\.Chat/, 'llmstxt.org: the name first');
+ for (const of_ of [`${SITE}/`, `${SITE}/rooms`, `${SITE}/updates`, `${SITE}/sitemap.xml`]) {
+ assert.ok(r.text.includes(of_), `mentions ${of_}`);
+ }
+ for (const re of PRIVATE) assert.ok(!re.test(r.text), `no private path in llms.txt (${re})`);
+});
+
+t('/robots.txt keeps every Disallow and welcomes the search and AI crawlers', async () => {
+ const r = await req('/robots.txt');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.type, /^text\/plain/);
+ for (const d of ['/r/*/settings', '/messages', '/settings', '/auth/', '/api/']) {
+ assert.ok(r.text.includes(`Disallow: ${d}`), `still disallows ${d}`);
+ }
+ for (const a of ['/', '/updates', '/rooms', '/r/']) assert.ok(r.text.includes(`Allow: ${a}`), `allows ${a}`);
+ assert.match(r.text, /Sitemap: https:\/\/openvibe\.chat\/sitemap\.xml/);
+ assert.match(r.text, /User-agent: GPTBot/);
+ assert.match(r.text, /User-agent: ClaudeBot/);
+});
+
+t('/sitemap.xml is the public pages with a real lastmod, never today', async () => {
+ const r = await req('/sitemap.xml');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.type, /^application\/xml/);
+ assert.match(r.text, /^<\?xml version="1\.0" encoding="UTF-8"\?>/);
+ for (const p of ['/', '/rooms', '/updates', '/r/night-owls']) {
+ assert.ok(r.text.includes(`<loc>${SITE}${p === '/' ? '/' : p}</loc>`), `lists ${p}`);
+ }
+ assert.ok(!r.text.includes('private-room'), 'a private room is not in the sitemap');
+ for (const re of PRIVATE) assert.ok(!re.test(r.text), `no private path in the sitemap (${re})`);
+ const today = new Date().toISOString().slice(0, 10);
+ const mods = [...r.text.matchAll(/<lastmod>([\d-]+)<\/lastmod>/g)].map((m) => m[1]);
+ assert.ok(mods.length >= 2, 'at least the room and the global chat carry a lastmod');
+ for (const m of mods) {
+ assert.match(m, /^\d{4}-\d{2}-\d{2}$/);
+ assert.notStrictEqual(m, today, 'lastmod is never the day the sitemap was fetched');
+ }
+ // The global chat's lastmod is the newest public message's own day, not the fetch day.
+ const home = r.text.split('<url>').find((u) => u.includes(`<loc>${SITE}/</loc>`));
+ assert.ok(home.includes('<lastmod>2026-01-02</lastmod>'), 'the home page lastmod is the message\'s own day');
+ const room = r.text.split('<url>').find((u) => u.includes(`<loc>${SITE}/r/night-owls</loc>`));
+ assert.ok(room.includes('<lastmod>2026-02-03</lastmod>'), 'the room lastmod is its last_message_at');
+});
+
+t('the home page carries JSON-LD: WebSite plus the site\'s primary type', async () => {
+ const r = await req('/');
+ assert.strictEqual(r.status, 200);
+ const blocks = [...r.text.matchAll(/<script type="application\/ld\+json">(.*?)<\/script>/gs)].map((m) => JSON.parse(m[1]));
+ const types = blocks.flatMap((b) => [b['@type'], ...(b['@type'] === 'WebSite' ? [] : [])]).filter(Boolean);
+ assert.ok(types.includes('WebSite'), 'WebSite');
+ assert.ok(types.includes('WebApplication'), 'the chat itself (softwareApp)');
+ const site = blocks.find((b) => b['@type'] === 'WebSite');
+ assert.strictEqual(site.url, SITE);
+ assert.strictEqual(site.name, 'OpenVibe.Chat');
+ const app = blocks.find((b) => b['@type'] === 'WebApplication');
+ assert.strictEqual(app.url, SITE);
+ const ldOnly = blocks.map((b) => JSON.stringify(b)).join('\n');
+ for (const re of PRIVATE) assert.ok(!re.test(ldOnly), `no private path in the JSON-LD (${re})`);
+ assert.ok(!/alice|\/r\/night-owls/.test(ldOnly), 'no person and no room in the JSON-LD');
+ // The reader's own identity never reaches the head: the same markup signed in or out.
+ const me = await req('/');
+ const asUser = await fetch(`${h.base}/`, { headers: { cookie: `ov_token=${alice.token}` } }).then((r) => r.text());
+ assert.strictEqual([...asUser.matchAll(/<script type="application\/ld\+json">(.*?)<\/script>/gs)].map((m) => m[1]).join(''),
+ [...me.text.matchAll(/<script type="application\/ld\+json">(.*?)<\/script>/gs)].map((m) => m[1]).join(''),
+ 'the JSON-LD is the same for every reader');
+});
+
+t.run(async () => { if (h && h.close) await h.close(); });