openvibers/openvibe.codes Commits
robots.txt, sitemap.xml and a new llms.txt come from openvibe-shared/seo (plan T11, server/http/discovery.js). The sitemap adds each public app and published release with its real publish date (one light query, releases.publicIndex); fixed pages carry STATUS.json's content date, never today; every previous Disallow kept. The home page carries WebSite + WebApplication + WebPage JSON-LD. test/discovery.test.js covers the artifacts.
Changes
@@@ -51,6 +51,9 @@ function createReleases({ store, outbox, trust }) {`SELECT * FROM releases WHERE app_id = ? ${includeDrafts ? '' : "AND status != 'draft'"} ORDER BY created_at DESC, id DESC`,).all(String(appId))).map(view)));const recentPublic = async (limit = 20) => (await Promise.all((await db.prepare("SELECT * FROM releases WHERE status IN ('published', 'deprecated') ORDER BY published_at DESC LIMIT ?").all(limit)).map(view)));++ // Light rows for the sitemap (http/discovery.js): no trust lookup per row, no N+1. Only the two++ // non-draft statuses the public pages render; drafts, revoked and private releases never appear.++ const publicIndex = async (limit = 5000) => await db.prepare("SELECT id, app_id, status, published_at FROM releases WHERE status IN ('published', 'deprecated') ORDER BY published_at DESC LIMIT ?").all(limit);const log = async (releaseId) => (await db.prepare('SELECT action, actor, at, detail FROM release_log WHERE release_id = ? ORDER BY id').all(releaseId)).map((l) => ({ ...l, detail: JSON.parse(l.detail || '{}') }));@@@ -218,7 +221,7 @@ function createReleases({ store, outbox, trust }) {return out;}-- return { get, manifestOf, listForApp, listForProject, recentPublic, log, createDraft, publish, deprecate, revoke, retireProject, manageRole };++ return { get, manifestOf, listForApp, listForProject, recentPublic, publicIndex, log, createDraft, publish, deprecate, revoke, retireProject, manageRole };}module.exports = { createReleases, ReleaseError, manageRole, RANK };nnew file mode 100644@@@ -0,0 +1,184 @@++'use strict';++++/**++ * Crawl and machine-readability artifacts (plan T11): GET /robots.txt, GET /sitemap.xml and++ * GET /llms.txt, plus the home page's JSON-LD. Every one is built with openvibe-shared/seo — the++ * same toolkit the other OpenVibe sites use — so they say the same things here as everywhere.++ *++ * Public pages only, and never the viewer: the portal, staff console, sign-in and the API are absent++ * from all of them and Disallowed in robots.txt. lastmod is a real timestamp from the site's own++ * data — the committed content-revision date in STATUS.json (and each published release's publish++ * time for the pages a release changes) — never "today" from the clock, which would tell crawlers++ * the whole site changed on every fetch.++ */++const fs = require('fs');++const path = require('path');++const contracts = require('openvibe-contracts');++const seo = require('openvibe-shared/seo');++const { asyncRouter } = require('./router');++++const SITE_NAME = 'OpenVibe.Codes';++const DESCRIPTION = 'The OpenVibe developer portal: create projects and apps in OpenVibe.Network, get scoped credentials and capability grants, and read reference documentation generated from the exact contracts and SDK versions the platform runs.';++// Every rule robots.txt had before this module existed: none of them may quietly disappear.++const DISALLOW = ['/projects', '/auth/', '/oauth/test-callback', '/staff', '/api/'];++const RELEASE_LIMIT = 5000;++++/** "2026-09-28" or "2026-09-28T12:00:00Z" as YYYY-MM-DD; null when the value is unusable. */++function dayOf(ts) {++ const m = String(ts == null ? '' : ts).match(/^(\d{4}-\d{2}-\d{2})/);++ return m ? m[1] : null;++}++++/** The committed status file's content date: the site's own revision date, never the clock. */++function siteUpdated() {++ try { return dayOf(JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'STATUS.json'), 'utf8')).updated); } catch { return null; }++}++++/**++ * The home page's JSON-LD: the site as a WebSite, the developer portal as the site's primary type++ * (the shared kit's WebApplication) and the page that carries them. The same nodes for every++ * crawler; nothing about the reader.++ */++function homeJsonLd(config) {++ const site = String(config.baseUrl).replace(/\/+$/, '');++ return [++ seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),++ seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'DeveloperApplication', keywords: 'developer portal, api, oauth, webhooks, sdk, openvibe' }),++ seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),++ ];++}++++/**++ * The site's fixed public pages, with their crawl hints. Docs and policy pages are generated from++ * the pinned contracts and SDK and from the repository's Markdown, so their lastmod is the site's++ * content-revision date; governance drafts stay out (they are noindex and not published).++ */++function publicPages({ docs, governance = [] }) {++ const pages = [++ { path: '/', changefreq: 'daily', priority: 1.0 },++ { path: '/updates', changefreq: 'daily', priority: 0.5 },++ { path: '/docs', changefreq: 'weekly', priority: 0.9 },++ { path: '/docs/api', changefreq: 'weekly', priority: 0.8 },++ { path: '/docs/updates', changefreq: 'daily', priority: 0.6 },++ { path: '/docs/contracts', changefreq: 'weekly', priority: 0.7 },++ { path: '/docs/capabilities', changefreq: 'weekly', priority: 0.7 },++ { path: '/docs/events', changefreq: 'weekly', priority: 0.6 },++ { path: '/docs/services', changefreq: 'weekly', priority: 0.6 },++ { path: '/docs/billing', changefreq: 'monthly', priority: 0.5 },++ { path: '/docs/limits', changefreq: 'monthly', priority: 0.5 },++ { path: '/docs/tools', changefreq: 'monthly', priority: 0.5 },++ { path: '/docs/export', changefreq: 'monthly', priority: 0.5 },++ { path: '/docs/sdk', changefreq: 'weekly', priority: 0.7 },++ { path: '/oauth', changefreq: 'monthly', priority: 0.5 },++ { path: '/tools/webhooks', changefreq: 'monthly', priority: 0.5 },++ { path: '/manifests/validate', changefreq: 'monthly', priority: 0.5 },++ { path: '/policy', changefreq: 'monthly', priority: 0.5 },++ { path: '/policy/rfc', changefreq: 'monthly', priority: 0.4 },++ { path: '/policy/compatibility', changefreq: 'monthly', priority: 0.4 },++ { path: '/policy/licensing', changefreq: 'monthly', priority: 0.4 },++ { path: '/policy/transparency', changefreq: 'monthly', priority: 0.4 },++ ];++ for (const g of governance) if (!g.draft) pages.push({ path: `/policy/${g.slug}`, changefreq: 'monthly', priority: 0.3 });++ for (const c of docs.contracts) pages.push({ path: `/docs/contracts/${c.id}`, changefreq: 'monthly', priority: 0.4 });++ for (const c of docs.capabilities) pages.push({ path: `/docs/capabilities/${c.id}`, changefreq: 'monthly', priority: 0.4 });++ for (const x of contracts.openapi.index()) pages.push({ path: `/docs/api/${x.service}`, changefreq: 'monthly', priority: 0.4 });++ for (const m of docs.sdk) pages.push({ path: `/docs/sdk/${m.slug}`, changefreq: 'monthly', priority: 0.3 });++ for (const a of docs.adrs) pages.push({ path: `/docs/adr/${a.id}`, changefreq: 'yearly', priority: 0.3 });++ return pages;++}++++function createDiscoveryRoutes(ctx) {++ const { config, docs, releases, governance = [] } = ctx;++ const r = asyncRouter();++ const site = String(config.baseUrl).replace(/\/+$/, '');++ const abs = (p) => `${site}${p}`;++++ // The public, indexable releases: what the sitemap lists and what dates the pages a release++ // changes. Never drafts, revoked or private ones (only 'published' pages are index: true).++ async function releaseIndex() {++ try { return await releases.publicIndex(RELEASE_LIMIT); } catch { return []; }++ }++++ async function sitemapEntries() {++ const content = siteUpdated();++ const rows = await releaseIndex();++ const entries = publicPages({ docs, governance }).map((p) => ({ ...p, lastmod: content }));++ const newest = rows.map((x) => dayOf(x.published_at)).filter(Boolean).sort().pop() || null;++ // The home and shipped pages change when a release publishes, so they carry the newer of++ // that real publish time and the site's content date.++ for (const e of entries) if ((e.path === '/' || e.path === '/updates') && newest && (!e.lastmod || newest > e.lastmod)) e.lastmod = newest;++ const byApp = new Map();++ for (const x of rows) {++ const d = dayOf(x.published_at);++ if (d && (!byApp.has(x.app_id) || d > byApp.get(x.app_id))) byApp.set(x.app_id, d);++ }++ for (const [appId, lastmod] of byApp) entries.push({ path: `/apps/${appId}`, changefreq: 'weekly', priority: 0.4, lastmod });++ for (const x of rows.filter((x) => x.status === 'published')) entries.push({ path: `/releases/${x.id}`, changefreq: 'monthly', priority: 0.3, lastmod: dayOf(x.published_at) });++ return entries;++ }++++ r.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 (DISALLOW).++ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(++ '# openvibe.codes: the public pages are for search and AI crawlers; the portal, sign-in, staff and the API are not.\n'++ + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));++ });++++ r.get('/llms.txt', (_req, res) => {++ const firstContract = docs.contracts[0] ? docs.contracts[0].id : null;++ const firstService = (contracts.openapi.index()[0] || {}).service || null;++ const machine = [++ { title: 'Sitemap', url: abs('/sitemap.xml'), note: 'the public pages below, each with a real lastmod' },++ { title: 'robots.txt', url: abs('/robots.txt'), note: 'search and AI crawlers are welcome on the public pages' },++ { title: 'Release metadata (JSON)', url: abs('/release.json'), note: 'this service\'s current release, per ADR-016' },++ ];++ if (firstContract) machine.push({ title: 'Contract JSON Schema', url: abs(`/docs/contracts/${firstContract}.json`), note: 'append .json to any contract page for its schema' });++ if (firstService) machine.push({ title: 'Service OpenAPI', url: abs(`/docs/api/${firstService}.json`), note: 'append .json to any API explorer page for its OpenAPI document' });++ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(seo.llmsTxt({++ name: SITE_NAME,++ summary: 'OpenVibe.Codes: the OpenVibe developer portal over OpenVibe.Network\'s API — projects, apps, scoped credentials and capability grants, reference documentation generated from the pinned contracts and SDK, and OAuth, webhook and manifest tools.',++ details: 'Every page is server-rendered and readable without JavaScript, and every one is public: no account is needed to read the docs or use the OAuth, webhook and manifest tools. Projects, apps, credentials and grants belong to OpenVibe.Network; the pages that manage them need a signed-in OpenVibe account and are deliberately not listed here or in the sitemap. The reference under /docs is generated at boot from the exact openvibe-contracts and openvibe-sdk versions the platform runs, so it cannot drift from them.',++ sections: [++ { title: 'Start here', links: [++ { title: 'The developer portal', url: abs('/'), note: 'what the portal is for and how an account becomes an integration' },++ { title: 'Reference documentation', url: abs('/docs'), note: 'generated from the pinned contracts and SDK' },++ { title: 'What shipped on OpenVibe.Codes', url: abs('/updates') },++ { title: 'Transparency', url: abs('/policy/transparency'), note: 'what the portal stores, what it does not, and what works today' },++ ] },++ { title: 'Reference', links: [++ { title: 'API explorer', url: abs('/docs/api'), note: 'each service\'s OpenAPI document, per page as .json' },++ { title: 'Contracts', url: abs('/docs/contracts'), note: 'schemas, field tables and fixtures, per page as .json' },++ { title: 'Capabilities', url: abs('/docs/capabilities') },++ { title: 'Events', url: abs('/docs/events') },++ { title: 'Services', url: abs('/docs/services') },++ { title: 'Tools', url: abs('/docs/tools') },++ { title: 'SDK', url: abs('/docs/sdk') },++ ] },++ { title: 'Developer tools', links: [++ { title: 'OAuth callback helper', url: abs('/oauth'), note: 'authorization code + PKCE, with a callback that never exchanges the code' },++ { title: 'Webhook signature tester', url: abs('/tools/webhooks'), note: 'decides as a receiver requiring signature v2 does' },++ { title: 'Manifest validation', url: abs('/manifests/validate'), note: 'app and mod manifests, validated against the pinned contracts' },++ ] },++ { title: 'Policy', links: [++ { title: 'Policy home', url: abs('/policy') },++ { title: 'Proposals and decisions', url: abs('/policy/rfc') },++ { title: 'Compatibility and deprecation', url: abs('/policy/compatibility') },++ { title: 'Licensing', url: abs('/policy/licensing') },++ ...governance.filter((g) => !g.draft).map((g) => ({ title: g.title, url: abs(`/policy/${g.slug}`) })),++ ] },++ { title: 'Machine-readable', links: machine },++ ],++ }));++ });++++ r.get('/sitemap.xml', async (_req, res) => {++ const entries = await sitemapEntries();++ const urls = entries.map((e) => ({ loc: abs(e.path), ...(e.lastmod ? { lastmod: e.lastmod } : {}), changefreq: e.changefreq, priority: e.priority }));++ res.type('application/xml').set('Cache-Control', 'public, max-age=3600').send(seo.sitemapXml(urls));++ });++++ return r;++}++++module.exports = { createDiscoveryRoutes, homeJsonLd, publicPages, dayOf };@@@ -2,7 +2,8 @@/*** Public pages: the landing page, policy pages, public release and trust pages, the staff trust-- * console, robots.txt and the sitemap.++ * console. Crawl artifacts (robots.txt, sitemap.xml, llms.txt, the home page's JSON-LD) live in++ * http/discovery.js and are mounted here.** Policy pages are derived, not paraphrased: the compatibility and deprecation policy renders* ADR-002 and ADR-016 exactly as openvibe-contracts publishes them; the decision index is the ADR@@@ -16,6 +17,7 @@ const frame = require('openvibe-shared/frame');const path = require('path');const express = require('express');const { asyncRouter } = require('./router');++const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');const { html, raw, table, code, badge, time, notice, csrfField, problemBox } = require('../render/html');const { send } = require('../render/layout');const { markdown } = require('../render/markdown');@@@ -60,6 +62,7 @@ function createPageRoutes(ctx) {const recent = await releases.recentPublic(10);page(req, res, {index: true, cache: PUBLIC_CACHE,++ jsonLd: homeJsonLd(config),body: html`<h1>Build on OpenVibe</h1><p class="lead">OpenVibe.Codes is the developer portal: create a project and apps in OpenVibe.Network, get scoped credentials, request capability grants, and read reference docs generated from the exact contract and SDK versions the platform runs.</p><div class="notice">Status: alpha. What works and what does not is listed on the <a href="/policy/transparency">transparency page</a>. Network owns projects, apps, credentials, grants and quotas; Codes is a portal over its API and never stores a secret.</div>@@@ -264,23 +267,9 @@ ${signedIn ? releaseActions(req, rel) : ''}}});-- // ── Discovery ───────────────────────────────────────────-- r.get('/robots.txt', (req, res) => {-- res.set('Cache-Control', 'public, max-age=3600').type('text/plain').send([-- 'User-agent: *', 'Disallow: /projects', 'Disallow: /auth/', 'Disallow: /oauth/test-callback', 'Disallow: /staff', 'Disallow: /api/',-- 'Allow: /', `Sitemap: ${config.baseUrl}/sitemap.xml`, '',-- ].join('\n'));-- });-- r.get('/sitemap.xml', (req, res) => {-- const urls = ['/', '/docs', '/docs/api', '/docs/updates', '/docs/contracts', '/docs/capabilities', '/docs/events', '/docs/services', '/docs/billing', '/docs/limits', '/docs/tools', '/docs/sdk', '/oauth', '/tools/webhooks', '/manifests/validate',-- '/policy', '/policy/rfc', '/policy/compatibility', '/policy/licensing', '/policy/transparency',-- ...governance.filter((g) => !g.draft).map((g) => `/policy/${g.slug}`),-- ...docs.contracts.map((c) => `/docs/contracts/${c.id}`), ...docs.capabilities.map((c) => `/docs/capabilities/${c.id}`),-- ...require('openvibe-contracts').openapi.index().map((x) => `/docs/api/${x.service}`),-- ...docs.sdk.map((m) => `/docs/sdk/${m.slug}`), ...docs.adrs.map((a) => `/docs/adr/${a.id}`)];-- const x = (s) => s.replace(/&/g, '&').replace(/</g, '<');-- res.set('Cache-Control', 'public, max-age=3600').type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.map((u) => `<url><loc>${x(config.baseUrl + u)}</loc></url>`).join('\n')}\n</urlset>\n`);-- });++ // ── Discovery: robots.txt, sitemap.xml, llms.txt and the home page's JSON-LD (http/discovery.js,++ // built with openvibe-shared/seo) ─────────────────────++ r.use(createDiscoveryRoutes({ ...ctx, governance }));return r;}@@@ -14,6 +14,7 @@ const fs = require('fs');const path = require('path');const appIcon = require('openvibe-shared/app-icon');const frame = require('openvibe-shared/frame');++const seo = require('openvibe-shared/seo');const { html, raw, esc } = require('./html');const NETWORK_URL = 'https://openvibe.network';@@@ -40,7 +41,7 @@ const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;/*** o: title, description, body (html), viewer, config, path, index (default false), scripts [rel],-- * crumbs [{ label, href }]++ * crumbs [{ label, href }], jsonLd [nodes] (serialised by openvibe-shared/seo)*/function renderPage(o) {const viewer = o.viewer || { kind: 'anonymous' };@@@ -82,6 +83,7 @@ function renderPage(o) {<link rel="canonical" href="${esc(canonical)}"><meta name="referrer" content="${o.noReferrer ? 'no-referrer' : 'strict-origin-when-cross-origin'}">${appIcon.headTags({ site: 'codes' })}++${(o.jsonLd || []).filter(Boolean).map(seo.jsonLdTag).join('\n')}<link rel="stylesheet" href="${asset('css/codes.css')}"><script src="${ovServe.url('theme-loader.js')}" defer></script><script src="${ovServe.url('navbar.js')}" defer></script>nnew file mode 100644@@@ -0,0 +1,70 @@++'use strict';++/**++ * The public crawl artifacts (plan T11), fetched from the booted app: /robots.txt, /sitemap.xml,++ * /llms.txt and the home page's JSON-LD. Each is served with the right status and content type,++ * carries at least one real entry, lists public pages only, and takes lastmod from the site's own++ * data (STATUS.json) — never from the clock.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { boot, check, done } = require('./helpers/boot');++++const STATUS_UPDATED = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'STATUS.json'), 'utf8')).updated;++const PRIVATE = ['/projects', '/staff', '/auth/', '/oauth/test-callback'];++++(async () => {++ const t = await boot();++ try {++ await check('robots.txt: 200 text/plain, every previous Disallow kept, sitemap named', async () => {++ const r = await t.get('/robots.txt');++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type'), /^text\/plain/);++ assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);++ for (const d of [...PRIVATE, '/api/']) assert.ok(r.text.includes(`Disallow: ${d}`), `missing Disallow: ${d}`);++ assert.ok(r.text.includes('Sitemap: https://openvibe.codes/sitemap.xml'), 'sitemap not named');++ assert.ok(/^User-agent: \*$/m.test(r.text), 'no User-agent: * group');++ });++++ await check('sitemap.xml: 200 application/xml, real entries, lastmod from the data', async () => {++ const r = await t.get('/sitemap.xml');++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type'), /^application\/xml/);++ assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);++ assert.ok(r.text.includes('<loc>https://openvibe.codes/docs</loc>'), 'no /docs entry');++ assert.ok(r.text.includes('<loc>https://openvibe.codes/policy/transparency</loc>'), 'no policy entry');++ const lastmods = [...r.text.matchAll(/<lastmod>([^<]+)<\/lastmod>/g)].map((m) => m[1]);++ assert.ok(lastmods.length > 0, 'no lastmod anywhere');++ assert.ok(lastmods.every((d) => /^\d{4}-\d{2}-\d{2}$/.test(d)), `bad lastmod: ${lastmods.slice(0, 3)}`);++ assert.ok(lastmods.every((d) => d === STATUS_UPDATED), `lastmod must come from STATUS.json (${STATUS_UPDATED}), got ${[...new Set(lastmods)].join(', ')}`);++ for (const p of PRIVATE) assert.ok(!r.text.includes(`https://openvibe.codes${p}`), `private path in sitemap: ${p}`);++ });++++ await check('llms.txt: 200 text/plain, real links, nothing private', async () => {++ const r = await t.get('/llms.txt');++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type'), /^text\/plain/);++ assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);++ assert.ok(r.text.startsWith('# OpenVibe.Codes'), 'no title');++ assert.ok(r.text.includes('(https://openvibe.codes/docs)'), 'no reference link');++ assert.ok(r.text.includes('https://openvibe.codes/sitemap.xml'), 'sitemap not listed');++ for (const p of PRIVATE) assert.ok(!r.text.includes(`https://openvibe.codes${p}`), `private path in llms.txt: ${p}`);++ assert.ok(!r.text.includes('https://openvibe.codes/api/'), 'api path in llms.txt');++ });++++ await check('home page: WebSite + the site\'s primary type, the same for every crawler', async () => {++ const r = await t.get('/');++ assert.strictEqual(r.status, 200);++ const blocks = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));++ const types = blocks.map((b) => b['@type']);++ assert.ok(types.includes('WebSite'), `no WebSite node: ${types}`);++ assert.ok(types.includes('WebApplication'), `no primary-type node: ${types}`);++ const site = blocks.find((b) => b['@type'] === 'WebSite');++ assert.strictEqual(site.url, 'https://openvibe.codes');++ assert.strictEqual(site.name, 'OpenVibe.Codes');++ const page = blocks.find((b) => b['@type'] === 'WebPage');++ assert.strictEqual(page.url, 'https://openvibe.codes/');++ });++ } finally { await t.close(); }++ done();++})();
@@ -51,6 +51,9 @@ function createReleases({ store, outbox, trust }) {
@@ -51,6 +51,9 @@ function createReleases({ store, outbox, trust }) {
`SELECT * FROM releases WHERE app_id = ? ${includeDrafts ? '' : "AND status != 'draft'"} ORDER BY created_at DESC, id DESC`,
`SELECT * FROM releases WHERE app_id = ? ${includeDrafts ? '' : "AND status != 'draft'"} ORDER BY created_at DESC, id DESC`,
).all(String(appId))).map(view)));
).all(String(appId))).map(view)));
const recentPublic = async (limit = 20) => (await Promise.all((await db.prepare("SELECT * FROM releases WHERE status IN ('published', 'deprecated') ORDER BY published_at DESC LIMIT ?").all(limit)).map(view)));
const recentPublic = async (limit = 20) => (await Promise.all((await db.prepare("SELECT * FROM releases WHERE status IN ('published', 'deprecated') ORDER BY published_at DESC LIMIT ?").all(limit)).map(view)));
+ // Light rows for the sitemap (http/discovery.js): no trust lookup per row, no N+1. Only the two
+ // non-draft statuses the public pages render; drafts, revoked and private releases never appear.
+ const publicIndex = async (limit = 5000) => await db.prepare("SELECT id, app_id, status, published_at FROM releases WHERE status IN ('published', 'deprecated') ORDER BY published_at DESC LIMIT ?").all(limit);
const log = async (releaseId) => (await db.prepare('SELECT action, actor, at, detail FROM release_log WHERE release_id = ? ORDER BY id').all(releaseId))
const log = async (releaseId) => (await db.prepare('SELECT action, actor, at, detail FROM release_log WHERE release_id = ? ORDER BY id').all(releaseId))
.map((l) => ({ ...l, detail: JSON.parse(l.detail || '{}') }));
.map((l) => ({ ...l, detail: JSON.parse(l.detail || '{}') }));
@@ -218,7 +221,7 @@ function createReleases({ store, outbox, trust }) {
@@ -218,7 +221,7 @@ function createReleases({ store, outbox, trust }) {
return out;
return out;
}
}
- return { get, manifestOf, listForApp, listForProject, recentPublic, log, createDraft, publish, deprecate, revoke, retireProject, manageRole };
+ return { get, manifestOf, listForApp, listForProject, recentPublic, publicIndex, log, createDraft, publish, deprecate, revoke, retireProject, manageRole };
}
}
module.exports = { createReleases, ReleaseError, manageRole, RANK };
module.exports = { createReleases, ReleaseError, manageRole, RANK };
new file mode 100644
new file mode 100644
@@ -0,0 +1,184 @@
@@ -0,0 +1,184 @@
+'use strict';
+
+/**
+ * Crawl and machine-readability artifacts (plan T11): GET /robots.txt, GET /sitemap.xml and
+ * GET /llms.txt, plus the home page's JSON-LD. Every one is built with openvibe-shared/seo — the
+ * same toolkit the other OpenVibe sites use — so they say the same things here as everywhere.
+ *
+ * Public pages only, and never the viewer: the portal, staff console, sign-in and the API are absent
+ * from all of them and Disallowed in robots.txt. lastmod is a real timestamp from the site's own
+ * data — the committed content-revision date in STATUS.json (and each published release's publish
+ * time for the pages a release changes) — never "today" from the clock, which would tell crawlers
+ * the whole site changed on every fetch.
+ */
+const fs = require('fs');
+const path = require('path');
+const contracts = require('openvibe-contracts');
+const seo = require('openvibe-shared/seo');
+const { asyncRouter } = require('./router');
+
+const SITE_NAME = 'OpenVibe.Codes';
+const DESCRIPTION = 'The OpenVibe developer portal: create projects and apps in OpenVibe.Network, get scoped credentials and capability grants, and read reference documentation generated from the exact contracts and SDK versions the platform runs.';
+// Every rule robots.txt had before this module existed: none of them may quietly disappear.
+const DISALLOW = ['/projects', '/auth/', '/oauth/test-callback', '/staff', '/api/'];
+const RELEASE_LIMIT = 5000;
+
+/** "2026-09-28" or "2026-09-28T12:00:00Z" as YYYY-MM-DD; null when the value is unusable. */
+function dayOf(ts) {
+ const m = String(ts == null ? '' : ts).match(/^(\d{4}-\d{2}-\d{2})/);
+ return m ? m[1] : null;
+}
+
+/** The committed status file's content date: the site's own revision date, never the clock. */
+function siteUpdated() {
+ try { return dayOf(JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'STATUS.json'), 'utf8')).updated); } catch { return null; }
+}
+
+/**
+ * The home page's JSON-LD: the site as a WebSite, the developer portal as the site's primary type
+ * (the shared kit's WebApplication) and the page that carries them. The same nodes for every
+ * crawler; nothing about the reader.
+ */
+function homeJsonLd(config) {
+ const site = String(config.baseUrl).replace(/\/+$/, '');
+ return [
+ seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),
+ seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'DeveloperApplication', keywords: 'developer portal, api, oauth, webhooks, sdk, openvibe' }),
+ seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),
+ ];
+}
+
+/**
+ * The site's fixed public pages, with their crawl hints. Docs and policy pages are generated from
+ * the pinned contracts and SDK and from the repository's Markdown, so their lastmod is the site's
+ * content-revision date; governance drafts stay out (they are noindex and not published).
+ */
+function publicPages({ docs, governance = [] }) {
+ const pages = [
+ { path: '/', changefreq: 'daily', priority: 1.0 },
+ { path: '/updates', changefreq: 'daily', priority: 0.5 },
+ { path: '/docs', changefreq: 'weekly', priority: 0.9 },
+ { path: '/docs/api', changefreq: 'weekly', priority: 0.8 },
+ { path: '/docs/updates', changefreq: 'daily', priority: 0.6 },
+ { path: '/docs/contracts', changefreq: 'weekly', priority: 0.7 },
+ { path: '/docs/capabilities', changefreq: 'weekly', priority: 0.7 },
+ { path: '/docs/events', changefreq: 'weekly', priority: 0.6 },
+ { path: '/docs/services', changefreq: 'weekly', priority: 0.6 },
+ { path: '/docs/billing', changefreq: 'monthly', priority: 0.5 },
+ { path: '/docs/limits', changefreq: 'monthly', priority: 0.5 },
+ { path: '/docs/tools', changefreq: 'monthly', priority: 0.5 },
+ { path: '/docs/export', changefreq: 'monthly', priority: 0.5 },
+ { path: '/docs/sdk', changefreq: 'weekly', priority: 0.7 },
+ { path: '/oauth', changefreq: 'monthly', priority: 0.5 },
+ { path: '/tools/webhooks', changefreq: 'monthly', priority: 0.5 },
+ { path: '/manifests/validate', changefreq: 'monthly', priority: 0.5 },
+ { path: '/policy', changefreq: 'monthly', priority: 0.5 },
+ { path: '/policy/rfc', changefreq: 'monthly', priority: 0.4 },
+ { path: '/policy/compatibility', changefreq: 'monthly', priority: 0.4 },
+ { path: '/policy/licensing', changefreq: 'monthly', priority: 0.4 },
+ { path: '/policy/transparency', changefreq: 'monthly', priority: 0.4 },
+ ];
+ for (const g of governance) if (!g.draft) pages.push({ path: `/policy/${g.slug}`, changefreq: 'monthly', priority: 0.3 });
+ for (const c of docs.contracts) pages.push({ path: `/docs/contracts/${c.id}`, changefreq: 'monthly', priority: 0.4 });
+ for (const c of docs.capabilities) pages.push({ path: `/docs/capabilities/${c.id}`, changefreq: 'monthly', priority: 0.4 });
+ for (const x of contracts.openapi.index()) pages.push({ path: `/docs/api/${x.service}`, changefreq: 'monthly', priority: 0.4 });
+ for (const m of docs.sdk) pages.push({ path: `/docs/sdk/${m.slug}`, changefreq: 'monthly', priority: 0.3 });
+ for (const a of docs.adrs) pages.push({ path: `/docs/adr/${a.id}`, changefreq: 'yearly', priority: 0.3 });
+ return pages;
+}
+
+function createDiscoveryRoutes(ctx) {
+ const { config, docs, releases, governance = [] } = ctx;
+ const r = asyncRouter();
+ const site = String(config.baseUrl).replace(/\/+$/, '');
+ const abs = (p) => `${site}${p}`;
+
+ // The public, indexable releases: what the sitemap lists and what dates the pages a release
+ // changes. Never drafts, revoked or private ones (only 'published' pages are index: true).
+ async function releaseIndex() {
+ try { return await releases.publicIndex(RELEASE_LIMIT); } catch { return []; }
+ }
+
+ async function sitemapEntries() {
+ const content = siteUpdated();
+ const rows = await releaseIndex();
+ const entries = publicPages({ docs, governance }).map((p) => ({ ...p, lastmod: content }));
+ const newest = rows.map((x) => dayOf(x.published_at)).filter(Boolean).sort().pop() || null;
+ // The home and shipped pages change when a release publishes, so they carry the newer of
+ // that real publish time and the site's content date.
+ for (const e of entries) if ((e.path === '/' || e.path === '/updates') && newest && (!e.lastmod || newest > e.lastmod)) e.lastmod = newest;
+ const byApp = new Map();
+ for (const x of rows) {
+ const d = dayOf(x.published_at);
+ if (d && (!byApp.has(x.app_id) || d > byApp.get(x.app_id))) byApp.set(x.app_id, d);
+ }
+ for (const [appId, lastmod] of byApp) entries.push({ path: `/apps/${appId}`, changefreq: 'weekly', priority: 0.4, lastmod });
+ for (const x of rows.filter((x) => x.status === 'published')) entries.push({ path: `/releases/${x.id}`, changefreq: 'monthly', priority: 0.3, lastmod: dayOf(x.published_at) });
+ return entries;
+ }
+
+ r.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 (DISALLOW).
+ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(
+ '# openvibe.codes: the public pages are for search and AI crawlers; the portal, sign-in, staff and the API are not.\n'
+ + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
+ });
+
+ r.get('/llms.txt', (_req, res) => {
+ const firstContract = docs.contracts[0] ? docs.contracts[0].id : null;
+ const firstService = (contracts.openapi.index()[0] || {}).service || null;
+ const machine = [
+ { title: 'Sitemap', url: abs('/sitemap.xml'), note: 'the public pages below, each with a real lastmod' },
+ { title: 'robots.txt', url: abs('/robots.txt'), note: 'search and AI crawlers are welcome on the public pages' },
+ { title: 'Release metadata (JSON)', url: abs('/release.json'), note: 'this service\'s current release, per ADR-016' },
+ ];
+ if (firstContract) machine.push({ title: 'Contract JSON Schema', url: abs(`/docs/contracts/${firstContract}.json`), note: 'append .json to any contract page for its schema' });
+ if (firstService) machine.push({ title: 'Service OpenAPI', url: abs(`/docs/api/${firstService}.json`), note: 'append .json to any API explorer page for its OpenAPI document' });
+ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(seo.llmsTxt({
+ name: SITE_NAME,
+ summary: 'OpenVibe.Codes: the OpenVibe developer portal over OpenVibe.Network\'s API — projects, apps, scoped credentials and capability grants, reference documentation generated from the pinned contracts and SDK, and OAuth, webhook and manifest tools.',
+ details: 'Every page is server-rendered and readable without JavaScript, and every one is public: no account is needed to read the docs or use the OAuth, webhook and manifest tools. Projects, apps, credentials and grants belong to OpenVibe.Network; the pages that manage them need a signed-in OpenVibe account and are deliberately not listed here or in the sitemap. The reference under /docs is generated at boot from the exact openvibe-contracts and openvibe-sdk versions the platform runs, so it cannot drift from them.',
+ sections: [
+ { title: 'Start here', links: [
+ { title: 'The developer portal', url: abs('/'), note: 'what the portal is for and how an account becomes an integration' },
+ { title: 'Reference documentation', url: abs('/docs'), note: 'generated from the pinned contracts and SDK' },
+ { title: 'What shipped on OpenVibe.Codes', url: abs('/updates') },
+ { title: 'Transparency', url: abs('/policy/transparency'), note: 'what the portal stores, what it does not, and what works today' },
+ ] },
+ { title: 'Reference', links: [
+ { title: 'API explorer', url: abs('/docs/api'), note: 'each service\'s OpenAPI document, per page as .json' },
+ { title: 'Contracts', url: abs('/docs/contracts'), note: 'schemas, field tables and fixtures, per page as .json' },
+ { title: 'Capabilities', url: abs('/docs/capabilities') },
+ { title: 'Events', url: abs('/docs/events') },
+ { title: 'Services', url: abs('/docs/services') },
+ { title: 'Tools', url: abs('/docs/tools') },
+ { title: 'SDK', url: abs('/docs/sdk') },
+ ] },
+ { title: 'Developer tools', links: [
+ { title: 'OAuth callback helper', url: abs('/oauth'), note: 'authorization code + PKCE, with a callback that never exchanges the code' },
+ { title: 'Webhook signature tester', url: abs('/tools/webhooks'), note: 'decides as a receiver requiring signature v2 does' },
+ { title: 'Manifest validation', url: abs('/manifests/validate'), note: 'app and mod manifests, validated against the pinned contracts' },
+ ] },
+ { title: 'Policy', links: [
+ { title: 'Policy home', url: abs('/policy') },
+ { title: 'Proposals and decisions', url: abs('/policy/rfc') },
+ { title: 'Compatibility and deprecation', url: abs('/policy/compatibility') },
+ { title: 'Licensing', url: abs('/policy/licensing') },
+ ...governance.filter((g) => !g.draft).map((g) => ({ title: g.title, url: abs(`/policy/${g.slug}`) })),
+ ] },
+ { title: 'Machine-readable', links: machine },
+ ],
+ }));
+ });
+
+ r.get('/sitemap.xml', async (_req, res) => {
+ const entries = await sitemapEntries();
+ const urls = entries.map((e) => ({ loc: abs(e.path), ...(e.lastmod ? { lastmod: e.lastmod } : {}), changefreq: e.changefreq, priority: e.priority }));
+ res.type('application/xml').set('Cache-Control', 'public, max-age=3600').send(seo.sitemapXml(urls));
+ });
+
+ return r;
+}
+
+module.exports = { createDiscoveryRoutes, homeJsonLd, publicPages, dayOf };
@@ -2,7 +2,8 @@
@@ -2,7 +2,8 @@
/**
/**
* Public pages: the landing page, policy pages, public release and trust pages, the staff trust
* Public pages: the landing page, policy pages, public release and trust pages, the staff trust
- * console, robots.txt and the sitemap.
+ * console. Crawl artifacts (robots.txt, sitemap.xml, llms.txt, the home page's JSON-LD) live in
+ * http/discovery.js and are mounted here.
*
*
* Policy pages are derived, not paraphrased: the compatibility and deprecation policy renders
* Policy pages are derived, not paraphrased: the compatibility and deprecation policy renders
* ADR-002 and ADR-016 exactly as openvibe-contracts publishes them; the decision index is the ADR
* ADR-002 and ADR-016 exactly as openvibe-contracts publishes them; the decision index is the ADR
@@ -16,6 +17,7 @@ const frame = require('openvibe-shared/frame');
@@ -16,6 +17,7 @@ const frame = require('openvibe-shared/frame');
const path = require('path');
const path = require('path');
const express = require('express');
const express = require('express');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
+const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { html, raw, table, code, badge, time, notice, csrfField, problemBox } = require('../render/html');
const { html, raw, table, code, badge, time, notice, csrfField, problemBox } = require('../render/html');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const { markdown } = require('../render/markdown');
const { markdown } = require('../render/markdown');
@@ -60,6 +62,7 @@ function createPageRoutes(ctx) {
@@ -60,6 +62,7 @@ function createPageRoutes(ctx) {
const recent = await releases.recentPublic(10);
const recent = await releases.recentPublic(10);
page(req, res, {
page(req, res, {
index: true, cache: PUBLIC_CACHE,
index: true, cache: PUBLIC_CACHE,
+ jsonLd: homeJsonLd(config),
body: html`<h1>Build on OpenVibe</h1>
body: html`<h1>Build on OpenVibe</h1>
<p class="lead">OpenVibe.Codes is the developer portal: create a project and apps in OpenVibe.Network, get scoped credentials, request capability grants, and read reference docs generated from the exact contract and SDK versions the platform runs.</p>
<p class="lead">OpenVibe.Codes is the developer portal: create a project and apps in OpenVibe.Network, get scoped credentials, request capability grants, and read reference docs generated from the exact contract and SDK versions the platform runs.</p>
<div class="notice">Status: alpha. What works and what does not is listed on the <a href="/policy/transparency">transparency page</a>. Network owns projects, apps, credentials, grants and quotas; Codes is a portal over its API and never stores a secret.</div>
<div class="notice">Status: alpha. What works and what does not is listed on the <a href="/policy/transparency">transparency page</a>. Network owns projects, apps, credentials, grants and quotas; Codes is a portal over its API and never stores a secret.</div>
@@ -264,23 +267,9 @@ ${signedIn ? releaseActions(req, rel) : ''}
@@ -264,23 +267,9 @@ ${signedIn ? releaseActions(req, rel) : ''}
}
}
});
});
- // ── Discovery ───────────────────────────────────────────
- r.get('/robots.txt', (req, res) => {
- res.set('Cache-Control', 'public, max-age=3600').type('text/plain').send([
- 'User-agent: *', 'Disallow: /projects', 'Disallow: /auth/', 'Disallow: /oauth/test-callback', 'Disallow: /staff', 'Disallow: /api/',
- 'Allow: /', `Sitemap: ${config.baseUrl}/sitemap.xml`, '',
- ].join('\n'));
- });
- r.get('/sitemap.xml', (req, res) => {
- const urls = ['/', '/docs', '/docs/api', '/docs/updates', '/docs/contracts', '/docs/capabilities', '/docs/events', '/docs/services', '/docs/billing', '/docs/limits', '/docs/tools', '/docs/sdk', '/oauth', '/tools/webhooks', '/manifests/validate',
- '/policy', '/policy/rfc', '/policy/compatibility', '/policy/licensing', '/policy/transparency',
- ...governance.filter((g) => !g.draft).map((g) => `/policy/${g.slug}`),
- ...docs.contracts.map((c) => `/docs/contracts/${c.id}`), ...docs.capabilities.map((c) => `/docs/capabilities/${c.id}`),
- ...require('openvibe-contracts').openapi.index().map((x) => `/docs/api/${x.service}`),
- ...docs.sdk.map((m) => `/docs/sdk/${m.slug}`), ...docs.adrs.map((a) => `/docs/adr/${a.id}`)];
- const x = (s) => s.replace(/&/g, '&').replace(/</g, '<');
- res.set('Cache-Control', 'public, max-age=3600').type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.map((u) => `<url><loc>${x(config.baseUrl + u)}</loc></url>`).join('\n')}\n</urlset>\n`);
- });
+ // ── Discovery: robots.txt, sitemap.xml, llms.txt and the home page's JSON-LD (http/discovery.js,
+ // built with openvibe-shared/seo) ─────────────────────
+ r.use(createDiscoveryRoutes({ ...ctx, governance }));
return r;
return r;
}
}
@@ -14,6 +14,7 @@ const fs = require('fs');
@@ -14,6 +14,7 @@ const fs = require('fs');
const path = require('path');
const path = require('path');
const appIcon = require('openvibe-shared/app-icon');
const appIcon = require('openvibe-shared/app-icon');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
+const seo = require('openvibe-shared/seo');
const { html, raw, esc } = require('./html');
const { html, raw, esc } = require('./html');
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
@@ -40,7 +41,7 @@ const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
@@ -40,7 +41,7 @@ const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
/**
/**
* o: title, description, body (html), viewer, config, path, index (default false), scripts [rel],
* o: title, description, body (html), viewer, config, path, index (default false), scripts [rel],
- * crumbs [{ label, href }]
+ * crumbs [{ label, href }], jsonLd [nodes] (serialised by openvibe-shared/seo)
*/
*/
function renderPage(o) {
function renderPage(o) {
const viewer = o.viewer || { kind: 'anonymous' };
const viewer = o.viewer || { kind: 'anonymous' };
@@ -82,6 +83,7 @@ function renderPage(o) {
@@ -82,6 +83,7 @@ function renderPage(o) {
<link rel="canonical" href="${esc(canonical)}">
<link rel="canonical" href="${esc(canonical)}">
<meta name="referrer" content="${o.noReferrer ? 'no-referrer' : 'strict-origin-when-cross-origin'}">
<meta name="referrer" content="${o.noReferrer ? 'no-referrer' : 'strict-origin-when-cross-origin'}">
${appIcon.headTags({ site: 'codes' })}
${appIcon.headTags({ site: 'codes' })}
+${(o.jsonLd || []).filter(Boolean).map(seo.jsonLdTag).join('\n')}
<link rel="stylesheet" href="${asset('css/codes.css')}">
<link rel="stylesheet" href="${asset('css/codes.css')}">
<script src="${ovServe.url('theme-loader.js')}" defer></script>
<script src="${ovServe.url('theme-loader.js')}" defer></script>
<script src="${ovServe.url('navbar.js')}" defer></script>
<script src="${ovServe.url('navbar.js')}" defer></script>
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+'use strict';
+/**
+ * The public crawl artifacts (plan T11), fetched from the booted app: /robots.txt, /sitemap.xml,
+ * /llms.txt and the home page's JSON-LD. Each is served with the right status and content type,
+ * carries at least one real entry, lists public pages only, and takes lastmod from the site's own
+ * data (STATUS.json) — never from the clock.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { boot, check, done } = require('./helpers/boot');
+
+const STATUS_UPDATED = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'STATUS.json'), 'utf8')).updated;
+const PRIVATE = ['/projects', '/staff', '/auth/', '/oauth/test-callback'];
+
+(async () => {
+ const t = await boot();
+ try {
+ await check('robots.txt: 200 text/plain, every previous Disallow kept, sitemap named', async () => {
+ const r = await t.get('/robots.txt');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type'), /^text\/plain/);
+ assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);
+ for (const d of [...PRIVATE, '/api/']) assert.ok(r.text.includes(`Disallow: ${d}`), `missing Disallow: ${d}`);
+ assert.ok(r.text.includes('Sitemap: https://openvibe.codes/sitemap.xml'), 'sitemap not named');
+ assert.ok(/^User-agent: \*$/m.test(r.text), 'no User-agent: * group');
+ });
+
+ await check('sitemap.xml: 200 application/xml, real entries, lastmod from the data', async () => {
+ const r = await t.get('/sitemap.xml');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type'), /^application\/xml/);
+ assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);
+ assert.ok(r.text.includes('<loc>https://openvibe.codes/docs</loc>'), 'no /docs entry');
+ assert.ok(r.text.includes('<loc>https://openvibe.codes/policy/transparency</loc>'), 'no policy entry');
+ const lastmods = [...r.text.matchAll(/<lastmod>([^<]+)<\/lastmod>/g)].map((m) => m[1]);
+ assert.ok(lastmods.length > 0, 'no lastmod anywhere');
+ assert.ok(lastmods.every((d) => /^\d{4}-\d{2}-\d{2}$/.test(d)), `bad lastmod: ${lastmods.slice(0, 3)}`);
+ assert.ok(lastmods.every((d) => d === STATUS_UPDATED), `lastmod must come from STATUS.json (${STATUS_UPDATED}), got ${[...new Set(lastmods)].join(', ')}`);
+ for (const p of PRIVATE) assert.ok(!r.text.includes(`https://openvibe.codes${p}`), `private path in sitemap: ${p}`);
+ });
+
+ await check('llms.txt: 200 text/plain, real links, nothing private', async () => {
+ const r = await t.get('/llms.txt');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type'), /^text\/plain/);
+ assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);
+ assert.ok(r.text.startsWith('# OpenVibe.Codes'), 'no title');
+ assert.ok(r.text.includes('(https://openvibe.codes/docs)'), 'no reference link');
+ assert.ok(r.text.includes('https://openvibe.codes/sitemap.xml'), 'sitemap not listed');
+ for (const p of PRIVATE) assert.ok(!r.text.includes(`https://openvibe.codes${p}`), `private path in llms.txt: ${p}`);
+ assert.ok(!r.text.includes('https://openvibe.codes/api/'), 'api path in llms.txt');
+ });
+
+ await check('home page: WebSite + the site\'s primary type, the same for every crawler', async () => {
+ const r = await t.get('/');
+ assert.strictEqual(r.status, 200);
+ const blocks = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
+ const types = blocks.map((b) => b['@type']);
+ assert.ok(types.includes('WebSite'), `no WebSite node: ${types}`);
+ assert.ok(types.includes('WebApplication'), `no primary-type node: ${types}`);
+ const site = blocks.find((b) => b['@type'] === 'WebSite');
+ assert.strictEqual(site.url, 'https://openvibe.codes');
+ assert.strictEqual(site.name, 'OpenVibe.Codes');
+ const page = blocks.find((b) => b['@type'] === 'WebPage');
+ assert.strictEqual(page.url, 'https://openvibe.codes/');
+ });
+ } finally { await t.close(); }
+ done();
+})();