openvibers/openvibe.network Commits

Register Connect on Link and isolate share-host trust (#670)

OP OpenVibers committed 5 days ago · a3f15d79c1 · 9 files, +56 −13

* Register Connect on Link while isolating share hosts

* Synchronize browser share-host guards and keep frame service metadata intact

Changes

ddiff --git a/public/js/login.js b/public/js/login.js
iindex 3389fa7b..761936f5 100644
---- a/public/js/login.js
++++ b/public/js/login.js
@@@ -662,7 +662,7 @@ function ripple(btn, e) {
// Where a sign-in may send the browser: a path on this site, this origin, or https on a zone
// OpenVibe owns (server/auth/sso-owned.js). Never javascript:/data:, never another host.
const OV_OWNED_ZONES = ['openvibe.network', 'openvibe.live', 'openvibe.tools', 'openvibe.media', 'openvibe.games', 'openvibe.community', 'openvibe.space', 'openvibe.chat', 'openvibe.codes', 'openvibe.blog', 'openvibe.wiki', 'openvibe.news', 'openvibe.reviews', 'openvibe.tips', 'openvibe.vip', 'openvibe.trade', 'openvibe.host', 'openvibe.deals', 'openvibe.coupons', 'openre.stream', 'open.shoes', 'openvibe.today', 'openvibe.vision', 'openvibe.domains', 'openvibe.report', 'openvibe.meme', 'openvibe.casino', 'openvibe.surf', 'openvibe.fun', 'openvibe.tours', 'openvibe.exchange', 'openvibe.cam', 'openvibe.design', 'openvibe.computer', 'openvibe.email', 'openvibe.link', 'openvibe.locker', 'openvibe.my', 'openvibe.party', 'openvibe.studio'];
--const OV_USER_CONTENT_ZONES = ['openvibe.host']; // tenant sites: only the apex is ours
++const OV_USER_CONTENT_ZONES = ['openvibe.host', 'openvibe.link']; // tenant sites: only the apex is ours
function safeReturnUrl(raw) {
const s = String(raw == null ? '' : raw);
if (!s || /[\\\u0000-\u001f\u007f]/.test(s)) return '/';
@@@ -672,7 +672,7 @@ function safeReturnUrl(raw) {
if (u.username || u.password) return '/';
if (u.origin === window.location.origin) return u.toString();
const h = u.hostname.toLowerCase();
-- if (u.protocol === 'https:' && OV_OWNED_ZONES.some(z => h === z || (h.endsWith('.' + z) && !OV_USER_CONTENT_ZONES.includes(z)))) return u.toString();
++ if (u.protocol === 'https:' && (['connect.openvibe.website', 'connect.openvibe.link'].includes(h) || OV_OWNED_ZONES.some(z => h === z || (h.endsWith('.' + z) && !OV_USER_CONTENT_ZONES.includes(z))))) return u.toString();
const local = /^(localhost|127\.0\.0\.1)$/;
if (u.protocol === 'http:' && local.test(h) && local.test(window.location.hostname)) return u.toString();
} catch (e) { /* not a URL */ }
ddiff --git a/public/sso-fanout.html b/public/sso-fanout.html
iindex 219ba63f..25497e6a 100644
---- a/public/sso-fanout.html
++++ b/public/sso-fanout.html
@@@ -82,7 +82,7 @@ li.now .dot i{animation:breathe 1.2s var(--ov-ease-in-out, ease-in-out) infinite
// Where a sign-in may send the browser: a path on this site, this origin, or https on a zone
// OpenVibe owns (server/auth/sso-owned.js). Never javascript:/data:, never another host.
const OV_OWNED_ZONES = ['openvibe.network', 'openvibe.live', 'openvibe.tools', 'openvibe.media', 'openvibe.games', 'openvibe.community', 'openvibe.space', 'openvibe.chat', 'openvibe.codes', 'openvibe.blog', 'openvibe.wiki', 'openvibe.news', 'openvibe.reviews', 'openvibe.tips', 'openvibe.vip', 'openvibe.trade', 'openvibe.host', 'openvibe.deals', 'openvibe.coupons', 'openre.stream', 'open.shoes', 'openvibe.today', 'openvibe.vision', 'openvibe.domains', 'openvibe.report', 'openvibe.meme', 'openvibe.casino', 'openvibe.surf', 'openvibe.fun', 'openvibe.tours', 'openvibe.exchange', 'openvibe.cam', 'openvibe.design', 'openvibe.computer', 'openvibe.email', 'openvibe.link', 'openvibe.locker', 'openvibe.my', 'openvibe.party', 'openvibe.studio'];
-- const OV_USER_CONTENT_ZONES = ['openvibe.host']; // tenant sites: only the apex is ours
++ const OV_USER_CONTENT_ZONES = ['openvibe.host', 'openvibe.link']; // tenant sites: only the apex is ours
function safeNext(raw) {
const s = String(raw == null ? '' : raw);
if (!s || /[\\\u0000-\u001f\u007f]/.test(s)) return '/';
@@@ -92,7 +92,7 @@ li.now .dot i{animation:breathe 1.2s var(--ov-ease-in-out, ease-in-out) infinite
if (u.username || u.password) return '/';
if (u.origin === window.location.origin) return u.toString();
const h = u.hostname.toLowerCase();
-- if (u.protocol === 'https:' && OV_OWNED_ZONES.some(z => h === z || (h.endsWith('.' + z) && !OV_USER_CONTENT_ZONES.includes(z)))) return u.toString();
++ if (u.protocol === 'https:' && (['connect.openvibe.website', 'connect.openvibe.link'].includes(h) || OV_OWNED_ZONES.some(z => h === z || (h.endsWith('.' + z) && !OV_USER_CONTENT_ZONES.includes(z))))) return u.toString();
const local = /^(localhost|127\.0\.0\.1)$/;
if (u.protocol === 'http:' && local.test(h) && local.test(window.location.hostname)) return u.toString();
} catch (e) { /* not a URL */ }
ddiff --git a/server/auth/sso-owned.js b/server/auth/sso-owned.js
iindex 819a4ba8..6b1963bb 100644
---- a/server/auth/sso-owned.js
++++ b/server/auth/sso-owned.js
@@@ -13,7 +13,9 @@ const OWNED_ZONES = [
// Zones whose subdomains are people's own content (OpenVibe.Host tenant sites, <site>.openvibe.host,
// which run the tenant's JS): only the apex is OpenVibe's. Domain validation still refuses them as
// tool hosts through OWNED_ZONES; trust checks go through isTrustedHost.
--const USER_CONTENT_ZONES = ['openvibe.host'];
++const USER_CONTENT_ZONES = ['openvibe.host', 'openvibe.link'];
++// Connect's control panel is trusted; share hosts run arbitrary local apps.
++const CONNECT_HOSTS = new Set(['connect.openvibe.website', 'connect.openvibe.link']);
// Every exact host a service manifest lists (openvibe-contracts manifests' `domains`). The zones above were written by
// hand and the sites added since were never on them (openvibe.video, .download, .pics, .quest, .food, .rent, .work,
@@@ -29,7 +31,8 @@ const MANIFEST_HOSTS = (() => {
/** Is `hostname` an OpenVibe site we trust across sites (an owned zone, minus tenant subdomains, or a manifest's host)? */
function isTrustedHost(hostname) {
const h = String(hostname || '').toLowerCase();
-- return MANIFEST_HOSTS.has(h) || OWNED_ZONES.some(z => h === z || (h.endsWith('.' + z) && !USER_CONTENT_ZONES.includes(z)));
++ if (/--[^.]+\.openvibe\.(?:link|website)$/.test(h)) return false;
++ return CONNECT_HOSTS.has(h) || MANIFEST_HOSTS.has(h) || OWNED_ZONES.some(z => h === z || (h.endsWith('.' + z) && !USER_CONTENT_ZONES.includes(z)));
}
/** A bare https origin (no port, no path) on a trusted host: a site that may use cross-site sign-in. */
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 48fe1415..e3768c6f 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -162,7 +162,7 @@ async function seedDb(db, { log = console } = {}) {
{ client_id: 'shoes', name: 'OpenVibe.Shoes', redirect_uris: ['https://open.shoes/auth/callback'] },
// Your computer, on the web, privately (plan 5.9b, Website Connect v0): sign-in at connect.openvibe.website,
// the gate in front of every <label>--<user>.openvibe.website share.
-- { client_id: 'connect', name: 'OpenVibe Connect', redirect_uris: ['https://connect.openvibe.website/auth/callback'] },
++ { client_id: 'connect', name: 'OpenVibe Connect', redirect_uris: ['https://connect.openvibe.website/auth/callback', 'https://connect.openvibe.link/auth/callback'] },
// The shared AI benchmark and comparison engine; a developer service at bench.openvibe.services.
{ client_id: 'bench', name: 'OpenVibe.Bench', redirect_uris: ['https://bench.openvibe.services/auth/callback'] },
// Sources' merchant opt-in pages sign a source owner in (scope profile, no site of their own).
ddiff --git a/server/frame/sites.js b/server/frame/sites.js
iindex a02ae362..65706a4a 100644
---- a/server/frame/sites.js
++++ b/server/frame/sites.js
@@@ -34,12 +34,15 @@ const ALL_SITES = RAW_SITES.map((s) => {
const e = exposureOf(s.service);
const host = manifestHost(s.service) || s.host;
if (!host) throw new Error(`site ${s.id}: no host (its manifest has no publicOrigin and the entry names none)`);
-- return { ...s, host, ...(s.service === 'inventory' ? { url: 'https://openvibe.my/inventory' } : {}), status: e.state === 'live' && e.public_site === 'service' ? 'open' : 'soon', state: e.state };
++ return { ...s, host, ...(s.service === 'inventory' ? { url: 'https://openvibe.my/inventory' } : {}),
++ // The navigation moves first; the service/API manifest keeps its working Website origin during rollout.
++ ...(s.service === 'connect' ? { url: 'https://connect.openvibe.link/' } : {}), status: e.state === 'live' && e.public_site === 'service' ? 'open' : 'soon', state: e.state };
});
// The menu, footer, home and llms.txt list SITES; siteForHost still knows Play so a Play-host page (or a token
// consent screen) keeps its own name, without the site being offered as a destination.
const SITES = ALL_SITES.filter((s) => !HIDDEN_SITE.has(s.id));
const byHost = new Map(ALL_SITES.map(s => [s.host, s]));
++byHost.set('connect.openvibe.link', ALL_SITES.find(s => s.service === 'connect'));
/** A site's full name: its own when it is a brand already (OpenRestream), otherwise OpenVibe.<name> (OpenVibe.Live).
* "OpenVibe.OpenRestream" read wrong on the home page's cards (the owner, 2026-10-28). */
@@@ -74,7 +77,7 @@ const MENU_LINE = {
deals: 'Deals people share', coupons: 'Codes that work', reviews: 'Honest reviews', work: 'Find a job', rent: 'Places to rent',
homes: 'Homes for sale', food: 'Food near you', help: 'Help nearby', trade: 'Watch the markets', watch: 'Know when it changes',
search: 'Search everything', host: 'Compare hosting', report: 'Your numbers, honest', vision: 'Ask a picture',
-- exchange: 'AI models, one account',
++ exchange: 'AI models, one account', connect: 'Share a port with Link',
network: 'Your account', inventory: 'Your items',
services: 'APIs, SDK and keys', bench: 'Which AI is best', run: 'Run jobs anywhere', zone: 'S3 storage', events: 'Event streams', media: 'Media API',
};
@@@ -107,6 +110,8 @@ const ICON_ALIAS = { 'media-hub': 'video' };
/** The site a hostname belongs to (subdomains included), or null. */
function siteForHost(hostname) {
const h = String(hostname || '').toLowerCase().replace(/:\d+$/, '').replace(/^www\./, '');
++ // User-run shares are never an OpenVibe frame destination.
++ if (/--[^.]+\.openvibe\.(?:link|website)$/.test(h)) return null;
if (byHost.has(h)) return byHost.get(h);
// A product on its own domain belongs to the site that serves it (Bench's host is Report's AI section now).
// Before the subdomain pass, because services.openvibe.services would otherwise claim bench.openvibe.services.
ddiff --git a/server/registry/node-principals.js b/server/registry/node-principals.js
iindex bfeefc77..fcec3dbd 100644
---- a/server/registry/node-principals.js
++++ b/server/registry/node-principals.js
@@@ -368,7 +368,7 @@ function userRouter(requireAuth, now = () => Date.now()) {
}
/** Audiences every person's machine may buy a token for, whichever service paired it (CON-4). */
--const NODE_WIDE_AUDIENCES = Object.freeze(['openvibe.website']);
++const NODE_WIDE_AUDIENCES = Object.freeze(['openvibe.link', 'openvibe.website']);
/** Every refusal of a node's credential, whatever the reason: one status, one body. */
const INVALID_CLIENT = Object.freeze({ status: 401, body: Object.freeze({ error: 'invalid_client', error_description: 'Invalid client credentials' }) });
ddiff --git a/test/connect-link.test.js b/test/connect-link.test.js
nnew file mode 100644
iindex 00000000..3dae35ef
---- /dev/null
++++ b/test/connect-link.test.js
@@@ -0,0 +1,28 @@
++'use strict';
++const assert = require('node:assert/strict');
++const { getDb, seedDb } = require('../server/db/database');
++const { isTrustedHost, isTrustedOrigin, clientOriginMatcher } = require('../server/auth/sso-owned');
++const { siteForHost, SITES } = require('../server/frame/sites');
++(async () => {
++ const db = await getDb();
++ await db.prepare("UPDATE oauth_clients SET client_secret = ?, redirect_uris = ? WHERE client_id = 'connect'")
++ .run('test-connect-credential', JSON.stringify(['https://connect.openvibe.website/auth/callback', 'https://existing.example/callback']));
++ await seedDb(db, { log: { log() {}, warn() {} } });
++ await seedDb(db, { log: { log() {}, warn() {} } });
++ const c = await db.prepare("SELECT * FROM oauth_clients WHERE client_id = 'connect'").get();
++ assert.equal(c.client_secret, 'test-connect-credential');
++ assert.deepEqual(JSON.parse(c.redirect_uris), ['https://connect.openvibe.website/auth/callback', 'https://existing.example/callback', 'https://connect.openvibe.link/auth/callback']);
++ const matches = clientOriginMatcher(c);
++ for (const host of ['connect.openvibe.link', 'connect.openvibe.website']) {
++ assert.equal(isTrustedHost(host), true); assert.equal(isTrustedOrigin(`https://${host}`), true);
++ assert.equal(matches(`https://${host}`), true);
++ assert.equal(siteForHost(host).service, 'connect');
++ }
++ for (const host of ['portal--alex.openvibe.link', 'portal--alex.openvibe.website', 'nested.portal--alex.openvibe.link', 'connect.openvibe.link.evil.example']) {
++ assert.equal(isTrustedHost(host), false, host); assert.equal(isTrustedOrigin(`https://${host}`), false, host);
++ assert.equal(matches(`https://${host}`), false, host); assert.equal(siteForHost(host), null, host);
++ }
++ assert.equal(SITES.find(s => s.service === 'connect').url, 'https://connect.openvibe.link/');
++ assert.equal(isTrustedHost('openvibe.link'), true);
++ console.log('Connect Link callbacks, credential preservation, frame destination and tenant isolation passed');
++})().catch(e => { console.error(e); process.exitCode = 1; });
ddiff --git a/test/node-tokens.test.js b/test/node-tokens.test.js
iindex 50f60d2d..9baba6d7 100644
---- a/test/node-tokens.test.js
++++ b/test/node-tokens.test.js
@@@ -114,12 +114,14 @@ try {
assert.ok(v.ok, v.reason);
assert.deepStrictEqual([v.claims.aud, v.claims.cap], [['openvibe.bot'], []], 'Bot authorises the node by its own binding');
// Any person's machine may speak to OpenVibe Connect, whoever paired it (CON-4), with no capability.
-- const w = await tokenCall(A.principal, A.credential, 'openvibe.website');
++ for (const aud of ['openvibe.link', 'openvibe.website']) {
++ const w = await tokenCall(A.principal, A.credential, aud);
assert.strictEqual(w.status, 200, w.text);
-- const vw = serviceAuth.verifyServiceToken(w.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.website' });
++ const vw = serviceAuth.verifyServiceToken(w.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: aud });
assert.ok(vw.ok, vw.reason);
-- assert.deepStrictEqual([vw.claims.sub, vw.claims.aud, vw.claims.cap], [`node:${A.principal}`, ['openvibe.website'], []]);
++ assert.deepStrictEqual([vw.claims.sub, vw.claims.aud, vw.claims.cap], [`node:${A.principal}`, [aud], []]);
assert.strictEqual((await call('PUT', '/api/v1/node/self/capabilities', doc(A.node_id), bearer(w.body.access_token))).status, 401, 'no use on Network');
++ }
for (const aud of ['openvibe.media', 'openvibe.network.evil', 'openvibe.websites', 'connect.openvibe.website', '', undefined]) {
const bad = await tokenCall(A.principal, A.credential, aud);
assert.deepStrictEqual([bad.status, bad.body.error], [400, 'invalid_scope'], String(aud));
ddiff --git a/test/security-redirects.test.js b/test/security-redirects.test.js
iindex 72bcb195..9d81a155 100644
---- a/test/security-redirects.test.js
++++ b/test/security-redirects.test.js
@@@ -28,6 +28,8 @@ const BAD = [
'https://openvibe.lol/', 'https://login.openvibe.xyz/', 'http://openvibe.live/', 'https://user:[email protected]/',
// *.openvibe.host are tenant sites (OpenVibe.Host Stage B): people's own content, not ours
'https://evil.openvibe.host/', 'https://a.b.openvibe.host/x',
++ 'https://portal--alex.openvibe.link/', 'https://nested.portal--alex.openvibe.link/',
++ 'https://portal--alex.openvibe.website/', 'https://unknown.openvibe.link/',
];
const GOOD = [
['/', '/'], ['/my#linked', '/my#linked'], ['/admin?tab=users', '/admin?tab=users'],
@@@ -37,6 +39,9 @@ const GOOD = [
['https://openvibe.network/admin', 'https://openvibe.network/admin'],
['https://ingest.openre.stream/', 'https://ingest.openre.stream/'],
['https://openvibe.host/dashboard', 'https://openvibe.host/dashboard'],
++ ['https://openvibe.link/', 'https://openvibe.link/'],
++ ['https://connect.openvibe.link/', 'https://connect.openvibe.link/'],
++ ['https://connect.openvibe.website/', 'https://connect.openvibe.website/'],
];
for (const [file, name] of [['js/login.js', 'safeReturnUrl'], ['sso-fanout.html', 'safeNext']]) {