openvibers/openvibe.connect Commits
OpenVibe Link rename: the machine references and docs (#87)
Changes
@@@ -26,7 +26,7 @@ computer, on the web, privately.- **Shares.** A person makes a share (a name and a local port) and it answers at`https://<name>--<user>.openvibe.website`. OpenVibe.Space's site names can never contain "--", so the two nevermeet. Five open shares per person; a closed share keeps its address.--- **The client.** `public/ov-connect.js`: one file, Node 22+, no dependencies. It opens one outgoing WebSocket to++- **The client.** `public/ov-connect.js`: one file, Link 22+, no dependencies. It opens one outgoing WebSocket to`wss://connect.openvibe.website/tunnel` (through Cloudflare on 443), says hello with the share's key, and carriesrequests to `localhost:<port>`: HTTP with streaming bodies both ways, server-sent events, and WebSocket upgrades as abyte pipe. It reconnects after sleep, stops on a wrong key or a closed share, and a newer client takes over.@@@ -5,7 +5,7 @@ OpenVibe Connect lives at `connect.openvibe.link`; the old `.openvibe.website` a**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--settings, no open ports, no app to install: one file and Node.++settings, no open ports, no app to install: one file and Link.Live at **[connect.openvibe.link](https://connect.openvibe.link)**. Connect shares ports through Link; [OpenVibe.Space](https://openvibe.space) builds and hosts sites.@@@ -13,7 +13,7 @@ Live at **[connect.openvibe.link](https://connect.openvibe.link)**. Connect shar1. 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):++2. On your computer (Link 22 or newer):```bashcurl -fsSLO https://connect.openvibe.link/ov-connect.js@@@ -27,16 +27,16 @@ only ever connects to `localhost` on the port you gave it.### From a computer with OpenVibe Link (no key)--A computer you added on [openvibe.bot/machines](https://openvibe.bot/machines) runs OpenVibe Link and has its own++A computer you added on [openvibe.link/machines](https://openvibe.link/machines) runs OpenVibe Link and has its ownOpenVibe sign-in. On the share's page, pick it under "Or serve it from one of your computers", then on that computer:```bash--openvibe-node share --share shr_… # serve it now--sudo openvibe-node share add --share shr_… # or keep it up with the Link service++openvibe-link share --share shr_… # serve it now++sudo openvibe-link share add --share shr_… # or keep it up with the Link service```There is no key to copy. The computer proves itself twice at every connection: with its Network node token (subject--`node:nod_…`, audience `openvibe.link`, 5 minutes) and with a tunnel token Connect gives it for that one share (60++`link:lnk_…`, 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).@@@ -167,14 +167,14 @@ With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.l| `GET /api/v1/shares/:id/visits` | who opened it in the last 7 days || `GET /api/v1/shares/:id/requests` `?after=<seq>` | the last 100 requests through it, newest first (the inspector, below) || `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: 'lnk_…' \| 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.link`) as a Bearer:| Call | What ||---|---|--| `GET /api/v1/node/shares` | the open shares this computer serves: `id`, `host`, `url`, `local_port`, `mode` |--| `POST /api/v1/node/shares/:id/tunnel-token` | `{ tunnel_token, expires_in: 60, share }`, for one hello on `/tunnel`: `{ t: 'hello', v: 1, share, node_token, tunnel }`, then `{ t: 'reauth', node_token }` before the node token runs out |++| `GET /api/v1/link/shares` | the open shares this computer serves: `id`, `host`, `url`, `local_port`, `mode` |++| `POST /api/v1/link/shares/:id/tunnel-token` | `{ tunnel_token, expires_in: 60, share }`, for one hello on `/tunnel`: `{ t: 'hello', v: 1, share, node_token, tunnel }`, then `{ t: 'reauth', node_token }` before the node token runs out |## How it works@@@ -218,7 +218,7 @@ Environment: `.env.example`. Production: `/etc/openvibe/connect.env`, unit `openAGPL-3.0-only. The client (`public/ov-connect.js`) is part of this repository under the same license.<!-- versions:start -->--- openvibe-contracts: v0.338.0++- openvibe-contracts: v0.373.0- openvibe-sdk: v0.123.0- openvibe-shared: v4.173.0<!-- versions:end -->@@@ -7,21 +7,21 @@"runtime": "node 22, express 4, ws 8, PostgreSQL 18 (openvibe-sdk/db, async)","port": "5240","domain": "connect.openvibe.link",-- "contracts": "openvibe-contracts v0.338.0",++ "contracts": "openvibe-contracts v0.373.0","sdk": "openvibe-sdk v0.123.0","shared": "openvibe-shared v4.173.0","packages": [-- "openvibe-contracts v0.338.0",++ "openvibe-contracts v0.373.0","openvibe-sdk v0.123.0","openvibe-shared v4.173.0"],-- "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 (Link 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.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",-- "The OpenVibe Link plugin (CON-4): a share served by the owner's paired computer, no key, with `openvibe-node share <port>` and the share in `openvibe-node status`",++ "The OpenVibe Link plugin (CON-4): a share served by the owner's paired computer, no key, with `openvibe-link share <port>` and the share in `openvibe-link status`","What the app gets: localhost Host, X-Forwarded-Host, X-OpenVibe-User/Subject; Set-Cookie Domain removed; no CDN caching","Pages (front door, your shares, a share's page), the API for a person's token, account export/deletion","Checks on PGlite and PostgreSQL, including a share end to end through the real client"@@@ -1,7 +1,7 @@# Connect shares on Link: Opus rolloutThis PR makes no DNS, Cloudflare or host changes. Leave the four PRs for review. The operator must complete the--following stages in order; do not release the new Node audience before Network and Connect accept it.++following stages in order; do not release the new Link audience before Network and Connect accept it.## 1. Prepare trust and the dual-host service@@@ -81,7 +81,7 @@ Use an existing private share and a new test share to verify over Cloudflare, wi* Inspect each gate Set-Cookie: `__Host-ovc`, Secure, HttpOnly, SameSite=Lax, Path=/, **no Domain**. A cookie copiedto the other alias or another share fails. Local-app cookies also have Domain stripped.* GET, POST, SSE, and an authenticated WebSocket work on both hosts; responses bypass Cloudflare cache.--* The existing Node binary still connects, and the new audience works after deploying Network. MCP sessions++* The existing Link binary still connects, and the new audience works after deploying Network. MCP sessionskeep their exact original host audience; changing alias requires authorization for that alias.* `https://connect.openvibe.link/auth/health` (following the control redirect) passes and the old control host works.@@@ -102,7 +102,7 @@ accepts both audiences, so owner configuration calls continue working.The old control URL remains usable (HTTP pages redirect; tunnel upgrades remain direct). Both share aliases stillanswer, while API/list/ready responses and `openvibe-link share` advertise Link. Verify sign-in on the new control--origin; its host-only cookie requires one fresh sign-in. Release Node after this stage.++origin; its host-only cookie requires one fresh sign-in. Release Link after this stage.After the new share URL is proved, set `CONNECT_REDIRECT_LEGACY=true` and redeploy Connect. Old share requestsreceive **302**, preserving path and query exactly. Gate codes/cookies are never copied into the new host's session;@@@ -112,5 +112,5 @@ For APIs, 302 can change POST to GET in clients, so update callers to Link beforRollback without losing shares: set `CONNECT_REDIRECT_LEGACY=false`, keep both domains, and restore`CONNECT_SHARE_DOMAIN=openvibe.website` / the Website BASE_URL and OAuth redirect. Keep the dual-audience server--versions and additive migration in place (new Node binaries need Link audience; old server code cannot insert the++versions and additive migration in place (new Link binaries need Link audience; old server code cannot insert thenew required MCP audience column). No DNS/certificate removal is required.@@@ -15,7 +15,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.123.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.173.0","pg": "^8.23.1",@@@ -1158,9 +1158,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.338.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",-- "integrity": "sha512-75FD6IxMsXoud3NFXKDs0/D0rhJtF2D+WL8M92ArIyabkUnM/sZvw6E6L97EqwQ3O7M/kB+uwsNSxGudbil+ng==",++ "version": "0.373.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0",++ "integrity": "sha512-ddW6yG5deFxRgS/MQLgV6pyYx8vco49Nt83DrE+m9XAPwuHxFSeSZgvZ5CznK0cjul3CMmB5NaVXA6jdEbXAfQ==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -25,7 +25,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.123.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.173.0","pg": "^8.23.1",@@@ -6,7 +6,7 @@** <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 Link (http/node-api.js, CON-4)++ * /api/v1/link/* a person's computer as OpenVibe Link (http/link-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)@@@ -29,8 +29,8 @@ const { createSso } = require('./auth/sso');const { createPrincipal } = require('./http/principal');const settingsLib = require('./settings');const { createApi } = require('./http/api');--const { createNodeApi } = require('./http/node-api');--const { createNodeAuth } = require('./auth/node-auth');++const { createLinkApi } = require('./http/link-api');++const { createLinkAuth } = require('./auth/link-auth');const { createMachines } = require('./machines');const { createHooks } = require('./hooks');const { createApiKeys } = require('./api-keys');@@@ -83,10 +83,10 @@ async function createApp(opts = {}) {const shares = createShares(s, { config });// "Tell me when it's back" (CON-17): when a share's computer connects, everyone waiting on it gets one notification.const notifier = opts.notifier || require('./notify').createNotifier({ config, fetchImpl, log });-- // A person's computer as OpenVibe Node (CON-4): its node token and a single-use tunnel token; its owner's machines.-- const nodeAuth = createNodeAuth({ config, keys, now: s.now });++ // A person's computer as OpenVibe Link (CON-4): its node token and a single-use tunnel token; its owner's machines.++ const linkAuth = createLinkAuth({ config, keys, now: s.now });const machines = opts.machines || createMachines({ config, fetchImpl, log, now: s.now });-- const tunnels = createTunnels({ shares, config, log, nodeAuth, now: s.now, onConnect: async (share) => {++ const tunnels = createTunnels({ shares, config, log, linkAuth, now: s.now, onConnect: async (share) => {// Webhooks that waited for this computer (CON-15) go at once, oldest first; Events' later retry finds them// settled. One that fails waits for that retry.ctx.deliverWaiting(share).catch((err) => log.warn('[OpenVibe.Connect] waiting webhooks not handed over:', err.message));@@@ -119,7 +119,7 @@ async function createApp(opts = {}) {});const hooks = createHooks(s, { config, outbox, now: s.now, log });-- const ctx = { config, s, keys, sso, principal, settings, shares, tunnels, gate, proxy, apiKeys, oauth, notifier, nodeAuth, machines, hooks, outbox, accountData, accountSend, log };++ const ctx = { config, s, keys, sso, principal, settings, shares, tunnels, gate, proxy, apiKeys, oauth, notifier, linkAuth, machines, hooks, outbox, accountData, accountSend, log };ctx.probe = createProbe({ shares, proxy, config, log, now: s.now });ctx.probeDelayMs = opts.probeDelayMs != null ? opts.probeDelayMs : 10_000;@@@ -295,7 +295,7 @@ async function createApp(opts = {}) {// ── API ─────────────────────────────────────────────────app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.CONNECT_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));-- app.use('/api/v1/node', createNodeApi(ctx));++ app.use('/api/v1/link', createLinkApi(ctx));app.use('/api/v1', createApi(ctx));// GET /openapi.json (SEO-3): the API as OpenAPI 3.1. It is not in openvibe-contracts, so the document is read from// the routes this app mounts under /api/, with the summaries the API files' header comments give them@@@ -306,7 +306,7 @@ async function createApp(opts = {}) {app.get('/openapi.json', require('./http/openapi-routes').openapiRoute(app, {title: `${d.name} API`, description: d.purpose, version: require('../package.json').version, baseUrl: site,docs: `${site}/llms-full.txt`,-- files: ['http/api.js', 'http/node-api.js'].map((f) => require('path').join(__dirname, f)),++ files: ['http/api.js', 'http/link-api.js'].map((f) => require('path').join(__dirname, f)),}));}app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));nnew file mode 100644@@@ -0,0 +1,61 @@++'use strict';++++/**++ * 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:<lnk_…> (actor_type node), for audience++ * 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.++ *++ * verify(token) → { ok, node, exp } | { ok: false, status, code, reason }++ * mint(shareId, node) → { token, expires_in }++ * redeem(token, shareId, node) → true once, for that share and computer, within 60 s; any other try spends it++ */++const crypto = require('crypto');++const { serviceAuth } = require('openvibe-contracts');++++const LINK_SUB = /^link:(lnk_[0-9A-HJKMNP-TV-Z]{26})$/;++const LINK_ID = /^lnk_[0-9A-HJKMNP-TV-Z]{26}$/;++const TUNNEL_RE = /^ovt_[A-Za-z0-9_-]{43}$/;++const TUNNEL_TTL_MS = 60_000;++const MAX_TICKETS = 10_000;++++const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');++++function createLinkAuth({ config, keys, now = () => Date.now() }) {++ async function verify(token) {++ if (typeof token !== 'string' || token.split('.').length !== 3) return { ok: false, status: 401, code: 'token.malformed', reason: 'not a node token' };++ const publicKey = await keys.pemForToken(token);++ if (!publicKey) return { ok: false, status: 503, code: 'identity.unavailable', reason: 'the Network signing key is not loaded yet' };++ const results = (config.linkAudiences || [config.audience]).map((audience) => serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience, now: now() }));++ const r = results.find((result) => result.ok) || results[0];++ if (!r.ok) return { ok: false, status: 401, code: r.code || 'token.invalid', reason: r.reason || 'the token does not verify' };++ const m = LINK_SUB.exec(String(r.claims.sub || ''));++ if (!m || r.claims.actor_type !== 'link') return { ok: false, status: 401, code: 'token.not_link', reason: 'this is not a computer\'s Link token' };++ return { ok: true, node: m[1], exp: Number(r.claims.exp) * 1000 };++ }++++ const tickets = new Map(); // sha256(token) → { shareId, node, exp }++ function mint(shareId, node) {++ const t = now();++ for (const [k, v] of tickets) if (v.exp <= t) tickets.delete(k);++ while (tickets.size >= MAX_TICKETS) tickets.delete(tickets.keys().next().value);++ const token = `ovt_${crypto.randomBytes(32).toString('base64url')}`;++ tickets.set(sha256(token), { shareId, node, exp: t + TUNNEL_TTL_MS });++ return { token, expires_in: TUNNEL_TTL_MS / 1000 };++ }++ function redeem(token, shareId, node) {++ if (typeof token !== 'string' || !TUNNEL_RE.test(token)) return false;++ const k = sha256(token);++ const v = tickets.get(k);++ if (!v) return false;++ tickets.delete(k);++ return v.exp > now() && v.shareId === shareId && v.node === node;++ }++++ return { verify, mint, redeem, TUNNEL_TTL_MS, pending: () => tickets.size };++}++++module.exports = { createLinkAuth, LINK_ID, LINK_SUB, TUNNEL_TTL_MS };ddeleted file mode 100644@@@ -1,61 +0,0 @@--'use strict';----/**-- * 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.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.-- *-- * verify(token) → { ok, node, exp } | { ok: false, status, code, reason }-- * mint(shareId, node) → { token, expires_in }-- * redeem(token, shareId, node) → true once, for that share and computer, within 60 s; any other try spends it-- */--const crypto = require('crypto');--const { serviceAuth } = require('openvibe-contracts');----const NODE_SUB = /^node:(nod_[0-9A-HJKMNP-TV-Z]{26})$/;--const NODE_ID = /^nod_[0-9A-HJKMNP-TV-Z]{26}$/;--const TUNNEL_RE = /^ovt_[A-Za-z0-9_-]{43}$/;--const TUNNEL_TTL_MS = 60_000;--const MAX_TICKETS = 10_000;----const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');----function createNodeAuth({ config, keys, now = () => Date.now() }) {-- async function verify(token) {-- if (typeof token !== 'string' || token.split('.').length !== 3) return { ok: false, status: 401, code: 'token.malformed', reason: 'not a node token' };-- const publicKey = await keys.pemForToken(token);-- if (!publicKey) return { ok: false, status: 503, code: 'identity.unavailable', reason: 'the Network signing key is not loaded yet' };-- const results = (config.nodeAudiences || [config.audience]).map((audience) => serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience, now: now() }));-- const r = results.find((result) => result.ok) || results[0];-- if (!r.ok) return { ok: false, status: 401, code: r.code || 'token.invalid', reason: r.reason || 'the token does not verify' };-- const m = NODE_SUB.exec(String(r.claims.sub || ''));-- if (!m || r.claims.actor_type !== 'node') return { ok: false, status: 401, code: 'token.not_node', reason: 'this is not a computer\'s node token' };-- return { ok: true, node: m[1], exp: Number(r.claims.exp) * 1000 };-- }---- const tickets = new Map(); // sha256(token) → { shareId, node, exp }-- function mint(shareId, node) {-- const t = now();-- for (const [k, v] of tickets) if (v.exp <= t) tickets.delete(k);-- while (tickets.size >= MAX_TICKETS) tickets.delete(tickets.keys().next().value);-- const token = `ovt_${crypto.randomBytes(32).toString('base64url')}`;-- tickets.set(sha256(token), { shareId, node, exp: t + TUNNEL_TTL_MS });-- return { token, expires_in: TUNNEL_TTL_MS / 1000 };-- }-- function redeem(token, shareId, node) {-- if (typeof token !== 'string' || !TUNNEL_RE.test(token)) return false;-- const k = sha256(token);-- const v = tickets.get(k);-- if (!v) return false;-- tickets.delete(k);-- return v.exp > now() && v.shareId === shareId && v.node === node;-- }---- return { verify, mint, redeem, TUNNEL_TTL_MS, pending: () => tickets.size };--}----module.exports = { createNodeAuth, NODE_ID, NODE_SUB, TUNNEL_TTL_MS };@@@ -42,7 +42,7 @@ function load(env = process.env) {// The registered service/admin audience stays Website; node audiences are independent.audience: env.CONNECT_AUDIENCE || 'openvibe.website',// Old binaries still request the Website audience during the transition.-- nodeAudiences: [...new Set([env.CONNECT_AUDIENCE || 'openvibe.link', 'openvibe.link', 'openvibe.website'])],++ linkAudiences: [...new Set([env.CONNECT_AUDIENCE || 'openvibe.link', 'openvibe.link', 'openvibe.website'])],partySharedSecret: env.CONNECT_PARTY_SHARED_SECRET || '',oauth: {clientId: env.OV_OAUTH_CLIENT_ID || 'connect',@@@ -10,8 +10,8 @@* tunnel.send(obj), tunnel.data(id, buf), tunnel.end(id), tunnel.abort(id, code, message), tunnel.release(id)** A computer bound to a share as OpenVibe Link (CON-4) says hello with two credentials instead of a key:-- * { t: 'hello', share: 'shr_…', node_token: <its Network node token>, tunnel: 'ovt_…' } (server/auth/node-auth.js). Its-- * node token lasts 300 s, so it sends { t: 'reauth', node_token } with a fresh one before then (answered++ * { t: 'hello', share: 'shr_…', link_token: <its Network node token>, tunnel: 'ovt_…' } (server/auth/link-auth.js). Its++ * node token lasts 300 s, so it sends { t: 'reauth', link_token } with a fresh one before then (answered* { t: 'reauthed', until }); a tunnel whose token ran out is closed (4000, the computer reconnects), so a computer* removed on Network loses its shares within about five minutes. A refused node token is 4002 and a share this* computer does not serve is 4003 (the computer stops both); a spent or late tunnel token is 4000 (it asks again).@@@ -101,7 +101,7 @@ class Tunnel {// How long past its node token's expiry a computer's tunnel may stay open while its reauth is on the way.const REAUTH_GRACE_MS = 30_000;--function createTunnels({ shares, config, log = console, onConnect = null, nodeAuth = null, now = () => Date.now() }) {++function createTunnels({ shares, config, log = console, onConnect = null, linkAuth = null, now = () => Date.now() }) {const wss = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024 * 1024, perMessageDeflate: false });const live = new Map(); // share id → Tunnelconst stats = { connects: 0, refused: 0, replaced: 0 };@@@ -163,14 +163,14 @@ function createTunnels({ shares, config, log = console, onConnect = null, nodeAuif (m.v !== frames.VERSION) return refuse(ws, 'version', `This server speaks ${frames.PROTOCOL}. Download the client again from ${config.baseUrl}/ov-connect.js`);let share;let node = null;-- if (m.key === undefined && (m.node_token !== undefined || m.tunnel !== undefined)) {++ if (m.key === undefined && (m.link_token !== undefined || m.tunnel !== undefined)) {// A computer as OpenVibe Link (CON-4): its node token and a tunnel token, both checked.-- if (!nodeAuth) return refuse(ws, 'unavailable', 'Try again in a moment.');-- const v = await nodeAuth.verify(m.node_token);++ if (!linkAuth) return refuse(ws, 'unavailable', 'Try again in a moment.');++ const v = await linkAuth.verify(m.link_token);if (!v.ok) return v.status === 503 ? refuse(ws, 'unavailable', 'Try again in a moment.') : refuse(ws, 'node_invalid', 'OpenVibe did not take this computer\'s node token. Pair it with OpenVibe again.');try { share = await shares.get(typeof m.share === 'string' ? m.share.slice(0, 64) : ''); } catch (err) { log.warn('[OpenVibe.Connect] share lookup failed:', err.message); return refuse(ws, 'unavailable', 'Try again in a moment.'); }// The ticket is spent whatever happens next: one try each.-- const ticket = share ? nodeAuth.redeem(m.tunnel, share.id, v.node) : false;++ const ticket = share ? linkAuth.redeem(m.tunnel, share.id, v.node) : false;if (!share || !share.node || share.node.id !== v.node) return refuse(ws, 'node_not_bound', 'This computer does not serve that share. Pick it on the share\'s page.');if (share.status !== 'active') return refuse(ws, 'share_revoked', 'That share is closed.');if (!ticket) return refuse(ws, 'tunnel_token', 'That tunnel token is spent or late. Ask for a new one.');@@@ -198,7 +198,7 @@ function createTunnels({ shares, config, log = console, onConnect = null, nodeAu/** A fresh node token from a computer's open tunnel: the same computer, still bound to the share. */async function reauth(t, m) {-- const v = nodeAuth ? await nodeAuth.verify(m.node_token) : { ok: false, status: 503 };++ const v = linkAuth ? await linkAuth.verify(m.link_token) : { ok: false, status: 503 };if (!v.ok && v.status === 503) return t.send({ t: 'error', code: 'unavailable', message: 'Try again in a moment.' });if (!v.ok || v.node !== t.node) { stats.refused++; return t.shutdown(4002, 'node token refused'); }let share = null;@@@ -12,7 +12,7 @@* 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++ * PUT /shares/:id/node { node: 'lnk_…' } 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@@@ -112,7 +112,7 @@ function createApi(ctx) {tunnels.close(share.id, 4003, 'the computer was let go');return res.json({ share: shape(updated), key });}-- if (typeof want !== 'string') return fail(res, req, 422, 'share.invalid_node', 'Name one of your computers by its id (nod_…), or null.');++ if (typeof want !== 'string') return fail(res, req, 422, 'share.invalid_node', 'Name one of your computers by its id (lnk_…), or null.');const found = await machines.find(req.principal.token, want);if (!found.ok) return fail(res, req, 503, 'machines.unavailable', 'Your computers could not be read from OpenVibe just now. Try again in a moment.');if (!found.machine) return fail(res, req, 404, 'share.unknown_node', 'That is not one of your computers. Pair it with OpenVibe Link first.');nnew file mode 100644@@@ -0,0 +1,96 @@++'use strict';++++/**++ * /api/v1/link: 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 link:lnk_…, 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 }] }++ * POST /shares/:id/tunnel-token → { tunnel_token, expires_in: 60, share } for one hello on /tunnel, within 60 s++ *++ * The tunnel token is in this answer only, and is never logged.++ */++const { http } = require('openvibe-contracts');++const express = require('express');++const { asyncRouter } = require('./router');++const { verify } = require('../party-grants');++const { ShareError } = require('../shares');++++function createLinkApi(ctx) {++ const { config, shares, linkAuth, tunnels, proxy } = ctx;++ const host = new URL(config.baseUrl).host;++ const r = asyncRouter();++ r.use(express.json({ limit: '4kb' }));++ 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.link) as a Bearer');++ const v = await linkAuth.verify(header.slice(7).trim());++ if (!v.ok) return fail(res, req, v.status, v.code, v.reason);++ req.node = v.node;++ return next();++ });++++ const brief = (s) => ({ id: s.id, host: s.host, url: s.url, local_port: s.local_port, mode: s.mode });++++ r.get('/shares', async (req, res) => res.json({ shares: (await shares.byNode(req.node)).map(brief) }));++++ r.post('/egress-share', async (req, res) => {++ const identity = await require('../link-egress').createEgressAuth({ config }).identity(req.body?.network_token);++ if (!identity || identity.node_id !== req.node) return fail(res, req, 403, 'egress.owner', 'Network did not confirm this computer');++ const share = await shares.createEgress({ node: req.node, owner: identity.owner, name: identity.name, port: req.body.port });++ proxy.forget(share.host);++ return res.json({ share: brief(share) });++ });++++ r.post('/party-shares', async (req, res) => {++ const claim = verify(req.body && req.body.grant, config.partySharedSecret);++ if (!claim || claim.type !== 'create' || claim.node !== req.node)++ return fail(res, req, 403, 'party.invalid_grant', 'Chat did not authorize this party for this machine');++ try {++ const share = await shares.createParty(claim);++ return res.status(201).json({ share: brief(share) });++ } catch (err) {++ if (err instanceof ShareError) return fail(res, req, err.status, err.code, err.message);++ throw err;++ }++ });++++ r.put('/shares/:id/party-members', async (req, res) => {++ try {++ const share = await shares.partyMembers(await shares.get(req.params.id), req.node, req.body && req.body.members);++ proxy.forget(share.host);++ return res.json({ share: brief(share) });++ } catch (err) {++ if (err instanceof ShareError) return fail(res, req, err.status, err.code, err.message);++ throw err;++ }++ });++++ r.delete('/shares/:id/party', async (req, res) => {++ try {++ const share = await shares.closeParty(await shares.get(req.params.id), req.node);++ tunnels.close(share.id, 4003, 'party ended');++ proxy.forget(share.host);++ return res.json({ share: brief(share) });++ } catch (err) {++ if (err instanceof ShareError) return fail(res, req, err.status, err.code, err.message);++ throw err;++ }++ });++++ 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 ${host}.`);++ if (share.status !== 'active') return fail(res, req, 410, 'share.revoked', 'That share is closed.');++ const t = linkAuth.mint(share.id, req.node);++ return res.json({ tunnel_token: t.token, expires_in: t.expires_in, share: brief(share) });++ });++++ return r;++}++++module.exports = { createLinkApi };ddeleted file mode 100644@@@ -1,96 +0,0 @@--'use strict';----/**-- * /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.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 }] }-- * POST /shares/:id/tunnel-token → { tunnel_token, expires_in: 60, share } for one hello on /tunnel, within 60 s-- *-- * The tunnel token is in this answer only, and is never logged.-- */--const { http } = require('openvibe-contracts');--const express = require('express');--const { asyncRouter } = require('./router');--const { verify } = require('../party-grants');--const { ShareError } = require('../shares');----function createNodeApi(ctx) {-- const { config, shares, nodeAuth, tunnels, proxy } = ctx;-- const host = new URL(config.baseUrl).host;-- const r = asyncRouter();-- r.use(express.json({ limit: '4kb' }));-- 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.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;-- return next();-- });---- const brief = (s) => ({ id: s.id, host: s.host, url: s.url, local_port: s.local_port, mode: s.mode });---- r.get('/shares', async (req, res) => res.json({ shares: (await shares.byNode(req.node)).map(brief) }));---- r.post('/egress-share', async (req, res) => {-- const identity = await require('../link-egress').createEgressAuth({ config }).identity(req.body?.network_token);-- if (!identity || identity.node_id !== req.node) return fail(res, req, 403, 'egress.owner', 'Network did not confirm this computer');-- const share = await shares.createEgress({ node: req.node, owner: identity.owner, name: identity.name, port: req.body.port });-- proxy.forget(share.host);-- return res.json({ share: brief(share) });-- });---- r.post('/party-shares', async (req, res) => {-- const claim = verify(req.body && req.body.grant, config.partySharedSecret);-- if (!claim || claim.type !== 'create' || claim.node !== req.node)-- return fail(res, req, 403, 'party.invalid_grant', 'Chat did not authorize this party for this machine');-- try {-- const share = await shares.createParty(claim);-- return res.status(201).json({ share: brief(share) });-- } catch (err) {-- if (err instanceof ShareError) return fail(res, req, err.status, err.code, err.message);-- throw err;-- }-- });---- r.put('/shares/:id/party-members', async (req, res) => {-- try {-- const share = await shares.partyMembers(await shares.get(req.params.id), req.node, req.body && req.body.members);-- proxy.forget(share.host);-- return res.json({ share: brief(share) });-- } catch (err) {-- if (err instanceof ShareError) return fail(res, req, err.status, err.code, err.message);-- throw err;-- }-- });---- r.delete('/shares/:id/party', async (req, res) => {-- try {-- const share = await shares.closeParty(await shares.get(req.params.id), req.node);-- tunnels.close(share.id, 4003, 'party ended');-- proxy.forget(share.host);-- return res.json({ share: brief(share) });-- } catch (err) {-- if (err instanceof ShareError) return fail(res, req, err.status, err.code, err.message);-- throw err;-- }-- });---- 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 ${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) });-- });---- return r;--}----module.exports = { createNodeApi };@@@ -8,7 +8,7 @@* /shares/:id one share: connected or not, who may open it, who did, the command* POST /shares/:id/policy who can open it, the port* POST /shares/:id/key a new key (shown once); the connected computer is disconnected-- * POST /shares/:id/node node=nod_… one of your computers serves it (CON-4); node= (empty) lets it go and shows a++ * POST /shares/:id/node node=lnk_… one of your computers serves it (CON-4); node= (empty) lets it go and shows a* new key once* POST /shares/:id/hooks path=&label= a webhook address for it (CON-15)* POST /shares/:id/hooks/:hook/remove@@@ -11,7 +11,7 @@ function createEgressAuth({ config, fetchImpl = globalThis.fetch }) {} catch { return null; }}return {-- identity: token => network('/api/v1/node/self/egress/identity', null, token),++ identity: token => network('/api/v1/link/self/egress/identity', null, token),async open(share, req) {if (req.url !== '/__ov_egress' || req.method !== 'GET' || String(req.headers.upgrade).toLowerCase() !== 'websocket') return false;const session = req.headers['x-ov-session'], credential = req.headers['x-ov-egress'];@@@ -11,7 +11,7 @@* is kept 20 seconds (by a hash of their token; at most 500 people). Binding always reads it fresh.*/const crypto = require('crypto');--const { NODE_ID } = require('./auth/node-auth');++const { LINK_ID } = require('./auth/link-auth');const KEEP_MS = 20_000;const KEEP_MAX = 500;@@@ -33,7 +33,7 @@ function createMachines({ config, fetchImpl = globalThis.fetch, log = console, nif (!res.ok) return { ok: false, reason: `network_${res.status}` };const body = await res.json();const machines = (Array.isArray(body && body.nodes) ? body.nodes : [])-- .filter((n) => n && n.status === 'active' && NODE_ID.test(String(n.principal || '')))++ .filter((n) => n && n.status === 'active' && LINK_ID.test(String(n.principal || ''))).map((n) => ({id: n.principal,name: typeof n.name === 'string' && n.name.trim() ? n.name.trim().slice(0, 80) : 'A computer',@@@ -280,7 +280,7 @@ function createShares(s, { config }) {}/**-- * One of the owner's computers serves the share (CON-4): `machine` is { id: 'nod_…', name }, already checked to be++ * One of the owner's computers serves the share (CON-4): `machine` is { id: 'lnk_…', name }, already checked to be* the owner's and active (the route asks Network with the owner's own token). The connect key is replaced by one* nobody is shown, so only that computer, with its node token and a tunnel token, can serve it now. null unbinds* and returns a new key (shown once), for the one-file client again.@@@ -306,7 +306,7 @@ function createShares(s, { config }) {}async function createEgress({ node, owner, name, port }) {-- if (!/^nod_[0-9A-HJKMNP-TV-Z]{26}$/.test(node) || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(owner) ||++ if (!/^lnk_[0-9A-HJKMNP-TV-Z]{26}$/.test(node) || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(owner) ||!Number.isInteger(port) || port < 1 || port > 65535) throw new ShareError(422, 'egress.invalid', 'Invalid computer');const old = await db.maybe("SELECT * FROM shares WHERE node_id = $1 AND link_egress AND status = 'active'", [node]);if (old) {@@@ -325,7 +325,7 @@ function createShares(s, { config }) {async function createParty({ party, owner, username, node, port }) {if (!/^party-[a-f0-9]{8}$/.test(String(party || '')) || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(owner || '')) ||-- !/^nod_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(node || ''))) throw new ShareError(422, 'party.invalid', 'Invalid party or machine');++ !/^lnk_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(node || ''))) throw new ShareError(422, 'party.invalid', 'Invalid party or machine');const existing = await db.maybe("SELECT * FROM shares WHERE party_slug = $1 AND status = 'active'", [party]);if (existing) {const current = view(existing);@@@ -229,7 +229,7 @@ function requestRow(q) {return `<li class="cn-req is-${tone}" data-seq="${esc(q.seq)}"><span class="cn-req-badge">${esc(badge)}</span><code class="cn-req-line"><b>${esc(q.ws ? 'WS' : q.method)}</b> ${esc(q.path)}</code><span class="cn-req-meta">${esc(meta)}</span><time datetime="${esc(q.at)}">${esc(String(q.at).slice(11, 19))}</time></li>`;}--const MACHINES_URL = 'https://openvibe.bot/machines';++const MACHINES_URL = 'https://openvibe.link/machines';/*** Which computer serves it (CON-4): one of the owner's computers as OpenVibe Link (no key to copy; removing the computer@@@ -237,8 +237,8 @@ const MACHINES_URL = 'https://openvibe.bot/machines';*/function nodeBlock(share, machines, now) {if (share.node) {-- const run = `openvibe-node share --share ${share.id}`;-- const keep = `sudo openvibe-node share add --share ${share.id}`;++ const run = `openvibe-link share --share ${share.id}`;++ const keep = `sudo openvibe-link share add --share ${share.id}`;return `<section class="cn-node" aria-labelledby="cn-node-h"><h2 id="cn-node-h">Your computer serves it</h2><p><b>${esc(share.node.name)}</b> serves this share as OpenVibe Link: no key to copy, and removing the computer on OpenVibe stops it within five minutes.</p><ol class="cn-steps">@@@ -251,7 +251,7 @@ function nodeBlock(share, machines, now) {const intro = '<p>Pick one of your computers that runs OpenVibe Link and it serves this share with its own sign-in: no key to copy, and removing the computer on OpenVibe stops it. The connect key stops working while a computer serves it.</p>';let list;if (machines === null) list = '<p class="cn-empty">Your computers could not be read from OpenVibe just now. Reload the page in a moment.</p>';-- else if (!machines.length) list = `<p class="cn-empty">No computers yet. <a href="${esc(MACHINES_URL)}">Add one on openvibe.bot/machines</a>, then come back here.</p>`;++ else if (!machines.length) list = `<p class="cn-empty">No computers yet. <a href="${esc(MACHINES_URL)}">Add one on openvibe.link/machines</a>, then come back here.</p>`;else {list = `<ul class="cn-machines">${machines.map((m) => `<li><span class="cn-machine"><b>${esc(m.name)}</b><small>${m.last_seen_at ? `seen ${esc(ago(m.last_seen_at, now))}` : 'not seen yet'}</small></span><form method="post" action="/shares/${esc(share.id)}/node"><input type="hidden" name="node" value="${esc(m.id)}"><button class="cn-btn cn-btn-quiet" type="submit">${kit.icon('check')} Serve it from this computer</button></form></li>`).join('')}</ul>`;@@@ -136,16 +136,16 @@ async function startNetwork() {return {url: srv.url, publicPem, privatePem, state: st, requests: st.requests, tokenRequests: st.tokenRequests,addUser, userToken, signJwt: (claims) => signJwt(claims, privatePem), issuer: () => issuer,-- /** A machine `user` owns on Network (a node principal) → { id: 'nod_…', name }. */++ /** A machine `user` owns on Network (a node principal) → { id: 'lnk_…', name }. */addNode(user, { name = 'Desk PC', status = 'active' } = {}) {-- const n = { id: ids.newId('node'), node_id: `n-${crypto.randomBytes(6).toString('hex')}`, name, owner: user.subject, status, last_seen_at: new Date().toISOString() };++ const n = { id: ids.newId('link'), node_id: `n-${crypto.randomBytes(6).toString('hex')}`, name, owner: user.subject, status, last_seen_at: new Date().toISOString() };st.nodes.push(n);return n;},/** That machine's node token, as Network's /oauth/token would sell it (audience openvibe.website, 300 s). */-- nodeToken(node, { aud = 'openvibe.website', ttl = 300, iat = null, sub = null } = {}) {++ linkToken(node, { aud = 'openvibe.website', ttl = 300, iat = null, sub = null } = {}) {const now = iat != null ? iat : Math.floor(Date.now() / 1000);-- return serviceAuth.signServiceToken({ iss: issuer, sub: sub || `node:${node.id}`, actor_type: 'node', aud: [aud], cap: [], iat: now, exp: now + ttl, jti: `tok_${crypto.randomBytes(12).toString('hex')}` }, privatePem);++ return serviceAuth.signServiceToken({ iss: issuer, sub: sub || `link:${node.id}`, actor_type: 'link', aud: [aud], cap: [], iat: now, exp: now + ttl, jti: `tok_${crypto.randomBytes(12).toString('hex')}` }, privatePem);},issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },setDown(v) { st.down = v; },nnew file mode 100644@@@ -0,0 +1,10 @@++'use strict';++const assert = require('node:assert/strict');++const { spawnSync } = require('node:child_process');++const path = require('node:path');++const root = path.resolve(__dirname, '..');++const oldName = ['OpenVibe[ .-]No' + 'de', 'openvibe-' + 'node', 'no' + 'd_', 'node[.]openvibe[.]network', 'node:lnk_', 'openvibe[.](bot|my|network)/mach' + 'ines'].join('|');++const result = spawnSync('git', ['grep', '-n', '-I', '-i', '-E', oldName, '--', '.', ':!migrations/**', ':!test/link-brand.test.js'], { cwd: root, encoding: 'utf8' });++assert.ok(result.status === 0 || result.status === 1, result.stderr || 'git grep failed');++assert.equal(result.status, 1, `Old Link brand or principal returned:\n${(result.stdout || '').split('\n').slice(0, 10).join('\n')}`);++console.log(' ok old Link brand and principal IDs are absent');@@@ -11,7 +11,7 @@ const { once } = require('events');const credential = crypto.randomBytes(32).toString('base64url');let identity, checks = 0, allowed = true;globalThis.fetch = async (url, options = {}) => {-- if (String(url).endsWith('/api/v1/node/self/egress/identity')) return new Response(JSON.stringify(identity));++ if (String(url).endsWith('/api/v1/link/self/egress/identity')) return new Response(JSON.stringify(identity));if (String(url).endsWith('/api/v1/link-egress/open')) {checks++;const b = JSON.parse(options.body);@@@ -35,11 +35,11 @@ const { once } = require('events');const owner = t.network.addUser('egressowner');const node = t.network.addNode(owner, { name: 'Home PC' });identity = { node_id: node.id, owner: owner.subject, name: 'Home PC' };-- const made = await t.get('/api/v1/node/egress-share', { method: 'POST', bearer: t.network.nodeToken(node),++ const made = await t.get('/api/v1/link/egress-share', { method: 'POST', bearer: t.network.linkToken(node),json: { network_token: 'test-network-token', port: endpoint.address().port } });assert.equal(made.status, 200, 'Node creates its dedicated share after Network confirms ownership');const share = made.json().share;-- const ticket = await t.get(`/api/v1/node/shares/${share.id}/tunnel-token`, { method: 'POST', bearer: t.network.nodeToken(node) });++ const ticket = await t.get(`/api/v1/link/shares/${share.id}/tunnel-token`, { method: 'POST', bearer: t.network.linkToken(node) });tunnel = new WebSocket(`ws://127.0.0.1:${t.port}/tunnel`);const packet = (kind, id, data = Buffer.alloc(0)) => { const h = Buffer.alloc(5); h[0] = kind; h.writeUInt32BE(id, 1); return Buffer.concat([h, data]); };const ready = new Promise((resolve, reject) => {@@@ -64,7 +64,7 @@ const { once } = require('events');});await once(tunnel, 'open');tunnel.send(JSON.stringify({ t: 'hello', v: 1, client: 'egress-test', share: share.id,-- node_token: t.network.nodeToken(node), tunnel: ticket.json().tunnel_token, port: endpoint.address().port }));++ link_token: t.network.linkToken(node), tunnel: ticket.json().tunnel_token, port: endpoint.address().port }));await ready;const connect = () => new WebSocket(`ws://127.0.0.1:${t.port}/__ov_egress`, { headers: { Host: share.host, 'X-OV-Session': 'ses_test', 'X-OV-Egress': credential } });const visitor = connect(); await once(visitor, 'open');@@@ -9,13 +9,13 @@ const { createEgressAuth } = require('../server/link-egress');const b = JSON.parse(options.body);return { ok: allowed, json: async () => ({ node_id: b.node_id, session_id: b.session_id }) };} });-- const share = { node: { id: 'nod_mine' } };++ const share = { node: { id: 'lnk_mine' } };const req = { method: 'GET', url: '/__ov_egress', headers: { upgrade: 'websocket', 'x-ov-session': 'ses_mine', 'x-ov-egress': 'a'.repeat(43) } };assert.equal(await gate.open(share, req), true);allowed = false;assert.equal(await gate.open(share, req), false, 'successful ownership checks are never cached');assert.equal(seen.length, 2);-- assert.equal(JSON.parse(seen[0].options.body).node_id, 'nod_mine');++ assert.equal(JSON.parse(seen[0].options.body).node_id, 'lnk_mine');assert.equal(await gate.open(share, { ...req, headers: { cookie: 'owner-session' } }), false, 'owner cookie is insufficient');assert.equal(await gate.open(share, { ...req, url: '/' }), false);assert.equal(await gate.open(share, { ...req, url: '/__ov_egress?credential=secret' }), false);@@@ -96,8 +96,8 @@ function upgrade(t, host, cookie) {});await check('both node audiences work; unrelated audiences do not', async () => {const node = t.network.addNode(alex);-- for (const aud of ['openvibe.website', 'openvibe.link']) assert.equal((await t.ctx.nodeAuth.verify(t.network.nodeToken(node, { aud }))).ok, true);-- assert.equal((await t.ctx.nodeAuth.verify(t.network.nodeToken(node, { aud: 'openvibe.network' }))).ok, false);++ for (const aud of ['openvibe.website', 'openvibe.link']) assert.equal((await t.ctx.linkAuth.verify(t.network.linkToken(node, { aud }))).ok, true);++ assert.equal((await t.ctx.linkAuth.verify(t.network.linkToken(node, { aud: 'openvibe.network' }))).ok, false);});await check('redirects are opt-in, preserve the path/query, and cover upgrades without forwarding cookies', async () => {assert.equal(t.config.shares.redirectLegacy, false);nnew file mode 100644@@@ -0,0 +1,203 @@++'use strict';++/**++ * A share served by one of its owner's computers, as OpenVibe Link (CON-4, ADR-057 decision 4): the owner picks the++ * computer from their machines on Network; the computer asks for a tunnel token with its node token and says hello with++ * both; a visitor's request reaches it; it sends a fresh node token before the old one runs out, and a tunnel whose token++ * ran out is closed. Every refusal: another person's computer, a token for another audience, a spent or late tunnel++ * token, a share it does not serve, the key once a computer serves it.++ * node test/link-share.test.js++ */++const assert = require('assert');++const WebSocket = require('ws');++const { boot } = require('./helpers/boot');++const { check, done } = require('./helpers/check');++++const sleep = (ms) => new Promise((r) => setTimeout(r, ms));++++/** A computer's tunnel: hello with its two credentials, and an answer to every request ("from node <path>"). */++function linkTunnel(t, hello) {++ const ws = new WebSocket(`ws://127.0.0.1:${t.port}/tunnel`);++ const frames = [];++ const waiters = [];++ let closed = null;++ ws.on('message', (data, isBinary) => {++ if (isBinary) return;++ const m = JSON.parse(String(data));++ frames.push(m);++ if (m.t === 'req') {++ ws.send(JSON.stringify({ t: 'res', id: m.id, status: 200, headers: [['content-type', 'text/plain']] }));++ const head = Buffer.alloc(5); head[0] = 1; head.writeUInt32BE(m.id, 1);++ ws.send(Buffer.concat([head, Buffer.from(`from node ${m.path}`)]));++ const end = Buffer.alloc(5); end[0] = 2; end.writeUInt32BE(m.id, 1);++ ws.send(end);++ }++ waiters.splice(0).forEach((w) => w());++ });++ const closedP = new Promise((resolve) => ws.on('close', (code, reason) => { closed = { code, reason: String(reason) }; resolve(closed); waiters.splice(0).forEach((w) => w()); }));++ ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 1, client: 'openvibe-link/test', port: 1234, ...hello })));++ async function next(type, ms = 3000) {++ const end = Date.now() + ms;++ for (;;) {++ const f = frames.find((x) => x.t === type);++ if (f) { frames.splice(frames.indexOf(f), 1); return f; }++ if (closed) return null;++ if (Date.now() > end) throw new Error(`no ${type} frame`);++ await new Promise((r) => { waiters.push(r); setTimeout(r, 50); });++ }++ }++ return { ws, next, closed: () => closedP, frames };++}++++(async () => {++ const t = await boot();++ const alex = t.network.addUser('alex');++ const bob = t.network.addUser('bob');++ const desk = t.network.addNode(alex, { name: 'Desk PC' });++ const gone = t.network.addNode(alex, { name: 'Old laptop', status: 'revoked' });++ const bobs = t.network.addNode(bob, { name: 'Bob box' });++ let made;++ let ticket;++++ await check('the owner sees their active computers, and only theirs', async () => {++ made = await t.makeShare(alex, { label: 'portal', port: 8790 });++ const r = await t.get('/api/v1/machines', { as: alex });++ assert.strictEqual(r.status, 200, r.text);++ const m = r.json().machines;++ assert.deepStrictEqual(m.map((x) => [x.id, x.name]), [[desk.id, 'Desk PC']], 'a revoked machine and Bob\'s are left out');++ const page = await t.get(`/shares/${made.share.id}`, { as: alex });++ assert.match(page.text, /Serve it from this computer/);++ assert.match(page.text, /Desk PC/);++ assert.doesNotMatch(page.text, /Old laptop|Bob box/);++ });++++ await check('binding: only one of the owner\'s active computers; the key stops working at once', async () => {++ for (const bad of [bobs.id, gone.id, 'lnk_01M6FCPA3HCE14MFEM40HQ7VDJ']) {++ const r = await t.get(`/api/v1/shares/${made.share.id}/node`, { as: alex, method: 'PUT', json: { node: bad } });++ assert.strictEqual(r.status, 404, `${bad}: ${r.text}`);++ }++ const other = await t.get(`/api/v1/shares/${made.share.id}/node`, { as: bob, method: 'PUT', json: { node: bobs.id } });++ assert.strictEqual(other.status, 404, 'not Bob\'s share');++ const c = t.client({ key: made.key, port: 8790 });++ await c.ready();++ const r = await t.get(`/api/v1/shares/${made.share.id}/node`, { as: alex, method: 'PUT', json: { node: desk.id } });++ assert.strictEqual(r.status, 200, r.text);++ const s = r.json().share;++ assert.deepStrictEqual(s.node, { id: desk.id, name: 'Desk PC' });++ assert.strictEqual(s.key_prefix, null, 'no key to show');++ await sleep(100);++ assert.strictEqual(t.ctx.tunnels.isConnected(made.share.id), false, 'the key\'s computer was disconnected');++ c.stop();++ const k = linkTunnel(t, { key: made.key });++ const closed = await k.closed();++ assert.strictEqual(closed.code, 4002, 'the key no longer opens it');++ });++++ await check('the computer: its shares and a tunnel token, only with its own node token for openvibe.website', async () => {++ const list = await t.get('/api/v1/link/shares', { bearer: t.network.linkToken(desk) });++ assert.strictEqual(list.status, 200, list.text);++ assert.deepStrictEqual(list.json().shares.map((x) => [x.id, x.local_port]), [[made.share.id, 8790]]);++ assert.strictEqual((await t.get('/api/v1/link/shares')).status, 401, 'no token');++ assert.strictEqual((await t.get('/api/v1/link/shares', { bearer: t.network.userToken(alex) })).status, 401, 'a person\'s token is not a computer\'s');++ assert.strictEqual((await t.get('/api/v1/link/shares', { bearer: t.network.linkToken(desk, { aud: 'openvibe.bot' }) })).status, 401, 'another audience');++ assert.strictEqual((await t.get('/api/v1/link/shares', { bearer: t.network.linkToken(desk, { iat: Math.floor(Date.now() / 1000) - 1000 }) })).status, 401, 'expired');++ assert.strictEqual((await t.get('/api/v1/link/shares', { bearer: t.network.linkToken(desk, { sub: 'svc:bot' }) })).status, 401, 'a service is not a computer');++ const bobList = await t.get('/api/v1/link/shares', { bearer: t.network.linkToken(bobs) });++ assert.deepStrictEqual(bobList.json().shares, [], 'Bob\'s computer serves nothing of Alex\'s');++ const no = await t.get(`/api/v1/link/shares/${made.share.id}/tunnel-token`, { method: 'POST', bearer: t.network.linkToken(bobs) });++ assert.strictEqual(no.status, 404, 'another computer gets no tunnel token');++ const r = await t.get(`/api/v1/link/shares/${made.share.id}/tunnel-token`, { method: 'POST', bearer: t.network.linkToken(desk) });++ assert.strictEqual(r.status, 200, r.text);++ ticket = r.json();++ assert.match(ticket.tunnel_token, /^ovt_[A-Za-z0-9_-]{43}$/);++ assert.strictEqual(ticket.expires_in, 60);++ assert.ok(!t.logs().includes(ticket.tunnel_token), 'never logged');++ });++++ await check('hello with both: ready, and a visitor\'s request reaches the computer', async () => {++ const n = linkTunnel(t, { share: made.share.id, link_token: t.network.linkToken(desk), tunnel: ticket.tunnel_token });++ const ready = await n.next('ready');++ assert.ok(ready, 'ready');++ assert.strictEqual(ready.node, true);++ assert.strictEqual(ready.share.local_port, 8790);++ assert.ok(t.ctx.tunnels.isConnected(made.share.id));++ const visitor = await t.enter(alex, made.share);++ const page = await t.share(made.share.host, '/hello', { cookie: visitor.cookie });++ assert.strictEqual(page.status, 200, page.text);++ assert.strictEqual(page.text, 'from node /hello');++ const html = await t.get(`/shares/${made.share.id}`, { as: alex });++ assert.match(html.text, /Your computer serves it/);++ assert.match(html.text, /openvibe-link share --share shr_/);++ n.ws.close();++ await n.closed();++ });++++ await check('a tunnel token is spent by any try, and is for one share and one computer', async () => {++ const fresh = async (node) => (await t.get(`/api/v1/link/shares/${made.share.id}/tunnel-token`, { method: 'POST', bearer: t.network.linkToken(node) })).json().tunnel_token;++ const again = linkTunnel(t, { share: made.share.id, link_token: t.network.linkToken(desk), tunnel: ticket.tunnel_token });++ assert.strictEqual((await again.closed()).code, 4000, 'spent: ask again');++ const tok = await fresh(desk);++ const wrong = linkTunnel(t, { share: made.share.id, link_token: t.network.linkToken(bobs), tunnel: tok });++ assert.strictEqual((await wrong.closed()).code, 4003, 'Bob\'s computer does not serve it');++ const after = linkTunnel(t, { share: made.share.id, link_token: t.network.linkToken(desk), tunnel: tok });++ assert.strictEqual((await after.closed()).code, 4000, 'and the try spent it');++ const bad = linkTunnel(t, { share: made.share.id, link_token: t.network.linkToken(desk, { aud: 'openvibe.network' }), tunnel: await fresh(desk) });++ assert.strictEqual((await bad.closed()).code, 4002, 'a node token for Network is refused: the computer stops');++ assert.strictEqual(t.ctx.linkAuth.pending(), 1, 'the unused one waits; nothing else is kept');++ });++++ await check('reauth: a fresh token keeps it open; another computer\'s token closes it; a token that ran out closes it', async () => {++ const tok = (await t.get(`/api/v1/link/shares/${made.share.id}/tunnel-token`, { method: 'POST', bearer: t.network.linkToken(desk) })).json().tunnel_token;++ const n = linkTunnel(t, { share: made.share.id, link_token: t.network.linkToken(desk, { ttl: 2 }), tunnel: tok });++ await n.next('ready');++ n.ws.send(JSON.stringify({ t: 'reauth', link_token: t.network.linkToken(desk) }));++ const ok = await n.next('reauthed');++ assert.ok(ok && Date.parse(ok.until) > Date.now() + 200_000, 'until moves on');++ n.ws.send(JSON.stringify({ t: 'reauth', link_token: t.network.linkToken(bobs) }));++ assert.strictEqual((await n.closed()).code, 4002);++++ const tok2 = (await t.get(`/api/v1/link/shares/${made.share.id}/tunnel-token`, { method: 'POST', bearer: t.network.linkToken(desk) })).json().tunnel_token;++ const m = linkTunnel(t, { share: made.share.id, link_token: t.network.linkToken(desk), tunnel: tok2 });++ await m.next('ready');++ const live = t.ctx.tunnels.get(made.share.id);++ live.authUntil = Date.now() - 60_000; // its token ran out a minute ago and no reauth came++ t.ctx.tunnels.sweep();++ assert.strictEqual((await m.closed()).code, 4000, 'closed; the computer reconnects with a fresh token');++ });++++ await check('letting the computer go: a new key (shown once) serves it again; the computer no longer can', async () => {++ const page = await t.get(`/shares/${made.share.id}/node`, { as: alex, form: { node: '' }, method: 'POST' });++ assert.strictEqual(page.status, 200, page.text);++ const key = /<code id="cn-key-v">(ovc_[A-Za-z0-9_-]{43})<\/code>/.exec(page.text);++ assert.ok(key, 'the new key is shown once');++ const s = (await t.get(`/api/v1/shares/${made.share.id}`, { as: alex })).json().share;++ assert.strictEqual(s.node, null);++ const r = await t.get(`/api/v1/link/shares/${made.share.id}/tunnel-token`, { method: 'POST', bearer: t.network.linkToken(desk) });++ assert.strictEqual(r.status, 404);++ const c = t.client({ key: key[1], port: 8790 });++ await c.ready();++ assert.ok(t.ctx.tunnels.isConnected(made.share.id), 'the key works again');++ c.stop();++ });++++ await check('binding from the page: a plain form, then the commands for that computer', async () => {++ const r = await t.get(`/shares/${made.share.id}/node`, { as: alex, form: { node: desk.id }, method: 'POST' });++ assert.strictEqual(r.status, 303, r.text);++ const page = await t.get(r.headers.get('location'), { as: alex });++ assert.match(page.text, /Desk PC serves it now/);++ assert.match(page.text, /sudo openvibe-link share add --share/);++ const cross = await t.get(`/shares/${made.share.id}/node`, { as: alex, form: { node: desk.id }, method: 'POST', origin: 'https://evil.example' });++ assert.strictEqual(cross.status, 403, 'another site cannot bind it');++ });++++ await check('a computer that Network cannot list for its owner just now: the page says so, nothing changes', async () => {++ t.network.setDown(true);++ try {++ const page = await t.get(`/shares/${made.share.id}/node`, { as: alex, form: { node: desk.id }, method: 'POST' });++ assert.strictEqual(page.status, 503);++ assert.match(page.text, /could not be read from OpenVibe just now/);++ } finally { t.network.setDown(false); }++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });ddeleted file mode 100644@@@ -1,203 +0,0 @@--'use strict';--/**-- * A share served by one of its owner's computers, as OpenVibe Link (CON-4, ADR-057 decision 4): the owner picks the-- * computer from their machines on Network; the computer asks for a tunnel token with its node token and says hello with-- * both; a visitor's request reaches it; it sends a fresh node token before the old one runs out, and a tunnel whose token-- * ran out is closed. Every refusal: another person's computer, a token for another audience, a spent or late tunnel-- * token, a share it does not serve, the key once a computer serves it.-- * node test/node-share.test.js-- */--const assert = require('assert');--const WebSocket = require('ws');--const { boot } = require('./helpers/boot');--const { check, done } = require('./helpers/check');----const sleep = (ms) => new Promise((r) => setTimeout(r, ms));----/** A computer's tunnel: hello with its two credentials, and an answer to every request ("from node <path>"). */--function nodeTunnel(t, hello) {-- const ws = new WebSocket(`ws://127.0.0.1:${t.port}/tunnel`);-- const frames = [];-- const waiters = [];-- let closed = null;-- ws.on('message', (data, isBinary) => {-- if (isBinary) return;-- const m = JSON.parse(String(data));-- frames.push(m);-- if (m.t === 'req') {-- ws.send(JSON.stringify({ t: 'res', id: m.id, status: 200, headers: [['content-type', 'text/plain']] }));-- const head = Buffer.alloc(5); head[0] = 1; head.writeUInt32BE(m.id, 1);-- ws.send(Buffer.concat([head, Buffer.from(`from node ${m.path}`)]));-- const end = Buffer.alloc(5); end[0] = 2; end.writeUInt32BE(m.id, 1);-- ws.send(end);-- }-- waiters.splice(0).forEach((w) => w());-- });-- const closedP = new Promise((resolve) => ws.on('close', (code, reason) => { closed = { code, reason: String(reason) }; resolve(closed); waiters.splice(0).forEach((w) => w()); }));-- ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 1, client: 'openvibe-node/test', port: 1234, ...hello })));-- async function next(type, ms = 3000) {-- const end = Date.now() + ms;-- for (;;) {-- const f = frames.find((x) => x.t === type);-- if (f) { frames.splice(frames.indexOf(f), 1); return f; }-- if (closed) return null;-- if (Date.now() > end) throw new Error(`no ${type} frame`);-- await new Promise((r) => { waiters.push(r); setTimeout(r, 50); });-- }-- }-- return { ws, next, closed: () => closedP, frames };--}----(async () => {-- const t = await boot();-- const alex = t.network.addUser('alex');-- const bob = t.network.addUser('bob');-- const desk = t.network.addNode(alex, { name: 'Desk PC' });-- const gone = t.network.addNode(alex, { name: 'Old laptop', status: 'revoked' });-- const bobs = t.network.addNode(bob, { name: 'Bob box' });-- let made;-- let ticket;---- await check('the owner sees their active computers, and only theirs', async () => {-- made = await t.makeShare(alex, { label: 'portal', port: 8790 });-- const r = await t.get('/api/v1/machines', { as: alex });-- assert.strictEqual(r.status, 200, r.text);-- const m = r.json().machines;-- assert.deepStrictEqual(m.map((x) => [x.id, x.name]), [[desk.id, 'Desk PC']], 'a revoked machine and Bob\'s are left out');-- const page = await t.get(`/shares/${made.share.id}`, { as: alex });-- assert.match(page.text, /Serve it from this computer/);-- assert.match(page.text, /Desk PC/);-- assert.doesNotMatch(page.text, /Old laptop|Bob box/);-- });---- await check('binding: only one of the owner\'s active computers; the key stops working at once', async () => {-- for (const bad of [bobs.id, gone.id, 'nod_01M6FCPA3HCE14MFEM40HQ7VDJ']) {-- const r = await t.get(`/api/v1/shares/${made.share.id}/node`, { as: alex, method: 'PUT', json: { node: bad } });-- assert.strictEqual(r.status, 404, `${bad}: ${r.text}`);-- }-- const other = await t.get(`/api/v1/shares/${made.share.id}/node`, { as: bob, method: 'PUT', json: { node: bobs.id } });-- assert.strictEqual(other.status, 404, 'not Bob\'s share');-- const c = t.client({ key: made.key, port: 8790 });-- await c.ready();-- const r = await t.get(`/api/v1/shares/${made.share.id}/node`, { as: alex, method: 'PUT', json: { node: desk.id } });-- assert.strictEqual(r.status, 200, r.text);-- const s = r.json().share;-- assert.deepStrictEqual(s.node, { id: desk.id, name: 'Desk PC' });-- assert.strictEqual(s.key_prefix, null, 'no key to show');-- await sleep(100);-- assert.strictEqual(t.ctx.tunnels.isConnected(made.share.id), false, 'the key\'s computer was disconnected');-- c.stop();-- const k = nodeTunnel(t, { key: made.key });-- const closed = await k.closed();-- assert.strictEqual(closed.code, 4002, 'the key no longer opens it');-- });---- await check('the computer: its shares and a tunnel token, only with its own node token for openvibe.website', async () => {-- const list = await t.get('/api/v1/node/shares', { bearer: t.network.nodeToken(desk) });-- assert.strictEqual(list.status, 200, list.text);-- assert.deepStrictEqual(list.json().shares.map((x) => [x.id, x.local_port]), [[made.share.id, 8790]]);-- assert.strictEqual((await t.get('/api/v1/node/shares')).status, 401, 'no token');-- assert.strictEqual((await t.get('/api/v1/node/shares', { bearer: t.network.userToken(alex) })).status, 401, 'a person\'s token is not a computer\'s');-- assert.strictEqual((await t.get('/api/v1/node/shares', { bearer: t.network.nodeToken(desk, { aud: 'openvibe.bot' }) })).status, 401, 'another audience');-- assert.strictEqual((await t.get('/api/v1/node/shares', { bearer: t.network.nodeToken(desk, { iat: Math.floor(Date.now() / 1000) - 1000 }) })).status, 401, 'expired');-- assert.strictEqual((await t.get('/api/v1/node/shares', { bearer: t.network.nodeToken(desk, { sub: 'svc:bot' }) })).status, 401, 'a service is not a computer');-- const bobList = await t.get('/api/v1/node/shares', { bearer: t.network.nodeToken(bobs) });-- assert.deepStrictEqual(bobList.json().shares, [], 'Bob\'s computer serves nothing of Alex\'s');-- const no = await t.get(`/api/v1/node/shares/${made.share.id}/tunnel-token`, { method: 'POST', bearer: t.network.nodeToken(bobs) });-- assert.strictEqual(no.status, 404, 'another computer gets no tunnel token');-- const r = await t.get(`/api/v1/node/shares/${made.share.id}/tunnel-token`, { method: 'POST', bearer: t.network.nodeToken(desk) });-- assert.strictEqual(r.status, 200, r.text);-- ticket = r.json();-- assert.match(ticket.tunnel_token, /^ovt_[A-Za-z0-9_-]{43}$/);-- assert.strictEqual(ticket.expires_in, 60);-- assert.ok(!t.logs().includes(ticket.tunnel_token), 'never logged');-- });---- await check('hello with both: ready, and a visitor\'s request reaches the computer', async () => {-- const n = nodeTunnel(t, { share: made.share.id, node_token: t.network.nodeToken(desk), tunnel: ticket.tunnel_token });-- const ready = await n.next('ready');-- assert.ok(ready, 'ready');-- assert.strictEqual(ready.node, true);-- assert.strictEqual(ready.share.local_port, 8790);-- assert.ok(t.ctx.tunnels.isConnected(made.share.id));-- const visitor = await t.enter(alex, made.share);-- const page = await t.share(made.share.host, '/hello', { cookie: visitor.cookie });-- assert.strictEqual(page.status, 200, page.text);-- assert.strictEqual(page.text, 'from node /hello');-- const html = await t.get(`/shares/${made.share.id}`, { as: alex });-- assert.match(html.text, /Your computer serves it/);-- assert.match(html.text, /openvibe-node share --share shr_/);-- n.ws.close();-- await n.closed();-- });---- await check('a tunnel token is spent by any try, and is for one share and one computer', async () => {-- const fresh = async (node) => (await t.get(`/api/v1/node/shares/${made.share.id}/tunnel-token`, { method: 'POST', bearer: t.network.nodeToken(node) })).json().tunnel_token;-- const again = nodeTunnel(t, { share: made.share.id, node_token: t.network.nodeToken(desk), tunnel: ticket.tunnel_token });-- assert.strictEqual((await again.closed()).code, 4000, 'spent: ask again');-- const tok = await fresh(desk);-- const wrong = nodeTunnel(t, { share: made.share.id, node_token: t.network.nodeToken(bobs), tunnel: tok });-- assert.strictEqual((await wrong.closed()).code, 4003, 'Bob\'s computer does not serve it');-- const after = nodeTunnel(t, { share: made.share.id, node_token: t.network.nodeToken(desk), tunnel: tok });-- assert.strictEqual((await after.closed()).code, 4000, 'and the try spent it');-- const bad = nodeTunnel(t, { share: made.share.id, node_token: t.network.nodeToken(desk, { aud: 'openvibe.network' }), tunnel: await fresh(desk) });-- assert.strictEqual((await bad.closed()).code, 4002, 'a node token for Network is refused: the computer stops');-- assert.strictEqual(t.ctx.nodeAuth.pending(), 1, 'the unused one waits; nothing else is kept');-- });---- await check('reauth: a fresh token keeps it open; another computer\'s token closes it; a token that ran out closes it', async () => {-- const tok = (await t.get(`/api/v1/node/shares/${made.share.id}/tunnel-token`, { method: 'POST', bearer: t.network.nodeToken(desk) })).json().tunnel_token;-- const n = nodeTunnel(t, { share: made.share.id, node_token: t.network.nodeToken(desk, { ttl: 2 }), tunnel: tok });-- await n.next('ready');-- n.ws.send(JSON.stringify({ t: 'reauth', node_token: t.network.nodeToken(desk) }));-- const ok = await n.next('reauthed');-- assert.ok(ok && Date.parse(ok.until) > Date.now() + 200_000, 'until moves on');-- n.ws.send(JSON.stringify({ t: 'reauth', node_token: t.network.nodeToken(bobs) }));-- assert.strictEqual((await n.closed()).code, 4002);---- const tok2 = (await t.get(`/api/v1/node/shares/${made.share.id}/tunnel-token`, { method: 'POST', bearer: t.network.nodeToken(desk) })).json().tunnel_token;-- const m = nodeTunnel(t, { share: made.share.id, node_token: t.network.nodeToken(desk), tunnel: tok2 });-- await m.next('ready');-- const live = t.ctx.tunnels.get(made.share.id);-- live.authUntil = Date.now() - 60_000; // its token ran out a minute ago and no reauth came-- t.ctx.tunnels.sweep();-- assert.strictEqual((await m.closed()).code, 4000, 'closed; the computer reconnects with a fresh token');-- });---- await check('letting the computer go: a new key (shown once) serves it again; the computer no longer can', async () => {-- const page = await t.get(`/shares/${made.share.id}/node`, { as: alex, form: { node: '' }, method: 'POST' });-- assert.strictEqual(page.status, 200, page.text);-- const key = /<code id="cn-key-v">(ovc_[A-Za-z0-9_-]{43})<\/code>/.exec(page.text);-- assert.ok(key, 'the new key is shown once');-- const s = (await t.get(`/api/v1/shares/${made.share.id}`, { as: alex })).json().share;-- assert.strictEqual(s.node, null);-- const r = await t.get(`/api/v1/node/shares/${made.share.id}/tunnel-token`, { method: 'POST', bearer: t.network.nodeToken(desk) });-- assert.strictEqual(r.status, 404);-- const c = t.client({ key: key[1], port: 8790 });-- await c.ready();-- assert.ok(t.ctx.tunnels.isConnected(made.share.id), 'the key works again');-- c.stop();-- });---- await check('binding from the page: a plain form, then the commands for that computer', async () => {-- const r = await t.get(`/shares/${made.share.id}/node`, { as: alex, form: { node: desk.id }, method: 'POST' });-- assert.strictEqual(r.status, 303, r.text);-- const page = await t.get(r.headers.get('location'), { as: alex });-- assert.match(page.text, /Desk PC serves it now/);-- assert.match(page.text, /sudo openvibe-node share add --share/);-- const cross = await t.get(`/shares/${made.share.id}/node`, { as: alex, form: { node: desk.id }, method: 'POST', origin: 'https://evil.example' });-- assert.strictEqual(cross.status, 403, 'another site cannot bind it');-- });---- await check('a computer that Network cannot list for its owner just now: the page says so, nothing changes', async () => {-- t.network.setDown(true);-- try {-- const page = await t.get(`/shares/${made.share.id}/node`, { as: alex, form: { node: desk.id }, method: 'POST' });-- assert.strictEqual(page.status, 503);-- assert.match(page.text, /could not be read from OpenVibe just now/);-- } finally { t.network.setDown(false); }-- });---- await t.close();-- done();--})().catch((e) => { console.error(e); process.exit(1); });@@@ -20,11 +20,11 @@ const sign = (body) => {await check('only Chat-granted Link machine creates the named party share', async () => {const claim = sign({ type: 'create', party: slug, owner: alex.subject, username: 'alex', node: desk.id, port: 18180 });-- const noToken = await t.get('/api/v1/node/party-shares', { method: 'POST', json: { grant: claim } });++ const noToken = await t.get('/api/v1/link/party-shares', { method: 'POST', json: { grant: claim } });assert.strictEqual(noToken.status, 401);-- const bad = await t.get('/api/v1/node/party-shares', { method: 'POST', bearer: t.network.nodeToken(desk), json: { grant: claim + 'x' } });++ const bad = await t.get('/api/v1/link/party-shares', { method: 'POST', bearer: t.network.linkToken(desk), json: { grant: claim + 'x' } });assert.strictEqual(bad.status, 403);-- const made = await t.get('/api/v1/node/party-shares', { method: 'POST', bearer: t.network.nodeToken(desk), json: { grant: claim } });++ const made = await t.get('/api/v1/link/party-shares', { method: 'POST', bearer: t.network.linkToken(desk), json: { grant: claim } });assert.strictEqual(made.status, 201, made.text);share = made.json().share;assert.strictEqual(share.host, `${slug}--alex.openvibe.link`);@@@ -43,7 +43,7 @@ const sign = (body) => {const asBob = sign({ type: 'access', share: share.id, subject: bob.subject, username: 'bob' });const opts = { method: 'POST', headers: { origin: `https://${share.host}`, 'content-type': 'application/json' }, body: JSON.stringify({ ticket: asBob }) };assert.strictEqual((await t.share(share.host, '/__ovc/party', opts)).status, 403);-- const synced = await t.get(`/api/v1/node/shares/${share.id}/party-members`, { method: 'PUT', bearer: t.network.nodeToken(desk), json: { members: ['alex', 'bob'] } });++ const synced = await t.get(`/api/v1/link/shares/${share.id}/party-members`, { method: 'PUT', bearer: t.network.linkToken(desk), json: { members: ['alex', 'bob'] } });assert.strictEqual(synced.status, 200, synced.text);const entered = await t.share(share.host, '/__ovc/party', opts);assert.strictEqual(entered.status, 200, entered.text);@@@ -54,7 +54,7 @@ const sign = (body) => {});await check('the party machine closes its share', async () => {-- const closed = await t.get(`/api/v1/node/shares/${share.id}/party`, { method: 'DELETE', bearer: t.network.nodeToken(desk) });++ const closed = await t.get(`/api/v1/link/shares/${share.id}/party`, { method: 'DELETE', bearer: t.network.linkToken(desk) });assert.strictEqual(closed.status, 200, closed.text);assert.strictEqual((await t.ctx.shares.get(share.id)).status, 'revoked');assert.strictEqual((await t.share(share.host, '/__ovc/party')).status, 410);
@@ -26,7 +26,7 @@ computer, on the web, privately.
@@ -26,7 +26,7 @@ computer, on the web, privately.
- **Shares.** A person makes a share (a name and a local port) and it answers at
- **Shares.** A person makes a share (a name and a local port) and it answers at
`https://<name>--<user>.openvibe.website`. OpenVibe.Space's site names can never contain "--", so the two never
`https://<name>--<user>.openvibe.website`. OpenVibe.Space's site names can never contain "--", so the two never
meet. Five open shares per person; a closed share keeps its address.
meet. Five open shares per person; a closed share keeps its address.
-- **The client.** `public/ov-connect.js`: one file, Node 22+, no dependencies. It opens one outgoing WebSocket to
+- **The client.** `public/ov-connect.js`: one file, Link 22+, no dependencies. It opens one outgoing WebSocket to
`wss://connect.openvibe.website/tunnel` (through Cloudflare on 443), says hello with the share's key, and carries
`wss://connect.openvibe.website/tunnel` (through Cloudflare on 443), says hello with the share's key, and carries
requests to `localhost:<port>`: HTTP with streaming bodies both ways, server-sent events, and WebSocket upgrades as a
requests to `localhost:<port>`: HTTP with streaming bodies both ways, server-sent events, and WebSocket upgrades as a
byte pipe. It reconnects after sleep, stops on a wrong key or a closed share, and a newer client takes over.
byte pipe. It reconnects after sleep, stops on a wrong key or a closed share, and a newer client takes over.
@@ -5,7 +5,7 @@ OpenVibe Connect lives at `connect.openvibe.link`; the old `.openvibe.website` a
@@ -5,7 +5,7 @@ OpenVibe Connect lives at `connect.openvibe.link`; the old `.openvibe.website` a
**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 Link.
Live at **[connect.openvibe.link](https://connect.openvibe.link)**. 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.
@@ -13,7 +13,7 @@ Live at **[connect.openvibe.link](https://connect.openvibe.link)**. Connect shar
@@ -13,7 +13,7 @@ Live at **[connect.openvibe.link](https://connect.openvibe.link)**. Connect shar
1. Sign in at connect.openvibe.link 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 (Link 22 or newer):
```bash
```bash
curl -fsSLO https://connect.openvibe.link/ov-connect.js
curl -fsSLO https://connect.openvibe.link/ov-connect.js
@@ -27,16 +27,16 @@ only ever connects to `localhost` on the port you gave it.
@@ -27,16 +27,16 @@ only ever connects to `localhost` on the port you gave it.
### From a computer with OpenVibe Link (no key)
### From a computer with OpenVibe Link (no key)
-A computer you added on [openvibe.bot/machines](https://openvibe.bot/machines) runs OpenVibe Link and has its own
+A computer you added on [openvibe.link/machines](https://openvibe.link/machines) runs OpenVibe Link and has its own
OpenVibe sign-in. On the share's page, pick it under "Or serve it from one of your computers", then on that computer:
OpenVibe sign-in. On the share's page, pick it under "Or serve it from one of your computers", then on that computer:
```bash
```bash
-openvibe-node share --share shr_… # serve it now
-sudo openvibe-node share add --share shr_… # or keep it up with the Link service
+openvibe-link share --share shr_… # serve it now
+sudo openvibe-link share add --share shr_… # or keep it up with the Link service
```
```
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.link`, 5 minutes) and with a tunnel token Connect gives it for that one share (60
+`link:lnk_…`, 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).
@@ -167,14 +167,14 @@ With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.l
@@ -167,14 +167,14 @@ With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.l
| `GET /api/v1/shares/:id/visits` | who opened it in the last 7 days |
| `GET /api/v1/shares/:id/visits` | who opened it in the last 7 days |
| `GET /api/v1/shares/:id/requests` `?after=<seq>` | the last 100 requests through it, newest first (the inspector, below) |
| `GET /api/v1/shares/:id/requests` `?after=<seq>` | the last 100 requests through it, newest first (the inspector, below) |
| `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: 'lnk_…' \| 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.link`) as a Bearer:
A computer, with its Network node token (audience `openvibe.link`) as a Bearer:
| Call | What |
| Call | What |
|---|---|
|---|---|
-| `GET /api/v1/node/shares` | the open shares this computer serves: `id`, `host`, `url`, `local_port`, `mode` |
-| `POST /api/v1/node/shares/:id/tunnel-token` | `{ tunnel_token, expires_in: 60, share }`, for one hello on `/tunnel`: `{ t: 'hello', v: 1, share, node_token, tunnel }`, then `{ t: 'reauth', node_token }` before the node token runs out |
+| `GET /api/v1/link/shares` | the open shares this computer serves: `id`, `host`, `url`, `local_port`, `mode` |
+| `POST /api/v1/link/shares/:id/tunnel-token` | `{ tunnel_token, expires_in: 60, share }`, for one hello on `/tunnel`: `{ t: 'hello', v: 1, share, node_token, tunnel }`, then `{ t: 'reauth', node_token }` before the node token runs out |
## How it works
## How it works
@@ -218,7 +218,7 @@ Environment: `.env.example`. Production: `/etc/openvibe/connect.env`, unit `open
@@ -218,7 +218,7 @@ Environment: `.env.example`. Production: `/etc/openvibe/connect.env`, unit `open
AGPL-3.0-only. The client (`public/ov-connect.js`) is part of this repository under the same license.
AGPL-3.0-only. The client (`public/ov-connect.js`) is part of this repository under the same license.
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.338.0
+- openvibe-contracts: v0.373.0
- openvibe-sdk: v0.123.0
- openvibe-sdk: v0.123.0
- openvibe-shared: v4.173.0
- openvibe-shared: v4.173.0
<!-- versions:end -->
<!-- versions:end -->
@@ -7,21 +7,21 @@
@@ -7,21 +7,21 @@
"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.link",
"domain": "connect.openvibe.link",
- "contracts": "openvibe-contracts v0.338.0",
+ "contracts": "openvibe-contracts v0.373.0",
"sdk": "openvibe-sdk v0.123.0",
"sdk": "openvibe-sdk v0.123.0",
"shared": "openvibe-shared v4.173.0",
"shared": "openvibe-shared v4.173.0",
"packages": [
"packages": [
- "openvibe-contracts v0.338.0",
+ "openvibe-contracts v0.373.0",
"openvibe-sdk v0.123.0",
"openvibe-sdk v0.123.0",
"openvibe-shared v4.173.0"
"openvibe-shared v4.173.0"
],
],
- "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 (Link 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.link, 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",
- "The OpenVibe Link plugin (CON-4): a share served by the owner's paired computer, no key, with `openvibe-node share <port>` and the share in `openvibe-node status`",
+ "The OpenVibe Link plugin (CON-4): a share served by the owner's paired computer, no key, with `openvibe-link share <port>` and the share in `openvibe-link status`",
"What the app gets: localhost Host, X-Forwarded-Host, X-OpenVibe-User/Subject; Set-Cookie Domain removed; no CDN caching",
"What the app gets: localhost Host, X-Forwarded-Host, X-OpenVibe-User/Subject; Set-Cookie Domain removed; no CDN caching",
"Pages (front door, your shares, a share's page), the API for a person's token, account export/deletion",
"Pages (front door, your shares, a share's page), the API for a person's token, account export/deletion",
"Checks on PGlite and PostgreSQL, including a share end to end through the real client"
"Checks on PGlite and PostgreSQL, including a share end to end through the real client"
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
# Connect shares on Link: Opus rollout
# Connect shares on Link: Opus rollout
This PR makes no DNS, Cloudflare or host changes. Leave the four PRs for review. The operator must complete the
This PR makes no DNS, Cloudflare or host changes. Leave the four PRs for review. The operator must complete the
-following stages in order; do not release the new Node audience before Network and Connect accept it.
+following stages in order; do not release the new Link audience before Network and Connect accept it.
## 1. Prepare trust and the dual-host service
## 1. Prepare trust and the dual-host service
@@ -81,7 +81,7 @@ Use an existing private share and a new test share to verify over Cloudflare, wi
@@ -81,7 +81,7 @@ Use an existing private share and a new test share to verify over Cloudflare, wi
* Inspect each gate Set-Cookie: `__Host-ovc`, Secure, HttpOnly, SameSite=Lax, Path=/, **no Domain**. A cookie copied
* Inspect each gate Set-Cookie: `__Host-ovc`, Secure, HttpOnly, SameSite=Lax, Path=/, **no Domain**. A cookie copied
to the other alias or another share fails. Local-app cookies also have Domain stripped.
to the other alias or another share fails. Local-app cookies also have Domain stripped.
* GET, POST, SSE, and an authenticated WebSocket work on both hosts; responses bypass Cloudflare cache.
* GET, POST, SSE, and an authenticated WebSocket work on both hosts; responses bypass Cloudflare cache.
-* The existing Node binary still connects, and the new audience works after deploying Network. MCP sessions
+* The existing Link binary still connects, and the new audience works after deploying Network. MCP sessions
keep their exact original host audience; changing alias requires authorization for that alias.
keep their exact original host audience; changing alias requires authorization for that alias.
* `https://connect.openvibe.link/auth/health` (following the control redirect) passes and the old control host works.
* `https://connect.openvibe.link/auth/health` (following the control redirect) passes and the old control host works.
@@ -102,7 +102,7 @@ accepts both audiences, so owner configuration calls continue working.
@@ -102,7 +102,7 @@ accepts both audiences, so owner configuration calls continue working.
The old control URL remains usable (HTTP pages redirect; tunnel upgrades remain direct). Both share aliases still
The old control URL remains usable (HTTP pages redirect; tunnel upgrades remain direct). Both share aliases still
answer, while API/list/ready responses and `openvibe-link share` advertise Link. Verify sign-in on the new control
answer, while API/list/ready responses and `openvibe-link share` advertise Link. Verify sign-in on the new control
-origin; its host-only cookie requires one fresh sign-in. Release Node after this stage.
+origin; its host-only cookie requires one fresh sign-in. Release Link after this stage.
After the new share URL is proved, set `CONNECT_REDIRECT_LEGACY=true` and redeploy Connect. Old share requests
After the new share URL is proved, set `CONNECT_REDIRECT_LEGACY=true` and redeploy Connect. Old share requests
receive **302**, preserving path and query exactly. Gate codes/cookies are never copied into the new host's session;
receive **302**, preserving path and query exactly. Gate codes/cookies are never copied into the new host's session;
@@ -112,5 +112,5 @@ For APIs, 302 can change POST to GET in clients, so update callers to Link befor
@@ -112,5 +112,5 @@ For APIs, 302 can change POST to GET in clients, so update callers to Link befor
Rollback without losing shares: set `CONNECT_REDIRECT_LEGACY=false`, keep both domains, and restore
Rollback without losing shares: set `CONNECT_REDIRECT_LEGACY=false`, keep both domains, and restore
`CONNECT_SHARE_DOMAIN=openvibe.website` / the Website BASE_URL and OAuth redirect. Keep the dual-audience server
`CONNECT_SHARE_DOMAIN=openvibe.website` / the Website BASE_URL and OAuth redirect. Keep the dual-audience server
-versions and additive migration in place (new Node binaries need Link audience; old server code cannot insert the
+versions and additive migration in place (new Link binaries need Link audience; old server code cannot insert the
new required MCP audience column). No DNS/certificate removal is required.
new required MCP audience column). No DNS/certificate removal is required.
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.123.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.123.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.173.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.173.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
@@ -1158,9 +1158,9 @@
@@ -1158,9 +1158,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.338.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
- "integrity": "sha512-75FD6IxMsXoud3NFXKDs0/D0rhJtF2D+WL8M92ArIyabkUnM/sZvw6E6L97EqwQ3O7M/kB+uwsNSxGudbil+ng==",
+ "version": "0.373.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0",
+ "integrity": "sha512-ddW6yG5deFxRgS/MQLgV6pyYx8vco49Nt83DrE+m9XAPwuHxFSeSZgvZ5CznK0cjul3CMmB5NaVXA6jdEbXAfQ==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.123.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.123.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.173.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.173.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
*
*
* <label>--<user>.openvibe.link/* 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 Link (http/node-api.js, CON-4)
+ * /api/v1/link/* a person's computer as OpenVibe Link (http/link-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)
@@ -29,8 +29,8 @@ const { createSso } = require('./auth/sso');
@@ -29,8 +29,8 @@ const { createSso } = require('./auth/sso');
const { createPrincipal } = require('./http/principal');
const { createPrincipal } = require('./http/principal');
const settingsLib = require('./settings');
const settingsLib = require('./settings');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
-const { createNodeApi } = require('./http/node-api');
-const { createNodeAuth } = require('./auth/node-auth');
+const { createLinkApi } = require('./http/link-api');
+const { createLinkAuth } = require('./auth/link-auth');
const { createMachines } = require('./machines');
const { createMachines } = require('./machines');
const { createHooks } = require('./hooks');
const { createHooks } = require('./hooks');
const { createApiKeys } = require('./api-keys');
const { createApiKeys } = require('./api-keys');
@@ -83,10 +83,10 @@ async function createApp(opts = {}) {
@@ -83,10 +83,10 @@ async function createApp(opts = {}) {
const shares = createShares(s, { config });
const shares = createShares(s, { config });
// "Tell me when it's back" (CON-17): when a share's computer connects, everyone waiting on it gets one notification.
// "Tell me when it's back" (CON-17): when a share's computer connects, everyone waiting on it gets one notification.
const notifier = opts.notifier || require('./notify').createNotifier({ config, fetchImpl, log });
const notifier = opts.notifier || require('./notify').createNotifier({ config, fetchImpl, log });
- // A person's computer as OpenVibe Node (CON-4): its node token and a single-use tunnel token; its owner's machines.
- const nodeAuth = createNodeAuth({ config, keys, now: s.now });
+ // A person's computer as OpenVibe Link (CON-4): its node token and a single-use tunnel token; its owner's machines.
+ const linkAuth = createLinkAuth({ config, keys, now: s.now });
const machines = opts.machines || createMachines({ config, fetchImpl, log, now: s.now });
const machines = opts.machines || createMachines({ config, fetchImpl, log, now: s.now });
- const tunnels = createTunnels({ shares, config, log, nodeAuth, now: s.now, onConnect: async (share) => {
+ const tunnels = createTunnels({ shares, config, log, linkAuth, now: s.now, onConnect: async (share) => {
// Webhooks that waited for this computer (CON-15) go at once, oldest first; Events' later retry finds them
// Webhooks that waited for this computer (CON-15) go at once, oldest first; Events' later retry finds them
// settled. One that fails waits for that retry.
// settled. One that fails waits for that retry.
ctx.deliverWaiting(share).catch((err) => log.warn('[OpenVibe.Connect] waiting webhooks not handed over:', err.message));
ctx.deliverWaiting(share).catch((err) => log.warn('[OpenVibe.Connect] waiting webhooks not handed over:', err.message));
@@ -119,7 +119,7 @@ async function createApp(opts = {}) {
@@ -119,7 +119,7 @@ async function createApp(opts = {}) {
});
});
const hooks = createHooks(s, { config, outbox, now: s.now, log });
const hooks = createHooks(s, { config, outbox, now: s.now, log });
- const ctx = { config, s, keys, sso, principal, settings, shares, tunnels, gate, proxy, apiKeys, oauth, notifier, nodeAuth, machines, hooks, outbox, accountData, accountSend, log };
+ const ctx = { config, s, keys, sso, principal, settings, shares, tunnels, gate, proxy, apiKeys, oauth, notifier, linkAuth, machines, hooks, outbox, accountData, accountSend, log };
ctx.probe = createProbe({ shares, proxy, config, log, now: s.now });
ctx.probe = createProbe({ shares, proxy, config, log, now: s.now });
ctx.probeDelayMs = opts.probeDelayMs != null ? opts.probeDelayMs : 10_000;
ctx.probeDelayMs = opts.probeDelayMs != null ? opts.probeDelayMs : 10_000;
@@ -295,7 +295,7 @@ async function createApp(opts = {}) {
@@ -295,7 +295,7 @@ async function createApp(opts = {}) {
// ── API ─────────────────────────────────────────────────
// ── API ─────────────────────────────────────────────────
app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.CONNECT_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));
app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.CONNECT_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));
- app.use('/api/v1/node', createNodeApi(ctx));
+ app.use('/api/v1/link', createLinkApi(ctx));
app.use('/api/v1', createApi(ctx));
app.use('/api/v1', createApi(ctx));
// GET /openapi.json (SEO-3): the API as OpenAPI 3.1. It is not in openvibe-contracts, so the document is read from
// GET /openapi.json (SEO-3): the API as OpenAPI 3.1. It is not in openvibe-contracts, so the document is read from
// the routes this app mounts under /api/, with the summaries the API files' header comments give them
// the routes this app mounts under /api/, with the summaries the API files' header comments give them
@@ -306,7 +306,7 @@ async function createApp(opts = {}) {
@@ -306,7 +306,7 @@ async function createApp(opts = {}) {
app.get('/openapi.json', require('./http/openapi-routes').openapiRoute(app, {
app.get('/openapi.json', require('./http/openapi-routes').openapiRoute(app, {
title: `${d.name} API`, description: d.purpose, version: require('../package.json').version, baseUrl: site,
title: `${d.name} API`, description: d.purpose, version: require('../package.json').version, baseUrl: site,
docs: `${site}/llms-full.txt`,
docs: `${site}/llms-full.txt`,
- files: ['http/api.js', 'http/node-api.js'].map((f) => require('path').join(__dirname, f)),
+ files: ['http/api.js', 'http/link-api.js'].map((f) => require('path').join(__dirname, f)),
}));
}));
}
}
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
+'use strict';
+
+/**
+ * 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:<lnk_…> (actor_type node), for audience
+ * 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.
+ *
+ * verify(token) → { ok, node, exp } | { ok: false, status, code, reason }
+ * mint(shareId, node) → { token, expires_in }
+ * redeem(token, shareId, node) → true once, for that share and computer, within 60 s; any other try spends it
+ */
+const crypto = require('crypto');
+const { serviceAuth } = require('openvibe-contracts');
+
+const LINK_SUB = /^link:(lnk_[0-9A-HJKMNP-TV-Z]{26})$/;
+const LINK_ID = /^lnk_[0-9A-HJKMNP-TV-Z]{26}$/;
+const TUNNEL_RE = /^ovt_[A-Za-z0-9_-]{43}$/;
+const TUNNEL_TTL_MS = 60_000;
+const MAX_TICKETS = 10_000;
+
+const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
+
+function createLinkAuth({ config, keys, now = () => Date.now() }) {
+ async function verify(token) {
+ if (typeof token !== 'string' || token.split('.').length !== 3) return { ok: false, status: 401, code: 'token.malformed', reason: 'not a node token' };
+ const publicKey = await keys.pemForToken(token);
+ if (!publicKey) return { ok: false, status: 503, code: 'identity.unavailable', reason: 'the Network signing key is not loaded yet' };
+ const results = (config.linkAudiences || [config.audience]).map((audience) => serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience, now: now() }));
+ const r = results.find((result) => result.ok) || results[0];
+ if (!r.ok) return { ok: false, status: 401, code: r.code || 'token.invalid', reason: r.reason || 'the token does not verify' };
+ const m = LINK_SUB.exec(String(r.claims.sub || ''));
+ if (!m || r.claims.actor_type !== 'link') return { ok: false, status: 401, code: 'token.not_link', reason: 'this is not a computer\'s Link token' };
+ return { ok: true, node: m[1], exp: Number(r.claims.exp) * 1000 };
+ }
+
+ const tickets = new Map(); // sha256(token) → { shareId, node, exp }
+ function mint(shareId, node) {
+ const t = now();
+ for (const [k, v] of tickets) if (v.exp <= t) tickets.delete(k);
+ while (tickets.size >= MAX_TICKETS) tickets.delete(tickets.keys().next().value);
+ const token = `ovt_${crypto.randomBytes(32).toString('base64url')}`;
+ tickets.set(sha256(token), { shareId, node, exp: t + TUNNEL_TTL_MS });
+ return { token, expires_in: TUNNEL_TTL_MS / 1000 };
+ }
+ function redeem(token, shareId, node) {
+ if (typeof token !== 'string' || !TUNNEL_RE.test(token)) return false;
+ const k = sha256(token);
+ const v = tickets.get(k);
+ if (!v) return false;
+ tickets.delete(k);
+ return v.exp > now() && v.shareId === shareId && v.node === node;
+ }
+
+ return { verify, mint, redeem, TUNNEL_TTL_MS, pending: () => tickets.size };
+}
+
+module.exports = { createLinkAuth, LINK_ID, LINK_SUB, TUNNEL_TTL_MS };
deleted file mode 100644
deleted file mode 100644
@@ -1,61 +0,0 @@
@@ -1,61 +0,0 @@
-'use strict';
-
-/**
- * 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.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.
- *
- * verify(token) → { ok, node, exp } | { ok: false, status, code, reason }
- * mint(shareId, node) → { token, expires_in }
- * redeem(token, shareId, node) → true once, for that share and computer, within 60 s; any other try spends it
- */
-const crypto = require('crypto');
-const { serviceAuth } = require('openvibe-contracts');
-
-const NODE_SUB = /^node:(nod_[0-9A-HJKMNP-TV-Z]{26})$/;
-const NODE_ID = /^nod_[0-9A-HJKMNP-TV-Z]{26}$/;
-const TUNNEL_RE = /^ovt_[A-Za-z0-9_-]{43}$/;
-const TUNNEL_TTL_MS = 60_000;
-const MAX_TICKETS = 10_000;
-
-const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
-
-function createNodeAuth({ config, keys, now = () => Date.now() }) {
- async function verify(token) {
- if (typeof token !== 'string' || token.split('.').length !== 3) return { ok: false, status: 401, code: 'token.malformed', reason: 'not a node token' };
- const publicKey = await keys.pemForToken(token);
- if (!publicKey) return { ok: false, status: 503, code: 'identity.unavailable', reason: 'the Network signing key is not loaded yet' };
- const results = (config.nodeAudiences || [config.audience]).map((audience) => serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience, now: now() }));
- const r = results.find((result) => result.ok) || results[0];
- if (!r.ok) return { ok: false, status: 401, code: r.code || 'token.invalid', reason: r.reason || 'the token does not verify' };
- const m = NODE_SUB.exec(String(r.claims.sub || ''));
- if (!m || r.claims.actor_type !== 'node') return { ok: false, status: 401, code: 'token.not_node', reason: 'this is not a computer\'s node token' };
- return { ok: true, node: m[1], exp: Number(r.claims.exp) * 1000 };
- }
-
- const tickets = new Map(); // sha256(token) → { shareId, node, exp }
- function mint(shareId, node) {
- const t = now();
- for (const [k, v] of tickets) if (v.exp <= t) tickets.delete(k);
- while (tickets.size >= MAX_TICKETS) tickets.delete(tickets.keys().next().value);
- const token = `ovt_${crypto.randomBytes(32).toString('base64url')}`;
- tickets.set(sha256(token), { shareId, node, exp: t + TUNNEL_TTL_MS });
- return { token, expires_in: TUNNEL_TTL_MS / 1000 };
- }
- function redeem(token, shareId, node) {
- if (typeof token !== 'string' || !TUNNEL_RE.test(token)) return false;
- const k = sha256(token);
- const v = tickets.get(k);
- if (!v) return false;
- tickets.delete(k);
- return v.exp > now() && v.shareId === shareId && v.node === node;
- }
-
- return { verify, mint, redeem, TUNNEL_TTL_MS, pending: () => tickets.size };
-}
-
-module.exports = { createNodeAuth, NODE_ID, NODE_SUB, TUNNEL_TTL_MS };
@@ -42,7 +42,7 @@ function load(env = process.env) {
@@ -42,7 +42,7 @@ function load(env = process.env) {
// 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 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'])],
+ linkAudiences: [...new Set([env.CONNECT_AUDIENCE || 'openvibe.link', 'openvibe.link', 'openvibe.website'])],
partySharedSecret: env.CONNECT_PARTY_SHARED_SECRET || '',
partySharedSecret: env.CONNECT_PARTY_SHARED_SECRET || '',
oauth: {
oauth: {
clientId: env.OV_OAUTH_CLIENT_ID || 'connect',
clientId: env.OV_OAUTH_CLIENT_ID || 'connect',
@@ -10,8 +10,8 @@
@@ -10,8 +10,8 @@
* tunnel.send(obj), tunnel.data(id, buf), tunnel.end(id), tunnel.abort(id, code, message), tunnel.release(id)
* tunnel.send(obj), tunnel.data(id, buf), tunnel.end(id), tunnel.abort(id, code, message), tunnel.release(id)
*
*
* A computer bound to a share as OpenVibe Link (CON-4) says hello with two credentials instead of a key:
* A computer bound to a share as OpenVibe Link (CON-4) says hello with two credentials instead of a key:
- * { t: 'hello', share: 'shr_…', node_token: <its Network node token>, tunnel: 'ovt_…' } (server/auth/node-auth.js). Its
- * node token lasts 300 s, so it sends { t: 'reauth', node_token } with a fresh one before then (answered
+ * { t: 'hello', share: 'shr_…', link_token: <its Network node token>, tunnel: 'ovt_…' } (server/auth/link-auth.js). Its
+ * node token lasts 300 s, so it sends { t: 'reauth', link_token } with a fresh one before then (answered
* { t: 'reauthed', until }); a tunnel whose token ran out is closed (4000, the computer reconnects), so a computer
* { t: 'reauthed', until }); a tunnel whose token ran out is closed (4000, the computer reconnects), so a computer
* removed on Network loses its shares within about five minutes. A refused node token is 4002 and a share this
* removed on Network loses its shares within about five minutes. A refused node token is 4002 and a share this
* computer does not serve is 4003 (the computer stops both); a spent or late tunnel token is 4000 (it asks again).
* computer does not serve is 4003 (the computer stops both); a spent or late tunnel token is 4000 (it asks again).
@@ -101,7 +101,7 @@ class Tunnel {
@@ -101,7 +101,7 @@ class Tunnel {
// How long past its node token's expiry a computer's tunnel may stay open while its reauth is on the way.
// How long past its node token's expiry a computer's tunnel may stay open while its reauth is on the way.
const REAUTH_GRACE_MS = 30_000;
const REAUTH_GRACE_MS = 30_000;
-function createTunnels({ shares, config, log = console, onConnect = null, nodeAuth = null, now = () => Date.now() }) {
+function createTunnels({ shares, config, log = console, onConnect = null, linkAuth = null, now = () => Date.now() }) {
const wss = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024 * 1024, perMessageDeflate: false });
const wss = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024 * 1024, perMessageDeflate: false });
const live = new Map(); // share id → Tunnel
const live = new Map(); // share id → Tunnel
const stats = { connects: 0, refused: 0, replaced: 0 };
const stats = { connects: 0, refused: 0, replaced: 0 };
@@ -163,14 +163,14 @@ function createTunnels({ shares, config, log = console, onConnect = null, nodeAu
@@ -163,14 +163,14 @@ function createTunnels({ shares, config, log = console, onConnect = null, nodeAu
if (m.v !== frames.VERSION) return refuse(ws, 'version', `This server speaks ${frames.PROTOCOL}. Download the client again from ${config.baseUrl}/ov-connect.js`);
if (m.v !== frames.VERSION) return refuse(ws, 'version', `This server speaks ${frames.PROTOCOL}. Download the client again from ${config.baseUrl}/ov-connect.js`);
let share;
let share;
let node = null;
let node = null;
- if (m.key === undefined && (m.node_token !== undefined || m.tunnel !== undefined)) {
+ if (m.key === undefined && (m.link_token !== undefined || m.tunnel !== undefined)) {
// A computer as OpenVibe Link (CON-4): its node token and a tunnel token, both checked.
// A computer as OpenVibe Link (CON-4): its node token and a tunnel token, both checked.
- if (!nodeAuth) return refuse(ws, 'unavailable', 'Try again in a moment.');
- const v = await nodeAuth.verify(m.node_token);
+ if (!linkAuth) return refuse(ws, 'unavailable', 'Try again in a moment.');
+ const v = await linkAuth.verify(m.link_token);
if (!v.ok) return v.status === 503 ? refuse(ws, 'unavailable', 'Try again in a moment.') : refuse(ws, 'node_invalid', 'OpenVibe did not take this computer\'s node token. Pair it with OpenVibe again.');
if (!v.ok) return v.status === 503 ? refuse(ws, 'unavailable', 'Try again in a moment.') : refuse(ws, 'node_invalid', 'OpenVibe did not take this computer\'s node token. Pair it with OpenVibe again.');
try { share = await shares.get(typeof m.share === 'string' ? m.share.slice(0, 64) : ''); } catch (err) { log.warn('[OpenVibe.Connect] share lookup failed:', err.message); return refuse(ws, 'unavailable', 'Try again in a moment.'); }
try { share = await shares.get(typeof m.share === 'string' ? m.share.slice(0, 64) : ''); } catch (err) { log.warn('[OpenVibe.Connect] share lookup failed:', err.message); return refuse(ws, 'unavailable', 'Try again in a moment.'); }
// The ticket is spent whatever happens next: one try each.
// The ticket is spent whatever happens next: one try each.
- const ticket = share ? nodeAuth.redeem(m.tunnel, share.id, v.node) : false;
+ const ticket = share ? linkAuth.redeem(m.tunnel, share.id, v.node) : false;
if (!share || !share.node || share.node.id !== v.node) return refuse(ws, 'node_not_bound', 'This computer does not serve that share. Pick it on the share\'s page.');
if (!share || !share.node || share.node.id !== v.node) return refuse(ws, 'node_not_bound', 'This computer does not serve that share. Pick it on the share\'s page.');
if (share.status !== 'active') return refuse(ws, 'share_revoked', 'That share is closed.');
if (share.status !== 'active') return refuse(ws, 'share_revoked', 'That share is closed.');
if (!ticket) return refuse(ws, 'tunnel_token', 'That tunnel token is spent or late. Ask for a new one.');
if (!ticket) return refuse(ws, 'tunnel_token', 'That tunnel token is spent or late. Ask for a new one.');
@@ -198,7 +198,7 @@ function createTunnels({ shares, config, log = console, onConnect = null, nodeAu
@@ -198,7 +198,7 @@ function createTunnels({ shares, config, log = console, onConnect = null, nodeAu
/** A fresh node token from a computer's open tunnel: the same computer, still bound to the share. */
/** A fresh node token from a computer's open tunnel: the same computer, still bound to the share. */
async function reauth(t, m) {
async function reauth(t, m) {
- const v = nodeAuth ? await nodeAuth.verify(m.node_token) : { ok: false, status: 503 };
+ const v = linkAuth ? await linkAuth.verify(m.link_token) : { ok: false, status: 503 };
if (!v.ok && v.status === 503) return t.send({ t: 'error', code: 'unavailable', message: 'Try again in a moment.' });
if (!v.ok && v.status === 503) return t.send({ t: 'error', code: 'unavailable', message: 'Try again in a moment.' });
if (!v.ok || v.node !== t.node) { stats.refused++; return t.shutdown(4002, 'node token refused'); }
if (!v.ok || v.node !== t.node) { stats.refused++; return t.shutdown(4002, 'node token refused'); }
let share = null;
let share = null;
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
* goes while the computer is off, one of your OpenVibe.Space sites (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: 'lnk_…' } 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
@@ -112,7 +112,7 @@ function createApi(ctx) {
@@ -112,7 +112,7 @@ function createApi(ctx) {
tunnels.close(share.id, 4003, 'the computer was let go');
tunnels.close(share.id, 4003, 'the computer was let go');
return res.json({ share: shape(updated), key });
return res.json({ share: shape(updated), key });
}
}
- if (typeof want !== 'string') return fail(res, req, 422, 'share.invalid_node', 'Name one of your computers by its id (nod_…), or null.');
+ if (typeof want !== 'string') return fail(res, req, 422, 'share.invalid_node', 'Name one of your computers by its id (lnk_…), or null.');
const found = await machines.find(req.principal.token, want);
const found = await machines.find(req.principal.token, want);
if (!found.ok) return fail(res, req, 503, 'machines.unavailable', 'Your computers could not be read from OpenVibe just now. Try again in a moment.');
if (!found.ok) return fail(res, req, 503, 'machines.unavailable', 'Your computers could not be read from OpenVibe just now. Try again in a moment.');
if (!found.machine) return fail(res, req, 404, 'share.unknown_node', 'That is not one of your computers. Pair it with OpenVibe Link first.');
if (!found.machine) return fail(res, req, 404, 'share.unknown_node', 'That is not one of your computers. Pair it with OpenVibe Link first.');
new file mode 100644
new file mode 100644
@@ -0,0 +1,96 @@
@@ -0,0 +1,96 @@
+'use strict';
+
+/**
+ * /api/v1/link: 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 link:lnk_…, 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 }] }
+ * POST /shares/:id/tunnel-token → { tunnel_token, expires_in: 60, share } for one hello on /tunnel, within 60 s
+ *
+ * The tunnel token is in this answer only, and is never logged.
+ */
+const { http } = require('openvibe-contracts');
+const express = require('express');
+const { asyncRouter } = require('./router');
+const { verify } = require('../party-grants');
+const { ShareError } = require('../shares');
+
+function createLinkApi(ctx) {
+ const { config, shares, linkAuth, tunnels, proxy } = ctx;
+ const host = new URL(config.baseUrl).host;
+ const r = asyncRouter();
+ r.use(express.json({ limit: '4kb' }));
+ 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.link) as a Bearer');
+ const v = await linkAuth.verify(header.slice(7).trim());
+ if (!v.ok) return fail(res, req, v.status, v.code, v.reason);
+ req.node = v.node;
+ return next();
+ });
+
+ const brief = (s) => ({ id: s.id, host: s.host, url: s.url, local_port: s.local_port, mode: s.mode });
+
+ r.get('/shares', async (req, res) => res.json({ shares: (await shares.byNode(req.node)).map(brief) }));
+
+ r.post('/egress-share', async (req, res) => {
+ const identity = await require('../link-egress').createEgressAuth({ config }).identity(req.body?.network_token);
+ if (!identity || identity.node_id !== req.node) return fail(res, req, 403, 'egress.owner', 'Network did not confirm this computer');
+ const share = await shares.createEgress({ node: req.node, owner: identity.owner, name: identity.name, port: req.body.port });
+ proxy.forget(share.host);
+ return res.json({ share: brief(share) });
+ });
+
+ r.post('/party-shares', async (req, res) => {
+ const claim = verify(req.body && req.body.grant, config.partySharedSecret);
+ if (!claim || claim.type !== 'create' || claim.node !== req.node)
+ return fail(res, req, 403, 'party.invalid_grant', 'Chat did not authorize this party for this machine');
+ try {
+ const share = await shares.createParty(claim);
+ return res.status(201).json({ share: brief(share) });
+ } catch (err) {
+ if (err instanceof ShareError) return fail(res, req, err.status, err.code, err.message);
+ throw err;
+ }
+ });
+
+ r.put('/shares/:id/party-members', async (req, res) => {
+ try {
+ const share = await shares.partyMembers(await shares.get(req.params.id), req.node, req.body && req.body.members);
+ proxy.forget(share.host);
+ return res.json({ share: brief(share) });
+ } catch (err) {
+ if (err instanceof ShareError) return fail(res, req, err.status, err.code, err.message);
+ throw err;
+ }
+ });
+
+ r.delete('/shares/:id/party', async (req, res) => {
+ try {
+ const share = await shares.closeParty(await shares.get(req.params.id), req.node);
+ tunnels.close(share.id, 4003, 'party ended');
+ proxy.forget(share.host);
+ return res.json({ share: brief(share) });
+ } catch (err) {
+ if (err instanceof ShareError) return fail(res, req, err.status, err.code, err.message);
+ throw err;
+ }
+ });
+
+ 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 ${host}.`);
+ if (share.status !== 'active') return fail(res, req, 410, 'share.revoked', 'That share is closed.');
+ const t = linkAuth.mint(share.id, req.node);
+ return res.json({ tunnel_token: t.token, expires_in: t.expires_in, share: brief(share) });
+ });
+
+ return r;
+}
+
+module.exports = { createLinkApi };
deleted file mode 100644
deleted file mode 100644
@@ -1,96 +0,0 @@
@@ -1,96 +0,0 @@
-'use strict';
-
-/**
- * /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.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 }] }
- * POST /shares/:id/tunnel-token → { tunnel_token, expires_in: 60, share } for one hello on /tunnel, within 60 s
- *
- * The tunnel token is in this answer only, and is never logged.
- */
-const { http } = require('openvibe-contracts');
-const express = require('express');
-const { asyncRouter } = require('./router');
-const { verify } = require('../party-grants');
-const { ShareError } = require('../shares');
-
-function createNodeApi(ctx) {
- const { config, shares, nodeAuth, tunnels, proxy } = ctx;
- const host = new URL(config.baseUrl).host;
- const r = asyncRouter();
- r.use(express.json({ limit: '4kb' }));
- 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.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;
- return next();
- });
-
- const brief = (s) => ({ id: s.id, host: s.host, url: s.url, local_port: s.local_port, mode: s.mode });
-
- r.get('/shares', async (req, res) => res.json({ shares: (await shares.byNode(req.node)).map(brief) }));
-
- r.post('/egress-share', async (req, res) => {
- const identity = await require('../link-egress').createEgressAuth({ config }).identity(req.body?.network_token);
- if (!identity || identity.node_id !== req.node) return fail(res, req, 403, 'egress.owner', 'Network did not confirm this computer');
- const share = await shares.createEgress({ node: req.node, owner: identity.owner, name: identity.name, port: req.body.port });
- proxy.forget(share.host);
- return res.json({ share: brief(share) });
- });
-
- r.post('/party-shares', async (req, res) => {
- const claim = verify(req.body && req.body.grant, config.partySharedSecret);
- if (!claim || claim.type !== 'create' || claim.node !== req.node)
- return fail(res, req, 403, 'party.invalid_grant', 'Chat did not authorize this party for this machine');
- try {
- const share = await shares.createParty(claim);
- return res.status(201).json({ share: brief(share) });
- } catch (err) {
- if (err instanceof ShareError) return fail(res, req, err.status, err.code, err.message);
- throw err;
- }
- });
-
- r.put('/shares/:id/party-members', async (req, res) => {
- try {
- const share = await shares.partyMembers(await shares.get(req.params.id), req.node, req.body && req.body.members);
- proxy.forget(share.host);
- return res.json({ share: brief(share) });
- } catch (err) {
- if (err instanceof ShareError) return fail(res, req, err.status, err.code, err.message);
- throw err;
- }
- });
-
- r.delete('/shares/:id/party', async (req, res) => {
- try {
- const share = await shares.closeParty(await shares.get(req.params.id), req.node);
- tunnels.close(share.id, 4003, 'party ended');
- proxy.forget(share.host);
- return res.json({ share: brief(share) });
- } catch (err) {
- if (err instanceof ShareError) return fail(res, req, err.status, err.code, err.message);
- throw err;
- }
- });
-
- 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 ${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) });
- });
-
- return r;
-}
-
-module.exports = { createNodeApi };
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
* /shares/:id one share: connected or not, who may open it, who did, the command
* /shares/:id one share: connected or not, who may open it, who did, the command
* POST /shares/:id/policy who can open it, the port
* POST /shares/:id/policy who can open it, the port
* POST /shares/:id/key a new key (shown once); the connected computer is disconnected
* POST /shares/:id/key a new key (shown once); the connected computer is disconnected
- * POST /shares/:id/node node=nod_… one of your computers serves it (CON-4); node= (empty) lets it go and shows a
+ * POST /shares/:id/node node=lnk_… one of your computers serves it (CON-4); node= (empty) lets it go and shows a
* new key once
* new key once
* POST /shares/:id/hooks path=&label= a webhook address for it (CON-15)
* POST /shares/:id/hooks path=&label= a webhook address for it (CON-15)
* POST /shares/:id/hooks/:hook/remove
* POST /shares/:id/hooks/:hook/remove
@@ -11,7 +11,7 @@ function createEgressAuth({ config, fetchImpl = globalThis.fetch }) {
@@ -11,7 +11,7 @@ function createEgressAuth({ config, fetchImpl = globalThis.fetch }) {
} catch { return null; }
} catch { return null; }
}
}
return {
return {
- identity: token => network('/api/v1/node/self/egress/identity', null, token),
+ identity: token => network('/api/v1/link/self/egress/identity', null, token),
async open(share, req) {
async open(share, req) {
if (req.url !== '/__ov_egress' || req.method !== 'GET' || String(req.headers.upgrade).toLowerCase() !== 'websocket') return false;
if (req.url !== '/__ov_egress' || req.method !== 'GET' || String(req.headers.upgrade).toLowerCase() !== 'websocket') return false;
const session = req.headers['x-ov-session'], credential = req.headers['x-ov-egress'];
const session = req.headers['x-ov-session'], credential = req.headers['x-ov-egress'];
@@ -11,7 +11,7 @@
@@ -11,7 +11,7 @@
* is kept 20 seconds (by a hash of their token; at most 500 people). Binding always reads it fresh.
* is kept 20 seconds (by a hash of their token; at most 500 people). Binding always reads it fresh.
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
-const { NODE_ID } = require('./auth/node-auth');
+const { LINK_ID } = require('./auth/link-auth');
const KEEP_MS = 20_000;
const KEEP_MS = 20_000;
const KEEP_MAX = 500;
const KEEP_MAX = 500;
@@ -33,7 +33,7 @@ function createMachines({ config, fetchImpl = globalThis.fetch, log = console, n
@@ -33,7 +33,7 @@ function createMachines({ config, fetchImpl = globalThis.fetch, log = console, n
if (!res.ok) return { ok: false, reason: `network_${res.status}` };
if (!res.ok) return { ok: false, reason: `network_${res.status}` };
const body = await res.json();
const body = await res.json();
const machines = (Array.isArray(body && body.nodes) ? body.nodes : [])
const machines = (Array.isArray(body && body.nodes) ? body.nodes : [])
- .filter((n) => n && n.status === 'active' && NODE_ID.test(String(n.principal || '')))
+ .filter((n) => n && n.status === 'active' && LINK_ID.test(String(n.principal || '')))
.map((n) => ({
.map((n) => ({
id: n.principal,
id: n.principal,
name: typeof n.name === 'string' && n.name.trim() ? n.name.trim().slice(0, 80) : 'A computer',
name: typeof n.name === 'string' && n.name.trim() ? n.name.trim().slice(0, 80) : 'A computer',
@@ -280,7 +280,7 @@ function createShares(s, { config }) {
@@ -280,7 +280,7 @@ function createShares(s, { config }) {
}
}
/**
/**
- * One of the owner's computers serves the share (CON-4): `machine` is { id: 'nod_…', name }, already checked to be
+ * One of the owner's computers serves the share (CON-4): `machine` is { id: 'lnk_…', name }, already checked to be
* the owner's and active (the route asks Network with the owner's own token). The connect key is replaced by one
* the owner's and active (the route asks Network with the owner's own token). The connect key is replaced by one
* nobody is shown, so only that computer, with its node token and a tunnel token, can serve it now. null unbinds
* nobody is shown, so only that computer, with its node token and a tunnel token, can serve it now. null unbinds
* and returns a new key (shown once), for the one-file client again.
* and returns a new key (shown once), for the one-file client again.
@@ -306,7 +306,7 @@ function createShares(s, { config }) {
@@ -306,7 +306,7 @@ function createShares(s, { config }) {
}
}
async function createEgress({ node, owner, name, port }) {
async function createEgress({ node, owner, name, port }) {
- if (!/^nod_[0-9A-HJKMNP-TV-Z]{26}$/.test(node) || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(owner) ||
+ if (!/^lnk_[0-9A-HJKMNP-TV-Z]{26}$/.test(node) || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(owner) ||
!Number.isInteger(port) || port < 1 || port > 65535) throw new ShareError(422, 'egress.invalid', 'Invalid computer');
!Number.isInteger(port) || port < 1 || port > 65535) throw new ShareError(422, 'egress.invalid', 'Invalid computer');
const old = await db.maybe("SELECT * FROM shares WHERE node_id = $1 AND link_egress AND status = 'active'", [node]);
const old = await db.maybe("SELECT * FROM shares WHERE node_id = $1 AND link_egress AND status = 'active'", [node]);
if (old) {
if (old) {
@@ -325,7 +325,7 @@ function createShares(s, { config }) {
@@ -325,7 +325,7 @@ function createShares(s, { config }) {
async function createParty({ party, owner, username, node, port }) {
async function createParty({ party, owner, username, node, port }) {
if (!/^party-[a-f0-9]{8}$/.test(String(party || '')) || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(owner || '')) ||
if (!/^party-[a-f0-9]{8}$/.test(String(party || '')) || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(owner || '')) ||
- !/^nod_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(node || ''))) throw new ShareError(422, 'party.invalid', 'Invalid party or machine');
+ !/^lnk_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(node || ''))) throw new ShareError(422, 'party.invalid', 'Invalid party or machine');
const existing = await db.maybe("SELECT * FROM shares WHERE party_slug = $1 AND status = 'active'", [party]);
const existing = await db.maybe("SELECT * FROM shares WHERE party_slug = $1 AND status = 'active'", [party]);
if (existing) {
if (existing) {
const current = view(existing);
const current = view(existing);
@@ -229,7 +229,7 @@ function requestRow(q) {
@@ -229,7 +229,7 @@ function requestRow(q) {
return `<li class="cn-req is-${tone}" data-seq="${esc(q.seq)}"><span class="cn-req-badge">${esc(badge)}</span><code class="cn-req-line"><b>${esc(q.ws ? 'WS' : q.method)}</b> ${esc(q.path)}</code><span class="cn-req-meta">${esc(meta)}</span><time datetime="${esc(q.at)}">${esc(String(q.at).slice(11, 19))}</time></li>`;
return `<li class="cn-req is-${tone}" data-seq="${esc(q.seq)}"><span class="cn-req-badge">${esc(badge)}</span><code class="cn-req-line"><b>${esc(q.ws ? 'WS' : q.method)}</b> ${esc(q.path)}</code><span class="cn-req-meta">${esc(meta)}</span><time datetime="${esc(q.at)}">${esc(String(q.at).slice(11, 19))}</time></li>`;
}
}
-const MACHINES_URL = 'https://openvibe.bot/machines';
+const MACHINES_URL = 'https://openvibe.link/machines';
/**
/**
* Which computer serves it (CON-4): one of the owner's computers as OpenVibe Link (no key to copy; removing the computer
* Which computer serves it (CON-4): one of the owner's computers as OpenVibe Link (no key to copy; removing the computer
@@ -237,8 +237,8 @@ const MACHINES_URL = 'https://openvibe.bot/machines';
@@ -237,8 +237,8 @@ const MACHINES_URL = 'https://openvibe.bot/machines';
*/
*/
function nodeBlock(share, machines, now) {
function nodeBlock(share, machines, now) {
if (share.node) {
if (share.node) {
- const run = `openvibe-node share --share ${share.id}`;
- const keep = `sudo openvibe-node share add --share ${share.id}`;
+ const run = `openvibe-link share --share ${share.id}`;
+ const keep = `sudo openvibe-link share add --share ${share.id}`;
return `<section class="cn-node" aria-labelledby="cn-node-h"><h2 id="cn-node-h">Your computer serves it</h2>
return `<section class="cn-node" aria-labelledby="cn-node-h"><h2 id="cn-node-h">Your computer serves it</h2>
<p><b>${esc(share.node.name)}</b> serves this share as OpenVibe Link: no key to copy, and removing the computer on OpenVibe stops it within five minutes.</p>
<p><b>${esc(share.node.name)}</b> serves this share as OpenVibe Link: no key to copy, and removing the computer on OpenVibe stops it within five minutes.</p>
<ol class="cn-steps">
<ol class="cn-steps">
@@ -251,7 +251,7 @@ function nodeBlock(share, machines, now) {
@@ -251,7 +251,7 @@ function nodeBlock(share, machines, now) {
const intro = '<p>Pick one of your computers that runs OpenVibe Link and it serves this share with its own sign-in: no key to copy, and removing the computer on OpenVibe stops it. The connect key stops working while a computer serves it.</p>';
const intro = '<p>Pick one of your computers that runs OpenVibe Link and it serves this share with its own sign-in: no key to copy, and removing the computer on OpenVibe stops it. The connect key stops working while a computer serves it.</p>';
let list;
let list;
if (machines === null) list = '<p class="cn-empty">Your computers could not be read from OpenVibe just now. Reload the page in a moment.</p>';
if (machines === null) list = '<p class="cn-empty">Your computers could not be read from OpenVibe just now. Reload the page in a moment.</p>';
- else if (!machines.length) list = `<p class="cn-empty">No computers yet. <a href="${esc(MACHINES_URL)}">Add one on openvibe.bot/machines</a>, then come back here.</p>`;
+ else if (!machines.length) list = `<p class="cn-empty">No computers yet. <a href="${esc(MACHINES_URL)}">Add one on openvibe.link/machines</a>, then come back here.</p>`;
else {
else {
list = `<ul class="cn-machines">${machines.map((m) => `<li><span class="cn-machine"><b>${esc(m.name)}</b><small>${m.last_seen_at ? `seen ${esc(ago(m.last_seen_at, now))}` : 'not seen yet'}</small></span>
list = `<ul class="cn-machines">${machines.map((m) => `<li><span class="cn-machine"><b>${esc(m.name)}</b><small>${m.last_seen_at ? `seen ${esc(ago(m.last_seen_at, now))}` : 'not seen yet'}</small></span>
<form method="post" action="/shares/${esc(share.id)}/node"><input type="hidden" name="node" value="${esc(m.id)}"><button class="cn-btn cn-btn-quiet" type="submit">${kit.icon('check')} Serve it from this computer</button></form></li>`).join('')}</ul>`;
<form method="post" action="/shares/${esc(share.id)}/node"><input type="hidden" name="node" value="${esc(m.id)}"><button class="cn-btn cn-btn-quiet" type="submit">${kit.icon('check')} Serve it from this computer</button></form></li>`).join('')}</ul>`;
@@ -136,16 +136,16 @@ async function startNetwork() {
@@ -136,16 +136,16 @@ async function startNetwork() {
return {
return {
url: srv.url, publicPem, privatePem, state: st, requests: st.requests, tokenRequests: st.tokenRequests,
url: srv.url, publicPem, privatePem, state: st, requests: st.requests, tokenRequests: st.tokenRequests,
addUser, userToken, signJwt: (claims) => signJwt(claims, privatePem), issuer: () => issuer,
addUser, userToken, signJwt: (claims) => signJwt(claims, privatePem), issuer: () => issuer,
- /** A machine `user` owns on Network (a node principal) → { id: 'nod_…', name }. */
+ /** A machine `user` owns on Network (a node principal) → { id: 'lnk_…', name }. */
addNode(user, { name = 'Desk PC', status = 'active' } = {}) {
addNode(user, { name = 'Desk PC', status = 'active' } = {}) {
- const n = { id: ids.newId('node'), node_id: `n-${crypto.randomBytes(6).toString('hex')}`, name, owner: user.subject, status, last_seen_at: new Date().toISOString() };
+ const n = { id: ids.newId('link'), node_id: `n-${crypto.randomBytes(6).toString('hex')}`, name, owner: user.subject, status, last_seen_at: new Date().toISOString() };
st.nodes.push(n);
st.nodes.push(n);
return n;
return n;
},
},
/** That machine's node token, as Network's /oauth/token would sell it (audience openvibe.website, 300 s). */
/** That machine's node token, as Network's /oauth/token would sell it (audience openvibe.website, 300 s). */
- nodeToken(node, { aud = 'openvibe.website', ttl = 300, iat = null, sub = null } = {}) {
+ linkToken(node, { aud = 'openvibe.website', ttl = 300, iat = null, sub = null } = {}) {
const now = iat != null ? iat : Math.floor(Date.now() / 1000);
const now = iat != null ? iat : Math.floor(Date.now() / 1000);
- return serviceAuth.signServiceToken({ iss: issuer, sub: sub || `node:${node.id}`, actor_type: 'node', aud: [aud], cap: [], iat: now, exp: now + ttl, jti: `tok_${crypto.randomBytes(12).toString('hex')}` }, privatePem);
+ return serviceAuth.signServiceToken({ iss: issuer, sub: sub || `link:${node.id}`, actor_type: 'link', aud: [aud], cap: [], iat: now, exp: now + ttl, jti: `tok_${crypto.randomBytes(12).toString('hex')}` }, privatePem);
},
},
issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },
issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },
setDown(v) { st.down = v; },
setDown(v) { st.down = v; },
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+'use strict';
+const assert = require('node:assert/strict');
+const { spawnSync } = require('node:child_process');
+const path = require('node:path');
+const root = path.resolve(__dirname, '..');
+const oldName = ['OpenVibe[ .-]No' + 'de', 'openvibe-' + 'node', 'no' + 'd_', 'node[.]openvibe[.]network', 'node:lnk_', 'openvibe[.](bot|my|network)/mach' + 'ines'].join('|');
+const result = spawnSync('git', ['grep', '-n', '-I', '-i', '-E', oldName, '--', '.', ':!migrations/**', ':!test/link-brand.test.js'], { cwd: root, encoding: 'utf8' });
+assert.ok(result.status === 0 || result.status === 1, result.stderr || 'git grep failed');
+assert.equal(result.status, 1, `Old Link brand or principal returned:\n${(result.stdout || '').split('\n').slice(0, 10).join('\n')}`);
+console.log(' ok old Link brand and principal IDs are absent');
@@ -11,7 +11,7 @@ const { once } = require('events');
@@ -11,7 +11,7 @@ const { once } = require('events');
const credential = crypto.randomBytes(32).toString('base64url');
const credential = crypto.randomBytes(32).toString('base64url');
let identity, checks = 0, allowed = true;
let identity, checks = 0, allowed = true;
globalThis.fetch = async (url, options = {}) => {
globalThis.fetch = async (url, options = {}) => {
- if (String(url).endsWith('/api/v1/node/self/egress/identity')) return new Response(JSON.stringify(identity));
+ if (String(url).endsWith('/api/v1/link/self/egress/identity')) return new Response(JSON.stringify(identity));
if (String(url).endsWith('/api/v1/link-egress/open')) {
if (String(url).endsWith('/api/v1/link-egress/open')) {
checks++;
checks++;
const b = JSON.parse(options.body);
const b = JSON.parse(options.body);
@@ -35,11 +35,11 @@ const { once } = require('events');
@@ -35,11 +35,11 @@ const { once } = require('events');
const owner = t.network.addUser('egressowner');
const owner = t.network.addUser('egressowner');
const node = t.network.addNode(owner, { name: 'Home PC' });
const node = t.network.addNode(owner, { name: 'Home PC' });
identity = { node_id: node.id, owner: owner.subject, name: 'Home PC' };
identity = { node_id: node.id, owner: owner.subject, name: 'Home PC' };
- const made = await t.get('/api/v1/node/egress-share', { method: 'POST', bearer: t.network.nodeToken(node),
+ const made = await t.get('/api/v1/link/egress-share', { method: 'POST', bearer: t.network.linkToken(node),
json: { network_token: 'test-network-token', port: endpoint.address().port } });
json: { network_token: 'test-network-token', port: endpoint.address().port } });
assert.equal(made.status, 200, 'Node creates its dedicated share after Network confirms ownership');
assert.equal(made.status, 200, 'Node creates its dedicated share after Network confirms ownership');
const share = made.json().share;
const share = made.json().share;
- const ticket = await t.get(`/api/v1/node/shares/${share.id}/tunnel-token`, { method: 'POST', bearer: t.network.nodeToken(node) });
+ const ticket = await t.get(`/api/v1/link/shares/${share.id}/tunnel-token`, { method: 'POST', bearer: t.network.linkToken(node) });
tunnel = new WebSocket(`ws://127.0.0.1:${t.port}/tunnel`);
tunnel = new WebSocket(`ws://127.0.0.1:${t.port}/tunnel`);
const packet = (kind, id, data = Buffer.alloc(0)) => { const h = Buffer.alloc(5); h[0] = kind; h.writeUInt32BE(id, 1); return Buffer.concat([h, data]); };
const packet = (kind, id, data = Buffer.alloc(0)) => { const h = Buffer.alloc(5); h[0] = kind; h.writeUInt32BE(id, 1); return Buffer.concat([h, data]); };
const ready = new Promise((resolve, reject) => {
const ready = new Promise((resolve, reject) => {
@@ -64,7 +64,7 @@ const { once } = require('events');
@@ -64,7 +64,7 @@ const { once } = require('events');
});
});
await once(tunnel, 'open');
await once(tunnel, 'open');
tunnel.send(JSON.stringify({ t: 'hello', v: 1, client: 'egress-test', share: share.id,
tunnel.send(JSON.stringify({ t: 'hello', v: 1, client: 'egress-test', share: share.id,
- node_token: t.network.nodeToken(node), tunnel: ticket.json().tunnel_token, port: endpoint.address().port }));
+ link_token: t.network.linkToken(node), tunnel: ticket.json().tunnel_token, port: endpoint.address().port }));
await ready;
await ready;
const connect = () => new WebSocket(`ws://127.0.0.1:${t.port}/__ov_egress`, { headers: { Host: share.host, 'X-OV-Session': 'ses_test', 'X-OV-Egress': credential } });
const connect = () => new WebSocket(`ws://127.0.0.1:${t.port}/__ov_egress`, { headers: { Host: share.host, 'X-OV-Session': 'ses_test', 'X-OV-Egress': credential } });
const visitor = connect(); await once(visitor, 'open');
const visitor = connect(); await once(visitor, 'open');
@@ -9,13 +9,13 @@ const { createEgressAuth } = require('../server/link-egress');
@@ -9,13 +9,13 @@ const { createEgressAuth } = require('../server/link-egress');
const b = JSON.parse(options.body);
const b = JSON.parse(options.body);
return { ok: allowed, json: async () => ({ node_id: b.node_id, session_id: b.session_id }) };
return { ok: allowed, json: async () => ({ node_id: b.node_id, session_id: b.session_id }) };
} });
} });
- const share = { node: { id: 'nod_mine' } };
+ const share = { node: { id: 'lnk_mine' } };
const req = { method: 'GET', url: '/__ov_egress', headers: { upgrade: 'websocket', 'x-ov-session': 'ses_mine', 'x-ov-egress': 'a'.repeat(43) } };
const req = { method: 'GET', url: '/__ov_egress', headers: { upgrade: 'websocket', 'x-ov-session': 'ses_mine', 'x-ov-egress': 'a'.repeat(43) } };
assert.equal(await gate.open(share, req), true);
assert.equal(await gate.open(share, req), true);
allowed = false;
allowed = false;
assert.equal(await gate.open(share, req), false, 'successful ownership checks are never cached');
assert.equal(await gate.open(share, req), false, 'successful ownership checks are never cached');
assert.equal(seen.length, 2);
assert.equal(seen.length, 2);
- assert.equal(JSON.parse(seen[0].options.body).node_id, 'nod_mine');
+ assert.equal(JSON.parse(seen[0].options.body).node_id, 'lnk_mine');
assert.equal(await gate.open(share, { ...req, headers: { cookie: 'owner-session' } }), false, 'owner cookie is insufficient');
assert.equal(await gate.open(share, { ...req, headers: { cookie: 'owner-session' } }), false, 'owner cookie is insufficient');
assert.equal(await gate.open(share, { ...req, url: '/' }), false);
assert.equal(await gate.open(share, { ...req, url: '/' }), false);
assert.equal(await gate.open(share, { ...req, url: '/__ov_egress?credential=secret' }), false);
assert.equal(await gate.open(share, { ...req, url: '/__ov_egress?credential=secret' }), false);
@@ -96,8 +96,8 @@ function upgrade(t, host, cookie) {
@@ -96,8 +96,8 @@ function upgrade(t, host, cookie) {
});
});
await check('both node audiences work; unrelated audiences do not', async () => {
await check('both node audiences work; unrelated audiences do not', async () => {
const node = t.network.addNode(alex);
const node = t.network.addNode(alex);
- for (const aud of ['openvibe.website', 'openvibe.link']) assert.equal((await t.ctx.nodeAuth.verify(t.network.nodeToken(node, { aud }))).ok, true);
- assert.equal((await t.ctx.nodeAuth.verify(t.network.nodeToken(node, { aud: 'openvibe.network' }))).ok, false);
+ for (const aud of ['openvibe.website', 'openvibe.link']) assert.equal((await t.ctx.linkAuth.verify(t.network.linkToken(node, { aud }))).ok, true);
+ assert.equal((await t.ctx.linkAuth.verify(t.network.linkToken(node, { aud: 'openvibe.network' }))).ok, false);
});
});
await check('redirects are opt-in, preserve the path/query, and cover upgrades without forwarding cookies', async () => {
await check('redirects are opt-in, preserve the path/query, and cover upgrades without forwarding cookies', async () => {
assert.equal(t.config.shares.redirectLegacy, false);
assert.equal(t.config.shares.redirectLegacy, false);
new file mode 100644
new file mode 100644
@@ -0,0 +1,203 @@
@@ -0,0 +1,203 @@
+'use strict';
+/**
+ * A share served by one of its owner's computers, as OpenVibe Link (CON-4, ADR-057 decision 4): the owner picks the
+ * computer from their machines on Network; the computer asks for a tunnel token with its node token and says hello with
+ * both; a visitor's request reaches it; it sends a fresh node token before the old one runs out, and a tunnel whose token
+ * ran out is closed. Every refusal: another person's computer, a token for another audience, a spent or late tunnel
+ * token, a share it does not serve, the key once a computer serves it.
+ * node test/link-share.test.js
+ */
+const assert = require('assert');
+const WebSocket = require('ws');
+const { boot } = require('./helpers/boot');
+const { check, done } = require('./helpers/check');
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+/** A computer's tunnel: hello with its two credentials, and an answer to every request ("from node <path>"). */
+function linkTunnel(t, hello) {
+ const ws = new WebSocket(`ws://127.0.0.1:${t.port}/tunnel`);
+ const frames = [];
+ const waiters = [];
+ let closed = null;
+ ws.on('message', (data, isBinary) => {
+ if (isBinary) return;
+ const m = JSON.parse(String(data));
+ frames.push(m);
+ if (m.t === 'req') {
+ ws.send(JSON.stringify({ t: 'res', id: m.id, status: 200, headers: [['content-type', 'text/plain']] }));
+ const head = Buffer.alloc(5); head[0] = 1; head.writeUInt32BE(m.id, 1);
+ ws.send(Buffer.concat([head, Buffer.from(`from node ${m.path}`)]));
+ const end = Buffer.alloc(5); end[0] = 2; end.writeUInt32BE(m.id, 1);
+ ws.send(end);
+ }
+ waiters.splice(0).forEach((w) => w());
+ });
+ const closedP = new Promise((resolve) => ws.on('close', (code, reason) => { closed = { code, reason: String(reason) }; resolve(closed); waiters.splice(0).forEach((w) => w()); }));
+ ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 1, client: 'openvibe-link/test', port: 1234, ...hello })));
+ async function next(type, ms = 3000) {
+ const end = Date.now() + ms;
+ for (;;) {
+ const f = frames.find((x) => x.t === type);
+ if (f) { frames.splice(frames.indexOf(f), 1); return f; }
+ if (closed) return null;
+ if (Date.now() > end) throw new Error(`no ${type} frame`);
+ await new Promise((r) => { waiters.push(r); setTimeout(r, 50); });
+ }
+ }
+ return { ws, next, closed: () => closedP, frames };
+}
+
+(async () => {
+ const t = await boot();
+ const alex = t.network.addUser('alex');
+ const bob = t.network.addUser('bob');
+ const desk = t.network.addNode(alex, { name: 'Desk PC' });
+ const gone = t.network.addNode(alex, { name: 'Old laptop', status: 'revoked' });
+ const bobs = t.network.addNode(bob, { name: 'Bob box' });
+ let made;
+ let ticket;
+
+ await check('the owner sees their active computers, and only theirs', async () => {
+ made = await t.makeShare(alex, { label: 'portal', port: 8790 });
+ const r = await t.get('/api/v1/machines', { as: alex });
+ assert.strictEqual(r.status, 200, r.text);
+ const m = r.json().machines;
+ assert.deepStrictEqual(m.map((x) => [x.id, x.name]), [[desk.id, 'Desk PC']], 'a revoked machine and Bob\'s are left out');
+ const page = await t.get(`/shares/${made.share.id}`, { as: alex });
+ assert.match(page.text, /Serve it from this computer/);
+ assert.match(page.text, /Desk PC/);
+ assert.doesNotMatch(page.text, /Old laptop|Bob box/);
+ });
+
+ await check('binding: only one of the owner\'s active computers; the key stops working at once', async () => {
+ for (const bad of [bobs.id, gone.id, 'lnk_01M6FCPA3HCE14MFEM40HQ7VDJ']) {
+ const r = await t.get(`/api/v1/shares/${made.share.id}/node`, { as: alex, method: 'PUT', json: { node: bad } });
+ assert.strictEqual(r.status, 404, `${bad}: ${r.text}`);
+ }
+ const other = await t.get(`/api/v1/shares/${made.share.id}/node`, { as: bob, method: 'PUT', json: { node: bobs.id } });
+ assert.strictEqual(other.status, 404, 'not Bob\'s share');
+ const c = t.client({ key: made.key, port: 8790 });
+ await c.ready();
+ const r = await t.get(`/api/v1/shares/${made.share.id}/node`, { as: alex, method: 'PUT', json: { node: desk.id } });
+ assert.strictEqual(r.status, 200, r.text);
+ const s = r.json().share;
+ assert.deepStrictEqual(s.node, { id: desk.id, name: 'Desk PC' });
+ assert.strictEqual(s.key_prefix, null, 'no key to show');
+ await sleep(100);
+ assert.strictEqual(t.ctx.tunnels.isConnected(made.share.id), false, 'the key\'s computer was disconnected');
+ c.stop();
+ const k = linkTunnel(t, { key: made.key });
+ const closed = await k.closed();
+ assert.strictEqual(closed.code, 4002, 'the key no longer opens it');
+ });
+
+ await check('the computer: its shares and a tunnel token, only with its own node token for openvibe.website', async () => {
+ const list = await t.get('/api/v1/link/shares', { bearer: t.network.linkToken(desk) });
+ assert.strictEqual(list.status, 200, list.text);
+ assert.deepStrictEqual(list.json().shares.map((x) => [x.id, x.local_port]), [[made.share.id, 8790]]);
+ assert.strictEqual((await t.get('/api/v1/link/shares')).status, 401, 'no token');
+ assert.strictEqual((await t.get('/api/v1/link/shares', { bearer: t.network.userToken(alex) })).status, 401, 'a person\'s token is not a computer\'s');
+ assert.strictEqual((await t.get('/api/v1/link/shares', { bearer: t.network.linkToken(desk, { aud: 'openvibe.bot' }) })).status, 401, 'another audience');
+ assert.strictEqual((await t.get('/api/v1/link/shares', { bearer: t.network.linkToken(desk, { iat: Math.floor(Date.now() / 1000) - 1000 }) })).status, 401, 'expired');
+ assert.strictEqual((await t.get('/api/v1/link/shares', { bearer: t.network.linkToken(desk, { sub: 'svc:bot' }) })).status, 401, 'a service is not a computer');
+ const bobList = await t.get('/api/v1/link/shares', { bearer: t.network.linkToken(bobs) });
+ assert.deepStrictEqual(bobList.json().shares, [], 'Bob\'s computer serves nothing of Alex\'s');
+ const no = await t.get(`/api/v1/link/shares/${made.share.id}/tunnel-token`, { method: 'POST', bearer: t.network.linkToken(bobs) });
+ assert.strictEqual(no.status, 404, 'another computer gets no tunnel token');
+ const r = await t.get(`/api/v1/link/shares/${made.share.id}/tunnel-token`, { method: 'POST', bearer: t.network.linkToken(desk) });
+ assert.strictEqual(r.status, 200, r.text);
+ ticket = r.json();
+ assert.match(ticket.tunnel_token, /^ovt_[A-Za-z0-9_-]{43}$/);
+ assert.strictEqual(ticket.expires_in, 60);
+ assert.ok(!t.logs().includes(ticket.tunnel_token), 'never logged');
+ });
+
+ await check('hello with both: ready, and a visitor\'s request reaches the computer', async () => {
+ const n = linkTunnel(t, { share: made.share.id, link_token: t.network.linkToken(desk), tunnel: ticket.tunnel_token });
+ const ready = await n.next('ready');
+ assert.ok(ready, 'ready');
+ assert.strictEqual(ready.node, true);
+ assert.strictEqual(ready.share.local_port, 8790);
+ assert.ok(t.ctx.tunnels.isConnected(made.share.id));
+ const visitor = await t.enter(alex, made.share);
+ const page = await t.share(made.share.host, '/hello', { cookie: visitor.cookie });
+ assert.strictEqual(page.status, 200, page.text);
+ assert.strictEqual(page.text, 'from node /hello');
+ const html = await t.get(`/shares/${made.share.id}`, { as: alex });
+ assert.match(html.text, /Your computer serves it/);
+ assert.match(html.text, /openvibe-link share --share shr_/);
+ n.ws.close();
+ await n.closed();
+ });
+
+ await check('a tunnel token is spent by any try, and is for one share and one computer', async () => {
+ const fresh = async (node) => (await t.get(`/api/v1/link/shares/${made.share.id}/tunnel-token`, { method: 'POST', bearer: t.network.linkToken(node) })).json().tunnel_token;
+ const again = linkTunnel(t, { share: made.share.id, link_token: t.network.linkToken(desk), tunnel: ticket.tunnel_token });
+ assert.strictEqual((await again.closed()).code, 4000, 'spent: ask again');
+ const tok = await fresh(desk);
+ const wrong = linkTunnel(t, { share: made.share.id, link_token: t.network.linkToken(bobs), tunnel: tok });
+ assert.strictEqual((await wrong.closed()).code, 4003, 'Bob\'s computer does not serve it');
+ const after = linkTunnel(t, { share: made.share.id, link_token: t.network.linkToken(desk), tunnel: tok });
+ assert.strictEqual((await after.closed()).code, 4000, 'and the try spent it');
+ const bad = linkTunnel(t, { share: made.share.id, link_token: t.network.linkToken(desk, { aud: 'openvibe.network' }), tunnel: await fresh(desk) });
+ assert.strictEqual((await bad.closed()).code, 4002, 'a node token for Network is refused: the computer stops');
+ assert.strictEqual(t.ctx.linkAuth.pending(), 1, 'the unused one waits; nothing else is kept');
+ });
+
+ await check('reauth: a fresh token keeps it open; another computer\'s token closes it; a token that ran out closes it', async () => {
+ const tok = (await t.get(`/api/v1/link/shares/${made.share.id}/tunnel-token`, { method: 'POST', bearer: t.network.linkToken(desk) })).json().tunnel_token;
+ const n = linkTunnel(t, { share: made.share.id, link_token: t.network.linkToken(desk, { ttl: 2 }), tunnel: tok });
+ await n.next('ready');
+ n.ws.send(JSON.stringify({ t: 'reauth', link_token: t.network.linkToken(desk) }));
+ const ok = await n.next('reauthed');
+ assert.ok(ok && Date.parse(ok.until) > Date.now() + 200_000, 'until moves on');
+ n.ws.send(JSON.stringify({ t: 'reauth', link_token: t.network.linkToken(bobs) }));
+ assert.strictEqual((await n.closed()).code, 4002);
+
+ const tok2 = (await t.get(`/api/v1/link/shares/${made.share.id}/tunnel-token`, { method: 'POST', bearer: t.network.linkToken(desk) })).json().tunnel_token;
+ const m = linkTunnel(t, { share: made.share.id, link_token: t.network.linkToken(desk), tunnel: tok2 });
+ await m.next('ready');
+ const live = t.ctx.tunnels.get(made.share.id);
+ live.authUntil = Date.now() - 60_000; // its token ran out a minute ago and no reauth came
+ t.ctx.tunnels.sweep();
+ assert.strictEqual((await m.closed()).code, 4000, 'closed; the computer reconnects with a fresh token');
+ });
+
+ await check('letting the computer go: a new key (shown once) serves it again; the computer no longer can', async () => {
+ const page = await t.get(`/shares/${made.share.id}/node`, { as: alex, form: { node: '' }, method: 'POST' });
+ assert.strictEqual(page.status, 200, page.text);
+ const key = /<code id="cn-key-v">(ovc_[A-Za-z0-9_-]{43})<\/code>/.exec(page.text);
+ assert.ok(key, 'the new key is shown once');
+ const s = (await t.get(`/api/v1/shares/${made.share.id}`, { as: alex })).json().share;
+ assert.strictEqual(s.node, null);
+ const r = await t.get(`/api/v1/link/shares/${made.share.id}/tunnel-token`, { method: 'POST', bearer: t.network.linkToken(desk) });
+ assert.strictEqual(r.status, 404);
+ const c = t.client({ key: key[1], port: 8790 });
+ await c.ready();
+ assert.ok(t.ctx.tunnels.isConnected(made.share.id), 'the key works again');
+ c.stop();
+ });
+
+ await check('binding from the page: a plain form, then the commands for that computer', async () => {
+ const r = await t.get(`/shares/${made.share.id}/node`, { as: alex, form: { node: desk.id }, method: 'POST' });
+ assert.strictEqual(r.status, 303, r.text);
+ const page = await t.get(r.headers.get('location'), { as: alex });
+ assert.match(page.text, /Desk PC serves it now/);
+ assert.match(page.text, /sudo openvibe-link share add --share/);
+ const cross = await t.get(`/shares/${made.share.id}/node`, { as: alex, form: { node: desk.id }, method: 'POST', origin: 'https://evil.example' });
+ assert.strictEqual(cross.status, 403, 'another site cannot bind it');
+ });
+
+ await check('a computer that Network cannot list for its owner just now: the page says so, nothing changes', async () => {
+ t.network.setDown(true);
+ try {
+ const page = await t.get(`/shares/${made.share.id}/node`, { as: alex, form: { node: desk.id }, method: 'POST' });
+ assert.strictEqual(page.status, 503);
+ assert.match(page.text, /could not be read from OpenVibe just now/);
+ } finally { t.network.setDown(false); }
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
deleted file mode 100644
deleted file mode 100644
@@ -1,203 +0,0 @@
@@ -1,203 +0,0 @@
-'use strict';
-/**
- * A share served by one of its owner's computers, as OpenVibe Link (CON-4, ADR-057 decision 4): the owner picks the
- * computer from their machines on Network; the computer asks for a tunnel token with its node token and says hello with
- * both; a visitor's request reaches it; it sends a fresh node token before the old one runs out, and a tunnel whose token
- * ran out is closed. Every refusal: another person's computer, a token for another audience, a spent or late tunnel
- * token, a share it does not serve, the key once a computer serves it.
- * node test/node-share.test.js
- */
-const assert = require('assert');
-const WebSocket = require('ws');
-const { boot } = require('./helpers/boot');
-const { check, done } = require('./helpers/check');
-
-const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
-
-/** A computer's tunnel: hello with its two credentials, and an answer to every request ("from node <path>"). */
-function nodeTunnel(t, hello) {
- const ws = new WebSocket(`ws://127.0.0.1:${t.port}/tunnel`);
- const frames = [];
- const waiters = [];
- let closed = null;
- ws.on('message', (data, isBinary) => {
- if (isBinary) return;
- const m = JSON.parse(String(data));
- frames.push(m);
- if (m.t === 'req') {
- ws.send(JSON.stringify({ t: 'res', id: m.id, status: 200, headers: [['content-type', 'text/plain']] }));
- const head = Buffer.alloc(5); head[0] = 1; head.writeUInt32BE(m.id, 1);
- ws.send(Buffer.concat([head, Buffer.from(`from node ${m.path}`)]));
- const end = Buffer.alloc(5); end[0] = 2; end.writeUInt32BE(m.id, 1);
- ws.send(end);
- }
- waiters.splice(0).forEach((w) => w());
- });
- const closedP = new Promise((resolve) => ws.on('close', (code, reason) => { closed = { code, reason: String(reason) }; resolve(closed); waiters.splice(0).forEach((w) => w()); }));
- ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 1, client: 'openvibe-node/test', port: 1234, ...hello })));
- async function next(type, ms = 3000) {
- const end = Date.now() + ms;
- for (;;) {
- const f = frames.find((x) => x.t === type);
- if (f) { frames.splice(frames.indexOf(f), 1); return f; }
- if (closed) return null;
- if (Date.now() > end) throw new Error(`no ${type} frame`);
- await new Promise((r) => { waiters.push(r); setTimeout(r, 50); });
- }
- }
- return { ws, next, closed: () => closedP, frames };
-}
-
-(async () => {
- const t = await boot();
- const alex = t.network.addUser('alex');
- const bob = t.network.addUser('bob');
- const desk = t.network.addNode(alex, { name: 'Desk PC' });
- const gone = t.network.addNode(alex, { name: 'Old laptop', status: 'revoked' });
- const bobs = t.network.addNode(bob, { name: 'Bob box' });
- let made;
- let ticket;
-
- await check('the owner sees their active computers, and only theirs', async () => {
- made = await t.makeShare(alex, { label: 'portal', port: 8790 });
- const r = await t.get('/api/v1/machines', { as: alex });
- assert.strictEqual(r.status, 200, r.text);
- const m = r.json().machines;
- assert.deepStrictEqual(m.map((x) => [x.id, x.name]), [[desk.id, 'Desk PC']], 'a revoked machine and Bob\'s are left out');
- const page = await t.get(`/shares/${made.share.id}`, { as: alex });
- assert.match(page.text, /Serve it from this computer/);
- assert.match(page.text, /Desk PC/);
- assert.doesNotMatch(page.text, /Old laptop|Bob box/);
- });
-
- await check('binding: only one of the owner\'s active computers; the key stops working at once', async () => {
- for (const bad of [bobs.id, gone.id, 'nod_01M6FCPA3HCE14MFEM40HQ7VDJ']) {
- const r = await t.get(`/api/v1/shares/${made.share.id}/node`, { as: alex, method: 'PUT', json: { node: bad } });
- assert.strictEqual(r.status, 404, `${bad}: ${r.text}`);
- }
- const other = await t.get(`/api/v1/shares/${made.share.id}/node`, { as: bob, method: 'PUT', json: { node: bobs.id } });
- assert.strictEqual(other.status, 404, 'not Bob\'s share');
- const c = t.client({ key: made.key, port: 8790 });
- await c.ready();
- const r = await t.get(`/api/v1/shares/${made.share.id}/node`, { as: alex, method: 'PUT', json: { node: desk.id } });
- assert.strictEqual(r.status, 200, r.text);
- const s = r.json().share;
- assert.deepStrictEqual(s.node, { id: desk.id, name: 'Desk PC' });
- assert.strictEqual(s.key_prefix, null, 'no key to show');
- await sleep(100);
- assert.strictEqual(t.ctx.tunnels.isConnected(made.share.id), false, 'the key\'s computer was disconnected');
- c.stop();
- const k = nodeTunnel(t, { key: made.key });
- const closed = await k.closed();
- assert.strictEqual(closed.code, 4002, 'the key no longer opens it');
- });
-
- await check('the computer: its shares and a tunnel token, only with its own node token for openvibe.website', async () => {
- const list = await t.get('/api/v1/node/shares', { bearer: t.network.nodeToken(desk) });
- assert.strictEqual(list.status, 200, list.text);
- assert.deepStrictEqual(list.json().shares.map((x) => [x.id, x.local_port]), [[made.share.id, 8790]]);
- assert.strictEqual((await t.get('/api/v1/node/shares')).status, 401, 'no token');
- assert.strictEqual((await t.get('/api/v1/node/shares', { bearer: t.network.userToken(alex) })).status, 401, 'a person\'s token is not a computer\'s');
- assert.strictEqual((await t.get('/api/v1/node/shares', { bearer: t.network.nodeToken(desk, { aud: 'openvibe.bot' }) })).status, 401, 'another audience');
- assert.strictEqual((await t.get('/api/v1/node/shares', { bearer: t.network.nodeToken(desk, { iat: Math.floor(Date.now() / 1000) - 1000 }) })).status, 401, 'expired');
- assert.strictEqual((await t.get('/api/v1/node/shares', { bearer: t.network.nodeToken(desk, { sub: 'svc:bot' }) })).status, 401, 'a service is not a computer');
- const bobList = await t.get('/api/v1/node/shares', { bearer: t.network.nodeToken(bobs) });
- assert.deepStrictEqual(bobList.json().shares, [], 'Bob\'s computer serves nothing of Alex\'s');
- const no = await t.get(`/api/v1/node/shares/${made.share.id}/tunnel-token`, { method: 'POST', bearer: t.network.nodeToken(bobs) });
- assert.strictEqual(no.status, 404, 'another computer gets no tunnel token');
- const r = await t.get(`/api/v1/node/shares/${made.share.id}/tunnel-token`, { method: 'POST', bearer: t.network.nodeToken(desk) });
- assert.strictEqual(r.status, 200, r.text);
- ticket = r.json();
- assert.match(ticket.tunnel_token, /^ovt_[A-Za-z0-9_-]{43}$/);
- assert.strictEqual(ticket.expires_in, 60);
- assert.ok(!t.logs().includes(ticket.tunnel_token), 'never logged');
- });
-
- await check('hello with both: ready, and a visitor\'s request reaches the computer', async () => {
- const n = nodeTunnel(t, { share: made.share.id, node_token: t.network.nodeToken(desk), tunnel: ticket.tunnel_token });
- const ready = await n.next('ready');
- assert.ok(ready, 'ready');
- assert.strictEqual(ready.node, true);
- assert.strictEqual(ready.share.local_port, 8790);
- assert.ok(t.ctx.tunnels.isConnected(made.share.id));
- const visitor = await t.enter(alex, made.share);
- const page = await t.share(made.share.host, '/hello', { cookie: visitor.cookie });
- assert.strictEqual(page.status, 200, page.text);
- assert.strictEqual(page.text, 'from node /hello');
- const html = await t.get(`/shares/${made.share.id}`, { as: alex });
- assert.match(html.text, /Your computer serves it/);
- assert.match(html.text, /openvibe-node share --share shr_/);
- n.ws.close();
- await n.closed();
- });
-
- await check('a tunnel token is spent by any try, and is for one share and one computer', async () => {
- const fresh = async (node) => (await t.get(`/api/v1/node/shares/${made.share.id}/tunnel-token`, { method: 'POST', bearer: t.network.nodeToken(node) })).json().tunnel_token;
- const again = nodeTunnel(t, { share: made.share.id, node_token: t.network.nodeToken(desk), tunnel: ticket.tunnel_token });
- assert.strictEqual((await again.closed()).code, 4000, 'spent: ask again');
- const tok = await fresh(desk);
- const wrong = nodeTunnel(t, { share: made.share.id, node_token: t.network.nodeToken(bobs), tunnel: tok });
- assert.strictEqual((await wrong.closed()).code, 4003, 'Bob\'s computer does not serve it');
- const after = nodeTunnel(t, { share: made.share.id, node_token: t.network.nodeToken(desk), tunnel: tok });
- assert.strictEqual((await after.closed()).code, 4000, 'and the try spent it');
- const bad = nodeTunnel(t, { share: made.share.id, node_token: t.network.nodeToken(desk, { aud: 'openvibe.network' }), tunnel: await fresh(desk) });
- assert.strictEqual((await bad.closed()).code, 4002, 'a node token for Network is refused: the computer stops');
- assert.strictEqual(t.ctx.nodeAuth.pending(), 1, 'the unused one waits; nothing else is kept');
- });
-
- await check('reauth: a fresh token keeps it open; another computer\'s token closes it; a token that ran out closes it', async () => {
- const tok = (await t.get(`/api/v1/node/shares/${made.share.id}/tunnel-token`, { method: 'POST', bearer: t.network.nodeToken(desk) })).json().tunnel_token;
- const n = nodeTunnel(t, { share: made.share.id, node_token: t.network.nodeToken(desk, { ttl: 2 }), tunnel: tok });
- await n.next('ready');
- n.ws.send(JSON.stringify({ t: 'reauth', node_token: t.network.nodeToken(desk) }));
- const ok = await n.next('reauthed');
- assert.ok(ok && Date.parse(ok.until) > Date.now() + 200_000, 'until moves on');
- n.ws.send(JSON.stringify({ t: 'reauth', node_token: t.network.nodeToken(bobs) }));
- assert.strictEqual((await n.closed()).code, 4002);
-
- const tok2 = (await t.get(`/api/v1/node/shares/${made.share.id}/tunnel-token`, { method: 'POST', bearer: t.network.nodeToken(desk) })).json().tunnel_token;
- const m = nodeTunnel(t, { share: made.share.id, node_token: t.network.nodeToken(desk), tunnel: tok2 });
- await m.next('ready');
- const live = t.ctx.tunnels.get(made.share.id);
- live.authUntil = Date.now() - 60_000; // its token ran out a minute ago and no reauth came
- t.ctx.tunnels.sweep();
- assert.strictEqual((await m.closed()).code, 4000, 'closed; the computer reconnects with a fresh token');
- });
-
- await check('letting the computer go: a new key (shown once) serves it again; the computer no longer can', async () => {
- const page = await t.get(`/shares/${made.share.id}/node`, { as: alex, form: { node: '' }, method: 'POST' });
- assert.strictEqual(page.status, 200, page.text);
- const key = /<code id="cn-key-v">(ovc_[A-Za-z0-9_-]{43})<\/code>/.exec(page.text);
- assert.ok(key, 'the new key is shown once');
- const s = (await t.get(`/api/v1/shares/${made.share.id}`, { as: alex })).json().share;
- assert.strictEqual(s.node, null);
- const r = await t.get(`/api/v1/node/shares/${made.share.id}/tunnel-token`, { method: 'POST', bearer: t.network.nodeToken(desk) });
- assert.strictEqual(r.status, 404);
- const c = t.client({ key: key[1], port: 8790 });
- await c.ready();
- assert.ok(t.ctx.tunnels.isConnected(made.share.id), 'the key works again');
- c.stop();
- });
-
- await check('binding from the page: a plain form, then the commands for that computer', async () => {
- const r = await t.get(`/shares/${made.share.id}/node`, { as: alex, form: { node: desk.id }, method: 'POST' });
- assert.strictEqual(r.status, 303, r.text);
- const page = await t.get(r.headers.get('location'), { as: alex });
- assert.match(page.text, /Desk PC serves it now/);
- assert.match(page.text, /sudo openvibe-node share add --share/);
- const cross = await t.get(`/shares/${made.share.id}/node`, { as: alex, form: { node: desk.id }, method: 'POST', origin: 'https://evil.example' });
- assert.strictEqual(cross.status, 403, 'another site cannot bind it');
- });
-
- await check('a computer that Network cannot list for its owner just now: the page says so, nothing changes', async () => {
- t.network.setDown(true);
- try {
- const page = await t.get(`/shares/${made.share.id}/node`, { as: alex, form: { node: desk.id }, method: 'POST' });
- assert.strictEqual(page.status, 503);
- assert.match(page.text, /could not be read from OpenVibe just now/);
- } finally { t.network.setDown(false); }
- });
-
- await t.close();
- done();
-})().catch((e) => { console.error(e); process.exit(1); });
@@ -20,11 +20,11 @@ const sign = (body) => {
@@ -20,11 +20,11 @@ const sign = (body) => {
await check('only Chat-granted Link machine creates the named party share', async () => {
await check('only Chat-granted Link machine creates the named party share', async () => {
const claim = sign({ type: 'create', party: slug, owner: alex.subject, username: 'alex', node: desk.id, port: 18180 });
const claim = sign({ type: 'create', party: slug, owner: alex.subject, username: 'alex', node: desk.id, port: 18180 });
- const noToken = await t.get('/api/v1/node/party-shares', { method: 'POST', json: { grant: claim } });
+ const noToken = await t.get('/api/v1/link/party-shares', { method: 'POST', json: { grant: claim } });
assert.strictEqual(noToken.status, 401);
assert.strictEqual(noToken.status, 401);
- const bad = await t.get('/api/v1/node/party-shares', { method: 'POST', bearer: t.network.nodeToken(desk), json: { grant: claim + 'x' } });
+ const bad = await t.get('/api/v1/link/party-shares', { method: 'POST', bearer: t.network.linkToken(desk), json: { grant: claim + 'x' } });
assert.strictEqual(bad.status, 403);
assert.strictEqual(bad.status, 403);
- const made = await t.get('/api/v1/node/party-shares', { method: 'POST', bearer: t.network.nodeToken(desk), json: { grant: claim } });
+ const made = await t.get('/api/v1/link/party-shares', { method: 'POST', bearer: t.network.linkToken(desk), json: { grant: claim } });
assert.strictEqual(made.status, 201, made.text);
assert.strictEqual(made.status, 201, made.text);
share = made.json().share;
share = made.json().share;
assert.strictEqual(share.host, `${slug}--alex.openvibe.link`);
assert.strictEqual(share.host, `${slug}--alex.openvibe.link`);
@@ -43,7 +43,7 @@ const sign = (body) => {
@@ -43,7 +43,7 @@ const sign = (body) => {
const asBob = sign({ type: 'access', share: share.id, subject: bob.subject, username: 'bob' });
const asBob = sign({ type: 'access', share: share.id, subject: bob.subject, username: 'bob' });
const opts = { method: 'POST', headers: { origin: `https://${share.host}`, 'content-type': 'application/json' }, body: JSON.stringify({ ticket: asBob }) };
const opts = { method: 'POST', headers: { origin: `https://${share.host}`, 'content-type': 'application/json' }, body: JSON.stringify({ ticket: asBob }) };
assert.strictEqual((await t.share(share.host, '/__ovc/party', opts)).status, 403);
assert.strictEqual((await t.share(share.host, '/__ovc/party', opts)).status, 403);
- const synced = await t.get(`/api/v1/node/shares/${share.id}/party-members`, { method: 'PUT', bearer: t.network.nodeToken(desk), json: { members: ['alex', 'bob'] } });
+ const synced = await t.get(`/api/v1/link/shares/${share.id}/party-members`, { method: 'PUT', bearer: t.network.linkToken(desk), json: { members: ['alex', 'bob'] } });
assert.strictEqual(synced.status, 200, synced.text);
assert.strictEqual(synced.status, 200, synced.text);
const entered = await t.share(share.host, '/__ovc/party', opts);
const entered = await t.share(share.host, '/__ovc/party', opts);
assert.strictEqual(entered.status, 200, entered.text);
assert.strictEqual(entered.status, 200, entered.text);
@@ -54,7 +54,7 @@ const sign = (body) => {
@@ -54,7 +54,7 @@ const sign = (body) => {
});
});
await check('the party machine closes its share', async () => {
await check('the party machine closes its share', async () => {
- const closed = await t.get(`/api/v1/node/shares/${share.id}/party`, { method: 'DELETE', bearer: t.network.nodeToken(desk) });
+ const closed = await t.get(`/api/v1/link/shares/${share.id}/party`, { method: 'DELETE', bearer: t.network.linkToken(desk) });
assert.strictEqual(closed.status, 200, closed.text);
assert.strictEqual(closed.status, 200, closed.text);
assert.strictEqual((await t.ctx.shares.get(share.id)).status, 'revoked');
assert.strictEqual((await t.ctx.shares.get(share.id)).status, 'revoked');
assert.strictEqual((await t.share(share.host, '/__ovc/party')).status, 410);
assert.strictEqual((await t.share(share.host, '/__ovc/party')).status, 410);