openvibers/openvibe.bot Commits
Bot: the agent layer (the manifest and the Markdown twins of the public pages) (#223)
Changes
@@@ -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;}nnew file mode 100644@@@ -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.',++ },++};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 = /^\/(?: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 };@@@ -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 });@@@ -292,6 +292,7 @@ function framePage({ config, title, description, canonicalPath = '/', body, headreturn 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'] }),
@@ -208,6 +208,22 @@ server {
@@ -208,6 +208,22 @@ server {
proxy_pass http://127.0.0.1:4630;
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) {
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.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.',
+ },
+};
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 = /^\/(?: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 };
@@ -176,7 +176,33 @@ function createApp(opts = {}) {
@@ -176,7 +176,33 @@ function createApp(opts = {}) {
{ const legal = require('openvibe-shared/legal');
{ 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.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(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) => {
app.use((req, res) => {
if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
@@ -292,6 +292,7 @@ function framePage({ config, title, description, canonicalPath = '/', body, head
@@ -292,6 +292,7 @@ function framePage({ config, title, description, canonicalPath = '/', body, head
return shell.page({
return shell.page({
name: SITE_NAME, service: 'bot', lang: 'en', title, siteName: SITE_NAME, description, summary: description,
name: SITE_NAME, service: 'bot', lang: 'en', title, siteName: SITE_NAME, description, summary: description,
canonical: `${config.baseUrl}${canonicalPath}`, robots: 'index, follow',
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: '/',
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' }],
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'),
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 =
@@ -329,6 +330,7 @@ function renderHome({ config, signedIn = false, models = [], robots = [], kit =
summary: DESCRIPTION,
summary: DESCRIPTION,
canonical: url,
canonical: url,
robots: 'index, follow',
robots: 'index, follow',
+ agentLayer: true, llms: '/llms.txt',
jsonLd: [
jsonLd: [
seo.jsonLd.website({ name: SITE_NAME, url, description: DESCRIPTION }),
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'] }),
seo.jsonLd.softwareApp({ name: SITE_NAME, url, description: DESCRIPTION, category: 'UtilitiesApplication', keywords: ['robot control panel', 'Raspberry Pi robot', 'ROS 2', 'Cozmo', 'telepresence'] }),