openvibers/openvibe.exchange Commits
Agent layer: /.well-known/agent.json and Markdown twins of the public pages (#3)
Changes
nnew file mode 100644@@@ -0,0 +1,17 @@++'use strict';++// What openvibe.exchange tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.++// Every field is something this site really serves: the JSON API under /api/v1, the developers page that++// documents it, and the app's own per-address limits. No OpenAPI document or MCP endpoint is published.++module.exports = {++ id: 'exchange',++ name: 'OpenVibe.Exchange',++ purpose: 'Trade what people make: free and swap listings for agents, MCP tools, templates, themes, game assets and creator services, each with a permission passport, a sandbox and one-tap revocation.',++ home: 'https://openvibe.exchange',++ site: 'openvibe.exchange',++ api: '/api/v1',++ docs: '/developers',++ policy: {++ auth: ['none', 'Network OIDC'],++ rateLimit: '300 page requests a minute per address; 240 API requests a minute; sign-in limited to 60 attempts per 15 minutes',++ },++};nnew file mode 100644@@@ -0,0 +1,91 @@++'use strict';++// The public pages the agent layer serves as Markdown twins (openvibe-shared/agent-layer). Each twin is the++// page's own render through the same views and layout the HTML routes use, always signed out: a listing's++// owner/staff actions, installs and offers never appear in a twin, and an unlisted listing has no twin at all.++const seo = require('openvibe-shared/seo');++const views = require('./views');++const { raw } = require('./render/html');++const { renderPage } = require('./render/layout');++const { homeJsonLd } = require('./http/discovery');++++const HOME_DESCRIPTION = 'OpenVibe.Exchange: trade what people make. Free and swap listings for agents, MCP tools, templates, themes, game assets and creator services, each with a permission passport, a sandbox and one-tap revocation.';++const LISTINGS_DESCRIPTION = 'Every public free and swap listing on OpenVibe.Exchange: agents, MCP tools, templates, themes, game assets and creator services, each with a permission passport.';++++function createAgentPages(ctx) {++ const { config, entries } = ctx;++ const base = () => String(config.baseUrl).replace(/\/+$/, '');++ const page = (path, o) => ({ html: renderPage({ viewer: { kind: 'anonymous' }, config, path, agentLayer: true, ...o }), title: o.title });++++ async function home(path) {++ const [list, counts] = await Promise.all([entries.list({ limit: 8 }), entries.counts()]);++ return page(path, {++ index: true, title: null, description: HOME_DESCRIPTION,++ jsonLd: homeJsonLd(config),++ body: raw(views.home({ listings: list.listings, counts })),++ });++ }++++ async function listings(path, query) {++ const mode = typeof query.get('mode') === 'string' && ['free', 'swap'].includes(query.get('mode')) ? query.get('mode') : null;++ const kind = entries.KINDS.includes(query.get('kind')) ? query.get('kind') : null;++ const q = query.get('q') ? query.get('q').slice(0, 120) : '';++ const cursor = query.get('cursor') ? query.get('cursor') : null;++ const out = await entries.list({ limit: 24, mode, kind, q, cursor });++ return page(path, {++ index: true, title: 'All listings', description: LISTINGS_DESCRIPTION,++ body: raw(views.listingsPage({ listings: out.listings || [], nextCursor: out.next_cursor || null, filters: { q, mode: mode || '', kind: kind || '' } })),++ });++ }++++ async function listing(path, slug) {++ const detail = await entries.detail(String(slug || ''));++ if (!detail || detail.row.status !== 'listed') return null;++ const row = detail.row;++ const listing = detail.listing;++ const jsonLd = seo.jsonLd.webPage({++ name: listing.title,++ url: `${base()}/listings/${listing.slug}`,++ description: (listing.summary || `${listing.title} on OpenVibe.Exchange`).slice(0, 160),++ siteUrl: base(),++ });++ return page(path, {++ index: true,++ title: listing.title,++ description: (listing.summary || `${listing.title} on OpenVibe.Exchange`).slice(0, 160),++ jsonLd: [jsonLd],++ body: raw(views.listingPage({++ listing, tests: listing.tests && listing.tests.rows ? listing.tests.rows : [],++ mine: false, staff: false, signedIn: false, activeInstall: null, trialPassed: false,++ offers: [], received: false, problem: null, notice: null,++ })),++ });++ }++++ async function renderPublicPage(rawPath, { query } = {}) {++ const p = String(rawPath || '/').replace(/\/+$/, '') || '/';++ const q = query instanceof URLSearchParams ? query : new URLSearchParams();++ if (p === '/') return home('/');++ if (p === '/listings') return listings('/listings', q);++ const m = p.match(/^\/listings\/([A-Za-z0-9._~-]{1,80})$/);++ if (m) return listing(p, m[1]);++ if (p === '/safety') {++ return page(p, {++ index: true, title: 'Free and swap safety',++ description: 'OpenVibe.Exchange v1 has no money: free and swap only. Read the passport, meet safely, and revoke any time.',++ body: raw(views.safetyPage()),++ });++ }++ if (p === '/developers') {++ return page(p, {++ index: true, title: 'Build on OpenVibe.Exchange',++ description: 'The OpenVibe.Exchange JSON API, the permission passport, the sandbox, installs and the Search document.',++ body: raw(views.developersPage()),++ });++ }++ return null;++ }++++ return { renderPublicPage };++}++++module.exports = { createAgentPages };@@@ -152,6 +152,22 @@ async function createApp(opts = {}) {// ── Pages ───────────────────────────────────────────────app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.EXCHANGE_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));++ // ── Agent layer (openvibe-shared/agent-layer) ───────────++ // The manifest and the Markdown twins of the public pages. Gated to twin requests only, so a browser and a++ // page drawn for a signed-in person always reach the site's own routes; the twin renderer is always 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: require('./agent-pages').createAgentPages(ctx).renderPublicPage });++ app.use(agent);++ }app.use(createPageRoutes(ctx));app.use((req, res) => send(res, 404, {viewer: req.viewer, config, path: req.originalUrl, title: 'Not found',@@@ -52,7 +52,8 @@ function createDiscoveryRoutes(ctx) {r.get('/robots.txt', (_req, res) => {res.type('text/plain').set('Cache-Control', TEXT).send('# openvibe.exchange: public listings are for search and AI crawlers; sign-in, the API and private routes are not.\n'-- + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));++ + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW })++ + `# Agent manifest: ${abs('/.well-known/agent.json')}\n`);});r.get('/llms.txt', (_req, res) => {@@@ -44,7 +44,7 @@ function createPageRoutes(ctx) {r.get('/', async (req, res) => {const [list, counts] = await Promise.all([entries.list({ limit: 8 }), entries.counts()]);return page(req, res, {-- index: true, cache: PUBLIC_CACHE,++ index: true, cache: PUBLIC_CACHE, agentLayer: true,title: null,description: 'OpenVibe.Exchange: trade what people make. Free and swap listings for agents, MCP tools, templates, themes, game assets and creator services, each with a permission passport, a sandbox and one-tap revocation.',jsonLd: homeJsonLd(config),@@@ -62,7 +62,7 @@ function createPageRoutes(ctx) {cursor: typeof req.query.cursor === 'string' && req.query.cursor ? req.query.cursor : null,});return page(req, res, {-- index: true, cache: PUBLIC_CACHE,++ index: true, cache: PUBLIC_CACHE, agentLayer: true,title: 'All listings',description: 'Every public free and swap listing on OpenVibe.Exchange: agents, MCP tools, templates, themes, game assets and creator services, each with a permission passport.',body: raw(views.listingsPage({ listings: out.listings || [], nextCursor: out.next_cursor || null, filters: { q, mode: mode || '', kind: kind || '' } })),@@@ -95,6 +95,7 @@ function createPageRoutes(ctx) {});return page(req, res, {index: row.status === 'listed',++ agentLayer: row.status === 'listed',cache: row.status === 'listed' ? PUBLIC_CACHE : null,title: listing.title,description: (listing.summary || `${listing.title} on OpenVibe.Exchange`).slice(0, 160),@@@ -250,14 +251,14 @@ function createPageRoutes(ctx) {// ── Static pages ─────────────────────────────────────────r.get('/safety', (req, res) => page(req, res, {-- index: true, cache: PUBLIC_CACHE,++ index: true, cache: PUBLIC_CACHE, agentLayer: true,title: 'Free and swap safety',description: 'OpenVibe.Exchange v1 has no money: free and swap only. Read the passport, meet safely, and revoke any time.',body: raw(views.safetyPage()),}));r.get('/developers', (req, res) => page(req, res, {-- index: true, cache: PUBLIC_CACHE,++ index: true, cache: PUBLIC_CACHE, agentLayer: true,title: 'The OpenVibe.Exchange API',description: 'The JSON API, the permission passport, the sandbox, installs and the Search document.',jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Exchange API', url: `${base()}/developers`, description: 'The JSON API, the passport, the sandbox, installs and the Search document.', siteUrl: base() })],@@@ -75,6 +75,7 @@ function renderPage(o) {siteName: SITE_NAME,description: o.description || `${SITE_NAME} — ${TAGLINE}`,canonical,++ agentLayer: Boolean(o.agentLayer),robots: o.index ? 'index, follow' : 'noindex, nofollow',jsonLd: (o.jsonLd || []).filter(Boolean),home: '/',@@@ -113,6 +114,10 @@ function send(res, status, o) {const personal = o.viewer && o.viewer.kind === 'user';if (!res.get('Cache-Control')) res.set('Cache-Control', personal ? cache.htmlHeaders({ private: true }) : (o.cache || cache.htmlHeaders({ private: true })));res.set('Vary', 'Cookie');++ if (o.agentLayer) {++ const at = `${o.config.baseUrl}${String(o.path || '/').split('?')[0]}`;++ res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(at).header);++ }res.status(status).type('html').send(renderPage(o));}nnew file mode 100644@@@ -0,0 +1,68 @@++'use strict';++/**++ * The agent layer: /.well-known/agent.json, the home's discovery <link> and Link header, and Markdown twins of++ * the public pages rendered signed out. An unknown twin is a 404; the well-known prefix is not swallowed.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const fixtures = require('./helpers/fixtures');++++(async () => {++ const t = await boot();++ const alice = t.network.addUser('alice');++ const listing = await fixtures.listed(t, alice, { title: 'A twin-tested template', summary: 'A template with a Markdown twin.' });++ try {++ await check('manifest is JSON with only the endpoints Exchange 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, 'exchange');++ assert.strictEqual(m.home, 'https://openvibe.exchange');++ assert.strictEqual(m.api, 'https://openvibe.exchange/api/v1');++ assert.strictEqual(m.docs, 'https://openvibe.exchange/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.exchange/index.md">'));++ assert.ok(r.text.includes('<link rel="describedby" href="/llms.txt">'));++ assert.match(r.headers.get('link'), /https:\/\/openvibe\.exchange\/index\.md>; rel="alternate"; type="text\/markdown"/);++ assert.ok(r.text.includes('A twin-tested template'));++ });++++ await check('/index.md is Markdown of the same home, and its listings', async () => {++ const r = await t.get('/index.md');++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type'), /text\/markdown/);++ assert.ok(r.text.startsWith('---\ntitle:'));++ assert.ok(r.text.includes('# OpenVibe.Exchange: Trade what people make.'));++ assert.ok(r.text.includes('A twin-tested template'));++ });++++ await check('one listing twin is the public page, a signed-in-only page and an unknown .md are 404', async () => {++ const r = await t.get(`/listings/${listing.slug}.md`);++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type'), /text\/markdown/);++ assert.ok(r.text.includes('# A twin-tested template'), 'title heading');++ assert.ok(r.text.includes('CC0-1.0'), 'the licence from the same view');++ assert.ok(r.text.includes('A template with a Markdown twin.'), 'the summary');++ assert.ok(!r.text.includes('Run the sandbox'), 'a signed-out twin does not offer a sandbox run');++ assert.strictEqual((await t.get('/installs.md')).status, 404);++ assert.strictEqual((await t.get('/no-such-page.md')).status, 404);++ });++++ await check('/.well-known/security.txt is not swallowed by the layer', async () => {++ const r = await t.get('/.well-known/security.txt');++ assert.strictEqual(r.status, 404);++ assert.doesNotMatch(r.headers.get('content-type') || '', /application\/json/);++ });++ } finally {++ await t.close();++ done();++ }++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
+'use strict';
+// What openvibe.exchange tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.
+// Every field is something this site really serves: the JSON API under /api/v1, the developers page that
+// documents it, and the app's own per-address limits. No OpenAPI document or MCP endpoint is published.
+module.exports = {
+ id: 'exchange',
+ name: 'OpenVibe.Exchange',
+ purpose: 'Trade what people make: free and swap listings for agents, MCP tools, templates, themes, game assets and creator services, each with a permission passport, a sandbox and one-tap revocation.',
+ home: 'https://openvibe.exchange',
+ site: 'openvibe.exchange',
+ api: '/api/v1',
+ docs: '/developers',
+ policy: {
+ auth: ['none', 'Network OIDC'],
+ rateLimit: '300 page requests a minute per address; 240 API requests a minute; sign-in limited to 60 attempts per 15 minutes',
+ },
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
+'use strict';
+// The public pages the agent layer serves as Markdown twins (openvibe-shared/agent-layer). Each twin is the
+// page's own render through the same views and layout the HTML routes use, always signed out: a listing's
+// owner/staff actions, installs and offers never appear in a twin, and an unlisted listing has no twin at all.
+const seo = require('openvibe-shared/seo');
+const views = require('./views');
+const { raw } = require('./render/html');
+const { renderPage } = require('./render/layout');
+const { homeJsonLd } = require('./http/discovery');
+
+const HOME_DESCRIPTION = 'OpenVibe.Exchange: trade what people make. Free and swap listings for agents, MCP tools, templates, themes, game assets and creator services, each with a permission passport, a sandbox and one-tap revocation.';
+const LISTINGS_DESCRIPTION = 'Every public free and swap listing on OpenVibe.Exchange: agents, MCP tools, templates, themes, game assets and creator services, each with a permission passport.';
+
+function createAgentPages(ctx) {
+ const { config, entries } = ctx;
+ const base = () => String(config.baseUrl).replace(/\/+$/, '');
+ const page = (path, o) => ({ html: renderPage({ viewer: { kind: 'anonymous' }, config, path, agentLayer: true, ...o }), title: o.title });
+
+ async function home(path) {
+ const [list, counts] = await Promise.all([entries.list({ limit: 8 }), entries.counts()]);
+ return page(path, {
+ index: true, title: null, description: HOME_DESCRIPTION,
+ jsonLd: homeJsonLd(config),
+ body: raw(views.home({ listings: list.listings, counts })),
+ });
+ }
+
+ async function listings(path, query) {
+ const mode = typeof query.get('mode') === 'string' && ['free', 'swap'].includes(query.get('mode')) ? query.get('mode') : null;
+ const kind = entries.KINDS.includes(query.get('kind')) ? query.get('kind') : null;
+ const q = query.get('q') ? query.get('q').slice(0, 120) : '';
+ const cursor = query.get('cursor') ? query.get('cursor') : null;
+ const out = await entries.list({ limit: 24, mode, kind, q, cursor });
+ return page(path, {
+ index: true, title: 'All listings', description: LISTINGS_DESCRIPTION,
+ body: raw(views.listingsPage({ listings: out.listings || [], nextCursor: out.next_cursor || null, filters: { q, mode: mode || '', kind: kind || '' } })),
+ });
+ }
+
+ async function listing(path, slug) {
+ const detail = await entries.detail(String(slug || ''));
+ if (!detail || detail.row.status !== 'listed') return null;
+ const row = detail.row;
+ const listing = detail.listing;
+ const jsonLd = seo.jsonLd.webPage({
+ name: listing.title,
+ url: `${base()}/listings/${listing.slug}`,
+ description: (listing.summary || `${listing.title} on OpenVibe.Exchange`).slice(0, 160),
+ siteUrl: base(),
+ });
+ return page(path, {
+ index: true,
+ title: listing.title,
+ description: (listing.summary || `${listing.title} on OpenVibe.Exchange`).slice(0, 160),
+ jsonLd: [jsonLd],
+ body: raw(views.listingPage({
+ listing, tests: listing.tests && listing.tests.rows ? listing.tests.rows : [],
+ mine: false, staff: false, signedIn: false, activeInstall: null, trialPassed: false,
+ offers: [], received: false, problem: null, notice: null,
+ })),
+ });
+ }
+
+ async function renderPublicPage(rawPath, { query } = {}) {
+ const p = String(rawPath || '/').replace(/\/+$/, '') || '/';
+ const q = query instanceof URLSearchParams ? query : new URLSearchParams();
+ if (p === '/') return home('/');
+ if (p === '/listings') return listings('/listings', q);
+ const m = p.match(/^\/listings\/([A-Za-z0-9._~-]{1,80})$/);
+ if (m) return listing(p, m[1]);
+ if (p === '/safety') {
+ return page(p, {
+ index: true, title: 'Free and swap safety',
+ description: 'OpenVibe.Exchange v1 has no money: free and swap only. Read the passport, meet safely, and revoke any time.',
+ body: raw(views.safetyPage()),
+ });
+ }
+ if (p === '/developers') {
+ return page(p, {
+ index: true, title: 'Build on OpenVibe.Exchange',
+ description: 'The OpenVibe.Exchange JSON API, the permission passport, the sandbox, installs and the Search document.',
+ body: raw(views.developersPage()),
+ });
+ }
+ return null;
+ }
+
+ return { renderPublicPage };
+}
+
+module.exports = { createAgentPages };
@@ -152,6 +152,22 @@ async function createApp(opts = {}) {
@@ -152,6 +152,22 @@ async function createApp(opts = {}) {
// ── Pages ───────────────────────────────────────────────
// ── Pages ───────────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.EXCHANGE_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.EXCHANGE_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
+ // ── Agent layer (openvibe-shared/agent-layer) ───────────
+ // The manifest and the Markdown twins of the public pages. Gated to twin requests only, so a browser and a
+ // page drawn for a signed-in person always reach the site's own routes; the twin renderer is always 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: require('./agent-pages').createAgentPages(ctx).renderPublicPage });
+ app.use(agent);
+ }
app.use(createPageRoutes(ctx));
app.use(createPageRoutes(ctx));
app.use((req, res) => send(res, 404, {
app.use((req, res) => send(res, 404, {
viewer: req.viewer, config, path: req.originalUrl, title: 'Not found',
viewer: req.viewer, config, path: req.originalUrl, title: 'Not found',
@@ -52,7 +52,8 @@ function createDiscoveryRoutes(ctx) {
@@ -52,7 +52,8 @@ function createDiscoveryRoutes(ctx) {
r.get('/robots.txt', (_req, res) => {
r.get('/robots.txt', (_req, res) => {
res.type('text/plain').set('Cache-Control', TEXT).send(
res.type('text/plain').set('Cache-Control', TEXT).send(
'# openvibe.exchange: public listings are for search and AI crawlers; sign-in, the API and private routes are not.\n'
'# openvibe.exchange: public listings are for search and AI crawlers; sign-in, the API and private routes are not.\n'
- + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
+ + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW })
+ + `# Agent manifest: ${abs('/.well-known/agent.json')}\n`);
});
});
r.get('/llms.txt', (_req, res) => {
r.get('/llms.txt', (_req, res) => {
@@ -44,7 +44,7 @@ function createPageRoutes(ctx) {
@@ -44,7 +44,7 @@ function createPageRoutes(ctx) {
r.get('/', async (req, res) => {
r.get('/', async (req, res) => {
const [list, counts] = await Promise.all([entries.list({ limit: 8 }), entries.counts()]);
const [list, counts] = await Promise.all([entries.list({ limit: 8 }), entries.counts()]);
return page(req, res, {
return page(req, res, {
- index: true, cache: PUBLIC_CACHE,
+ index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: null,
title: null,
description: 'OpenVibe.Exchange: trade what people make. Free and swap listings for agents, MCP tools, templates, themes, game assets and creator services, each with a permission passport, a sandbox and one-tap revocation.',
description: 'OpenVibe.Exchange: trade what people make. Free and swap listings for agents, MCP tools, templates, themes, game assets and creator services, each with a permission passport, a sandbox and one-tap revocation.',
jsonLd: homeJsonLd(config),
jsonLd: homeJsonLd(config),
@@ -62,7 +62,7 @@ function createPageRoutes(ctx) {
@@ -62,7 +62,7 @@ function createPageRoutes(ctx) {
cursor: typeof req.query.cursor === 'string' && req.query.cursor ? req.query.cursor : null,
cursor: typeof req.query.cursor === 'string' && req.query.cursor ? req.query.cursor : null,
});
});
return page(req, res, {
return page(req, res, {
- index: true, cache: PUBLIC_CACHE,
+ index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: 'All listings',
title: 'All listings',
description: 'Every public free and swap listing on OpenVibe.Exchange: agents, MCP tools, templates, themes, game assets and creator services, each with a permission passport.',
description: 'Every public free and swap listing on OpenVibe.Exchange: agents, MCP tools, templates, themes, game assets and creator services, each with a permission passport.',
body: raw(views.listingsPage({ listings: out.listings || [], nextCursor: out.next_cursor || null, filters: { q, mode: mode || '', kind: kind || '' } })),
body: raw(views.listingsPage({ listings: out.listings || [], nextCursor: out.next_cursor || null, filters: { q, mode: mode || '', kind: kind || '' } })),
@@ -95,6 +95,7 @@ function createPageRoutes(ctx) {
@@ -95,6 +95,7 @@ function createPageRoutes(ctx) {
});
});
return page(req, res, {
return page(req, res, {
index: row.status === 'listed',
index: row.status === 'listed',
+ agentLayer: row.status === 'listed',
cache: row.status === 'listed' ? PUBLIC_CACHE : null,
cache: row.status === 'listed' ? PUBLIC_CACHE : null,
title: listing.title,
title: listing.title,
description: (listing.summary || `${listing.title} on OpenVibe.Exchange`).slice(0, 160),
description: (listing.summary || `${listing.title} on OpenVibe.Exchange`).slice(0, 160),
@@ -250,14 +251,14 @@ function createPageRoutes(ctx) {
@@ -250,14 +251,14 @@ function createPageRoutes(ctx) {
// ── Static pages ─────────────────────────────────────────
// ── Static pages ─────────────────────────────────────────
r.get('/safety', (req, res) => page(req, res, {
r.get('/safety', (req, res) => page(req, res, {
- index: true, cache: PUBLIC_CACHE,
+ index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: 'Free and swap safety',
title: 'Free and swap safety',
description: 'OpenVibe.Exchange v1 has no money: free and swap only. Read the passport, meet safely, and revoke any time.',
description: 'OpenVibe.Exchange v1 has no money: free and swap only. Read the passport, meet safely, and revoke any time.',
body: raw(views.safetyPage()),
body: raw(views.safetyPage()),
}));
}));
r.get('/developers', (req, res) => page(req, res, {
r.get('/developers', (req, res) => page(req, res, {
- index: true, cache: PUBLIC_CACHE,
+ index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: 'The OpenVibe.Exchange API',
title: 'The OpenVibe.Exchange API',
description: 'The JSON API, the permission passport, the sandbox, installs and the Search document.',
description: 'The JSON API, the permission passport, the sandbox, installs and the Search document.',
jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Exchange API', url: `${base()}/developers`, description: 'The JSON API, the passport, the sandbox, installs and the Search document.', siteUrl: base() })],
jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Exchange API', url: `${base()}/developers`, description: 'The JSON API, the passport, the sandbox, installs and the Search document.', siteUrl: base() })],
@@ -75,6 +75,7 @@ function renderPage(o) {
@@ -75,6 +75,7 @@ function renderPage(o) {
siteName: SITE_NAME,
siteName: SITE_NAME,
description: o.description || `${SITE_NAME} — ${TAGLINE}`,
description: o.description || `${SITE_NAME} — ${TAGLINE}`,
canonical,
canonical,
+ agentLayer: Boolean(o.agentLayer),
robots: o.index ? 'index, follow' : 'noindex, nofollow',
robots: o.index ? 'index, follow' : 'noindex, nofollow',
jsonLd: (o.jsonLd || []).filter(Boolean),
jsonLd: (o.jsonLd || []).filter(Boolean),
home: '/',
home: '/',
@@ -113,6 +114,10 @@ function send(res, status, o) {
@@ -113,6 +114,10 @@ function send(res, status, o) {
const personal = o.viewer && o.viewer.kind === 'user';
const personal = o.viewer && o.viewer.kind === 'user';
if (!res.get('Cache-Control')) res.set('Cache-Control', personal ? cache.htmlHeaders({ private: true }) : (o.cache || cache.htmlHeaders({ private: true })));
if (!res.get('Cache-Control')) res.set('Cache-Control', personal ? cache.htmlHeaders({ private: true }) : (o.cache || cache.htmlHeaders({ private: true })));
res.set('Vary', 'Cookie');
res.set('Vary', 'Cookie');
+ if (o.agentLayer) {
+ const at = `${o.config.baseUrl}${String(o.path || '/').split('?')[0]}`;
+ res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(at).header);
+ }
res.status(status).type('html').send(renderPage(o));
res.status(status).type('html').send(renderPage(o));
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+'use strict';
+/**
+ * The agent layer: /.well-known/agent.json, the home's discovery <link> and Link header, and Markdown twins of
+ * the public pages rendered signed out. An unknown twin is a 404; the well-known prefix is not swallowed.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const fixtures = require('./helpers/fixtures');
+
+(async () => {
+ const t = await boot();
+ const alice = t.network.addUser('alice');
+ const listing = await fixtures.listed(t, alice, { title: 'A twin-tested template', summary: 'A template with a Markdown twin.' });
+ try {
+ await check('manifest is JSON with only the endpoints Exchange 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, 'exchange');
+ assert.strictEqual(m.home, 'https://openvibe.exchange');
+ assert.strictEqual(m.api, 'https://openvibe.exchange/api/v1');
+ assert.strictEqual(m.docs, 'https://openvibe.exchange/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.exchange/index.md">'));
+ assert.ok(r.text.includes('<link rel="describedby" href="/llms.txt">'));
+ assert.match(r.headers.get('link'), /https:\/\/openvibe\.exchange\/index\.md>; rel="alternate"; type="text\/markdown"/);
+ assert.ok(r.text.includes('A twin-tested template'));
+ });
+
+ await check('/index.md is Markdown of the same home, and its listings', async () => {
+ const r = await t.get('/index.md');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type'), /text\/markdown/);
+ assert.ok(r.text.startsWith('---\ntitle:'));
+ assert.ok(r.text.includes('# OpenVibe.Exchange: Trade what people make.'));
+ assert.ok(r.text.includes('A twin-tested template'));
+ });
+
+ await check('one listing twin is the public page, a signed-in-only page and an unknown .md are 404', async () => {
+ const r = await t.get(`/listings/${listing.slug}.md`);
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type'), /text\/markdown/);
+ assert.ok(r.text.includes('# A twin-tested template'), 'title heading');
+ assert.ok(r.text.includes('CC0-1.0'), 'the licence from the same view');
+ assert.ok(r.text.includes('A template with a Markdown twin.'), 'the summary');
+ assert.ok(!r.text.includes('Run the sandbox'), 'a signed-out twin does not offer a sandbox run');
+ assert.strictEqual((await t.get('/installs.md')).status, 404);
+ assert.strictEqual((await t.get('/no-such-page.md')).status, 404);
+ });
+
+ await check('/.well-known/security.txt is not swallowed by the layer', async () => {
+ const r = await t.get('/.well-known/security.txt');
+ assert.strictEqual(r.status, 404);
+ assert.doesNotMatch(r.headers.get('content-type') || '', /application\/json/);
+ });
+ } finally {
+ await t.close();
+ done();
+ }
+})();