openvibers/openvibe.codes Commits

API explorer (WS-C task 6): /docs/api lists every service's OpenAPI 3.1 document from openvibe-contracts 0.51.0; /docs/api/:service shows each route with its method, capabilities, visibility, parameters, request and response schemas (linked to their contract pages) and a public-only filter; /docs/api/:service.json serves the document with CORS *; capability pages link their routes; in the sitemap

OP OpenVibers committed 25 Sept 2026 · eba81c5a10 · 6 files, +135 −13


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex a924db0..573af11 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,7 +16,7 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.51.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0"
},
@@@ -1023,9 +1023,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.49.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
-- "integrity": "sha512-lapsLoBm3ZpndgiOMELdCBh2PJtuuyqj6SUhQsfwJkxnLdQfyhXfRFgiuLh+iQrg4kqtG9611191Mp8zAfk7fQ==",
++ "version": "0.51.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.51.0",
++ "integrity": "sha512-XH+WF0iRJcThoNYE3Ys4oL52BzFwnyPX4Bzsq3MqJvDqdMWTQydvOTTHxdSbbtsTo7CIOZk3QNVy1HovyTId0A==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 160f7bd..b61aaaf 100644
---- a/package.json
++++ b/package.json
@@@ -27,7 +27,7 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.51.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0"
},
ddiff --git a/public/css/codes.css b/public/css/codes.css
iindex 8c8440f..a0dba3a 100644
---- a/public/css/codes.css
++++ b/public/css/codes.css
@@@ -39,6 +39,13 @@ ol.steps li { margin: .4rem 0; }
.badge.ok { border-color: var(--success, #22c55e); color: var(--success, #22c55e); }
.badge.warn { border-color: var(--warning, #f59e0b); color: var(--warning, #f59e0b); }
.badge.bad { border-color: var(--error, #ef4444); color: var(--error, #ef4444); }
++/* API explorer: one block per route, the method as a badge */
++.api-op { border: 1px solid var(--border, #1f2d47); border-radius: 10px; padding: 10px 14px; margin: 12px 0; background: var(--bg-card, #131c2e); scroll-margin-top: 72px; }
++.api-op h3 { margin: 0 0 4px; font-size: 1rem; overflow-wrap: anywhere; }
++.api-op .facts { margin: 6px 0; }
++.badge.method-get { border-color: var(--success, #22c55e); color: var(--success, #22c55e); }
++.badge.method-post, .badge.method-put, .badge.method-patch { border-color: var(--accent-light, #60a5fa); color: var(--accent-light, #60a5fa); }
++.badge.method-delete { border-color: var(--error, #ef4444); color: var(--error, #ef4444); }
.notice, .problem, .result { border-left: 3px solid var(--info, #38bdf8); padding: 8px 12px; margin: 12px 0; background: var(--bg-card, #131c2e); border-radius: 0 8px 8px 0; }
.notice.ok, .result.ok { border-left-color: var(--success, #22c55e); }
.notice.warn { border-left-color: var(--warning, #f59e0b); }
ddiff --git a/server/http/docs.js b/server/http/docs.js
iindex 5970a2d..aac31eb 100644
---- a/server/http/docs.js
++++ b/server/http/docs.js
@@@ -8,6 +8,8 @@
* /docs/contracts[/:id] catalog; one page per contract: field table, fixtures, raw schema
* /docs/contracts/:id.json the schema itself
* /docs/capabilities[/:id] capability catalog; grantable (public/partner, active) highlighted
++ * /docs/api[/:service] API explorer: every service's routes from openvibe-contracts' OpenAPI
++ * /docs/api/:service.json that OpenAPI 3.1 document (CORS *, for Swagger-style tools)
* /docs/events event types from the service manifests
* /docs/services the Network's registry with health as reported (never invented)
* /docs/sdk[/:module] SDK reference from its .d.ts files
@@@ -21,6 +23,9 @@ const { markdown } = require('../render/markdown');
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 = 'public, max-age=300';
const tagNote = (tag, version) => (tag !== `v${version}` ? html` (tag ${tag})` : '');
@@@ -39,6 +44,7 @@ function createDocsRoutes(ctx) {
<p>Nothing on these pages is written by hand: each one is rendered from the published packages above when Codes starts, so it always matches what the platform runs against. If something is missing here, it is not part of the public surface yet (<a href="/policy/compatibility">why</a>).</p>
<ul class="cards">
<li><a href="/docs/contracts"><strong>Contracts</strong></a><span>${docs.contracts.length} JSON Schemas with fields, examples and versions</span></li>
++<li><a href="/docs/api"><strong>API explorer</strong></a><span>${apiIndex.reduce((n, s) => n + s.operations, 0)} routes across ${apiIndex.length} services, with their capabilities and schemas (OpenAPI 3.1)</span></li>
<li><a href="/docs/capabilities"><strong>Capabilities</strong></a><span>${docs.capabilities.length} capabilities; ${grantable} can be granted to apps</span></li>
<li><a href="/docs/events"><strong>Events</strong></a><span>${docs.events.length} event types services declare they produce</span></li>
<li><a href="/docs/services"><strong>Services</strong></a><span>the registry as OpenVibe.Network reports it, with health</span></li>
@@@ -144,7 +150,86 @@ ${c.grantable ? html`<div class="notice ok">Grantable to apps: request it for an
${c.inputSchema ? html`<dt>Input</dt><dd>${docs.contract(c.inputSchema.split('@')[0]) ? html`<a href="/docs/contracts/${c.inputSchema.split('@')[0]}">${c.inputSchema}</a>` : c.inputSchema}</dd>` : ''}
${c.outputSchema ? html`<dt>Output</dt><dd>${c.outputSchema}</dd>` : ''}
<dt>Events</dt><dd>${c.events.length ? c.events.join(', ') : '—'}</dd>
--<dt>Implemented by</dt><dd>${(c.implementedBy || []).length ? html`<ul class="plain">${c.implementedBy.map((x) => html`<li><code>${x}</code></li>`)}</ul>` : '—'}</dd></dl>`,
++<dt>Implemented by</dt><dd>${(c.implementedBy || []).length ? html`<ul class="plain">${c.implementedBy.map((x) => html`<li><code>${x}</code></li>`)}</ul>` : '—'}</dd></dl>
++${apiDocs.has(c.owner) ? html`<p><a href="/docs/api/${c.owner}#cap-${c.id}">These routes in the API explorer</a></p>` : ''}`,
++ });
++ });
++
++ // ── 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.codes/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/:service.json', (req, res, next) => {
++ const doc = apiDocs.get(req.params.service);
++ if (!doc) return next();
++ res.set({ 'Cache-Control': PUBLIC_CACHE, 'Access-Control-Allow-Origin': '*' }).type('application/vnd.oai.openapi+json;version=3.1').send(JSON.stringify(doc, null, 2));
++ });
++
++ 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>` : ''}`,
});
});
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex fc40e82..04fc8e2 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -74,7 +74,7 @@ function createPageRoutes(ctx) {
<li>Publish release metadata with a <a href="/manifests/validate">validated manifest</a>; rotate or revoke credentials any time.</li>
</ol>
<h2>Reference</h2>
--<p>Generated from <code>openvibe-contracts v${docs.contractsVersion}</code> and <code>openvibe-sdk v${docs.sdkVersion}</code>: <a href="/docs/contracts">contracts</a> · <a href="/docs/capabilities">capabilities</a> · <a href="/docs/events">events</a> · <a href="/docs/services">services</a> · <a href="/docs/tools">tools</a> · <a href="/docs/sdk">SDK</a>.</p>
++<p>Generated from <code>openvibe-contracts v${docs.contractsVersion}</code> and <code>openvibe-sdk v${docs.sdkVersion}</code>: <a href="/docs/api">API explorer</a> · <a href="/docs/contracts">contracts</a> · <a href="/docs/capabilities">capabilities</a> · <a href="/docs/events">events</a> · <a href="/docs/services">services</a> · <a href="/docs/tools">tools</a> · <a href="/docs/sdk">SDK</a>.</p>
<h2>Recent releases</h2>
${table(['App', 'Kind', 'Version', 'Status', 'Trust', 'Published'], recent.map((x) => [
html`<a href="/apps/${x.app_id}">${x.name}</a>`, x.kind, html`<a href="/releases/${x.id}">${x.version}</a>`, x.status, x.trust.tier, time(x.published_at),
@@@ -271,10 +271,11 @@ ${signedIn ? releaseActions(req, rel) : ''}
].join('\n'));
});
r.get('/sitemap.xml', (req, res) => {
-- const urls = ['/', '/docs', '/docs/contracts', '/docs/capabilities', '/docs/events', '/docs/services', '/docs/tools', '/docs/sdk', '/oauth', '/tools/webhooks', '/manifests/validate',
++ const urls = ['/', '/docs', '/docs/api', '/docs/contracts', '/docs/capabilities', '/docs/events', '/docs/services', '/docs/tools', '/docs/sdk', '/oauth', '/tools/webhooks', '/manifests/validate',
'/policy', '/policy/rfc', '/policy/compatibility', '/policy/licensing', '/policy/transparency',
...governance.filter((g) => !g.draft).map((g) => `/policy/${g.slug}`),
...docs.contracts.map((c) => `/docs/contracts/${c.id}`), ...docs.capabilities.map((c) => `/docs/capabilities/${c.id}`),
++ ...require('openvibe-contracts').openapi.index().map((x) => `/docs/api/${x.service}`),
...docs.sdk.map((m) => `/docs/sdk/${m.slug}`), ...docs.adrs.map((a) => `/docs/adr/${a.id}`)];
const x = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;');
res.set('Cache-Control', 'public, max-age=3600').type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.map((u) => `<url><loc>${x(config.baseUrl + u)}</loc></url>`).join('\n')}\n</urlset>\n`);
ddiff --git a/test/docs.test.js b/test/docs.test.js
iindex deb6641..6ca2ec4 100644
---- a/test/docs.test.js
++++ b/test/docs.test.js
@@@ -19,11 +19,11 @@ const sdkPkg = require('openvibe-sdk/package.json');
await check('package.json pins the contracts, SDK and shared tags that are installed', async () => {
const tag = (dep) => (pkg.dependencies[dep].match(/refs\/tags\/(v[0-9.]+)$/) || [])[1];
-- assert.strictEqual(tag('openvibe-contracts'), 'v0.49.0');
++ assert.strictEqual(tag('openvibe-contracts'), 'v0.51.0');
assert.strictEqual(tag('openvibe-sdk'), `v${sdkPkg.version}`);
assert.strictEqual(tag('openvibe-shared'), `v${require('openvibe-shared/package.json').version}`);
assert.strictEqual(sdkPkg.version, '0.11.0');
-- // The contracts tag v0.49.0 carries the released codes manifest and codes.app-manifest@1.
++ // The contracts tag v0.51.0 carries the released codes manifest and codes.app-manifest@1.
assert.ok(contracts.services.get('codes') && contracts.services.get('codes').status === 'alpha');
assert.ok(contracts.resolve('codes.app-manifest@1'));
});
@@@ -33,13 +33,13 @@ const sdkPkg = require('openvibe-sdk/package.json');
const r = await t.get(p);
assert.strictEqual(r.status, 200, p);
assert.ok(r.text.includes(`openvibe-contracts v${contractsVersion}`), `${p} names the installed contracts version`);
-- assert.ok(r.text.includes('OpenVibe.Contracts/tree/v0.49.0'), `${p} links the pinned tag`);
-- if (contractsVersion !== '0.49.0') assert.ok(r.text.includes('(tag v0.49.0)'), `${p} says the tag differs from the package version`);
++ assert.ok(r.text.includes('OpenVibe.Contracts/tree/v0.51.0'), `${p} links the pinned tag`);
++ if (contractsVersion !== '0.51.0') assert.ok(r.text.includes('(tag v0.51.0)'), `${p} says the tag differs from the package version`);
assert.ok(r.text.includes(`openvibe-sdk v${sdkPkg.version}`), `${p} names the SDK version`);
}
const v = (await t.get('/api/v1/docs/versions')).json();
assert.strictEqual(v.contracts, contractsVersion);
-- assert.strictEqual(v.contracts_tag, 'v0.49.0');
++ assert.strictEqual(v.contracts_tag, 'v0.51.0');
assert.strictEqual(v.sdk, sdkPkg.version);
});
@@@ -105,6 +105,35 @@ const sdkPkg = require('openvibe-sdk/package.json');
assert.match(r.headers.get('cache-control'), /public/);
const sm = await t.get('/sitemap.xml');
assert.ok(sm.text.includes('/docs/contracts/mods.mod-manifest</loc>'));
++ assert.ok(sm.text.includes('/docs/api</loc>') && sm.text.includes('/docs/api/tools</loc>'), 'the API explorer is in the sitemap');
++ });
++
++ await check('the API explorer lists every service document from the pinned contracts (WS-C task 6)', async () => {
++ const index = contracts.openapi.index();
++ assert.ok(index.length >= 20);
++ const list = await t.get('/docs/api');
++ assert.strictEqual(list.status, 200);
++ for (const s of index) assert.ok(list.text.includes(`href="/docs/api/${s.service}"`), `${s.service} is listed`);
++ assert.ok((await t.get('/docs')).text.includes('href="/docs/api"'), 'the docs index links it');
++ const raw = await t.get('/docs/api/tools.json');
++ assert.strictEqual(raw.status, 200);
++ assert.match(raw.headers.get('content-type'), /application\/vnd\.oai\.openapi\+json/);
++ assert.strictEqual(raw.headers.get('access-control-allow-origin'), '*');
++ assert.deepStrictEqual(raw.json(), contracts.openapi.document('tools'), 'the document as the package ships it');
++ const page = await t.get('/docs/api/tools');
++ assert.strictEqual(page.status, 200);
++ const doc = contracts.openapi.document('tools');
++ const ops = Object.values(doc.paths).reduce((n, m) => n + Object.keys(m).length, 0);
++ assert.strictEqual((page.text.match(/class="api-op"/g) || []).length, ops, 'one block per route');
++ assert.ok(page.text.includes('id="cap-tools.job.create"') && page.text.includes('href="/docs/capabilities/tools.job.create"'), 'capabilities anchored and linked');
++ assert.ok(page.text.includes('href="/docs/contracts/tools.job"'), 'schemas link to their contract pages');
++ assert.ok(page.text.includes('<code>/api/v1/jobs/{id}</code>'));
++ const pub = await t.get('/docs/api/tools?public=1');
++ assert.ok((pub.text.match(/class="api-op"/g) || []).length <= ops);
++ assert.strictEqual((await t.get('/docs/api/nope')).status, 404);
++ assert.strictEqual((await t.get('/docs/api/nope.json')).status, 404);
++ const cap = await t.get('/docs/capabilities/tools.job.create');
++ assert.ok(cap.text.includes('href="/docs/api/tools#cap-tools.job.create"'), 'a capability links its routes');
});
await check('pages rendered for a signed-in person are never publicly cacheable', async () => {