openvibers/openvibe.quest Commits

Quest: the agent layer (the manifest and the Markdown twins of the public pages) (#126)

OP OpenVibers committed 15 days ago · 51e1479155 · 6 files, +129 −2

Changes

ddiff --git a/deploy/nginx/openvibe.quest.conf b/deploy/nginx/openvibe.quest.conf
iindex b878965..a22a4d9 100644
---- a/deploy/nginx/openvibe.quest.conf
++++ b/deploy/nginx/openvibe.quest.conf
@@@ -111,6 +111,18 @@ server {
proxy_pass http://127.0.0.1:4980;
}
++ # ── Agent layer: the site manifest and the Markdown twins of the public pages ──
++ # GET only. The manifest is matched exactly so the host's /.well-known/ snippet (the file that serves
++ # security.txt) cannot swallow it; the twins are listed so they reach the app explicitly.
++ location = /.well-known/agent.json {
++ limit_except GET { deny all; }
++ proxy_pass http://127.0.0.1:4980;
++ }
++ location ~ "^/(index|updates|how-it-works|quests/[^/]+|series/[^/]+|apps/quests/[^/]+)\.md$" {
++ limit_except GET { deny all; }
++ proxy_pass http://127.0.0.1:4980;
++ }
++
location ~ /\.(?!well-known) {
deny all;
}
ddiff --git a/server/agent-descriptor.js b/server/agent-descriptor.js
nnew file mode 100644
iindex 0000000..8129ea5
---- /dev/null
++++ b/server/agent-descriptor.js
@@@ -0,0 +1,13 @@
++'use strict';
++// The agent manifest of OpenVibe.Quest: what the site is, where its API is, and how it is reached.
++// openvibe-shared/agent-layer turns this into /.well-known/agent.json.
++module.exports = {
++ id: 'quest', name: 'OpenVibe.Quest',
++ purpose: 'One quest log across the whole OpenVibe network: finish quests by streaming, posting and playing, and earn badges, items and OpenCoins.',
++ home: 'https://openvibe.quest', api: '/api/v1',
++ docs: 'https://openvibe.quest/how-it-works',
++ policy: {
++ auth: ['none', 'bearer', 'Network OIDC'],
++ rateLimit: 'The API is rate limited per caller; the limits are per address and per account.',
++ },
++};
ddiff --git a/server/agent-pages.js b/server/agent-pages.js
nnew file mode 100644
iindex 0000000..17b639f
---- /dev/null
++++ b/server/agent-pages.js
@@@ -0,0 +1,75 @@
++'use strict';
++// The Markdown twins of the public pages (openvibe-shared/agent-layer).
++//
++// A twin is the page's own render, signed out: the site's own router answers the request in-process,
++// without a session or a cookie, so a twin cannot drift from the page a browser gets and never carries a
++// signed-in person's view. A page that is not public (a person's own page, a missing page) answers 404
++// and has no twin.
++const TWIN_PATH_RE = /^\/(?:how-it-works|updates|quests\/[^/]+|series\/[^/]+|apps\/quests\/[^/]+)?$/;
++
++/** A request as an anonymous browser makes it: no session, no cookies. */
++function anonymousRequest(path, query, host) {
++ const url = path;
++ return {
++ method: 'GET', url, originalUrl: url, path, params: {}, query: query || {},
++ headers: { accept: 'text/html', host }, cookies: {}, body: undefined, protocol: 'https', secure: true,
++ ip: '127.0.0.1', ips: ['127.0.0.1'], hostname: host,
++ get(name) { return this.headers[String(name || '').toLowerCase()] || ''; },
++ accepts(types) {
++ if (!Array.isArray(types)) return 'text/html';
++ return types.some((t) => t === 'html' || t === 'text/html') ? 'html' : false;
++ },
++ is() { return false; },
++ };
++}
++
++/** A response that keeps what the page wrote, so the twin can be built from it. */
++function captureResponse() {
++ return {
++ statusCode: 200, headers: {}, body: null, headersSent: false,
++ status(code) { this.statusCode = code; return this; },
++ set(name, value) { this.headers[String(name)] = value; return this; },
++ setHeader(name, value) { this.headers[String(name)] = value; return this; },
++ getHeader(name) { return this.headers[String(name)]; },
++ get(name) { return this.headers[String(name)]; },
++ removeHeader(name) { delete this.headers[String(name)]; },
++ type(value) { this.headers['Content-Type'] = value === 'html' ? 'text/html; charset=utf-8' : String(value); return this; },
++ location(url) { this.headers.Location = url; return this; },
++ send(body) { this.body = body; this.headersSent = true; return this; },
++ json(body) { this.body = JSON.stringify(body); this.headersSent = true; return this; },
++ redirect(code, url) { this.statusCode = code; this.headers.Location = url; this.body = ''; this.headersSent = true; return this; },
++ end(body) { if (body !== undefined) this.body = body; this.headersSent = true; return this; },
++ cookie() { return this; }, clearCookie() { return this; }, vary() { return this; },
++ };
++}
++
++function createAgentPages({ router, host }) {
++ async function renderPublicPage(path, { query } = {}) {
++ const p = String(path || '/').replace(/\/+$/, '') || '/';
++ if (!TWIN_PATH_RE.test(p)) return null;
++ const req = anonymousRequest(p, Object.fromEntries(new URLSearchParams(query ? query.toString() : '')), host);
++ const res = captureResponse();
++ await new Promise((resolve) => {
++ let settled = false;
++ const finish = () => { if (!settled) { settled = true; resolve(); } };
++ const send = res.send.bind(res);
++ res.send = (body) => { send(body); finish(); return res; };
++ const end = res.end.bind(res);
++ res.end = (body) => { end(body); finish(); return res; };
++ const redirect = res.redirect.bind(res);
++ res.redirect = (code, url) => { redirect(code, url); finish(); return res; };
++ try { router(req, res, () => finish()); } catch { finish(); }
++ const timer = setTimeout(finish, 10000);
++ if (timer.unref) timer.unref();
++ });
++ // A page that is not public (a signed-in page, a missing page) has no twin.
++ if (res.statusCode !== 200 || res.body == null) return null;
++ const html = Buffer.isBuffer(res.body) ? res.body.toString('utf8') : String(res.body);
++ if (!/^text\/html/i.test(String(res.headers['Content-Type'] || ''))) return null;
++ return { html };
++ }
++
++ return { renderPublicPage };
++}
++
++module.exports = { createAgentPages, TWIN_PATH_RE };
ddiff --git a/server/app.js b/server/app.js
iindex 649d9bf..2cd1634 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -154,7 +154,25 @@ async function createApp(opts = {}) {
// ── Pages ───────────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.QUEST_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
-- app.use(createPageRoutes(ctx));
++ const pageRoutes = createPageRoutes(ctx);
++ // The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and the Markdown twins of the
++ // public pages. Only the manifest and a twin request reach it — the layer renders every page signed out,
++ // and the site's own routes answer every browser.
++ {
++ const agentRouter = express.Router();
++ require('openvibe-shared/agent-layer').mount(agentRouter, {
++ descriptor: require('./agent-descriptor'),
++ render: require('./agent-pages').createAgentPages({ router: pageRoutes, host: new URL(config.baseUrl).hostname }).renderPublicPage,
++ });
++ app.use((req, res, next) => {
++ if (req.path === '/.well-known/agent.json') return agentRouter(req, res, next);
++ const p = String(req.path || '');
++ if (p.endsWith('.md') || (req.query && req.query.format === 'markdown')
++ || req.accepts(['html', 'text/markdown']) === 'text/markdown') return agentRouter(req, res, next);
++ return next();
++ });
++ }
++ app.use(pageRoutes);
app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try <a href="/">the home page</a> or <a href="/updates">the update log</a>.</p>` }));
// eslint-disable-next-line no-unused-vars
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex b86d04a..b5dbdcf 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -46,7 +46,9 @@ function createPageRoutes(ctx) {
const { config, s, coins } = ctx;
const r = asyncRouter();
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
-- const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
++ // The pages with a Markdown twin (openvibe-shared/agent-layer): the public ones only.
++ const TWIN_PATH_RE = /^\/(?:how-it-works|updates|quests\/[^/]+|series\/[^/]+|apps\/quests\/[^/]+)?$/;
++ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, agentLayer: TWIN_PATH_RE.test(String(req.path || '/').split('?')[0]), llms: '/llms.txt', ...o });
const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
const coinsUi = { coinsEnabled: coins.enabled, dailyCap: config.coins.dailyCap, globalDailyCap: config.coins.globalDailyCap };
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 2aacd43..1aaaed4 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -84,6 +84,8 @@ function renderPage(o) {
description: o.description || `${SITE_NAME} — ${TAGLINE}`,
canonical,
robots: o.index ? 'index, follow' : 'noindex, nofollow',
++ agentLayer: o.agentLayer,
++ llms: o.llms,
jsonLd: (o.jsonLd || []).filter(Boolean),
home: '/',
navLinks: NAV,
@@@ -121,6 +123,11 @@ function send(res, status, o) {
const personal = o.viewer && o.viewer.kind === 'user';
if (!res.get('Cache-Control')) res.set('Cache-Control', !personal && o.cache ? o.cache : cache.htmlHeaders({ private: true }));
res.set('Vary', 'Cookie');
++ // The page names its Markdown twin (openvibe-shared/agent-layer) and the site's llms.txt.
++ if (o.agentLayer) {
++ const canonical = `${o.config.baseUrl}${String(o.path || '/').split('?')[0]}`;
++ res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(canonical, `${o.config.baseUrl}/llms.txt`).header);
++ }
res.status(status).type('html').send(renderPage(o));
}