openvibers/openvibe.exchange Commits

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

OP OpenVibers committed 11 days ago · 830c3eb59b · 7 files, +204 −5

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

openvibe-shared v4.34.0 (was v4.31.0). The descriptor is what openvibe.exchange really serves: API /api/v1, docs
/developers, and the app's own limits; no OpenAPI document or MCP endpoint is published. server/agent-pages.js
twins the home, /listings, /listings/:slug, /safety and /developers through the same views and layout the HTML
routes use, always signed out; an unlisted listing, installs, offers and the sandbox have no twin. The mount is
gated to twin requests only, so a browser and a page drawn for a signed-in person still get the site's own route.
HTML pages name their twin in the head and the Link header; robots.txt names the manifest.

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

# Conflicts:
#	README.md
#	STATUS.json
#	package-lock.json
#	package.json

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

Changes

ddiff --git a/server/agent-descriptor.js b/server/agent-descriptor.js
nnew file mode 100644
iindex 0000000..1e6ee57
---- /dev/null
++++ b/server/agent-descriptor.js
@@@ -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',
++ },
++};
ddiff --git a/server/agent-pages.js b/server/agent-pages.js
nnew file mode 100644
iindex 0000000..215259d
---- /dev/null
++++ b/server/agent-pages.js
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
iindex 641c81b..e6a67fe 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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',
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 2073ba7..24c8aef 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -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) => {
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex f3fa92d..4706fca 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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() })],
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex c5a7255..71fe995 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -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));
}
ddiff --git a/test/agent-layer.test.js b/test/agent-layer.test.js
nnew file mode 100644
iindex 0000000..3f2705c
---- /dev/null
++++ b/test/agent-layer.test.js
@@@ -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();
++ }
++})();