openvibers/openvibe.codes Commits
Agent view: the Dock token may read, and nothing else (#301)
Changes
@@@ -29,6 +29,14 @@ const ACCESS_COOKIE = 'codes_at';const REFRESH_COOKIE = 'codes_rt';const HINT_COOKIE = 'ov_sso_hint';++// The Dock's private-issuer token (Network server/auth/dock-tokens.js): the Companion extension's one-click++// sign-in. It is never a session here — only the agent view's two read routes opt in, and only for `agent.view`.++const DOCK_CLIENT_ID = 'companion';++const DOCK_AUDIENCE = 'openvibe.network';++const DOCK_SCOPE = 'agent.view';++const DOCK_FAMILY_RE = /^fam_[0-9a-f]{24}$/;++const DOCK_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++function pkcePair() {const verifier = crypto.randomBytes(32).toString('base64url');const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');@@@ -151,6 +159,28 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat};}++ /**++ * The Dock token, verified exactly as Network's own Dock guard verifies it (signature against Network's keys,++ * the private `<network issuer>/dock` issuer, audience openvibe.network) and then its claim shape: no typed++ * token, no role or staff caps, client `companion`, a refresh family, and a canonical person. Only a token++ * that names the scope `agent.view` is a reader; the rest is a 403, and anything that does not verify is a 401.++ * Codes cannot see Network's oauth_tokens table, so a revoked access token is refused by its expiry here.++ */++ async function dockViewer(token) {++ if (!token) return { status: 401, code: 'auth.required' };++ const issuer = `${String(config.networkIssuer || config.networkUrl).replace(/\/+$/, '')}/dock`;++ const v = await keys.verifyUser(token, { issuer, audience: DOCK_AUDIENCE });++ if (!v.ok) return { status: 401, code: 'auth.required', reason: v.reason };++ const claims = v.claims;++ const shaped = claims.typ === undefined && claims.purpose === undefined && claims.actor_type === undefined++ && claims.client_id === DOCK_CLIENT_ID && claims.role === undefined && claims.staff_caps === undefined++ && DOCK_FAMILY_RE.test(String(claims.fam || '')) && DOCK_SUBJECT_RE.test(String(claims.subject_id || ''));++ if (!shaped) return { status: 401, code: 'auth.required' };++ const scopes = String(claims.scope || '').split(/\s+/).filter(Boolean);++ if (!scopes.includes(DOCK_SCOPE)) return { status: 403, code: 'auth.scope', scopes };++ return { viewer: { ...viewerFrom(claims, token), dock: true }, token, scopes };++ }++/** A call with the person's own token; a 401 refreshes the session once and tries again. */async function asViewer(req, res, fn) {const token = req.token || (req.viewer && req.viewer.token) || null;@@@ -226,7 +256,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Datreturn r;}-- return { middleware, routes, asViewer, tokenGrant: (fields) => sso.tokenGrant(fields), pkcePair, client: sso, checkClient: (o) => sso.checkClient(o) };++ return { middleware, routes, asViewer, dockViewer, tokenGrant: (fields) => sso.tokenGrant(fields), pkcePair, client: sso, checkClient: (o) => sso.checkClient(o) };}module.exports = { createSso, pkcePair, sanitizeNext, ownOrigins, authorizeUrl, ACCESS_COOKIE, REFRESH_COOKIE };@@@ -35,6 +35,10 @@ function createAgentView({ config, sso, actorLimits, fetchImpl = (...a) => fetchconst json = express.json({ limit: '4kb' });const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });const signedIn = (req) => !!(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);++ const bearerOf = (req) => {++ const h = String(req.get('authorization') || '');++ return h.startsWith('Bearer ') ? h.slice(7).trim() : '';++ };const liveBase = () => String((config.live && config.live.url) || 'https://openvibe.live').replace(/\/+$/, '');/** One call to Live with the person's own token. Its own deadline: the Dock must not hang on a slow Live. */@@@ -143,10 +147,46 @@ function createAgentView({ config, sso, actorLimits, fetchImpl = (...a) => fetchreturn next();});-- r.get('/me/agent-view', actorLimits.reads('codes.read'), async (req, res) => {-- if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to see your agent');++ // ── The Dock's read-only token (Companion's one-click sign-in) ──++ // The panel holds a Network token with the private `<network>/dock` issuer, which the SSO middleware rightly++ // refuses as a session. The two GET routes below are the only ones that accept it, and only with `agent.view`;++ // the person it names is the caller, with read rights. Every POST stays the owner's own session.++ const dockReader = async (req, res, next) => {++ if (signedIn(req)) return next();++ const v = await sso.dockViewer(bearerOf(req));++ if (v.viewer) {++ req.viewer = v.viewer; // the person, for the actor limit and the handlers++ req.dock = true;++ } else if (v.status === 403) {++ req.dockReadDenied = v; // a verified Dock token without agent.view++ }++ return next();++ };++++ // A Dock token on a write is a 403 with the way to do it, never a 401: the panel is signed in, it is just read-only.++ const dockWrite = async (req, res, next) => {++ if (signedIn(req)) return next();++ const v = await sso.dockViewer(bearerOf(req));++ if (v.viewer || v.status === 403) {++ return problem(req, res, 403, 'auth.read_only', 'the side panel shows the agent; its buttons open openvibe.codes');++ }++ return next();++ };++++ /** The read routes' answer for a caller who is neither a session nor a Dock reader; true when it answered. */++ const refusedRead = (req, res) => {++ if (req.dockReadDenied) return !!problem(req, res, 403, 'auth.scope', 'this sign-in is not allowed to see your agent');++ if (!signedIn(req)) return !!problem(req, res, 401, 'auth.required', 'sign in to see your agent');++ return false;++ };++++ /** The person's token to Live: the session's (refreshed once on a 401), or the Dock token as it is. */++ const withViewer = (req, res, fn) => (req.dock ? fn(req.viewer.token) : sso.asViewer(req, res, fn));++++ r.get('/me/agent-view', dockReader, actorLimits.reads('codes.read'), async (req, res) => {++ if (refusedRead(req, res)) return undefined;try {-- const agent = await sso.asViewer(req, res, (token) => viewOf(token));++ const agent = await withViewer(req, res, (token) => viewOf(token));return res.json({ agent });} catch (e) {if (e && e.status === 401) return problem(req, res, 401, 'auth.required', 'sign in to see your agent');@@@ -156,11 +196,11 @@ function createAgentView({ config, sso, actorLimits, fetchImpl = (...a) => fetch});// The last frame: Live serves it to the owner only, so Codes asks with their token and passes the picture on.-- r.get('/me/agent-view/frame', actorLimits.reads('codes.read'), async (req, res) => {-- if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to see your agent');++ r.get('/me/agent-view/frame', dockReader, actorLimits.reads('codes.read'), async (req, res) => {++ if (refusedRead(req, res)) return undefined;let frame = null;try {-- frame = await sso.asViewer(req, res, async (token) => {++ frame = await withViewer(req, res, async (token) => {const row = await streamOf(token);if (!row || !row.last_frame_url) return null;// Built here from the stream's id and our own Live base: Codes never fetches an address Live names.@@@ -224,10 +264,10 @@ function createAgentView({ config, sso, actorLimits, fetchImpl = (...a) => fetch}};-- r.post('/me/agent-view/pause', actorLimits.budget('codes.agent_view.control'), json, controlWithBody('pause', actControl('pause', (req) => ({ control: 'pause', paused: !!(req.body && req.body.paused) }))));-- r.post('/me/agent-view/curtain', actorLimits.budget('codes.agent_view.control'), json, control('curtain', act('/curtain', { up: true, reason: 'manual' })));-- r.post('/me/agent-view/mute', actorLimits.budget('codes.agent_view.control'), json, controlWithBody('mute', actControl('mute', (req) => ({ control: 'mute', muted: !!(req.body && req.body.muted) }))));-- r.post('/me/agent-view/end', actorLimits.budget('codes.agent_view.control'), json, control('end', act('/end', {})));++ r.post('/me/agent-view/pause', dockWrite, actorLimits.budget('codes.agent_view.control'), json, controlWithBody('pause', actControl('pause', (req) => ({ control: 'pause', paused: !!(req.body && req.body.paused) }))));++ r.post('/me/agent-view/curtain', dockWrite, actorLimits.budget('codes.agent_view.control'), json, control('curtain', act('/curtain', { up: true, reason: 'manual' })));++ r.post('/me/agent-view/mute', dockWrite, actorLimits.budget('codes.agent_view.control'), json, controlWithBody('mute', actControl('mute', (req) => ({ control: 'mute', muted: !!(req.body && req.body.muted) }))));++ r.post('/me/agent-view/end', dockWrite, actorLimits.budget('codes.agent_view.control'), json, control('end', act('/end', {})));return r;}@@@ -155,6 +155,56 @@ const STREAM = (o = {}) => ({assert.strictEqual(res.json().code, 'agent_view.unavailable');live.fail = false;});++++ // ── The Dock token (Companion's one-click sign-in) ─────────++ // Network's Dock issuer and keys, the `agent.view` scope, the person it names: the two read routes++ // accept it. Every write stays the owner's own session.++++ await check('a Dock token with agent.view reads the view and the still, as that person', async () => {++ live.streams = [STREAM({ last_frame_url: 'https://openvibe.live/api/streams/agent/' + LIVE_ID + '/frame' })];++ live.authorizations.length = 0;++ const token = t.network.dockToken(ada);++ const res = await t.get('/api/v1/me/agent-view', { bearer: token });++ assert.strictEqual(res.status, 200, res.text);++ const a = res.json().agent;++ assert.strictEqual(a.id, LIVE_ID);++ assert.strictEqual(a.state, 'live');++ assert.strictEqual(a.embed_url, `https://openvibe.live/embed/agent/${LIVE_ID}?vt=wvt_the_view_ticket`);++ for (const auth of live.authorizations) assert.strictEqual(auth, `Bearer ${token}`, 'the Dock token is the person\'s own bearer to Live');++++ const frame = await t.get('/api/v1/me/agent-view/frame', { bearer: token });++ assert.strictEqual(frame.status, 200, frame.text);++ assert.deepStrictEqual([...frame.buffer], [...FRAME]);++ });++++ await check('a Dock token without agent.view is refused the read', async () => {++ const res = await t.get('/api/v1/me/agent-view', { bearer: t.network.dockToken(ada, { scope: 'dock activity chat.read' }) });++ assert.strictEqual(res.status, 403, res.text);++ assert.strictEqual(res.json().code, 'auth.scope');++ const frame = await t.get('/api/v1/me/agent-view/frame', { bearer: t.network.dockToken(ada, { scope: 'dock' }) });++ assert.strictEqual(frame.status, 403, frame.text);++ });++++ await check('a forged or expired Dock token is a 401', async () => {++ const good = t.network.dockToken(ada);++ const [h, p] = good.split('.');++ const forged = `${h}.${p}.${'A'.repeat(342)}`;++ assert.strictEqual((await t.get('/api/v1/me/agent-view', { bearer: forged })).status, 401);++ assert.strictEqual((await t.get('/api/v1/me/agent-view', { bearer: t.network.dockToken(ada, { ttl: -120 }) })).status, 401);++ const notDock = t.network.userToken(ada);++ assert.strictEqual((await t.get('/api/v1/me/agent-view', { bearer: notDock })).status, 200, 'a normal session still reads');++ });++++ await check('every write route refuses a Dock token with a plain reason', async () => {++ const before = calls.length;++ for (const what of ['pause', 'curtain', 'mute', 'end']) {++ const res = await t.get(`/api/v1/me/agent-view/${what}`, { bearer: t.network.dockToken(ada), method: 'POST', json: {} });++ assert.strictEqual(res.status, 403, `${what}: ${res.text}`);++ assert.strictEqual(res.json().code, 'auth.read_only', what);++ assert.match(res.json().detail, /openvibe\.codes/, what);++ }++ assert.strictEqual(calls.length, before, 'no control reached Live');++ });} finally {await t.close();server.close();@@@ -73,6 +73,17 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.mconst now = iat != null ? iat : Math.floor(Date.now() / 1000);return signJwt({ iss: issuer, sub: u.id, subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role, aud: audience || ['openvibe.network'], iat: now, exp: now + ttl }, privatePem);}++ /**++ * A Dock token exactly as Network mints one for the public client `companion`: the private `<network>/dock`++ * issuer, audience openvibe.network, client_id, scope and refresh family, and no role or staff caps.++ */++ function dockToken(u, { ttl = 3600, iat = null, scope = 'agent.view', clientId = 'companion', subject = u.subject, fam = 'fam_0123456789abcdef01234567' } = {}) {++ const now = iat != null ? iat : Math.floor(Date.now() / 1000);++ return signJwt({++ iss: `${issuer}/dock`, sub: u.id, id: u.id, subject_id: subject, username: u.username, display_name: u.display_name,++ client_id: clientId, scope, fam, aud: ['openvibe.network', 'openvibe.chat'], iat: now, exp: now + ttl,++ }, privatePem);++ }function userFrom(req) {const h = String(req.headers.authorization || '');if (!h.startsWith('Bearer ')) return null;@@@ -398,7 +409,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.mreturn {url: srv.url, publicPem, privatePem, state: st, requests: st.requests, tokenRequests: st.tokenRequests,-- addUser, userToken,++ addUser, userToken, dockToken,/** An authorization code the mock will exchange for this user if the verifier matches. */issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },setAllowance(projectId, caps) { st.projects.get(projectId).allowance = caps; },
@@ -29,6 +29,14 @@ const ACCESS_COOKIE = 'codes_at';
@@ -29,6 +29,14 @@ const ACCESS_COOKIE = 'codes_at';
const REFRESH_COOKIE = 'codes_rt';
const REFRESH_COOKIE = 'codes_rt';
const HINT_COOKIE = 'ov_sso_hint';
const HINT_COOKIE = 'ov_sso_hint';
+// The Dock's private-issuer token (Network server/auth/dock-tokens.js): the Companion extension's one-click
+// sign-in. It is never a session here — only the agent view's two read routes opt in, and only for `agent.view`.
+const DOCK_CLIENT_ID = 'companion';
+const DOCK_AUDIENCE = 'openvibe.network';
+const DOCK_SCOPE = 'agent.view';
+const DOCK_FAMILY_RE = /^fam_[0-9a-f]{24}$/;
+const DOCK_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+
function pkcePair() {
function pkcePair() {
const verifier = crypto.randomBytes(32).toString('base64url');
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
@@ -151,6 +159,28 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
@@ -151,6 +159,28 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
};
};
}
}
+ /**
+ * The Dock token, verified exactly as Network's own Dock guard verifies it (signature against Network's keys,
+ * the private `<network issuer>/dock` issuer, audience openvibe.network) and then its claim shape: no typed
+ * token, no role or staff caps, client `companion`, a refresh family, and a canonical person. Only a token
+ * that names the scope `agent.view` is a reader; the rest is a 403, and anything that does not verify is a 401.
+ * Codes cannot see Network's oauth_tokens table, so a revoked access token is refused by its expiry here.
+ */
+ async function dockViewer(token) {
+ if (!token) return { status: 401, code: 'auth.required' };
+ const issuer = `${String(config.networkIssuer || config.networkUrl).replace(/\/+$/, '')}/dock`;
+ const v = await keys.verifyUser(token, { issuer, audience: DOCK_AUDIENCE });
+ if (!v.ok) return { status: 401, code: 'auth.required', reason: v.reason };
+ const claims = v.claims;
+ const shaped = claims.typ === undefined && claims.purpose === undefined && claims.actor_type === undefined
+ && claims.client_id === DOCK_CLIENT_ID && claims.role === undefined && claims.staff_caps === undefined
+ && DOCK_FAMILY_RE.test(String(claims.fam || '')) && DOCK_SUBJECT_RE.test(String(claims.subject_id || ''));
+ if (!shaped) return { status: 401, code: 'auth.required' };
+ const scopes = String(claims.scope || '').split(/\s+/).filter(Boolean);
+ if (!scopes.includes(DOCK_SCOPE)) return { status: 403, code: 'auth.scope', scopes };
+ return { viewer: { ...viewerFrom(claims, token), dock: true }, token, scopes };
+ }
+
/** A call with the person's own token; a 401 refreshes the session once and tries again. */
/** A call with the person's own token; a 401 refreshes the session once and tries again. */
async function asViewer(req, res, fn) {
async function asViewer(req, res, fn) {
const token = req.token || (req.viewer && req.viewer.token) || null;
const token = req.token || (req.viewer && req.viewer.token) || null;
@@ -226,7 +256,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
@@ -226,7 +256,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
return r;
return r;
}
}
- return { middleware, routes, asViewer, tokenGrant: (fields) => sso.tokenGrant(fields), pkcePair, client: sso, checkClient: (o) => sso.checkClient(o) };
+ return { middleware, routes, asViewer, dockViewer, tokenGrant: (fields) => sso.tokenGrant(fields), pkcePair, client: sso, checkClient: (o) => sso.checkClient(o) };
}
}
module.exports = { createSso, pkcePair, sanitizeNext, ownOrigins, authorizeUrl, ACCESS_COOKIE, REFRESH_COOKIE };
module.exports = { createSso, pkcePair, sanitizeNext, ownOrigins, authorizeUrl, ACCESS_COOKIE, REFRESH_COOKIE };
@@ -35,6 +35,10 @@ function createAgentView({ config, sso, actorLimits, fetchImpl = (...a) => fetch
@@ -35,6 +35,10 @@ function createAgentView({ config, sso, actorLimits, fetchImpl = (...a) => fetch
const json = express.json({ limit: '4kb' });
const json = express.json({ limit: '4kb' });
const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
const signedIn = (req) => !!(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
const signedIn = (req) => !!(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
+ const bearerOf = (req) => {
+ const h = String(req.get('authorization') || '');
+ return h.startsWith('Bearer ') ? h.slice(7).trim() : '';
+ };
const liveBase = () => String((config.live && config.live.url) || 'https://openvibe.live').replace(/\/+$/, '');
const liveBase = () => String((config.live && config.live.url) || 'https://openvibe.live').replace(/\/+$/, '');
/** One call to Live with the person's own token. Its own deadline: the Dock must not hang on a slow Live. */
/** One call to Live with the person's own token. Its own deadline: the Dock must not hang on a slow Live. */
@@ -143,10 +147,46 @@ function createAgentView({ config, sso, actorLimits, fetchImpl = (...a) => fetch
@@ -143,10 +147,46 @@ function createAgentView({ config, sso, actorLimits, fetchImpl = (...a) => fetch
return next();
return next();
});
});
- r.get('/me/agent-view', actorLimits.reads('codes.read'), async (req, res) => {
- if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to see your agent');
+ // ── The Dock's read-only token (Companion's one-click sign-in) ──
+ // The panel holds a Network token with the private `<network>/dock` issuer, which the SSO middleware rightly
+ // refuses as a session. The two GET routes below are the only ones that accept it, and only with `agent.view`;
+ // the person it names is the caller, with read rights. Every POST stays the owner's own session.
+ const dockReader = async (req, res, next) => {
+ if (signedIn(req)) return next();
+ const v = await sso.dockViewer(bearerOf(req));
+ if (v.viewer) {
+ req.viewer = v.viewer; // the person, for the actor limit and the handlers
+ req.dock = true;
+ } else if (v.status === 403) {
+ req.dockReadDenied = v; // a verified Dock token without agent.view
+ }
+ return next();
+ };
+
+ // A Dock token on a write is a 403 with the way to do it, never a 401: the panel is signed in, it is just read-only.
+ const dockWrite = async (req, res, next) => {
+ if (signedIn(req)) return next();
+ const v = await sso.dockViewer(bearerOf(req));
+ if (v.viewer || v.status === 403) {
+ return problem(req, res, 403, 'auth.read_only', 'the side panel shows the agent; its buttons open openvibe.codes');
+ }
+ return next();
+ };
+
+ /** The read routes' answer for a caller who is neither a session nor a Dock reader; true when it answered. */
+ const refusedRead = (req, res) => {
+ if (req.dockReadDenied) return !!problem(req, res, 403, 'auth.scope', 'this sign-in is not allowed to see your agent');
+ if (!signedIn(req)) return !!problem(req, res, 401, 'auth.required', 'sign in to see your agent');
+ return false;
+ };
+
+ /** The person's token to Live: the session's (refreshed once on a 401), or the Dock token as it is. */
+ const withViewer = (req, res, fn) => (req.dock ? fn(req.viewer.token) : sso.asViewer(req, res, fn));
+
+ r.get('/me/agent-view', dockReader, actorLimits.reads('codes.read'), async (req, res) => {
+ if (refusedRead(req, res)) return undefined;
try {
try {
- const agent = await sso.asViewer(req, res, (token) => viewOf(token));
+ const agent = await withViewer(req, res, (token) => viewOf(token));
return res.json({ agent });
return res.json({ agent });
} catch (e) {
} catch (e) {
if (e && e.status === 401) return problem(req, res, 401, 'auth.required', 'sign in to see your agent');
if (e && e.status === 401) return problem(req, res, 401, 'auth.required', 'sign in to see your agent');
@@ -156,11 +196,11 @@ function createAgentView({ config, sso, actorLimits, fetchImpl = (...a) => fetch
@@ -156,11 +196,11 @@ function createAgentView({ config, sso, actorLimits, fetchImpl = (...a) => fetch
});
});
// The last frame: Live serves it to the owner only, so Codes asks with their token and passes the picture on.
// The last frame: Live serves it to the owner only, so Codes asks with their token and passes the picture on.
- r.get('/me/agent-view/frame', actorLimits.reads('codes.read'), async (req, res) => {
- if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to see your agent');
+ r.get('/me/agent-view/frame', dockReader, actorLimits.reads('codes.read'), async (req, res) => {
+ if (refusedRead(req, res)) return undefined;
let frame = null;
let frame = null;
try {
try {
- frame = await sso.asViewer(req, res, async (token) => {
+ frame = await withViewer(req, res, async (token) => {
const row = await streamOf(token);
const row = await streamOf(token);
if (!row || !row.last_frame_url) return null;
if (!row || !row.last_frame_url) return null;
// Built here from the stream's id and our own Live base: Codes never fetches an address Live names.
// Built here from the stream's id and our own Live base: Codes never fetches an address Live names.
@@ -224,10 +264,10 @@ function createAgentView({ config, sso, actorLimits, fetchImpl = (...a) => fetch
@@ -224,10 +264,10 @@ function createAgentView({ config, sso, actorLimits, fetchImpl = (...a) => fetch
}
}
};
};
- r.post('/me/agent-view/pause', actorLimits.budget('codes.agent_view.control'), json, controlWithBody('pause', actControl('pause', (req) => ({ control: 'pause', paused: !!(req.body && req.body.paused) }))));
- r.post('/me/agent-view/curtain', actorLimits.budget('codes.agent_view.control'), json, control('curtain', act('/curtain', { up: true, reason: 'manual' })));
- r.post('/me/agent-view/mute', actorLimits.budget('codes.agent_view.control'), json, controlWithBody('mute', actControl('mute', (req) => ({ control: 'mute', muted: !!(req.body && req.body.muted) }))));
- r.post('/me/agent-view/end', actorLimits.budget('codes.agent_view.control'), json, control('end', act('/end', {})));
+ r.post('/me/agent-view/pause', dockWrite, actorLimits.budget('codes.agent_view.control'), json, controlWithBody('pause', actControl('pause', (req) => ({ control: 'pause', paused: !!(req.body && req.body.paused) }))));
+ r.post('/me/agent-view/curtain', dockWrite, actorLimits.budget('codes.agent_view.control'), json, control('curtain', act('/curtain', { up: true, reason: 'manual' })));
+ r.post('/me/agent-view/mute', dockWrite, actorLimits.budget('codes.agent_view.control'), json, controlWithBody('mute', actControl('mute', (req) => ({ control: 'mute', muted: !!(req.body && req.body.muted) }))));
+ r.post('/me/agent-view/end', dockWrite, actorLimits.budget('codes.agent_view.control'), json, control('end', act('/end', {})));
return r;
return r;
}
}
@@ -155,6 +155,56 @@ const STREAM = (o = {}) => ({
@@ -155,6 +155,56 @@ const STREAM = (o = {}) => ({
assert.strictEqual(res.json().code, 'agent_view.unavailable');
assert.strictEqual(res.json().code, 'agent_view.unavailable');
live.fail = false;
live.fail = false;
});
});
+
+ // ── The Dock token (Companion's one-click sign-in) ─────────
+ // Network's Dock issuer and keys, the `agent.view` scope, the person it names: the two read routes
+ // accept it. Every write stays the owner's own session.
+
+ await check('a Dock token with agent.view reads the view and the still, as that person', async () => {
+ live.streams = [STREAM({ last_frame_url: 'https://openvibe.live/api/streams/agent/' + LIVE_ID + '/frame' })];
+ live.authorizations.length = 0;
+ const token = t.network.dockToken(ada);
+ const res = await t.get('/api/v1/me/agent-view', { bearer: token });
+ assert.strictEqual(res.status, 200, res.text);
+ const a = res.json().agent;
+ assert.strictEqual(a.id, LIVE_ID);
+ assert.strictEqual(a.state, 'live');
+ assert.strictEqual(a.embed_url, `https://openvibe.live/embed/agent/${LIVE_ID}?vt=wvt_the_view_ticket`);
+ for (const auth of live.authorizations) assert.strictEqual(auth, `Bearer ${token}`, 'the Dock token is the person\'s own bearer to Live');
+
+ const frame = await t.get('/api/v1/me/agent-view/frame', { bearer: token });
+ assert.strictEqual(frame.status, 200, frame.text);
+ assert.deepStrictEqual([...frame.buffer], [...FRAME]);
+ });
+
+ await check('a Dock token without agent.view is refused the read', async () => {
+ const res = await t.get('/api/v1/me/agent-view', { bearer: t.network.dockToken(ada, { scope: 'dock activity chat.read' }) });
+ assert.strictEqual(res.status, 403, res.text);
+ assert.strictEqual(res.json().code, 'auth.scope');
+ const frame = await t.get('/api/v1/me/agent-view/frame', { bearer: t.network.dockToken(ada, { scope: 'dock' }) });
+ assert.strictEqual(frame.status, 403, frame.text);
+ });
+
+ await check('a forged or expired Dock token is a 401', async () => {
+ const good = t.network.dockToken(ada);
+ const [h, p] = good.split('.');
+ const forged = `${h}.${p}.${'A'.repeat(342)}`;
+ assert.strictEqual((await t.get('/api/v1/me/agent-view', { bearer: forged })).status, 401);
+ assert.strictEqual((await t.get('/api/v1/me/agent-view', { bearer: t.network.dockToken(ada, { ttl: -120 }) })).status, 401);
+ const notDock = t.network.userToken(ada);
+ assert.strictEqual((await t.get('/api/v1/me/agent-view', { bearer: notDock })).status, 200, 'a normal session still reads');
+ });
+
+ await check('every write route refuses a Dock token with a plain reason', async () => {
+ const before = calls.length;
+ for (const what of ['pause', 'curtain', 'mute', 'end']) {
+ const res = await t.get(`/api/v1/me/agent-view/${what}`, { bearer: t.network.dockToken(ada), method: 'POST', json: {} });
+ assert.strictEqual(res.status, 403, `${what}: ${res.text}`);
+ assert.strictEqual(res.json().code, 'auth.read_only', what);
+ assert.match(res.json().detail, /openvibe\.codes/, what);
+ }
+ assert.strictEqual(calls.length, before, 'no control reached Live');
+ });
} finally {
} finally {
await t.close();
await t.close();
server.close();
server.close();
@@ -73,6 +73,17 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -73,6 +73,17 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
const now = iat != null ? iat : Math.floor(Date.now() / 1000);
const now = iat != null ? iat : Math.floor(Date.now() / 1000);
return signJwt({ iss: issuer, sub: u.id, subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role, aud: audience || ['openvibe.network'], iat: now, exp: now + ttl }, privatePem);
return signJwt({ iss: issuer, sub: u.id, subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role, aud: audience || ['openvibe.network'], iat: now, exp: now + ttl }, privatePem);
}
}
+ /**
+ * A Dock token exactly as Network mints one for the public client `companion`: the private `<network>/dock`
+ * issuer, audience openvibe.network, client_id, scope and refresh family, and no role or staff caps.
+ */
+ function dockToken(u, { ttl = 3600, iat = null, scope = 'agent.view', clientId = 'companion', subject = u.subject, fam = 'fam_0123456789abcdef01234567' } = {}) {
+ const now = iat != null ? iat : Math.floor(Date.now() / 1000);
+ return signJwt({
+ iss: `${issuer}/dock`, sub: u.id, id: u.id, subject_id: subject, username: u.username, display_name: u.display_name,
+ client_id: clientId, scope, fam, aud: ['openvibe.network', 'openvibe.chat'], iat: now, exp: now + ttl,
+ }, privatePem);
+ }
function userFrom(req) {
function userFrom(req) {
const h = String(req.headers.authorization || '');
const h = String(req.headers.authorization || '');
if (!h.startsWith('Bearer ')) return null;
if (!h.startsWith('Bearer ')) return null;
@@ -398,7 +409,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -398,7 +409,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
return {
return {
url: srv.url, publicPem, privatePem, state: st, requests: st.requests, tokenRequests: st.tokenRequests,
url: srv.url, publicPem, privatePem, state: st, requests: st.requests, tokenRequests: st.tokenRequests,
- addUser, userToken,
+ addUser, userToken, dockToken,
/** An authorization code the mock will exchange for this user if the verifier matches. */
/** An authorization code the mock will exchange for this user if the verifier matches. */
issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },
issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },
setAllowance(projectId, caps) { st.projects.get(projectId).allowance = caps; },
setAllowance(projectId, caps) { st.projects.get(projectId).allowance = caps; },