openvibers/openvibe.wiki Commits

Agent layer: /.well-known/agent.json and Markdown twins of the home, wikis and articles (#144)

OP OpenVibers committed 15 days ago · 57b660aeb4 · 10 files, +423 −16

openvibe-shared v4.34.0 (was v4.33.0). The descriptor is what openvibe.wiki really serves: the public wikis and
pages, /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 page router, and renders through that same
router signed out, so the home, a wiki's page tree and an article twin keep the page's own headings, links and
revision note without ever serving 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/render/layout.js
 server/http/pages.js
 server/http/machine.js
 STATUS.json
 README.md

Changes

ddiff --git a/README.md b/README.md
iindex a1cf534..dc630bb 100644
---- a/README.md
++++ b/README.md
@@@ -316,7 +316,7 @@ Production: `/opt/openvibe.wiki`, env `/etc/openvibe/wiki.env`, unit
- PostgreSQL 18 and Valkey 9 (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through
`openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional: without `VALKEY_URL` they count per process).
- `openvibe-publishing` v1.3.0 (async PostgreSQL stores, ingest and publication chassis), `openvibe-contracts`
-- v0.107.0, `openvibe-shared` v4.33.0
++ v0.107.0, `openvibe-shared` v4.34.0
(Frame, release, metrics, readiness, SEO helpers, legal pages), `openvibe-sdk` v0.67.0 (db, auth, PostgreSQL
events outbox, per-actor limits, account export and deletion, testing) — pinned release tarballs.
- OpenVibe.Network (SSO, JWKS, service principal `wiki`), OpenVibe.Events, OpenVibe.Community,
@@@ -446,6 +446,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.3.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex b58d56b..a66064f 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -9,10 +9,10 @@
"domain": "openvibe.wiki",
"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.3.0",
-- "openvibe-shared v4.33.0",
++ "openvibe-shared v4.34.0",
"openvibe-sdk v0.67.0"
],
"capabilities": [
@@@ -46,5 +46,5 @@
"per-actor limits (openvibe-sdk/limits)"
],
"database": "PostgreSQL ov_wiki (ADR-035), schema migrations/0001_initial.sql",
-- "updated": "2026-10-28"
++ "updated": "2026-10-29"
}
ddiff --git a/package-lock.json b/package-lock.json
iindex e094b6c..90993e8 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,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.3.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"
},
"devDependencies": {
@@@ -27,6 +27,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",
@@@ -116,6 +122,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",
@@@ -218,6 +237,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",
@@@ -255,6 +307,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",
@@@ -305,6 +424,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",
@@@ -551,6 +682,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",
@@@ -696,6 +931,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",
@@@ -834,6 +1093,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",
@@@ -921,12 +1196,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"
@@@ -1369,6 +1645,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 772ec4b..6bcbeea 100644
---- a/package.json
++++ b/package.json
@@@ -28,7 +28,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.3.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"
},
"engines": {
ddiff --git a/server/agent-descriptor.js b/server/agent-descriptor.js
nnew file mode 100644
iindex 0000000..51629d4
---- /dev/null
++++ b/server/agent-descriptor.js
@@@ -0,0 +1,18 @@
++'use strict';
++// What openvibe.wiki tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.
++// Every field is something this host really serves: the public wiki pages (the home, a wiki's page tree and
++// its articles), the JSON API under /api/v1 with its OpenAPI document, llms.txt/llms-full.txt and the sitemap.
++// No separate documentation page is published, so none is named.
++module.exports = {
++ id: 'wiki',
++ name: 'OpenVibe.Wiki',
++ purpose: 'Wikis with page trees, immutable revision history, citations attached to the revision that used them, infoboxes and internal links, and a discussion thread on every page.',
++ home: 'https://openvibe.wiki',
++ site: 'openvibe.wiki',
++ 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 300 requests a minute per address, search to 60 a minute and sign-in attempts to 60 a quarter of an hour',
++ },
++};
ddiff --git a/server/agent-pages.js b/server/agent-pages.js
nnew file mode 100644
iindex 0000000..d5f0f24
---- /dev/null
++++ b/server/agent-pages.js
@@@ -0,0 +1,74 @@
++'use strict';
++// The Markdown twins of OpenVibe.Wiki's public pages (openvibe-shared/agent-layer): the home, a wiki's
++// page tree (/s/:space) and its articles (/w/:space/:slug).
++//
++// A twin is the page's own render: the page router answers the request in-process, signed out, so a twin
++// cannot drift from the page a browser gets and never carries a signed-in person's view. A wiki or a page
++// that is not public (a members-only space, an unpublished article, a missing page) answers 404 and has
++// no twin.
++const TWIN_PATH_RE = /^\/(?:s\/[^/]+|w\/[^/]+\/[^/]+)?$/;
++
++/** A request as an anonymous browser makes it: no session, no cookies. */
++function anonymousRequest(path, query, host) {
++ const url = path;
++ return {
++ method: 'GET', url, originalUrl: url, path, params: {}, query: 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, Object.fromEntries(new URLSearchParams(query ? query.toString() : '')), 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, 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 members-only wiki, an unpublished article, a missing page) 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 c3afd36..0b153e6 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -136,6 +136,26 @@ function createApp({ config, telemetry = null, svc, viewers: baseViewers, platfo
// Search scans the text of every published page (LIKE): a person's pace, not a crawler's.
app.get('/search', limiter(60000, 60));
const pages = createPages({ svc, viewers, platform, config, log, limits, vibes });
++ // The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json, and the Markdown twins of the
++ // public pages (the home, a wiki's page tree and its articles). Only the manifest and a twin request
++ // reach it — the layer renders every page signed out, and a wiki page is not one page (a signed-in
++ // editor's tools, a members-only wiki). Anything else is the app's own page, which names its twin in
++ // the head and the Link header (server/render/layout.js).
++ {
++ const agentRouter = express.Router();
++ require('openvibe-shared/agent-layer').mount(agentRouter, {
++ descriptor: require('./agent-descriptor'),
++ render: require('./agent-pages').createAgentPages({ router: pages.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);
++ 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(pages.router);
app.use((req, res) => pages.errorPage(req, res, 404, 'Page not found', 'Nothing lives at that address.'));
ddiff --git a/server/http/machine.js b/server/http/machine.js
iindex 3782965..f722dc1 100644
---- a/server/http/machine.js
++++ b/server/http/machine.js
@@@ -34,7 +34,7 @@ function createMachine({ svc, config }) {
setCache(res, 3600).type('text/plain').send(seo.robotsTxt({
sitemaps: [`${origin}/sitemap.xml`],
disallow: ['/auth/', '/api/', '/new-space', '/search', '/proposals/'],
-- }));
++ }) + `# Agent manifest: ${origin}/.well-known/agent.json\n`);
});
// RFC 9116 (SEO-30). Contact, Expires and Canonical are required; Canonical is this site's own, from the
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex af4ff39..556dbeb 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -77,8 +77,13 @@ function createPages({ svc, viewers, platform, config, log = console, limits, vi
const send = (req, res, status, body, o = {}) => {
const cacheable = o.cache === 'public' && req.actor.kind === 'anonymous' && status === 200;
-- res.status(status).set('Cache-Control', cacheable ? cache.htmlHeaders({ maxAge: 60 }) : cache.htmlHeaders({ private: true })).set('Vary', 'Cookie, Authorization').type('html')
-- .send(renderPage({ config, actor: req.actor, path: o.path || req.path, ...o, body }));
++ const path = o.path || req.path;
++ const canonical = `${config.baseUrl}${path}`;
++ const hasTwin = status === 200 && require('../agent-pages').TWIN_PATH_RE.test(path) && !(o.robots && /noindex/i.test(o.robots));
++ res.status(status).set('Cache-Control', cacheable ? cache.htmlHeaders({ maxAge: 60 }) : cache.htmlHeaders({ private: true })).set('Vary', 'Cookie, Authorization, Accept');
++ // The page's Markdown twin and the site's llms.txt (openvibe-shared/agent-layer).
++ if (hasTwin) res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(canonical, `${config.baseUrl}/llms.txt`).header);
++ res.type('html').send(renderPage({ config, actor: req.actor, path, ...o, body }));
};
const errorPage = (req, res, status, title, message) => send(req, res, status, views.errorBody({ status, title, message }), { title, robots: 'noindex, nofollow' });
const notFound = (req, res) => errorPage(req, res, 404, 'Page not found', 'Nothing lives at that address, or it is not visible to you.');
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex d254487..bbd7ce8 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -12,6 +12,7 @@ const fs = require('fs');
const path = require('path');
const layout = require('openvibe-publishing/layout');
const { escapeHtml: esc } = require('openvibe-publishing/ssr');
++const seo = require('openvibe-shared/seo');
const SITE_NAME = 'OpenVibe.Wiki';
const NETWORK_URL = 'https://openvibe.network';
@@@ -72,9 +73,15 @@ function navConfig(o, config) {
* decision for an article, an explicit string for every other page), head (extra head HTML, added
* after the shared tags), jsonLd, ogType, body, active, actor, config, feeds, styles, query
*/
++const { TWIN_PATH_RE } = require('../agent-pages');
++
function renderPage(o) {
const { config } = o;
if (!o.robots && !o.decision) throw new TypeError('renderPage needs explicit robots (or the gate decision)');
++ const path = o.path || '/';
++ const canonical = `${config.baseUrl}${path}`;
++ // A page with a twin names it; an editing surface, a private wiki and an error page have no twin.
++ const hasTwin = TWIN_PATH_RE.test(path) && !(o.robots && /noindex/i.test(o.robots));
const feeds = o.feeds === false ? [] : [
{ type: 'atom', title: `${SITE_NAME}: recent changes (Atom)`, href: '/feed.atom' },
{ type: 'json', title: `${SITE_NAME}: recent changes (JSON Feed)`, href: '/feed.json' },
@@@ -91,7 +98,7 @@ function renderPage(o) {
title: o.title ? `${o.title} · ${SITE_NAME}` : o.path === '/' ? `${SITE_NAME}: wikis with full history and cited sources` : SITE_NAME,
description: o.description || DEFAULT_DESCRIPTION,
summary: o.summary,
-- canonical: `${config.baseUrl}${o.path || '/'}`,
++ canonical,
decision: o.decision,
robots: o.robots,
type: o.ogType || 'website',
@@@ -108,7 +115,8 @@ function renderPage(o) {
mainClass: 'wk-main',
header: `<header class="wk-bar"><a class="wk-brand" href="/">${SITE_NAME}</a><form class="wk-search" action="/search" method="get" role="search"><label for="wk-q" class="wk-sr">Search the wiki</label><input id="wk-q" name="q" type="search" placeholder="Search the wiki" value="${esc(o.query || '')}"><button type="submit">Search</button></form><noscript><span class="wk-account">${who}</span></noscript></header>`,
body: o.body,
-- head: o.head,
++ // The page names its Markdown twin (openvibe-shared/agent-layer) and the site's llms.txt.
++ head: [hasTwin ? seo.linkHeaderTags(canonical, `${config.baseUrl}/llms.txt`).head : '', o.head].filter(Boolean).join('\n'),
});
}