openvibers/openvibe.play Commits

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

OP OpenVibers committed 15 days ago · f402c3cc47 · 10 files, +405 −35

openvibe-shared v4.34.0 (was v4.33.0). The descriptor names what openvibe.play really serves: the public read
API at /api/v1, the developer guide at /docs, and the three ways in (none for reads, Network OIDC for people,
a game's Network service token to write). The public pages keep their own builders and server/agent-pages.js
renders their twins through the same ones (createPageRoutes().publicViews), so a twin cannot drift from its
page: the home, every production game and its boards, the developer guide and the update log. A sandbox
title's pages stay unwritten — not listed, not indexed, not advertised — and /me is a person's own. Only the
manifest and twin requests reach the mount, even though it sits before the page routes, so signed-in pages
keep their account bar; each public page advertises its twin with both discovery links and the Link header.
robots.txt gains the manifest comment.

Changes

ddiff --git a/README.md b/README.md
iindex fb15bda..0453f5a 100644
---- a/README.md
++++ b/README.md
@@@ -151,5 +151,5 @@ AGPL-3.0. Part of the [OpenVibe network](https://openvibe.network).
<!-- 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 c4f63cb..30d028e 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -9,11 +9,11 @@
"domain": "play.openvibe.network",
"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-sdk v0.67.0",
-- "openvibe-shared v4.33.0"
++ "openvibe-shared v4.34.0"
],
"summary": "OpenVibe.Play — achievements and leaderboards for every OpenVibe game (Games ADR-0007 decision 10). A title is registered by its own principal (a game's service or app token), which alone writes to it: achievements (with hidden ones), boards that rank each player's best score, the sum of theirs or their fastest time in milliseconds, this week and all time, idempotent score and unlock posts, and guests adopted by the person who signs in. Boards and titles are public reads (CORS) and pages; people's rows are in the account export and erased with the account.",
"works": [
ddiff --git a/package-lock.json b/package-lock.json
iindex ff735e9..0afb2c0 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -18,7 +18,7 @@
"iovalkey": "^0.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": {
@@@ -28,6 +28,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.10",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.10.tgz",
@@@ -117,6 +123,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",
@@@ -219,6 +238,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",
@@@ -256,6 +308,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",
@@@ -306,6 +425,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",
@@@ -564,6 +695,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",
@@@ -709,6 +944,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",
@@@ -847,6 +1106,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",
@@@ -916,12 +1191,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"
@@@ -1364,6 +1640,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 3b76fc0..e65f19c 100644
---- a/package.json
++++ b/package.json
@@@ -25,7 +25,7 @@
"iovalkey": "^0.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..a062d72
---- /dev/null
++++ b/server/agent-descriptor.js
@@@ -0,0 +1,16 @@
++'use strict';
++// What openvibe.play tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.
++// Every field is something this site really serves: the public read API at /api/v1, the developer guide at
++// /docs, and the three ways in (none for reads, Network OIDC for people, a game's Network service token to write).
++module.exports = {
++ id: 'play',
++ name: 'OpenVibe.Play',
++ purpose: 'Achievements, weekly and all-time leaderboards and cloud saves for every OpenVibe game, with guests welcome and an SDK for developers.',
++ home: 'https://play.openvibe.network',
++ site: 'play.openvibe.network',
++ api: '/api/v1',
++ docs: '/docs',
++ policy: {
++ auth: ['none', 'Network OIDC', 'Network service token (Bearer, per game)'],
++ },
++};
ddiff --git a/server/agent-pages.js b/server/agent-pages.js
nnew file mode 100644
iindex 0000000..fd11805
---- /dev/null
++++ b/server/agent-pages.js
@@@ -0,0 +1,30 @@
++'use strict';
++// The Markdown twins of openvibe.play's public pages (openvibe-shared/agent-layer), rendered by the same page
++// builders the routes use (createPageRoutes().publicViews + render/layout.js's renderPage), so a twin cannot
++// drift from its page: the home, every production game and its boards, the developer guide and the update log.
++// A sandbox title's pages are not listed, not indexed and not advertised, so they have no twin.
++const { renderPage } = require('./render/layout');
++
++function createAgentPages(ctx, publicViews) {
++ // A view that is not marked public (a sandbox title's) has no twin.
++ const twin = (page, path) => (page && page.agent === true ? { html: renderPage({ ...page, viewer: { kind: 'anonymous' }, config: ctx.config, path }) } : null);
++
++ async function renderPublicPage(path, { query } = {}) {
++ if (path === '/') return twin(await publicViews.home({ signed: false }), path);
++ if (path === '/docs') return twin(publicViews.docs(), path);
++ if (path === '/updates') return twin(publicViews.updates(), path);
++ let m = /^\/t\/([^/]+)$/.exec(path);
++ if (m) return twin(await publicViews.title(decodeURIComponent(m[1])), path);
++ m = /^\/t\/([^/]+)\/b\/([^/]+)$/.exec(path);
++ if (m) {
++ const period = query && query.get('period') === 'week' ? 'week' : 'all';
++ const pageNo = Math.max(1, Math.min(10_000, parseInt(query && query.get('page'), 10) || 1));
++ return twin(await publicViews.board(decodeURIComponent(m[1]), decodeURIComponent(m[2]), { period, page: pageNo }), path);
++ }
++ return null;
++ }
++
++ return { renderPublicPage };
++}
++
++module.exports = { createAgentPages };
ddiff --git a/server/app.js b/server/app.js
iindex 05cf400..598f494 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -164,7 +164,21 @@ async function createApp(opts = {}) {
// ── Pages ───────────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.PLAY_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
-- app.use(createPageRoutes(ctx));
++ const pageRoutes = createPageRoutes(ctx);
++ // The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and the Markdown twins of the public
++ // pages. The pages keep their own routes — a signed-in person's progress is 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, pageRoutes.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);
++ });
++ app.use(pageRoutes);
app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try <a href="/">the home page</a> or <a href="/updates">the update log</a>.</p>` }));
// eslint-disable-next-line no-unused-vars
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 5f718ba..fef1967 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -59,6 +59,7 @@ function createDiscoveryRoutes(ctx) {
r.get('/robots.txt', (_req, res) => {
res.type('text/plain').set('Cache-Control', TEXT).send(
'# play.openvibe.network: the public pages are for search and AI crawlers; sign-in and the API are not.\n'
++ + '# Agent manifest: https://play.openvibe.network/.well-known/agent.json; every public page has a .md twin.\n'
+ seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
});
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 38ae1c6..e44735a 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -62,9 +62,9 @@ function createPageRoutes(ctx) {
const notFound = (req, res, what) => page(req, res, { title: 'Not found', body: html`<h1>Not found</h1><p>${what} <a href="/">See every game</a>.</p>` }, 404);
// ── Home ─────────────────────────────────────────────────
-- r.get('/', async (req, res) => {
++ async function homePage({ signed = false } = {}) {
const titles = await play.titles();
-- const me = signedIn(req);
++ const me = signed;
const leaders = new Map();
for (const t of titles) {
if (t.boards.length) leaders.set(t.id, await play.board(t.id, t.boards[0].id, { period: 'week' }));
@@@ -90,8 +90,8 @@ function createPageRoutes(ctx) {
note: 'Boards show a name and a score, never a player id.',
html: `<dl class="pl-stats">${stats.map(([dt, dd]) => `<div><dt>${dt}</dt><dd>${dd}</dd></div>`).join('')}</dl>`,
});
-- page(req, res, {
-- index: true, cache: me ? null : PUBLIC_CACHE,
++ return {
++ agent: true, index: true, cache: me ? null : PUBLIC_CACHE,
description: 'Game services for OpenVibe games: achievements, weekly and all-time leaderboards and cloud saves, with guests welcome and an SDK for developers.',
jsonLd: [...homeJsonLd(config), homeFaqJsonLd()],
styles: [showcase.STYLESHEET],
@@@ -164,17 +164,18 @@ ${raw(showcase.limits({
],
}))}
<section class="pl-faq" aria-labelledby="pl-faq-h"><h2 id="pl-faq-h">Questions</h2>${HOME_FAQ.map((f) => html`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</section>`,
-- });
-- });
++ };
++ }
++ r.get('/', async (req, res) => page(req, res, await homePage({ signed: !!signedIn(req) })));
// ── A title ──────────────────────────────────────────────
-- r.get('/t/:title', async (req, res) => {
-- const t = await play.title(req.params.title);
-- if (!t) return notFound(req, res, 'No game here.');
++ async function titlePage(title) {
++ const t = await play.title(title);
++ if (!t) return null;
const boards = [];
for (const b of t.boards) boards.push(await play.board(t.id, b.id, { period: 'week' }));
-- page(req, res, {
-- index: !t.sandbox, cache: PUBLIC_CACHE, title: `${t.name}: achievements and leaderboards`,
++ return {
++ agent: !t.sandbox, index: !t.sandbox, cache: PUBLIC_CACHE, title: `${t.name}: achievements and leaderboards`,
description: `${t.name} on OpenVibe.Play: ${plural(t.achievements.length, 'achievement', 'achievements')} and ${plural(t.boards.length, 'leaderboard', 'leaderboards')}, this week and all time.`,
body: html`<p class="pl-crumbs"><a href="/">Games</a> /</p>
<h1>${t.name}</h1>
@@@ -183,16 +184,19 @@ ${t.summary ? html`<p class="pl-lede">${t.summary}</p>` : ''}
${t.url ? html`<p><a class="pl-button" href="${t.url}">Play ${t.name}</a></p>` : ''}
${boards.length ? html`<section aria-labelledby="pl-boards-h"><h2 id="pl-boards-h">Leaderboards this week</h2>${boards.map((b) => html`<h3>${b.name}</h3>${boardTable({ ...b, entries: b.entries.slice(0, 10) })}<p><a href="/t/${t.id}/b/${b.board}?period=week">This week</a> · <a href="/t/${t.id}/b/${b.board}">All time</a></p>`)}</section>` : ''}
<section aria-labelledby="pl-ach-h"><h2 id="pl-ach-h">Achievements</h2><p class="pl-muted">${plural(t.players, 'player has', 'players have')} unlocked at least one.</p>${achievementList(t.achievements, { players: t.players })}</section>`,
-- });
++ };
++ }
++ r.get('/t/:title', async (req, res) => {
++ const out = await titlePage(req.params.title);
++ if (!out) return notFound(req, res, 'No game here.');
++ page(req, res, out);
});
// ── A board ──────────────────────────────────────────────
-- r.get('/t/:title/b/:board', async (req, res) => {
-- const period = req.query.period === 'week' ? 'week' : 'all';
-- const pageNo = Math.max(1, Math.min(10_000, parseInt(req.query.page, 10) || 1));
++ async function boardPage(title, board, { period = 'all', page: pageNo = 1 } = {}) {
let b;
-- try { b = await play.board(req.params.title, req.params.board, { period, page: pageNo }); } catch (err) {
-- if (err instanceof PlayError) return notFound(req, res, 'No board here.');
++ try { b = await play.board(title, board, { period, page: pageNo }); } catch (err) {
++ if (err instanceof PlayError) return null;
throw err;
}
// All time is the board's plain address and this week is ?period=week: two pages, each its own canonical (the
@@@ -201,7 +205,8 @@ ${boards.length ? html`<section aria-labelledby="pl-boards-h"><h2 id="pl-boards-
const q = [(o.period || period) === 'week' ? 'period=week' : null, (o.page || 1) > 1 ? `page=${o.page}` : null].filter(Boolean);
return `/t/${b.title}/b/${b.board}${q.length ? `?${q.join('&')}` : ''}`;
};
-- page(req, res, {
++ return {
++ agent: !isSandbox(b.title),
canonicalPath: link({ page: pageNo }),
index: !isSandbox(b.title), cache: PUBLIC_CACHE, title: `${b.name}, ${period === 'week' ? 'this week' : 'all time'} · ${b.title_name}`,
description: `The ${b.name} leaderboard of ${b.title_name}, ${period === 'week' ? 'this week' : 'all time'}: ${b.kind === 'sum' ? 'each player\'s total' : b.kind === 'time' ? 'each player\'s fastest time' : 'each player\'s best score'}.`,
@@@ -211,7 +216,14 @@ ${boards.length ? html`<section aria-labelledby="pl-boards-h"><h2 id="pl-boards-
<nav class="pl-tabs" aria-label="Period">${period === 'week' ? html`<span aria-current="page">This week</span>` : html`<a href="${link({ period: 'week' })}">This week</a>`}${period === 'all' ? html`<span aria-current="page">All time</span>` : html`<a href="${link({ period: 'all' })}">All time</a>`}</nav>
${boardTable(b)}
${b.pages > 1 ? html`<nav class="pl-pages" aria-label="Pages">${pageNo > 1 ? html`<a href="${link({ page: pageNo - 1 })}" rel="prev">Previous</a>` : ''}<span>Page ${String(pageNo)} of ${String(b.pages)}</span>${pageNo < b.pages ? html`<a href="${link({ page: pageNo + 1 })}" rel="next">Next</a>` : ''}</nav>` : ''}`,
-- });
++ };
++ }
++ r.get('/t/:title/b/:board', async (req, res) => {
++ const period = req.query.period === 'week' ? 'week' : 'all';
++ const pageNo = Math.max(1, Math.min(10_000, parseInt(req.query.page, 10) || 1));
++ const out = await boardPage(req.params.title, req.params.board, { period, page: pageNo });
++ if (!out) return notFound(req, res, 'No board here.');
++ page(req, res, out);
});
// ── Yours ────────────────────────────────────────────────
@@@ -232,8 +244,8 @@ ${saves.length ? html`<section><h2>Your saves</h2><p class="pl-muted">What games
});
// ── For developers ───────────────────────────────────────
-- r.get('/docs', (req, res) => page(req, res, {
-- index: true, cache: PUBLIC_CACHE, title: 'The Play API',
++ function docsPage() { return {
++ agent: true, index: true, cache: PUBLIC_CACHE, title: 'The Play API',
description: 'Register a game\'s achievements and leaderboards, post scores and unlocks, and read the boards: the OpenVibe.Play API.',
body: html`<h1>The Play API</h1>
<p class="pl-lede">A game registers its title with its own OpenVibe token, posts what its players did, and reads its boards. Base address: <code>https://play.openvibe.network/api/v1</code>. Errors are RFC 9457 problem+json with a stable <code>code</code>.</p>
@@@ -269,13 +281,19 @@ ${saves.length ? html`<section><h2>Your saves</h2><p class="pl-muted">What games
<li>Get a token for audience <code>openvibe.play</code> (client credentials) and <code>PUT /titles/sandbox-your-game</code>.</li>
</ol>
<p>A sandbox app registers up to ${String(MAX_SANDBOX_TITLES)} titles, each id starting with <code>sandbox-</code>, and deletes one with everything in it by <code>DELETE /titles/:title</code> (<code>play.title.manage</code>; a production title is never deleted) (anything else answers <code>400 play.sandbox_title</code>). Their players are guests only (<code>400 play.sandbox_guests_only</code> for a person), nothing is adopted, and they are never listed, counted or indexed: their pages and JSON answer at their id, marked <code>sandbox: true</code>, so you can check your boards. When your game is ready, staff turn production on for your project; its production app registers the real name and real players' progress counts on their profiles.</p>`,
-- }));
++ }; }
++ r.get('/docs', (req, res) => page(req, res, docsPage()));
-- r.get('/updates', (req, res) => page(req, res, {
-- index: true, cache: PUBLIC_CACHE,
++ const updatesPage = () => ({
++ agent: true, index: true, cache: PUBLIC_CACHE,
title: `What shipped on ${SITE_NAME}`,
body: raw(frame.updatesBody({ service: 'play', siteName: SITE_NAME }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`),
-- }));
++ });
++ r.get('/updates', (req, res) => page(req, res, updatesPage()));
++
++ // The public pages the agent layer's Markdown twins (server/agent-pages.js) render with the same builders.
++ // A sandbox title's pages stay unwritten: they are not listed, not indexed and not advertised.
++ r.publicViews = { home: homePage, title: titlePage, board: boardPage, docs: docsPage, updates: updatesPage };
// ── Discovery: robots.txt, sitemap.xml, llms.txt, llms-full.txt ──
r.use(createDiscoveryRoutes(ctx));
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex a39e894..f05ea1d 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -86,6 +86,10 @@ function renderPage(o) {
description: o.description || `${SITE_NAME} — ${TAGLINE}`,
canonical,
robots: o.index ? 'index, follow' : 'noindex, nofollow',
++ // A page with a Markdown twin (o.agent) advertises it in the head and the Link header; the twin itself
++ // is server/agent-pages.js.
++ agentLayer: o.agent === true,
++ llms: `${String(o.config.baseUrl).replace(/\/+$/, '')}/llms.txt`,
jsonLd: (o.jsonLd || []).filter(Boolean),
home: '/',
navLinks: NAV,
@@@ -123,6 +127,11 @@ function send(res, status, o) {
const personal = o.viewer && o.viewer.kind === 'user';
if (!res.get('Cache-Control')) res.set('Cache-Control', !personal && o.cache ? o.cache : cache.htmlHeaders({ private: true }));
res.set('Vary', 'Cookie');
++ if (o.agent) {
++ const site = String(o.config.baseUrl).replace(/\/+$/, '');
++ const path = o.canonicalPath || String(o.path || '/').split('?')[0];
++ res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(`${site}${path}`, `${site}/llms.txt`).header);
++ }
res.status(status).type('html').send(renderPage(o));
}