openvibers/openvibe.inventory Commits

Inventory: the sitemap and llms.txt list every item, kind and the public API (#3)

OP OpenVibers committed 9 Oct 2026 · 20c6b8f860 · 2 files, +77 −9

Changes

ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 551c91f..65fa3cb 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -5,7 +5,9 @@
* llms-full.txt, and the home page's JSON-LD. The public pages are for search engines and AI crawlers; sign-in and
* the API are not.
*
-- * The product extends PAGE_TEXT and publicPages() with its own pages; the routes and headers here stay.
++ * The product extends PAGE_TEXT and publicPages() with its own pages; the routes and headers here stay. The catalog is
++ * read through ctx.inv: the sitemap lists /items, a page per kind with items and every published item; llms.txt maps the
++ * kinds and the public read API; llms-full.txt spells out every item. seo.sitemapXml escapes each loc ('&' → '&').
*/
const fs = require('fs');
const path = require('path');
@@@ -16,10 +18,14 @@ const { asyncRouter } = require('./router');
const SITE_NAME = 'OpenVibe.Inventory';
const DESCRIPTION = 'OpenVibe.Inventory — Your items, on every OpenVibe site.';
const DISALLOW = ['/auth/', '/api/'];
++// The two routes that need a subject point at the API reference, not at a URL that answers 400.
++const API_DOC = 'https://github.com/OpenVibers/OpenVibe.Inventory#api-apiv1-problemjson-errors';
++const RARITY_LABEL = { common: 'Common', uncommon: 'Uncommon', rare: 'Rare', epic: 'Epic', legendary: 'Legendary' };
const PAGE_TEXT = {
'/': ['OpenVibe.Inventory home', 'OpenVibe.Inventory: Your items, on every OpenVibe site.'],
'/updates': ['What shipped on OpenVibe.Inventory', 'This site\'s update log, from the network changelog feed.'],
++ '/items': ['Every item', 'Every published item on OpenVibe.Inventory, by kind, with its rarity.'],
};
function dayOf(ts) {
@@@ -44,8 +50,31 @@ const publicPages = () => [
{ path: '/updates', changefreq: 'daily', priority: 0.5 },
];
++const CATALOG_PAGE = { path: '/items', changefreq: 'daily', priority: 0.7 };
++const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
++const kindPath = (id) => `/items?kind=${encodeURIComponent(id)}`;
++
++/** The catalog read straight from ctx.inv: kinds, their published items, and the items grouped by kind. */
++async function catalog(inv) {
++ const [kinds, defs] = await Promise.all([inv.listKinds(), inv.listDefinitions({ limit: 500 })]);
++ const byKind = new Map(kinds.map((k) => [k.id, []]));
++ for (const d of defs) if (byKind.has(d.kind)) byKind.get(d.kind).push(d);
++ return { kinds, defs, byKind };
++}
++const kindsWithItems = ({ kinds, byKind }) => kinds.filter((k) => byKind.get(k.id).length);
++
++/** Every public URL: the static pages, /items, a page per kind with items, and each published item. */
++function catalogPages({ kinds, defs, byKind }) {
++ return [
++ ...publicPages(),
++ CATALOG_PAGE,
++ ...kindsWithItems({ kinds, byKind }).map((k) => ({ path: kindPath(k.id), changefreq: 'weekly', priority: 0.6 })),
++ ...defs.map((d) => ({ path: `/items/${d.id}`, changefreq: 'monthly', priority: 0.4 })),
++ ];
++}
++
function createDiscoveryRoutes(ctx) {
-- const { config } = ctx;
++ const { config, inv } = ctx;
const r = asyncRouter();
const site = String(config.baseUrl).replace(/\/+$/, '');
const abs = (p) => `${site}${p}`;
@@@ -57,16 +86,30 @@ function createDiscoveryRoutes(ctx) {
+ seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
});
-- r.get('/llms.txt', (_req, res) => {
++ r.get('/llms.txt', async (_req, res) => {
++ const { kinds, defs, byKind } = await catalog(inv);
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
name: SITE_NAME,
summary: 'OpenVibe.Inventory: Your items, on every OpenVibe site.',
-- details: 'Every page is server-rendered and readable without JavaScript.',
++ details: 'One inventory for an OpenVibe account: items earned or given by sites, games and apps, worn in chat, on '
++ + 'stream overlays and on your profile. Nothing here is sold, bought, traded or converted. '
++ + 'Every page is server-rendered and readable without JavaScript.',
sections: [
{ title: 'Start here', links: [
{ title: 'OpenVibe.Inventory', url: abs('/'), note: 'Your items, on every OpenVibe site.' },
{ title: 'What shipped on OpenVibe.Inventory', url: abs('/updates') },
] },
++ { title: 'Browse the inventory', links: [
++ { title: 'Every item', url: abs('/items'), note: `${plural(defs.length, 'published item', 'published items')}` },
++ // Every kind, its own page and count; a kind with nothing in it yet shows 0.
++ ...kinds.map((k) => ({ title: k.name, url: abs(kindPath(k.id)), note: plural(byKind.get(k.id).length, 'item', 'items') })),
++ ] },
++ { title: 'API', links: [
++ { title: 'GET /api/v1/kinds', url: abs('/api/v1/kinds'), note: 'every item kind, with where it is worn and shown' },
++ { title: 'GET /api/v1/definitions', url: abs('/api/v1/definitions'), note: 'published items; filter with ?kind= or ?issuer=' },
++ { title: 'GET /api/v1/people/:subject/items', url: API_DOC, note: 'the items one person owns' },
++ { title: 'GET /api/v1/equipped?subjects=…', url: API_DOC, note: 'up to 100 equipped sets in one call' },
++ ] },
{ title: 'Machine-readable', links: [
{ title: 'Sitemap', url: abs('/sitemap.xml') },
{ title: 'Full text for language models', url: abs('/llms-full.txt') },
@@@ -80,20 +123,28 @@ function createDiscoveryRoutes(ctx) {
}));
});
-- r.get('/llms-full.txt', (_req, res) => {
-- const pages = publicPages().map((p) => ({ url: p.path, title: PAGE_TEXT[p.path][0], text: PAGE_TEXT[p.path][1] }));
++ r.get('/llms-full.txt', async (_req, res) => {
++ const { kinds, defs, byKind } = await catalog(inv);
++ const pages = [...publicPages(), CATALOG_PAGE].map((e) => ({ url: e.path, title: PAGE_TEXT[e.path][0], text: PAGE_TEXT[e.path][1] }));
++ // Every published item, one line each (name, rarity, kind and URL), grouped by kind.
++ const itemSections = kindsWithItems({ kinds, byKind }).map((k) => {
++ const items = byKind.get(k.id);
++ pages.push({ url: kindPath(k.id), title: `${k.name} items`, text: `${plural(items.length, 'item', 'items')}, with their rarity.` });
++ return { title: k.name, pages: items.map((d) => ({ title: d.name, url: `/items/${d.id}`, text: `${RARITY_LABEL[d.rarity] || d.rarity} ${k.name}` })) };
++ });
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
site: SITE_NAME,
summary: 'Every public page of OpenVibe.Inventory, one line each.',
base: site,
maxBytes: 64 * 1024,
-- sections: [{ title: 'Pages', pages }],
++ sections: [{ title: 'Pages', pages }, ...itemSections],
}));
});
-- r.get('/sitemap.xml', (_req, res) => {
++ r.get('/sitemap.xml', async (_req, res) => {
const lastmod = siteUpdated();
-- const urls = publicPages().map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority }));
++ // seo.sitemapXml escapes every loc, so a query string's '&' comes out as '&'.
++ const urls = catalogPages(await catalog(inv)).map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority }));
res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));
});
ddiff --git a/test/discovery.test.js b/test/discovery.test.js
iindex d1deb80..a034b57 100644
---- a/test/discovery.test.js
++++ b/test/discovery.test.js
@@@ -34,6 +34,14 @@ const PRIVATE = ['/auth/', '/api/'];
assert.match(r.headers.get('content-type'), /^application\/xml/);
assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);
for (const p of ['/', '/updates']) assert.ok(r.text.includes(`<loc>https://inventory.openvibe.network${p}</loc>`), `no ${p} entry`);
++ // The catalog: /items, each kind with items, and every published item.
++ assert.ok(r.text.includes('<loc>https://inventory.openvibe.network/items</loc>'), 'no /items entry');
++ // This kind's URL has no '&', so it must appear exactly, unescaped — and with no stray '&amp;'.
++ assert.ok(r.text.includes('<loc>https://inventory.openvibe.network/items?kind=live.hat</loc>'), 'no /items?kind=live.hat entry');
++ assert.ok(!r.text.includes('items?kind=live.hat&amp;'), 'that URL must not gain an entity');
++ const items = [...r.text.matchAll(/<loc>https:\/\/inventory\.openvibe\.network\/items\/itd_/g)];
++ assert.strictEqual(items.length, 70, `expected 70 item entries, got ${items.length}`);
++ for (const p of ['/me', '/u/', '/api/']) assert.ok(!r.text.includes(`<loc>https://inventory.openvibe.network${p}`), `private path in sitemap: ${p}`);
for (const p of ['/auth/login', '/api/v1/ping']) assert.ok(!r.text.includes(`<loc>https://inventory.openvibe.network${p}</loc>`), `${p} is not a public page`);
const lastmods = [...r.text.matchAll(/<lastmod>([^<]+)<\/lastmod>/g)].map((m) => m[1]);
assert.ok(lastmods.length > 0, 'no lastmod anywhere');
@@@ -52,6 +60,10 @@ const PRIVATE = ['/auth/', '/api/'];
assert.ok(r.text.includes('(https://openvibe.services)'), 'no pointer to the developer platform');
assert.ok(r.text.includes('https://inventory.openvibe.network/sitemap.xml'), 'sitemap not listed');
assert.ok(r.text.includes('(https://inventory.openvibe.network/llms-full.txt)'), 'llms-full.txt not listed');
++ // The catalog and the public read API are named for language models on purpose.
++ assert.ok(r.text.includes('(https://inventory.openvibe.network/items)'), 'no /items link');
++ assert.ok(r.text.includes('https://inventory.openvibe.network/items?kind=live.hat'), 'no kind link');
++ assert.ok(r.text.includes('GET /api/v1/kinds'), 'the API section is missing');
for (const p of ['/auth/']) assert.ok(!r.text.includes(`https://inventory.openvibe.network${p}`), `private path in llms.txt: ${p}`);
});
@@@ -66,6 +78,11 @@ const PRIVATE = ['/auth/', '/api/'];
assert.ok(locs.length >= 2, `sitemap too small: ${locs.length}`);
for (const loc of locs) assert.ok(r.text.includes(loc), `llms-full.txt is missing ${loc}`);
assert.ok(r.text.includes('URL: https://inventory.openvibe.network/updates\n'), 'no /updates entry');
++ // Every published item: its name, rarity, kind and URL, grouped by kind.
++ assert.ok(r.text.includes('Royal Crown'), 'no item names');
++ assert.ok(/### Royal Crown\n\nURL: https:\/\/inventory\.openvibe\.network\/items\/itd_[0-9A-HJKMNP-TV-Z]{26}\n/.test(r.text), 'no Royal Crown item line');
++ assert.ok(r.text.includes('\n## Hat\n'), 'items are not grouped by kind');
++ assert.ok(r.text.includes('Epic Hat'), 'the item line lacks its rarity and kind');
for (const p of PRIVATE) assert.ok(!r.text.includes(`https://inventory.openvibe.network${p}`), `private path in llms-full.txt: ${p}`);
});