openvibers/openvibe.connect Commits
Serve private Connect shares on Link alongside Website (#67)
Changes
@@@ -1,11 +1,13 @@# OpenVibe Connect++Shares move to `.openvibe.link` beside their existing `.openvibe.website` aliases. See++[the staged rollout and host commands](docs/link-share-rollout.md) before deployment.++**Your computer, on the web, privately.** Share one port on your computer at its own HTTPS address,--`https://<name>--<you>.openvibe.website`, and only the people you pick can open it, signed in with OpenVibe. No router++`https://<name>--<you>.openvibe.link`, and only the people you pick can open it, signed in with OpenVibe. No routersettings, no open ports, no app to install: one file and Node.--Live at **[connect.openvibe.website](https://connect.openvibe.website)**. Part of openvibe.website: Connect is the--"your machine" half; [OpenVibe.Space](https://openvibe.space) builds and hosts sites.++Live at **[connect.openvibe.website](https://connect.openvibe.website)**. Connect shares ports through Link; [OpenVibe.Space](https://openvibe.space) builds and hosts sites.## Use it@@@ -18,7 +20,7 @@ Live at **[connect.openvibe.website](https://connect.openvibe.website)**. Part onode ov-connect.js --key ovc_… --port 8790```--3. Open `https://agents--you.openvibe.website` anywhere, on a phone over 5G included, and sign in.++3. Open `https://agents--you.openvibe.link` anywhere, on a phone over 5G included, and sign in.The client opens one outgoing connection, so it works behind any router or hotspot, and it reconnects by itself. Itonly ever connects to `localhost` on the port you gave it.@@@ -31,7 +31,7 @@ log_format connect_share '$remote_addr - [$time_local] "$request_method $host$urserver {listen 80;listen [::]:80;-- server_name connect.openvibe.website ~^[a-z0-9-]+--[a-z0-9-]+\.openvibe\.website$;++ server_name connect.openvibe.website connect.openvibe.link ~^[a-z0-9-]+--[a-z0-9-]+\.openvibe\.link$ ~^[a-z0-9-]+--[a-z0-9-]+\.openvibe\.website$;location /.well-known/acme-challenge/ { root /var/www/certbot; }location / { return 301 https://$host$request_uri; }@@@ -147,3 +147,115 @@ server {proxy_pass http://127.0.0.1:5240;}}++++# Link aliases: install only once the wildcard certificate exists.++# ── connect.openvibe.link ──────────────────────────────────++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name connect.openvibe.link;++ include snippets/security-txt.conf;++++ ssl_certificate /etc/letsencrypt/live/openvibe.link/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.link/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ access_log /var/log/nginx/connect.openvibe.link.access.log;++ error_log /var/log/nginx/connect.openvibe.link.error.log;++++ client_max_body_size 64k;++++ add_header X-Content-Type-Options nosniff always;++ add_header Strict-Transport-Security "max-age=31536000" always;++++ gzip on;++ gzip_proxied any;++ gzip_vary on;++ gzip_comp_level 5;++ gzip_min_length 1024;++ gzip_types text/css application/javascript text/javascript application/json application/problem+json image/svg+xml text/plain application/xml;++++ proxy_http_version 1.1;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header CF-Connecting-IP $remote_addr;++ proxy_set_header X-Forwarded-Proto $scheme;++++ # ── A computer connecting a share: one long WebSocket (the server pings every 25 s) ──++ location = /tunnel {++ limit_req zone=connect_tunnel burst=20 nodelay;++ limit_req_status 429;++ proxy_set_header Upgrade $http_upgrade;++ proxy_set_header Connection $connect_connection;++ proxy_buffering off;++ proxy_read_timeout 3600s;++ proxy_send_timeout 3600s;++ proxy_pass http://127.0.0.1:5240;++ }++++ location = /auth/me {++ limit_req zone=connect_api burst=30 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5240;++ }++++ location /auth/ {++ limit_req zone=connect_auth burst=10 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5240;++ }++++ location /api/ {++ limit_req zone=connect_api burst=20 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5240;++ }++++ location ~* ^/metrics(/|$) { return 404; }++ location ~* ^/internal(/|$) { return 404; }++++ location / {++ proxy_pass http://127.0.0.1:5240;++ }++++ location ~ /\.(?!well-known) {++ deny all;++ }++}++++# ── <label>--<user>.openvibe.link: a share ─────────────────++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name ~^[a-z0-9-]+--[a-z0-9-]+\.openvibe\.link$;++++ ssl_certificate /etc/letsencrypt/live/openvibe.link/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.link/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ access_log /var/log/nginx/connect-link-shares.access.log connect_share;++ error_log /var/log/nginx/connect-link-shares.error.log;++++ # Connect counts a body against its own cap (100 MB); Cloudflare's own cap is the same.++ client_max_body_size 100m;++++ add_header X-Content-Type-Options nosniff always;++++ location / {++ proxy_http_version 1.1;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-Proto $scheme;++ proxy_set_header Upgrade $http_upgrade;++ proxy_set_header Connection $connect_connection;++ proxy_buffering off;++ proxy_request_buffering off;++ proxy_cache off;++ proxy_read_timeout 3600s;++ proxy_send_timeout 3600s;++ proxy_pass http://127.0.0.1:5240;++ }++}nnew file mode 100644@@@ -0,0 +1,116 @@++# Connect shares on Link: Opus rollout++++This PR makes no DNS, Cloudflare or host changes. Leave the four PRs for review. The operator must complete the++following stages in order; do not release the new Node audience before Network and Connect accept it.++++## 1. Prepare trust and the dual-host service++++Merge/release the Shared overlay fix and pin it in services that serve overlay.js before publishing share URLs on++Link. Both share domains are untrusted card sources and handoff destinations; Connect also denies framing at the++edge. Shared's new version is assigned by the normal release/pin process after review, not by these PRs.++++Network adds the exact Connect callback `https://connect.openvibe.link/auth/callback` beside the existing Website++callback, preserving the credential and other registered callbacks. No wildcard callback is registered. It accepts++both node audiences (`openvibe.link`, `openvibe.website`), and only the Link apex and Connect control host receive++SSO trust. Its frame entry points at `connect.openvibe.link`; deploy Network only once that host is reachable (stage 3).++++Initially deploy Connect with these non-secret overrides through the normal service environment mechanism:++++```text++CONNECT_SHARE_DOMAIN=openvibe.website++CONNECT_SHARE_DOMAINS=openvibe.link,openvibe.website++CONNECT_REDIRECT_LEGACY=false++BASE_URL=https://connect.openvibe.website++OV_OAUTH_REDIRECT_URI=https://connect.openvibe.website/auth/callback++```++++Migration 0015 keeps stored share hosts, IDs and connect keys intact, enforces uniqueness across aliases, and++backfills existing MCP sessions' exact host audience. Old browser gate cookies are intentionally invalidated once:++the visitor goes through Network sign-in again and receives a new host-bound, host-only cookie. Neither gate nor++control-panel cookies may have a Domain attribute. Never set a cookie on `.openvibe.link`.++++## 2. DNS and certificates (operator only)++++In Cloudflare's **openvibe.link** zone add these records, leaving apex/www records alone:++++| Type | Name | Content | Proxy |++| --- | --- | --- | --- |++| A | `connect` | Copy the **origin IPv4 value** of the existing `connect.openvibe.website` A record from its DNS dashboard | Proxied |++| CNAME | `*` | `connect.openvibe.link` | Proxied |++++If the existing Connect origin is a CNAME rather than an A record, use that same origin CNAME target for `connect`.++Do not copy the public `dig` answer of a proxied host (it is a Cloudflare edge address). Leave existing Website++records in place. Enable WebSockets, use Full (strict) TLS, and apply the same cache bypass as Website to++`connect.openvibe.link` and `*--*.openvibe.link`; do not add a Cache Everything rule. Arbitrary non-share Link++subdomains have no Connect application route. No QUIC or DNS-only transport is introduced.++++Expand the existing `/etc/letsencrypt/live/openvibe.link/` certificate to cover `openvibe.link`, `www.openvibe.link`,++and `*.openvibe.link`. Keep its existing automated DNS-01 renewal method if configured. An exact interactive++DNS-01 issuance command, if automation is unavailable, is:++++```sh++sudo certbot certonly --manual --preferred-challenges dns --cert-name openvibe.link --expand \++ -d openvibe.link -d www.openvibe.link -d '*.openvibe.link'++```++++Create each TXT record Certbot requests in Cloudflare (`_acme-challenge.openvibe.link` for apex/wildcard and++`_acme-challenge.www.openvibe.link` if requested for www; keep all simultaneously++requested values until validation finishes), then continue Certbot. This manual alternative requires a DNS hook++or manual renewal before expiry; preserve/configure automated DNS-01 renewal before considering rollout complete.++Do not replace the Website certificate. The Link certificate must exist **before** the combined vhost is installed.++++## 3. Install the vhost and prove both names++++From the reviewed/deployed **OpenVibe.Connect** checkout on openvibe-ovh run:++++```sh++sudo ov-vhost-install deploy/nginx/connect.openvibe.website.conf connect.openvibe.website.conf++sudo nginx -t++```++++The installer owns installation/reload. Do not install a second copy of this combined file: its maps and rate-limit++zones are declared once. The file retains both Website servers and adds the exact Link control host and a++`<label>--<user>.openvibe.link` regex, forwarding to `127.0.0.1:5240` with HTTP/1.1 Upgrade, streaming and no buffering.++The Link apex's Network vhost stays untouched. `/tunnel` works on either control hostname; HTTP control pages on++the noncanonical hostname 302 to BASE_URL, so callbacks and sessions are never split between control hosts.++++Use an existing private share and a new test share to verify over Cloudflare, with redirects still disabled:++++* Both share aliases reach the Network gate, preserve a deep path/query, and open after sign-in.++* A different user is denied unless explicitly named; revocation closes both aliases.++* Inspect each gate Set-Cookie: `__Host-ovc`, Secure, HttpOnly, SameSite=Lax, Path=/, **no Domain**. A cookie copied++ to the other alias or another share fails. Local-app cookies also have Domain stripped.++* GET, POST, SSE, and an authenticated WebSocket work on both hosts; responses bypass Cloudflare cache.++* The existing Node binary still connects, and the new audience works after deploying Network. MCP sessions++ keep their exact original host audience; changing alias requires authorization for that alias.++* `https://connect.openvibe.link/auth/health` (following the control redirect) passes and the old control host works.++++## 4. Publish Link URLs, then redirect++++Once both names work, deploy Network, then set/redeploy Connect with:++++```text++CONNECT_SHARE_DOMAIN=openvibe.link++CONNECT_SHARE_DOMAINS=openvibe.link,openvibe.website++CONNECT_REDIRECT_LEGACY=false++BASE_URL=https://connect.openvibe.link++OV_OAUTH_REDIRECT_URI=https://connect.openvibe.link/auth/callback++```++++Keep CONNECT_AUDIENCE at its existing service/admin value (`openvibe.website`); the node verifier separately++accepts both audiences, so owner configuration calls continue working.++++The old control URL remains usable (HTTP pages redirect; tunnel upgrades remain direct). Both share aliases still++answer, while API/list/ready responses and `openvibe-link share` advertise Link. Verify sign-in on the new control++origin; its host-only cookie requires one fresh sign-in. Release Node after this stage.++++After the new share URL is proved, set `CONNECT_REDIRECT_LEGACY=true` and redeploy Connect. Old share requests++receive **302**, preserving path and query exactly. Gate codes/cookies are never copied into the new host's session;++an in-flight old-host callback may require retrying sign-in. Legacy WebSocket upgrades also receive 302; clients++that do not follow upgrade redirects must reconnect at the Link URL. Existing tunnel connections use the same ID.++For APIs, 302 can change POST to GET in clients, so update callers to Link before enabling the switch.++++Rollback without losing shares: set `CONNECT_REDIRECT_LEGACY=false`, keep both domains, and restore++`CONNECT_SHARE_DOMAIN=openvibe.website` / the Website BASE_URL and OAuth redirect. Keep the dual-audience server++versions and additive migration in place (new Node binaries need Link audience; old server code cannot insert the++new required MCP audience column). No DNS/certificate removal is required.nnew file mode 100644@@@ -0,0 +1,10 @@++-- phase: expand++-- Both public names identify the same share; keep stored hosts and keys intact.++CREATE UNIQUE INDEX shares_alias_active ON shares++ ((regexp_replace(host, '\.openvibe\.(website|link)$', '.openvibe.link')))++ WHERE status = 'active';++++-- Existing MCP sessions retain their original host audience.++ALTER TABLE share_oauth_tokens ADD COLUMN resource_host text;++UPDATE share_oauth_tokens t SET resource_host = s.host FROM shares s WHERE s.id = t.share_id;++ALTER TABLE share_oauth_tokens ALTER COLUMN resource_host SET NOT NULL;@@@ -217,6 +217,14 @@ async function createApp(opts = {}) {crossOriginResourcePolicy: { policy: 'same-site' },referrerPolicy: { policy: 'strict-origin-when-cross-origin' },}));++ app.use((req, res, next) => {++ const host = String(req.headers.host || '').toLowerCase().replace(/:\d+$/, '');++ const canonical = new URL(config.baseUrl).hostname;++ if (['connect.openvibe.link', 'connect.openvibe.website'].includes(host) && host !== canonical) {++ return res.redirect(302, `${config.baseUrl}${req.originalUrl}`);++ }++ return next();++ });app.use(cookieParser());// ── Machine endpoints ───────────────────────────────────@@@ -28,7 +28,8 @@ function createNodeAuth({ config, keys, now = () => Date.now() }) {if (typeof token !== 'string' || token.split('.').length !== 3) return { ok: false, status: 401, code: 'token.malformed', reason: 'not a node token' };const publicKey = await keys.pemForToken(token);if (!publicKey) return { ok: false, status: 503, code: 'identity.unavailable', reason: 'the Network signing key is not loaded yet' };-- const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience, now: now() });++ const results = (config.nodeAudiences || [config.audience]).map((audience) => serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience, now: now() }));++ const r = results.find((result) => result.ok) || results[0];if (!r.ok) return { ok: false, status: 401, code: r.code || 'token.invalid', reason: r.reason || 'the token does not verify' };const m = NODE_SUB.exec(String(r.claims.sub || ''));if (!m || r.claims.actor_type !== 'node') return { ok: false, status: 401, code: 'token.not_node', reason: 'this is not a computer\'s node token' };@@@ -39,7 +39,10 @@ function load(env = process.env) {networkUrl,networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),++ // The registered service/admin audience stays Website; node audiences are independent.audience: env.CONNECT_AUDIENCE || 'openvibe.website',++ // Old Link binaries still request the Website audience during the transition.++ nodeAudiences: [...new Set([env.CONNECT_AUDIENCE || 'openvibe.link', 'openvibe.link', 'openvibe.website'])],oauth: {clientId: env.OV_OAUTH_CLIENT_ID || 'connect',clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',@@@ -59,7 +62,10 @@ function load(env = process.env) {// The shares. A share answers at https://<label>--<user>.<shareDomain>. The session secret signs the// visitor's host-only cookie on a share host and is required in production (32+ characters).shares: {-- domain: String(env.CONNECT_SHARE_DOMAIN || 'openvibe.website').toLowerCase(),++ domain: String(env.CONNECT_SHARE_DOMAIN || 'openvibe.link').toLowerCase(),++ domains: [...new Set([env.CONNECT_SHARE_DOMAIN || 'openvibe.link', ...(env.CONNECT_SHARE_DOMAINS || 'openvibe.link,openvibe.website').split(',')].map((s) => s.trim().toLowerCase()).filter(Boolean))],++ // Enable only after DNS, wildcard TLS and the new vhost have been proved.++ redirectLegacy: bool(env.CONNECT_REDIRECT_LEGACY),scheme: env.CONNECT_SHARE_SCHEME || (isProduction ? 'https' : 'http'),sessionSecret: env.CONNECT_SESSION_SECRET || (isProduction ? '' : 'development-only-session-secret-0123456789'),sessionHours: Math.max(1, Math.min(72, int(env.CONNECT_SESSION_HOURS, 12))),@@@ -8,10 +8,10 @@** 1. A visitor without a valid cookie is sent to https://connect.openvibe.website/grant?share=<id>&next=<path>.* 2. There, signed in to OpenVibe (sign-in happens first if needed), the share's policy is checked. A person it lets-- * in gets a one-time code (60 seconds, used once, bound to that share) and goes back to++ * in gets a one-time code (60 seconds, used once, bound to that share and host) and goes back to* https://<share host>/__ovc/callback?code=…&next=….* 3. The share host swaps the code for its own cookie: `__Host-ovc` (Secure, HttpOnly, SameSite=Lax, Path=/, no-- * Domain), an HMAC-signed { share, subject, username, policy version, expiry }.++ * Domain), an HMAC-signed { share, host audience, subject, username, policy version, expiry }.** A policy change bumps the share's policy_version, so every cookie made before it stops working on the next request* and its holder goes through the grant again (the owner never notices: they are let straight back in).@@@ -34,9 +34,9 @@ function createGate({ config, now = () => Date.now() }) {const mac = (data) => crypto.createHmac('sha256', secret).update(data).digest('base64url');-- function sign({ shareId, subject, username, policyVersion }) {++ function sign({ shareId, subject, username, policyVersion, host }) {const exp = now() + config.shares.sessionHours * 3600_000;-- const data = Buffer.from(JSON.stringify({ s: shareId, u: subject, n: username || null, v: policyVersion, e: exp })).toString('base64url');++ const data = Buffer.from(JSON.stringify({ s: shareId, a: host, u: subject, n: username || null, v: policyVersion, e: exp })).toString('base64url');return `${data}.${mac(data)}`;}@@@ -51,7 +51,7 @@ function createGate({ config, now = () => Date.now() }) {if (sig.length !== want.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want))) return null;let c;try { c = JSON.parse(Buffer.from(data, 'base64url').toString('utf8')); } catch { return null; }-- if (!c || c.s !== share.id || c.v !== share.policy_version || !(c.e > now())) return null;++ if (!c || c.s !== share.id || c.a !== share.host || c.v !== share.policy_version || !(c.e > now())) return null;return { subject: c.u, username: c.n };}@@@ -76,17 +76,17 @@ function createGate({ config, now = () => Date.now() }) {return { ours, rest: rest.join('; ') };}-- function mintCode({ shareId, subject, username }) {++ function mintCode({ shareId, subject, username, host }) {const code = crypto.randomBytes(24).toString('base64url');-- codes.set(code, { shareId, subject, username, exp: now() + CODE_TTL_MS });++ codes.set(code, { shareId, subject, username, host, exp: now() + CODE_TTL_MS });return code;}-- function redeemCode(code, shareId) {++ function redeemCode(code, shareId, host) {if (typeof code !== 'string') return null;const v = codes.get(code);if (!v) return null;codes.delete(code);-- if (v.exp <= now() || v.shareId !== shareId) return null;++ if (v.exp <= now() || v.shareId !== shareId || v.host !== host) return null;return v;}@@@ -42,7 +42,7 @@ const PRM = '/.well-known/oauth-protected-resource';function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = null, log = console, now = () => Date.now() }) {// The last requests through each share, for its owner's page (CON-8 v0; memory only).const inspector = require('./inspector').createInspector({ now });-- const domain = config.shares.domain;++ const domain = config.shares.domains || [config.shares.domain];const cache = new Map(); // host → { share, at }const windows = new Map(); // share id → { start, count }const ownerBytes = new Map(); // owner subject → { day, bytes }@@@ -52,7 +52,7 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nuconst today = () => new Date(now()).toISOString().slice(0, 10);const shareUrl = (share) => share.url || `https://${share.host}`;const resourceMetadata = (share) => `${shareUrl(share)}${PRM}`;-- const grantUrl = (share, next) => `${config.baseUrl}/grant?share=${encodeURIComponent(share.id)}&next=${encodeURIComponent(gate.safeNext(next))}`;++ const grantUrl = (share, next) => `${config.baseUrl}/grant?share=${encodeURIComponent(share.id)}&next=${encodeURIComponent(gate.safeNext(next))}${share.host !== share.canonicalHost && share.canonicalHost ? `&host=${encodeURIComponent(share.host)}` : ''}`;async function lookup(host) {const hit = cache.get(host);@@@ -63,7 +63,10 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nureturn share;}/** Forget what is cached for a share (a policy change, a new key, a revoke). */-- function forget(host) { cache.delete(String(host || '').toLowerCase()); }++ function forget(host) {++ const p = parseHost(host, domain);++ if (p) for (const d of domain) cache.delete(`${p.label}--${p.handle}.${d}`);++ }function counter(shareId) {if (!counters[shareId]) counters[shareId] = { requests: 0, bytesIn: 0, bytesOut: 0, visits: {} };@@@ -149,6 +152,9 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nuout['cache-control'] = privateCache(cacheControl);out['cloudflare-cdn-cache-control'] = 'no-store';out['x-robots-tag'] = 'noindex, nofollow';++ // A local app is not a first-party card. Keep its own CSP and add an independent framing policy.++ out['content-security-policy'] = [].concat(out['content-security-policy'] || [], "frame-ancestors 'none'");++ out['x-frame-options'] = 'DENY';return out;}@@@ -262,7 +268,14 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nuif (expired) { sendProblem(req, res, 410, 'expired', 'This share has ended', `${share.host} was shared until ${new Date(share.expires_at).toUTCString().replace(' GMT', ' UTC')}, and that time is up.`, { link: { href: config.baseUrl, text: 'What is OpenVibe Connect?' } }); return null; }if (share.status !== 'active' && share.closed_reason === 'report') { sendProblem(req, res, 410, 'closed', 'This share was closed', `OpenVibe closed ${share.host} after a report.`, { link: { href: config.baseUrl, text: 'What is OpenVibe Connect?' } }); return null; }if (share.status !== 'active') { sendProblem(req, res, 410, 'closed', 'This share is closed', `${share.owner_username} closed ${share.host}.`, { link: { href: config.baseUrl, text: 'What is OpenVibe Connect?' } }); return null; }-- return share;++ const canonicalHost = share.host;++ if (config.shares.redirectLegacy && parsed.host !== canonicalHost) {++ // Build from a validated host; preserve the original path and query, never its authority.++ res.writeHead(302, { location: `${config.shares.scheme}://${canonicalHost}${String(req.url || '/')}`, 'cache-control': 'no-store', 'cloudflare-cdn-cache-control': 'no-store', 'referrer-policy': 'no-referrer' });++ res.end();++ return null;++ }++ return { ...share, canonicalHost, host: parsed.host, url: `${config.shares.scheme}://${parsed.host}` };}async function handle(req, res) {@@@ -273,9 +286,9 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nuif (path === '/__ovc/callback') {const q = new URL(url, 'http://x').searchParams;-- const v = gate.redeemCode(q.get('code'), share.id);++ const v = gate.redeemCode(q.get('code'), share.id, share.host);if (!v) return sendPage(res, 400, 'That link ran out', 'Sign-in links work once, for a minute.', { link: { href: grantUrl(share, q.get('next') || '/'), text: 'Try again' } });-- const cookie = gate.sign({ shareId: share.id, subject: v.subject, username: v.username, policyVersion: share.policy_version });++ const cookie = gate.sign({ shareId: share.id, subject: v.subject, username: v.username, policyVersion: share.policy_version, host: share.host });res.writeHead(303, { location: gate.safeNext(q.get('next')), 'set-cookie': gate.cookieHeader(cookie), 'cache-control': 'no-store', 'cloudflare-cdn-cache-control': 'no-store', 'referrer-policy': 'no-referrer' });return res.end();}@@@ -60,7 +60,7 @@ function createPageRoutes(ctx) {title: null,index: true,description: 'OpenVibe Connect: share one port on your computer at its own HTTPS address, behind OpenVibe sign-in. Only the people you pick can open it. One file, Node 22, no router settings.',-- body: views.home({ viewer: req.viewer, shares: list, connectedIds: connectedIds(list), stats: await homeStats(), baseUrl: config.baseUrl }),++ body: views.home({ viewer: req.viewer, shares: list, connectedIds: connectedIds(list), stats: await homeStats(), domain: config.shares.domain, baseUrl: config.baseUrl }),});});@@@ -74,7 +74,7 @@ function createPageRoutes(ctx) {if (!(err instanceof ShareError)) throw err;const list = await shares.list(req.viewer.subject);const form = { values: input, error: err.message, username: req.viewer.username };-- return page(req, res, { title: 'Make a share', body: views.home({ viewer: req.viewer, shares: list, connectedIds: connectedIds(list), stats: await homeStats(), baseUrl: config.baseUrl, form }) }, err.status || 422);++ return page(req, res, { title: 'Make a share', body: views.home({ viewer: req.viewer, shares: list, connectedIds: connectedIds(list), stats: await homeStats(), domain: config.shares.domain, baseUrl: config.baseUrl, form }) }, err.status || 422);}});@@@ -238,7 +238,13 @@ function createPageRoutes(ctx) {res.set('Cache-Control', 'no-store');const id = typeof req.query.share === 'string' ? req.query.share.slice(0, 64) : '';const next = gate.safeNext(typeof req.query.next === 'string' ? req.query.next : '/');-- const share = id ? await shares.get(id) : null;++ let share = id ? await shares.get(id) : null;++ if (share && req.query.host != null) {++ const host = typeof req.query.host === 'string' ? req.query.host.toLowerCase() : '';++ const alias = await shares.byHost(host);++ if (!alias || alias.id !== share.id || !/^[a-z0-9.-]+$/.test(host)) return res.sendStatus(400);++ if (!config.shares.redirectLegacy) share = { ...share, host, url: `${config.shares.scheme}://${host}` };++ }if (!share || share.status !== 'active') {return page(req, res, { title: 'No such share', body: views.message({ title: 'No such share', text: share ? `${share.host} is closed.` : 'That share does not exist.', link: { href: '/', label: 'OpenVibe Connect' } }) }, share ? 410 : 404);}@@@ -247,7 +253,7 @@ function createPageRoutes(ctx) {proxy.denied(share, req.viewer);return page(req, res, { title: 'Not for you', body: views.grantDenied({ share, viewer: req.viewer }) }, 403);}-- const code = gate.mintCode({ shareId: share.id, subject: req.viewer.subject, username: req.viewer.username });++ const code = gate.mintCode({ shareId: share.id, subject: req.viewer.subject, username: req.viewer.username, host: share.host });res.set('Referrer-Policy', 'no-referrer');return res.redirect(303, `${share.url}/__ovc/callback?code=${encodeURIComponent(code)}&next=${encodeURIComponent(next)}`);});@@@ -79,9 +79,9 @@ function createOAuth(s, { shares, scheme = 'https', now = () => Date.now() } = {async function shareFor(resource) {let u;try { u = new URL(String(resource || '')); } catch { return null; }-- if (u.protocol !== `${scheme}:`) return null; // https in production (CONNECT_SHARE_SCHEME)++ if (u.protocol !== `${scheme}:` || u.port || u.username || u.password) return null; // https in production (CONNECT_SHARE_SCHEME)const share = await shares.byHost(u.host.toLowerCase());-- return share && share.status === 'active' ? share : null;++ return share && share.status === 'active' ? { ...share, host: u.hostname, url: u.origin } : null;}/**@@@ -102,17 +102,17 @@ function createOAuth(s, { shares, scheme = 'https', now = () => Date.now() } = {function issueCode({ client: c, share, viewer, redirectUri, challenge }) {sweep();const code = b64url(crypto.randomBytes(32));-- codes.set(code, { clientId: c.id, redirectUri, challenge, shareId: share.id, subject: viewer.subject, username: viewer.username || null, policyVersion: share.policy_version, exp: now() + CODE_TTL_MS });++ codes.set(code, { clientId: c.id, redirectUri, challenge, shareId: share.id, host: share.host, subject: viewer.subject, username: viewer.username || null, policyVersion: share.policy_version, exp: now() + CODE_TTL_MS });return code;}-- async function mintTokens({ shareId, clientId, subject, username, policyVersion }) {++ async function mintTokens({ shareId, clientId, subject, username, policyVersion, host }) {const access = `ovat_${b64url(crypto.randomBytes(32))}`;const refresh = `ovrt_${b64url(crypto.randomBytes(32))}`;const id = s.newId('sot');-- await db.exec(`INSERT INTO share_oauth_tokens (id, share_id, client_id, subject, username, policy_version, access_hash, access_expires_at, refresh_hash, refresh_expires_at)-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,-- [id, shareId, clientId, subject, username, policyVersion, sha256(access), new Date(now() + ACCESS_TTL_MS), sha256(refresh), new Date(now() + REFRESH_TTL_MS)]);++ await db.exec(`INSERT INTO share_oauth_tokens (id, share_id, client_id, subject, username, policy_version, access_hash, access_expires_at, refresh_hash, refresh_expires_at, resource_host)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,++ [id, shareId, clientId, subject, username, policyVersion, sha256(access), new Date(now() + ACCESS_TTL_MS), sha256(refresh), new Date(now() + REFRESH_TTL_MS), host]);db.exec('UPDATE oauth_clients SET last_used_at = now() WHERE id = $1', [clientId]).catch(() => {});return { access_token: access, token_type: 'Bearer', expires_in: Math.round(ACCESS_TTL_MS / 1000), refresh_token: refresh, scope: 'share' };}@@@ -127,7 +127,7 @@ function createOAuth(s, { shares, scheme = 'https', now = () => Date.now() } = {if (b.client_id !== g.clientId || b.redirect_uri !== g.redirectUri) throw new OAuthError(400, 'invalid_grant', 'The code was given to another client or redirect_uri.');const verifier = String(b.code_verifier || '');if (!/^[A-Za-z0-9._~-]{43,128}$/.test(verifier) || b64url(crypto.createHash('sha256').update(verifier).digest()) !== g.challenge) throw new OAuthError(400, 'invalid_grant', 'The PKCE code_verifier does not match.');-- if (b.resource) { const share = await shareFor(b.resource); if (!share || share.id !== g.shareId) throw new OAuthError(400, 'invalid_target', 'resource is not the share the code was for'); }++ if (b.resource) { const share = await shareFor(b.resource); if (!share || share.id !== g.shareId || share.host !== g.host) throw new OAuthError(400, 'invalid_target', 'resource is not the share the code was for'); }return mintTokens(g);}if (b.grant_type === 'refresh_token') {@@@ -143,9 +143,13 @@ function createOAuth(s, { shares, scheme = 'https', now = () => Date.now() } = {await db.exec('UPDATE share_oauth_tokens SET revoked_at = now() WHERE id = $1', [r.id]);throw new OAuthError(400, 'invalid_grant', 'The share changed who it lets in: sign in again.');}++ if (b.resource) {++ const resource = await shareFor(b.resource);++ if (!resource || resource.id !== r.share_id || resource.host !== r.resource_host) throw new OAuthError(400, 'invalid_target', 'resource is not the host the refresh token was for');++ }// Rotation: this row ends, a new one starts; the old refresh token used again finds a revoked row.await db.exec('UPDATE share_oauth_tokens SET revoked_at = now() WHERE id = $1', [r.id]);-- return mintTokens({ shareId: r.share_id, clientId: r.client_id, subject: r.subject, username: r.username, policyVersion: Number(r.policy_version) });++ return mintTokens({ shareId: r.share_id, clientId: r.client_id, subject: r.subject, username: r.username, policyVersion: Number(r.policy_version), host: r.resource_host });}throw new OAuthError(400, 'unsupported_grant_type', 'grant_type is authorization_code or refresh_token');}@@@ -156,7 +160,7 @@ function createOAuth(s, { shares, scheme = 'https', now = () => Date.now() } = {if (!share || share.status !== 'active' || !ACCESS_RE.test(String(accessToken || ''))) return null;const r = await db.maybe(`SELECT t.*, c.name AS client_name FROM share_oauth_tokens t JOIN oauth_clients c ON c.id = t.client_idWHERE t.access_hash = $1 AND t.share_id = $2 AND t.revoked_at IS NULL`, [sha256(accessToken), share.id]);-- if (!r || new Date(r.access_expires_at).getTime() <= now() || Number(r.policy_version) !== share.policy_version) return null;++ if (!r || r.resource_host !== share.host || new Date(r.access_expires_at).getTime() <= now() || Number(r.policy_version) !== share.policy_version) return null;if (!shares.allows(share, { subject: r.subject, username: r.username })) return null;const t = now();if (!touched.has(r.id) || t - touched.get(r.id) > 60_000) {@@@ -88,6 +88,7 @@ function hostFor(label, username, domain) {/** The share label in a host, or null: "agents--alex.openvibe.website" → { label: 'agents', handle: 'alex' }. */function parseHost(host, domain) {++ if (Array.isArray(domain)) return domain.map((d) => parseHost(host, d)).find(Boolean) || null;const h = String(host || '').toLowerCase().replace(/:\d+$/, '');const suffix = `.${domain}`;if (!h.endsWith(suffix)) return null;@@@ -132,10 +133,17 @@ function createShares(s, { config }) {const { db } = s;const domain = config.shares.domain;const scheme = config.shares.scheme;++ const domains = config.shares.domains || [domain];++ const aliases = (host) => {++ const p = parseHost(host, domains);++ return p ? domains.map((d) => `${p.label}--${p.handle}.${d}`) : [];++ };const urlOf = (row) => `${scheme}://${row.host}`;function view(row) {if (!row) return null;++ const parsed = parseHost(row.host, domains);++ if (parsed) row = { ...row, host: `${parsed.label}--${parsed.handle}.${domain}` };return {id: row.id,owner_subject: row.owner_subject,@@@ -175,7 +183,7 @@ function createShares(s, { config }) {}async function get(id) { return view(await db.maybe('SELECT * FROM shares WHERE id = $1', [String(id || '')])); }async function byHost(host) {-- return view(await db.maybe("SELECT * FROM shares WHERE host = $1 ORDER BY status = 'active' DESC, created_at DESC LIMIT 1", [String(host || '').toLowerCase()]));++ return view(await db.maybe("SELECT * FROM shares WHERE host = ANY($1::text[]) ORDER BY status = 'active' DESC, created_at DESC LIMIT 1", [aliases(host)]));}async function byKey(key) {if (typeof key !== 'string' || !/^ovc_[A-Za-z0-9_-]{43}$/.test(key)) return null;@@@ -209,7 +217,7 @@ function createShares(s, { config }) {if (live.n >= config.shares.perPerson) throw new ShareError(409, 'share.limit', `You have ${live.n} shares open, the most for now. Close one first.`);// A host someone else used stays theirs (a revoked share holds its address, so nobody else can serve on links// that still point there). The owner may reuse their own.-- const taken = await db.maybe('SELECT owner_subject, status FROM shares WHERE host = $1 ORDER BY created_at DESC LIMIT 1', [host]);++ const taken = await db.maybe('SELECT owner_subject, status FROM shares WHERE host = ANY($1::text[]) ORDER BY created_at DESC LIMIT 1', [aliases(host)]);if (taken && (taken.status === 'active' || taken.owner_subject !== owner.subject)) {throw new ShareError(409, 'share.taken', taken.owner_subject === owner.subject ? 'You already have a share with that name.' : 'That address is taken. Pick another name.');}@@@ -243,7 +251,7 @@ function createShares(s, { config }) {const policyChanged = mode !== share.mode || JSON.stringify(named) !== JSON.stringify(share.named);// offline_site: undefined keeps it; '' / null clears it (not a policy change: nobody is signed out).const keepOffline = input.offline_site === undefined;-- const offline = keepOffline ? null : offlineSite(input.offline_site, { domain, scheme });++ const offline = keepOffline ? null : offlineSite(input.offline_site, { domain: domain === 'openvibe.link' ? 'openvibe.website' : domain, scheme });// expires_in: undefined keeps the time it has; null / '' / 'never' takes it away; seconds set it from now.const keepExpiry = input.expires_in === undefined || input.expires_in === 'keep';const ttl = keepExpiry ? null : expiresIn(input.expires_in);@@@ -302,7 +310,7 @@ function createShares(s, { config }) {// ── Reports (CON-9 v0) ──────────────────────────────────────────────────────────────────────────────────────/** Anyone reports a share by its address. Returns { id, share }. */async function report({ host, reason, details, reporter = null }) {-- const parsed = parseHost(String(host || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, ''), domain);++ const parsed = parseHost(String(host || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, ''), domains);const share = parsed ? await byHost(parsed.host) : null;if (!share) throw new ShareError(404, 'share.not_found', 'There is no share at that address. Check it, or paste the whole address.');if (!REPORT_REASONS[reason]) throw new ShareError(422, 'report.invalid_reason', 'Pick what the report is about.');@@@ -34,11 +34,11 @@ function ago(iso, now = Date.now()) {}/** The hero's picture: a port on a laptop, a line through OpenVibe, an address on a phone with a lock. */--function motif() {++function motif(domain) {return `<div class="cn-motif" aria-hidden="true"><div class="cn-m-box cn-m-pc"><span class="cn-m-k">your computer</span><b>localhost:8080</b><span class="cn-m-dot"></span></div><div class="cn-m-line"><i></i><span>one outgoing connection</span></div>--<div class="cn-m-box cn-m-phone"><span class="cn-m-k">anywhere</span><b>agents--you.openvibe.website</b><span class="cn-m-lock">${kit.icon('lock')} signed in as you</span></div>++<div class="cn-m-box cn-m-phone"><span class="cn-m-k">anywhere</span><b>agents--you.${esc(domain)}</b><span class="cn-m-lock">${kit.icon('lock')} signed in as you</span></div></div>`;}@@@ -46,8 +46,8 @@ function terminal(lines) {return `<div class="cn-term" role="img" aria-label="A terminal running ov-connect"><div class="cn-term-bar"><i></i><i></i><i></i></div><pre>${lines.map((l) => (l.startsWith('$ ') ? `<span class="cn-term-cmd">${esc(l)}</span>` : `<span class="cn-term-out">${esc(l)}</span>`)).join('\n')}</pre></div>`;}--function phone() {-- return `<div class="cn-phone" role="img" aria-label="A phone opening a share"><div class="cn-phone-url">${kit.icon('lock')}<span>agents--you.openvibe.website</span></div>++function phone(domain) {++ return `<div class="cn-phone" role="img" aria-label="A phone opening a share"><div class="cn-phone-url">${kit.icon('lock')}<span>agents--you.${esc(domain)}</span></div><div class="cn-phone-body"><b>Your agent portal</b><span>Runs on your desk. Open here, over 5G.</span><span class="cn-phone-pill">Signed in as you</span></div></div>`;}@@@ -94,13 +94,13 @@ function closesField(current, { keep = null } = {}) {return `<label class="cn-field cn-field-closes"><span>Closes by itself</span><select name="expires_in">${opts.map(([v, t]) => `<option value="${esc(v)}"${String(current) === v ? ' selected' : ''}>${esc(t)}</option>`).join('')}</select><small>The address answers "ended" after that, and the computer is disconnected.</small></label>`;}--function createForm({ values = {}, error = null, username = '', staff = false } = {}) {++function createForm({ values = {}, error = null, username = '', staff = false, domain = 'openvibe.link' } = {}) {const handle = String(username || 'you').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'you';return `<form class="cn-form" method="post" action="/shares" id="new"><h2>Make a share</h2>${error ? `<p class="cn-error" role="alert">${esc(error)}</p>` : ''}<div class="cn-fields">--<label class="cn-field"><span>Name</span><span class="cn-addr"><input name="label" required maxlength="40" pattern="[a-z0-9]([a-z0-9]|-(?=[a-z0-9])){0,39}" value="${esc(values.label || '')}" placeholder="agents" autocapitalize="none" autocomplete="off" spellcheck="false"><span class="cn-addr-tail">--${esc(handle)}.openvibe.website</span></span><small>a–z, 0–9 and hyphens</small></label>++<label class="cn-field"><span>Name</span><span class="cn-addr"><input name="label" required maxlength="40" pattern="[a-z0-9]([a-z0-9]|-(?=[a-z0-9])){0,39}" value="${esc(values.label || '')}" placeholder="agents" autocapitalize="none" autocomplete="off" spellcheck="false"><span class="cn-addr-tail">--${esc(handle)}.${esc(domain)}</span></span><small>a–z, 0–9 and hyphens</small></label><label class="cn-field cn-field-port"><span>Port on your computer</span><input name="port" required inputmode="numeric" pattern="[0-9]{1,5}" maxlength="5" value="${esc(values.port || '')}" placeholder="8080"></label></div>${closesField(values.expires_in == null ? '' : values.expires_in)}@@@ -113,7 +113,7 @@ ${modeChoices(values.mode || 'owner', staff)}</form>`;}--function yourShares({ viewer, shares, connectedIds, form }) {++function yourShares({ viewer, shares, connectedIds, form, domain }) {if (!viewer || viewer.kind !== 'user') return '';const open = shares.filter((s) => s.status === 'active');const closed = shares.filter((s) => s.status !== 'active');@@@ -121,18 +121,18 @@ function yourShares({ viewer, shares, connectedIds, form }) {? `<ul class="cn-shares">${open.map((s) => shareRow(s, { connected: connectedIds.has(s.id) })).join('')}</ul>`: '<p class="cn-empty">No shares yet. Make one below: a name, a port, who can open it.</p>';const closedList = closed.length ? `<details class="cn-closed"><summary>Closed shares (${closed.length})</summary><ul class="cn-shares">${closed.map((s) => shareRow(s, { connected: false })).join('')}</ul></details>` : '';-- return `<section class="cn-yours" aria-labelledby="cn-yours-h"><h2 id="cn-yours-h">Your shares</h2>${list}${closedList}${createForm({ username: viewer.username, ...(form || {}), staff: viewer.role === 'admin' })}</section>`;++ return `<section class="cn-yours" aria-labelledby="cn-yours-h"><h2 id="cn-yours-h">Your shares</h2>${list}${closedList}${createForm({ domain, username: viewer.username, ...(form || {}), staff: viewer.role === 'admin' })}</section>`;}--function home({ viewer, shares = [], connectedIds = new Set(), stats = {}, baseUrl, form = null }) {++function home({ viewer, shares = [], connectedIds = new Set(), stats = {}, baseUrl, form = null, domain = 'openvibe.link' }) {const signedIn = viewer && viewer.kind === 'user';const body = sc.room({room: 'website',hero: {title: 'Your computer, on the web, privately',-- lede: 'Share one port on your computer at its own address, like agents--you.openvibe.website. Only the people you pick can open it, signed in with OpenVibe. No router settings, no open ports, no app to install: one file and Node.',++ lede: `Share one port on your computer at its own address, like agents--you.${esc(domain)}. Only the people you pick can open it, signed in with OpenVibe. No router settings, no open ports, no app to install: one file and Node.`,start: signedIn ? { href: '#new', label: 'Make a share' } : { href: '/auth/login?next=%2F%23new', label: 'Sign in to make a share' },-- motif: motif(),++ motif: motif(domain),},proof: [{ value: stats.connected, label: stats.connected === 1 ? 'share connected now' : 'shares connected now', live: true },@@@ -145,7 +145,7 @@ function home({ viewer, shares = [], connectedIds = new Set(), stats = {}, baseUhtml: terminal([`$ curl -fsSLO ${CLIENT_URL(baseUrl)}`,'$ node ov-connect.js --key ovc_… --port 8080',-- 'Connected. https://agents--you.openvibe.website → localhost:8080',++ `Connected. https://agents--you.${domain} → localhost:8080`,'GET / → 200 (14 ms)',]),action: signedIn ? { href: '#new', label: 'Make a share' } : { href: '/auth/login?next=%2F%23new', label: 'Sign in to start' },@@@ -153,7 +153,7 @@ function home({ viewer, shares = [], connectedIds = new Set(), stats = {}, baseU{title: 'Open it anywhere, signed in',text: 'Your agent portal, a dev server, a home dashboard: open its address on your phone over 5G. It is real HTTPS, so the microphone, the camera and WebSockets work. Live streams (server-sent events) stream.',-- html: phone(),++ html: phone(domain),},{title: 'You pick who gets in',@@@ -184,7 +184,7 @@ function home({ viewer, shares = [], connectedIds = new Set(), stats = {}, baseUnetwork: {service: 'website',title: 'Part of OpenVibe',-- lede: 'Connect is the "your machine" half of openvibe.website. The other half builds and hosts sites.',++ lede: 'Connect shares a port from your machine through OpenVibe Link. Space builds and hosts sites.',items: [{ to: 'space', what: 'Build and host a site at <name>.openvibe.website', href: 'https://openvibe.space' },{ to: 'network', what: 'The sign-in your visitors use', href: 'https://openvibe.network' },@@@ -192,7 +192,7 @@ function home({ viewer, shares = [], connectedIds = new Set(), stats = {}, baseU],},});-- const yours = yourShares({ viewer, shares, connectedIds, form });++ const yours = yourShares({ viewer, shares, connectedIds, form, domain });const at = body.indexOf('<section class="sc-room-scenes"');return `<div class="cn-wrap">${at < 0 ? body + yours : body.slice(0, at) + yours + body.slice(at)}</div>`;}@@@ -428,7 +428,7 @@ function reportForm({ host = '', values = {}, error = null, reasons = {}, signed<p>Tell OpenVibe about a share that tricks people, spreads harm or breaks the law. Staff read every report and can close the share. Its owner is not told who reported it.</p></header>${error ? `<p class="cn-error" role="alert">${esc(error)}</p>` : ''}<form class="cn-form" method="post" action="/report">--<label class="cn-field"><span>The share's address</span><input name="host" required maxlength="200" value="${esc(values.host || host)}" placeholder="name--someone.openvibe.website" autocapitalize="none" autocomplete="off" spellcheck="false"></label>++<label class="cn-field"><span>The share's address</span><input name="host" required maxlength="200" value="${esc(values.host || host)}" placeholder="name--someone.openvibe.link" autocapitalize="none" autocomplete="off" spellcheck="false"></label><fieldset class="cn-modes"><legend>What is wrong</legend>${Object.entries(reasons).map(([k, v]) => `<label><input type="radio" name="reason" value="${esc(k)}"${picked === k ? ' checked' : ''} required> <span>${esc(v)}</span></label>`).join('\n')}</fieldset>@@@ -30,7 +30,7 @@ async function boot(opts = {}) {OV_OAUTH_CLIENT_ID: 'connect', OV_OAUTH_CLIENT_SECRET: 'connect-secret', COOKIE_SECURE: 'false',CONNECT_DRILL: '1',CONNECT_EVENTS_URL: network.url,-- CONNECT_SHARE_SCHEME: 'https',++ CONNECT_SHARE_SCHEME: 'https', CONNECT_SHARE_DOMAIN: 'openvibe.website',CONNECT_SESSION_SECRET: 'test-session-secret-0123456789abcdef0123',CONNECT_PING_MS: '60000',...(opts.env || {}),nnew file mode 100644@@@ -0,0 +1,151 @@++'use strict';++const assert = require('node:assert/strict');++const net = require('node:net');++const crypto = require('node:crypto');++const { boot, startLocalApp } = require('./helpers/boot');++const { check, done } = require('./helpers/check');++++function upgrade(t, host, cookie) {++ return new Promise((resolve, reject) => {++ const socket = net.connect(t.port, '127.0.0.1');++ let data = '';++ socket.setTimeout(3000, () => socket.destroy(new Error('upgrade timed out')));++ socket.on('error', reject);++ socket.on('connect', () => socket.write(`GET /ws?keep=1 HTTP/1.1\r\nHost: ${host}\r\nConnection: Upgrade\r\nUpgrade: echo\r\nCookie: ${cookie}\r\n\r\n`));++ socket.on('data', (chunk) => { data += chunk.toString(); if (data.includes('\r\n\r\n')) { socket.destroy(); resolve(data); } });++ });++}++++(async () => {++ const t = await boot({ env: { CONNECT_SHARE_DOMAIN: null, COOKIE_SECURE: 'true' } });++ const app = await startLocalApp();++ const alex = t.network.addUser('alex');++ let made, old, cookies = {};++ try {++ await check('new shares advertise Link; pre-existing Website rows keep their ID, key and both names', async () => {++ made = await t.makeShare(alex, { label: 'portal', port: app.port });++ assert.equal(made.share.host, 'portal--alex.openvibe.link');++ old = made.share.host.replace('.link', '.website');++ await t.ctx.s.db.exec('UPDATE shares SET host = $1 WHERE id = $2', [old, made.share.id]);++ for (const host of [old, made.share.host]) assert.equal((await t.ctx.shares.byHost(host)).id, made.share.id);++ assert.equal((await t.ctx.shares.byKey(made.key)).url, made.share.url);++ assert.equal((await t.get('/api/v1/shares', { as: alex })).json().shares[0].host, made.share.host);++ const duplicate = await t.get('/api/v1/shares', { as: alex, json: { label: 'portal', port: app.port } });++ assert.equal(duplicate.status, 409);++ const c = t.client({ key: made.key, port: app.port });++ await c.ready();++ });++ await check('each alias uses Network sign-in and receives a Secure host-only cookie, bound to that host', async () => {++ for (const host of [made.share.host, old]) {++ const gate = await t.share(host, '/deep/path?q=1');++ assert.equal(gate.status, 302);++ const g = new URL(gate.headers.location);++ const grant = await t.get(g.pathname + g.search, { as: alex });++ const cb = new URL(grant.headers.get('location'));++ assert.equal(cb.hostname, host);++ const entered = await t.share(host, cb.pathname + cb.search);++ assert.equal(entered.status, 303);++ assert.equal(entered.headers.location, '/deep/path?q=1');++ const cookie = entered.headers['set-cookie'][0];++ assert.match(cookie, /^__Host-ovc=/); assert.match(cookie, /; Secure/); assert.doesNotMatch(cookie, /domain=/i);++ cookies[host] = cookie.split(';')[0];++ assert.equal((await t.share(host, '/hello', { cookie: cookies[host] })).status, 200);++ }++ assert.equal((await t.share(old, '/', { cookie: cookies[made.share.host] })).status, 302);++ assert.equal((await t.share(made.share.host, '/', { cookie: cookies[old] })).status, 302);++ const other = await t.makeShare(alex, { label: 'other', port: app.port });++ assert.equal((await t.share(other.share.host, '/', { cookie: cookies[made.share.host] })).status, 302);++ });++ await check('grant codes cannot cross aliases and arbitrary return hosts are refused', async () => {++ const grant = await t.get(`/grant?share=${made.share.id}`, { as: alex });++ const cb = new URL(grant.headers.get('location'));++ assert.equal((await t.share(old, cb.pathname + cb.search)).status, 400);++ assert.equal((await t.share(made.share.host, cb.pathname + cb.search)).status, 400, 'attempt spends code');++ assert.equal((await t.get(`/grant?share=${made.share.id}&host=evil.example`, { as: alex })).status, 400);++ });++ await check('HTTP, SSE and WebSocket upgrades use the same tunnel on both hosts', async () => {++ for (const host of [made.share.host, old]) {++ const cookie = cookies[host];++ const post = await t.share(host, '/echo?x=1', { cookie, method: 'POST', body: 'hello' });++ assert.equal(post.json().body, 'hello');++ assert.equal(app.seen.at(-1).headers['x-forwarded-host'], host);++ assert.match((await t.share(host, '/sse', { cookie })).text, /tick 3/);++ assert.match(await upgrade(t, host, cookie), /^HTTP\/1.1 101/);++ const response = await t.share(host, '/cookie', { cookie });++ assert.doesNotMatch(response.headers['set-cookie'].join(';'), /domain=|__Host-ovc=/i);++ assert.equal(response.headers['cloudflare-cdn-cache-control'], 'no-store');++ assert.match(response.headers['content-security-policy'], /frame-ancestors 'none'/);++ assert.equal(response.headers['x-frame-options'], 'DENY');++ }++ });++ await check('MCP metadata and bearer-token audience stay on the requesting share host', async () => {++ const oauth = t.ctx.oauth;++ const client = await oauth.register({ client_name: 'test', redirect_uris: ['http://localhost/callback'] });++ const verifier = 'a'.repeat(43);++ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');++ const share = await oauth.shareFor(`https://${old}`);++ const c = await oauth.client(client.client_id);++ const code = oauth.issueCode({ client: c, share, viewer: alex, redirectUri: 'http://localhost/callback', challenge });++ const token = await oauth.token({ grant_type: 'authorization_code', code, client_id: c.id, redirect_uri: 'http://localhost/callback', code_verifier: verifier });++ assert.ok(await oauth.check({ ...made.share, host: old }, token.access_token));++ assert.equal(await oauth.check(made.share, token.access_token), null);++ assert.equal(await oauth.shareFor(`https://${old}:8443/`), null);++ assert.equal(await oauth.shareFor(`https://person@${old}/`), null);++ await assert.rejects(() => oauth.token({ grant_type: 'refresh_token', refresh_token: token.refresh_token, client_id: c.id, resource: made.share.url }), /resource/);++ for (const host of [old, made.share.host]) assert.equal((await t.share(host, '/.well-known/oauth-protected-resource')).json().resource, `https://${host}`);++ });++ await check('both node audiences work; unrelated audiences do not', async () => {++ const node = t.network.addNode(alex);++ for (const aud of ['openvibe.website', 'openvibe.link']) assert.equal((await t.ctx.nodeAuth.verify(t.network.nodeToken(node, { aud }))).ok, true);++ assert.equal((await t.ctx.nodeAuth.verify(t.network.nodeToken(node, { aud: 'openvibe.network' }))).ok, false);++ });++ await check('redirects are opt-in, preserve the path/query, and cover upgrades without forwarding cookies', async () => {++ assert.equal(t.config.shares.redirectLegacy, false);++ t.config.shares.redirectLegacy = true;++ const r = await t.share(old, '/a%20b?x=1&y=2', { cookie: cookies[old] });++ assert.equal(r.status, 302); assert.equal(r.headers.location, `${made.share.url}/a%20b?x=1&y=2`);++ assert.equal(r.headers['set-cookie'], undefined);++ assert.match(await upgrade(t, old, cookies[old]), /^HTTP\/1.1 302/);++ assert.equal((await t.share(made.share.host, '/', { cookie: cookies[made.share.host] })).status, 200);++ });++ await check('revocation invalidates both aliases immediately', async () => {++ t.config.shares.redirectLegacy = false;++ assert.equal((await t.get(`/api/v1/shares/${made.share.id}`, { as: alex, method: 'DELETE' })).status, 200);++ for (const host of [old, made.share.host]) assert.equal((await t.share(host, '/', { cookie: cookies[host] })).status, 410);++ });++ } finally { await t.close(); await app.close(); }++ await check('new control origin uses its exact callback and host-only SSO; old control pages redirect', async () => {++ const n = await boot({ env: { BASE_URL: 'https://connect.openvibe.link', CONNECT_SHARE_DOMAIN: null, COOKIE_SECURE: 'true' } });++ try {++ const user = n.network.addUser('kim');++ const old = await n.share('connect.openvibe.website', '/auth/login?next=%2Fshares');++ assert.equal(old.status, 302);++ assert.equal(old.headers.location, 'https://connect.openvibe.link/auth/login?next=%2Fshares');++ const login = await n.share('connect.openvibe.link', '/auth/login?next=%2Fshares');++ const authorize = new URL(login.headers.location);++ assert.equal(authorize.searchParams.get('redirect_uri'), 'https://connect.openvibe.link/auth/callback');++ const code = n.network.issueCode(user, authorize.searchParams.get('code_challenge'));++ const cookie = login.headers['set-cookie'].map(c => c.split(';')[0]).join('; ');++ const callback = await n.share('connect.openvibe.link', `/auth/callback?code=${code}&state=${authorize.searchParams.get('state')}`, { cookie });++ assert.equal(callback.status, 302);++ assert.equal(callback.headers.location, '/shares');++ assert.ok(callback.headers['set-cookie'].some(c => c.startsWith('ov_token=')));++ for (const c of callback.headers['set-cookie']) assert.doesNotMatch(c, /domain=/i);++ const share = await n.makeShare(user, { label: 'tunnel', port: 9000 });++ for (const host of ['connect.openvibe.website', 'connect.openvibe.link']) {++ const WebSocket = require('ws');++ const ws = new WebSocket(`ws://127.0.0.1:${n.port}/tunnel`, { headers: { Host: host } });++ try {++ const ready = await new Promise((resolve, reject) => {++ ws.on('error', reject);++ ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 1, key: share.key, port: 9000 })));++ ws.on('message', d => resolve(JSON.parse(String(d))));++ });++ assert.equal(ready.t, 'ready');++ assert.equal(ready.share.host, share.share.host);++ } finally { ws.terminate(); }++ }++ } finally { await n.close(); }++ });++ done();++})();nnew file mode 100644@@@ -0,0 +1,43 @@++'use strict';++const assert = require('node:assert/strict');++const fs = require('node:fs');++const os = require('node:os');++const path = require('node:path');++const { execFileSync } = require('node:child_process');++const src = fs.readFileSync(path.join(__dirname, '../deploy/nginx/connect.openvibe.website.conf'), 'utf8');++for (const domain of ['website', 'link']) {++ assert.ok(src.includes(`server_name connect.openvibe.${domain};`));++ assert.ok(src.includes(`server_name ~^[a-z0-9-]+--[a-z0-9-]+\\.openvibe\\.${domain}$;`));++ assert.ok(src.includes(`/etc/letsencrypt/live/openvibe.${domain}/fullchain.pem;`));++}++assert.equal((src.match(/location = \/tunnel/g) || []).length, 2);++assert.equal((src.match(/proxy_request_buffering off/g) || []).length, 2);++assert.equal((src.match(/limit_req_zone .*zone=connect_auth/g) || []).length, 1);++assert.doesNotMatch(src, /server_name\s+\*\.openvibe\.link/, 'only share names go to Connect; apex stays Network');++try { execFileSync('nginx', ['-v'], { stdio: 'pipe' }); } catch {++ console.log('nginx-link: routing assertions passed; nginx syntax check skipped (nginx unavailable)');++ process.exit(0);++}++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'connect-nginx-'));++try {++ // nginx -t may run through a container wrapper as root; keep its empty scratch dirs removable by this test.++ // The enclosing mkdtemp directory remains private (0700).++ for (const name of ['client', 'proxy', 'fastcgi', 'uwsgi', 'scgi']) {++ fs.mkdirSync(`${dir}/${name}_temp`);++ fs.chmodSync(`${dir}/${name}_temp`, 0o777);++ }++ execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1', '-subj', '/CN=localhost', '-keyout', `${dir}/test.key`, '-out', `${dir}/test.pem`], { stdio: 'pipe' });++ const config = src.replace(/include snippets\/security-txt\.conf;/g, '')++ .replace(/listen (\[::\]:)?80;/g, (_, ip = '') => `listen ${ip}18080;`)++ .replace(/listen (\[::\]:)?443 ssl;/g, (_, ip = '') => `listen ${ip}18443 ssl;`)++ .replace(/ssl_certificate\s+[^;]+;/g, `ssl_certificate ${dir}/test.pem;`)++ .replace(/ssl_certificate_key\s+[^;]+;/g, `ssl_certificate_key ${dir}/test.key;`)++ .replace(/\/var\/log\/nginx\/[^ ;]+/g, '/dev/null');++ fs.writeFileSync(`${dir}/nginx.conf`, `pid ${dir}/nginx.pid;\nerror_log /dev/null;\nevents {}\nhttp {\naccess_log /dev/null;\nclient_body_temp_path ${dir}/client_temp;\nproxy_temp_path ${dir}/proxy_temp;\nfastcgi_temp_path ${dir}/fastcgi_temp;\nuwsgi_temp_path ${dir}/uwsgi_temp;\nscgi_temp_path ${dir}/scgi_temp;\n${config}\n}\n`);++ try {++ execFileSync('nginx', ['-t', '-p', dir, '-c', `${dir}/nginx.conf`], { cwd: dir, stdio: 'pipe' });++ } catch (error) {++ throw new Error(`nginx -t: ${String(error.stderr || error.message)}`);++ }++ console.log('nginx-link: dual-host routing and nginx -t passed');++} finally { fs.rmSync(dir, { recursive: true, force: true }); }@@@ -45,11 +45,11 @@ const { createGate } = require('../server/edge/gate');let now = 1_800_000_000_000;const config = { shares: { sessionSecret: 'x'.repeat(40), sessionHours: 12 }, cookies: { secure: true } };const gate = createGate({ config, now: () => now });-- const share = { id: 'shr_1', policy_version: 2 };-- const v = gate.sign({ shareId: 'shr_1', subject: 'usr_1', username: 'alex', policyVersion: 2 });++ const share = { id: 'shr_1', policy_version: 2, host: 'agents--alex.openvibe.link' };++ const v = gate.sign({ shareId: 'shr_1', subject: 'usr_1', username: 'alex', policyVersion: 2, host: share.host });assert.deepStrictEqual(gate.verify(v, share), { subject: 'usr_1', username: 'alex' });-- assert.strictEqual(gate.verify(v, { id: 'shr_2', policy_version: 2 }), null);-- assert.strictEqual(gate.verify(v, { id: 'shr_1', policy_version: 3 }), null);++ assert.strictEqual(gate.verify(v, { id: 'shr_2', policy_version: 2, host: 'agents--alex.openvibe.link' }), null);++ assert.strictEqual(gate.verify(v, { id: 'shr_1', policy_version: 3, host: 'agents--alex.openvibe.link' }), null);assert.strictEqual(gate.verify(`${v}x`, share), null);now += 13 * 3600_000;assert.strictEqual(gate.verify(v, share), null, 'expired');
@@ -1,11 +1,13 @@
@@ -1,11 +1,13 @@
# OpenVibe Connect
# OpenVibe Connect
+Shares move to `.openvibe.link` beside their existing `.openvibe.website` aliases. See
+[the staged rollout and host commands](docs/link-share-rollout.md) before deployment.
+
**Your computer, on the web, privately.** Share one port on your computer at its own HTTPS address,
**Your computer, on the web, privately.** Share one port on your computer at its own HTTPS address,
-`https://<name>--<you>.openvibe.website`, and only the people you pick can open it, signed in with OpenVibe. No router
+`https://<name>--<you>.openvibe.link`, and only the people you pick can open it, signed in with OpenVibe. No router
settings, no open ports, no app to install: one file and Node.
settings, no open ports, no app to install: one file and Node.
-Live at **[connect.openvibe.website](https://connect.openvibe.website)**. Part of openvibe.website: Connect is the
-"your machine" half; [OpenVibe.Space](https://openvibe.space) builds and hosts sites.
+Live at **[connect.openvibe.website](https://connect.openvibe.website)**. Connect shares ports through Link; [OpenVibe.Space](https://openvibe.space) builds and hosts sites.
## Use it
## Use it
@@ -18,7 +20,7 @@ Live at **[connect.openvibe.website](https://connect.openvibe.website)**. Part o
@@ -18,7 +20,7 @@ Live at **[connect.openvibe.website](https://connect.openvibe.website)**. Part o
node ov-connect.js --key ovc_… --port 8790
node ov-connect.js --key ovc_… --port 8790
```
```
-3. Open `https://agents--you.openvibe.website` anywhere, on a phone over 5G included, and sign in.
+3. Open `https://agents--you.openvibe.link` anywhere, on a phone over 5G included, and sign in.
The client opens one outgoing connection, so it works behind any router or hotspot, and it reconnects by itself. It
The client opens one outgoing connection, so it works behind any router or hotspot, and it reconnects by itself. It
only ever connects to `localhost` on the port you gave it.
only ever connects to `localhost` on the port you gave it.
@@ -31,7 +31,7 @@ log_format connect_share '$remote_addr - [$time_local] "$request_method $host$ur
@@ -31,7 +31,7 @@ log_format connect_share '$remote_addr - [$time_local] "$request_method $host$ur
server {
server {
listen 80;
listen 80;
listen [::]:80;
listen [::]:80;
- server_name connect.openvibe.website ~^[a-z0-9-]+--[a-z0-9-]+\.openvibe\.website$;
+ server_name connect.openvibe.website connect.openvibe.link ~^[a-z0-9-]+--[a-z0-9-]+\.openvibe\.link$ ~^[a-z0-9-]+--[a-z0-9-]+\.openvibe\.website$;
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 301 https://$host$request_uri; }
location / { return 301 https://$host$request_uri; }
@@ -147,3 +147,115 @@ server {
@@ -147,3 +147,115 @@ server {
proxy_pass http://127.0.0.1:5240;
proxy_pass http://127.0.0.1:5240;
}
}
}
}
+
+# Link aliases: install only once the wildcard certificate exists.
+# ── connect.openvibe.link ──────────────────────────────────
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name connect.openvibe.link;
+ include snippets/security-txt.conf;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.link/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.link/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ access_log /var/log/nginx/connect.openvibe.link.access.log;
+ error_log /var/log/nginx/connect.openvibe.link.error.log;
+
+ client_max_body_size 64k;
+
+ add_header X-Content-Type-Options nosniff always;
+ add_header Strict-Transport-Security "max-age=31536000" always;
+
+ gzip on;
+ gzip_proxied any;
+ gzip_vary on;
+ gzip_comp_level 5;
+ gzip_min_length 1024;
+ gzip_types text/css application/javascript text/javascript application/json application/problem+json image/svg+xml text/plain application/xml;
+
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header CF-Connecting-IP $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # ── A computer connecting a share: one long WebSocket (the server pings every 25 s) ──
+ location = /tunnel {
+ limit_req zone=connect_tunnel burst=20 nodelay;
+ limit_req_status 429;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connect_connection;
+ proxy_buffering off;
+ proxy_read_timeout 3600s;
+ proxy_send_timeout 3600s;
+ proxy_pass http://127.0.0.1:5240;
+ }
+
+ location = /auth/me {
+ limit_req zone=connect_api burst=30 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5240;
+ }
+
+ location /auth/ {
+ limit_req zone=connect_auth burst=10 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5240;
+ }
+
+ location /api/ {
+ limit_req zone=connect_api burst=20 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5240;
+ }
+
+ location ~* ^/metrics(/|$) { return 404; }
+ location ~* ^/internal(/|$) { return 404; }
+
+ location / {
+ proxy_pass http://127.0.0.1:5240;
+ }
+
+ location ~ /\.(?!well-known) {
+ deny all;
+ }
+}
+
+# ── <label>--<user>.openvibe.link: a share ─────────────────
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name ~^[a-z0-9-]+--[a-z0-9-]+\.openvibe\.link$;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.link/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.link/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ access_log /var/log/nginx/connect-link-shares.access.log connect_share;
+ error_log /var/log/nginx/connect-link-shares.error.log;
+
+ # Connect counts a body against its own cap (100 MB); Cloudflare's own cap is the same.
+ client_max_body_size 100m;
+
+ add_header X-Content-Type-Options nosniff always;
+
+ location / {
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connect_connection;
+ proxy_buffering off;
+ proxy_request_buffering off;
+ proxy_cache off;
+ proxy_read_timeout 3600s;
+ proxy_send_timeout 3600s;
+ proxy_pass http://127.0.0.1:5240;
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,116 @@
@@ -0,0 +1,116 @@
+# Connect shares on Link: Opus rollout
+
+This PR makes no DNS, Cloudflare or host changes. Leave the four PRs for review. The operator must complete the
+following stages in order; do not release the new Node audience before Network and Connect accept it.
+
+## 1. Prepare trust and the dual-host service
+
+Merge/release the Shared overlay fix and pin it in services that serve overlay.js before publishing share URLs on
+Link. Both share domains are untrusted card sources and handoff destinations; Connect also denies framing at the
+edge. Shared's new version is assigned by the normal release/pin process after review, not by these PRs.
+
+Network adds the exact Connect callback `https://connect.openvibe.link/auth/callback` beside the existing Website
+callback, preserving the credential and other registered callbacks. No wildcard callback is registered. It accepts
+both node audiences (`openvibe.link`, `openvibe.website`), and only the Link apex and Connect control host receive
+SSO trust. Its frame entry points at `connect.openvibe.link`; deploy Network only once that host is reachable (stage 3).
+
+Initially deploy Connect with these non-secret overrides through the normal service environment mechanism:
+
+```text
+CONNECT_SHARE_DOMAIN=openvibe.website
+CONNECT_SHARE_DOMAINS=openvibe.link,openvibe.website
+CONNECT_REDIRECT_LEGACY=false
+BASE_URL=https://connect.openvibe.website
+OV_OAUTH_REDIRECT_URI=https://connect.openvibe.website/auth/callback
+```
+
+Migration 0015 keeps stored share hosts, IDs and connect keys intact, enforces uniqueness across aliases, and
+backfills existing MCP sessions' exact host audience. Old browser gate cookies are intentionally invalidated once:
+the visitor goes through Network sign-in again and receives a new host-bound, host-only cookie. Neither gate nor
+control-panel cookies may have a Domain attribute. Never set a cookie on `.openvibe.link`.
+
+## 2. DNS and certificates (operator only)
+
+In Cloudflare's **openvibe.link** zone add these records, leaving apex/www records alone:
+
+| Type | Name | Content | Proxy |
+| --- | --- | --- | --- |
+| A | `connect` | Copy the **origin IPv4 value** of the existing `connect.openvibe.website` A record from its DNS dashboard | Proxied |
+| CNAME | `*` | `connect.openvibe.link` | Proxied |
+
+If the existing Connect origin is a CNAME rather than an A record, use that same origin CNAME target for `connect`.
+Do not copy the public `dig` answer of a proxied host (it is a Cloudflare edge address). Leave existing Website
+records in place. Enable WebSockets, use Full (strict) TLS, and apply the same cache bypass as Website to
+`connect.openvibe.link` and `*--*.openvibe.link`; do not add a Cache Everything rule. Arbitrary non-share Link
+subdomains have no Connect application route. No QUIC or DNS-only transport is introduced.
+
+Expand the existing `/etc/letsencrypt/live/openvibe.link/` certificate to cover `openvibe.link`, `www.openvibe.link`,
+and `*.openvibe.link`. Keep its existing automated DNS-01 renewal method if configured. An exact interactive
+DNS-01 issuance command, if automation is unavailable, is:
+
+```sh
+sudo certbot certonly --manual --preferred-challenges dns --cert-name openvibe.link --expand \
+ -d openvibe.link -d www.openvibe.link -d '*.openvibe.link'
+```
+
+Create each TXT record Certbot requests in Cloudflare (`_acme-challenge.openvibe.link` for apex/wildcard and
+`_acme-challenge.www.openvibe.link` if requested for www; keep all simultaneously
+requested values until validation finishes), then continue Certbot. This manual alternative requires a DNS hook
+or manual renewal before expiry; preserve/configure automated DNS-01 renewal before considering rollout complete.
+Do not replace the Website certificate. The Link certificate must exist **before** the combined vhost is installed.
+
+## 3. Install the vhost and prove both names
+
+From the reviewed/deployed **OpenVibe.Connect** checkout on openvibe-ovh run:
+
+```sh
+sudo ov-vhost-install deploy/nginx/connect.openvibe.website.conf connect.openvibe.website.conf
+sudo nginx -t
+```
+
+The installer owns installation/reload. Do not install a second copy of this combined file: its maps and rate-limit
+zones are declared once. The file retains both Website servers and adds the exact Link control host and a
+`<label>--<user>.openvibe.link` regex, forwarding to `127.0.0.1:5240` with HTTP/1.1 Upgrade, streaming and no buffering.
+The Link apex's Network vhost stays untouched. `/tunnel` works on either control hostname; HTTP control pages on
+the noncanonical hostname 302 to BASE_URL, so callbacks and sessions are never split between control hosts.
+
+Use an existing private share and a new test share to verify over Cloudflare, with redirects still disabled:
+
+* Both share aliases reach the Network gate, preserve a deep path/query, and open after sign-in.
+* A different user is denied unless explicitly named; revocation closes both aliases.
+* Inspect each gate Set-Cookie: `__Host-ovc`, Secure, HttpOnly, SameSite=Lax, Path=/, **no Domain**. A cookie copied
+ to the other alias or another share fails. Local-app cookies also have Domain stripped.
+* GET, POST, SSE, and an authenticated WebSocket work on both hosts; responses bypass Cloudflare cache.
+* The existing Node binary still connects, and the new audience works after deploying Network. MCP sessions
+ keep their exact original host audience; changing alias requires authorization for that alias.
+* `https://connect.openvibe.link/auth/health` (following the control redirect) passes and the old control host works.
+
+## 4. Publish Link URLs, then redirect
+
+Once both names work, deploy Network, then set/redeploy Connect with:
+
+```text
+CONNECT_SHARE_DOMAIN=openvibe.link
+CONNECT_SHARE_DOMAINS=openvibe.link,openvibe.website
+CONNECT_REDIRECT_LEGACY=false
+BASE_URL=https://connect.openvibe.link
+OV_OAUTH_REDIRECT_URI=https://connect.openvibe.link/auth/callback
+```
+
+Keep CONNECT_AUDIENCE at its existing service/admin value (`openvibe.website`); the node verifier separately
+accepts both audiences, so owner configuration calls continue working.
+
+The old control URL remains usable (HTTP pages redirect; tunnel upgrades remain direct). Both share aliases still
+answer, while API/list/ready responses and `openvibe-link share` advertise Link. Verify sign-in on the new control
+origin; its host-only cookie requires one fresh sign-in. Release Node after this stage.
+
+After the new share URL is proved, set `CONNECT_REDIRECT_LEGACY=true` and redeploy Connect. Old share requests
+receive **302**, preserving path and query exactly. Gate codes/cookies are never copied into the new host's session;
+an in-flight old-host callback may require retrying sign-in. Legacy WebSocket upgrades also receive 302; clients
+that do not follow upgrade redirects must reconnect at the Link URL. Existing tunnel connections use the same ID.
+For APIs, 302 can change POST to GET in clients, so update callers to Link before enabling the switch.
+
+Rollback without losing shares: set `CONNECT_REDIRECT_LEGACY=false`, keep both domains, and restore
+`CONNECT_SHARE_DOMAIN=openvibe.website` / the Website BASE_URL and OAuth redirect. Keep the dual-audience server
+versions and additive migration in place (new Node binaries need Link audience; old server code cannot insert the
+new required MCP audience column). No DNS/certificate removal is required.
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+-- phase: expand
+-- Both public names identify the same share; keep stored hosts and keys intact.
+CREATE UNIQUE INDEX shares_alias_active ON shares
+ ((regexp_replace(host, '\.openvibe\.(website|link)$', '.openvibe.link')))
+ WHERE status = 'active';
+
+-- Existing MCP sessions retain their original host audience.
+ALTER TABLE share_oauth_tokens ADD COLUMN resource_host text;
+UPDATE share_oauth_tokens t SET resource_host = s.host FROM shares s WHERE s.id = t.share_id;
+ALTER TABLE share_oauth_tokens ALTER COLUMN resource_host SET NOT NULL;
@@ -217,6 +217,14 @@ async function createApp(opts = {}) {
@@ -217,6 +217,14 @@ async function createApp(opts = {}) {
crossOriginResourcePolicy: { policy: 'same-site' },
crossOriginResourcePolicy: { policy: 'same-site' },
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
}));
+ app.use((req, res, next) => {
+ const host = String(req.headers.host || '').toLowerCase().replace(/:\d+$/, '');
+ const canonical = new URL(config.baseUrl).hostname;
+ if (['connect.openvibe.link', 'connect.openvibe.website'].includes(host) && host !== canonical) {
+ return res.redirect(302, `${config.baseUrl}${req.originalUrl}`);
+ }
+ return next();
+ });
app.use(cookieParser());
app.use(cookieParser());
// ── Machine endpoints ───────────────────────────────────
// ── Machine endpoints ───────────────────────────────────
@@ -28,7 +28,8 @@ function createNodeAuth({ config, keys, now = () => Date.now() }) {
@@ -28,7 +28,8 @@ function createNodeAuth({ config, keys, now = () => Date.now() }) {
if (typeof token !== 'string' || token.split('.').length !== 3) return { ok: false, status: 401, code: 'token.malformed', reason: 'not a node token' };
if (typeof token !== 'string' || token.split('.').length !== 3) return { ok: false, status: 401, code: 'token.malformed', reason: 'not a node token' };
const publicKey = await keys.pemForToken(token);
const publicKey = await keys.pemForToken(token);
if (!publicKey) return { ok: false, status: 503, code: 'identity.unavailable', reason: 'the Network signing key is not loaded yet' };
if (!publicKey) return { ok: false, status: 503, code: 'identity.unavailable', reason: 'the Network signing key is not loaded yet' };
- const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience, now: now() });
+ const results = (config.nodeAudiences || [config.audience]).map((audience) => serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience, now: now() }));
+ const r = results.find((result) => result.ok) || results[0];
if (!r.ok) return { ok: false, status: 401, code: r.code || 'token.invalid', reason: r.reason || 'the token does not verify' };
if (!r.ok) return { ok: false, status: 401, code: r.code || 'token.invalid', reason: r.reason || 'the token does not verify' };
const m = NODE_SUB.exec(String(r.claims.sub || ''));
const m = NODE_SUB.exec(String(r.claims.sub || ''));
if (!m || r.claims.actor_type !== 'node') return { ok: false, status: 401, code: 'token.not_node', reason: 'this is not a computer\'s node token' };
if (!m || r.claims.actor_type !== 'node') return { ok: false, status: 401, code: 'token.not_node', reason: 'this is not a computer\'s node token' };
@@ -39,7 +39,10 @@ function load(env = process.env) {
@@ -39,7 +39,10 @@ function load(env = process.env) {
networkUrl,
networkUrl,
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
+ // The registered service/admin audience stays Website; node audiences are independent.
audience: env.CONNECT_AUDIENCE || 'openvibe.website',
audience: env.CONNECT_AUDIENCE || 'openvibe.website',
+ // Old Link binaries still request the Website audience during the transition.
+ nodeAudiences: [...new Set([env.CONNECT_AUDIENCE || 'openvibe.link', 'openvibe.link', 'openvibe.website'])],
oauth: {
oauth: {
clientId: env.OV_OAUTH_CLIENT_ID || 'connect',
clientId: env.OV_OAUTH_CLIENT_ID || 'connect',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
@@ -59,7 +62,10 @@ function load(env = process.env) {
@@ -59,7 +62,10 @@ function load(env = process.env) {
// The shares. A share answers at https://<label>--<user>.<shareDomain>. The session secret signs the
// The shares. A share answers at https://<label>--<user>.<shareDomain>. The session secret signs the
// visitor's host-only cookie on a share host and is required in production (32+ characters).
// visitor's host-only cookie on a share host and is required in production (32+ characters).
shares: {
shares: {
- domain: String(env.CONNECT_SHARE_DOMAIN || 'openvibe.website').toLowerCase(),
+ domain: String(env.CONNECT_SHARE_DOMAIN || 'openvibe.link').toLowerCase(),
+ domains: [...new Set([env.CONNECT_SHARE_DOMAIN || 'openvibe.link', ...(env.CONNECT_SHARE_DOMAINS || 'openvibe.link,openvibe.website').split(',')].map((s) => s.trim().toLowerCase()).filter(Boolean))],
+ // Enable only after DNS, wildcard TLS and the new vhost have been proved.
+ redirectLegacy: bool(env.CONNECT_REDIRECT_LEGACY),
scheme: env.CONNECT_SHARE_SCHEME || (isProduction ? 'https' : 'http'),
scheme: env.CONNECT_SHARE_SCHEME || (isProduction ? 'https' : 'http'),
sessionSecret: env.CONNECT_SESSION_SECRET || (isProduction ? '' : 'development-only-session-secret-0123456789'),
sessionSecret: env.CONNECT_SESSION_SECRET || (isProduction ? '' : 'development-only-session-secret-0123456789'),
sessionHours: Math.max(1, Math.min(72, int(env.CONNECT_SESSION_HOURS, 12))),
sessionHours: Math.max(1, Math.min(72, int(env.CONNECT_SESSION_HOURS, 12))),
@@ -8,10 +8,10 @@
@@ -8,10 +8,10 @@
*
*
* 1. A visitor without a valid cookie is sent to https://connect.openvibe.website/grant?share=<id>&next=<path>.
* 1. A visitor without a valid cookie is sent to https://connect.openvibe.website/grant?share=<id>&next=<path>.
* 2. There, signed in to OpenVibe (sign-in happens first if needed), the share's policy is checked. A person it lets
* 2. There, signed in to OpenVibe (sign-in happens first if needed), the share's policy is checked. A person it lets
- * in gets a one-time code (60 seconds, used once, bound to that share) and goes back to
+ * in gets a one-time code (60 seconds, used once, bound to that share and host) and goes back to
* https://<share host>/__ovc/callback?code=…&next=….
* https://<share host>/__ovc/callback?code=…&next=….
* 3. The share host swaps the code for its own cookie: `__Host-ovc` (Secure, HttpOnly, SameSite=Lax, Path=/, no
* 3. The share host swaps the code for its own cookie: `__Host-ovc` (Secure, HttpOnly, SameSite=Lax, Path=/, no
- * Domain), an HMAC-signed { share, subject, username, policy version, expiry }.
+ * Domain), an HMAC-signed { share, host audience, subject, username, policy version, expiry }.
*
*
* A policy change bumps the share's policy_version, so every cookie made before it stops working on the next request
* A policy change bumps the share's policy_version, so every cookie made before it stops working on the next request
* and its holder goes through the grant again (the owner never notices: they are let straight back in).
* and its holder goes through the grant again (the owner never notices: they are let straight back in).
@@ -34,9 +34,9 @@ function createGate({ config, now = () => Date.now() }) {
@@ -34,9 +34,9 @@ function createGate({ config, now = () => Date.now() }) {
const mac = (data) => crypto.createHmac('sha256', secret).update(data).digest('base64url');
const mac = (data) => crypto.createHmac('sha256', secret).update(data).digest('base64url');
- function sign({ shareId, subject, username, policyVersion }) {
+ function sign({ shareId, subject, username, policyVersion, host }) {
const exp = now() + config.shares.sessionHours * 3600_000;
const exp = now() + config.shares.sessionHours * 3600_000;
- const data = Buffer.from(JSON.stringify({ s: shareId, u: subject, n: username || null, v: policyVersion, e: exp })).toString('base64url');
+ const data = Buffer.from(JSON.stringify({ s: shareId, a: host, u: subject, n: username || null, v: policyVersion, e: exp })).toString('base64url');
return `${data}.${mac(data)}`;
return `${data}.${mac(data)}`;
}
}
@@ -51,7 +51,7 @@ function createGate({ config, now = () => Date.now() }) {
@@ -51,7 +51,7 @@ function createGate({ config, now = () => Date.now() }) {
if (sig.length !== want.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want))) return null;
if (sig.length !== want.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want))) return null;
let c;
let c;
try { c = JSON.parse(Buffer.from(data, 'base64url').toString('utf8')); } catch { return null; }
try { c = JSON.parse(Buffer.from(data, 'base64url').toString('utf8')); } catch { return null; }
- if (!c || c.s !== share.id || c.v !== share.policy_version || !(c.e > now())) return null;
+ if (!c || c.s !== share.id || c.a !== share.host || c.v !== share.policy_version || !(c.e > now())) return null;
return { subject: c.u, username: c.n };
return { subject: c.u, username: c.n };
}
}
@@ -76,17 +76,17 @@ function createGate({ config, now = () => Date.now() }) {
@@ -76,17 +76,17 @@ function createGate({ config, now = () => Date.now() }) {
return { ours, rest: rest.join('; ') };
return { ours, rest: rest.join('; ') };
}
}
- function mintCode({ shareId, subject, username }) {
+ function mintCode({ shareId, subject, username, host }) {
const code = crypto.randomBytes(24).toString('base64url');
const code = crypto.randomBytes(24).toString('base64url');
- codes.set(code, { shareId, subject, username, exp: now() + CODE_TTL_MS });
+ codes.set(code, { shareId, subject, username, host, exp: now() + CODE_TTL_MS });
return code;
return code;
}
}
- function redeemCode(code, shareId) {
+ function redeemCode(code, shareId, host) {
if (typeof code !== 'string') return null;
if (typeof code !== 'string') return null;
const v = codes.get(code);
const v = codes.get(code);
if (!v) return null;
if (!v) return null;
codes.delete(code);
codes.delete(code);
- if (v.exp <= now() || v.shareId !== shareId) return null;
+ if (v.exp <= now() || v.shareId !== shareId || v.host !== host) return null;
return v;
return v;
}
}
@@ -42,7 +42,7 @@ const PRM = '/.well-known/oauth-protected-resource';
@@ -42,7 +42,7 @@ const PRM = '/.well-known/oauth-protected-resource';
function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = null, log = console, now = () => Date.now() }) {
function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = null, log = console, now = () => Date.now() }) {
// The last requests through each share, for its owner's page (CON-8 v0; memory only).
// The last requests through each share, for its owner's page (CON-8 v0; memory only).
const inspector = require('./inspector').createInspector({ now });
const inspector = require('./inspector').createInspector({ now });
- const domain = config.shares.domain;
+ const domain = config.shares.domains || [config.shares.domain];
const cache = new Map(); // host → { share, at }
const cache = new Map(); // host → { share, at }
const windows = new Map(); // share id → { start, count }
const windows = new Map(); // share id → { start, count }
const ownerBytes = new Map(); // owner subject → { day, bytes }
const ownerBytes = new Map(); // owner subject → { day, bytes }
@@ -52,7 +52,7 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nu
@@ -52,7 +52,7 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nu
const today = () => new Date(now()).toISOString().slice(0, 10);
const today = () => new Date(now()).toISOString().slice(0, 10);
const shareUrl = (share) => share.url || `https://${share.host}`;
const shareUrl = (share) => share.url || `https://${share.host}`;
const resourceMetadata = (share) => `${shareUrl(share)}${PRM}`;
const resourceMetadata = (share) => `${shareUrl(share)}${PRM}`;
- const grantUrl = (share, next) => `${config.baseUrl}/grant?share=${encodeURIComponent(share.id)}&next=${encodeURIComponent(gate.safeNext(next))}`;
+ const grantUrl = (share, next) => `${config.baseUrl}/grant?share=${encodeURIComponent(share.id)}&next=${encodeURIComponent(gate.safeNext(next))}${share.host !== share.canonicalHost && share.canonicalHost ? `&host=${encodeURIComponent(share.host)}` : ''}`;
async function lookup(host) {
async function lookup(host) {
const hit = cache.get(host);
const hit = cache.get(host);
@@ -63,7 +63,10 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nu
@@ -63,7 +63,10 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nu
return share;
return share;
}
}
/** Forget what is cached for a share (a policy change, a new key, a revoke). */
/** Forget what is cached for a share (a policy change, a new key, a revoke). */
- function forget(host) { cache.delete(String(host || '').toLowerCase()); }
+ function forget(host) {
+ const p = parseHost(host, domain);
+ if (p) for (const d of domain) cache.delete(`${p.label}--${p.handle}.${d}`);
+ }
function counter(shareId) {
function counter(shareId) {
if (!counters[shareId]) counters[shareId] = { requests: 0, bytesIn: 0, bytesOut: 0, visits: {} };
if (!counters[shareId]) counters[shareId] = { requests: 0, bytesIn: 0, bytesOut: 0, visits: {} };
@@ -149,6 +152,9 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nu
@@ -149,6 +152,9 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nu
out['cache-control'] = privateCache(cacheControl);
out['cache-control'] = privateCache(cacheControl);
out['cloudflare-cdn-cache-control'] = 'no-store';
out['cloudflare-cdn-cache-control'] = 'no-store';
out['x-robots-tag'] = 'noindex, nofollow';
out['x-robots-tag'] = 'noindex, nofollow';
+ // A local app is not a first-party card. Keep its own CSP and add an independent framing policy.
+ out['content-security-policy'] = [].concat(out['content-security-policy'] || [], "frame-ancestors 'none'");
+ out['x-frame-options'] = 'DENY';
return out;
return out;
}
}
@@ -262,7 +268,14 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nu
@@ -262,7 +268,14 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nu
if (expired) { sendProblem(req, res, 410, 'expired', 'This share has ended', `${share.host} was shared until ${new Date(share.expires_at).toUTCString().replace(' GMT', ' UTC')}, and that time is up.`, { link: { href: config.baseUrl, text: 'What is OpenVibe Connect?' } }); return null; }
if (expired) { sendProblem(req, res, 410, 'expired', 'This share has ended', `${share.host} was shared until ${new Date(share.expires_at).toUTCString().replace(' GMT', ' UTC')}, and that time is up.`, { link: { href: config.baseUrl, text: 'What is OpenVibe Connect?' } }); return null; }
if (share.status !== 'active' && share.closed_reason === 'report') { sendProblem(req, res, 410, 'closed', 'This share was closed', `OpenVibe closed ${share.host} after a report.`, { link: { href: config.baseUrl, text: 'What is OpenVibe Connect?' } }); return null; }
if (share.status !== 'active' && share.closed_reason === 'report') { sendProblem(req, res, 410, 'closed', 'This share was closed', `OpenVibe closed ${share.host} after a report.`, { link: { href: config.baseUrl, text: 'What is OpenVibe Connect?' } }); return null; }
if (share.status !== 'active') { sendProblem(req, res, 410, 'closed', 'This share is closed', `${share.owner_username} closed ${share.host}.`, { link: { href: config.baseUrl, text: 'What is OpenVibe Connect?' } }); return null; }
if (share.status !== 'active') { sendProblem(req, res, 410, 'closed', 'This share is closed', `${share.owner_username} closed ${share.host}.`, { link: { href: config.baseUrl, text: 'What is OpenVibe Connect?' } }); return null; }
- return share;
+ const canonicalHost = share.host;
+ if (config.shares.redirectLegacy && parsed.host !== canonicalHost) {
+ // Build from a validated host; preserve the original path and query, never its authority.
+ res.writeHead(302, { location: `${config.shares.scheme}://${canonicalHost}${String(req.url || '/')}`, 'cache-control': 'no-store', 'cloudflare-cdn-cache-control': 'no-store', 'referrer-policy': 'no-referrer' });
+ res.end();
+ return null;
+ }
+ return { ...share, canonicalHost, host: parsed.host, url: `${config.shares.scheme}://${parsed.host}` };
}
}
async function handle(req, res) {
async function handle(req, res) {
@@ -273,9 +286,9 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nu
@@ -273,9 +286,9 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nu
if (path === '/__ovc/callback') {
if (path === '/__ovc/callback') {
const q = new URL(url, 'http://x').searchParams;
const q = new URL(url, 'http://x').searchParams;
- const v = gate.redeemCode(q.get('code'), share.id);
+ const v = gate.redeemCode(q.get('code'), share.id, share.host);
if (!v) return sendPage(res, 400, 'That link ran out', 'Sign-in links work once, for a minute.', { link: { href: grantUrl(share, q.get('next') || '/'), text: 'Try again' } });
if (!v) return sendPage(res, 400, 'That link ran out', 'Sign-in links work once, for a minute.', { link: { href: grantUrl(share, q.get('next') || '/'), text: 'Try again' } });
- const cookie = gate.sign({ shareId: share.id, subject: v.subject, username: v.username, policyVersion: share.policy_version });
+ const cookie = gate.sign({ shareId: share.id, subject: v.subject, username: v.username, policyVersion: share.policy_version, host: share.host });
res.writeHead(303, { location: gate.safeNext(q.get('next')), 'set-cookie': gate.cookieHeader(cookie), 'cache-control': 'no-store', 'cloudflare-cdn-cache-control': 'no-store', 'referrer-policy': 'no-referrer' });
res.writeHead(303, { location: gate.safeNext(q.get('next')), 'set-cookie': gate.cookieHeader(cookie), 'cache-control': 'no-store', 'cloudflare-cdn-cache-control': 'no-store', 'referrer-policy': 'no-referrer' });
return res.end();
return res.end();
}
}
@@ -60,7 +60,7 @@ function createPageRoutes(ctx) {
@@ -60,7 +60,7 @@ function createPageRoutes(ctx) {
title: null,
title: null,
index: true,
index: true,
description: 'OpenVibe Connect: share one port on your computer at its own HTTPS address, behind OpenVibe sign-in. Only the people you pick can open it. One file, Node 22, no router settings.',
description: 'OpenVibe Connect: share one port on your computer at its own HTTPS address, behind OpenVibe sign-in. Only the people you pick can open it. One file, Node 22, no router settings.',
- body: views.home({ viewer: req.viewer, shares: list, connectedIds: connectedIds(list), stats: await homeStats(), baseUrl: config.baseUrl }),
+ body: views.home({ viewer: req.viewer, shares: list, connectedIds: connectedIds(list), stats: await homeStats(), domain: config.shares.domain, baseUrl: config.baseUrl }),
});
});
});
});
@@ -74,7 +74,7 @@ function createPageRoutes(ctx) {
@@ -74,7 +74,7 @@ function createPageRoutes(ctx) {
if (!(err instanceof ShareError)) throw err;
if (!(err instanceof ShareError)) throw err;
const list = await shares.list(req.viewer.subject);
const list = await shares.list(req.viewer.subject);
const form = { values: input, error: err.message, username: req.viewer.username };
const form = { values: input, error: err.message, username: req.viewer.username };
- return page(req, res, { title: 'Make a share', body: views.home({ viewer: req.viewer, shares: list, connectedIds: connectedIds(list), stats: await homeStats(), baseUrl: config.baseUrl, form }) }, err.status || 422);
+ return page(req, res, { title: 'Make a share', body: views.home({ viewer: req.viewer, shares: list, connectedIds: connectedIds(list), stats: await homeStats(), domain: config.shares.domain, baseUrl: config.baseUrl, form }) }, err.status || 422);
}
}
});
});
@@ -238,7 +238,13 @@ function createPageRoutes(ctx) {
@@ -238,7 +238,13 @@ function createPageRoutes(ctx) {
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
const id = typeof req.query.share === 'string' ? req.query.share.slice(0, 64) : '';
const id = typeof req.query.share === 'string' ? req.query.share.slice(0, 64) : '';
const next = gate.safeNext(typeof req.query.next === 'string' ? req.query.next : '/');
const next = gate.safeNext(typeof req.query.next === 'string' ? req.query.next : '/');
- const share = id ? await shares.get(id) : null;
+ let share = id ? await shares.get(id) : null;
+ if (share && req.query.host != null) {
+ const host = typeof req.query.host === 'string' ? req.query.host.toLowerCase() : '';
+ const alias = await shares.byHost(host);
+ if (!alias || alias.id !== share.id || !/^[a-z0-9.-]+$/.test(host)) return res.sendStatus(400);
+ if (!config.shares.redirectLegacy) share = { ...share, host, url: `${config.shares.scheme}://${host}` };
+ }
if (!share || share.status !== 'active') {
if (!share || share.status !== 'active') {
return page(req, res, { title: 'No such share', body: views.message({ title: 'No such share', text: share ? `${share.host} is closed.` : 'That share does not exist.', link: { href: '/', label: 'OpenVibe Connect' } }) }, share ? 410 : 404);
return page(req, res, { title: 'No such share', body: views.message({ title: 'No such share', text: share ? `${share.host} is closed.` : 'That share does not exist.', link: { href: '/', label: 'OpenVibe Connect' } }) }, share ? 410 : 404);
}
}
@@ -247,7 +253,7 @@ function createPageRoutes(ctx) {
@@ -247,7 +253,7 @@ function createPageRoutes(ctx) {
proxy.denied(share, req.viewer);
proxy.denied(share, req.viewer);
return page(req, res, { title: 'Not for you', body: views.grantDenied({ share, viewer: req.viewer }) }, 403);
return page(req, res, { title: 'Not for you', body: views.grantDenied({ share, viewer: req.viewer }) }, 403);
}
}
- const code = gate.mintCode({ shareId: share.id, subject: req.viewer.subject, username: req.viewer.username });
+ const code = gate.mintCode({ shareId: share.id, subject: req.viewer.subject, username: req.viewer.username, host: share.host });
res.set('Referrer-Policy', 'no-referrer');
res.set('Referrer-Policy', 'no-referrer');
return res.redirect(303, `${share.url}/__ovc/callback?code=${encodeURIComponent(code)}&next=${encodeURIComponent(next)}`);
return res.redirect(303, `${share.url}/__ovc/callback?code=${encodeURIComponent(code)}&next=${encodeURIComponent(next)}`);
});
});
@@ -79,9 +79,9 @@ function createOAuth(s, { shares, scheme = 'https', now = () => Date.now() } = {
@@ -79,9 +79,9 @@ function createOAuth(s, { shares, scheme = 'https', now = () => Date.now() } = {
async function shareFor(resource) {
async function shareFor(resource) {
let u;
let u;
try { u = new URL(String(resource || '')); } catch { return null; }
try { u = new URL(String(resource || '')); } catch { return null; }
- if (u.protocol !== `${scheme}:`) return null; // https in production (CONNECT_SHARE_SCHEME)
+ if (u.protocol !== `${scheme}:` || u.port || u.username || u.password) return null; // https in production (CONNECT_SHARE_SCHEME)
const share = await shares.byHost(u.host.toLowerCase());
const share = await shares.byHost(u.host.toLowerCase());
- return share && share.status === 'active' ? share : null;
+ return share && share.status === 'active' ? { ...share, host: u.hostname, url: u.origin } : null;
}
}
/**
/**
@@ -102,17 +102,17 @@ function createOAuth(s, { shares, scheme = 'https', now = () => Date.now() } = {
@@ -102,17 +102,17 @@ function createOAuth(s, { shares, scheme = 'https', now = () => Date.now() } = {
function issueCode({ client: c, share, viewer, redirectUri, challenge }) {
function issueCode({ client: c, share, viewer, redirectUri, challenge }) {
sweep();
sweep();
const code = b64url(crypto.randomBytes(32));
const code = b64url(crypto.randomBytes(32));
- codes.set(code, { clientId: c.id, redirectUri, challenge, shareId: share.id, subject: viewer.subject, username: viewer.username || null, policyVersion: share.policy_version, exp: now() + CODE_TTL_MS });
+ codes.set(code, { clientId: c.id, redirectUri, challenge, shareId: share.id, host: share.host, subject: viewer.subject, username: viewer.username || null, policyVersion: share.policy_version, exp: now() + CODE_TTL_MS });
return code;
return code;
}
}
- async function mintTokens({ shareId, clientId, subject, username, policyVersion }) {
+ async function mintTokens({ shareId, clientId, subject, username, policyVersion, host }) {
const access = `ovat_${b64url(crypto.randomBytes(32))}`;
const access = `ovat_${b64url(crypto.randomBytes(32))}`;
const refresh = `ovrt_${b64url(crypto.randomBytes(32))}`;
const refresh = `ovrt_${b64url(crypto.randomBytes(32))}`;
const id = s.newId('sot');
const id = s.newId('sot');
- await db.exec(`INSERT INTO share_oauth_tokens (id, share_id, client_id, subject, username, policy_version, access_hash, access_expires_at, refresh_hash, refresh_expires_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
- [id, shareId, clientId, subject, username, policyVersion, sha256(access), new Date(now() + ACCESS_TTL_MS), sha256(refresh), new Date(now() + REFRESH_TTL_MS)]);
+ await db.exec(`INSERT INTO share_oauth_tokens (id, share_id, client_id, subject, username, policy_version, access_hash, access_expires_at, refresh_hash, refresh_expires_at, resource_host)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
+ [id, shareId, clientId, subject, username, policyVersion, sha256(access), new Date(now() + ACCESS_TTL_MS), sha256(refresh), new Date(now() + REFRESH_TTL_MS), host]);
db.exec('UPDATE oauth_clients SET last_used_at = now() WHERE id = $1', [clientId]).catch(() => {});
db.exec('UPDATE oauth_clients SET last_used_at = now() WHERE id = $1', [clientId]).catch(() => {});
return { access_token: access, token_type: 'Bearer', expires_in: Math.round(ACCESS_TTL_MS / 1000), refresh_token: refresh, scope: 'share' };
return { access_token: access, token_type: 'Bearer', expires_in: Math.round(ACCESS_TTL_MS / 1000), refresh_token: refresh, scope: 'share' };
}
}
@@ -127,7 +127,7 @@ function createOAuth(s, { shares, scheme = 'https', now = () => Date.now() } = {
@@ -127,7 +127,7 @@ function createOAuth(s, { shares, scheme = 'https', now = () => Date.now() } = {
if (b.client_id !== g.clientId || b.redirect_uri !== g.redirectUri) throw new OAuthError(400, 'invalid_grant', 'The code was given to another client or redirect_uri.');
if (b.client_id !== g.clientId || b.redirect_uri !== g.redirectUri) throw new OAuthError(400, 'invalid_grant', 'The code was given to another client or redirect_uri.');
const verifier = String(b.code_verifier || '');
const verifier = String(b.code_verifier || '');
if (!/^[A-Za-z0-9._~-]{43,128}$/.test(verifier) || b64url(crypto.createHash('sha256').update(verifier).digest()) !== g.challenge) throw new OAuthError(400, 'invalid_grant', 'The PKCE code_verifier does not match.');
if (!/^[A-Za-z0-9._~-]{43,128}$/.test(verifier) || b64url(crypto.createHash('sha256').update(verifier).digest()) !== g.challenge) throw new OAuthError(400, 'invalid_grant', 'The PKCE code_verifier does not match.');
- if (b.resource) { const share = await shareFor(b.resource); if (!share || share.id !== g.shareId) throw new OAuthError(400, 'invalid_target', 'resource is not the share the code was for'); }
+ if (b.resource) { const share = await shareFor(b.resource); if (!share || share.id !== g.shareId || share.host !== g.host) throw new OAuthError(400, 'invalid_target', 'resource is not the share the code was for'); }
return mintTokens(g);
return mintTokens(g);
}
}
if (b.grant_type === 'refresh_token') {
if (b.grant_type === 'refresh_token') {
@@ -143,9 +143,13 @@ function createOAuth(s, { shares, scheme = 'https', now = () => Date.now() } = {
@@ -143,9 +143,13 @@ function createOAuth(s, { shares, scheme = 'https', now = () => Date.now() } = {
await db.exec('UPDATE share_oauth_tokens SET revoked_at = now() WHERE id = $1', [r.id]);
await db.exec('UPDATE share_oauth_tokens SET revoked_at = now() WHERE id = $1', [r.id]);
throw new OAuthError(400, 'invalid_grant', 'The share changed who it lets in: sign in again.');
throw new OAuthError(400, 'invalid_grant', 'The share changed who it lets in: sign in again.');
}
}
+ if (b.resource) {
+ const resource = await shareFor(b.resource);
+ if (!resource || resource.id !== r.share_id || resource.host !== r.resource_host) throw new OAuthError(400, 'invalid_target', 'resource is not the host the refresh token was for');
+ }
// Rotation: this row ends, a new one starts; the old refresh token used again finds a revoked row.
// Rotation: this row ends, a new one starts; the old refresh token used again finds a revoked row.
await db.exec('UPDATE share_oauth_tokens SET revoked_at = now() WHERE id = $1', [r.id]);
await db.exec('UPDATE share_oauth_tokens SET revoked_at = now() WHERE id = $1', [r.id]);
- return mintTokens({ shareId: r.share_id, clientId: r.client_id, subject: r.subject, username: r.username, policyVersion: Number(r.policy_version) });
+ return mintTokens({ shareId: r.share_id, clientId: r.client_id, subject: r.subject, username: r.username, policyVersion: Number(r.policy_version), host: r.resource_host });
}
}
throw new OAuthError(400, 'unsupported_grant_type', 'grant_type is authorization_code or refresh_token');
throw new OAuthError(400, 'unsupported_grant_type', 'grant_type is authorization_code or refresh_token');
}
}
@@ -156,7 +160,7 @@ function createOAuth(s, { shares, scheme = 'https', now = () => Date.now() } = {
@@ -156,7 +160,7 @@ function createOAuth(s, { shares, scheme = 'https', now = () => Date.now() } = {
if (!share || share.status !== 'active' || !ACCESS_RE.test(String(accessToken || ''))) return null;
if (!share || share.status !== 'active' || !ACCESS_RE.test(String(accessToken || ''))) return null;
const r = await db.maybe(`SELECT t.*, c.name AS client_name FROM share_oauth_tokens t JOIN oauth_clients c ON c.id = t.client_id
const r = await db.maybe(`SELECT t.*, c.name AS client_name FROM share_oauth_tokens t JOIN oauth_clients c ON c.id = t.client_id
WHERE t.access_hash = $1 AND t.share_id = $2 AND t.revoked_at IS NULL`, [sha256(accessToken), share.id]);
WHERE t.access_hash = $1 AND t.share_id = $2 AND t.revoked_at IS NULL`, [sha256(accessToken), share.id]);
- if (!r || new Date(r.access_expires_at).getTime() <= now() || Number(r.policy_version) !== share.policy_version) return null;
+ if (!r || r.resource_host !== share.host || new Date(r.access_expires_at).getTime() <= now() || Number(r.policy_version) !== share.policy_version) return null;
if (!shares.allows(share, { subject: r.subject, username: r.username })) return null;
if (!shares.allows(share, { subject: r.subject, username: r.username })) return null;
const t = now();
const t = now();
if (!touched.has(r.id) || t - touched.get(r.id) > 60_000) {
if (!touched.has(r.id) || t - touched.get(r.id) > 60_000) {
@@ -88,6 +88,7 @@ function hostFor(label, username, domain) {
@@ -88,6 +88,7 @@ function hostFor(label, username, domain) {
/** The share label in a host, or null: "agents--alex.openvibe.website" → { label: 'agents', handle: 'alex' }. */
/** The share label in a host, or null: "agents--alex.openvibe.website" → { label: 'agents', handle: 'alex' }. */
function parseHost(host, domain) {
function parseHost(host, domain) {
+ if (Array.isArray(domain)) return domain.map((d) => parseHost(host, d)).find(Boolean) || null;
const h = String(host || '').toLowerCase().replace(/:\d+$/, '');
const h = String(host || '').toLowerCase().replace(/:\d+$/, '');
const suffix = `.${domain}`;
const suffix = `.${domain}`;
if (!h.endsWith(suffix)) return null;
if (!h.endsWith(suffix)) return null;
@@ -132,10 +133,17 @@ function createShares(s, { config }) {
@@ -132,10 +133,17 @@ function createShares(s, { config }) {
const { db } = s;
const { db } = s;
const domain = config.shares.domain;
const domain = config.shares.domain;
const scheme = config.shares.scheme;
const scheme = config.shares.scheme;
+ const domains = config.shares.domains || [domain];
+ const aliases = (host) => {
+ const p = parseHost(host, domains);
+ return p ? domains.map((d) => `${p.label}--${p.handle}.${d}`) : [];
+ };
const urlOf = (row) => `${scheme}://${row.host}`;
const urlOf = (row) => `${scheme}://${row.host}`;
function view(row) {
function view(row) {
if (!row) return null;
if (!row) return null;
+ const parsed = parseHost(row.host, domains);
+ if (parsed) row = { ...row, host: `${parsed.label}--${parsed.handle}.${domain}` };
return {
return {
id: row.id,
id: row.id,
owner_subject: row.owner_subject,
owner_subject: row.owner_subject,
@@ -175,7 +183,7 @@ function createShares(s, { config }) {
@@ -175,7 +183,7 @@ function createShares(s, { config }) {
}
}
async function get(id) { return view(await db.maybe('SELECT * FROM shares WHERE id = $1', [String(id || '')])); }
async function get(id) { return view(await db.maybe('SELECT * FROM shares WHERE id = $1', [String(id || '')])); }
async function byHost(host) {
async function byHost(host) {
- return view(await db.maybe("SELECT * FROM shares WHERE host = $1 ORDER BY status = 'active' DESC, created_at DESC LIMIT 1", [String(host || '').toLowerCase()]));
+ return view(await db.maybe("SELECT * FROM shares WHERE host = ANY($1::text[]) ORDER BY status = 'active' DESC, created_at DESC LIMIT 1", [aliases(host)]));
}
}
async function byKey(key) {
async function byKey(key) {
if (typeof key !== 'string' || !/^ovc_[A-Za-z0-9_-]{43}$/.test(key)) return null;
if (typeof key !== 'string' || !/^ovc_[A-Za-z0-9_-]{43}$/.test(key)) return null;
@@ -209,7 +217,7 @@ function createShares(s, { config }) {
@@ -209,7 +217,7 @@ function createShares(s, { config }) {
if (live.n >= config.shares.perPerson) throw new ShareError(409, 'share.limit', `You have ${live.n} shares open, the most for now. Close one first.`);
if (live.n >= config.shares.perPerson) throw new ShareError(409, 'share.limit', `You have ${live.n} shares open, the most for now. Close one first.`);
// A host someone else used stays theirs (a revoked share holds its address, so nobody else can serve on links
// A host someone else used stays theirs (a revoked share holds its address, so nobody else can serve on links
// that still point there). The owner may reuse their own.
// that still point there). The owner may reuse their own.
- const taken = await db.maybe('SELECT owner_subject, status FROM shares WHERE host = $1 ORDER BY created_at DESC LIMIT 1', [host]);
+ const taken = await db.maybe('SELECT owner_subject, status FROM shares WHERE host = ANY($1::text[]) ORDER BY created_at DESC LIMIT 1', [aliases(host)]);
if (taken && (taken.status === 'active' || taken.owner_subject !== owner.subject)) {
if (taken && (taken.status === 'active' || taken.owner_subject !== owner.subject)) {
throw new ShareError(409, 'share.taken', taken.owner_subject === owner.subject ? 'You already have a share with that name.' : 'That address is taken. Pick another name.');
throw new ShareError(409, 'share.taken', taken.owner_subject === owner.subject ? 'You already have a share with that name.' : 'That address is taken. Pick another name.');
}
}
@@ -243,7 +251,7 @@ function createShares(s, { config }) {
@@ -243,7 +251,7 @@ function createShares(s, { config }) {
const policyChanged = mode !== share.mode || JSON.stringify(named) !== JSON.stringify(share.named);
const policyChanged = mode !== share.mode || JSON.stringify(named) !== JSON.stringify(share.named);
// offline_site: undefined keeps it; '' / null clears it (not a policy change: nobody is signed out).
// offline_site: undefined keeps it; '' / null clears it (not a policy change: nobody is signed out).
const keepOffline = input.offline_site === undefined;
const keepOffline = input.offline_site === undefined;
- const offline = keepOffline ? null : offlineSite(input.offline_site, { domain, scheme });
+ const offline = keepOffline ? null : offlineSite(input.offline_site, { domain: domain === 'openvibe.link' ? 'openvibe.website' : domain, scheme });
// expires_in: undefined keeps the time it has; null / '' / 'never' takes it away; seconds set it from now.
// expires_in: undefined keeps the time it has; null / '' / 'never' takes it away; seconds set it from now.
const keepExpiry = input.expires_in === undefined || input.expires_in === 'keep';
const keepExpiry = input.expires_in === undefined || input.expires_in === 'keep';
const ttl = keepExpiry ? null : expiresIn(input.expires_in);
const ttl = keepExpiry ? null : expiresIn(input.expires_in);
@@ -302,7 +310,7 @@ function createShares(s, { config }) {
@@ -302,7 +310,7 @@ function createShares(s, { config }) {
// ── Reports (CON-9 v0) ──────────────────────────────────────────────────────────────────────────────────────
// ── Reports (CON-9 v0) ──────────────────────────────────────────────────────────────────────────────────────
/** Anyone reports a share by its address. Returns { id, share }. */
/** Anyone reports a share by its address. Returns { id, share }. */
async function report({ host, reason, details, reporter = null }) {
async function report({ host, reason, details, reporter = null }) {
- const parsed = parseHost(String(host || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, ''), domain);
+ const parsed = parseHost(String(host || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, ''), domains);
const share = parsed ? await byHost(parsed.host) : null;
const share = parsed ? await byHost(parsed.host) : null;
if (!share) throw new ShareError(404, 'share.not_found', 'There is no share at that address. Check it, or paste the whole address.');
if (!share) throw new ShareError(404, 'share.not_found', 'There is no share at that address. Check it, or paste the whole address.');
if (!REPORT_REASONS[reason]) throw new ShareError(422, 'report.invalid_reason', 'Pick what the report is about.');
if (!REPORT_REASONS[reason]) throw new ShareError(422, 'report.invalid_reason', 'Pick what the report is about.');
@@ -34,11 +34,11 @@ function ago(iso, now = Date.now()) {
@@ -34,11 +34,11 @@ function ago(iso, now = Date.now()) {
}
}
/** The hero's picture: a port on a laptop, a line through OpenVibe, an address on a phone with a lock. */
/** The hero's picture: a port on a laptop, a line through OpenVibe, an address on a phone with a lock. */
-function motif() {
+function motif(domain) {
return `<div class="cn-motif" aria-hidden="true">
return `<div class="cn-motif" aria-hidden="true">
<div class="cn-m-box cn-m-pc"><span class="cn-m-k">your computer</span><b>localhost:8080</b><span class="cn-m-dot"></span></div>
<div class="cn-m-box cn-m-pc"><span class="cn-m-k">your computer</span><b>localhost:8080</b><span class="cn-m-dot"></span></div>
<div class="cn-m-line"><i></i><span>one outgoing connection</span></div>
<div class="cn-m-line"><i></i><span>one outgoing connection</span></div>
-<div class="cn-m-box cn-m-phone"><span class="cn-m-k">anywhere</span><b>agents--you.openvibe.website</b><span class="cn-m-lock">${kit.icon('lock')} signed in as you</span></div>
+<div class="cn-m-box cn-m-phone"><span class="cn-m-k">anywhere</span><b>agents--you.${esc(domain)}</b><span class="cn-m-lock">${kit.icon('lock')} signed in as you</span></div>
</div>`;
</div>`;
}
}
@@ -46,8 +46,8 @@ function terminal(lines) {
@@ -46,8 +46,8 @@ function terminal(lines) {
return `<div class="cn-term" role="img" aria-label="A terminal running ov-connect"><div class="cn-term-bar"><i></i><i></i><i></i></div><pre>${lines.map((l) => (l.startsWith('$ ') ? `<span class="cn-term-cmd">${esc(l)}</span>` : `<span class="cn-term-out">${esc(l)}</span>`)).join('\n')}</pre></div>`;
return `<div class="cn-term" role="img" aria-label="A terminal running ov-connect"><div class="cn-term-bar"><i></i><i></i><i></i></div><pre>${lines.map((l) => (l.startsWith('$ ') ? `<span class="cn-term-cmd">${esc(l)}</span>` : `<span class="cn-term-out">${esc(l)}</span>`)).join('\n')}</pre></div>`;
}
}
-function phone() {
- return `<div class="cn-phone" role="img" aria-label="A phone opening a share"><div class="cn-phone-url">${kit.icon('lock')}<span>agents--you.openvibe.website</span></div>
+function phone(domain) {
+ return `<div class="cn-phone" role="img" aria-label="A phone opening a share"><div class="cn-phone-url">${kit.icon('lock')}<span>agents--you.${esc(domain)}</span></div>
<div class="cn-phone-body"><b>Your agent portal</b><span>Runs on your desk. Open here, over 5G.</span><span class="cn-phone-pill">Signed in as you</span></div></div>`;
<div class="cn-phone-body"><b>Your agent portal</b><span>Runs on your desk. Open here, over 5G.</span><span class="cn-phone-pill">Signed in as you</span></div></div>`;
}
}
@@ -94,13 +94,13 @@ function closesField(current, { keep = null } = {}) {
@@ -94,13 +94,13 @@ function closesField(current, { keep = null } = {}) {
return `<label class="cn-field cn-field-closes"><span>Closes by itself</span><select name="expires_in">${opts.map(([v, t]) => `<option value="${esc(v)}"${String(current) === v ? ' selected' : ''}>${esc(t)}</option>`).join('')}</select><small>The address answers "ended" after that, and the computer is disconnected.</small></label>`;
return `<label class="cn-field cn-field-closes"><span>Closes by itself</span><select name="expires_in">${opts.map(([v, t]) => `<option value="${esc(v)}"${String(current) === v ? ' selected' : ''}>${esc(t)}</option>`).join('')}</select><small>The address answers "ended" after that, and the computer is disconnected.</small></label>`;
}
}
-function createForm({ values = {}, error = null, username = '', staff = false } = {}) {
+function createForm({ values = {}, error = null, username = '', staff = false, domain = 'openvibe.link' } = {}) {
const handle = String(username || 'you').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'you';
const handle = String(username || 'you').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'you';
return `<form class="cn-form" method="post" action="/shares" id="new">
return `<form class="cn-form" method="post" action="/shares" id="new">
<h2>Make a share</h2>
<h2>Make a share</h2>
${error ? `<p class="cn-error" role="alert">${esc(error)}</p>` : ''}
${error ? `<p class="cn-error" role="alert">${esc(error)}</p>` : ''}
<div class="cn-fields">
<div class="cn-fields">
-<label class="cn-field"><span>Name</span><span class="cn-addr"><input name="label" required maxlength="40" pattern="[a-z0-9]([a-z0-9]|-(?=[a-z0-9])){0,39}" value="${esc(values.label || '')}" placeholder="agents" autocapitalize="none" autocomplete="off" spellcheck="false"><span class="cn-addr-tail">--${esc(handle)}.openvibe.website</span></span><small>a–z, 0–9 and hyphens</small></label>
+<label class="cn-field"><span>Name</span><span class="cn-addr"><input name="label" required maxlength="40" pattern="[a-z0-9]([a-z0-9]|-(?=[a-z0-9])){0,39}" value="${esc(values.label || '')}" placeholder="agents" autocapitalize="none" autocomplete="off" spellcheck="false"><span class="cn-addr-tail">--${esc(handle)}.${esc(domain)}</span></span><small>a–z, 0–9 and hyphens</small></label>
<label class="cn-field cn-field-port"><span>Port on your computer</span><input name="port" required inputmode="numeric" pattern="[0-9]{1,5}" maxlength="5" value="${esc(values.port || '')}" placeholder="8080"></label>
<label class="cn-field cn-field-port"><span>Port on your computer</span><input name="port" required inputmode="numeric" pattern="[0-9]{1,5}" maxlength="5" value="${esc(values.port || '')}" placeholder="8080"></label>
</div>
</div>
${closesField(values.expires_in == null ? '' : values.expires_in)}
${closesField(values.expires_in == null ? '' : values.expires_in)}
@@ -113,7 +113,7 @@ ${modeChoices(values.mode || 'owner', staff)}
@@ -113,7 +113,7 @@ ${modeChoices(values.mode || 'owner', staff)}
</form>`;
</form>`;
}
}
-function yourShares({ viewer, shares, connectedIds, form }) {
+function yourShares({ viewer, shares, connectedIds, form, domain }) {
if (!viewer || viewer.kind !== 'user') return '';
if (!viewer || viewer.kind !== 'user') return '';
const open = shares.filter((s) => s.status === 'active');
const open = shares.filter((s) => s.status === 'active');
const closed = shares.filter((s) => s.status !== 'active');
const closed = shares.filter((s) => s.status !== 'active');
@@ -121,18 +121,18 @@ function yourShares({ viewer, shares, connectedIds, form }) {
@@ -121,18 +121,18 @@ function yourShares({ viewer, shares, connectedIds, form }) {
? `<ul class="cn-shares">${open.map((s) => shareRow(s, { connected: connectedIds.has(s.id) })).join('')}</ul>`
? `<ul class="cn-shares">${open.map((s) => shareRow(s, { connected: connectedIds.has(s.id) })).join('')}</ul>`
: '<p class="cn-empty">No shares yet. Make one below: a name, a port, who can open it.</p>';
: '<p class="cn-empty">No shares yet. Make one below: a name, a port, who can open it.</p>';
const closedList = closed.length ? `<details class="cn-closed"><summary>Closed shares (${closed.length})</summary><ul class="cn-shares">${closed.map((s) => shareRow(s, { connected: false })).join('')}</ul></details>` : '';
const closedList = closed.length ? `<details class="cn-closed"><summary>Closed shares (${closed.length})</summary><ul class="cn-shares">${closed.map((s) => shareRow(s, { connected: false })).join('')}</ul></details>` : '';
- return `<section class="cn-yours" aria-labelledby="cn-yours-h"><h2 id="cn-yours-h">Your shares</h2>${list}${closedList}${createForm({ username: viewer.username, ...(form || {}), staff: viewer.role === 'admin' })}</section>`;
+ return `<section class="cn-yours" aria-labelledby="cn-yours-h"><h2 id="cn-yours-h">Your shares</h2>${list}${closedList}${createForm({ domain, username: viewer.username, ...(form || {}), staff: viewer.role === 'admin' })}</section>`;
}
}
-function home({ viewer, shares = [], connectedIds = new Set(), stats = {}, baseUrl, form = null }) {
+function home({ viewer, shares = [], connectedIds = new Set(), stats = {}, baseUrl, form = null, domain = 'openvibe.link' }) {
const signedIn = viewer && viewer.kind === 'user';
const signedIn = viewer && viewer.kind === 'user';
const body = sc.room({
const body = sc.room({
room: 'website',
room: 'website',
hero: {
hero: {
title: 'Your computer, on the web, privately',
title: 'Your computer, on the web, privately',
- lede: 'Share one port on your computer at its own address, like agents--you.openvibe.website. Only the people you pick can open it, signed in with OpenVibe. No router settings, no open ports, no app to install: one file and Node.',
+ lede: `Share one port on your computer at its own address, like agents--you.${esc(domain)}. Only the people you pick can open it, signed in with OpenVibe. No router settings, no open ports, no app to install: one file and Node.`,
start: signedIn ? { href: '#new', label: 'Make a share' } : { href: '/auth/login?next=%2F%23new', label: 'Sign in to make a share' },
start: signedIn ? { href: '#new', label: 'Make a share' } : { href: '/auth/login?next=%2F%23new', label: 'Sign in to make a share' },
- motif: motif(),
+ motif: motif(domain),
},
},
proof: [
proof: [
{ value: stats.connected, label: stats.connected === 1 ? 'share connected now' : 'shares connected now', live: true },
{ value: stats.connected, label: stats.connected === 1 ? 'share connected now' : 'shares connected now', live: true },
@@ -145,7 +145,7 @@ function home({ viewer, shares = [], connectedIds = new Set(), stats = {}, baseU
@@ -145,7 +145,7 @@ function home({ viewer, shares = [], connectedIds = new Set(), stats = {}, baseU
html: terminal([
html: terminal([
`$ curl -fsSLO ${CLIENT_URL(baseUrl)}`,
`$ curl -fsSLO ${CLIENT_URL(baseUrl)}`,
'$ node ov-connect.js --key ovc_… --port 8080',
'$ node ov-connect.js --key ovc_… --port 8080',
- 'Connected. https://agents--you.openvibe.website → localhost:8080',
+ `Connected. https://agents--you.${domain} → localhost:8080`,
'GET / → 200 (14 ms)',
'GET / → 200 (14 ms)',
]),
]),
action: signedIn ? { href: '#new', label: 'Make a share' } : { href: '/auth/login?next=%2F%23new', label: 'Sign in to start' },
action: signedIn ? { href: '#new', label: 'Make a share' } : { href: '/auth/login?next=%2F%23new', label: 'Sign in to start' },
@@ -153,7 +153,7 @@ function home({ viewer, shares = [], connectedIds = new Set(), stats = {}, baseU
@@ -153,7 +153,7 @@ function home({ viewer, shares = [], connectedIds = new Set(), stats = {}, baseU
{
{
title: 'Open it anywhere, signed in',
title: 'Open it anywhere, signed in',
text: 'Your agent portal, a dev server, a home dashboard: open its address on your phone over 5G. It is real HTTPS, so the microphone, the camera and WebSockets work. Live streams (server-sent events) stream.',
text: 'Your agent portal, a dev server, a home dashboard: open its address on your phone over 5G. It is real HTTPS, so the microphone, the camera and WebSockets work. Live streams (server-sent events) stream.',
- html: phone(),
+ html: phone(domain),
},
},
{
{
title: 'You pick who gets in',
title: 'You pick who gets in',
@@ -184,7 +184,7 @@ function home({ viewer, shares = [], connectedIds = new Set(), stats = {}, baseU
@@ -184,7 +184,7 @@ function home({ viewer, shares = [], connectedIds = new Set(), stats = {}, baseU
network: {
network: {
service: 'website',
service: 'website',
title: 'Part of OpenVibe',
title: 'Part of OpenVibe',
- lede: 'Connect is the "your machine" half of openvibe.website. The other half builds and hosts sites.',
+ lede: 'Connect shares a port from your machine through OpenVibe Link. Space builds and hosts sites.',
items: [
items: [
{ to: 'space', what: 'Build and host a site at <name>.openvibe.website', href: 'https://openvibe.space' },
{ to: 'space', what: 'Build and host a site at <name>.openvibe.website', href: 'https://openvibe.space' },
{ to: 'network', what: 'The sign-in your visitors use', href: 'https://openvibe.network' },
{ to: 'network', what: 'The sign-in your visitors use', href: 'https://openvibe.network' },
@@ -192,7 +192,7 @@ function home({ viewer, shares = [], connectedIds = new Set(), stats = {}, baseU
@@ -192,7 +192,7 @@ function home({ viewer, shares = [], connectedIds = new Set(), stats = {}, baseU
],
],
},
},
});
});
- const yours = yourShares({ viewer, shares, connectedIds, form });
+ const yours = yourShares({ viewer, shares, connectedIds, form, domain });
const at = body.indexOf('<section class="sc-room-scenes"');
const at = body.indexOf('<section class="sc-room-scenes"');
return `<div class="cn-wrap">${at < 0 ? body + yours : body.slice(0, at) + yours + body.slice(at)}</div>`;
return `<div class="cn-wrap">${at < 0 ? body + yours : body.slice(0, at) + yours + body.slice(at)}</div>`;
}
}
@@ -428,7 +428,7 @@ function reportForm({ host = '', values = {}, error = null, reasons = {}, signed
@@ -428,7 +428,7 @@ function reportForm({ host = '', values = {}, error = null, reasons = {}, signed
<p>Tell OpenVibe about a share that tricks people, spreads harm or breaks the law. Staff read every report and can close the share. Its owner is not told who reported it.</p></header>
<p>Tell OpenVibe about a share that tricks people, spreads harm or breaks the law. Staff read every report and can close the share. Its owner is not told who reported it.</p></header>
${error ? `<p class="cn-error" role="alert">${esc(error)}</p>` : ''}
${error ? `<p class="cn-error" role="alert">${esc(error)}</p>` : ''}
<form class="cn-form" method="post" action="/report">
<form class="cn-form" method="post" action="/report">
-<label class="cn-field"><span>The share's address</span><input name="host" required maxlength="200" value="${esc(values.host || host)}" placeholder="name--someone.openvibe.website" autocapitalize="none" autocomplete="off" spellcheck="false"></label>
+<label class="cn-field"><span>The share's address</span><input name="host" required maxlength="200" value="${esc(values.host || host)}" placeholder="name--someone.openvibe.link" autocapitalize="none" autocomplete="off" spellcheck="false"></label>
<fieldset class="cn-modes"><legend>What is wrong</legend>
<fieldset class="cn-modes"><legend>What is wrong</legend>
${Object.entries(reasons).map(([k, v]) => `<label><input type="radio" name="reason" value="${esc(k)}"${picked === k ? ' checked' : ''} required> <span>${esc(v)}</span></label>`).join('\n')}
${Object.entries(reasons).map(([k, v]) => `<label><input type="radio" name="reason" value="${esc(k)}"${picked === k ? ' checked' : ''} required> <span>${esc(v)}</span></label>`).join('\n')}
</fieldset>
</fieldset>
@@ -30,7 +30,7 @@ async function boot(opts = {}) {
@@ -30,7 +30,7 @@ async function boot(opts = {}) {
OV_OAUTH_CLIENT_ID: 'connect', OV_OAUTH_CLIENT_SECRET: 'connect-secret', COOKIE_SECURE: 'false',
OV_OAUTH_CLIENT_ID: 'connect', OV_OAUTH_CLIENT_SECRET: 'connect-secret', COOKIE_SECURE: 'false',
CONNECT_DRILL: '1',
CONNECT_DRILL: '1',
CONNECT_EVENTS_URL: network.url,
CONNECT_EVENTS_URL: network.url,
- CONNECT_SHARE_SCHEME: 'https',
+ CONNECT_SHARE_SCHEME: 'https', CONNECT_SHARE_DOMAIN: 'openvibe.website',
CONNECT_SESSION_SECRET: 'test-session-secret-0123456789abcdef0123',
CONNECT_SESSION_SECRET: 'test-session-secret-0123456789abcdef0123',
CONNECT_PING_MS: '60000',
CONNECT_PING_MS: '60000',
...(opts.env || {}),
...(opts.env || {}),
new file mode 100644
new file mode 100644
@@ -0,0 +1,151 @@
@@ -0,0 +1,151 @@
+'use strict';
+const assert = require('node:assert/strict');
+const net = require('node:net');
+const crypto = require('node:crypto');
+const { boot, startLocalApp } = require('./helpers/boot');
+const { check, done } = require('./helpers/check');
+
+function upgrade(t, host, cookie) {
+ return new Promise((resolve, reject) => {
+ const socket = net.connect(t.port, '127.0.0.1');
+ let data = '';
+ socket.setTimeout(3000, () => socket.destroy(new Error('upgrade timed out')));
+ socket.on('error', reject);
+ socket.on('connect', () => socket.write(`GET /ws?keep=1 HTTP/1.1\r\nHost: ${host}\r\nConnection: Upgrade\r\nUpgrade: echo\r\nCookie: ${cookie}\r\n\r\n`));
+ socket.on('data', (chunk) => { data += chunk.toString(); if (data.includes('\r\n\r\n')) { socket.destroy(); resolve(data); } });
+ });
+}
+
+(async () => {
+ const t = await boot({ env: { CONNECT_SHARE_DOMAIN: null, COOKIE_SECURE: 'true' } });
+ const app = await startLocalApp();
+ const alex = t.network.addUser('alex');
+ let made, old, cookies = {};
+ try {
+ await check('new shares advertise Link; pre-existing Website rows keep their ID, key and both names', async () => {
+ made = await t.makeShare(alex, { label: 'portal', port: app.port });
+ assert.equal(made.share.host, 'portal--alex.openvibe.link');
+ old = made.share.host.replace('.link', '.website');
+ await t.ctx.s.db.exec('UPDATE shares SET host = $1 WHERE id = $2', [old, made.share.id]);
+ for (const host of [old, made.share.host]) assert.equal((await t.ctx.shares.byHost(host)).id, made.share.id);
+ assert.equal((await t.ctx.shares.byKey(made.key)).url, made.share.url);
+ assert.equal((await t.get('/api/v1/shares', { as: alex })).json().shares[0].host, made.share.host);
+ const duplicate = await t.get('/api/v1/shares', { as: alex, json: { label: 'portal', port: app.port } });
+ assert.equal(duplicate.status, 409);
+ const c = t.client({ key: made.key, port: app.port });
+ await c.ready();
+ });
+ await check('each alias uses Network sign-in and receives a Secure host-only cookie, bound to that host', async () => {
+ for (const host of [made.share.host, old]) {
+ const gate = await t.share(host, '/deep/path?q=1');
+ assert.equal(gate.status, 302);
+ const g = new URL(gate.headers.location);
+ const grant = await t.get(g.pathname + g.search, { as: alex });
+ const cb = new URL(grant.headers.get('location'));
+ assert.equal(cb.hostname, host);
+ const entered = await t.share(host, cb.pathname + cb.search);
+ assert.equal(entered.status, 303);
+ assert.equal(entered.headers.location, '/deep/path?q=1');
+ const cookie = entered.headers['set-cookie'][0];
+ assert.match(cookie, /^__Host-ovc=/); assert.match(cookie, /; Secure/); assert.doesNotMatch(cookie, /domain=/i);
+ cookies[host] = cookie.split(';')[0];
+ assert.equal((await t.share(host, '/hello', { cookie: cookies[host] })).status, 200);
+ }
+ assert.equal((await t.share(old, '/', { cookie: cookies[made.share.host] })).status, 302);
+ assert.equal((await t.share(made.share.host, '/', { cookie: cookies[old] })).status, 302);
+ const other = await t.makeShare(alex, { label: 'other', port: app.port });
+ assert.equal((await t.share(other.share.host, '/', { cookie: cookies[made.share.host] })).status, 302);
+ });
+ await check('grant codes cannot cross aliases and arbitrary return hosts are refused', async () => {
+ const grant = await t.get(`/grant?share=${made.share.id}`, { as: alex });
+ const cb = new URL(grant.headers.get('location'));
+ assert.equal((await t.share(old, cb.pathname + cb.search)).status, 400);
+ assert.equal((await t.share(made.share.host, cb.pathname + cb.search)).status, 400, 'attempt spends code');
+ assert.equal((await t.get(`/grant?share=${made.share.id}&host=evil.example`, { as: alex })).status, 400);
+ });
+ await check('HTTP, SSE and WebSocket upgrades use the same tunnel on both hosts', async () => {
+ for (const host of [made.share.host, old]) {
+ const cookie = cookies[host];
+ const post = await t.share(host, '/echo?x=1', { cookie, method: 'POST', body: 'hello' });
+ assert.equal(post.json().body, 'hello');
+ assert.equal(app.seen.at(-1).headers['x-forwarded-host'], host);
+ assert.match((await t.share(host, '/sse', { cookie })).text, /tick 3/);
+ assert.match(await upgrade(t, host, cookie), /^HTTP\/1.1 101/);
+ const response = await t.share(host, '/cookie', { cookie });
+ assert.doesNotMatch(response.headers['set-cookie'].join(';'), /domain=|__Host-ovc=/i);
+ assert.equal(response.headers['cloudflare-cdn-cache-control'], 'no-store');
+ assert.match(response.headers['content-security-policy'], /frame-ancestors 'none'/);
+ assert.equal(response.headers['x-frame-options'], 'DENY');
+ }
+ });
+ await check('MCP metadata and bearer-token audience stay on the requesting share host', async () => {
+ const oauth = t.ctx.oauth;
+ const client = await oauth.register({ client_name: 'test', redirect_uris: ['http://localhost/callback'] });
+ const verifier = 'a'.repeat(43);
+ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
+ const share = await oauth.shareFor(`https://${old}`);
+ const c = await oauth.client(client.client_id);
+ const code = oauth.issueCode({ client: c, share, viewer: alex, redirectUri: 'http://localhost/callback', challenge });
+ const token = await oauth.token({ grant_type: 'authorization_code', code, client_id: c.id, redirect_uri: 'http://localhost/callback', code_verifier: verifier });
+ assert.ok(await oauth.check({ ...made.share, host: old }, token.access_token));
+ assert.equal(await oauth.check(made.share, token.access_token), null);
+ assert.equal(await oauth.shareFor(`https://${old}:8443/`), null);
+ assert.equal(await oauth.shareFor(`https://person@${old}/`), null);
+ await assert.rejects(() => oauth.token({ grant_type: 'refresh_token', refresh_token: token.refresh_token, client_id: c.id, resource: made.share.url }), /resource/);
+ for (const host of [old, made.share.host]) assert.equal((await t.share(host, '/.well-known/oauth-protected-resource')).json().resource, `https://${host}`);
+ });
+ await check('both node audiences work; unrelated audiences do not', async () => {
+ const node = t.network.addNode(alex);
+ for (const aud of ['openvibe.website', 'openvibe.link']) assert.equal((await t.ctx.nodeAuth.verify(t.network.nodeToken(node, { aud }))).ok, true);
+ assert.equal((await t.ctx.nodeAuth.verify(t.network.nodeToken(node, { aud: 'openvibe.network' }))).ok, false);
+ });
+ await check('redirects are opt-in, preserve the path/query, and cover upgrades without forwarding cookies', async () => {
+ assert.equal(t.config.shares.redirectLegacy, false);
+ t.config.shares.redirectLegacy = true;
+ const r = await t.share(old, '/a%20b?x=1&y=2', { cookie: cookies[old] });
+ assert.equal(r.status, 302); assert.equal(r.headers.location, `${made.share.url}/a%20b?x=1&y=2`);
+ assert.equal(r.headers['set-cookie'], undefined);
+ assert.match(await upgrade(t, old, cookies[old]), /^HTTP\/1.1 302/);
+ assert.equal((await t.share(made.share.host, '/', { cookie: cookies[made.share.host] })).status, 200);
+ });
+ await check('revocation invalidates both aliases immediately', async () => {
+ t.config.shares.redirectLegacy = false;
+ assert.equal((await t.get(`/api/v1/shares/${made.share.id}`, { as: alex, method: 'DELETE' })).status, 200);
+ for (const host of [old, made.share.host]) assert.equal((await t.share(host, '/', { cookie: cookies[host] })).status, 410);
+ });
+ } finally { await t.close(); await app.close(); }
+ await check('new control origin uses its exact callback and host-only SSO; old control pages redirect', async () => {
+ const n = await boot({ env: { BASE_URL: 'https://connect.openvibe.link', CONNECT_SHARE_DOMAIN: null, COOKIE_SECURE: 'true' } });
+ try {
+ const user = n.network.addUser('kim');
+ const old = await n.share('connect.openvibe.website', '/auth/login?next=%2Fshares');
+ assert.equal(old.status, 302);
+ assert.equal(old.headers.location, 'https://connect.openvibe.link/auth/login?next=%2Fshares');
+ const login = await n.share('connect.openvibe.link', '/auth/login?next=%2Fshares');
+ const authorize = new URL(login.headers.location);
+ assert.equal(authorize.searchParams.get('redirect_uri'), 'https://connect.openvibe.link/auth/callback');
+ const code = n.network.issueCode(user, authorize.searchParams.get('code_challenge'));
+ const cookie = login.headers['set-cookie'].map(c => c.split(';')[0]).join('; ');
+ const callback = await n.share('connect.openvibe.link', `/auth/callback?code=${code}&state=${authorize.searchParams.get('state')}`, { cookie });
+ assert.equal(callback.status, 302);
+ assert.equal(callback.headers.location, '/shares');
+ assert.ok(callback.headers['set-cookie'].some(c => c.startsWith('ov_token=')));
+ for (const c of callback.headers['set-cookie']) assert.doesNotMatch(c, /domain=/i);
+ const share = await n.makeShare(user, { label: 'tunnel', port: 9000 });
+ for (const host of ['connect.openvibe.website', 'connect.openvibe.link']) {
+ const WebSocket = require('ws');
+ const ws = new WebSocket(`ws://127.0.0.1:${n.port}/tunnel`, { headers: { Host: host } });
+ try {
+ const ready = await new Promise((resolve, reject) => {
+ ws.on('error', reject);
+ ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 1, key: share.key, port: 9000 })));
+ ws.on('message', d => resolve(JSON.parse(String(d))));
+ });
+ assert.equal(ready.t, 'ready');
+ assert.equal(ready.share.host, share.share.host);
+ } finally { ws.terminate(); }
+ }
+ } finally { await n.close(); }
+ });
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
+'use strict';
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { execFileSync } = require('node:child_process');
+const src = fs.readFileSync(path.join(__dirname, '../deploy/nginx/connect.openvibe.website.conf'), 'utf8');
+for (const domain of ['website', 'link']) {
+ assert.ok(src.includes(`server_name connect.openvibe.${domain};`));
+ assert.ok(src.includes(`server_name ~^[a-z0-9-]+--[a-z0-9-]+\\.openvibe\\.${domain}$;`));
+ assert.ok(src.includes(`/etc/letsencrypt/live/openvibe.${domain}/fullchain.pem;`));
+}
+assert.equal((src.match(/location = \/tunnel/g) || []).length, 2);
+assert.equal((src.match(/proxy_request_buffering off/g) || []).length, 2);
+assert.equal((src.match(/limit_req_zone .*zone=connect_auth/g) || []).length, 1);
+assert.doesNotMatch(src, /server_name\s+\*\.openvibe\.link/, 'only share names go to Connect; apex stays Network');
+try { execFileSync('nginx', ['-v'], { stdio: 'pipe' }); } catch {
+ console.log('nginx-link: routing assertions passed; nginx syntax check skipped (nginx unavailable)');
+ process.exit(0);
+}
+const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'connect-nginx-'));
+try {
+ // nginx -t may run through a container wrapper as root; keep its empty scratch dirs removable by this test.
+ // The enclosing mkdtemp directory remains private (0700).
+ for (const name of ['client', 'proxy', 'fastcgi', 'uwsgi', 'scgi']) {
+ fs.mkdirSync(`${dir}/${name}_temp`);
+ fs.chmodSync(`${dir}/${name}_temp`, 0o777);
+ }
+ execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1', '-subj', '/CN=localhost', '-keyout', `${dir}/test.key`, '-out', `${dir}/test.pem`], { stdio: 'pipe' });
+ const config = src.replace(/include snippets\/security-txt\.conf;/g, '')
+ .replace(/listen (\[::\]:)?80;/g, (_, ip = '') => `listen ${ip}18080;`)
+ .replace(/listen (\[::\]:)?443 ssl;/g, (_, ip = '') => `listen ${ip}18443 ssl;`)
+ .replace(/ssl_certificate\s+[^;]+;/g, `ssl_certificate ${dir}/test.pem;`)
+ .replace(/ssl_certificate_key\s+[^;]+;/g, `ssl_certificate_key ${dir}/test.key;`)
+ .replace(/\/var\/log\/nginx\/[^ ;]+/g, '/dev/null');
+ fs.writeFileSync(`${dir}/nginx.conf`, `pid ${dir}/nginx.pid;\nerror_log /dev/null;\nevents {}\nhttp {\naccess_log /dev/null;\nclient_body_temp_path ${dir}/client_temp;\nproxy_temp_path ${dir}/proxy_temp;\nfastcgi_temp_path ${dir}/fastcgi_temp;\nuwsgi_temp_path ${dir}/uwsgi_temp;\nscgi_temp_path ${dir}/scgi_temp;\n${config}\n}\n`);
+ try {
+ execFileSync('nginx', ['-t', '-p', dir, '-c', `${dir}/nginx.conf`], { cwd: dir, stdio: 'pipe' });
+ } catch (error) {
+ throw new Error(`nginx -t: ${String(error.stderr || error.message)}`);
+ }
+ console.log('nginx-link: dual-host routing and nginx -t passed');
+} finally { fs.rmSync(dir, { recursive: true, force: true }); }
@@ -45,11 +45,11 @@ const { createGate } = require('../server/edge/gate');
@@ -45,11 +45,11 @@ const { createGate } = require('../server/edge/gate');
let now = 1_800_000_000_000;
let now = 1_800_000_000_000;
const config = { shares: { sessionSecret: 'x'.repeat(40), sessionHours: 12 }, cookies: { secure: true } };
const config = { shares: { sessionSecret: 'x'.repeat(40), sessionHours: 12 }, cookies: { secure: true } };
const gate = createGate({ config, now: () => now });
const gate = createGate({ config, now: () => now });
- const share = { id: 'shr_1', policy_version: 2 };
- const v = gate.sign({ shareId: 'shr_1', subject: 'usr_1', username: 'alex', policyVersion: 2 });
+ const share = { id: 'shr_1', policy_version: 2, host: 'agents--alex.openvibe.link' };
+ const v = gate.sign({ shareId: 'shr_1', subject: 'usr_1', username: 'alex', policyVersion: 2, host: share.host });
assert.deepStrictEqual(gate.verify(v, share), { subject: 'usr_1', username: 'alex' });
assert.deepStrictEqual(gate.verify(v, share), { subject: 'usr_1', username: 'alex' });
- assert.strictEqual(gate.verify(v, { id: 'shr_2', policy_version: 2 }), null);
- assert.strictEqual(gate.verify(v, { id: 'shr_1', policy_version: 3 }), null);
+ assert.strictEqual(gate.verify(v, { id: 'shr_2', policy_version: 2, host: 'agents--alex.openvibe.link' }), null);
+ assert.strictEqual(gate.verify(v, { id: 'shr_1', policy_version: 3, host: 'agents--alex.openvibe.link' }), null);
assert.strictEqual(gate.verify(`${v}x`, share), null);
assert.strictEqual(gate.verify(`${v}x`, share), null);
now += 13 * 3600_000;
now += 13 * 3600_000;
assert.strictEqual(gate.verify(v, share), null, 'expired');
assert.strictEqual(gate.verify(v, share), null, 'expired');