openvibers/openvibe.live Commits
Vibe-coding publication needs the narrow vibe_coding_publish scope: /ws/vibe-coding/publish (publish-server.js) and the /api/vibe-coding writes (auth.js token scope table) no longer accept the broad stream scope, which let any stream-control token (live state, restreams, VODs, clips) publish a coding feed. Tokens made with the GitHub Copilot Companion preset (read + vibe_coding_publish) keep working; session sign-in is unaffected. Moving publication onto Network project credentials, surfaced in OpenVibe.Codes, is later work. docs/vibe-coding.md and docs/api-tokens.md say so; test/vibe-coding-scope.test.js connects to the real endpoint with real hbt_ tokens (stream-only closed with 4403, publisher ready) and checks the REST scope table
Changes
@@@ -68,7 +68,7 @@ curl https://openvibe.live/api/streams \### Vibe Coding Publisher--For the OpenVibe.Live VS Code companion or any other coding-feed publisher, use a token with `vibe_coding_publish` scope instead of broader stream control when possible.++For the OpenVibe.Live VS Code companion or any other coding-feed publisher, use a token with the `vibe_coding_publish` scope. It is the only scope `/ws/vibe-coding/publish` and the `/api/vibe-coding` writes accept; a `stream` token is refused there.### WebSocket (Chat)@@@ -45,7 +45,7 @@ Streamers publish live coding activity over a dedicated WebSocket endpoint:GET /ws/vibe-coding/publish?token=hbt_xxx&managedStreamId=123&slotSlug=camp-code```--Authentication accepts JWTs or `hbt_` API tokens. API tokens should include the dedicated `vibe_coding_publish` scope. The legacy `stream` scope is still accepted for backward compatibility.++Authentication accepts JWTs or `hbt_` API tokens. An API token needs the dedicated `vibe_coding_publish` scope (the Dashboard's **GitHub Copilot Companion** preset); the broad `stream` scope is not accepted any more (close code `4403`). The same holds for the REST settings routes under `/api/vibe-coding`.### Server messages@@@ -213,7 +213,7 @@ const TOKEN_WRITE_SCOPES = [['/api/streams', ['stream']], ['/api/restream', ['stream']], ['/api/robotstreamer', ['stream']], ['/api/thumbnails', ['stream']],['/api/vods', ['stream']], ['/api/clips', ['stream']], ['/api/media', ['stream', 'chat']],['/api/controls', ['control']], ['/api/onvif', ['control']],-- ['/api/vibe-coding', ['vibe_coding_publish', 'stream']],++ ['/api/vibe-coding', ['vibe_coding_publish']], // not the broad `stream` scope];function apiTokenAllows(req, scopes) {const path = String(req.originalUrl || req.url || '').split('?')[0];@@@ -5,7 +5,11 @@ const WebSocket = require('ws');const { extractWsToken, authenticateWs } = require('../auth/auth');const vibeService = require('./service');--const VIBE_CODING_PUBLISH_SCOPES = new Set(['vibe_coding_publish', 'stream']);++// Only the narrow publisher scope. The broad `stream` scope (live state, restreams, VODs, clips)++// used to be accepted too, so any stream-control token could publish a coding feed; a publisher++// token needs vibe_coding_publish (the Dashboard's "GitHub Copilot Companion" preset has it).++// Moving publication onto Network project credentials (surfaced in OpenVibe.Codes) is later work.++const VIBE_CODING_PUBLISH_SCOPES = new Set(['vibe_coding_publish']);function hasVibeCodingPublishScope(user) {return !!(user && Array.isArray(user.scopes) && user.scopes.some((scope) => VIBE_CODING_PUBLISH_SCOPES.has(scope)));@@@ -192,4 +196,5 @@ class VibeCodingPublishServer {}}--module.exports = VibeCodingPublishServer;\\ No newline at end of file++module.exports = VibeCodingPublishServer;++module.exports.hasVibeCodingPublishScope = hasVibeCodingPublishScope;\\ No newline at end of filennew file mode 100644@@@ -0,0 +1,81 @@++/**++ * Vibe-coding publication needs the narrow `vibe_coding_publish` scope.++ *++ * /ws/vibe-coding/publish and the /api/vibe-coding writes used to accept the broad `stream` scope++ * too, so any stream-control token (live state, restreams, VODs, clips) could publish a coding feed.++ * Now an hbt_ token with `stream` alone is closed with 4403 / refused, and a token with++ * `vibe_coding_publish` (the "GitHub Copilot Companion" preset) keeps working. Session sign-in is++ * unaffected. (Moving publication to Network project credentials in OpenVibe.Codes is later work.)++ *++ * node test/vibe-coding-scope.test.js++ */++'use strict';++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-vibe-scope-'));++process.env.DB_PATH = path.join(tmp, 'live.db');++process.env.NODE_ENV = 'test';++const quiet = console.log;++console.log = () => {};++console.warn = () => {};++++const db = require('../server/db/database');++db.initDb();++db.getDb().prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role) VALUES (7, 'coder', 'coder', 'c@x', 'x', 'streamer')`).run();++const slot = Number(db.createManagedStream({ user_id: 7, slug: 'camp-code', title: 'code', stream_key: 'key-code' }).lastInsertRowid);++const narrow = db.createApiToken(7, 'Copilot Companion', ['read', 'vibe_coding_publish']).token;++const broad = db.createApiToken(7, 'Stream Controller', ['read', 'stream', 'control']).token;++++const auth = require('../server/auth/auth');++const WebSocket = require('ws');++const VibeCodingPublishServer = require('../server/vibe-coding/publish-server');++++/** Connect and return how the socket ended up: { code } when closed, or { ready } on vibe-coding.ready. */++function connect(port, token) {++ return new Promise((resolve) => {++ const ws = new WebSocket(`ws://127.0.0.1:${port}/ws/vibe-coding/publish?token=${token}&managedStreamId=${slot}&slotSlug=camp-code`);++ ws.on('message', (data) => {++ const msg = JSON.parse(String(data));++ if (msg.type === 'vibe-coding.ready') { ws.close(); resolve({ ready: true }); }++ });++ ws.on('close', (code) => resolve({ code }));++ ws.on('error', () => {});++ });++}++++(async () => {++ // Scope check itself.++ const { hasVibeCodingPublishScope } = VibeCodingPublishServer;++ assert.strictEqual(hasVibeCodingPublishScope({ scopes: ['vibe_coding_publish'] }), true);++ assert.strictEqual(hasVibeCodingPublishScope({ scopes: ['read', 'stream', 'control'] }), false, 'the broad stream scope is not a publisher scope');++++ // The real WebSocket endpoint with real hbt_ tokens.++ const pub = new VibeCodingPublishServer({ broadcastToStream() {} }, db);++ const server = http.createServer();++ pub.init(server);++ server.on('upgrade', (req, socket, head) => { if (!pub.handleUpgrade(req, socket, head)) socket.destroy(); });++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const port = server.address().port;++ assert.deepStrictEqual(await connect(port, broad), { code: 4403 }, 'a stream-scoped token is closed with 4403');++ assert.deepStrictEqual(await connect(port, narrow), { ready: true }, 'a vibe_coding_publish token still publishes');++ assert.deepStrictEqual(await connect(port, 'hbt_' + '0'.repeat(64)), { code: 4401 }, 'an unknown token is refused');++ server.close();++++ // REST: the /api/vibe-coding writes take only the narrow scope.++ const t = (method, url, scopes) => auth.apiTokenAllows({ method, originalUrl: url }, scopes);++ assert.strictEqual(t('PUT', `/api/vibe-coding/managed/${slot}/settings`, ['read', 'stream', 'control']), false, 'stream no longer writes vibe-coding settings');++ assert.strictEqual(t('PUT', `/api/vibe-coding/managed/${slot}/settings`, ['read', 'vibe_coding_publish']), true);++ assert.strictEqual(t('GET', `/api/vibe-coding/managed/${slot}/events`, ['read']), true, 'reads are unchanged');++ assert.strictEqual(t('POST', '/api/streams/managed', ['stream']), true, 'the stream scope still covers stream control');++++ fs.rmSync(tmp, { recursive: true, force: true });++ quiet('vibe-coding-scope: ok');++ process.exit(0);++})().catch((err) => {++ quiet(err);++ process.exit(1);++});
@@ -68,7 +68,7 @@ curl https://openvibe.live/api/streams \
@@ -68,7 +68,7 @@ curl https://openvibe.live/api/streams \
### Vibe Coding Publisher
### Vibe Coding Publisher
-For the OpenVibe.Live VS Code companion or any other coding-feed publisher, use a token with `vibe_coding_publish` scope instead of broader stream control when possible.
+For the OpenVibe.Live VS Code companion or any other coding-feed publisher, use a token with the `vibe_coding_publish` scope. It is the only scope `/ws/vibe-coding/publish` and the `/api/vibe-coding` writes accept; a `stream` token is refused there.
### WebSocket (Chat)
### WebSocket (Chat)
@@ -45,7 +45,7 @@ Streamers publish live coding activity over a dedicated WebSocket endpoint:
@@ -45,7 +45,7 @@ Streamers publish live coding activity over a dedicated WebSocket endpoint:
GET /ws/vibe-coding/publish?token=hbt_xxx&managedStreamId=123&slotSlug=camp-code
GET /ws/vibe-coding/publish?token=hbt_xxx&managedStreamId=123&slotSlug=camp-code
```
```
-Authentication accepts JWTs or `hbt_` API tokens. API tokens should include the dedicated `vibe_coding_publish` scope. The legacy `stream` scope is still accepted for backward compatibility.
+Authentication accepts JWTs or `hbt_` API tokens. An API token needs the dedicated `vibe_coding_publish` scope (the Dashboard's **GitHub Copilot Companion** preset); the broad `stream` scope is not accepted any more (close code `4403`). The same holds for the REST settings routes under `/api/vibe-coding`.
### Server messages
### Server messages
@@ -213,7 +213,7 @@ const TOKEN_WRITE_SCOPES = [
@@ -213,7 +213,7 @@ const TOKEN_WRITE_SCOPES = [
['/api/streams', ['stream']], ['/api/restream', ['stream']], ['/api/robotstreamer', ['stream']], ['/api/thumbnails', ['stream']],
['/api/streams', ['stream']], ['/api/restream', ['stream']], ['/api/robotstreamer', ['stream']], ['/api/thumbnails', ['stream']],
['/api/vods', ['stream']], ['/api/clips', ['stream']], ['/api/media', ['stream', 'chat']],
['/api/vods', ['stream']], ['/api/clips', ['stream']], ['/api/media', ['stream', 'chat']],
['/api/controls', ['control']], ['/api/onvif', ['control']],
['/api/controls', ['control']], ['/api/onvif', ['control']],
- ['/api/vibe-coding', ['vibe_coding_publish', 'stream']],
+ ['/api/vibe-coding', ['vibe_coding_publish']], // not the broad `stream` scope
];
];
function apiTokenAllows(req, scopes) {
function apiTokenAllows(req, scopes) {
const path = String(req.originalUrl || req.url || '').split('?')[0];
const path = String(req.originalUrl || req.url || '').split('?')[0];
@@ -5,7 +5,11 @@ const WebSocket = require('ws');
@@ -5,7 +5,11 @@ const WebSocket = require('ws');
const { extractWsToken, authenticateWs } = require('../auth/auth');
const { extractWsToken, authenticateWs } = require('../auth/auth');
const vibeService = require('./service');
const vibeService = require('./service');
-const VIBE_CODING_PUBLISH_SCOPES = new Set(['vibe_coding_publish', 'stream']);
+// Only the narrow publisher scope. The broad `stream` scope (live state, restreams, VODs, clips)
+// used to be accepted too, so any stream-control token could publish a coding feed; a publisher
+// token needs vibe_coding_publish (the Dashboard's "GitHub Copilot Companion" preset has it).
+// Moving publication onto Network project credentials (surfaced in OpenVibe.Codes) is later work.
+const VIBE_CODING_PUBLISH_SCOPES = new Set(['vibe_coding_publish']);
function hasVibeCodingPublishScope(user) {
function hasVibeCodingPublishScope(user) {
return !!(user && Array.isArray(user.scopes) && user.scopes.some((scope) => VIBE_CODING_PUBLISH_SCOPES.has(scope)));
return !!(user && Array.isArray(user.scopes) && user.scopes.some((scope) => VIBE_CODING_PUBLISH_SCOPES.has(scope)));
@@ -192,4 +196,5 @@ class VibeCodingPublishServer {
@@ -192,4 +196,5 @@ class VibeCodingPublishServer {
}
}
}
}
-module.exports = VibeCodingPublishServer;
\ No newline at end of file
\ No newline at end of file
+module.exports = VibeCodingPublishServer;
+module.exports.hasVibeCodingPublishScope = hasVibeCodingPublishScope;
\ No newline at end of file
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+/**
+ * Vibe-coding publication needs the narrow `vibe_coding_publish` scope.
+ *
+ * /ws/vibe-coding/publish and the /api/vibe-coding writes used to accept the broad `stream` scope
+ * too, so any stream-control token (live state, restreams, VODs, clips) could publish a coding feed.
+ * Now an hbt_ token with `stream` alone is closed with 4403 / refused, and a token with
+ * `vibe_coding_publish` (the "GitHub Copilot Companion" preset) keeps working. Session sign-in is
+ * unaffected. (Moving publication to Network project credentials in OpenVibe.Codes is later work.)
+ *
+ * node test/vibe-coding-scope.test.js
+ */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-vibe-scope-'));
+process.env.DB_PATH = path.join(tmp, 'live.db');
+process.env.NODE_ENV = 'test';
+const quiet = console.log;
+console.log = () => {};
+console.warn = () => {};
+
+const db = require('../server/db/database');
+db.initDb();
+db.getDb().prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role) VALUES (7, 'coder', 'coder', 'c@x', 'x', 'streamer')`).run();
+const slot = Number(db.createManagedStream({ user_id: 7, slug: 'camp-code', title: 'code', stream_key: 'key-code' }).lastInsertRowid);
+const narrow = db.createApiToken(7, 'Copilot Companion', ['read', 'vibe_coding_publish']).token;
+const broad = db.createApiToken(7, 'Stream Controller', ['read', 'stream', 'control']).token;
+
+const auth = require('../server/auth/auth');
+const WebSocket = require('ws');
+const VibeCodingPublishServer = require('../server/vibe-coding/publish-server');
+
+/** Connect and return how the socket ended up: { code } when closed, or { ready } on vibe-coding.ready. */
+function connect(port, token) {
+ return new Promise((resolve) => {
+ const ws = new WebSocket(`ws://127.0.0.1:${port}/ws/vibe-coding/publish?token=${token}&managedStreamId=${slot}&slotSlug=camp-code`);
+ ws.on('message', (data) => {
+ const msg = JSON.parse(String(data));
+ if (msg.type === 'vibe-coding.ready') { ws.close(); resolve({ ready: true }); }
+ });
+ ws.on('close', (code) => resolve({ code }));
+ ws.on('error', () => {});
+ });
+}
+
+(async () => {
+ // Scope check itself.
+ const { hasVibeCodingPublishScope } = VibeCodingPublishServer;
+ assert.strictEqual(hasVibeCodingPublishScope({ scopes: ['vibe_coding_publish'] }), true);
+ assert.strictEqual(hasVibeCodingPublishScope({ scopes: ['read', 'stream', 'control'] }), false, 'the broad stream scope is not a publisher scope');
+
+ // The real WebSocket endpoint with real hbt_ tokens.
+ const pub = new VibeCodingPublishServer({ broadcastToStream() {} }, db);
+ const server = http.createServer();
+ pub.init(server);
+ server.on('upgrade', (req, socket, head) => { if (!pub.handleUpgrade(req, socket, head)) socket.destroy(); });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const port = server.address().port;
+ assert.deepStrictEqual(await connect(port, broad), { code: 4403 }, 'a stream-scoped token is closed with 4403');
+ assert.deepStrictEqual(await connect(port, narrow), { ready: true }, 'a vibe_coding_publish token still publishes');
+ assert.deepStrictEqual(await connect(port, 'hbt_' + '0'.repeat(64)), { code: 4401 }, 'an unknown token is refused');
+ server.close();
+
+ // REST: the /api/vibe-coding writes take only the narrow scope.
+ const t = (method, url, scopes) => auth.apiTokenAllows({ method, originalUrl: url }, scopes);
+ assert.strictEqual(t('PUT', `/api/vibe-coding/managed/${slot}/settings`, ['read', 'stream', 'control']), false, 'stream no longer writes vibe-coding settings');
+ assert.strictEqual(t('PUT', `/api/vibe-coding/managed/${slot}/settings`, ['read', 'vibe_coding_publish']), true);
+ assert.strictEqual(t('GET', `/api/vibe-coding/managed/${slot}/events`, ['read']), true, 'reads are unchanged');
+ assert.strictEqual(t('POST', '/api/streams/managed', ['stream']), true, 'the stream scope still covers stream control');
+
+ fs.rmSync(tmp, { recursive: true, force: true });
+ quiet('vibe-coding-scope: ok');
+ process.exit(0);
+})().catch((err) => {
+ quiet(err);
+ process.exit(1);
+});