openvibers/openvibe.fun Commits
Agent layer: /.well-known/agent.json and Markdown twins of the public pages (#2)
Changes
nnew file mode 100644@@@ -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',++ },++};nnew file mode 100644@@@ -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 };@@@ -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',@@@ -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) => {@@@ -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() })],@@@ -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));}nnew file mode 100644@@@ -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();++ }++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -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',
+ },
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,132 @@
@@ -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 };
@@ -161,6 +161,22 @@ async function createApp(opts = {}) {
@@ -161,6 +161,22 @@ async function createApp(opts = {}) {
// ── Pages ───────────────────────────────────────────────
// ── Pages ───────────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.FUN_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
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(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',
@@ -54,7 +54,8 @@ function createDiscoveryRoutes(ctx) {
@@ -54,7 +54,8 @@ function createDiscoveryRoutes(ctx) {
res.type('text/plain').set('Cache-Control', TEXT).send(
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'
'# 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'
+ '# 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) => {
r.get('/llms.txt', (_req, res) => {
@@ -59,7 +59,7 @@ function createPageRoutes(ctx) {
@@ -59,7 +59,7 @@ function createPageRoutes(ctx) {
r.get('/', async (req, res) => {
r.get('/', async (req, res) => {
const [counts, featured, planOut] = await Promise.all([store.counts(), store.featuredQuests(6), store.listPlans({ limit: 4 })]);
const [counts, featured, planOut] = await Promise.all([store.counts(), store.featuredQuests(6), store.listPlans({ limit: 4 })]);
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: '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.',
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),
jsonLd: homeJsonLd(config),
@@ -89,7 +89,7 @@ function createPageRoutes(ctx) {
@@ -89,7 +89,7 @@ function createPageRoutes(ctx) {
}
}
}
}
return page(req, res, {
return page(req, res, {
- index: true, cache: result ? PUBLIC_CACHE : null,
+ index: true, cache: result ? PUBLIC_CACHE : null, agentLayer: true,
title: 'The local guide',
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.',
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() })],
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) {
@@ -101,7 +101,7 @@ function createPageRoutes(ctx) {
r.get('/quests', async (req, res) => {
r.get('/quests', async (req, res) => {
const out = await store.listQuests({ limit: 30 });
const out = await store.listQuests({ limit: 30 });
return page(req, res, {
return page(req, res, {
- index: true, cache: PUBLIC_CACHE,
+ index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: 'IRL quests on Live',
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.',
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() })],
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) {
@@ -144,7 +144,7 @@ function createPageRoutes(ctx) {
const mine = Boolean(signedIn(req) && rawBoard && rawBoard.streamer_subject === req.viewer.subject);
const mine = Boolean(signedIn(req) && rawBoard && rawBoard.streamer_subject === req.viewer.subject);
const live = await liveOf(out.board.live_username);
const live = await liveOf(out.board.live_username);
return page(req, res, {
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 })),
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) {
@@ -186,7 +186,7 @@ function createPageRoutes(ctx) {
if (!out) return notFound(req, res);
if (!out) return notFound(req, res);
const live = await liveOf(out.quest.streamer_username);
const live = await liveOf(out.quest.streamer_username);
return page(req, res, {
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,
title: out.quest.title,
description: out.quest.brief.slice(0, 160),
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() })] : [],
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) {
@@ -252,7 +252,7 @@ function createPageRoutes(ctx) {
r.get('/plans', async (req, res) => {
r.get('/plans', async (req, res) => {
const out = await store.listPlans({ limit: 30 });
const out = await store.listPlans({ limit: 30 });
return page(req, res, {
return page(req, res, {
- index: true, cache: PUBLIC_CACHE,
+ index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: 'Group plans',
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.',
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() })],
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) {
@@ -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) });
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);
if (!out) return notFound(req, res);
return page(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,
title: out.plan.title,
description: (out.plan.summary || 'A group plan on OpenVibe.Fun').slice(0, 160),
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() })] : [],
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) {
@@ -360,7 +360,7 @@ function createPageRoutes(ctx) {
// ── Developers, discovery, 404 ──────────────────────────
// ── Developers, discovery, 404 ──────────────────────────
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.Fun API',
title: 'The OpenVibe.Fun API',
description: 'The JSON API, the Search documents, the public sources and the safety rules.',
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() })],
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() })],
@@ -76,6 +76,7 @@ function renderPage(o) {
@@ -76,6 +76,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: '/',
@@ -114,6 +115,10 @@ function send(res, status, o) {
@@ -114,6 +115,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,54 @@
@@ -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();
+ }
+})();