openvibers/openvibe.mediahub Commits
Agent layer: /.well-known/agent.json and Markdown twins of the home and watch pages (#182)
Changes
@@@ -17,7 +17,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0","pg": "^8.23.0","sharp": "^0.33.5"},@@@ -28,6 +28,12 @@"node": ">=22.0.0"}},++ "node_modules/@acemir/cssom": {++ "version": "0.9.32",++ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.32.tgz",++ "integrity": "sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==",++ "license": "MIT"++ },"node_modules/@electric-sql/pglite": {"version": "0.5.8","resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",@@@ -524,6 +530,19 @@"npm": "1.2.8000 || >= 1.4.16"}},++ "node_modules/boolbase": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",++ "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",++ "license": "ISC",++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ },"node_modules/buffer-equal-constant-time": {"version": "1.0.1","resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",@@@ -667,6 +686,39 @@"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==","license": "MIT"},++ "node_modules/css-select": {++ "version": "7.0.0",++ "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",++ "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "boolbase": "^2.0.0",++ "css-what": "^8.0.0",++ "domhandler": "^6.0.1",++ "domutils": "^4.0.2",++ "nth-check": "^3.0.1"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ },++ "node_modules/css-what": {++ "version": "8.0.0",++ "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",++ "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ },"node_modules/debug": {"version": "2.6.9","resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",@@@ -713,6 +765,73 @@"node": ">=8"}},++ "node_modules/dom-serializer": {++ "version": "3.1.1",++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",++ "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",++ "license": "MIT",++ "dependencies": {++ "domelementtype": "^3.0.0",++ "domhandler": "^6.0.0",++ "entities": "^8.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"++ }++ },++ "node_modules/domelementtype": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",++ "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=20.19.0"++ }++ },++ "node_modules/domhandler": {++ "version": "6.0.1",++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",++ "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "domelementtype": "^3.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/fb55/domhandler?sponsor=1"++ }++ },++ "node_modules/domutils": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",++ "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "dom-serializer": "^3.0.0",++ "domelementtype": "^3.0.0",++ "domhandler": "^6.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/fb55/domutils?sponsor=1"++ }++ },"node_modules/dotenv": {"version": "16.6.1","resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",@@@ -763,6 +882,18 @@"node": ">= 0.8"}},++ "node_modules/entities": {++ "version": "8.1.0",++ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",++ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "url": "https://github.com/fb55/entities?sponsor=1"++ }++ },"node_modules/es-define-property": {"version": "1.0.1","resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",@@@ -1021,6 +1152,110 @@"url": "https://github.com/sponsors/EvanHahn"}},++ "node_modules/html-escaper": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",++ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",++ "license": "MIT"++ },++ "node_modules/htmlparser2": {++ "version": "10.1.0",++ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",++ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",++ "funding": [++ "https://github.com/fb55/htmlparser2?sponsor=1",++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "MIT",++ "dependencies": {++ "domelementtype": "^2.3.0",++ "domhandler": "^5.0.3",++ "domutils": "^3.2.2",++ "entities": "^7.0.1"++ }++ },++ "node_modules/htmlparser2/node_modules/dom-serializer": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",++ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",++ "license": "MIT",++ "dependencies": {++ "domelementtype": "^2.3.0",++ "domhandler": "^5.0.2",++ "entities": "^4.2.0"++ },++ "funding": {++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": {++ "version": "4.5.0",++ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",++ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=0.12"++ },++ "funding": {++ "url": "https://github.com/fb55/entities?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/domelementtype": {++ "version": "2.3.0",++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",++ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "BSD-2-Clause"++ },++ "node_modules/htmlparser2/node_modules/domhandler": {++ "version": "5.0.3",++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",++ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "domelementtype": "^2.3.0"++ },++ "engines": {++ "node": ">= 4"++ },++ "funding": {++ "url": "https://github.com/fb55/domhandler?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/domutils": {++ "version": "3.2.2",++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",++ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "dom-serializer": "^2.0.0",++ "domelementtype": "^2.3.0",++ "domhandler": "^5.0.3"++ },++ "funding": {++ "url": "https://github.com/fb55/domutils?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/entities": {++ "version": "7.0.1",++ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",++ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=0.12"++ },++ "funding": {++ "url": "https://github.com/fb55/entities?sponsor=1"++ }++ },"node_modules/http-errors": {"version": "2.0.1","resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",@@@ -1172,6 +1407,30 @@"safe-buffer": "^5.0.1"}},++ "node_modules/linkedom": {++ "version": "0.18.14",++ "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.14.tgz",++ "integrity": "sha512-BRUnkOm4YR5vo+qI3j0EbungB0vZPSCue5AdnTQRaxAGSbsOZiBFRO0rh4zlo8qXUhli8pxvnpEXDklMnlq2Rg==",++ "license": "ISC",++ "dependencies": {++ "@acemir/cssom": "^0.9.32",++ "css-select": "^7.0.0",++ "html-escaper": "^3.0.3",++ "htmlparser2": "^10.1.0",++ "uhyphen": "^0.2.0"++ },++ "engines": {++ "node": ">=16"++ },++ "peerDependencies": {++ "canvas": ">= 2"++ },++ "peerDependenciesMeta": {++ "canvas": {++ "optional": true++ }++ }++ },"node_modules/lodash.defaults": {"version": "4.2.0","resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",@@@ -1310,6 +1569,22 @@"node": ">= 0.6"}},++ "node_modules/nth-check": {++ "version": "3.0.1",++ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",++ "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "boolbase": "^2.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/fb55/nth-check?sponsor=1"++ }++ },"node_modules/object-inspect": {"version": "1.13.4","resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",@@@ -1379,12 +1654,13 @@}},"node_modules/openvibe-shared": {-- "version": "4.33.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",-- "integrity": "sha512-t9XvhrQ+lQebOrFTjMCzI6/7/i4iQn9dmMr/8HVTNW1XoV1exyJa7IH/HfyyKuLxjA8xaowrDfD28LwKIibBNg==",++ "version": "4.34.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",++ "integrity": "sha512-Rj5uRO3MbWkyIYAm+vK4Y94ugDNYEjEgGkYnPjSej4yMqhalpaPCkU96dzphMGiOUGP1PNWsiKhwaBj8tewgBg==","license": "MIT","dependencies": {-- "jsonwebtoken": "^9.0.0"++ "jsonwebtoken": "^9.0.0",++ "linkedom": "^0.18.13"},"bin": {"openvibe-build-assets": "scripts/build-assets.js"@@@ -1882,6 +2158,12 @@"node": ">= 0.6"}},++ "node_modules/uhyphen": {++ "version": "0.2.0",++ "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",++ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",++ "license": "ISC"++ },"node_modules/unpipe": {"version": "1.0.0","resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",@@@ -24,7 +24,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0","pg": "^8.23.0","sharp": "^0.33.5"},nnew file mode 100644@@@ -0,0 +1,17 @@++'use strict';++// What openvibe.video tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.++// Every field is something this host really serves: the public watch pages, the playlist API under++// /api/v1/video, llms.txt and the sitemap, and the network's sign-in. No documentation page, OpenAPI++// document or MCP endpoint is published here, so none is named.++module.exports = {++ id: 'video',++ name: 'OpenVibe.Video',++ purpose: 'Watch videos on the OpenVibe network: public watch pages for OpenVibe.Live\'s stream recordings and clips, browser recordings and uploads with AI captions, chapters and an AI cut, and playlists.',++ home: 'https://openvibe.video',++ site: 'openvibe.video',++ api: '/api/v1/video',++ policy: {++ auth: ['none', 'Network OIDC'],++ rateLimit: 'The public watch pages are not rate-limited by the app; the playlist API is limited to 120 reads a minute and 3,000 an hour per caller',++ },++};@@@ -168,6 +168,27 @@ async function createApp(opts = {}) {const picsRoutes = createPicsRoutes(ctx);// GET /<key>.txt on the video host: IndexNow's key file, only when a key is configured.if (ctx.indexnow.enabled) app.use((req, res, next) => (videoHost && req.hostname === videoHost ? ctx.indexnow.keyFile(req, res, next) : next()));++ // The agent layer (openvibe-shared/agent-layer) on the video host: /.well-known/agent.json and the Markdown++ // twins of the public watch pages. Before the video routes, so a twin is the page's own render and never a++ // route of its own; on the video host only, so the drive never answers with the video's manifest.++ // A twin request and the manifest only: the layer renders every page as a signed-out visitor, and a watch page++ // is not one page (the owner's Manage panel, a signed-in person's playlist form). Anything else is the app's++ // own page, which names its twin in the head and the Link header itself (server/video/layout.js).++ if (videoHost) {++ const agentRouter = express.Router();++ require('openvibe-shared/agent-layer').mount(agentRouter, {++ descriptor: require('./agent-descriptor'),++ render: require('./video/agent-pages').createAgentPages({ router: videoRoutes, videoHost }).renderPublicPage,++ });++ const wantsTwin = (req) => /\.md$/.test(req.path)++ || req.query.format === 'markdown'++ || req.accepts(['html', 'text/markdown']) === 'text/markdown';++ app.use((req, res, next) => {++ if (req.hostname !== videoHost) return next();++ if (req.path === '/.well-known/agent.json' || wantsTwin(req)) return agentRouter(req, res, next);++ return next();++ });++ }app.use((req, res, next) => {if (videoHost && req.hostname === videoHost) return videoRoutes(req, res, next);if (picsHost && req.hostname === picsHost) return picsRoutes(req, res, next);nnew file mode 100644@@@ -0,0 +1,77 @@++'use strict';++// The Markdown twins of openvibe.video's public pages (openvibe-shared/agent-layer): the home and the++// watch pages of a recording (/v/:id) and a clip (/c/:id).++//++// A twin is the page's own render: the video 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 page++// that is not public (a private recording, a missing item) has no twin.++const TWIN_PATH_RE = /^\/(?:v|c)\/\d+$/;++++/** A request as an anonymous browser makes it: no session, no cookies, the video host. */++function anonymousRequest(path, search, host) {++ const url = path + search;++ return {++ method: 'GET', url, originalUrl: url, path,++ params: {}, 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,++ 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,++ 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; },++ };++}++++function createAgentPages({ router, videoHost }) {++ async function renderPublicPage(path, { query } = {}) {++ const p = String(path || '/').replace(/\/+$/, '') || '/';++ if (p !== '/' && !TWIN_PATH_RE.test(p)) return null;++ const search = query && query.toString() ? `?${query.toString()}` : '';++ const req = anonymousRequest(p, search, videoHost);++ 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 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 private recording, a missing item, a redirect) 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 };@@@ -16,6 +16,7 @@ const appIcon = require('openvibe-shared/app-icon');const ovServe = require('openvibe-shared/serve');const shell = require('openvibe-shared/shell');const cache = require('openvibe-shared/cache-policy');++const seo = require('openvibe-shared/seo');const { html, raw, esc, render } = require('../render/html');const NETWORK_URL = 'https://openvibe.network';@@@ -33,6 +34,14 @@ function assetVersion(rel) {}const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;++// The pages with a Markdown twin (openvibe-shared/agent-layer): the home and the watch pages of a++// recording and a clip. The others (search, categories, an account's playlists) have none.++const TWIN_PATH_RE = /^\/(?:v|c)\/\d+$/;++function hasTwin(path) {++ const p = String(path || '/').split('?')[0].replace(/\/+$/, '') || '/';++ return p === '/' || TWIN_PATH_RE.test(p);++}++/** o: title, description, body (html), config, path, index, noindex, viewer, private, crumbs [{label, href}], scripts [rel],* player (true: the network's video player, openvibe-shared player.js/css, on video[data-ov-player]),* jsonLd (structured data for the page), image (its share image, an https URL), ogType */@@@ -75,6 +84,9 @@ ${render(o.body)}...(o.image ? { image: o.image, imageAlt: title } : {}),...(o.ogType ? { type: o.ogType } : {}),home: '/',++ // The pages with a twin name it, so a reader (or an agent) finds the Markdown of this page.++ agentLayer: hasTwin(pathNow),++ llms: '/llms.txt',navLinks: NAV_LINKS,navbar: {service: 'media-hub',@@@ -117,6 +129,11 @@ const GUEST_HTML = 'public, max-age=0, must-revalidate';function send(res, status, o) {if (!res.get('Cache-Control')) res.set('Cache-Control', o.cache || (o.private ? cache.htmlHeaders({ private: true }) : GUEST_HTML));res.set('Vary', 'Host');++ // A page with a twin names it in the Link header, the same way the twin names the page.++ if (hasTwin(o.path)) {++ const canonical = `${String(o.baseUrl).replace(/\/+$/, '')}${String(o.path || '/').split('?')[0]}`;++ res.set('Link', seo.linkHeaderTags(canonical, '/llms.txt').header);++ }res.status(status).type('html').send(renderPage(o));}@@@ -1745,7 +1745,7 @@ ${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and downr.get('/robots.txt', (_req, res) => {return res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))-- .send(`# openvibe.video: the public watch pages are for search and AI crawlers.\n${seo.robotsTxt({ sitemaps: [`${base}/sitemap.xml`] })}`);++ .send(`# openvibe.video: the public watch pages are for search and AI crawlers.\n${seo.robotsTxt({ sitemaps: [`${base}/sitemap.xml`] })}\n# Agent manifest: ${base}/.well-known/agent.json\n`);});// RFC 9116, canonical to this host (openvibe.video), not the drive's.r.get('/.well-known/security.txt', securityTxt.handler(base));nnew file mode 100644@@@ -0,0 +1,134 @@++'use strict';++/**++ * The agent layer (openvibe-shared/agent-layer) on openvibe.video: /.well-known/agent.json, and a Markdown twin of++ * the home, a recording's and a clip's watch page. A twin is the page's own render, so it cannot drift; a twin++ * request (a .md path, ?format=markdown or an explicit text/markdown preference) is the only thing the layer++ * answers — a browser's request is the app's own page, because a watch page is not one page (the owner's Manage++ * panel, a signed-in person's playlist form) and the layer renders every page signed out.++ */++const assert = require('assert');++const http = require('http');++const { boot, check, done } = require('./helpers/boot');++++const usernameOf = (auth) => {++ const tok = String(auth || '').replace(/^Bearer /, '');++ try { return JSON.parse(Buffer.from(tok.split('.')[1], 'base64url').toString('utf8')).username || null; } catch { return null; }++};++++function startLive() {++ const vods = {++ 101: { id: 101, title: 'Kim stream', description: 'First', duration_seconds: 3600, created_at: '2026-10-12T10:00:00.000Z', playback_url: 'https://openvibe.media/v/101', visibility: 'public', username: 'kim', display_name: 'Kim' },++ 103: { id: 103, title: 'Kim private', description: '', duration_seconds: 20, created_at: '2026-10-12T09:00:00.000Z', playback_url: 'https://openvibe.media/v/103', visibility: 'private', username: 'kim', display_name: 'Kim' },++ };++ const clips = {++ 201: { id: 201, vod_id: 101, title: 'Kim clip', start_time: 5, end_time: 15, duration_seconds: 10, playback_url: 'https://openvibe.media/c/201', visibility: 'public', username: 'kim', display_name: 'Kim' },++ };++ const server = http.createServer((req, res) => {++ const url = new URL(req.url, 'http://127.0.0.1');++ const who = usernameOf(req.headers.authorization);++ const json = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); };++ const m = /^\/api\/(vods|clips)\/(\d+)$/.exec(url.pathname);++ if (m) {++ const item = (m[1] === 'vods' ? vods : clips)[m[2]];++ if (!item) return json(404, { error: 'not found' });++ if (item.visibility === 'private' && !(who && who === item.username)) return json(404, { error: 'not found' });++ return json(200, m[1] === 'vods' ? { vod: item, clips: [] } : { clip: item });++ }++ if (url.pathname === '/api/vods') return json(200, { vods: Object.values(vods).filter((v) => v.visibility === 'public'), hasMore: false });++ if (url.pathname === '/api/clips') return json(200, { clips: Object.values(clips).filter((c) => c.visibility === 'public') });++ return json(404, { error: 'not found' });++ });++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => server.close(r)) })));++}++++function vreq(base, path, { method = 'GET', cookie, host = 'openvibe.video', accept } = {}) {++ return new Promise((resolve, reject) => {++ const u = new URL(base + path);++ const h = { Host: host };++ if (cookie) h.cookie = cookie;++ if (accept) h.accept = accept;++ const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method, headers: h }, (res) => {++ const chunks = [];++ res.on('data', (c) => chunks.push(c));++ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, text: Buffer.concat(chunks).toString('utf8') }));++ });++ req.on('error', reject);++ req.end();++ });++}++++(async () => {++ const live = await startLive();++ const t = await boot({ env: { MEDIAHUB_VIDEO_URL: 'https://openvibe.video', MEDIAHUB_LIVE_URL: live.url, MEDIAHUB_LIVE_PUBLIC_URL: 'https://openvibe.live', MEDIAHUB_CHAT_URL: live.url } });++ const kim = t.network.addUser('kim');++ const kimCookie = t.signIn(kim);++++ await check('the manifest is the video host\'s own', async () => {++ const r = await vreq(t.base, '/.well-known/agent.json');++ assert.strictEqual(r.status, 200);++ assert.ok(/^application\/json/.test(r.headers['content-type']), 'JSON');++ const d = JSON.parse(r.text);++ assert.strictEqual(d.id, 'video');++ assert.strictEqual(d.site, 'openvibe.video');++ assert.strictEqual(d.home, 'https://openvibe.video');++ assert.strictEqual(d.api, 'https://openvibe.video/api/v1/video', 'the API is absolute, on this host');++ assert.strictEqual(d.llms, 'https://openvibe.video/llms.txt');++ 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');++ });++++ await check('a recording\'s twin is the page\'s own render', async () => {++ const r = await vreq(t.base, '/v/101.md');++ assert.strictEqual(r.status, 200);++ assert.ok(/^text\/markdown/.test(r.headers['content-type']), 'markdown');++ assert.ok(r.text.startsWith('---\ntitle: '), 'front matter first');++ assert.ok(r.text.includes('canonical: "https://openvibe.video/v/101"'), 'the twin names its page');++ assert.ok(r.text.includes('# Kim stream'), 'the page\'s own title');++ assert.ok(r.text.includes('First'), 'and its description');++ const clip = await vreq(t.base, '/c/201.md');++ assert.strictEqual(clip.status, 200);++ assert.ok(clip.text.includes('# Kim clip') && clip.text.includes('canonical: "https://openvibe.video/c/201"'), 'a clip has its twin too');++ const home = await vreq(t.base, '/index.md');++ assert.strictEqual(home.status, 200);++ assert.ok(home.text.includes('canonical: "https://openvibe.video/"'), 'and so does the home');++ });++++ await check('the HTML page names its twin, in the head and the Link header', async () => {++ const r = await vreq(t.base, '/v/101');++ assert.strictEqual(r.status, 200);++ assert.ok(/<link rel="alternate" type="text\/markdown" href="https:\/\/openvibe\.video\/v\/101\.md">/.test(r.text), 'the head names the twin');++ assert.ok(/<link rel="describedby" href="\/llms\.txt">/.test(r.text), 'and llms.txt');++ assert.ok(r.headers.link.includes('<https://openvibe.video/v/101.md>; rel="alternate"; type="text/markdown"'), 'the Link header too');++ assert.ok(!(await vreq(t.base, '/search')).headers.link, 'a page with no twin claims none');++ });++++ await check('a preference for markdown gets the twin from the page path', async () => {++ const r = await vreq(t.base, '/v/101', { accept: 'text/markdown' });++ assert.strictEqual(r.status, 200);++ assert.ok(/^text\/markdown/.test(r.headers['content-type']), 'markdown, not HTML');++ assert.ok(r.text.includes('# Kim stream'));++ const q = await vreq(t.base, '/v/101?format=markdown');++ assert.ok(/^text\/markdown/.test(q.headers['content-type']), '?format=markdown is the twin');++ });++++ await check('a signed-in page is never replaced by the signed-out render', async () => {++ const mine = await vreq(t.base, '/v/101', { cookie: kimCookie });++ assert.strictEqual(mine.status, 200);++ assert.ok(mine.text.includes('Edit this recording'), 'the owner panel is still there');++ assert.ok(mine.headers.link.includes('/v/101.md'), 'and the page still names its twin');++ // The twin itself is the signed-out render: it carries no owner controls, whoever asks.++ const twin = await vreq(t.base, '/v/101.md', { cookie: kimCookie });++ assert.ok(!twin.text.includes('Edit this recording'), 'the twin is public and signed out');++ });++++ await check('a private recording and a missing page have no twin', async () => {++ assert.strictEqual((await vreq(t.base, '/v/103.md')).status, 404, 'a private recording is not a twin');++ assert.strictEqual((await vreq(t.base, '/v/999.md')).status, 404, 'nor is a missing one');++ assert.strictEqual((await vreq(t.base, '/v/999')).status, 404, 'and the page is a real 404');++ });++++ await live.close();++ done();++})().catch((err) => { console.error(err); process.exit(1); });
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"sharp": "^0.33.5"
"sharp": "^0.33.5"
},
},
@@ -28,6 +28,12 @@
@@ -28,6 +28,12 @@
"node": ">=22.0.0"
"node": ">=22.0.0"
}
}
},
},
+ "node_modules/@acemir/cssom": {
+ "version": "0.9.32",
+ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.32.tgz",
+ "integrity": "sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==",
+ "license": "MIT"
+ },
"node_modules/@electric-sql/pglite": {
"node_modules/@electric-sql/pglite": {
"version": "0.5.8",
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
@@ -524,6 +530,19 @@
@@ -524,6 +530,19 @@
"npm": "1.2.8000 || >= 1.4.16"
"npm": "1.2.8000 || >= 1.4.16"
}
}
},
},
+ "node_modules/boolbase": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
+ "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
"node_modules/buffer-equal-constant-time": {
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -667,6 +686,39 @@
@@ -667,6 +686,39 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/css-select": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",
+ "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^2.0.0",
+ "css-what": "^8.0.0",
+ "domhandler": "^6.0.1",
+ "domutils": "^4.0.2",
+ "nth-check": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/css-what": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",
+ "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
"node_modules/debug": {
"node_modules/debug": {
"version": "2.6.9",
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -713,6 +765,73 @@
@@ -713,6 +765,73 @@
"node": ">=8"
"node": ">=8"
}
}
},
},
+ "node_modules/dom-serializer": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
+ "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^3.0.0",
+ "domhandler": "^6.0.0",
+ "entities": "^8.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
+ "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/domhandler": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
+ "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
+ "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^3.0.0",
+ "domelementtype": "^3.0.0",
+ "domhandler": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
"node_modules/dotenv": {
"node_modules/dotenv": {
"version": "16.6.1",
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -763,6 +882,18 @@
@@ -763,6 +882,18 @@
"node": ">= 0.8"
"node": ">= 0.8"
}
}
},
},
+ "node_modules/entities": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
+ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/es-define-property": {
"node_modules/es-define-property": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -1021,6 +1152,110 @@
@@ -1021,6 +1152,110 @@
"url": "https://github.com/sponsors/EvanHahn"
"url": "https://github.com/sponsors/EvanHahn"
}
}
},
},
+ "node_modules/html-escaper": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
+ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
+ "license": "MIT"
+ },
+ "node_modules/htmlparser2": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
+ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "entities": "^7.0.1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/htmlparser2/node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/http-errors": {
"node_modules/http-errors": {
"version": "2.0.1",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -1172,6 +1407,30 @@
@@ -1172,6 +1407,30 @@
"safe-buffer": "^5.0.1"
"safe-buffer": "^5.0.1"
}
}
},
},
+ "node_modules/linkedom": {
+ "version": "0.18.14",
+ "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.14.tgz",
+ "integrity": "sha512-BRUnkOm4YR5vo+qI3j0EbungB0vZPSCue5AdnTQRaxAGSbsOZiBFRO0rh4zlo8qXUhli8pxvnpEXDklMnlq2Rg==",
+ "license": "ISC",
+ "dependencies": {
+ "@acemir/cssom": "^0.9.32",
+ "css-select": "^7.0.0",
+ "html-escaper": "^3.0.3",
+ "htmlparser2": "^10.1.0",
+ "uhyphen": "^0.2.0"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "peerDependencies": {
+ "canvas": ">= 2"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
"node_modules/lodash.defaults": {
"node_modules/lodash.defaults": {
"version": "4.2.0",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@@ -1310,6 +1569,22 @@
@@ -1310,6 +1569,22 @@
"node": ">= 0.6"
"node": ">= 0.6"
}
}
},
},
+ "node_modules/nth-check": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",
+ "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/fb55/nth-check?sponsor=1"
+ }
+ },
"node_modules/object-inspect": {
"node_modules/object-inspect": {
"version": "1.13.4",
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -1379,12 +1654,13 @@
@@ -1379,12 +1654,13 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.33.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
- "integrity": "sha512-t9XvhrQ+lQebOrFTjMCzI6/7/i4iQn9dmMr/8HVTNW1XoV1exyJa7IH/HfyyKuLxjA8xaowrDfD28LwKIibBNg==",
+ "version": "4.34.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
+ "integrity": "sha512-Rj5uRO3MbWkyIYAm+vK4Y94ugDNYEjEgGkYnPjSej4yMqhalpaPCkU96dzphMGiOUGP1PNWsiKhwaBj8tewgBg==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
- "jsonwebtoken": "^9.0.0"
+ "jsonwebtoken": "^9.0.0",
+ "linkedom": "^0.18.13"
},
},
"bin": {
"bin": {
"openvibe-build-assets": "scripts/build-assets.js"
"openvibe-build-assets": "scripts/build-assets.js"
@@ -1882,6 +2158,12 @@
@@ -1882,6 +2158,12 @@
"node": ">= 0.6"
"node": ">= 0.6"
}
}
},
},
+ "node_modules/uhyphen": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
+ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
+ "license": "ISC"
+ },
"node_modules/unpipe": {
"node_modules/unpipe": {
"version": "1.0.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"sharp": "^0.33.5"
"sharp": "^0.33.5"
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
+'use strict';
+// What openvibe.video tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.
+// Every field is something this host really serves: the public watch pages, the playlist API under
+// /api/v1/video, llms.txt and the sitemap, and the network's sign-in. No documentation page, OpenAPI
+// document or MCP endpoint is published here, so none is named.
+module.exports = {
+ id: 'video',
+ name: 'OpenVibe.Video',
+ purpose: 'Watch videos on the OpenVibe network: public watch pages for OpenVibe.Live\'s stream recordings and clips, browser recordings and uploads with AI captions, chapters and an AI cut, and playlists.',
+ home: 'https://openvibe.video',
+ site: 'openvibe.video',
+ api: '/api/v1/video',
+ policy: {
+ auth: ['none', 'Network OIDC'],
+ rateLimit: 'The public watch pages are not rate-limited by the app; the playlist API is limited to 120 reads a minute and 3,000 an hour per caller',
+ },
+};
@@ -168,6 +168,27 @@ async function createApp(opts = {}) {
@@ -168,6 +168,27 @@ async function createApp(opts = {}) {
const picsRoutes = createPicsRoutes(ctx);
const picsRoutes = createPicsRoutes(ctx);
// GET /<key>.txt on the video host: IndexNow's key file, only when a key is configured.
// GET /<key>.txt on the video host: IndexNow's key file, only when a key is configured.
if (ctx.indexnow.enabled) app.use((req, res, next) => (videoHost && req.hostname === videoHost ? ctx.indexnow.keyFile(req, res, next) : next()));
if (ctx.indexnow.enabled) app.use((req, res, next) => (videoHost && req.hostname === videoHost ? ctx.indexnow.keyFile(req, res, next) : next()));
+ // The agent layer (openvibe-shared/agent-layer) on the video host: /.well-known/agent.json and the Markdown
+ // twins of the public watch pages. Before the video routes, so a twin is the page's own render and never a
+ // route of its own; on the video host only, so the drive never answers with the video's manifest.
+ // A twin request and the manifest only: the layer renders every page as a signed-out visitor, and a watch page
+ // is not one page (the owner's Manage panel, a signed-in person's playlist form). Anything else is the app's
+ // own page, which names its twin in the head and the Link header itself (server/video/layout.js).
+ if (videoHost) {
+ const agentRouter = express.Router();
+ require('openvibe-shared/agent-layer').mount(agentRouter, {
+ descriptor: require('./agent-descriptor'),
+ render: require('./video/agent-pages').createAgentPages({ router: videoRoutes, videoHost }).renderPublicPage,
+ });
+ const wantsTwin = (req) => /\.md$/.test(req.path)
+ || req.query.format === 'markdown'
+ || req.accepts(['html', 'text/markdown']) === 'text/markdown';
+ app.use((req, res, next) => {
+ if (req.hostname !== videoHost) return next();
+ if (req.path === '/.well-known/agent.json' || wantsTwin(req)) return agentRouter(req, res, next);
+ return next();
+ });
+ }
app.use((req, res, next) => {
app.use((req, res, next) => {
if (videoHost && req.hostname === videoHost) return videoRoutes(req, res, next);
if (videoHost && req.hostname === videoHost) return videoRoutes(req, res, next);
if (picsHost && req.hostname === picsHost) return picsRoutes(req, res, next);
if (picsHost && req.hostname === picsHost) return picsRoutes(req, res, next);
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
+'use strict';
+// The Markdown twins of openvibe.video's public pages (openvibe-shared/agent-layer): the home and the
+// watch pages of a recording (/v/:id) and a clip (/c/:id).
+//
+// A twin is the page's own render: the video 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 page
+// that is not public (a private recording, a missing item) has no twin.
+const TWIN_PATH_RE = /^\/(?:v|c)\/\d+$/;
+
+/** A request as an anonymous browser makes it: no session, no cookies, the video host. */
+function anonymousRequest(path, search, host) {
+ const url = path + search;
+ return {
+ method: 'GET', url, originalUrl: url, path,
+ params: {}, 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,
+ 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,
+ 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; },
+ };
+}
+
+function createAgentPages({ router, videoHost }) {
+ async function renderPublicPage(path, { query } = {}) {
+ const p = String(path || '/').replace(/\/+$/, '') || '/';
+ if (p !== '/' && !TWIN_PATH_RE.test(p)) return null;
+ const search = query && query.toString() ? `?${query.toString()}` : '';
+ const req = anonymousRequest(p, search, videoHost);
+ 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 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 private recording, a missing item, a redirect) 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 };
@@ -16,6 +16,7 @@ const appIcon = require('openvibe-shared/app-icon');
@@ -16,6 +16,7 @@ const appIcon = require('openvibe-shared/app-icon');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const shell = require('openvibe-shared/shell');
const shell = require('openvibe-shared/shell');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
+const seo = require('openvibe-shared/seo');
const { html, raw, esc, render } = require('../render/html');
const { html, raw, esc, render } = require('../render/html');
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
@@ -33,6 +34,14 @@ function assetVersion(rel) {
@@ -33,6 +34,14 @@ function assetVersion(rel) {
}
}
const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
+// The pages with a Markdown twin (openvibe-shared/agent-layer): the home and the watch pages of a
+// recording and a clip. The others (search, categories, an account's playlists) have none.
+const TWIN_PATH_RE = /^\/(?:v|c)\/\d+$/;
+function hasTwin(path) {
+ const p = String(path || '/').split('?')[0].replace(/\/+$/, '') || '/';
+ return p === '/' || TWIN_PATH_RE.test(p);
+}
+
/** o: title, description, body (html), config, path, index, noindex, viewer, private, crumbs [{label, href}], scripts [rel],
/** o: title, description, body (html), config, path, index, noindex, viewer, private, crumbs [{label, href}], scripts [rel],
* player (true: the network's video player, openvibe-shared player.js/css, on video[data-ov-player]),
* player (true: the network's video player, openvibe-shared player.js/css, on video[data-ov-player]),
* jsonLd (structured data for the page), image (its share image, an https URL), ogType */
* jsonLd (structured data for the page), image (its share image, an https URL), ogType */
@@ -75,6 +84,9 @@ ${render(o.body)}
@@ -75,6 +84,9 @@ ${render(o.body)}
...(o.image ? { image: o.image, imageAlt: title } : {}),
...(o.image ? { image: o.image, imageAlt: title } : {}),
...(o.ogType ? { type: o.ogType } : {}),
...(o.ogType ? { type: o.ogType } : {}),
home: '/',
home: '/',
+ // The pages with a twin name it, so a reader (or an agent) finds the Markdown of this page.
+ agentLayer: hasTwin(pathNow),
+ llms: '/llms.txt',
navLinks: NAV_LINKS,
navLinks: NAV_LINKS,
navbar: {
navbar: {
service: 'media-hub',
service: 'media-hub',
@@ -117,6 +129,11 @@ const GUEST_HTML = 'public, max-age=0, must-revalidate';
@@ -117,6 +129,11 @@ const GUEST_HTML = 'public, max-age=0, must-revalidate';
function send(res, status, o) {
function send(res, status, o) {
if (!res.get('Cache-Control')) res.set('Cache-Control', o.cache || (o.private ? cache.htmlHeaders({ private: true }) : GUEST_HTML));
if (!res.get('Cache-Control')) res.set('Cache-Control', o.cache || (o.private ? cache.htmlHeaders({ private: true }) : GUEST_HTML));
res.set('Vary', 'Host');
res.set('Vary', 'Host');
+ // A page with a twin names it in the Link header, the same way the twin names the page.
+ if (hasTwin(o.path)) {
+ const canonical = `${String(o.baseUrl).replace(/\/+$/, '')}${String(o.path || '/').split('?')[0]}`;
+ res.set('Link', seo.linkHeaderTags(canonical, '/llms.txt').header);
+ }
res.status(status).type('html').send(renderPage(o));
res.status(status).type('html').send(renderPage(o));
}
}
@@ -1745,7 +1745,7 @@ ${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down
@@ -1745,7 +1745,7 @@ ${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down
r.get('/robots.txt', (_req, res) => {
r.get('/robots.txt', (_req, res) => {
return res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
return res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
- .send(`# openvibe.video: the public watch pages are for search and AI crawlers.\n${seo.robotsTxt({ sitemaps: [`${base}/sitemap.xml`] })}`);
+ .send(`# openvibe.video: the public watch pages are for search and AI crawlers.\n${seo.robotsTxt({ sitemaps: [`${base}/sitemap.xml`] })}\n# Agent manifest: ${base}/.well-known/agent.json\n`);
});
});
// RFC 9116, canonical to this host (openvibe.video), not the drive's.
// RFC 9116, canonical to this host (openvibe.video), not the drive's.
r.get('/.well-known/security.txt', securityTxt.handler(base));
r.get('/.well-known/security.txt', securityTxt.handler(base));
new file mode 100644
new file mode 100644
@@ -0,0 +1,134 @@
@@ -0,0 +1,134 @@
+'use strict';
+/**
+ * The agent layer (openvibe-shared/agent-layer) on openvibe.video: /.well-known/agent.json, and a Markdown twin of
+ * the home, a recording's and a clip's watch page. A twin is the page's own render, so it cannot drift; a twin
+ * request (a .md path, ?format=markdown or an explicit text/markdown preference) is the only thing the layer
+ * answers — a browser's request is the app's own page, because a watch page is not one page (the owner's Manage
+ * panel, a signed-in person's playlist form) and the layer renders every page signed out.
+ */
+const assert = require('assert');
+const http = require('http');
+const { boot, check, done } = require('./helpers/boot');
+
+const usernameOf = (auth) => {
+ const tok = String(auth || '').replace(/^Bearer /, '');
+ try { return JSON.parse(Buffer.from(tok.split('.')[1], 'base64url').toString('utf8')).username || null; } catch { return null; }
+};
+
+function startLive() {
+ const vods = {
+ 101: { id: 101, title: 'Kim stream', description: 'First', duration_seconds: 3600, created_at: '2026-10-12T10:00:00.000Z', playback_url: 'https://openvibe.media/v/101', visibility: 'public', username: 'kim', display_name: 'Kim' },
+ 103: { id: 103, title: 'Kim private', description: '', duration_seconds: 20, created_at: '2026-10-12T09:00:00.000Z', playback_url: 'https://openvibe.media/v/103', visibility: 'private', username: 'kim', display_name: 'Kim' },
+ };
+ const clips = {
+ 201: { id: 201, vod_id: 101, title: 'Kim clip', start_time: 5, end_time: 15, duration_seconds: 10, playback_url: 'https://openvibe.media/c/201', visibility: 'public', username: 'kim', display_name: 'Kim' },
+ };
+ const server = http.createServer((req, res) => {
+ const url = new URL(req.url, 'http://127.0.0.1');
+ const who = usernameOf(req.headers.authorization);
+ const json = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); };
+ const m = /^\/api\/(vods|clips)\/(\d+)$/.exec(url.pathname);
+ if (m) {
+ const item = (m[1] === 'vods' ? vods : clips)[m[2]];
+ if (!item) return json(404, { error: 'not found' });
+ if (item.visibility === 'private' && !(who && who === item.username)) return json(404, { error: 'not found' });
+ return json(200, m[1] === 'vods' ? { vod: item, clips: [] } : { clip: item });
+ }
+ if (url.pathname === '/api/vods') return json(200, { vods: Object.values(vods).filter((v) => v.visibility === 'public'), hasMore: false });
+ if (url.pathname === '/api/clips') return json(200, { clips: Object.values(clips).filter((c) => c.visibility === 'public') });
+ return json(404, { error: 'not found' });
+ });
+ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => server.close(r)) })));
+}
+
+function vreq(base, path, { method = 'GET', cookie, host = 'openvibe.video', accept } = {}) {
+ return new Promise((resolve, reject) => {
+ const u = new URL(base + path);
+ const h = { Host: host };
+ if (cookie) h.cookie = cookie;
+ if (accept) h.accept = accept;
+ const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method, headers: h }, (res) => {
+ const chunks = [];
+ res.on('data', (c) => chunks.push(c));
+ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, text: Buffer.concat(chunks).toString('utf8') }));
+ });
+ req.on('error', reject);
+ req.end();
+ });
+}
+
+(async () => {
+ const live = await startLive();
+ const t = await boot({ env: { MEDIAHUB_VIDEO_URL: 'https://openvibe.video', MEDIAHUB_LIVE_URL: live.url, MEDIAHUB_LIVE_PUBLIC_URL: 'https://openvibe.live', MEDIAHUB_CHAT_URL: live.url } });
+ const kim = t.network.addUser('kim');
+ const kimCookie = t.signIn(kim);
+
+ await check('the manifest is the video host\'s own', async () => {
+ const r = await vreq(t.base, '/.well-known/agent.json');
+ assert.strictEqual(r.status, 200);
+ assert.ok(/^application\/json/.test(r.headers['content-type']), 'JSON');
+ const d = JSON.parse(r.text);
+ assert.strictEqual(d.id, 'video');
+ assert.strictEqual(d.site, 'openvibe.video');
+ assert.strictEqual(d.home, 'https://openvibe.video');
+ assert.strictEqual(d.api, 'https://openvibe.video/api/v1/video', 'the API is absolute, on this host');
+ assert.strictEqual(d.llms, 'https://openvibe.video/llms.txt');
+ 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');
+ });
+
+ await check('a recording\'s twin is the page\'s own render', async () => {
+ const r = await vreq(t.base, '/v/101.md');
+ assert.strictEqual(r.status, 200);
+ assert.ok(/^text\/markdown/.test(r.headers['content-type']), 'markdown');
+ assert.ok(r.text.startsWith('---\ntitle: '), 'front matter first');
+ assert.ok(r.text.includes('canonical: "https://openvibe.video/v/101"'), 'the twin names its page');
+ assert.ok(r.text.includes('# Kim stream'), 'the page\'s own title');
+ assert.ok(r.text.includes('First'), 'and its description');
+ const clip = await vreq(t.base, '/c/201.md');
+ assert.strictEqual(clip.status, 200);
+ assert.ok(clip.text.includes('# Kim clip') && clip.text.includes('canonical: "https://openvibe.video/c/201"'), 'a clip has its twin too');
+ const home = await vreq(t.base, '/index.md');
+ assert.strictEqual(home.status, 200);
+ assert.ok(home.text.includes('canonical: "https://openvibe.video/"'), 'and so does the home');
+ });
+
+ await check('the HTML page names its twin, in the head and the Link header', async () => {
+ const r = await vreq(t.base, '/v/101');
+ assert.strictEqual(r.status, 200);
+ assert.ok(/<link rel="alternate" type="text\/markdown" href="https:\/\/openvibe\.video\/v\/101\.md">/.test(r.text), 'the head names the twin');
+ assert.ok(/<link rel="describedby" href="\/llms\.txt">/.test(r.text), 'and llms.txt');
+ assert.ok(r.headers.link.includes('<https://openvibe.video/v/101.md>; rel="alternate"; type="text/markdown"'), 'the Link header too');
+ assert.ok(!(await vreq(t.base, '/search')).headers.link, 'a page with no twin claims none');
+ });
+
+ await check('a preference for markdown gets the twin from the page path', async () => {
+ const r = await vreq(t.base, '/v/101', { accept: 'text/markdown' });
+ assert.strictEqual(r.status, 200);
+ assert.ok(/^text\/markdown/.test(r.headers['content-type']), 'markdown, not HTML');
+ assert.ok(r.text.includes('# Kim stream'));
+ const q = await vreq(t.base, '/v/101?format=markdown');
+ assert.ok(/^text\/markdown/.test(q.headers['content-type']), '?format=markdown is the twin');
+ });
+
+ await check('a signed-in page is never replaced by the signed-out render', async () => {
+ const mine = await vreq(t.base, '/v/101', { cookie: kimCookie });
+ assert.strictEqual(mine.status, 200);
+ assert.ok(mine.text.includes('Edit this recording'), 'the owner panel is still there');
+ assert.ok(mine.headers.link.includes('/v/101.md'), 'and the page still names its twin');
+ // The twin itself is the signed-out render: it carries no owner controls, whoever asks.
+ const twin = await vreq(t.base, '/v/101.md', { cookie: kimCookie });
+ assert.ok(!twin.text.includes('Edit this recording'), 'the twin is public and signed out');
+ });
+
+ await check('a private recording and a missing page have no twin', async () => {
+ assert.strictEqual((await vreq(t.base, '/v/103.md')).status, 404, 'a private recording is not a twin');
+ assert.strictEqual((await vreq(t.base, '/v/999.md')).status, 404, 'nor is a missing one');
+ assert.strictEqual((await vreq(t.base, '/v/999')).status, 404, 'and the page is a real 404');
+ });
+
+ await live.close();
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });