openvibers/openvibe.news Commits

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

OP OpenVibers committed 15 days ago · 7673239a43 · 10 files, +420 −13

openvibe-shared v4.34.0 (was v4.33.0). The descriptor is what openvibe.news really serves: the public stories,
topics, wire, live and AI briefs, /api/v1, llms.txt/llms-full.txt and the sitemap; no OpenAPI document or separate
docs page is published. The mount is gated to the manifest and twin requests only, before the public router, and
renders through that same router signed out, so a story twin carries the page's byline, date and cited sources'
links without ever carrying editor controls or a signed-in view. HTML pages name their twin in the head and the
Link header, robots.txt ends with the manifest comment, and /.well-known/security.txt is untouched.

 package.json
 package-lock.json
 server/agent-descriptor.js
 server/agent-pages.js
 server/app.js
 server/http/public.js
 server/http/discovery.js
 server/render/layout.js
 STATUS.json
 README.md

Changes

ddiff --git a/README.md b/README.md
iindex c1269db..54d5ea1 100644
---- a/README.md
++++ b/README.md
@@@ -449,6 +449,6 @@ 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
- openvibe-publishing: v1.4.1
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 4ce82ce..97370bd 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -9,11 +9,11 @@
"domain": "openvibe.news",
"contracts": "openvibe-contracts v0.222.0",
"sdk": "openvibe-sdk v0.67.0",
-- "shared": "openvibe-shared v4.33.0",
++ "shared": "openvibe-shared v4.34.0",
"packages": [
"openvibe-publishing v1.4.1",
"openvibe-contracts v0.222.0",
-- "openvibe-shared v4.33.0",
++ "openvibe-shared v4.34.0",
"openvibe-sdk v0.67.0"
],
"capabilities": [
@@@ -52,5 +52,5 @@
"SDK outbox events and Search index documents",
"per-actor limits (openvibe-sdk/limits)"
],
-- "updated": "2026-10-28"
++ "updated": "2026-10-29"
}
ddiff --git a/package-lock.json b/package-lock.json
iindex 25e4e16..a28e532 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -18,7 +18,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.1",
"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"
},
"devDependencies": {
@@@ -29,6 +29,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",
@@@ -118,6 +124,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",
@@@ -220,6 +239,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",
@@@ -257,6 +309,73 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
++ "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",
@@@ -307,6 +426,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",
@@@ -565,6 +696,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",
@@@ -710,6 +945,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",
@@@ -848,6 +1107,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",
@@@ -935,12 +1210,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"
@@@ -1383,6 +1659,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 6abbb29..6c20ce2 100644
---- a/package.json
++++ b/package.json
@@@ -32,7 +32,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.1",
"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"
},
"devDependencies": {
ddiff --git a/server/agent-descriptor.js b/server/agent-descriptor.js
nnew file mode 100644
iindex 0000000..08c33c8
---- /dev/null
++++ b/server/agent-descriptor.js
@@@ -0,0 +1,17 @@
++'use strict';
++// What openvibe.news tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.
++// Every field is something this host really serves: the public stories, topics, wire and briefs, the
++// JSON API under /api/v1, llms.txt/llms-full.txt and the sitemap. No OpenAPI document or separate
++// documentation page is published, so neither is named.
++module.exports = {
++ id: 'news',
++ name: 'OpenVibe.News',
++ purpose: 'Source-backed news: editors write stories where every paragraph cites the reports it rests on, with AI briefs, a live wire of public sources and topic pages.',
++ home: 'https://openvibe.news',
++ site: 'openvibe.news',
++ api: '/api/v1',
++ policy: {
++ auth: ['none', 'Network OIDC'],
++ rateLimit: 'The public pages are not limited by the app; /api/v1 is limited to 240 requests a minute per address and sign-in attempts to 60 per 15 minutes',
++ },
++};
ddiff --git a/server/agent-pages.js b/server/agent-pages.js
nnew file mode 100644
iindex 0000000..0a66031
---- /dev/null
++++ b/server/agent-pages.js
@@@ -0,0 +1,83 @@
++'use strict';
++// The Markdown twins of openvibe.news' public pages (openvibe-shared/agent-layer): the home, topics,
++// the wire, live, AI briefs and stories. A twin is the page's own render: the public router answers the
++// request in-process, signed out (no session cookie, no Authorization), so a twin cannot drift from the
++// page a browser gets and never carries a signed-in person's editor controls or private cache.
++//
++// A retracted story keeps its twin (the page is still readable and labelled retracted); an unpublished
++// or missing story, a missing topic or brief answers 404 and has no twin.
++const TWIN_PATH_RE = /^\/(?:topics(?:\/[^/]+)?|wire|live|briefs(?:\/[^/]+)?|stories\/[^/]+)?$/;
++
++/** A request as an anonymous browser makes it: no session, no cookies, no Authorization header. */
++function anonymousRequest(url, path, query, host) {
++ 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,
++ 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)]; },
++ vary(field) {
++ const prev = this.headers.Vary ? String(this.headers.Vary).split(',').map((s) => s.trim()) : [];
++ for (const f of String(field || '').split(',').map((s) => s.trim()).filter(Boolean)) {
++ if (!prev.some((p) => p.toLowerCase() === f.toLowerCase())) prev.push(f);
++ }
++ this.headers.Vary = prev.join(', ');
++ return this;
++ },
++ 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 search = query ? query.toString() : '';
++ const url = search ? `${p}?${search}` : p;
++ const req = anonymousRequest(url, p, Object.fromEntries(new URLSearchParams(search)), host);
++ const res = captureResponse();
++ await new Promise((resolve) => {
++ let settled = false;
++ const finish = () => { if (!settled) { settled = true; resolve(); } };
++ const send = res.send.bind(res);
++ res.send = (body) => { send(body); finish(); return res; };
++ const redirect = res.redirect.bind(res);
++ res.redirect = (code, location) => { redirect(code, location); 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 (an unpublished story, a missing topic or brief) 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 de6918e..0204467 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -187,6 +187,23 @@ async function createApp(opts = {}) {
app.use(createDiscoveryRoutes(ctx));
const publicRoutes = createPublicRoutes(ctx);
ctx.publicRoutes = publicRoutes;
++ // The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json, and the Markdown twins of the
++ // public pages. Gated to the manifest and twin requests only, so a browser (signed in or not) keeps the
++ // page its own route renders; the twins are rendered signed out through the same public router.
++ {
++ const agentRouter = express.Router();
++ require('openvibe-shared/agent-layer').mount(agentRouter, {
++ descriptor: require('./agent-descriptor'),
++ render: require('./agent-pages').createAgentPages({ router: publicRoutes.router, host: new URL(config.baseUrl).hostname }).renderPublicPage,
++ });
++ app.use((req, res, next) => {
++ // The manifest exactly: the rest of /.well-known/ (security.txt) belongs to the app.
++ if (req.path === '/.well-known/agent.json') return agentRouter(req, res, next);
++ if (String(req.path || '').endsWith('.md') || (req.query && req.query.format === 'markdown')
++ || req.accepts(['html', 'text/markdown']) === 'text/markdown') return agentRouter(req, res, next);
++ return next();
++ });
++ }
app.use(['/edit', '/clusters'], rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }));
app.use(createEditorRoutes({ ...ctx, publicRoutes }));
app.use(publicRoutes.router);
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 386d6ce..1fcc57f 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -57,6 +57,7 @@ function createDiscoveryRoutes(ctx) {
'# sign-in and the API are not for crawling. Pages decide their own indexability (meta robots /',
'# X-Robots-Tag): retracted stories stay readable but are noindex. A Disallow is not a noindex.',
sharedSeo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: ['/edit', '/clusters/', '/auth/', '/api/', '/internal/'] }),
++ `# Agent manifest: ${abs('/.well-known/agent.json')}`,
].join('\n');
res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(body);
});
ddiff --git a/server/http/public.js b/server/http/public.js
iindex 0f4b667..5bdfad8 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -50,6 +50,7 @@ function createPublicRoutes(ctx) {
function cacheHeaders(res, { cacheable, robots }) {
res.vary('Cookie');
res.vary('Authorization');
++ res.vary('Accept');
if (cacheable) res.set('Cache-Control', cache.htmlHeaders({ maxAge: 60 }));
else res.set('Cache-Control', cache.htmlHeaders({ private: true }));
if (robots && robots !== 'index, follow') res.set('X-Robots-Tag', robots);
@@@ -57,7 +58,12 @@ function createPublicRoutes(ctx) {
function send(req, res, status, page, { cacheable = false } = {}) {
cacheHeaders(res, { cacheable: cacheable && req.viewer.kind === 'anonymous' && status === 200, robots: page.decision.robots });
-- res.status(status).type('html').send(renderPage({ ...page, viewer: req.viewer, config, path: req.originalUrl, editor: access.isEditor(config, req.viewer) }));
++ // The public pages with a Markdown twin (server/agent-pages.js) name it in the head and in the Link header.
++ const canonical = page.canonical || `${config.baseUrl}${req.path}`;
++ const hasTwin = status === 200 && require('../agent-pages').TWIN_PATH_RE.test(req.path);
++ const head = hasTwin ? require('openvibe-shared/agent-layer').linkHeaderTags(canonical, `${config.baseUrl}/llms.txt`) : null;
++ if (head) res.set('Link', head.header);
++ res.status(status).type('html').send(renderPage({ ...page, viewer: req.viewer, config, path: req.originalUrl, editor: access.isEditor(config, req.viewer), head: head ? head.head : '' }));
}
/** A decision for a page that is not a story (lists, messages). */
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 97d3ffb..e7b884d 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -106,8 +106,9 @@ function renderPage(o) {
home: '/',
css: asset('css/news.css'),
styles: o.styles, // openvibe-shared stylesheet names (the home page's showcase.css)
-- // This service's own extra stylesheets for one kind of page (the briefs and live pages' css/briefs.css).
-- head: (o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`).join('\n'),
++ // This service's own extra stylesheets for one kind of page (the briefs and live pages' css/briefs.css)
++ // and the discovery tags of the page's Markdown twin (public.js's send adds both together).
++ head: [...(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`), o.head].filter(Boolean).join('\n'),
release: RELEASE,
account,
body: o.body,