openvibers/openvibe.quest Commits
Quest: the agent layer (the manifest and the Markdown twins of the public pages) (#126)
Changes
@@@ -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;}nnew file mode 100644@@@ -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.',++ },++};nnew file mode 100644@@@ -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 };@@@ -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@@@ -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 };@@@ -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));}
@@ -111,6 +111,18 @@ server {
@@ -111,6 +111,18 @@ server {
proxy_pass http://127.0.0.1:4980;
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) {
location ~ /\.(?!well-known) {
deny all;
deny all;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -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.',
+ },
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -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 };
@@ -154,7 +154,25 @@ async function createApp(opts = {}) {
@@ -154,7 +154,25 @@ async function createApp(opts = {}) {
// ── Pages ───────────────────────────────────────────────
// ── Pages ───────────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.QUEST_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
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>` }));
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
// eslint-disable-next-line no-unused-vars
@@ -46,7 +46,9 @@ function createPageRoutes(ctx) {
@@ -46,7 +46,9 @@ function createPageRoutes(ctx) {
const { config, s, coins } = ctx;
const { config, s, coins } = ctx;
const r = asyncRouter();
const r = asyncRouter();
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
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 signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
const coinsUi = { coinsEnabled: coins.enabled, dailyCap: config.coins.dailyCap, globalDailyCap: config.coins.globalDailyCap };
const coinsUi = { coinsEnabled: coins.enabled, dailyCap: config.coins.dailyCap, globalDailyCap: config.coins.globalDailyCap };
@@ -84,6 +84,8 @@ function renderPage(o) {
@@ -84,6 +84,8 @@ function renderPage(o) {
description: o.description || `${SITE_NAME} — ${TAGLINE}`,
description: o.description || `${SITE_NAME} — ${TAGLINE}`,
canonical,
canonical,
robots: o.index ? 'index, follow' : 'noindex, nofollow',
robots: o.index ? 'index, follow' : 'noindex, nofollow',
+ agentLayer: o.agentLayer,
+ llms: o.llms,
jsonLd: (o.jsonLd || []).filter(Boolean),
jsonLd: (o.jsonLd || []).filter(Boolean),
home: '/',
home: '/',
navLinks: NAV,
navLinks: NAV,
@@ -121,6 +123,11 @@ function send(res, status, o) {
@@ -121,6 +123,11 @@ 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 && o.cache ? o.cache : cache.htmlHeaders({ private: true }));
if (!res.get('Cache-Control')) res.set('Cache-Control', !personal && o.cache ? o.cache : cache.htmlHeaders({ private: true }));
res.set('Vary', 'Cookie');
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));
res.status(status).type('html').send(renderPage(o));
}
}