openvibers/openvibe.network Commits
Register Connect on Link and isolate share-host trust (#670)
Changes
@@@ -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 oursfunction 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 */ }@@@ -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 oursfunction 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) infiniteif (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 */ }@@@ -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. */@@@ -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).@@@ -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.@@@ -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' }) });nnew file mode 100644@@@ -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; });@@@ -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));@@@ -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']]) {
@@ -662,7 +662,7 @@ function ripple(btn, e) {
@@ -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
// 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.
// 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_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) {
function safeReturnUrl(raw) {
const s = String(raw == null ? '' : raw);
const s = String(raw == null ? '' : raw);
if (!s || /[\\\u0000-\u001f\u007f]/.test(s)) return '/';
if (!s || /[\\\u0000-\u001f\u007f]/.test(s)) return '/';
@@ -672,7 +672,7 @@ function safeReturnUrl(raw) {
@@ -672,7 +672,7 @@ function safeReturnUrl(raw) {
if (u.username || u.password) return '/';
if (u.username || u.password) return '/';
if (u.origin === window.location.origin) return u.toString();
if (u.origin === window.location.origin) return u.toString();
const h = u.hostname.toLowerCase();
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)$/;
const local = /^(localhost|127\.0\.0\.1)$/;
if (u.protocol === 'http:' && local.test(h) && local.test(window.location.hostname)) return u.toString();
if (u.protocol === 'http:' && local.test(h) && local.test(window.location.hostname)) return u.toString();
} catch (e) { /* not a URL */ }
} catch (e) { /* not a URL */ }
@@ -82,7 +82,7 @@ li.now .dot i{animation:breathe 1.2s var(--ov-ease-in-out, ease-in-out) infinite
@@ -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
// 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.
// 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_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) {
function safeNext(raw) {
const s = String(raw == null ? '' : raw);
const s = String(raw == null ? '' : raw);
if (!s || /[\\\u0000-\u001f\u007f]/.test(s)) return '/';
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
@@ -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.username || u.password) return '/';
if (u.origin === window.location.origin) return u.toString();
if (u.origin === window.location.origin) return u.toString();
const h = u.hostname.toLowerCase();
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)$/;
const local = /^(localhost|127\.0\.0\.1)$/;
if (u.protocol === 'http:' && local.test(h) && local.test(window.location.hostname)) return u.toString();
if (u.protocol === 'http:' && local.test(h) && local.test(window.location.hostname)) return u.toString();
} catch (e) { /* not a URL */ }
} catch (e) { /* not a URL */ }
@@ -13,7 +13,9 @@ const OWNED_ZONES = [
@@ -13,7 +13,9 @@ const OWNED_ZONES = [
// Zones whose subdomains are people's own content (OpenVibe.Host tenant sites, <site>.openvibe.host,
// 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
// 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.
// 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
// 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,
// 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 = (() => {
@@ -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)? */
/** Is `hostname` an OpenVibe site we trust across sites (an owned zone, minus tenant subdomains, or a manifest's host)? */
function isTrustedHost(hostname) {
function isTrustedHost(hostname) {
const h = String(hostname || '').toLowerCase();
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. */
/** A bare https origin (no port, no path) on a trusted host: a site that may use cross-site sign-in. */
@@ -162,7 +162,7 @@ async function seedDb(db, { log = console } = {}) {
@@ -162,7 +162,7 @@ async function seedDb(db, { log = console } = {}) {
{ client_id: 'shoes', name: 'OpenVibe.Shoes', redirect_uris: ['https://open.shoes/auth/callback'] },
{ 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,
// 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.
// 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.
// 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'] },
{ 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).
// Sources' merchant opt-in pages sign a source owner in (scope profile, no site of their own).
@@ -34,12 +34,15 @@ const ALL_SITES = RAW_SITES.map((s) => {
@@ -34,12 +34,15 @@ const ALL_SITES = RAW_SITES.map((s) => {
const e = exposureOf(s.service);
const e = exposureOf(s.service);
const host = manifestHost(s.service) || s.host;
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)`);
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
// 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.
// 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 SITES = ALL_SITES.filter((s) => !HIDDEN_SITE.has(s.id));
const byHost = new Map(ALL_SITES.map(s => [s.host, s]));
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).
/** 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). */
* "OpenVibe.OpenRestream" read wrong on the home page's cards (the owner, 2026-10-28). */
@@ -74,7 +77,7 @@ const MENU_LINE = {
@@ -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',
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',
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',
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',
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',
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' };
@@ -107,6 +110,8 @@ const ICON_ALIAS = { 'media-hub': 'video' };
/** The site a hostname belongs to (subdomains included), or null. */
/** The site a hostname belongs to (subdomains included), or null. */
function siteForHost(hostname) {
function siteForHost(hostname) {
const h = String(hostname || '').toLowerCase().replace(/:\d+$/, '').replace(/^www\./, '');
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);
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).
// 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.
// Before the subdomain pass, because services.openvibe.services would otherwise claim bench.openvibe.services.
@@ -368,7 +368,7 @@ function userRouter(requireAuth, now = () => Date.now()) {
@@ -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). */
/** 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. */
/** 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' }) });
const INVALID_CLIENT = Object.freeze({ status: 401, body: Object.freeze({ error: 'invalid_client', error_description: 'Invalid client credentials' }) });
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -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; });
@@ -114,12 +114,14 @@ try {
@@ -114,12 +114,14 @@ try {
assert.ok(v.ok, v.reason);
assert.ok(v.ok, v.reason);
assert.deepStrictEqual([v.claims.aud, v.claims.cap], [['openvibe.bot'], []], 'Bot authorises the node by its own binding');
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.
// 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);
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.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');
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]) {
for (const aud of ['openvibe.media', 'openvibe.network.evil', 'openvibe.websites', 'connect.openvibe.website', '', undefined]) {
const bad = await tokenCall(A.principal, A.credential, aud);
const bad = await tokenCall(A.principal, A.credential, aud);
assert.deepStrictEqual([bad.status, bad.body.error], [400, 'invalid_scope'], String(aud));
assert.deepStrictEqual([bad.status, bad.body.error], [400, 'invalid_scope'], String(aud));
@@ -28,6 +28,8 @@ const BAD = [
@@ -28,6 +28,8 @@ const BAD = [
'https://openvibe.lol/', 'https://login.openvibe.xyz/', 'http://openvibe.live/', 'https://user:[email protected]/',
'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
// *.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://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 = [
const GOOD = [
['/', '/'], ['/my#linked', '/my#linked'], ['/admin?tab=users', '/admin?tab=users'],
['/', '/'], ['/my#linked', '/my#linked'], ['/admin?tab=users', '/admin?tab=users'],
@@ -37,6 +39,9 @@ const GOOD = [
@@ -37,6 +39,9 @@ const GOOD = [
['https://openvibe.network/admin', 'https://openvibe.network/admin'],
['https://openvibe.network/admin', 'https://openvibe.network/admin'],
['https://ingest.openre.stream/', 'https://ingest.openre.stream/'],
['https://ingest.openre.stream/', 'https://ingest.openre.stream/'],
['https://openvibe.host/dashboard', 'https://openvibe.host/dashboard'],
['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']]) {
for (const [file, name] of [['js/login.js', 'safeReturnUrl'], ['sso-fanout.html', 'safeNext']]) {