openvibers/openvibe.connect Commits

OpenVibe Link rename: the machine references and docs (#87)

OP OpenVibers committed 4 days ago · 9766c5bee3 · 27 files, +442 −432

* OpenVibe Link rename, in progress (saved as it was left)

* Merge remote-tracking branch 'origin/main' into rename/link-1109-Connect

* Merge remote-tracking branch 'origin/main' into rename/link-1109-Connect

* Pin openvibe-contracts v0.373.0 and refresh the version docs

v0.373.0 carries the Link ids, principals and capabilities the tests now expect; the old 0.338.0 pin is what returned no id prefix for kind link. STATUS.json and the README versions block name the same tag.

Changes

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