openvibers/openvibe.live Commits

VOD and clip comments are OpenVibe.Community threads (Wave 5 exit: Live and a second product share one thread)

OP OpenVibers committed 23 Sept 2026 · b8afb713eb · 17 files, +915 −156

/api/comments keeps its paths and response shapes but is an adapter over the Community thread of { service: 'live', type: 'vod'|'clip', id } (server/comments-client.js, Community 7d7cc3d). Live checks the item with access.js first, resolves the thread with its service token (community.comment.write) and remembers it in comment_thread_refs; people read, comment and edit as their Network subject (X-OV-Subject; an account without one gets 409), signed in only as before; edit is the author's only; delete goes as the author, as staff (X-OV-Staff, community.comment.moderate) or, for the item's owners, as Live itself. The visitor's address is forwarded so Community's per-address limit counts people. /:commentId/replies now answers (it was shadowed by /:type/:id).

- Community unreachable or failing: every route answers 503 "Comments are unavailable right now" (code comments_unavailable) and the player says so, never an empty list; comment_count on VOD/clip detail is null then (1.5 s timeout, skipped for 30 s after a failure). A thread Community's moderators hid is 404 comments_hidden here too.
- Public and unlisted items link the same thread on Community (thread.url, "View this thread on OpenVibe.Community"); private ones never do.
- Deleting a VOD or clip (player, bulk actions, admin storage) hides its thread, paced under Community's API limit; the admin bulk clip delete no longer deletes Live comment rows.
- Live's comments table is read-only: the helpers are gone and test/frozen-tables.test.js now fails on any write to it. Its rows move with Community's scripts/import-live-comments.js.
- Tests: test/comments-community.test.js (against test/community-stub.js), media-privacy covers replies/edit/delete of a private item's comments.

Changes

ddiff --git a/.env.example b/.env.example
iindex 22de26b5..ea95709c 100644
---- a/.env.example
++++ b/.env.example
@@@ -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=0
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
# stops serving /ws/chat and /api/chat|dm|tts|sounds (nginx sends them to Chat), forwards its own
ddiff --git a/AGENTS.md b/AGENTS.md
iindex 52df615b..b79e4df1 100644
---- a/AGENTS.md
++++ b/AGENTS.md
@@@ -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
ddiff --git a/docs/architecture.md b/docs/architecture.md
iindex 6c3ca26b..b23fac9e 100644
---- a/docs/architecture.md
++++ b/docs/architecture.md
@@@ -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
ddiff --git a/docs/vods-and-clips.md b/docs/vods-and-clips.md
iindex fcb22efc..3b054a72 100644
---- a/docs/vods-and-clips.md
++++ b/docs/vods-and-clips.md
@@@ -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
ddiff --git a/public/css/features/media.css b/public/css/features/media.css
iindex 4710dfa0..32719138 100644
---- a/public/css/features/media.css
++++ b/public/css/features/media.css
@@@ -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; }
ddiff --git a/public/fragments/documentation.html b/public/fragments/documentation.html
iindex 023bbbcf..da852cf3 100644
---- a/public/fragments/documentation.html
++++ b/public/fragments/documentation.html
@@@ -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>) &rarr; <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">
ddiff --git a/public/js/app-media.js b/public/js/app-media.js
iindex a06d035c..ae1a908c 100644
---- a/public/js/app-media.js
++++ b/public/js/app-media.js
@@@ -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 in
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 {
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>
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex b85b0dd4..1309c85c 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -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-only
deleted++;
} catch (err) {
errors.push(`Clip ${id}: ${err.message}`);
ddiff --git a/server/comments-client.js b/server/comments-client.js
nnew file mode 100644
iindex 00000000..5c48bf3d
---- /dev/null
++++ b/server/comments-client.js
@@@ -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,
++};
ddiff --git a/server/db/database.js b/server/db/database.js
iindex ad9cf465..ebf756f8 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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 Keys
createVerificationKey, getVerificationKeyByKey, getVerificationKeyByUsername,
getAllVerificationKeys, redeemVerificationKey, revokeVerificationKey, isUsernameReserved,
-- // Comments
-- createComment, getComments, getCommentReplies, getCommentById, getCommentCount,
-- deleteComment, updateComment,
// Chat Replay
getChatReplay,
// Channel lookup
ddiff --git a/server/media-proxy/clips.js b/server/media-proxy/clips.js
iindex 8f389c9e..7110855f 100644
---- a/server/media-proxy/clips.js
++++ b/server/media-proxy/clips.js
@@@ -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 unreachable
clip.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');
ddiff --git a/server/media-proxy/comments.js b/server/media-proxy/comments.js
iindex 34d180e4..24db3023 100644
---- a/server/media-proxy/comments.js
++++ b/server/media-proxy/comments.js
@@@ -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;
ddiff --git a/server/media-proxy/vods.js b/server/media-proxy/vods.js
iindex 90401e29..5c440162 100644
---- a/server/media-proxy/vods.js
++++ b/server/media-proxy/vods.js
@@@ -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 unreachable
res.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');
ddiff --git a/test/comments-community.test.js b/test/comments-community.test.js
nnew file mode 100644
iindex 00000000..a49e4dd4
---- /dev/null
++++ b/test/comments-community.test.js
@@@ -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); });
ddiff --git a/test/community-stub.js b/test/community-stub.js
nnew file mode 100644
iindex 00000000..470bdae1
---- /dev/null
++++ b/test/community-stub.js
@@@ -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 };
ddiff --git a/test/frozen-tables.test.js b/test/frozen-tables.test.js
iindex e8aa964c..7f0e6621 100644
---- a/test/frozen-tables.test.js
++++ b/test/frozen-tables.test.js
@@@ -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",
ddiff --git a/test/media-privacy.test.js b/test/media-privacy.test.js
iindex 6b311c2e..0fb4344d 100644
---- a/test/media-privacy.test.js
++++ b/test/media-privacy.test.js
@@@ -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');