openvibers/openvibe.trade Commits

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

OP OpenVibers committed 15 days ago · 3e3ebd9d77 · 9 files, +414 −11

openvibe-shared v4.34.0 (was v4.33.0). server/agent-descriptor.js describes what openvibe.trade
really serves: the bearer-token API at /api/v1, the public pages that list what is known, llms.txt
and the sitemap. server/agent-pages.js renders the twins of /, /instruments, /filings and /sources
through the site own page router, driven by a request without a session, so a twin is the page own
render signed out - the same builders, the same data, no personalisation. The layer is mounted in
server/app.js before the page routes but gated to twin requests only (a .md path, ?format=markdown
or an explicit text/markdown Accept) plus /.well-known/agent.json matched exactly, so a browser keeps
the page own render and /.well-known/security.txt is untouched. The head names the twin, and
deploy/nginx gets the two GET-only locations the vhost needs: without them its deny-all rule for
dotted paths would refuse every .md twin.

Changes

ddiff --git a/README.md b/README.md
iindex eb0a2fb..90f3d25 100644
---- a/README.md
++++ b/README.md
@@@ -277,7 +277,7 @@ services need to call Trade, is under [Grants the Network must hold](#grants-the
- **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).
- **Packages** (pinned by release tarball): `openvibe-contracts` v0.97.0, `openvibe-publishing`
-- v1.3.0 (revisions, authorship, seo, index-hooks, ssr, ingest, publication), `openvibe-shared` v4.33.0 (Frame, app icon,
++ v1.3.0 (revisions, authorship, seo, index-hooks, ssr, ingest, publication), `openvibe-shared` v4.34.0 (Frame, app icon,
footer, legal, release, metrics, ready), `openvibe-sdk` v0.35.0 (events outbox and inbox, webhook
signatures v2, service tokens, per-actor limits, service kit lifecycle).
- **OpenVibe.Network:** SSO (OAuth client `trade`, redirect `https://openvibe.trade/auth/callback`),
@@@ -458,6 +458,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 5772768..07669cf 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -10,11 +10,11 @@
"scope": "informational only (ADR-025): no custody, no order execution, no escrow, no listings between users, no personalised advice",
"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-contracts v0.222.0",
"openvibe-publishing v1.3.0",
-- "openvibe-shared v4.33.0",
++ "openvibe-shared v4.34.0",
"openvibe-sdk v0.67.0"
],
"capabilities": [
ddiff --git a/deploy/nginx/openvibe.trade.conf b/deploy/nginx/openvibe.trade.conf
iindex 3714f92..0c93d76 100644
---- a/deploy/nginx/openvibe.trade.conf
++++ b/deploy/nginx/openvibe.trade.conf
@@@ -122,6 +122,11 @@ server {
proxy_pass http://127.0.0.1:4860;
}
++ # The agent layer (openvibe-shared/agent-layer): the manifest an agent reads first, and the Markdown
++ # twins of the public pages (the .md address of the page itself). GET only.
++ location = /.well-known/agent.json { limit_except GET { deny all; } proxy_pass http://127.0.0.1:4860; }
++ location ~ "^/(index|instruments|filings|sources)\.md$" { limit_except GET { deny all; } proxy_pass http://127.0.0.1:4860; }
++
location ~ /\.(?!well-known) {
deny all;
}
ddiff --git a/package-lock.json b/package-lock.json
iindex acdcd28..30d8877 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.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": {
@@@ -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 17ad3dd..e7ff44b 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.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": {
ddiff --git a/server/agent-descriptor.js b/server/agent-descriptor.js
nnew file mode 100644
iindex 0000000..3e33c1d
---- /dev/null
++++ b/server/agent-descriptor.js
@@@ -0,0 +1,15 @@
++'use strict';
++// What openvibe.trade tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.
++// Every field is something this site really serves: the bearer-token API, the pages that list what is
++// known, llms.txt and the sitemap.
++module.exports = {
++ id: 'trade',
++ name: 'OpenVibe.Trade',
++ purpose: 'Company figures and filings gathered from public sources: every number keeps the date it was observed and the source it came from, and a stale figure says so.',
++ home: 'https://openvibe.trade',
++ site: 'openvibe.trade',
++ api: '/api/v1',
++ policy: {
++ auth: ['none', 'Network OIDC'],
++ },
++};
ddiff --git a/server/agent-pages.js b/server/agent-pages.js
nnew file mode 100644
iindex 0000000..3abcc4a
---- /dev/null
++++ b/server/agent-pages.js
@@@ -0,0 +1,76 @@
++'use strict';
++// The Markdown twins of this site's public pages (openvibe-shared/agent-layer).
++//
++// A twin is the page's own render, signed out: the request that drives it carries no session, so every
++// page renders exactly as it does for a visitor who is not signed in - the same router, the same builders
++// and the same data. A page behind a person's own token (a watch, a review, an editor) has no twin.
++const PATHS = new Set(['/', '/instruments', '/filings', '/sources']);
++
++/** A response the page code can write to; the first body written is the twin's HTML. */
++function mockRes() {
++ return {
++ statusCode: 200, headers: {}, headersSent: false, locals: {}, finished: false,
++ setHeader(k, v) { this.headers[String(k).toLowerCase()] = v; return this; },
++ getHeader(k) { return this.headers[String(k).toLowerCase()]; },
++ set(k, v) { return typeof v === 'function' ? v.call(this) : this.setHeader(k, v); },
++ status(code) { this.statusCode = code; return this; },
++ type(t) { this.setHeader('content-type', t); return this; },
++ redirect(code, url) { this.statusCode = code; this.headers.location = url; this.finished = true; return this; },
++ json(body) { this.body = body; this.finished = true; return this; },
++ send(body) { this.body = body; this.finished = true; return this; },
++ end(body) { if (body) this.body = body; this.finished = true; return this; },
++ sendStatus(code) { this.statusCode = code; this.finished = true; return this; },
++ append(k, v) { this.setHeader(k, v); return this; },
++ write(chunk) { this.body = (this.body || '') + chunk; return true; },
++ vary() { return this; },
++ location(url) { this.setHeader('location', url); return this; },
++ format(map) { return this; },
++ cookie() { return this; },
++ clearCookie() { return this; },
++ attachment() { return this; },
++ links() { return this; },
++ get(name) { return this.getHeader(name); },
++ render() { return this; },
++ on() { return this; }, once() { return this; }, emit() { return false; },
++ };
++}
++
++/** A request without a session, so the page renders signed out. */
++function mockReq(path, query) {
++ const search = String(query || '');
++ const url = path + (search ? `?${search}` : '');
++ return {
++ method: 'GET', url, originalUrl: url, path, baseUrl: '', params: {},
++ query: Object.fromEntries(new URLSearchParams(search)),
++ headers: { host: 'localhost', accept: 'text/html' }, secure: false, protocol: 'http',
++ hostname: 'localhost', socket: {}, cookies: {}, body: {},
++ get(name) { const v = this.headers[String(name).toLowerCase()]; return v === undefined ? undefined : v; },
++ header(name) { return this.get(name); },
++ };
++}
++
++/** Run the site's own page router for one path and keep the HTML it wrote. */
++function renderWithRouter(router, path, query) {
++ return new Promise((resolve) => {
++ const res = mockRes();
++ const keep = (body) => { resolve(typeof body === 'string' ? body : null); return res; };
++ res.send = keep; res.end = keep; res.json = keep;
++ try {
++ router.handle(mockReq(path, query), res, () => resolve(null));
++ } catch (err) {
++ resolve(null);
++ }
++ });
++}
++
++function createAgentPages({ router }) {
++ async function renderPublicPage(path) {
++ const p = String(path || '/').replace(/\/+$/, '') || '/';
++ if (!PATHS.has(p)) return null;
++ const html = await renderWithRouter(router, p, '');
++ return html ? { html, title: null, updated: null } : null;
++ }
++ return { renderPublicPage };
++}
++
++module.exports = { createAgentPages, PATHS };
ddiff --git a/server/app.js b/server/app.js
iindex 53661cf..bfb52dd 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -221,7 +221,24 @@ async function createApp(opts = {}) {
app.use(['/watchlists', '/alerts', '/editor'], rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }));
app.use(createPrivate(ctx));
app.use(createEditor(ctx));
-- app.use(createPages(ctx));
++
++ const pageRoutes = createPages(ctx);
++ // The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and the Markdown twins of the
++ // public pages. Mounted before the page routes but gated to twin requests only, so a browser keeps the
++ // page's own render (signed in or out) and only a twin request reaches the agent layer. The manifest
++ // path is matched exactly, so /.well-known/security.txt and the rest of /.well-known/ are untouched.
++ const agentLayer = require('openvibe-shared/agent-layer');
++ agentLayer.mount({ use: (mw) => app.use((req, res, next) => {
++ const p = req.path || '';
++ const twin = p.endsWith('.md') || req.query.format === 'markdown' || /text\/markdown/.test(String(req.get('accept') || ''));
++ if (p !== '/.well-known/agent.json' && !twin) return next();
++ return mw(req, res, next);
++ }) }, {
++ descriptor: require('./agent-descriptor'),
++ render: require('./agent-pages').createAgentPages({ router: pageRoutes }).renderPublicPage,
++ });
++
++ app.use(pageRoutes);
app.use(function pageNotFound(req, res) { ctx.common.notFound(req, res); });
// eslint-disable-next-line no-unused-vars
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 3b83522..6a2a50f 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -52,6 +52,12 @@ const NAV_LINKS = [
* body (HTML), viewer, config, path, bodyClass, summary (one-line AI summary; with facts, updated
* and url it becomes the ai-summary meta and WebPage JSON-LD via openvibe-publishing/layout)
*/
++// The Markdown twin of this page (openvibe-shared/agent-layer): the head names it, so a reader that
++// already has the HTML finds the twin from it. The twin's address is this page's own address + '.md'.
++const agentHeadTags = (o) => {
++ const canonical = o.canonical || (o.config && o.config.baseUrl ? `${o.config.baseUrl}${String(o.path || '/').split('?')[0]}` : null);
++ return canonical ? require('openvibe-shared/agent-layer').linkHeaderTags(canonical, '/llms.txt').head : '';
++};
function renderPage(o) {
if (!o.decision) throw new TypeError('renderPage needs the gate decision');
const viewer = o.viewer || { kind: 'anonymous' };
@@@ -82,6 +88,8 @@ function renderPage(o) {
title: o.title ? `${o.title} · ${SITE_NAME}` : `${SITE_NAME} — sourced market information`,
description: o.description || `Instruments, timestamped observations and filings with their sources. ${DISCLAIMER}`,
canonical: o.canonical,
++ // The head names the page's own Markdown twin (openvibe-shared/agent-layer).
++ head: agentHeadTags(o),
decision: o.decision,
type: o.type || 'website',
image: o.image,