openvibers/openvibe.connect Commits
Connect: read Link addresses everywhere (#72)
Changes
@@@ -36,8 +36,9 @@ CONNECT_TWILIO_CLIENT_SECRET=CONNECT_EVENTS_URL=CONNECT_EVENTS_SECRET=--# Shares. The session secret signs visitors' cookies on share hosts (required in production, 32+ characters).--CONNECT_SHARE_DOMAIN=openvibe.website++# Shares. The canonical domain is Link; the old openvibe.website aliases stay served (CONNECT_SHARE_DOMAINS).++# The session secret signs visitors' cookies on share hosts (required in production, 32+ characters).++CONNECT_SHARE_DOMAIN=openvibe.linkCONNECT_SESSION_SECRET=CONNECT_SESSION_HOURS=12CONNECT_SHARES_PER_PERSON=5@@@ -1,22 +1,22 @@# 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.++OpenVibe Connect lives at `connect.openvibe.link`; the old `.openvibe.website` addresses still work and redirect. See++[the staged rollout and host commands](docs/link-share-rollout.md) for how the move was deployed.**Your computer, on the web, privately.** Share one port on your computer at its own HTTPS address,`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)**. Connect shares ports through Link; [OpenVibe.Space](https://openvibe.space) builds and hosts sites.++Live at **[connect.openvibe.link](https://connect.openvibe.link)**. Connect shares ports through Link; [OpenVibe.Space](https://openvibe.space) builds and hosts sites.## Use it--1. Sign in at connect.openvibe.website and make a share: a name (`agents`), the port on your computer (`8790`), and++1. Sign in at connect.openvibe.link and make a share: a name (`agents`), the port on your computer (`8790`), andwho can open it.2. On your computer (Node 22 or newer):```bash-- curl -fsSLO https://connect.openvibe.website/ov-connect.js++ curl -fsSLO https://connect.openvibe.link/ov-connect.jsnode ov-connect.js --key ovc_… --port 8790```@@@ -36,14 +36,14 @@ sudo openvibe-node share add --share shr_… # or keep it up with the Link```There is no key to copy. The computer proves itself twice at every connection: with its Network node token (subject--`node:nod_…`, audience `openvibe.website`, 5 minutes) and with a tunnel token Connect gives it for that one share (60++`node:nod_…`, audience `openvibe.link`, 5 minutes) and with a tunnel token Connect gives it for that one share (60seconds, one use). It sends a fresh node token before the old one runs out, so a computer removed on OpenVibe stopsserving within about five minutes. While a computer serves a share, the share's key does not work; "Use a connect keyinstead" lets the computer go and shows a new key once (ADR-057 decision 4, CON-4).### Webhooks that wait for your computer--On a share's page, "Make a webhook address" gives you `https://connect.openvibe.website/h/hk_…` and a path on your app++On a share's page, "Make a webhook address" gives you `https://connect.openvibe.link/h/hk_…` and a path on your app(`/webhooks/github`). Give that address to GitHub, Stripe or anything else that sends webhooks. Each webhook is sealed,kept for up to 72 hours and handed to your app, at that path, as soon as your computer is connected, so a laptop thatsleeps misses nothing (CON-15, ADR-057 decision 7):@@@ -84,10 +84,10 @@ sleeps misses nothing (CON-15, ADR-057 decision 7):| `named` | you and the OpenVibe usernames you list || `signin` | anyone signed in to OpenVibe (OpenVibe staff only until the abuse check is in, as with Space's public publishing) |--Public shares (no sign-in) come once the abuse check and openvibe.website's entry in the Public Suffix List are in++Public shares (no sign-in) come once the abuse check and openvibe.link's entry in the Public Suffix List are in(ADR-057, CON-9 and CON-21). Changing who can open a share signs everyone out of it.--Anyone can report a share at [/report](https://connect.openvibe.website/report) (CON-9 v0): its address, what is++Anyone can report a share at [/report](https://connect.openvibe.link/report) (CON-9 v0): its address, what iswrong and what they saw. OpenVibe staff read the open reports at `/admin/reports` and can close a share; its computeris disconnected and its address then says it was closed after a report. A report keeps the reporter's account onlywhen they were signed in, never an address.@@@ -107,7 +107,7 @@ computer (Ollama, llama.cpp's server, anything OpenAI-compatible) or an API of yanother app:```--curl -H "Authorization: Bearer ovsk_…" https://llm--you.openvibe.website/v1/models++curl -H "Authorization: Bearer ovsk_…" https://llm--you.openvibe.link/v1/models```- **A key opens the share's API, never its pages.** An HTML answer to a key is refused at the edge (406), so a key@@@ -133,7 +133,7 @@ curl -H "Authorization: Bearer ovsk_…" https://llm--you.openvibe.website/v1/mo### Claude and other MCP clients (CON-16, MCP OAuth 2.1)If what you share is an MCP server, its address works as a connector URL in Claude or any MCP client--(`https://tools--you.openvibe.website/mcp`). The client signs the person in with OpenVibe, with no key to paste:++(`https://tools--you.openvibe.link/mcp`). The client signs the person in with OpenVibe, with no key to paste:1. The share answers a program that has no credentials `401` with`WWW-Authenticate: Bearer resource_metadata="https://<share>/.well-known/oauth-protected-resource"`. That document@@@ -154,7 +154,7 @@ kept. Cases: `test/mcp-oauth.test.js`.## API--With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.website):++With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.link):| Call | What ||---|---|@@@ -169,7 +169,7 @@ With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.w| `GET /api/v1/machines` | your computers that can serve a share (OpenVibe Link, from Network) || `PUT /api/v1/shares/:id/node` `{ node: 'nod_…' \| null }` | one of your computers serves it (the key stops working) → `{ share }`; `null` lets it go → `{ share, key }` |--A computer, with its Network node token (audience `openvibe.website`) as a Bearer:++A computer, with its Network node token (audience `openvibe.link`) as a Bearer:| Call | What ||---|---|@@@ -12,9 +12,11 @@ We reply within 7 days, keep you updated while we fix it, and credit you when th## Scope--Every OpenVibe service and site (connect.openvibe.website and every <name>--<user>.openvibe.website, openvibe.live, openvibe.network, openvibe.today, openvibe.tools,--openvibe.media, openvibe.community, openvibe.games and the other openvibe.* domains) and every repository under--github.com/OpenVibers. Machine-readable contact details are at `/.well-known/security.txt` on each site.++Every OpenVibe service and site (connect.openvibe.link and every <name>--<user>.openvibe.link, the old++connect.openvibe.website and every <name>--<user>.openvibe.website alias, openvibe.live, openvibe.network,++openvibe.today, openvibe.tools, openvibe.media, openvibe.community, openvibe.games and the other openvibe.* domains)++and every repository under github.com/OpenVibers. Machine-readable contact details are at `/.well-known/security.txt`++on each site.## What OpenVibe Connect holds@@@ -30,7 +32,7 @@ github.com/OpenVibers. Machine-readable contact details are at `/.well-known/sec- **The gate.** Every visitor signs in with OpenVibe and must be allowed by the share's policy (only the owner, theowner and named people, or anyone signed in). There are no public shares: those wait for the abuse check and the-- Public Suffix List entry for openvibe.website (ADR-057, CON-9 and CON-21). Because anyone can make an OpenVibe++ Public Suffix List entry for openvibe.link (ADR-057, CON-9 and CON-21). Because anyone can make an OpenVibeaccount, "anyone signed in" is open to OpenVibe staff only until then.- **Host-only cookies.** The gate's cookie on a share host is `__Host-ovc` (Secure, HttpOnly, SameSite=Lax, Path=/, noDomain), HMAC-signed and bound to that share and its policy version; changing the policy invalidates every cookie.@@@ -3,10 +3,10 @@"version": "0.1.0","stage": "product","code": true,-- "plan": "Website Connect v0 (plan 2026-10-28 §5.9b, ADR-057 reviewed 2026-11-02): share one port on your computer at https://<name>--<user>.openvibe.website behind OpenVibe sign-in",++ "plan": "OpenVibe Connect v0 (plan 2026-10-28 §5.9b, ADR-057 reviewed 2026-11-02): share one port on your computer at https://<name>--<user>.openvibe.link behind OpenVibe sign-in","runtime": "node 22, express 4, ws 8, PostgreSQL 18 (openvibe-sdk/db, async)","port": "5240",-- "domain": "connect.openvibe.website",++ "domain": "connect.openvibe.link","contracts": "openvibe-contracts v0.338.0","sdk": "openvibe-sdk v0.94.0","shared": "openvibe-shared v4.154.0",@@@ -17,7 +17,7 @@],"summary": "OpenVibe Connect puts one port on a person's computer on the web at its own HTTPS address, behind OpenVibe sign-in. A one-file client (Node 22, no dependencies) opens one outgoing WebSocket through Cloudflare and carries requests to localhost: HTTP with streaming both ways, server-sent events and WebSocket upgrades. Every visitor signs in and must be allowed by the share's policy (only the owner, named people, or anyone signed in); the share host keeps its own host-only signed cookie. Nothing that passes through a share is stored; a webhook addressed to one is sealed and kept for up to 72 hours until its computer takes it.","works": [-- "Shares at <name>--<user>.openvibe.website, never colliding with Space sites; five per person; a closed share keeps its address",++ "Shares at <name>--<user>.openvibe.link, never colliding with Space sites; five per person; a closed share keeps its address","The client: hello with a hashed-at-rest key, streams both ways with backpressure, SSE, WebSocket upgrades as a byte pipe, reconnect, takeover, stop on a bad key","The gate: /grant with OpenVibe sign-in, one-time codes, __Host-ovc bound to the share and its policy version","Durable webhooks (CON-15): an address on each share keeps up to 1,000 webhooks for 72 hours, sealed at rest, replays them in order with x-openvibe-received-at and a delivery log the owner can read",@@@ -1,15 +1,18 @@## ═══════════════════════════════════════════════════════════════## OpenVibe Connect — Nginx configuration (REFERENCE)##--## Deployed to /etc/nginx/sites-available/connect.openvibe.website.conf (ov-vhost-install). The Node app (port 5240,--## systemd unit openvibe-connect) serves two kinds of host:++## Deployed to /etc/nginx/sites-available/connect.openvibe.website.conf (ov-vhost-install). The file name keeps the++## original host; the Node app (port 5240, systemd unit openvibe-connect) serves four kinds of host:##--## connect.openvibe.website the pages, the API, sign-in, and the computers' tunnel (WebSocket /tunnel)--## <label>--<user>.openvibe.website a share: the gate, then the tunnel to that person's computer++## connect.openvibe.link canonical: the pages, the API, sign-in, and the computers' tunnel (WebSocket /tunnel)++## <label>--<user>.openvibe.link canonical share hosts: the gate, then the tunnel to that person's computer++## connect.openvibe.website the old control name: pages 302 to Link; /tunnel still answers++## <label>--<user>.openvibe.website the old share names: still served, and 302 to Link when CONNECT_REDIRECT_LEGACY=1#### Every other *.openvibe.website host is OpenVibe.Space's (deploy/nginx/openvibe.website.conf in OpenVibe.Space),## whose server_name leaves out any host with "--" in it: a Space site's name can never contain "--", so the two never--## meet. connect.openvibe.website is an exact name and wins over Space's pattern anyway.++## meet. connect.openvibe.website is an exact name and wins over Space's pattern anyway. A *.openvibe.link host without++## "--" is not a Connect host: the Link apex stays Network's.#### A share streams: no proxy buffering either way (server-sent events, uploads), WebSocket upgrades pass, and a## connection may stay open for an hour. The app sets every cache header itself (a share is never cached by@@@ -37,7 +40,7 @@ server {location / { return 301 https://$host$request_uri; }}--# ── connect.openvibe.website ──────────────────────────────────++# ── connect.openvibe.website (old control name; pages 302 to Link) ──server {listen 443 ssl;listen [::]:443 ssl;@@@ -113,7 +116,7 @@ server {}}--# ── <label>--<user>.openvibe.website: a share ─────────────────++# ── <label>--<user>.openvibe.website: an old share alias ──────server {listen 443 ssl;listen [::]:443 ssl;@@@ -148,7 +151,7 @@ server {}}--# Link aliases: install only once the wildcard certificate exists.++# The canonical Link hosts.# ── connect.openvibe.link ──────────────────────────────────server {listen 443 ssl;@@@ -1,5 +1,5 @@[Unit]--Description=OpenVibe Connect — your computer, on the web, privately (connect.openvibe.website and every <name>--<user>.openvibe.website)++Description=OpenVibe Connect — your computer, on the web, privately (connect.openvibe.link and every <name>--<user>.openvibe.link)# Network (sign-in, JWKS, account events) should be up first, but Connect degrades honestly without it: connected# shares keep passing requests for people already let in, and sign-in waits for the signing key (see /api/ready).After=network.target postgresql.service pgbouncer.service valkey-server.service@@@ -2,7 +2,7 @@"name": "openvibe-connect","version": "0.1.0","private": true,-- "description": "OpenVibe Connect: share one port on your computer at its own HTTPS address on openvibe.website, behind OpenVibe sign-in.",++ "description": "OpenVibe Connect: share one port on your computer at its own HTTPS address on openvibe.link, behind OpenVibe sign-in.","main": "server/index.js","scripts": {"start": "node server/index.js",@@@ -1,5 +1,5 @@/* OpenVibe Connect: the pieces sc.room does not draw. Every colour is a theme token (kit.css, room.css); the room's-- accent is openvibe.website's (--room-accent / --room-fill / --room-on). Phone first; nothing moves on its own. */++ accent is the Website room's (--room-accent / --room-fill / --room-on). Phone first; nothing moves on its own. */.cn-wrap { max-width: 1120px; margin: 0 auto; padding: 0 max(16px, env(safe-area-inset-left, 0px)) 48px; }.cn-page { max-width: 760px; padding-top: 20px; display: grid; gap: 22px; }@@@ -11,7 +11,7 @@** --key <ovc_…> your share's connect key (or OV_CONNECT_KEY)* --port <n> the local port to share (or the first bare number)-- * --server <wss://> default wss://connect.openvibe.website/tunnel++ * --server <wss://> default wss://connect.openvibe.link/tunnel* --quiet no line per request** Source: https://github.com/OpenVibers/OpenVibe.Connect (AGPL-3.0). Protocol: ovconnect-ws/1 (server/edge/frames.js).@@@ -55,7 +55,7 @@ function main() {const opts = parseArgs(process.argv.slice(2));if (opts.version) { console.log(`ov-connect ${VERSION}`); return; }if (opts.help || !opts.key || !opts.port) {-- console.log('ov-connect: share a port on this computer at your OpenVibe Connect address.\n\n node ov-connect.js --key ovc_… --port 8080\n\nMake a share and its key at https://connect.openvibe.website');++ console.log('ov-connect: share a port on this computer at your OpenVibe Connect address.\n\n node ov-connect.js --key ovc_… --port 8080\n\nMake a share and its key at https://connect.openvibe.link');process.exitCode = opts.help ? 0 : 2;return;}@@@ -71,7 +71,7 @@ function main() {/*** The client itself (exported for tests): createClient({ key, port, server, quiet, log, localHost }) → { start, stop, onReady }*/--const DEFAULT_SERVER = 'wss://connect.openvibe.website/tunnel';++const DEFAULT_SERVER = 'wss://connect.openvibe.link/tunnel';function createClient(opts) {opts = { ...opts, server: opts.server || DEFAULT_SERVER };@@@ -4,9 +4,9 @@module.exports = {id: "connect",name: "OpenVibe Connect",-- purpose: "Share one port on your computer at https://<name>--<username>.openvibe.website, behind OpenVibe sign-in; programs pass with a share's API key, MCP clients sign in with OAuth.",-- home: "https://connect.openvibe.website",-- site: "connect.openvibe.website",++ purpose: "Share one port on your computer at https://<name>--<username>.openvibe.link, behind OpenVibe sign-in; programs pass with a share's API key, MCP clients sign in with OAuth.",++ home: "https://connect.openvibe.link",++ site: "connect.openvibe.link",api: "/api/v1",openapi: '/openapi.json',policy: {@@@ -4,9 +4,9 @@* OpenVibe.Connect: the Express app factory; server/index.js listens, tests build their own instance with a temp* database and a mock Network.*-- * <label>--<user>.openvibe.website/* a share: the gate, then the tunnel (edge/proxy.js), before anything else++ * <label>--<user>.openvibe.link/* a share: the gate, then the tunnel (edge/proxy.js), before anything else* /, /shares/*, /grant the pages (http/pages.js)-- * /api/v1/node/* a person's computer as OpenVibe Node (http/node-api.js, CON-4)++ * /api/v1/node/* a person's computer as OpenVibe Link (http/node-api.js, CON-4)* /api/v1/* the API (http/api.js)* /ov-connect.js the client, served as it is in public/* /auth/* Network SSO with PKCE (auth/sso.js)@@@ -243,7 +243,7 @@ async function createApp(opts = {}) {// ── Sign-in (OAuth2 + PKCE client of OpenVibe.Network) ──app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));app.use('/auth', sso.routes());-- { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'connect', service: 'connect', host: 'connect.openvibe.website', name: 'OpenVibe Connect', profile: 'ugc' })); }++ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'connect', service: 'connect', host: 'connect.openvibe.link', name: 'OpenVibe Connect', profile: 'ugc' })); }// ── Webhooks to a share's hooks, and Events' deliveries of them back (CON-15; /internal/ is loopback only) ──app.use(createHookRoutes(ctx));@@@ -1,10 +1,11 @@'use strict';/**-- * A person's computer, as OpenVibe Node (CON-4, ADR-057 decision 4). Two credentials, both checked:++ * A person's computer, as OpenVibe Link (CON-4, ADR-057 decision 4). Two credentials, both checked:** 1. its Network node token: an RS256 service token whose subject is node:<nod_…> (actor_type node), for audience-- * openvibe.website, 300 s. Network issues it to any active machine a person owns (Network #538);++ * openvibe.link (old binaries: openvibe.website), 300 s. Network issues it to any active machine a person owns++ * (Network #538);* 2. a tunnel token Connect mints for one share and that computer: `ovt_` + 32 random bytes, 60 s, one use. It is* kept in memory as a SHA-256 hash only, never logged, stored or put in an event.*@@@ -1,7 +1,7 @@'use strict';/**-- * OpenVibe.Connect configuration (connect.openvibe.website). Every value comes from the environment (production:++ * OpenVibe.Connect configuration (connect.openvibe.link). Every value comes from the environment (production:* /etc/openvibe/connect.env, see .env.example). Only environment variable NAMES appear in code and docs; secrets are* never logged.*@@@ -16,7 +16,7 @@ function load(env = process.env) {const nodeEnv = env.NODE_ENV || 'development';const isProduction = nodeEnv === 'production';const port = int(env.PORT, 5240);-- const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://connect.openvibe.website' : `http://localhost:${port}`));++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://connect.openvibe.link' : `http://localhost:${port}`));const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');return {@@@ -41,7 +41,7 @@ function load(env = process.env) {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.++ // Old 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',@@@ -3,10 +3,10 @@/*** The gate in front of every share: who may open it, and how a visitor's browser proves it on a share host.*-- * A share host is another origin (agents--alex.openvibe.website), and openvibe.website is not on the Public Suffix++ * A share host is another origin (agents--alex.openvibe.link), and openvibe.link is not on the Public Suffix* List yet, so the gate keeps every cookie host-only and signs it itself:*-- * 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.link/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 host) and goes back to* https://<share host>/__ovc/callback?code=…&next=….@@@ -1,7 +1,7 @@'use strict';/**-- * The visitor's side of a share: every request to https://<label>--<user>.openvibe.website.++ * The visitor's side of a share: every request to https://<label>--<user>.openvibe.link.** handle(req, res) an HTTP request (SSE and streaming bodies included)* upgrade(req, socket, head) a WebSocket upgrade: after the gate, a byte pipe to the local port@@@ -13,8 +13,8 @@* X-Forwarded-Host, the visitor's address in X-Forwarded-For, and who they are in X-OpenVibe-User and* X-OpenVibe-Subject (headers of those names from the visitor are dropped). The gate's own cookie never reaches it.*-- * Responses: a Set-Cookie's Domain attribute is removed (until openvibe.website is on the Public Suffix List, a-- * cookie for .openvibe.website would reach every other share and every Space site), and nothing a share sends may be++ * Responses: a Set-Cookie's Domain attribute is removed (until openvibe.link is on the Public Suffix List, a++ * cookie for .openvibe.link would reach every other share), and nothing a share sends may be* kept by Cloudflare's cache (Cloudflare-CDN-Cache-Control: no-store; Cache-Control is made private): a cached copy* would be served past the gate.*/@@@ -2,7 +2,7 @@/*** The tunnels: one WebSocket per connected share, opened by the client (public/ov-connect.js) to-- * wss://connect.openvibe.website/tunnel and authenticated by its first message, { t: 'hello', key }.++ * wss://connect.openvibe.link/tunnel and authenticated by its first message, { t: 'hello', key }.** createTunnels({ shares, config, log }) → { handleUpgrade(req, socket, head), get(shareId), close(shareId, code, why),* closeAll(), status() }@@@ -2,7 +2,7 @@/*** Hooks that wait for your computer (CON-15, ADR-057 decision 7). A hook is an address a provider sends webhooks to,-- * https://connect.openvibe.website/h/<hk_…>, bound to one share and a path on the computer's app. A webhook is kept++ * https://connect.openvibe.link/h/<hk_…>, bound to one share and a path on the computer's app. A webhook is kept* (72 hours) and a pointer to it is published as connect.hook.received through the outbox, in one transaction. Events* delivers it to Connect's own subscription (retried for up to 72 hours), and Connect hands it to the share's* computer when it is connected. The computer may be asleep for days and still get every webhook, in order of arrival,@@@ -2,21 +2,21 @@/*** /api/v1: OpenVibe.Connect's JSON API, for a person (the connect session cookie, or a Network user token as a-- * Bearer). A session write must come from connect.openvibe.website itself (403 connect.cross_site).++ * Bearer). A session write must come from connect.openvibe.link itself (403 connect.cross_site).** GET /shares your shares (website.share@1 shapes, plus connected)* POST /shares { label, port, mode?, named?, expires_in? } → 201 { share, key } the key is in this answer only* GET /shares/:id one of your shares* PATCH /shares/:id { mode?, named?, port?, note?, expires_in?, offline_site? } expires_in: seconds from now* (600 to 30 days), or null to stay open until you close it; offline_site: where a browser-- * goes while the computer is off, one of the sites on the domain (CON-17), '' or null to clear++ * goes while the computer is off, one of your OpenVibe.Space sites (CON-17), '' or null to clear* DELETE /shares/:id close it (the host answers 410 from then on)* POST /shares/:id/key a new key → { share, key }; the connected computer is disconnected* PUT /shares/:id/node { node: 'nod_…' } one of your computers serves it, as OpenVibe Link (CON-4): the key* stops working → { share }; { node: null } lets the computer go → { share, key } (a new key)* GET /machines your computers that can serve a share (Network's list of your machines)* GET /shares/:id/hooks its webhook addresses (CON-15): each kept webhook waits up to 72 hours for the computer-- * POST /shares/:id/hooks { path, label? } → 201 { hook }: https://connect.openvibe.website/h/<id> → that path++ * POST /shares/:id/hooks { path, label? } → 201 { hook }: https://connect.openvibe.link/h/<id> → that path* DELETE /shares/:id/hooks/:hook removes it; webhooks still waiting are dropped* GET /shares/:id/hooks/:hook/deliveries the owner's log: what the app answered, and when* GET /shares/:id/visits who opened it in the last 7 days@@@ -2,7 +2,7 @@/*** /api/v1/node: a person's computer, as OpenVibe Link (CON-4, ADR-057 decision 4). Every call carries the computer's-- * Network node token (Authorization: Bearer, subject node:nod_…, audience openvibe.website); nothing here answers to a++ * Network node token (Authorization: Bearer, subject node:nod_…, audience openvibe.link); nothing here answers to a* person's token or the session.** GET /shares the open shares this computer serves → { shares: [{ id, host, url, local_port, mode }] }@@@ -14,14 +14,15 @@ const { http } = require('openvibe-contracts');const { asyncRouter } = require('./router');function createNodeApi(ctx) {-- const { shares, nodeAuth } = ctx;++ const { config, shares, nodeAuth } = ctx;++ const host = new URL(config.baseUrl).host;const r = asyncRouter();r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });const fail = (res, req, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });r.use(async (req, res, next) => {const header = String(req.headers.authorization || '');-- if (!header.startsWith('Bearer ')) return fail(res, req, 401, 'token.required', 'send this computer\'s Network node token (audience openvibe.website) as a Bearer');++ if (!header.startsWith('Bearer ')) return fail(res, req, 401, 'token.required', 'send this computer\'s Network node token (audience openvibe.link) as a Bearer');const v = await nodeAuth.verify(header.slice(7).trim());if (!v.ok) return fail(res, req, v.status, v.code, v.reason);req.node = v.node;@@@ -35,7 +36,7 @@ function createNodeApi(ctx) {r.post('/shares/:id/tunnel-token', async (req, res) => {const share = await shares.get(req.params.id);// Another person's share and a share this computer does not serve look the same: not found.-- if (!share || !share.node || share.node.id !== req.node) return fail(res, req, 404, 'share.not_found', 'This computer does not serve that share. Pick it on the share\'s page on connect.openvibe.website.');++ if (!share || !share.node || share.node.id !== req.node) return fail(res, req, 404, 'share.not_found', `This computer does not serve that share. Pick it on the share's page on ${host}.`);if (share.status !== 'active') return fail(res, req, 410, 'share.revoked', 'That share is closed.');const t = nodeAuth.mint(share.id, req.node);return res.json({ tunnel_token: t.token, expires_in: t.expires_in, share: brief(share) });@@@ -10,7 +10,7 @@* POST /oauth/token code + PKCE verifier, or a refresh token → tokens** The metadata, registration and token answers carry CORS for any origin (MCP clients run in browsers too); the-- * consent is a page on connect.openvibe.website and cannot be posted from elsewhere.++ * consent is a page on connect.openvibe.link and cannot be posted from elsewhere.*/const express = require('express');const { asyncRouter } = require('./router');@@@ -23,6 +23,7 @@ function createOAuthRoutes(ctx) {const { config, oauth, shares, proxy } = ctx;const r = asyncRouter();const issuer = config.baseUrl;++ const host = new URL(issuer).host;const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);const cors = (res) => { res.set('Access-Control-Allow-Origin', '*'); res.set('Access-Control-Allow-Headers', 'content-type, authorization, mcp-protocol-version'); res.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); };@@@ -112,7 +113,7 @@ function createOAuthRoutes(ctx) {res.set('Cache-Control', 'no-store');res.set('Referrer-Policy', 'no-referrer');if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2F');-- if (!sameOrigin(req, config.baseUrl)) return page(req, res, { title: 'Not from this site', body: views.message({ title: 'That came from somewhere else', text: 'Answer on connect.openvibe.website itself.', link: { href: '/', label: 'OpenVibe Connect' } }) }, 403);++ if (!sameOrigin(req, config.baseUrl)) return page(req, res, { title: 'Not from this site', body: views.message({ title: 'That came from somewhere else', text: `Answer on ${host} itself.`, link: { href: '/', label: 'OpenVibe Connect' } }) }, 403);const q = pick(req.body || {});const ok = await checked(req, res, q);if (!ok) return undefined;@@@ -1,7 +1,7 @@'use strict';/**-- * Connect's pages on connect.openvibe.website. They work without JavaScript: forms are plain POSTs.++ * Connect's pages on connect.openvibe.link. They work without JavaScript: forms are plain POSTs.** / the front door; signed in, your shares and the form to make one* POST /shares make a share → its page, with the key shown once@@@ -32,6 +32,7 @@ const seo = require('openvibe-shared/seo');function createPageRoutes(ctx) {const { config, shares, tunnels, gate, proxy, machines, hooks, apiKeys } = ctx;++ const host = new URL(config.baseUrl).host;const r = asyncRouter();r.use(express.urlencoded({ extended: false, limit: '16kb' }));const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });@@@ -41,7 +42,7 @@ function createPageRoutes(ctx) {const guard = (req, res) => {if (!signedIn(req)) { res.redirect(303, loginTo(req.originalUrl)); return false; }if (!sameOrigin(req, config.baseUrl)) {-- page(req, res, { title: 'Not from this site', body: views.message({ title: 'That came from somewhere else', text: 'Changes to your shares must be made on connect.openvibe.website itself.', link: { href: '/', label: 'Your shares' } }) }, 403);++ page(req, res, { title: 'Not from this site', body: views.message({ title: 'That came from somewhere else', text: `Changes to your shares must be made on ${host} itself.`, link: { href: '/', label: 'Your shares' } }) }, 403);return false;}return true;@@@ -274,7 +275,7 @@ function createPageRoutes(ctx) {r.post('/report', async (req, res) => {const values = { host: req.body.host, reason: req.body.reason, details: req.body.details };const again = (status, error) => page(req, res, { title: 'Report a share', body: views.reportForm({ values, error, reasons: REPORT_REASONS, signedIn: signedIn(req) }) }, status);-- if (!sameOrigin(req, config.baseUrl)) return again(403, 'Send the report from connect.openvibe.website itself.');++ if (!sameOrigin(req, config.baseUrl)) return again(403, `Send the report from ${host} itself.`);if (overReports(req)) return again(429, 'That is a lot of reports in an hour. Try again later, or write to OpenVibe from the Help site.');try {await shares.report({ host: values.host, reason: values.reason, details: values.details, reporter: signedIn(req) ? req.viewer.subject : null });@@@ -297,7 +298,7 @@ function createPageRoutes(ctx) {});r.post('/admin/reports/:id/:action', async (req, res) => {if (!staffOnly(req, res)) return undefined;-- if (!sameOrigin(req, config.baseUrl)) return page(req, res, { title: 'Not from this site', body: views.message({ title: 'That came from somewhere else', text: 'Handle reports on connect.openvibe.website itself.', link: { href: '/admin/reports', label: 'Reports' } }) }, 403);++ if (!sameOrigin(req, config.baseUrl)) return page(req, res, { title: 'Not from this site', body: views.message({ title: 'That came from somewhere else', text: `Handle reports on ${host} itself.`, link: { href: '/admin/reports', label: 'Reports' } }) }, 403);try {const out = await shares.handleReport(owner(req), req.params.id, req.params.action);if (out.action === 'close' && out.share) {@@@ -325,9 +326,9 @@ function createPageRoutes(ctx) {// What llms.txt says (SEO-33: /llms-full.txt adds the README, Connect's full documentation, after it).const NAME = 'OpenVibe Connect';-- const SUMMARY = 'Share one port on your computer at https://<name>--<username>.openvibe.website, behind OpenVibe sign-in.';++ const SUMMARY = `Share one port on your computer at https://<name>--<username>.${config.shares.domain}, behind OpenVibe sign-in.`;const DETAILS = [-- 'Client: one file, Node 22+: curl -fsSLO https://connect.openvibe.website/ov-connect.js; node ov-connect.js --key ovc_… --port 8080.',++ `Client: one file, Node 22+: curl -fsSLO ${site}/ov-connect.js; node ov-connect.js --key ovc_… --port 8080.`,'Who can open a share: only the owner, the owner and named OpenVibe usernames, or anyone signed in to OpenVibe. No public shares yet.','The app behind a share receives X-OpenVibe-User and X-OpenVibe-Subject; Host is localhost:<port>.','API (a person\'s Network token as a Bearer): GET/POST /api/v1/shares, GET/PATCH/DELETE /api/v1/shares/{id}, POST /api/v1/shares/{id}/key.',@@@ -6,8 +6,9 @@* { kind: 'user', subject: 'usr_…', username, role, token, viaSession }* a person: their Network user token as a Bearer, or this site's session cookie (the connect session).* { kind: 'app', requester: 'app:…' | 'agent:…' | 'service:…', project, claims }-- * a Network app, agent or service token for audience connect.openvibe.website. No Connect route needs one; the shape is-- * kept so a future capability route can be added in one place.++ * a Network app, agent or service token for Connect's service audience (CONNECT_AUDIENCE,++ * openvibe.website). No Connect route needs one; the shape is kept so a future capability route can be added in++ * one place.* { kind: 'anonymous' }** A request that presents a token is judged on that token alone: a bad one is refused, never downgraded to the@@@ -50,6 +51,7 @@ function sameOrigin(req, baseUrl) {}function createPrincipal({ config, keys }) {++ const host = new URL(config.baseUrl).host;async function resolve(req) {const header = String(req.headers.authorization || '');if (header.startsWith('Bearer ')) {@@@ -86,9 +88,9 @@ function createPrincipal({ config, keys }) {function require_(cap) {if (!CAPABILITIES.includes(cap)) throw new Error(`requireCapability(${cap}): not one of OpenVibe.Connect's capabilities`);return function guard(req, res, next) {-- if (req.principal.kind === 'anonymous') return http.sendProblem(res, 401, 'token.required', { detail: 'Sign in at connect.openvibe.website, or send a person\'s Network token as a Bearer.', ctx: req.ov });++ if (req.principal.kind === 'anonymous') return http.sendProblem(res, 401, 'token.required', { detail: `Sign in at ${host}, or send a person's Network token as a Bearer.`, ctx: req.ov });if (req.principal.kind === 'user') {-- if (req.principal.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) return http.sendProblem(res, 403, 'connect.cross_site', { detail: 'A signed-in request that changes something must come from connect.openvibe.website itself.', ctx: req.ov });++ if (req.principal.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) return http.sendProblem(res, 403, 'connect.cross_site', { detail: `A signed-in request that changes something must come from ${host} itself.`, ctx: req.ov });return next();}return http.sendProblem(res, 403, 'connect.person_required', { detail: 'This route is for a person, not an app.', ctx: req.ov });@@@ -99,12 +101,12 @@ function createPrincipal({ config, keys }) {function requirePerson(req, res, next) {if (req.principal.kind === 'user') {if (req.principal.viaSession && req.method !== 'GET' && req.method !== 'HEAD' && !sameOrigin(req, config.baseUrl)) {-- return http.sendProblem(res, 403, 'connect.cross_site', { detail: 'A signed-in request that changes something must come from connect.openvibe.website itself.', ctx: req.ov });++ return http.sendProblem(res, 403, 'connect.cross_site', { detail: `A signed-in request that changes something must come from ${host} itself.`, ctx: req.ov });}return next();}if (req.principal.kind === 'app') return http.sendProblem(res, 403, 'connect.person_required', { detail: 'This route is for a person, not an app or a service.', ctx: req.ov });-- return http.sendProblem(res, 401, 'token.required', { detail: 'Sign in at connect.openvibe.website, or send a person\'s Network token as a Bearer.', ctx: req.ov });++ return http.sendProblem(res, 401, 'token.required', { detail: `Sign in at ${host}, or send a person's Network token as a Bearer.`, ctx: req.ov });}return { middleware, resolve, requireCapability: require_, requirePerson };@@@ -2,7 +2,7 @@/*** OpenVibe.Connect: process entry. `node server/index.js`-- * Listens on PORT (5240) behind nginx (deploy/): connect.openvibe.website and every <label>--<user>.openvibe.website.++ * Listens on PORT (5240) behind nginx (deploy/): connect.openvibe.link and every <label>--<user>.openvibe.link.*/const { createApp } = require('./app');const { gracefulStop } = require('openvibe-sdk/service');@@@ -65,7 +65,7 @@ function createProbe({ shares, proxy, config, log = console, now = () => Date.noif (!force && now() - at < DAY_MS) return null;last.set(share.id, now());if (last.size > 10000) last.delete(last.keys().next().value);-- const out = await proxy.fetchThrough(share, { path: '/', headers: [['x-openvibe-probe', signature(share.id, secret, Math.floor(now() / 1000))], ['user-agent', 'OpenVibeConnectProbe/1 (+https://connect.openvibe.website/report)']] });++ const out = await proxy.fetchThrough(share, { path: '/', headers: [['x-openvibe-probe', signature(share.id, secret, Math.floor(now() / 1000))], ['user-agent', `OpenVibeConnectProbe/1 (+${String(config.baseUrl).replace(/\/+$/, '')}/report)`]] });if (!out || out.offline || out.error || !out.body) return null;if (!/html/i.test(out.type || '') && !/<html|<form/i.test(out.body.toString('utf8', 0, 2048))) return { verdict: 'ok', flags: [], brands: [], why: null };const a = assess(out.body.toString('utf8'), share);@@@ -2,7 +2,7 @@/*** Page shell. Every page is server-rendered through openvibe-shared/shell and is complete without JavaScript.-- * Connect is the "your machine" half of openvibe.website, so it wears that room's accent (openvibe-shared/rooms++ * Connect is the "your machine" half of OpenVibe Link, so it wears the Website room's accent (openvibe-shared/rooms* `website`, #8EA0FF dark / #4B53C2 light); the navigation's name is `Connect`.*/const crypto = require('crypto');@@@ -24,7 +24,7 @@ const NAV = [{ label: 'Connect', href: '/', icon: 'fa-plug' },{ label: 'Sites', href: 'https://openvibe.space', icon: 'fa-globe' },];--const API_BASE = 'https://connect.openvibe.website';++const API_BASE = 'https://connect.openvibe.link';const hashes = new Map();function assetVersion(rel) {@@@ -50,9 +50,9 @@ function layout(o) {const canonical = o.canonical === undefined ? `${String(config.baseUrl).replace(/\/+$/, '')}${pathNow.split('?')[0]}` : o.canonical;const navbar = {service: 'connect',-- // Connect.OpenVibe.Website, the way the navbar names every product on a subdomain (Bench.OpenVibe.Services).++ // Connect.OpenVibe.Link, the way the navbar names every product on a subdomain (Bench.OpenVibe.Services).// With tldLabel 'Connect' the host's own "connect" was added in front: "Connect.OpenVibe.Connect" (11-02).-- brand: { sub: 'connect', tld: 'website' },++ brand: { sub: 'connect', tld: 'link' },apiBase: NETWORK_URL,links: NAV.map((l) => ({ ...l, active: l.href === active || (l.href !== '/' && pathNow.startsWith(`${l.href}/`)) })),appBar: [@@@ -18,7 +18,7 @@ const SERVICE = 'connect';const NAMESPACE = 'connect.signin_apps';const BASE_PATH = '/internal/config';const GROUP = 'Sign-in apps';--const REDIRECT = 'https://connect.openvibe.website/auth/callback';++const REDIRECT = 'https://connect.openvibe.link/auth/callback';/*** One entry per outside service. `env` names the fallback variables; `test` is the one cheap@@@ -47,13 +47,13 @@ const SIGNIN_NOT_YET = '"Anyone signed in" opens with the abuse check. For now,const isStaff = (owner) => Boolean(owner && owner.role === 'admin');// A Space site's name: one DNS label with single hyphens, so never a share's "--" host (OpenVibe.Space's NAME_RE).const SITE_LABEL_RE = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,62}$/;--const OFFLINE_SITE_HELP = 'The fallback is one of the sites on this domain, such as your OpenVibe.Space site: https://<name>.openvibe.website, optionally with a path.';++const OFFLINE_SITE_HELP = 'The fallback is one of your OpenVibe.Space sites: https://<name>.openvibe.website, optionally with a path.';/*** Where a browser goes while a share's computer is off (CON-17): '' / null / undefined-as-cleared → null; otherwise-- * a site on the share domain itself (https://<name>.<domain>, one label, never a share's "--" host), no port and no-- * credentials, a path and query allowed, the fragment dropped. Anything else throws, so a share can never send its-- * visitors off the domain.++ * a Space site on the Website domain (https://<name>.openvibe.website, one label, never a share's "--" host), no port++ * and no credentials, a path and query allowed, the fragment dropped. Anything else throws, so a share can never send++ * its visitors off the allowed domain.*/function offlineSite(value, { domain, scheme = 'https' }) {if (value == null || String(value).trim() === '') return null;@@@ -86,7 +86,7 @@ function hostFor(label, username, domain) {return `${first}.${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.link" → { 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+$/, '');@@@ -175,7 +175,7 @@ function home({ viewer, shares = [], connectedIds = new Set(), stats = {}, baseUitems: [['What can the server make my computer do?', 'Nothing but answer requests for your share on the one port you gave the client, on localhost. The client never opens a connection anywhere else, and you can read all of it: it is one file.'],['Who can open my share?', 'People you allow, after they sign in with OpenVibe: only you, or you and the people you name. "Anyone signed in" is open to OpenVibe staff until the abuse check is in. Changing who can open a share signs everyone out of it at once.'],-- ['Can I make it public?', 'Not yet. A public address can be abused for phishing, so public shares come with the abuse check and openvibe.website\'s entry in the Public Suffix List. Until then every visitor signs in.'],++ ['Can I make it public?', `Not yet. A public address can be abused for phishing, so public shares come with the abuse check and ${esc(domain)}'s entry in the Public Suffix List. Until then every visitor signs in.`],['What does my app see?', 'The request as the browser sent it, with Host set to localhost:<port>, the share\'s address in X-Forwarded-Host, the visitor\'s address in X-Forwarded-For, and who they are in X-OpenVibe-User and X-OpenVibe-Subject. Cookies your app sets stay on your share\'s address.'],['What are the limits?', 'Five open shares per person, 64 requests at once per share, 1,200 requests a minute per share, 100 MB per request body, and 10 GB a day across your shares. Nothing is stored: requests pass through.'],['How is this different from ngrok or Tailscale?', 'It is part of your OpenVibe account: the people you let in are OpenVibe people, no separate login, no install beyond one file. TCP and UDP shares (game servers), custom domains, a private mesh between your machines and durable webhooks are the next steps on the plan.'],@@@ -9,10 +9,10 @@ const { agentManifest } = require('openvibe-shared/agent-layer');const m = agentManifest(descriptor);assert.strictEqual(m.network, 'OpenVibe');--assert.strictEqual(m.home, 'https://connect.openvibe.website');++assert.strictEqual(m.home, 'https://connect.openvibe.link');assert.ok(m.purpose && m.purpose.length > 40, 'a purpose');assert.ok(/^https:\/\//.test(m.api), 'its API, absolute');--assert.strictEqual(m.llms, 'https://connect.openvibe.website/llms.txt');++assert.strictEqual(m.llms, 'https://connect.openvibe.link/llms.txt');assert.ok(Array.isArray(m.policy.auth) && m.policy.auth.length, 'how it is called');const app = fs.readFileSync(path.join(__dirname, '..', 'server', 'app.js'), 'utf8');assert.match(app, /require\('openvibe-shared\/agent-layer'\)\.mount\(app, \{ descriptor: require\('\.\/agent-descriptor'\) \}\);/);@@@ -71,7 +71,7 @@ const { check, done } = require('./helpers/check');assert.ok(page.text.includes(`name="offline_site" type="url" maxlength="300" value="${site}"`), 'the saved site is shown');const bad = await t.get(`/shares/${id}/policy`, { as: kim, form: { mode: 'named', named: '@lee, sam', offline_site: 'https://evil.example/' } });assert.strictEqual(bad.status, 422);-- assert.match(bad.text, /one of the sites on this domain/);++ assert.match(bad.text, /one of your OpenVibe\.Space sites/);await t.get(`/shares/${id}/policy`, { as: kim, form: { mode: 'named', named: '@lee, sam', offline_site: '' } });assert.strictEqual((await t.get('/api/v1/shares', { as: kim })).json().shares[0].offline_site, null, 'cleared');});@@@ -127,7 +127,7 @@ const { check, done } = require('./helpers/check');const agent = await t.get('/.well-known/agent.json');assert.strictEqual(agent.status, 200);const m = agent.json();-- assert.deepStrictEqual([m.network, m.id, m.home], ['OpenVibe', 'connect', 'https://connect.openvibe.website']);++ assert.deepStrictEqual([m.network, m.id, m.home], ['OpenVibe', 'connect', 'https://connect.openvibe.link']);// SEO-33: llms.txt, then the README (Connect's full documentation).const full = await t.get('/llms-full.txt');assert.strictEqual(full.status, 200);@@@ -77,7 +77,7 @@ const { createGate } = require('../server/edge/gate');assert.strictEqual(parseArgs(['--key=ovc_y', '--port=9']).key, 'ovc_y');// Used as a module (createClient) with no server, it still goes to production, not to "undefined".const { DEFAULT_SERVER } = require('../public/ov-connect');-- assert.strictEqual(DEFAULT_SERVER, 'wss://connect.openvibe.website/tunnel');++ assert.strictEqual(DEFAULT_SERVER, 'wss://connect.openvibe.link/tunnel');assert.strictEqual(parseArgs([]).server, DEFAULT_SERVER);});
@@ -36,8 +36,9 @@ CONNECT_TWILIO_CLIENT_SECRET=
@@ -36,8 +36,9 @@ CONNECT_TWILIO_CLIENT_SECRET=
CONNECT_EVENTS_URL=
CONNECT_EVENTS_URL=
CONNECT_EVENTS_SECRET=
CONNECT_EVENTS_SECRET=
-# Shares. The session secret signs visitors' cookies on share hosts (required in production, 32+ characters).
-CONNECT_SHARE_DOMAIN=openvibe.website
+# Shares. The canonical domain is Link; the old openvibe.website aliases stay served (CONNECT_SHARE_DOMAINS).
+# The session secret signs visitors' cookies on share hosts (required in production, 32+ characters).
+CONNECT_SHARE_DOMAIN=openvibe.link
CONNECT_SESSION_SECRET=
CONNECT_SESSION_SECRET=
CONNECT_SESSION_HOURS=12
CONNECT_SESSION_HOURS=12
CONNECT_SHARES_PER_PERSON=5
CONNECT_SHARES_PER_PERSON=5
@@ -1,22 +1,22 @@
@@ -1,22 +1,22 @@
# 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.
+OpenVibe Connect lives at `connect.openvibe.link`; the old `.openvibe.website` addresses still work and redirect. See
+[the staged rollout and host commands](docs/link-share-rollout.md) for how the move was deployed.
**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.link`, 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)**. Connect shares ports through Link; [OpenVibe.Space](https://openvibe.space) builds and hosts sites.
+Live at **[connect.openvibe.link](https://connect.openvibe.link)**. Connect shares ports through Link; [OpenVibe.Space](https://openvibe.space) builds and hosts sites.
## Use it
## Use it
-1. Sign in at connect.openvibe.website and make a share: a name (`agents`), the port on your computer (`8790`), and
+1. Sign in at connect.openvibe.link and make a share: a name (`agents`), the port on your computer (`8790`), and
who can open it.
who can open it.
2. On your computer (Node 22 or newer):
2. On your computer (Node 22 or newer):
```bash
```bash
- curl -fsSLO https://connect.openvibe.website/ov-connect.js
+ curl -fsSLO https://connect.openvibe.link/ov-connect.js
node ov-connect.js --key ovc_… --port 8790
node ov-connect.js --key ovc_… --port 8790
```
```
@@ -36,14 +36,14 @@ sudo openvibe-node share add --share shr_… # or keep it up with the Link
@@ -36,14 +36,14 @@ sudo openvibe-node share add --share shr_… # or keep it up with the Link
```
```
There is no key to copy. The computer proves itself twice at every connection: with its Network node token (subject
There is no key to copy. The computer proves itself twice at every connection: with its Network node token (subject
-`node:nod_…`, audience `openvibe.website`, 5 minutes) and with a tunnel token Connect gives it for that one share (60
+`node:nod_…`, audience `openvibe.link`, 5 minutes) and with a tunnel token Connect gives it for that one share (60
seconds, one use). It sends a fresh node token before the old one runs out, so a computer removed on OpenVibe stops
seconds, one use). It sends a fresh node token before the old one runs out, so a computer removed on OpenVibe stops
serving within about five minutes. While a computer serves a share, the share's key does not work; "Use a connect key
serving within about five minutes. While a computer serves a share, the share's key does not work; "Use a connect key
instead" lets the computer go and shows a new key once (ADR-057 decision 4, CON-4).
instead" lets the computer go and shows a new key once (ADR-057 decision 4, CON-4).
### Webhooks that wait for your computer
### Webhooks that wait for your computer
-On a share's page, "Make a webhook address" gives you `https://connect.openvibe.website/h/hk_…` and a path on your app
+On a share's page, "Make a webhook address" gives you `https://connect.openvibe.link/h/hk_…` and a path on your app
(`/webhooks/github`). Give that address to GitHub, Stripe or anything else that sends webhooks. Each webhook is sealed,
(`/webhooks/github`). Give that address to GitHub, Stripe or anything else that sends webhooks. Each webhook is sealed,
kept for up to 72 hours and handed to your app, at that path, as soon as your computer is connected, so a laptop that
kept for up to 72 hours and handed to your app, at that path, as soon as your computer is connected, so a laptop that
sleeps misses nothing (CON-15, ADR-057 decision 7):
sleeps misses nothing (CON-15, ADR-057 decision 7):
@@ -84,10 +84,10 @@ sleeps misses nothing (CON-15, ADR-057 decision 7):
@@ -84,10 +84,10 @@ sleeps misses nothing (CON-15, ADR-057 decision 7):
| `named` | you and the OpenVibe usernames you list |
| `named` | you and the OpenVibe usernames you list |
| `signin` | anyone signed in to OpenVibe (OpenVibe staff only until the abuse check is in, as with Space's public publishing) |
| `signin` | anyone signed in to OpenVibe (OpenVibe staff only until the abuse check is in, as with Space's public publishing) |
-Public shares (no sign-in) come once the abuse check and openvibe.website's entry in the Public Suffix List are in
+Public shares (no sign-in) come once the abuse check and openvibe.link's entry in the Public Suffix List are in
(ADR-057, CON-9 and CON-21). Changing who can open a share signs everyone out of it.
(ADR-057, CON-9 and CON-21). Changing who can open a share signs everyone out of it.
-Anyone can report a share at [/report](https://connect.openvibe.website/report) (CON-9 v0): its address, what is
+Anyone can report a share at [/report](https://connect.openvibe.link/report) (CON-9 v0): its address, what is
wrong and what they saw. OpenVibe staff read the open reports at `/admin/reports` and can close a share; its computer
wrong and what they saw. OpenVibe staff read the open reports at `/admin/reports` and can close a share; its computer
is disconnected and its address then says it was closed after a report. A report keeps the reporter's account only
is disconnected and its address then says it was closed after a report. A report keeps the reporter's account only
when they were signed in, never an address.
when they were signed in, never an address.
@@ -107,7 +107,7 @@ computer (Ollama, llama.cpp's server, anything OpenAI-compatible) or an API of y
@@ -107,7 +107,7 @@ computer (Ollama, llama.cpp's server, anything OpenAI-compatible) or an API of y
another app:
another app:
```
```
-curl -H "Authorization: Bearer ovsk_…" https://llm--you.openvibe.website/v1/models
+curl -H "Authorization: Bearer ovsk_…" https://llm--you.openvibe.link/v1/models
```
```
- **A key opens the share's API, never its pages.** An HTML answer to a key is refused at the edge (406), so a key
- **A key opens the share's API, never its pages.** An HTML answer to a key is refused at the edge (406), so a key
@@ -133,7 +133,7 @@ curl -H "Authorization: Bearer ovsk_…" https://llm--you.openvibe.website/v1/mo
@@ -133,7 +133,7 @@ curl -H "Authorization: Bearer ovsk_…" https://llm--you.openvibe.website/v1/mo
### Claude and other MCP clients (CON-16, MCP OAuth 2.1)
### Claude and other MCP clients (CON-16, MCP OAuth 2.1)
If what you share is an MCP server, its address works as a connector URL in Claude or any MCP client
If what you share is an MCP server, its address works as a connector URL in Claude or any MCP client
-(`https://tools--you.openvibe.website/mcp`). The client signs the person in with OpenVibe, with no key to paste:
+(`https://tools--you.openvibe.link/mcp`). The client signs the person in with OpenVibe, with no key to paste:
1. The share answers a program that has no credentials `401` with
1. The share answers a program that has no credentials `401` with
`WWW-Authenticate: Bearer resource_metadata="https://<share>/.well-known/oauth-protected-resource"`. That document
`WWW-Authenticate: Bearer resource_metadata="https://<share>/.well-known/oauth-protected-resource"`. That document
@@ -154,7 +154,7 @@ kept. Cases: `test/mcp-oauth.test.js`.
@@ -154,7 +154,7 @@ kept. Cases: `test/mcp-oauth.test.js`.
## API
## API
-With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.website):
+With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.link):
| Call | What |
| Call | What |
|---|---|
|---|---|
@@ -169,7 +169,7 @@ With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.w
@@ -169,7 +169,7 @@ With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.w
| `GET /api/v1/machines` | your computers that can serve a share (OpenVibe Link, from Network) |
| `GET /api/v1/machines` | your computers that can serve a share (OpenVibe Link, from Network) |
| `PUT /api/v1/shares/:id/node` `{ node: 'nod_…' \| null }` | one of your computers serves it (the key stops working) → `{ share }`; `null` lets it go → `{ share, key }` |
| `PUT /api/v1/shares/:id/node` `{ node: 'nod_…' \| null }` | one of your computers serves it (the key stops working) → `{ share }`; `null` lets it go → `{ share, key }` |
-A computer, with its Network node token (audience `openvibe.website`) as a Bearer:
+A computer, with its Network node token (audience `openvibe.link`) as a Bearer:
| Call | What |
| Call | What |
|---|---|
|---|---|
@@ -12,9 +12,11 @@ We reply within 7 days, keep you updated while we fix it, and credit you when th
@@ -12,9 +12,11 @@ We reply within 7 days, keep you updated while we fix it, and credit you when th
## Scope
## Scope
-Every OpenVibe service and site (connect.openvibe.website and every <name>--<user>.openvibe.website, openvibe.live, openvibe.network, openvibe.today, openvibe.tools,
-openvibe.media, openvibe.community, openvibe.games and the other openvibe.* domains) and every repository under
-github.com/OpenVibers. Machine-readable contact details are at `/.well-known/security.txt` on each site.
+Every OpenVibe service and site (connect.openvibe.link and every <name>--<user>.openvibe.link, the old
+connect.openvibe.website and every <name>--<user>.openvibe.website alias, openvibe.live, openvibe.network,
+openvibe.today, openvibe.tools, openvibe.media, openvibe.community, openvibe.games and the other openvibe.* domains)
+and every repository under github.com/OpenVibers. Machine-readable contact details are at `/.well-known/security.txt`
+on each site.
## What OpenVibe Connect holds
## What OpenVibe Connect holds
@@ -30,7 +32,7 @@ github.com/OpenVibers. Machine-readable contact details are at `/.well-known/sec
@@ -30,7 +32,7 @@ github.com/OpenVibers. Machine-readable contact details are at `/.well-known/sec
- **The gate.** Every visitor signs in with OpenVibe and must be allowed by the share's policy (only the owner, the
- **The gate.** Every visitor signs in with OpenVibe and must be allowed by the share's policy (only the owner, the
owner and named people, or anyone signed in). There are no public shares: those wait for the abuse check and the
owner and named people, or anyone signed in). There are no public shares: those wait for the abuse check and the
- Public Suffix List entry for openvibe.website (ADR-057, CON-9 and CON-21). Because anyone can make an OpenVibe
+ Public Suffix List entry for openvibe.link (ADR-057, CON-9 and CON-21). Because anyone can make an OpenVibe
account, "anyone signed in" is open to OpenVibe staff only until then.
account, "anyone signed in" is open to OpenVibe staff only until then.
- **Host-only cookies.** The gate's cookie on a share host is `__Host-ovc` (Secure, HttpOnly, SameSite=Lax, Path=/, no
- **Host-only cookies.** The gate's cookie on a share host is `__Host-ovc` (Secure, HttpOnly, SameSite=Lax, Path=/, no
Domain), HMAC-signed and bound to that share and its policy version; changing the policy invalidates every cookie.
Domain), HMAC-signed and bound to that share and its policy version; changing the policy invalidates every cookie.
@@ -3,10 +3,10 @@
@@ -3,10 +3,10 @@
"version": "0.1.0",
"version": "0.1.0",
"stage": "product",
"stage": "product",
"code": true,
"code": true,
- "plan": "Website Connect v0 (plan 2026-10-28 §5.9b, ADR-057 reviewed 2026-11-02): share one port on your computer at https://<name>--<user>.openvibe.website behind OpenVibe sign-in",
+ "plan": "OpenVibe Connect v0 (plan 2026-10-28 §5.9b, ADR-057 reviewed 2026-11-02): share one port on your computer at https://<name>--<user>.openvibe.link behind OpenVibe sign-in",
"runtime": "node 22, express 4, ws 8, PostgreSQL 18 (openvibe-sdk/db, async)",
"runtime": "node 22, express 4, ws 8, PostgreSQL 18 (openvibe-sdk/db, async)",
"port": "5240",
"port": "5240",
- "domain": "connect.openvibe.website",
+ "domain": "connect.openvibe.link",
"contracts": "openvibe-contracts v0.338.0",
"contracts": "openvibe-contracts v0.338.0",
"sdk": "openvibe-sdk v0.94.0",
"sdk": "openvibe-sdk v0.94.0",
"shared": "openvibe-shared v4.154.0",
"shared": "openvibe-shared v4.154.0",
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
],
],
"summary": "OpenVibe Connect puts one port on a person's computer on the web at its own HTTPS address, behind OpenVibe sign-in. A one-file client (Node 22, no dependencies) opens one outgoing WebSocket through Cloudflare and carries requests to localhost: HTTP with streaming both ways, server-sent events and WebSocket upgrades. Every visitor signs in and must be allowed by the share's policy (only the owner, named people, or anyone signed in); the share host keeps its own host-only signed cookie. Nothing that passes through a share is stored; a webhook addressed to one is sealed and kept for up to 72 hours until its computer takes it.",
"summary": "OpenVibe Connect puts one port on a person's computer on the web at its own HTTPS address, behind OpenVibe sign-in. A one-file client (Node 22, no dependencies) opens one outgoing WebSocket through Cloudflare and carries requests to localhost: HTTP with streaming both ways, server-sent events and WebSocket upgrades. Every visitor signs in and must be allowed by the share's policy (only the owner, named people, or anyone signed in); the share host keeps its own host-only signed cookie. Nothing that passes through a share is stored; a webhook addressed to one is sealed and kept for up to 72 hours until its computer takes it.",
"works": [
"works": [
- "Shares at <name>--<user>.openvibe.website, never colliding with Space sites; five per person; a closed share keeps its address",
+ "Shares at <name>--<user>.openvibe.link, never colliding with Space sites; five per person; a closed share keeps its address",
"The client: hello with a hashed-at-rest key, streams both ways with backpressure, SSE, WebSocket upgrades as a byte pipe, reconnect, takeover, stop on a bad key",
"The client: hello with a hashed-at-rest key, streams both ways with backpressure, SSE, WebSocket upgrades as a byte pipe, reconnect, takeover, stop on a bad key",
"The gate: /grant with OpenVibe sign-in, one-time codes, __Host-ovc bound to the share and its policy version",
"The gate: /grant with OpenVibe sign-in, one-time codes, __Host-ovc bound to the share and its policy version",
"Durable webhooks (CON-15): an address on each share keeps up to 1,000 webhooks for 72 hours, sealed at rest, replays them in order with x-openvibe-received-at and a delivery log the owner can read",
"Durable webhooks (CON-15): an address on each share keeps up to 1,000 webhooks for 72 hours, sealed at rest, replays them in order with x-openvibe-received-at and a delivery log the owner can read",
@@ -1,15 +1,18 @@
@@ -1,15 +1,18 @@
## ═══════════════════════════════════════════════════════════════
## ═══════════════════════════════════════════════════════════════
## OpenVibe Connect — Nginx configuration (REFERENCE)
## OpenVibe Connect — Nginx configuration (REFERENCE)
##
##
-## Deployed to /etc/nginx/sites-available/connect.openvibe.website.conf (ov-vhost-install). The Node app (port 5240,
-## systemd unit openvibe-connect) serves two kinds of host:
+## Deployed to /etc/nginx/sites-available/connect.openvibe.website.conf (ov-vhost-install). The file name keeps the
+## original host; the Node app (port 5240, systemd unit openvibe-connect) serves four kinds of host:
##
##
-## connect.openvibe.website the pages, the API, sign-in, and the computers' tunnel (WebSocket /tunnel)
-## <label>--<user>.openvibe.website a share: the gate, then the tunnel to that person's computer
+## connect.openvibe.link canonical: the pages, the API, sign-in, and the computers' tunnel (WebSocket /tunnel)
+## <label>--<user>.openvibe.link canonical share hosts: the gate, then the tunnel to that person's computer
+## connect.openvibe.website the old control name: pages 302 to Link; /tunnel still answers
+## <label>--<user>.openvibe.website the old share names: still served, and 302 to Link when CONNECT_REDIRECT_LEGACY=1
##
##
## Every other *.openvibe.website host is OpenVibe.Space's (deploy/nginx/openvibe.website.conf in OpenVibe.Space),
## Every other *.openvibe.website host is OpenVibe.Space's (deploy/nginx/openvibe.website.conf in OpenVibe.Space),
## whose server_name leaves out any host with "--" in it: a Space site's name can never contain "--", so the two never
## whose server_name leaves out any host with "--" in it: a Space site's name can never contain "--", so the two never
-## meet. connect.openvibe.website is an exact name and wins over Space's pattern anyway.
+## meet. connect.openvibe.website is an exact name and wins over Space's pattern anyway. A *.openvibe.link host without
+## "--" is not a Connect host: the Link apex stays Network's.
##
##
## A share streams: no proxy buffering either way (server-sent events, uploads), WebSocket upgrades pass, and a
## A share streams: no proxy buffering either way (server-sent events, uploads), WebSocket upgrades pass, and a
## connection may stay open for an hour. The app sets every cache header itself (a share is never cached by
## connection may stay open for an hour. The app sets every cache header itself (a share is never cached by
@@ -37,7 +40,7 @@ server {
@@ -37,7 +40,7 @@ server {
location / { return 301 https://$host$request_uri; }
location / { return 301 https://$host$request_uri; }
}
}
-# ── connect.openvibe.website ──────────────────────────────────
+# ── connect.openvibe.website (old control name; pages 302 to Link) ──
server {
server {
listen 443 ssl;
listen 443 ssl;
listen [::]:443 ssl;
listen [::]:443 ssl;
@@ -113,7 +116,7 @@ server {
@@ -113,7 +116,7 @@ server {
}
}
}
}
-# ── <label>--<user>.openvibe.website: a share ─────────────────
+# ── <label>--<user>.openvibe.website: an old share alias ──────
server {
server {
listen 443 ssl;
listen 443 ssl;
listen [::]:443 ssl;
listen [::]:443 ssl;
@@ -148,7 +151,7 @@ server {
@@ -148,7 +151,7 @@ server {
}
}
}
}
-# Link aliases: install only once the wildcard certificate exists.
+# The canonical Link hosts.
# ── connect.openvibe.link ──────────────────────────────────
# ── connect.openvibe.link ──────────────────────────────────
server {
server {
listen 443 ssl;
listen 443 ssl;
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
[Unit]
[Unit]
-Description=OpenVibe Connect — your computer, on the web, privately (connect.openvibe.website and every <name>--<user>.openvibe.website)
+Description=OpenVibe Connect — your computer, on the web, privately (connect.openvibe.link and every <name>--<user>.openvibe.link)
# Network (sign-in, JWKS, account events) should be up first, but Connect degrades honestly without it: connected
# Network (sign-in, JWKS, account events) should be up first, but Connect degrades honestly without it: connected
# shares keep passing requests for people already let in, and sign-in waits for the signing key (see /api/ready).
# shares keep passing requests for people already let in, and sign-in waits for the signing key (see /api/ready).
After=network.target postgresql.service pgbouncer.service valkey-server.service
After=network.target postgresql.service pgbouncer.service valkey-server.service
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"name": "openvibe-connect",
"name": "openvibe-connect",
"version": "0.1.0",
"version": "0.1.0",
"private": true,
"private": true,
- "description": "OpenVibe Connect: share one port on your computer at its own HTTPS address on openvibe.website, behind OpenVibe sign-in.",
+ "description": "OpenVibe Connect: share one port on your computer at its own HTTPS address on openvibe.link, behind OpenVibe sign-in.",
"main": "server/index.js",
"main": "server/index.js",
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
/* OpenVibe Connect: the pieces sc.room does not draw. Every colour is a theme token (kit.css, room.css); the room's
/* OpenVibe Connect: the pieces sc.room does not draw. Every colour is a theme token (kit.css, room.css); the room's
- accent is openvibe.website's (--room-accent / --room-fill / --room-on). Phone first; nothing moves on its own. */
+ accent is the Website room's (--room-accent / --room-fill / --room-on). Phone first; nothing moves on its own. */
.cn-wrap { max-width: 1120px; margin: 0 auto; padding: 0 max(16px, env(safe-area-inset-left, 0px)) 48px; }
.cn-wrap { max-width: 1120px; margin: 0 auto; padding: 0 max(16px, env(safe-area-inset-left, 0px)) 48px; }
.cn-page { max-width: 760px; padding-top: 20px; display: grid; gap: 22px; }
.cn-page { max-width: 760px; padding-top: 20px; display: grid; gap: 22px; }
@@ -11,7 +11,7 @@
@@ -11,7 +11,7 @@
*
*
* --key <ovc_…> your share's connect key (or OV_CONNECT_KEY)
* --key <ovc_…> your share's connect key (or OV_CONNECT_KEY)
* --port <n> the local port to share (or the first bare number)
* --port <n> the local port to share (or the first bare number)
- * --server <wss://> default wss://connect.openvibe.website/tunnel
+ * --server <wss://> default wss://connect.openvibe.link/tunnel
* --quiet no line per request
* --quiet no line per request
*
*
* Source: https://github.com/OpenVibers/OpenVibe.Connect (AGPL-3.0). Protocol: ovconnect-ws/1 (server/edge/frames.js).
* Source: https://github.com/OpenVibers/OpenVibe.Connect (AGPL-3.0). Protocol: ovconnect-ws/1 (server/edge/frames.js).
@@ -55,7 +55,7 @@ function main() {
@@ -55,7 +55,7 @@ function main() {
const opts = parseArgs(process.argv.slice(2));
const opts = parseArgs(process.argv.slice(2));
if (opts.version) { console.log(`ov-connect ${VERSION}`); return; }
if (opts.version) { console.log(`ov-connect ${VERSION}`); return; }
if (opts.help || !opts.key || !opts.port) {
if (opts.help || !opts.key || !opts.port) {
- console.log('ov-connect: share a port on this computer at your OpenVibe Connect address.\n\n node ov-connect.js --key ovc_… --port 8080\n\nMake a share and its key at https://connect.openvibe.website');
+ console.log('ov-connect: share a port on this computer at your OpenVibe Connect address.\n\n node ov-connect.js --key ovc_… --port 8080\n\nMake a share and its key at https://connect.openvibe.link');
process.exitCode = opts.help ? 0 : 2;
process.exitCode = opts.help ? 0 : 2;
return;
return;
}
}
@@ -71,7 +71,7 @@ function main() {
@@ -71,7 +71,7 @@ function main() {
/**
/**
* The client itself (exported for tests): createClient({ key, port, server, quiet, log, localHost }) → { start, stop, onReady }
* The client itself (exported for tests): createClient({ key, port, server, quiet, log, localHost }) → { start, stop, onReady }
*/
*/
-const DEFAULT_SERVER = 'wss://connect.openvibe.website/tunnel';
+const DEFAULT_SERVER = 'wss://connect.openvibe.link/tunnel';
function createClient(opts) {
function createClient(opts) {
opts = { ...opts, server: opts.server || DEFAULT_SERVER };
opts = { ...opts, server: opts.server || DEFAULT_SERVER };
@@ -4,9 +4,9 @@
@@ -4,9 +4,9 @@
module.exports = {
module.exports = {
id: "connect",
id: "connect",
name: "OpenVibe Connect",
name: "OpenVibe Connect",
- purpose: "Share one port on your computer at https://<name>--<username>.openvibe.website, behind OpenVibe sign-in; programs pass with a share's API key, MCP clients sign in with OAuth.",
- home: "https://connect.openvibe.website",
- site: "connect.openvibe.website",
+ purpose: "Share one port on your computer at https://<name>--<username>.openvibe.link, behind OpenVibe sign-in; programs pass with a share's API key, MCP clients sign in with OAuth.",
+ home: "https://connect.openvibe.link",
+ site: "connect.openvibe.link",
api: "/api/v1",
api: "/api/v1",
openapi: '/openapi.json',
openapi: '/openapi.json',
policy: {
policy: {
@@ -4,9 +4,9 @@
@@ -4,9 +4,9 @@
* OpenVibe.Connect: the Express app factory; server/index.js listens, tests build their own instance with a temp
* OpenVibe.Connect: the Express app factory; server/index.js listens, tests build their own instance with a temp
* database and a mock Network.
* database and a mock Network.
*
*
- * <label>--<user>.openvibe.website/* a share: the gate, then the tunnel (edge/proxy.js), before anything else
+ * <label>--<user>.openvibe.link/* a share: the gate, then the tunnel (edge/proxy.js), before anything else
* /, /shares/*, /grant the pages (http/pages.js)
* /, /shares/*, /grant the pages (http/pages.js)
- * /api/v1/node/* a person's computer as OpenVibe Node (http/node-api.js, CON-4)
+ * /api/v1/node/* a person's computer as OpenVibe Link (http/node-api.js, CON-4)
* /api/v1/* the API (http/api.js)
* /api/v1/* the API (http/api.js)
* /ov-connect.js the client, served as it is in public/
* /ov-connect.js the client, served as it is in public/
* /auth/* Network SSO with PKCE (auth/sso.js)
* /auth/* Network SSO with PKCE (auth/sso.js)
@@ -243,7 +243,7 @@ async function createApp(opts = {}) {
@@ -243,7 +243,7 @@ async function createApp(opts = {}) {
// ── Sign-in (OAuth2 + PKCE client of OpenVibe.Network) ──
// ── Sign-in (OAuth2 + PKCE client of OpenVibe.Network) ──
app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));
app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));
app.use('/auth', sso.routes());
app.use('/auth', sso.routes());
- { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'connect', service: 'connect', host: 'connect.openvibe.website', name: 'OpenVibe Connect', profile: 'ugc' })); }
+ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'connect', service: 'connect', host: 'connect.openvibe.link', name: 'OpenVibe Connect', profile: 'ugc' })); }
// ── Webhooks to a share's hooks, and Events' deliveries of them back (CON-15; /internal/ is loopback only) ──
// ── Webhooks to a share's hooks, and Events' deliveries of them back (CON-15; /internal/ is loopback only) ──
app.use(createHookRoutes(ctx));
app.use(createHookRoutes(ctx));
@@ -1,10 +1,11 @@
@@ -1,10 +1,11 @@
'use strict';
'use strict';
/**
/**
- * A person's computer, as OpenVibe Node (CON-4, ADR-057 decision 4). Two credentials, both checked:
+ * A person's computer, as OpenVibe Link (CON-4, ADR-057 decision 4). Two credentials, both checked:
*
*
* 1. its Network node token: an RS256 service token whose subject is node:<nod_…> (actor_type node), for audience
* 1. its Network node token: an RS256 service token whose subject is node:<nod_…> (actor_type node), for audience
- * openvibe.website, 300 s. Network issues it to any active machine a person owns (Network #538);
+ * openvibe.link (old binaries: openvibe.website), 300 s. Network issues it to any active machine a person owns
+ * (Network #538);
* 2. a tunnel token Connect mints for one share and that computer: `ovt_` + 32 random bytes, 60 s, one use. It is
* 2. a tunnel token Connect mints for one share and that computer: `ovt_` + 32 random bytes, 60 s, one use. It is
* kept in memory as a SHA-256 hash only, never logged, stored or put in an event.
* kept in memory as a SHA-256 hash only, never logged, stored or put in an event.
*
*
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
/**
/**
- * OpenVibe.Connect configuration (connect.openvibe.website). Every value comes from the environment (production:
+ * OpenVibe.Connect configuration (connect.openvibe.link). Every value comes from the environment (production:
* /etc/openvibe/connect.env, see .env.example). Only environment variable NAMES appear in code and docs; secrets are
* /etc/openvibe/connect.env, see .env.example). Only environment variable NAMES appear in code and docs; secrets are
* never logged.
* never logged.
*
*
@@ -16,7 +16,7 @@ function load(env = process.env) {
@@ -16,7 +16,7 @@ function load(env = process.env) {
const nodeEnv = env.NODE_ENV || 'development';
const nodeEnv = env.NODE_ENV || 'development';
const isProduction = nodeEnv === 'production';
const isProduction = nodeEnv === 'production';
const port = int(env.PORT, 5240);
const port = int(env.PORT, 5240);
- const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://connect.openvibe.website' : `http://localhost:${port}`));
+ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://connect.openvibe.link' : `http://localhost:${port}`));
const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
return {
return {
@@ -41,7 +41,7 @@ function load(env = process.env) {
@@ -41,7 +41,7 @@ function load(env = process.env) {
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.
// 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.
+ // Old binaries still request the Website audience during the transition.
nodeAudiences: [...new Set([env.CONNECT_AUDIENCE || 'openvibe.link', 'openvibe.link', 'openvibe.website'])],
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',
@@ -3,10 +3,10 @@
@@ -3,10 +3,10 @@
/**
/**
* The gate in front of every share: who may open it, and how a visitor's browser proves it on a share host.
* The gate in front of every share: who may open it, and how a visitor's browser proves it on a share host.
*
*
- * A share host is another origin (agents--alex.openvibe.website), and openvibe.website is not on the Public Suffix
+ * A share host is another origin (agents--alex.openvibe.link), and openvibe.link is not on the Public Suffix
* List yet, so the gate keeps every cookie host-only and signs it itself:
* List yet, so the gate keeps every cookie host-only and signs it itself:
*
*
- * 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.link/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 host) 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=….
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
/**
/**
- * The visitor's side of a share: every request to https://<label>--<user>.openvibe.website.
+ * The visitor's side of a share: every request to https://<label>--<user>.openvibe.link.
*
*
* handle(req, res) an HTTP request (SSE and streaming bodies included)
* handle(req, res) an HTTP request (SSE and streaming bodies included)
* upgrade(req, socket, head) a WebSocket upgrade: after the gate, a byte pipe to the local port
* upgrade(req, socket, head) a WebSocket upgrade: after the gate, a byte pipe to the local port
@@ -13,8 +13,8 @@
@@ -13,8 +13,8 @@
* X-Forwarded-Host, the visitor's address in X-Forwarded-For, and who they are in X-OpenVibe-User and
* X-Forwarded-Host, the visitor's address in X-Forwarded-For, and who they are in X-OpenVibe-User and
* X-OpenVibe-Subject (headers of those names from the visitor are dropped). The gate's own cookie never reaches it.
* X-OpenVibe-Subject (headers of those names from the visitor are dropped). The gate's own cookie never reaches it.
*
*
- * Responses: a Set-Cookie's Domain attribute is removed (until openvibe.website is on the Public Suffix List, a
- * cookie for .openvibe.website would reach every other share and every Space site), and nothing a share sends may be
+ * Responses: a Set-Cookie's Domain attribute is removed (until openvibe.link is on the Public Suffix List, a
+ * cookie for .openvibe.link would reach every other share), and nothing a share sends may be
* kept by Cloudflare's cache (Cloudflare-CDN-Cache-Control: no-store; Cache-Control is made private): a cached copy
* kept by Cloudflare's cache (Cloudflare-CDN-Cache-Control: no-store; Cache-Control is made private): a cached copy
* would be served past the gate.
* would be served past the gate.
*/
*/
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
/**
/**
* The tunnels: one WebSocket per connected share, opened by the client (public/ov-connect.js) to
* The tunnels: one WebSocket per connected share, opened by the client (public/ov-connect.js) to
- * wss://connect.openvibe.website/tunnel and authenticated by its first message, { t: 'hello', key }.
+ * wss://connect.openvibe.link/tunnel and authenticated by its first message, { t: 'hello', key }.
*
*
* createTunnels({ shares, config, log }) → { handleUpgrade(req, socket, head), get(shareId), close(shareId, code, why),
* createTunnels({ shares, config, log }) → { handleUpgrade(req, socket, head), get(shareId), close(shareId, code, why),
* closeAll(), status() }
* closeAll(), status() }
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
/**
/**
* Hooks that wait for your computer (CON-15, ADR-057 decision 7). A hook is an address a provider sends webhooks to,
* Hooks that wait for your computer (CON-15, ADR-057 decision 7). A hook is an address a provider sends webhooks to,
- * https://connect.openvibe.website/h/<hk_…>, bound to one share and a path on the computer's app. A webhook is kept
+ * https://connect.openvibe.link/h/<hk_…>, bound to one share and a path on the computer's app. A webhook is kept
* (72 hours) and a pointer to it is published as connect.hook.received through the outbox, in one transaction. Events
* (72 hours) and a pointer to it is published as connect.hook.received through the outbox, in one transaction. Events
* delivers it to Connect's own subscription (retried for up to 72 hours), and Connect hands it to the share's
* delivers it to Connect's own subscription (retried for up to 72 hours), and Connect hands it to the share's
* computer when it is connected. The computer may be asleep for days and still get every webhook, in order of arrival,
* computer when it is connected. The computer may be asleep for days and still get every webhook, in order of arrival,
@@ -2,21 +2,21 @@
@@ -2,21 +2,21 @@
/**
/**
* /api/v1: OpenVibe.Connect's JSON API, for a person (the connect session cookie, or a Network user token as a
* /api/v1: OpenVibe.Connect's JSON API, for a person (the connect session cookie, or a Network user token as a
- * Bearer). A session write must come from connect.openvibe.website itself (403 connect.cross_site).
+ * Bearer). A session write must come from connect.openvibe.link itself (403 connect.cross_site).
*
*
* GET /shares your shares (website.share@1 shapes, plus connected)
* GET /shares your shares (website.share@1 shapes, plus connected)
* POST /shares { label, port, mode?, named?, expires_in? } → 201 { share, key } the key is in this answer only
* POST /shares { label, port, mode?, named?, expires_in? } → 201 { share, key } the key is in this answer only
* GET /shares/:id one of your shares
* GET /shares/:id one of your shares
* PATCH /shares/:id { mode?, named?, port?, note?, expires_in?, offline_site? } expires_in: seconds from now
* PATCH /shares/:id { mode?, named?, port?, note?, expires_in?, offline_site? } expires_in: seconds from now
* (600 to 30 days), or null to stay open until you close it; offline_site: where a browser
* (600 to 30 days), or null to stay open until you close it; offline_site: where a browser
- * goes while the computer is off, one of the sites on the domain (CON-17), '' or null to clear
+ * goes while the computer is off, one of your OpenVibe.Space sites (CON-17), '' or null to clear
* DELETE /shares/:id close it (the host answers 410 from then on)
* DELETE /shares/:id close it (the host answers 410 from then on)
* POST /shares/:id/key a new key → { share, key }; the connected computer is disconnected
* POST /shares/:id/key a new key → { share, key }; the connected computer is disconnected
* PUT /shares/:id/node { node: 'nod_…' } one of your computers serves it, as OpenVibe Link (CON-4): the key
* PUT /shares/:id/node { node: 'nod_…' } one of your computers serves it, as OpenVibe Link (CON-4): the key
* stops working → { share }; { node: null } lets the computer go → { share, key } (a new key)
* stops working → { share }; { node: null } lets the computer go → { share, key } (a new key)
* GET /machines your computers that can serve a share (Network's list of your machines)
* GET /machines your computers that can serve a share (Network's list of your machines)
* GET /shares/:id/hooks its webhook addresses (CON-15): each kept webhook waits up to 72 hours for the computer
* GET /shares/:id/hooks its webhook addresses (CON-15): each kept webhook waits up to 72 hours for the computer
- * POST /shares/:id/hooks { path, label? } → 201 { hook }: https://connect.openvibe.website/h/<id> → that path
+ * POST /shares/:id/hooks { path, label? } → 201 { hook }: https://connect.openvibe.link/h/<id> → that path
* DELETE /shares/:id/hooks/:hook removes it; webhooks still waiting are dropped
* DELETE /shares/:id/hooks/:hook removes it; webhooks still waiting are dropped
* GET /shares/:id/hooks/:hook/deliveries the owner's log: what the app answered, and when
* GET /shares/:id/hooks/:hook/deliveries the owner's log: what the app answered, and when
* GET /shares/:id/visits who opened it in the last 7 days
* GET /shares/:id/visits who opened it in the last 7 days
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
/**
/**
* /api/v1/node: a person's computer, as OpenVibe Link (CON-4, ADR-057 decision 4). Every call carries the computer's
* /api/v1/node: a person's computer, as OpenVibe Link (CON-4, ADR-057 decision 4). Every call carries the computer's
- * Network node token (Authorization: Bearer, subject node:nod_…, audience openvibe.website); nothing here answers to a
+ * Network node token (Authorization: Bearer, subject node:nod_…, audience openvibe.link); nothing here answers to a
* person's token or the session.
* person's token or the session.
*
*
* GET /shares the open shares this computer serves → { shares: [{ id, host, url, local_port, mode }] }
* GET /shares the open shares this computer serves → { shares: [{ id, host, url, local_port, mode }] }
@@ -14,14 +14,15 @@ const { http } = require('openvibe-contracts');
@@ -14,14 +14,15 @@ const { http } = require('openvibe-contracts');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
function createNodeApi(ctx) {
function createNodeApi(ctx) {
- const { shares, nodeAuth } = ctx;
+ const { config, shares, nodeAuth } = ctx;
+ const host = new URL(config.baseUrl).host;
const r = asyncRouter();
const r = asyncRouter();
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
const fail = (res, req, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
const fail = (res, req, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
r.use(async (req, res, next) => {
r.use(async (req, res, next) => {
const header = String(req.headers.authorization || '');
const header = String(req.headers.authorization || '');
- if (!header.startsWith('Bearer ')) return fail(res, req, 401, 'token.required', 'send this computer\'s Network node token (audience openvibe.website) as a Bearer');
+ if (!header.startsWith('Bearer ')) return fail(res, req, 401, 'token.required', 'send this computer\'s Network node token (audience openvibe.link) as a Bearer');
const v = await nodeAuth.verify(header.slice(7).trim());
const v = await nodeAuth.verify(header.slice(7).trim());
if (!v.ok) return fail(res, req, v.status, v.code, v.reason);
if (!v.ok) return fail(res, req, v.status, v.code, v.reason);
req.node = v.node;
req.node = v.node;
@@ -35,7 +36,7 @@ function createNodeApi(ctx) {
@@ -35,7 +36,7 @@ function createNodeApi(ctx) {
r.post('/shares/:id/tunnel-token', async (req, res) => {
r.post('/shares/:id/tunnel-token', async (req, res) => {
const share = await shares.get(req.params.id);
const share = await shares.get(req.params.id);
// Another person's share and a share this computer does not serve look the same: not found.
// Another person's share and a share this computer does not serve look the same: not found.
- if (!share || !share.node || share.node.id !== req.node) return fail(res, req, 404, 'share.not_found', 'This computer does not serve that share. Pick it on the share\'s page on connect.openvibe.website.');
+ if (!share || !share.node || share.node.id !== req.node) return fail(res, req, 404, 'share.not_found', `This computer does not serve that share. Pick it on the share's page on ${host}.`);
if (share.status !== 'active') return fail(res, req, 410, 'share.revoked', 'That share is closed.');
if (share.status !== 'active') return fail(res, req, 410, 'share.revoked', 'That share is closed.');
const t = nodeAuth.mint(share.id, req.node);
const t = nodeAuth.mint(share.id, req.node);
return res.json({ tunnel_token: t.token, expires_in: t.expires_in, share: brief(share) });
return res.json({ tunnel_token: t.token, expires_in: t.expires_in, share: brief(share) });
@@ -10,7 +10,7 @@
@@ -10,7 +10,7 @@
* POST /oauth/token code + PKCE verifier, or a refresh token → tokens
* POST /oauth/token code + PKCE verifier, or a refresh token → tokens
*
*
* The metadata, registration and token answers carry CORS for any origin (MCP clients run in browsers too); the
* The metadata, registration and token answers carry CORS for any origin (MCP clients run in browsers too); the
- * consent is a page on connect.openvibe.website and cannot be posted from elsewhere.
+ * consent is a page on connect.openvibe.link and cannot be posted from elsewhere.
*/
*/
const express = require('express');
const express = require('express');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
@@ -23,6 +23,7 @@ function createOAuthRoutes(ctx) {
@@ -23,6 +23,7 @@ function createOAuthRoutes(ctx) {
const { config, oauth, shares, proxy } = ctx;
const { config, oauth, shares, proxy } = ctx;
const r = asyncRouter();
const r = asyncRouter();
const issuer = config.baseUrl;
const issuer = config.baseUrl;
+ const host = new URL(issuer).host;
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
const cors = (res) => { res.set('Access-Control-Allow-Origin', '*'); res.set('Access-Control-Allow-Headers', 'content-type, authorization, mcp-protocol-version'); res.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); };
const cors = (res) => { res.set('Access-Control-Allow-Origin', '*'); res.set('Access-Control-Allow-Headers', 'content-type, authorization, mcp-protocol-version'); res.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); };
@@ -112,7 +113,7 @@ function createOAuthRoutes(ctx) {
@@ -112,7 +113,7 @@ function createOAuthRoutes(ctx) {
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
res.set('Referrer-Policy', 'no-referrer');
res.set('Referrer-Policy', 'no-referrer');
if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2F');
if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2F');
- if (!sameOrigin(req, config.baseUrl)) return page(req, res, { title: 'Not from this site', body: views.message({ title: 'That came from somewhere else', text: 'Answer on connect.openvibe.website itself.', link: { href: '/', label: 'OpenVibe Connect' } }) }, 403);
+ if (!sameOrigin(req, config.baseUrl)) return page(req, res, { title: 'Not from this site', body: views.message({ title: 'That came from somewhere else', text: `Answer on ${host} itself.`, link: { href: '/', label: 'OpenVibe Connect' } }) }, 403);
const q = pick(req.body || {});
const q = pick(req.body || {});
const ok = await checked(req, res, q);
const ok = await checked(req, res, q);
if (!ok) return undefined;
if (!ok) return undefined;
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
/**
/**
- * Connect's pages on connect.openvibe.website. They work without JavaScript: forms are plain POSTs.
+ * Connect's pages on connect.openvibe.link. They work without JavaScript: forms are plain POSTs.
*
*
* / the front door; signed in, your shares and the form to make one
* / the front door; signed in, your shares and the form to make one
* POST /shares make a share → its page, with the key shown once
* POST /shares make a share → its page, with the key shown once
@@ -32,6 +32,7 @@ const seo = require('openvibe-shared/seo');
@@ -32,6 +32,7 @@ const seo = require('openvibe-shared/seo');
function createPageRoutes(ctx) {
function createPageRoutes(ctx) {
const { config, shares, tunnels, gate, proxy, machines, hooks, apiKeys } = ctx;
const { config, shares, tunnels, gate, proxy, machines, hooks, apiKeys } = ctx;
+ const host = new URL(config.baseUrl).host;
const r = asyncRouter();
const r = asyncRouter();
r.use(express.urlencoded({ extended: false, limit: '16kb' }));
r.use(express.urlencoded({ extended: false, limit: '16kb' }));
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
@@ -41,7 +42,7 @@ function createPageRoutes(ctx) {
@@ -41,7 +42,7 @@ function createPageRoutes(ctx) {
const guard = (req, res) => {
const guard = (req, res) => {
if (!signedIn(req)) { res.redirect(303, loginTo(req.originalUrl)); return false; }
if (!signedIn(req)) { res.redirect(303, loginTo(req.originalUrl)); return false; }
if (!sameOrigin(req, config.baseUrl)) {
if (!sameOrigin(req, config.baseUrl)) {
- page(req, res, { title: 'Not from this site', body: views.message({ title: 'That came from somewhere else', text: 'Changes to your shares must be made on connect.openvibe.website itself.', link: { href: '/', label: 'Your shares' } }) }, 403);
+ page(req, res, { title: 'Not from this site', body: views.message({ title: 'That came from somewhere else', text: `Changes to your shares must be made on ${host} itself.`, link: { href: '/', label: 'Your shares' } }) }, 403);
return false;
return false;
}
}
return true;
return true;
@@ -274,7 +275,7 @@ function createPageRoutes(ctx) {
@@ -274,7 +275,7 @@ function createPageRoutes(ctx) {
r.post('/report', async (req, res) => {
r.post('/report', async (req, res) => {
const values = { host: req.body.host, reason: req.body.reason, details: req.body.details };
const values = { host: req.body.host, reason: req.body.reason, details: req.body.details };
const again = (status, error) => page(req, res, { title: 'Report a share', body: views.reportForm({ values, error, reasons: REPORT_REASONS, signedIn: signedIn(req) }) }, status);
const again = (status, error) => page(req, res, { title: 'Report a share', body: views.reportForm({ values, error, reasons: REPORT_REASONS, signedIn: signedIn(req) }) }, status);
- if (!sameOrigin(req, config.baseUrl)) return again(403, 'Send the report from connect.openvibe.website itself.');
+ if (!sameOrigin(req, config.baseUrl)) return again(403, `Send the report from ${host} itself.`);
if (overReports(req)) return again(429, 'That is a lot of reports in an hour. Try again later, or write to OpenVibe from the Help site.');
if (overReports(req)) return again(429, 'That is a lot of reports in an hour. Try again later, or write to OpenVibe from the Help site.');
try {
try {
await shares.report({ host: values.host, reason: values.reason, details: values.details, reporter: signedIn(req) ? req.viewer.subject : null });
await shares.report({ host: values.host, reason: values.reason, details: values.details, reporter: signedIn(req) ? req.viewer.subject : null });
@@ -297,7 +298,7 @@ function createPageRoutes(ctx) {
@@ -297,7 +298,7 @@ function createPageRoutes(ctx) {
});
});
r.post('/admin/reports/:id/:action', async (req, res) => {
r.post('/admin/reports/:id/:action', async (req, res) => {
if (!staffOnly(req, res)) return undefined;
if (!staffOnly(req, res)) return undefined;
- if (!sameOrigin(req, config.baseUrl)) return page(req, res, { title: 'Not from this site', body: views.message({ title: 'That came from somewhere else', text: 'Handle reports on connect.openvibe.website itself.', link: { href: '/admin/reports', label: 'Reports' } }) }, 403);
+ if (!sameOrigin(req, config.baseUrl)) return page(req, res, { title: 'Not from this site', body: views.message({ title: 'That came from somewhere else', text: `Handle reports on ${host} itself.`, link: { href: '/admin/reports', label: 'Reports' } }) }, 403);
try {
try {
const out = await shares.handleReport(owner(req), req.params.id, req.params.action);
const out = await shares.handleReport(owner(req), req.params.id, req.params.action);
if (out.action === 'close' && out.share) {
if (out.action === 'close' && out.share) {
@@ -325,9 +326,9 @@ function createPageRoutes(ctx) {
@@ -325,9 +326,9 @@ function createPageRoutes(ctx) {
// What llms.txt says (SEO-33: /llms-full.txt adds the README, Connect's full documentation, after it).
// What llms.txt says (SEO-33: /llms-full.txt adds the README, Connect's full documentation, after it).
const NAME = 'OpenVibe Connect';
const NAME = 'OpenVibe Connect';
- const SUMMARY = 'Share one port on your computer at https://<name>--<username>.openvibe.website, behind OpenVibe sign-in.';
+ const SUMMARY = `Share one port on your computer at https://<name>--<username>.${config.shares.domain}, behind OpenVibe sign-in.`;
const DETAILS = [
const DETAILS = [
- 'Client: one file, Node 22+: curl -fsSLO https://connect.openvibe.website/ov-connect.js; node ov-connect.js --key ovc_… --port 8080.',
+ `Client: one file, Node 22+: curl -fsSLO ${site}/ov-connect.js; node ov-connect.js --key ovc_… --port 8080.`,
'Who can open a share: only the owner, the owner and named OpenVibe usernames, or anyone signed in to OpenVibe. No public shares yet.',
'Who can open a share: only the owner, the owner and named OpenVibe usernames, or anyone signed in to OpenVibe. No public shares yet.',
'The app behind a share receives X-OpenVibe-User and X-OpenVibe-Subject; Host is localhost:<port>.',
'The app behind a share receives X-OpenVibe-User and X-OpenVibe-Subject; Host is localhost:<port>.',
'API (a person\'s Network token as a Bearer): GET/POST /api/v1/shares, GET/PATCH/DELETE /api/v1/shares/{id}, POST /api/v1/shares/{id}/key.',
'API (a person\'s Network token as a Bearer): GET/POST /api/v1/shares, GET/PATCH/DELETE /api/v1/shares/{id}, POST /api/v1/shares/{id}/key.',
@@ -6,8 +6,9 @@
@@ -6,8 +6,9 @@
* { kind: 'user', subject: 'usr_…', username, role, token, viaSession }
* { kind: 'user', subject: 'usr_…', username, role, token, viaSession }
* a person: their Network user token as a Bearer, or this site's session cookie (the connect session).
* a person: their Network user token as a Bearer, or this site's session cookie (the connect session).
* { kind: 'app', requester: 'app:…' | 'agent:…' | 'service:…', project, claims }
* { kind: 'app', requester: 'app:…' | 'agent:…' | 'service:…', project, claims }
- * a Network app, agent or service token for audience connect.openvibe.website. No Connect route needs one; the shape is
- * kept so a future capability route can be added in one place.
+ * a Network app, agent or service token for Connect's service audience (CONNECT_AUDIENCE,
+ * openvibe.website). No Connect route needs one; the shape is kept so a future capability route can be added in
+ * one place.
* { kind: 'anonymous' }
* { kind: 'anonymous' }
*
*
* A request that presents a token is judged on that token alone: a bad one is refused, never downgraded to the
* A request that presents a token is judged on that token alone: a bad one is refused, never downgraded to the
@@ -50,6 +51,7 @@ function sameOrigin(req, baseUrl) {
@@ -50,6 +51,7 @@ function sameOrigin(req, baseUrl) {
}
}
function createPrincipal({ config, keys }) {
function createPrincipal({ config, keys }) {
+ const host = new URL(config.baseUrl).host;
async function resolve(req) {
async function resolve(req) {
const header = String(req.headers.authorization || '');
const header = String(req.headers.authorization || '');
if (header.startsWith('Bearer ')) {
if (header.startsWith('Bearer ')) {
@@ -86,9 +88,9 @@ function createPrincipal({ config, keys }) {
@@ -86,9 +88,9 @@ function createPrincipal({ config, keys }) {
function require_(cap) {
function require_(cap) {
if (!CAPABILITIES.includes(cap)) throw new Error(`requireCapability(${cap}): not one of OpenVibe.Connect's capabilities`);
if (!CAPABILITIES.includes(cap)) throw new Error(`requireCapability(${cap}): not one of OpenVibe.Connect's capabilities`);
return function guard(req, res, next) {
return function guard(req, res, next) {
- if (req.principal.kind === 'anonymous') return http.sendProblem(res, 401, 'token.required', { detail: 'Sign in at connect.openvibe.website, or send a person\'s Network token as a Bearer.', ctx: req.ov });
+ if (req.principal.kind === 'anonymous') return http.sendProblem(res, 401, 'token.required', { detail: `Sign in at ${host}, or send a person's Network token as a Bearer.`, ctx: req.ov });
if (req.principal.kind === 'user') {
if (req.principal.kind === 'user') {
- if (req.principal.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) return http.sendProblem(res, 403, 'connect.cross_site', { detail: 'A signed-in request that changes something must come from connect.openvibe.website itself.', ctx: req.ov });
+ if (req.principal.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) return http.sendProblem(res, 403, 'connect.cross_site', { detail: `A signed-in request that changes something must come from ${host} itself.`, ctx: req.ov });
return next();
return next();
}
}
return http.sendProblem(res, 403, 'connect.person_required', { detail: 'This route is for a person, not an app.', ctx: req.ov });
return http.sendProblem(res, 403, 'connect.person_required', { detail: 'This route is for a person, not an app.', ctx: req.ov });
@@ -99,12 +101,12 @@ function createPrincipal({ config, keys }) {
@@ -99,12 +101,12 @@ function createPrincipal({ config, keys }) {
function requirePerson(req, res, next) {
function requirePerson(req, res, next) {
if (req.principal.kind === 'user') {
if (req.principal.kind === 'user') {
if (req.principal.viaSession && req.method !== 'GET' && req.method !== 'HEAD' && !sameOrigin(req, config.baseUrl)) {
if (req.principal.viaSession && req.method !== 'GET' && req.method !== 'HEAD' && !sameOrigin(req, config.baseUrl)) {
- return http.sendProblem(res, 403, 'connect.cross_site', { detail: 'A signed-in request that changes something must come from connect.openvibe.website itself.', ctx: req.ov });
+ return http.sendProblem(res, 403, 'connect.cross_site', { detail: `A signed-in request that changes something must come from ${host} itself.`, ctx: req.ov });
}
}
return next();
return next();
}
}
if (req.principal.kind === 'app') return http.sendProblem(res, 403, 'connect.person_required', { detail: 'This route is for a person, not an app or a service.', ctx: req.ov });
if (req.principal.kind === 'app') return http.sendProblem(res, 403, 'connect.person_required', { detail: 'This route is for a person, not an app or a service.', ctx: req.ov });
- return http.sendProblem(res, 401, 'token.required', { detail: 'Sign in at connect.openvibe.website, or send a person\'s Network token as a Bearer.', ctx: req.ov });
+ return http.sendProblem(res, 401, 'token.required', { detail: `Sign in at ${host}, or send a person's Network token as a Bearer.`, ctx: req.ov });
}
}
return { middleware, resolve, requireCapability: require_, requirePerson };
return { middleware, resolve, requireCapability: require_, requirePerson };
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
/**
/**
* OpenVibe.Connect: process entry. `node server/index.js`
* OpenVibe.Connect: process entry. `node server/index.js`
- * Listens on PORT (5240) behind nginx (deploy/): connect.openvibe.website and every <label>--<user>.openvibe.website.
+ * Listens on PORT (5240) behind nginx (deploy/): connect.openvibe.link and every <label>--<user>.openvibe.link.
*/
*/
const { createApp } = require('./app');
const { createApp } = require('./app');
const { gracefulStop } = require('openvibe-sdk/service');
const { gracefulStop } = require('openvibe-sdk/service');
@@ -65,7 +65,7 @@ function createProbe({ shares, proxy, config, log = console, now = () => Date.no
@@ -65,7 +65,7 @@ function createProbe({ shares, proxy, config, log = console, now = () => Date.no
if (!force && now() - at < DAY_MS) return null;
if (!force && now() - at < DAY_MS) return null;
last.set(share.id, now());
last.set(share.id, now());
if (last.size > 10000) last.delete(last.keys().next().value);
if (last.size > 10000) last.delete(last.keys().next().value);
- const out = await proxy.fetchThrough(share, { path: '/', headers: [['x-openvibe-probe', signature(share.id, secret, Math.floor(now() / 1000))], ['user-agent', 'OpenVibeConnectProbe/1 (+https://connect.openvibe.website/report)']] });
+ const out = await proxy.fetchThrough(share, { path: '/', headers: [['x-openvibe-probe', signature(share.id, secret, Math.floor(now() / 1000))], ['user-agent', `OpenVibeConnectProbe/1 (+${String(config.baseUrl).replace(/\/+$/, '')}/report)`]] });
if (!out || out.offline || out.error || !out.body) return null;
if (!out || out.offline || out.error || !out.body) return null;
if (!/html/i.test(out.type || '') && !/<html|<form/i.test(out.body.toString('utf8', 0, 2048))) return { verdict: 'ok', flags: [], brands: [], why: null };
if (!/html/i.test(out.type || '') && !/<html|<form/i.test(out.body.toString('utf8', 0, 2048))) return { verdict: 'ok', flags: [], brands: [], why: null };
const a = assess(out.body.toString('utf8'), share);
const a = assess(out.body.toString('utf8'), share);
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
/**
/**
* Page shell. Every page is server-rendered through openvibe-shared/shell and is complete without JavaScript.
* Page shell. Every page is server-rendered through openvibe-shared/shell and is complete without JavaScript.
- * Connect is the "your machine" half of openvibe.website, so it wears that room's accent (openvibe-shared/rooms
+ * Connect is the "your machine" half of OpenVibe Link, so it wears the Website room's accent (openvibe-shared/rooms
* `website`, #8EA0FF dark / #4B53C2 light); the navigation's name is `Connect`.
* `website`, #8EA0FF dark / #4B53C2 light); the navigation's name is `Connect`.
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
@@ -24,7 +24,7 @@ const NAV = [
@@ -24,7 +24,7 @@ const NAV = [
{ label: 'Connect', href: '/', icon: 'fa-plug' },
{ label: 'Connect', href: '/', icon: 'fa-plug' },
{ label: 'Sites', href: 'https://openvibe.space', icon: 'fa-globe' },
{ label: 'Sites', href: 'https://openvibe.space', icon: 'fa-globe' },
];
];
-const API_BASE = 'https://connect.openvibe.website';
+const API_BASE = 'https://connect.openvibe.link';
const hashes = new Map();
const hashes = new Map();
function assetVersion(rel) {
function assetVersion(rel) {
@@ -50,9 +50,9 @@ function layout(o) {
@@ -50,9 +50,9 @@ function layout(o) {
const canonical = o.canonical === undefined ? `${String(config.baseUrl).replace(/\/+$/, '')}${pathNow.split('?')[0]}` : o.canonical;
const canonical = o.canonical === undefined ? `${String(config.baseUrl).replace(/\/+$/, '')}${pathNow.split('?')[0]}` : o.canonical;
const navbar = {
const navbar = {
service: 'connect',
service: 'connect',
- // Connect.OpenVibe.Website, the way the navbar names every product on a subdomain (Bench.OpenVibe.Services).
+ // Connect.OpenVibe.Link, the way the navbar names every product on a subdomain (Bench.OpenVibe.Services).
// With tldLabel 'Connect' the host's own "connect" was added in front: "Connect.OpenVibe.Connect" (11-02).
// With tldLabel 'Connect' the host's own "connect" was added in front: "Connect.OpenVibe.Connect" (11-02).
- brand: { sub: 'connect', tld: 'website' },
+ brand: { sub: 'connect', tld: 'link' },
apiBase: NETWORK_URL,
apiBase: NETWORK_URL,
links: NAV.map((l) => ({ ...l, active: l.href === active || (l.href !== '/' && pathNow.startsWith(`${l.href}/`)) })),
links: NAV.map((l) => ({ ...l, active: l.href === active || (l.href !== '/' && pathNow.startsWith(`${l.href}/`)) })),
appBar: [
appBar: [
@@ -18,7 +18,7 @@ const SERVICE = 'connect';
@@ -18,7 +18,7 @@ const SERVICE = 'connect';
const NAMESPACE = 'connect.signin_apps';
const NAMESPACE = 'connect.signin_apps';
const BASE_PATH = '/internal/config';
const BASE_PATH = '/internal/config';
const GROUP = 'Sign-in apps';
const GROUP = 'Sign-in apps';
-const REDIRECT = 'https://connect.openvibe.website/auth/callback';
+const REDIRECT = 'https://connect.openvibe.link/auth/callback';
/**
/**
* One entry per outside service. `env` names the fallback variables; `test` is the one cheap
* One entry per outside service. `env` names the fallback variables; `test` is the one cheap
@@ -47,13 +47,13 @@ const SIGNIN_NOT_YET = '"Anyone signed in" opens with the abuse check. For now,
@@ -47,13 +47,13 @@ const SIGNIN_NOT_YET = '"Anyone signed in" opens with the abuse check. For now,
const isStaff = (owner) => Boolean(owner && owner.role === 'admin');
const isStaff = (owner) => Boolean(owner && owner.role === 'admin');
// A Space site's name: one DNS label with single hyphens, so never a share's "--" host (OpenVibe.Space's NAME_RE).
// A Space site's name: one DNS label with single hyphens, so never a share's "--" host (OpenVibe.Space's NAME_RE).
const SITE_LABEL_RE = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,62}$/;
const SITE_LABEL_RE = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,62}$/;
-const OFFLINE_SITE_HELP = 'The fallback is one of the sites on this domain, such as your OpenVibe.Space site: https://<name>.openvibe.website, optionally with a path.';
+const OFFLINE_SITE_HELP = 'The fallback is one of your OpenVibe.Space sites: https://<name>.openvibe.website, optionally with a path.';
/**
/**
* Where a browser goes while a share's computer is off (CON-17): '' / null / undefined-as-cleared → null; otherwise
* Where a browser goes while a share's computer is off (CON-17): '' / null / undefined-as-cleared → null; otherwise
- * a site on the share domain itself (https://<name>.<domain>, one label, never a share's "--" host), no port and no
- * credentials, a path and query allowed, the fragment dropped. Anything else throws, so a share can never send its
- * visitors off the domain.
+ * a Space site on the Website domain (https://<name>.openvibe.website, one label, never a share's "--" host), no port
+ * and no credentials, a path and query allowed, the fragment dropped. Anything else throws, so a share can never send
+ * its visitors off the allowed domain.
*/
*/
function offlineSite(value, { domain, scheme = 'https' }) {
function offlineSite(value, { domain, scheme = 'https' }) {
if (value == null || String(value).trim() === '') return null;
if (value == null || String(value).trim() === '') return null;
@@ -86,7 +86,7 @@ function hostFor(label, username, domain) {
@@ -86,7 +86,7 @@ function hostFor(label, username, domain) {
return `${first}.${domain}`;
return `${first}.${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.link" → { 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;
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+$/, '');
@@ -175,7 +175,7 @@ function home({ viewer, shares = [], connectedIds = new Set(), stats = {}, baseU
@@ -175,7 +175,7 @@ function home({ viewer, shares = [], connectedIds = new Set(), stats = {}, baseU
items: [
items: [
['What can the server make my computer do?', 'Nothing but answer requests for your share on the one port you gave the client, on localhost. The client never opens a connection anywhere else, and you can read all of it: it is one file.'],
['What can the server make my computer do?', 'Nothing but answer requests for your share on the one port you gave the client, on localhost. The client never opens a connection anywhere else, and you can read all of it: it is one file.'],
['Who can open my share?', 'People you allow, after they sign in with OpenVibe: only you, or you and the people you name. "Anyone signed in" is open to OpenVibe staff until the abuse check is in. Changing who can open a share signs everyone out of it at once.'],
['Who can open my share?', 'People you allow, after they sign in with OpenVibe: only you, or you and the people you name. "Anyone signed in" is open to OpenVibe staff until the abuse check is in. Changing who can open a share signs everyone out of it at once.'],
- ['Can I make it public?', 'Not yet. A public address can be abused for phishing, so public shares come with the abuse check and openvibe.website\'s entry in the Public Suffix List. Until then every visitor signs in.'],
+ ['Can I make it public?', `Not yet. A public address can be abused for phishing, so public shares come with the abuse check and ${esc(domain)}'s entry in the Public Suffix List. Until then every visitor signs in.`],
['What does my app see?', 'The request as the browser sent it, with Host set to localhost:<port>, the share\'s address in X-Forwarded-Host, the visitor\'s address in X-Forwarded-For, and who they are in X-OpenVibe-User and X-OpenVibe-Subject. Cookies your app sets stay on your share\'s address.'],
['What does my app see?', 'The request as the browser sent it, with Host set to localhost:<port>, the share\'s address in X-Forwarded-Host, the visitor\'s address in X-Forwarded-For, and who they are in X-OpenVibe-User and X-OpenVibe-Subject. Cookies your app sets stay on your share\'s address.'],
['What are the limits?', 'Five open shares per person, 64 requests at once per share, 1,200 requests a minute per share, 100 MB per request body, and 10 GB a day across your shares. Nothing is stored: requests pass through.'],
['What are the limits?', 'Five open shares per person, 64 requests at once per share, 1,200 requests a minute per share, 100 MB per request body, and 10 GB a day across your shares. Nothing is stored: requests pass through.'],
['How is this different from ngrok or Tailscale?', 'It is part of your OpenVibe account: the people you let in are OpenVibe people, no separate login, no install beyond one file. TCP and UDP shares (game servers), custom domains, a private mesh between your machines and durable webhooks are the next steps on the plan.'],
['How is this different from ngrok or Tailscale?', 'It is part of your OpenVibe account: the people you let in are OpenVibe people, no separate login, no install beyond one file. TCP and UDP shares (game servers), custom domains, a private mesh between your machines and durable webhooks are the next steps on the plan.'],
@@ -9,10 +9,10 @@ const { agentManifest } = require('openvibe-shared/agent-layer');
@@ -9,10 +9,10 @@ const { agentManifest } = require('openvibe-shared/agent-layer');
const m = agentManifest(descriptor);
const m = agentManifest(descriptor);
assert.strictEqual(m.network, 'OpenVibe');
assert.strictEqual(m.network, 'OpenVibe');
-assert.strictEqual(m.home, 'https://connect.openvibe.website');
+assert.strictEqual(m.home, 'https://connect.openvibe.link');
assert.ok(m.purpose && m.purpose.length > 40, 'a purpose');
assert.ok(m.purpose && m.purpose.length > 40, 'a purpose');
assert.ok(/^https:\/\//.test(m.api), 'its API, absolute');
assert.ok(/^https:\/\//.test(m.api), 'its API, absolute');
-assert.strictEqual(m.llms, 'https://connect.openvibe.website/llms.txt');
+assert.strictEqual(m.llms, 'https://connect.openvibe.link/llms.txt');
assert.ok(Array.isArray(m.policy.auth) && m.policy.auth.length, 'how it is called');
assert.ok(Array.isArray(m.policy.auth) && m.policy.auth.length, 'how it is called');
const app = fs.readFileSync(path.join(__dirname, '..', 'server', 'app.js'), 'utf8');
const app = fs.readFileSync(path.join(__dirname, '..', 'server', 'app.js'), 'utf8');
assert.match(app, /require\('openvibe-shared\/agent-layer'\)\.mount\(app, \{ descriptor: require\('\.\/agent-descriptor'\) \}\);/);
assert.match(app, /require\('openvibe-shared\/agent-layer'\)\.mount\(app, \{ descriptor: require\('\.\/agent-descriptor'\) \}\);/);
@@ -71,7 +71,7 @@ const { check, done } = require('./helpers/check');
@@ -71,7 +71,7 @@ const { check, done } = require('./helpers/check');
assert.ok(page.text.includes(`name="offline_site" type="url" maxlength="300" value="${site}"`), 'the saved site is shown');
assert.ok(page.text.includes(`name="offline_site" type="url" maxlength="300" value="${site}"`), 'the saved site is shown');
const bad = await t.get(`/shares/${id}/policy`, { as: kim, form: { mode: 'named', named: '@lee, sam', offline_site: 'https://evil.example/' } });
const bad = await t.get(`/shares/${id}/policy`, { as: kim, form: { mode: 'named', named: '@lee, sam', offline_site: 'https://evil.example/' } });
assert.strictEqual(bad.status, 422);
assert.strictEqual(bad.status, 422);
- assert.match(bad.text, /one of the sites on this domain/);
+ assert.match(bad.text, /one of your OpenVibe\.Space sites/);
await t.get(`/shares/${id}/policy`, { as: kim, form: { mode: 'named', named: '@lee, sam', offline_site: '' } });
await t.get(`/shares/${id}/policy`, { as: kim, form: { mode: 'named', named: '@lee, sam', offline_site: '' } });
assert.strictEqual((await t.get('/api/v1/shares', { as: kim })).json().shares[0].offline_site, null, 'cleared');
assert.strictEqual((await t.get('/api/v1/shares', { as: kim })).json().shares[0].offline_site, null, 'cleared');
});
});
@@ -127,7 +127,7 @@ const { check, done } = require('./helpers/check');
@@ -127,7 +127,7 @@ const { check, done } = require('./helpers/check');
const agent = await t.get('/.well-known/agent.json');
const agent = await t.get('/.well-known/agent.json');
assert.strictEqual(agent.status, 200);
assert.strictEqual(agent.status, 200);
const m = agent.json();
const m = agent.json();
- assert.deepStrictEqual([m.network, m.id, m.home], ['OpenVibe', 'connect', 'https://connect.openvibe.website']);
+ assert.deepStrictEqual([m.network, m.id, m.home], ['OpenVibe', 'connect', 'https://connect.openvibe.link']);
// SEO-33: llms.txt, then the README (Connect's full documentation).
// SEO-33: llms.txt, then the README (Connect's full documentation).
const full = await t.get('/llms-full.txt');
const full = await t.get('/llms-full.txt');
assert.strictEqual(full.status, 200);
assert.strictEqual(full.status, 200);
@@ -77,7 +77,7 @@ const { createGate } = require('../server/edge/gate');
@@ -77,7 +77,7 @@ const { createGate } = require('../server/edge/gate');
assert.strictEqual(parseArgs(['--key=ovc_y', '--port=9']).key, 'ovc_y');
assert.strictEqual(parseArgs(['--key=ovc_y', '--port=9']).key, 'ovc_y');
// Used as a module (createClient) with no server, it still goes to production, not to "undefined".
// Used as a module (createClient) with no server, it still goes to production, not to "undefined".
const { DEFAULT_SERVER } = require('../public/ov-connect');
const { DEFAULT_SERVER } = require('../public/ov-connect');
- assert.strictEqual(DEFAULT_SERVER, 'wss://connect.openvibe.website/tunnel');
+ assert.strictEqual(DEFAULT_SERVER, 'wss://connect.openvibe.link/tunnel');
assert.strictEqual(parseArgs([]).server, DEFAULT_SERVER);
assert.strictEqual(parseArgs([]).server, DEFAULT_SERVER);
});
});