openvibers/openvibe.connect Commits
Shares served by your own computer as OpenVibe Node, no key (CON-4) (#14)
Changes
@@@ -23,6 +23,22 @@ Live at **[connect.openvibe.website](https://connect.openvibe.website)**. Part oThe client opens one outgoing connection, so it works behind any router or hotspot, and it reconnects by itself. Itonly ever connects to `localhost` on the port you gave it.++### From a computer with OpenVibe Node (no key)++++A computer you added on [openvibe.bot/machines](https://openvibe.bot/machines) runs OpenVibe Node 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:++++```bash++openvibe-node share --share shr_… # serve it now++sudo openvibe-node share add --share shr_… # or keep it up with the Node 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.website`, 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++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).++## What your app sees- `Host: localhost:<port>`; the share's address in `X-Forwarded-Host`; `X-Forwarded-Proto: https`.@@@ -61,6 +77,15 @@ With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.w| `POST /api/v1/shares/:id/key` | a new key → `{ share, key }`; the connected computer is disconnected || `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 Node, from Network) |++| `PUT /api/v1/shares/:id/node` `{ node: 'nod_…' \| null }` | one of your computers serves it (the key stops working) → `{ share }`; `null` lets it go → `{ share, key }` |++++A computer, with its Network node token (audience `openvibe.website`) as a Bearer:++++| 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 |## How it works@@@ -77,6 +102,8 @@ browser ──https──▶ Cloudflare ──▶ nginx ──▶ Connect (5240)- `server/edge/inspector.js` — the share page's Requests list (CON-8 v0): the last 100 requests per share, in memoryonly, with the method, the path (query values shown as "…"), status, time, bytes and the visitor's name. No headers,no bodies; a restart forgets them.++- `server/auth/node-auth.js` — a computer as OpenVibe Node: its node token, and the single-use tunnel tokens (CON-4).++- `server/machines.js` — the owner's computers, read from Network with the owner's own token.- `public/ov-connect.js` — the client.The v0 transport is a WebSocket on 443 through Cloudflare (ADR-057's fallback path), so nothing needs a UDP port or annew file mode 100644@@@ -0,0 +1,7 @@++-- phase: expand++-- A share served by one of its owner's computers, as OpenVibe Node (CON-4, ADR-057 decision 4). The computer proves++-- itself with its Network node token (subject node:<node_id>, audience openvibe.website) and a single-use tunnel token++-- from Connect; the share's connect key stops working while a computer is bound. Null: the connect key, as before.++ALTER TABLE shares ADD COLUMN IF NOT EXISTS node_id text; -- nod_… (Network's node principal)++ALTER TABLE shares ADD COLUMN IF NOT EXISTS node_name text; -- the computer's name on Network, as it was when bound++CREATE INDEX IF NOT EXISTS shares_node ON shares (node_id) WHERE node_id IS NOT NULL AND status = 'active';@@@ -132,6 +132,19 @@.cn-run { display: grid; gap: 10px; }.cn-run h2 { margin: 0; }.cn-run a { color: var(--room-strong); }++/* Which computer serves it (CON-4): the owner's machines, one row each with its button; the bound one's commands. */++.cn-node { display: grid; gap: 10px; }++.cn-node h2 { margin: 0; }++.cn-node p { margin: 0; color: var(--ovk-muted); }++.cn-node p b { color: var(--ovk-text); }++.cn-node a { color: var(--room-strong); }++.cn-node > form { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; }++.cn-node > form span { font-size: 13.5px; color: var(--ovk-muted); flex: 1 1 220px; }++.cn-machines { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }++.cn-machines li { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 12px; padding: 10px 14px; border-radius: 10px; background: var(--ovk-panel); border: 1px solid var(--ovk-line); }++.cn-machine { display: grid; gap: 2px; min-width: 0; }++.cn-machine b { overflow-wrap: anywhere; }++.cn-machine small { color: var(--ovk-muted); font-size: 13px; }.cn-visits { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }.cn-visits li { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; padding: 10px 14px; border-radius: 10px; background: var(--ovk-panel); border: 1px solid var(--ovk-line); }.cn-visits span { color: var(--ovk-muted); font-size: 14px; }@@@ -6,6 +6,7 @@** <label>--<user>.openvibe.website/* a share: the gate, then the tunnel (edge/proxy.js), before anything else* /, /shares/*, /grant the pages (http/pages.js)++ * /api/v1/node/* a person's computer as OpenVibe Node (http/node-api.js, CON-4)* /api/v1/* the API (http/api.js)* /ov-connect.js the client, served as it is in public/* /auth/* Network SSO with PKCE (auth/sso.js)@@@ -27,6 +28,9 @@ const { createKeyStore } = require('./auth/keys');const { createSso } = require('./auth/sso');const { createPrincipal } = require('./http/principal');const { createApi } = require('./http/api');++const { createNodeApi } = require('./http/node-api');++const { createNodeAuth } = require('./auth/node-auth');++const { createMachines } = require('./machines');const { createPageRoutes } = require('./http/pages');const { createServiceReadiness } = require('./observability');const { assetVersion, setRelease } = require('./render/layout');@@@ -59,7 +63,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 });-- const tunnels = createTunnels({ shares, config, log, onConnect: async (share) => {++ // 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 });++ 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 waiting = await shares.takeWaiters(share.id);for (const subject of waiting) await notifier.shareBack(subject, share);if (waiting.length) log.log(`[OpenVibe.Connect] told ${waiting.length} person(s) ${share.host} is back`);@@@ -72,7 +79,7 @@ async function createApp(opts = {}) {? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl }): async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Connect cannot answer account events'); });-- const ctx = { config, s, keys, sso, principal, shares, tunnels, gate, proxy, notifier, accountData, accountSend, log };++ const ctx = { config, s, keys, sso, principal, shares, tunnels, gate, proxy, notifier, nodeAuth, machines, accountData, accountSend, log };/** Shares whose time is up close themselves (CON-6): their computers are disconnected and their lists forgotten. */ctx.expireSweep = async () => {@@@ -186,6 +193,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', createApi(ctx));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,59 @@++'use strict';++++/**++ * A person's computer, as OpenVibe Node (CON-4, ADR-057 decision 4). Two credentials, both checked:++ *++ * 1. its Network node token: an RS256 service token whose subject is node:<nod_…> (actor_type node), for audience++ * openvibe.website, 300 s. Network issues it to any active machine a person owns (Network #538);++ * 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 r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience, now: now() });++ 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 };@@@ -9,6 +9,13 @@* tunnel.open({ onRes, onData, onEnd, onAbort, onPause, onResume }) → stream 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 Node (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: '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).++ ** A newer connection for the same share replaces the older one (close code 4001), so a laptop that reconnects after* sleep never leaves a dead tunnel behind. A wrong key is 4002; a revoked share or a rotated key closes its tunnel* (4003); a tunnel that stops answering pings is 4008. The server@@@ -91,13 +98,25 @@ class Tunnel {}}--function createTunnels({ shares, config, log = console, onConnect = null }) {++// 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() }) {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 };++ /** Close every computer's tunnel whose node token ran out with no reauth (CON-4); the computer reconnects. */++ function sweep() {++ let n = 0;++ for (const t of live.values()) if (t.authUntil && now() > t.authUntil + REAUTH_GRACE_MS) { t.shutdown(4000, 'the node token ran out'); n++; }++ return n;++ }++const pinger = setInterval(() => {++ sweep();for (const t of live.values()) {++ if (t.closed) continue;if (!t.alive) { t.shutdown(4008, 'no answer to ping'); continue; }t.alive = false;try { t.ws.ping(); } catch { /* closing */ }@@@ -108,8 +127,10 @@ function createTunnels({ shares, config, log = console, onConnect = null }) {function refuse(ws, code, message) {stats.refused++;try { ws.send(JSON.stringify({ t: 'error', code, message })); } catch { /* gone */ }-- // 4002: the key is wrong, 4003: the share is closed (the client stops for both); 4000: anything else.-- try { ws.close(code === 'key_invalid' ? 4002 : code === 'share_revoked' ? 4003 : 4000, message.slice(0, 120)); } catch { /* gone */ }++ // 4002: the key (or the computer's node token) is wrong, 4003: the share is closed or not this computer's (the++ // client stops for both); 4000: anything else, the client tries again.++ const close = code === 'key_invalid' || code === 'node_invalid' ? 4002 : code === 'share_revoked' || code === 'node_not_bound' ? 4003 : 4000;++ try { ws.close(close, message.slice(0, 120)); } catch { /* gone */ }}function accept(ws) {@@@ -125,7 +146,15 @@ function createTunnels({ shares, config, log = console, onConnect = null }) {}});ws.on('message', async (message, isBinary) => {-- if (tunnel) { tunnel.alive = true; return tunnel.onMessage(message, isBinary); }++ if (tunnel) {++ tunnel.alive = true;++ if (tunnel.node && !isBinary && message.length < 8192 && String(message).includes('"reauth"')) {++ let r = null;++ try { r = JSON.parse(String(message)); } catch { /* not ours */ }++ if (r && r.t === 'reauth') return reauth(tunnel, r);++ }++ return tunnel.onMessage(message, isBinary);++ }if (isBinary) return refuse(ws, 'hello_expected', 'The first message is the hello.');let m;try { m = JSON.parse(String(message)); } catch { return refuse(ws, 'hello_expected', 'The first message is the hello.'); }@@@ -133,10 +162,26 @@ function createTunnels({ shares, config, log = console, onConnect = null }) {clearTimeout(hello);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;-- try { share = await shares.byKey(m.key); } catch (err) { log.warn('[OpenVibe.Connect] key lookup failed:', err.message); return refuse(ws, 'unavailable', 'Try again in a moment.'); }-- if (!share) return refuse(ws, 'key_invalid', 'That connect key is not one we know. Make a new one on your share page.');-- if (share.status !== 'active') return refuse(ws, 'share_revoked', 'That share is closed.');++ let node = null;++ if (m.key === undefined && (m.node_token !== undefined || m.tunnel !== undefined)) {++ // A computer as OpenVibe Node (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 (!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;++ 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.');++ node = v;++ } else {++ try { share = await shares.byKey(m.key); } catch (err) { log.warn('[OpenVibe.Connect] key lookup failed:', err.message); return refuse(ws, 'unavailable', 'Try again in a moment.'); }++ if (!share) return refuse(ws, 'key_invalid', 'That connect key is not one we know. Make a new one on your share page.');++ if (share.status !== 'active') return refuse(ws, 'share_revoked', 'That share is closed.');++ }const t = new Tunnel(ws, share, { log, maxStreams: config.shares.maxStreams });++ if (node) { t.node = node.node; t.authUntil = node.exp; }t.clientInfo = { client: String(m.client || '').slice(0, 60), port: Number.isInteger(m.port) ? m.port : null };const old = live.get(share.id);if (old) { stats.replaced++; old.shutdown(4001, 'replaced by a newer connection'); }@@@ -146,11 +191,23 @@ function createTunnels({ shares, config, log = console, onConnect = null }) {shares.touchConnected(share.id).catch(() => {});// The people who asked to be told it is back (CON-17); never in the way of the connection.if (onConnect) Promise.resolve().then(() => onConnect(share)).catch(() => {});-- t.send({ t: 'ready', v: frames.VERSION, share: { id: share.id, host: share.host, url: share.url, mode: share.mode } });++ t.send({ t: 'ready', v: frames.VERSION, share: { id: share.id, host: share.host, url: share.url, mode: share.mode, local_port: share.local_port }, ...(node ? { node: true, until: new Date(node.exp).toISOString() } : {}) });return undefined;});}++ /** 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 };++ 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;++ try { share = await shares.get(t.share.id); } catch { return t.send({ t: 'error', code: 'unavailable', message: 'Try again in a moment.' }); }++ if (!share || share.status !== 'active' || !share.node || share.node.id !== v.node) return t.shutdown(4003, 'this computer no longer serves the share');++ t.authUntil = v.exp;++ return t.send({ t: 'reauthed', until: new Date(v.exp).toISOString() });++ }++function handleUpgrade(req, socket, head) {wss.handleUpgrade(req, socket, head, (ws) => accept(ws));}@@@ -173,7 +230,7 @@ function createTunnels({ shares, config, log = console, onConnect = null }) {return { connected: live.size, streams: [...live.values()].reduce((n, t) => n + t.streams.size, 0), ...stats };}-- return { handleUpgrade, get: (id) => live.get(id) || null, close, closeAll, status, isConnected: (id) => live.has(id) };++ return { handleUpgrade, get: (id) => live.get(id) || null, close, closeAll, status, sweep, isConnected: (id) => live.has(id) };}module.exports = { createTunnels, Tunnel, HIGH_WATER };@@@ -11,6 +11,9 @@* or null to stay open until you close it* 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 Node (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/visits who opened it in the last 7 days* GET /shares/:id/requests the last 100 requests through it, newest first (?after=<seq>: only newer ones);* memory only, query values shown as "…" (edge/inspector.js)@@@ -23,7 +26,7 @@ const { asyncRouter } = require('./router');const { ShareError } = require('../shares');function createApi(ctx) {-- const { shares, tunnels, principal, proxy } = ctx;++ const { shares, tunnels, principal, proxy, machines } = ctx;const r = asyncRouter();r.use(express.json({ limit: '16kb' }));r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });@@@ -87,6 +90,30 @@ function createApi(ctx) {return res.json({ share: shape(updated), key });}));++ r.get('/machines', guard(async (req, res) => {++ const m = await machines.list(req.principal.token);++ if (!m.ok) return fail(res, req, 503, 'machines.unavailable', 'Your computers could not be read from OpenVibe just now. Try again in a moment.');++ return res.json({ machines: m.machines });++ }));++++ r.put('/shares/:id/node', guard(async (req, res) => {++ const share = await mine(req, res);++ if (!share) return undefined;++ const want = (req.body || {}).node;++ if (want === null || want === '') {++ const { share: updated, key } = await shares.bindNode(owner(req), share.id, null);++ 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.');++ 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 Node first.');++ const { share: updated } = await shares.bindNode(owner(req), share.id, found.machine);++ tunnels.close(share.id, 4003, 'now served by one of your computers');++ return res.json({ share: shape(updated) });++ }));++r.get('/shares/:id/requests', guard(async (req, res) => {const share = await mine(req, res);return share ? res.json({ requests: proxy.inspector.list(share.id, { after: req.query.after }), keep: proxy.inspector.KEEP }) : undefined;nnew file mode 100644@@@ -0,0 +1,47 @@++'use strict';++++/**++ * /api/v1/node: a person's computer, as OpenVibe Node (CON-4, ADR-057 decision 4). Every call carries the computer's++ * Network node token (Authorization: Bearer, subject node:nod_…, audience openvibe.website); nothing here answers to a++ * 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 { asyncRouter } = require('./router');++++function createNodeApi(ctx) {++ const { shares, nodeAuth } = ctx;++ const r = asyncRouter();++ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });++ const fail = (res, req, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });++++ r.use(async (req, res, next) => {++ const header = String(req.headers.authorization || '');++ if (!header.startsWith('Bearer ')) return fail(res, req, 401, 'token.required', 'send this computer\'s Network node token (audience openvibe.website) as a Bearer');++ 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('/shares/:id/tunnel-token', async (req, res) => {++ const share = await shares.get(req.params.id);++ // Another person's share and a share this computer does not serve look the same: not found.++ if (!share || !share.node || share.node.id !== req.node) return fail(res, req, 404, 'share.not_found', 'This computer does not serve that share. Pick it on the share\'s page on connect.openvibe.website.');++ if (share.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,6 +8,8 @@* /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++ * new key once* POST /shares/:id/close close it: the address answers "closed" from then on* /grant?share=&next= the gate's sign-in step (server/edge/gate.js): signed in and allowed → back to the* share host with a one-time code@@@ -21,7 +23,7 @@ const views = require('../views');const { ShareError } = require('../shares');function createPageRoutes(ctx) {-- const { config, shares, tunnels, gate, proxy } = ctx;++ const { config, shares, tunnels, gate, proxy, machines } = ctx;const r = asyncRouter();r.use(express.urlencoded({ extended: false, limit: '16kb' }));const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });@@@ -80,14 +82,17 @@ function createPageRoutes(ctx) {async function showShare(req, res, share, extra = {}, status = 200) {const tunnel = tunnels.get(share.id);-- const visits = await shares.visits(share.id);-- return page(req, res, { title: share.host, body: views.sharePage({ share, connected: Boolean(tunnel), tunnel, visits, requests: ctx.proxy ? ctx.proxy.inspector.list(share.id) : [], baseUrl: config.baseUrl, staff: req.viewer.role === 'admin', ...extra }) }, status);++ const [visits, mine] = await Promise.all([++ shares.visits(share.id),++ share.status === 'active' ? machines.list(req.viewer.token) : Promise.resolve({ ok: true, machines: [] }),++ ]);++ return page(req, res, { title: share.host, body: views.sharePage({ share, connected: Boolean(tunnel), tunnel, visits, requests: ctx.proxy ? ctx.proxy.inspector.list(share.id) : [], baseUrl: config.baseUrl, staff: req.viewer.role === 'admin', machines: mine.ok ? mine.machines : null, ...extra }) }, status);}r.get('/shares/:id', async (req, res) => {const share = await mine(req, res);if (!share) return undefined;-- const notice = req.query.saved ? 'Saved.' : null;++ const notice = req.query.saved ? 'Saved.' : req.query.served ? `${share.node ? share.node.name : 'Your computer'} serves it now. Run the command below on it.` : null;return showShare(req, res, share, { notice });});@@@ -119,6 +124,29 @@ function createPageRoutes(ctx) {}});++ r.post('/shares/:id/node', async (req, res) => {++ if (!guard(req, res)) return undefined;++ const share = await mine(req, res);++ if (!share) return undefined;++ const want = String(req.body.node || '').trim();++ try {++ if (!want) {++ const { share: updated, key } = await shares.bindNode(owner(req), share.id, null);++ tunnels.close(share.id, 4003, 'the computer was let go');++ return showShare(req, res, updated, { key, notice: 'Your computer no longer serves it. Use this key with ov-connect.js, or pick a computer again.' });++ }++ const found = await machines.find(req.viewer.token, want);++ if (!found.ok) return showShare(req, res, share, { error: 'Your computers could not be read from OpenVibe just now. Try again in a moment.' }, 503);++ if (!found.machine) return showShare(req, res, share, { error: 'That is not one of your computers. Pair it with OpenVibe Node first.' }, 404);++ const { share: updated } = await shares.bindNode(owner(req), share.id, found.machine);++ tunnels.close(share.id, 4003, 'now served by one of your computers');++ return res.redirect(303, `/shares/${encodeURIComponent(share.id)}?served=1`);++ } catch (err) {++ if (!(err instanceof ShareError)) throw err;++ return showShare(req, res, share, { error: err.message }, err.status || 422);++ }++ });++r.post('/shares/:id/close', async (req, res) => {if (!guard(req, res)) return undefined;const share = await mine(req, res);nnew file mode 100644@@@ -0,0 +1,62 @@++'use strict';++++/**++ * A person's computers, as Network knows them (CON-4): GET /api/v1/me/nodes with the person's own Network token, so++ * Connect sees only what they could see themself. A share can be bound only to one of their active machines.++ *++ * list(userToken) → { ok: true, machines: [{ id, name, last_seen_at, paired_for }] } | { ok: false, reason }++ * find(userToken, id) → the machine, or null++ *++ * Every call reaches Network from this one address, and Network limits /api/v1/me/nodes per address, so a person's list++ * 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 KEEP_MS = 20_000;++const KEEP_MAX = 500;++++function createMachines({ config, fetchImpl = globalThis.fetch, log = console, now = () => Date.now() }) {++ const base = config.networkInternalUrl || config.networkUrl;++ const kept = new Map(); // sha256(token) → { at, machines }++++ async function list(userToken, { fresh = false } = {}) {++ if (!userToken) return { ok: false, reason: 'signed_out' };++ const k = crypto.createHash('sha256').update(String(userToken)).digest('hex');++ const hit = kept.get(k);++ if (!fresh && hit && now() - hit.at < KEEP_MS) return { ok: true, machines: hit.machines };++ try {++ const res = await fetchImpl(`${base}/api/v1/me/nodes`, {++ headers: { Accept: 'application/json', Authorization: `Bearer ${userToken}` },++ signal: AbortSignal.timeout(8_000),++ });++ if (!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 || '')))++ .map((n) => ({++ id: n.principal,++ name: typeof n.name === 'string' && n.name.trim() ? n.name.trim().slice(0, 80) : 'A computer',++ last_seen_at: n.last_seen_at || null,++ paired_for: n.paired_for && n.paired_for.service ? String(n.paired_for.service).slice(0, 40) : null,++ }));++ kept.delete(k);++ if (kept.size >= KEEP_MAX) kept.delete(kept.keys().next().value);++ kept.set(k, { at: now(), machines });++ return { ok: true, machines };++ } catch (err) {++ log.warn(`[OpenVibe.Connect] machines not read: ${(err && err.message) || err}`);++ return { ok: false, reason: 'error' };++ }++ }++++ async function find(userToken, id) {++ const r = await list(userToken, { fresh: true });++ if (!r.ok) return { ok: false, reason: r.reason };++ return { ok: true, machine: r.machines.find((m) => m.id === id) || null };++ }++++ return { list, find };++}++++module.exports = { createMachines };@@@ -7,6 +7,9 @@* list(ownerSubject), get(id), byHost(host), byKey(key)* setPolicy(owner, id, { mode, named }) bumps policy_version: every visitor's cookie goes stale* rotateKey(owner, id) → key the old key stops working; a connected tunnel is closed++ * bindNode(owner, id, machine | null) one of the owner's computers serves it (CON-4); the key stops++ * working while one is bound. null: back to a new connect key++ * byNode(nodeId) the open shares a computer serves* revoke(owner, id) the host answers 410 from now on* hostFor(label, username), parseHost(host) the naming rule (pure)*@@@ -121,7 +124,9 @@ function createShares(s, { config }) {named: Array.isArray(row.named) ? row.named : [],status: row.status,policy_version: Number(row.policy_version),-- key_prefix: row.key_prefix,++ key_prefix: row.node_id ? null : row.key_prefix,++ // Served by one of the owner's computers, as OpenVibe Node (CON-4): its Network node principal and name.++ node: row.node_id ? { id: row.node_id, name: row.node_name || 'A computer' } : null,note: row.note || null,created_at: iso(row.created_at),updated_at: iso(row.updated_at),@@@ -227,10 +232,36 @@ function createShares(s, { config }) {const share = await owned(owner, id);if (share.status !== 'active') throw new ShareError(410, 'share.revoked', 'That share is closed.');const key = newKey();-- await db.exec('UPDATE shares SET key_prefix = $2, key_hash = $3, updated_at = now() WHERE id = $1', [share.id, key.slice(0, 10), sha256(key)]);++ // A new key means the key serves it: a computer bound as OpenVibe Node is let go (CON-4).++ await db.exec('UPDATE shares SET key_prefix = $2, key_hash = $3, node_id = NULL, node_name = NULL, updated_at = now() WHERE id = $1', [share.id, key.slice(0, 10), sha256(key)]);return { share: await get(share.id), key };}++ /**++ * One of the owner's computers serves the share (CON-4): `machine` is { id: 'nod_…', 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.++ */++ async function bindNode(owner, id, machine) {++ const share = await owned(owner, id);++ if (share.status !== 'active') throw new ShareError(410, 'share.revoked', 'That share is closed.');++ const key = newKey();++ if (machine) {++ await db.exec('UPDATE shares SET node_id = $2, node_name = $3, key_prefix = $4, key_hash = $5, updated_at = now() WHERE id = $1',++ [share.id, machine.id, String(machine.name || '').slice(0, 80) || null, 'node', sha256(key)]);++ return { share: await get(share.id), key: null };++ }++ await db.exec('UPDATE shares SET node_id = NULL, node_name = NULL, key_prefix = $2, key_hash = $3, updated_at = now() WHERE id = $1',++ [share.id, key.slice(0, 10), sha256(key)]);++ return { share: await get(share.id), key };++ }++++ async function byNode(nodeId) {++ const rows = await db.many("SELECT * FROM shares WHERE node_id = $1 AND status = 'active' ORDER BY created_at DESC LIMIT 100", [String(nodeId || '')]);++ return rows.map(view);++ }++async function revoke(owner, id) {const share = await owned(owner, id);if (share.status === 'revoked') return share;@@@ -337,7 +368,7 @@ function createShares(s, { config }) {return false;}-- return { create, list, get, byHost, byKey, setPolicy, rotateKey, revoke, expireDue, report, openReports, handleReport, addWaiter, takeWaiters, touchConnected, flushCounters, visits, allows, view, domain };++ return { create, list, get, byHost, byKey, byNode, bindNode, setPolicy, rotateKey, revoke, expireDue, report, openReports, handleReport, addWaiter, takeWaiters, touchConnected, flushCounters, visits, allows, view, domain };}function iso(v) {@@@ -229,12 +229,42 @@ 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>`;}--function sharePage({ share, connected, tunnel = null, visits = [], requests = [], key = null, baseUrl, notice = null, error = null, staff = false, now = Date.now() }) {++const MACHINES_URL = 'https://openvibe.bot/machines';++++/**++ * Which computer serves it (CON-4): one of the owner's computers as OpenVibe Node (no key to copy; removing the computer++ * on OpenVibe stops it), or the one-file client with the connect key. `machines` is null when Network could not be read.++ */++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}`;++ 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 Node: no key to copy, and removing the computer on OpenVibe stops it within five minutes.</p>++<ol class="cn-steps">++<li><span>On that computer, serve it now:</span><div class="cn-copy"><code id="cn-node-run">${esc(run)}</code><button type="button" class="cn-btn cn-btn-quiet" data-ovk-copy="#cn-node-run" hidden>${kit.icon('copy')} Copy</button></div></li>++<li><span>Or keep it up with the Node service, across restarts:</span><div class="cn-copy"><code id="cn-node-keep">${esc(keep)}</code><button type="button" class="cn-btn cn-btn-quiet" data-ovk-copy="#cn-node-keep" hidden>${kit.icon('copy')} Copy</button></div></li>++</ol>++<form method="post" action="/shares/${esc(share.id)}/node"><input type="hidden" name="node" value=""><button class="cn-btn cn-btn-quiet" type="submit">${kit.icon('key')} Use a connect key instead</button><span>The computer stops serving it and a new key is shown once.</span></form>++</section>`;++ }++ const intro = '<p>Pick one of your computers that runs OpenVibe Node 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 {++ 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>`;++ }++ return `<section class="cn-node" aria-labelledby="cn-node-h"><h2 id="cn-node-h">Or serve it from one of your computers</h2>${intro}${list}</section>`;++}++++function sharePage({ share, connected, tunnel = null, visits = [], requests = [], key = null, baseUrl, notice = null, error = null, staff = false, machines = [], now = Date.now() }) {const closed = share.status !== 'active';const state = closed ? 'Closed' : connected ? 'Connected' : 'Not connected';const stateText = closed ? `Closed ${ago(share.revoked_at, now)}. The address answers "closed" and stays yours.`: connected ? `Connected ${ago(tunnel && tunnel.connectedAt ? new Date(tunnel.connectedAt).toISOString() : share.last_connected_at, now)}${tunnel && tunnel.clientInfo && tunnel.clientInfo.port ? `, serving localhost:${tunnel.clientInfo.port}` : ''}.`-- : share.last_connected_at ? `Last connected ${ago(share.last_connected_at, now)}. Run ov-connect on your computer to bring it back.` : 'Never connected yet. Run the command below on your computer.';++ : share.last_connected_at ? `Last connected ${ago(share.last_connected_at, now)}. ${share.node ? `Start OpenVibe Node on ${share.node.name}` : 'Run ov-connect on your computer'} to bring it back.` : 'Never connected yet. Run the command below on your computer.';const visitRows = visits.length? `<ul class="cn-visits">${visits.map((v) => `<li><b>${v.username ? `@${esc(v.username)}` : 'someone'}</b><span>${esc(v.requests)} ${v.requests === 1 ? 'request' : 'requests'}${v.denied ? `, ${esc(v.denied)} turned away` : ''} · ${esc(ago(v.last_at, now))}</span></li>`).join('')}</ul>`: '<p class="cn-empty">Nobody has opened it in the last 7 days.</p>';@@@ -266,11 +296,12 @@ ${key ? keyBlock(share, key, baseUrl) : ''}<div><dt>Port</dt><dd>localhost:${esc(share.local_port)}</dd></div><div><dt>Requests</dt><dd>${esc(Number(share.requests).toLocaleString('en-US'))}</dd></div><div><dt>Data</dt><dd>${esc(fmtBytes(share.bytes_in))} in · ${esc(fmtBytes(share.bytes_out))} out</dd></div>--<div><dt>Key</dt><dd><code>${esc(share.key_prefix)}…</code></dd></div>++${share.node ? `<div><dt>Served by</dt><dd>${esc(share.node.name)} (OpenVibe Node)</dd></div>` : `<div><dt>Key</dt><dd><code>${esc(share.key_prefix)}…</code></dd></div>`}<div><dt>Made</dt><dd>${esc(ago(share.created_at, now))}</dd></div>${share.expires_at ? `<div><dt>${closed ? 'Ended' : 'Closes'}</dt><dd><time datetime="${esc(share.expires_at)}">${esc(closed ? ago(share.expires_at, now) : until(share.expires_at, now))}</time></dd></div>` : ''}</dl>--${!key && !closed ? `<section class="cn-run"><h2>Connect it</h2><div class="cn-copy"><code id="cn-run">node ov-connect.js --key ovc_… --port ${esc(share.local_port)}</code></div><p class="cn-fine">The key was shown once, when it was made. <a href="${esc(CLIENT_URL(baseUrl))}">Download ov-connect.js</a> (Node 22 or newer).</p></section>` : ''}++${!key && !closed && !share.node ? `<section class="cn-run"><h2>Connect it</h2><div class="cn-copy"><code id="cn-run">node ov-connect.js --key ovc_… --port ${esc(share.local_port)}</code></div><p class="cn-fine">The key was shown once, when it was made. <a href="${esc(CLIENT_URL(baseUrl))}">Download ov-connect.js</a> (Node 22 or newer).</p></section>` : ''}++${closed ? '' : nodeBlock(share, machines, now)}<section aria-labelledby="cn-visits-h"><h2 id="cn-visits-h">Who opened it (7 days)</h2>${visitRows}</section>${closed ? '' : `<section class="cn-inspector" aria-labelledby="cn-reqs-h" data-cn-reqs="${esc(share.id)}"><h2 id="cn-reqs-h">Requests</h2><p class="cn-fine">The last 100 through this share, newest first, added as they happen. Kept in memory only, never written down: no headers, no bodies, and query values show as "…".</p>@@@ -42,7 +42,7 @@ async function startNetwork() {const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });const publicPem = publicKey.export({ type: 'spki', format: 'pem' });const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });-- const st = { users: new Map(), byUsername: new Map(), codes: new Map(), refresh: new Map(), requests: [], tokenRequests: [], down: false };++ const st = { users: new Map(), byUsername: new Map(), codes: new Map(), refresh: new Map(), requests: [], tokenRequests: [], down: false, nodes: [] };let issuer = null;let nextUserId = 1;@@@ -118,6 +118,14 @@ async function startNetwork() {return json(200, { items: [], next_cursor: null });}++ // A person's machines (Network's userRouter): their node principals, any status.++ if (url.pathname === '/api/v1/me/nodes' && req.method === 'GET') {++ const u = userFrom(req);++ if (u === 'expired') return problem(json, 401, 'auth.expired', 'expired');++ if (!u) return problem(json, 401, 'auth.required', 'send Authorization: Bearer <Network access token>');++ return json(200, { nodes: st.nodes.filter((n) => n.owner === u.subject).map((n) => ({ principal: n.id, node_id: n.node_id, name: n.name, owner: { kind: 'user', subject: n.owner }, status: n.status, paired_for: { service: 'bot', ref: 'rob_x' }, last_seen_at: n.last_seen_at, created_at: '2026-11-01T00:00:00.000Z' })) });++ }++if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') return json(200, { results: {} });if (url.pathname === '/internal/notifications/push' && req.method === 'POST') return json(200, { ok: true });@@@ -128,6 +136,17 @@ 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 }. */++ 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() };++ 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 } = {}) {++ 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);++ },issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },setDown(v) { st.down = v; },close: srv.close,nnew file mode 100644@@@ -0,0 +1,203 @@++'use strict';++/**++ * A share served by one of its owner's computers, as OpenVibe Node (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); });
@@ -23,6 +23,22 @@ Live at **[connect.openvibe.website](https://connect.openvibe.website)**. Part o
@@ -23,6 +23,22 @@ Live at **[connect.openvibe.website](https://connect.openvibe.website)**. Part o
The client opens one outgoing connection, so it works behind any router or hotspot, and it reconnects by itself. It
The client opens one outgoing connection, so it works behind any router or hotspot, and it reconnects by itself. It
only ever connects to `localhost` on the port you gave it.
only ever connects to `localhost` on the port you gave it.
+### From a computer with OpenVibe Node (no key)
+
+A computer you added on [openvibe.bot/machines](https://openvibe.bot/machines) runs OpenVibe Node 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:
+
+```bash
+openvibe-node share --share shr_… # serve it now
+sudo openvibe-node share add --share shr_… # or keep it up with the Node 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.website`, 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
+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).
+
## What your app sees
## What your app sees
- `Host: localhost:<port>`; the share's address in `X-Forwarded-Host`; `X-Forwarded-Proto: https`.
- `Host: localhost:<port>`; the share's address in `X-Forwarded-Host`; `X-Forwarded-Proto: https`.
@@ -61,6 +77,15 @@ With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.w
@@ -61,6 +77,15 @@ With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.w
| `POST /api/v1/shares/:id/key` | a new key → `{ share, key }`; the connected computer is disconnected |
| `POST /api/v1/shares/:id/key` | a new key → `{ share, key }`; the connected computer is disconnected |
| `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 Node, from Network) |
+| `PUT /api/v1/shares/:id/node` `{ node: 'nod_…' \| null }` | one of your computers serves it (the key stops working) → `{ share }`; `null` lets it go → `{ share, key }` |
+
+A computer, with its Network node token (audience `openvibe.website`) as a Bearer:
+
+| 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 |
## How it works
## How it works
@@ -77,6 +102,8 @@ browser ──https──▶ Cloudflare ──▶ nginx ──▶ Connect (5240)
@@ -77,6 +102,8 @@ browser ──https──▶ Cloudflare ──▶ nginx ──▶ Connect (5240)
- `server/edge/inspector.js` — the share page's Requests list (CON-8 v0): the last 100 requests per share, in memory
- `server/edge/inspector.js` — the share page's Requests list (CON-8 v0): the last 100 requests per share, in memory
only, with the method, the path (query values shown as "…"), status, time, bytes and the visitor's name. No headers,
only, with the method, the path (query values shown as "…"), status, time, bytes and the visitor's name. No headers,
no bodies; a restart forgets them.
no bodies; a restart forgets them.
+- `server/auth/node-auth.js` — a computer as OpenVibe Node: its node token, and the single-use tunnel tokens (CON-4).
+- `server/machines.js` — the owner's computers, read from Network with the owner's own token.
- `public/ov-connect.js` — the client.
- `public/ov-connect.js` — the client.
The v0 transport is a WebSocket on 443 through Cloudflare (ADR-057's fallback path), so nothing needs a UDP port or a
The v0 transport is a WebSocket on 443 through Cloudflare (ADR-057's fallback path), so nothing needs a UDP port or a
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+-- phase: expand
+-- A share served by one of its owner's computers, as OpenVibe Node (CON-4, ADR-057 decision 4). The computer proves
+-- itself with its Network node token (subject node:<node_id>, audience openvibe.website) and a single-use tunnel token
+-- from Connect; the share's connect key stops working while a computer is bound. Null: the connect key, as before.
+ALTER TABLE shares ADD COLUMN IF NOT EXISTS node_id text; -- nod_… (Network's node principal)
+ALTER TABLE shares ADD COLUMN IF NOT EXISTS node_name text; -- the computer's name on Network, as it was when bound
+CREATE INDEX IF NOT EXISTS shares_node ON shares (node_id) WHERE node_id IS NOT NULL AND status = 'active';
@@ -132,6 +132,19 @@
@@ -132,6 +132,19 @@
.cn-run { display: grid; gap: 10px; }
.cn-run { display: grid; gap: 10px; }
.cn-run h2 { margin: 0; }
.cn-run h2 { margin: 0; }
.cn-run a { color: var(--room-strong); }
.cn-run a { color: var(--room-strong); }
+/* Which computer serves it (CON-4): the owner's machines, one row each with its button; the bound one's commands. */
+.cn-node { display: grid; gap: 10px; }
+.cn-node h2 { margin: 0; }
+.cn-node p { margin: 0; color: var(--ovk-muted); }
+.cn-node p b { color: var(--ovk-text); }
+.cn-node a { color: var(--room-strong); }
+.cn-node > form { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; }
+.cn-node > form span { font-size: 13.5px; color: var(--ovk-muted); flex: 1 1 220px; }
+.cn-machines { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
+.cn-machines li { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 12px; padding: 10px 14px; border-radius: 10px; background: var(--ovk-panel); border: 1px solid var(--ovk-line); }
+.cn-machine { display: grid; gap: 2px; min-width: 0; }
+.cn-machine b { overflow-wrap: anywhere; }
+.cn-machine small { color: var(--ovk-muted); font-size: 13px; }
.cn-visits { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
.cn-visits { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
.cn-visits li { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; padding: 10px 14px; border-radius: 10px; background: var(--ovk-panel); border: 1px solid var(--ovk-line); }
.cn-visits li { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; padding: 10px 14px; border-radius: 10px; background: var(--ovk-panel); border: 1px solid var(--ovk-line); }
.cn-visits span { color: var(--ovk-muted); font-size: 14px; }
.cn-visits span { color: var(--ovk-muted); font-size: 14px; }
@@ -6,6 +6,7 @@
@@ -6,6 +6,7 @@
*
*
* <label>--<user>.openvibe.website/* a share: the gate, then the tunnel (edge/proxy.js), before anything else
* <label>--<user>.openvibe.website/* a share: the gate, then the tunnel (edge/proxy.js), before anything else
* /, /shares/*, /grant the pages (http/pages.js)
* /, /shares/*, /grant the pages (http/pages.js)
+ * /api/v1/node/* a person's computer as OpenVibe Node (http/node-api.js, CON-4)
* /api/v1/* 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)
@@ -27,6 +28,9 @@ const { createKeyStore } = require('./auth/keys');
@@ -27,6 +28,9 @@ const { createKeyStore } = require('./auth/keys');
const { createSso } = require('./auth/sso');
const { createSso } = require('./auth/sso');
const { createPrincipal } = require('./http/principal');
const { createPrincipal } = require('./http/principal');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
+const { createNodeApi } = require('./http/node-api');
+const { createNodeAuth } = require('./auth/node-auth');
+const { createMachines } = require('./machines');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
const { createServiceReadiness } = require('./observability');
const { createServiceReadiness } = require('./observability');
const { assetVersion, setRelease } = require('./render/layout');
const { assetVersion, setRelease } = require('./render/layout');
@@ -59,7 +63,10 @@ async function createApp(opts = {}) {
@@ -59,7 +63,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 });
- const tunnels = createTunnels({ shares, config, log, onConnect: async (share) => {
+ // 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 });
+ 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 waiting = await shares.takeWaiters(share.id);
const waiting = await shares.takeWaiters(share.id);
for (const subject of waiting) await notifier.shareBack(subject, share);
for (const subject of waiting) await notifier.shareBack(subject, share);
if (waiting.length) log.log(`[OpenVibe.Connect] told ${waiting.length} person(s) ${share.host} is back`);
if (waiting.length) log.log(`[OpenVibe.Connect] told ${waiting.length} person(s) ${share.host} is back`);
@@ -72,7 +79,7 @@ async function createApp(opts = {}) {
@@ -72,7 +79,7 @@ async function createApp(opts = {}) {
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Connect cannot answer account events'); });
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Connect cannot answer account events'); });
- const ctx = { config, s, keys, sso, principal, shares, tunnels, gate, proxy, notifier, accountData, accountSend, log };
+ const ctx = { config, s, keys, sso, principal, shares, tunnels, gate, proxy, notifier, nodeAuth, machines, accountData, accountSend, log };
/** Shares whose time is up close themselves (CON-6): their computers are disconnected and their lists forgotten. */
/** Shares whose time is up close themselves (CON-6): their computers are disconnected and their lists forgotten. */
ctx.expireSweep = async () => {
ctx.expireSweep = async () => {
@@ -186,6 +193,7 @@ async function createApp(opts = {}) {
@@ -186,6 +193,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', createApi(ctx));
app.use('/api/v1', createApi(ctx));
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,59 @@
@@ -0,0 +1,59 @@
+'use strict';
+
+/**
+ * A person's computer, as OpenVibe Node (CON-4, ADR-057 decision 4). Two credentials, both checked:
+ *
+ * 1. its Network node token: an RS256 service token whose subject is node:<nod_…> (actor_type node), for audience
+ * openvibe.website, 300 s. Network issues it to any active machine a person owns (Network #538);
+ * 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 r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience, now: now() });
+ 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 };
@@ -9,6 +9,13 @@
@@ -9,6 +9,13 @@
* tunnel.open({ onRes, onData, onEnd, onAbort, onPause, onResume }) → stream id
* tunnel.open({ onRes, onData, onEnd, onAbort, onPause, onResume }) → stream id
* 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 Node (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: '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).
+ *
* A newer connection for the same share replaces the older one (close code 4001), so a laptop that reconnects after
* A newer connection for the same share replaces the older one (close code 4001), so a laptop that reconnects after
* sleep never leaves a dead tunnel behind. A wrong key is 4002; a revoked share or a rotated key closes its tunnel
* sleep never leaves a dead tunnel behind. A wrong key is 4002; a revoked share or a rotated key closes its tunnel
* (4003); a tunnel that stops answering pings is 4008. The server
* (4003); a tunnel that stops answering pings is 4008. The server
@@ -91,13 +98,25 @@ class Tunnel {
@@ -91,13 +98,25 @@ class Tunnel {
}
}
}
}
-function createTunnels({ shares, config, log = console, onConnect = null }) {
+// 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() }) {
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 };
+ /** Close every computer's tunnel whose node token ran out with no reauth (CON-4); the computer reconnects. */
+ function sweep() {
+ let n = 0;
+ for (const t of live.values()) if (t.authUntil && now() > t.authUntil + REAUTH_GRACE_MS) { t.shutdown(4000, 'the node token ran out'); n++; }
+ return n;
+ }
+
const pinger = setInterval(() => {
const pinger = setInterval(() => {
+ sweep();
for (const t of live.values()) {
for (const t of live.values()) {
+ if (t.closed) continue;
if (!t.alive) { t.shutdown(4008, 'no answer to ping'); continue; }
if (!t.alive) { t.shutdown(4008, 'no answer to ping'); continue; }
t.alive = false;
t.alive = false;
try { t.ws.ping(); } catch { /* closing */ }
try { t.ws.ping(); } catch { /* closing */ }
@@ -108,8 +127,10 @@ function createTunnels({ shares, config, log = console, onConnect = null }) {
@@ -108,8 +127,10 @@ function createTunnels({ shares, config, log = console, onConnect = null }) {
function refuse(ws, code, message) {
function refuse(ws, code, message) {
stats.refused++;
stats.refused++;
try { ws.send(JSON.stringify({ t: 'error', code, message })); } catch { /* gone */ }
try { ws.send(JSON.stringify({ t: 'error', code, message })); } catch { /* gone */ }
- // 4002: the key is wrong, 4003: the share is closed (the client stops for both); 4000: anything else.
- try { ws.close(code === 'key_invalid' ? 4002 : code === 'share_revoked' ? 4003 : 4000, message.slice(0, 120)); } catch { /* gone */ }
+ // 4002: the key (or the computer's node token) is wrong, 4003: the share is closed or not this computer's (the
+ // client stops for both); 4000: anything else, the client tries again.
+ const close = code === 'key_invalid' || code === 'node_invalid' ? 4002 : code === 'share_revoked' || code === 'node_not_bound' ? 4003 : 4000;
+ try { ws.close(close, message.slice(0, 120)); } catch { /* gone */ }
}
}
function accept(ws) {
function accept(ws) {
@@ -125,7 +146,15 @@ function createTunnels({ shares, config, log = console, onConnect = null }) {
@@ -125,7 +146,15 @@ function createTunnels({ shares, config, log = console, onConnect = null }) {
}
}
});
});
ws.on('message', async (message, isBinary) => {
ws.on('message', async (message, isBinary) => {
- if (tunnel) { tunnel.alive = true; return tunnel.onMessage(message, isBinary); }
+ if (tunnel) {
+ tunnel.alive = true;
+ if (tunnel.node && !isBinary && message.length < 8192 && String(message).includes('"reauth"')) {
+ let r = null;
+ try { r = JSON.parse(String(message)); } catch { /* not ours */ }
+ if (r && r.t === 'reauth') return reauth(tunnel, r);
+ }
+ return tunnel.onMessage(message, isBinary);
+ }
if (isBinary) return refuse(ws, 'hello_expected', 'The first message is the hello.');
if (isBinary) return refuse(ws, 'hello_expected', 'The first message is the hello.');
let m;
let m;
try { m = JSON.parse(String(message)); } catch { return refuse(ws, 'hello_expected', 'The first message is the hello.'); }
try { m = JSON.parse(String(message)); } catch { return refuse(ws, 'hello_expected', 'The first message is the hello.'); }
@@ -133,10 +162,26 @@ function createTunnels({ shares, config, log = console, onConnect = null }) {
@@ -133,10 +162,26 @@ function createTunnels({ shares, config, log = console, onConnect = null }) {
clearTimeout(hello);
clearTimeout(hello);
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;
- try { share = await shares.byKey(m.key); } catch (err) { log.warn('[OpenVibe.Connect] key lookup failed:', err.message); return refuse(ws, 'unavailable', 'Try again in a moment.'); }
- if (!share) return refuse(ws, 'key_invalid', 'That connect key is not one we know. Make a new one on your share page.');
- if (share.status !== 'active') return refuse(ws, 'share_revoked', 'That share is closed.');
+ let node = null;
+ if (m.key === undefined && (m.node_token !== undefined || m.tunnel !== undefined)) {
+ // A computer as OpenVibe Node (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 (!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;
+ 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.');
+ node = v;
+ } else {
+ try { share = await shares.byKey(m.key); } catch (err) { log.warn('[OpenVibe.Connect] key lookup failed:', err.message); return refuse(ws, 'unavailable', 'Try again in a moment.'); }
+ if (!share) return refuse(ws, 'key_invalid', 'That connect key is not one we know. Make a new one on your share page.');
+ if (share.status !== 'active') return refuse(ws, 'share_revoked', 'That share is closed.');
+ }
const t = new Tunnel(ws, share, { log, maxStreams: config.shares.maxStreams });
const t = new Tunnel(ws, share, { log, maxStreams: config.shares.maxStreams });
+ if (node) { t.node = node.node; t.authUntil = node.exp; }
t.clientInfo = { client: String(m.client || '').slice(0, 60), port: Number.isInteger(m.port) ? m.port : null };
t.clientInfo = { client: String(m.client || '').slice(0, 60), port: Number.isInteger(m.port) ? m.port : null };
const old = live.get(share.id);
const old = live.get(share.id);
if (old) { stats.replaced++; old.shutdown(4001, 'replaced by a newer connection'); }
if (old) { stats.replaced++; old.shutdown(4001, 'replaced by a newer connection'); }
@@ -146,11 +191,23 @@ function createTunnels({ shares, config, log = console, onConnect = null }) {
@@ -146,11 +191,23 @@ function createTunnels({ shares, config, log = console, onConnect = null }) {
shares.touchConnected(share.id).catch(() => {});
shares.touchConnected(share.id).catch(() => {});
// The people who asked to be told it is back (CON-17); never in the way of the connection.
// The people who asked to be told it is back (CON-17); never in the way of the connection.
if (onConnect) Promise.resolve().then(() => onConnect(share)).catch(() => {});
if (onConnect) Promise.resolve().then(() => onConnect(share)).catch(() => {});
- t.send({ t: 'ready', v: frames.VERSION, share: { id: share.id, host: share.host, url: share.url, mode: share.mode } });
+ t.send({ t: 'ready', v: frames.VERSION, share: { id: share.id, host: share.host, url: share.url, mode: share.mode, local_port: share.local_port }, ...(node ? { node: true, until: new Date(node.exp).toISOString() } : {}) });
return undefined;
return undefined;
});
});
}
}
+ /** 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 };
+ 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;
+ try { share = await shares.get(t.share.id); } catch { return t.send({ t: 'error', code: 'unavailable', message: 'Try again in a moment.' }); }
+ if (!share || share.status !== 'active' || !share.node || share.node.id !== v.node) return t.shutdown(4003, 'this computer no longer serves the share');
+ t.authUntil = v.exp;
+ return t.send({ t: 'reauthed', until: new Date(v.exp).toISOString() });
+ }
+
function handleUpgrade(req, socket, head) {
function handleUpgrade(req, socket, head) {
wss.handleUpgrade(req, socket, head, (ws) => accept(ws));
wss.handleUpgrade(req, socket, head, (ws) => accept(ws));
}
}
@@ -173,7 +230,7 @@ function createTunnels({ shares, config, log = console, onConnect = null }) {
@@ -173,7 +230,7 @@ function createTunnels({ shares, config, log = console, onConnect = null }) {
return { connected: live.size, streams: [...live.values()].reduce((n, t) => n + t.streams.size, 0), ...stats };
return { connected: live.size, streams: [...live.values()].reduce((n, t) => n + t.streams.size, 0), ...stats };
}
}
- return { handleUpgrade, get: (id) => live.get(id) || null, close, closeAll, status, isConnected: (id) => live.has(id) };
+ return { handleUpgrade, get: (id) => live.get(id) || null, close, closeAll, status, sweep, isConnected: (id) => live.has(id) };
}
}
module.exports = { createTunnels, Tunnel, HIGH_WATER };
module.exports = { createTunnels, Tunnel, HIGH_WATER };
@@ -11,6 +11,9 @@
@@ -11,6 +11,9 @@
* or null to stay open until you close it
* or null to stay open until you close it
* 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 Node (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/visits who opened it in the last 7 days
* GET /shares/:id/visits who opened it in the last 7 days
* GET /shares/:id/requests the last 100 requests through it, newest first (?after=<seq>: only newer ones);
* GET /shares/:id/requests the last 100 requests through it, newest first (?after=<seq>: only newer ones);
* memory only, query values shown as "…" (edge/inspector.js)
* memory only, query values shown as "…" (edge/inspector.js)
@@ -23,7 +26,7 @@ const { asyncRouter } = require('./router');
@@ -23,7 +26,7 @@ const { asyncRouter } = require('./router');
const { ShareError } = require('../shares');
const { ShareError } = require('../shares');
function createApi(ctx) {
function createApi(ctx) {
- const { shares, tunnels, principal, proxy } = ctx;
+ const { shares, tunnels, principal, proxy, machines } = ctx;
const r = asyncRouter();
const r = asyncRouter();
r.use(express.json({ limit: '16kb' }));
r.use(express.json({ limit: '16kb' }));
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
@@ -87,6 +90,30 @@ function createApi(ctx) {
@@ -87,6 +90,30 @@ function createApi(ctx) {
return res.json({ share: shape(updated), key });
return res.json({ share: shape(updated), key });
}));
}));
+ r.get('/machines', guard(async (req, res) => {
+ const m = await machines.list(req.principal.token);
+ if (!m.ok) return fail(res, req, 503, 'machines.unavailable', 'Your computers could not be read from OpenVibe just now. Try again in a moment.');
+ return res.json({ machines: m.machines });
+ }));
+
+ r.put('/shares/:id/node', guard(async (req, res) => {
+ const share = await mine(req, res);
+ if (!share) return undefined;
+ const want = (req.body || {}).node;
+ if (want === null || want === '') {
+ const { share: updated, key } = await shares.bindNode(owner(req), share.id, null);
+ 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.');
+ 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 Node first.');
+ const { share: updated } = await shares.bindNode(owner(req), share.id, found.machine);
+ tunnels.close(share.id, 4003, 'now served by one of your computers');
+ return res.json({ share: shape(updated) });
+ }));
+
r.get('/shares/:id/requests', guard(async (req, res) => {
r.get('/shares/:id/requests', guard(async (req, res) => {
const share = await mine(req, res);
const share = await mine(req, res);
return share ? res.json({ requests: proxy.inspector.list(share.id, { after: req.query.after }), keep: proxy.inspector.KEEP }) : undefined;
return share ? res.json({ requests: proxy.inspector.list(share.id, { after: req.query.after }), keep: proxy.inspector.KEEP }) : undefined;
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
+'use strict';
+
+/**
+ * /api/v1/node: a person's computer, as OpenVibe Node (CON-4, ADR-057 decision 4). Every call carries the computer's
+ * Network node token (Authorization: Bearer, subject node:nod_…, audience openvibe.website); nothing here answers to a
+ * 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 { asyncRouter } = require('./router');
+
+function createNodeApi(ctx) {
+ const { shares, nodeAuth } = ctx;
+ const r = asyncRouter();
+ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
+ const fail = (res, req, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
+
+ r.use(async (req, res, next) => {
+ const header = String(req.headers.authorization || '');
+ if (!header.startsWith('Bearer ')) return fail(res, req, 401, 'token.required', 'send this computer\'s Network node token (audience openvibe.website) as a Bearer');
+ 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('/shares/:id/tunnel-token', async (req, res) => {
+ const share = await shares.get(req.params.id);
+ // Another person's share and a share this computer does not serve look the same: not found.
+ if (!share || !share.node || share.node.id !== req.node) return fail(res, req, 404, 'share.not_found', 'This computer does not serve that share. Pick it on the share\'s page on connect.openvibe.website.');
+ if (share.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,6 +8,8 @@
@@ -8,6 +8,8 @@
* /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
+ * new key once
* POST /shares/:id/close close it: the address answers "closed" from then on
* POST /shares/:id/close close it: the address answers "closed" from then on
* /grant?share=&next= the gate's sign-in step (server/edge/gate.js): signed in and allowed → back to the
* /grant?share=&next= the gate's sign-in step (server/edge/gate.js): signed in and allowed → back to the
* share host with a one-time code
* share host with a one-time code
@@ -21,7 +23,7 @@ const views = require('../views');
@@ -21,7 +23,7 @@ const views = require('../views');
const { ShareError } = require('../shares');
const { ShareError } = require('../shares');
function createPageRoutes(ctx) {
function createPageRoutes(ctx) {
- const { config, shares, tunnels, gate, proxy } = ctx;
+ const { config, shares, tunnels, gate, proxy, machines } = ctx;
const r = asyncRouter();
const r = asyncRouter();
r.use(express.urlencoded({ extended: false, limit: '16kb' }));
r.use(express.urlencoded({ extended: false, limit: '16kb' }));
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
@@ -80,14 +82,17 @@ function createPageRoutes(ctx) {
@@ -80,14 +82,17 @@ function createPageRoutes(ctx) {
async function showShare(req, res, share, extra = {}, status = 200) {
async function showShare(req, res, share, extra = {}, status = 200) {
const tunnel = tunnels.get(share.id);
const tunnel = tunnels.get(share.id);
- const visits = await shares.visits(share.id);
- return page(req, res, { title: share.host, body: views.sharePage({ share, connected: Boolean(tunnel), tunnel, visits, requests: ctx.proxy ? ctx.proxy.inspector.list(share.id) : [], baseUrl: config.baseUrl, staff: req.viewer.role === 'admin', ...extra }) }, status);
+ const [visits, mine] = await Promise.all([
+ shares.visits(share.id),
+ share.status === 'active' ? machines.list(req.viewer.token) : Promise.resolve({ ok: true, machines: [] }),
+ ]);
+ return page(req, res, { title: share.host, body: views.sharePage({ share, connected: Boolean(tunnel), tunnel, visits, requests: ctx.proxy ? ctx.proxy.inspector.list(share.id) : [], baseUrl: config.baseUrl, staff: req.viewer.role === 'admin', machines: mine.ok ? mine.machines : null, ...extra }) }, status);
}
}
r.get('/shares/:id', async (req, res) => {
r.get('/shares/:id', async (req, res) => {
const share = await mine(req, res);
const share = await mine(req, res);
if (!share) return undefined;
if (!share) return undefined;
- const notice = req.query.saved ? 'Saved.' : null;
+ const notice = req.query.saved ? 'Saved.' : req.query.served ? `${share.node ? share.node.name : 'Your computer'} serves it now. Run the command below on it.` : null;
return showShare(req, res, share, { notice });
return showShare(req, res, share, { notice });
});
});
@@ -119,6 +124,29 @@ function createPageRoutes(ctx) {
@@ -119,6 +124,29 @@ function createPageRoutes(ctx) {
}
}
});
});
+ r.post('/shares/:id/node', async (req, res) => {
+ if (!guard(req, res)) return undefined;
+ const share = await mine(req, res);
+ if (!share) return undefined;
+ const want = String(req.body.node || '').trim();
+ try {
+ if (!want) {
+ const { share: updated, key } = await shares.bindNode(owner(req), share.id, null);
+ tunnels.close(share.id, 4003, 'the computer was let go');
+ return showShare(req, res, updated, { key, notice: 'Your computer no longer serves it. Use this key with ov-connect.js, or pick a computer again.' });
+ }
+ const found = await machines.find(req.viewer.token, want);
+ if (!found.ok) return showShare(req, res, share, { error: 'Your computers could not be read from OpenVibe just now. Try again in a moment.' }, 503);
+ if (!found.machine) return showShare(req, res, share, { error: 'That is not one of your computers. Pair it with OpenVibe Node first.' }, 404);
+ const { share: updated } = await shares.bindNode(owner(req), share.id, found.machine);
+ tunnels.close(share.id, 4003, 'now served by one of your computers');
+ return res.redirect(303, `/shares/${encodeURIComponent(share.id)}?served=1`);
+ } catch (err) {
+ if (!(err instanceof ShareError)) throw err;
+ return showShare(req, res, share, { error: err.message }, err.status || 422);
+ }
+ });
+
r.post('/shares/:id/close', async (req, res) => {
r.post('/shares/:id/close', async (req, res) => {
if (!guard(req, res)) return undefined;
if (!guard(req, res)) return undefined;
const share = await mine(req, res);
const share = await mine(req, res);
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
+'use strict';
+
+/**
+ * A person's computers, as Network knows them (CON-4): GET /api/v1/me/nodes with the person's own Network token, so
+ * Connect sees only what they could see themself. A share can be bound only to one of their active machines.
+ *
+ * list(userToken) → { ok: true, machines: [{ id, name, last_seen_at, paired_for }] } | { ok: false, reason }
+ * find(userToken, id) → the machine, or null
+ *
+ * Every call reaches Network from this one address, and Network limits /api/v1/me/nodes per address, so a person's list
+ * 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 KEEP_MS = 20_000;
+const KEEP_MAX = 500;
+
+function createMachines({ config, fetchImpl = globalThis.fetch, log = console, now = () => Date.now() }) {
+ const base = config.networkInternalUrl || config.networkUrl;
+ const kept = new Map(); // sha256(token) → { at, machines }
+
+ async function list(userToken, { fresh = false } = {}) {
+ if (!userToken) return { ok: false, reason: 'signed_out' };
+ const k = crypto.createHash('sha256').update(String(userToken)).digest('hex');
+ const hit = kept.get(k);
+ if (!fresh && hit && now() - hit.at < KEEP_MS) return { ok: true, machines: hit.machines };
+ try {
+ const res = await fetchImpl(`${base}/api/v1/me/nodes`, {
+ headers: { Accept: 'application/json', Authorization: `Bearer ${userToken}` },
+ signal: AbortSignal.timeout(8_000),
+ });
+ if (!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 || '')))
+ .map((n) => ({
+ id: n.principal,
+ name: typeof n.name === 'string' && n.name.trim() ? n.name.trim().slice(0, 80) : 'A computer',
+ last_seen_at: n.last_seen_at || null,
+ paired_for: n.paired_for && n.paired_for.service ? String(n.paired_for.service).slice(0, 40) : null,
+ }));
+ kept.delete(k);
+ if (kept.size >= KEEP_MAX) kept.delete(kept.keys().next().value);
+ kept.set(k, { at: now(), machines });
+ return { ok: true, machines };
+ } catch (err) {
+ log.warn(`[OpenVibe.Connect] machines not read: ${(err && err.message) || err}`);
+ return { ok: false, reason: 'error' };
+ }
+ }
+
+ async function find(userToken, id) {
+ const r = await list(userToken, { fresh: true });
+ if (!r.ok) return { ok: false, reason: r.reason };
+ return { ok: true, machine: r.machines.find((m) => m.id === id) || null };
+ }
+
+ return { list, find };
+}
+
+module.exports = { createMachines };
@@ -7,6 +7,9 @@
@@ -7,6 +7,9 @@
* list(ownerSubject), get(id), byHost(host), byKey(key)
* list(ownerSubject), get(id), byHost(host), byKey(key)
* setPolicy(owner, id, { mode, named }) bumps policy_version: every visitor's cookie goes stale
* setPolicy(owner, id, { mode, named }) bumps policy_version: every visitor's cookie goes stale
* rotateKey(owner, id) → key the old key stops working; a connected tunnel is closed
* rotateKey(owner, id) → key the old key stops working; a connected tunnel is closed
+ * bindNode(owner, id, machine | null) one of the owner's computers serves it (CON-4); the key stops
+ * working while one is bound. null: back to a new connect key
+ * byNode(nodeId) the open shares a computer serves
* revoke(owner, id) the host answers 410 from now on
* revoke(owner, id) the host answers 410 from now on
* hostFor(label, username), parseHost(host) the naming rule (pure)
* hostFor(label, username), parseHost(host) the naming rule (pure)
*
*
@@ -121,7 +124,9 @@ function createShares(s, { config }) {
@@ -121,7 +124,9 @@ function createShares(s, { config }) {
named: Array.isArray(row.named) ? row.named : [],
named: Array.isArray(row.named) ? row.named : [],
status: row.status,
status: row.status,
policy_version: Number(row.policy_version),
policy_version: Number(row.policy_version),
- key_prefix: row.key_prefix,
+ key_prefix: row.node_id ? null : row.key_prefix,
+ // Served by one of the owner's computers, as OpenVibe Node (CON-4): its Network node principal and name.
+ node: row.node_id ? { id: row.node_id, name: row.node_name || 'A computer' } : null,
note: row.note || null,
note: row.note || null,
created_at: iso(row.created_at),
created_at: iso(row.created_at),
updated_at: iso(row.updated_at),
updated_at: iso(row.updated_at),
@@ -227,10 +232,36 @@ function createShares(s, { config }) {
@@ -227,10 +232,36 @@ function createShares(s, { config }) {
const share = await owned(owner, id);
const share = await owned(owner, id);
if (share.status !== 'active') throw new ShareError(410, 'share.revoked', 'That share is closed.');
if (share.status !== 'active') throw new ShareError(410, 'share.revoked', 'That share is closed.');
const key = newKey();
const key = newKey();
- await db.exec('UPDATE shares SET key_prefix = $2, key_hash = $3, updated_at = now() WHERE id = $1', [share.id, key.slice(0, 10), sha256(key)]);
+ // A new key means the key serves it: a computer bound as OpenVibe Node is let go (CON-4).
+ await db.exec('UPDATE shares SET key_prefix = $2, key_hash = $3, node_id = NULL, node_name = NULL, updated_at = now() WHERE id = $1', [share.id, key.slice(0, 10), sha256(key)]);
return { share: await get(share.id), key };
return { share: await get(share.id), key };
}
}
+ /**
+ * One of the owner's computers serves the share (CON-4): `machine` is { id: 'nod_…', 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.
+ */
+ async function bindNode(owner, id, machine) {
+ const share = await owned(owner, id);
+ if (share.status !== 'active') throw new ShareError(410, 'share.revoked', 'That share is closed.');
+ const key = newKey();
+ if (machine) {
+ await db.exec('UPDATE shares SET node_id = $2, node_name = $3, key_prefix = $4, key_hash = $5, updated_at = now() WHERE id = $1',
+ [share.id, machine.id, String(machine.name || '').slice(0, 80) || null, 'node', sha256(key)]);
+ return { share: await get(share.id), key: null };
+ }
+ await db.exec('UPDATE shares SET node_id = NULL, node_name = NULL, key_prefix = $2, key_hash = $3, updated_at = now() WHERE id = $1',
+ [share.id, key.slice(0, 10), sha256(key)]);
+ return { share: await get(share.id), key };
+ }
+
+ async function byNode(nodeId) {
+ const rows = await db.many("SELECT * FROM shares WHERE node_id = $1 AND status = 'active' ORDER BY created_at DESC LIMIT 100", [String(nodeId || '')]);
+ return rows.map(view);
+ }
+
async function revoke(owner, id) {
async function revoke(owner, id) {
const share = await owned(owner, id);
const share = await owned(owner, id);
if (share.status === 'revoked') return share;
if (share.status === 'revoked') return share;
@@ -337,7 +368,7 @@ function createShares(s, { config }) {
@@ -337,7 +368,7 @@ function createShares(s, { config }) {
return false;
return false;
}
}
- return { create, list, get, byHost, byKey, setPolicy, rotateKey, revoke, expireDue, report, openReports, handleReport, addWaiter, takeWaiters, touchConnected, flushCounters, visits, allows, view, domain };
+ return { create, list, get, byHost, byKey, byNode, bindNode, setPolicy, rotateKey, revoke, expireDue, report, openReports, handleReport, addWaiter, takeWaiters, touchConnected, flushCounters, visits, allows, view, domain };
}
}
function iso(v) {
function iso(v) {
@@ -229,12 +229,42 @@ function requestRow(q) {
@@ -229,12 +229,42 @@ 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>`;
}
}
-function sharePage({ share, connected, tunnel = null, visits = [], requests = [], key = null, baseUrl, notice = null, error = null, staff = false, now = Date.now() }) {
+const MACHINES_URL = 'https://openvibe.bot/machines';
+
+/**
+ * Which computer serves it (CON-4): one of the owner's computers as OpenVibe Node (no key to copy; removing the computer
+ * on OpenVibe stops it), or the one-file client with the connect key. `machines` is null when Network could not be read.
+ */
+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}`;
+ 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 Node: no key to copy, and removing the computer on OpenVibe stops it within five minutes.</p>
+<ol class="cn-steps">
+<li><span>On that computer, serve it now:</span><div class="cn-copy"><code id="cn-node-run">${esc(run)}</code><button type="button" class="cn-btn cn-btn-quiet" data-ovk-copy="#cn-node-run" hidden>${kit.icon('copy')} Copy</button></div></li>
+<li><span>Or keep it up with the Node service, across restarts:</span><div class="cn-copy"><code id="cn-node-keep">${esc(keep)}</code><button type="button" class="cn-btn cn-btn-quiet" data-ovk-copy="#cn-node-keep" hidden>${kit.icon('copy')} Copy</button></div></li>
+</ol>
+<form method="post" action="/shares/${esc(share.id)}/node"><input type="hidden" name="node" value=""><button class="cn-btn cn-btn-quiet" type="submit">${kit.icon('key')} Use a connect key instead</button><span>The computer stops serving it and a new key is shown once.</span></form>
+</section>`;
+ }
+ const intro = '<p>Pick one of your computers that runs OpenVibe Node 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 {
+ 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>`;
+ }
+ return `<section class="cn-node" aria-labelledby="cn-node-h"><h2 id="cn-node-h">Or serve it from one of your computers</h2>${intro}${list}</section>`;
+}
+
+function sharePage({ share, connected, tunnel = null, visits = [], requests = [], key = null, baseUrl, notice = null, error = null, staff = false, machines = [], now = Date.now() }) {
const closed = share.status !== 'active';
const closed = share.status !== 'active';
const state = closed ? 'Closed' : connected ? 'Connected' : 'Not connected';
const state = closed ? 'Closed' : connected ? 'Connected' : 'Not connected';
const stateText = closed ? `Closed ${ago(share.revoked_at, now)}. The address answers "closed" and stays yours.`
const stateText = closed ? `Closed ${ago(share.revoked_at, now)}. The address answers "closed" and stays yours.`
: connected ? `Connected ${ago(tunnel && tunnel.connectedAt ? new Date(tunnel.connectedAt).toISOString() : share.last_connected_at, now)}${tunnel && tunnel.clientInfo && tunnel.clientInfo.port ? `, serving localhost:${tunnel.clientInfo.port}` : ''}.`
: connected ? `Connected ${ago(tunnel && tunnel.connectedAt ? new Date(tunnel.connectedAt).toISOString() : share.last_connected_at, now)}${tunnel && tunnel.clientInfo && tunnel.clientInfo.port ? `, serving localhost:${tunnel.clientInfo.port}` : ''}.`
- : share.last_connected_at ? `Last connected ${ago(share.last_connected_at, now)}. Run ov-connect on your computer to bring it back.` : 'Never connected yet. Run the command below on your computer.';
+ : share.last_connected_at ? `Last connected ${ago(share.last_connected_at, now)}. ${share.node ? `Start OpenVibe Node on ${share.node.name}` : 'Run ov-connect on your computer'} to bring it back.` : 'Never connected yet. Run the command below on your computer.';
const visitRows = visits.length
const visitRows = visits.length
? `<ul class="cn-visits">${visits.map((v) => `<li><b>${v.username ? `@${esc(v.username)}` : 'someone'}</b><span>${esc(v.requests)} ${v.requests === 1 ? 'request' : 'requests'}${v.denied ? `, ${esc(v.denied)} turned away` : ''} · ${esc(ago(v.last_at, now))}</span></li>`).join('')}</ul>`
? `<ul class="cn-visits">${visits.map((v) => `<li><b>${v.username ? `@${esc(v.username)}` : 'someone'}</b><span>${esc(v.requests)} ${v.requests === 1 ? 'request' : 'requests'}${v.denied ? `, ${esc(v.denied)} turned away` : ''} · ${esc(ago(v.last_at, now))}</span></li>`).join('')}</ul>`
: '<p class="cn-empty">Nobody has opened it in the last 7 days.</p>';
: '<p class="cn-empty">Nobody has opened it in the last 7 days.</p>';
@@ -266,11 +296,12 @@ ${key ? keyBlock(share, key, baseUrl) : ''}
@@ -266,11 +296,12 @@ ${key ? keyBlock(share, key, baseUrl) : ''}
<div><dt>Port</dt><dd>localhost:${esc(share.local_port)}</dd></div>
<div><dt>Port</dt><dd>localhost:${esc(share.local_port)}</dd></div>
<div><dt>Requests</dt><dd>${esc(Number(share.requests).toLocaleString('en-US'))}</dd></div>
<div><dt>Requests</dt><dd>${esc(Number(share.requests).toLocaleString('en-US'))}</dd></div>
<div><dt>Data</dt><dd>${esc(fmtBytes(share.bytes_in))} in · ${esc(fmtBytes(share.bytes_out))} out</dd></div>
<div><dt>Data</dt><dd>${esc(fmtBytes(share.bytes_in))} in · ${esc(fmtBytes(share.bytes_out))} out</dd></div>
-<div><dt>Key</dt><dd><code>${esc(share.key_prefix)}…</code></dd></div>
+${share.node ? `<div><dt>Served by</dt><dd>${esc(share.node.name)} (OpenVibe Node)</dd></div>` : `<div><dt>Key</dt><dd><code>${esc(share.key_prefix)}…</code></dd></div>`}
<div><dt>Made</dt><dd>${esc(ago(share.created_at, now))}</dd></div>
<div><dt>Made</dt><dd>${esc(ago(share.created_at, now))}</dd></div>
${share.expires_at ? `<div><dt>${closed ? 'Ended' : 'Closes'}</dt><dd><time datetime="${esc(share.expires_at)}">${esc(closed ? ago(share.expires_at, now) : until(share.expires_at, now))}</time></dd></div>` : ''}
${share.expires_at ? `<div><dt>${closed ? 'Ended' : 'Closes'}</dt><dd><time datetime="${esc(share.expires_at)}">${esc(closed ? ago(share.expires_at, now) : until(share.expires_at, now))}</time></dd></div>` : ''}
</dl>
</dl>
-${!key && !closed ? `<section class="cn-run"><h2>Connect it</h2><div class="cn-copy"><code id="cn-run">node ov-connect.js --key ovc_… --port ${esc(share.local_port)}</code></div><p class="cn-fine">The key was shown once, when it was made. <a href="${esc(CLIENT_URL(baseUrl))}">Download ov-connect.js</a> (Node 22 or newer).</p></section>` : ''}
+${!key && !closed && !share.node ? `<section class="cn-run"><h2>Connect it</h2><div class="cn-copy"><code id="cn-run">node ov-connect.js --key ovc_… --port ${esc(share.local_port)}</code></div><p class="cn-fine">The key was shown once, when it was made. <a href="${esc(CLIENT_URL(baseUrl))}">Download ov-connect.js</a> (Node 22 or newer).</p></section>` : ''}
+${closed ? '' : nodeBlock(share, machines, now)}
<section aria-labelledby="cn-visits-h"><h2 id="cn-visits-h">Who opened it (7 days)</h2>${visitRows}</section>
<section aria-labelledby="cn-visits-h"><h2 id="cn-visits-h">Who opened it (7 days)</h2>${visitRows}</section>
${closed ? '' : `<section class="cn-inspector" aria-labelledby="cn-reqs-h" data-cn-reqs="${esc(share.id)}"><h2 id="cn-reqs-h">Requests</h2>
${closed ? '' : `<section class="cn-inspector" aria-labelledby="cn-reqs-h" data-cn-reqs="${esc(share.id)}"><h2 id="cn-reqs-h">Requests</h2>
<p class="cn-fine">The last 100 through this share, newest first, added as they happen. Kept in memory only, never written down: no headers, no bodies, and query values show as "…".</p>
<p class="cn-fine">The last 100 through this share, newest first, added as they happen. Kept in memory only, never written down: no headers, no bodies, and query values show as "…".</p>
@@ -42,7 +42,7 @@ async function startNetwork() {
@@ -42,7 +42,7 @@ async function startNetwork() {
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
- const st = { users: new Map(), byUsername: new Map(), codes: new Map(), refresh: new Map(), requests: [], tokenRequests: [], down: false };
+ const st = { users: new Map(), byUsername: new Map(), codes: new Map(), refresh: new Map(), requests: [], tokenRequests: [], down: false, nodes: [] };
let issuer = null;
let issuer = null;
let nextUserId = 1;
let nextUserId = 1;
@@ -118,6 +118,14 @@ async function startNetwork() {
@@ -118,6 +118,14 @@ async function startNetwork() {
return json(200, { items: [], next_cursor: null });
return json(200, { items: [], next_cursor: null });
}
}
+ // A person's machines (Network's userRouter): their node principals, any status.
+ if (url.pathname === '/api/v1/me/nodes' && req.method === 'GET') {
+ const u = userFrom(req);
+ if (u === 'expired') return problem(json, 401, 'auth.expired', 'expired');
+ if (!u) return problem(json, 401, 'auth.required', 'send Authorization: Bearer <Network access token>');
+ return json(200, { nodes: st.nodes.filter((n) => n.owner === u.subject).map((n) => ({ principal: n.id, node_id: n.node_id, name: n.name, owner: { kind: 'user', subject: n.owner }, status: n.status, paired_for: { service: 'bot', ref: 'rob_x' }, last_seen_at: n.last_seen_at, created_at: '2026-11-01T00:00:00.000Z' })) });
+ }
+
if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') return json(200, { results: {} });
if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') return json(200, { results: {} });
if (url.pathname === '/internal/notifications/push' && req.method === 'POST') return json(200, { ok: true });
if (url.pathname === '/internal/notifications/push' && req.method === 'POST') return json(200, { ok: true });
@@ -128,6 +136,17 @@ async function startNetwork() {
@@ -128,6 +136,17 @@ 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 }. */
+ 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() };
+ 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 } = {}) {
+ 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);
+ },
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; },
close: srv.close,
close: srv.close,
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 Node (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); });