openvibers/openvibe.network Commits

Public, CORS-open discovery: /.well-known/openvibe, /api/v1/registry/* and /contracts/*.json answer any origin (preflight included; traceparent, X-OpenVibe-Request-Id, Authorization, Content-Type) without credentials; every other route keeps the first-party allow-list

OP OpenVibers committed 23 Sept 2026 · 8d0e64d9db · 3 files, +153 −2


Changes

ddiff --git a/server/index.js b/server/index.js
iindex 2694d745..04f265ac 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -265,7 +265,9 @@ function isAllowedOrigin(origin) {
return false;
}
--app.use(cors({
++// Public discovery (/.well-known/openvibe, /api/v1/registry/*, /contracts/*.json) answers any
++// origin, preflight included; every other route keeps this allow-list (server/public-cors.js).
++app.use(require('./public-cors').gate(cors({
origin(origin, callback) {
if (!origin) return callback(null, true); // non-browser / server-to-server
if (isAllowedOrigin(origin)) return callback(null, true);
@@@ -273,7 +275,7 @@ app.use(cors({
return callback(new Error('Origin not allowed by CORS'));
},
credentials: true,
--}));
++})));
// ── Rate Limiting ────────────────────────────────────────────
app.use('/api/', rateLimit({ windowMs: 60_000, max: 120 }));
@@@ -424,6 +426,9 @@ app.use('/api/modules', require('./identity/modules').userRouter(requireAuth));
// Developer projects, apps, credentials, grants and quotas (server/developer, ADR-014). Bearer user
// tokens only; never X-Internal-Key.
app.use('/api/v1/projects', rateLimit({ windowMs: 60_000, max: 60 }), require('./developer/routes').router());
++// Their network.app.* / credential / grant events go to OpenVibe.Events through an outbox when
++// OV_EVENTS_INTERNAL_URL is set (server/developer/event-relay.js); off otherwise.
++require('./developer/event-relay').startRelay(db, { eventsUrl: config.eventsInternalUrl, privateKey, issuer: config.jwt.issuer });
// Notification API (authenticated users)
app.use('/api/notifications', createNotificationRoutes(db, notificationService, requireAuth));
ddiff --git a/server/public-cors.js b/server/public-cors.js
nnew file mode 100644
iindex 00000000..23051f37
---- /dev/null
++++ b/server/public-cors.js
@@@ -0,0 +1,59 @@
++'use strict';
++/**
++ * Public, CORS-open discovery (roadmap Wave 20): any origin may read the platform descriptor, the
++ * ecosystem registry and the contract schemas, so a plain browser page can discover services
++ * without a server of its own.
++ *
++ * GET /.well-known/openvibe
++ * GET /api/v1/registry and /api/v1/registry/<...>
++ * GET /contracts/<domain>/<name>.v<N>.json
++ *
++ * These answer every origin, preflight included, with `Access-Control-Allow-Origin: *` and no
++ * credentials (cookies are never needed and never sent). Every other route keeps the first-party
++ * allow-list: gate() sends everything else to the restricted cors() middleware unchanged.
++ */
++
++const ALLOW_HEADERS = 'traceparent, X-OpenVibe-Request-Id, Authorization, Content-Type';
++const EXPOSE_HEADERS = 'X-OpenVibe-Request-Id, traceparent';
++const MAX_AGE_S = 86400;
++
++// A registry segment: URL-safe characters, never '.' or '..' and never an encoded dot or slash.
++const SEGMENT_RE = /^[A-Za-z0-9_~:@!$&'()*+,;=.-]+$/;
++const CONTRACT_RE = /^\/contracts\/[a-z0-9-]+\/[a-z0-9-]+\.v\d+\.json$/;
++
++function isPublicDiscoveryPath(p) {
++ const path = String(p || '');
++ if (path === '/.well-known/openvibe') return true;
++ if (CONTRACT_RE.test(path)) return true;
++ if (path === '/api/v1/registry' || path === '/api/v1/registry/') return true;
++ if (!path.startsWith('/api/v1/registry/')) return false;
++ const segments = path.slice('/api/v1/registry/'.length).replace(/\/$/, '').split('/');
++ return segments.every(s => SEGMENT_RE.test(s) && s !== '.' && s !== '..' && !/%2e|%2f|%5c/i.test(s));
++}
++
++/** Headers for a public discovery response; a preflight is answered here with 204. */
++function publicCors(req, res, next) {
++ res.setHeader('Access-Control-Allow-Origin', '*');
++ res.setHeader('Access-Control-Expose-Headers', EXPOSE_HEADERS);
++ // Helmet's default same-origin resource policy would stop other sites from reading these.
++ res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
++ if (req.method === 'OPTIONS') {
++ res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
++ res.setHeader('Access-Control-Allow-Headers', ALLOW_HEADERS);
++ res.setHeader('Access-Control-Max-Age', String(MAX_AGE_S));
++ res.statusCode = 204;
++ res.setHeader('Content-Length', '0');
++ return res.end();
++ }
++ return next();
++}
++
++/** One CORS middleware for the app: public discovery paths open, everything else `restricted`. */
++function gate(restricted) {
++ return function corsGate(req, res, next) {
++ if (isPublicDiscoveryPath(req.path)) return publicCors(req, res, next);
++ return restricted(req, res, next);
++ };
++}
++
++module.exports = { gate, publicCors, isPublicDiscoveryPath, ALLOW_HEADERS, EXPOSE_HEADERS };
ddiff --git a/test/public-cors.test.js b/test/public-cors.test.js
nnew file mode 100644
iindex 00000000..e81c03d5
---- /dev/null
++++ b/test/public-cors.test.js
@@@ -0,0 +1,87 @@
++'use strict';
++// Public discovery CORS (server/public-cors.js): /.well-known/openvibe, /api/v1/registry/* and
++// /contracts/*.json answer any origin (preflight included); every other route keeps the allow-list.
++// node test/public-cors.test.js
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const cors = require('cors');
++const publicCors = require('../server/public-cors');
++const { createEcosystemRegistry } = require('../server/registry/ecosystem');
++
++(async () => {
++ // Path decision
++ for (const p of ['/.well-known/openvibe', '/api/v1/registry', '/api/v1/registry/', '/api/v1/registry/services', '/api/v1/registry/services/network',
++ '/api/v1/registry/domains/openvibe.community', '/api/v1/registry/capabilities/media.object.upload', '/contracts/identity/subject-ref.v1.json']) {
++ assert.ok(publicCors.isPublicDiscoveryPath(p), `${p} is public`);
++ }
++ for (const p of ['/api/v1/registryx', '/api/v1/registry-admin', '/api/v1/registry/../projects', '/api/v1/registry/%2e%2e/projects', '/api/v1/registry/a%2Fb',
++ '/api/v1/registry//services', '/contracts/../x', '/contracts/foo', '/contracts/identity/subject-ref.json', '/contracts/identity/subject-ref.v1.json/x',
++ '/.well-known/openvibe/x', '/.well-known/openid-configuration', '/api/.well-known/jwks', '/api/auth/login', '/api/v1/projects', '/oauth/token', '/']) {
++ assert.ok(!publicCors.isPublicDiscoveryPath(p), `${p} is not public`);
++ }
++
++ // The same wiring as server/index.js: gate(restricted cors) in front of everything.
++ const allowed = new Set(['https://openvibe.live']);
++ const eco = createEcosystemRegistry({ issuer: 'https://openvibe.network', internalOverrides: {} });
++ const app = express();
++ app.use(publicCors.gate(cors({
++ origin(origin, cb) { if (!origin || allowed.has(origin)) return cb(null, true); return cb(new Error('Origin not allowed by CORS')); },
++ credentials: true,
++ })));
++ app.use(eco.router());
++ app.post('/api/auth/login', (_req, res) => res.json({ ok: true }));
++ app.get('/api/v1/projects', (_req, res) => res.json({ projects: [] }));
++ app.post('/oauth/token', (_req, res) => res.json({ ok: true }));
++ app.get('/api/v1/registryx', (_req, res) => res.json({ ok: true }));
++ app.use((err, _req, res, _next) => res.status(500).json({ error: String(err.message) }));
++ const srv = http.createServer(app);
++ await new Promise(r => srv.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${srv.address().port}`;
++ const FOREIGN = 'https://someones-app.example';
++ const req = (method, p, headers = {}) => fetch(base + p, { method, headers });
++
++ for (const p of ['/.well-known/openvibe', '/api/v1/registry', '/api/v1/registry/services', '/api/v1/registry/capabilities', '/api/v1/registry/contracts',
++ '/contracts/identity/subject-ref.v1.json']) {
++ let r = await req('OPTIONS', p, { origin: FOREIGN, 'access-control-request-method': 'GET', 'access-control-request-headers': 'traceparent, x-openvibe-request-id, authorization, content-type' });
++ assert.strictEqual(r.status, 204, `${p} preflight`);
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');
++ assert.match(r.headers.get('access-control-allow-methods'), /GET/);
++ const allowHeaders = r.headers.get('access-control-allow-headers').toLowerCase();
++ for (const h of ['traceparent', 'x-openvibe-request-id', 'authorization', 'content-type']) assert.ok(allowHeaders.includes(h), `${p} allows ${h}`);
++ assert.ok(Number(r.headers.get('access-control-max-age')) > 0);
++ assert.strictEqual(r.headers.get('access-control-allow-credentials'), null, 'never with credentials');
++
++ r = await req('GET', p, { origin: FOREIGN, traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01' });
++ assert.strictEqual(r.status, 200, `${p} GET`);
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');
++ assert.strictEqual(r.headers.get('access-control-allow-credentials'), null);
++ assert.match(r.headers.get('access-control-expose-headers'), /X-OpenVibe-Request-Id/);
++ assert.strictEqual(r.headers.get('cross-origin-resource-policy'), 'cross-origin');
++ assert.match(r.headers.get('cache-control') || '', /^public, max-age=\d+$/, `${p} keeps its caching headers`);
++ await r.arrayBuffer();
++ }
++ // Problem responses on public paths are readable too.
++ let r = await req('GET', '/api/v1/registry/services/nope', { origin: FOREIGN });
++ assert.strictEqual(r.status, 404);
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');
++ r = await req('GET', '/contracts/identity/nope.v1.json', { origin: FOREIGN });
++ assert.strictEqual(r.status, 404);
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');
++
++ // Everything else keeps the allow-list: a foreign origin gets no CORS grant (and an error).
++ for (const [method, p] of [['POST', '/api/auth/login'], ['GET', '/api/v1/projects'], ['POST', '/oauth/token'], ['GET', '/api/v1/registryx'], ['OPTIONS', '/api/v1/projects'], ['OPTIONS', '/oauth/token']]) {
++ r = await req(method, p, { origin: FOREIGN, 'access-control-request-method': 'POST' });
++ assert.notStrictEqual(r.headers.get('access-control-allow-origin'), '*', `${method} ${p} is not opened`);
++ assert.notStrictEqual(r.headers.get('access-control-allow-origin'), FOREIGN, `${method} ${p} does not reflect a foreign origin`);
++ assert.strictEqual(r.status, 500, `${method} ${p} rejects the foreign origin`);
++ }
++ // ...while first-party origins still work there, with credentials.
++ r = await req('GET', '/api/v1/projects', { origin: 'https://openvibe.live' });
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), 'https://openvibe.live');
++ assert.strictEqual(r.headers.get('access-control-allow-credentials'), 'true');
++
++ eco.stop(); srv.close();
++ console.log('public discovery CORS: all checks passed');
++})().catch(err => { console.error(err); process.exit(1); });