openvibers/openvibe.live Commits
VOD and clip comments are OpenVibe.Community threads (Wave 5 exit: Live and a second product share one thread)
Changes
@@@ -132,6 +132,10 @@ OPS_ALERT_WEBHOOK_URL=# Pastes are served by openvibe.community when this is 1 (Live 301s /p/:slug and /pastes there)PASTES_ON_COMMUNITY=0OV_COMMUNITY_URL=https://openvibe.community++# Community's loopback API: pastes (PASTES_AUTHORITY=community) and VOD/clip comments, which are++# Community threads (server/comments-client.js; Live's service token needs community.comment.write++# and community.comment.moderate on openvibe.community)++OV_COMMUNITY_INTERNAL_URL=http://127.0.0.1:4200# Chat runs in OpenVibe.Chat when this is "chat" (roadmap W6; server/chat/chat-authority.js): Live# stops serving /ws/chat and /api/chat|dm|tts|sounds (nginx sends them to Chat), forwards its own@@@ -4,7 +4,7 @@Self-hosted live streaming platform — Node.js/Express monolith, vanilla JS SPA frontend, SQLite (better-sqlite3). Part of the **OpenVibe network** with SSO via openvibe.network. See [README.md](README.md) for features and [docs/architecture.md](docs/architecture.md) for system design.--**Network context:** SSO/OAuth2 + the OpenCoins wallet come from **OpenVibe.Network** (`server/monetization/wallet-client.js`). The media subsystem (VODs/clips/pastes/thumbnails/files) lives in **OpenVibe.Media** — Live talks to it via [server/media-client.js](server/media-client.js), the thin proxy routers in [server/media-proxy/](server/media-proxy/), and the Media-backed recorder [server/streaming/recorder.js](server/streaming/recorder.js). Media completion events arrive at `POST /internal/media-webhook`. The legacy local `vods`/`clips`/`pastes` tables are READ-ONLY (frozen for the cutover migration — never write them); Live-owned AI/transcript state for Media-hosted content lives in `vod_ai_state`/`clip_ai_state`. See ../CONTRACTS.md for the binding inter-service contracts. **Money:** `BILLING_AUTHORITY` (read only in [server/monetization/money-authority.js](server/monetization/money-authority.js)) is `live` by default; `billing` makes OpenVibe.Billing the ledger ([billing-client.js](server/monetization/billing-client.js), [billing-actions.js](server/monetization/billing-actions.js)) and Live's `openvibe_bucks_*`/`transactions`/`payment_orders`/`subscriptions` read-only (a database-level tripwire throws). The owner-only `money_writes_frozen` flag (`/api/admin/money`) refuses money actions in both modes.++**Network context:** SSO/OAuth2 + the OpenCoins wallet come from **OpenVibe.Network** (`server/monetization/wallet-client.js`). The media subsystem (VODs/clips/pastes/thumbnails/files) lives in **OpenVibe.Media** — Live talks to it via [server/media-client.js](server/media-client.js), the thin proxy routers in [server/media-proxy/](server/media-proxy/), and the Media-backed recorder [server/streaming/recorder.js](server/streaming/recorder.js). Media completion events arrive at `POST /internal/media-webhook`. The legacy local `vods`/`clips`/`pastes` tables are READ-ONLY (frozen for the cutover migration — never write them); VOD/clip comments are OpenVibe.Community threads ([server/comments-client.js](server/comments-client.js)) and the local `comments` table is READ-ONLY too; Live-owned AI/transcript state for Media-hosted content lives in `vod_ai_state`/`clip_ai_state`. See ../CONTRACTS.md for the binding inter-service contracts. **Money:** `BILLING_AUTHORITY` (read only in [server/monetization/money-authority.js](server/monetization/money-authority.js)) is `live` by default; `billing` makes OpenVibe.Billing the ledger ([billing-client.js](server/monetization/billing-client.js), [billing-actions.js](server/monetization/billing-actions.js)) and Live's `openvibe_bucks_*`/`transactions`/`payment_orders`/`subscriptions` read-only (a database-level tripwire throws). The owner-only `money_writes_frozen` flag (`/api/admin/money`) refuses money actions in both modes.## Commands@@@ -12,6 +12,8 @@ OpenVibe.Live is a self-hosted live streaming platform, one of the OpenVibe serv- **OpenVibe.Network** — SSO/OAuth2 (RS256 JWTs), OpenCoins wallet, shared browser modules (`openvibe-shared`, a pinned OpenVibe.Shared release served at `/shared/*` from `node_modules`)- **OpenVibe.Media** — VODs, clips, pastes, thumbnails and files. Live proxies to it(`server/media-client.js`, `server/media-proxy/`); the local `vods`/`clips`/`pastes` tables are frozen, read-only.++- **OpenVibe.Community** — pastes (`server/pastes-client.js`) and VOD/clip comments: `/api/comments` is an adapter over Community comment threads++ (`server/comments-client.js`, [vods-and-clips.md](vods-and-clips.md#comments-are-openvibecommunity-threads)); the local `comments` table is frozen, read-only.## Authentication@@@ -55,7 +55,21 @@ From the dashboard:## Who sees a private VOD or clip--A private item (visibility `private`, or a legacy row with no visibility and `is_public = 0`) is visible only to its owners — the uploader or clipper, the clipped channel's streamer, the streamer whose stream it came from — and to staff (admin, global mod). Everyone else gets exactly the answer an unknown id gets: the same 404 and body on `/api/vods/:id` and its `live-info`/`memories`/`context`, `/api/clips/:id`, comments, thumbnail regeneration and the write routes (a 403 would confirm the id exists). It is also left out of `/api/chat-ai/vod-transcripts`, the server-rendered `/vod/:id` and `/clip/:id` pages, and `/api/streams/recent`. The rule lives in `server/media-proxy/access.js`; Media applies the same rule to `openvibe.media/v/:id` and `/c/:id`. Unlisted items stay reachable by direct link.++A private item (visibility `private`, or a legacy row with no visibility and `is_public = 0`) is visible only to its owners — the uploader or clipper, the clipped channel's streamer, the streamer whose stream it came from — and to staff (admin, global mod). Everyone else gets exactly the answer an unknown id gets: the same 404 and body on `/api/vods/:id` and its `live-info`/`memories`/`context`, `/api/clips/:id`, comments (including a comment's replies, edit and delete), thumbnail regeneration and the write routes (a 403 would confirm the id exists). It is also left out of `/api/chat-ai/vod-transcripts`, the server-rendered `/vod/:id` and `/clip/:id` pages, and `/api/streams/recent`. The rule lives in `server/media-proxy/access.js`; Media applies the same rule to `openvibe.media/v/:id` and `/c/:id`. Unlisted items stay reachable by direct link.++++## Comments are OpenVibe.Community threads++++Comments on a VOD or clip live in OpenVibe.Community, not in Live (roadmap Wave 5: Live and a second product share one Community thread). The thread of a VOD is the one Community keeps for the EntityRef `{ service: 'live', type: 'vod', id: '<vod id>' }` (`type: 'clip'` for clips). `/api/comments` keeps its paths and response shapes; `server/media-proxy/comments.js` is an adapter over `server/comments-client.js`:++++- **Who sees them.** Live checks the VOD/clip with `access.js` first (a private item's comments are missing like the item), then resolves the thread with its service token (`community.comment.write`, audience `openvibe.community`) and remembers the thread id in `comment_thread_refs`. Community refuses browsers that try to open live/vod or live/clip threads themselves, so a private item's thread is reachable only through Live.++- **Who writes.** Signed-in people only, as before (Community refuses anonymous comments on these threads too). Live sends the person's Network subject (`X-OV-Subject`, from `linked_accounts.subject_id`); an account without one gets 409 "sign in again". Community's own limits apply (10 s between comments, 5 a minute, no repeats) and come back as the error message.++- **Edit** — the author only. **Delete** — the author (as themselves), staff (admin/global mod, as themselves with `X-OV-Staff: 1`, which needs `community.comment.moderate`), or the item's owners (uploader or clipper, the clipped channel, the streamer whose stream it came from — Live deletes for them as itself with `community.comment.moderate`). A top-level comment with replies stays as a tombstone (`deleted: true`).++- **Deleting** a VOD or clip (the player, the dashboard bulk actions, the admin storage page) hides its thread.++- **Moderation in Community** shows here: a thread Community's moderators hid answers `404 { code: "comments_hidden" }` on Live too, and a locked one refuses new comments with Community's message.++- **Community down** — every `/api/comments` route answers `503 { error: "Comments are unavailable right now", code: "comments_unavailable" }` and the player says so; it never shows an empty list. `comment_count` on `/api/vods/:id` and `/api/clips/:id` is `null` then (short timeout, skipped for 30 s after a failure).++- **The same thread elsewhere.** For public and unlisted items the list carries `thread: { id, url }` — the thread's page on OpenVibe.Community (`https://openvibe.community/c/<access id>`), linked under the comments as "View this thread on OpenVibe.Community". Comments made there show on Live and the other way round.++++Live's old `comments` table is read-only (`test/frozen-tables.test.js` fails if server code writes it). Its rows were moved with OpenVibe.Community's `scripts/import-live-comments.js`: dry run by default, `--apply` only with `--backup` of Community's database, a ledger so re-runs import nothing twice, authors mapped to Network subjects (unmapped ones held and listed), and a reconciliation `read = imported + held + excluded`. The exact production commands are in OpenVibe.Community's README ("Live's VOD and clip comments").## Chat Replay@@@ -389,3 +389,8 @@.media-transcript-segs { display: flex; flex-direction: column; padding: 6px; gap: 1px; }.ts-seg:hover { background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--text-primary); }.ts-text { flex: 1; }++++/* Comments are OpenVibe.Community threads: the link to the same thread there, deleted tombstones */++.comments-elsewhere { margin: 10px 0 0; font-size: .85rem; color: var(--text-muted); }++.comments-elsewhere:empty { display: none; }++.comment-item.comment-deleted .comment-text { font-style: italic; }@@@ -1163,11 +1163,14 @@ https://www.101soundboards.com/sounds/12345 150p 2</code></pre><table class="doc-table"><thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead><tbody>--<tr><td>GET</td><td>/api/comments/:type/:id</td><td>Opt</td><td>List comments + replies (<code>type</code> = <code>vod|clip</code>)</td></tr>++<tr><td>GET</td><td>/api/comments/:type/:id</td><td>Opt</td><td>List comments + replies, newest first (<code>type</code> = <code>vod|clip</code>, <code>?limit,offset</code>) → <code>{ comments, total, thread? }</code>; <code>thread.url</code> is the same thread on OpenVibe.Community (public and unlisted items)</td></tr>++<tr><td>GET</td><td>/api/comments/:commentId/replies</td><td>Opt</td><td>All replies of a comment</td></tr><tr><td>POST</td><td>/api/comments/:type/:id</td><td>Yes</td><td>Add a comment/reply (<code>message</code>, <code>parent_id?</code>)</td></tr>--<tr><td>PUT / DELETE</td><td>/api/comments/:commentId</td><td>Author/mod</td><td>Edit / delete</td></tr>++<tr><td>PUT</td><td>/api/comments/:commentId</td><td>Author</td><td>Edit your own comment</td></tr>++<tr><td>DELETE</td><td>/api/comments/:commentId</td><td>Author/owner/staff</td><td>Delete (the VOD's or clip's owners and staff may delete any comment on it)</td></tr></tbody></table>++<p>Comments are <a href="https://openvibe.community">OpenVibe.Community</a> comment threads: the same thread shows on Community's page for it. When Community cannot be reached these routes answer <code>503</code> with <code>code: "comments_unavailable"</code>.</p><h3>Pastes — <code>/api/pastes</code></h3><table class="doc-table">@@@ -1531,9 +1531,19 @@ async function loadComments(contentType, contentId, prefix) {const countEl = document.getElementById(`${prefix}-comment-count`);const listEl = document.getElementById(`${prefix}-comments-list`);const formEl = document.getElementById(`${prefix}-comment-form`);++ if (!listEl) return;// Show comment form if logged inif (formEl) formEl.style.display = currentUser ? '' : 'none';++ // The same thread on OpenVibe.Community (comments are Community threads; the link is offered for items anyone may see).++ let elsewhere = document.getElementById(`${prefix}-comments-elsewhere`);++ if (!elsewhere) {++ elsewhere = document.createElement('p');++ elsewhere.id = `${prefix}-comments-elsewhere`;++ elsewhere.className = 'comments-elsewhere';++ listEl.after(elsewhere);++ }++ elsewhere.replaceChildren();try {const data = await api(`/comments/${contentType}/${contentId}`);@@@ -1541,6 +1551,14 @@ async function loadComments(contentType, contentId, prefix) {const total = data.total || 0;if (countEl) countEl.textContent = total > 0 ? `(${total})` : '';++ if (data.thread && data.thread.url) {++ const a = document.createElement('a');++ a.href = data.thread.url;++ a.target = '_blank';++ a.rel = 'noopener';++ a.textContent = 'View this thread on OpenVibe.Community';++ elsewhere.append(a);++ }if (!comments.length) {listEl.innerHTML = '<div class="comments-empty"><i class="fa-solid fa-comment-dots" style="font-size:1.5rem;margin-bottom:8px"></i><p>No comments yet. Be the first!</p></div>';@@@ -1549,24 +1567,42 @@ async function loadComments(contentType, contentId, prefix) {listEl.innerHTML = comments.map(c => renderComment(c, contentType, contentId)).join('');} catch (e) {-- listEl.innerHTML = '<p class="muted">Failed to load comments</p>';++ if (countEl) countEl.textContent = '';++ // Never an empty list that looks real: say what happened.++ if (formEl && e && e.status === 503) formEl.style.display = 'none';++ listEl.innerHTML = e && e.status === 503++ ? '<div class="comments-empty comments-unavailable"><i class="fa-solid fa-plug-circle-exclamation" style="font-size:1.5rem;margin-bottom:8px"></i><p>Comments are unavailable right now. Try again in a moment.</p></div>'++ : `<p class="muted">${esc((e && e.message) || 'Failed to load comments')}</p>`;}}function renderComment(c, contentType, contentId) {-- const initial = (c.username || '?')[0].toUpperCase();-- const color = c.profile_color || '#8b5cf6';++ if (c.deleted) {++ const replies = c.replies && c.replies.length ? `<div class="comment-replies">${c.replies.map(r => renderComment(r, contentType, contentId)).join('')}</div>` : '';++ return `++ <div class="comment-item comment-deleted" id="comment-${c.id}">++ <div class="comment-avatar" style="background:var(--bg-tertiary, #444)">?</div>++ <div class="comment-body">++ <div class="comment-text muted">This comment was deleted.</div>++ ${replies}++ </div>++ </div>`;++ }const name = c.display_name || c.username || 'Unknown';-- const isOwn = currentUser && (c.user_id === currentUser.id);-- const isAdmin = currentUser && currentUser.capabilities?.moderate_global;-- const edited = c.updated_at && c.updated_at !== c.created_at;++ const initial = (c.username || name || '?')[0].toUpperCase();++ const color = c.profile_color || '#8b5cf6';++ const canEdit = c.can_edit ?? (currentUser && c.user_id === currentUser.id);++ const canDelete = c.can_delete ?? (canEdit || (currentUser && currentUser.capabilities?.moderate_global));++ const edited = !!c.edited_at || (c.updated_at && c.updated_at !== c.created_at);let actionsHtml = '';if (currentUser) {actionsHtml += `<button onclick="showReplyForm(${c.id}, '${contentType}', ${contentId})"><i class="fa-solid fa-reply"></i> Reply</button>`;}-- if (isOwn || isAdmin) {++ if (canEdit) {actionsHtml += `<button onclick="editComment(${c.id}, '${contentType}', ${contentId})"><i class="fa-solid fa-pen"></i> Edit</button>`;++ }++ if (canDelete) {actionsHtml += `<button onclick="deleteCommentAction(${c.id}, '${contentType}', ${contentId})"><i class="fa-solid fa-trash"></i> Delete</button>`;}@@@ -1577,7 +1613,7 @@ function renderComment(c, contentType, contentId) {return `<div class="comment-item" id="comment-${c.id}">-- <div class="comment-avatar" style="background:${esc(color)}">${initial}</div>++ <div class="comment-avatar" style="background:${esc(color)}">${esc(initial)}</div><div class="comment-body"><div class="comment-meta"><span class="comment-author" style="color:${esc(color)}">${esc(name)}</span>@@@ -1010,6 +1010,7 @@ router.get('/storage', (req, res) => {// proxy the Media API; the tiering + media-tools endpoints return a stub note// so the admin SPA shows where the controls went instead of erroring.const mediaClient = require('../media-client');++const commentsClient = require('../comments-client');// ── GET /api/admin/storage/vods — proxied VOD listing ────────router.get('/storage/vods', async (req, res) => {@@@ -1047,6 +1048,7 @@ router.delete('/storage/vods/bulk', async (req, res) => {}freed += vod.file_size || 0;await mediaClient.deleteVod(id);++ commentsClient.hideThreadOf('vod', Number(id));deleted++;} catch (err) {errors.push(`VOD ${id}: ${err.message}`);@@@ -1079,7 +1081,7 @@ router.delete('/storage/clips/bulk', async (req, res) => {}await mediaClient.deleteClip(id);db.run("DELETE FROM content_views WHERE content_type = 'clip' AND content_id = ?", [id]);-- db.run("DELETE FROM comments WHERE content_type = 'clip' AND content_id = ?", [id]);++ commentsClient.hideThreadOf('clip', Number(id)); // comments are Community threads; Live's old table is read-onlydeleted++;} catch (err) {errors.push(`Clip ${id}: ${err.message}`);nnew file mode 100644@@@ -0,0 +1,169 @@++'use strict';++/**++ * Comments on VODs and clips are OpenVibe.Community threads (roadmap Wave 5: "Live and a second++ * product share one Community thread"). Live keeps no comments of its own any more — the old++ * `comments` table is read-only, kept for the import (OpenVibe.Community++ * scripts/import-live-comments.js) — and /api/comments (media-proxy/comments.js) is an adapter over++ * the thread of EntityRef { service: 'live', type: 'vod'|'clip', id }.++ *++ * Calls go to Community's /api/v1/comments with Live's service token (audience openvibe.community):++ * community.comment.write resolve a thread; read, comment, edit and delete as the person++ * (X-OV-Subject: their usr_ id)++ * community.comment.moderate delete as staff (X-OV-Staff: 1) or as Live itself for the++ * VOD's/clip's owner; hide the thread of deleted content++ *++ * Only Live opens these threads (Community refuses browsers for live/vod and live/clip): a private++ * item is Live's call, so Live checks visibility first and hands the thread's unguessable access id++ * only to people who may see the item. Thread ids are remembered in comment_thread_refs.++ *++ * Calls made for a visitor forward their address (X-Forwarded-For, as the paste proxy does), so++ * Community's per-address API limit counts people, not Live's loopback address.++ *++ * Any failure to reach Community (or an answer ≥ 500) is a 503 CommentsError: callers must++ * say "comments are unavailable", never show an empty list that looks real.++ */++const db = require('./db/database');++const principal = require('./net/network-principal');++const { subjectForLiveUser } = require('./pastes-client');++++const COMMUNITY_URL = () => (process.env.OV_COMMUNITY_INTERNAL_URL || 'http://127.0.0.1:4200').replace(/\/+$/, '');++const COMMUNITY_PUBLIC_URL = () => (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/+$/, '');++const AUDIENCE = 'openvibe.community';++const TYPES = ['vod', 'clip'];++++class CommentsError extends Error {++ constructor(status, message, code) {++ super(message);++ this.name = 'CommentsError';++ this.status = status;++ this.code = code || null;++ }++}++const unavailable = (why) => new CommentsError(503, 'Comments are unavailable right now', `comments_unavailable${why ? `:${why}` : ''}`);++++let _tables = false;++function ensureTables() {++ if (_tables) return;++ db.getDb().exec(`CREATE TABLE IF NOT EXISTS comment_thread_refs (++ content_type TEXT NOT NULL,++ content_id INTEGER NOT NULL,++ thread_id INTEGER NOT NULL,++ access_id TEXT NOT NULL,++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ PRIMARY KEY (content_type, content_id)++ )`);++ _tables = true;++}++++/**++ * One call to Community's comment API. act: { subject } acts for a person, { staff: true } adds++ * X-OV-Staff (the person is Live staff), {} is Live itself. ip: the visitor's address.++ */++async function call(method, path, { body, act = {}, ip = null, timeoutMs = 5000, retried = false } = {}) {++ let headers;++ try { headers = { Accept: 'application/json', ...(await principal.serviceHeaders(AUDIENCE)) }; }++ catch (err) { console.warn(`[Comments] no service token for Community: ${err.message}`); throw unavailable('token'); }++ if (act.subject) headers['X-OV-Subject'] = act.subject;++ if (act.staff) headers['X-OV-Staff'] = '1';++ if (ip) headers['X-Forwarded-For'] = String(ip);++ if (body !== undefined) headers['Content-Type'] = 'application/json';++ let res;++ try {++ res = await fetch(`${COMMUNITY_URL()}/api/v1/comments${path}`, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(timeoutMs) });++ } catch (err) {++ console.warn(`[Comments] Community unreachable (${method} ${path}): ${err.message}`);++ throw unavailable('unreachable');++ }++ const out = await res.json().catch(() => null);++ if (res.status === 401 && !retried && !(out && out.code === 'auth.required')) { // our token was refused: fetch a fresh one once++ principal.invalidate(AUDIENCE);++ return call(method, path, { body, act, ip, timeoutMs, retried: true });++ }++ if (res.status >= 500 || (res.status === 401 && retried)) {++ console.warn(`[Comments] Community answered ${res.status} (${method} ${path})${out && out.code ? ` ${out.code}` : ''}`);++ throw unavailable(String(res.status));++ }++ if (res.status === 403 && out && out.code === 'capability.denied') {++ console.warn(`[Comments] Community refused Live's token for ${method} ${path}: ${out.detail || out.error}`);++ throw unavailable('capability');++ }++ if (!res.ok) throw new CommentsError(res.status, (out && (out.detail || out.error)) || `Community answered ${res.status}`, out && out.code);++ return out;++}++++/** The Community thread of a VOD/clip (resolved once, then remembered). → { id, access_id } */++async function threadFor(type, id, label, { timeoutMs, ip } = {}) {++ if (!TYPES.includes(type)) throw new CommentsError(400, 'Invalid content type');++ ensureTables();++ const known = db.getDb().prepare('SELECT thread_id, access_id FROM comment_thread_refs WHERE content_type = ? AND content_id = ?').get(type, Number(id));++ if (known) return { id: known.thread_id, access_id: known.access_id };++ const ref = { service: 'live', type, id: String(id) };++ if (label) ref.label = String(label).slice(0, 200);++ const out = await call('POST', '/threads/resolve', { body: { ref }, timeoutMs, ip });++ const t = out && out.thread;++ if (!t || !Number.isInteger(t.id) || !t.access_id) throw unavailable('resolve');++ db.getDb().prepare('INSERT OR IGNORE INTO comment_thread_refs (content_type, content_id, thread_id, access_id) VALUES (?, ?, ?, ?)').run(type, Number(id), t.id, t.access_id);++ return { id: t.id, access_id: t.access_id };++}++++/** Read a thread page (as the viewer, if signed in). q: { sort, after, limit, parent } */++function readThread(threadId, { subject = null, timeoutMs, ip, ...q } = {}) {++ const qs = new URLSearchParams(Object.entries(q).filter(([, v]) => v != null && v !== '').map(([k, v]) => [k, String(v)])).toString();++ return call('GET', `/threads/${encodeURIComponent(threadId)}${qs ? `?${qs}` : ''}`, { act: { subject }, timeoutMs, ip });++}++++function addComment(threadId, subject, { message, parentId = null }, { ip } = {}) {++ return call('POST', `/threads/${encodeURIComponent(threadId)}/comments`, { act: { subject }, ip, body: { message, ...(parentId ? { parent_id: parentId } : {}) } });++}++++/** One comment and its thread (with the ref). null when Community has no such (visible) comment. */++async function getComment(commentId, subject = null, { ip } = {}) {++ if (!/^\d{1,15}$/.test(String(commentId))) return null;++ try { return await call('GET', `/${commentId}`, { act: { subject }, ip }); }++ catch (err) { if (err.status === 404) return null; throw err; }++}++++function editComment(commentId, subject, message, { ip } = {}) {++ return call('PATCH', `/${encodeURIComponent(commentId)}`, { act: { subject }, ip, body: { message } });++}++++/** as: 'author' (the person), 'staff' (the person, vouched as staff), 'owner' (Live itself, moderating for the content's owner). */++function deleteComment(commentId, { subject = null, as = 'author', ip = null } = {}) {++ const act = as === 'owner' ? {} : { subject, staff: as === 'staff' };++ return call('DELETE', `/${encodeURIComponent(commentId)}`, { act, ip });++}++++// Hiding threads of deleted content runs one at a time in the background, paced under Community's++// per-address API limit (bulk deletes queue hundreds, all from Live's own address).++const HIDE_GAP_MS = process.env.NODE_ENV === 'test' ? 0 : 1100;++let _hideQueue = Promise.resolve();++/** Best effort: hide the thread of a VOD/clip that was deleted. Never throws. */++function hideThreadOf(type, id) {++ if (!TYPES.includes(type) || !(Number(id) > 0)) return _hideQueue;++ _hideQueue = _hideQueue.then(async () => {++ try {++ const t = await threadFor(type, id);++ await call('PUT', `/threads/${t.id}/visibility`, { body: { visibility: 'hidden' } });++ } catch (err) {++ console.warn(`[Comments] could not hide the comment thread of ${type} ${id}: ${err.message}`);++ }++ if (HIDE_GAP_MS) await new Promise((r) => setTimeout(r, HIDE_GAP_MS).unref());++ });++ return _hideQueue;++}++++/** Live user id for a Network subject, when that person has a Live account linked. */++function liveUserForSubject(subject) {++ if (!subject) return null;++ try {++ const row = db.getDb().prepare("SELECT user_id FROM linked_accounts WHERE service = 'network' AND subject_id = ? ORDER BY id LIMIT 1").get(subject);++ return row ? row.user_id : null;++ } catch { return null; }++}++++const threadUrl = (accessId) => `${COMMUNITY_PUBLIC_URL()}/c/${encodeURIComponent(accessId)}`;++++module.exports = {++ CommentsError, TYPES, threadFor, readThread, addComment, getComment, editComment, deleteComment,++ hideThreadOf, liveUserForSubject, subjectForLiveUser, threadUrl, ensureTables,++};@@@ -6935,54 +6935,10 @@ function renormalizePendingMediaRequestPositions(streamerId) {tx(rows);}--// ── Comment helpers ──────────────────────────────────────────----function createComment({ content_type, content_id, user_id, parent_id, message }) {-- return run(-- `INSERT INTO comments (content_type, content_id, user_id, parent_id, message)-- VALUES (?, ?, ?, ?, ?)`,-- [content_type, content_id, user_id, parent_id || null, message]-- );--}----function getComments(contentType, contentId, limit = 50, offset = 0) {-- return all(`-- SELECT c.*, u.username, u.display_name, u.avatar_url, u.profile_color, u.role-- FROM comments c-- JOIN users u ON c.user_id = u.id-- WHERE c.content_type = ? AND c.content_id = ? AND c.is_deleted = 0 AND c.parent_id IS NULL-- ORDER BY c.created_at DESC-- LIMIT ? OFFSET ?-- `, [contentType, contentId, limit, offset]);--}----function getCommentReplies(parentId) {-- return all(`-- SELECT c.*, u.username, u.display_name, u.avatar_url, u.profile_color, u.role-- FROM comments c-- JOIN users u ON c.user_id = u.id-- WHERE c.parent_id = ? AND c.is_deleted = 0-- ORDER BY c.created_at ASC-- `, [parentId]);--}----function getCommentById(id) {-- return get('SELECT * FROM comments WHERE id = ?', [id]);--}----function getCommentCount(contentType, contentId) {-- const row = get('SELECT COUNT(*) as c FROM comments WHERE content_type = ? AND content_id = ? AND is_deleted = 0',-- [contentType, contentId]);-- return row ? row.c : 0;--}----function deleteComment(id) {-- return run('UPDATE comments SET is_deleted = 1 WHERE id = ?', [id]);--}----function updateComment(id, message) {-- return run('UPDATE comments SET message = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [message, id]);--}++// ── Comments ─────────────────────────────────────────────────++// VOD/clip comments are OpenVibe.Community threads (server/comments-client.js). The `comments`++// table is read-only: the one-time import (OpenVibe.Community scripts/import-live-comments.js)++// reads it; test/frozen-tables.test.js fails if server code writes it again.// ── Chat replay helpers ──────────────────────────────────────@@@ -8451,9 +8407,6 @@ module.exports = {// Verification KeyscreateVerificationKey, getVerificationKeyByKey, getVerificationKeyByUsername,getAllVerificationKeys, redeemVerificationKey, revokeVerificationKey, isUsernameReserved,-- // Comments-- createComment, getComments, getCommentReplies, getCommentById, getCommentCount,-- deleteComment, updateComment,// Chat ReplaygetChatReplay,// Channel lookup@@@ -11,6 +11,8 @@ const media = require('../media-client');const { requireAuth, optionalAuth } = require('../auth/auth');const permissions = require('../auth/permissions');const access = require('./access');++const commentsClient = require('../comments-client');++const { commentCount } = require('./comments');const router = express.Router();@@@ -244,7 +246,7 @@ router.get('/:id', optionalAuth, async (req, res) => {}} catch { /* */ }-- clip.comment_count = db.getCommentCount('clip', clip.id);++ clip.comment_count = await commentCount('clip', clip.id, clip.title, req.ip); // null when Community is unreachableclip.can_delete = await canActorDeleteClip(req.user, clip);clip.can_edit = await canActorModerateClip(req.user, clip);@@@ -319,7 +321,7 @@ router.post('/bulk', requireAuth, async (req, res) => {const allowed = clip && (action === 'delete' ? await canActorDeleteClip(req.user, clip) : await canActorModerateClip(req.user, clip));if (!allowed) { skipped++; continue; }try {-- if (action === 'delete') await media.deleteClip(id);++ if (action === 'delete') { await media.deleteClip(id); commentsClient.hideThreadOf('clip', id); }else await media.updateClip(id, { visibility: action });done++;} catch { skipped++; }@@@ -337,6 +339,7 @@ router.delete('/:id', requireAuth, async (req, res) => {if (!clip) return res.status(404).json({ error: 'Clip not found' });if (!(await canActorDeleteClip(req.user, clip))) return refuse(req, res, clip, 'Not authorized to delete this clip');await media.deleteClip(clip.id);++ commentsClient.hideThreadOf('clip', clip.id);res.json({ message: 'Clip deleted' });} catch (err) {mediaErr(res, err, 'Failed to delete clip');@@@ -1,19 +1,35 @@/*** OpenVibe.Live — Comments API Routes (/api/comments)*-- * Comments on VODs/clips are Live-local social data (comments table in live.db);-- * only the commented-on content lives in OpenVibe.Media now, so target lookups-- * (owner, title) go through the media client.++ * Comments on VODs and clips are OpenVibe.Community threads (roadmap Wave 5); these routes are an++ * adapter over them (server/comments-client.js) that keeps the paths and response shapes the SPA++ * uses. Live still decides who may see the commented-on VOD/clip (./access — a private one is++ * missing to everyone but its owners and staff, and so are its comments) and who may delete a++ * comment (its author, staff, or the item's owners); Community stores the comments, enforces its++ * own limits, and does the moderation.++ *++ * Commenting needs a signed-in account (as before; Community refuses anonymous comments on Live++ * threads too). If Community cannot be reached every route answers 503 "Comments are unavailable++ * right now" — never an empty list that looks real.++ *++ * Live's old `comments` table is read-only: it is only the source of the one-time import++ * (OpenVibe.Community scripts/import-live-comments.js).*/'use strict';const express = require('express');const db = require('../db/database');const media = require('../media-client');++const comments = require('../comments-client');++const permissions = require('../auth/permissions');const { requireAuth, optionalAuth } = require('../auth/auth');const { pushNotification, actorInfo: notificationActor } = require('../utils/notify');const access = require('./access');const router = express.Router();++const MAX_MESSAGE = 2000;++const MAX_OFFSET = 1000;++const MAX_REPLIES = 500;++const HIDDEN = { error: 'Comments are hidden here', code: 'comments_hidden' };function truncatePreview(text, max = 120) {const clean = String(text || '').replace(/\s+/g, ' ').trim();@@@ -46,6 +62,102 @@ async function visibleTarget(user, contentType, contentId) {return target && access.canView(user, target.row) ? target : null;}++function sendError(res, err, fallback) {++ if (err instanceof comments.CommentsError) {++ const body = { error: err.message };++ if (err.status === 503) body.code = 'comments_unavailable';++ return res.status(err.status).json(body);++ }++ console.error(`[Comments] ${fallback}:`, err && err.message);++ return res.status(500).json({ error: fallback });++}++++const viewerSubject = async (user) => (user ? comments.subjectForLiveUser(user.id) : null);++++/** How this viewer may delete a comment on this target: 'author' | 'staff' | 'owner' | null. */++function deleteRight(user, subject, c, target) {++ if (!user || c.deleted) return null;++ if (subject && c.author && c.author.subject === subject) return 'author';++ if (permissions.isStaff(user)) return 'staff';++ if (target && access.ownerIds(target.row).includes(Number(user.id))) return 'owner';++ return null;++}++++/**++ * A Community comment in the shape /api/comments always returned (the old row plus the author's++ * Live profile), with the Community fields the SPA can use on top.++ */++function shape(c, ctx) {++ const a = c.author || null;++ const liveUserId = a && a.subject ? comments.liveUserForSubject(a.subject) : null;++ const u = liveUserId ? db.getUserById(liveUserId) : null;++ const edited = c.edited_at || null;++ const out = {++ id: c.id,++ content_type: ctx.type,++ content_id: ctx.id,++ user_id: u ? u.id : null,++ subject: a ? a.subject || null : null,++ parent_id: c.parent_id || null,++ message: c.deleted ? '' : c.message,++ is_deleted: c.deleted ? 1 : 0,++ deleted: !!c.deleted,++ created_at: c.created_at,++ updated_at: edited || c.created_at,++ edited_at: edited,++ username: c.deleted ? null : (u ? u.username : (a && a.username) || null),++ display_name: c.deleted ? null : (u ? u.display_name || u.username : c.display_name || (a && a.display_name) || null),++ avatar_url: c.deleted ? null : (u ? u.avatar_url : (a && a.avatar_url)) || null,++ profile_color: c.deleted ? null : (u ? u.profile_color : (a && a.profile_color)) || null,++ role: u && !c.deleted ? u.role : null,++ is_ai: !!(a && a.is_ai),++ reply_count: c.reply_count || 0,++ can_edit: !!(ctx.subject && a && a.subject === ctx.subject && !c.deleted),++ can_delete: !!deleteRight(ctx.user, ctx.subject, c, ctx.target),++ };++ if (c.replies) out.replies = c.replies.map((r) => shape(r, ctx));++ return out;++}++++/** Every reply of a top-level comment (Community nests the first 20; the rest are paged). */++async function allReplies(threadId, c, subject, ip) {++ const replies = [...(c.replies || [])];++ let after = replies.length ? replies[replies.length - 1].id : null;++ while (replies.length < Math.min(c.reply_count || 0, MAX_REPLIES)) {++ const page = await comments.readThread(threadId, { subject, parent: c.id, after, limit: 100, ip });++ replies.push(...page.comments);++ if (!page.next_cursor || !page.comments.length) break;++ after = page.next_cursor;++ }++ return replies;++}++++/** The Live VOD/clip a Community comment belongs to, or null (another product's comment, or gone). */++function liveRefOf(found) {++ const ref = found && found.thread && found.thread.ref;++ if (!ref || ref.service !== 'live' || !comments.TYPES.includes(ref.type) || !/^\d{1,15}$/.test(ref.id)) return null;++ return { type: ref.type, id: Number(ref.id) };++}++++// ── Get Replies ──────────────────────────────────────────────++// (Before /:type/:id, which would otherwise take "/<id>/replies".)++router.get('/:commentId/replies', optionalAuth, async (req, res) => {++ try {++ const subject = await viewerSubject(req.user);++ const found = await comments.getComment(req.params.commentId, subject, { ip: req.ip });++ const ref = liveRefOf(found);++ const target = ref && await visibleTarget(req.user, ref.type, ref.id);++ if (!target) return res.status(404).json({ error: 'Comment not found' });++ const ctx = { type: ref.type, id: ref.id, user: req.user, subject, target };++ // Replies nest one level: a reply has none of its own.++ const c = found.comment;++ const replies = c.parent_id ? [] : await allReplies(found.thread.id, { id: c.id, replies: [], reply_count: c.reply_count || 0 }, subject, req.ip);++ res.json({ replies: replies.map((r) => shape(r, ctx)) });++ } catch (err) {++ sendError(res, err, 'Failed to get replies');++ }++});++// ── List Comments ────────────────────────────────────────────router.get('/:type/:id', optionalAuth, async (req, res) => {try {@@@ -54,20 +166,42 @@ router.get('/:type/:id', optionalAuth, async (req, res) => {if (!['vod', 'clip'].includes(contentType) || !contentId) {return res.status(400).json({ error: 'Invalid content type or ID' });}-- if (!(await visibleTarget(req.user, contentType, contentId))) return res.status(404).json({ error: 'Content not found' });-- const limit = Math.min(parseInt(req.query.limit || '50'), 100);-- const offset = parseInt(req.query.offset || '0');-- const comments = db.getComments(contentType, contentId, limit, offset);-- const totalCount = db.getCommentCount(contentType, contentId);-- for (const c of comments) {-- const replies = db.getCommentReplies(c.id);-- c.replies = replies;-- c.reply_count = replies.length;++ const target = await visibleTarget(req.user, contentType, contentId);++ if (!target) return res.status(404).json({ error: 'Content not found' });++ const limit = Math.min(Math.max(parseInt(req.query.limit || '50') || 50, 1), 100);++ const offset = Math.min(Math.max(parseInt(req.query.offset || '0') || 0, 0), MAX_OFFSET);++++ const subject = await viewerSubject(req.user);++ const thread = await comments.threadFor(contentType, contentId, target.row.title, { ip: req.ip });++ const ctx = { type: contentType, id: contentId, user: req.user, subject, target };++++ // Newest first, like before; Community pages by cursor, so an offset is walked.++ const picked = [];++ let skipped = 0, after = null, total = 0;++ for (;;) {++ let page;++ try { page = await comments.readThread(thread.id, { subject, sort: 'new', limit: 100, after, ip: req.ip }); }++ catch (err) { if (err.status === 404) return res.status(404).json(HIDDEN); throw err; }++ // A thread Community's moderators hid is hidden here too (Live reads anonymous visitors' pages as itself, which sees it).++ if (page.thread && page.thread.visibility === 'hidden' && !permissions.isStaff(req.user)) return res.status(404).json(HIDDEN);++ total = page.thread ? page.thread.comment_count || 0 : 0;++ for (const c of page.comments) {++ if (skipped < offset) { skipped++; continue; }++ if (picked.length < limit) picked.push(c);++ }++ if (picked.length >= limit || !page.next_cursor) break;++ after = page.next_cursor;++ }++ for (const c of picked) {++ if ((c.reply_count || 0) > (c.replies || []).length) c.replies = await allReplies(thread.id, c, subject, req.ip);}-- res.json({ comments, total: totalCount });++ const out = { comments: picked.map((c) => shape(c, ctx)), total };++ // The same thread on Community's own page — offered for items anyone may see (the access id++ // is all it takes to read it there).++ if (!access.isPrivate(target.row)) out.thread = { id: thread.access_id, url: comments.threadUrl(thread.access_id) };++ res.json(out);} catch (err) {-- console.error('[Comments] List error:', err.message);-- res.status(500).json({ error: 'Failed to get comments' });++ sendError(res, err, 'Failed to get comments');}});@@@ -79,35 +213,28 @@ router.post('/:type/:id', requireAuth, async (req, res) => {if (!['vod', 'clip'].includes(contentType) || !contentId) {return res.status(400).json({ error: 'Invalid content type or ID' });}-- const message = (req.body.message || '').trim();-- if (!message || message.length > 2000) {-- return res.status(400).json({ error: 'Comment must be 1-2000 characters' });++ const message = String(req.body.message || '').trim();++ if (!message || message.length > MAX_MESSAGE) {++ return res.status(400).json({ error: `Comment must be 1-${MAX_MESSAGE} characters` });}const parentId = req.body.parent_id ? parseInt(req.body.parent_id) : null;const target = await visibleTarget(req.user, contentType, contentId);if (!target) return res.status(404).json({ error: 'Content not found' });++ const subject = await viewerSubject(req.user);++ if (!subject) return res.status(409).json({ error: 'This account is not linked to an OpenVibe account yet — sign in again and retry.' });++ const thread = await comments.threadFor(contentType, contentId, target.row.title, { ip: req.ip });++let parent = null;if (parentId) {-- parent = db.getCommentById(parentId);-- if (!parent || parent.content_type !== contentType || parent.content_id !== contentId) {++ parent = await comments.getComment(parentId, subject, { ip: req.ip });++ if (!parent || !parent.thread || parent.thread.id !== thread.id) {return res.status(400).json({ error: 'Invalid parent comment' });}}-- const result = db.createComment({-- content_type: contentType,-- content_id: contentId,-- user_id: req.user.id,-- parent_id: parentId,-- message,-- });---- const comment = db.get(`-- SELECT c.*, u.username, u.display_name, u.avatar_url, u.profile_color, u.role-- FROM comments c JOIN users u ON c.user_id = u.id-- WHERE c.id = ?-- `, [result.lastInsertRowid]);++ const out = await comments.addComment(thread.id, subject, { message, parentId }, { ip: req.ip });++ const comment = shape(out.comment, { type: contentType, id: contentId, user: req.user, subject, target });const actor = notificationActor(req.user);const recipients = new Map();@@@ -129,9 +256,11 @@ router.post('/:type/:id', requireAuth, async (req, res) => {},});}-- if (parent?.user_id && parent.user_id !== req.user.id) {-- recipients.set(parent.user_id, {-- user_id: parent.user_id,++ const parentAuthor = parent && parent.comment.author && parent.comment.author.subject++ ? comments.liveUserForSubject(parent.comment.author.subject) : null;++ if (parentAuthor && parentAuthor !== req.user.id) {++ recipients.set(parentAuthor, {++ user_id: parentAuthor,type: 'CONTENT_REPLY',category: 'social',priority: 'normal',@@@ -151,66 +280,72 @@ router.post('/:type/:id', requireAuth, async (req, res) => {res.status(201).json({ comment });} catch (err) {-- console.error('[Comments] Create error:', err.message);-- res.status(500).json({ error: 'Failed to post comment' });++ sendError(res, err, 'Failed to post comment');}});--// ── Get Replies ──────────────────────────────────────────────--router.get('/:commentId/replies', optionalAuth, async (req, res) => {-- try {-- const parent = db.getCommentById(parseInt(req.params.commentId));-- if (!parent || !(await visibleTarget(req.user, parent.content_type, parent.content_id))) {-- return res.status(404).json({ error: 'Comment not found' });-- }-- const replies = db.getCommentReplies(parent.id);-- res.json({ replies });-- } catch {-- res.status(500).json({ error: 'Failed to get replies' });-- }--});++/** The comment and its Live target, when this caller may see both; otherwise null (→ 404). */++async function commentForCaller(req, subject) {++ const found = await comments.getComment(req.params.commentId, subject, { ip: req.ip });++ const ref = liveRefOf(found);++ const target = ref && await visibleTarget(req.user, ref.type, ref.id);++ return target ? { found, ref, target } : null;++}--// ── Edit Comment ─────────────────────────────────────────────--router.put('/:commentId', requireAuth, (req, res) => {++// ── Edit Comment (the author only) ───────────────────────────++router.put('/:commentId', requireAuth, async (req, res) => {try {-- const comment = db.getCommentById(req.params.commentId);-- if (!comment) return res.status(404).json({ error: 'Comment not found' });-- if (comment.user_id !== req.user.id && req.user.role !== 'admin') {-- return res.status(403).json({ error: 'Not authorized' });++ const subject = await viewerSubject(req.user);++ const hit = await commentForCaller(req, subject);++ if (!hit) return res.status(404).json({ error: 'Comment not found' });++ const c = hit.found.comment;++ if (!subject || !c.author || c.author.subject !== subject) {++ return res.status(403).json({ error: 'Only the author can edit a comment' });}-- const message = (req.body.message || '').trim();-- if (!message || message.length > 2000) {-- return res.status(400).json({ error: 'Comment must be 1-2000 characters' });++ const message = String(req.body.message || '').trim();++ if (!message || message.length > MAX_MESSAGE) {++ return res.status(400).json({ error: `Comment must be 1-${MAX_MESSAGE} characters` });}-- db.updateComment(comment.id, message);++ await comments.editComment(c.id, subject, message, { ip: req.ip });res.json({ message: 'Comment updated' });-- } catch {-- res.status(500).json({ error: 'Failed to update comment' });++ } catch (err) {++ sendError(res, err, 'Failed to update comment');}});--// ── Delete Comment ───────────────────────────────────────────++// ── Delete Comment (author, staff, or the VOD's/clip's owners) ──router.delete('/:commentId', requireAuth, async (req, res) => {try {-- const comment = db.getCommentById(req.params.commentId);-- if (!comment) return res.status(404).json({ error: 'Comment not found' });---- let canDelete = (comment.user_id === req.user.id) || (req.user.role === 'admin');-- if (!canDelete) {-- const target = await getCommentTarget(comment.content_type, comment.content_id);-- if (target && target.user_id === req.user.id) canDelete = true;-- if (!canDelete && target?.stream_id) {-- const stream = db.getStreamById(target.stream_id);-- if (stream && stream.user_id === req.user.id) canDelete = true;-- }-- }-- if (!canDelete) return res.status(403).json({ error: 'Not authorized' });---- db.deleteComment(comment.id);++ const subject = await viewerSubject(req.user);++ const hit = await commentForCaller(req, subject);++ if (!hit) return res.status(404).json({ error: 'Comment not found' });++ let right = deleteRight(req.user, subject, hit.found.comment, hit.target);++ if (!right) return res.status(403).json({ error: 'Not authorized' });++ if (right === 'staff' && !subject) right = 'owner'; // staff without a linked subject: Live moderates for them++ await comments.deleteComment(hit.found.comment.id, { subject, as: right, ip: req.ip });res.json({ message: 'Comment deleted' });-- } catch {-- res.status(500).json({ error: 'Failed to delete comment' });++ } catch (err) {++ sendError(res, err, 'Failed to delete comment');}});++/**++ * The comment count of a VOD/clip for detail responses: a number, or null when Community is++ * unreachable. Short timeouts, and after a failure the count is skipped for a while, so a++ * Community outage never slows VOD and clip pages down.++ */++let countsDownUntil = 0;++async function commentCount(contentType, contentId, label, ip = null) {++ if (Date.now() < countsDownUntil) return null;++ try {++ const thread = await comments.threadFor(contentType, contentId, label, { timeoutMs: 1500, ip });++ const page = await comments.readThread(thread.id, { limit: 1, timeoutMs: 1500, ip });++ return page.thread ? page.thread.comment_count || 0 : 0;++ } catch (err) {++ if (err && err.status === 503) countsDownUntil = Date.now() + 30_000;++ return null;++ }++}++module.exports = router;++module.exports.commentCount = commentCount;@@@ -16,6 +16,8 @@ const recorder = require('../streaming/recorder');const { requireAuth, optionalAuth } = require('../auth/auth');const permissions = require('../auth/permissions');const access = require('./access');++const commentsClient = require('../comments-client');++const { commentCount } = require('./comments');const router = express.Router();@@@ -350,7 +352,7 @@ router.post('/bulk-delete-old', requireAuth, async (req, res) => {const out = await media.listVods({ user_id: req.user.id, include_private: 1, limit: 1000 });for (const v of (out?.vods || [])) {if (!tooOld(v) || v.is_recording) continue;-- if (action === 'delete') await media.deleteVod(v.id).catch(() => {});++ if (action === 'delete') { if (await media.deleteVod(v.id).then(() => true, () => false)) commentsClient.hideThreadOf('vod', v.id); }else await media.updateVod(v.id, { visibility: action }).catch(() => {});vodCount++;}@@@ -359,7 +361,7 @@ router.post('/bulk-delete-old', requireAuth, async (req, res) => {const out = await media.listClips({ user_id: req.user.id, include_private: 1, limit: 1000 });for (const c of (out?.clips || [])) {if (!tooOld(c)) continue;-- if (action === 'delete') await media.deleteClip(c.id).catch(() => {});++ if (action === 'delete') { if (await media.deleteClip(c.id).then(() => true, () => false)) commentsClient.hideThreadOf('clip', c.id); }else await media.updateClip(c.id, { visibility: action }).catch(() => {});clipCount++;}@@@ -508,7 +510,7 @@ router.get('/:id', optionalAuth, async (req, res) => {}} catch { /* */ }-- vod.comment_count = db.getCommentCount('vod', vod.id);++ vod.comment_count = await commentCount('vod', vod.id, vod.title, req.ip); // null when Community is unreachableres.json({ vod, clips });} catch {res.status(500).json({ error: 'Failed to get VOD' });@@@ -549,7 +551,7 @@ router.post('/bulk', requireAuth, async (req, res) => {if (!owns) owns = permissions.canModerateContentOwner(req.user, vod.user_id ? db.getUserById(vod.user_id) : null);if (!owns) { skipped++; continue; }try {-- if (action === 'delete') await media.deleteVod(id);++ if (action === 'delete') { await media.deleteVod(id); commentsClient.hideThreadOf('vod', id); }else await media.updateVod(id, { visibility: action });done++;} catch { skipped++; }@@@ -565,6 +567,7 @@ router.delete('/:id', requireAuth, async (req, res) => {if (!vod) return;try {await media.deleteVod(req.params.id);++ commentsClient.hideThreadOf('vod', Number(req.params.id));res.json({ message: 'VOD deleted' });} catch (err) {mediaErr(res, err, 'Failed to delete VOD');nnew file mode 100644@@@ -0,0 +1,280 @@++/**++ * VOD and clip comments are OpenVibe.Community threads (roadmap Wave 5). /api/comments keeps its++ * paths and response shapes but is an adapter (server/comments-client.js): the thread of++ * { service: 'live', type, id } is resolved once with Live's service token and remembered; people++ * read, comment and edit as their Network subject (X-OV-Subject); deletes go as the author, as++ * staff (X-OV-Staff) or, for the item's owners, as Live itself; deleting a VOD hides its thread; a++ * Community outage is a 503 "comments are unavailable", never an empty list; and Live's own++ * comments table is neither read nor written.++ *++ * The real routers run on a temp database with Media stubbed, Community replaced by++ * test/community-stub.js and sign-in stubbed by an `x-test-user` header.++ *++ * node test/comments-community.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 = path.join(os.tmpdir(), `ov-comments-community-${process.pid}.db`);++process.env.DB_PATH = tmp;++process.env.NODE_ENV = 'test';++process.env.OV_COMMUNITY_URL = 'https://openvibe.community';++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++console.warn = () => {};++++const db = require('../server/db/database');++db.initDb();++const raw = db.getDb();++++const auth = require('../server/auth/auth');++const signIn = (req) => {++ const id = Number(req.headers['x-test-user'] || 0);++ const u = id ? db.getUserById(id) : null;++ if (u) { req.user = u; req.authSource = 'network'; }++ return u;++};++auth.requireAuth = (req, res, next) => (signIn(req) ? next() : res.status(401).json({ error: 'Authentication required' }));++auth.optionalAuth = (req, res, next) => { signIn(req); next(); };++++const notify = require('../server/utils/notify');++const pushed = [];++notify.pushNotification = (p) => { pushed.push(p); };++const principal = require('../server/net/network-principal');++principal.serviceHeaders = async () => ({ Authorization: 'Bearer test-service-token' });++++const SUB = (c) => `usr_01JAB2C3D4E5F6G7H8J9K0MNP${c}`;++const addUser = (id, username, role, subject) => {++ raw.prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role, profile_color, created_at)++ VALUES (?, ?, ?, ?, 'x', ?, '#123456', '2025-01-01 00:00:00')`).run(id, username, username.toUpperCase(), `${username}@x`, role);++ if (subject) raw.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, 'network', ?, ?)").run(id, String(100 + id), subject);++};++addUser(1, 'admin', 'admin', SUB('A'));++addUser(3, 'alice', 'streamer', SUB('B')); // owns VOD 100 and the stream clip 200 came from++addUser(5, 'carol', 'user', SUB('C')); // made clip 200++addUser(7, 'mallory', 'user', SUB('E')); // nobody special++addUser(9, 'nolink', 'user', null); // no Network subject on file++db.ensureChannel(3);++const streamA = Number(db.createStream({ user_id: 3, channel_id: db.getChannelByUserId(3).id, title: 'A', protocol: 'webrtc' }).lastInsertRowid);++++// A legacy row in Live's frozen comments table: never shown, never touched.++raw.prepare("INSERT INTO comments (content_type, content_id, user_id, message) VALUES ('vod', 100, 5, 'legacy local row')").run();++const legacyBefore = raw.prepare('SELECT * FROM comments').all();++++const media = require('../server/media-client');++const VODS = {++ 100: { id: 100, user_id: 3, title: 'Public VOD', visibility: 'public', is_public: 1, status: 'ready' },++ 101: { id: 101, user_id: 3, title: 'Private VOD', visibility: 'private', is_public: 0, status: 'ready' },++ 102: { id: 102, user_id: 3, title: 'Unlisted VOD', visibility: 'unlisted', is_public: 0, status: 'ready' },++};++const CLIPS = { 200: { id: 200, user_id: 5, stream_id: streamA, title: 'Public clip', visibility: 'public', is_public: 1, status: 'ready' } };++const missing = (what) => new media.MediaApiError(`${what} not found`, 404, { error: `${what} not found` });++media.getVod = async (id) => { const v = VODS[Number(id)]; if (!v) throw missing('VOD'); return { ...v }; };++media.getClip = async (id) => { const c = CLIPS[Number(id)]; if (!c) throw missing('Clip'); return { ...c }; };++media.listClips = async () => ({ clips: [] });++media.deleteVod = async (id) => { delete VODS[Number(id)]; return {}; };++++const express = require('express');++const app = express();++app.use(express.json());++app.use('/api/vods', require('../server/media-proxy/vods'));++app.use('/api/clips', require('../server/media-proxy/clips'));++app.use('/api/comments', require('../server/media-proxy/comments'));++const commentsClient = require('../server/comments-client');++const server = http.createServer(app).listen(0);++++function call(method, p, user, body) {++ return new Promise((resolve, reject) => {++ const data = body ? JSON.stringify(body) : null;++ const headers = { 'content-type': 'application/json' };++ if (user) headers['x-test-user'] = String(user);++ const req = http.request({ port: server.address().port, path: p, method, headers }, (res) => {++ let text = '';++ res.on('data', (c) => { text += c; });++ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json, text }); });++ });++ req.on('error', reject);++ if (data) req.write(data);++ req.end();++ });++}++++let failures = 0;++async function check(name, fn) {++ try { await fn(); quiet(' ✓', name); }++ catch (e) { failures++; quiet(' ✗', name, '\n ', e.stack.split('\n').slice(0, 3).join('\n ')); }++}++++(async () => {++ await new Promise((r) => server.once('listening', r));++ const stub = await (require('./community-stub').startCommunityStub({ people: { [SUB('Z')]: { username: 'zed', display_name: 'Zed from Community' } } }));++ process.env.OV_COMMUNITY_INTERNAL_URL = stub.url;++ const last = (method, re) => [...stub.calls].reverse().find((c) => c.method === method && re.test(c.path));++ let carolTop, aliceReply;++++ await check('list: the thread of live/vod/<id> is resolved once (labelled, remembered), with a link to the same thread on Community', async () => {++ const r = await call('GET', '/api/comments/vod/100');++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(r.json.comments, []);++ assert.strictEqual(r.json.total, 0);++ const resolve = last('POST', /^\/threads\/resolve$/);++ assert.deepStrictEqual(resolve.body, { ref: { service: 'live', type: 'vod', id: '100', label: 'Public VOD' } });++ assert.ok(resolve.auth, 'with Live\'s service token');++ assert.match(r.json.thread.id, /^cth_/);++ assert.strictEqual(r.json.thread.url, `https://openvibe.community/c/${r.json.thread.id}`);++ const n = stub.calls.filter((c) => c.path === '/threads/resolve').length;++ await call('GET', '/api/comments/vod/100', 5);++ assert.strictEqual(stub.calls.filter((c) => c.path === '/threads/resolve').length, n, 'remembered in comment_thread_refs');++ assert.strictEqual(raw.prepare('SELECT COUNT(*) AS n FROM comment_thread_refs').get().n, 1);++ assert.ok((await call('GET', '/api/comments/vod/102')).json.thread, 'unlisted items are linked too');++ assert.strictEqual((await call('GET', '/api/comments/vod/101', 3)).json.thread, undefined, 'private items never are');++ assert.strictEqual((await call('GET', '/api/comments/vod/101')).status, 404);++ });++++ await check('post: as the signed-in person\'s subject, the old response shape, notifications to the owner and the parent\'s author', async () => {++ const r = await call('POST', '/api/comments/vod/100', 5, { message: ' great stream ' });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(last('POST', /\/comments$/).subject, SUB('C'));++ assert.ok(last('POST', /\/comments$/).xff, 'the visitor\'s address rides along (Community limits per address)');++ carolTop = r.json.comment;++ for (const [k, v] of Object.entries({ content_type: 'vod', content_id: 100, user_id: 5, username: 'carol', display_name: 'CAROL', profile_color: '#123456', role: 'user', message: 'great stream', parent_id: null, is_deleted: 0, can_edit: true, can_delete: true })) {++ assert.deepStrictEqual(carolTop[k], v, k);++ }++ assert.strictEqual(carolTop.updated_at, carolTop.created_at, 'not edited');++ assert.deepStrictEqual(pushed.map((p) => [p.user_id, p.type]), [[3, 'CONTENT_COMMENT']]);++++ const reply = await call('POST', '/api/comments/vod/100', 3, { message: 'thanks!', parent_id: carolTop.id });++ assert.strictEqual(reply.status, 201, reply.text);++ aliceReply = reply.json.comment;++ assert.strictEqual(aliceReply.parent_id, carolTop.id);++ assert.deepStrictEqual(pushed.slice(1).map((p) => [p.user_id, p.type]), [[5, 'CONTENT_REPLY']], 'the parent\'s author, found by subject');++++ assert.strictEqual((await call('POST', '/api/comments/vod/100', null, { message: 'anon' })).status, 401, 'signed in only, as before');++ assert.strictEqual((await call('POST', '/api/comments/vod/100', 9, { message: 'no subject' })).status, 409);++ assert.strictEqual((await call('POST', '/api/comments/vod/100', 5, { message: 'x'.repeat(2001) })).status, 400);++ assert.strictEqual((await call('POST', '/api/comments/vod/100', 5, { message: ' ' })).status, 400);++ const clipComment = (await call('POST', '/api/comments/clip/200', 5, { message: 'on the clip' })).json.comment;++ const cross = await call('POST', '/api/comments/vod/100', 5, { message: 'cross', parent_id: clipComment.id });++ assert.strictEqual(cross.status, 400, 'a parent from another thread');++ assert.strictEqual((await call('POST', '/api/comments/vod/101', 7, { message: 'hi' })).status, 404);++ });++++ await check('list: newest first, replies nested, total, people who only use Community, edits marked, limit/offset', async () => {++ const vodThread = stub.threads.find((t) => t.ref.type === 'vod' && t.ref.id === '100');++ stub.comments.push({ id: stub.comments.length + 1, thread_id: vodThread.id, parent_id: null, author: SUB('Z'), message: 'said on Community', created_at: '2026-09-02T00:00:00.000Z', edited_at: null, deleted_at: null });++ const r = await call('GET', '/api/comments/vod/100', 5);++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json.total, 3);++ assert.deepStrictEqual(r.json.comments.map((c) => c.message), ['said on Community', 'great stream']);++ const zed = r.json.comments[0];++ assert.deepStrictEqual([zed.user_id, zed.username, zed.display_name, zed.subject, zed.can_edit, zed.can_delete], [null, 'zed', 'Zed from Community', SUB('Z'), false, false]);++ assert.deepStrictEqual(r.json.comments[1].replies.map((c) => [c.message, c.username, c.can_delete]), [['thanks!', 'alice', false]]);++ const asOwner = (await call('GET', '/api/comments/vod/100', 3)).json;++ assert.ok(asOwner.comments.every((c) => c.can_delete), 'the VOD\'s owner may delete any comment on it');++ assert.ok(!asOwner.comments[0].can_edit, 'but edit only their own');++ assert.strictEqual(last('GET', /^\/threads\/\d+$/).subject, SUB('B'), 'read as the viewer');++ const page = (await call('GET', '/api/comments/vod/100?limit=1&offset=1')).json;++ assert.deepStrictEqual(page.comments.map((c) => c.message), ['great stream']);++ assert.ok(!r.text.includes('legacy local row'), 'Live\'s own table is not read');++ });++++ await check('replies: /:commentId/replies answers for Live comments the caller may see', async () => {++ const r = await call('GET', `/api/comments/${carolTop.id}/replies`);++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(r.json.replies.map((c) => c.message), ['thanks!']);++ assert.deepStrictEqual((await call('GET', `/api/comments/${aliceReply.id}/replies`)).json.replies, []);++ assert.strictEqual((await call('GET', '/api/comments/424242/replies')).status, 404);++ });++++ await check('edit: the author only (as their subject); edits show as edited', async () => {++ assert.strictEqual((await call('PUT', `/api/comments/${carolTop.id}`, 7, { message: 'hijack' })).status, 403);++ assert.strictEqual((await call('PUT', `/api/comments/${carolTop.id}`, 1, { message: 'staff rewrite' })).status, 403, 'staff delete, they do not rewrite');++ assert.strictEqual((await call('PUT', `/api/comments/${carolTop.id}`, 5, { message: '' })).status, 400);++ const ok = await call('PUT', `/api/comments/${carolTop.id}`, 5, { message: 'great stream!!' });++ assert.strictEqual(ok.status, 200, ok.text);++ assert.deepStrictEqual(ok.json, { message: 'Comment updated' });++ assert.strictEqual(last('PATCH', /^\/\d+$/).subject, SUB('C'));++ const c = (await call('GET', '/api/comments/vod/100')).json.comments.find((x) => x.id === carolTop.id);++ assert.strictEqual(c.message, 'great stream!!');++ assert.ok(c.edited_at && c.updated_at !== c.created_at, 'the SPA shows "(edited)"');++ });++++ await check('delete: strangers 403; the author as themselves; staff vouched as staff; the item\'s owners through Live itself', async () => {++ assert.strictEqual((await call('DELETE', `/api/comments/${carolTop.id}`, 7)).status, 403);++ const extra = (await call('POST', '/api/comments/vod/100', 7, { message: 'mallory was here' })).json.comment;++ assert.strictEqual((await call('DELETE', `/api/comments/${extra.id}`, 7)).status, 200);++ let d = last('DELETE', /^\/\d+$/);++ assert.deepStrictEqual([d.subject, d.staff], [SUB('E'), false]);++ const byStaff = (await call('POST', '/api/comments/vod/100', 7, { message: 'staff will remove this' })).json.comment;++ assert.strictEqual((await call('DELETE', `/api/comments/${byStaff.id}`, 1)).status, 200);++ d = last('DELETE', /^\/\d+$/);++ assert.deepStrictEqual([d.subject, d.staff], [SUB('A'), true]);++ const onClip = (await call('POST', '/api/comments/clip/200', 7, { message: 'rude clip comment' })).json.comment;++ assert.strictEqual((await call('DELETE', `/api/comments/${onClip.id}`, 3)).status, 200, 'the streamer the clip was cut from');++ d = last('DELETE', /^\/\d+$/);++ assert.deepStrictEqual([d.subject, d.staff], [null, false], 'Live moderates as itself');++ // A top-level comment with replies stays as a tombstone.++ assert.strictEqual((await call('DELETE', `/api/comments/${carolTop.id}`, 3)).status, 200);++ const list = (await call('GET', '/api/comments/vod/100')).json;++ const tomb = list.comments.find((c) => c.id === carolTop.id);++ assert.deepStrictEqual([tomb.deleted, tomb.is_deleted, tomb.message, tomb.username, tomb.can_delete], [true, 1, '', null, false]);++ assert.deepStrictEqual(tomb.replies.map((c) => c.message), ['thanks!']);++ assert.strictEqual((await call('DELETE', `/api/comments/${carolTop.id}`, 3)).status, 404, 'gone');++ });++++ await check('a thread Community\'s moderators hid is hidden on Live too, whoever asks', async () => {++ const t = stub.threads.find((x) => x.ref.type === 'clip' && x.ref.id === '200');++ t.visibility = 'hidden';++ for (const user of [null, 5]) {++ const r = await call('GET', '/api/comments/clip/200', user);++ assert.deepStrictEqual([r.status, r.json], [404, { error: 'Comments are hidden here', code: 'comments_hidden' }], `user ${user}`);++ }++ t.visibility = 'public';++ assert.strictEqual((await call('GET', '/api/comments/clip/200')).status, 200);++ });++++ await check('Community down: every route says comments are unavailable (503), never an empty list; VOD pages still load, fast', async () => {++ stub.down = true;++ const unavailable = { error: 'Comments are unavailable right now', code: 'comments_unavailable' };++ for (const [method, p, user, body] of [++ ['GET', '/api/comments/vod/100', null], ['GET', '/api/comments/vod/100', 5], ['POST', '/api/comments/vod/100', 5, { message: 'hello?' }],++ ['PUT', `/api/comments/${aliceReply.id}`, 3, { message: 'x' }], ['DELETE', `/api/comments/${aliceReply.id}`, 3], ['GET', `/api/comments/${aliceReply.id}/replies`, null],++ ]) {++ const r = await call(method, p, user, body);++ assert.deepStrictEqual([r.status, r.json], [503, unavailable], `${method} ${p}`);++ }++ const t0 = Date.now();++ const vod = await call('GET', '/api/vods/100');++ assert.strictEqual(vod.status, 200);++ assert.strictEqual(vod.json.vod.comment_count, null, 'unknown, not 0');++ const again = Date.now();++ assert.strictEqual((await call('GET', '/api/vods/100')).json.vod.comment_count, null);++ assert.ok(Date.now() - again < 1000 && again - t0 < 5000, 'no waiting on a Community that is down');++ stub.down = false;++ });++++ await check('deleting a VOD hides its Community thread', async () => {++ const r = await call('DELETE', '/api/vods/100', 3);++ assert.strictEqual(r.status, 200, r.text);++ await commentsClient.hideThreadOf('none', 0); // the queue: wait for the background hide++ const t = stub.threads.find((x) => x.ref.type === 'vod' && x.ref.id === '100');++ assert.strictEqual(t.visibility, 'hidden');++ const put = last('PUT', /\/visibility$/);++ assert.deepStrictEqual([put.subject, put.body], [null, { visibility: 'hidden' }]);++ });++++ await check('Live\'s own comments table was neither read nor written', async () => {++ assert.deepStrictEqual(raw.prepare('SELECT * FROM comments').all(), legacyBefore);++ });++++ server.close();++ await stub.close();++ for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }++ if (failures) { quiet(`\n${failures} check(s) failed`); process.exit(1); }++ quiet('comments on Community: all checks passed');++ process.exit(0);++})().catch((err) => { quiet(err); server.close(); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,136 @@++'use strict';++/**++ * An in-process stand-in for OpenVibe.Community's comment API (/api/v1/comments), for Live tests++ * of the comments adapter (server/comments-client.js). It follows Community's contract as a service++ * caller sees it: resolve by EntityRef (sequential id + access id), threads newest/oldest first with++ * one level of nested replies and an ?after= cursor, ?parent= reply pages, comments as X-OV-Subject++ * (none on live vod/clip without a subject), GET/PATCH/DELETE /:commentId, X-OV-Staff, visibility.++ *++ * const stub = await startCommunityStub({ people: { usr_…: { username, display_name } } });++ * process.env.OV_COMMUNITY_INTERNAL_URL = stub.url;++ * stub.down = true; // every request fails (connection reset) until set back++ * stub.calls // [{ method, path, subject, staff, body }]++ */++const http = require('http');++const crypto = require('crypto');++++function startCommunityStub({ people = {} } = {}) {++ const threads = []; // { id, access_id, ref, visibility, comment_count }++ const comments = []; // { id, thread_id, parent_id, author, message, created_at, edited_at, deleted_at }++ const calls = [];++ const state = { down: false };++ let clock = Date.parse('2026-09-01T00:00:00Z');++ const now = () => new Date(clock += 1000).toISOString();++++ const personOf = (subject) => (subject ? { subject, username: (people[subject] || {}).username || null, display_name: (people[subject] || {}).display_name || null, avatar_url: null, profile_color: null } : null);++ const threadBy = (id) => threads.find((t) => String(t.id) === String(id) || t.access_id === id) || null;++ const replyCount = (c) => comments.filter((r) => r.parent_id === c.id && !r.deleted_at).length;++ const shapeThread = (t) => ({ id: t.id, access_id: t.access_id, ref: t.ref, visibility: t.visibility, comment_count: comments.filter((c) => c.thread_id === t.id && !c.deleted_at).length });++ function shapeComment(c, subject, withReplies) {++ const deleted = !!c.deleted_at;++ const out = {++ id: c.id, thread_id: c.thread_id, parent_id: c.parent_id, origin: 'user',++ author: deleted ? null : personOf(c.author), anon_name: null,++ display_name: deleted ? null : ((people[c.author] || {}).display_name || null),++ message: deleted ? null : c.message, deleted, score: 0, upvotes: 0, downvotes: 0, my_vote: 0,++ reply_count: replyCount(c), created_at: c.created_at, updated_at: c.edited_at || c.created_at, edited_at: deleted ? null : c.edited_at,++ can_edit: !deleted && !!subject && c.author === subject, can_delete: !deleted && !!subject && c.author === subject,++ };++ if (withReplies) out.replies = comments.filter((r) => r.parent_id === c.id && !r.deleted_at).sort((a, b) => a.id - b.id).slice(0, 20).map((r) => shapeComment(r, subject, false));++ return out;++ }++++ const server = http.createServer((req, res) => {++ let raw = '';++ req.on('data', (c) => { raw += c; });++ req.on('end', () => {++ if (state.down) { req.socket.destroy(); return; }++ const url = new URL(req.url, 'http://x');++ const p = url.pathname.replace(/^\/api\/v1\/comments/, '');++ const subject = req.headers['x-ov-subject'] || null;++ const staff = req.headers['x-ov-staff'] === '1';++ const body = raw ? JSON.parse(raw) : undefined;++ calls.push({ method: req.method, path: p, query: url.search, subject, staff, body, auth: req.headers.authorization || null, xff: req.headers['x-forwarded-for'] || null });++ const send = (status, obj) => { res.statusCode = status; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(obj)); };++ const problem = (status, code, detail) => send(status, { type: 'about:blank', status, code, detail, error: detail });++ if (!/^Bearer /.test(req.headers.authorization || '')) return problem(401, 'token.missing', 'service token required');++ const moderator = !subject || staff;++ let m;++++ if (req.method === 'POST' && p === '/threads/resolve') {++ const ref = body.ref;++ let t = threads.find((x) => x.ref.service === ref.service && x.ref.type === ref.type && x.ref.id === ref.id);++ if (t) { if (ref.label) t.ref.label = ref.label; return send(200, { thread: shapeThread(t), created: false }); }++ t = { id: threads.length + 1, access_id: `cth_${crypto.randomBytes(16).toString('base64url')}`, ref: { ...ref }, visibility: 'public' };++ threads.push(t);++ return send(201, { thread: shapeThread(t), created: true });++ }++ if ((m = p.match(/^\/threads\/([^/]+)$/)) && req.method === 'GET') {++ const t = threadBy(m[1]);++ if (!t || (t.visibility === 'hidden' && !moderator)) return problem(404, 'thread.not_found', 'Comment thread not found');++ const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit'), 10) || 30, 1), 100);++ const after = url.searchParams.get('after') ? Number(url.searchParams.get('after')) : null;++ const parent = url.searchParams.get('parent');++ let rows;++ if (parent) {++ rows = comments.filter((c) => c.parent_id === Number(parent) && !c.deleted_at && (after == null || c.id > after)).sort((a, b) => a.id - b.id);++ } else {++ const newest = url.searchParams.get('sort') === 'new';++ rows = comments.filter((c) => c.thread_id === t.id && !c.parent_id && (!c.deleted_at || replyCount(c) > 0) && (after == null || (newest ? c.id < after : c.id > after)))++ .sort((a, b) => (newest ? b.id - a.id : a.id - b.id));++ }++ const more = rows.length > limit;++ rows = rows.slice(0, limit);++ return send(200, { thread: shapeThread(t), comments: rows.map((c) => shapeComment(c, subject, !parent)), next_cursor: more ? String(rows[rows.length - 1].id) : null, viewer: {} });++ }++ if ((m = p.match(/^\/threads\/([^/]+)\/comments$/)) && req.method === 'POST') {++ const t = threadBy(m[1]);++ if (!t || t.visibility === 'hidden') return problem(404, 'thread.not_found', 'Comment thread not found');++ if (!subject && t.ref.service === 'live') return problem(401, 'auth.required', 'Sign in to comment here');++ let parentId = null;++ if (body.parent_id) {++ const parent = comments.find((c) => c.id === Number(body.parent_id));++ if (!parent || parent.thread_id !== t.id || parent.deleted_at) return problem(400, 'comment.invalid_parent', 'Invalid parent comment');++ parentId = parent.parent_id || parent.id;++ }++ const c = { id: comments.length + 1, thread_id: t.id, parent_id: parentId, author: subject, message: String(body.message).trim(), created_at: now(), edited_at: null, deleted_at: null };++ comments.push(c);++ return send(201, { comment: shapeComment(c, subject, false) });++ }++ if ((m = p.match(/^\/threads\/([^/]+)\/visibility$/)) && req.method === 'PUT') {++ const t = threadBy(m[1]);++ if (!t) return problem(404, 'thread.not_found', 'Comment thread not found');++ if (!moderator) return problem(403, 'capability.denied', 'moderators only');++ t.visibility = body.visibility;++ return send(200, { thread: shapeThread(t) });++ }++ if ((m = p.match(/^\/(\d+)$/))) {++ const c = comments.find((x) => x.id === Number(m[1]));++ const t = c && threads.find((x) => x.id === c.thread_id);++ if (!c || c.deleted_at || (t.visibility === 'hidden' && !moderator)) return problem(404, 'comment.not_found', 'Comment not found');++ if (req.method === 'GET') return send(200, { comment: shapeComment(c, subject, false), thread: shapeThread(t) });++ if (req.method === 'PATCH') {++ if (!subject || c.author !== subject) return problem(403, 'comment.not_yours', 'Only the author can edit a comment');++ c.message = String(body.message).trim();++ c.edited_at = now();++ return send(200, { comment: shapeComment(c, subject, false) });++ }++ if (req.method === 'DELETE') {++ if (!(subject && c.author === subject) && !moderator) return problem(403, 'comment.not_yours', 'Not authorized to delete this comment');++ c.deleted_at = now();++ return send(200, { ok: true, id: c.id });++ }++ }++ return problem(404, 'route.not_found', 'Not found');++ });++ });++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({++ url: `http://127.0.0.1:${server.address().port}`,++ threads, comments, calls,++ get down() { return state.down; },++ set down(v) { state.down = !!v; },++ close: () => new Promise((r) => { server.closeAllConnections?.(); server.close(r); }),++ })));++}++++module.exports = { startCommunityStub };@@@ -2,7 +2,8 @@// Live's legacy vods/clips/pastes tables are frozen: Media and Community own that data now// (AGENTS.md, Wave 22 register C-73). Live may read them for pre-cutover pages, but no server code--// may write them. The only writes allowed are the two guarded one-time visibility backfills that++// may write them. The VOD/clip `comments` table is frozen too: comments are OpenVibe.Community++// threads (roadmap Wave 5), and the old rows are only read by Community's one-time import. The only writes allowed are the two guarded one-time visibility backfills that// run when the column is first added.const assert = require('assert');@@@ -10,7 +11,7 @@ const fs = require('fs');const path = require('path');const ROOT = path.join(__dirname, '..');--const WRITE = /\b(INSERT(?:\s+OR\s+\w+)?\s+INTO|UPDATE|DELETE\s+FROM|REPLACE\s+INTO)\s+(vods|clips|pastes|paste_likes)\b/i;++const WRITE = /\b(INSERT(?:\s+OR\s+\w+)?\s+INTO|UPDATE|DELETE\s+FROM|REPLACE\s+INTO)\s+(vods|clips|pastes|paste_likes|comments)\b/i;const ALLOWED = ["UPDATE vods SET visibility = CASE WHEN is_public = 1 THEN 'public' ELSE 'private' END","UPDATE clips SET visibility = CASE WHEN is_public = 1 THEN 'public' ELSE 'unlisted' END",@@@ -78,6 +78,13 @@ media.createClip = async () => ({ id: 999, status: 'processing' });media.updateVod = async () => ({});media.updateClip = async () => ({});media.deleteClip = async () => ({});++// Comments are OpenVibe.Community threads: a stub Community (started below) and a stub service token.++const principal = require('../server/net/network-principal');++principal.serviceHeaders = async () => ({ Authorization: 'Bearer test-service-token' });++const { startCommunityStub } = require('./community-stub');++for (const [id, n] of [[1, 'A'], [3, 'B'], [5, 'C'], [6, 'D'], [7, 'E'], [8, 'F']]) {++ raw.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, 'network', ?, ?)").run(id, String(100 + id), `usr_01JAB2C3D4E5F6G7H8J9K0MNP${n}`);++}const recorder = require('../server/streaming/recorder');recorder.getActiveRecording = (sid) => (Number(sid) === streamA ? { vodId: 101, startedAt: Date.now() - 60000 } : null);@@@ -130,6 +137,8 @@ async function check(name, fn) {(async () => {await new Promise((r) => server.once('listening', r));++ const community = await startCommunityStub();++ process.env.OV_COMMUNITY_INTERNAL_URL = community.url;await check('VOD detail: private and legacy-private look missing to anonymous and other users', async () => {for (const user of [null, 7, 5]) {@@@ -211,9 +220,12 @@ async function check(name, fn) {await sameAsMissing('GET', '/api/comments/vod/101', '/api/comments/vod/999', null);await sameAsMissing('GET', '/api/comments/clip/201', '/api/comments/clip/999', 7);await sameAsMissing('POST', '/api/comments/vod/101', '/api/comments/vod/999', 7, { message: 'hi' });-- // (/:commentId/replies is shadowed by /:type/:id today and answers 400; either way nothing leaks.)-- const r = await call('GET', `/api/comments/${posted.json.comment.id}/replies`);-- assert.ok(r.status >= 400 && !/secret/.test(r.text), 'replies under a private VOD');++ await sameAsMissing('GET', `/api/comments/${posted.json.comment.id}/replies`, '/api/comments/999999/replies', null);++ await sameAsMissing('PUT', `/api/comments/${posted.json.comment.id}`, '/api/comments/999999', 7, { message: 'mine now' });++ await sameAsMissing('DELETE', `/api/comments/${posted.json.comment.id}`, '/api/comments/999999', 7);++ assert.strictEqual((await call('GET', `/api/comments/${posted.json.comment.id}/replies`, 3)).status, 200, 'the owner reads them');++ const own = await call('GET', '/api/comments/vod/101', 3);++ assert.strictEqual(own.json.thread, undefined, 'a private item\'s thread is never linked on Community');assert.strictEqual((await call('GET', '/api/comments/vod/101', 6)).status, 200, 'staff read them');assert.strictEqual((await call('GET', '/api/comments/vod/100')).status, 200, 'public item comments stay open');});@@@ -251,6 +263,7 @@ async function check(name, fn) {});server.close();++ await community.close();for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }if (failures) { quiet(`\n${failures} check(s) failed`); process.exit(1); }quiet('media privacy: all checks passed');
@@ -132,6 +132,10 @@ OPS_ALERT_WEBHOOK_URL=
@@ -132,6 +132,10 @@ OPS_ALERT_WEBHOOK_URL=
# Pastes are served by openvibe.community when this is 1 (Live 301s /p/:slug and /pastes there)
# Pastes are served by openvibe.community when this is 1 (Live 301s /p/:slug and /pastes there)
PASTES_ON_COMMUNITY=0
PASTES_ON_COMMUNITY=0
OV_COMMUNITY_URL=https://openvibe.community
OV_COMMUNITY_URL=https://openvibe.community
+# Community's loopback API: pastes (PASTES_AUTHORITY=community) and VOD/clip comments, which are
+# Community threads (server/comments-client.js; Live's service token needs community.comment.write
+# and community.comment.moderate on openvibe.community)
+OV_COMMUNITY_INTERNAL_URL=http://127.0.0.1:4200
# Chat runs in OpenVibe.Chat when this is "chat" (roadmap W6; server/chat/chat-authority.js): Live
# Chat runs in OpenVibe.Chat when this is "chat" (roadmap W6; server/chat/chat-authority.js): Live
# stops serving /ws/chat and /api/chat|dm|tts|sounds (nginx sends them to Chat), forwards its own
# stops serving /ws/chat and /api/chat|dm|tts|sounds (nginx sends them to Chat), forwards its own
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
Self-hosted live streaming platform — Node.js/Express monolith, vanilla JS SPA frontend, SQLite (better-sqlite3). Part of the **OpenVibe network** with SSO via openvibe.network. See [README.md](README.md) for features and [docs/architecture.md](docs/architecture.md) for system design.
Self-hosted live streaming platform — Node.js/Express monolith, vanilla JS SPA frontend, SQLite (better-sqlite3). Part of the **OpenVibe network** with SSO via openvibe.network. See [README.md](README.md) for features and [docs/architecture.md](docs/architecture.md) for system design.
-**Network context:** SSO/OAuth2 + the OpenCoins wallet come from **OpenVibe.Network** (`server/monetization/wallet-client.js`). The media subsystem (VODs/clips/pastes/thumbnails/files) lives in **OpenVibe.Media** — Live talks to it via [server/media-client.js](server/media-client.js), the thin proxy routers in [server/media-proxy/](server/media-proxy/), and the Media-backed recorder [server/streaming/recorder.js](server/streaming/recorder.js). Media completion events arrive at `POST /internal/media-webhook`. The legacy local `vods`/`clips`/`pastes` tables are READ-ONLY (frozen for the cutover migration — never write them); Live-owned AI/transcript state for Media-hosted content lives in `vod_ai_state`/`clip_ai_state`. See ../CONTRACTS.md for the binding inter-service contracts. **Money:** `BILLING_AUTHORITY` (read only in [server/monetization/money-authority.js](server/monetization/money-authority.js)) is `live` by default; `billing` makes OpenVibe.Billing the ledger ([billing-client.js](server/monetization/billing-client.js), [billing-actions.js](server/monetization/billing-actions.js)) and Live's `openvibe_bucks_*`/`transactions`/`payment_orders`/`subscriptions` read-only (a database-level tripwire throws). The owner-only `money_writes_frozen` flag (`/api/admin/money`) refuses money actions in both modes.
+**Network context:** SSO/OAuth2 + the OpenCoins wallet come from **OpenVibe.Network** (`server/monetization/wallet-client.js`). The media subsystem (VODs/clips/pastes/thumbnails/files) lives in **OpenVibe.Media** — Live talks to it via [server/media-client.js](server/media-client.js), the thin proxy routers in [server/media-proxy/](server/media-proxy/), and the Media-backed recorder [server/streaming/recorder.js](server/streaming/recorder.js). Media completion events arrive at `POST /internal/media-webhook`. The legacy local `vods`/`clips`/`pastes` tables are READ-ONLY (frozen for the cutover migration — never write them); VOD/clip comments are OpenVibe.Community threads ([server/comments-client.js](server/comments-client.js)) and the local `comments` table is READ-ONLY too; Live-owned AI/transcript state for Media-hosted content lives in `vod_ai_state`/`clip_ai_state`. See ../CONTRACTS.md for the binding inter-service contracts. **Money:** `BILLING_AUTHORITY` (read only in [server/monetization/money-authority.js](server/monetization/money-authority.js)) is `live` by default; `billing` makes OpenVibe.Billing the ledger ([billing-client.js](server/monetization/billing-client.js), [billing-actions.js](server/monetization/billing-actions.js)) and Live's `openvibe_bucks_*`/`transactions`/`payment_orders`/`subscriptions` read-only (a database-level tripwire throws). The owner-only `money_writes_frozen` flag (`/api/admin/money`) refuses money actions in both modes.
## Commands
## Commands
@@ -12,6 +12,8 @@ OpenVibe.Live is a self-hosted live streaming platform, one of the OpenVibe serv
@@ -12,6 +12,8 @@ OpenVibe.Live is a self-hosted live streaming platform, one of the OpenVibe serv
- **OpenVibe.Network** — SSO/OAuth2 (RS256 JWTs), OpenCoins wallet, shared browser modules (`openvibe-shared`, a pinned OpenVibe.Shared release served at `/shared/*` from `node_modules`)
- **OpenVibe.Network** — SSO/OAuth2 (RS256 JWTs), OpenCoins wallet, shared browser modules (`openvibe-shared`, a pinned OpenVibe.Shared release served at `/shared/*` from `node_modules`)
- **OpenVibe.Media** — VODs, clips, pastes, thumbnails and files. Live proxies to it
- **OpenVibe.Media** — VODs, clips, pastes, thumbnails and files. Live proxies to it
(`server/media-client.js`, `server/media-proxy/`); the local `vods`/`clips`/`pastes` tables are frozen, read-only.
(`server/media-client.js`, `server/media-proxy/`); the local `vods`/`clips`/`pastes` tables are frozen, read-only.
+- **OpenVibe.Community** — pastes (`server/pastes-client.js`) and VOD/clip comments: `/api/comments` is an adapter over Community comment threads
+ (`server/comments-client.js`, [vods-and-clips.md](vods-and-clips.md#comments-are-openvibecommunity-threads)); the local `comments` table is frozen, read-only.
## Authentication
## Authentication
@@ -55,7 +55,21 @@ From the dashboard:
@@ -55,7 +55,21 @@ From the dashboard:
## Who sees a private VOD or clip
## Who sees a private VOD or clip
-A private item (visibility `private`, or a legacy row with no visibility and `is_public = 0`) is visible only to its owners — the uploader or clipper, the clipped channel's streamer, the streamer whose stream it came from — and to staff (admin, global mod). Everyone else gets exactly the answer an unknown id gets: the same 404 and body on `/api/vods/:id` and its `live-info`/`memories`/`context`, `/api/clips/:id`, comments, thumbnail regeneration and the write routes (a 403 would confirm the id exists). It is also left out of `/api/chat-ai/vod-transcripts`, the server-rendered `/vod/:id` and `/clip/:id` pages, and `/api/streams/recent`. The rule lives in `server/media-proxy/access.js`; Media applies the same rule to `openvibe.media/v/:id` and `/c/:id`. Unlisted items stay reachable by direct link.
+A private item (visibility `private`, or a legacy row with no visibility and `is_public = 0`) is visible only to its owners — the uploader or clipper, the clipped channel's streamer, the streamer whose stream it came from — and to staff (admin, global mod). Everyone else gets exactly the answer an unknown id gets: the same 404 and body on `/api/vods/:id` and its `live-info`/`memories`/`context`, `/api/clips/:id`, comments (including a comment's replies, edit and delete), thumbnail regeneration and the write routes (a 403 would confirm the id exists). It is also left out of `/api/chat-ai/vod-transcripts`, the server-rendered `/vod/:id` and `/clip/:id` pages, and `/api/streams/recent`. The rule lives in `server/media-proxy/access.js`; Media applies the same rule to `openvibe.media/v/:id` and `/c/:id`. Unlisted items stay reachable by direct link.
+
+## Comments are OpenVibe.Community threads
+
+Comments on a VOD or clip live in OpenVibe.Community, not in Live (roadmap Wave 5: Live and a second product share one Community thread). The thread of a VOD is the one Community keeps for the EntityRef `{ service: 'live', type: 'vod', id: '<vod id>' }` (`type: 'clip'` for clips). `/api/comments` keeps its paths and response shapes; `server/media-proxy/comments.js` is an adapter over `server/comments-client.js`:
+
+- **Who sees them.** Live checks the VOD/clip with `access.js` first (a private item's comments are missing like the item), then resolves the thread with its service token (`community.comment.write`, audience `openvibe.community`) and remembers the thread id in `comment_thread_refs`. Community refuses browsers that try to open live/vod or live/clip threads themselves, so a private item's thread is reachable only through Live.
+- **Who writes.** Signed-in people only, as before (Community refuses anonymous comments on these threads too). Live sends the person's Network subject (`X-OV-Subject`, from `linked_accounts.subject_id`); an account without one gets 409 "sign in again". Community's own limits apply (10 s between comments, 5 a minute, no repeats) and come back as the error message.
+- **Edit** — the author only. **Delete** — the author (as themselves), staff (admin/global mod, as themselves with `X-OV-Staff: 1`, which needs `community.comment.moderate`), or the item's owners (uploader or clipper, the clipped channel, the streamer whose stream it came from — Live deletes for them as itself with `community.comment.moderate`). A top-level comment with replies stays as a tombstone (`deleted: true`).
+- **Deleting** a VOD or clip (the player, the dashboard bulk actions, the admin storage page) hides its thread.
+- **Moderation in Community** shows here: a thread Community's moderators hid answers `404 { code: "comments_hidden" }` on Live too, and a locked one refuses new comments with Community's message.
+- **Community down** — every `/api/comments` route answers `503 { error: "Comments are unavailable right now", code: "comments_unavailable" }` and the player says so; it never shows an empty list. `comment_count` on `/api/vods/:id` and `/api/clips/:id` is `null` then (short timeout, skipped for 30 s after a failure).
+- **The same thread elsewhere.** For public and unlisted items the list carries `thread: { id, url }` — the thread's page on OpenVibe.Community (`https://openvibe.community/c/<access id>`), linked under the comments as "View this thread on OpenVibe.Community". Comments made there show on Live and the other way round.
+
+Live's old `comments` table is read-only (`test/frozen-tables.test.js` fails if server code writes it). Its rows were moved with OpenVibe.Community's `scripts/import-live-comments.js`: dry run by default, `--apply` only with `--backup` of Community's database, a ledger so re-runs import nothing twice, authors mapped to Network subjects (unmapped ones held and listed), and a reconciliation `read = imported + held + excluded`. The exact production commands are in OpenVibe.Community's README ("Live's VOD and clip comments").
## Chat Replay
## Chat Replay
@@ -389,3 +389,8 @@
@@ -389,3 +389,8 @@
.media-transcript-segs { display: flex; flex-direction: column; padding: 6px; gap: 1px; }
.media-transcript-segs { display: flex; flex-direction: column; padding: 6px; gap: 1px; }
.ts-seg:hover { background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--text-primary); }
.ts-seg:hover { background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--text-primary); }
.ts-text { flex: 1; }
.ts-text { flex: 1; }
+
+/* Comments are OpenVibe.Community threads: the link to the same thread there, deleted tombstones */
+.comments-elsewhere { margin: 10px 0 0; font-size: .85rem; color: var(--text-muted); }
+.comments-elsewhere:empty { display: none; }
+.comment-item.comment-deleted .comment-text { font-style: italic; }
@@ -1163,11 +1163,14 @@ https://www.101soundboards.com/sounds/12345 150p 2</code></pre>
@@ -1163,11 +1163,14 @@ https://www.101soundboards.com/sounds/12345 150p 2</code></pre>
<table class="doc-table">
<table class="doc-table">
<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead>
<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead>
<tbody>
<tbody>
-<tr><td>GET</td><td>/api/comments/:type/:id</td><td>Opt</td><td>List comments + replies (<code>type</code> = <code>vod|clip</code>)</td></tr>
+<tr><td>GET</td><td>/api/comments/:type/:id</td><td>Opt</td><td>List comments + replies, newest first (<code>type</code> = <code>vod|clip</code>, <code>?limit,offset</code>) → <code>{ comments, total, thread? }</code>; <code>thread.url</code> is the same thread on OpenVibe.Community (public and unlisted items)</td></tr>
+<tr><td>GET</td><td>/api/comments/:commentId/replies</td><td>Opt</td><td>All replies of a comment</td></tr>
<tr><td>POST</td><td>/api/comments/:type/:id</td><td>Yes</td><td>Add a comment/reply (<code>message</code>, <code>parent_id?</code>)</td></tr>
<tr><td>POST</td><td>/api/comments/:type/:id</td><td>Yes</td><td>Add a comment/reply (<code>message</code>, <code>parent_id?</code>)</td></tr>
-<tr><td>PUT / DELETE</td><td>/api/comments/:commentId</td><td>Author/mod</td><td>Edit / delete</td></tr>
+<tr><td>PUT</td><td>/api/comments/:commentId</td><td>Author</td><td>Edit your own comment</td></tr>
+<tr><td>DELETE</td><td>/api/comments/:commentId</td><td>Author/owner/staff</td><td>Delete (the VOD's or clip's owners and staff may delete any comment on it)</td></tr>
</tbody>
</tbody>
</table>
</table>
+<p>Comments are <a href="https://openvibe.community">OpenVibe.Community</a> comment threads: the same thread shows on Community's page for it. When Community cannot be reached these routes answer <code>503</code> with <code>code: "comments_unavailable"</code>.</p>
<h3>Pastes — <code>/api/pastes</code></h3>
<h3>Pastes — <code>/api/pastes</code></h3>
<table class="doc-table">
<table class="doc-table">
@@ -1531,9 +1531,19 @@ async function loadComments(contentType, contentId, prefix) {
@@ -1531,9 +1531,19 @@ async function loadComments(contentType, contentId, prefix) {
const countEl = document.getElementById(`${prefix}-comment-count`);
const countEl = document.getElementById(`${prefix}-comment-count`);
const listEl = document.getElementById(`${prefix}-comments-list`);
const listEl = document.getElementById(`${prefix}-comments-list`);
const formEl = document.getElementById(`${prefix}-comment-form`);
const formEl = document.getElementById(`${prefix}-comment-form`);
+ if (!listEl) return;
// Show comment form if logged in
// Show comment form if logged in
if (formEl) formEl.style.display = currentUser ? '' : 'none';
if (formEl) formEl.style.display = currentUser ? '' : 'none';
+ // The same thread on OpenVibe.Community (comments are Community threads; the link is offered for items anyone may see).
+ let elsewhere = document.getElementById(`${prefix}-comments-elsewhere`);
+ if (!elsewhere) {
+ elsewhere = document.createElement('p');
+ elsewhere.id = `${prefix}-comments-elsewhere`;
+ elsewhere.className = 'comments-elsewhere';
+ listEl.after(elsewhere);
+ }
+ elsewhere.replaceChildren();
try {
try {
const data = await api(`/comments/${contentType}/${contentId}`);
const data = await api(`/comments/${contentType}/${contentId}`);
@@ -1541,6 +1551,14 @@ async function loadComments(contentType, contentId, prefix) {
@@ -1541,6 +1551,14 @@ async function loadComments(contentType, contentId, prefix) {
const total = data.total || 0;
const total = data.total || 0;
if (countEl) countEl.textContent = total > 0 ? `(${total})` : '';
if (countEl) countEl.textContent = total > 0 ? `(${total})` : '';
+ if (data.thread && data.thread.url) {
+ const a = document.createElement('a');
+ a.href = data.thread.url;
+ a.target = '_blank';
+ a.rel = 'noopener';
+ a.textContent = 'View this thread on OpenVibe.Community';
+ elsewhere.append(a);
+ }
if (!comments.length) {
if (!comments.length) {
listEl.innerHTML = '<div class="comments-empty"><i class="fa-solid fa-comment-dots" style="font-size:1.5rem;margin-bottom:8px"></i><p>No comments yet. Be the first!</p></div>';
listEl.innerHTML = '<div class="comments-empty"><i class="fa-solid fa-comment-dots" style="font-size:1.5rem;margin-bottom:8px"></i><p>No comments yet. Be the first!</p></div>';
@@ -1549,24 +1567,42 @@ async function loadComments(contentType, contentId, prefix) {
@@ -1549,24 +1567,42 @@ async function loadComments(contentType, contentId, prefix) {
listEl.innerHTML = comments.map(c => renderComment(c, contentType, contentId)).join('');
listEl.innerHTML = comments.map(c => renderComment(c, contentType, contentId)).join('');
} catch (e) {
} catch (e) {
- listEl.innerHTML = '<p class="muted">Failed to load comments</p>';
+ if (countEl) countEl.textContent = '';
+ // Never an empty list that looks real: say what happened.
+ if (formEl && e && e.status === 503) formEl.style.display = 'none';
+ listEl.innerHTML = e && e.status === 503
+ ? '<div class="comments-empty comments-unavailable"><i class="fa-solid fa-plug-circle-exclamation" style="font-size:1.5rem;margin-bottom:8px"></i><p>Comments are unavailable right now. Try again in a moment.</p></div>'
+ : `<p class="muted">${esc((e && e.message) || 'Failed to load comments')}</p>`;
}
}
}
}
function renderComment(c, contentType, contentId) {
function renderComment(c, contentType, contentId) {
- const initial = (c.username || '?')[0].toUpperCase();
- const color = c.profile_color || '#8b5cf6';
+ if (c.deleted) {
+ const replies = c.replies && c.replies.length ? `<div class="comment-replies">${c.replies.map(r => renderComment(r, contentType, contentId)).join('')}</div>` : '';
+ return `
+ <div class="comment-item comment-deleted" id="comment-${c.id}">
+ <div class="comment-avatar" style="background:var(--bg-tertiary, #444)">?</div>
+ <div class="comment-body">
+ <div class="comment-text muted">This comment was deleted.</div>
+ ${replies}
+ </div>
+ </div>`;
+ }
const name = c.display_name || c.username || 'Unknown';
const name = c.display_name || c.username || 'Unknown';
- const isOwn = currentUser && (c.user_id === currentUser.id);
- const isAdmin = currentUser && currentUser.capabilities?.moderate_global;
- const edited = c.updated_at && c.updated_at !== c.created_at;
+ const initial = (c.username || name || '?')[0].toUpperCase();
+ const color = c.profile_color || '#8b5cf6';
+ const canEdit = c.can_edit ?? (currentUser && c.user_id === currentUser.id);
+ const canDelete = c.can_delete ?? (canEdit || (currentUser && currentUser.capabilities?.moderate_global));
+ const edited = !!c.edited_at || (c.updated_at && c.updated_at !== c.created_at);
let actionsHtml = '';
let actionsHtml = '';
if (currentUser) {
if (currentUser) {
actionsHtml += `<button onclick="showReplyForm(${c.id}, '${contentType}', ${contentId})"><i class="fa-solid fa-reply"></i> Reply</button>`;
actionsHtml += `<button onclick="showReplyForm(${c.id}, '${contentType}', ${contentId})"><i class="fa-solid fa-reply"></i> Reply</button>`;
}
}
- if (isOwn || isAdmin) {
+ if (canEdit) {
actionsHtml += `<button onclick="editComment(${c.id}, '${contentType}', ${contentId})"><i class="fa-solid fa-pen"></i> Edit</button>`;
actionsHtml += `<button onclick="editComment(${c.id}, '${contentType}', ${contentId})"><i class="fa-solid fa-pen"></i> Edit</button>`;
+ }
+ if (canDelete) {
actionsHtml += `<button onclick="deleteCommentAction(${c.id}, '${contentType}', ${contentId})"><i class="fa-solid fa-trash"></i> Delete</button>`;
actionsHtml += `<button onclick="deleteCommentAction(${c.id}, '${contentType}', ${contentId})"><i class="fa-solid fa-trash"></i> Delete</button>`;
}
}
@@ -1577,7 +1613,7 @@ function renderComment(c, contentType, contentId) {
@@ -1577,7 +1613,7 @@ function renderComment(c, contentType, contentId) {
return `
return `
<div class="comment-item" id="comment-${c.id}">
<div class="comment-item" id="comment-${c.id}">
- <div class="comment-avatar" style="background:${esc(color)}">${initial}</div>
+ <div class="comment-avatar" style="background:${esc(color)}">${esc(initial)}</div>
<div class="comment-body">
<div class="comment-body">
<div class="comment-meta">
<div class="comment-meta">
<span class="comment-author" style="color:${esc(color)}">${esc(name)}</span>
<span class="comment-author" style="color:${esc(color)}">${esc(name)}</span>
@@ -1010,6 +1010,7 @@ router.get('/storage', (req, res) => {
@@ -1010,6 +1010,7 @@ router.get('/storage', (req, res) => {
// proxy the Media API; the tiering + media-tools endpoints return a stub note
// proxy the Media API; the tiering + media-tools endpoints return a stub note
// so the admin SPA shows where the controls went instead of erroring.
// so the admin SPA shows where the controls went instead of erroring.
const mediaClient = require('../media-client');
const mediaClient = require('../media-client');
+const commentsClient = require('../comments-client');
// ── GET /api/admin/storage/vods — proxied VOD listing ────────
// ── GET /api/admin/storage/vods — proxied VOD listing ────────
router.get('/storage/vods', async (req, res) => {
router.get('/storage/vods', async (req, res) => {
@@ -1047,6 +1048,7 @@ router.delete('/storage/vods/bulk', async (req, res) => {
@@ -1047,6 +1048,7 @@ router.delete('/storage/vods/bulk', async (req, res) => {
}
}
freed += vod.file_size || 0;
freed += vod.file_size || 0;
await mediaClient.deleteVod(id);
await mediaClient.deleteVod(id);
+ commentsClient.hideThreadOf('vod', Number(id));
deleted++;
deleted++;
} catch (err) {
} catch (err) {
errors.push(`VOD ${id}: ${err.message}`);
errors.push(`VOD ${id}: ${err.message}`);
@@ -1079,7 +1081,7 @@ router.delete('/storage/clips/bulk', async (req, res) => {
@@ -1079,7 +1081,7 @@ router.delete('/storage/clips/bulk', async (req, res) => {
}
}
await mediaClient.deleteClip(id);
await mediaClient.deleteClip(id);
db.run("DELETE FROM content_views WHERE content_type = 'clip' AND content_id = ?", [id]);
db.run("DELETE FROM content_views WHERE content_type = 'clip' AND content_id = ?", [id]);
- db.run("DELETE FROM comments WHERE content_type = 'clip' AND content_id = ?", [id]);
+ commentsClient.hideThreadOf('clip', Number(id)); // comments are Community threads; Live's old table is read-only
deleted++;
deleted++;
} catch (err) {
} catch (err) {
errors.push(`Clip ${id}: ${err.message}`);
errors.push(`Clip ${id}: ${err.message}`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,169 @@
@@ -0,0 +1,169 @@
+'use strict';
+/**
+ * Comments on VODs and clips are OpenVibe.Community threads (roadmap Wave 5: "Live and a second
+ * product share one Community thread"). Live keeps no comments of its own any more — the old
+ * `comments` table is read-only, kept for the import (OpenVibe.Community
+ * scripts/import-live-comments.js) — and /api/comments (media-proxy/comments.js) is an adapter over
+ * the thread of EntityRef { service: 'live', type: 'vod'|'clip', id }.
+ *
+ * Calls go to Community's /api/v1/comments with Live's service token (audience openvibe.community):
+ * community.comment.write resolve a thread; read, comment, edit and delete as the person
+ * (X-OV-Subject: their usr_ id)
+ * community.comment.moderate delete as staff (X-OV-Staff: 1) or as Live itself for the
+ * VOD's/clip's owner; hide the thread of deleted content
+ *
+ * Only Live opens these threads (Community refuses browsers for live/vod and live/clip): a private
+ * item is Live's call, so Live checks visibility first and hands the thread's unguessable access id
+ * only to people who may see the item. Thread ids are remembered in comment_thread_refs.
+ *
+ * Calls made for a visitor forward their address (X-Forwarded-For, as the paste proxy does), so
+ * Community's per-address API limit counts people, not Live's loopback address.
+ *
+ * Any failure to reach Community (or an answer ≥ 500) is a 503 CommentsError: callers must
+ * say "comments are unavailable", never show an empty list that looks real.
+ */
+const db = require('./db/database');
+const principal = require('./net/network-principal');
+const { subjectForLiveUser } = require('./pastes-client');
+
+const COMMUNITY_URL = () => (process.env.OV_COMMUNITY_INTERNAL_URL || 'http://127.0.0.1:4200').replace(/\/+$/, '');
+const COMMUNITY_PUBLIC_URL = () => (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/+$/, '');
+const AUDIENCE = 'openvibe.community';
+const TYPES = ['vod', 'clip'];
+
+class CommentsError extends Error {
+ constructor(status, message, code) {
+ super(message);
+ this.name = 'CommentsError';
+ this.status = status;
+ this.code = code || null;
+ }
+}
+const unavailable = (why) => new CommentsError(503, 'Comments are unavailable right now', `comments_unavailable${why ? `:${why}` : ''}`);
+
+let _tables = false;
+function ensureTables() {
+ if (_tables) return;
+ db.getDb().exec(`CREATE TABLE IF NOT EXISTS comment_thread_refs (
+ content_type TEXT NOT NULL,
+ content_id INTEGER NOT NULL,
+ thread_id INTEGER NOT NULL,
+ access_id TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (content_type, content_id)
+ )`);
+ _tables = true;
+}
+
+/**
+ * One call to Community's comment API. act: { subject } acts for a person, { staff: true } adds
+ * X-OV-Staff (the person is Live staff), {} is Live itself. ip: the visitor's address.
+ */
+async function call(method, path, { body, act = {}, ip = null, timeoutMs = 5000, retried = false } = {}) {
+ let headers;
+ try { headers = { Accept: 'application/json', ...(await principal.serviceHeaders(AUDIENCE)) }; }
+ catch (err) { console.warn(`[Comments] no service token for Community: ${err.message}`); throw unavailable('token'); }
+ if (act.subject) headers['X-OV-Subject'] = act.subject;
+ if (act.staff) headers['X-OV-Staff'] = '1';
+ if (ip) headers['X-Forwarded-For'] = String(ip);
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
+ let res;
+ try {
+ res = await fetch(`${COMMUNITY_URL()}/api/v1/comments${path}`, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(timeoutMs) });
+ } catch (err) {
+ console.warn(`[Comments] Community unreachable (${method} ${path}): ${err.message}`);
+ throw unavailable('unreachable');
+ }
+ const out = await res.json().catch(() => null);
+ if (res.status === 401 && !retried && !(out && out.code === 'auth.required')) { // our token was refused: fetch a fresh one once
+ principal.invalidate(AUDIENCE);
+ return call(method, path, { body, act, ip, timeoutMs, retried: true });
+ }
+ if (res.status >= 500 || (res.status === 401 && retried)) {
+ console.warn(`[Comments] Community answered ${res.status} (${method} ${path})${out && out.code ? ` ${out.code}` : ''}`);
+ throw unavailable(String(res.status));
+ }
+ if (res.status === 403 && out && out.code === 'capability.denied') {
+ console.warn(`[Comments] Community refused Live's token for ${method} ${path}: ${out.detail || out.error}`);
+ throw unavailable('capability');
+ }
+ if (!res.ok) throw new CommentsError(res.status, (out && (out.detail || out.error)) || `Community answered ${res.status}`, out && out.code);
+ return out;
+}
+
+/** The Community thread of a VOD/clip (resolved once, then remembered). → { id, access_id } */
+async function threadFor(type, id, label, { timeoutMs, ip } = {}) {
+ if (!TYPES.includes(type)) throw new CommentsError(400, 'Invalid content type');
+ ensureTables();
+ const known = db.getDb().prepare('SELECT thread_id, access_id FROM comment_thread_refs WHERE content_type = ? AND content_id = ?').get(type, Number(id));
+ if (known) return { id: known.thread_id, access_id: known.access_id };
+ const ref = { service: 'live', type, id: String(id) };
+ if (label) ref.label = String(label).slice(0, 200);
+ const out = await call('POST', '/threads/resolve', { body: { ref }, timeoutMs, ip });
+ const t = out && out.thread;
+ if (!t || !Number.isInteger(t.id) || !t.access_id) throw unavailable('resolve');
+ db.getDb().prepare('INSERT OR IGNORE INTO comment_thread_refs (content_type, content_id, thread_id, access_id) VALUES (?, ?, ?, ?)').run(type, Number(id), t.id, t.access_id);
+ return { id: t.id, access_id: t.access_id };
+}
+
+/** Read a thread page (as the viewer, if signed in). q: { sort, after, limit, parent } */
+function readThread(threadId, { subject = null, timeoutMs, ip, ...q } = {}) {
+ const qs = new URLSearchParams(Object.entries(q).filter(([, v]) => v != null && v !== '').map(([k, v]) => [k, String(v)])).toString();
+ return call('GET', `/threads/${encodeURIComponent(threadId)}${qs ? `?${qs}` : ''}`, { act: { subject }, timeoutMs, ip });
+}
+
+function addComment(threadId, subject, { message, parentId = null }, { ip } = {}) {
+ return call('POST', `/threads/${encodeURIComponent(threadId)}/comments`, { act: { subject }, ip, body: { message, ...(parentId ? { parent_id: parentId } : {}) } });
+}
+
+/** One comment and its thread (with the ref). null when Community has no such (visible) comment. */
+async function getComment(commentId, subject = null, { ip } = {}) {
+ if (!/^\d{1,15}$/.test(String(commentId))) return null;
+ try { return await call('GET', `/${commentId}`, { act: { subject }, ip }); }
+ catch (err) { if (err.status === 404) return null; throw err; }
+}
+
+function editComment(commentId, subject, message, { ip } = {}) {
+ return call('PATCH', `/${encodeURIComponent(commentId)}`, { act: { subject }, ip, body: { message } });
+}
+
+/** as: 'author' (the person), 'staff' (the person, vouched as staff), 'owner' (Live itself, moderating for the content's owner). */
+function deleteComment(commentId, { subject = null, as = 'author', ip = null } = {}) {
+ const act = as === 'owner' ? {} : { subject, staff: as === 'staff' };
+ return call('DELETE', `/${encodeURIComponent(commentId)}`, { act, ip });
+}
+
+// Hiding threads of deleted content runs one at a time in the background, paced under Community's
+// per-address API limit (bulk deletes queue hundreds, all from Live's own address).
+const HIDE_GAP_MS = process.env.NODE_ENV === 'test' ? 0 : 1100;
+let _hideQueue = Promise.resolve();
+/** Best effort: hide the thread of a VOD/clip that was deleted. Never throws. */
+function hideThreadOf(type, id) {
+ if (!TYPES.includes(type) || !(Number(id) > 0)) return _hideQueue;
+ _hideQueue = _hideQueue.then(async () => {
+ try {
+ const t = await threadFor(type, id);
+ await call('PUT', `/threads/${t.id}/visibility`, { body: { visibility: 'hidden' } });
+ } catch (err) {
+ console.warn(`[Comments] could not hide the comment thread of ${type} ${id}: ${err.message}`);
+ }
+ if (HIDE_GAP_MS) await new Promise((r) => setTimeout(r, HIDE_GAP_MS).unref());
+ });
+ return _hideQueue;
+}
+
+/** Live user id for a Network subject, when that person has a Live account linked. */
+function liveUserForSubject(subject) {
+ if (!subject) return null;
+ try {
+ const row = db.getDb().prepare("SELECT user_id FROM linked_accounts WHERE service = 'network' AND subject_id = ? ORDER BY id LIMIT 1").get(subject);
+ return row ? row.user_id : null;
+ } catch { return null; }
+}
+
+const threadUrl = (accessId) => `${COMMUNITY_PUBLIC_URL()}/c/${encodeURIComponent(accessId)}`;
+
+module.exports = {
+ CommentsError, TYPES, threadFor, readThread, addComment, getComment, editComment, deleteComment,
+ hideThreadOf, liveUserForSubject, subjectForLiveUser, threadUrl, ensureTables,
+};
@@ -6935,54 +6935,10 @@ function renormalizePendingMediaRequestPositions(streamerId) {
@@ -6935,54 +6935,10 @@ function renormalizePendingMediaRequestPositions(streamerId) {
tx(rows);
tx(rows);
}
}
-// ── Comment helpers ──────────────────────────────────────────
-
-function createComment({ content_type, content_id, user_id, parent_id, message }) {
- return run(
- `INSERT INTO comments (content_type, content_id, user_id, parent_id, message)
- VALUES (?, ?, ?, ?, ?)`,
- [content_type, content_id, user_id, parent_id || null, message]
- );
-}
-
-function getComments(contentType, contentId, limit = 50, offset = 0) {
- return all(`
- SELECT c.*, u.username, u.display_name, u.avatar_url, u.profile_color, u.role
- FROM comments c
- JOIN users u ON c.user_id = u.id
- WHERE c.content_type = ? AND c.content_id = ? AND c.is_deleted = 0 AND c.parent_id IS NULL
- ORDER BY c.created_at DESC
- LIMIT ? OFFSET ?
- `, [contentType, contentId, limit, offset]);
-}
-
-function getCommentReplies(parentId) {
- return all(`
- SELECT c.*, u.username, u.display_name, u.avatar_url, u.profile_color, u.role
- FROM comments c
- JOIN users u ON c.user_id = u.id
- WHERE c.parent_id = ? AND c.is_deleted = 0
- ORDER BY c.created_at ASC
- `, [parentId]);
-}
-
-function getCommentById(id) {
- return get('SELECT * FROM comments WHERE id = ?', [id]);
-}
-
-function getCommentCount(contentType, contentId) {
- const row = get('SELECT COUNT(*) as c FROM comments WHERE content_type = ? AND content_id = ? AND is_deleted = 0',
- [contentType, contentId]);
- return row ? row.c : 0;
-}
-
-function deleteComment(id) {
- return run('UPDATE comments SET is_deleted = 1 WHERE id = ?', [id]);
-}
-
-function updateComment(id, message) {
- return run('UPDATE comments SET message = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [message, id]);
-}
+// ── Comments ─────────────────────────────────────────────────
+// VOD/clip comments are OpenVibe.Community threads (server/comments-client.js). The `comments`
+// table is read-only: the one-time import (OpenVibe.Community scripts/import-live-comments.js)
+// reads it; test/frozen-tables.test.js fails if server code writes it again.
// ── Chat replay helpers ──────────────────────────────────────
// ── Chat replay helpers ──────────────────────────────────────
@@ -8451,9 +8407,6 @@ module.exports = {
@@ -8451,9 +8407,6 @@ module.exports = {
// Verification Keys
// Verification Keys
createVerificationKey, getVerificationKeyByKey, getVerificationKeyByUsername,
createVerificationKey, getVerificationKeyByKey, getVerificationKeyByUsername,
getAllVerificationKeys, redeemVerificationKey, revokeVerificationKey, isUsernameReserved,
getAllVerificationKeys, redeemVerificationKey, revokeVerificationKey, isUsernameReserved,
- // Comments
- createComment, getComments, getCommentReplies, getCommentById, getCommentCount,
- deleteComment, updateComment,
// Chat Replay
// Chat Replay
getChatReplay,
getChatReplay,
// Channel lookup
// Channel lookup
@@ -11,6 +11,8 @@ const media = require('../media-client');
@@ -11,6 +11,8 @@ const media = require('../media-client');
const { requireAuth, optionalAuth } = require('../auth/auth');
const { requireAuth, optionalAuth } = require('../auth/auth');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
const access = require('./access');
const access = require('./access');
+const commentsClient = require('../comments-client');
+const { commentCount } = require('./comments');
const router = express.Router();
const router = express.Router();
@@ -244,7 +246,7 @@ router.get('/:id', optionalAuth, async (req, res) => {
@@ -244,7 +246,7 @@ router.get('/:id', optionalAuth, async (req, res) => {
}
}
} catch { /* */ }
} catch { /* */ }
- clip.comment_count = db.getCommentCount('clip', clip.id);
+ clip.comment_count = await commentCount('clip', clip.id, clip.title, req.ip); // null when Community is unreachable
clip.can_delete = await canActorDeleteClip(req.user, clip);
clip.can_delete = await canActorDeleteClip(req.user, clip);
clip.can_edit = await canActorModerateClip(req.user, clip);
clip.can_edit = await canActorModerateClip(req.user, clip);
@@ -319,7 +321,7 @@ router.post('/bulk', requireAuth, async (req, res) => {
@@ -319,7 +321,7 @@ router.post('/bulk', requireAuth, async (req, res) => {
const allowed = clip && (action === 'delete' ? await canActorDeleteClip(req.user, clip) : await canActorModerateClip(req.user, clip));
const allowed = clip && (action === 'delete' ? await canActorDeleteClip(req.user, clip) : await canActorModerateClip(req.user, clip));
if (!allowed) { skipped++; continue; }
if (!allowed) { skipped++; continue; }
try {
try {
- if (action === 'delete') await media.deleteClip(id);
+ if (action === 'delete') { await media.deleteClip(id); commentsClient.hideThreadOf('clip', id); }
else await media.updateClip(id, { visibility: action });
else await media.updateClip(id, { visibility: action });
done++;
done++;
} catch { skipped++; }
} catch { skipped++; }
@@ -337,6 +339,7 @@ router.delete('/:id', requireAuth, async (req, res) => {
@@ -337,6 +339,7 @@ router.delete('/:id', requireAuth, async (req, res) => {
if (!clip) return res.status(404).json({ error: 'Clip not found' });
if (!clip) return res.status(404).json({ error: 'Clip not found' });
if (!(await canActorDeleteClip(req.user, clip))) return refuse(req, res, clip, 'Not authorized to delete this clip');
if (!(await canActorDeleteClip(req.user, clip))) return refuse(req, res, clip, 'Not authorized to delete this clip');
await media.deleteClip(clip.id);
await media.deleteClip(clip.id);
+ commentsClient.hideThreadOf('clip', clip.id);
res.json({ message: 'Clip deleted' });
res.json({ message: 'Clip deleted' });
} catch (err) {
} catch (err) {
mediaErr(res, err, 'Failed to delete clip');
mediaErr(res, err, 'Failed to delete clip');
@@ -1,19 +1,35 @@
@@ -1,19 +1,35 @@
/**
/**
* OpenVibe.Live — Comments API Routes (/api/comments)
* OpenVibe.Live — Comments API Routes (/api/comments)
*
*
- * Comments on VODs/clips are Live-local social data (comments table in live.db);
- * only the commented-on content lives in OpenVibe.Media now, so target lookups
- * (owner, title) go through the media client.
+ * Comments on VODs and clips are OpenVibe.Community threads (roadmap Wave 5); these routes are an
+ * adapter over them (server/comments-client.js) that keeps the paths and response shapes the SPA
+ * uses. Live still decides who may see the commented-on VOD/clip (./access — a private one is
+ * missing to everyone but its owners and staff, and so are its comments) and who may delete a
+ * comment (its author, staff, or the item's owners); Community stores the comments, enforces its
+ * own limits, and does the moderation.
+ *
+ * Commenting needs a signed-in account (as before; Community refuses anonymous comments on Live
+ * threads too). If Community cannot be reached every route answers 503 "Comments are unavailable
+ * right now" — never an empty list that looks real.
+ *
+ * Live's old `comments` table is read-only: it is only the source of the one-time import
+ * (OpenVibe.Community scripts/import-live-comments.js).
*/
*/
'use strict';
'use strict';
const express = require('express');
const express = require('express');
const db = require('../db/database');
const db = require('../db/database');
const media = require('../media-client');
const media = require('../media-client');
+const comments = require('../comments-client');
+const permissions = require('../auth/permissions');
const { requireAuth, optionalAuth } = require('../auth/auth');
const { requireAuth, optionalAuth } = require('../auth/auth');
const { pushNotification, actorInfo: notificationActor } = require('../utils/notify');
const { pushNotification, actorInfo: notificationActor } = require('../utils/notify');
const access = require('./access');
const access = require('./access');
const router = express.Router();
const router = express.Router();
+const MAX_MESSAGE = 2000;
+const MAX_OFFSET = 1000;
+const MAX_REPLIES = 500;
+const HIDDEN = { error: 'Comments are hidden here', code: 'comments_hidden' };
function truncatePreview(text, max = 120) {
function truncatePreview(text, max = 120) {
const clean = String(text || '').replace(/\s+/g, ' ').trim();
const clean = String(text || '').replace(/\s+/g, ' ').trim();
@@ -46,6 +62,102 @@ async function visibleTarget(user, contentType, contentId) {
@@ -46,6 +62,102 @@ async function visibleTarget(user, contentType, contentId) {
return target && access.canView(user, target.row) ? target : null;
return target && access.canView(user, target.row) ? target : null;
}
}
+function sendError(res, err, fallback) {
+ if (err instanceof comments.CommentsError) {
+ const body = { error: err.message };
+ if (err.status === 503) body.code = 'comments_unavailable';
+ return res.status(err.status).json(body);
+ }
+ console.error(`[Comments] ${fallback}:`, err && err.message);
+ return res.status(500).json({ error: fallback });
+}
+
+const viewerSubject = async (user) => (user ? comments.subjectForLiveUser(user.id) : null);
+
+/** How this viewer may delete a comment on this target: 'author' | 'staff' | 'owner' | null. */
+function deleteRight(user, subject, c, target) {
+ if (!user || c.deleted) return null;
+ if (subject && c.author && c.author.subject === subject) return 'author';
+ if (permissions.isStaff(user)) return 'staff';
+ if (target && access.ownerIds(target.row).includes(Number(user.id))) return 'owner';
+ return null;
+}
+
+/**
+ * A Community comment in the shape /api/comments always returned (the old row plus the author's
+ * Live profile), with the Community fields the SPA can use on top.
+ */
+function shape(c, ctx) {
+ const a = c.author || null;
+ const liveUserId = a && a.subject ? comments.liveUserForSubject(a.subject) : null;
+ const u = liveUserId ? db.getUserById(liveUserId) : null;
+ const edited = c.edited_at || null;
+ const out = {
+ id: c.id,
+ content_type: ctx.type,
+ content_id: ctx.id,
+ user_id: u ? u.id : null,
+ subject: a ? a.subject || null : null,
+ parent_id: c.parent_id || null,
+ message: c.deleted ? '' : c.message,
+ is_deleted: c.deleted ? 1 : 0,
+ deleted: !!c.deleted,
+ created_at: c.created_at,
+ updated_at: edited || c.created_at,
+ edited_at: edited,
+ username: c.deleted ? null : (u ? u.username : (a && a.username) || null),
+ display_name: c.deleted ? null : (u ? u.display_name || u.username : c.display_name || (a && a.display_name) || null),
+ avatar_url: c.deleted ? null : (u ? u.avatar_url : (a && a.avatar_url)) || null,
+ profile_color: c.deleted ? null : (u ? u.profile_color : (a && a.profile_color)) || null,
+ role: u && !c.deleted ? u.role : null,
+ is_ai: !!(a && a.is_ai),
+ reply_count: c.reply_count || 0,
+ can_edit: !!(ctx.subject && a && a.subject === ctx.subject && !c.deleted),
+ can_delete: !!deleteRight(ctx.user, ctx.subject, c, ctx.target),
+ };
+ if (c.replies) out.replies = c.replies.map((r) => shape(r, ctx));
+ return out;
+}
+
+/** Every reply of a top-level comment (Community nests the first 20; the rest are paged). */
+async function allReplies(threadId, c, subject, ip) {
+ const replies = [...(c.replies || [])];
+ let after = replies.length ? replies[replies.length - 1].id : null;
+ while (replies.length < Math.min(c.reply_count || 0, MAX_REPLIES)) {
+ const page = await comments.readThread(threadId, { subject, parent: c.id, after, limit: 100, ip });
+ replies.push(...page.comments);
+ if (!page.next_cursor || !page.comments.length) break;
+ after = page.next_cursor;
+ }
+ return replies;
+}
+
+/** The Live VOD/clip a Community comment belongs to, or null (another product's comment, or gone). */
+function liveRefOf(found) {
+ const ref = found && found.thread && found.thread.ref;
+ if (!ref || ref.service !== 'live' || !comments.TYPES.includes(ref.type) || !/^\d{1,15}$/.test(ref.id)) return null;
+ return { type: ref.type, id: Number(ref.id) };
+}
+
+// ── Get Replies ──────────────────────────────────────────────
+// (Before /:type/:id, which would otherwise take "/<id>/replies".)
+router.get('/:commentId/replies', optionalAuth, async (req, res) => {
+ try {
+ const subject = await viewerSubject(req.user);
+ const found = await comments.getComment(req.params.commentId, subject, { ip: req.ip });
+ const ref = liveRefOf(found);
+ const target = ref && await visibleTarget(req.user, ref.type, ref.id);
+ if (!target) return res.status(404).json({ error: 'Comment not found' });
+ const ctx = { type: ref.type, id: ref.id, user: req.user, subject, target };
+ // Replies nest one level: a reply has none of its own.
+ const c = found.comment;
+ const replies = c.parent_id ? [] : await allReplies(found.thread.id, { id: c.id, replies: [], reply_count: c.reply_count || 0 }, subject, req.ip);
+ res.json({ replies: replies.map((r) => shape(r, ctx)) });
+ } catch (err) {
+ sendError(res, err, 'Failed to get replies');
+ }
+});
+
// ── List Comments ────────────────────────────────────────────
// ── List Comments ────────────────────────────────────────────
router.get('/:type/:id', optionalAuth, async (req, res) => {
router.get('/:type/:id', optionalAuth, async (req, res) => {
try {
try {
@@ -54,20 +166,42 @@ router.get('/:type/:id', optionalAuth, async (req, res) => {
@@ -54,20 +166,42 @@ router.get('/:type/:id', optionalAuth, async (req, res) => {
if (!['vod', 'clip'].includes(contentType) || !contentId) {
if (!['vod', 'clip'].includes(contentType) || !contentId) {
return res.status(400).json({ error: 'Invalid content type or ID' });
return res.status(400).json({ error: 'Invalid content type or ID' });
}
}
- if (!(await visibleTarget(req.user, contentType, contentId))) return res.status(404).json({ error: 'Content not found' });
- const limit = Math.min(parseInt(req.query.limit || '50'), 100);
- const offset = parseInt(req.query.offset || '0');
- const comments = db.getComments(contentType, contentId, limit, offset);
- const totalCount = db.getCommentCount(contentType, contentId);
- for (const c of comments) {
- const replies = db.getCommentReplies(c.id);
- c.replies = replies;
- c.reply_count = replies.length;
+ const target = await visibleTarget(req.user, contentType, contentId);
+ if (!target) return res.status(404).json({ error: 'Content not found' });
+ const limit = Math.min(Math.max(parseInt(req.query.limit || '50') || 50, 1), 100);
+ const offset = Math.min(Math.max(parseInt(req.query.offset || '0') || 0, 0), MAX_OFFSET);
+
+ const subject = await viewerSubject(req.user);
+ const thread = await comments.threadFor(contentType, contentId, target.row.title, { ip: req.ip });
+ const ctx = { type: contentType, id: contentId, user: req.user, subject, target };
+
+ // Newest first, like before; Community pages by cursor, so an offset is walked.
+ const picked = [];
+ let skipped = 0, after = null, total = 0;
+ for (;;) {
+ let page;
+ try { page = await comments.readThread(thread.id, { subject, sort: 'new', limit: 100, after, ip: req.ip }); }
+ catch (err) { if (err.status === 404) return res.status(404).json(HIDDEN); throw err; }
+ // A thread Community's moderators hid is hidden here too (Live reads anonymous visitors' pages as itself, which sees it).
+ if (page.thread && page.thread.visibility === 'hidden' && !permissions.isStaff(req.user)) return res.status(404).json(HIDDEN);
+ total = page.thread ? page.thread.comment_count || 0 : 0;
+ for (const c of page.comments) {
+ if (skipped < offset) { skipped++; continue; }
+ if (picked.length < limit) picked.push(c);
+ }
+ if (picked.length >= limit || !page.next_cursor) break;
+ after = page.next_cursor;
+ }
+ for (const c of picked) {
+ if ((c.reply_count || 0) > (c.replies || []).length) c.replies = await allReplies(thread.id, c, subject, req.ip);
}
}
- res.json({ comments, total: totalCount });
+ const out = { comments: picked.map((c) => shape(c, ctx)), total };
+ // The same thread on Community's own page — offered for items anyone may see (the access id
+ // is all it takes to read it there).
+ if (!access.isPrivate(target.row)) out.thread = { id: thread.access_id, url: comments.threadUrl(thread.access_id) };
+ res.json(out);
} catch (err) {
} catch (err) {
- console.error('[Comments] List error:', err.message);
- res.status(500).json({ error: 'Failed to get comments' });
+ sendError(res, err, 'Failed to get comments');
}
}
});
});
@@ -79,35 +213,28 @@ router.post('/:type/:id', requireAuth, async (req, res) => {
@@ -79,35 +213,28 @@ router.post('/:type/:id', requireAuth, async (req, res) => {
if (!['vod', 'clip'].includes(contentType) || !contentId) {
if (!['vod', 'clip'].includes(contentType) || !contentId) {
return res.status(400).json({ error: 'Invalid content type or ID' });
return res.status(400).json({ error: 'Invalid content type or ID' });
}
}
- const message = (req.body.message || '').trim();
- if (!message || message.length > 2000) {
- return res.status(400).json({ error: 'Comment must be 1-2000 characters' });
+ const message = String(req.body.message || '').trim();
+ if (!message || message.length > MAX_MESSAGE) {
+ return res.status(400).json({ error: `Comment must be 1-${MAX_MESSAGE} characters` });
}
}
const parentId = req.body.parent_id ? parseInt(req.body.parent_id) : null;
const parentId = req.body.parent_id ? parseInt(req.body.parent_id) : null;
const target = await visibleTarget(req.user, contentType, contentId);
const target = await visibleTarget(req.user, contentType, contentId);
if (!target) return res.status(404).json({ error: 'Content not found' });
if (!target) return res.status(404).json({ error: 'Content not found' });
+ const subject = await viewerSubject(req.user);
+ if (!subject) return res.status(409).json({ error: 'This account is not linked to an OpenVibe account yet — sign in again and retry.' });
+ const thread = await comments.threadFor(contentType, contentId, target.row.title, { ip: req.ip });
+
let parent = null;
let parent = null;
if (parentId) {
if (parentId) {
- parent = db.getCommentById(parentId);
- if (!parent || parent.content_type !== contentType || parent.content_id !== contentId) {
+ parent = await comments.getComment(parentId, subject, { ip: req.ip });
+ if (!parent || !parent.thread || parent.thread.id !== thread.id) {
return res.status(400).json({ error: 'Invalid parent comment' });
return res.status(400).json({ error: 'Invalid parent comment' });
}
}
}
}
- const result = db.createComment({
- content_type: contentType,
- content_id: contentId,
- user_id: req.user.id,
- parent_id: parentId,
- message,
- });
-
- const comment = db.get(`
- SELECT c.*, u.username, u.display_name, u.avatar_url, u.profile_color, u.role
- FROM comments c JOIN users u ON c.user_id = u.id
- WHERE c.id = ?
- `, [result.lastInsertRowid]);
+ const out = await comments.addComment(thread.id, subject, { message, parentId }, { ip: req.ip });
+ const comment = shape(out.comment, { type: contentType, id: contentId, user: req.user, subject, target });
const actor = notificationActor(req.user);
const actor = notificationActor(req.user);
const recipients = new Map();
const recipients = new Map();
@@ -129,9 +256,11 @@ router.post('/:type/:id', requireAuth, async (req, res) => {
@@ -129,9 +256,11 @@ router.post('/:type/:id', requireAuth, async (req, res) => {
},
},
});
});
}
}
- if (parent?.user_id && parent.user_id !== req.user.id) {
- recipients.set(parent.user_id, {
- user_id: parent.user_id,
+ const parentAuthor = parent && parent.comment.author && parent.comment.author.subject
+ ? comments.liveUserForSubject(parent.comment.author.subject) : null;
+ if (parentAuthor && parentAuthor !== req.user.id) {
+ recipients.set(parentAuthor, {
+ user_id: parentAuthor,
type: 'CONTENT_REPLY',
type: 'CONTENT_REPLY',
category: 'social',
category: 'social',
priority: 'normal',
priority: 'normal',
@@ -151,66 +280,72 @@ router.post('/:type/:id', requireAuth, async (req, res) => {
@@ -151,66 +280,72 @@ router.post('/:type/:id', requireAuth, async (req, res) => {
res.status(201).json({ comment });
res.status(201).json({ comment });
} catch (err) {
} catch (err) {
- console.error('[Comments] Create error:', err.message);
- res.status(500).json({ error: 'Failed to post comment' });
+ sendError(res, err, 'Failed to post comment');
}
}
});
});
-// ── Get Replies ──────────────────────────────────────────────
-router.get('/:commentId/replies', optionalAuth, async (req, res) => {
- try {
- const parent = db.getCommentById(parseInt(req.params.commentId));
- if (!parent || !(await visibleTarget(req.user, parent.content_type, parent.content_id))) {
- return res.status(404).json({ error: 'Comment not found' });
- }
- const replies = db.getCommentReplies(parent.id);
- res.json({ replies });
- } catch {
- res.status(500).json({ error: 'Failed to get replies' });
- }
-});
+/** The comment and its Live target, when this caller may see both; otherwise null (→ 404). */
+async function commentForCaller(req, subject) {
+ const found = await comments.getComment(req.params.commentId, subject, { ip: req.ip });
+ const ref = liveRefOf(found);
+ const target = ref && await visibleTarget(req.user, ref.type, ref.id);
+ return target ? { found, ref, target } : null;
+}
-// ── Edit Comment ─────────────────────────────────────────────
-router.put('/:commentId', requireAuth, (req, res) => {
+// ── Edit Comment (the author only) ───────────────────────────
+router.put('/:commentId', requireAuth, async (req, res) => {
try {
try {
- const comment = db.getCommentById(req.params.commentId);
- if (!comment) return res.status(404).json({ error: 'Comment not found' });
- if (comment.user_id !== req.user.id && req.user.role !== 'admin') {
- return res.status(403).json({ error: 'Not authorized' });
+ const subject = await viewerSubject(req.user);
+ const hit = await commentForCaller(req, subject);
+ if (!hit) return res.status(404).json({ error: 'Comment not found' });
+ const c = hit.found.comment;
+ if (!subject || !c.author || c.author.subject !== subject) {
+ return res.status(403).json({ error: 'Only the author can edit a comment' });
}
}
- const message = (req.body.message || '').trim();
- if (!message || message.length > 2000) {
- return res.status(400).json({ error: 'Comment must be 1-2000 characters' });
+ const message = String(req.body.message || '').trim();
+ if (!message || message.length > MAX_MESSAGE) {
+ return res.status(400).json({ error: `Comment must be 1-${MAX_MESSAGE} characters` });
}
}
- db.updateComment(comment.id, message);
+ await comments.editComment(c.id, subject, message, { ip: req.ip });
res.json({ message: 'Comment updated' });
res.json({ message: 'Comment updated' });
- } catch {
- res.status(500).json({ error: 'Failed to update comment' });
+ } catch (err) {
+ sendError(res, err, 'Failed to update comment');
}
}
});
});
-// ── Delete Comment ───────────────────────────────────────────
+// ── Delete Comment (author, staff, or the VOD's/clip's owners) ──
router.delete('/:commentId', requireAuth, async (req, res) => {
router.delete('/:commentId', requireAuth, async (req, res) => {
try {
try {
- const comment = db.getCommentById(req.params.commentId);
- if (!comment) return res.status(404).json({ error: 'Comment not found' });
-
- let canDelete = (comment.user_id === req.user.id) || (req.user.role === 'admin');
- if (!canDelete) {
- const target = await getCommentTarget(comment.content_type, comment.content_id);
- if (target && target.user_id === req.user.id) canDelete = true;
- if (!canDelete && target?.stream_id) {
- const stream = db.getStreamById(target.stream_id);
- if (stream && stream.user_id === req.user.id) canDelete = true;
- }
- }
- if (!canDelete) return res.status(403).json({ error: 'Not authorized' });
-
- db.deleteComment(comment.id);
+ const subject = await viewerSubject(req.user);
+ const hit = await commentForCaller(req, subject);
+ if (!hit) return res.status(404).json({ error: 'Comment not found' });
+ let right = deleteRight(req.user, subject, hit.found.comment, hit.target);
+ if (!right) return res.status(403).json({ error: 'Not authorized' });
+ if (right === 'staff' && !subject) right = 'owner'; // staff without a linked subject: Live moderates for them
+ await comments.deleteComment(hit.found.comment.id, { subject, as: right, ip: req.ip });
res.json({ message: 'Comment deleted' });
res.json({ message: 'Comment deleted' });
- } catch {
- res.status(500).json({ error: 'Failed to delete comment' });
+ } catch (err) {
+ sendError(res, err, 'Failed to delete comment');
}
}
});
});
+/**
+ * The comment count of a VOD/clip for detail responses: a number, or null when Community is
+ * unreachable. Short timeouts, and after a failure the count is skipped for a while, so a
+ * Community outage never slows VOD and clip pages down.
+ */
+let countsDownUntil = 0;
+async function commentCount(contentType, contentId, label, ip = null) {
+ if (Date.now() < countsDownUntil) return null;
+ try {
+ const thread = await comments.threadFor(contentType, contentId, label, { timeoutMs: 1500, ip });
+ const page = await comments.readThread(thread.id, { limit: 1, timeoutMs: 1500, ip });
+ return page.thread ? page.thread.comment_count || 0 : 0;
+ } catch (err) {
+ if (err && err.status === 503) countsDownUntil = Date.now() + 30_000;
+ return null;
+ }
+}
+
module.exports = router;
module.exports = router;
+module.exports.commentCount = commentCount;
@@ -16,6 +16,8 @@ const recorder = require('../streaming/recorder');
@@ -16,6 +16,8 @@ const recorder = require('../streaming/recorder');
const { requireAuth, optionalAuth } = require('../auth/auth');
const { requireAuth, optionalAuth } = require('../auth/auth');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
const access = require('./access');
const access = require('./access');
+const commentsClient = require('../comments-client');
+const { commentCount } = require('./comments');
const router = express.Router();
const router = express.Router();
@@ -350,7 +352,7 @@ router.post('/bulk-delete-old', requireAuth, async (req, res) => {
@@ -350,7 +352,7 @@ router.post('/bulk-delete-old', requireAuth, async (req, res) => {
const out = await media.listVods({ user_id: req.user.id, include_private: 1, limit: 1000 });
const out = await media.listVods({ user_id: req.user.id, include_private: 1, limit: 1000 });
for (const v of (out?.vods || [])) {
for (const v of (out?.vods || [])) {
if (!tooOld(v) || v.is_recording) continue;
if (!tooOld(v) || v.is_recording) continue;
- if (action === 'delete') await media.deleteVod(v.id).catch(() => {});
+ if (action === 'delete') { if (await media.deleteVod(v.id).then(() => true, () => false)) commentsClient.hideThreadOf('vod', v.id); }
else await media.updateVod(v.id, { visibility: action }).catch(() => {});
else await media.updateVod(v.id, { visibility: action }).catch(() => {});
vodCount++;
vodCount++;
}
}
@@ -359,7 +361,7 @@ router.post('/bulk-delete-old', requireAuth, async (req, res) => {
@@ -359,7 +361,7 @@ router.post('/bulk-delete-old', requireAuth, async (req, res) => {
const out = await media.listClips({ user_id: req.user.id, include_private: 1, limit: 1000 });
const out = await media.listClips({ user_id: req.user.id, include_private: 1, limit: 1000 });
for (const c of (out?.clips || [])) {
for (const c of (out?.clips || [])) {
if (!tooOld(c)) continue;
if (!tooOld(c)) continue;
- if (action === 'delete') await media.deleteClip(c.id).catch(() => {});
+ if (action === 'delete') { if (await media.deleteClip(c.id).then(() => true, () => false)) commentsClient.hideThreadOf('clip', c.id); }
else await media.updateClip(c.id, { visibility: action }).catch(() => {});
else await media.updateClip(c.id, { visibility: action }).catch(() => {});
clipCount++;
clipCount++;
}
}
@@ -508,7 +510,7 @@ router.get('/:id', optionalAuth, async (req, res) => {
@@ -508,7 +510,7 @@ router.get('/:id', optionalAuth, async (req, res) => {
}
}
} catch { /* */ }
} catch { /* */ }
- vod.comment_count = db.getCommentCount('vod', vod.id);
+ vod.comment_count = await commentCount('vod', vod.id, vod.title, req.ip); // null when Community is unreachable
res.json({ vod, clips });
res.json({ vod, clips });
} catch {
} catch {
res.status(500).json({ error: 'Failed to get VOD' });
res.status(500).json({ error: 'Failed to get VOD' });
@@ -549,7 +551,7 @@ router.post('/bulk', requireAuth, async (req, res) => {
@@ -549,7 +551,7 @@ router.post('/bulk', requireAuth, async (req, res) => {
if (!owns) owns = permissions.canModerateContentOwner(req.user, vod.user_id ? db.getUserById(vod.user_id) : null);
if (!owns) owns = permissions.canModerateContentOwner(req.user, vod.user_id ? db.getUserById(vod.user_id) : null);
if (!owns) { skipped++; continue; }
if (!owns) { skipped++; continue; }
try {
try {
- if (action === 'delete') await media.deleteVod(id);
+ if (action === 'delete') { await media.deleteVod(id); commentsClient.hideThreadOf('vod', id); }
else await media.updateVod(id, { visibility: action });
else await media.updateVod(id, { visibility: action });
done++;
done++;
} catch { skipped++; }
} catch { skipped++; }
@@ -565,6 +567,7 @@ router.delete('/:id', requireAuth, async (req, res) => {
@@ -565,6 +567,7 @@ router.delete('/:id', requireAuth, async (req, res) => {
if (!vod) return;
if (!vod) return;
try {
try {
await media.deleteVod(req.params.id);
await media.deleteVod(req.params.id);
+ commentsClient.hideThreadOf('vod', Number(req.params.id));
res.json({ message: 'VOD deleted' });
res.json({ message: 'VOD deleted' });
} catch (err) {
} catch (err) {
mediaErr(res, err, 'Failed to delete VOD');
mediaErr(res, err, 'Failed to delete VOD');
new file mode 100644
new file mode 100644
@@ -0,0 +1,280 @@
@@ -0,0 +1,280 @@
+/**
+ * VOD and clip comments are OpenVibe.Community threads (roadmap Wave 5). /api/comments keeps its
+ * paths and response shapes but is an adapter (server/comments-client.js): the thread of
+ * { service: 'live', type, id } is resolved once with Live's service token and remembered; people
+ * read, comment and edit as their Network subject (X-OV-Subject); deletes go as the author, as
+ * staff (X-OV-Staff) or, for the item's owners, as Live itself; deleting a VOD hides its thread; a
+ * Community outage is a 503 "comments are unavailable", never an empty list; and Live's own
+ * comments table is neither read nor written.
+ *
+ * The real routers run on a temp database with Media stubbed, Community replaced by
+ * test/community-stub.js and sign-in stubbed by an `x-test-user` header.
+ *
+ * node test/comments-community.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 = path.join(os.tmpdir(), `ov-comments-community-${process.pid}.db`);
+process.env.DB_PATH = tmp;
+process.env.NODE_ENV = 'test';
+process.env.OV_COMMUNITY_URL = 'https://openvibe.community';
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+console.warn = () => {};
+
+const db = require('../server/db/database');
+db.initDb();
+const raw = db.getDb();
+
+const auth = require('../server/auth/auth');
+const signIn = (req) => {
+ const id = Number(req.headers['x-test-user'] || 0);
+ const u = id ? db.getUserById(id) : null;
+ if (u) { req.user = u; req.authSource = 'network'; }
+ return u;
+};
+auth.requireAuth = (req, res, next) => (signIn(req) ? next() : res.status(401).json({ error: 'Authentication required' }));
+auth.optionalAuth = (req, res, next) => { signIn(req); next(); };
+
+const notify = require('../server/utils/notify');
+const pushed = [];
+notify.pushNotification = (p) => { pushed.push(p); };
+const principal = require('../server/net/network-principal');
+principal.serviceHeaders = async () => ({ Authorization: 'Bearer test-service-token' });
+
+const SUB = (c) => `usr_01JAB2C3D4E5F6G7H8J9K0MNP${c}`;
+const addUser = (id, username, role, subject) => {
+ raw.prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role, profile_color, created_at)
+ VALUES (?, ?, ?, ?, 'x', ?, '#123456', '2025-01-01 00:00:00')`).run(id, username, username.toUpperCase(), `${username}@x`, role);
+ if (subject) raw.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, 'network', ?, ?)").run(id, String(100 + id), subject);
+};
+addUser(1, 'admin', 'admin', SUB('A'));
+addUser(3, 'alice', 'streamer', SUB('B')); // owns VOD 100 and the stream clip 200 came from
+addUser(5, 'carol', 'user', SUB('C')); // made clip 200
+addUser(7, 'mallory', 'user', SUB('E')); // nobody special
+addUser(9, 'nolink', 'user', null); // no Network subject on file
+db.ensureChannel(3);
+const streamA = Number(db.createStream({ user_id: 3, channel_id: db.getChannelByUserId(3).id, title: 'A', protocol: 'webrtc' }).lastInsertRowid);
+
+// A legacy row in Live's frozen comments table: never shown, never touched.
+raw.prepare("INSERT INTO comments (content_type, content_id, user_id, message) VALUES ('vod', 100, 5, 'legacy local row')").run();
+const legacyBefore = raw.prepare('SELECT * FROM comments').all();
+
+const media = require('../server/media-client');
+const VODS = {
+ 100: { id: 100, user_id: 3, title: 'Public VOD', visibility: 'public', is_public: 1, status: 'ready' },
+ 101: { id: 101, user_id: 3, title: 'Private VOD', visibility: 'private', is_public: 0, status: 'ready' },
+ 102: { id: 102, user_id: 3, title: 'Unlisted VOD', visibility: 'unlisted', is_public: 0, status: 'ready' },
+};
+const CLIPS = { 200: { id: 200, user_id: 5, stream_id: streamA, title: 'Public clip', visibility: 'public', is_public: 1, status: 'ready' } };
+const missing = (what) => new media.MediaApiError(`${what} not found`, 404, { error: `${what} not found` });
+media.getVod = async (id) => { const v = VODS[Number(id)]; if (!v) throw missing('VOD'); return { ...v }; };
+media.getClip = async (id) => { const c = CLIPS[Number(id)]; if (!c) throw missing('Clip'); return { ...c }; };
+media.listClips = async () => ({ clips: [] });
+media.deleteVod = async (id) => { delete VODS[Number(id)]; return {}; };
+
+const express = require('express');
+const app = express();
+app.use(express.json());
+app.use('/api/vods', require('../server/media-proxy/vods'));
+app.use('/api/clips', require('../server/media-proxy/clips'));
+app.use('/api/comments', require('../server/media-proxy/comments'));
+const commentsClient = require('../server/comments-client');
+const server = http.createServer(app).listen(0);
+
+function call(method, p, user, body) {
+ return new Promise((resolve, reject) => {
+ const data = body ? JSON.stringify(body) : null;
+ const headers = { 'content-type': 'application/json' };
+ if (user) headers['x-test-user'] = String(user);
+ const req = http.request({ port: server.address().port, path: p, method, headers }, (res) => {
+ let text = '';
+ res.on('data', (c) => { text += c; });
+ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json, text }); });
+ });
+ req.on('error', reject);
+ if (data) req.write(data);
+ req.end();
+ });
+}
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); quiet(' ✓', name); }
+ catch (e) { failures++; quiet(' ✗', name, '\n ', e.stack.split('\n').slice(0, 3).join('\n ')); }
+}
+
+(async () => {
+ await new Promise((r) => server.once('listening', r));
+ const stub = await (require('./community-stub').startCommunityStub({ people: { [SUB('Z')]: { username: 'zed', display_name: 'Zed from Community' } } }));
+ process.env.OV_COMMUNITY_INTERNAL_URL = stub.url;
+ const last = (method, re) => [...stub.calls].reverse().find((c) => c.method === method && re.test(c.path));
+ let carolTop, aliceReply;
+
+ await check('list: the thread of live/vod/<id> is resolved once (labelled, remembered), with a link to the same thread on Community', async () => {
+ const r = await call('GET', '/api/comments/vod/100');
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(r.json.comments, []);
+ assert.strictEqual(r.json.total, 0);
+ const resolve = last('POST', /^\/threads\/resolve$/);
+ assert.deepStrictEqual(resolve.body, { ref: { service: 'live', type: 'vod', id: '100', label: 'Public VOD' } });
+ assert.ok(resolve.auth, 'with Live\'s service token');
+ assert.match(r.json.thread.id, /^cth_/);
+ assert.strictEqual(r.json.thread.url, `https://openvibe.community/c/${r.json.thread.id}`);
+ const n = stub.calls.filter((c) => c.path === '/threads/resolve').length;
+ await call('GET', '/api/comments/vod/100', 5);
+ assert.strictEqual(stub.calls.filter((c) => c.path === '/threads/resolve').length, n, 'remembered in comment_thread_refs');
+ assert.strictEqual(raw.prepare('SELECT COUNT(*) AS n FROM comment_thread_refs').get().n, 1);
+ assert.ok((await call('GET', '/api/comments/vod/102')).json.thread, 'unlisted items are linked too');
+ assert.strictEqual((await call('GET', '/api/comments/vod/101', 3)).json.thread, undefined, 'private items never are');
+ assert.strictEqual((await call('GET', '/api/comments/vod/101')).status, 404);
+ });
+
+ await check('post: as the signed-in person\'s subject, the old response shape, notifications to the owner and the parent\'s author', async () => {
+ const r = await call('POST', '/api/comments/vod/100', 5, { message: ' great stream ' });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(last('POST', /\/comments$/).subject, SUB('C'));
+ assert.ok(last('POST', /\/comments$/).xff, 'the visitor\'s address rides along (Community limits per address)');
+ carolTop = r.json.comment;
+ for (const [k, v] of Object.entries({ content_type: 'vod', content_id: 100, user_id: 5, username: 'carol', display_name: 'CAROL', profile_color: '#123456', role: 'user', message: 'great stream', parent_id: null, is_deleted: 0, can_edit: true, can_delete: true })) {
+ assert.deepStrictEqual(carolTop[k], v, k);
+ }
+ assert.strictEqual(carolTop.updated_at, carolTop.created_at, 'not edited');
+ assert.deepStrictEqual(pushed.map((p) => [p.user_id, p.type]), [[3, 'CONTENT_COMMENT']]);
+
+ const reply = await call('POST', '/api/comments/vod/100', 3, { message: 'thanks!', parent_id: carolTop.id });
+ assert.strictEqual(reply.status, 201, reply.text);
+ aliceReply = reply.json.comment;
+ assert.strictEqual(aliceReply.parent_id, carolTop.id);
+ assert.deepStrictEqual(pushed.slice(1).map((p) => [p.user_id, p.type]), [[5, 'CONTENT_REPLY']], 'the parent\'s author, found by subject');
+
+ assert.strictEqual((await call('POST', '/api/comments/vod/100', null, { message: 'anon' })).status, 401, 'signed in only, as before');
+ assert.strictEqual((await call('POST', '/api/comments/vod/100', 9, { message: 'no subject' })).status, 409);
+ assert.strictEqual((await call('POST', '/api/comments/vod/100', 5, { message: 'x'.repeat(2001) })).status, 400);
+ assert.strictEqual((await call('POST', '/api/comments/vod/100', 5, { message: ' ' })).status, 400);
+ const clipComment = (await call('POST', '/api/comments/clip/200', 5, { message: 'on the clip' })).json.comment;
+ const cross = await call('POST', '/api/comments/vod/100', 5, { message: 'cross', parent_id: clipComment.id });
+ assert.strictEqual(cross.status, 400, 'a parent from another thread');
+ assert.strictEqual((await call('POST', '/api/comments/vod/101', 7, { message: 'hi' })).status, 404);
+ });
+
+ await check('list: newest first, replies nested, total, people who only use Community, edits marked, limit/offset', async () => {
+ const vodThread = stub.threads.find((t) => t.ref.type === 'vod' && t.ref.id === '100');
+ stub.comments.push({ id: stub.comments.length + 1, thread_id: vodThread.id, parent_id: null, author: SUB('Z'), message: 'said on Community', created_at: '2026-09-02T00:00:00.000Z', edited_at: null, deleted_at: null });
+ const r = await call('GET', '/api/comments/vod/100', 5);
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json.total, 3);
+ assert.deepStrictEqual(r.json.comments.map((c) => c.message), ['said on Community', 'great stream']);
+ const zed = r.json.comments[0];
+ assert.deepStrictEqual([zed.user_id, zed.username, zed.display_name, zed.subject, zed.can_edit, zed.can_delete], [null, 'zed', 'Zed from Community', SUB('Z'), false, false]);
+ assert.deepStrictEqual(r.json.comments[1].replies.map((c) => [c.message, c.username, c.can_delete]), [['thanks!', 'alice', false]]);
+ const asOwner = (await call('GET', '/api/comments/vod/100', 3)).json;
+ assert.ok(asOwner.comments.every((c) => c.can_delete), 'the VOD\'s owner may delete any comment on it');
+ assert.ok(!asOwner.comments[0].can_edit, 'but edit only their own');
+ assert.strictEqual(last('GET', /^\/threads\/\d+$/).subject, SUB('B'), 'read as the viewer');
+ const page = (await call('GET', '/api/comments/vod/100?limit=1&offset=1')).json;
+ assert.deepStrictEqual(page.comments.map((c) => c.message), ['great stream']);
+ assert.ok(!r.text.includes('legacy local row'), 'Live\'s own table is not read');
+ });
+
+ await check('replies: /:commentId/replies answers for Live comments the caller may see', async () => {
+ const r = await call('GET', `/api/comments/${carolTop.id}/replies`);
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(r.json.replies.map((c) => c.message), ['thanks!']);
+ assert.deepStrictEqual((await call('GET', `/api/comments/${aliceReply.id}/replies`)).json.replies, []);
+ assert.strictEqual((await call('GET', '/api/comments/424242/replies')).status, 404);
+ });
+
+ await check('edit: the author only (as their subject); edits show as edited', async () => {
+ assert.strictEqual((await call('PUT', `/api/comments/${carolTop.id}`, 7, { message: 'hijack' })).status, 403);
+ assert.strictEqual((await call('PUT', `/api/comments/${carolTop.id}`, 1, { message: 'staff rewrite' })).status, 403, 'staff delete, they do not rewrite');
+ assert.strictEqual((await call('PUT', `/api/comments/${carolTop.id}`, 5, { message: '' })).status, 400);
+ const ok = await call('PUT', `/api/comments/${carolTop.id}`, 5, { message: 'great stream!!' });
+ assert.strictEqual(ok.status, 200, ok.text);
+ assert.deepStrictEqual(ok.json, { message: 'Comment updated' });
+ assert.strictEqual(last('PATCH', /^\/\d+$/).subject, SUB('C'));
+ const c = (await call('GET', '/api/comments/vod/100')).json.comments.find((x) => x.id === carolTop.id);
+ assert.strictEqual(c.message, 'great stream!!');
+ assert.ok(c.edited_at && c.updated_at !== c.created_at, 'the SPA shows "(edited)"');
+ });
+
+ await check('delete: strangers 403; the author as themselves; staff vouched as staff; the item\'s owners through Live itself', async () => {
+ assert.strictEqual((await call('DELETE', `/api/comments/${carolTop.id}`, 7)).status, 403);
+ const extra = (await call('POST', '/api/comments/vod/100', 7, { message: 'mallory was here' })).json.comment;
+ assert.strictEqual((await call('DELETE', `/api/comments/${extra.id}`, 7)).status, 200);
+ let d = last('DELETE', /^\/\d+$/);
+ assert.deepStrictEqual([d.subject, d.staff], [SUB('E'), false]);
+ const byStaff = (await call('POST', '/api/comments/vod/100', 7, { message: 'staff will remove this' })).json.comment;
+ assert.strictEqual((await call('DELETE', `/api/comments/${byStaff.id}`, 1)).status, 200);
+ d = last('DELETE', /^\/\d+$/);
+ assert.deepStrictEqual([d.subject, d.staff], [SUB('A'), true]);
+ const onClip = (await call('POST', '/api/comments/clip/200', 7, { message: 'rude clip comment' })).json.comment;
+ assert.strictEqual((await call('DELETE', `/api/comments/${onClip.id}`, 3)).status, 200, 'the streamer the clip was cut from');
+ d = last('DELETE', /^\/\d+$/);
+ assert.deepStrictEqual([d.subject, d.staff], [null, false], 'Live moderates as itself');
+ // A top-level comment with replies stays as a tombstone.
+ assert.strictEqual((await call('DELETE', `/api/comments/${carolTop.id}`, 3)).status, 200);
+ const list = (await call('GET', '/api/comments/vod/100')).json;
+ const tomb = list.comments.find((c) => c.id === carolTop.id);
+ assert.deepStrictEqual([tomb.deleted, tomb.is_deleted, tomb.message, tomb.username, tomb.can_delete], [true, 1, '', null, false]);
+ assert.deepStrictEqual(tomb.replies.map((c) => c.message), ['thanks!']);
+ assert.strictEqual((await call('DELETE', `/api/comments/${carolTop.id}`, 3)).status, 404, 'gone');
+ });
+
+ await check('a thread Community\'s moderators hid is hidden on Live too, whoever asks', async () => {
+ const t = stub.threads.find((x) => x.ref.type === 'clip' && x.ref.id === '200');
+ t.visibility = 'hidden';
+ for (const user of [null, 5]) {
+ const r = await call('GET', '/api/comments/clip/200', user);
+ assert.deepStrictEqual([r.status, r.json], [404, { error: 'Comments are hidden here', code: 'comments_hidden' }], `user ${user}`);
+ }
+ t.visibility = 'public';
+ assert.strictEqual((await call('GET', '/api/comments/clip/200')).status, 200);
+ });
+
+ await check('Community down: every route says comments are unavailable (503), never an empty list; VOD pages still load, fast', async () => {
+ stub.down = true;
+ const unavailable = { error: 'Comments are unavailable right now', code: 'comments_unavailable' };
+ for (const [method, p, user, body] of [
+ ['GET', '/api/comments/vod/100', null], ['GET', '/api/comments/vod/100', 5], ['POST', '/api/comments/vod/100', 5, { message: 'hello?' }],
+ ['PUT', `/api/comments/${aliceReply.id}`, 3, { message: 'x' }], ['DELETE', `/api/comments/${aliceReply.id}`, 3], ['GET', `/api/comments/${aliceReply.id}/replies`, null],
+ ]) {
+ const r = await call(method, p, user, body);
+ assert.deepStrictEqual([r.status, r.json], [503, unavailable], `${method} ${p}`);
+ }
+ const t0 = Date.now();
+ const vod = await call('GET', '/api/vods/100');
+ assert.strictEqual(vod.status, 200);
+ assert.strictEqual(vod.json.vod.comment_count, null, 'unknown, not 0');
+ const again = Date.now();
+ assert.strictEqual((await call('GET', '/api/vods/100')).json.vod.comment_count, null);
+ assert.ok(Date.now() - again < 1000 && again - t0 < 5000, 'no waiting on a Community that is down');
+ stub.down = false;
+ });
+
+ await check('deleting a VOD hides its Community thread', async () => {
+ const r = await call('DELETE', '/api/vods/100', 3);
+ assert.strictEqual(r.status, 200, r.text);
+ await commentsClient.hideThreadOf('none', 0); // the queue: wait for the background hide
+ const t = stub.threads.find((x) => x.ref.type === 'vod' && x.ref.id === '100');
+ assert.strictEqual(t.visibility, 'hidden');
+ const put = last('PUT', /\/visibility$/);
+ assert.deepStrictEqual([put.subject, put.body], [null, { visibility: 'hidden' }]);
+ });
+
+ await check('Live\'s own comments table was neither read nor written', async () => {
+ assert.deepStrictEqual(raw.prepare('SELECT * FROM comments').all(), legacyBefore);
+ });
+
+ server.close();
+ await stub.close();
+ for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }
+ if (failures) { quiet(`\n${failures} check(s) failed`); process.exit(1); }
+ quiet('comments on Community: all checks passed');
+ process.exit(0);
+})().catch((err) => { quiet(err); server.close(); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,136 @@
@@ -0,0 +1,136 @@
+'use strict';
+/**
+ * An in-process stand-in for OpenVibe.Community's comment API (/api/v1/comments), for Live tests
+ * of the comments adapter (server/comments-client.js). It follows Community's contract as a service
+ * caller sees it: resolve by EntityRef (sequential id + access id), threads newest/oldest first with
+ * one level of nested replies and an ?after= cursor, ?parent= reply pages, comments as X-OV-Subject
+ * (none on live vod/clip without a subject), GET/PATCH/DELETE /:commentId, X-OV-Staff, visibility.
+ *
+ * const stub = await startCommunityStub({ people: { usr_…: { username, display_name } } });
+ * process.env.OV_COMMUNITY_INTERNAL_URL = stub.url;
+ * stub.down = true; // every request fails (connection reset) until set back
+ * stub.calls // [{ method, path, subject, staff, body }]
+ */
+const http = require('http');
+const crypto = require('crypto');
+
+function startCommunityStub({ people = {} } = {}) {
+ const threads = []; // { id, access_id, ref, visibility, comment_count }
+ const comments = []; // { id, thread_id, parent_id, author, message, created_at, edited_at, deleted_at }
+ const calls = [];
+ const state = { down: false };
+ let clock = Date.parse('2026-09-01T00:00:00Z');
+ const now = () => new Date(clock += 1000).toISOString();
+
+ const personOf = (subject) => (subject ? { subject, username: (people[subject] || {}).username || null, display_name: (people[subject] || {}).display_name || null, avatar_url: null, profile_color: null } : null);
+ const threadBy = (id) => threads.find((t) => String(t.id) === String(id) || t.access_id === id) || null;
+ const replyCount = (c) => comments.filter((r) => r.parent_id === c.id && !r.deleted_at).length;
+ const shapeThread = (t) => ({ id: t.id, access_id: t.access_id, ref: t.ref, visibility: t.visibility, comment_count: comments.filter((c) => c.thread_id === t.id && !c.deleted_at).length });
+ function shapeComment(c, subject, withReplies) {
+ const deleted = !!c.deleted_at;
+ const out = {
+ id: c.id, thread_id: c.thread_id, parent_id: c.parent_id, origin: 'user',
+ author: deleted ? null : personOf(c.author), anon_name: null,
+ display_name: deleted ? null : ((people[c.author] || {}).display_name || null),
+ message: deleted ? null : c.message, deleted, score: 0, upvotes: 0, downvotes: 0, my_vote: 0,
+ reply_count: replyCount(c), created_at: c.created_at, updated_at: c.edited_at || c.created_at, edited_at: deleted ? null : c.edited_at,
+ can_edit: !deleted && !!subject && c.author === subject, can_delete: !deleted && !!subject && c.author === subject,
+ };
+ if (withReplies) out.replies = comments.filter((r) => r.parent_id === c.id && !r.deleted_at).sort((a, b) => a.id - b.id).slice(0, 20).map((r) => shapeComment(r, subject, false));
+ return out;
+ }
+
+ const server = http.createServer((req, res) => {
+ let raw = '';
+ req.on('data', (c) => { raw += c; });
+ req.on('end', () => {
+ if (state.down) { req.socket.destroy(); return; }
+ const url = new URL(req.url, 'http://x');
+ const p = url.pathname.replace(/^\/api\/v1\/comments/, '');
+ const subject = req.headers['x-ov-subject'] || null;
+ const staff = req.headers['x-ov-staff'] === '1';
+ const body = raw ? JSON.parse(raw) : undefined;
+ calls.push({ method: req.method, path: p, query: url.search, subject, staff, body, auth: req.headers.authorization || null, xff: req.headers['x-forwarded-for'] || null });
+ const send = (status, obj) => { res.statusCode = status; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(obj)); };
+ const problem = (status, code, detail) => send(status, { type: 'about:blank', status, code, detail, error: detail });
+ if (!/^Bearer /.test(req.headers.authorization || '')) return problem(401, 'token.missing', 'service token required');
+ const moderator = !subject || staff;
+ let m;
+
+ if (req.method === 'POST' && p === '/threads/resolve') {
+ const ref = body.ref;
+ let t = threads.find((x) => x.ref.service === ref.service && x.ref.type === ref.type && x.ref.id === ref.id);
+ if (t) { if (ref.label) t.ref.label = ref.label; return send(200, { thread: shapeThread(t), created: false }); }
+ t = { id: threads.length + 1, access_id: `cth_${crypto.randomBytes(16).toString('base64url')}`, ref: { ...ref }, visibility: 'public' };
+ threads.push(t);
+ return send(201, { thread: shapeThread(t), created: true });
+ }
+ if ((m = p.match(/^\/threads\/([^/]+)$/)) && req.method === 'GET') {
+ const t = threadBy(m[1]);
+ if (!t || (t.visibility === 'hidden' && !moderator)) return problem(404, 'thread.not_found', 'Comment thread not found');
+ const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit'), 10) || 30, 1), 100);
+ const after = url.searchParams.get('after') ? Number(url.searchParams.get('after')) : null;
+ const parent = url.searchParams.get('parent');
+ let rows;
+ if (parent) {
+ rows = comments.filter((c) => c.parent_id === Number(parent) && !c.deleted_at && (after == null || c.id > after)).sort((a, b) => a.id - b.id);
+ } else {
+ const newest = url.searchParams.get('sort') === 'new';
+ rows = comments.filter((c) => c.thread_id === t.id && !c.parent_id && (!c.deleted_at || replyCount(c) > 0) && (after == null || (newest ? c.id < after : c.id > after)))
+ .sort((a, b) => (newest ? b.id - a.id : a.id - b.id));
+ }
+ const more = rows.length > limit;
+ rows = rows.slice(0, limit);
+ return send(200, { thread: shapeThread(t), comments: rows.map((c) => shapeComment(c, subject, !parent)), next_cursor: more ? String(rows[rows.length - 1].id) : null, viewer: {} });
+ }
+ if ((m = p.match(/^\/threads\/([^/]+)\/comments$/)) && req.method === 'POST') {
+ const t = threadBy(m[1]);
+ if (!t || t.visibility === 'hidden') return problem(404, 'thread.not_found', 'Comment thread not found');
+ if (!subject && t.ref.service === 'live') return problem(401, 'auth.required', 'Sign in to comment here');
+ let parentId = null;
+ if (body.parent_id) {
+ const parent = comments.find((c) => c.id === Number(body.parent_id));
+ if (!parent || parent.thread_id !== t.id || parent.deleted_at) return problem(400, 'comment.invalid_parent', 'Invalid parent comment');
+ parentId = parent.parent_id || parent.id;
+ }
+ const c = { id: comments.length + 1, thread_id: t.id, parent_id: parentId, author: subject, message: String(body.message).trim(), created_at: now(), edited_at: null, deleted_at: null };
+ comments.push(c);
+ return send(201, { comment: shapeComment(c, subject, false) });
+ }
+ if ((m = p.match(/^\/threads\/([^/]+)\/visibility$/)) && req.method === 'PUT') {
+ const t = threadBy(m[1]);
+ if (!t) return problem(404, 'thread.not_found', 'Comment thread not found');
+ if (!moderator) return problem(403, 'capability.denied', 'moderators only');
+ t.visibility = body.visibility;
+ return send(200, { thread: shapeThread(t) });
+ }
+ if ((m = p.match(/^\/(\d+)$/))) {
+ const c = comments.find((x) => x.id === Number(m[1]));
+ const t = c && threads.find((x) => x.id === c.thread_id);
+ if (!c || c.deleted_at || (t.visibility === 'hidden' && !moderator)) return problem(404, 'comment.not_found', 'Comment not found');
+ if (req.method === 'GET') return send(200, { comment: shapeComment(c, subject, false), thread: shapeThread(t) });
+ if (req.method === 'PATCH') {
+ if (!subject || c.author !== subject) return problem(403, 'comment.not_yours', 'Only the author can edit a comment');
+ c.message = String(body.message).trim();
+ c.edited_at = now();
+ return send(200, { comment: shapeComment(c, subject, false) });
+ }
+ if (req.method === 'DELETE') {
+ if (!(subject && c.author === subject) && !moderator) return problem(403, 'comment.not_yours', 'Not authorized to delete this comment');
+ c.deleted_at = now();
+ return send(200, { ok: true, id: c.id });
+ }
+ }
+ return problem(404, 'route.not_found', 'Not found');
+ });
+ });
+ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({
+ url: `http://127.0.0.1:${server.address().port}`,
+ threads, comments, calls,
+ get down() { return state.down; },
+ set down(v) { state.down = !!v; },
+ close: () => new Promise((r) => { server.closeAllConnections?.(); server.close(r); }),
+ })));
+}
+
+module.exports = { startCommunityStub };
@@ -2,7 +2,8 @@
@@ -2,7 +2,8 @@
// Live's legacy vods/clips/pastes tables are frozen: Media and Community own that data now
// Live's legacy vods/clips/pastes tables are frozen: Media and Community own that data now
// (AGENTS.md, Wave 22 register C-73). Live may read them for pre-cutover pages, but no server code
// (AGENTS.md, Wave 22 register C-73). Live may read them for pre-cutover pages, but no server code
-// may write them. The only writes allowed are the two guarded one-time visibility backfills that
+// may write them. The VOD/clip `comments` table is frozen too: comments are OpenVibe.Community
+// threads (roadmap Wave 5), and the old rows are only read by Community's one-time import. The only writes allowed are the two guarded one-time visibility backfills that
// run when the column is first added.
// run when the column is first added.
const assert = require('assert');
const assert = require('assert');
@@ -10,7 +11,7 @@ const fs = require('fs');
@@ -10,7 +11,7 @@ const fs = require('fs');
const path = require('path');
const path = require('path');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
-const WRITE = /\b(INSERT(?:\s+OR\s+\w+)?\s+INTO|UPDATE|DELETE\s+FROM|REPLACE\s+INTO)\s+(vods|clips|pastes|paste_likes)\b/i;
+const WRITE = /\b(INSERT(?:\s+OR\s+\w+)?\s+INTO|UPDATE|DELETE\s+FROM|REPLACE\s+INTO)\s+(vods|clips|pastes|paste_likes|comments)\b/i;
const ALLOWED = [
const ALLOWED = [
"UPDATE vods SET visibility = CASE WHEN is_public = 1 THEN 'public' ELSE 'private' END",
"UPDATE vods SET visibility = CASE WHEN is_public = 1 THEN 'public' ELSE 'private' END",
"UPDATE clips SET visibility = CASE WHEN is_public = 1 THEN 'public' ELSE 'unlisted' END",
"UPDATE clips SET visibility = CASE WHEN is_public = 1 THEN 'public' ELSE 'unlisted' END",
@@ -78,6 +78,13 @@ media.createClip = async () => ({ id: 999, status: 'processing' });
@@ -78,6 +78,13 @@ media.createClip = async () => ({ id: 999, status: 'processing' });
media.updateVod = async () => ({});
media.updateVod = async () => ({});
media.updateClip = async () => ({});
media.updateClip = async () => ({});
media.deleteClip = async () => ({});
media.deleteClip = async () => ({});
+// Comments are OpenVibe.Community threads: a stub Community (started below) and a stub service token.
+const principal = require('../server/net/network-principal');
+principal.serviceHeaders = async () => ({ Authorization: 'Bearer test-service-token' });
+const { startCommunityStub } = require('./community-stub');
+for (const [id, n] of [[1, 'A'], [3, 'B'], [5, 'C'], [6, 'D'], [7, 'E'], [8, 'F']]) {
+ raw.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, 'network', ?, ?)").run(id, String(100 + id), `usr_01JAB2C3D4E5F6G7H8J9K0MNP${n}`);
+}
const recorder = require('../server/streaming/recorder');
const recorder = require('../server/streaming/recorder');
recorder.getActiveRecording = (sid) => (Number(sid) === streamA ? { vodId: 101, startedAt: Date.now() - 60000 } : null);
recorder.getActiveRecording = (sid) => (Number(sid) === streamA ? { vodId: 101, startedAt: Date.now() - 60000 } : null);
@@ -130,6 +137,8 @@ async function check(name, fn) {
@@ -130,6 +137,8 @@ async function check(name, fn) {
(async () => {
(async () => {
await new Promise((r) => server.once('listening', r));
await new Promise((r) => server.once('listening', r));
+ const community = await startCommunityStub();
+ process.env.OV_COMMUNITY_INTERNAL_URL = community.url;
await check('VOD detail: private and legacy-private look missing to anonymous and other users', async () => {
await check('VOD detail: private and legacy-private look missing to anonymous and other users', async () => {
for (const user of [null, 7, 5]) {
for (const user of [null, 7, 5]) {
@@ -211,9 +220,12 @@ async function check(name, fn) {
@@ -211,9 +220,12 @@ async function check(name, fn) {
await sameAsMissing('GET', '/api/comments/vod/101', '/api/comments/vod/999', null);
await sameAsMissing('GET', '/api/comments/vod/101', '/api/comments/vod/999', null);
await sameAsMissing('GET', '/api/comments/clip/201', '/api/comments/clip/999', 7);
await sameAsMissing('GET', '/api/comments/clip/201', '/api/comments/clip/999', 7);
await sameAsMissing('POST', '/api/comments/vod/101', '/api/comments/vod/999', 7, { message: 'hi' });
await sameAsMissing('POST', '/api/comments/vod/101', '/api/comments/vod/999', 7, { message: 'hi' });
- // (/:commentId/replies is shadowed by /:type/:id today and answers 400; either way nothing leaks.)
- const r = await call('GET', `/api/comments/${posted.json.comment.id}/replies`);
- assert.ok(r.status >= 400 && !/secret/.test(r.text), 'replies under a private VOD');
+ await sameAsMissing('GET', `/api/comments/${posted.json.comment.id}/replies`, '/api/comments/999999/replies', null);
+ await sameAsMissing('PUT', `/api/comments/${posted.json.comment.id}`, '/api/comments/999999', 7, { message: 'mine now' });
+ await sameAsMissing('DELETE', `/api/comments/${posted.json.comment.id}`, '/api/comments/999999', 7);
+ assert.strictEqual((await call('GET', `/api/comments/${posted.json.comment.id}/replies`, 3)).status, 200, 'the owner reads them');
+ const own = await call('GET', '/api/comments/vod/101', 3);
+ assert.strictEqual(own.json.thread, undefined, 'a private item\'s thread is never linked on Community');
assert.strictEqual((await call('GET', '/api/comments/vod/101', 6)).status, 200, 'staff read them');
assert.strictEqual((await call('GET', '/api/comments/vod/101', 6)).status, 200, 'staff read them');
assert.strictEqual((await call('GET', '/api/comments/vod/100')).status, 200, 'public item comments stay open');
assert.strictEqual((await call('GET', '/api/comments/vod/100')).status, 200, 'public item comments stay open');
});
});
@@ -251,6 +263,7 @@ async function check(name, fn) {
@@ -251,6 +263,7 @@ async function check(name, fn) {
});
});
server.close();
server.close();
+ await community.close();
for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }
for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }
if (failures) { quiet(`\n${failures} check(s) failed`); process.exit(1); }
if (failures) { quiet(`\n${failures} check(s) failed`); process.exit(1); }
quiet('media privacy: all checks passed');
quiet('media privacy: all checks passed');