openvibers/openrestream Commits

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

OP OpenVibers committed 15 days ago · e14d24739f · 10 files, +420 −11

Changes

ddiff --git a/README.md b/README.md
iindex f1ed5cf..fce06b7 100644
---- a/README.md
++++ b/README.md
@@@ -315,5 +315,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.29.0
++- openvibe-shared: v4.34.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 3919f5f..f3706e9 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -10,7 +10,7 @@
"domain": "openre.stream",
"contracts": "openvibe-contracts v0.222.0",
"sdk": "openvibe-sdk v0.67.0",
-- "shared": "openvibe-shared v4.29.0",
++ "shared": "openvibe-shared v4.34.0",
"protocols": {
"rtmp": "ingest ported (openre-rtmp-ingest)",
"restream": "RTMP source to RTMP/RTMPS/SRT destinations ported (openre-restream-worker)",
ddiff --git a/package-lock.json b/package-lock.json
iindex 1f5defd..e154262 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"node-media-server": "2.7.4",
"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.29.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
"pg": "^8.23.0",
"sdp-transform": "^3.0.0",
"ws": "^8.18.0"
@@@ -30,6 +30,12 @@
"node": ">=22.12.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",
@@@ -481,6 +487,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",
@@@ -617,6 +636,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/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
@@@ -685,6 +737,73 @@
"node": ">=6"
}
},
++ "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",
@@@ -735,6 +854,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",
@@@ -1065,6 +1196,110 @@
"node": ">= 0.4"
}
},
++ "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",
@@@ -1273,6 +1508,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": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@@@ -1613,6 +1872,22 @@
"node": ">=8.0.0"
}
},
++ "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",
@@@ -1682,12 +1957,13 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.29.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.29.0",
-- "integrity": "sha512-IcWh6eMwoLMoYjf2dHVGdVCNv1B0X2bm/2ZHLBOYCfF1Y1Nawn2wL+eMvo57JDjYL1Vm9RnL3bK1eZamCW1zQA==",
++ "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"
@@@ -2253,6 +2529,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",
ddiff --git a/package.json b/package.json
iindex 394554b..34ccf39 100644
---- a/package.json
++++ b/package.json
@@@ -33,7 +33,7 @@
"node-media-server": "2.7.4",
"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.29.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
"pg": "^8.23.0",
"sdp-transform": "^3.0.0",
"ws": "^8.18.0"
ddiff --git a/server/agent-descriptor.js b/server/agent-descriptor.js
nnew file mode 100644
iindex 0000000..889e8cb
---- /dev/null
++++ b/server/agent-descriptor.js
@@@ -0,0 +1,17 @@
++'use strict';
++// What openre.stream tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.
++// Every field is something this host really serves: the front page and the update log, the JSON API under /api/v1,
++// llms.txt and the sitemap. Creating a stream and its keys needs an OpenVibe account (Network OIDC); services call
++// the API with a Network client-credentials token. No OpenAPI document, documentation page or MCP endpoint is
++// published here, so none is named.
++module.exports = {
++ id: 'openre',
++ name: 'OpenRestream',
++ purpose: 'Send one stream from OBS or any RTMP encoder and restream it to Twitch, YouTube, Kick and any RTMP, RTMPS or SRT server at once, with the person\'s OpenVibe Live channel on by default; free and open source, with no follower minimum.',
++ home: 'https://openre.stream',
++ site: 'openre.stream',
++ api: '/api/v1',
++ policy: {
++ auth: ['none', 'Network OIDC', 'Network service token'],
++ },
++};
ddiff --git a/server/agent-pages.js b/server/agent-pages.js
nnew file mode 100644
iindex 0000000..dff1436
---- /dev/null
++++ b/server/agent-pages.js
@@@ -0,0 +1,75 @@
++'use strict';
++// The Markdown twins of OpenRestream's public pages (openvibe-shared/agent-layer): the front page and the update log.
++//
++// A twin is the page's own render: the UI 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 owner's view. The streams, one stream, destinations,
++// sessions and one session render a person's own data and have no twin.
++const TWIN_PATH_RE = /^\/(?:updates)?$/;
++
++/** A request as an anonymous browser makes it: no session, no cookies. */
++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,
++ 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, host }) {
++ async function renderPublicPage(path, { query } = {}) {
++ const p = String(path || '/').replace(/\/+$/, '') || '/';
++ if (!TWIN_PATH_RE.test(p)) return null;
++ const req = anonymousRequest(p, query, host);
++ req.viewer = { kind: 'anonymous' };
++ req.caller = { kind: 'anonymous' };
++ 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 needs a person, a missing page or a page that is not public has no twin.
++ if (res.statusCode !== 200 || res.body == null) return null;
++ const html = Buffer.isBuffer(res.body) ? res.body.toString('utf8') : String(res.body);
++ if (!/^text\/html/i.test(String(res.headers['Content-Type'] || ''))) return null;
++ return { html };
++ }
++
++ return { renderPublicPage };
++}
++
++module.exports = { createAgentPages, TWIN_PATH_RE };
ddiff --git a/server/app.js b/server/app.js
iindex c9113c2..9a0a7d5 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -163,7 +163,28 @@ function createApp({ rt, auth, keys, log = console, fetchImpl }) {
app.use('/auth', createSsoRoutes({ config, auth, fetchImpl }));
// Crawl and machine-readability artifacts (robots.txt, sitemap.xml, llms.txt): openvibe-shared/seo.
app.use('/', createDiscoveryRoutes({ config, releaseLastmod: release.full().released_at === release.full().booted_at ? undefined : release.full().released_at }));
-- app.use('/', createUiRouter({ rt, auth, limits: actorLimits, liveLink }));
++ // The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json, and the Markdown twins of the two
++ // public pages (the front page and the update log). Entered only for the manifest exactly (the rest of
++ // /.well-known/ belongs to the app) and a twin request — a `.md` path, ?format=markdown or an explicit
++ // text/markdown preference — because the layer renders pages signed out and the streams, destinations and
++ // sessions pages are one owner's data. It comes before the UI routes so `/index.md` is not read as a missing
++ // page; the pages name their own twins in the head and the Link header (server/ui/layout.js, server/ui/routes.js).
++ {
++ const uiRoutes = createUiRouter({ rt, auth, limits: actorLimits, liveLink });
++ const agentRouter = express.Router();
++ require('openvibe-shared/agent-layer').mount(agentRouter, {
++ descriptor: require('./agent-descriptor'),
++ render: require('./agent-pages').createAgentPages({ router: uiRoutes, host: new URL(config.baseUrl).hostname }).renderPublicPage,
++ });
++ app.use('/', (req, res, next) => {
++ 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('/', uiRoutes);
++ }
app.use((req, res) => contracts.http.sendProblem(res, 404, 'openre.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov }));
// eslint-disable-next-line no-unused-vars
ddiff --git a/server/discovery.js b/server/discovery.js
iindex 5cb37f1..0eb99f6 100644
---- a/server/discovery.js
++++ b/server/discovery.js
@@@ -24,7 +24,8 @@ function createDiscoveryRoutes({ config, releaseLastmod }) {
const abs = (p) => seo.absolute(p, config.baseUrl);
router.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders()).send(
-- seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW })));
++ seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW })
++ + `# Agent manifest: ${abs('/.well-known/agent.json')}\n`));
router.get('/sitemap.xml', (_req, res) => res.type('application/xml').set('Cache-Control', cache.htmlHeaders()).send(
seo.sitemapXml(PUBLIC_PATHS.map((loc) => ({ loc: abs(loc), lastmod: releaseLastmod })))));
ddiff --git a/server/ui/layout.js b/server/ui/layout.js
iindex 217812c..54d0aff 100644
---- a/server/ui/layout.js
++++ b/server/ui/layout.js
@@@ -2,6 +2,8 @@
const ovServe = require('openvibe-shared/serve');
const seo = require('openvibe-shared/seo');
const social = require('openvibe-shared/social');
++const { TWIN_PATH_RE } = require('../agent-pages');
++const { linkHeaderTags } = require('openvibe-shared/agent-layer');
/**
* Page shell for openre.stream: server-rendered HTML that works without JavaScript, with the shared
* OpenVibe Frame (theme loader, navbar and footer from openvibe.network, footer SSR from the
@@@ -69,6 +71,8 @@ function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,
image: indexable ? social.url('openre') : null,
jsonLd: indexable ? jsonLd : null,
});
++ // A public page names its Markdown twin (openvibe-shared/agent-layer); a signed-in owner page has none.
++ const twinHead = TWIN_PATH_RE.test(canonicalPath.split('?')[0]) ? linkHeaderTags(canonical, '/llms.txt').head : '';
const nav = {
service: 'openre',
apiBase: NETWORK_URL,
@@@ -98,6 +102,7 @@ function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
${seoHead}
++${twinHead}
${icon}
<script src="${ovServe.url('theme-loader.js')}" defer></script>
<style>${CSS}</style>
ddiff --git a/server/ui/routes.js b/server/ui/routes.js
iindex 977f485..af9fd0a 100644
---- a/server/ui/routes.js
++++ b/server/ui/routes.js
@@@ -20,6 +20,8 @@ const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
const express = require('express');
const { renderPage, esc } = require('./layout');
++const { TWIN_PATH_RE } = require('../agent-pages');
++const { linkHeaderTags } = require('openvibe-shared/agent-layer');
const { StoreError } = require('../store/definitions');
const { testDestination } = require('../destination-test');
@@@ -61,7 +63,13 @@ function createUiRouter({ rt, auth, limits = null, liveLink = null }) {
// The API's per-person limits count these form posts too (a test, a new stream, a rotation): one budget per person.
if (limits) router.use(limits);
-- const page = (req, res, o, status = 200) => res.status(status).type('html').send(renderPage({ ...o, user: req.caller.kind === 'user' ? req.caller : null, config }));
++ const page = (req, res, o, status = 200) => {
++ // The Link header names the same twin as the head, where the site sets headers; a signed-in page has none.
++ if (TWIN_PATH_RE.test(String(o.canonicalPath || '/').split('?')[0])) {
++ res.set('Link', linkHeaderTags(config.baseUrl + (o.canonicalPath || '/'), '/llms.txt').header);
++ }
++ return res.status(status).type('html').send(renderPage({ ...o, user: req.caller.kind === 'user' ? req.caller : null, config }));
++ };
const csrfField = (req) => `<input type="hidden" name="_csrf" value="${esc(csrfFor(req.caller.token))}">`;
const form = (req, action, inner, { cls = '', confirm } = {}) => `<form method="post" action="${esc(action)}" class="${cls}"${confirm ? ` onsubmit="return confirm('${esc(confirm)}')"` : ''}>${csrfField(req)}${inner}</form>`;