openvibers/openvibe.mediahub Commits

Agent layer on the drive host: /.well-known/agent.json and Markdown twins of the public pages (#185)

OP OpenVibers committed 15 days ago · 8720a58047 · 9 files, +144 −7

openvibe-shared v4.34.0 (was v4.33.0). The descriptor is what openvibe.download really serves: the public
pages of the private drive (the home page, the safety rules and the update log), the JSON API under /api/v1,
the OpenAPI 3.1 document at /openapi.json, llms.txt, llms-full.txt and the sitemap; no MCP endpoint is
published here, so none is named. server/drive/agent-pages.js twins those pages by asking the page router for
the page in-process, signed out, so a twin cannot drift from the page a browser gets and never carries a
signed-in person's view; a person's drive, a file, a share link, the staff queue and a missing page have no
twin. The layer is entered only for a twin request and the manifest, before the page routes so /safety.md is
not read as a page called safety.md; a browser keeps the app's own page, which names its twin in the head
(server/render/layout.js). On the drive host only, so the video host keeps its own manifest. robots.txt ends
with the manifest comment, and /.well-known/security.txt is unchanged.

Changes

ddiff --git a/README.md b/README.md
iindex de43736..3a9aee9 100644
---- a/README.md
++++ b/README.md
@@@ -525,5 +525,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
- openvibe-contracts: v0.222.0
- openvibe-sdk: v0.67.0
--- openvibe-shared: v4.33.0
++- openvibe-shared: v4.34.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 8cf4c64..fe3e7d5 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -10,7 +10,7 @@
"packages": [
"openvibe-contracts v0.222.0",
"openvibe-sdk v0.67.0",
-- "openvibe-shared v4.33.0"
++ "openvibe-shared v4.34.0"
],
"summary": "OpenVibe.MediaHub: openvibe.download as a private drive. Account-only uploads stream into OpenVibe.Media (a plain form up to 256 MB, resumable parts beyond). Files are private. Share links expire in 1 hour to 7 days, need a signed-in downloader and can be limited to named accounts (matched by subject). Reports suspend a share for staff. Downloads are always sandboxed attachments. Per-person quotas, and account export and deletion (ADR-033). openvibe.video serves public watch pages and playlists for Live's recordings and clips (no uploads); openvibe.pics is parked and redirects to openvibe.network.",
"works": [
@@@ -44,7 +44,7 @@
],
"contracts": "openvibe-contracts v0.222.0",
"sdk": "openvibe-sdk v0.67.0",
-- "shared": "openvibe-shared v4.33.0",
++ "shared": "openvibe-shared v4.34.0",
"deployment": "openvibe-ovh: unit openvibe-media-hub.service on 127.0.0.1:4990 (git checkout /opt/openvibe.download, env /etc/openvibe/media-hub.env, database ov_media_hub), deployed with `sudo ovhost deploy media-hub`; public at https://openvibe.download since 2026-10-08, https://openvibe.video since 2026-10-12 and https://openvibe.pics since 2026-10-15.",
"updated": "2026-10-28"
}
ddiff --git a/server/app.js b/server/app.js
iindex 64567cf..1f90537 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -230,7 +230,32 @@ async function createApp(opts = {}) {
// ── Pages ───────────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.MEDIAHUB_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
-- app.use(createPageRoutes(ctx));
++ // The agent layer (openvibe-shared/agent-layer) on the drive host: /.well-known/agent.json and the Markdown twins
++ // of the public pages (the home page, the safety rules and the update log). Before the page routes, so a twin is
++ // the page's own render and never a route of its own; on the drive host only, so the video host never answers
++ // with the drive's manifest.
++ // A twin request and the manifest only: the layer renders every page as a signed-out visitor, and the drive is
++ // mostly one person's pages (their files, their share links). Anything else is the app's own page, which names
++ // its twin in the head itself (server/render/layout.js).
++ const downloadHost = (() => { try { return new URL(config.baseUrl).hostname; } catch { return null; } })();
++ const pageRoutes = createPageRoutes(ctx);
++ if (downloadHost) {
++ const agentRouter = express.Router();
++ require('openvibe-shared/agent-layer').mount(agentRouter, {
++ descriptor: require('./drive/agent-descriptor'),
++ render: require('./drive/agent-pages').createAgentPages({ router: pageRoutes, host: downloadHost }).renderPublicPage,
++ });
++ app.use((req, res, next) => {
++ if (req.hostname !== downloadHost) return next();
++ // The manifest exactly: the rest of /.well-known/ belongs to the app and the host's security.txt.
++ 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/drive/agent-descriptor.js b/server/drive/agent-descriptor.js
nnew file mode 100644
iindex 0000000..edb28c6
---- /dev/null
++++ b/server/drive/agent-descriptor.js
@@@ -0,0 +1,18 @@
++'use strict';
++// What openvibe.download tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.
++// Every field is something this host really serves: the public pages of the private drive (the home page, the
++// safety rules and the update log), the JSON API under /api/v1, the OpenAPI 3.1 document at /openapi.json,
++// llms.txt and the sitemap. No MCP endpoint is published here, so none is named.
++module.exports = {
++ id: 'download',
++ name: 'OpenVibe.Download',
++ purpose: 'A private drive on the OpenVibe network: keep your own files in your account, share them with a link that expires, and back up a folder from your computer.',
++ home: 'https://openvibe.download',
++ site: 'openvibe.download',
++ api: '/api/v1',
++ openapi: '/openapi.json',
++ policy: {
++ auth: ['Network OIDC'],
++ rateLimit: 'The public pages are not limited per address; the API answers a signed-in caller and is bounded by the project\'s quotas, published at /limits.json',
++ },
++};
ddiff --git a/server/drive/agent-pages.js b/server/drive/agent-pages.js
nnew file mode 100644
iindex 0000000..64f3429
---- /dev/null
++++ b/server/drive/agent-pages.js
@@@ -0,0 +1,85 @@
++'use strict';
++// The Markdown twins of openvibe.download's public pages (openvibe-shared/agent-layer): the home page, the safety
++// rules and the update log.
++//
++// A twin is the page's own render: the page router answers the request in-process, signed out, so a twin cannot
++// drift from the page a browser gets and never carries a signed-in person's view. A person's drive, a file, a
++// share link, the staff queue and a missing page have no twin.
++const TWIN_PATHS = new Set(['/', '/safety', '/updates']);
++
++/** A request as an anonymous browser makes it: no session, no cookies, no viewer. */
++function anonymousRequest(path, query, host) {
++ const search = query && query.toString() ? `?${query.toString()}` : '';
++ const url = `${path}${search}`;
++ return {
++ method: 'GET', url, originalUrl: url, path, params: {}, query: query ? Object.fromEntries(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,
++ // The viewer the SSO middleware sets for a signed-out visitor.
++ viewer: { kind: 'anonymous' },
++ 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, locals: {},
++ status(code) { this.statusCode = code; return this; },
++ // Header names are case-insensitive, as in Express: they are stored lowercase and read the same way.
++ set(name, value) { this.headers[String(name).toLowerCase()] = value; return this; },
++ setHeader(name, value) { this.headers[String(name).toLowerCase()] = value; return this; },
++ getHeader(name) { return this.headers[String(name).toLowerCase()]; },
++ get(name) { return this.headers[String(name).toLowerCase()]; },
++ removeHeader(name) { delete this.headers[String(name).toLowerCase()]; },
++ 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; },
++ write(chunk) { this.body = (this.body || '') + String(chunk); return true; },
++ writeHead(code, headers) { this.statusCode = code; Object.assign(this.headers, headers || {}); return this; },
++ sendStatus(code) { this.statusCode = code; this.body = ''; this.headersSent = true; return this; },
++ cookie() { return this; }, clearCookie() { return this; },
++ };
++}
++
++function createAgentPages({ router, host }) {
++ async function renderPublicPage(path, { query } = {}) {
++ const p = String(path || '/').replace(/\/+$/, '') || '/';
++ if (!TWIN_PATHS.has(p)) return null;
++ const req = anonymousRequest(p, query, host);
++ const res = captureResponse();
++ req.res = res;
++ 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 json = res.json.bind(res);
++ res.json = (body) => { json(body); finish(); return res; };
++ const redirect = res.redirect.bind(res);
++ res.redirect = (code, url) => { redirect(code, url); finish(); return res; };
++ const end = res.end.bind(res);
++ res.end = (body) => { end(body); finish(); return res; };
++ try { router(req, res, () => finish()); } catch { finish(); }
++ const timer = setTimeout(finish, 10000);
++ if (timer.unref) timer.unref();
++ });
++ if (res.statusCode !== 200 || res.body == null) return null;
++ const html = Buffer.isBuffer(res.body) ? res.body.toString('utf8') : String(res.body);
++ const type = String(res.headers['content-type'] || '');
++ if (!/^text\/html/i.test(type) && !(type === '' && /^\s*</.test(html))) return null;
++ return { html };
++ }
++
++ return { renderPublicPage };
++}
++
++module.exports = { createAgentPages, TWIN_PATHS };
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 9444317..761b7a7 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -71,7 +71,9 @@ function createDiscoveryRoutes(ctx) {
r.get('/robots.txt', (_req, res) => {
res.type('text/plain').set('Cache-Control', TEXT).send(
'# openvibe.download: the public pages are for search and AI crawlers; sign-in and the API are not.\n'
-- + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
++ + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW })
++ // The agent manifest, named for the machines that read this file (openvibe-shared/agent-layer).
++ + `# Agent manifest: ${site}/.well-known/agent.json\n`);
});
r.get('/llms.txt', (_req, res) => {
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex c927675..002b4f1 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -36,6 +36,7 @@ const cache = require('openvibe-shared/cache-policy');
const seo = require('openvibe-shared/seo');
const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd, homeDescription } = require('./discovery');
++const { TWIN_PATHS } = require('../drive/agent-pages');
const { sameOrigin } = require('./principal');
const { html, raw, table, notice, time, badge } = require('../render/html');
const { send } = require('../render/layout');
@@@ -67,7 +68,8 @@ function createPageRoutes(ctx) {
// Revalidated on every view, as Video's and Pics' guest pages are (server/video/layout.js GUEST_HTML): Cloudflare drops
// `Vary`, so a guest copy kept in the browser would hide a sign-in.
const PUBLIC_CACHE = 'public, max-age=0, must-revalidate';
-- const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
++ // The public pages name their Markdown twin (openvibe-shared/agent-layer) in the head; the signed-in pages have none.
++ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, agentLayer: TWIN_PATHS.has(req.path), ...o });
const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
const staffViewer = (req) => isStaff(req.viewer);
const requesterOf = (req) => `user:${req.viewer.subject}`;
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex d8dcc77..51014dd 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -84,6 +84,8 @@ function renderPage(o) {
siteName: SITE_NAME,
description: o.description || `${SITE_NAME} — ${TAGLINE}`,
canonical,
++ // The page names its Markdown twin (openvibe-shared/agent-layer) when it has one.
++ ...(o.agentLayer ? { agentLayer: true, llms: '/llms.txt' } : {}),
robots: o.index ? 'index, follow' : 'noindex, nofollow',
jsonLd: (o.jsonLd || []).filter(Boolean),
home: '/',
ddiff --git a/test/video-agent-layer.test.js b/test/video-agent-layer.test.js
iindex 6cf33ab..22b4663 100644
---- a/test/video-agent-layer.test.js
++++ b/test/video-agent-layer.test.js
@@@ -76,7 +76,10 @@ function vreq(base, path, { method = 'GET', cookie, host = 'openvibe.video', acc
assert.strictEqual(d.sitemap, 'https://openvibe.video/sitemap.xml');
assert.deepStrictEqual(d.policy.auth, ['none', 'Network OIDC']);
const drive = await vreq(t.base, '/.well-known/agent.json', { host: 'openvibe.download' });
-- assert.strictEqual(drive.status, 404, 'the drive does not answer with the video manifest');
++ assert.strictEqual(drive.status, 200, 'the drive answers with its own manifest');
++ const dd = JSON.parse(drive.text);
++ assert.strictEqual(dd.id, 'download', 'the drive does not answer with the video manifest');
++ assert.strictEqual(dd.site, 'openvibe.download');
});
await check('a recording\'s twin is the page\'s own render', async () => {