openvibers/openvibe.fun Commits

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

OP OpenVibers committed 11 days ago · 411a6ef9df · 7 files, +234 −9

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

openvibe-shared v4.34.0 (was v4.31.0). The descriptor is what openvibe.fun 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, guide, quests, plans, developers, public quest and plan pages and quest boards through the same
views and layout the HTML routes use, always signed out; a non-public quest or plan, the streamer dashboard and
every signed-in form 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-2

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

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

Changes

ddiff --git a/server/agent-descriptor.js b/server/agent-descriptor.js
nnew file mode 100644
iindex 0000000..d137ecf
---- /dev/null
++++ b/server/agent-descriptor.js
@@@ -0,0 +1,17 @@
++'use strict';
++// What openvibe.fun 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: 'fun',
++ name: 'OpenVibe.Fun',
++ purpose: 'Something to do, alone, together, or live: IRL quests on OpenVibe.Live, a local guide from OpenStreetMap, and group plans that the group votes on.',
++ home: 'https://openvibe.fun',
++ site: 'openvibe.fun',
++ 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..50477bc
---- /dev/null
++++ b/server/agent-pages.js
@@@ -0,0 +1,132 @@
++'use strict';
++// The public pages the agent layer serves as Markdown twins (openvibe-shared/agent-layer), rendered through the
++// same views and layout as the HTML routes and always signed out: a board's streamer forms, a quest's vote/report
++// actions and a plan's options are never in a twin, and a non-public quest or plan has no twin at all.
++const seo = require('openvibe-shared/seo');
++const views = require('./views');
++const ideas = require('./quests/ideas');
++const { raw } = require('./render/html');
++const { renderPage } = require('./render/layout');
++const { homeJsonLd } = require('./http/discovery');
++
++function createAgentPages(ctx) {
++ const { config, store, upstreams, guide } = 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 liveOf(username) {
++ try { return await upstreams.liveChannel(username); } catch { return null; }
++ }
++
++ async function home() {
++ const [counts, featured, planOut] = await Promise.all([store.counts(), store.featuredQuests(6), store.listPlans({ limit: 4 })]);
++ return page('/', {
++ index: true, title: null,
++ description: 'Something to do, alone, together, or live: IRL quests on OpenVibe.Live, a local guide from OpenStreetMap, and group plans that the group votes on.',
++ jsonLd: homeJsonLd(config),
++ body: raw(views.home({ counts, featured, plans: planOut.plans })),
++ });
++ }
++
++ async function guidePage(query) {
++ const values = {
++ q: typeof query.get('q') === 'string' ? query.get('q') : '',
++ kind: typeof query.get('kind') === 'string' && query.get('kind') ? query.get('kind') : 'all',
++ radius: typeof query.get('radius') === 'string' ? query.get('radius') : '',
++ accessible: query.get('accessible') === '1', rainy: query.get('rainy') === '1',
++ kids: query.get('kids') === '1', cheap: query.get('cheap') === '1',
++ };
++ let result = null; let problem = null;
++ if (values.q.trim()) {
++ try {
++ result = await guide.search({
++ q: values.q, kind: values.kind, radiusKm: values.radius ? Number(values.radius) : null,
++ accessible: values.accessible, rainy: values.rainy, kids: values.kids, cheap: values.cheap,
++ });
++ } catch (err) {
++ problem = (err && err.message) || 'The guide could not answer just now.';
++ }
++ }
++ return page('/guide', {
++ index: true, title: 'The local guide',
++ description: 'What\'s fun near me? Places from OpenStreetMap and local events from Ticketmaster when a key is configured, with a map, times and links.',
++ jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Fun local guide', url: `${base()}/guide`, description: 'Places from OpenStreetMap, local events when Ticketmaster is configured.', siteUrl: base() })],
++ body: raw(views.guide({ values, result, error: problem })),
++ });
++ }
++
++ async function renderPublicPage(rawPath, { query } = {}) {
++ const p = String(rawPath || '/').replace(/\/+$/, '') || '/';
++ const q = query instanceof URLSearchParams ? query : new URLSearchParams();
++ if (p === '/') return home();
++ if (p === '/guide') return guidePage(q);
++ if (p === '/quests') {
++ const out = await store.listQuests({ limit: 30 });
++ return page(p, {
++ index: true, title: 'IRL quests on Live',
++ description: 'Viewers suggest, chat votes, the streamer accepts and a moderator or the chat verifies. Safe, accessible challenges for the real world.',
++ jsonLd: [seo.jsonLd.webPage({ name: 'IRL quests on OpenVibe.Live', url: `${base()}/quests`, description: 'Safe IRL challenges for streamers and their chat.', siteUrl: base() })],
++ body: raw(views.quests({ quests: out.quests, ideas: ideas.IDEAS })),
++ });
++ }
++ if (p === '/plans') {
++ const out = await store.listPlans({ limit: 30 });
++ return page(p, {
++ index: true, title: 'Group plans',
++ description: 'Two or three options, one link, one vote each. The group picks the plan and the winner can go to Today.',
++ jsonLd: [seo.jsonLd.webPage({ name: 'Group plans on OpenVibe.Fun', url: `${base()}/plans`, description: 'Decide a plan together and send the winner to Today.', siteUrl: base() })],
++ body: raw(views.plans({ plans: out.plans })),
++ });
++ }
++ if (p === '/developers') {
++ return page(p, {
++ index: true, title: 'The OpenVibe.Fun API',
++ description: 'The JSON API, the Search documents, the public sources and the safety rules.',
++ jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Fun API', url: `${base()}/developers`, description: 'The JSON API, the Search documents and the public sources.', siteUrl: base() })],
++ body: raw(views.developers()),
++ });
++ }
++ const boardMatch = p.match(/^\/quests\/boards\/([^/]+)$/);
++ if (boardMatch) {
++ let id;
++ try { id = decodeURIComponent(boardMatch[1]); } catch { return null; }
++ const out = await store.boardPage(id);
++ if (!out) return null;
++ const live = await liveOf(out.board.live_username);
++ return page(p, {
++ index: false, title: out.board.title,
++ body: raw(views.board({ board: out.board, quests: out.quests, live, signedIn: false, mine: false, staff: false, flash: null })),
++ });
++ }
++ const questMatch = p.match(/^\/q\/([^/]+)$/);
++ if (questMatch) {
++ let slug;
++ try { slug = decodeURIComponent(questMatch[1]); } catch { return null; }
++ const out = await store.questForPage(slug, { subject: null, staff: false });
++ if (!out || out.quest.visibility !== 'public') return null;
++ const live = await liveOf(out.quest.streamer_username);
++ return page(p, {
++ index: true, title: out.quest.title, description: out.quest.brief.slice(0, 160),
++ jsonLd: [seo.jsonLd.webPage({ name: out.quest.title, url: `${base()}/q/${out.quest.slug}`, description: out.quest.brief.slice(0, 160), siteUrl: base() })],
++ body: raw(views.quest({ q: out.quest, board: out.board, live, signedIn: false, voted: false, mine: false, staff: false, flash: null, problem: null, verifyVotes: config.quests.verifyVotes })),
++ });
++ }
++ const planMatch = p.match(/^\/plans\/([^/]+)$/);
++ if (planMatch) {
++ let slug;
++ try { slug = decodeURIComponent(planMatch[1]); } catch { return null; }
++ const out = await store.planForPage(slug, { subject: null, staff: false });
++ if (!out || out.plan.visibility !== 'public') return null;
++ return page(p, {
++ index: true, title: out.plan.title, description: (out.plan.summary || 'A group plan on OpenVibe.Fun').slice(0, 160),
++ jsonLd: [seo.jsonLd.webPage({ name: out.plan.title, url: `${base()}/plans/${out.plan.slug}`, description: (out.plan.summary || 'A group plan on OpenVibe.Fun').slice(0, 160), siteUrl: base() })],
++ body: raw(views.plan({ p: out.plan, options: out.options, winner: out.winner, signedIn: false, mine: false, staff: false, flash: null, problem: null, myOptionId: out.plan.my_option_id || null, totalVotes: out.plan.total_votes })),
++ });
++ }
++ return null;
++ }
++
++ return { renderPublicPage };
++}
++
++module.exports = { createAgentPages };
ddiff --git a/server/app.js b/server/app.js
iindex 73a2293..b2a43ea 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -161,6 +161,22 @@ async function createApp(opts = {}) {
// ── Pages ───────────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.FUN_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 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 ff7df63..6608f1f 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -54,7 +54,8 @@ function createDiscoveryRoutes(ctx) {
res.type('text/plain').set('Cache-Control', TEXT).send(
'# openvibe.fun: the public quests, plans and guide are for search engines; sign-in, the API, boards,\n'
+ '# private plans and the overlay 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 edbf31b..b36ba08 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -59,7 +59,7 @@ function createPageRoutes(ctx) {
r.get('/', async (req, res) => {
const [counts, featured, planOut] = await Promise.all([store.counts(), store.featuredQuests(6), store.listPlans({ limit: 4 })]);
return page(req, res, {
-- index: true, cache: PUBLIC_CACHE,
++ index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: null,
description: 'Something to do, alone, together, or live: IRL quests on OpenVibe.Live, a local guide from OpenStreetMap, and group plans that the group votes on.',
jsonLd: homeJsonLd(config),
@@@ -89,7 +89,7 @@ function createPageRoutes(ctx) {
}
}
return page(req, res, {
-- index: true, cache: result ? PUBLIC_CACHE : null,
++ index: true, cache: result ? PUBLIC_CACHE : null, agentLayer: true,
title: 'The local guide',
description: 'What\'s fun near me? Places from OpenStreetMap and local events from Ticketmaster when a key is configured, with a map, times and links.',
jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Fun local guide', url: `${base()}/guide`, description: 'Places from OpenStreetMap, local events when Ticketmaster is configured.', siteUrl: base() })],
@@@ -101,7 +101,7 @@ function createPageRoutes(ctx) {
r.get('/quests', async (req, res) => {
const out = await store.listQuests({ limit: 30 });
return page(req, res, {
-- index: true, cache: PUBLIC_CACHE,
++ index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: 'IRL quests on Live',
description: 'Viewers suggest, chat votes, the streamer accepts and a moderator or the chat verifies. Safe, accessible challenges for the real world.',
jsonLd: [seo.jsonLd.webPage({ name: 'IRL quests on OpenVibe.Live', url: `${base()}/quests`, description: 'Safe IRL challenges for streamers and their chat.', siteUrl: base() })],
@@@ -144,7 +144,7 @@ function createPageRoutes(ctx) {
const mine = Boolean(signedIn(req) && rawBoard && rawBoard.streamer_subject === req.viewer.subject);
const live = await liveOf(out.board.live_username);
return page(req, res, {
-- index: false, title: out.board.title,
++ index: false, agentLayer: true, title: out.board.title,
body: raw(views.board({ board: out.board, quests: out.quests, live, signedIn: Boolean(signedIn(req)), mine, staff: staffViewer(req), flash: req.query.saved === '1' ? 'Saved.' : null })),
});
});
@@@ -186,7 +186,7 @@ function createPageRoutes(ctx) {
if (!out) return notFound(req, res);
const live = await liveOf(out.quest.streamer_username);
return page(req, res, {
-- index: out.quest.visibility === 'public', cache: out.quest.visibility === 'public' ? PUBLIC_CACHE : null,
++ index: out.quest.visibility === 'public', agentLayer: out.quest.visibility === 'public', cache: out.quest.visibility === 'public' ? PUBLIC_CACHE : null,
title: out.quest.title,
description: out.quest.brief.slice(0, 160),
jsonLd: out.quest.visibility === 'public' ? [seo.jsonLd.webPage({ name: out.quest.title, url: `${base()}/q/${out.quest.slug}`, description: out.quest.brief.slice(0, 160), siteUrl: base() })] : [],
@@@ -252,7 +252,7 @@ function createPageRoutes(ctx) {
r.get('/plans', async (req, res) => {
const out = await store.listPlans({ limit: 30 });
return page(req, res, {
-- index: true, cache: PUBLIC_CACHE,
++ index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: 'Group plans',
description: 'Two or three options, one link, one vote each. The group picks the plan and the winner can go to Today.',
jsonLd: [seo.jsonLd.webPage({ name: 'Group plans on OpenVibe.Fun', url: `${base()}/plans`, description: 'Decide a plan together and send the winner to Today.', siteUrl: base() })],
@@@ -285,7 +285,7 @@ function createPageRoutes(ctx) {
const out = await store.planForPage(String(req.params.slug || ''), { subject: signedIn(req) ? req.viewer.subject : null, staff: staffViewer(req) });
if (!out) return notFound(req, res);
return page(req, res, {
-- index: out.plan.visibility === 'public', cache: out.plan.visibility === 'public' ? PUBLIC_CACHE : null,
++ index: out.plan.visibility === 'public', agentLayer: out.plan.visibility === 'public', cache: out.plan.visibility === 'public' ? PUBLIC_CACHE : null,
title: out.plan.title,
description: (out.plan.summary || 'A group plan on OpenVibe.Fun').slice(0, 160),
jsonLd: out.plan.visibility === 'public' ? [seo.jsonLd.webPage({ name: out.plan.title, url: `${base()}/plans/${out.plan.slug}`, description: (out.plan.summary || 'A group plan on OpenVibe.Fun').slice(0, 160), siteUrl: base() })] : [],
@@@ -360,7 +360,7 @@ function createPageRoutes(ctx) {
// ── Developers, discovery, 404 ──────────────────────────
r.get('/developers', (req, res) => page(req, res, {
-- index: true, cache: PUBLIC_CACHE,
++ index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: 'The OpenVibe.Fun API',
description: 'The JSON API, the Search documents, the public sources and the safety rules.',
jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Fun API', url: `${base()}/developers`, description: 'The JSON API, the Search documents and the public sources.', siteUrl: base() })],
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 5ca0772..91f3055 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -76,6 +76,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: '/',
@@@ -114,6 +115,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..2b302c6
---- /dev/null
++++ b/test/agent-layer.test.js
@@@ -0,0 +1,54 @@
++'use strict';
++/** The agent layer: the manifest and the Markdown twins of the public pages, rendered through the same views. */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('manifest is JSON with only the endpoints Fun 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, 'fun');
++ assert.strictEqual(m.home, 'https://openvibe.fun');
++ assert.strictEqual(m.api, 'https://openvibe.fun/api/v1');
++ assert.strictEqual(m.docs, 'https://openvibe.fun/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.fun/index.md">'));
++ assert.match(r.headers.get('link'), /https:\/\/openvibe\.fun\/index\.md>; rel="alternate"; type="text\/markdown"/);
++ });
++
++ await check('/index.md and a content twin 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:'));
++ const dev = await t.get('/developers.md');
++ assert.strictEqual(dev.status, 200);
++ assert.ok(dev.text.includes('# The OpenVibe.Fun API'));
++ assert.strictEqual((await t.get('/no-such-page.md')).status, 404);
++ assert.strictEqual((await t.get('/live.md')).status, 404, 'the streamer dashboard has no twin');
++ });
++
++ 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.fun/.well-known/agent.json'));
++ });
++ } finally {
++ await t.close();
++ done();
++ }
++})();