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
Changes
@@@ -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-serverif (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));nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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); });
@@ -265,7 +265,9 @@ function isAllowedOrigin(origin) {
@@ -265,7 +265,9 @@ function isAllowedOrigin(origin) {
return false;
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) {
origin(origin, callback) {
if (!origin) return callback(null, true); // non-browser / server-to-server
if (!origin) return callback(null, true); // non-browser / server-to-server
if (isAllowedOrigin(origin)) return callback(null, true);
if (isAllowedOrigin(origin)) return callback(null, true);
@@ -273,7 +275,7 @@ app.use(cors({
@@ -273,7 +275,7 @@ app.use(cors({
return callback(new Error('Origin not allowed by CORS'));
return callback(new Error('Origin not allowed by CORS'));
},
},
credentials: true,
credentials: true,
-}));
+})));
// ── Rate Limiting ────────────────────────────────────────────
// ── Rate Limiting ────────────────────────────────────────────
app.use('/api/', rateLimit({ windowMs: 60_000, max: 120 }));
app.use('/api/', rateLimit({ windowMs: 60_000, max: 120 }));
@@ -424,6 +426,9 @@ app.use('/api/modules', require('./identity/modules').userRouter(requireAuth));
@@ -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
// Developer projects, apps, credentials, grants and quotas (server/developer, ADR-014). Bearer user
// tokens only; never X-Internal-Key.
// tokens only; never X-Internal-Key.
app.use('/api/v1/projects', rateLimit({ windowMs: 60_000, max: 60 }), require('./developer/routes').router());
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)
// Notification API (authenticated users)
app.use('/api/notifications', createNotificationRoutes(db, notificationService, requireAuth));
app.use('/api/notifications', createNotificationRoutes(db, notificationService, requireAuth));
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -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); });