openvibers/openvibe.inventory Commits
Agent layer: /.well-known/agent.json and Markdown twins of the public pages (#113)
Changes
@@@ -232,6 +232,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.4.0<!-- versions:end -->@@@ -9,11 +9,11 @@"domain": "inventory.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","openvibe-publishing v1.4.0"],"summary": "OpenVibe.Inventory — one inventory for an OpenVibe account (ADR-054). Item kinds (a contract plus a renderer per surface), definitions, instances, the equipped set and an append-only ledger in its own PostgreSQL database. Issuers (services, apps) grant only within their namespace; people see and equip what they own; every movement is a ledger row and an inventory.* event. Nothing is sold, bought, traded or converted. Live's name effects, particles, hats and voices are the first kinds (70 definitions seeded at boot), and scripts/import-live.js moves Live's unlocks and equipped slots here, row for row.",@@@ -20,7 +20,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.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": {@@@ -30,6 +30,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",@@@ -119,6 +125,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",@@@ -232,6 +251,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",@@@ -269,6 +321,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",@@@ -319,6 +438,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",@@@ -577,6 +708,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",@@@ -722,6 +957,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",@@@ -860,6 +1119,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",@@@ -947,12 +1222,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"@@@ -1403,6 +1679,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",@@@ -27,7 +27,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.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": {nnew file mode 100644@@@ -0,0 +1,15 @@++'use strict';++// What inventory.openvibe.network tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.++// Every field is something this site really serves: the public catalog and item API at /api/v1, and the public++// catalog pages. There is no documentation page of its own (the API is described in the README), so `docs` is omitted.++module.exports = {++ id: 'inventory',++ name: 'OpenVibe.Inventory',++ purpose: 'One inventory for your whole OpenVibe account: name effects, particles, hats, voices and badges, earned across the network and worn in chat, on stream overlays and on your profile.',++ home: 'https://inventory.openvibe.network',++ site: 'inventory.openvibe.network',++ api: '/api/v1',++ policy: {++ auth: ['none', 'Network OIDC'],++ },++};nnew file mode 100644@@@ -0,0 +1,26 @@++'use strict';++// The Markdown twins of inventory.openvibe.network's public pages (openvibe-shared/agent-layer), rendered by the++// same page builders the routes use (createPageRoutes().publicViews, createWorkshopRoutes().publicViews and++// render/layout.js's renderPage), so a twin cannot drift from its page: the home, the catalog (all and one kind),++// every published or retired item, the Workshop and the update log. /me is a person's own and /u/:subject is a++// person's page (noindex): neither has a twin.++const { renderPage } = require('./render/layout');++++function createAgentPages(ctx, publicViews) {++ // A view that is not marked public (a person's page) 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 === '/items') return twin(await publicViews.items(query && query.get('kind') ? String(query.get('kind')) : null, { signed: false }), path);++ if (path === '/updates') return twin(publicViews.updates(), path);++ if (path === '/workshop') return twin(await publicViews.workshop(), path);++ const m = /^\/items\/([^/]+)$/.exec(path);++ if (m) return twin(await publicViews.item(decodeURIComponent(m[1]), { signed: false }), path);++ return null;++ }++++ return { renderPublicPage };++}++++module.exports = { createAgentPages };@@@ -192,8 +192,23 @@ async function createApp(opts = {}) {// ── Pages ───────────────────────────────────────────────app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.INVENTORY_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));-- app.use(createWorkshopRoutes(ctx));-- app.use(createPageRoutes(ctx));++ const workshopRoutes = createWorkshopRoutes(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 inventory and forms 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, { ...pageRoutes.publicViews, workshop: workshopRoutes.publicViews.workshop }).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(workshopRoutes);++ 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@@@ -87,6 +87,7 @@ function createDiscoveryRoutes(ctx) {r.get('/robots.txt', (_req, res) => {res.type('text/plain').set('Cache-Control', TEXT).send('# inventory.openvibe.network: the public pages are for search and AI crawlers; sign-in and the API are not.\n'++ + '# Agent manifest: https://inventory.openvibe.network/.well-known/agent.json; every public page has a .md twin.\n'+ seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));});@@@ -89,10 +89,10 @@ function createPageRoutes(ctx) {}// ── Home ─────────────────────────────────────────────────-- r.get('/', async (req, res) => {++ async function homePage({ signed = false } = {}) {const { kinds, defs, byKind } = await catalog();const featured = [...defs].sort((a, b) => RARITY.indexOf(b.rarity) - RARITY.indexOf(a.rarity) || a.name.localeCompare(b.name)).slice(0, 6);-- const me = signedIn(req);++ const me = signed;const hero = showcase.hero({eyebrow: `${SITE_NAME} · part of OpenVibe`,title: 'Your items,',@@@ -104,8 +104,8 @@ function createPageRoutes(ctx) {note: 'Items are earned or given, never sold here. Open source (AGPL-3.0).',aside: { html: html`<div class="inv-hero-panel" aria-label="A few of the rarest items"><p class="inv-hero-title">From the collection</p><ul class="inv-hero-grid">${featured.map((d) => html`<li><a href="/items/${d.id}" title="${d.name} · ${RARITY_LABEL[d.rarity]}">${art(d, { size: 'lg' })}<span class="inv-hero-name">${d.name}</span></a></li>`)}</ul></div>`.toString() },});-- page(req, res, {-- index: true, cache: me ? null : PUBLIC_CACHE,++ return {++ agent: true, index: true, cache: me ? null : PUBLIC_CACHE,jsonLd: homeJsonLd(config),styles: [showcase.STYLESHEET],body: html`${raw(hero)}@@@ -129,18 +129,18 @@ ${raw(showcase.steps({}))}${raw(showcase.network({ service: 'inventory' }))}${raw(showcase.cta({ title: me ? 'See what you have' : 'Already earned something?', text: me ? 'Your items and what you are wearing, on one page.' : 'Sign in with your OpenVibe account to see your inventory and choose what to wear.', actions: me ? [{ label: 'Your inventory', href: '/me' }] : [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2Fme' }, { label: 'The API', href: '/api/v1/kinds' }] }))}`,-- });-- });++ };++ }++ r.get('/', async (req, res) => page(req, res, await homePage({ signed: !!signedIn(req) })));// ── The catalog ──────────────────────────────────────────-- r.get('/items', async (req, res) => {++ async function itemsPage(only = null, { signed = false } = {}) {const { kinds, defs, byKind } = await catalog();-- const only = req.query.kind ? String(req.query.kind) : null;-- if (only && !byKind.has(only)) return notFound(req, res, 'There is no such kind of item.');++ if (only && !byKind.has(only)) return null;// All: the kinds that have items; one kind: that kind, even while it is empty.const shown = kinds.filter((k) => (only ? k.id === only : (byKind.get(k.id) || []).length));-- page(req, res, {-- index: !only, cache: signedIn(req) ? null : PUBLIC_CACHE,++ return {++ agent: true, index: !only, cache: signed ? null : PUBLIC_CACHE,title: only ? `${shown[0].name} items` : 'Every item',description: `${plural(defs.length, 'item', 'items')} you can earn and wear across OpenVibe, with their rarity.`,body: html`<h1>${only ? `${shown[0].name} items` : 'Every item'}</h1>@@@ -150,16 +150,22 @@ ${shown.map((k) => html`<section class="inv-kind" aria-labelledby="k-${k.id.repl<h2 id="k-${k.id.replace(/\W/g, '-')}">${k.name} <span class="muted small">shows in ${k.surfaces.map((s) => surfaceLabel(s.surface)).join(', ')}</span></h2>${(byKind.get(k.id) || []).length ? html`<ul class="inv-grid">${byKind.get(k.id).map((d) => card(d))}</ul>` : html`<p class="muted">No ${k.name.toLowerCase()} items yet.</p>`}</section>`)}`,-- });++ };++ }++ r.get('/items', async (req, res) => {++ const only = req.query.kind ? String(req.query.kind) : null;++ const out = await itemsPage(only, { signed: !!signedIn(req) });++ if (!out) return notFound(req, res, 'There is no such kind of item.');++ page(req, res, out);});-- r.get('/items/:id', async (req, res) => {-- const d = await inv.getDefinition(req.params.id);-- if (!d || (d.status !== 'published' && d.status !== 'retired')) return notFound(req, res, 'There is no such item.');++ async function itemPage(id, { signed = false } = {}) {++ const d = await inv.getDefinition(id);++ if (!d || (d.status !== 'published' && d.status !== 'retired')) return null;const k = await inv.getKind(d.kind);const issuer = d.issuer.startsWith('service:') ? `OpenVibe.${d.issuer.slice(8).replace(/^./, (c) => c.toUpperCase())}` : d.credit && d.credit.name ? d.credit.name : 'a creator';-- page(req, res, {-- index: d.status === 'published', cache: signedIn(req) ? null : PUBLIC_CACHE,++ return {++ agent: true, index: d.status === 'published', cache: signed ? null : PUBLIC_CACHE,title: `${d.name} · ${RARITY_LABEL[d.rarity]} ${k.name.toLowerCase()}`,// What the item is, how many people own it and how it is got; its flavor text alone ("Angelic") said none of that.description: itemDescription(d, k),@@@ -186,7 +192,12 @@ ${(d.grantors || []).includes('service:games') && d.status === 'published' ? htm<h2>Who gives it</h2><p>${issuer}${(d.grantors || []).length ? ', and the services it names (see How to get it)' : ''}. ${(d.grantors || []).length ? 'Nobody else' : 'Only its issuer'} can give this item; it is never sold here.</p><p class="muted small">Item <code>${d.id}</code>${(d.aliases || []).length ? html` · also known as <code>${d.aliases.join(', ')}</code>` : ''} · <a href="/api/v1/definitions/${d.id}">JSON</a></p>`,-- });++ };++ }++ r.get('/items/:id', async (req, res) => {++ const out = await itemPage(req.params.id, { signed: !!signedIn(req) });++ if (!out) return notFound(req, res, 'There is no such item.');++ page(req, res, out);});// ── Your inventory ───────────────────────────────────────@@@ -262,11 +273,16 @@ ${!owned.length ? html`<p class="muted">Nothing to show yet.</p>` : ''}`,});// ── The update log ───────────────────────────────────────-- 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: 'inventory', 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.++ // /u/:subject is a person's page (noindex) and /me is their own: neither has a twin.++ r.publicViews = { home: homePage, items: itemsPage, item: itemPage, updates: updatesPage };// ── Discovery: robots.txt, sitemap.xml, llms.txt, llms-full.txt ──r.use(createDiscoveryRoutes(ctx));@@@ -84,6 +84,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,@@@ -121,6 +125,10 @@ 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(/\/+$/, '');++ res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(`${site}${String(o.path || '/').split('?')[0]}`, `${site}/llms.txt`).header);++ }res.status(status).type('html').send(renderPage(o));}@@@ -60,11 +60,11 @@ function createWorkshopRoutes(ctx) {const notice = (req) => html`${req.query.done ? html`<p class="notice ok" role="status">${String(req.query.done).slice(0, 200)}</p>` : ''}${req.query.error ? html`<p class="notice bad" role="alert">${String(req.query.error).slice(0, 200)}</p>` : ''}`;// ── The Workshop ─────────────────────────────────────────-- r.get('/workshop', async (req, res) => {++ async function workshopPage() {const published = await inv.workshopItems({ status: 'published', limit: 200 });-- page(req, res, {++ return {// A public page in search, as the sitemap says (it was noindex by default).-- title: 'The Workshop', index: true,++ agent: true, title: 'The Workshop', index: true,description: 'Badges made by people in the OpenVibe community, reviewed by staff, given free by their makers.',body: html`<h1>The Workshop</h1><p class="lede">Badges made by people in the community. Make one, staff review it, then you give it to the people you choose: your viewers, your server, the friends you played with. Every badge here is free: nothing in the Workshop is sold or traded.</p>@@@ -77,8 +77,9 @@ function createWorkshopRoutes(ctx) {</ol><h2>Community badges <span class="muted small">${published.length}</span></h2>${published.length ? html`<ul class="inv-grid">${published.map((d) => html`<li class="inv-card r-${d.rarity}"><a class="inv-card-link" href="/items/${d.id}"><span class="inv-art inv-art-md r-${d.rarity}">${img(d)}</span><span class="inv-card-body"><span class="inv-card-name">${d.name}</span><span class="inv-rarity r-${d.rarity}">${RARITY_LABEL[d.rarity]}</span></span></a>${d.credit && d.credit.name ? html`<p class="inv-card-meta">by @${d.credit.name} · ${d.supply.issued} given</p>` : ''}</li>`)}</ul>` : html`<p class="muted">None yet. Yours could be the first.</p>`}`,-- });-- });++ };++ }++ r.get('/workshop', async (req, res) => page(req, res, await workshopPage()));// ── Make a badge ─────────────────────────────────────────r.get('/workshop/new', async (req, res) => {@@@ -266,6 +267,10 @@ ${live.length ? html`<ul class="ws-mine">${live.map((d) => html`<li class="ws-it}});++ // The public Workshop page, for the agent layer's Markdown twin (server/agent-pages.js), rendered by the same++ // builder. /workshop/new, /workshop/mine and /workshop/review are a signed-in person's and have no twin.++ r.publicViews = { workshop: workshopPage };++return r;}
@@ -232,6 +232,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
@@ -232,6 +232,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.222.0
- openvibe-contracts: v0.222.0
- openvibe-sdk: v0.67.0
- openvibe-sdk: v0.67.0
-- openvibe-shared: v4.33.0
+- openvibe-shared: v4.34.0
- openvibe-publishing: v1.4.0
- openvibe-publishing: v1.4.0
<!-- versions:end -->
<!-- versions:end -->
@@ -9,11 +9,11 @@
@@ -9,11 +9,11 @@
"domain": "inventory.openvibe.network",
"domain": "inventory.openvibe.network",
"contracts": "openvibe-contracts v0.222.0",
"contracts": "openvibe-contracts v0.222.0",
"sdk": "openvibe-sdk v0.67.0",
"sdk": "openvibe-sdk v0.67.0",
- "shared": "openvibe-shared v4.33.0",
+ "shared": "openvibe-shared v4.34.0",
"packages": [
"packages": [
"openvibe-contracts v0.222.0",
"openvibe-contracts v0.222.0",
"openvibe-sdk v0.67.0",
"openvibe-sdk v0.67.0",
- "openvibe-shared v4.33.0",
+ "openvibe-shared v4.34.0",
"openvibe-publishing v1.4.0"
"openvibe-publishing v1.4.0"
],
],
"summary": "OpenVibe.Inventory — one inventory for an OpenVibe account (ADR-054). Item kinds (a contract plus a renderer per surface), definitions, instances, the equipped set and an append-only ledger in its own PostgreSQL database. Issuers (services, apps) grant only within their namespace; people see and equip what they own; every movement is a ledger row and an inventory.* event. Nothing is sold, bought, traded or converted. Live's name effects, particles, hats and voices are the first kinds (70 definitions seeded at boot), and scripts/import-live.js moves Live's unlocks and equipped slots here, row for row.",
"summary": "OpenVibe.Inventory — one inventory for an OpenVibe account (ADR-054). Item kinds (a contract plus a renderer per surface), definitions, instances, the equipped set and an append-only ledger in its own PostgreSQL database. Issuers (services, apps) grant only within their namespace; people see and equip what they own; every movement is a ledger row and an inventory.* event. Nothing is sold, bought, traded or converted. Live's name effects, particles, hats and voices are the first kinds (70 definitions seeded at boot), and scripts/import-live.js moves Live's unlocks and equipped slots here, row for row.",
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"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.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.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"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -30,6 +30,12 @@
@@ -30,6 +30,12 @@
"node": ">=22.0.0"
"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": {
"node_modules/@electric-sql/pglite": {
"version": "0.5.8",
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
@@ -119,6 +125,19 @@
@@ -119,6 +125,19 @@
"npm": "1.2.8000 || >= 1.4.16"
"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": {
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -232,6 +251,39 @@
@@ -232,6 +251,39 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
"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": {
"node_modules/debug": {
"version": "2.6.9",
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -269,6 +321,73 @@
@@ -269,6 +321,73 @@
"npm": "1.2.8000 || >= 1.4.16"
"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": {
"node_modules/dotenv": {
"version": "16.6.1",
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -319,6 +438,18 @@
@@ -319,6 +438,18 @@
"node": ">= 0.8"
"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": {
"node_modules/es-define-property": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -577,6 +708,110 @@
@@ -577,6 +708,110 @@
"url": "https://github.com/sponsors/EvanHahn"
"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": {
"node_modules/http-errors": {
"version": "2.0.1",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -722,6 +957,30 @@
@@ -722,6 +957,30 @@
"safe-buffer": "^5.0.1"
"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": {
"node_modules/lodash.defaults": {
"version": "4.2.0",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@@ -860,6 +1119,22 @@
@@ -860,6 +1119,22 @@
"node": ">= 0.6"
"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": {
"node_modules/object-inspect": {
"version": "1.13.4",
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -947,12 +1222,13 @@
@@ -947,12 +1222,13 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
- "jsonwebtoken": "^9.0.0"
+ "jsonwebtoken": "^9.0.0",
+ "linkedom": "^0.18.13"
},
},
"bin": {
"bin": {
"openvibe-build-assets": "scripts/build-assets.js"
"openvibe-build-assets": "scripts/build-assets.js"
@@ -1403,6 +1679,12 @@
@@ -1403,6 +1679,12 @@
"node": ">= 0.6"
"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": {
"node_modules/unpipe": {
"version": "1.0.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"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.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.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"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+'use strict';
+// What inventory.openvibe.network tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.
+// Every field is something this site really serves: the public catalog and item API at /api/v1, and the public
+// catalog pages. There is no documentation page of its own (the API is described in the README), so `docs` is omitted.
+module.exports = {
+ id: 'inventory',
+ name: 'OpenVibe.Inventory',
+ purpose: 'One inventory for your whole OpenVibe account: name effects, particles, hats, voices and badges, earned across the network and worn in chat, on stream overlays and on your profile.',
+ home: 'https://inventory.openvibe.network',
+ site: 'inventory.openvibe.network',
+ api: '/api/v1',
+ policy: {
+ auth: ['none', 'Network OIDC'],
+ },
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+'use strict';
+// The Markdown twins of inventory.openvibe.network's public pages (openvibe-shared/agent-layer), rendered by the
+// same page builders the routes use (createPageRoutes().publicViews, createWorkshopRoutes().publicViews and
+// render/layout.js's renderPage), so a twin cannot drift from its page: the home, the catalog (all and one kind),
+// every published or retired item, the Workshop and the update log. /me is a person's own and /u/:subject is a
+// person's page (noindex): neither has a twin.
+const { renderPage } = require('./render/layout');
+
+function createAgentPages(ctx, publicViews) {
+ // A view that is not marked public (a person's page) 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 === '/items') return twin(await publicViews.items(query && query.get('kind') ? String(query.get('kind')) : null, { signed: false }), path);
+ if (path === '/updates') return twin(publicViews.updates(), path);
+ if (path === '/workshop') return twin(await publicViews.workshop(), path);
+ const m = /^\/items\/([^/]+)$/.exec(path);
+ if (m) return twin(await publicViews.item(decodeURIComponent(m[1]), { signed: false }), path);
+ return null;
+ }
+
+ return { renderPublicPage };
+}
+
+module.exports = { createAgentPages };
@@ -192,8 +192,23 @@ async function createApp(opts = {}) {
@@ -192,8 +192,23 @@ async function createApp(opts = {}) {
// ── Pages ───────────────────────────────────────────────
// ── Pages ───────────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.INVENTORY_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.INVENTORY_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
- app.use(createWorkshopRoutes(ctx));
- app.use(createPageRoutes(ctx));
+ const workshopRoutes = createWorkshopRoutes(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 inventory and forms 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, { ...pageRoutes.publicViews, workshop: workshopRoutes.publicViews.workshop }).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(workshopRoutes);
+ 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>` }));
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
// eslint-disable-next-line no-unused-vars
@@ -87,6 +87,7 @@ function createDiscoveryRoutes(ctx) {
@@ -87,6 +87,7 @@ function createDiscoveryRoutes(ctx) {
r.get('/robots.txt', (_req, res) => {
r.get('/robots.txt', (_req, res) => {
res.type('text/plain').set('Cache-Control', TEXT).send(
res.type('text/plain').set('Cache-Control', TEXT).send(
'# inventory.openvibe.network: the public pages are for search and AI crawlers; sign-in and the API are not.\n'
'# inventory.openvibe.network: the public pages are for search and AI crawlers; sign-in and the API are not.\n'
+ + '# Agent manifest: https://inventory.openvibe.network/.well-known/agent.json; every public page has a .md twin.\n'
+ seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
+ seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
});
});
@@ -89,10 +89,10 @@ function createPageRoutes(ctx) {
@@ -89,10 +89,10 @@ function createPageRoutes(ctx) {
}
}
// ── Home ─────────────────────────────────────────────────
// ── Home ─────────────────────────────────────────────────
- r.get('/', async (req, res) => {
+ async function homePage({ signed = false } = {}) {
const { kinds, defs, byKind } = await catalog();
const { kinds, defs, byKind } = await catalog();
const featured = [...defs].sort((a, b) => RARITY.indexOf(b.rarity) - RARITY.indexOf(a.rarity) || a.name.localeCompare(b.name)).slice(0, 6);
const featured = [...defs].sort((a, b) => RARITY.indexOf(b.rarity) - RARITY.indexOf(a.rarity) || a.name.localeCompare(b.name)).slice(0, 6);
- const me = signedIn(req);
+ const me = signed;
const hero = showcase.hero({
const hero = showcase.hero({
eyebrow: `${SITE_NAME} · part of OpenVibe`,
eyebrow: `${SITE_NAME} · part of OpenVibe`,
title: 'Your items,',
title: 'Your items,',
@@ -104,8 +104,8 @@ function createPageRoutes(ctx) {
@@ -104,8 +104,8 @@ function createPageRoutes(ctx) {
note: 'Items are earned or given, never sold here. Open source (AGPL-3.0).',
note: 'Items are earned or given, never sold here. Open source (AGPL-3.0).',
aside: { html: html`<div class="inv-hero-panel" aria-label="A few of the rarest items"><p class="inv-hero-title">From the collection</p><ul class="inv-hero-grid">${featured.map((d) => html`<li><a href="/items/${d.id}" title="${d.name} · ${RARITY_LABEL[d.rarity]}">${art(d, { size: 'lg' })}<span class="inv-hero-name">${d.name}</span></a></li>`)}</ul></div>`.toString() },
aside: { html: html`<div class="inv-hero-panel" aria-label="A few of the rarest items"><p class="inv-hero-title">From the collection</p><ul class="inv-hero-grid">${featured.map((d) => html`<li><a href="/items/${d.id}" title="${d.name} · ${RARITY_LABEL[d.rarity]}">${art(d, { size: 'lg' })}<span class="inv-hero-name">${d.name}</span></a></li>`)}</ul></div>`.toString() },
});
});
- page(req, res, {
- index: true, cache: me ? null : PUBLIC_CACHE,
+ return {
+ agent: true, index: true, cache: me ? null : PUBLIC_CACHE,
jsonLd: homeJsonLd(config),
jsonLd: homeJsonLd(config),
styles: [showcase.STYLESHEET],
styles: [showcase.STYLESHEET],
body: html`${raw(hero)}
body: html`${raw(hero)}
@@ -129,18 +129,18 @@ ${raw(showcase.steps({
@@ -129,18 +129,18 @@ ${raw(showcase.steps({
}))}
}))}
${raw(showcase.network({ service: 'inventory' }))}
${raw(showcase.network({ service: 'inventory' }))}
${raw(showcase.cta({ title: me ? 'See what you have' : 'Already earned something?', text: me ? 'Your items and what you are wearing, on one page.' : 'Sign in with your OpenVibe account to see your inventory and choose what to wear.', actions: me ? [{ label: 'Your inventory', href: '/me' }] : [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2Fme' }, { label: 'The API', href: '/api/v1/kinds' }] }))}`,
${raw(showcase.cta({ title: me ? 'See what you have' : 'Already earned something?', text: me ? 'Your items and what you are wearing, on one page.' : 'Sign in with your OpenVibe account to see your inventory and choose what to wear.', actions: me ? [{ label: 'Your inventory', href: '/me' }] : [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2Fme' }, { label: 'The API', href: '/api/v1/kinds' }] }))}`,
- });
- });
+ };
+ }
+ r.get('/', async (req, res) => page(req, res, await homePage({ signed: !!signedIn(req) })));
// ── The catalog ──────────────────────────────────────────
// ── The catalog ──────────────────────────────────────────
- r.get('/items', async (req, res) => {
+ async function itemsPage(only = null, { signed = false } = {}) {
const { kinds, defs, byKind } = await catalog();
const { kinds, defs, byKind } = await catalog();
- const only = req.query.kind ? String(req.query.kind) : null;
- if (only && !byKind.has(only)) return notFound(req, res, 'There is no such kind of item.');
+ if (only && !byKind.has(only)) return null;
// All: the kinds that have items; one kind: that kind, even while it is empty.
// All: the kinds that have items; one kind: that kind, even while it is empty.
const shown = kinds.filter((k) => (only ? k.id === only : (byKind.get(k.id) || []).length));
const shown = kinds.filter((k) => (only ? k.id === only : (byKind.get(k.id) || []).length));
- page(req, res, {
- index: !only, cache: signedIn(req) ? null : PUBLIC_CACHE,
+ return {
+ agent: true, index: !only, cache: signed ? null : PUBLIC_CACHE,
title: only ? `${shown[0].name} items` : 'Every item',
title: only ? `${shown[0].name} items` : 'Every item',
description: `${plural(defs.length, 'item', 'items')} you can earn and wear across OpenVibe, with their rarity.`,
description: `${plural(defs.length, 'item', 'items')} you can earn and wear across OpenVibe, with their rarity.`,
body: html`<h1>${only ? `${shown[0].name} items` : 'Every item'}</h1>
body: html`<h1>${only ? `${shown[0].name} items` : 'Every item'}</h1>
@@ -150,16 +150,22 @@ ${shown.map((k) => html`<section class="inv-kind" aria-labelledby="k-${k.id.repl
@@ -150,16 +150,22 @@ ${shown.map((k) => html`<section class="inv-kind" aria-labelledby="k-${k.id.repl
<h2 id="k-${k.id.replace(/\W/g, '-')}">${k.name} <span class="muted small">shows in ${k.surfaces.map((s) => surfaceLabel(s.surface)).join(', ')}</span></h2>
<h2 id="k-${k.id.replace(/\W/g, '-')}">${k.name} <span class="muted small">shows in ${k.surfaces.map((s) => surfaceLabel(s.surface)).join(', ')}</span></h2>
${(byKind.get(k.id) || []).length ? html`<ul class="inv-grid">${byKind.get(k.id).map((d) => card(d))}</ul>` : html`<p class="muted">No ${k.name.toLowerCase()} items yet.</p>`}
${(byKind.get(k.id) || []).length ? html`<ul class="inv-grid">${byKind.get(k.id).map((d) => card(d))}</ul>` : html`<p class="muted">No ${k.name.toLowerCase()} items yet.</p>`}
</section>`)}`,
</section>`)}`,
- });
+ };
+ }
+ r.get('/items', async (req, res) => {
+ const only = req.query.kind ? String(req.query.kind) : null;
+ const out = await itemsPage(only, { signed: !!signedIn(req) });
+ if (!out) return notFound(req, res, 'There is no such kind of item.');
+ page(req, res, out);
});
});
- r.get('/items/:id', async (req, res) => {
- const d = await inv.getDefinition(req.params.id);
- if (!d || (d.status !== 'published' && d.status !== 'retired')) return notFound(req, res, 'There is no such item.');
+ async function itemPage(id, { signed = false } = {}) {
+ const d = await inv.getDefinition(id);
+ if (!d || (d.status !== 'published' && d.status !== 'retired')) return null;
const k = await inv.getKind(d.kind);
const k = await inv.getKind(d.kind);
const issuer = d.issuer.startsWith('service:') ? `OpenVibe.${d.issuer.slice(8).replace(/^./, (c) => c.toUpperCase())}` : d.credit && d.credit.name ? d.credit.name : 'a creator';
const issuer = d.issuer.startsWith('service:') ? `OpenVibe.${d.issuer.slice(8).replace(/^./, (c) => c.toUpperCase())}` : d.credit && d.credit.name ? d.credit.name : 'a creator';
- page(req, res, {
- index: d.status === 'published', cache: signedIn(req) ? null : PUBLIC_CACHE,
+ return {
+ agent: true, index: d.status === 'published', cache: signed ? null : PUBLIC_CACHE,
title: `${d.name} · ${RARITY_LABEL[d.rarity]} ${k.name.toLowerCase()}`,
title: `${d.name} · ${RARITY_LABEL[d.rarity]} ${k.name.toLowerCase()}`,
// What the item is, how many people own it and how it is got; its flavor text alone ("Angelic") said none of that.
// What the item is, how many people own it and how it is got; its flavor text alone ("Angelic") said none of that.
description: itemDescription(d, k),
description: itemDescription(d, k),
@@ -186,7 +192,12 @@ ${(d.grantors || []).includes('service:games') && d.status === 'published' ? htm
@@ -186,7 +192,12 @@ ${(d.grantors || []).includes('service:games') && d.status === 'published' ? htm
<h2>Who gives it</h2>
<h2>Who gives it</h2>
<p>${issuer}${(d.grantors || []).length ? ', and the services it names (see How to get it)' : ''}. ${(d.grantors || []).length ? 'Nobody else' : 'Only its issuer'} can give this item; it is never sold here.</p>
<p>${issuer}${(d.grantors || []).length ? ', and the services it names (see How to get it)' : ''}. ${(d.grantors || []).length ? 'Nobody else' : 'Only its issuer'} can give this item; it is never sold here.</p>
<p class="muted small">Item <code>${d.id}</code>${(d.aliases || []).length ? html` · also known as <code>${d.aliases.join(', ')}</code>` : ''} · <a href="/api/v1/definitions/${d.id}">JSON</a></p>`,
<p class="muted small">Item <code>${d.id}</code>${(d.aliases || []).length ? html` · also known as <code>${d.aliases.join(', ')}</code>` : ''} · <a href="/api/v1/definitions/${d.id}">JSON</a></p>`,
- });
+ };
+ }
+ r.get('/items/:id', async (req, res) => {
+ const out = await itemPage(req.params.id, { signed: !!signedIn(req) });
+ if (!out) return notFound(req, res, 'There is no such item.');
+ page(req, res, out);
});
});
// ── Your inventory ───────────────────────────────────────
// ── Your inventory ───────────────────────────────────────
@@ -262,11 +273,16 @@ ${!owned.length ? html`<p class="muted">Nothing to show yet.</p>` : ''}`,
@@ -262,11 +273,16 @@ ${!owned.length ? html`<p class="muted">Nothing to show yet.</p>` : ''}`,
});
});
// ── The update log ───────────────────────────────────────
// ── The update log ───────────────────────────────────────
- 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}`,
title: `What shipped on ${SITE_NAME}`,
body: raw(frame.updatesBody({ service: 'inventory', siteName: SITE_NAME }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`),
body: raw(frame.updatesBody({ service: 'inventory', 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.
+ // /u/:subject is a person's page (noindex) and /me is their own: neither has a twin.
+ r.publicViews = { home: homePage, items: itemsPage, item: itemPage, updates: updatesPage };
// ── Discovery: robots.txt, sitemap.xml, llms.txt, llms-full.txt ──
// ── Discovery: robots.txt, sitemap.xml, llms.txt, llms-full.txt ──
r.use(createDiscoveryRoutes(ctx));
r.use(createDiscoveryRoutes(ctx));
@@ -84,6 +84,10 @@ function renderPage(o) {
@@ -84,6 +84,10 @@ function renderPage(o) {
description: o.description || `${SITE_NAME} — ${TAGLINE}`,
description: o.description || `${SITE_NAME} — ${TAGLINE}`,
canonical,
canonical,
robots: o.index ? 'index, follow' : 'noindex, nofollow',
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),
jsonLd: (o.jsonLd || []).filter(Boolean),
home: '/',
home: '/',
navLinks: NAV,
navLinks: NAV,
@@ -121,6 +125,10 @@ function send(res, status, o) {
@@ -121,6 +125,10 @@ function send(res, status, o) {
const personal = o.viewer && o.viewer.kind === 'user';
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 }));
if (!res.get('Cache-Control')) res.set('Cache-Control', !personal && o.cache ? o.cache : cache.htmlHeaders({ private: true }));
res.set('Vary', 'Cookie');
res.set('Vary', 'Cookie');
+ if (o.agent) {
+ const site = String(o.config.baseUrl).replace(/\/+$/, '');
+ res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(`${site}${String(o.path || '/').split('?')[0]}`, `${site}/llms.txt`).header);
+ }
res.status(status).type('html').send(renderPage(o));
res.status(status).type('html').send(renderPage(o));
}
}
@@ -60,11 +60,11 @@ function createWorkshopRoutes(ctx) {
@@ -60,11 +60,11 @@ function createWorkshopRoutes(ctx) {
const notice = (req) => html`${req.query.done ? html`<p class="notice ok" role="status">${String(req.query.done).slice(0, 200)}</p>` : ''}${req.query.error ? html`<p class="notice bad" role="alert">${String(req.query.error).slice(0, 200)}</p>` : ''}`;
const notice = (req) => html`${req.query.done ? html`<p class="notice ok" role="status">${String(req.query.done).slice(0, 200)}</p>` : ''}${req.query.error ? html`<p class="notice bad" role="alert">${String(req.query.error).slice(0, 200)}</p>` : ''}`;
// ── The Workshop ─────────────────────────────────────────
// ── The Workshop ─────────────────────────────────────────
- r.get('/workshop', async (req, res) => {
+ async function workshopPage() {
const published = await inv.workshopItems({ status: 'published', limit: 200 });
const published = await inv.workshopItems({ status: 'published', limit: 200 });
- page(req, res, {
+ return {
// A public page in search, as the sitemap says (it was noindex by default).
// A public page in search, as the sitemap says (it was noindex by default).
- title: 'The Workshop', index: true,
+ agent: true, title: 'The Workshop', index: true,
description: 'Badges made by people in the OpenVibe community, reviewed by staff, given free by their makers.',
description: 'Badges made by people in the OpenVibe community, reviewed by staff, given free by their makers.',
body: html`<h1>The Workshop</h1>
body: html`<h1>The Workshop</h1>
<p class="lede">Badges made by people in the community. Make one, staff review it, then you give it to the people you choose: your viewers, your server, the friends you played with. Every badge here is free: nothing in the Workshop is sold or traded.</p>
<p class="lede">Badges made by people in the community. Make one, staff review it, then you give it to the people you choose: your viewers, your server, the friends you played with. Every badge here is free: nothing in the Workshop is sold or traded.</p>
@@ -77,8 +77,9 @@ function createWorkshopRoutes(ctx) {
@@ -77,8 +77,9 @@ function createWorkshopRoutes(ctx) {
</ol>
</ol>
<h2>Community badges <span class="muted small">${published.length}</span></h2>
<h2>Community badges <span class="muted small">${published.length}</span></h2>
${published.length ? html`<ul class="inv-grid">${published.map((d) => html`<li class="inv-card r-${d.rarity}"><a class="inv-card-link" href="/items/${d.id}"><span class="inv-art inv-art-md r-${d.rarity}">${img(d)}</span><span class="inv-card-body"><span class="inv-card-name">${d.name}</span><span class="inv-rarity r-${d.rarity}">${RARITY_LABEL[d.rarity]}</span></span></a>${d.credit && d.credit.name ? html`<p class="inv-card-meta">by @${d.credit.name} · ${d.supply.issued} given</p>` : ''}</li>`)}</ul>` : html`<p class="muted">None yet. Yours could be the first.</p>`}`,
${published.length ? html`<ul class="inv-grid">${published.map((d) => html`<li class="inv-card r-${d.rarity}"><a class="inv-card-link" href="/items/${d.id}"><span class="inv-art inv-art-md r-${d.rarity}">${img(d)}</span><span class="inv-card-body"><span class="inv-card-name">${d.name}</span><span class="inv-rarity r-${d.rarity}">${RARITY_LABEL[d.rarity]}</span></span></a>${d.credit && d.credit.name ? html`<p class="inv-card-meta">by @${d.credit.name} · ${d.supply.issued} given</p>` : ''}</li>`)}</ul>` : html`<p class="muted">None yet. Yours could be the first.</p>`}`,
- });
- });
+ };
+ }
+ r.get('/workshop', async (req, res) => page(req, res, await workshopPage()));
// ── Make a badge ─────────────────────────────────────────
// ── Make a badge ─────────────────────────────────────────
r.get('/workshop/new', async (req, res) => {
r.get('/workshop/new', async (req, res) => {
@@ -266,6 +267,10 @@ ${live.length ? html`<ul class="ws-mine">${live.map((d) => html`<li class="ws-it
@@ -266,6 +267,10 @@ ${live.length ? html`<ul class="ws-mine">${live.map((d) => html`<li class="ws-it
}
}
});
});
+ // The public Workshop page, for the agent layer's Markdown twin (server/agent-pages.js), rendered by the same
+ // builder. /workshop/new, /workshop/mine and /workshop/review are a signed-in person's and have no twin.
+ r.publicViews = { workshop: workshopPage };
+
return r;
return r;
}
}