openvibers/openvibe.bot Commits

Bot: the agent layer (the manifest and the Markdown twins of the public pages) (#223)

OP OpenVibers committed 15 days ago · a2b1ba47a9 · 5 files, +133 −1

Changes

ddiff --git a/deploy/nginx/openvibe.bot.conf b/deploy/nginx/openvibe.bot.conf
iindex b250808..dad388c 100644
---- a/deploy/nginx/openvibe.bot.conf
++++ b/deploy/nginx/openvibe.bot.conf
@@@ -208,6 +208,22 @@ server {
proxy_pass http://127.0.0.1:4630;
}
++ # ── 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; }
++ limit_req zone=ovbot_api burst=20 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:4630;
++ }
++ location ~ "^/(index|updates|robots|docs(/(drivers|profiles|automation|computers))?|models/[^/]+|r/[^/]+)\.md$" {
++ limit_except GET { deny all; }
++ limit_req zone=ovbot_api burst=20 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:4630;
++ }
++
location ~ /\.(?!well-known) {
deny all;
}
ddiff --git a/server/agent-descriptor.js b/server/agent-descriptor.js
nnew file mode 100644
iindex 0000000..63c6655
---- /dev/null
++++ b/server/agent-descriptor.js
@@@ -0,0 +1,13 @@
++'use strict';
++// The agent manifest of OpenVibe.Bot: what the site is, where its API and docs are, and how it is
++// reached. openvibe-shared/agent-layer turns this into /.well-known/agent.json.
++module.exports = {
++ id: 'bot', name: 'OpenVibe.Bot',
++ purpose: 'Devices, pairing and control for robots: add a robot, pair it with one pasted command, then drive it from any browser through a panel laid out from its profile.',
++ home: 'https://openvibe.bot', api: '/api/v1',
++ docs: 'https://openvibe.bot/docs',
++ policy: {
++ auth: ['bearer', 'Network OIDC'],
++ rateLimit: 'The API is rate limited per caller and per robot; the owner\'s plan sets the numbers.',
++ },
++};
ddiff --git a/server/agent-pages.js b/server/agent-pages.js
nnew file mode 100644
iindex 0000000..6a22dc3
---- /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 = /^\/(?:updates|docs(?:\/(?:drivers|profiles|automation|computers))?|robots|models(?:\/[^/]+)?|r\/[^/]+)?$/;
++
++/** 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 be86ca3..ea6341b 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -176,7 +176,33 @@ function createApp(opts = {}) {
{ const legal = require('openvibe-shared/legal');
app.get(legal.PATHS, (req, res, next) => { res.setHeader('Content-Security-Policy', HOME_CSP); next(); }, legal.handler({ id: 'bot', service: 'bot', host: 'openvibe.bot', name: 'OpenVibe.Bot', profile: 'games' })); }
app.use(viewerMiddleware(userAuth));
-- app.use(createWebRoutes(config, { domain, sim, onvif, limits, identity, hub, log, submissions, routines, webhooks, claims, setupKeys, releaseLastmod: release.full().released_at === release.full().booted_at ? undefined : release.full().released_at }));
++ const webRoutes = createWebRoutes(config, { domain, sim, onvif, limits, identity, hub, log, submissions, routines, webhooks, claims, setupKeys, releaseLastmod: release.full().released_at === release.full().booted_at ? undefined : release.full().released_at });
++ // 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: webRoutes, host: new URL(config.baseUrl).hostname }).renderPublicPage,
++ });
++ // The page names its Markdown twin (openvibe-shared/agent-layer) and the site's llms.txt.
++ app.use((req, res, next) => {
++ if (req.method === 'GET' && require('./agent-pages').TWIN_PATH_RE.test(String(req.path || ''))) {
++ res.setHeader('Link', require('openvibe-shared/agent-layer').linkHeaderTags(config.baseUrl + req.path, config.baseUrl + '/llms.txt').header);
++ }
++ next();
++ });
++ app.use((req, res, next) => {
++ // The manifest exactly: the rest of /.well-known/ (security.txt) belongs to the app.
++ 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(webRoutes);
app.use((req, res) => {
if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
ddiff --git a/server/web/home.js b/server/web/home.js
iindex bf5d64d..b98d8f5 100644
---- a/server/web/home.js
++++ b/server/web/home.js
@@@ -292,6 +292,7 @@ function framePage({ config, title, description, canonicalPath = '/', body, head
return shell.page({
name: SITE_NAME, service: 'bot', lang: 'en', title, siteName: SITE_NAME, description, summary: description,
canonical: `${config.baseUrl}${canonicalPath}`, robots: 'index, follow',
++ agentLayer: require('../agent-pages').TWIN_PATH_RE.test(canonicalPath), llms: '/llms.txt',
navbar: nav, footer: { service: 'bot', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' }, home: '/',
navLinks: [{ label: 'Robots', href: '/robots' }, { label: 'Build', href: '/docs' }],
head: [appIcon.headTags({ site: 'bot' }), '<link rel="manifest" href="/manifest.webmanifest">', `<link rel="apple-touch-icon" href="${require('openvibe-shared/app-icons').url('bot', 192)}">`, head].filter(Boolean).join('\n'),
@@@ -329,6 +330,7 @@ function renderHome({ config, signedIn = false, models = [], robots = [], kit =
summary: DESCRIPTION,
canonical: url,
robots: 'index, follow',
++ agentLayer: true, llms: '/llms.txt',
jsonLd: [
seo.jsonLd.website({ name: SITE_NAME, url, description: DESCRIPTION }),
seo.jsonLd.softwareApp({ name: SITE_NAME, url, description: DESCRIPTION, category: 'UtilitiesApplication', keywords: ['robot control panel', 'Raspberry Pi robot', 'ROS 2', 'Cozmo', 'telepresence'] }),