openvibers/openvibe.services Commits

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

OP OpenVibers committed 15 days ago · 25fad8e338 · 8 files, +555 −123

openvibe-shared v4.34.0 mounts the agent manifest, the discovery <link> tags and the Link header, and renders the public pages (home, updates, policy, docs, the per-service API reference) as Markdown twins from the same view builders the routes use. Private pages (/projects, /staff, /api) are never rendered as twins, and a signed-in visitor keeps the page their own route renders.

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 109942c..b74ea34 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": {
@@@ -29,6 +29,12 @@
"node": ">=22.0.0"
}
},
++ "node_modules/@acemir/cssom": {
++ "version": "0.9.32",
++ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.32.tgz",
++ "integrity": "sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==",
++ "license": "MIT"
++ },
"node_modules/@electric-sql/pglite": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
@@@ -118,6 +124,19 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
++ "node_modules/boolbase": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
++ "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",
++ "license": "ISC",
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@@ -237,6 +256,39 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
++ "node_modules/css-select": {
++ "version": "7.0.0",
++ "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",
++ "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "boolbase": "^2.0.0",
++ "css-what": "^8.0.0",
++ "domhandler": "^6.0.1",
++ "domutils": "^4.0.2",
++ "nth-check": "^3.0.1"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ },
++ "node_modules/css-what": {
++ "version": "8.0.0",
++ "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",
++ "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ },
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@@ -274,6 +326,73 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
++ "node_modules/dom-serializer": {
++ "version": "3.1.1",
++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
++ "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
++ "license": "MIT",
++ "dependencies": {
++ "domelementtype": "^3.0.0",
++ "domhandler": "^6.0.0",
++ "entities": "^8.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
++ }
++ },
++ "node_modules/domelementtype": {
++ "version": "3.0.0",
++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
++ "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ ],
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=20.19.0"
++ }
++ },
++ "node_modules/domhandler": {
++ "version": "6.0.1",
++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
++ "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "domelementtype": "^3.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/fb55/domhandler?sponsor=1"
++ }
++ },
++ "node_modules/domutils": {
++ "version": "4.0.2",
++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
++ "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "dom-serializer": "^3.0.0",
++ "domelementtype": "^3.0.0",
++ "domhandler": "^6.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/fb55/domutils?sponsor=1"
++ }
++ },
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@@ -324,6 +443,18 @@
"node": ">= 0.8"
}
},
++ "node_modules/entities": {
++ "version": "8.1.0",
++ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
++ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/entities?sponsor=1"
++ }
++ },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@@ -582,6 +713,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",
@@@ -727,6 +962,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",
@@@ -865,6 +1124,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",
@@@ -934,12 +1209,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"
@@@ -1390,6 +1666,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 d71a6be..015f37e 100644
---- a/package.json
++++ b/package.json
@@@ -29,7 +29,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": {
ddiff --git a/server/agent-descriptor.js b/server/agent-descriptor.js
nnew file mode 100644
iindex 0000000..5b1e792
---- /dev/null
++++ b/server/agent-descriptor.js
@@@ -0,0 +1,18 @@
++'use strict';
++// What openvibe.services tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.
++// Every field is something this site really serves; the OpenAPI document is this service's own, published
++// from the pinned openvibe-contracts (http/docs.js serves it at /docs/api/services.json).
++module.exports = {
++ id: 'services',
++ name: 'OpenVibe.Services',
++ purpose: 'The developer platform of OpenVibe: projects, apps, scoped credentials, capability grants, usage and releases over every OpenVibe service, with API, contract, SDK and event reference generated from the exact versions production runs.',
++ home: 'https://openvibe.services',
++ site: 'openvibe.services',
++ api: '/api/v1',
++ openapi: '/docs/api/services.json',
++ docs: '/docs',
++ policy: {
++ auth: ['none', 'Network OIDC', 'bearer'],
++ rateLimit: '300 page requests and 240 API requests a minute per address; sign-in 60 attempts per 15 minutes',
++ },
++};
ddiff --git a/server/agent-pages.js b/server/agent-pages.js
nnew file mode 100644
iindex 0000000..caed6ec
---- /dev/null
++++ b/server/agent-pages.js
@@@ -0,0 +1,46 @@
++'use strict';
++// The Markdown twins of openvibe.services' public pages (openvibe-shared/agent-layer), rendered by the same
++// view builders the routes use (http/pages.js and http/docs.js + render/layout.js's renderPage), so a twin
++// cannot drift from its page. Anonymous only: /projects, /staff and the API are never rendered here.
++const { createPageViews } = require('./http/pages');
++const { createDocsViews } = require('./http/docs');
++const { renderPage } = require('./render/layout');
++
++function createAgentPages(ctx) {
++ const pages = createPageViews(ctx);
++ const docs = createDocsViews(ctx);
++
++ const twin = (o, path, query) => {
++ if (!o) return null;
++ const search = query && query.toString();
++ return {
++ html: renderPage({
++ ...o, index: true, config: ctx.config, path: search ? `${path}?${search}` : path,
++ viewer: { kind: 'anonymous' },
++ }),
++ };
++ };
++
++ async function renderPublicPage(path, { query } = {}) {
++ if (path === '/') return twin(await pages.home(), '/');
++ if (path === '/updates') return twin(pages.updates(), path);
++ if (path === '/mod') return twin(pages.mod(), path);
++ if (path === '/policy') return twin(pages.policy(), path);
++ if (path === '/policy/rfc') return twin(pages.policyRfc(), path);
++ if (path === '/policy/compatibility') return twin(pages.policyCompatibility(), path);
++ if (path === '/policy/licensing') return twin(pages.policyLicensing(), path);
++ if (path === '/policy/transparency') return twin(pages.policyTransparency(), path);
++ if (path === '/docs') return twin(docs.index(), path);
++ if (path === '/docs/api') return twin(docs.apiExplorer(), path);
++ const api = /^\/docs\/api\/([^/]+)$/.exec(path);
++ if (api) {
++ const id = decodeURIComponent(api[1]);
++ return twin(docs.apiService(id, { publicOnly: query && query.get('public') === '1' }), path, query);
++ }
++ return null;
++ }
++
++ return { renderPublicPage };
++}
++
++module.exports = { createAgentPages };
ddiff --git a/server/app.js b/server/app.js
iindex 00988e9..df0e328 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -207,6 +207,29 @@ async function createApp(opts = {}) {
app.use('/auth', sso.routes());
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'services', service: 'services', host: 'openvibe.services', name: 'OpenVibe.Services', profile: 'ugc' })); }
++ // The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json, and the public pages as Markdown
++ // twins. It answers their HTML as well, with the discovery tags in the head, so it mounts before the routes
++ // for those pages and before the static files; private pages (/projects, /staff, /api) are never rendered here.
++ const agentLayer = express();
++ require('openvibe-shared/agent-layer').mount(agentLayer, {
++ descriptor: require('./agent-descriptor'),
++ render: require('./agent-pages').createAgentPages(ctx).renderPublicPage,
++ });
++ app.use((req, res, next) => {
++ const markdown = req.path.endsWith('.md') || req.query.format === 'markdown' || (req.headers.accept || '').includes('text/markdown');
++ // A signed-in visitor keeps the page their own route renders: their account bar, and a private cache.
++ if (!markdown && req.viewer && req.viewer.kind === 'user') return next();
++ if (markdown) return agentLayer(req, res, next);
++ // Telemetry labels a request by Express's route template; the pages this mount answers are a known,
++ // bounded set, so label them with their own path rather than 'unmatched'.
++ const route = req.route;
++ const owner = req.app;
++ req.route = { path: req.path };
++ // The child app sets req.app while it handles; restore it on pass-through so the parent's settings
++ // (trust proxy, for one) still apply to the routes below.
++ agentLayer(req, res, (err) => { req.route = route; req.app = owner; next(err); });
++ });
++
// GET /<key>.txt — the IndexNow key file (mounted only when a key is configured; it serves itself).
if (indexnow.enabled) app.use(indexnow.keyFile);
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex a70e081..a9effb7 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -196,7 +196,8 @@ function createDiscoveryRoutes(ctx) {
// the sitemap. Every previous Disallow is kept (DISALLOW).
res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(
'# openvibe.services: the public pages are for search and AI crawlers; the portal, sign-in, staff and the API are not.\n'
-- + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
++ + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW })
++ + `# Agent manifest: ${abs('/.well-known/agent.json')}\n`);
});
r.get('/llms.txt', (_req, res) => {
ddiff --git a/server/http/docs.js b/server/http/docs.js
iindex 1a1f939..2341529 100644
---- a/server/http/docs.js
++++ b/server/http/docs.js
@@@ -27,9 +27,12 @@ const { send } = require('../render/layout');
const { markdown } = require('../render/markdown');
const { createLimitsReader, amount } = require('../domain/limits');
--function createDocsRoutes(ctx) {
-- const { docs, config, network } = ctx;
-- const r = asyncRouter();
++/**
++ * The public documentation pages as data (title, body, description), shared by the routes below and the
++ * agent-layer twins (server/agent-pages.js) so a twin cannot drift from its page.
++ */
++function createDocsViews(ctx) {
++ const { docs } = ctx;
const openapi = require('openvibe-contracts').openapi;
const apiIndex = openapi.index();
const apiDocs = new Map(apiIndex.map((s) => [s.service, openapi.document(s.service)]));
@@@ -39,11 +42,23 @@ function createDocsRoutes(ctx) {
const versions = () => html`<p class="versions">Generated at ${time(docs.generatedAt)} from
<a href="https://github.com/OpenVibers/OpenVibe.Contracts/tree/${docs.contractsTag}"><code>openvibe-contracts v${docs.contractsVersion}</code></a>${tagNote(docs.contractsTag, docs.contractsVersion)} and
<a href="https://github.com/OpenVibers/OpenVibe.SDK/tree/${docs.sdkTag}"><code>openvibe-sdk v${docs.sdkVersion}</code></a>${tagNote(docs.sdkTag, docs.sdkVersion)}.</p>`;
-- const page = (req, res, o, status = 200) => send(res, status, { index: true, cache: PUBLIC_CACHE, viewer: req.viewer, config, path: req.originalUrl, ...o });
++ const METHOD_ORDER = ['get', 'head', 'post', 'put', 'patch', 'delete'];
++ const schemaLink = (s) => {
++ if (!s || typeof s !== 'object') return '—';
++ if (s.$ref) {
++ const key = String(s.$ref).replace('#/components/schemas/', '');
++ const id = key.replace(/\.v\d+$/, '');
++ return docs.contract(id) ? html`<a href="/docs/contracts/${id}">${id}@${key.split('.v').pop()}</a>` : code(key);
++ }
++ if (s.anyOf) return html`one of ${s.anyOf.map((x, i) => html`${i ? ', ' : ''}${schemaLink(x)}`)}`;
++ if (s.contentEncoding === 'binary') return 'bytes';
++ return code(s.type || 'schema');
++ };
++ const bodyOf = (content) => (content ? Object.entries(content).map(([type, m]) => html`<div><code>${type}</code> ${schemaLink(m.schema)}</div>`) : '—');
-- r.get('/', (req, res) => {
++ function index() {
const grantable = docs.capabilities.filter((c) => c.grantable).length;
-- page(req, res, {
++ return {
title: 'Docs',
description: `OpenVibe platform reference generated from openvibe-contracts v${docs.contractsVersion} and openvibe-sdk v${docs.sdkVersion}.`,
crumbs: [{ label: 'Docs' }],
@@@ -68,8 +83,84 @@ function createDocsRoutes(ctx) {
<pre tabindex="0"><code>npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/${docs.sdkTag}
npm install https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/${docs.contractsTag}</code></pre>
<p>The SDK is ${docs.sdkLicense}; the contracts are ${docs.contractsLicense}. The SDK release is tested against contracts <code>${docs.sdkContractsRange || 'n/a'}</code>.</p>`,
-- });
-- });
++ };
++ }
++
++ function apiExplorer() {
++ return {
++ title: 'API explorer',
++ description: `Every OpenVibe service route the contracts describe, with its capabilities and schemas: ${apiIndex.length} OpenAPI 3.1 documents.`,
++ crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'API explorer' }],
++ body: html`<h1>API explorer</h1>${versions()}
++<p>One OpenAPI 3.1 document per service, generated from the capabilities in openvibe-contracts: every route a capability names, the capabilities it performs (a token for the service must carry one), its request and response schemas and its problem+json errors. A service may answer more routes than these; what is here is the contract.</p>
++${table(['Service', 'Routes', 'Capabilities', 'Origin', 'OpenAPI'], apiIndex.map((s) => [
++ html`<a href="/docs/api/${s.service}">${s.name}</a>`, String(s.operations), String(s.capabilities),
++ s.origin ? code(s.origin) : '—', html`<a href="/docs/api/${s.service}.json">${s.service}.json</a>`,
++ ]))}
++<p class="muted">Load a document into any OpenAPI tool from <code>https://openvibe.services/docs/api/&lt;service&gt;.json</code>, or read it from the package: <code>require('openvibe-contracts').openapi.document('tools')</code>.</p>`,
++ };
++ }
++
++ function apiService(service, { publicOnly = false } = {}) {
++ const doc = apiDocs.get(service);
++ if (!doc) return null;
++ const s = apiIndex.find((x) => x.service === service);
++ const ops = [];
++ for (const [p, methods] of Object.entries(doc.paths)) {
++ for (const m of Object.keys(methods).sort((a, b) => METHOD_ORDER.indexOf(a) - METHOD_ORDER.indexOf(b))) {
++ const op = methods[m];
++ if (publicOnly && !op['x-openvibe-visibility'].some((v) => v === 'public' || v === 'partner')) continue;
++ ops.push({ path: p, method: m, op });
++ }
++ }
++ const firstCap = new Set();
++ const opBlock = ({ path: p, method, op }) => {
++ const anchors = op['x-openvibe-capabilities'].filter((id) => !firstCap.has(id));
++ anchors.forEach((id) => firstCap.add(id));
++ const params = op.parameters || [];
++ return html`<section class="api-op" id="${op.operationId}">${anchors.map((id) => html`<span id="cap-${id}"></span>`)}
++<h3><span class="badge method-${method}">${method.toUpperCase()}</span> <code>${p}</code></h3>
++<p>${op.summary}</p>
++<dl class="facts"><dt>Capabilities</dt><dd>${op['x-openvibe-capabilities'].map((id, i) => html`${i ? ', ' : ''}<a href="/docs/capabilities/${id}">${id}</a>`)}${op.security.some((x) => !Object.keys(x).length) ? html` ${badge('open', 'ok')}` : ''}</dd>
++<dt>Visibility</dt><dd>${op['x-openvibe-visibility'].join(', ')}</dd>
++${params.length ? html`<dt>Parameters</dt><dd><ul class="plain">${params.map((x) => html`<li><code>${x.name}</code> (${x.in}${x.required ? ', required' : ''})${x.description ? html` — ${x.description}` : ''}</li>`)}</ul></dd>` : ''}
++${op.requestBody ? html`<dt>Request body</dt><dd>${bodyOf(op.requestBody.content)}</dd>` : ''}
++${op['x-openvibe-input'] && !op.requestBody ? html`<dt>Input</dt><dd>${schemaLink(op['x-openvibe-input'])}</dd>` : ''}
++<dt>Response</dt><dd>${bodyOf(op.responses['2XX'].content)}</dd>
++<dt>Errors</dt><dd><a href="/docs/contracts/errors.problem">errors.problem@1</a> (application/problem+json)</dd></dl>
++<details><summary>Description</summary>${markdown(op.description)}</details></section>`;
++ };
++ return {
++ title: `${s.name} API`,
++ description: `${s.operations} routes of ${s.name} with their capabilities and schemas (OpenAPI 3.1 from openvibe-contracts v${docs.contractsVersion}).`,
++ crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'API explorer', href: '/docs/api' }, { label: s.name }],
++ body: html`<h1>${s.name} API</h1>${versions()}
++<p>${doc.servers ? html`Server <code>${doc.servers[0].url}</code>. ` : ''}${s.operations} routes performing ${s.capabilities} capabilities. <a href="/docs/api/${s.service}.json">OpenAPI 3.1 document</a>.</p>
++<form method="get" action="/docs/api/${s.service}" class="inline-form"><label><input type="checkbox" name="public" value="1"${publicOnly ? raw(' checked') : ''}> only routes apps can be granted (public, partner)</label> <button type="submit">Filter</button></form>
++${ops.length ? ops.map(opBlock) : html`<p class="muted">No route here matches.</p>`}
++${(doc['x-openvibe-other-bindings'] || []).length ? html`<h2>Other bindings</h2><ul class="plain">${doc['x-openvibe-other-bindings'].map((b) => html`<li><a href="/docs/capabilities/${b.capability}">${b.capability}</a>: <code>${b.binding}</code></li>`)}</ul>` : ''}`,
++ };
++ }
++
++ return { index, apiExplorer, apiService, cache: PUBLIC_CACHE };
++}
++
++function createDocsRoutes(ctx) {
++ const { docs, config, network } = ctx;
++ const r = asyncRouter();
++ const openapi = require('openvibe-contracts').openapi;
++ const apiIndex = openapi.index();
++ const apiDocs = new Map(apiIndex.map((s) => [s.service, openapi.document(s.service)]));
++ const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
++
++ const tagNote = (tag, version) => (tag !== `v${version}` ? html` (tag ${tag})` : '');
++ const versions = () => html`<p class="versions">Generated at ${time(docs.generatedAt)} from
++<a href="https://github.com/OpenVibers/OpenVibe.Contracts/tree/${docs.contractsTag}"><code>openvibe-contracts v${docs.contractsVersion}</code></a>${tagNote(docs.contractsTag, docs.contractsVersion)} and
++<a href="https://github.com/OpenVibers/OpenVibe.SDK/tree/${docs.sdkTag}"><code>openvibe-sdk v${docs.sdkVersion}</code></a>${tagNote(docs.sdkTag, docs.sdkVersion)}.</p>`;
++ const page = (req, res, o, status = 200) => send(res, status, { index: true, cache: PUBLIC_CACHE, viewer: req.viewer, config, path: req.originalUrl, ...o });
++ const views = createDocsViews(ctx);
++
++ r.get('/', (req, res) => page(req, res, views.index()));
// ── Contracts ───────────────────────────────────────────
r.get('/contracts', (req, res) => {
@@@ -176,34 +267,7 @@ ${apiDocs.has(c.owner) ? html`<p><a href="/docs/api/${c.owner}#cap-${c.id}">Thes
});
// ── API explorer (WS-C task 6): openvibe-contracts' OpenAPI 3.1 per service ──
-- const METHOD_ORDER = ['get', 'head', 'post', 'put', 'patch', 'delete'];
-- const schemaLink = (s) => {
-- if (!s || typeof s !== 'object') return '—';
-- if (s.$ref) {
-- const key = String(s.$ref).replace('#/components/schemas/', '');
-- const id = key.replace(/\.v\d+$/, '');
-- return docs.contract(id) ? html`<a href="/docs/contracts/${id}">${id}@${key.split('.v').pop()}</a>` : code(key);
-- }
-- if (s.anyOf) return html`one of ${s.anyOf.map((x, i) => html`${i ? ', ' : ''}${schemaLink(x)}`)}`;
-- if (s.contentEncoding === 'binary') return 'bytes';
-- return code(s.type || 'schema');
-- };
-- const bodyOf = (content) => (content ? Object.entries(content).map(([type, m]) => html`<div><code>${type}</code> ${schemaLink(m.schema)}</div>`) : '—');
--
-- r.get('/api', (req, res) => {
-- page(req, res, {
-- title: 'API explorer',
-- description: `Every OpenVibe service route the contracts describe, with its capabilities and schemas: ${apiIndex.length} OpenAPI 3.1 documents.`,
-- crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'API explorer' }],
-- body: html`<h1>API explorer</h1>${versions()}
--<p>One OpenAPI 3.1 document per service, generated from the capabilities in openvibe-contracts: every route a capability names, the capabilities it performs (a token for the service must carry one), its request and response schemas and its problem+json errors. A service may answer more routes than these; what is here is the contract.</p>
--${table(['Service', 'Routes', 'Capabilities', 'Origin', 'OpenAPI'], apiIndex.map((s) => [
-- html`<a href="/docs/api/${s.service}">${s.name}</a>`, String(s.operations), String(s.capabilities),
-- s.origin ? code(s.origin) : '—', html`<a href="/docs/api/${s.service}.json">${s.service}.json</a>`,
-- ]))}
--<p class="muted">Load a document into any OpenAPI tool from <code>https://openvibe.services/docs/api/&lt;service&gt;.json</code>, or read it from the package: <code>require('openvibe-contracts').openapi.document('tools')</code>.</p>`,
-- });
-- });
++ r.get('/api', (req, res) => page(req, res, views.apiExplorer()));
r.get('/api/:service.json', (req, res, next) => {
const doc = apiDocs.get(req.params.service);
@@@ -212,45 +276,9 @@ ${table(['Service', 'Routes', 'Capabilities', 'Origin', 'OpenAPI'], apiIndex.map
});
r.get('/api/:service', (req, res, next) => {
-- const doc = apiDocs.get(req.params.service);
-- if (!doc) return next();
-- const s = apiIndex.find((x) => x.service === req.params.service);
-- const onlyPublic = req.query.public === '1';
-- const ops = [];
-- for (const [p, methods] of Object.entries(doc.paths)) {
-- for (const m of Object.keys(methods).sort((a, b) => METHOD_ORDER.indexOf(a) - METHOD_ORDER.indexOf(b))) {
-- const op = methods[m];
-- if (onlyPublic && !op['x-openvibe-visibility'].some((v) => v === 'public' || v === 'partner')) continue;
-- ops.push({ path: p, method: m, op });
-- }
-- }
-- const firstCap = new Set();
-- const opBlock = ({ path: p, method, op }) => {
-- const anchors = op['x-openvibe-capabilities'].filter((id) => !firstCap.has(id));
-- anchors.forEach((id) => firstCap.add(id));
-- const params = op.parameters || [];
-- return html`<section class="api-op" id="${op.operationId}">${anchors.map((id) => html`<span id="cap-${id}"></span>`)}
--<h3><span class="badge method-${method}">${method.toUpperCase()}</span> <code>${p}</code></h3>
--<p>${op.summary}</p>
--<dl class="facts"><dt>Capabilities</dt><dd>${op['x-openvibe-capabilities'].map((id, i) => html`${i ? ', ' : ''}<a href="/docs/capabilities/${id}">${id}</a>`)}${op.security.some((x) => !Object.keys(x).length) ? html` ${badge('open', 'ok')}` : ''}</dd>
--<dt>Visibility</dt><dd>${op['x-openvibe-visibility'].join(', ')}</dd>
--${params.length ? html`<dt>Parameters</dt><dd><ul class="plain">${params.map((x) => html`<li><code>${x.name}</code> (${x.in}${x.required ? ', required' : ''})${x.description ? html` — ${x.description}` : ''}</li>`)}</ul></dd>` : ''}
--${op.requestBody ? html`<dt>Request body</dt><dd>${bodyOf(op.requestBody.content)}</dd>` : ''}
--${op['x-openvibe-input'] && !op.requestBody ? html`<dt>Input</dt><dd>${schemaLink(op['x-openvibe-input'])}</dd>` : ''}
--<dt>Response</dt><dd>${bodyOf(op.responses['2XX'].content)}</dd>
--<dt>Errors</dt><dd><a href="/docs/contracts/errors.problem">errors.problem@1</a> (application/problem+json)</dd></dl>
--<details><summary>Description</summary>${markdown(op.description)}</details></section>`;
-- };
-- page(req, res, {
-- title: `${s.name} API`,
-- description: `${s.operations} routes of ${s.name} with their capabilities and schemas (OpenAPI 3.1 from openvibe-contracts v${docs.contractsVersion}).`,
-- crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'API explorer', href: '/docs/api' }, { label: s.name }],
-- body: html`<h1>${s.name} API</h1>${versions()}
--<p>${doc.servers ? html`Server <code>${doc.servers[0].url}</code>. ` : ''}${s.operations} routes performing ${s.capabilities} capabilities. <a href="/docs/api/${s.service}.json">OpenAPI 3.1 document</a>.</p>
--<form method="get" action="/docs/api/${s.service}" class="inline-form"><label><input type="checkbox" name="public" value="1"${onlyPublic ? raw(' checked') : ''}> only routes apps can be granted (public, partner)</label> <button type="submit">Filter</button></form>
--${ops.length ? ops.map(opBlock) : html`<p class="muted">No route here matches.</p>`}
--${(doc['x-openvibe-other-bindings'] || []).length ? html`<h2>Other bindings</h2><ul class="plain">${doc['x-openvibe-other-bindings'].map((b) => html`<li><a href="/docs/capabilities/${b.capability}">${b.capability}</a>: <code>${b.binding}</code></li>`)}</ul>` : ''}`,
-- });
++ const o = views.apiService(req.params.service, { publicOnly: req.query.public === '1' });
++ if (!o) return next();
++ page(req, res, o);
});
// ── The update system (roadmap WS-A task 4) ─────────────
@@@ -534,4 +562,4 @@ ${m.declarations.map((d, i) => html`<section id="d${i}" class="decl"><h2><small>
return r;
}
--module.exports = { createDocsRoutes };
++module.exports = { createDocsRoutes, createDocsViews };
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex f05525a..32244aa 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -33,16 +33,17 @@ const ROOT = path.join(__dirname, '..', '..');
const readJson = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } };
--function createPageRoutes(ctx) {
-- const { config, docs, releases, trust, network, sso } = ctx;
-- const r = asyncRouter();
-- const form = express.urlencoded({ extended: false, limit: '32kb' });
++/**
++ * The public pages as data (title, body, cache, JSON-LD), shared by the routes below and the
++ * agent-layer twins (server/agent-pages.js) so a twin cannot drift from its page.
++ */
++function createPageViews(ctx) {
++ const { config, docs, releases } = ctx;
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
-- const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
// Services up across the network, from Network's public status (read in the background, never awaited here).
const networkStatus = home.createNetworkStatus({ url: config.network.internalUrl, enabled: config.nodeEnv !== 'test' });
-- r.get('/', async (req, res) => {
++ async function homeView() {
const recent = await releases.recentPublic(10);
// The limits a developer's project really gets, from the service that enforces them (never restated here).
const eventsSource = ctx.limits && ctx.limits.bySource ? ctx.limits.bySource('events') : null;
@@@ -68,7 +69,7 @@ const services = await registry.services();`;
const programmable = contracts.services.manifests
.filter((m) => m && m.id !== 'services' && m.exposure && m.exposure.state === 'live' && apiDocs.has(m.id) && grantable(m))
.sort((a, b) => ((a.site && a.site.position) ?? 50) - ((b.site && b.site.position) ?? 50) || a.name.localeCompare(b.name));
-- page(req, res, {
++ return {
index: true, cache: PUBLIC_CACHE,
jsonLd: [...homeJsonLd(config), seo.jsonLd.faq(home.FAQ)],
styles: [showcase.STYLESHEET],
@@@ -131,31 +132,34 @@ ${table(['App', 'Kind', 'Version', 'Status', 'Trust', 'Published'], recent.map((
${raw(home.faq())}
${raw(showcase.network({ service: 'services' }))}
${raw(showcase.cta({ title: 'Start building', text: 'Sign in with your OpenVibe account; your first project takes a minute. Coding with an agent? OpenVibe.Codes routes the task to Claude Code, Codex, OpenCode or your own model.', actions: [{ label: 'Create a project', href: '/projects', primary: true }, { label: 'Code with any agent', href: 'https://openvibe.codes' }] }))}`,
-- });
-- });
++ };
++ }
-- // ── Policy ──────────────────────────────────────────────
-- const adr = (id) => docs.adrs.find((a) => a.id === id);
-- // What shipped on OpenVibe.Services: the shared update log every OpenVibe site has.
-- r.get('/updates', (req, res) => page(req, res, { index: true, cache: PUBLIC_CACHE, title: 'What shipped on OpenVibe.Services', body: raw(frame.updatesBody({ service: 'services', siteName: 'OpenVibe.Services' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`) }));
-- r.get('/mod', (req, res) => page(req, res, { index: true, cache: PUBLIC_CACHE, title: 'Mod the network', crumbs: [{ label: 'Mod the network' }], body: require('./mod').modPage() }));
-- r.get('/policy', (req, res) => page(req, res, {
-- index: true, cache: PUBLIC_CACHE, title: 'Policy', crumbs: [{ label: 'Policy' }],
-- body: html`<h1>Policy</h1><ul class="cards">
++ function updates() {
++ return { index: true, cache: PUBLIC_CACHE, title: 'What shipped on OpenVibe.Services', body: raw(frame.updatesBody({ service: 'services', siteName: 'OpenVibe.Services' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`) };
++ }
++
++ function mod() {
++ return { index: true, cache: PUBLIC_CACHE, title: 'Mod the network', crumbs: [{ label: 'Mod the network' }], body: require('./mod').modPage() };
++ }
++
++ function policy() {
++ return {
++ index: true, cache: PUBLIC_CACHE, title: 'Policy', crumbs: [{ label: 'Policy' }],
++ body: html`<h1>Policy</h1><ul class="cards">
<li><a href="/policy/rfc"><strong>Proposals and decisions</strong></a><span>how a contract, capability or event changes; the decision record</span></li>
<li><a href="/policy/compatibility"><strong>Compatibility and deprecation</strong></a><span>ADR-002 and ADR-016, as published</span></li>
<li><a href="/policy/licensing"><strong>Licensing</strong></a><span>what each package is licensed under</span></li>
<li><a href="/policy/transparency"><strong>Transparency</strong></a><span>what Services stores, what it does not, and what works today</span></li></ul>
<h2>Community</h2><p class="muted">These live on <a href="${CODES_ORIGIN}">OpenVibe.Codes</a>, where OpenVibe itself is built.</p><ul class="cards">
${COMMUNITY.map((g) => html`<li><a href="${CODES_ORIGIN}/policy/${g.slug}"><strong>${g.title}</strong></a><span>${g.blurb}</span></li>`)}</ul>`,
-- }));
--
-- // The community documents moved with Codes' contributor side: their old addresses here answer with Codes' page.
-- for (const g of COMMUNITY) r.get(`/policy/${g.slug}`, (req, res) => res.redirect(301, `${CODES_ORIGIN}/policy/${g.slug}`));
++ };
++ }
-- r.get('/policy/rfc', (req, res) => page(req, res, {
-- index: true, cache: PUBLIC_CACHE, title: 'Proposals and decisions', crumbs: [{ label: 'Policy', href: '/policy' }, { label: 'Proposals' }],
-- body: html`<h1>Proposals and decisions</h1>
++ function policyRfc() {
++ return {
++ index: true, cache: PUBLIC_CACHE, title: 'Proposals and decisions', crumbs: [{ label: 'Policy', href: '/policy' }, { label: 'Proposals' }],
++ body: html`<h1>Proposals and decisions</h1>
<p>Changes to the platform's public surface — a contract, a capability, an event type, or a major product policy — are proposed in the open and recorded as an architecture decision record (ADR) in <a href="https://github.com/OpenVibers/OpenVibe.Contracts">OpenVibe.Contracts</a>.</p>
<ol class="steps">
<li><strong>Propose.</strong> Open an issue or pull request on OpenVibe.Contracts describing the change, who owns it, and who consumes it. A service that introduces capabilities ships proposals in its own repository (<code>docs/capabilities-proposal/</code>, <code>docs/service-manifest-proposal.json</code>) — this portal's are in <a href="https://github.com/OpenVibers/OpenVibe.Services/tree/main/docs">its repository</a>.</li>
@@@ -165,12 +169,13 @@ ${COMMUNITY.map((g) => html`<li><a href="${CODES_ORIGIN}/policy/${g.slug}"><stro
<h2>Decision record</h2>
<p class="versions">The ADRs in <code>openvibe-contracts v${docs.contractsVersion}</code>:</p>
${table(['Decision', 'Status'], docs.adrs.map((a) => [html`<a href="/docs/adr/${a.id}">${a.title}</a>`, a.status]))}`,
-- }));
++ };
++ }
-- r.get('/policy/compatibility', (req, res) => {
-- const a2 = adr('ADR-002');
-- const a16 = adr('ADR-016');
-- page(req, res, {
++ function policyCompatibility() {
++ const a2 = docs.adrs.find((a) => a.id === 'ADR-002');
++ const a16 = docs.adrs.find((a) => a.id === 'ADR-016');
++ return {
index: true, cache: PUBLIC_CACHE, title: 'Compatibility and deprecation', crumbs: [{ label: 'Policy', href: '/policy' }, { label: 'Compatibility' }],
body: html`<h1>Compatibility and deprecation</h1>
<p class="versions">This page renders two decisions exactly as published in <code>openvibe-contracts v${docs.contractsVersion}</code>, so it cannot say anything they do not.</p>
@@@ -178,28 +183,28 @@ ${table(['Decision', 'Status'], docs.adrs.map((a) => [html`<a href="/docs/adr/${
Deprecated capabilities: ${docs.capabilities.filter((c) => c.status === 'deprecated').map((c) => c.id).join(', ') || 'none'}.</p>
${a2 ? html`<article class="prose">${raw(markdown(a2.markdown))}</article>` : notice('ADR-002 is not in this contracts release.', 'warn')}
${a16 ? html`<article class="prose">${raw(markdown(a16.markdown))}</article>` : notice('ADR-016 is not in this contracts release.', 'warn')}`,
-- });
-- });
++ };
++ }
-- r.get('/policy/licensing', (req, res) => {
++ function policyLicensing() {
const pkgs = ['openvibe-contracts', 'openvibe-sdk', 'openvibe-shared'].map((n) => {
const p = readJson(path.join(path.dirname(require.resolve(`${n}/package.json`)), 'package.json')) || {};
return [code(n), p.version || '—', p.license || 'not declared'];
});
const own = readJson(path.join(ROOT, 'package.json')) || {};
-- page(req, res, {
++ return {
index: true, cache: PUBLIC_CACHE, title: 'Licensing', crumbs: [{ label: 'Policy', href: '/policy' }, { label: 'Licensing' }],
body: html`<h1>Licensing</h1>
<p>Read from the packages this portal runs, not typed in:</p>
${table(['Package', 'Version', 'License'], [[code(own.name || 'openvibe-services'), own.version || '—', own.license || 'not declared'], ...pkgs])}
<p>The SDK is the library apps outside the network embed; the services and their contracts are open source under the licenses above. Each repository's LICENSE file is authoritative. This page is not legal advice.</p>
<p>Source: <a href="https://github.com/OpenVibers">github.com/OpenVibers</a>.</p>`,
-- });
-- });
++ };
++ }
-- r.get('/policy/transparency', (req, res) => {
++ function policyTransparency() {
const status = readJson(path.join(ROOT, 'STATUS.json')) || {};
-- page(req, res, {
++ return {
index: true, cache: PUBLIC_CACHE, title: 'Transparency', crumbs: [{ label: 'Policy', href: '/policy' }, { label: 'Transparency' }],
body: html`<h1>Transparency</h1>
<h2>What Services stores</h2>
@@@ -219,8 +224,37 @@ ${table(['Package', 'Version', 'License'], [[code(own.name || 'openvibe-services
<p>Stage <strong>${status.stage || 'unknown'}</strong>. ${status.summary || ''}</p>
${Array.isArray(status.works) ? html`<h3>Works</h3><ul>${status.works.map((w) => html`<li>${w}</li>`)}</ul>` : ''}
${Array.isArray(status.notYet) ? html`<h3>Not yet</h3><ul>${status.notYet.map((w) => html`<li>${w}</li>`)}</ul>` : ''}`,
-- });
-- });
++ };
++ }
++
++ return { home: homeView, updates, mod, policy, policyRfc, policyCompatibility, policyLicensing, policyTransparency };
++}
++
++function createPageRoutes(ctx) {
++ const { config, docs, releases, trust, network, sso } = ctx;
++ const views = createPageViews(ctx);
++ const r = asyncRouter();
++ const form = express.urlencoded({ extended: false, limit: '32kb' });
++ const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
++ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
++ // Services up across the network, from Network's public status (read in the background, never awaited here).
++ const networkStatus = home.createNetworkStatus({ url: config.network.internalUrl, enabled: config.nodeEnv !== 'test' });
++
++ r.get('/', async (req, res) => page(req, res, await views.home()));
++
++ // ── Policy ──────────────────────────────────────────────
++ // What shipped on OpenVibe.Services: the shared update log every OpenVibe site has.
++ r.get('/updates', (req, res) => page(req, res, views.updates()));
++ r.get('/mod', (req, res) => page(req, res, views.mod()));
++ r.get('/policy', (req, res) => page(req, res, views.policy()));
++
++ // The community documents moved with Codes' contributor side: their old addresses here answer with Codes' page.
++ for (const g of COMMUNITY) r.get(`/policy/${g.slug}`, (req, res) => res.redirect(301, `${CODES_ORIGIN}/policy/${g.slug}`));
++
++ r.get('/policy/rfc', (req, res) => page(req, res, views.policyRfc()));
++ r.get('/policy/compatibility', (req, res) => page(req, res, views.policyCompatibility()));
++ r.get('/policy/licensing', (req, res) => page(req, res, views.policyLicensing()));
++ r.get('/policy/transparency', (req, res) => page(req, res, views.policyTransparency()));
// ── Public release pages ────────────────────────────────
const APP_ID_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
@@@ -319,4 +353,4 @@ ${signedIn ? releaseActions(req, rel) : ''}
const statusBadge = (s) => badge(s, s === 'published' ? 'ok' : (s === 'revoked' ? 'bad' : (s === 'deprecated' ? 'warn' : '')));
--module.exports = { createPageRoutes, statusBadge };
++module.exports = { createPageRoutes, createPageViews, statusBadge };