openvibers/openvibe.connect Commits

Shares served by your own computer as OpenVibe Node, no key (CON-4) (#14)

OP OpenVibers committed 10 days ago · 0e8c1a35dc · 14 files, +643 −24

ADR-057 decision 4: the computer proves itself with its Network node token (node:nod_…, audience openvibe.website)
and a single-use, 60-second tunnel token for that share, and keeps its tunnel with a fresh node token before the old
one runs out. The owner picks the computer from their machines on Network; the key stops working while it serves.

Changes

ddiff --git a/README.md b/README.md
iindex 5cd953d..61fbc1c 100644
---- a/README.md
++++ b/README.md
@@@ -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
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
- `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 memory
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.
++- `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 a
ddiff --git a/migrations/0005_share_node.sql b/migrations/0005_share_node.sql
nnew file mode 100644
iindex 0000000..a1c8ed4
---- /dev/null
++++ b/migrations/0005_share_node.sql
@@@ -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';
ddiff --git a/public/css/connect.css b/public/css/connect.css
iindex 75db2d8..7f8efeb 100644
---- a/public/css/connect.css
++++ b/public/css/connect.css
@@@ -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; }
ddiff --git a/server/app.js b/server/app.js
iindex 923ecd4..6a0cd02 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 }));
ddiff --git a/server/auth/node-auth.js b/server/auth/node-auth.js
nnew file mode 100644
iindex 0000000..34b53fb
---- /dev/null
++++ b/server/auth/node-auth.js
@@@ -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 };
ddiff --git a/server/edge/tunnels.js b/server/edge/tunnels.js
iindex 14faec0..317b26d 100644
---- a/server/edge/tunnels.js
++++ b/server/edge/tunnels.js
@@@ -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 → Tunnel
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(() => {
++ 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 };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex b031f5a..219a17d 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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;
ddiff --git a/server/http/node-api.js b/server/http/node-api.js
nnew file mode 100644
iindex 0000000..5097855
---- /dev/null
++++ b/server/http/node-api.js
@@@ -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 };
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 8af2038..6d64e31 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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);
ddiff --git a/server/machines.js b/server/machines.js
nnew file mode 100644
iindex 0000000..be007e6
---- /dev/null
++++ b/server/machines.js
@@@ -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 };
ddiff --git a/server/shares.js b/server/shares.js
iindex 557daa5..c048f49 100644
---- a/server/shares.js
++++ b/server/shares.js
@@@ -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) {
ddiff --git a/server/views/index.js b/server/views/index.js
iindex fefa06f..7d54636 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -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>
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex 8559907..ab346f4 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -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,
ddiff --git a/test/node-share.test.js b/test/node-share.test.js
nnew file mode 100644
iindex 0000000..705b201
---- /dev/null
++++ b/test/node-share.test.js
@@@ -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); });