openvibers/openvibe.deals Commits

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

OP OpenVibers committed 15 days ago · bc8f0e9144 · 8 files, +526 −93

Mounts openvibe-shared/agent-layer (Shared 4.34.0) and bumps the pin. The descriptor
names what openvibe.deals really serves (api /api/v1, llms.txt, llms-full.txt,
sitemap.xml, none + Network OIDC, the limits this server enforces); Deals publishes no
OpenAPI document or docs page, so neither is named. server/agent-pages.js renders the
public pages through the same view builders the routes use, extracted in
createPages().publicViews, so a twin cannot drift from its page: the front page, the
lists, an offer (price, shop, time observed), a product, a store, a shop, the category
index and a category. Only manifest and twin requests reach the mount; the HTML pages
keep their own routes (a signed-in visitor's vote buttons stay) and advertise the twin
with both discovery links and the Link header. robots.txt gains the manifest comment.

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex c1e7297..25916e8 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 61588f8..46c5335 100644
---- a/package.json
++++ b/package.json
@@@ -29,7 +29,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..c4525f9
---- /dev/null
++++ b/server/agent-descriptor.js
@@@ -0,0 +1,16 @@
++'use strict';
++// What openvibe.deals tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.
++// Every field is something this site really serves; Deals publishes no OpenAPI document or docs page, so
++// neither is named. Prices are observations with the time they were seen, on the page and in its twin.
++module.exports = {
++ id: 'deals',
++ name: 'OpenVibe.Deals',
++ purpose: 'Deals from DealNews and the OpenVibe community, by shop, category and end date, with price watches and crowdfunding search; every price has its source and the time it was observed.',
++ home: 'https://openvibe.deals',
++ site: 'openvibe.deals',
++ api: '/api/v1',
++ policy: {
++ auth: ['none', 'Network OIDC'],
++ rateLimit: '240 API requests a minute per address; 60 form posts and 60 sign-in requests per 15 minutes',
++ },
++};
ddiff --git a/server/agent-pages.js b/server/agent-pages.js
nnew file mode 100644
iindex 0000000..d176aa4
---- /dev/null
++++ b/server/agent-pages.js
@@@ -0,0 +1,38 @@
++'use strict';
++// The Markdown twins of openvibe.deals' public pages (openvibe-shared/agent-layer), rendered by the same
++// view builders the routes use (createPages().publicViews + render/layout.js's renderPage), so a twin
++// cannot drift from its page. Anonymous only: no vote, edit or moderation controls are ever rendered here.
++const { renderPage } = require('./render/layout');
++
++function createAgentPages(ctx, publicViews) {
++ const views = publicViews || require('./http/pages').createPages(ctx).publicViews;
++ const twin = (page, path) => (page ? { html: renderPage({ ...page, config: ctx.config, path, viewer: { kind: 'anonymous' } }) } : null);
++ const pageOf = (query) => { const n = parseInt(query && query.get('page'), 10); return Number.isInteger(n) && n > 0 ? n : 1; };
++
++ async function renderPublicPage(path, { query } = {}) {
++ if (path === '/') return twin(await views.home(), path);
++ if (path === '/updates') return twin(views.updates(), path);
++ const list = { '/hot': 'hot', '/new': 'new', '/ending': 'ending', '/picks': 'picks' }[path];
++ if (list) return twin(await views.list(list, pageOf(query)), path);
++ if (path === '/at') return twin(await views.sellers(), path);
++ if (path === '/c') return twin(await views.categories(), path);
++ let m = /^\/d\/([^/]+)$/.exec(path);
++ if (m) return twin(await views.offer(decodeURIComponent(m[1])), path);
++ m = /^\/p\/([^/]+)$/.exec(path);
++ if (m) {
++ const out = await views.product(decodeURIComponent(m[1]));
++ return twin(out && out.page, path);
++ }
++ m = /^\/s\/([^/]+)$/.exec(path);
++ if (m) return twin(await views.store(decodeURIComponent(m[1]), pageOf(query)), path);
++ m = /^\/at\/([^/]+)$/.exec(path);
++ if (m) return twin(await views.seller(decodeURIComponent(m[1]), pageOf(query)), path);
++ m = /^\/c\/([^/]+)$/.exec(path);
++ if (m) return twin(await views.category(decodeURIComponent(m[1]), pageOf(query)), path);
++ return null;
++ }
++
++ return { renderPublicPage };
++}
++
++module.exports = { createAgentPages };
ddiff --git a/server/app.js b/server/app.js
iindex 8d04971..40595be 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -197,6 +197,19 @@ async function createApp(opts = {}) {
// ── Discovery, public pages ─────────────────────────────
app.use(createDiscoveryRoutes(ctx));
const pages = createPages(ctx);
++ // The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and the Markdown twins of the public
++ // pages. The pages themselves keep their own routes — a signed-in visitor's vote buttons and account bar are
++ // theirs — so only the manifest and twin requests reach this mount; the pages advertise the twin themselves.
++ const agentLayer = express();
++ require('openvibe-shared/agent-layer').mount(agentLayer, {
++ descriptor: require('./agent-descriptor'),
++ render: require('./agent-pages').createAgentPages(ctx, pages.publicViews).renderPublicPage,
++ });
++ app.use((req, res, next) => {
++ const twin = req.path.endsWith('.md') || req.query.format === 'markdown' || String(req.headers.accept || '').includes('text/markdown');
++ if (req.path !== '/.well-known/agent.json' && !twin) return next();
++ return agentLayer(req, res, next);
++ });
const formLimit = rateLimit({ windowMs: 60_000, max: 60, standardHeaders: true, legacyHeaders: false });
app.use(['/submit', '/watches', '/mod', '/d/:slug/:action'], (req, res, next) => (req.method === 'POST' ? formLimit(req, res, next) : next()));
app.use(require('./http/crowdfunding').createCrowdfundingRoutes(ctx));
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 0954af7..68e2164 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -93,6 +93,7 @@ function createDiscoveryRoutes({ config, store, publication, listings }) {
'# openvibe.deals automated-consumer policy: search engines and AI crawlers are welcome to read',
'# public deal, product and store pages, feeds and sitemaps. Prices are observations with times;',
'# read the "as of" time and the JSON twin (<deal URL>.json) rather than assuming a price is current.',
++ '# Agent manifest: https://openvibe.deals/.well-known/agent.json; every public page has a .md twin.',
'# Forms, sign-in, moderation and the API are not for crawling. Pages decide their own',
'# indexability (meta robots / X-Robots-Tag): stale or expired deals are noindex. A Disallow is not a noindex.',
sharedSeo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: ['/submit', '/watches', '/mod', '/search', '/auth/', '/api/', '/internal/'] }),
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex fe29d3c..ea709ed 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -62,6 +62,8 @@ function createPages(ctx) {
function send(req, res, status, page, { cacheable = false } = {}) {
cacheHeaders(req, res, { cacheable, robots: page.decision.robots, status });
++ // A page with a Markdown twin names it in the Link header, exactly as the agent layer's own responses do.
++ if (page.agent) res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(page.canonical, `${String(config.baseUrl).replace(/\/+$/, '')}/llms.txt`).header);
res.status(status).type('html').send(renderPage({ ...page, viewer: req.viewer, config, path: req.originalUrl }));
}
@@@ -128,16 +130,16 @@ function createPages(ctx) {
count: async () => await listings.countPicks(), rows: async (n, off) => await listings.picks(n, off), empty: 'No picks right now.' },
};
-- async function listPage(req, res, kind) {
++ async function listPage(kind, page = 1) {
const L = LISTS[kind];
-- const page = pageNumber(req);
const path = L.path;
const total = await L.count();
const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
-- if (pager.outOfRange && total) return notFound(req, res);
++ if (pager.outOfRange && total) return null;
const list = await Promise.all((await L.rows(PER_PAGE, pager.offset)).map(async (o) => await publication.offerView(o)));
const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });
-- send(req, res, 200, {
++ return {
++ agent: true,
title: L.heading,
description: L.description || 'Deals submitted and voted on by the community, each with its source and the time its price was observed.',
decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }),
@@@ -146,7 +148,7 @@ function createPages(ctx) {
prev: pager.prev ? pager.prev.href : null,
next: pager.next ? pager.next.href : null,
body: views.offerList({ heading: L.heading, intro: L.intro, views: list, pager, urls, tabs: views.tabs(kind), empty: L.empty }),
-- }, { cacheable: true });
++ };
}
// ── the front page ──────────────────────────────────────
@@@ -155,7 +157,7 @@ function createPages(ctx) {
const UNDER = { amount: 20, currency: 'USD' };
const HOME_CARDS = 8;
-- async function homePage(req, res) {
++ async function homePage() {
const now = store.now();
const fresh = now - config.freshnessMs;
const [total, sellers, categories, seenDay, endingWeek, cf] = await Promise.all([
@@@ -194,7 +196,8 @@ function createPages(ctx) {
});
const faq = home.faqItems({ freshnessHours: Math.round(config.freshnessMs / 3600000) });
const listed = [...picks, ...upvoted, ...latest];
-- send(req, res, 200, {
++ return {
++ agent: true,
title: 'Today\'s deals, every price with its time',
description: 'Deals from Amazon, Walmart, Best Buy and more, by shop, category and end date, with price watches and crowdfunding. Every price says when it was seen.',
// The home page carries the site summary an AI can read (ai-summary meta + WebPage JSON-LD).
@@@ -213,33 +216,40 @@ function createPages(ctx) {
stats: { active: total, shops: sellers.length, seenDay, endingWeek, categories: categories.length, campaigns: cf.total },
picks, upvoted, latest, ending, under, underLimit: UNDER, categories, sellers, campaigns: cf.campaigns,
}, urls),
-- }, { cacheable: true });
++ };
}
router.get('/', wrap(async (req, res) => {
// The hot list's later pages moved to /hot with the front page.
if (req.query.page && pageNumber(req) > 1) return res.redirect(301, `/hot?page=${pageNumber(req)}`);
-- await homePage(req, res);
++ send(req, res, 200, await homePage(), { cacheable: true });
}));
-- router.get('/hot', wrap(async (req, res) => await listPage(req, res, 'hot')));
-- router.get('/ending', wrap(async (req, res) => await listPage(req, res, 'ending')));
-- router.get('/picks', wrap(async (req, res) => await listPage(req, res, 'picks')));
++ const sendList = (kind) => wrap(async (req, res) => {
++ const page = await listPage(kind, pageNumber(req));
++ if (!page) return notFound(req, res);
++ send(req, res, 200, page, { cacheable: true });
++ });
++ router.get('/hot', sendList('hot'));
++ router.get('/ending', sendList('ending'));
++ router.get('/picks', sendList('picks'));
// What shipped on OpenVibe.Deals: the shared update log every OpenVibe site has.
-- router.get('/updates', (req, res) => send(req, res, 200, {
++ const updatesPage = () => ({
++ agent: true,
title: 'What shipped on OpenVibe.Deals', description: 'Every change deployed to OpenVibe.Deals, newest first.',
decision: pageDecision('/updates'), canonical: `${config.baseUrl}/updates`,
body: frame.updatesBody({ service: 'deals', siteName: 'OpenVibe.Deals' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,
-- }, { cacheable: true }));
-- router.get('/new', wrap(async (req, res) => await listPage(req, res, 'new')));
++ });
++ router.get('/updates', (req, res) => send(req, res, 200, updatesPage(), { cacheable: true }));
++ router.get('/new', sendList('new'));
// ── offers ──────────────────────────────────────────────
-- async function readComments(offer, label, req) {
++ async function readComments(offer, label, ov) {
if (!community.enabled) return { state: 'unavailable' };
try {
-- const threadId = await community.threadFor(offer, label, req.ov);
++ const threadId = await community.threadFor(offer, label, ov);
if (!threadId) return { state: 'unavailable' };
-- const data = await community.readThread(threadId, { ctx: req.ov });
++ const data = await community.readThread(threadId, { ctx: ov });
return { state: 'ok', comments: data.comments || [] };
} catch (err) {
console.warn(`[Deals] comments for ${offer.id} unavailable: ${err.message}`);
@@@ -247,38 +257,48 @@ function createPages(ctx) {
}
}
-- async function renderOffer(req, res, offer, { json = false } = {}) {
++ /** One public offer, as the route and its Markdown twin (server/agent-pages.js) both render it. */
++ async function offerPage(offer, { viewer = { kind: 'anonymous' }, ov = null, notice = null } = {}) {
const v = await publication.offerView(offer);
const r = v.root;
-- const dto = publication.offerDto(v);
-- if (r.status === 'disabled') {
-- if (json) return sendJson(req, res, 410, { id: r.id, slug: r.slug, status: 'disabled', reason: r.disabled_reason }, v.decision, { cacheable: false });
-- return messagePage(req, res, 410, 'This deal was removed', `Moderators removed this deal${r.disabled_reason ? `: ${r.disabled_reason}` : ''}.`, { href: '/', label: 'Hot deals' });
-- }
-- if (json) return sendJson(req, res, 200, { offer: dto }, v.decision);
-- const c = r.status === 'disabled' ? { state: 'off' } : await readComments(r, r.title, req);
++ if (r.status === 'disabled') return { status: 410, page: null, removed: r };
++ const person = viewer.kind === 'user' && Boolean(viewer.subject);
++ const c = await readComments(r, r.title, ov);
const mergedComments = [];
for (const m of v.members.slice(0, 3)) {
const tid = community.enabled ? await community.knownThread(m) : null;
if (!tid) continue;
-- try { const data = await community.readThread(tid, { ctx: req.ov }); mergedComments.push({ title: m.title, comments: { state: 'ok', comments: data.comments || [] } }); } catch { mergedComments.push({ title: m.title, comments: { state: 'unavailable' } }); }
++ try { const data = await community.readThread(tid, { ctx: ov }); mergedComments.push({ title: m.title, comments: { state: 'ok', comments: data.comments || [] } }); } catch { mergedComments.push({ title: m.title, comments: { state: 'unavailable' } }); }
}
-- const isMod = access.isStaff(req.viewer);
++ const isMod = access.isStaff(viewer);
const canonical = publication.abs(publication.offerPath(r));
-- send(req, res, 200, {
-- title: r.title,
-- description: `${views.priceText(v.latest)}${v.latest ? ` as of ${views.when(v.latest.observed_at)}` : ''}${r.seller ? ` at ${r.seller}` : v.store ? ` at ${v.store.name || v.store.domain}` : ''}.${r.description ? ` ${r.description.slice(0, 120)}` : ''}`,
-- decision: v.decision,
-- canonical,
-- type: 'website',
-- jsonLd: [publication.offerJsonLd(v), seo.structuredData.breadcrumbs([{ name: 'Deals', url: publication.abs('/') }, ...(v.store ? [{ name: v.store.name || v.store.domain, url: publication.abs(publication.storePath(v.store)) }] : []), { name: r.title, url: canonical }])],
-- body: views.offerPage({
-- v, dto, viewer: req.viewer, csrf: csrf(req), myVote: signedIn(req) ? await votes.mine(req.viewer.subject, r) : 0,
-- comments: c, mergedComments, canEdit: access.canEdit(req.viewer, r), isMod,
-- log: isMod ? await offers.moderationLog(r) : null, flags: isMod ? await Promise.all((await flags.forOffer(v.ids)).map(flags.dto)) : null, urls,
-- notice: req.query.done ? { text: { vote: 'Your vote is recorded.', observe: 'Thanks — your observation is recorded with the time you saw it.', flag: 'Thanks — moderators will look at your report.', expire: 'Marked expired.', comment: 'Comment posted.', moderated: 'Done.' }[req.query.done] || 'Done.' } : null,
-- }),
-- }, { cacheable: true });
++ return {
++ status: 200,
++ page: {
++ agent: true,
++ title: r.title,
++ description: `${views.priceText(v.latest)}${v.latest ? ` as of ${views.when(v.latest.observed_at)}` : ''}${r.seller ? ` at ${r.seller}` : v.store ? ` at ${v.store.name || v.store.domain}` : ''}.${r.description ? ` ${r.description.slice(0, 120)}` : ''}`,
++ decision: v.decision,
++ canonical,
++ type: 'website',
++ jsonLd: [publication.offerJsonLd(v), seo.structuredData.breadcrumbs([{ name: 'Deals', url: publication.abs('/') }, ...(v.store ? [{ name: v.store.name || v.store.domain, url: publication.abs(publication.storePath(v.store)) }] : []), { name: r.title, url: canonical }])],
++ body: views.offerPage({
++ v, dto: publication.offerDto(v), viewer, csrf: person ? csrfToken(config, viewer) : '', myVote: person ? await votes.mine(viewer.subject, r) : 0,
++ comments: c, mergedComments, canEdit: access.canEdit(viewer, r), isMod,
++ log: isMod ? await offers.moderationLog(r) : null, flags: isMod ? await Promise.all((await flags.forOffer(v.ids)).map(flags.dto)) : null, urls,
++ notice,
++ }),
++ },
++ };
++ }
++
++ /** One public offer by slug for the agent twin, following a merge to its root; null when unknown or removed. */
++ async function offerBySlug(slug) {
++ const offer = await reads.find(String(slug || ''));
++ if (!offer) return null;
++ const root = offer.merged_into ? await reads.root(offer) : offer;
++ const out = await offerPage(root);
++ return out.status === 200 ? out.page : null;
}
async function offerFor(req, res) {
@@@ -297,7 +317,17 @@ function createPages(ctx) {
router.get('/d/:slug', wrap(async (req, res) => {
const f = await offerFor(req, res);
-- if (f) await renderOffer(req, res, f.offer, { json: f.json });
++ if (!f) return;
++ if (f.json) {
++ const v = await publication.offerView(f.offer);
++ const r = v.root;
++ if (r.status === 'disabled') return sendJson(req, res, 410, { id: r.id, slug: r.slug, status: 'disabled', reason: r.disabled_reason }, v.decision, { cacheable: false });
++ return sendJson(req, res, 200, { offer: publication.offerDto(v) }, v.decision);
++ }
++ const notice = req.query.done ? { text: { vote: 'Your vote is recorded.', observe: 'Thanks — your observation is recorded with the time you saw it.', flag: 'Thanks — moderators will look at your report.', expire: 'Marked expired.', comment: 'Comment posted.', moderated: 'Done.' }[req.query.done] || 'Done.' } : null;
++ const out = await offerPage(f.offer, { viewer: req.viewer, ov: req.ov, notice });
++ if (out.status !== 200) return messagePage(req, res, out.status, 'This deal was removed', `Moderators removed this deal${out.removed.disabled_reason ? `: ${out.removed.disabled_reason}` : ''}.`, { href: '/', label: 'Hot deals' });
++ send(req, res, 200, out.page, { cacheable: true });
}));
const back = (req) => `/d/${encodeURIComponent(req.params.slug)}`;
@@@ -343,96 +373,137 @@ function createPages(ctx) {
// ── products and stores ─────────────────────────────────
-- router.get('/p/:slug', wrap(async (req, res) => {
-- const slug = String(req.params.slug || '');
-- const json = slug.endsWith('.json');
-- const product = await catalog.productBySlug(json ? slug.slice(0, -5) : slug);
-- if (!product) return notFound(req, res);
++ async function productPage(slug) {
++ const raw = String(slug || '');
++ const json = raw.endsWith('.json');
++ const product = await catalog.productBySlug(json ? raw.slice(0, -5) : raw);
++ if (!product) return null;
const pv = await publication.productView(product);
-- if (json) {
++ if (json) return { json: true, product, pv };
++ const canonical = publication.abs(publication.productPath(product));
++ return {
++ status: 200,
++ page: {
++ agent: true,
++ title: `${product.name} — price comparison`,
++ description: `Offers for ${product.name}, each with the time its price was observed.`,
++ decision: pv.decision, canonical,
++ jsonLd: [publication.productJsonLd(pv), seo.structuredData.breadcrumbs([{ name: 'Deals', url: publication.abs('/') }, { name: product.name, url: canonical }])],
++ body: views.productPage({ pv, urls }),
++ },
++ };
++ }
++
++ router.get('/p/:slug', wrap(async (req, res) => {
++ const out = await productPage(req.params.slug);
++ if (!out) return notFound(req, res);
++ if (out.json) {
++ const { product, pv } = out;
return sendJson(req, res, 200, {
product: { id: product.id, slug: product.slug, name: product.name, brand: product.brand, category: product.category, url: publication.abs(publication.productPath(product)), aliases: pv.aliases.map((a) => ({ kind: a.kind, value: a.value })) },
offers: pv.offers.map((v) => { const d = publication.offerDto(v); return { id: d.id, slug: d.slug, url: d.url, title: d.title, status: d.status, store: d.store, latest_observation: d.latest_observation, freshness: d.freshness }; }),
indexability: { indexable: pv.decision.indexable, robots: pv.decision.robots, reasons: pv.decision.reasons },
}, pv.decision);
}
-- const canonical = publication.abs(publication.productPath(product));
-- send(req, res, 200, {
-- title: `${product.name} — price comparison`,
-- description: `Offers for ${product.name}, each with the time its price was observed.`,
-- decision: pv.decision, canonical,
-- jsonLd: [publication.productJsonLd(pv), seo.structuredData.breadcrumbs([{ name: 'Deals', url: publication.abs('/') }, { name: product.name, url: canonical }])],
-- body: views.productPage({ pv, urls }),
-- }, { cacheable: true });
++ send(req, res, 200, out.page, { cacheable: true });
}));
-- router.get('/s/:domain', wrap(async (req, res) => {
-- const st = await catalog.storeByDomain(req.params.domain);
-- if (!st) return notFound(req, res);
++ async function storePage(domain, page = 1) {
++ const st = await catalog.storeByDomain(String(domain || ''));
++ if (!st) return null;
const path = publication.storePath(st);
const total = await listings.countStore(st.id);
-- const pager = ssr.paginate({ page: pageNumber(req), perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
-- if (pager.outOfRange && total) return notFound(req, res);
++ const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
++ if (pager.outOfRange && total) return null;
const list = await Promise.all((await listings.byStore(st.id, PER_PAGE, pager.offset)).map(async (o) => await publication.offerView(o)));
const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });
-- send(req, res, 200, {
++ return {
++ agent: true,
title: `Deals at ${st.name || st.domain}`, decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }), canonical,
prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,
body: views.storePage({ st, views: list, pager, urls }),
-- }, { cacheable: true });
++ };
++ }
++
++ router.get('/s/:domain', wrap(async (req, res) => {
++ const page = await storePage(req.params.domain, pageNumber(req));
++ if (!page) return notFound(req, res);
++ send(req, res, 200, page, { cacheable: true });
}));
// ── shops (the seller a source names, migration 0006) ──
-- router.get('/at', wrap(async (req, res) => {
++ async function sellersIndexPage() {
const sellers = await listings.sellers();
-- send(req, res, 200, {
++ return {
++ agent: true,
title: 'Deals by shop', decision: pageDecision('/at', { empty: sellers.length === 0 }), canonical: seo.canonicalUrl(config.baseUrl, '/at'),
body: views.sellersPage({ sellers }),
-- }, { cacheable: true });
-- }));
-- router.get('/at/:seller', wrap(async (req, res) => {
-- const want = String(req.params.seller || '').toLowerCase();
++ };
++ }
++
++ router.get('/at', wrap(async (req, res) => send(req, res, 200, await sellersIndexPage(), { cacheable: true })));
++
++ async function sellerPage(seller, page = 1) {
++ const want = String(seller || '').toLowerCase();
const hit = /^[a-z0-9-]{1,80}$/.test(want) ? (await listings.sellers()).find((x) => x.slug === want) : null;
-- if (!hit) return notFound(req, res);
++ if (!hit) return null;
const path = `/at/${hit.slug}`;
const total = await listings.countSeller(hit.seller);
-- const pager = ssr.paginate({ page: pageNumber(req), perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
-- if (pager.outOfRange && total) return notFound(req, res);
++ const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
++ if (pager.outOfRange && total) return null;
const list = await Promise.all((await listings.bySeller(hit.seller, PER_PAGE, pager.offset)).map(async (o) => await publication.offerView(o)));
const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });
-- send(req, res, 200, {
++ return {
++ agent: true,
title: `Deals at ${hit.seller}`, decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }), canonical,
description: `${total} deal${total === 1 ? '' : 's'} at ${hit.seller}, each with its source, price and when it was seen.`,
prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,
body: views.sellerPage({ seller: hit.seller, views: list, pager, urls, total }),
-- }, { cacheable: true });
++ };
++ }
++
++ router.get('/at/:seller', wrap(async (req, res) => {
++ const page = await sellerPage(req.params.seller, pageNumber(req));
++ if (!page) return notFound(req, res);
++ send(req, res, 200, page, { cacheable: true });
}));
// ── categories (as each deal's source names it, migration 0007) ──
-- router.get('/c', wrap(async (req, res) => {
++ async function categoriesIndexPage() {
const categories = await listings.categories();
-- send(req, res, 200, {
++ return {
++ agent: true,
title: 'Deals by category', decision: pageDecision('/c', { empty: categories.length === 0 }), canonical: seo.canonicalUrl(config.baseUrl, '/c'),
description: 'Every deal category on OpenVibe.Deals, from TVs to cleaning supplies, with how many active deals each has.',
body: views.categoriesPage({ categories }),
-- }, { cacheable: true });
-- }));
-- router.get('/c/:category', wrap(async (req, res) => {
-- const want = String(req.params.category || '').toLowerCase();
++ };
++ }
++
++ router.get('/c', wrap(async (req, res) => send(req, res, 200, await categoriesIndexPage(), { cacheable: true })));
++
++ async function categoryPage(category, page = 1) {
++ const want = String(category || '').toLowerCase();
const hit = /^[a-z0-9-]{1,80}$/.test(want) ? (await listings.categories()).find((x) => x.slug === want) : null;
-- if (!hit) return notFound(req, res);
++ if (!hit) return null;
const path = `/c/${hit.slug}`;
const total = await listings.countCategory(hit.category);
-- const pager = ssr.paginate({ page: pageNumber(req), perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
-- if (pager.outOfRange && total) return notFound(req, res);
++ const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
++ if (pager.outOfRange && total) return null;
const list = await Promise.all((await listings.byCategory(hit.category, PER_PAGE, pager.offset)).map(async (o) => await publication.offerView(o)));
const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });
-- send(req, res, 200, {
++ return {
++ agent: true,
title: `${hit.category} deals`, decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }), canonical,
description: `${total} active ${hit.category} deal${total === 1 ? '' : 's'}, each with its shop, its price and when that price was seen.`,
prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,
body: views.categoryPage({ category: hit.category, views: list, pager, urls, total }),
-- }, { cacheable: true });
++ };
++ }
++
++ router.get('/c/:category', wrap(async (req, res) => {
++ const page = await categoryPage(req.params.category, pageNumber(req));
++ if (!page) return notFound(req, res);
++ send(req, res, 200, page, { cacheable: true });
}));
// ── search ──────────────────────────────────────────────
@@@ -577,7 +648,14 @@ function createPages(ctx) {
});
router.get('/feed.json', async (_req, res) => res.type('application/feed+json').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(JSON.stringify(seo.jsonFeed({ ...channel(), feedUrl: publication.abs('/feed.json') }, await feedItems()))));
-- return { router, notFound, messagePage };
++ // The public pages the agent layer's Markdown twins (server/agent-pages.js) render with the same builders.
++ const publicViews = {
++ home: homePage, list: listPage, offer: offerBySlug, product: productPage,
++ store: storePage, sellers: sellersIndexPage, seller: sellerPage,
++ categories: categoriesIndexPage, category: categoryPage, updates: updatesPage,
++ };
++
++ return { router, notFound, messagePage, publicViews };
}
module.exports = { createPages };
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex eff7eb8..a88fc1c 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -76,7 +76,12 @@ function renderPage(o) {
const account = signedIn
? `${viewer.staff ? '<a href="/mod">Moderation</a> · ' : ''}<a href="/watches">Watches</a> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
: `<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;
++ // A page with a Markdown twin (o.agent) advertises it in the head; the twin itself is server/agent-pages.js.
++ const agentHead = o.agent && o.canonical
++ ? require('openvibe-shared/agent-layer').linkHeaderTags(o.canonical, `${o.config.baseUrl.replace(/\/+$/, '')}/llms.txt`).head
++ : '';
return layout.renderDocument({
++ head: agentHead,
site: 'deals',
siteName: SITE_NAME,
lang: o.lang,