openvibers/openvibe.mediahub Commits
Agent layer on the drive host: /.well-known/agent.json and Markdown twins of the public pages (#185)
Changes
@@@ -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 -->@@@ -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"}@@@ -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-varsnnew file mode 100644@@@ -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',++ },++};nnew file mode 100644@@@ -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 };@@@ -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) => {@@@ -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}`;@@@ -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: '/',@@@ -76,7 +76,10 @@ function vreq(base, path, { method = 'GET', cookie, host = 'openvibe.video', accassert.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 () => {
@@ -525,5 +525,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
@@ -525,5 +525,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.222.0
- openvibe-contracts: v0.222.0
- openvibe-sdk: v0.67.0
- openvibe-sdk: v0.67.0
-- openvibe-shared: v4.33.0
+- openvibe-shared: v4.34.0
<!-- versions:end -->
<!-- versions:end -->
@@ -10,7 +10,7 @@
@@ -10,7 +10,7 @@
"packages": [
"packages": [
"openvibe-contracts v0.222.0",
"openvibe-contracts v0.222.0",
"openvibe-sdk v0.67.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.",
"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": [
"works": [
@@ -44,7 +44,7 @@
@@ -44,7 +44,7 @@
],
],
"contracts": "openvibe-contracts v0.222.0",
"contracts": "openvibe-contracts v0.222.0",
"sdk": "openvibe-sdk v0.67.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.",
"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"
"updated": "2026-10-28"
}
}
@@ -230,7 +230,32 @@ async function createApp(opts = {}) {
@@ -230,7 +230,32 @@ async function createApp(opts = {}) {
// ── Pages ───────────────────────────────────────────────
// ── Pages ───────────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.MEDIAHUB_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
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>` }));
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
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -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',
+ },
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -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 };
@@ -71,7 +71,9 @@ function createDiscoveryRoutes(ctx) {
@@ -71,7 +71,9 @@ function createDiscoveryRoutes(ctx) {
r.get('/robots.txt', (_req, res) => {
r.get('/robots.txt', (_req, res) => {
res.type('text/plain').set('Cache-Control', TEXT).send(
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'
'# 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) => {
r.get('/llms.txt', (_req, res) => {
@@ -36,6 +36,7 @@ const cache = require('openvibe-shared/cache-policy');
@@ -36,6 +36,7 @@ const cache = require('openvibe-shared/cache-policy');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd, homeDescription } = require('./discovery');
const { createDiscoveryRoutes, homeJsonLd, homeDescription } = require('./discovery');
+const { TWIN_PATHS } = require('../drive/agent-pages');
const { sameOrigin } = require('./principal');
const { sameOrigin } = require('./principal');
const { html, raw, table, notice, time, badge } = require('../render/html');
const { html, raw, table, notice, time, badge } = require('../render/html');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
@@ -67,7 +68,8 @@ function createPageRoutes(ctx) {
@@ -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
// 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.
// `Vary`, so a guest copy kept in the browser would hide a sign-in.
const PUBLIC_CACHE = 'public, max-age=0, must-revalidate';
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 signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
const staffViewer = (req) => isStaff(req.viewer);
const staffViewer = (req) => isStaff(req.viewer);
const requesterOf = (req) => `user:${req.viewer.subject}`;
const requesterOf = (req) => `user:${req.viewer.subject}`;
@@ -84,6 +84,8 @@ function renderPage(o) {
@@ -84,6 +84,8 @@ function renderPage(o) {
siteName: SITE_NAME,
siteName: SITE_NAME,
description: o.description || `${SITE_NAME} — ${TAGLINE}`,
description: o.description || `${SITE_NAME} — ${TAGLINE}`,
canonical,
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',
robots: o.index ? 'index, follow' : 'noindex, nofollow',
jsonLd: (o.jsonLd || []).filter(Boolean),
jsonLd: (o.jsonLd || []).filter(Boolean),
home: '/',
home: '/',
@@ -76,7 +76,10 @@ function vreq(base, path, { method = 'GET', cookie, host = 'openvibe.video', acc
@@ -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.strictEqual(d.sitemap, 'https://openvibe.video/sitemap.xml');
assert.deepStrictEqual(d.policy.auth, ['none', 'Network OIDC']);
assert.deepStrictEqual(d.policy.auth, ['none', 'Network OIDC']);
const drive = await vreq(t.base, '/.well-known/agent.json', { host: 'openvibe.download' });
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 () => {
await check('a recording\'s twin is the page\'s own render', async () => {