openvibers/openvibe.live Commits

The cam button: a viewer takes a still of the live stream with OpenVibe.Cam (#432)

OP OpenVibers committed 16 days ago · 5de890bb19 · 10 files, +397 −0

* The cam button: a viewer takes a still of the live stream with OpenVibe.Cam

* The cam button: a viewer takes a still of the live stream with OpenVibe.Cam

Changes

ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex d788f517..7e2e312f 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -1010,6 +1010,20 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
.ssp-body { padding: 8px 12px; display: grid; grid-template-columns: auto 1fr; gap: 5px 12px; }
.ssp-body .ssp-k { color: #99a; }
.ssp-body .ssp-v { color: #fff; text-align: right; font-variant-numeric: tabular-nums; word-break: break-word; }
++/* The cam button's result overlay sits over the picture (not below it), so a phone viewer can still
++ see the stream while the still is open. */
++.cam-still-panel {
++ position: absolute; top: 12px; right: 12px; z-index: 12;
++ width: 288px; max-width: calc(100% - 24px);
++ background: rgba(15,15,18,0.92); backdrop-filter: blur(6px);
++ border: 1px solid rgba(255,255,255,0.12); border-radius: 10px;
++ color: #eee; font-size: 0.8rem; overflow: hidden;
++ box-shadow: 0 8px 28px rgba(0,0,0,0.5);
++}
++.cam-still-body { padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; }
++.cam-still-body img { width: 100%; border-radius: 8px; display: block; }
++.cam-still-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
++.cam-still-link { color: var(--accent); word-break: break-all; }
.weather-current {
display: flex; align-items: center; gap: 10px;
}
ddiff --git a/public/fragments/channel.html b/public/fragments/channel.html
iindex 163a75a8..dea81121 100644
---- a/public/fragments/channel.html
++++ b/public/fragments/channel.html
@@@ -68,6 +68,14 @@
</div>
<div class="ssp-body" id="stream-stats-body"></div>
</div>
++ <!-- Cam still overlay (the cam button's result: link + picture, with an optional chat post) -->
++ <div class="cam-still-panel" id="cam-still-panel" style="display:none">
++ <div class="ssp-header">
++ <span><i class="fa-solid fa-camera"></i> Still</span>
++ <button class="ssp-close" id="btn-cam-close" title="Close"><i class="fa-solid fa-xmark"></i></button>
++ </div>
++ <div class="cam-still-body" id="cam-still-body"></div>
++ </div>
<div class="fullscreen-chat-overlay" id="fullscreen-chat-overlay" aria-hidden="true">
<div class="fullscreen-chat-messages" id="fullscreen-chat-messages"></div>
<div class="fullscreen-chat-chrome" id="fullscreen-chat-chrome">
@@@ -120,6 +128,7 @@
<div class="ch-info-bar-right">
<span class="ch-follower-count" id="ch-follower-count">0 followers</span>
<a class="ch-game-badge" id="ch-game-badge" href="https://openvibe.games/" target="_blank" rel="noopener" hidden></a>
++ <button class="btn btn-outline" id="btn-cam" title="Take a still" style="display:none"><i class="fa-solid fa-camera"></i> Still</button>
<button class="btn btn-follow" id="ch-btn-follow">
<i class="fa-solid fa-heart"></i> Follow
</button>
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex e45b2bc0..35be9830 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -928,6 +928,8 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
document.getElementById('ch-live-area').style.display = 'none';
document.getElementById('ch-offline-area').style.display = '';
_channelFooter(false);
++ _wireCamButton();
++ _updateCamButton(null);
_placeBelowFold();
// Populate offline header
@@@ -1687,6 +1689,105 @@ function switchToLiveStream(username, streamId, btn) {
.finally(() => { if (myToken === _streamSwitchToken) showStreamSwitchOverlay(false); });
}
++// ── Cam button: a signed-in viewer takes a still of the live stream ─────────
++// The result is an overlay over the picture (not a block below it), so a phone viewer can
++// keep watching while the still is open (controls-keep-video-in-view).
++let _camBusy = false;
++
++function _camSlotFor(stream) {
++ if (!stream) return null;
++ return stream.managed_stream_slug || (stream.managed_stream_id != null ? String(stream.managed_stream_id) : null);
++}
++
++function _updateCamButton(stream) {
++ const btn = document.getElementById('btn-cam');
++ if (!btn) return;
++ const signedIn = !!(typeof currentUser !== 'undefined' && currentUser);
++ const slot = _camSlotFor(stream);
++ btn.style.display = (signedIn && stream && stream.is_live && slot) ? '' : 'none';
++}
++
++window.addEventListener('openvibe-auth-changed', () => {
++ const stream = (typeof currentStreamData !== 'undefined' && currentStreamData) || null;
++ _updateCamButton(stream && stream.is_live ? stream : null);
++});
++
++function _camShowPanel(html) {
++ const panel = document.getElementById('cam-still-panel');
++ if (!panel) return;
++ document.getElementById('cam-still-body').innerHTML = html;
++ const stats = document.getElementById('stream-stats-panel');
++ if (stats) stats.style.display = 'none';
++ panel.style.display = '';
++}
++
++function _camStillImageAndLink(data) {
++ const still = data && data.still;
++ const share = data && data.share;
++ const img = still && (still.pics_direct || still.pics_url);
++ const url = (share && share.url) || (still && still.pics_url);
++ return { img, url, share };
++}
++
++async function takeCamStill() {
++ if (_camBusy) return;
++ const stream = (typeof currentStreamData !== 'undefined' && currentStreamData) || null;
++ if (!currentUser) return showModal('login');
++ if (!stream || !stream.is_live) return toast('The stream is offline', 'error');
++ const slot = _camSlotFor(stream);
++ if (!slot) return toast('This stream has no cam slot', 'error');
++
++ _camBusy = true;
++ const btn = document.getElementById('btn-cam');
++ if (btn) btn.disabled = true;
++ _camShowPanel('<p class="muted">Taking a still…</p>');
++ try {
++ const data = await api('/cam/stills', { method: 'POST', body: { slot, stream_id: stream.id } });
++ const { img, url, share } = _camStillImageAndLink(data);
++ if (!img && !url) {
++ _camShowPanel('<p class="muted">No still came back.</p>');
++ return;
++ }
++ const imgHtml = img ? `<img src="${esc(img)}" alt="Your still from the stream" loading="lazy">` : '';
++ const linkHtml = url ? `<a class="cam-still-link" href="${esc(url)}" target="_blank" rel="noopener">Open still</a>` : '';
++ const postHtml = (share && share.text) ? '<button class="btn btn-small btn-outline" id="btn-cam-post">Post to chat</button>' : '';
++ _camShowPanel(`${imgHtml}<div class="cam-still-actions">${linkHtml}${postHtml}</div>`);
++ const postBtn = document.getElementById('btn-cam-post');
++ if (postBtn && share && share.text) {
++ postBtn.addEventListener('click', async () => {
++ postBtn.disabled = true;
++ try {
++ await api('/cam/post-share', { method: 'POST', body: { stream_id: stream.id, text: share.text } });
++ postBtn.textContent = 'Posted to chat';
++ } catch (err) {
++ toast((err && err.message) || 'Could not post to chat', 'error');
++ postBtn.disabled = false;
++ }
++ });
++ }
++ } catch (err) {
++ const msg = (err && err.message) || 'Could not take a still';
++ _camShowPanel(`<p class="muted">${esc(msg)}</p>`);
++ toast(msg, 'error');
++ } finally {
++ _camBusy = false;
++ if (btn) btn.disabled = false;
++ }
++}
++
++function _wireCamButton() {
++ const btn = document.getElementById('btn-cam');
++ if (btn && !btn.dataset.wired) {
++ btn.dataset.wired = '1';
++ btn.addEventListener('click', takeCamStill);
++ }
++ const close = document.getElementById('btn-cam-close');
++ if (close && !close.dataset.wired) {
++ close.dataset.wired = '1';
++ close.addEventListener('click', () => { document.getElementById('cam-still-panel').style.display = 'none'; });
++ }
++}
++
function activateChannelStream(stream) {
// NSFW age gate — block player init until viewer confirms
if (stream.is_nsfw && !sessionStorage.getItem('nsfw-ok-stream-' + stream.id)) {
@@@ -1788,6 +1889,10 @@ function activateChannelStream(stream) {
// Start polling for stream status changes
startStreamStatusPoll(stream);
++
++ // The cam button appears only on a live slot for a signed-in viewer.
++ _wireCamButton();
++ _updateCamButton(stream);
}
const STREAM_POLL_INTERVAL = 15000; // 15 seconds
// After the player reports the stream ended (or we otherwise know the channel just
ddiff --git a/server/cam/cam-client.js b/server/cam/cam-client.js
nnew file mode 100644
iindex 00000000..0229d085
---- /dev/null
++++ b/server/cam/cam-client.js
@@@ -0,0 +1,51 @@
++'use strict';
++/**
++ * OpenVibe.Cam — Live's server-side client for the cam button.
++ *
++ * Live calls Cam's POST /api/v1/stills with the viewer's own Network token (never a Cam service
++ * token). Cam fetches OpenVibe.Media's public frame, stores the still in OpenVibe.Pics as that
++ * person, and answers the still plus the exact line Live posts to the stream's chat.
++ *
++ * Env:
++ * CAM_URL base URL (default https://openvibe.cam)
++ */
++const drill = require('../drill');
++
++const CAM_URL = (process.env.CAM_URL || 'https://openvibe.cam').replace(/\/+$/, '');
++const TIMEOUT_MS = 10000;
++
++class CamApiError extends Error {
++ constructor(message, status, code) {
++ super(message);
++ this.name = 'CamApiError';
++ this.status = status || 0;
++ this.code = code || null;
++ }
++}
++
++function available() { return !!CAM_URL && !drill.enabled; }
++
++/** POST /api/v1/stills as the viewer. Resolves to Cam's JSON answer; rejects with CamApiError. */
++async function createStill({ token, slot, streamId, caption }) {
++ if (!available()) {
++ throw new CamApiError('OpenVibe.Cam is unavailable right now', 503, 'cam.unavailable');
++ }
++ if (!token) {
++ throw new CamApiError('A signed-in Network account is required', 401, 'cam.sign_in_required');
++ }
++ const res = await fetch(`${CAM_URL}/api/v1/stills`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
++ body: JSON.stringify({ slot, stream_id: streamId, caption }),
++ signal: AbortSignal.timeout(TIMEOUT_MS),
++ });
++ let data = null;
++ try { data = await res.json(); } catch { /* Cam's problem bodies are JSON; a proxy page is not */ }
++ if (!res.ok) {
++ const message = (data && (data.message || data.error)) || `OpenVibe.Cam answered ${res.status}`;
++ throw new CamApiError(message, res.status, data && data.code);
++ }
++ return data;
++}
++
++module.exports = { CAM_URL, available, createStill, CamApiError };
ddiff --git a/server/cam/routes.js b/server/cam/routes.js
nnew file mode 100644
iindex 00000000..f3253569
---- /dev/null
++++ b/server/cam/routes.js
@@@ -0,0 +1,108 @@
++'use strict';
++/**
++ * OpenVibe.Live — the cam button API.
++ *
++ * POST /api/cam/stills { slot, stream_id, caption? } take a still (Cam → Pics as the viewer)
++ * POST /api/cam/post-share { stream_id, text } post Cam's returned share.text to chat
++ *
++ * A signed-in viewer takes a still of a live stream. Live's server sends the viewer's own
++ * Network token to OpenVibe.Cam, returns the still (link and picture), and — when the viewer
++ * chooses — posts Cam's returned share.text into the stream's chat through Live's chat authority.
++ */
++const express = require('express');
++const db = require('../db/database');
++const { requireAuth, extractToken } = require('../auth/auth');
++const cam = require('./cam-client');
++const delivery = require('../chat/chat-delivery');
++
++const router = express.Router();
++
++// Cam's own slot rule (server/store.js SLOT_RE): a slot id, slug or @username.
++const SLOT_RE = /^@?[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/;
++const SHARE_TEXT_MAX = 500;
++
++async function liveStream(req, res) {
++ const streamId = req.body && Number(req.body.stream_id);
++ if (!Number.isInteger(streamId) || streamId <= 0) {
++ res.status(400).json({ error: 'stream_id must be a positive whole number' });
++ return null;
++ }
++ const stream = await db.getStreamById(streamId);
++ if (!stream) {
++ res.status(404).json({ error: 'Stream not found' });
++ return null;
++ }
++ if (!stream.is_live) {
++ res.status(409).json({ error: 'That stream is offline' });
++ return null;
++ }
++ return stream;
++}
++
++router.post('/stills', requireAuth, async (req, res) => {
++ try {
++ if (req.authSource === 'api_token') {
++ return res.status(403).json({ error: 'The cam button needs your signed-in Network account, not an API token' });
++ }
++
++ const body = req.body && typeof req.body === 'object' ? req.body : {};
++ const slot = String(body.slot == null ? '' : body.slot).trim();
++ const caption = body.caption == null ? '' : String(body.caption).trim();
++
++ if (!SLOT_RE.test(slot)) return res.status(400).json({ error: 'A slot id, slug or @username is required' });
++
++ const stream = await liveStream(req, res);
++ if (!stream) return;
++
++ const token = extractToken(req);
++ if (!token || String(token).startsWith('hbt_')) {
++ return res.status(401).json({ error: 'A Network sign-in is required' });
++ }
++
++ let result;
++ try {
++ result = await cam.createStill({ token, slot, streamId: stream.id, caption });
++ } catch (err) {
++ const status = err && err.status ? err.status : 502;
++ const message = (err && err.message) || 'OpenVibe.Cam could not take a still';
++ return res.status(status).json({ error: message, ...(err && err.code ? { code: err.code } : {}) });
++ }
++
++ return res.json(result);
++ } catch (err) {
++ console.error('[Cam] still error:', err.message);
++ res.status(500).json({ error: 'Failed to take a still' });
++ }
++});
++
++router.post('/post-share', requireAuth, async (req, res) => {
++ try {
++ if (req.authSource === 'api_token') {
++ return res.status(403).json({ error: 'The cam button needs your signed-in Network account, not an API token' });
++ }
++ const text = String((req.body && req.body.text) || '').trim().slice(0, SHARE_TEXT_MAX);
++ if (!text) return res.status(400).json({ error: 'A share line is required' });
++
++ const stream = await liveStream(req, res);
++ if (!stream) return;
++
++ const chatId = await delivery.message({
++ stream_id: stream.id,
++ channel_user_id: stream.user_id,
++ user_id: req.user.id,
++ username: req.user.username,
++ message: text,
++ message_type: 'chat',
++ is_global: false,
++ source_platform: 'cam',
++ frame: { role: 'user' },
++ });
++
++ return res.json({ posted: !!chatId });
++ } catch (err) {
++ console.error('[Cam] post-share error:', err.message);
++ res.status(500).json({ error: 'Failed to post the still to chat' });
++ }
++});
++
++module.exports = router;
ddiff --git a/server/config.js b/server/config.js
iindex 27e7f356..8caba1a3 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -185,6 +185,9 @@ function buildConfig(registryValues) {
publicUrl: process.env.MEDIA_PUBLIC_URL || 'https://openvibe.media',
appId: process.env.MEDIA_APP_ID || 'live',
},
++ cam: {
++ url: process.env.CAM_URL || 'https://openvibe.cam',
++ },
turn: {
url: turnUrl,
username: process.env.TURN_USERNAME || '',
ddiff --git a/server/index.js b/server/index.js
iindex 6ee6e619..bb1675e4 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -793,6 +793,7 @@ app.use('/api/today', require('./today/routes').createTodayRoutes()); // a cha
app.use('/api/recap', require('./recap/routes')); // after-show reports (docs/recap.md)
app.use('/api/social', require('./social/routes').router); // channel social links and their previews (server/social/)
app.get('/embed/x-timeline', require('./social/routes').xTimelinePage);
++app.use('/api/cam', require('./cam/routes')); // the cam button: a viewer takes a still of a live stream
app.use('/api/comments', commentRoutes);
app.use('/api/admin/openre', require('./openre/routes'));
app.use('/api/admin', adminRoutes);
ddiff --git a/server/net/actor-limits.js b/server/net/actor-limits.js
iindex e23ec52d..b8e4d143 100644
---- a/server/net/actor-limits.js
++++ b/server/net/actor-limits.js
@@@ -54,6 +54,8 @@ const ROUTES = [
['live.money', /^POST$/, /^\/(payments\/(bucks\/checkout|subscribe|subscriptions\/[^/]+\/cancel)|funds\/(purchase|donate|cashout|recycle))$/, { minute: 10, hour: 60 }],
// Testing an AI provider key calls that provider.
['live.ai.test', /^POST$/, /^\/ai-viewers\/byo\/test$/, { minute: 5, hour: 30 }],
++ // The cam button fetches Media's frame and uploads a still to Pics as the viewer.
++ ['live.cam.still', /^POST$/, /^\/cam\/stills$/, { minute: 6, hour: 60 }],
];
function createLiveActorLimits({ env = process.env, registry = null, now } = {}) {
ddiff --git a/test/cam-button.test.js b/test/cam-button.test.js
nnew file mode 100644
iindex 00000000..f5122b50
---- /dev/null
++++ b/test/cam-button.test.js
@@@ -0,0 +1,103 @@
++'use strict';
++// The cam button (CAM-1): a signed-in viewer takes a still of a live stream. Live's server sends the
++// viewer's own Network token to OpenVibe.Cam, returns the still (link + picture), and — when the viewer
++// chooses — posts Cam's returned share.text into the stream's chat with Live's chat authority.
++// node test/cam-button.test.js
++
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const crypto = require('crypto');
++const express = require('express');
++const jwt = require('jsonwebtoken');
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-cam-'));
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);
++process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');
++process.env.OV_NETWORK_URL = 'https://openvibe.network';
++process.env.NODE_ENV = 'test';
++console.warn = () => {};
++
++const ISS = 'https://openvibe.network';
++const SUBJECT = 'usr_01J0000000000000000000000C1';
++const viewerToken = () => jwt.sign({ sub: '77', username: 'alice', subject_id: SUBJECT, role: 'user' }, keys.privateKey, { algorithm: 'RS256', issuer: ISS, expiresIn: 600 });
++
++(async () => {
++ const db = require('../server/db/database');
++ await db.initDb();
++ const d = db.getDb();
++ await d.prepare("INSERT INTO users (id, username, display_name, password_hash) OVERRIDING SYSTEM VALUE VALUES (501, 'alice', 'Alice', 'x')").run();
++ await d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (501, 'network', '77', ?)").run(SUBJECT);
++
++ const slotId = Number((await db.run("INSERT INTO managed_streams (user_id, slug, title, protocol, stream_key) VALUES (501, 'whip', 'Whip', 'webrtc', 'whip-key') RETURNING id")).lastInsertRowid);
++ const streamId = Number((await db.run("INSERT INTO streams (user_id, managed_stream_id, title, protocol, is_live) VALUES (501, ?, 'Alice live', 'webrtc', 1) RETURNING id", [slotId])).lastInsertRowid);
++
++ const delivery = require('../server/chat/chat-delivery');
++ const sent = [];
++ let nextChatId = 900;
++ delivery.message = async (body) => { sent.push(body); return nextChatId++; };
++
++ const cam = require('../server/cam/cam-client');
++ const camCalls = [];
++ const shareText = 'A still from whip: https://openvibe.cam/s/cms_test';
++ cam.createStill = async ({ token, slot, streamId: sid, caption }) => {
++ camCalls.push({ token, slot, sid, caption });
++ return {
++ still: { id: 'cms_test', stream_id: sid, slot, caption, visibility: 'unlisted', pics_slug: 'slug', pics_url: 'https://pics.test/page', pics_direct: 'https://pics.test/direct' },
++ frame_url: 'https://openvibe.media/live/whip/frame.jpg?w=640',
++ share: { url: 'https://openvibe.cam/s/cms_test', caption, text: shareText },
++ };
++ };
++
++ const app = express();
++ app.use(express.json());
++ app.use('/api/cam', require('../server/cam/routes'));
++ const server = http.createServer(app);
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++
++ const post = (p, body, token = viewerToken()) => fetch(`${base}/api/cam${p}`, {
++ method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(body),
++ }).then(async (r) => ({ status: r.status, json: await r.json().catch(() => null) }));
++
++ try {
++ // Signed-out viewers cannot take a still.
++ assert.strictEqual((await post('/stills', { slot: 'whip', stream_id: streamId }, null)).status, 401);
++
++ // A signed-in viewer takes a still: Cam is called with the viewer's own Network token.
++ const taken = await post('/stills', { slot: 'whip', stream_id: streamId, caption: 'the moment' });
++ assert.strictEqual(taken.status, 200, JSON.stringify(taken.json));
++ assert.strictEqual(taken.json.still.id, 'cms_test');
++ assert.strictEqual(taken.json.share.text, shareText);
++ assert.strictEqual(camCalls.length, 1);
++ assert.strictEqual(camCalls[0].slot, 'whip');
++ assert.strictEqual(camCalls[0].sid, streamId);
++ assert.strictEqual(camCalls[0].token, viewerToken());
++
++ // A viewer chooses to post the returned share.text into the stream chat.
++ const posted = await post('/post-share', { stream_id: streamId, text: shareText });
++ assert.deepStrictEqual(posted.json, { posted: true });
++ assert.strictEqual(sent.length, 1);
++ assert.deepStrictEqual([sent[0].stream_id, sent[0].channel_user_id, sent[0].username, sent[0].message, sent[0].message_type, sent[0].source_platform],
++ [streamId, 501, 'alice', shareText, 'chat', 'cam']);
++
++ // The button refuses a still while the stream is offline.
++ await d.prepare('UPDATE streams SET is_live = 0 WHERE id = ?').run(streamId);
++ const offline = await post('/stills', { slot: 'whip', stream_id: streamId });
++ assert.strictEqual(offline.status, 409);
++ assert.strictEqual(offline.json.error, 'That stream is offline');
++
++ // Bad slot/stream inputs are refused before Cam is asked.
++ assert.strictEqual((await post('/stills', { slot: '', stream_id: streamId })).status, 400);
++ assert.strictEqual((await post('/stills', { slot: 'whip', stream_id: 'nope' })).status, 400);
++ } finally {
++ server.close();
++ fs.rmSync(tmp, { recursive: true, force: true });
++ }
++
++ process.stdout.write('cam-button: all passed\n');
++ process.exit(0);
++})().catch((e) => { process.stderr.write(`${e.stack}\n`); process.exit(1); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 5cca3bfe..af14a2c9 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -349,6 +349,7 @@ async function check(name, fn) {
'server/social/preview.js': 'OpenVibe.Network public profile API (configured OV_NETWORK_INTERNAL_URL, fixed path, username checked); every other preview goes through egress',
'server/monetization/inventory-client.js': 'OpenVibe.Inventory API for Live cosmetics (configured INVENTORY_URL)',
'server/bot/bot-client.js': 'OpenVibe.Bot command API for a bound channel\'s chat robot commands (configured LIVE_BOT_INTERNAL_URL)',
++ 'server/cam/cam-client.js': 'OpenVibe.Cam stills API for the cam button, as the viewer (configured CAM_URL)',
'server/chat/insight-client.js': 'OpenVibe.Chat public chat-AI reads (configured OV_CHAT_INTERNAL_URL, fixed paths, ids validated)',
'server/chat/chat-client.js': 'OpenVibe.Chat typed ingress (configured OV_CHAT_INTERNAL_URL, fixed /internal/chat/* paths)',
'server/chat/chat-delivery.js': 'OpenVibe.Tools anon resolve (configured)',