openvibers/openvibe.network Commits
A person's machine may buy a node token for OpenVibe Connect (CON-4) (#538)
Changes
@@@ -353,7 +353,8 @@ secrets set, tokens via `POST /oauth/token`), run with `node test/<file>.test.jsnode is absent from `GET /api/v1/nodes`; home cell from `region`, else `wnam-1`; Host's report naming a paired`node_id` is refused `409 registry.node_not_platform` (`cells.js:43`).- **N4c `test/node-tokens.test.js`:** credential → token whose claims validate as `identity.service-token-claims@1`-- with `sub node:nod_…`; audience `openvibe.bot` OK, `openvibe.chat` 400; revoked / wrong secret 401 with one message;++ with `sub node:nod_…`; audience `openvibe.bot` OK, `openvibe.website` OK for any person's machine (OpenVibe Connect,++ CON-4, 2026-11-02; no capability), `openvibe.chat` 400; revoked / wrong secret 401 with one message;rotation: new works, old works < 60 s and fails after (clock injected), revoke kills both; capabilities PUT withanother `node_id` 409; node A's valid token cannot change node B's capabilities or credential (B's rows unchanged);a revoked node's still-unexpired token on either self route 401 `registry.node_revoked`; a `svc:` token holding@@@ -367,6 +367,9 @@ function userRouter(requireAuth, now = () => Date.now()) {return router;}++/** Audiences every person's machine may buy a token for, whichever service paired it (CON-4). */++const NODE_WIDE_AUDIENCES = Object.freeze(['openvibe.website']);++/** Every refusal of a node's credential, whatever the reason: one status, one body. */const INVALID_CLIENT = Object.freeze({ status: 401, body: Object.freeze({ error: 'invalid_client', error_description: 'Invalid client credentials' }) });@@@ -386,8 +389,12 @@ async function issueNodeToken(db, { clientId, clientSecret, audience, privateKeyconst prevLive = !!(p && p.prev_valid_until && Date.parse(p.prev_valid_until) > now);if (!p || p.status !== 'active' || !p.credential_hash || !(current || (previous && prevLive))) return INVALID_CLIENT;const aud = typeof audience === 'string' ? audience.trim() : '';-- if (aud !== SELF_AUDIENCE && !(p.paired_by_service && aud === `openvibe.${p.paired_by_service}`)) {-- return { status: 400, body: { error: 'invalid_scope', error_description: `a node token is for ${SELF_AUDIENCE}${p.paired_by_service ? ` or openvibe.${p.paired_by_service}` : ''}` } };++ // A person's machine may also speak to the services any of their computers can use, whoever paired it: OpenVibe++ // Connect (openvibe.website) binds a share to one machine and checks the node token at every tunnel hello (ADR-057++ // decision 4, CON-4). The token carries no capability: Connect authorises the node by its own share binding.++ const anyNode = p.owner_kind === 'user' && NODE_WIDE_AUDIENCES.includes(aud);++ if (aud !== SELF_AUDIENCE && !anyNode && !(p.paired_by_service && aud === `openvibe.${p.paired_by_service}`)) {++ return { status: 400, body: { error: 'invalid_scope', error_description: `a node token is for ${SELF_AUDIENCE}${p.paired_by_service ? ` or openvibe.${p.paired_by_service}` : ''}${p.owner_kind === 'user' ? `, or ${NODE_WIDE_AUDIENCES.join(', ')}` : ''}` } };}const iat = Math.floor(now / 1000);const claims = {@@@ -524,4 +531,4 @@ function routers({ guard, now = () => Date.now() }) {return { internal, pairing };}--module.exports = { CODE_TTL_MS, MAX_TRIES, PREV_GRACE_MS, OPERATOR_STATUS, createPairing, redeem, revoke, revokeOwnedBy, principalView, principalFilter, listPrincipals, setPrincipalStatus, routers, userRouter, issueNodeToken, selfRouter };++module.exports = { CODE_TTL_MS, MAX_TRIES, NODE_WIDE_AUDIENCES, PREV_GRACE_MS, OPERATOR_STATUS, createPairing, redeem, revoke, revokeOwnedBy, principalView, principalFilter, listPrincipals, setPrincipalStatus, routers, userRouter, issueNodeToken, selfRouter };@@@ -107,13 +107,20 @@ try {assert.strictEqual((await row(A.principal)).last_seen_at, seen, 'not rewritten within 60 s');});-- await check('the audience is Network or the pairing service, nothing else', async () => {++ await check('the audience is Network, the pairing service or Connect, nothing else', async () => {const r = await tokenCall(A.principal, A.credential, 'openvibe.bot');assert.strictEqual(r.status, 200, r.text);const v = serviceAuth.verifyServiceToken(r.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.bot' });assert.ok(v.ok, v.reason);assert.deepStrictEqual([v.claims.aud, v.claims.cap], [['openvibe.bot'], []], 'Bot authorises the node by its own binding');-- for (const aud of ['openvibe.chat', 'openvibe.network.evil', '', undefined]) {++ // Any person's machine may speak to OpenVibe Connect, whoever paired it (CON-4), with no capability.++ const w = await tokenCall(A.principal, A.credential, 'openvibe.website');++ assert.strictEqual(w.status, 200, w.text);++ const vw = serviceAuth.verifyServiceToken(w.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.website' });++ assert.ok(vw.ok, vw.reason);++ assert.deepStrictEqual([vw.claims.sub, vw.claims.aud, vw.claims.cap], [`node:${A.principal}`, ['openvibe.website'], []]);++ assert.strictEqual((await call('PUT', '/api/v1/node/self/capabilities', doc(A.node_id), bearer(w.body.access_token))).status, 401, 'no use on Network');++ for (const aud of ['openvibe.chat', 'openvibe.network.evil', 'openvibe.websites', 'connect.openvibe.website', '', undefined]) {const bad = await tokenCall(A.principal, A.credential, aud);assert.deepStrictEqual([bad.status, bad.body.error], [400, 'invalid_scope'], String(aud));}
@@ -353,7 +353,8 @@ secrets set, tokens via `POST /oauth/token`), run with `node test/<file>.test.js
@@ -353,7 +353,8 @@ secrets set, tokens via `POST /oauth/token`), run with `node test/<file>.test.js
node is absent from `GET /api/v1/nodes`; home cell from `region`, else `wnam-1`; Host's report naming a paired
node is absent from `GET /api/v1/nodes`; home cell from `region`, else `wnam-1`; Host's report naming a paired
`node_id` is refused `409 registry.node_not_platform` (`cells.js:43`).
`node_id` is refused `409 registry.node_not_platform` (`cells.js:43`).
- **N4c `test/node-tokens.test.js`:** credential → token whose claims validate as `identity.service-token-claims@1`
- **N4c `test/node-tokens.test.js`:** credential → token whose claims validate as `identity.service-token-claims@1`
- with `sub node:nod_…`; audience `openvibe.bot` OK, `openvibe.chat` 400; revoked / wrong secret 401 with one message;
+ with `sub node:nod_…`; audience `openvibe.bot` OK, `openvibe.website` OK for any person's machine (OpenVibe Connect,
+ CON-4, 2026-11-02; no capability), `openvibe.chat` 400; revoked / wrong secret 401 with one message;
rotation: new works, old works < 60 s and fails after (clock injected), revoke kills both; capabilities PUT with
rotation: new works, old works < 60 s and fails after (clock injected), revoke kills both; capabilities PUT with
another `node_id` 409; node A's valid token cannot change node B's capabilities or credential (B's rows unchanged);
another `node_id` 409; node A's valid token cannot change node B's capabilities or credential (B's rows unchanged);
a revoked node's still-unexpired token on either self route 401 `registry.node_revoked`; a `svc:` token holding
a revoked node's still-unexpired token on either self route 401 `registry.node_revoked`; a `svc:` token holding
@@ -367,6 +367,9 @@ function userRouter(requireAuth, now = () => Date.now()) {
@@ -367,6 +367,9 @@ function userRouter(requireAuth, now = () => Date.now()) {
return router;
return router;
}
}
+/** Audiences every person's machine may buy a token for, whichever service paired it (CON-4). */
+const NODE_WIDE_AUDIENCES = Object.freeze(['openvibe.website']);
+
/** Every refusal of a node's credential, whatever the reason: one status, one body. */
/** Every refusal of a node's credential, whatever the reason: one status, one body. */
const INVALID_CLIENT = Object.freeze({ status: 401, body: Object.freeze({ error: 'invalid_client', error_description: 'Invalid client credentials' }) });
const INVALID_CLIENT = Object.freeze({ status: 401, body: Object.freeze({ error: 'invalid_client', error_description: 'Invalid client credentials' }) });
@@ -386,8 +389,12 @@ async function issueNodeToken(db, { clientId, clientSecret, audience, privateKey
@@ -386,8 +389,12 @@ async function issueNodeToken(db, { clientId, clientSecret, audience, privateKey
const prevLive = !!(p && p.prev_valid_until && Date.parse(p.prev_valid_until) > now);
const prevLive = !!(p && p.prev_valid_until && Date.parse(p.prev_valid_until) > now);
if (!p || p.status !== 'active' || !p.credential_hash || !(current || (previous && prevLive))) return INVALID_CLIENT;
if (!p || p.status !== 'active' || !p.credential_hash || !(current || (previous && prevLive))) return INVALID_CLIENT;
const aud = typeof audience === 'string' ? audience.trim() : '';
const aud = typeof audience === 'string' ? audience.trim() : '';
- if (aud !== SELF_AUDIENCE && !(p.paired_by_service && aud === `openvibe.${p.paired_by_service}`)) {
- return { status: 400, body: { error: 'invalid_scope', error_description: `a node token is for ${SELF_AUDIENCE}${p.paired_by_service ? ` or openvibe.${p.paired_by_service}` : ''}` } };
+ // A person's machine may also speak to the services any of their computers can use, whoever paired it: OpenVibe
+ // Connect (openvibe.website) binds a share to one machine and checks the node token at every tunnel hello (ADR-057
+ // decision 4, CON-4). The token carries no capability: Connect authorises the node by its own share binding.
+ const anyNode = p.owner_kind === 'user' && NODE_WIDE_AUDIENCES.includes(aud);
+ if (aud !== SELF_AUDIENCE && !anyNode && !(p.paired_by_service && aud === `openvibe.${p.paired_by_service}`)) {
+ return { status: 400, body: { error: 'invalid_scope', error_description: `a node token is for ${SELF_AUDIENCE}${p.paired_by_service ? ` or openvibe.${p.paired_by_service}` : ''}${p.owner_kind === 'user' ? `, or ${NODE_WIDE_AUDIENCES.join(', ')}` : ''}` } };
}
}
const iat = Math.floor(now / 1000);
const iat = Math.floor(now / 1000);
const claims = {
const claims = {
@@ -524,4 +531,4 @@ function routers({ guard, now = () => Date.now() }) {
@@ -524,4 +531,4 @@ function routers({ guard, now = () => Date.now() }) {
return { internal, pairing };
return { internal, pairing };
}
}
-module.exports = { CODE_TTL_MS, MAX_TRIES, PREV_GRACE_MS, OPERATOR_STATUS, createPairing, redeem, revoke, revokeOwnedBy, principalView, principalFilter, listPrincipals, setPrincipalStatus, routers, userRouter, issueNodeToken, selfRouter };
+module.exports = { CODE_TTL_MS, MAX_TRIES, NODE_WIDE_AUDIENCES, PREV_GRACE_MS, OPERATOR_STATUS, createPairing, redeem, revoke, revokeOwnedBy, principalView, principalFilter, listPrincipals, setPrincipalStatus, routers, userRouter, issueNodeToken, selfRouter };
@@ -107,13 +107,20 @@ try {
@@ -107,13 +107,20 @@ try {
assert.strictEqual((await row(A.principal)).last_seen_at, seen, 'not rewritten within 60 s');
assert.strictEqual((await row(A.principal)).last_seen_at, seen, 'not rewritten within 60 s');
});
});
- await check('the audience is Network or the pairing service, nothing else', async () => {
+ await check('the audience is Network, the pairing service or Connect, nothing else', async () => {
const r = await tokenCall(A.principal, A.credential, 'openvibe.bot');
const r = await tokenCall(A.principal, A.credential, 'openvibe.bot');
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
const v = serviceAuth.verifyServiceToken(r.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.bot' });
const v = serviceAuth.verifyServiceToken(r.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.bot' });
assert.ok(v.ok, v.reason);
assert.ok(v.ok, v.reason);
assert.deepStrictEqual([v.claims.aud, v.claims.cap], [['openvibe.bot'], []], 'Bot authorises the node by its own binding');
assert.deepStrictEqual([v.claims.aud, v.claims.cap], [['openvibe.bot'], []], 'Bot authorises the node by its own binding');
- for (const aud of ['openvibe.chat', 'openvibe.network.evil', '', undefined]) {
+ // Any person's machine may speak to OpenVibe Connect, whoever paired it (CON-4), with no capability.
+ const w = await tokenCall(A.principal, A.credential, 'openvibe.website');
+ assert.strictEqual(w.status, 200, w.text);
+ const vw = serviceAuth.verifyServiceToken(w.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.website' });
+ assert.ok(vw.ok, vw.reason);
+ assert.deepStrictEqual([vw.claims.sub, vw.claims.aud, vw.claims.cap], [`node:${A.principal}`, ['openvibe.website'], []]);
+ assert.strictEqual((await call('PUT', '/api/v1/node/self/capabilities', doc(A.node_id), bearer(w.body.access_token))).status, 401, 'no use on Network');
+ for (const aud of ['openvibe.chat', 'openvibe.network.evil', 'openvibe.websites', 'connect.openvibe.website', '', undefined]) {
const bad = await tokenCall(A.principal, A.credential, aud);
const bad = await tokenCall(A.principal, A.credential, aud);
assert.deepStrictEqual([bad.status, bad.body.error], [400, 'invalid_scope'], String(aud));
assert.deepStrictEqual([bad.status, bad.body.error], [400, 'invalid_scope'], String(aud));
}
}