openvibers/openvibe.domains Commits

Agent layer: /.well-known/agent.json and Markdown twins of the public pages (#8)

OP OpenVibers committed 11 days ago · 8b3647a974 · 6 files, +210 −45

* Agent layer: /.well-known/agent.json and Markdown twins of the public pages

openvibe-shared v4.34.0 (was v4.33.0). The descriptor is what openvibe.domains really serves: API /api/v1, docs
/developers, and the app's own 120/min limit; no OpenAPI document or MCP endpoint is published. The page model
builder in server/http/pages.js now returns the page for both the HTML routes and the agent layer, so the twins of
/, /search, /tld, /tld/:tld, /d/:domain and /developers cannot drift. The mount is gated to twin requests only, so a
browser always reaches the site's own route and the signed-in panel pages are never rendered signed out. HTML pages
name their twin in the head and the Link header; robots.txt names the manifest.

* Route /developers through renderPublicPage so its HTML carries the twin Link header

The page already named its twin in the head; the shared renderer adds the matching Link header
the rest of the public pages get.

* Merge remote-tracking branch 'origin/main' into refresh-8

# Conflicts:
#	README.md
#	STATUS.json
#	package-lock.json
#	package.json
#	server/views/index.js

Changes

ddiff --git a/server/agent-descriptor.js b/server/agent-descriptor.js
nnew file mode 100644
iindex 0000000..296d53e
---- /dev/null
++++ b/server/agent-descriptor.js
@@@ -0,0 +1,17 @@
++'use strict';
++// What openvibe.domains tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.
++// Every field is something this site really serves: the open JSON API under /api/v1 and the developers page that
++// documents it. No OpenAPI document or MCP endpoint is published.
++module.exports = {
++ id: 'domains',
++ name: 'OpenVibe.Domains',
++ purpose: 'Price a domain name across every ending and registrar, with real one-, three- and five-year totals and the renewal traps in plain sight, plus public registration history.',
++ home: 'https://openvibe.domains',
++ site: 'openvibe.domains',
++ api: '/api/v1',
++ docs: '/developers',
++ policy: {
++ auth: ['none', 'Network OIDC'],
++ rateLimit: '120 API and name-query requests a minute per address; pages are not rate limited',
++ },
++};
ddiff --git a/server/app.js b/server/app.js
iindex 5d4b70a..20a7fe4 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -153,8 +153,25 @@ async function createApp(opts = {}) {
});
// ── Pages ───────────────────────────────────────────────
++ const pages = createPageRoutes(ctx);
app.use(panelRoutes.pages);
-- app.use(createPageRoutes(ctx));
++ // ── Agent layer (openvibe-shared/agent-layer) ───────────
++ // The manifest and the Markdown twins of the public pages. Gated to twin requests only, so a browser always
++ // reaches the site's own route (and the panel's signed-in pages are never rendered signed out).
++ {
++ const agentLayer = require('openvibe-shared/agent-layer');
++ const agent = express.Router();
++ agent.use((req, _res, next) => {
++ const twinPath = req.path.endsWith('.md');
++ const twinFormat = req.query.format === 'markdown';
++ const twinAccept = !!(req.headers.accept && req.accepts(['html', 'text/markdown']) === 'text/markdown');
++ if (req.path === '/.well-known/agent.json' || twinPath || twinFormat || twinAccept) return next();
++ return next('router');
++ });
++ agentLayer.mount(agent, { descriptor: require('./agent-descriptor'), render: pages.renderPublicPage });
++ app.use(agent);
++ }
++ app.use(pages);
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, _next) => {
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 93b330a..39d01d0 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -68,7 +68,7 @@ function createDiscoveryRoutes(ctx) {
sitemaps: [abs('/sitemap.xml')],
allow: ['/'],
disallow: [],
-- }));
++ }) + `# Agent manifest: ${abs('/.well-known/agent.json')}\n`);
});
r.get('/sitemap.xml', async (_req, res) => {
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 140810b..5fa62b2 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -10,6 +10,7 @@
*
* Every page is server-rendered, complete without JavaScript and cached for a short public window
* (five minutes, two for a name query). The views are placeholders; this file builds the model.
++ * renderPublicPage is the one model builder: the HTML routes and the agent layer's Markdown twins both use it.
*/
const views = require('../views');
const { parseName, parseTld } = require('../names');
@@@ -28,47 +29,106 @@ function createPageRoutes(ctx) {
// The clients never throw; this keeps a misbehaving stand-in from turning a page into a 500.
const safe = (fn) => Promise.resolve().then(fn).catch(() => null);
-- const send = (res, status, body, cache = PAGE_CACHE) => {
-- res.status(status).set('Cache-Control', cache).type('html').send(body);
++ const send = (res, status, page, cache = PAGE_CACHE) => {
++ res.status(status).set('Cache-Control', cache).type('html');
++ if (page && page.canonical) res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(page.canonical).header);
++ res.send(typeof page === 'string' ? page : page.html);
};
const notFound = (res) => send(res, 404, views.notFound());
++ /** The public model behind a path, for the HTML routes and the agent layer. null means no public page. */
++ async function renderPublicPage(rawPath, { query } = {}) {
++ const p = String(rawPath || '/').replace(/\/+$/, '') || '/';
++ const q = query instanceof URLSearchParams ? query : new URLSearchParams();
++
++ if (p === '/') {
++ const [endings, lists] = await Promise.all([
++ safe(() => host.tlds({ sort: 'popular' })),
++ safe(() => tools.lists({ limit: 10 })),
++ ]);
++ return { html: views.home({ endings, lists, canonical: abs('/') }), title: null, canonical: abs('/') };
++ }
++
++ if (p === '/search') {
++ const raw = typeof q.get('q') === 'string' ? q.get('q').trim() : '';
++ const name = raw ? parseName(raw) : null;
++ if (!name) return null;
++ const check = await safe(() => host.check(name.ascii));
++ const rows = check && Array.isArray(check.prices) ? priceRows(check.prices) : [];
++ return {
++ html: views.search({ name, check, rows, available: check ? check.status : 'unknown', canonical: abs('/search') }),
++ title: `${name.display || name.ascii}: price and availability`,
++ canonical: abs('/search'),
++ };
++ }
++
++ if (p === '/tld') {
++ const endings = await host.tlds({ sort: 'renew' });
++ return { html: views.endings({ endings, canonical: abs('/tld') }), title: 'Every ending, cheapest to keep', canonical: abs('/tld') };
++ }
++
++ const tldMatch = p.match(/^\/tld\/([^/]+)$/);
++ if (tldMatch) {
++ let tld;
++ try { tld = parseTld(decodeURIComponent(tldMatch[1])); } catch { tld = null; }
++ if (!tld) return null;
++ const detail = await safe(() => host.tld(tld));
++ if (!detail) return null;
++ const priced = { ...detail, prices: priceRows(detail.prices) };
++ const name = priced.display || `.${priced.tld}`;
++ return {
++ html: views.tld({ tld: priced, canonical: abs(`/tld/${encodeURIComponent(tld)}`) }),
++ title: `${name} domain prices: every registrar, 1, 3 and 5 years`,
++ canonical: abs(`/tld/${encodeURIComponent(tld)}`),
++ };
++ }
++
++ if (p === '/developers') {
++ return { html: views.developers({ canonical: abs('/developers') }), title: 'The Domains API', canonical: abs('/developers') };
++ }
++
++ const domainMatch = p.match(/^\/d\/([^/]+)$/);
++ if (domainMatch) {
++ let name;
++ try { name = parseName(decodeURIComponent(domainMatch[1])); } catch { name = null; }
++ if (!name || !name.tld) return null;
++ const [history, check] = await Promise.all([
++ safe(() => tools.history(name.ascii)),
++ safe(() => host.check(name.ascii)),
++ ]);
++ const rows = check && Array.isArray(check.prices) ? check.prices.filter((row) => row && row.tld === name.tld) : [];
++ const canonical = abs(`/d/${encodeURIComponent(name.ascii)}`);
++ return {
++ html: views.domain({ name, history, prices: priceRows(rows), status: check ? check.status : null, check, canonical }),
++ title: `${name.display || name.ascii}: public record and history`,
++ canonical,
++ };
++ }
++
++ return null;
++ }
++
r.get('/', async (_req, res) => {
-- const [endings, lists] = await Promise.all([
-- safe(() => host.tlds({ sort: 'popular' })),
-- safe(() => tools.lists({ limit: 10 })),
-- ]);
-- send(res, 200, views.home({ endings, lists, canonical: abs('/') }));
++ send(res, 200, await renderPublicPage('/'));
});
r.get('/search', async (req, res) => {
const q = typeof req.query.q === 'string' ? req.query.q.trim() : '';
-- const name = q ? parseName(q) : null;
-- if (!name) return res.redirect(302, '/');
-- const check = await safe(() => host.check(name.ascii));
-- const rows = check && Array.isArray(check.prices) ? priceRows(check.prices) : [];
-- return send(res, 200, views.search({
-- name, check, rows,
-- available: check ? check.status : 'unknown',
-- canonical: abs('/search'),
-- }), QUERY_CACHE);
++ const page = await renderPublicPage('/search', { query: new URLSearchParams({ q }) });
++ if (!page) return res.redirect(302, '/');
++ return send(res, 200, page, QUERY_CACHE);
});
r.get('/tld/:tld', async (req, res) => {
-- const tld = parseTld(req.params.tld);
-- if (!tld) return notFound(res);
-- const detail = await safe(() => host.tld(tld));
-- if (!detail) return notFound(res);
-- const priced = { ...detail, prices: priceRows(detail.prices) };
-- return send(res, 200, views.tld({ tld: priced, canonical: abs(`/tld/${encodeURIComponent(tld)}`) }));
++ const page = await renderPublicPage(`/tld/${encodeURIComponent(req.params.tld)}`);
++ return page ? send(res, 200, page) : notFound(res);
});
r.get('/tld', async (_req, res) => {
-- const endings = await host.tlds({ sort: 'renew' });
-- send(res, 200, views.endings({ endings, canonical: abs('/tld') }));
++ send(res, 200, await renderPublicPage('/tld'));
});
-- r.get('/developers', (_req, res) => send(res, 200, views.developers({ canonical: abs('/developers') })));
++ r.get('/developers', async (_req, res) => send(res, 200, await renderPublicPage('/developers')));
// A person who types "/.dev" gets the ending page. The ending is normalized in one 301.
r.get(/^\/\.([^/]+)$/, (req, res) => {
@@@ -79,22 +139,14 @@ function createPageRoutes(ctx) {
});
r.get('/d/:domain', async (req, res) => {
-- const name = parseName(req.params.domain);
-- if (!name || !name.tld) return notFound(res);
-- const [history, check] = await Promise.all([
-- safe(() => tools.history(name.ascii)),
-- safe(() => host.check(name.ascii)),
-- ]);
-- const rows = check && Array.isArray(check.prices) ? check.prices.filter((row) => row && row.tld === name.tld) : [];
-- return send(res, 200, views.domain({
-- name, history, prices: priceRows(rows), status: check ? check.status : null, check,
-- canonical: abs(`/d/${encodeURIComponent(name.ascii)}`),
-- }), QUERY_CACHE);
++ const page = await renderPublicPage(`/d/${encodeURIComponent(req.params.domain)}`);
++ return page ? send(res, 200, page, QUERY_CACHE) : notFound(res);
});
r.use(createDiscoveryRoutes(ctx));
r.use((_req, res) => notFound(res));
++ r.renderPublicPage = renderPublicPage;
return r;
}
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 8c89c23..9268752 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -60,7 +60,7 @@ const NAV = [
];
/** The page around a body: the shared shell with the room's stylesheets and behaviour. */
--function layout({ title, description, canonical, robots = 'index, follow', body, active = '/', jsonLd = [] }) {
++function layout({ title, description, canonical, robots = 'index, follow', body, active = '/', jsonLd = [], agentLayer = false }) {
const navbar = {
service: 'domains',
apiBase: NETWORK,
@@@ -83,6 +83,7 @@ function layout({ title, description, canonical, robots = 'index, follow', body,
siteName: NAME,
description: description || `${ROOM.line}. Every ending and registrar for a name, with what you will really pay over 1, 3 and 5 years.`,
canonical,
++ agentLayer,
robots,
jsonLd,
home: '/',
@@@ -187,7 +188,7 @@ function home({ endings, lists, canonical } = {}) {
// the room is one or two screens. Both stay in the page for the crawlers and open where there is room.
body = foldSection(body, 'class="sc-faq"', 'faq-fold');
body = foldSection(body, 'class="sc-code"', 'dev-fold');
-- return layout({ title: null, canonical, body: `<div class="dm-wrap">${body}</div>`, active: '/', jsonLd: [{ '@context': 'https://schema.org', '@type': 'WebSite', name: NAME, url: 'https://openvibe.domains/', potentialAction: { '@type': 'SearchAction', target: 'https://openvibe.domains/search?q={name}', 'query-input': 'required name=name' } }] });
++ return layout({ agentLayer: true, title: null, canonical, body: `<div class="dm-wrap">${body}</div>`, active: '/', jsonLd: [{ '@context': 'https://schema.org', '@type': 'WebSite', name: NAME, url: 'https://openvibe.domains/', potentialAction: { '@type': 'SearchAction', target: 'https://openvibe.domains/search?q={name}', 'query-input': 'required name=name' } }] });
}
/** A table of priced rows: ending or registrar first, then the year totals, sorted by 3 years. */
@@@ -224,14 +225,14 @@ ${available === 'registered' && check ? `<p class="dm-act"><a class="dm-btn" hre
${rows.length ? `<section aria-labelledby="dm-all-h"><div class="dm-head-row"><h2 id="dm-all-h">${esc(label)} in every ending</h2></div>
<p class="dm-fine">What each ending costs to register and keep, cheapest over three years first. Availability is checked for <b>${esc(full)}</b>; tap another ending to check it.${latest(rows) ? ` Prices checked ${esc(day(latest(rows)))}.` : ''}</p>
${priceTable(rows, { by: 'ending', label: `${label} in every ending`, check: name ? name.label : null })}</section>` : '<p class="dm-fine">No prices for this name\'s endings yet.</p>'}`;
-- return layout({ title: `${full}: price and availability`, description: `${full}: whether it is free, and what every ending costs over 1, 3 and 5 years.`, robots: 'noindex, follow', body: `<div class="dm-wrap">${body}</div>`, active: '/' });
++ return layout({ agentLayer: true, title: `${full}: price and availability`, description: `${full}: whether it is free, and what every ending costs over 1, 3 and 5 years.`, robots: 'noindex, follow', body: `<div class="dm-wrap">${body}</div>`, active: '/' });
}
function endings({ endings: list, canonical } = {}) {
const rows = priceRows((list && list.tlds) || []);
const body = `<header class="dm-page-h"><h1>Every ending</h1><p class="dm-lede">All the endings OpenVibe prices, cheapest to keep over three years first.${latest(rows) ? ` Prices checked ${esc(day(latest(rows)))}.` : ''}</p>${searchBox()}</header>
${rows.length ? priceTable(rows, { by: 'ending', label: 'Every ending' }) : kit.empty({ title: 'No prices yet', text: 'The price lists are being read. Try again in a few minutes.' })}`;
-- return layout({ title: 'Every ending, cheapest to keep', description: 'Every domain ending OpenVibe prices, sorted by what three years really cost.', canonical, body: `<div class="dm-wrap">${body}</div>`, active: '/tld' });
++ return layout({ agentLayer: true, title: 'Every ending, cheapest to keep', description: 'Every domain ending OpenVibe prices, sorted by what three years really cost.', canonical, body: `<div class="dm-wrap">${body}</div>`, active: '/tld' });
}
/** A small history chart: each registrar's first-year price over time, as steps (real changes only). */
@@@ -271,7 +272,7 @@ ${searchBox()}</header>
<p class="dm-fine">From each registrar's own public price list.${latest(rows) ? ` Checked ${esc(day(latest(rows)))}.` : ''} Affiliate links, where there are any, are marked and never change the order.</p>
${rows.length ? priceTable(rows, { by: 'registrar', label: `Registrars for ${name}` }) : '<p class="dm-fine">No registrar prices this ending yet.</p>'}</section>
<section aria-labelledby="dm-hist-h"><h2 id="dm-hist-h">Price history</h2>${chart || `<p class="dm-fine">No price changes recorded${rows.length && rows[0].first_seen_at ? ` since ${esc(day(rows.map((r) => r.first_seen_at).filter(Boolean).sort()[0]))}` : ''}. Every change from now on is kept here.</p>`}</section>`;
-- return layout({ title: `${name} domain prices: every registrar, 1, 3 and 5 years`, description: `What ${name} costs at every registrar OpenVibe reads: first year, renewal and the real 3- and 5-year totals.`, canonical, body: `<div class="dm-wrap">${body}</div>`, active: '/tld' });
++ return layout({ agentLayer: true, title: `${name} domain prices: every registrar, 1, 3 and 5 years`, description: `What ${name} costs at every registrar OpenVibe reads: first year, renewal and the real 3- and 5-year totals.`, canonical, body: `<div class="dm-wrap">${body}</div>`, active: '/tld' });
}
function domain({ name, history, prices = [], status = null, check = null, canonical } = {}) {
@@@ -295,7 +296,7 @@ ${status ? `<p class="dm-status ${(STATUS[status] || ['is-unknown'])[0]}"><span
<section aria-labelledby="dm-rec-h"><h2 id="dm-rec-h">Public record</h2>${record || `<p class="dm-fine">OpenVibe keeps a domain's history once it has been looked up ${Number(h.min_lookups || 5)} times${h.lookups ? ` (${Number(h.lookups)} so far)` : ''}. <a href="https://whois.openvibe.tools/?q=${encodeURIComponent(name ? name.ascii : '')}">Look it up now on OpenVibe.Tools</a>.</p>`}
${timeline ? `<h3>What changed</h3>${timeline}` : ''}</section>
${prices.length ? `<section aria-labelledby="dm-p-h"><h2 id="dm-p-h">What ${esc(dot(name.tld))} costs</h2>${priceTable(prices, { by: 'ending', label: `${dot(name.tld)} prices` })}</section>` : ''}`;
-- return layout({ title: `${full}: public record and history`, description: `${full}: its registrar, dates and how they changed, from public records only.`, canonical, robots: snaps.length ? 'index, follow' : 'noindex, follow', body: `<div class="dm-wrap">${body}</div>` });
++ return layout({ agentLayer: true, title: `${full}: public record and history`, description: `${full}: its registrar, dates and how they changed, from public records only.`, canonical, robots: snaps.length ? 'index, follow' : 'noindex, follow', body: `<div class="dm-wrap">${body}</div>` });
}
/** The API, for people and agents: every endpoint with a call that copies. */
@@@ -310,7 +311,7 @@ function developers({ canonical } = {}) {
<section aria-labelledby="dm-ep-h"><h2 id="dm-ep-h">Endpoints</h2><dl class="dm-eps">${rows.map(([ep, what]) => `<div><dt><code>${esc(ep)}</code></dt><dd>${esc(what)}</dd></div>`).join('')}</dl></section>
${sc.code({ title: 'Try it', samples: [{ label: 'Price a name', lang: 'bash', code: 'curl "https://openvibe.domains/api/v1/search?q=sushitruck"' }, { label: 'JavaScript', lang: 'js', code: "const r = await fetch('https://openvibe.domains/api/v1/tlds/dev').then((x) => x.json());\nconsole.log(r.tld.prices[0]);" }] })}
<p class="dm-fine">Limits: 120 requests a minute from one address. Errors are problem+json (RFC 9457). <a href="/llms.txt">llms.txt</a> describes the site for agents.</p>`;
-- return layout({ title: 'The Domains API', description: 'Open JSON for domain prices, availability and history: every ending and registrar, with 1, 3 and 5-year totals.', canonical, body: `<div class="dm-wrap">${body}</div>`, active: '/developers' });
++ return layout({ agentLayer: true, title: 'The Domains API', description: 'Open JSON for domain prices, availability and history: every ending and registrar, with 1, 3 and 5-year totals.', canonical, body: `<div class="dm-wrap">${body}</div>`, active: '/developers' });
}
function notFound() {
ddiff --git a/test/agent-layer.test.js b/test/agent-layer.test.js
nnew file mode 100644
iindex 0000000..21285b9
---- /dev/null
++++ b/test/agent-layer.test.js
@@@ -0,0 +1,78 @@
++'use strict';
++
++/** The agent layer: the manifest and the Markdown twins of the public pages, through the same model builder. */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers');
++
++const LIST = [
++ { tld: 'com', display: '.com', kind: 'generic', currency: 'USD', register: 9.2, renew: 11.08, transfer: 9.2, five_year: 55.4, registrars: 2, checked_at: '2026-10-22T15:16:02.303Z' },
++ { tld: 'dev', display: '.dev', kind: 'generic', currency: 'USD', register: 8.75, renew: 12.87, transfer: 12.87, five_year: 60.23, registrars: 2, checked_at: '2026-10-22T15:16:02.303Z' },
++];
++const PRICES = [
++ { registrar: 'Porkbun', registrar_slug: 'porkbun', tld: 'dev', display: '.dev', kind: 'generic', currency: 'USD', register: 8.75, renew: 12.87, transfer: 12.87, five_year: 60.23, checked_at: '2026-10-22T15:16:02.303Z' },
++];
++const host = {
++ async tlds() { return { tlds: LIST, next_cursor: null }; },
++ async tld(t) {
++ return { tld: t, display: `.${t}`, kind: 'generic', currency: 'USD', prices: PRICES.filter((p) => p.tld === t), history: [] };
++ },
++ async check(name) {
++ return { label: String(name).split('.')[0], name, tld: 'dev', status: 'available', prices: PRICES };
++ },
++};
++const tools = {
++ async history(domain) { return { domain, snapshots: [], changes: [], lookups: 0 }; },
++ async lists() { return { most_looked_up: [] }; },
++};
++
++(async () => {
++ const t = await boot({ host, tools });
++ try {
++ await check('manifest is JSON with only the endpoints Domains really serves', async () => {
++ const r = await t.get('/.well-known/agent.json');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('content-type'), /application\/json/);
++ const m = r.json();
++ assert.strictEqual(m.id, 'domains');
++ assert.strictEqual(m.home, 'https://openvibe.domains');
++ assert.strictEqual(m.api, 'https://openvibe.domains/api/v1');
++ assert.strictEqual(m.docs, 'https://openvibe.domains/developers');
++ assert.deepStrictEqual(m.policy.auth, ['none', 'Network OIDC']);
++ assert.strictEqual(m.openapi, undefined);
++ assert.strictEqual(m.mcp, undefined);
++ });
++
++ await check('the home names its twin in the head and the Link header', async () => {
++ const r = await t.get('/');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('<link rel="alternate" type="text/markdown" href="https://openvibe.domains/index.md">'));
++ assert.match(r.headers.get('link'), /https:\/\/openvibe\.domains\/index\.md>; rel="alternate"; type="text\/markdown"/);
++ });
++
++ await check('/index.md and /tld/dev.md are Markdown of the same pages; unknown twins are 404', async () => {
++ const home = await t.get('/index.md');
++ assert.strictEqual(home.status, 200);
++ assert.match(home.headers.get('content-type'), /text\/markdown/);
++ assert.ok(home.text.startsWith('---\ntitle:'));
++ assert.ok(home.text.includes('.dev') && home.text.includes('$34.49'));
++ const tld = await t.get('/tld/dev.md');
++ assert.strictEqual(tld.status, 200);
++ assert.ok(tld.text.includes('# .dev domain prices: every registrar, 1, 3 and 5 years'));
++ assert.ok(tld.text.includes('Porkbun'));
++ assert.strictEqual((await t.get('/no-such-page.md')).status, 404);
++ assert.strictEqual((await t.get('/panel.md')).status, 404);
++ });
++
++ await check('/.well-known/security.txt is not swallowed by the layer, and robots names the manifest', async () => {
++ const sec = await t.get('/.well-known/security.txt');
++ assert.strictEqual(sec.status, 404);
++ assert.doesNotMatch(sec.headers.get('content-type') || '', /application\/json/);
++ const robots = await t.get('/robots.txt');
++ assert.strictEqual(robots.status, 200);
++ assert.ok(robots.text.includes('# Agent manifest: https://openvibe.domains/.well-known/agent.json'));
++ });
++ } finally {
++ await t.close();
++ done();
++ }
++})();