openvibers/openvibe.network Commits

The extension's Dock client may ask for agent.view (#614)

OP OpenVibers committed 6 days ago · 0ee4369cd6 · 3 files, +14 −7

The Companion panel reads OpenVibe.Codes' agent view with the Dock token, so
Codes refuses it unless the token names the new `agent.view` scope. Add it to the
public client `companion`'s scope ceiling, and only there: the seed's existing
client update carries it to every deployment on boot, and any other public
client asking for it is still refused.

Tests in test/oauth-extension.test.js: the companion grant asks for and receives
dock activity chat.read agent.view (and the token carries it); a second public
client with its own ceiling gets its own scopes but 400 for agent.view.

Changes

ddiff --git a/server/auth/dock-tokens.js b/server/auth/dock-tokens.js
iindex 080e7ba1..e889ff3a 100644
---- a/server/auth/dock-tokens.js
++++ b/server/auth/dock-tokens.js
@@@ -20,7 +20,7 @@ const jwt = require('jsonwebtoken');
const CLIENT_ID = 'companion';
const CLIENT_NAME = 'OpenVibe extension';
--const SCOPES = Object.freeze(['dock', 'activity', 'chat.read']);
++const SCOPES = Object.freeze(['dock', 'activity', 'chat.read', 'agent.view']);
const AUDIENCES = Object.freeze(['openvibe.network', 'openvibe.chat']);
const FAMILY_RE = /^fam_[0-9a-f]{24}$/;
const SCOPE_RE = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 06faa847..f96d0db7 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -175,8 +175,9 @@ async function seedDb(db, { log = console } = {}) {
// PKCE S256 required, the Chrome/Firefox extension redirect URIs exactly, and a read-only scope
// ceiling. The Chrome id is fixed by browser/manifest.json's key; the Firefox hash is SHA-1 of
// the add-on id (extension id -> getRedirectURL). Both are public values.
++ // `agent.view` (2026-11-07) is the panel's read of Codes' agent view; Codes accepts it, and only it.
{ client_id: 'companion', name: 'OpenVibe extension', public: true,
-- allowed_scopes: 'dock activity chat.read', access_token_ttl_s: 3600,
++ allowed_scopes: 'dock activity chat.read agent.view', access_token_ttl_s: 3600,
redirect_uris: [
'https://bjcjcfcecpkkmfndegkjekoipdmbgakd.chromiumapp.org/',
'https://bb6593a68b1dcfbb7acd9650f04a169f4c5b57a0.extensions.allizom.org/',
ddiff --git a/test/oauth-extension.test.js b/test/oauth-extension.test.js
iindex fe0bbe02..5a3d144a 100644
---- a/test/oauth-extension.test.js
++++ b/test/oauth-extension.test.js
@@@ -100,11 +100,11 @@ const challenge = (v) => b64(crypto.createHash('sha256').update(v).digest());
// Code -> token with no secret.
const codeVerifier = verifier();
const state = 'state_' + crypto.randomBytes(12).toString('hex');
-- const start = await authorize({ client_id: 'companion', redirect_uri: CHROME, response_type: 'code', scope: 'dock activity chat.read', state, code_challenge: challenge(codeVerifier), code_challenge_method: 'S256' });
++ const start = await authorize({ client_id: 'companion', redirect_uri: CHROME, response_type: 'code', scope: 'dock activity chat.read agent.view', state, code_challenge: challenge(codeVerifier), code_challenge_method: 'S256' });
assert.strictEqual(start.status, 302);
assert.match(start.location, /\/login\?/);
assert.match(start.location, /client_name=OpenVibe\+extension/);
-- const cx = await confirm({ client_id: 'companion', redirect_uri: CHROME, response_type: 'code', scope: 'dock activity chat.read', state, code_challenge: challenge(codeVerifier), code_challenge_method: 'S256' });
++ const cx = await confirm({ client_id: 'companion', redirect_uri: CHROME, response_type: 'code', scope: 'dock activity chat.read agent.view', state, code_challenge: challenge(codeVerifier), code_challenge_method: 'S256' });
assert.strictEqual(cx.status, 200, JSON.stringify(cx.body));
const redirect = new URL(cx.body.redirect);
assert.strictEqual(redirect.searchParams.get('state'), state);
@@@ -113,20 +113,26 @@ const challenge = (v) => b64(crypto.createHash('sha256').update(v).digest());
const noSecret = await token({ grant_type: 'authorization_code', client_id: 'companion', code, redirect_uri: CHROME, code_verifier: codeVerifier });
assert.strictEqual(noSecret.status, 200, JSON.stringify(noSecret.body));
assert.strictEqual(noSecret.body.expires_in, 3600);
-- assert.strictEqual(noSecret.body.scope, 'dock activity chat.read');
++ assert.strictEqual(noSecret.body.scope, 'dock activity chat.read agent.view');
const claims = jwt.decode(noSecret.body.access_token);
assert.strictEqual(claims.iss, `${ISSUER}/dock`);
assert.deepStrictEqual(claims.aud, ['openvibe.network', 'openvibe.chat']);
-- assert.deepStrictEqual([claims.client_id, claims.scope, claims.role, claims.staff_caps], ['companion', 'dock activity chat.read', undefined, undefined]);
++ assert.deepStrictEqual([claims.client_id, claims.scope, claims.role, claims.staff_caps], ['companion', 'dock activity chat.read agent.view', undefined, undefined]);
assert.match(claims.fam, /^fam_[0-9a-f]{24}$/);
assert.ok(noSecret.body.refresh_token);
++ // The agent view's scope is Companion's alone: another public client may not ask for it.
++ await db.prepare('INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party, is_public, allowed_scopes, access_token_ttl_s) VALUES (?, NULL, ?, ?, 1, 1, ?, 3600)')
++ .run('companion-other', 'Other extension', JSON.stringify(['https://other.example/cb']), 'dock activity chat.read');
++ assert.strictEqual((await authorize({ client_id: 'companion-other', redirect_uri: 'https://other.example/cb', response_type: 'code', state: 'o', scope: 'dock', code_challenge: challenge(verifier()), code_challenge_method: 'S256' })).status, 302, 'its own ceiling still works');
++ assert.strictEqual((await authorize({ client_id: 'companion-other', redirect_uri: 'https://other.example/cb', response_type: 'code', state: 'o', scope: 'agent.view', code_challenge: challenge(verifier()), code_challenge_method: 'S256' })).status, 400, 'agent.view is not its scope');
++
// The Dock accepts it; a normal account route refuses it. Activity reads are open, action
// writes are not, and a token that only has `dock` is refused the activity route.
assert.strictEqual((await api('GET', '/api/v1/me/dock', noSecret.body.access_token)).status, 200);
assert.strictEqual((await api('GET', '/api/v1/me/private', noSecret.body.access_token)).status, 401, 'not a normal session');
assert.strictEqual((await api('GET', '/api/v1/me/private', normal)).status, 200, 'a normal session still works');
-- assert.deepStrictEqual((await api('GET', '/api/v1/me/actions', noSecret.body.access_token)).body.scope, ['dock', 'activity', 'chat.read']);
++ assert.deepStrictEqual((await api('GET', '/api/v1/me/actions', noSecret.body.access_token)).body.scope, ['dock', 'activity', 'chat.read', 'agent.view']);
assert.strictEqual((await api('POST', '/api/v1/me/actions', noSecret.body.access_token)).status, 401, 'approve/decline is not a Dock-token route');
const dockOnly = jwt.sign({
sub: 1, id: 1, username: 'dockana', client_id: 'companion', scope: 'dock', fam: claims.fam,