openvibers/openvibe.community Commits

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

OP OpenVibers committed 15 days ago · 55f5986255 · 10 files, +429 −27

openvibe-shared v4.34.0 (was v4.33.0). The descriptor is what openvibe.community really serves: the public
home, /pastes and paste pages with their comment threads, the native paste API under /api/pastes and the
typed API under /api/v1, their OpenAPI document, llms.txt/llms-full.txt and the sitemap. The mount is gated
to the manifest and twin requests only, and renders through the same page builders signed out (the anonymous
viewer, noView), so a twin never carries a signed-in person's view, counts a page view or consumes a
burn-after-read paste. 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. The home's raw HTML budget moves 41 -> 42 KB
for the two discovery <link> tags (measured 41.6).

 package.json
 package-lock.json
 server/agent-descriptor.js
 server/agent-pages.js
 server/app.js
 server/render/layout.js
 server/render/pages.js
 test/perf-budget.test.js
 STATUS.json
 README.md

Changes

ddiff --git a/README.md b/README.md
iindex 6b0deee..0c987d6 100644
---- a/README.md
++++ b/README.md
@@@ -42,7 +42,7 @@ little progressive JavaScript for pagination, copy buttons and the upload path.
(a space's room), OpenVibe.Events (the outbox relay and the Pulse and account subscriptions)
- OpenVibe.Live only for old VOD/clip comment imports (it reads and writes no pastes here any more)
- `openvibe-contracts` v0.112.0, `openvibe-sdk` v0.67.0 (events outbox, per-actor limits, no-looks),
-- `openvibe-shared` v4.33.0, pinned by release tarball
++ `openvibe-shared` v4.34.0, pinned by release tarball
## How it fits the network
@@@ -846,5 +846,5 @@ release after paste comments moved onto the typed comment threads. [Submissions]
<!-- versions:start -->
- openvibe-contracts: v0.222.0
- openvibe-sdk: v0.67.0
--- openvibe-shared: v4.33.0
++- openvibe-shared: v4.34.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex a306bb7..f446091 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -9,7 +9,7 @@
"domain": "openvibe.community",
"contracts": "openvibe-contracts v0.222.0",
"sdk": "openvibe-sdk v0.67.0",
-- "shared": "openvibe-shared v4.33.0",
++ "shared": "openvibe-shared v4.34.0",
"capabilities": [
"community.paste.create",
"community.paste.write",
@@@ -44,5 +44,5 @@
"N-1 compatibility tests",
"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 1cfbe1a..69bbaa0 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -19,7 +19,7 @@
"multer": "^2.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
"pg": "^8.23.0"
},
"devDependencies": {
@@@ -29,6 +29,12 @@
"node": ">=20.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",
@@@ -124,6 +130,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",
@@@ -237,6 +256,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",
@@@ -274,6 +326,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",
@@@ -324,6 +443,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",
@@@ -591,6 +722,110 @@
"node": ">=12.0.0"
}
},
++ "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",
@@@ -736,6 +971,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",
@@@ -892,6 +1151,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",
@@@ -961,12 +1236,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"
@@@ -1417,6 +1693,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 ba7c36e..a23a3a6 100644
---- a/package.json
++++ b/package.json
@@@ -30,7 +30,7 @@
"multer": "^2.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
"pg": "^8.23.0"
},
"engines": {
ddiff --git a/server/agent-descriptor.js b/server/agent-descriptor.js
nnew file mode 100644
iindex 0000000..082fc70
---- /dev/null
++++ b/server/agent-descriptor.js
@@@ -0,0 +1,18 @@
++'use strict';
++// What openvibe.community tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.
++// Every field is something this host really serves: the public home, paste browse and paste pages (with
++// their comment threads), the native paste API under /api/pastes and the typed API under /api/v1, their
++// OpenAPI document, llms.txt/llms-full.txt and the sitemap.
++module.exports = {
++ id: 'community',
++ name: 'OpenVibe.Community',
++ purpose: 'The community hub of the OpenVibe network: pastes, forum spaces and threads, comment threads, Pulse and public submissions.',
++ home: 'https://openvibe.community',
++ site: 'openvibe.community',
++ api: '/api',
++ openapi: '/openapi.json',
++ policy: {
++ auth: ['none', 'Network OIDC', 'bearer'],
++ rateLimit: 'The public pages are not limited by the app; /api is limited to 120 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..d863a80
---- /dev/null
++++ b/server/agent-pages.js
@@@ -0,0 +1,33 @@
++'use strict';
++// The Markdown twins of openvibe.community's public pages (openvibe-shared/agent-layer): the home,
++// /pastes and a paste. The bodies come from the same page builders the HTML routes use (render/pages.js,
++// called through app.js's views with the anonymous viewer and noView), so a twin cannot drift from its
++// page and never carries a signed-in person's view. A pasted twin carries the paste's text as prose and
++// its comments as the page's threaded comment list.
++//
++// A private, deleted, burned or missing paste answers 404 and has no twin; a burn-after-read paste is
++// never consumed by a twin (app.js checks before the read).
++const TWIN_PATH_RE = /^\/(?:pastes|p\/[^/]+)?$/;
++
++function createAgentPages({ views }) {
++ async function renderPublicPage(path, { query } = {}) {
++ const p = String(path || '/').replace(/\/+$/, '') || '/';
++ if (!TWIN_PATH_RE.test(p)) return null;
++ const q = Object.fromEntries(new URLSearchParams(query ? query.toString() : ''));
++ let html;
++ if (p === '/') html = await views.home();
++ else if (p === '/pastes') html = await views.browse(q);
++ else {
++ let slug;
++ try { slug = decodeURIComponent(p.slice(3)); } catch { return null; }
++ const out = await views.paste(slug, q);
++ if (!out || out.status !== 200) return null;
++ html = out.body;
++ }
++ return html ? { html } : null;
++ }
++
++ return { renderPublicPage };
++}
++
++module.exports = { createAgentPages, TWIN_PATH_RE };
ddiff --git a/server/app.js b/server/app.js
iindex e2dd918..59d7f96 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -40,7 +40,7 @@ const { assetVersion, SITE_NAME, DEFAULT_DESCRIPTION } = require('./render/layou
const { createAuthClient, createAuthRoutes } = require('./auth/routes');
const { getDb } = require('./db');
const { createNetworkIdentity } = require('./identity/network');
--const { createViewerResolver } = require('./identity/viewer');
++const { createViewerResolver, ANONYMOUS } = require('./identity/viewer');
const v1 = require('./http/v1');
const { createCommentService } = require('./comments/service');
const { createCommentsApi } = require('./comments/api');
@@@ -276,7 +276,8 @@ async function createApp(opts = {}) {
// ── Machine endpoints ────────────────────────────────────
// Written by openvibe-shared/seo from server/discovery.js; the sitemap is rebuilt at most hourly.
app.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
-- .send(seo.robotsTxt({ sitemaps: [`${config.baseUrl}/sitemap.xml`], disallow: discovery.ROBOTS_DISALLOW })));
++ .send(seo.robotsTxt({ sitemaps: [`${config.baseUrl}/sitemap.xml`], disallow: discovery.ROBOTS_DISALLOW })
++ + `# Agent manifest: ${config.baseUrl}/.well-known/agent.json\n`));
app.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
.send(seo.llmsTxt({ name: SITE_NAME, summary: discovery.SUMMARY, details: discovery.CONTENT_LABELS, sections: discovery.llmsSections() })));
app.get('/llms-full.txt', wrap(async (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
@@@ -310,6 +311,12 @@ async function createApp(opts = {}) {
const withUser = viewers.middleware({ services: false });
const ctxOf = (req) => ({ token: req.token, ip: req.ip, userAgent: req.get('user-agent') || '', viewer: req.viewer });
const html = (res, body, status = 200) => res.status(status).type('html').set('Cache-Control', PAGE_CACHE).send(body);
++ // A public page with a Markdown twin names it in the Link header and varies on Accept. Only the
++ // pages the agent layer twins (the home, /pastes and a paste) call this.
++ const twinLink = (res, pathname) => {
++ res.vary('Accept');
++ res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(`${config.baseUrl}${pathname}`, `${config.baseUrl}/llms.txt`).header);
++ };
// Every page response below (redirects and error pages too) that sets no policy of its own.
app.use(cache.applyHtml({ private: true }));
@@@ -344,10 +351,50 @@ async function createApp(opts = {}) {
return value;
}
-- app.get('/', withUser, wrap(async (req, res) => {
++ // The public pages the agent layer twins, built once here and used by both the HTML routes and
++ // server/agent-pages.js. A twin passes the anonymous viewer and noView: it must never carry a
++ // signed-in person's view, count a page view or consume a burn-after-read paste.
++ async function homeView(user) {
// People's pastes lead; OpenVibe.AI's moment pastes from streams have their own shelf.
const [latest, trending, fromStreams, languages, extras] = await Promise.all([catalog.latest(12, { from: 'people' }), catalog.trending(8, { from: 'people' }), catalog.latest(8, { from: 'streams' }), catalog.languages(), readHomeExtras()]);
-- html(res, pages.homePage({ latest, trending, fromStreams, languages, user: req.user, ...extras }));
++ return pages.homePage({ latest, trending, fromStreams, languages, user, ...extras });
++ }
++
++ async function browseView(query) {
++ const [result, languages] = await Promise.all([catalog.browse(query), catalog.languages()]);
++ return pages.browsePage(result, languages);
++ }
++
++ const agentViews = {
++ home: () => homeView(null),
++ browse: (query) => browseView(query),
++ paste: (slug, query) => pasteView(slug, query, {
++ viewer: ANONYMOUS, user: null, twin: true,
++ ctx: { viewer: ANONYMOUS, noView: true, ip: '127.0.0.1', userAgent: '' },
++ }),
++ };
++
++ // The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json, and the Markdown twins of the
++ // home, /pastes and a paste. Gated to the manifest and twin requests only, so a browser (signed in or
++ // not) keeps the page its own route renders; the twins render through the same page builders signed out.
++ {
++ const agentRouter = express.Router();
++ require('openvibe-shared/agent-layer').mount(agentRouter, {
++ descriptor: require('./agent-descriptor'),
++ render: require('./agent-pages').createAgentPages({ views: agentViews }).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.get('/', withUser, wrap(async (req, res) => {
++ twinLink(res, '/');
++ html(res, await homeView(req.user));
}));
app.get('/updates', (req, res) => html(res, pages.updatesPage()));
@@@ -370,8 +417,8 @@ async function createApp(opts = {}) {
}));
app.get('/pastes', withUser, wrap(async (req, res) => {
-- const [result, languages] = await Promise.all([catalog.browse(req.query), catalog.languages()]);
-- html(res, pages.browsePage(result, languages));
++ twinLink(res, req.originalUrl);
++ html(res, await browseView(req.query));
}));
// Old Live-style /pastes/<slug> links and bare slugs land on the paste page.
@@@ -394,15 +441,20 @@ async function createApp(opts = {}) {
res.redirect(301, `/p/${clean}${rest}${q >= 0 ? req.originalUrl.slice(q) : ''}`);
});
-- app.get('/p/:slug', withUser, wrap(async (req, res) => {
-- const { slug } = req.params;
-- if (!SLUG_RE.test(slug)) return html(res, pages.errorPage({ status: 404, title: 'Paste not found', message: 'That link does not point at a paste.' }), 404);
++ /** The paste page's body, or the error page it answers with; the HTML route and the twin share it. */
++ async function pasteView(slug, query, { viewer, user, ctx, twin = false } = {}) {
++ if (!SLUG_RE.test(slug)) return { status: 404, body: pages.errorPage({ status: 404, title: 'Paste not found', message: 'That link does not point at a paste.' }) };
++ // A twin is not a person reading: never consume a burn-after-read paste for one.
++ if (twin) {
++ const row = await db.prepare('SELECT burn_after_read FROM pastes WHERE slug = ?').get(slug);
++ if (!row || Number(row.burn_after_read)) return null;
++ }
let paste;
try {
-- paste = await source.getPaste(slug, ctxOf(req));
++ paste = await source.getPaste(slug, ctx);
} catch (err) {
-- if (err.status === 404) return html(res, pages.errorPage({ status: 404, title: 'Paste not found', message: 'It may have been deleted, burned after reading, or never existed.' }), 404);
-- if (err.status === 410) return html(res, pages.errorPage({ status: 410, title: 'This paste has burned', message: 'It was set to burn after reading, and it has been read.' }), 410);
++ if (err.status === 404) return { status: 404, body: pages.errorPage({ status: 404, title: 'Paste not found', message: 'It may have been deleted, burned after reading, or never existed.' }) };
++ if (err.status === 410) return { status: 410, body: pages.errorPage({ status: 410, title: 'This paste has burned', message: 'It was set to burn after reading, and it has been read.' }) };
throw err;
}
const related = await catalog.related(paste, 6);
@@@ -410,10 +462,20 @@ async function createApp(opts = {}) {
// comments API show, sorted (?sort=old|new) and paged (?after=<last comment id>) as there; null while
// nobody has commented. The comment service applies the paste's visibility again.
// A burn-after-read paste is gone once shown, so it has no comment panel.
-- const after = /^\d{1,15}$/.test(String(req.query.after || '')) ? String(req.query.after) : null;
-- const sort = req.query.sort === 'old' ? 'old' : 'new';
-- const thread = paste.burn_after_read ? null : await comments.forRef(req.viewer, pasteRef(paste.slug), { sort, after, limit: 30 });
-- html(res, pages.pastePage({ paste, related, user: req.user, comments: thread, commentSort: sort, commentsAfter: after }));
++ const after = /^\d{1,15}$/.test(String(query.after || '')) ? String(query.after) : null;
++ const sort = query.sort === 'old' ? 'old' : 'new';
++ const thread = paste.burn_after_read ? null : await comments.forRef(viewer, pasteRef(paste.slug), { sort, after, limit: 30 });
++ return {
++ status: 200, slug: paste.slug,
++ body: pages.pastePage({ paste, related, user, comments: thread, commentSort: sort, commentsAfter: after }),
++ };
++ }
++
++ app.get('/p/:slug', withUser, wrap(async (req, res) => {
++ const out = await pasteView(req.params.slug, req.query, { viewer: req.viewer, user: req.user, ctx: ctxOf(req) });
++ if (!out) return html(res, pages.errorPage({ status: 404, title: 'Paste not found', message: 'It may have been deleted, burned after reading, or never existed.' }), 404);
++ if (out.status === 200) twinLink(res, `/p/${encodeURIComponent(out.slug)}`);
++ html(res, out.body, out.status);
}));
// No-JS comment form: signed in, the cookie (SameSite=Lax) and no Origin from another site, as on /c/:accessId.
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 3463ed3..7a3c800 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -129,6 +129,9 @@ function renderPage(o) {
siteName: SITE_NAME, description, canonical, robots: o.robots || 'index,follow',
type: o.ogType || 'website', image: o.ogImage || DEFAULT_OG_IMAGE, imageAlt: o.imageAlt,
jsonLd: o.jsonLd, alternates: o.alternates,
++ // The page names its Markdown twin (openvibe-shared/agent-layer) in the head; only pages whose
++ // twin is mounted set o.agentLayer (the home, /pastes and a paste).
++ agentLayer: !!o.agentLayer && !!canonical,
summary: description, url: canonical, updated: o.modified,
navbar: nav, home: '/', navLinks: nav.links.map(({ label, href }) => ({ label, href })),
footer: { service: 'community', variant: 'auto', path: o.canonicalPath, updates: '/updates' }, // full on the front page only (Shared 4.19)
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex 5c3a443..5feee0c 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -254,6 +254,7 @@ const { url } = await res.json(); // "/p/<slug>"` },
title: 'OpenVibe.Community: pastes, forums and the OpenVibe roadmap',
description: 'Paste code, logs or a screenshot and get a short link. Talk it over in the forum, follow the OpenVibe roadmap, and see what is happening in Pulse.',
canonicalPath: '/',
++ agentLayer: true, // the public page has a Markdown twin (server/agent-pages.js)
active: 'home',
jsonLd: [
ld.websiteLd(),
@@@ -316,6 +317,7 @@ function browsePage(result, languages) {
title,
description: `Browse ${sort === 'views' ? 'the most viewed' : 'the latest'} pastes on OpenVibe.Community${lang ? ` in ${languageLabel(lang)}` : ''} — code, logs and screenshots shared by the people of OpenVibe.`,
canonicalPath: canonical,
++ agentLayer: true, // the public page has a Markdown twin (server/agent-pages.js)
robots: q ? 'noindex,follow' : 'index,follow',
active: 'pastes',
jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Pastes', url: '/pastes' }])],
@@@ -439,6 +441,7 @@ ${related && related.length ? `<section class="section" id="related">
title,
description,
canonicalPath: `/p/${p.slug}`,
++ agentLayer: true, // the public page has a Markdown twin (server/agent-pages.js)
robots: indexable ? 'index,follow' : 'noindex,follow',
ogType: 'article',
ogImage: shot || undefined,
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 08ab0c1..5b87255 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -10,7 +10,8 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
-- htmlRawKB: 41, // 36.4 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); measured 31.7 on 10-22: the home gained the forum's places, the roadmap, Pulse, real counts and six
++ htmlRawKB: 42, // 41.6 with the agent layer's two discovery <link> tags (Shared 4.34.0, 2026-10-29); was 41 at 41.0
++ // 36.4 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); measured 31.7 on 10-22: the home gained the forum's places, the roadmap, Pulse, real counts and six
// questions with their FAQPage JSON-LD; was 26 at 22.0
htmlBrotliKB: 10, // 8.6 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 7.3 on 10-22 (the same shelves); was 6.5 at 5.7
jsFiles: 7, // 6: openvibe-shared/shell adds web-runtime.js (raised with the shell, 2026-10-04)