openvibers/openvibe.blog Commits

Agent layer: /.well-known/agent.json and Markdown twins of the published posts (#119)

OP OpenVibers committed 15 days ago · aa5da87f6f · 10 files, +422 −12

openvibe-shared v4.34.0 (was v4.33.0). The descriptor is what openvibe.blog really serves: the front page,
every blog's index and its published posts, /api/v1 and its OpenAPI document, llms.txt/llms-full.txt and the
sitemap; no separate docs page. 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 post twin carries the post's byline, date,
citations and series links without ever carrying a signed-in person's edit controls. A draft, private or
members-only post answers 404 anonymously, so a draft's twin never answers. 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 b2c1aa3..9003413 100644
---- a/README.md
++++ b/README.md
@@@ -309,7 +309,7 @@ Called elsewhere, as the service principal `blog`: `identity.subject.resolve` an
`openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional: without `VALKEY_URL` they count per process).
- **Packages** (all pinned by release tarball): `openvibe-publishing` v1.4.1 (async PostgreSQL stores: revisions,
schedule, taxonomy, citations, media, discussion, seo, authorship, index-hooks, ssr, layout), `openvibe-contracts`
-- v0.97.0, `openvibe-shared` v4.33.0 (Frame, app icon, footer, legal, release, metrics, ready, theme presets),
++ v0.97.0, `openvibe-shared` v4.34.0 (Frame, app icon, footer, legal, release, metrics, ready, theme presets),
`openvibe-sdk` v0.35.0 (sso, db, PostgreSQL events outbox, service tokens, per-actor limits, testing).
- **OpenVibe.Network:**
- SSO: the OAuth client `blog` is already seeded with redirect
@@@ -520,6 +520,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 cf84b42..72de48f 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -9,11 +9,11 @@
"domain": "openvibe.blog",
"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": [
@@@ -55,5 +55,5 @@
"per-actor limits (openvibe-sdk/limits)"
],
"database": "PostgreSQL ov_blog (ADR-035), schema migrations/0001_initial.sql applied at boot",
-- "updated": "2026-10-28"
++ "updated": "2026-10-29"
}
ddiff --git a/package-lock.json b/package-lock.json
iindex a7e729b..c3316e4 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 0acb283..7df5f98 100644
---- a/package.json
++++ b/package.json
@@@ -30,7 +30,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..3f962a9
---- /dev/null
++++ b/server/agent-descriptor.js
@@@ -0,0 +1,18 @@
++'use strict';
++// What openvibe.blog tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.
++// Every field is something this host really serves: the public front page, every blog's index and its
++// published posts, the JSON API under /api/v1 and its OpenAPI document, llms.txt/llms-full.txt and the
++// sitemap. No separate documentation page is published, so none is named.
++module.exports = {
++ id: 'blog',
++ name: 'OpenVibe.Blog',
++ purpose: 'The official OpenVibe blog and a blog for every member: authored posts with citations, series, categories and a correction history.',
++ home: 'https://openvibe.blog',
++ site: 'openvibe.blog',
++ api: '/api/v1',
++ openapi: '/openapi.json',
++ 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..3b1f03c
---- /dev/null
++++ b/server/agent-pages.js
@@@ -0,0 +1,83 @@
++'use strict';
++// The Markdown twins of openvibe.blog's public pages (openvibe-shared/agent-layer): the front page, a
++// blog's index and its published posts. 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 edit controls or private cache.
++//
++// A draft, a private post, a restricted post the anonymous reader may not see and a missing page answers
++// 404 (domain/access.js canReadPost) and has no twin, so a draft's twin never answers.
++const TWIN_PATH_RE = /^\/(?:@[^/]+(?:\/[^/]+)?)?$/;
++
++/** 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 the anonymous reader may not see (a draft, a private or members-only post) 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 9c5d7fd..9c6a06f 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -219,6 +219,23 @@ async function createApp(opts = {}) {
app.use(require('./desks/routes').createDeskRoutes(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('/write', rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }), createEditorRoutes({ ...ctx, publicRoutes }));
app.use(publicRoutes.router);
app.use((req, res) => publicRoutes.notFound(req, res));
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 0b31321..f6bc0bd 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -55,6 +55,7 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
'# public posts, feeds and sitemaps. Drafts, the editor, sign-in and the API are not for crawling.',
'# Pages decide their own indexability (meta robots / X-Robots-Tag); a Disallow is not a noindex.',
sharedSeo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: ['/write', '/auth/', '/api/'] }),
++ `# 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 4a1fba3..934abf9 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -47,6 +47,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);
@@@ -54,7 +55,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 }));
++ // 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, head: head ? head.head : '' }));
}
/** A decision for a page that is not a post (collections, messages). */
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 5351bba..e4c8478 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -111,6 +111,9 @@ function renderPage(o) {
body: o.body,
mainClass: 'page blog-surface',
bodyClass: o.bodyClass,
++ // The discovery tags of the page's Markdown twin (public.js's send adds them; renderDocument has no
++ // agentLayer switch).
++ head: o.head,
});
// A blog's theme preset applies to its content surface only: the custom properties and the preset's
// slug stay on <main> (the layout writes that tag before any page body, so the first match is it).