openvibers/openvibe.community Commits
Live's VOD and clip comments become Community threads (Wave 5 exit: Live and a second product share one thread)
Changes
@@@ -106,6 +106,7 @@ Pages (server-rendered HTML):| `GET /s/:space/new`, `POST /s/:space/new` | Start a thread (signed in) || `POST /s/:space/t/:slug/reply\|vote\|state\|delete` | The no-JS forms (reply, vote, pin/lock, delete) || `GET /pulse` | Public activity across the network — `?origin=user\|ai\|system`, `?after=` |++| `GET /c/:accessId`, `POST /c/:accessId` | One comment thread's own page — the same thread the owner product embeds (e.g. a Live VOD), newest first, `?after=` for older; signed-in people comment with the no-JS form. Opens only by the unguessable access id, `noindex` |API and machine endpoints:@@@ -156,6 +157,8 @@ resolves it. Errors are `application/problem+json` (`errors.problem@1`, with the| `POST /threads/resolve` `{ ref }` | Get-or-create the entity's thread (201 created, 200 existing; idempotent, unique on service+type+id) || `GET /threads/:id` | The thread + first page of top-level comments, replies nested one level. `?after=<last id>`, `?sort=old\|new`, `?limit=`; `?parent=<id>` pages one comment's replies || `POST /threads/:id/comments` `{ message, parent_id?, anon_name? }` | Comment. A reply to a reply joins the top-level comment's replies |++| `GET /:commentId` | Services only: one comment and its thread (with the ref) — an owner product checks what a comment belongs to before it edits or deletes it for someone. Browsers get 404 (comment ids are sequential) |++| `PATCH /:commentId` `{ message }` | The author only; sets `edited_at` || `DELETE /:commentId` | The author or a moderator (soft; a top-level comment with replies stays as a tombstone) || `POST /:commentId/votes` `{ value: 1\|-1\|0 }` | Add, change or remove your vote (people only) || `PUT /threads/:id/visibility` `{ visibility: public\|hidden\|locked }` | Moderators — e.g. the owner service hides the thread of an entity it took down |@@@ -164,9 +167,13 @@ Who may do what:- **Browsers** (the Network JWT as `ov_token` cookie here, or `Authorization: Bearer` fromanother OpenVibe site through CORS — `API_CORS_ORIGINS`, never cookies) resolve threads only-- for these types: `live` vod, clip, stream, channel · `media` object · `community` paste, post++ for these types: `live` stream, channel · `media` object · `community` paste, post· `wiki` page · `blog` post · `reviews` entity. Community's own refs must exist and be-- visible. Labels are only taken from services.++ visible. Labels are only taken from services. Live VODs and clips are resolved by Live only:++ a private one is missing to everyone but its owners and staff, which only Live can decide, so++ Live hands the access id only to people who may see the item.++- **Signed-in only**: threads of `live` vod and clip take no anonymous comments (Live's rule) —++ a browser without a subject, or a service naming nobody, gets 401 `auth.required`.- **Anonymous** visitors comment with an `anon_name`, under the same 20-writes-per-10-minutesbudget per address as anonymous pastes. People are limited per subject (10 s cooldown,5 per minute, no duplicate in a row; 60 votes a minute), whichever way they write.@@@ -183,6 +190,64 @@ Who may do what:- **Locked** threads can be read but take no comments or votes (moderators still may);**hidden** threads are 404 for everyone but moderators.- Paste comments stay in `paste_comments` behind `/api/pastes/:slug/comments` for now.++- Comments carry `edited_at` (null until the author edits) and `can_edit` / `can_delete` for the++ viewer.++++### Live's VOD and clip comments++++Since roadmap Wave 5 (exit criterion: Live and a second product share one Community thread),++OpenVibe.Live keeps no comments of its own: its `/api/comments/:type/:id` routes are an adapter++over the thread of `{ service: 'live', type: 'vod'|'clip', id }` (Live's++`server/comments-client.js`). Live resolves the thread with its service token after its own++visibility check, reads and comments as the signed-in person (`X-OV-Subject`), edits as the++author, and deletes as the author, as staff (`X-OV-Staff: 1`) or — for the VOD's or clip's++owner — as itself (`community.comment.moderate`). Deleting a VOD or clip hides its thread. The++same thread is this site's page `/c/<access id>`; Live links to it under a public or unlisted++item's comments.++++**Checking that both show the same thread** (production or local):++++1. Open a public VOD on Live (`https://openvibe.live/vod/<id>`), post a comment, and follow++ "View this thread on OpenVibe.Community" under the comments. The page `/c/cth_…` lists the++ same comments, newest first, with the same authors.++2. Comment on that Community page while signed in, reload the Live VOD: the new comment is++ there. Delete it on Live: it is gone from the Community page.++3. Service-side, the same answer: `GET /api/v1/comments/threads/<id>?sort=new` with Live's token++ returns the comments both pages render (`test/comments.test.js` checks the page against it).++++**Moving Live's old rows** (`scripts/import-live-comments.js`, `server/comments/live-import.js`):++reads Live's `comments` table read-only, puts every row in exactly one bucket and prints the++reconciliation `read = imported + held + excluded`. Imported rows keep their text and times++(edits keep `edited_at`); the ledger is `legacy_id_map` (`live`/`comment`/<live id> → comment++id), so re-running imports nothing twice. Authors become Network subjects from Live's++`linked_accounts.subject_id` and the Network's identity map (system `live`); a row whose author++maps nowhere (`unmapped_author`), maps two ways (`ambiguous_author`) or whose parent is held++(`parent_held`) is **held** in `import_hold` (source type `live_comment`) and listed, and a later++run imports it once the mapping exists. Deleted rows and replies under them, orphans and empty++messages are **excluded**. Dry run is the default (everything rolled back); `--apply` requires++`--backup <new file>`, an online backup of Community's database that is integrity-checked++before anything is written. Community must be deployed first (the script refuses a database++without `comments.edited_at` rather than migrate it from a dry run).++++Production (on the host, as the service's own user and environment through `systemd-run`, so++no file in `/var/lib/openvibe-community` ends up owned by root). Order: deploy Community → dry++run → apply → deploy Live → apply again (catches comments Live wrote in between; the ledger++skips the rest) → verify as above.++++```++RUN="sudo systemd-run --wait --pipe --collect -p User=ubuntu -p Group=ubuntu \++ -p WorkingDirectory=/opt/openvibe.community -p EnvironmentFile=/etc/openvibe/community.env \++ -E NODE_ENV=production -E COMMUNITY_DB_PATH=/var/lib/openvibe-community/community.db"++ARGS="--live-db /opt/openvibe.live/data/live.db --community-db /var/lib/openvibe-community/community.db"++++$RUN /usr/bin/env node scripts/import-live-comments.js $ARGS # dry run++$RUN /usr/bin/env node scripts/import-live-comments.js $ARGS --apply \++ --backup /var/lib/openvibe-community/community.pre-live-comments-$(date -u +%Y%m%dT%H%M%SZ).db++```++++Held rows: `sqlite3 /var/lib/openvibe-community/community.db "SELECT * FROM import_hold WHERE++source_type = 'live_comment'"`. Rollback: stop Community, copy the backup over `community.db`++(remove `community.db-wal`/`-shm`), start it; Live's own rows were never changed.Embedding it — server-side, from another product's backend (the usual way; the person is theone your own session says it is):@@@ -211,7 +276,7 @@ In the browser, from a page on an allowed origin that holds the visitor's Networconst api = (path, opts = {}) => fetch(`https://openvibe.community/api/v1/comments${path}`, {...opts, headers: { Authorization: `Bearer ${networkJwt}`, 'Content-Type': 'application/json', ...(opts.headers || {}) },}).then((r) => r.json());--const { thread } = await api('/threads/resolve', { method: 'POST', body: JSON.stringify({ ref: { service: 'live', type: 'clip', id: clipId } }) });++const { thread } = await api('/threads/resolve', { method: 'POST', body: JSON.stringify({ ref: { service: 'live', type: 'stream', id: streamId } }) });let { comments, next_cursor } = await api(`/threads/${thread.id}`);if (next_cursor) ({ comments } = await api(`/threads/${thread.id}?after=${next_cursor}`));await api(`/${comments[0].id}/votes`, { method: 'POST', body: JSON.stringify({ value: 1 }) });@@@ -419,7 +484,8 @@ server/pastes/importer.js Media export bundle → store (scripts/import-pastes.js is the CLI)pastes/source.js where pages read pastes from (Live or the store)pastes/catalog.js recent public pastes: trending, related, language filter-- comments/ typed comment threads: store (SQL), service (rules), api (/api/v1/comments)++ comments/ typed comment threads: store (SQL), service (rules), api (/api/v1/comments),++ routes (the /c/:accessId page), live-import (Live's old VOD/clip comments)forum/ spaces/threads/posts: store, service, api (/api/v1/spaces, /posts), routes (pages)pulse/ Pulse read model: store, service (hooks + ingest), api (/api/v1/pulse)relay/ Discord relay: discord.js (queue, worker, backoff), api (/api/v1/relay)@@@ -432,6 +498,7 @@ server/render/pages.js home / browse / paste / new / my / error templatesrender/forum.js spaces / threads / thread / new-thread templatesrender/pulse.js the /pulse page++ render/comments.js a comment thread's own pagerender/markdown.js the safe Markdown subset for postsrender/highlight.js highlight.js wrapper, language list, download extensionsseo.js robots, sitemap, RSS, JSON-LD builders@@@ -439,6 +506,7 @@ public/ css/community.css, js/community.js, favicon.svg, og-defaul(openvibe-shared is the pinned OpenVibe.Shared v1.0.0 release, installed by npm)deploy/ systemd unit, nginx vhostscripts/import-pastes.js Media paste bundle importer++scripts/import-live-comments.js Live's VOD/clip comments → Community threads (dry run by default)test/ run.js + *.test.js (mock Live, Network and Media with a real RS256 key)docs/capabilities-proposal/ Wave 5 capability manifests (released in openvibe-contracts v0.7.0)```@@@ -206,3 +206,8 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-.thread-row, .pulse-item { flex-wrap: wrap; }.thread-head { gap: 10px; }}++++/* ── A comment thread's own page (/c/:accessId) ── */++.comment-text { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }++.comment-replies { display: flex; flex-direction: column; gap: 8px; margin: 10px 0 0 18px; }++.comment-replies .post { padding: 10px 14px; }nnew file mode 100755@@@ -0,0 +1,108 @@++#!/usr/bin/env node++'use strict';++++/**++ * Import OpenVibe.Live's own VOD and clip comments into Community comment threads.++ *++ * node scripts/import-live-comments.js --live-db <live.db> # dry run (default)++ * node scripts/import-live-comments.js --live-db <live.db> --apply --backup <file.db> # write++ *++ * Options:++ * --live-db <file> Live's database, opened read-only (required)++ * --community-db <file> Community's database (default: COMMUNITY_DB_PATH, as the server)++ * --apply write; without it nothing is written and the report shows what would be++ * --backup <file> required with --apply: an online backup of Community's database is written++ * here (must not exist yet) and checked before anything changes++ * --live-links-only name authors from Live's linked_accounts only (no Network call)++ *++ * Reads the same environment as the server (.env / /etc/openvibe/community.env):++ * COMMUNITY_DB_PATH, OV_NETWORK_INTERNAL_URL, OV_OAUTH_CLIENT_ID, OV_OAUTH_CLIENT_SECRET (the++ * client needs identity.subject.resolve on openvibe.network). Safe to re-run: the ledger++ * (legacy_id_map live/comment) skips rows already imported. See server/comments/live-import.js.++ *++ * Exit codes: 0 done (or dry run finished), 2 refused (bad options, backup failed, reconciliation++ * failed), 1 anything else.++ */++const fs = require('fs');++const path = require('path');++++function usage(msg, code = msg ? 2 : 0) {++ if (msg) console.error(msg);++ console.error('usage: node scripts/import-live-comments.js --live-db <live.db> [--community-db <community.db>] [--apply --backup <file>] [--live-links-only]');++ process.exit(code);++}++++function parseArgs(argv) {++ const out = { liveDb: null, communityDb: null, apply: false, backup: null, liveLinksOnly: false };++ const value = (i, name) => { const v = argv[i]; if (!v || v.startsWith('--')) usage(`${name} needs a value`); return v; };++ for (let i = 0; i < argv.length; i++) {++ const a = argv[i];++ if (a === '--live-db') out.liveDb = value(++i, a);++ else if (a === '--community-db') out.communityDb = value(++i, a);++ else if (a === '--backup') out.backup = value(++i, a);++ else if (a === '--apply') out.apply = true;++ else if (a === '--dry-run') out.apply = false;++ else if (a === '--live-links-only') out.liveLinksOnly = true;++ else if (a === '-h' || a === '--help') usage();++ else usage(`unknown argument ${a}`);++ }++ if (!out.liveDb) usage('missing --live-db');++ if (out.apply && !out.backup) usage('--apply needs --backup <file>: a backup of Community\'s database is taken first');++ return out;++}++++async function main() {++ const args = parseArgs(process.argv.slice(2));++ const Database = require('better-sqlite3');++ const config = require('../server/config');++ const { openDb } = require('../server/db');++ const { importLiveComments, formatReport, LiveImportError } = require('../server/comments/live-import');++++ if (!fs.existsSync(args.liveDb)) usage(`no Live database at ${args.liveDb}`);++ const communityPath = args.communityDb || config.dbPath;++ if (!fs.existsSync(communityPath)) usage(`no Community database at ${communityPath}`);++ // Opening runs the additive schema migrations; a dry run must not be what applies them.++ {++ const probe = new Database(communityPath, { readonly: true, fileMustExist: true });++ const cols = new Set(probe.prepare('PRAGMA table_info(comments)').all().map((c) => c.name));++ probe.close();++ if (!cols.has('edited_at')) usage(`Community's database at ${communityPath} predates this importer: deploy and restart Community first`);++ }++++ const liveDb = new Database(args.liveDb, { readonly: true, fileMustExist: true });++ const db = openDb(communityPath);++ try {++ if (args.apply) {++ const target = path.resolve(args.backup);++ if (fs.existsSync(target)) usage(`${target} exists already; pick a new backup file`);++ await db.backup(target);++ const check = new Database(target, { readonly: true, fileMustExist: true });++ const ok = check.pragma('integrity_check', { simple: true });++ const counts = check.prepare('SELECT (SELECT COUNT(*) FROM comments) AS comments, (SELECT COUNT(*) FROM comment_threads) AS threads').get();++ check.close();++ if (ok !== 'ok') { console.error(`Refused: the backup at ${target} failed its integrity check (${ok})`); process.exit(2); }++ console.log(`Backup: ${target} (integrity ok; ${counts.comments} comments in ${counts.threads} threads)`);++ }++++ let resolveLegacy = null;++ if (!args.liveLinksOnly) {++ const { createNetworkIdentity } = require('../server/identity/network');++ const network = createNetworkIdentity({ config, db });++ // A dry run writes nothing at all, not even the display cache.++ resolveLegacy = (system, ids) => network.resolveLegacy(system, ids, { cache: args.apply });++ }++ const report = await importLiveComments(db, liveDb, {++ resolveLegacy, dryRun: !args.apply, source: path.basename(args.liveDb),++ });++ console.log(formatReport(report));++ if (!args.apply) console.log('\nNothing was written. Re-run with --apply --backup <file> to import.');++ } catch (err) {++ if (err instanceof LiveImportError) { console.error(`Refused: ${err.message}`); process.exit(2); }++ throw err;++ } finally {++ liveDb.close();++ db.close();++ }++}++++main().catch((err) => { console.error(err && err.stack ? err.stack : err); process.exit(1); });@@@ -16,6 +16,7 @@* GET /my signed-in user's pastes /auth/login|callback|logout|me|refresh* GET /s … spaces, threads, posts (forum/routes.js)* GET /pulse the network's public activity++ * GET|POST /c/:accessId one comment thread's own page (comments/routes.js)** Comments, the forum, Pulse and the relay live in Community's database in every mode.*@@@ -46,6 +47,7 @@ const { createViewerResolver } = require('./identity/viewer');const v1 = require('./http/v1');const { createCommentService } = require('./comments/service');const { createCommentsApi } = require('./comments/api');++const { createCommentPages } = require('./comments/routes');const { createForumService } = require('./forum/service');const { createSpacesApi, createPostsApi } = require('./forum/api');const { createForumRoutes } = require('./forum/routes');@@@ -302,6 +304,7 @@ function createApp(opts = {}) {// ── Forum and Pulse pages ────────────────────────────────app.use(createForumRoutes({ forum, viewers, config }));++ app.use(createCommentPages({ comments, viewers, config }));app.get('/pulse', viewers.middleware({ services: false }), wrap(async (req, res) => {const origin = pulse.ORIGINS.includes(req.query.origin) ? req.query.origin : '';let out;@@@ -8,6 +8,8 @@* :id is the access id (cth_…); the sequential id is for services only* POST /threads/:id/comments { message, parent_id?, anon_name? }* PUT /threads/:id/visibility { visibility: public|hidden|locked } moderators++ * GET /:commentId one comment + its thread (ref included) services only++ * PATCH /:commentId { message } the author only* DELETE /:commentId author or moderator* POST /:commentId/votes { value: 1|-1|0 }*@@@ -38,6 +40,8 @@ function createCommentsApi({ service, viewers, anonWriteLimiter, resolveLimiterrouter.post('/threads/:id/comments', serviceCap(WRITE), anonWriteLimiter || passAnon, jsonBody,run((req) => service.add(req.viewer, req.params.id, req.body || {}), 201));router.put('/threads/:id/visibility', serviceCap(MOD), jsonBody, run((req) => service.setVisibility(req.viewer, req.params.id, req.body || {})));++ router.get('/:commentId', read, run((req) => service.getComment(req.viewer, req.params.commentId)));++ router.patch('/:commentId', serviceCap(WRITE), jsonBody, run((req) => service.edit(req.viewer, req.params.commentId, req.body || {})));router.delete('/:commentId', serviceAnyCap([WRITE, MOD]), run((req) => service.remove(req.viewer, req.params.commentId)));router.post('/:commentId/votes', serviceCap(WRITE), jsonBody, run((req) => service.vote(req.viewer, req.params.commentId, req.body || {})));nnew file mode 100644@@@ -0,0 +1,220 @@++'use strict';++++/**++ * OpenVibe.Live's own VOD and clip comments (live.db `comments`) → Community comment threads.++ *++ * Live used to keep comments on VODs and clips in its own table; they are now the Community++ * threads of EntityRef { service: 'live', type: 'vod'|'clip', id } (Live's /api/comments is an++ * adapter over them). This moves the old rows across, once, keeping their times and text.++ *++ * Every Live row ends up in exactly one bucket, so the report reconciles:++ *++ * read = imported (new this run + already imported by an earlier run) + held + excluded++ *++ * imported the comment is in its thread; legacy_id_map ('live','comment',<live id>) → the++ * Community comment id is the ledger, so a second run imports nothing twice++ * held the author could not be named as a Network subject (unmapped_author), or two++ * sources named different subjects (ambiguous_author), or its parent comment is held++ * (parent_held). Recorded in import_hold ('live_comment', <live id>, reason) and++ * listed; a later run imports them once the mapping exists (and clears the hold)++ * excluded deleted on Live (deleted), a reply under a deleted comment (parent_deleted, Live++ * no longer showed those), a parent that is missing or on other content (orphan),++ * an empty message (empty) or a target type other than vod/clip (bad_target)++ *++ * Authors: Live's linked_accounts(service='network').subject_id (written from verified Network++ * tokens) and the Network's identity map (resolve-batch, system 'live', type 'user'). Either one++ * is enough; when both answer they must agree.++ *++ * Replies nest one level in Community: a Live reply to a reply joins its top-level comment.++ * A row Live marked edited (updated_at later than created_at) keeps that time as edited_at.++ * Live's database is only read. With dryRun (the default) everything runs in a transaction that++ * is rolled back, so the report is exactly what --apply would do.++ */++const store = require('./store');++++const SOURCE_TYPE = 'live_comment';++const LEDGER = ['live', 'comment'];++const IMPORTER = 'import:live-comments';++++class LiveImportError extends Error {}++++function readLive(liveDb) {++ const tables = new Set(liveDb.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));++ if (!tables.has('comments')) throw new LiveImportError('the Live database has no comments table');++ const rows = liveDb.prepare(`SELECT id, content_type, content_id, user_id, parent_id, message, is_deleted, created_at, updated_at++ FROM comments ORDER BY id`).all();++ const links = new Map();++ if (tables.has('linked_accounts')) {++ const cols = new Set(liveDb.prepare('PRAGMA table_info(linked_accounts)').all().map((c) => c.name));++ if (cols.has('subject_id')) {++ for (const r of liveDb.prepare("SELECT user_id, subject_id FROM linked_accounts WHERE service = 'network' AND subject_id IS NOT NULL").all()) {++ links.set(String(r.user_id), r.subject_id);++ }++ }++ }++ return { rows, links };++}++++/** Live's SQLite times are UTC 'YYYY-MM-DD HH:MM:SS'; keep them in that form. */++function sqlTime(v) {++ if (!v) return null;++ const s = String(v);++ if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(s)) return s;++ const d = new Date(/^\d{4}-\d{2}-\d{2}T/.test(s) || /Z$/.test(s) ? s : `${s.replace(' ', 'T')}Z`);++ return Number.isNaN(d.getTime()) ? null : d.toISOString().replace('T', ' ').slice(0, 19);++}++++const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++++/**++ * opts.resolveLegacy(system, ids) → Map(id → { subject: { type, id } } | null) (Network), or null++ * to rely on Live's own links only. opts.dryRun (default true). opts.source names the run.++ */++async function importLiveComments(db, liveDb, { resolveLegacy = null, dryRun = true, source = 'live.db' } = {}) {++ const { rows, links } = readLive(liveDb);++ const byId = new Map(rows.map((r) => [r.id, r]));++++ // ── Classify: excluded rows first (they need no author) ──++ const excluded = new Map(); // live id → reason++ const rootOf = new Map(); // live id → top-level live id++ for (const r of rows) {++ if (r.is_deleted) { excluded.set(r.id, 'deleted'); continue; }++ if (!['vod', 'clip'].includes(r.content_type) || !(Number(r.content_id) > 0)) { excluded.set(r.id, 'bad_target'); continue; }++ if (!String(r.message || '').trim()) { excluded.set(r.id, 'empty'); continue; }++ let cur = r, reason = null;++ const seen = new Set([r.id]);++ while (cur.parent_id != null) {++ const p = byId.get(cur.parent_id);++ if (!p || seen.has(p.id) || p.content_type !== r.content_type || p.content_id !== r.content_id) { reason = 'orphan'; break; }++ if (p.is_deleted) { reason = 'parent_deleted'; break; }++ seen.add(p.id);++ cur = p;++ }++ if (reason) excluded.set(r.id, reason);++ else rootOf.set(r.id, cur.id);++ }++++ // ── Authors → subjects ──++ const userIds = [...new Set(rows.filter((r) => rootOf.has(r.id)).map((r) => String(r.user_id)))];++ let network = new Map();++ if (resolveLegacy && userIds.length) network = await resolveLegacy('live', userIds);++ const subjectFor = new Map(); // live user id → { subject } | { reason, detail }++ for (const uid of userIds) {++ const hit = network.get(uid);++ const fromNet = hit && hit.subject && hit.subject.type === 'user' && USER_SUBJECT.test(hit.subject.id) ? hit.subject.id : null;++ const fromLive = USER_SUBJECT.test(String(links.get(uid) || '')) ? links.get(uid) : null;++ if (fromNet && fromLive && fromNet !== fromLive) subjectFor.set(uid, { reason: 'ambiguous_author', detail: `network says ${fromNet}, Live's link says ${fromLive}` });++ else if (fromNet || fromLive) subjectFor.set(uid, { subject: fromNet || fromLive });++ else subjectFor.set(uid, { reason: 'unmapped_author', detail: `live user ${uid} has no Network subject` });++ }++++ const report = {++ source, dry_run: !!dryRun, read: rows.length,++ imported: 0, imported_new: 0, imported_before: 0, held: 0, excluded: 0,++ excluded_by_reason: {}, held_rows: [], threads: [], reconciled: false,++ };++++ const ledger = db.prepare('SELECT target_id FROM legacy_id_map WHERE source_system = ? AND source_type = ? AND source_id = ?');++ const ledgerGet = (liveId) => {++ const hit = ledger.get(LEDGER[0], LEDGER[1], String(liveId));++ return hit ? Number(hit.target_id) : null;++ };++ const ledgerSet = db.prepare(`INSERT INTO legacy_id_map (source_system, source_type, source_id, target_type, target_id) VALUES (?, ?, ?, 'comment', ?)++ ON CONFLICT(source_system, source_type, source_id) DO NOTHING`);++ const hold = db.prepare(`INSERT INTO import_hold (source_type, source_id, reason, detail) VALUES (?, ?, ?, ?)++ ON CONFLICT(source_type, source_id, reason) DO UPDATE SET detail = excluded.detail`);++ const unhold = db.prepare('DELETE FROM import_hold WHERE source_type = ? AND source_id = ?');++ const insert = db.prepare(`INSERT INTO comments (thread_id, parent_id, author_subject, anon_name, origin, message, created_at, updated_at, edited_at)++ VALUES (?, ?, ?, NULL, 'user', ?, ?, ?, ?)`);++++ const apply = () => {++ const touchedThreads = new Set();++ const touchedParents = new Set();++ const threadIds = new Map(); // 'vod:12' → thread row++ const threadFor = (r) => {++ const key = `${r.content_type}:${r.content_id}`;++ if (!threadIds.has(key)) {++ const { thread } = store.resolveThread(db, { service: 'live', type: r.content_type, id: String(r.content_id) }, { createdBy: IMPORTER });++ threadIds.set(key, thread);++ }++ return threadIds.get(key);++ };++ const state = new Map(); // live id → 'imported' | 'held'++++ // Top-level comments first, then replies (whose parent must be placed already).++ const ordered = rows.filter((r) => rootOf.has(r.id)).sort((a, b) => ((a.parent_id == null ? 0 : 1) - (b.parent_id == null ? 0 : 1)) || (a.id - b.id));++ for (const r of ordered) {++ const root = rootOf.get(r.id);++ const who = subjectFor.get(String(r.user_id));++ let heldReason = who.subject ? null : who.reason;++ let detail = who.subject ? null : who.detail;++ if (!heldReason && root !== r.id && state.get(root) === 'held') { heldReason = 'parent_held'; detail = `parent comment ${root} is held`; }++ if (heldReason) {++ state.set(r.id, 'held');++ unhold.run(SOURCE_TYPE, String(r.id)); // one current reason per row++ hold.run(SOURCE_TYPE, String(r.id), heldReason, JSON.stringify({ detail, content_type: r.content_type, content_id: r.content_id, user_id: r.user_id }));++ report.held_rows.push({ live_id: r.id, reason: heldReason, content: `${r.content_type}/${r.content_id}`, live_user_id: r.user_id, detail });++ continue;++ }++ const thread = threadFor(r);++ if (!report.threads.includes(thread.access_id)) report.threads.push(thread.access_id);++ const known = ledgerGet(r.id);++ if (known) { state.set(r.id, 'imported'); report.imported_before++; continue; }++ const parentCommunityId = root === r.id ? null : ledgerGet(root);++ const created = sqlTime(r.created_at) || sqlTime(new Date().toISOString());++ const updated = sqlTime(r.updated_at) || created;++ const edited = updated > created ? updated : null;++ const info = insert.run(thread.id, parentCommunityId, who.subject, String(r.message).trim(), created, updated, edited);++ ledgerSet.run(LEDGER[0], LEDGER[1], String(r.id), String(info.lastInsertRowid));++ unhold.run(SOURCE_TYPE, String(r.id));++ state.set(r.id, 'imported');++ report.imported_new++;++ touchedThreads.add(thread.id);++ if (parentCommunityId) touchedParents.add(parentCommunityId);++ }++++ // Counters from the rows (the store keeps them in step for live writes; here we set them once).++ for (const id of touchedThreads) {++ db.prepare('UPDATE comment_threads SET comment_count = (SELECT COUNT(*) FROM comments WHERE thread_id = ? AND deleted_at IS NULL), updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(id, id);++ }++ for (const id of touchedParents) {++ db.prepare('UPDATE comments SET reply_count = (SELECT COUNT(*) FROM comments WHERE parent_id = ? AND deleted_at IS NULL) WHERE id = ?').run(id, id);++ }++++ report.imported = report.imported_new + report.imported_before;++ report.held = report.held_rows.length;++ for (const reason of excluded.values()) report.excluded_by_reason[reason] = (report.excluded_by_reason[reason] || 0) + 1;++ report.excluded = excluded.size;++ report.reconciled = report.read === report.imported + report.held + report.excluded;++ if (!report.reconciled) throw new LiveImportError(`reconciliation failed: read ${report.read} ≠ imported ${report.imported} + held ${report.held} + excluded ${report.excluded}`);++ if (!dryRun) {++ db.prepare('INSERT INTO migration_runs (source, finished_at, report) VALUES (?, CURRENT_TIMESTAMP, ?)').run(`live-comments:${source}`, JSON.stringify(report));++ }++ };++++ const ROLLBACK = Symbol('dry run');++ try {++ db.transaction(() => { apply(); if (dryRun) throw ROLLBACK; })();++ } catch (err) {++ if (err !== ROLLBACK) throw err;++ }++ return report;++}++++function formatReport(r) {++ const lines = [];++ lines.push(`Live comments → Community threads (${r.dry_run ? 'DRY RUN — nothing was written' : 'APPLIED'}) from ${r.source}`);++ lines.push(` read ${r.read}`);++ lines.push(` imported ${r.imported} (new ${r.imported_new}, already imported ${r.imported_before})`);++ lines.push(` held ${r.held}`);++ lines.push(` excluded ${r.excluded}${Object.keys(r.excluded_by_reason).length ? ` (${Object.entries(r.excluded_by_reason).map(([k, v]) => `${k} ${v}`).join(', ')})` : ''}`);++ lines.push(` check read ${r.read} = imported ${r.imported} + held ${r.held} + excluded ${r.excluded} → ${r.reconciled ? 'OK' : 'MISMATCH'}`);++ lines.push(` threads ${r.threads.length}`);++ if (r.held_rows.length) {++ lines.push(' held rows (not imported; re-run once the author has a Network subject):');++ for (const h of r.held_rows) lines.push(` live comment ${h.live_id} ${h.reason} on ${h.content} live user ${h.live_user_id}${h.detail ? ` — ${h.detail}` : ''}`);++ }++ return lines.join('\n');++}++++module.exports = { importLiveComments, formatReport, LiveImportError, SOURCE_TYPE };nnew file mode 100644@@@ -0,0 +1,69 @@++'use strict';++++/**++ * A comment thread's own page (render/comments.js has the markup, service.js the rules):++ *++ * GET /c/:accessId the thread, newest first (?after=<last comment id> for older ones)++ * POST /c/:accessId comment (signed in; the no-JS form)++ *++ * Only the unguessable access id (cth_…) opens a page — the one Community hands out on resolve and++ * owner products link to — so pages cannot be walked either. Hidden threads are 404. Form posts++ * carry the ov_token cookie (SameSite=Lax) and an Origin from another site is refused, as in the++ * forum.++ */++const express = require('express');++const pages = require('../render/pages');++const { threadPage } = require('../render/comments');++const { ApiError } = require('../http/v1');++++const ACCESS_ID_RE = /^cth_[A-Za-z0-9_-]{22}$/;++++function createCommentPages({ comments, viewers, config }) {++ const router = express.Router();++ const withViewer = viewers.middleware({ services: false });++ const form = express.urlencoded({ extended: false, limit: '64kb' });++ const html = (res, body, status = 200) => res.status(status).type('html').set('Cache-Control', 'no-cache').send(body);++ const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);++++ function failPage(req, res, err, next) {++ if (!(err instanceof ApiError)) return next(err);++ if (err.status === 401) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/c/${req.params.accessId}`)}`);++ const titles = { 404: 'Not found', 403: 'Not allowed', 429: 'Slow down' };++ return html(res, pages.errorPage({ status: err.status, title: titles[err.status] || 'That did not work', message: err.status === 404 ? 'There is no comment thread at that address.' : err.message }), err.status);++ }++++ const notFound = () => new ApiError(404, 'thread.not_found', 'Comment thread not found');++++ async function render(req, res, { status = 200, error = null, draft = '' } = {}) {++ const after = /^\d{1,15}$/.test(String(req.query.after || '')) ? String(req.query.after) : null;++ const page = await comments.get(req.viewer, req.params.accessId, { sort: 'new', after, limit: 30 });++ html(res, threadPage({ accessId: req.params.accessId, page, user: req.user, after, error, draft }), status);++ }++++ router.get('/c/:accessId', withViewer, wrap(async (req, res, next) => {++ try {++ if (!ACCESS_ID_RE.test(req.params.accessId)) throw notFound();++ await render(req, res);++ } catch (err) { failPage(req, res, err, next); }++ }));++++ router.post('/c/:accessId', withViewer, form, wrap(async (req, res, next) => {++ const origin = req.get('origin');++ if (origin && origin !== 'null' && origin !== config.baseUrl) return html(res, pages.errorPage({ status: 403, title: 'Not allowed', message: 'That form was sent from another site.' }), 403);++ const draft = String((req.body || {}).message || '').slice(0, 5000);++ try {++ if (!ACCESS_ID_RE.test(req.params.accessId)) throw notFound();++ // Signed-in people only on this page, whatever the thread allows through the API.++ if (!req.viewer || !req.viewer.subject) throw new ApiError(401, 'auth.required', 'Sign in to comment');++ const out = await comments.add(req.viewer, req.params.accessId, { message: draft });++ res.redirect(303, `/c/${req.params.accessId}#comment-${out.comment.id}`);++ } catch (err) {++ if (!(err instanceof ApiError) || err.status === 401 || err.status === 404) return failPage(req, res, err, next);++ try { await render(req, res, { status: err.status, error: err.message, draft }); } catch (e) { failPage(req, res, e, next); }++ }++ }));++++ return router;++}++++module.exports = { createCommentPages };@@@ -15,7 +15,11 @@* the refs (unlisted paste slugs, private pages) and comments of entities they were not given.* - comment: people (browser or service X-OV-Subject), anonymous with an anon_name, or AI output* from a service (origin ai, never attributed). Not on locked threads (moderators still may).++ * Threads of the types in SIGNED_IN_ONLY take no anonymous comments (the owner product's rule).++ * - edit: the comment's author only (edited_at records it).* - delete: the comment's author, or a moderator.++ * - one comment with its thread (getComment): services only — comment ids are sequential, so a++ * browser could otherwise walk them to the refs of threads it was never given.* - vote: people only; 1, -1 or 0 (remove).* - visibility public|hidden|locked: moderators.* A moderator is discussion staff (an admin/global_mod browser, or a service vouching with@@@ -34,15 +38,24 @@ const { createAuthors } = require('../identity/authors');const { createPersonLimiter } = require('../limits');const { discussionModerator: moderator } = require('../identity/capabilities');--/** The entity types a browser may open a thread for (services with community.comment.write: any). */++/**++ * The entity types a browser may open a thread for (services with community.comment.write: any).++ * Live VODs and clips are not here: a private one is missing to everyone but its owners and staff,++ * and only Live can tell, so only Live opens their threads (and hands the access id to people who++ * may see the item).++ */const BROWSER_REF_TYPES = {-- live: ['vod', 'clip', 'stream', 'channel'],++ live: ['stream', 'channel'],media: ['object'],community: ['paste', 'post'],wiki: ['page'],blog: ['post'],reviews: ['entity'],};++/** Threads that take comments from people only — no anonymous comments (Live's rule for VODs and clips). */++const SIGNED_IN_ONLY = {++ live: ['vod', 'clip'],++};const VISIBILITIES = ['public', 'hidden', 'locked'];const MAX_MESSAGE = 5000;const ACCESS_ID_RE = /^cth_[A-Za-z0-9_-]{22}$/;@@@ -87,6 +100,8 @@ function createCommentService({ db, network = null, pastesLocal = false, limitsreply_count: c.reply_count,created_at: isoTime(c.created_at),updated_at: isoTime(c.updated_at),++ edited_at: deleted ? null : isoTime(c.edited_at),++ can_edit: !deleted && !!(v && v.subject && c.author_subject === v.subject),can_delete: !deleted && (moderator(v) || !!(v && v.subject && c.author_subject === v.subject)),};if (c.replies) out.replies = c.replies.map((r) => shapeComment(r, v, projections, votes, threadId));@@@ -136,6 +151,15 @@ function createCommentService({ db, network = null, pastesLocal = false, limits}}++ const signedInOnly = (t) => (SIGNED_IN_ONLY[t.ref_service] || []).includes(t.ref_type);++++ function cleanMessage(raw) {++ const message = String(raw == null ? '' : raw).replace(/\u0000/g, '').trim();++ if (!message) fail(400, 'comment.empty', 'Comment cannot be empty');++ if (message.length > MAX_MESSAGE) fail(400, 'comment.too_long', `Comment must be under ${MAX_MESSAGE} characters`);++ return message;++ }++const cursorId = (after) => {if (after == null || after === '') return null;if (!/^\d{1,15}$/.test(String(after))) fail(400, 'request.invalid_cursor', '`after` is the id of the last comment you have');@@@ -144,6 +168,7 @@ function createCommentService({ db, network = null, pastesLocal = false, limitsreturn {BROWSER_REF_TYPES,++ SIGNED_IN_ONLY,/** POST /threads/resolve { ref } → { thread, created } */resolve(v, body = {}) {@@@ -197,9 +222,7 @@ function createCommentService({ db, network = null, pastesLocal = false, limitsasync add(v, id, body = {}) {const t = visibleThread(v, id);if (t.visibility === 'locked' && !moderator(v)) fail(403, 'thread.locked', 'This comment thread is locked');-- const message = String(body.message == null ? '' : body.message).replace(/\u0000/g, '').trim();-- if (!message) fail(400, 'comment.empty', 'Comment cannot be empty');-- if (message.length > MAX_MESSAGE) fail(400, 'comment.too_long', `Comment must be under ${MAX_MESSAGE} characters`);++ const message = cleanMessage(body.message);let parentId = null;if (body.parent_id != null && body.parent_id !== '') {@@@ -211,6 +234,7 @@ function createCommentService({ db, network = null, pastesLocal = false, limitsconst origin = v.origin === 'ai' ? 'ai' : 'user';const author = origin === 'ai' ? null : (v.subject || null);++ if (!author && origin !== 'ai' && !moderator(v) && signedInOnly(t)) fail(401, 'auth.required', 'Sign in to comment here');let anonName = null;if (!author && origin !== 'ai') {anonName = String(body.anon_name || '').trim().slice(0, 32).replace(/[^a-zA-Z0-9 _-]/g, '').trim() || 'Anonymous';@@@ -222,6 +246,27 @@ function createCommentService({ db, network = null, pastesLocal = false, limitsreturn { comment: (await shapeComments([c], v, t))[0] };},++ /**++ * GET /:commentId — one comment and its thread (with the ref), for services only: an owner++ * product checks what a comment belongs to before it edits or deletes it for someone.++ */++ async getComment(v, commentId) {++ if (!v || v.kind !== 'service') fail(404, 'comment.not_found', 'Comment not found');++ const { comment: c, thread: t } = visibleComment(v, commentId);++ return { comment: (await shapeComments([c], v, t))[0], thread: shapeThread(t, v) };++ },++++ /** PATCH /:commentId { message } — the author only. */++ async edit(v, commentId, body = {}) {++ if (!(v && v.subject)) fail(401, 'auth.required', 'Sign in to edit a comment');++ const { comment: c, thread: t } = visibleComment(v, commentId);++ if (!(c.author_subject && c.author_subject === v.subject)) fail(403, 'comment.not_yours', 'Only the author can edit a comment');++ if (t.visibility === 'locked' && !moderator(v)) fail(403, 'thread.locked', 'This comment thread is locked');++ const message = cleanMessage(body.message);++ const row = message === c.message ? c : store.editComment(db, c.id, message);++ return { comment: (await shapeComments([row], v, t))[0] };++ },++/** DELETE /comments/:id — the author or a moderator. */remove(v, commentId) {const { comment: c } = visibleComment(v, commentId);@@@ -257,4 +302,4 @@ function createCommentService({ db, network = null, pastesLocal = false, limits};}--module.exports = { createCommentService, BROWSER_REF_TYPES, VISIBILITIES };++module.exports = { createCommentService, BROWSER_REF_TYPES, SIGNED_IN_ONLY, VISIBILITIES };@@@ -63,6 +63,12 @@ function insertComment(db, { thread_id, parent_id = null, author_subject = null,})();}++/** The author's new text; edited_at records that it changed. → the row */++function editComment(db, id, message) {++ db.prepare('UPDATE comments SET message = ?, edited_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL').run(message, id);++ return getComment(db, id);++}++/** Soft delete; counters recomputed from the rows. → changes */function softDeleteComment(db, id, deletedBy = null) {return db.transaction(() => {@@@ -105,4 +111,4 @@ function listReplies(db, parentId, { after = null, limit = 50 } = {}) {return { rows, hasMore };}--module.exports = { getThread, getThreadByAccessId, getThreadByRef, resolveThread, setThreadVisibility, getComment, insertComment, softDeleteComment, listTopLevel, listReplies };++module.exports = { getThread, getThreadByAccessId, getThreadByRef, resolveThread, setThreadVisibility, getComment, insertComment, editComment, softDeleteComment, listTopLevel, listReplies };@@@ -157,7 +157,8 @@ CREATE TABLE IF NOT EXISTS comments (created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,deleted_at DATETIME,-- deleted_by TEXT++ deleted_by TEXT,++ edited_at DATETIME -- the author last changed the text (migrate() adds it to older databases));CREATE INDEX IF NOT EXISTS idx_comments_thread ON comments(thread_id, parent_id, id);CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id, id);@@@ -337,6 +338,7 @@ function openDb(file) {* comment_threads.access_id — the unguessable handle browsers address a thread by (128 random* bits); the sequential id is for services only, so threads cannot be enumerated. Threads made* before the column existed get one here.++ * comments.edited_at — when the author last edited the text (null: never edited).*/function migrate(db) {const cols = new Set(db.prepare('PRAGMA table_info(comment_threads)').all().map((c) => c.name));@@@ -347,6 +349,8 @@ function migrate(db) {db.transaction(() => { for (const r of missing) set.run(newThreadAccessId(), r.id); })();}db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_comment_threads_access ON comment_threads(access_id)');++ const commentCols = new Set(db.prepare('PRAGMA table_info(comments)').all().map((c) => c.name));++ if (!commentCols.has('edited_at')) db.exec('ALTER TABLE comments ADD COLUMN edited_at DATETIME');}/** cth_ + 22 base64url characters (16 random bytes). */@@@ -89,7 +89,7 @@ function cors(origins) {res.set('Access-Control-Allow-Origin', origin);res.set('Vary', 'Origin');res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, traceparent, X-OpenVibe-Request-Id');-- res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');++ res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE');res.set('Access-Control-Expose-Headers', 'X-OpenVibe-Request-Id, Retry-After');res.set('Access-Control-Max-Age', '600');}nnew file mode 100644@@@ -0,0 +1,103 @@++'use strict';++++/**++ * A comment thread's own page — /c/:accessId. The same thread another product embeds (a Live VOD++ * or clip, a wiki page, a blog post), read here straight from Community's store, so both places++ * always show the same comments. Complete without JavaScript: older comments are a link, commenting++ * is a plain form post. Messages are plain text, always escaped.++ */++const config = require('../config');++const { renderPage } = require('./layout');++const { escapeHtml: esc } = require('./highlight');++const { timeTag, fmtDate, num } = require('./pages');++const { who } = require('./forum');++++const REF_NAMES = {++ 'live/vod': 'VOD', 'live/clip': 'clip', 'live/stream': 'stream', 'live/channel': 'channel',++ 'wiki/page': 'wiki page', 'blog/post': 'blog post', 'community/paste': 'paste', 'community/post': 'post',++};++const PRODUCTS = { live: 'OpenVibe.Live', wiki: 'OpenVibe.Wiki', blog: 'OpenVibe.Blog', community: 'OpenVibe.Community', media: 'OpenVibe.Media', reviews: 'OpenVibe.Reviews' };++++/** Where the commented-on thing lives, when Community knows its address. */++function sourceUrl(ref) {++ const id = encodeURIComponent(ref.id);++ if (ref.service === 'live' && ref.type === 'vod') return `${config.liveUrl}/vod/${id}`;++ if (ref.service === 'live' && ref.type === 'clip') return `${config.liveUrl}/clip/${id}`;++ if (ref.service === 'live' && ref.type === 'channel') return `${config.liveUrl}/${id}`;++ if (ref.service === 'community' && ref.type === 'paste') return `/p/${id}`;++ return null;++}++++function refName(ref) { return REF_NAMES[`${ref.service}/${ref.type}`] || ref.type; }++++function commentHtml(c) {++ if (c.deleted) {++ return `<article class="post post-deleted" id="comment-${c.id}"><p class="muted small">This comment was deleted.</p>${repliesHtml(c)}</article>`;++ }++ const edited = c.edited_at ? ` <span class="sep">·</span> <span class="muted small" title="Edited ${esc(fmtDate(c.edited_at))}">edited</span>` : '';++ return `<article class="post comment" id="comment-${c.id}">++ <header class="post-head">${who(c.author || (c.anon_name ? { display_name: c.anon_name } : null))} <span class="sep">·</span> <a class="muted small" href="#comment-${c.id}">${timeTag(c.created_at)}</a>${edited}</header>++ <p class="comment-text">${esc(c.message)}</p>++ ${repliesHtml(c)}++ </article>`;++}++++function repliesHtml(c) {++ if (!c.replies || !c.replies.length) return '';++ const more = c.reply_count > c.replies.length ? `<p class="muted small">${num(c.reply_count - c.replies.length)} more ${c.reply_count - c.replies.length === 1 ? 'reply' : 'replies'} on the original page.</p>` : '';++ return `<div class="comment-replies">${c.replies.map(commentHtml).join('\n')}${more}</div>`;++}++++/**++ * page: the comment service's answer for this viewer ({ thread, comments, next_cursor, viewer }).++ */++function threadPage({ accessId, page, user, after = null, error = null, draft = '' }) {++ const { thread, comments, next_cursor: next, viewer } = page;++ const ref = thread.ref;++ const what = refName(ref);++ const product = PRODUCTS[ref.service] || ref.service;++ const label = ref.label || `a ${what} on ${product}`;++ const src = sourceUrl(ref);++ const base = `/c/${accessId}`;++++ let form = '';++ if (thread.visibility === 'locked' && !viewer.can_moderate) form = '<p class="alert">This comment thread is locked.</p>';++ else if (!user) form = `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(base)}">Sign in with your OpenVibe account</a> to comment.</p>`;++ else if (viewer.can_comment) {++ form = `<form class="paste-form reply-form" method="post" action="${esc(base)}" id="comment">++ ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}++ <label class="field"><span>Comment</span><textarea name="message" rows="4" maxlength="2000" required placeholder="Add a comment…">${esc(draft)}</textarea></label>++ <div class="form-actions"><button class="btn btn-primary" type="submit"><i class="fa-solid fa-comment" aria-hidden="true"></i> Comment</button><span class="muted small">Commenting as <strong>${esc(user.display_name || user.username || 'you')}</strong>. It shows on ${esc(product)} too.</span></div>++ </form>`;++ }++++ const list = comments.length++ ? `<div class="posts">${comments.map(commentHtml).join('\n')}</div>`++ : `<p class="empty">${after ? 'No older comments.' : 'No comments yet.'}</p>`;++ const older = next ? `<nav class="pager" aria-label="Pages"><span></span><a rel="next" href="${esc(`${base}?after=${encodeURIComponent(next)}`)}">Older comments <i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a></nav>` : '';++++ const body = `++<article class="thread comment-thread">++ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <span aria-current="page">Comments</span></nav>++ <header class="page-head">++ <h1>Comments on ${esc(label)}</h1>++ <p class="muted">${num(thread.comment_count)} ${thread.comment_count === 1 ? 'comment' : 'comments'} · one thread, shown here and on ${esc(product)}${src ? ` · <a href="${esc(src)}">Open the ${esc(what)} on ${esc(product)}</a>` : ''}</p>++ </header>++ ${after ? '' : form}++ ${after ? `<p><a href="${esc(base)}">Newest comments</a></p>` : ''}++ ${list}++ ${older}++</article>`;++ return renderPage({++ title: `Comments on ${label}`,++ description: `${num(thread.comment_count)} comments on ${label}, the same thread ${product} shows.`,++ canonicalPath: base,++ // The access id is the only thing keeping an unlisted item's thread unlisted: never indexed.++ robots: 'noindex,nofollow',++ active: 'comments',++ footerVariant: 'compact',++ body,++ });++}++++module.exports = { threadPage, sourceUrl };@@@ -33,7 +33,7 @@ const { checkCapability } = require('../server/identity/capabilities');const resolve = (ref, who = {}) => call('/api/v1/comments/threads/resolve', { method: 'POST', json: { ref }, ...who });const post = (id, json, who = {}) => call(`/api/v1/comments/threads/${id}/comments`, { method: 'POST', json, ...who });-- let vod;++ let vod; // a browser-opened thread (a Live stream); Live VODs and clips have their own checks belowawait check('capabilities: proposed ids match locally (exact or prefix.*); known ids go through the library', () => {assert.strictEqual(checkCapability({ cap: [WRITE] }, WRITE).allowed, true);@@@ -46,7 +46,7 @@ const { checkCapability } = require('../server/identity/capabilities');});await check('resolve: get-or-create, idempotent (201 then 200, same id), unique per service+type+id', async () => {-- const ref = { service: 'live', type: 'vod', id: '123' };++ const ref = { service: 'live', type: 'stream', id: '123' };const a = await resolve(ref, { cookie: alexJwt });assert.strictEqual(a.status, 201, a.text);assert.strictEqual(a.json().created, true);@@@ -58,9 +58,9 @@ const { checkCapability } = require('../server/identity/capabilities');assert.strictEqual(b.status, 200);assert.strictEqual(b.json().thread.id, vod.id);assert.strictEqual(b.json().created, false);-- const other = await resolve({ service: 'live', type: 'clip', id: '123' });++ const other = await resolve({ service: 'live', type: 'channel', id: '123' });assert.notStrictEqual(other.json().thread.id, vod.id);-- assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS c FROM comment_threads WHERE ref_service = 'live' AND ref_type = 'vod' AND ref_id = '123'").get().c, 1);++ assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS c FROM comment_threads WHERE ref_service = 'live' AND ref_type = 'stream' AND ref_id = '123'").get().c, 1);});await check('resolve: browsers only for allowlisted types; services with comment.write for any; labels only from services', async () => {@@@ -150,7 +150,7 @@ const { checkCapability } = require('../server/identity/capabilities');const r1 = (await post(vod.id, { message: 'reply', parent_id: top.id }, { cookie: samJwt })).json().comment;const r2 = await post(vod.id, { message: 'reply to reply', parent_id: r1.id }, { cookie: alexJwt });assert.strictEqual(r2.json().comment.parent_id, top.id);-- const other = (await resolve({ service: 'live', type: 'vod', id: '456' })).json().thread;++ const other = (await resolve({ service: 'live', type: 'stream', id: '456' })).json().thread;assert.strictEqual((await post(other.id, { message: 'cross', parent_id: top.id }, { cookie: alexJwt })).status, 400);const page = (await call(`/api/v1/comments/threads/${vod.id}?limit=100`)).json();const shown = page.comments.find((c) => c.id === top.id);@@@ -223,7 +223,7 @@ const { checkCapability } = require('../server/identity/capabilities');});await check('visibility: services need comment.moderate; locked = read-only; hidden = 404 except moderators', async () => {-- const th = (await resolve({ service: 'live', type: 'clip', id: 'vis-1' })).json().thread;++ const th = (await resolve({ service: 'live', type: 'channel', id: 'vis-1' })).json().thread;const c = (await post(th.id, { message: 'before lock' }, { cookie: samJwt })).json().comment;const put = (visibility, who) => call(`/api/v1/comments/threads/${th.id}/visibility`, { method: 'PUT', json: { visibility }, ...who });assert.strictEqual((await put('locked', { token: svc([WRITE]) })).status, 403);@@@ -246,8 +246,8 @@ const { checkCapability } = require('../server/identity/capabilities');assert.strictEqual((await call(`/api/v1/comments/threads/${th.id}`, { cookie: samJwt })).status, 404);assert.strictEqual((await post(th.id, { message: 'hidden?' }, { cookie: samJwt })).status, 404);assert.strictEqual((await call(`/api/v1/comments/${c.id}`, { method: 'DELETE', cookie: samJwt })).status, 404);-- const again = (await resolve({ service: 'live', type: 'clip', id: 'vis-1' })).json().thread;-- assert.deepStrictEqual(again, { id: th.id, access_id: th.id, ref: { service: 'live', type: 'clip', id: 'vis-1' }, visibility: 'hidden', comment_count: null });++ const again = (await resolve({ service: 'live', type: 'channel', id: 'vis-1' })).json().thread;++ assert.deepStrictEqual(again, { id: th.id, access_id: th.id, ref: { service: 'live', type: 'channel', id: 'vis-1' }, visibility: 'hidden', comment_count: null });const mod = await call(`/api/v1/comments/threads/${th.id}`, { token: svc([MOD]) });assert.strictEqual(mod.status, 200);assert.strictEqual(mod.json().comments.length, 2);@@@ -311,7 +311,7 @@ const { checkCapability } = require('../server/identity/capabilities');assert.strictEqual(byBrowser.json().comments[0].thread_id, th.access_id, 'no sequential id reaches a browser');assert.ok(!byBrowser.text.includes(`"thread_id":${th.id}`));assert.strictEqual((await post(th.access_id, { message: 'with the handle' }, { cookie: samJwt })).status, 201);-- const again = await resolve({ service: 'live', type: 'vod', id: '123' }, { cookie: samJwt });++ const again = await resolve({ service: 'live', type: 'stream', id: '123' }, { cookie: samJwt });assert.strictEqual(again.json().thread.id, vod.id, 'browser resolve hands out the access id');assert.match(vod.id, /^cth_/);const all = t.db.prepare('SELECT access_id FROM comment_threads').all().map((r) => r.access_id);@@@ -355,6 +355,134 @@ const { checkCapability } = require('../server/identity/capabilities');assert.strictEqual(bearer.json().comment.author.subject, alex.subject_id);});++ // ── Live VODs and clips: Live's /api/comments is an adapter over these threads ──++ let n2 = 0;++ const ip2 = () => `198.19.${(n2 >> 8) & 255}.${(n2++ & 255)}`;++ const callL = (p, o = {}) => call0(p, { ip: ip2(), ...o });++ const svcLive = svc([WRITE, MOD]);++ const asLive = (subject, extra = {}) => ({ token: svcLive, headers: { ...(subject ? { 'x-ov-subject': subject } : {}), ...extra } });++ let liveVod;++++ await check('Live VODs and clips: only services open their threads (a private one is Live\'s call); no anonymous comments', async () => {++ for (const type of ['vod', 'clip']) {++ const ref = { service: 'live', type, id: '7001' };++ for (const who of [{}, { cookie: alexJwt }, { cookie: adminJwt }]) {++ const r = await callL('/api/v1/comments/threads/resolve', { method: 'POST', json: { ref }, ...who });++ assert.strictEqual(r.status, 403, `browser resolve of live/${type}`);++ assert.strictEqual(r.json().code, 'ref.type_not_allowed');++ }++ }++ const r = await callL('/api/v1/comments/threads/resolve', { method: 'POST', json: { ref: { service: 'live', type: 'vod', id: '7001', label: 'My VOD' } }, token: svcLive });++ assert.strictEqual(r.status, 201, r.text);++ liveVod = r.json().thread;++ assert.ok(Number.isInteger(liveVod.id));++ assert.strictEqual(liveVod.ref.label, 'My VOD');++ const anon = await callL(`/api/v1/comments/threads/${liveVod.access_id}/comments`, { method: 'POST', json: { message: 'drive-by', anon_name: 'Ghost' } });++ assert.strictEqual(anon.status, 401, 'anonymous browser');++ assert.strictEqual(anon.json().code, 'auth.required');++ const anonSvc = await callL(`/api/v1/comments/threads/${liveVod.id}/comments`, { method: 'POST', json: { message: 'nobody' }, token: svc([WRITE]) });++ assert.strictEqual(anonSvc.status, 401, 'a service naming nobody');++ const person = await callL(`/api/v1/comments/threads/${liveVod.id}/comments`, { method: 'POST', json: { message: 'nice vod' }, ...asLive(alex.subject_id) });++ assert.strictEqual(person.status, 201, person.text);++ assert.strictEqual(person.json().comment.author.subject, alex.subject_id);++ const byBrowser = await callL(`/api/v1/comments/threads/${liveVod.access_id}/comments`, { method: 'POST', json: { message: 'from the thread page' }, cookie: samJwt });++ assert.strictEqual(byBrowser.status, 201, 'people with the access id may comment');++ });++++ await check('one comment with its thread (GET /:commentId): services only, so comment ids cannot be walked to refs', async () => {++ const c = (await callL(`/api/v1/comments/threads/${liveVod.id}/comments`, { method: 'POST', json: { message: 'look me up' }, ...asLive(sam.subject_id) })).json().comment;++ const r = await callL(`/api/v1/comments/${c.id}`, asLive(sam.subject_id));++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().comment.message, 'look me up');++ assert.strictEqual(r.json().comment.can_edit, true);++ assert.deepStrictEqual(r.json().thread.ref, { service: 'live', type: 'vod', id: '7001', label: 'My VOD' });++ assert.strictEqual(r.json().thread.id, liveVod.id);++ for (const who of [{}, { cookie: samJwt }, { cookie: adminJwt }]) {++ const b = await callL(`/api/v1/comments/${c.id}`, who);++ assert.strictEqual(b.status, 404, 'browsers never get it');++ }++ assert.strictEqual((await callL(`/api/v1/comments/${c.id}`, { token: svc(['community.pulse.write']) })).status, 403);++ assert.strictEqual((await callL('/api/v1/comments/99999999', asLive())).status, 404);++ });++++ await check('edit (PATCH /:commentId): the author only; edited_at; empty/too long refused; deleted is 404', async () => {++ const c = (await callL(`/api/v1/comments/threads/${liveVod.id}/comments`, { method: 'POST', json: { message: 'typo hre' }, ...asLive(alex.subject_id) })).json().comment;++ assert.strictEqual(c.edited_at, null);++ const patch = (json, who) => callL(`/api/v1/comments/${c.id}`, { method: 'PATCH', json, ...who });++ assert.strictEqual((await patch({ message: 'not yours' }, asLive(sam.subject_id))).status, 403);++ assert.strictEqual((await patch({ message: 'staff do not rewrite people' }, { cookie: adminJwt })).status, 403);++ assert.strictEqual((await patch({ message: 'x' }, {})).status, 401);++ assert.strictEqual((await patch({ message: ' ' }, asLive(alex.subject_id))).status, 400);++ assert.strictEqual((await patch({ message: 'y'.repeat(5001) }, asLive(alex.subject_id))).status, 400);++ const ok = await patch({ message: 'typo here' }, asLive(alex.subject_id));++ assert.strictEqual(ok.status, 200, ok.text);++ assert.strictEqual(ok.json().comment.message, 'typo here');++ assert.ok(ok.json().comment.edited_at, 'edited_at set');++ const byBrowser = await patch({ message: 'typo here!' }, { cookie: alexJwt });++ assert.strictEqual(byBrowser.status, 200, 'the author from a browser too');++ assert.strictEqual(t.db.prepare('SELECT message FROM comments WHERE id = ?').get(c.id).message, 'typo here!');++ await callL(`/api/v1/comments/${c.id}`, { method: 'DELETE', ...asLive(alex.subject_id) });++ assert.strictEqual((await patch({ message: 'back from the dead' }, asLive(alex.subject_id))).status, 404);++ });++++ await check('the thread\'s own page /c/:accessId shows the same comments as the API; people comment there; hidden and guessed ids are 404', async () => {++ const api = (await callL(`/api/v1/comments/threads/${liveVod.id}?sort=new&limit=100`, asLive())).json();++ const page = await callL(`/c/${liveVod.access_id}`);++ assert.strictEqual(page.status, 200, page.text.slice(0, 300));++ assert.match(page.headers.get('content-type'), /text\/html/);++ assert.match(page.text, /<meta name="robots" content="noindex,nofollow">/);++ assert.match(page.text, /Comments on My VOD/);++ assert.match(page.text, /href="https:\/\/openvibe\.live\/vod\/7001"/, 'links back to the VOD');++ const live = api.comments.filter((c) => !c.deleted).map((c) => c.message);++ assert.ok(live.length >= 3);++ for (const m of live) assert.ok(page.text.includes(m), `page shows "${m}"`);++ const order = live.map((m) => page.text.indexOf(m));++ assert.deepStrictEqual(order, [...order].sort((a, b) => a - b), 'newest first, like the API with sort=new');++ assert.match(page.text, /Sign in with your OpenVibe account/, 'anonymous visitors are asked to sign in');++++ const form = (body, who) => t.get(`/c/${liveVod.access_id}`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', 'x-forwarded-for': ip2(), ...(who.origin ? { origin: who.origin } : {}) }, body, cookies: who.cookie ? [`ov_token=${who.cookie}`] : [] });++ const empty = await form('message=+', { cookie: samJwt, origin: 'https://openvibe.community' });++ assert.strictEqual(empty.status, 400);++ assert.match(empty.text, /class="alert alert-error"[^>]*>Comment cannot be empty/, 'the page again, with the error');++ const made = await form('message=said+on+Community', { cookie: samJwt });++ assert.strictEqual(made.status, 303, made.text.slice(0, 200));++ assert.match(made.headers.get('location'), new RegExp(`^/c/${liveVod.access_id}#comment-\\d+$`));++ const seen = (await callL(`/api/v1/comments/threads/${liveVod.id}?sort=new`, asLive())).json();++ assert.strictEqual(seen.comments[0].message, 'said on Community', 'Live reads the comment made on the page');++ assert.strictEqual(seen.comments[0].author.subject, sam.subject_id);++ const anonForm = await form('message=who+am+i', {});++ assert.strictEqual(anonForm.status, 303);++ assert.match(anonForm.headers.get('location'), /^\/auth\/login/);++ const foreign = await form('message=csrf', { cookie: samJwt, origin: 'https://evil.example' });++ assert.strictEqual(foreign.status, 403);++++ assert.strictEqual((await callL(`/c/${liveVod.id}`)).status, 404, 'the sequential id opens nothing');++ assert.strictEqual((await callL(`/c/cth_${'A'.repeat(22)}`)).status, 404);++ await callL(`/api/v1/comments/threads/${liveVod.id}/visibility`, { method: 'PUT', json: { visibility: 'hidden' }, token: svcLive });++ assert.strictEqual((await callL(`/c/${liveVod.access_id}`)).status, 404, 'hidden threads are missing');++ await callL(`/api/v1/comments/threads/${liveVod.id}/visibility`, { method: 'PUT', json: { visibility: 'public' }, token: svcLive });++ assert.strictEqual((await callL(`/c/${liveVod.access_id}`)).status, 200);++ });++++ await check('comments.edited_at is added to databases made before it (idempotent)', async () => {++ const fs = require('fs'), os = require('os'), path = require('path');++ const Database = require('better-sqlite3');++ const { openDb } = require('../server/db');++ const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'ov-community-edit-')), 'old.db');++ const old = new Database(file);++ old.exec(`CREATE TABLE comments (id INTEGER PRIMARY KEY AUTOINCREMENT, thread_id INTEGER NOT NULL, parent_id INTEGER, author_subject TEXT, anon_name TEXT,++ origin TEXT NOT NULL DEFAULT 'user', message TEXT NOT NULL, score INTEGER NOT NULL DEFAULT 0, upvotes INTEGER NOT NULL DEFAULT 0, downvotes INTEGER NOT NULL DEFAULT 0,++ reply_count INTEGER NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME, deleted_by TEXT)`);++ old.prepare("INSERT INTO comments (thread_id, message) VALUES (1, 'kept')").run();++ old.close();++ const db = openDb(file);++ assert.ok(db.prepare('PRAGMA table_info(comments)').all().some((c) => c.name === 'edited_at'));++ assert.strictEqual(db.prepare('SELECT message, edited_at FROM comments').get().message, 'kept');++ db.close();++ openDb(file).close();++ fs.rmSync(path.dirname(file), { recursive: true, force: true });++ });++await t.close();done();})();nnew file mode 100644@@@ -0,0 +1,184 @@++'use strict';++/**++ * Live's own VOD/clip comments → Community threads (server/comments/live-import.js and++ * scripts/import-live-comments.js): reconciliation (read = imported + held + excluded), authors++ * from Live's links and the Network's identity map (unmapped and disagreeing ones held), replies++ * nested one level, times and edits kept, the ledger making re-runs import nothing twice, held rows++ * imported once their author maps, dry run writing nothing, and --apply refusing without a backup.++ */++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const { spawnSync } = require('child_process');++const Database = require('better-sqlite3');++const { check, done } = require('./helpers/app');++++process.env.NODE_ENV = 'test';++const { openDb } = require('../server/db');++const { importLiveComments, formatReport } = require('../server/comments/live-import');++const { createCommentService } = require('../server/comments/service');++++const A = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA'; // live user 1, linked on Live++const B = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB'; // live user 2, Network map only++const C = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC'; // live user 4 per Live's link…++const D = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPD'; // …and per the Network: they disagree++const E = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPE'; // live user 3, linked later++++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-comments-'));++const livePath = path.join(dir, 'live.db');++const live = new Database(livePath);++live.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT);++CREATE TABLE linked_accounts (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, service TEXT, service_user_id TEXT, subject_id TEXT);++CREATE TABLE comments (id INTEGER PRIMARY KEY AUTOINCREMENT, content_type TEXT NOT NULL CHECK(content_type IN ('vod', 'clip')), content_id INTEGER NOT NULL,++ user_id INTEGER NOT NULL, parent_id INTEGER, message TEXT NOT NULL, is_deleted INTEGER DEFAULT 0,++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);++live.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (1, 'network', '101', ?)").run(A);++live.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (4, 'network', '104', ?)").run(C);++live.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (2, 'twitch', 'x', NULL)").run();++const add = (id, type, cid, uid, parent, message, extra = {}) => live.prepare(`INSERT INTO comments (id, content_type, content_id, user_id, parent_id, message, is_deleted, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, type, cid, uid, parent, message, extra.deleted ? 1 : 0, extra.created || `2026-08-0${Math.min(id, 9)} 10:00:00`, extra.updated || extra.created || `2026-08-0${Math.min(id, 9)} 10:00:00`);++add(1, 'vod', 10, 1, null, 'first');++add(2, 'vod', 10, 2, 1, 'a reply');++add(3, 'vod', 10, 1, 2, 'reply to the reply');++add(4, 'vod', 10, 3, null, 'by someone unmapped');++add(5, 'vod', 10, 1, 4, 'under the held one');++add(6, 'clip', 20, 4, null, 'two answers for this author');++add(7, 'clip', 20, 1, null, 'deleted on live', { deleted: true });++add(8, 'clip', 20, 2, 7, 'reply under a deleted comment');++add(9, 'clip', 20, 1, null, 'edited once', { created: '2026-08-09 10:00:00', updated: '2026-08-09 11:30:00' });++add(10, 'vod', 11, 1, 99, 'parent missing');++add(11, 'vod', 10, 2, 9, 'parent on other content');++add(12, 'vod', 12, 1, null, ' ');++live.close();++++const networkMap = { 2: B, 4: D };++const calls = [];++const resolveLegacy = async (system, ids) => {++ calls.push({ system, ids });++ return new Map(ids.map((id) => [id, networkMap[id] ? { subject: { type: 'user', id: networkMap[id] } } : null]));++};++++(async () => {++ const db = openDb(':memory:');++ const liveRo = new Database(livePath, { readonly: true });++ const count = (sql, ...a) => db.prepare(sql).get(...a).n;++ const threadOf = (type, id) => db.prepare("SELECT * FROM comment_threads WHERE ref_service = 'live' AND ref_type = ? AND ref_id = ?").get(type, String(id));++++ await check('dry run (the default): the full report, nothing written', async () => {++ const r = await importLiveComments(db, liveRo, { resolveLegacy });++ assert.strictEqual(r.dry_run, true);++ assert.deepStrictEqual([r.read, r.imported, r.held, r.excluded], [12, 4, 3, 5]);++ assert.strictEqual(r.reconciled, true);++ assert.deepStrictEqual(r.excluded_by_reason, { deleted: 1, parent_deleted: 1, orphan: 2, empty: 1 });++ assert.deepStrictEqual(r.held_rows.map((h) => [h.live_id, h.reason]), [[4, 'unmapped_author'], [6, 'ambiguous_author'], [5, 'parent_held']]);++ assert.deepStrictEqual(calls[0], { system: 'live', ids: ['1', '2', '3', '4'] }, 'only authors of importable rows are looked up');++ for (const table of ['comments', 'comment_threads', 'legacy_id_map', 'import_hold', 'migration_runs']) {++ assert.strictEqual(count(`SELECT COUNT(*) AS n FROM ${table}`), 0, `${table} untouched`);++ }++ const text = formatReport(r);++ assert.match(text, /DRY RUN/);++ assert.match(text, /read 12 = imported 4 \+ held 3 \+ excluded 5 → OK/);++ assert.match(text, /live comment 4 {2}unmapped_author {2}on vod\/10 {2}live user 3/);++ });++++ await check('apply: threads per VOD/clip, authors as subjects, one level of nesting, times and edits kept, counters, holds, a run record', async () => {++ const r = await importLiveComments(db, liveRo, { resolveLegacy, dryRun: false, source: 'live.db' });++ assert.deepStrictEqual([r.read, r.imported_new, r.imported_before, r.held, r.excluded], [12, 4, 0, 3, 5]);++ const vod = threadOf('vod', 10), clip = threadOf('clip', 20);++ assert.ok(vod && clip && !threadOf('vod', 11) && !threadOf('vod', 12), 'threads only where something was imported');++ assert.strictEqual(r.threads.length, 2);++ assert.strictEqual(vod.comment_count, 3);++ assert.strictEqual(clip.comment_count, 1);++ const idOf = (liveId) => Number(db.prepare("SELECT target_id FROM legacy_id_map WHERE source_system = 'live' AND source_type = 'comment' AND source_id = ?").get(String(liveId)).target_id);++ const row = (liveId) => db.prepare('SELECT * FROM comments WHERE id = ?').get(idOf(liveId));++ assert.deepStrictEqual([row(1).author_subject, row(1).parent_id, row(1).message, row(1).created_at, row(1).edited_at], [A, null, 'first', '2026-08-01 10:00:00', null]);++ assert.deepStrictEqual([row(2).author_subject, row(2).parent_id], [B, idOf(1)], 'a Network-only author');++ assert.strictEqual(row(3).parent_id, idOf(1), 'a reply to a reply joins the top-level comment');++ assert.strictEqual(row(1).reply_count, 2);++ assert.deepStrictEqual([row(9).created_at, row(9).edited_at], ['2026-08-09 10:00:00', '2026-08-09 11:30:00']);++ assert.strictEqual(row(9).origin, 'user');++ const holds = db.prepare("SELECT source_id, reason FROM import_hold WHERE source_type = 'live_comment' ORDER BY source_id").all();++ assert.deepStrictEqual(holds.map((h) => [h.source_id, h.reason]), [['4', 'unmapped_author'], ['5', 'parent_held'], ['6', 'ambiguous_author']]);++ assert.strictEqual(count("SELECT COUNT(*) AS n FROM migration_runs WHERE source = 'live-comments:live.db'"), 1);++++ // What Live's adapter reads back: newest first, replies nested, edits marked.++ const svc = createCommentService({ db });++ const page = await svc.get({ kind: 'service', service: 'svc:live', claims: { cap: ['community.comment.write'] }, subject: null }, vod.id, { sort: 'new' });++ assert.deepStrictEqual(page.comments.map((c) => c.message), ['first']);++ assert.deepStrictEqual(page.comments[0].replies.map((c) => c.message), ['a reply', 'reply to the reply']);++ const clipPage = await svc.get({ kind: 'service', service: 'svc:live', claims: { cap: ['community.comment.write'] }, subject: null }, clip.id, {});++ assert.strictEqual(clipPage.comments[0].edited_at, '2026-08-09T11:30:00.000Z');++ });++++ await check('the ledger: a second run imports nothing twice', async () => {++ const before = count('SELECT COUNT(*) AS n FROM comments');++ const r = await importLiveComments(db, liveRo, { resolveLegacy, dryRun: false });++ assert.deepStrictEqual([r.imported_new, r.imported_before, r.held, r.excluded, r.reconciled], [0, 4, 3, 5, true]);++ assert.strictEqual(count('SELECT COUNT(*) AS n FROM comments'), before);++ assert.strictEqual(threadOf('vod', 10).comment_count, 3);++ });++++ await check('held rows import once their author maps; their holds are cleared; replies follow', async () => {++ const rw = new Database(livePath);++ rw.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (3, 'network', '103', ?)").run(E);++ rw.close();++ const r = await importLiveComments(db, liveRo, { resolveLegacy, dryRun: false });++ assert.deepStrictEqual([r.imported_new, r.imported_before, r.held, r.excluded], [2, 4, 1, 5]);++ assert.deepStrictEqual(db.prepare("SELECT source_id, reason FROM import_hold WHERE source_type = 'live_comment'").all(), [{ source_id: '6', reason: 'ambiguous_author' }]);++ assert.strictEqual(threadOf('vod', 10).comment_count, 5);++ const liveIdOf = (communityId) => db.prepare("SELECT source_id FROM legacy_id_map WHERE source_system = 'live' AND source_type = 'comment' AND target_id = ?").get(String(communityId)).source_id;++ const reply = db.prepare("SELECT * FROM comments WHERE message = 'under the held one'").get();++ assert.strictEqual(liveIdOf(reply.parent_id), '4');++ });++++ await check('Live links only (no Network call): the Network-only author is held', async () => {++ const fresh = openDb(':memory:');++ const r = await importLiveComments(fresh, liveRo, { resolveLegacy: null });++ assert.strictEqual(r.reconciled, true);++ assert.ok(r.held_rows.some((h) => h.live_id === 2 && h.reason === 'unmapped_author'));++ assert.ok(!r.held_rows.some((h) => h.live_id === 3), 'a reply to it joins the top-level comment, which is not held');++ assert.ok(!r.held_rows.some((h) => h.live_id === 6), 'with one source there is nothing to disagree');++ fresh.close();++ });++++ await check('the script: dry run by default; --apply refused without --backup; the backup is taken and checked; re-runs are no-ops', async () => {++ const communityPath = path.join(dir, 'community.db');++ openDb(communityPath).close();++ const run = (...args) => spawnSync(process.execPath, [path.join(__dirname, '..', 'scripts', 'import-live-comments.js'), ...args], {++ cwd: path.join(__dirname, '..'), encoding: 'utf8', env: { ...process.env, COMMUNITY_DB_PATH: communityPath, OV_OAUTH_CLIENT_SECRET: '' },++ });++ const base = ['--live-db', livePath, '--community-db', communityPath, '--live-links-only'];++ const dry = run(...base);++ assert.strictEqual(dry.status, 0, dry.stderr);++ assert.match(dry.stdout, /DRY RUN/);++ assert.match(dry.stdout, /Nothing was written/);++ const peek = () => { const d = new Database(communityPath, { readonly: true }); const n = d.prepare('SELECT COUNT(*) AS n FROM comments').get().n; d.close(); return n; };++ assert.strictEqual(peek(), 0);++ const noBackup = run(...base, '--apply');++ assert.strictEqual(noBackup.status, 2);++ assert.match(noBackup.stderr, /--apply needs --backup/);++ assert.strictEqual(peek(), 0);++ const backup = path.join(dir, 'community-before.db');++ const applied = run(...base, '--apply', '--backup', backup);++ assert.strictEqual(applied.status, 0, applied.stderr);++ assert.match(applied.stdout, /Backup: .*community-before\.db \(integrity ok; 0 comments in 0 threads\)/);++ assert.match(applied.stdout, /APPLIED/);++ assert.ok(fs.existsSync(backup));++ const imported = peek();++ assert.ok(imported > 0);++ const again = run(...base, '--apply', '--backup', backup);++ assert.strictEqual(again.status, 2, 'an existing backup file is never overwritten');++ const again2 = run(...base, '--apply', '--backup', path.join(dir, 'community-before-2.db'));++ assert.strictEqual(again2.status, 0, again2.stderr);++ assert.match(again2.stdout, /new 0, already imported/);++ assert.strictEqual(peek(), imported);++ const missing = run('--live-db', path.join(dir, 'nope.db'), '--community-db', communityPath);++ assert.strictEqual(missing.status, 2);++ });++++ liveRo.close();++ db.close();++ fs.rmSync(dir, { recursive: true, force: true });++ done();++})();
@@ -106,6 +106,7 @@ Pages (server-rendered HTML):
@@ -106,6 +106,7 @@ Pages (server-rendered HTML):
| `GET /s/:space/new`, `POST /s/:space/new` | Start a thread (signed in) |
| `GET /s/:space/new`, `POST /s/:space/new` | Start a thread (signed in) |
| `POST /s/:space/t/:slug/reply\|vote\|state\|delete` | The no-JS forms (reply, vote, pin/lock, delete) |
| `POST /s/:space/t/:slug/reply\|vote\|state\|delete` | The no-JS forms (reply, vote, pin/lock, delete) |
| `GET /pulse` | Public activity across the network — `?origin=user\|ai\|system`, `?after=` |
| `GET /pulse` | Public activity across the network — `?origin=user\|ai\|system`, `?after=` |
+| `GET /c/:accessId`, `POST /c/:accessId` | One comment thread's own page — the same thread the owner product embeds (e.g. a Live VOD), newest first, `?after=` for older; signed-in people comment with the no-JS form. Opens only by the unguessable access id, `noindex` |
API and machine endpoints:
API and machine endpoints:
@@ -156,6 +157,8 @@ resolves it. Errors are `application/problem+json` (`errors.problem@1`, with the
@@ -156,6 +157,8 @@ resolves it. Errors are `application/problem+json` (`errors.problem@1`, with the
| `POST /threads/resolve` `{ ref }` | Get-or-create the entity's thread (201 created, 200 existing; idempotent, unique on service+type+id) |
| `POST /threads/resolve` `{ ref }` | Get-or-create the entity's thread (201 created, 200 existing; idempotent, unique on service+type+id) |
| `GET /threads/:id` | The thread + first page of top-level comments, replies nested one level. `?after=<last id>`, `?sort=old\|new`, `?limit=`; `?parent=<id>` pages one comment's replies |
| `GET /threads/:id` | The thread + first page of top-level comments, replies nested one level. `?after=<last id>`, `?sort=old\|new`, `?limit=`; `?parent=<id>` pages one comment's replies |
| `POST /threads/:id/comments` `{ message, parent_id?, anon_name? }` | Comment. A reply to a reply joins the top-level comment's replies |
| `POST /threads/:id/comments` `{ message, parent_id?, anon_name? }` | Comment. A reply to a reply joins the top-level comment's replies |
+| `GET /:commentId` | Services only: one comment and its thread (with the ref) — an owner product checks what a comment belongs to before it edits or deletes it for someone. Browsers get 404 (comment ids are sequential) |
+| `PATCH /:commentId` `{ message }` | The author only; sets `edited_at` |
| `DELETE /:commentId` | The author or a moderator (soft; a top-level comment with replies stays as a tombstone) |
| `DELETE /:commentId` | The author or a moderator (soft; a top-level comment with replies stays as a tombstone) |
| `POST /:commentId/votes` `{ value: 1\|-1\|0 }` | Add, change or remove your vote (people only) |
| `POST /:commentId/votes` `{ value: 1\|-1\|0 }` | Add, change or remove your vote (people only) |
| `PUT /threads/:id/visibility` `{ visibility: public\|hidden\|locked }` | Moderators — e.g. the owner service hides the thread of an entity it took down |
| `PUT /threads/:id/visibility` `{ visibility: public\|hidden\|locked }` | Moderators — e.g. the owner service hides the thread of an entity it took down |
@@ -164,9 +167,13 @@ Who may do what:
@@ -164,9 +167,13 @@ Who may do what:
- **Browsers** (the Network JWT as `ov_token` cookie here, or `Authorization: Bearer` from
- **Browsers** (the Network JWT as `ov_token` cookie here, or `Authorization: Bearer` from
another OpenVibe site through CORS — `API_CORS_ORIGINS`, never cookies) resolve threads only
another OpenVibe site through CORS — `API_CORS_ORIGINS`, never cookies) resolve threads only
- for these types: `live` vod, clip, stream, channel · `media` object · `community` paste, post
+ for these types: `live` stream, channel · `media` object · `community` paste, post
· `wiki` page · `blog` post · `reviews` entity. Community's own refs must exist and be
· `wiki` page · `blog` post · `reviews` entity. Community's own refs must exist and be
- visible. Labels are only taken from services.
+ visible. Labels are only taken from services. Live VODs and clips are resolved by Live only:
+ a private one is missing to everyone but its owners and staff, which only Live can decide, so
+ Live hands the access id only to people who may see the item.
+- **Signed-in only**: threads of `live` vod and clip take no anonymous comments (Live's rule) —
+ a browser without a subject, or a service naming nobody, gets 401 `auth.required`.
- **Anonymous** visitors comment with an `anon_name`, under the same 20-writes-per-10-minutes
- **Anonymous** visitors comment with an `anon_name`, under the same 20-writes-per-10-minutes
budget per address as anonymous pastes. People are limited per subject (10 s cooldown,
budget per address as anonymous pastes. People are limited per subject (10 s cooldown,
5 per minute, no duplicate in a row; 60 votes a minute), whichever way they write.
5 per minute, no duplicate in a row; 60 votes a minute), whichever way they write.
@@ -183,6 +190,64 @@ Who may do what:
@@ -183,6 +190,64 @@ Who may do what:
- **Locked** threads can be read but take no comments or votes (moderators still may);
- **Locked** threads can be read but take no comments or votes (moderators still may);
**hidden** threads are 404 for everyone but moderators.
**hidden** threads are 404 for everyone but moderators.
- Paste comments stay in `paste_comments` behind `/api/pastes/:slug/comments` for now.
- Paste comments stay in `paste_comments` behind `/api/pastes/:slug/comments` for now.
+- Comments carry `edited_at` (null until the author edits) and `can_edit` / `can_delete` for the
+ viewer.
+
+### Live's VOD and clip comments
+
+Since roadmap Wave 5 (exit criterion: Live and a second product share one Community thread),
+OpenVibe.Live keeps no comments of its own: its `/api/comments/:type/:id` routes are an adapter
+over the thread of `{ service: 'live', type: 'vod'|'clip', id }` (Live's
+`server/comments-client.js`). Live resolves the thread with its service token after its own
+visibility check, reads and comments as the signed-in person (`X-OV-Subject`), edits as the
+author, and deletes as the author, as staff (`X-OV-Staff: 1`) or — for the VOD's or clip's
+owner — as itself (`community.comment.moderate`). Deleting a VOD or clip hides its thread. The
+same thread is this site's page `/c/<access id>`; Live links to it under a public or unlisted
+item's comments.
+
+**Checking that both show the same thread** (production or local):
+
+1. Open a public VOD on Live (`https://openvibe.live/vod/<id>`), post a comment, and follow
+ "View this thread on OpenVibe.Community" under the comments. The page `/c/cth_…` lists the
+ same comments, newest first, with the same authors.
+2. Comment on that Community page while signed in, reload the Live VOD: the new comment is
+ there. Delete it on Live: it is gone from the Community page.
+3. Service-side, the same answer: `GET /api/v1/comments/threads/<id>?sort=new` with Live's token
+ returns the comments both pages render (`test/comments.test.js` checks the page against it).
+
+**Moving Live's old rows** (`scripts/import-live-comments.js`, `server/comments/live-import.js`):
+reads Live's `comments` table read-only, puts every row in exactly one bucket and prints the
+reconciliation `read = imported + held + excluded`. Imported rows keep their text and times
+(edits keep `edited_at`); the ledger is `legacy_id_map` (`live`/`comment`/<live id> → comment
+id), so re-running imports nothing twice. Authors become Network subjects from Live's
+`linked_accounts.subject_id` and the Network's identity map (system `live`); a row whose author
+maps nowhere (`unmapped_author`), maps two ways (`ambiguous_author`) or whose parent is held
+(`parent_held`) is **held** in `import_hold` (source type `live_comment`) and listed, and a later
+run imports it once the mapping exists. Deleted rows and replies under them, orphans and empty
+messages are **excluded**. Dry run is the default (everything rolled back); `--apply` requires
+`--backup <new file>`, an online backup of Community's database that is integrity-checked
+before anything is written. Community must be deployed first (the script refuses a database
+without `comments.edited_at` rather than migrate it from a dry run).
+
+Production (on the host, as the service's own user and environment through `systemd-run`, so
+no file in `/var/lib/openvibe-community` ends up owned by root). Order: deploy Community → dry
+run → apply → deploy Live → apply again (catches comments Live wrote in between; the ledger
+skips the rest) → verify as above.
+
+```
+RUN="sudo systemd-run --wait --pipe --collect -p User=ubuntu -p Group=ubuntu \
+ -p WorkingDirectory=/opt/openvibe.community -p EnvironmentFile=/etc/openvibe/community.env \
+ -E NODE_ENV=production -E COMMUNITY_DB_PATH=/var/lib/openvibe-community/community.db"
+ARGS="--live-db /opt/openvibe.live/data/live.db --community-db /var/lib/openvibe-community/community.db"
+
+$RUN /usr/bin/env node scripts/import-live-comments.js $ARGS # dry run
+$RUN /usr/bin/env node scripts/import-live-comments.js $ARGS --apply \
+ --backup /var/lib/openvibe-community/community.pre-live-comments-$(date -u +%Y%m%dT%H%M%SZ).db
+```
+
+Held rows: `sqlite3 /var/lib/openvibe-community/community.db "SELECT * FROM import_hold WHERE
+source_type = 'live_comment'"`. Rollback: stop Community, copy the backup over `community.db`
+(remove `community.db-wal`/`-shm`), start it; Live's own rows were never changed.
Embedding it — server-side, from another product's backend (the usual way; the person is the
Embedding it — server-side, from another product's backend (the usual way; the person is the
one your own session says it is):
one your own session says it is):
@@ -211,7 +276,7 @@ In the browser, from a page on an allowed origin that holds the visitor's Networ
@@ -211,7 +276,7 @@ In the browser, from a page on an allowed origin that holds the visitor's Networ
const api = (path, opts = {}) => fetch(`https://openvibe.community/api/v1/comments${path}`, {
const api = (path, opts = {}) => fetch(`https://openvibe.community/api/v1/comments${path}`, {
...opts, headers: { Authorization: `Bearer ${networkJwt}`, 'Content-Type': 'application/json', ...(opts.headers || {}) },
...opts, headers: { Authorization: `Bearer ${networkJwt}`, 'Content-Type': 'application/json', ...(opts.headers || {}) },
}).then((r) => r.json());
}).then((r) => r.json());
-const { thread } = await api('/threads/resolve', { method: 'POST', body: JSON.stringify({ ref: { service: 'live', type: 'clip', id: clipId } }) });
+const { thread } = await api('/threads/resolve', { method: 'POST', body: JSON.stringify({ ref: { service: 'live', type: 'stream', id: streamId } }) });
let { comments, next_cursor } = await api(`/threads/${thread.id}`);
let { comments, next_cursor } = await api(`/threads/${thread.id}`);
if (next_cursor) ({ comments } = await api(`/threads/${thread.id}?after=${next_cursor}`));
if (next_cursor) ({ comments } = await api(`/threads/${thread.id}?after=${next_cursor}`));
await api(`/${comments[0].id}/votes`, { method: 'POST', body: JSON.stringify({ value: 1 }) });
await api(`/${comments[0].id}/votes`, { method: 'POST', body: JSON.stringify({ value: 1 }) });
@@ -419,7 +484,8 @@ server/
@@ -419,7 +484,8 @@ server/
pastes/importer.js Media export bundle → store (scripts/import-pastes.js is the CLI)
pastes/importer.js Media export bundle → store (scripts/import-pastes.js is the CLI)
pastes/source.js where pages read pastes from (Live or the store)
pastes/source.js where pages read pastes from (Live or the store)
pastes/catalog.js recent public pastes: trending, related, language filter
pastes/catalog.js recent public pastes: trending, related, language filter
- comments/ typed comment threads: store (SQL), service (rules), api (/api/v1/comments)
+ comments/ typed comment threads: store (SQL), service (rules), api (/api/v1/comments),
+ routes (the /c/:accessId page), live-import (Live's old VOD/clip comments)
forum/ spaces/threads/posts: store, service, api (/api/v1/spaces, /posts), routes (pages)
forum/ spaces/threads/posts: store, service, api (/api/v1/spaces, /posts), routes (pages)
pulse/ Pulse read model: store, service (hooks + ingest), api (/api/v1/pulse)
pulse/ Pulse read model: store, service (hooks + ingest), api (/api/v1/pulse)
relay/ Discord relay: discord.js (queue, worker, backoff), api (/api/v1/relay)
relay/ Discord relay: discord.js (queue, worker, backoff), api (/api/v1/relay)
@@ -432,6 +498,7 @@ server/
@@ -432,6 +498,7 @@ server/
render/pages.js home / browse / paste / new / my / error templates
render/pages.js home / browse / paste / new / my / error templates
render/forum.js spaces / threads / thread / new-thread templates
render/forum.js spaces / threads / thread / new-thread templates
render/pulse.js the /pulse page
render/pulse.js the /pulse page
+ render/comments.js a comment thread's own page
render/markdown.js the safe Markdown subset for posts
render/markdown.js the safe Markdown subset for posts
render/highlight.js highlight.js wrapper, language list, download extensions
render/highlight.js highlight.js wrapper, language list, download extensions
seo.js robots, sitemap, RSS, JSON-LD builders
seo.js robots, sitemap, RSS, JSON-LD builders
@@ -439,6 +506,7 @@ public/ css/community.css, js/community.js, favicon.svg, og-defaul
@@ -439,6 +506,7 @@ public/ css/community.css, js/community.js, favicon.svg, og-defaul
(openvibe-shared is the pinned OpenVibe.Shared v1.0.0 release, installed by npm)
(openvibe-shared is the pinned OpenVibe.Shared v1.0.0 release, installed by npm)
deploy/ systemd unit, nginx vhost
deploy/ systemd unit, nginx vhost
scripts/import-pastes.js Media paste bundle importer
scripts/import-pastes.js Media paste bundle importer
+scripts/import-live-comments.js Live's VOD/clip comments → Community threads (dry run by default)
test/ run.js + *.test.js (mock Live, Network and Media with a real RS256 key)
test/ run.js + *.test.js (mock Live, Network and Media with a real RS256 key)
docs/capabilities-proposal/ Wave 5 capability manifests (released in openvibe-contracts v0.7.0)
docs/capabilities-proposal/ Wave 5 capability manifests (released in openvibe-contracts v0.7.0)
```
```
@@ -206,3 +206,8 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
@@ -206,3 +206,8 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
.thread-row, .pulse-item { flex-wrap: wrap; }
.thread-row, .pulse-item { flex-wrap: wrap; }
.thread-head { gap: 10px; }
.thread-head { gap: 10px; }
}
}
+
+/* ── A comment thread's own page (/c/:accessId) ── */
+.comment-text { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
+.comment-replies { display: flex; flex-direction: column; gap: 8px; margin: 10px 0 0 18px; }
+.comment-replies .post { padding: 10px 14px; }
new file mode 100755
new file mode 100755
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * Import OpenVibe.Live's own VOD and clip comments into Community comment threads.
+ *
+ * node scripts/import-live-comments.js --live-db <live.db> # dry run (default)
+ * node scripts/import-live-comments.js --live-db <live.db> --apply --backup <file.db> # write
+ *
+ * Options:
+ * --live-db <file> Live's database, opened read-only (required)
+ * --community-db <file> Community's database (default: COMMUNITY_DB_PATH, as the server)
+ * --apply write; without it nothing is written and the report shows what would be
+ * --backup <file> required with --apply: an online backup of Community's database is written
+ * here (must not exist yet) and checked before anything changes
+ * --live-links-only name authors from Live's linked_accounts only (no Network call)
+ *
+ * Reads the same environment as the server (.env / /etc/openvibe/community.env):
+ * COMMUNITY_DB_PATH, OV_NETWORK_INTERNAL_URL, OV_OAUTH_CLIENT_ID, OV_OAUTH_CLIENT_SECRET (the
+ * client needs identity.subject.resolve on openvibe.network). Safe to re-run: the ledger
+ * (legacy_id_map live/comment) skips rows already imported. See server/comments/live-import.js.
+ *
+ * Exit codes: 0 done (or dry run finished), 2 refused (bad options, backup failed, reconciliation
+ * failed), 1 anything else.
+ */
+const fs = require('fs');
+const path = require('path');
+
+function usage(msg, code = msg ? 2 : 0) {
+ if (msg) console.error(msg);
+ console.error('usage: node scripts/import-live-comments.js --live-db <live.db> [--community-db <community.db>] [--apply --backup <file>] [--live-links-only]');
+ process.exit(code);
+}
+
+function parseArgs(argv) {
+ const out = { liveDb: null, communityDb: null, apply: false, backup: null, liveLinksOnly: false };
+ const value = (i, name) => { const v = argv[i]; if (!v || v.startsWith('--')) usage(`${name} needs a value`); return v; };
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ if (a === '--live-db') out.liveDb = value(++i, a);
+ else if (a === '--community-db') out.communityDb = value(++i, a);
+ else if (a === '--backup') out.backup = value(++i, a);
+ else if (a === '--apply') out.apply = true;
+ else if (a === '--dry-run') out.apply = false;
+ else if (a === '--live-links-only') out.liveLinksOnly = true;
+ else if (a === '-h' || a === '--help') usage();
+ else usage(`unknown argument ${a}`);
+ }
+ if (!out.liveDb) usage('missing --live-db');
+ if (out.apply && !out.backup) usage('--apply needs --backup <file>: a backup of Community\'s database is taken first');
+ return out;
+}
+
+async function main() {
+ const args = parseArgs(process.argv.slice(2));
+ const Database = require('better-sqlite3');
+ const config = require('../server/config');
+ const { openDb } = require('../server/db');
+ const { importLiveComments, formatReport, LiveImportError } = require('../server/comments/live-import');
+
+ if (!fs.existsSync(args.liveDb)) usage(`no Live database at ${args.liveDb}`);
+ const communityPath = args.communityDb || config.dbPath;
+ if (!fs.existsSync(communityPath)) usage(`no Community database at ${communityPath}`);
+ // Opening runs the additive schema migrations; a dry run must not be what applies them.
+ {
+ const probe = new Database(communityPath, { readonly: true, fileMustExist: true });
+ const cols = new Set(probe.prepare('PRAGMA table_info(comments)').all().map((c) => c.name));
+ probe.close();
+ if (!cols.has('edited_at')) usage(`Community's database at ${communityPath} predates this importer: deploy and restart Community first`);
+ }
+
+ const liveDb = new Database(args.liveDb, { readonly: true, fileMustExist: true });
+ const db = openDb(communityPath);
+ try {
+ if (args.apply) {
+ const target = path.resolve(args.backup);
+ if (fs.existsSync(target)) usage(`${target} exists already; pick a new backup file`);
+ await db.backup(target);
+ const check = new Database(target, { readonly: true, fileMustExist: true });
+ const ok = check.pragma('integrity_check', { simple: true });
+ const counts = check.prepare('SELECT (SELECT COUNT(*) FROM comments) AS comments, (SELECT COUNT(*) FROM comment_threads) AS threads').get();
+ check.close();
+ if (ok !== 'ok') { console.error(`Refused: the backup at ${target} failed its integrity check (${ok})`); process.exit(2); }
+ console.log(`Backup: ${target} (integrity ok; ${counts.comments} comments in ${counts.threads} threads)`);
+ }
+
+ let resolveLegacy = null;
+ if (!args.liveLinksOnly) {
+ const { createNetworkIdentity } = require('../server/identity/network');
+ const network = createNetworkIdentity({ config, db });
+ // A dry run writes nothing at all, not even the display cache.
+ resolveLegacy = (system, ids) => network.resolveLegacy(system, ids, { cache: args.apply });
+ }
+ const report = await importLiveComments(db, liveDb, {
+ resolveLegacy, dryRun: !args.apply, source: path.basename(args.liveDb),
+ });
+ console.log(formatReport(report));
+ if (!args.apply) console.log('\nNothing was written. Re-run with --apply --backup <file> to import.');
+ } catch (err) {
+ if (err instanceof LiveImportError) { console.error(`Refused: ${err.message}`); process.exit(2); }
+ throw err;
+ } finally {
+ liveDb.close();
+ db.close();
+ }
+}
+
+main().catch((err) => { console.error(err && err.stack ? err.stack : err); process.exit(1); });
@@ -16,6 +16,7 @@
@@ -16,6 +16,7 @@
* GET /my signed-in user's pastes /auth/login|callback|logout|me|refresh
* GET /my signed-in user's pastes /auth/login|callback|logout|me|refresh
* GET /s … spaces, threads, posts (forum/routes.js)
* GET /s … spaces, threads, posts (forum/routes.js)
* GET /pulse the network's public activity
* GET /pulse the network's public activity
+ * GET|POST /c/:accessId one comment thread's own page (comments/routes.js)
*
*
* Comments, the forum, Pulse and the relay live in Community's database in every mode.
* Comments, the forum, Pulse and the relay live in Community's database in every mode.
*
*
@@ -46,6 +47,7 @@ const { createViewerResolver } = require('./identity/viewer');
@@ -46,6 +47,7 @@ const { createViewerResolver } = require('./identity/viewer');
const v1 = require('./http/v1');
const v1 = require('./http/v1');
const { createCommentService } = require('./comments/service');
const { createCommentService } = require('./comments/service');
const { createCommentsApi } = require('./comments/api');
const { createCommentsApi } = require('./comments/api');
+const { createCommentPages } = require('./comments/routes');
const { createForumService } = require('./forum/service');
const { createForumService } = require('./forum/service');
const { createSpacesApi, createPostsApi } = require('./forum/api');
const { createSpacesApi, createPostsApi } = require('./forum/api');
const { createForumRoutes } = require('./forum/routes');
const { createForumRoutes } = require('./forum/routes');
@@ -302,6 +304,7 @@ function createApp(opts = {}) {
@@ -302,6 +304,7 @@ function createApp(opts = {}) {
// ── Forum and Pulse pages ────────────────────────────────
// ── Forum and Pulse pages ────────────────────────────────
app.use(createForumRoutes({ forum, viewers, config }));
app.use(createForumRoutes({ forum, viewers, config }));
+ app.use(createCommentPages({ comments, viewers, config }));
app.get('/pulse', viewers.middleware({ services: false }), wrap(async (req, res) => {
app.get('/pulse', viewers.middleware({ services: false }), wrap(async (req, res) => {
const origin = pulse.ORIGINS.includes(req.query.origin) ? req.query.origin : '';
const origin = pulse.ORIGINS.includes(req.query.origin) ? req.query.origin : '';
let out;
let out;
@@ -8,6 +8,8 @@
@@ -8,6 +8,8 @@
* :id is the access id (cth_…); the sequential id is for services only
* :id is the access id (cth_…); the sequential id is for services only
* POST /threads/:id/comments { message, parent_id?, anon_name? }
* POST /threads/:id/comments { message, parent_id?, anon_name? }
* PUT /threads/:id/visibility { visibility: public|hidden|locked } moderators
* PUT /threads/:id/visibility { visibility: public|hidden|locked } moderators
+ * GET /:commentId one comment + its thread (ref included) services only
+ * PATCH /:commentId { message } the author only
* DELETE /:commentId author or moderator
* DELETE /:commentId author or moderator
* POST /:commentId/votes { value: 1|-1|0 }
* POST /:commentId/votes { value: 1|-1|0 }
*
*
@@ -38,6 +40,8 @@ function createCommentsApi({ service, viewers, anonWriteLimiter, resolveLimiter
@@ -38,6 +40,8 @@ function createCommentsApi({ service, viewers, anonWriteLimiter, resolveLimiter
router.post('/threads/:id/comments', serviceCap(WRITE), anonWriteLimiter || passAnon, jsonBody,
router.post('/threads/:id/comments', serviceCap(WRITE), anonWriteLimiter || passAnon, jsonBody,
run((req) => service.add(req.viewer, req.params.id, req.body || {}), 201));
run((req) => service.add(req.viewer, req.params.id, req.body || {}), 201));
router.put('/threads/:id/visibility', serviceCap(MOD), jsonBody, run((req) => service.setVisibility(req.viewer, req.params.id, req.body || {})));
router.put('/threads/:id/visibility', serviceCap(MOD), jsonBody, run((req) => service.setVisibility(req.viewer, req.params.id, req.body || {})));
+ router.get('/:commentId', read, run((req) => service.getComment(req.viewer, req.params.commentId)));
+ router.patch('/:commentId', serviceCap(WRITE), jsonBody, run((req) => service.edit(req.viewer, req.params.commentId, req.body || {})));
router.delete('/:commentId', serviceAnyCap([WRITE, MOD]), run((req) => service.remove(req.viewer, req.params.commentId)));
router.delete('/:commentId', serviceAnyCap([WRITE, MOD]), run((req) => service.remove(req.viewer, req.params.commentId)));
router.post('/:commentId/votes', serviceCap(WRITE), jsonBody, run((req) => service.vote(req.viewer, req.params.commentId, req.body || {})));
router.post('/:commentId/votes', serviceCap(WRITE), jsonBody, run((req) => service.vote(req.viewer, req.params.commentId, req.body || {})));
new file mode 100644
new file mode 100644
@@ -0,0 +1,220 @@
@@ -0,0 +1,220 @@
+'use strict';
+
+/**
+ * OpenVibe.Live's own VOD and clip comments (live.db `comments`) → Community comment threads.
+ *
+ * Live used to keep comments on VODs and clips in its own table; they are now the Community
+ * threads of EntityRef { service: 'live', type: 'vod'|'clip', id } (Live's /api/comments is an
+ * adapter over them). This moves the old rows across, once, keeping their times and text.
+ *
+ * Every Live row ends up in exactly one bucket, so the report reconciles:
+ *
+ * read = imported (new this run + already imported by an earlier run) + held + excluded
+ *
+ * imported the comment is in its thread; legacy_id_map ('live','comment',<live id>) → the
+ * Community comment id is the ledger, so a second run imports nothing twice
+ * held the author could not be named as a Network subject (unmapped_author), or two
+ * sources named different subjects (ambiguous_author), or its parent comment is held
+ * (parent_held). Recorded in import_hold ('live_comment', <live id>, reason) and
+ * listed; a later run imports them once the mapping exists (and clears the hold)
+ * excluded deleted on Live (deleted), a reply under a deleted comment (parent_deleted, Live
+ * no longer showed those), a parent that is missing or on other content (orphan),
+ * an empty message (empty) or a target type other than vod/clip (bad_target)
+ *
+ * Authors: Live's linked_accounts(service='network').subject_id (written from verified Network
+ * tokens) and the Network's identity map (resolve-batch, system 'live', type 'user'). Either one
+ * is enough; when both answer they must agree.
+ *
+ * Replies nest one level in Community: a Live reply to a reply joins its top-level comment.
+ * A row Live marked edited (updated_at later than created_at) keeps that time as edited_at.
+ * Live's database is only read. With dryRun (the default) everything runs in a transaction that
+ * is rolled back, so the report is exactly what --apply would do.
+ */
+const store = require('./store');
+
+const SOURCE_TYPE = 'live_comment';
+const LEDGER = ['live', 'comment'];
+const IMPORTER = 'import:live-comments';
+
+class LiveImportError extends Error {}
+
+function readLive(liveDb) {
+ const tables = new Set(liveDb.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
+ if (!tables.has('comments')) throw new LiveImportError('the Live database has no comments table');
+ const rows = liveDb.prepare(`SELECT id, content_type, content_id, user_id, parent_id, message, is_deleted, created_at, updated_at
+ FROM comments ORDER BY id`).all();
+ const links = new Map();
+ if (tables.has('linked_accounts')) {
+ const cols = new Set(liveDb.prepare('PRAGMA table_info(linked_accounts)').all().map((c) => c.name));
+ if (cols.has('subject_id')) {
+ for (const r of liveDb.prepare("SELECT user_id, subject_id FROM linked_accounts WHERE service = 'network' AND subject_id IS NOT NULL").all()) {
+ links.set(String(r.user_id), r.subject_id);
+ }
+ }
+ }
+ return { rows, links };
+}
+
+/** Live's SQLite times are UTC 'YYYY-MM-DD HH:MM:SS'; keep them in that form. */
+function sqlTime(v) {
+ if (!v) return null;
+ const s = String(v);
+ if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(s)) return s;
+ const d = new Date(/^\d{4}-\d{2}-\d{2}T/.test(s) || /Z$/.test(s) ? s : `${s.replace(' ', 'T')}Z`);
+ return Number.isNaN(d.getTime()) ? null : d.toISOString().replace('T', ' ').slice(0, 19);
+}
+
+const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+/**
+ * opts.resolveLegacy(system, ids) → Map(id → { subject: { type, id } } | null) (Network), or null
+ * to rely on Live's own links only. opts.dryRun (default true). opts.source names the run.
+ */
+async function importLiveComments(db, liveDb, { resolveLegacy = null, dryRun = true, source = 'live.db' } = {}) {
+ const { rows, links } = readLive(liveDb);
+ const byId = new Map(rows.map((r) => [r.id, r]));
+
+ // ── Classify: excluded rows first (they need no author) ──
+ const excluded = new Map(); // live id → reason
+ const rootOf = new Map(); // live id → top-level live id
+ for (const r of rows) {
+ if (r.is_deleted) { excluded.set(r.id, 'deleted'); continue; }
+ if (!['vod', 'clip'].includes(r.content_type) || !(Number(r.content_id) > 0)) { excluded.set(r.id, 'bad_target'); continue; }
+ if (!String(r.message || '').trim()) { excluded.set(r.id, 'empty'); continue; }
+ let cur = r, reason = null;
+ const seen = new Set([r.id]);
+ while (cur.parent_id != null) {
+ const p = byId.get(cur.parent_id);
+ if (!p || seen.has(p.id) || p.content_type !== r.content_type || p.content_id !== r.content_id) { reason = 'orphan'; break; }
+ if (p.is_deleted) { reason = 'parent_deleted'; break; }
+ seen.add(p.id);
+ cur = p;
+ }
+ if (reason) excluded.set(r.id, reason);
+ else rootOf.set(r.id, cur.id);
+ }
+
+ // ── Authors → subjects ──
+ const userIds = [...new Set(rows.filter((r) => rootOf.has(r.id)).map((r) => String(r.user_id)))];
+ let network = new Map();
+ if (resolveLegacy && userIds.length) network = await resolveLegacy('live', userIds);
+ const subjectFor = new Map(); // live user id → { subject } | { reason, detail }
+ for (const uid of userIds) {
+ const hit = network.get(uid);
+ const fromNet = hit && hit.subject && hit.subject.type === 'user' && USER_SUBJECT.test(hit.subject.id) ? hit.subject.id : null;
+ const fromLive = USER_SUBJECT.test(String(links.get(uid) || '')) ? links.get(uid) : null;
+ if (fromNet && fromLive && fromNet !== fromLive) subjectFor.set(uid, { reason: 'ambiguous_author', detail: `network says ${fromNet}, Live's link says ${fromLive}` });
+ else if (fromNet || fromLive) subjectFor.set(uid, { subject: fromNet || fromLive });
+ else subjectFor.set(uid, { reason: 'unmapped_author', detail: `live user ${uid} has no Network subject` });
+ }
+
+ const report = {
+ source, dry_run: !!dryRun, read: rows.length,
+ imported: 0, imported_new: 0, imported_before: 0, held: 0, excluded: 0,
+ excluded_by_reason: {}, held_rows: [], threads: [], reconciled: false,
+ };
+
+ const ledger = db.prepare('SELECT target_id FROM legacy_id_map WHERE source_system = ? AND source_type = ? AND source_id = ?');
+ const ledgerGet = (liveId) => {
+ const hit = ledger.get(LEDGER[0], LEDGER[1], String(liveId));
+ return hit ? Number(hit.target_id) : null;
+ };
+ const ledgerSet = db.prepare(`INSERT INTO legacy_id_map (source_system, source_type, source_id, target_type, target_id) VALUES (?, ?, ?, 'comment', ?)
+ ON CONFLICT(source_system, source_type, source_id) DO NOTHING`);
+ const hold = db.prepare(`INSERT INTO import_hold (source_type, source_id, reason, detail) VALUES (?, ?, ?, ?)
+ ON CONFLICT(source_type, source_id, reason) DO UPDATE SET detail = excluded.detail`);
+ const unhold = db.prepare('DELETE FROM import_hold WHERE source_type = ? AND source_id = ?');
+ const insert = db.prepare(`INSERT INTO comments (thread_id, parent_id, author_subject, anon_name, origin, message, created_at, updated_at, edited_at)
+ VALUES (?, ?, ?, NULL, 'user', ?, ?, ?, ?)`);
+
+ const apply = () => {
+ const touchedThreads = new Set();
+ const touchedParents = new Set();
+ const threadIds = new Map(); // 'vod:12' → thread row
+ const threadFor = (r) => {
+ const key = `${r.content_type}:${r.content_id}`;
+ if (!threadIds.has(key)) {
+ const { thread } = store.resolveThread(db, { service: 'live', type: r.content_type, id: String(r.content_id) }, { createdBy: IMPORTER });
+ threadIds.set(key, thread);
+ }
+ return threadIds.get(key);
+ };
+ const state = new Map(); // live id → 'imported' | 'held'
+
+ // Top-level comments first, then replies (whose parent must be placed already).
+ const ordered = rows.filter((r) => rootOf.has(r.id)).sort((a, b) => ((a.parent_id == null ? 0 : 1) - (b.parent_id == null ? 0 : 1)) || (a.id - b.id));
+ for (const r of ordered) {
+ const root = rootOf.get(r.id);
+ const who = subjectFor.get(String(r.user_id));
+ let heldReason = who.subject ? null : who.reason;
+ let detail = who.subject ? null : who.detail;
+ if (!heldReason && root !== r.id && state.get(root) === 'held') { heldReason = 'parent_held'; detail = `parent comment ${root} is held`; }
+ if (heldReason) {
+ state.set(r.id, 'held');
+ unhold.run(SOURCE_TYPE, String(r.id)); // one current reason per row
+ hold.run(SOURCE_TYPE, String(r.id), heldReason, JSON.stringify({ detail, content_type: r.content_type, content_id: r.content_id, user_id: r.user_id }));
+ report.held_rows.push({ live_id: r.id, reason: heldReason, content: `${r.content_type}/${r.content_id}`, live_user_id: r.user_id, detail });
+ continue;
+ }
+ const thread = threadFor(r);
+ if (!report.threads.includes(thread.access_id)) report.threads.push(thread.access_id);
+ const known = ledgerGet(r.id);
+ if (known) { state.set(r.id, 'imported'); report.imported_before++; continue; }
+ const parentCommunityId = root === r.id ? null : ledgerGet(root);
+ const created = sqlTime(r.created_at) || sqlTime(new Date().toISOString());
+ const updated = sqlTime(r.updated_at) || created;
+ const edited = updated > created ? updated : null;
+ const info = insert.run(thread.id, parentCommunityId, who.subject, String(r.message).trim(), created, updated, edited);
+ ledgerSet.run(LEDGER[0], LEDGER[1], String(r.id), String(info.lastInsertRowid));
+ unhold.run(SOURCE_TYPE, String(r.id));
+ state.set(r.id, 'imported');
+ report.imported_new++;
+ touchedThreads.add(thread.id);
+ if (parentCommunityId) touchedParents.add(parentCommunityId);
+ }
+
+ // Counters from the rows (the store keeps them in step for live writes; here we set them once).
+ for (const id of touchedThreads) {
+ db.prepare('UPDATE comment_threads SET comment_count = (SELECT COUNT(*) FROM comments WHERE thread_id = ? AND deleted_at IS NULL), updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(id, id);
+ }
+ for (const id of touchedParents) {
+ db.prepare('UPDATE comments SET reply_count = (SELECT COUNT(*) FROM comments WHERE parent_id = ? AND deleted_at IS NULL) WHERE id = ?').run(id, id);
+ }
+
+ report.imported = report.imported_new + report.imported_before;
+ report.held = report.held_rows.length;
+ for (const reason of excluded.values()) report.excluded_by_reason[reason] = (report.excluded_by_reason[reason] || 0) + 1;
+ report.excluded = excluded.size;
+ report.reconciled = report.read === report.imported + report.held + report.excluded;
+ if (!report.reconciled) throw new LiveImportError(`reconciliation failed: read ${report.read} ≠ imported ${report.imported} + held ${report.held} + excluded ${report.excluded}`);
+ if (!dryRun) {
+ db.prepare('INSERT INTO migration_runs (source, finished_at, report) VALUES (?, CURRENT_TIMESTAMP, ?)').run(`live-comments:${source}`, JSON.stringify(report));
+ }
+ };
+
+ const ROLLBACK = Symbol('dry run');
+ try {
+ db.transaction(() => { apply(); if (dryRun) throw ROLLBACK; })();
+ } catch (err) {
+ if (err !== ROLLBACK) throw err;
+ }
+ return report;
+}
+
+function formatReport(r) {
+ const lines = [];
+ lines.push(`Live comments → Community threads (${r.dry_run ? 'DRY RUN — nothing was written' : 'APPLIED'}) from ${r.source}`);
+ lines.push(` read ${r.read}`);
+ lines.push(` imported ${r.imported} (new ${r.imported_new}, already imported ${r.imported_before})`);
+ lines.push(` held ${r.held}`);
+ lines.push(` excluded ${r.excluded}${Object.keys(r.excluded_by_reason).length ? ` (${Object.entries(r.excluded_by_reason).map(([k, v]) => `${k} ${v}`).join(', ')})` : ''}`);
+ lines.push(` check read ${r.read} = imported ${r.imported} + held ${r.held} + excluded ${r.excluded} → ${r.reconciled ? 'OK' : 'MISMATCH'}`);
+ lines.push(` threads ${r.threads.length}`);
+ if (r.held_rows.length) {
+ lines.push(' held rows (not imported; re-run once the author has a Network subject):');
+ for (const h of r.held_rows) lines.push(` live comment ${h.live_id} ${h.reason} on ${h.content} live user ${h.live_user_id}${h.detail ? ` — ${h.detail}` : ''}`);
+ }
+ return lines.join('\n');
+}
+
+module.exports = { importLiveComments, formatReport, LiveImportError, SOURCE_TYPE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+
+/**
+ * A comment thread's own page (render/comments.js has the markup, service.js the rules):
+ *
+ * GET /c/:accessId the thread, newest first (?after=<last comment id> for older ones)
+ * POST /c/:accessId comment (signed in; the no-JS form)
+ *
+ * Only the unguessable access id (cth_…) opens a page — the one Community hands out on resolve and
+ * owner products link to — so pages cannot be walked either. Hidden threads are 404. Form posts
+ * carry the ov_token cookie (SameSite=Lax) and an Origin from another site is refused, as in the
+ * forum.
+ */
+const express = require('express');
+const pages = require('../render/pages');
+const { threadPage } = require('../render/comments');
+const { ApiError } = require('../http/v1');
+
+const ACCESS_ID_RE = /^cth_[A-Za-z0-9_-]{22}$/;
+
+function createCommentPages({ comments, viewers, config }) {
+ const router = express.Router();
+ const withViewer = viewers.middleware({ services: false });
+ const form = express.urlencoded({ extended: false, limit: '64kb' });
+ const html = (res, body, status = 200) => res.status(status).type('html').set('Cache-Control', 'no-cache').send(body);
+ const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
+
+ function failPage(req, res, err, next) {
+ if (!(err instanceof ApiError)) return next(err);
+ if (err.status === 401) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/c/${req.params.accessId}`)}`);
+ const titles = { 404: 'Not found', 403: 'Not allowed', 429: 'Slow down' };
+ return html(res, pages.errorPage({ status: err.status, title: titles[err.status] || 'That did not work', message: err.status === 404 ? 'There is no comment thread at that address.' : err.message }), err.status);
+ }
+
+ const notFound = () => new ApiError(404, 'thread.not_found', 'Comment thread not found');
+
+ async function render(req, res, { status = 200, error = null, draft = '' } = {}) {
+ const after = /^\d{1,15}$/.test(String(req.query.after || '')) ? String(req.query.after) : null;
+ const page = await comments.get(req.viewer, req.params.accessId, { sort: 'new', after, limit: 30 });
+ html(res, threadPage({ accessId: req.params.accessId, page, user: req.user, after, error, draft }), status);
+ }
+
+ router.get('/c/:accessId', withViewer, wrap(async (req, res, next) => {
+ try {
+ if (!ACCESS_ID_RE.test(req.params.accessId)) throw notFound();
+ await render(req, res);
+ } catch (err) { failPage(req, res, err, next); }
+ }));
+
+ router.post('/c/:accessId', withViewer, form, wrap(async (req, res, next) => {
+ const origin = req.get('origin');
+ if (origin && origin !== 'null' && origin !== config.baseUrl) return html(res, pages.errorPage({ status: 403, title: 'Not allowed', message: 'That form was sent from another site.' }), 403);
+ const draft = String((req.body || {}).message || '').slice(0, 5000);
+ try {
+ if (!ACCESS_ID_RE.test(req.params.accessId)) throw notFound();
+ // Signed-in people only on this page, whatever the thread allows through the API.
+ if (!req.viewer || !req.viewer.subject) throw new ApiError(401, 'auth.required', 'Sign in to comment');
+ const out = await comments.add(req.viewer, req.params.accessId, { message: draft });
+ res.redirect(303, `/c/${req.params.accessId}#comment-${out.comment.id}`);
+ } catch (err) {
+ if (!(err instanceof ApiError) || err.status === 401 || err.status === 404) return failPage(req, res, err, next);
+ try { await render(req, res, { status: err.status, error: err.message, draft }); } catch (e) { failPage(req, res, e, next); }
+ }
+ }));
+
+ return router;
+}
+
+module.exports = { createCommentPages };
@@ -15,7 +15,11 @@
@@ -15,7 +15,11 @@
* the refs (unlisted paste slugs, private pages) and comments of entities they were not given.
* the refs (unlisted paste slugs, private pages) and comments of entities they were not given.
* - comment: people (browser or service X-OV-Subject), anonymous with an anon_name, or AI output
* - comment: people (browser or service X-OV-Subject), anonymous with an anon_name, or AI output
* from a service (origin ai, never attributed). Not on locked threads (moderators still may).
* from a service (origin ai, never attributed). Not on locked threads (moderators still may).
+ * Threads of the types in SIGNED_IN_ONLY take no anonymous comments (the owner product's rule).
+ * - edit: the comment's author only (edited_at records it).
* - delete: the comment's author, or a moderator.
* - delete: the comment's author, or a moderator.
+ * - one comment with its thread (getComment): services only — comment ids are sequential, so a
+ * browser could otherwise walk them to the refs of threads it was never given.
* - vote: people only; 1, -1 or 0 (remove).
* - vote: people only; 1, -1 or 0 (remove).
* - visibility public|hidden|locked: moderators.
* - visibility public|hidden|locked: moderators.
* A moderator is discussion staff (an admin/global_mod browser, or a service vouching with
* A moderator is discussion staff (an admin/global_mod browser, or a service vouching with
@@ -34,15 +38,24 @@ const { createAuthors } = require('../identity/authors');
@@ -34,15 +38,24 @@ const { createAuthors } = require('../identity/authors');
const { createPersonLimiter } = require('../limits');
const { createPersonLimiter } = require('../limits');
const { discussionModerator: moderator } = require('../identity/capabilities');
const { discussionModerator: moderator } = require('../identity/capabilities');
-/** The entity types a browser may open a thread for (services with community.comment.write: any). */
+/**
+ * The entity types a browser may open a thread for (services with community.comment.write: any).
+ * Live VODs and clips are not here: a private one is missing to everyone but its owners and staff,
+ * and only Live can tell, so only Live opens their threads (and hands the access id to people who
+ * may see the item).
+ */
const BROWSER_REF_TYPES = {
const BROWSER_REF_TYPES = {
- live: ['vod', 'clip', 'stream', 'channel'],
+ live: ['stream', 'channel'],
media: ['object'],
media: ['object'],
community: ['paste', 'post'],
community: ['paste', 'post'],
wiki: ['page'],
wiki: ['page'],
blog: ['post'],
blog: ['post'],
reviews: ['entity'],
reviews: ['entity'],
};
};
+/** Threads that take comments from people only — no anonymous comments (Live's rule for VODs and clips). */
+const SIGNED_IN_ONLY = {
+ live: ['vod', 'clip'],
+};
const VISIBILITIES = ['public', 'hidden', 'locked'];
const VISIBILITIES = ['public', 'hidden', 'locked'];
const MAX_MESSAGE = 5000;
const MAX_MESSAGE = 5000;
const ACCESS_ID_RE = /^cth_[A-Za-z0-9_-]{22}$/;
const ACCESS_ID_RE = /^cth_[A-Za-z0-9_-]{22}$/;
@@ -87,6 +100,8 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
@@ -87,6 +100,8 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
reply_count: c.reply_count,
reply_count: c.reply_count,
created_at: isoTime(c.created_at),
created_at: isoTime(c.created_at),
updated_at: isoTime(c.updated_at),
updated_at: isoTime(c.updated_at),
+ edited_at: deleted ? null : isoTime(c.edited_at),
+ can_edit: !deleted && !!(v && v.subject && c.author_subject === v.subject),
can_delete: !deleted && (moderator(v) || !!(v && v.subject && c.author_subject === v.subject)),
can_delete: !deleted && (moderator(v) || !!(v && v.subject && c.author_subject === v.subject)),
};
};
if (c.replies) out.replies = c.replies.map((r) => shapeComment(r, v, projections, votes, threadId));
if (c.replies) out.replies = c.replies.map((r) => shapeComment(r, v, projections, votes, threadId));
@@ -136,6 +151,15 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
@@ -136,6 +151,15 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
}
}
}
}
+ const signedInOnly = (t) => (SIGNED_IN_ONLY[t.ref_service] || []).includes(t.ref_type);
+
+ function cleanMessage(raw) {
+ const message = String(raw == null ? '' : raw).replace(/\u0000/g, '').trim();
+ if (!message) fail(400, 'comment.empty', 'Comment cannot be empty');
+ if (message.length > MAX_MESSAGE) fail(400, 'comment.too_long', `Comment must be under ${MAX_MESSAGE} characters`);
+ return message;
+ }
+
const cursorId = (after) => {
const cursorId = (after) => {
if (after == null || after === '') return null;
if (after == null || after === '') return null;
if (!/^\d{1,15}$/.test(String(after))) fail(400, 'request.invalid_cursor', '`after` is the id of the last comment you have');
if (!/^\d{1,15}$/.test(String(after))) fail(400, 'request.invalid_cursor', '`after` is the id of the last comment you have');
@@ -144,6 +168,7 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
@@ -144,6 +168,7 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
return {
return {
BROWSER_REF_TYPES,
BROWSER_REF_TYPES,
+ SIGNED_IN_ONLY,
/** POST /threads/resolve { ref } → { thread, created } */
/** POST /threads/resolve { ref } → { thread, created } */
resolve(v, body = {}) {
resolve(v, body = {}) {
@@ -197,9 +222,7 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
@@ -197,9 +222,7 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
async add(v, id, body = {}) {
async add(v, id, body = {}) {
const t = visibleThread(v, id);
const t = visibleThread(v, id);
if (t.visibility === 'locked' && !moderator(v)) fail(403, 'thread.locked', 'This comment thread is locked');
if (t.visibility === 'locked' && !moderator(v)) fail(403, 'thread.locked', 'This comment thread is locked');
- const message = String(body.message == null ? '' : body.message).replace(/\u0000/g, '').trim();
- if (!message) fail(400, 'comment.empty', 'Comment cannot be empty');
- if (message.length > MAX_MESSAGE) fail(400, 'comment.too_long', `Comment must be under ${MAX_MESSAGE} characters`);
+ const message = cleanMessage(body.message);
let parentId = null;
let parentId = null;
if (body.parent_id != null && body.parent_id !== '') {
if (body.parent_id != null && body.parent_id !== '') {
@@ -211,6 +234,7 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
@@ -211,6 +234,7 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
const origin = v.origin === 'ai' ? 'ai' : 'user';
const origin = v.origin === 'ai' ? 'ai' : 'user';
const author = origin === 'ai' ? null : (v.subject || null);
const author = origin === 'ai' ? null : (v.subject || null);
+ if (!author && origin !== 'ai' && !moderator(v) && signedInOnly(t)) fail(401, 'auth.required', 'Sign in to comment here');
let anonName = null;
let anonName = null;
if (!author && origin !== 'ai') {
if (!author && origin !== 'ai') {
anonName = String(body.anon_name || '').trim().slice(0, 32).replace(/[^a-zA-Z0-9 _-]/g, '').trim() || 'Anonymous';
anonName = String(body.anon_name || '').trim().slice(0, 32).replace(/[^a-zA-Z0-9 _-]/g, '').trim() || 'Anonymous';
@@ -222,6 +246,27 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
@@ -222,6 +246,27 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
return { comment: (await shapeComments([c], v, t))[0] };
return { comment: (await shapeComments([c], v, t))[0] };
},
},
+ /**
+ * GET /:commentId — one comment and its thread (with the ref), for services only: an owner
+ * product checks what a comment belongs to before it edits or deletes it for someone.
+ */
+ async getComment(v, commentId) {
+ if (!v || v.kind !== 'service') fail(404, 'comment.not_found', 'Comment not found');
+ const { comment: c, thread: t } = visibleComment(v, commentId);
+ return { comment: (await shapeComments([c], v, t))[0], thread: shapeThread(t, v) };
+ },
+
+ /** PATCH /:commentId { message } — the author only. */
+ async edit(v, commentId, body = {}) {
+ if (!(v && v.subject)) fail(401, 'auth.required', 'Sign in to edit a comment');
+ const { comment: c, thread: t } = visibleComment(v, commentId);
+ if (!(c.author_subject && c.author_subject === v.subject)) fail(403, 'comment.not_yours', 'Only the author can edit a comment');
+ if (t.visibility === 'locked' && !moderator(v)) fail(403, 'thread.locked', 'This comment thread is locked');
+ const message = cleanMessage(body.message);
+ const row = message === c.message ? c : store.editComment(db, c.id, message);
+ return { comment: (await shapeComments([row], v, t))[0] };
+ },
+
/** DELETE /comments/:id — the author or a moderator. */
/** DELETE /comments/:id — the author or a moderator. */
remove(v, commentId) {
remove(v, commentId) {
const { comment: c } = visibleComment(v, commentId);
const { comment: c } = visibleComment(v, commentId);
@@ -257,4 +302,4 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
@@ -257,4 +302,4 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
};
};
}
}
-module.exports = { createCommentService, BROWSER_REF_TYPES, VISIBILITIES };
+module.exports = { createCommentService, BROWSER_REF_TYPES, SIGNED_IN_ONLY, VISIBILITIES };
@@ -63,6 +63,12 @@ function insertComment(db, { thread_id, parent_id = null, author_subject = null,
@@ -63,6 +63,12 @@ function insertComment(db, { thread_id, parent_id = null, author_subject = null,
})();
})();
}
}
+/** The author's new text; edited_at records that it changed. → the row */
+function editComment(db, id, message) {
+ db.prepare('UPDATE comments SET message = ?, edited_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL').run(message, id);
+ return getComment(db, id);
+}
+
/** Soft delete; counters recomputed from the rows. → changes */
/** Soft delete; counters recomputed from the rows. → changes */
function softDeleteComment(db, id, deletedBy = null) {
function softDeleteComment(db, id, deletedBy = null) {
return db.transaction(() => {
return db.transaction(() => {
@@ -105,4 +111,4 @@ function listReplies(db, parentId, { after = null, limit = 50 } = {}) {
@@ -105,4 +111,4 @@ function listReplies(db, parentId, { after = null, limit = 50 } = {}) {
return { rows, hasMore };
return { rows, hasMore };
}
}
-module.exports = { getThread, getThreadByAccessId, getThreadByRef, resolveThread, setThreadVisibility, getComment, insertComment, softDeleteComment, listTopLevel, listReplies };
+module.exports = { getThread, getThreadByAccessId, getThreadByRef, resolveThread, setThreadVisibility, getComment, insertComment, editComment, softDeleteComment, listTopLevel, listReplies };
@@ -157,7 +157,8 @@ CREATE TABLE IF NOT EXISTS comments (
@@ -157,7 +157,8 @@ CREATE TABLE IF NOT EXISTS comments (
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME,
deleted_at DATETIME,
- deleted_by TEXT
+ deleted_by TEXT,
+ edited_at DATETIME -- the author last changed the text (migrate() adds it to older databases)
);
);
CREATE INDEX IF NOT EXISTS idx_comments_thread ON comments(thread_id, parent_id, id);
CREATE INDEX IF NOT EXISTS idx_comments_thread ON comments(thread_id, parent_id, id);
CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id, id);
CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id, id);
@@ -337,6 +338,7 @@ function openDb(file) {
@@ -337,6 +338,7 @@ function openDb(file) {
* comment_threads.access_id — the unguessable handle browsers address a thread by (128 random
* comment_threads.access_id — the unguessable handle browsers address a thread by (128 random
* bits); the sequential id is for services only, so threads cannot be enumerated. Threads made
* bits); the sequential id is for services only, so threads cannot be enumerated. Threads made
* before the column existed get one here.
* before the column existed get one here.
+ * comments.edited_at — when the author last edited the text (null: never edited).
*/
*/
function migrate(db) {
function migrate(db) {
const cols = new Set(db.prepare('PRAGMA table_info(comment_threads)').all().map((c) => c.name));
const cols = new Set(db.prepare('PRAGMA table_info(comment_threads)').all().map((c) => c.name));
@@ -347,6 +349,8 @@ function migrate(db) {
@@ -347,6 +349,8 @@ function migrate(db) {
db.transaction(() => { for (const r of missing) set.run(newThreadAccessId(), r.id); })();
db.transaction(() => { for (const r of missing) set.run(newThreadAccessId(), r.id); })();
}
}
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_comment_threads_access ON comment_threads(access_id)');
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_comment_threads_access ON comment_threads(access_id)');
+ const commentCols = new Set(db.prepare('PRAGMA table_info(comments)').all().map((c) => c.name));
+ if (!commentCols.has('edited_at')) db.exec('ALTER TABLE comments ADD COLUMN edited_at DATETIME');
}
}
/** cth_ + 22 base64url characters (16 random bytes). */
/** cth_ + 22 base64url characters (16 random bytes). */
@@ -89,7 +89,7 @@ function cors(origins) {
@@ -89,7 +89,7 @@ function cors(origins) {
res.set('Access-Control-Allow-Origin', origin);
res.set('Access-Control-Allow-Origin', origin);
res.set('Vary', 'Origin');
res.set('Vary', 'Origin');
res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, traceparent, X-OpenVibe-Request-Id');
res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, traceparent, X-OpenVibe-Request-Id');
- res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
+ res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE');
res.set('Access-Control-Expose-Headers', 'X-OpenVibe-Request-Id, Retry-After');
res.set('Access-Control-Expose-Headers', 'X-OpenVibe-Request-Id, Retry-After');
res.set('Access-Control-Max-Age', '600');
res.set('Access-Control-Max-Age', '600');
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,103 @@
@@ -0,0 +1,103 @@
+'use strict';
+
+/**
+ * A comment thread's own page — /c/:accessId. The same thread another product embeds (a Live VOD
+ * or clip, a wiki page, a blog post), read here straight from Community's store, so both places
+ * always show the same comments. Complete without JavaScript: older comments are a link, commenting
+ * is a plain form post. Messages are plain text, always escaped.
+ */
+const config = require('../config');
+const { renderPage } = require('./layout');
+const { escapeHtml: esc } = require('./highlight');
+const { timeTag, fmtDate, num } = require('./pages');
+const { who } = require('./forum');
+
+const REF_NAMES = {
+ 'live/vod': 'VOD', 'live/clip': 'clip', 'live/stream': 'stream', 'live/channel': 'channel',
+ 'wiki/page': 'wiki page', 'blog/post': 'blog post', 'community/paste': 'paste', 'community/post': 'post',
+};
+const PRODUCTS = { live: 'OpenVibe.Live', wiki: 'OpenVibe.Wiki', blog: 'OpenVibe.Blog', community: 'OpenVibe.Community', media: 'OpenVibe.Media', reviews: 'OpenVibe.Reviews' };
+
+/** Where the commented-on thing lives, when Community knows its address. */
+function sourceUrl(ref) {
+ const id = encodeURIComponent(ref.id);
+ if (ref.service === 'live' && ref.type === 'vod') return `${config.liveUrl}/vod/${id}`;
+ if (ref.service === 'live' && ref.type === 'clip') return `${config.liveUrl}/clip/${id}`;
+ if (ref.service === 'live' && ref.type === 'channel') return `${config.liveUrl}/${id}`;
+ if (ref.service === 'community' && ref.type === 'paste') return `/p/${id}`;
+ return null;
+}
+
+function refName(ref) { return REF_NAMES[`${ref.service}/${ref.type}`] || ref.type; }
+
+function commentHtml(c) {
+ if (c.deleted) {
+ return `<article class="post post-deleted" id="comment-${c.id}"><p class="muted small">This comment was deleted.</p>${repliesHtml(c)}</article>`;
+ }
+ const edited = c.edited_at ? ` <span class="sep">·</span> <span class="muted small" title="Edited ${esc(fmtDate(c.edited_at))}">edited</span>` : '';
+ return `<article class="post comment" id="comment-${c.id}">
+ <header class="post-head">${who(c.author || (c.anon_name ? { display_name: c.anon_name } : null))} <span class="sep">·</span> <a class="muted small" href="#comment-${c.id}">${timeTag(c.created_at)}</a>${edited}</header>
+ <p class="comment-text">${esc(c.message)}</p>
+ ${repliesHtml(c)}
+ </article>`;
+}
+
+function repliesHtml(c) {
+ if (!c.replies || !c.replies.length) return '';
+ const more = c.reply_count > c.replies.length ? `<p class="muted small">${num(c.reply_count - c.replies.length)} more ${c.reply_count - c.replies.length === 1 ? 'reply' : 'replies'} on the original page.</p>` : '';
+ return `<div class="comment-replies">${c.replies.map(commentHtml).join('\n')}${more}</div>`;
+}
+
+/**
+ * page: the comment service's answer for this viewer ({ thread, comments, next_cursor, viewer }).
+ */
+function threadPage({ accessId, page, user, after = null, error = null, draft = '' }) {
+ const { thread, comments, next_cursor: next, viewer } = page;
+ const ref = thread.ref;
+ const what = refName(ref);
+ const product = PRODUCTS[ref.service] || ref.service;
+ const label = ref.label || `a ${what} on ${product}`;
+ const src = sourceUrl(ref);
+ const base = `/c/${accessId}`;
+
+ let form = '';
+ if (thread.visibility === 'locked' && !viewer.can_moderate) form = '<p class="alert">This comment thread is locked.</p>';
+ else if (!user) form = `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(base)}">Sign in with your OpenVibe account</a> to comment.</p>`;
+ else if (viewer.can_comment) {
+ form = `<form class="paste-form reply-form" method="post" action="${esc(base)}" id="comment">
+ ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
+ <label class="field"><span>Comment</span><textarea name="message" rows="4" maxlength="2000" required placeholder="Add a comment…">${esc(draft)}</textarea></label>
+ <div class="form-actions"><button class="btn btn-primary" type="submit"><i class="fa-solid fa-comment" aria-hidden="true"></i> Comment</button><span class="muted small">Commenting as <strong>${esc(user.display_name || user.username || 'you')}</strong>. It shows on ${esc(product)} too.</span></div>
+ </form>`;
+ }
+
+ const list = comments.length
+ ? `<div class="posts">${comments.map(commentHtml).join('\n')}</div>`
+ : `<p class="empty">${after ? 'No older comments.' : 'No comments yet.'}</p>`;
+ const older = next ? `<nav class="pager" aria-label="Pages"><span></span><a rel="next" href="${esc(`${base}?after=${encodeURIComponent(next)}`)}">Older comments <i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a></nav>` : '';
+
+ const body = `
+<article class="thread comment-thread">
+ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <span aria-current="page">Comments</span></nav>
+ <header class="page-head">
+ <h1>Comments on ${esc(label)}</h1>
+ <p class="muted">${num(thread.comment_count)} ${thread.comment_count === 1 ? 'comment' : 'comments'} · one thread, shown here and on ${esc(product)}${src ? ` · <a href="${esc(src)}">Open the ${esc(what)} on ${esc(product)}</a>` : ''}</p>
+ </header>
+ ${after ? '' : form}
+ ${after ? `<p><a href="${esc(base)}">Newest comments</a></p>` : ''}
+ ${list}
+ ${older}
+</article>`;
+ return renderPage({
+ title: `Comments on ${label}`,
+ description: `${num(thread.comment_count)} comments on ${label}, the same thread ${product} shows.`,
+ canonicalPath: base,
+ // The access id is the only thing keeping an unlisted item's thread unlisted: never indexed.
+ robots: 'noindex,nofollow',
+ active: 'comments',
+ footerVariant: 'compact',
+ body,
+ });
+}
+
+module.exports = { threadPage, sourceUrl };
@@ -33,7 +33,7 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -33,7 +33,7 @@ const { checkCapability } = require('../server/identity/capabilities');
const resolve = (ref, who = {}) => call('/api/v1/comments/threads/resolve', { method: 'POST', json: { ref }, ...who });
const resolve = (ref, who = {}) => call('/api/v1/comments/threads/resolve', { method: 'POST', json: { ref }, ...who });
const post = (id, json, who = {}) => call(`/api/v1/comments/threads/${id}/comments`, { method: 'POST', json, ...who });
const post = (id, json, who = {}) => call(`/api/v1/comments/threads/${id}/comments`, { method: 'POST', json, ...who });
- let vod;
+ let vod; // a browser-opened thread (a Live stream); Live VODs and clips have their own checks below
await check('capabilities: proposed ids match locally (exact or prefix.*); known ids go through the library', () => {
await check('capabilities: proposed ids match locally (exact or prefix.*); known ids go through the library', () => {
assert.strictEqual(checkCapability({ cap: [WRITE] }, WRITE).allowed, true);
assert.strictEqual(checkCapability({ cap: [WRITE] }, WRITE).allowed, true);
@@ -46,7 +46,7 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -46,7 +46,7 @@ const { checkCapability } = require('../server/identity/capabilities');
});
});
await check('resolve: get-or-create, idempotent (201 then 200, same id), unique per service+type+id', async () => {
await check('resolve: get-or-create, idempotent (201 then 200, same id), unique per service+type+id', async () => {
- const ref = { service: 'live', type: 'vod', id: '123' };
+ const ref = { service: 'live', type: 'stream', id: '123' };
const a = await resolve(ref, { cookie: alexJwt });
const a = await resolve(ref, { cookie: alexJwt });
assert.strictEqual(a.status, 201, a.text);
assert.strictEqual(a.status, 201, a.text);
assert.strictEqual(a.json().created, true);
assert.strictEqual(a.json().created, true);
@@ -58,9 +58,9 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -58,9 +58,9 @@ const { checkCapability } = require('../server/identity/capabilities');
assert.strictEqual(b.status, 200);
assert.strictEqual(b.status, 200);
assert.strictEqual(b.json().thread.id, vod.id);
assert.strictEqual(b.json().thread.id, vod.id);
assert.strictEqual(b.json().created, false);
assert.strictEqual(b.json().created, false);
- const other = await resolve({ service: 'live', type: 'clip', id: '123' });
+ const other = await resolve({ service: 'live', type: 'channel', id: '123' });
assert.notStrictEqual(other.json().thread.id, vod.id);
assert.notStrictEqual(other.json().thread.id, vod.id);
- assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS c FROM comment_threads WHERE ref_service = 'live' AND ref_type = 'vod' AND ref_id = '123'").get().c, 1);
+ assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS c FROM comment_threads WHERE ref_service = 'live' AND ref_type = 'stream' AND ref_id = '123'").get().c, 1);
});
});
await check('resolve: browsers only for allowlisted types; services with comment.write for any; labels only from services', async () => {
await check('resolve: browsers only for allowlisted types; services with comment.write for any; labels only from services', async () => {
@@ -150,7 +150,7 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -150,7 +150,7 @@ const { checkCapability } = require('../server/identity/capabilities');
const r1 = (await post(vod.id, { message: 'reply', parent_id: top.id }, { cookie: samJwt })).json().comment;
const r1 = (await post(vod.id, { message: 'reply', parent_id: top.id }, { cookie: samJwt })).json().comment;
const r2 = await post(vod.id, { message: 'reply to reply', parent_id: r1.id }, { cookie: alexJwt });
const r2 = await post(vod.id, { message: 'reply to reply', parent_id: r1.id }, { cookie: alexJwt });
assert.strictEqual(r2.json().comment.parent_id, top.id);
assert.strictEqual(r2.json().comment.parent_id, top.id);
- const other = (await resolve({ service: 'live', type: 'vod', id: '456' })).json().thread;
+ const other = (await resolve({ service: 'live', type: 'stream', id: '456' })).json().thread;
assert.strictEqual((await post(other.id, { message: 'cross', parent_id: top.id }, { cookie: alexJwt })).status, 400);
assert.strictEqual((await post(other.id, { message: 'cross', parent_id: top.id }, { cookie: alexJwt })).status, 400);
const page = (await call(`/api/v1/comments/threads/${vod.id}?limit=100`)).json();
const page = (await call(`/api/v1/comments/threads/${vod.id}?limit=100`)).json();
const shown = page.comments.find((c) => c.id === top.id);
const shown = page.comments.find((c) => c.id === top.id);
@@ -223,7 +223,7 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -223,7 +223,7 @@ const { checkCapability } = require('../server/identity/capabilities');
});
});
await check('visibility: services need comment.moderate; locked = read-only; hidden = 404 except moderators', async () => {
await check('visibility: services need comment.moderate; locked = read-only; hidden = 404 except moderators', async () => {
- const th = (await resolve({ service: 'live', type: 'clip', id: 'vis-1' })).json().thread;
+ const th = (await resolve({ service: 'live', type: 'channel', id: 'vis-1' })).json().thread;
const c = (await post(th.id, { message: 'before lock' }, { cookie: samJwt })).json().comment;
const c = (await post(th.id, { message: 'before lock' }, { cookie: samJwt })).json().comment;
const put = (visibility, who) => call(`/api/v1/comments/threads/${th.id}/visibility`, { method: 'PUT', json: { visibility }, ...who });
const put = (visibility, who) => call(`/api/v1/comments/threads/${th.id}/visibility`, { method: 'PUT', json: { visibility }, ...who });
assert.strictEqual((await put('locked', { token: svc([WRITE]) })).status, 403);
assert.strictEqual((await put('locked', { token: svc([WRITE]) })).status, 403);
@@ -246,8 +246,8 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -246,8 +246,8 @@ const { checkCapability } = require('../server/identity/capabilities');
assert.strictEqual((await call(`/api/v1/comments/threads/${th.id}`, { cookie: samJwt })).status, 404);
assert.strictEqual((await call(`/api/v1/comments/threads/${th.id}`, { cookie: samJwt })).status, 404);
assert.strictEqual((await post(th.id, { message: 'hidden?' }, { cookie: samJwt })).status, 404);
assert.strictEqual((await post(th.id, { message: 'hidden?' }, { cookie: samJwt })).status, 404);
assert.strictEqual((await call(`/api/v1/comments/${c.id}`, { method: 'DELETE', cookie: samJwt })).status, 404);
assert.strictEqual((await call(`/api/v1/comments/${c.id}`, { method: 'DELETE', cookie: samJwt })).status, 404);
- const again = (await resolve({ service: 'live', type: 'clip', id: 'vis-1' })).json().thread;
- assert.deepStrictEqual(again, { id: th.id, access_id: th.id, ref: { service: 'live', type: 'clip', id: 'vis-1' }, visibility: 'hidden', comment_count: null });
+ const again = (await resolve({ service: 'live', type: 'channel', id: 'vis-1' })).json().thread;
+ assert.deepStrictEqual(again, { id: th.id, access_id: th.id, ref: { service: 'live', type: 'channel', id: 'vis-1' }, visibility: 'hidden', comment_count: null });
const mod = await call(`/api/v1/comments/threads/${th.id}`, { token: svc([MOD]) });
const mod = await call(`/api/v1/comments/threads/${th.id}`, { token: svc([MOD]) });
assert.strictEqual(mod.status, 200);
assert.strictEqual(mod.status, 200);
assert.strictEqual(mod.json().comments.length, 2);
assert.strictEqual(mod.json().comments.length, 2);
@@ -311,7 +311,7 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -311,7 +311,7 @@ const { checkCapability } = require('../server/identity/capabilities');
assert.strictEqual(byBrowser.json().comments[0].thread_id, th.access_id, 'no sequential id reaches a browser');
assert.strictEqual(byBrowser.json().comments[0].thread_id, th.access_id, 'no sequential id reaches a browser');
assert.ok(!byBrowser.text.includes(`"thread_id":${th.id}`));
assert.ok(!byBrowser.text.includes(`"thread_id":${th.id}`));
assert.strictEqual((await post(th.access_id, { message: 'with the handle' }, { cookie: samJwt })).status, 201);
assert.strictEqual((await post(th.access_id, { message: 'with the handle' }, { cookie: samJwt })).status, 201);
- const again = await resolve({ service: 'live', type: 'vod', id: '123' }, { cookie: samJwt });
+ const again = await resolve({ service: 'live', type: 'stream', id: '123' }, { cookie: samJwt });
assert.strictEqual(again.json().thread.id, vod.id, 'browser resolve hands out the access id');
assert.strictEqual(again.json().thread.id, vod.id, 'browser resolve hands out the access id');
assert.match(vod.id, /^cth_/);
assert.match(vod.id, /^cth_/);
const all = t.db.prepare('SELECT access_id FROM comment_threads').all().map((r) => r.access_id);
const all = t.db.prepare('SELECT access_id FROM comment_threads').all().map((r) => r.access_id);
@@ -355,6 +355,134 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -355,6 +355,134 @@ const { checkCapability } = require('../server/identity/capabilities');
assert.strictEqual(bearer.json().comment.author.subject, alex.subject_id);
assert.strictEqual(bearer.json().comment.author.subject, alex.subject_id);
});
});
+ // ── Live VODs and clips: Live's /api/comments is an adapter over these threads ──
+ let n2 = 0;
+ const ip2 = () => `198.19.${(n2 >> 8) & 255}.${(n2++ & 255)}`;
+ const callL = (p, o = {}) => call0(p, { ip: ip2(), ...o });
+ const svcLive = svc([WRITE, MOD]);
+ const asLive = (subject, extra = {}) => ({ token: svcLive, headers: { ...(subject ? { 'x-ov-subject': subject } : {}), ...extra } });
+ let liveVod;
+
+ await check('Live VODs and clips: only services open their threads (a private one is Live\'s call); no anonymous comments', async () => {
+ for (const type of ['vod', 'clip']) {
+ const ref = { service: 'live', type, id: '7001' };
+ for (const who of [{}, { cookie: alexJwt }, { cookie: adminJwt }]) {
+ const r = await callL('/api/v1/comments/threads/resolve', { method: 'POST', json: { ref }, ...who });
+ assert.strictEqual(r.status, 403, `browser resolve of live/${type}`);
+ assert.strictEqual(r.json().code, 'ref.type_not_allowed');
+ }
+ }
+ const r = await callL('/api/v1/comments/threads/resolve', { method: 'POST', json: { ref: { service: 'live', type: 'vod', id: '7001', label: 'My VOD' } }, token: svcLive });
+ assert.strictEqual(r.status, 201, r.text);
+ liveVod = r.json().thread;
+ assert.ok(Number.isInteger(liveVod.id));
+ assert.strictEqual(liveVod.ref.label, 'My VOD');
+ const anon = await callL(`/api/v1/comments/threads/${liveVod.access_id}/comments`, { method: 'POST', json: { message: 'drive-by', anon_name: 'Ghost' } });
+ assert.strictEqual(anon.status, 401, 'anonymous browser');
+ assert.strictEqual(anon.json().code, 'auth.required');
+ const anonSvc = await callL(`/api/v1/comments/threads/${liveVod.id}/comments`, { method: 'POST', json: { message: 'nobody' }, token: svc([WRITE]) });
+ assert.strictEqual(anonSvc.status, 401, 'a service naming nobody');
+ const person = await callL(`/api/v1/comments/threads/${liveVod.id}/comments`, { method: 'POST', json: { message: 'nice vod' }, ...asLive(alex.subject_id) });
+ assert.strictEqual(person.status, 201, person.text);
+ assert.strictEqual(person.json().comment.author.subject, alex.subject_id);
+ const byBrowser = await callL(`/api/v1/comments/threads/${liveVod.access_id}/comments`, { method: 'POST', json: { message: 'from the thread page' }, cookie: samJwt });
+ assert.strictEqual(byBrowser.status, 201, 'people with the access id may comment');
+ });
+
+ await check('one comment with its thread (GET /:commentId): services only, so comment ids cannot be walked to refs', async () => {
+ const c = (await callL(`/api/v1/comments/threads/${liveVod.id}/comments`, { method: 'POST', json: { message: 'look me up' }, ...asLive(sam.subject_id) })).json().comment;
+ const r = await callL(`/api/v1/comments/${c.id}`, asLive(sam.subject_id));
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().comment.message, 'look me up');
+ assert.strictEqual(r.json().comment.can_edit, true);
+ assert.deepStrictEqual(r.json().thread.ref, { service: 'live', type: 'vod', id: '7001', label: 'My VOD' });
+ assert.strictEqual(r.json().thread.id, liveVod.id);
+ for (const who of [{}, { cookie: samJwt }, { cookie: adminJwt }]) {
+ const b = await callL(`/api/v1/comments/${c.id}`, who);
+ assert.strictEqual(b.status, 404, 'browsers never get it');
+ }
+ assert.strictEqual((await callL(`/api/v1/comments/${c.id}`, { token: svc(['community.pulse.write']) })).status, 403);
+ assert.strictEqual((await callL('/api/v1/comments/99999999', asLive())).status, 404);
+ });
+
+ await check('edit (PATCH /:commentId): the author only; edited_at; empty/too long refused; deleted is 404', async () => {
+ const c = (await callL(`/api/v1/comments/threads/${liveVod.id}/comments`, { method: 'POST', json: { message: 'typo hre' }, ...asLive(alex.subject_id) })).json().comment;
+ assert.strictEqual(c.edited_at, null);
+ const patch = (json, who) => callL(`/api/v1/comments/${c.id}`, { method: 'PATCH', json, ...who });
+ assert.strictEqual((await patch({ message: 'not yours' }, asLive(sam.subject_id))).status, 403);
+ assert.strictEqual((await patch({ message: 'staff do not rewrite people' }, { cookie: adminJwt })).status, 403);
+ assert.strictEqual((await patch({ message: 'x' }, {})).status, 401);
+ assert.strictEqual((await patch({ message: ' ' }, asLive(alex.subject_id))).status, 400);
+ assert.strictEqual((await patch({ message: 'y'.repeat(5001) }, asLive(alex.subject_id))).status, 400);
+ const ok = await patch({ message: 'typo here' }, asLive(alex.subject_id));
+ assert.strictEqual(ok.status, 200, ok.text);
+ assert.strictEqual(ok.json().comment.message, 'typo here');
+ assert.ok(ok.json().comment.edited_at, 'edited_at set');
+ const byBrowser = await patch({ message: 'typo here!' }, { cookie: alexJwt });
+ assert.strictEqual(byBrowser.status, 200, 'the author from a browser too');
+ assert.strictEqual(t.db.prepare('SELECT message FROM comments WHERE id = ?').get(c.id).message, 'typo here!');
+ await callL(`/api/v1/comments/${c.id}`, { method: 'DELETE', ...asLive(alex.subject_id) });
+ assert.strictEqual((await patch({ message: 'back from the dead' }, asLive(alex.subject_id))).status, 404);
+ });
+
+ await check('the thread\'s own page /c/:accessId shows the same comments as the API; people comment there; hidden and guessed ids are 404', async () => {
+ const api = (await callL(`/api/v1/comments/threads/${liveVod.id}?sort=new&limit=100`, asLive())).json();
+ const page = await callL(`/c/${liveVod.access_id}`);
+ assert.strictEqual(page.status, 200, page.text.slice(0, 300));
+ assert.match(page.headers.get('content-type'), /text\/html/);
+ assert.match(page.text, /<meta name="robots" content="noindex,nofollow">/);
+ assert.match(page.text, /Comments on My VOD/);
+ assert.match(page.text, /href="https:\/\/openvibe\.live\/vod\/7001"/, 'links back to the VOD');
+ const live = api.comments.filter((c) => !c.deleted).map((c) => c.message);
+ assert.ok(live.length >= 3);
+ for (const m of live) assert.ok(page.text.includes(m), `page shows "${m}"`);
+ const order = live.map((m) => page.text.indexOf(m));
+ assert.deepStrictEqual(order, [...order].sort((a, b) => a - b), 'newest first, like the API with sort=new');
+ assert.match(page.text, /Sign in with your OpenVibe account/, 'anonymous visitors are asked to sign in');
+
+ const form = (body, who) => t.get(`/c/${liveVod.access_id}`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', 'x-forwarded-for': ip2(), ...(who.origin ? { origin: who.origin } : {}) }, body, cookies: who.cookie ? [`ov_token=${who.cookie}`] : [] });
+ const empty = await form('message=+', { cookie: samJwt, origin: 'https://openvibe.community' });
+ assert.strictEqual(empty.status, 400);
+ assert.match(empty.text, /class="alert alert-error"[^>]*>Comment cannot be empty/, 'the page again, with the error');
+ const made = await form('message=said+on+Community', { cookie: samJwt });
+ assert.strictEqual(made.status, 303, made.text.slice(0, 200));
+ assert.match(made.headers.get('location'), new RegExp(`^/c/${liveVod.access_id}#comment-\\d+$`));
+ const seen = (await callL(`/api/v1/comments/threads/${liveVod.id}?sort=new`, asLive())).json();
+ assert.strictEqual(seen.comments[0].message, 'said on Community', 'Live reads the comment made on the page');
+ assert.strictEqual(seen.comments[0].author.subject, sam.subject_id);
+ const anonForm = await form('message=who+am+i', {});
+ assert.strictEqual(anonForm.status, 303);
+ assert.match(anonForm.headers.get('location'), /^\/auth\/login/);
+ const foreign = await form('message=csrf', { cookie: samJwt, origin: 'https://evil.example' });
+ assert.strictEqual(foreign.status, 403);
+
+ assert.strictEqual((await callL(`/c/${liveVod.id}`)).status, 404, 'the sequential id opens nothing');
+ assert.strictEqual((await callL(`/c/cth_${'A'.repeat(22)}`)).status, 404);
+ await callL(`/api/v1/comments/threads/${liveVod.id}/visibility`, { method: 'PUT', json: { visibility: 'hidden' }, token: svcLive });
+ assert.strictEqual((await callL(`/c/${liveVod.access_id}`)).status, 404, 'hidden threads are missing');
+ await callL(`/api/v1/comments/threads/${liveVod.id}/visibility`, { method: 'PUT', json: { visibility: 'public' }, token: svcLive });
+ assert.strictEqual((await callL(`/c/${liveVod.access_id}`)).status, 200);
+ });
+
+ await check('comments.edited_at is added to databases made before it (idempotent)', async () => {
+ const fs = require('fs'), os = require('os'), path = require('path');
+ const Database = require('better-sqlite3');
+ const { openDb } = require('../server/db');
+ const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'ov-community-edit-')), 'old.db');
+ const old = new Database(file);
+ old.exec(`CREATE TABLE comments (id INTEGER PRIMARY KEY AUTOINCREMENT, thread_id INTEGER NOT NULL, parent_id INTEGER, author_subject TEXT, anon_name TEXT,
+ origin TEXT NOT NULL DEFAULT 'user', message TEXT NOT NULL, score INTEGER NOT NULL DEFAULT 0, upvotes INTEGER NOT NULL DEFAULT 0, downvotes INTEGER NOT NULL DEFAULT 0,
+ reply_count INTEGER NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME, deleted_by TEXT)`);
+ old.prepare("INSERT INTO comments (thread_id, message) VALUES (1, 'kept')").run();
+ old.close();
+ const db = openDb(file);
+ assert.ok(db.prepare('PRAGMA table_info(comments)').all().some((c) => c.name === 'edited_at'));
+ assert.strictEqual(db.prepare('SELECT message, edited_at FROM comments').get().message, 'kept');
+ db.close();
+ openDb(file).close();
+ fs.rmSync(path.dirname(file), { recursive: true, force: true });
+ });
+
await t.close();
await t.close();
done();
done();
})();
})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,184 @@
@@ -0,0 +1,184 @@
+'use strict';
+/**
+ * Live's own VOD/clip comments → Community threads (server/comments/live-import.js and
+ * scripts/import-live-comments.js): reconciliation (read = imported + held + excluded), authors
+ * from Live's links and the Network's identity map (unmapped and disagreeing ones held), replies
+ * nested one level, times and edits kept, the ledger making re-runs import nothing twice, held rows
+ * imported once their author maps, dry run writing nothing, and --apply refusing without a backup.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { spawnSync } = require('child_process');
+const Database = require('better-sqlite3');
+const { check, done } = require('./helpers/app');
+
+process.env.NODE_ENV = 'test';
+const { openDb } = require('../server/db');
+const { importLiveComments, formatReport } = require('../server/comments/live-import');
+const { createCommentService } = require('../server/comments/service');
+
+const A = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA'; // live user 1, linked on Live
+const B = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB'; // live user 2, Network map only
+const C = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC'; // live user 4 per Live's link…
+const D = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPD'; // …and per the Network: they disagree
+const E = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPE'; // live user 3, linked later
+
+const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-comments-'));
+const livePath = path.join(dir, 'live.db');
+const live = new Database(livePath);
+live.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT);
+CREATE TABLE linked_accounts (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, service TEXT, service_user_id TEXT, subject_id TEXT);
+CREATE TABLE comments (id INTEGER PRIMARY KEY AUTOINCREMENT, content_type TEXT NOT NULL CHECK(content_type IN ('vod', 'clip')), content_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL, parent_id INTEGER, message TEXT NOT NULL, is_deleted INTEGER DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
+live.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (1, 'network', '101', ?)").run(A);
+live.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (4, 'network', '104', ?)").run(C);
+live.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (2, 'twitch', 'x', NULL)").run();
+const add = (id, type, cid, uid, parent, message, extra = {}) => live.prepare(`INSERT INTO comments (id, content_type, content_id, user_id, parent_id, message, is_deleted, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, type, cid, uid, parent, message, extra.deleted ? 1 : 0, extra.created || `2026-08-0${Math.min(id, 9)} 10:00:00`, extra.updated || extra.created || `2026-08-0${Math.min(id, 9)} 10:00:00`);
+add(1, 'vod', 10, 1, null, 'first');
+add(2, 'vod', 10, 2, 1, 'a reply');
+add(3, 'vod', 10, 1, 2, 'reply to the reply');
+add(4, 'vod', 10, 3, null, 'by someone unmapped');
+add(5, 'vod', 10, 1, 4, 'under the held one');
+add(6, 'clip', 20, 4, null, 'two answers for this author');
+add(7, 'clip', 20, 1, null, 'deleted on live', { deleted: true });
+add(8, 'clip', 20, 2, 7, 'reply under a deleted comment');
+add(9, 'clip', 20, 1, null, 'edited once', { created: '2026-08-09 10:00:00', updated: '2026-08-09 11:30:00' });
+add(10, 'vod', 11, 1, 99, 'parent missing');
+add(11, 'vod', 10, 2, 9, 'parent on other content');
+add(12, 'vod', 12, 1, null, ' ');
+live.close();
+
+const networkMap = { 2: B, 4: D };
+const calls = [];
+const resolveLegacy = async (system, ids) => {
+ calls.push({ system, ids });
+ return new Map(ids.map((id) => [id, networkMap[id] ? { subject: { type: 'user', id: networkMap[id] } } : null]));
+};
+
+(async () => {
+ const db = openDb(':memory:');
+ const liveRo = new Database(livePath, { readonly: true });
+ const count = (sql, ...a) => db.prepare(sql).get(...a).n;
+ const threadOf = (type, id) => db.prepare("SELECT * FROM comment_threads WHERE ref_service = 'live' AND ref_type = ? AND ref_id = ?").get(type, String(id));
+
+ await check('dry run (the default): the full report, nothing written', async () => {
+ const r = await importLiveComments(db, liveRo, { resolveLegacy });
+ assert.strictEqual(r.dry_run, true);
+ assert.deepStrictEqual([r.read, r.imported, r.held, r.excluded], [12, 4, 3, 5]);
+ assert.strictEqual(r.reconciled, true);
+ assert.deepStrictEqual(r.excluded_by_reason, { deleted: 1, parent_deleted: 1, orphan: 2, empty: 1 });
+ assert.deepStrictEqual(r.held_rows.map((h) => [h.live_id, h.reason]), [[4, 'unmapped_author'], [6, 'ambiguous_author'], [5, 'parent_held']]);
+ assert.deepStrictEqual(calls[0], { system: 'live', ids: ['1', '2', '3', '4'] }, 'only authors of importable rows are looked up');
+ for (const table of ['comments', 'comment_threads', 'legacy_id_map', 'import_hold', 'migration_runs']) {
+ assert.strictEqual(count(`SELECT COUNT(*) AS n FROM ${table}`), 0, `${table} untouched`);
+ }
+ const text = formatReport(r);
+ assert.match(text, /DRY RUN/);
+ assert.match(text, /read 12 = imported 4 \+ held 3 \+ excluded 5 → OK/);
+ assert.match(text, /live comment 4 {2}unmapped_author {2}on vod\/10 {2}live user 3/);
+ });
+
+ await check('apply: threads per VOD/clip, authors as subjects, one level of nesting, times and edits kept, counters, holds, a run record', async () => {
+ const r = await importLiveComments(db, liveRo, { resolveLegacy, dryRun: false, source: 'live.db' });
+ assert.deepStrictEqual([r.read, r.imported_new, r.imported_before, r.held, r.excluded], [12, 4, 0, 3, 5]);
+ const vod = threadOf('vod', 10), clip = threadOf('clip', 20);
+ assert.ok(vod && clip && !threadOf('vod', 11) && !threadOf('vod', 12), 'threads only where something was imported');
+ assert.strictEqual(r.threads.length, 2);
+ assert.strictEqual(vod.comment_count, 3);
+ assert.strictEqual(clip.comment_count, 1);
+ const idOf = (liveId) => Number(db.prepare("SELECT target_id FROM legacy_id_map WHERE source_system = 'live' AND source_type = 'comment' AND source_id = ?").get(String(liveId)).target_id);
+ const row = (liveId) => db.prepare('SELECT * FROM comments WHERE id = ?').get(idOf(liveId));
+ assert.deepStrictEqual([row(1).author_subject, row(1).parent_id, row(1).message, row(1).created_at, row(1).edited_at], [A, null, 'first', '2026-08-01 10:00:00', null]);
+ assert.deepStrictEqual([row(2).author_subject, row(2).parent_id], [B, idOf(1)], 'a Network-only author');
+ assert.strictEqual(row(3).parent_id, idOf(1), 'a reply to a reply joins the top-level comment');
+ assert.strictEqual(row(1).reply_count, 2);
+ assert.deepStrictEqual([row(9).created_at, row(9).edited_at], ['2026-08-09 10:00:00', '2026-08-09 11:30:00']);
+ assert.strictEqual(row(9).origin, 'user');
+ const holds = db.prepare("SELECT source_id, reason FROM import_hold WHERE source_type = 'live_comment' ORDER BY source_id").all();
+ assert.deepStrictEqual(holds.map((h) => [h.source_id, h.reason]), [['4', 'unmapped_author'], ['5', 'parent_held'], ['6', 'ambiguous_author']]);
+ assert.strictEqual(count("SELECT COUNT(*) AS n FROM migration_runs WHERE source = 'live-comments:live.db'"), 1);
+
+ // What Live's adapter reads back: newest first, replies nested, edits marked.
+ const svc = createCommentService({ db });
+ const page = await svc.get({ kind: 'service', service: 'svc:live', claims: { cap: ['community.comment.write'] }, subject: null }, vod.id, { sort: 'new' });
+ assert.deepStrictEqual(page.comments.map((c) => c.message), ['first']);
+ assert.deepStrictEqual(page.comments[0].replies.map((c) => c.message), ['a reply', 'reply to the reply']);
+ const clipPage = await svc.get({ kind: 'service', service: 'svc:live', claims: { cap: ['community.comment.write'] }, subject: null }, clip.id, {});
+ assert.strictEqual(clipPage.comments[0].edited_at, '2026-08-09T11:30:00.000Z');
+ });
+
+ await check('the ledger: a second run imports nothing twice', async () => {
+ const before = count('SELECT COUNT(*) AS n FROM comments');
+ const r = await importLiveComments(db, liveRo, { resolveLegacy, dryRun: false });
+ assert.deepStrictEqual([r.imported_new, r.imported_before, r.held, r.excluded, r.reconciled], [0, 4, 3, 5, true]);
+ assert.strictEqual(count('SELECT COUNT(*) AS n FROM comments'), before);
+ assert.strictEqual(threadOf('vod', 10).comment_count, 3);
+ });
+
+ await check('held rows import once their author maps; their holds are cleared; replies follow', async () => {
+ const rw = new Database(livePath);
+ rw.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (3, 'network', '103', ?)").run(E);
+ rw.close();
+ const r = await importLiveComments(db, liveRo, { resolveLegacy, dryRun: false });
+ assert.deepStrictEqual([r.imported_new, r.imported_before, r.held, r.excluded], [2, 4, 1, 5]);
+ assert.deepStrictEqual(db.prepare("SELECT source_id, reason FROM import_hold WHERE source_type = 'live_comment'").all(), [{ source_id: '6', reason: 'ambiguous_author' }]);
+ assert.strictEqual(threadOf('vod', 10).comment_count, 5);
+ const liveIdOf = (communityId) => db.prepare("SELECT source_id FROM legacy_id_map WHERE source_system = 'live' AND source_type = 'comment' AND target_id = ?").get(String(communityId)).source_id;
+ const reply = db.prepare("SELECT * FROM comments WHERE message = 'under the held one'").get();
+ assert.strictEqual(liveIdOf(reply.parent_id), '4');
+ });
+
+ await check('Live links only (no Network call): the Network-only author is held', async () => {
+ const fresh = openDb(':memory:');
+ const r = await importLiveComments(fresh, liveRo, { resolveLegacy: null });
+ assert.strictEqual(r.reconciled, true);
+ assert.ok(r.held_rows.some((h) => h.live_id === 2 && h.reason === 'unmapped_author'));
+ assert.ok(!r.held_rows.some((h) => h.live_id === 3), 'a reply to it joins the top-level comment, which is not held');
+ assert.ok(!r.held_rows.some((h) => h.live_id === 6), 'with one source there is nothing to disagree');
+ fresh.close();
+ });
+
+ await check('the script: dry run by default; --apply refused without --backup; the backup is taken and checked; re-runs are no-ops', async () => {
+ const communityPath = path.join(dir, 'community.db');
+ openDb(communityPath).close();
+ const run = (...args) => spawnSync(process.execPath, [path.join(__dirname, '..', 'scripts', 'import-live-comments.js'), ...args], {
+ cwd: path.join(__dirname, '..'), encoding: 'utf8', env: { ...process.env, COMMUNITY_DB_PATH: communityPath, OV_OAUTH_CLIENT_SECRET: '' },
+ });
+ const base = ['--live-db', livePath, '--community-db', communityPath, '--live-links-only'];
+ const dry = run(...base);
+ assert.strictEqual(dry.status, 0, dry.stderr);
+ assert.match(dry.stdout, /DRY RUN/);
+ assert.match(dry.stdout, /Nothing was written/);
+ const peek = () => { const d = new Database(communityPath, { readonly: true }); const n = d.prepare('SELECT COUNT(*) AS n FROM comments').get().n; d.close(); return n; };
+ assert.strictEqual(peek(), 0);
+ const noBackup = run(...base, '--apply');
+ assert.strictEqual(noBackup.status, 2);
+ assert.match(noBackup.stderr, /--apply needs --backup/);
+ assert.strictEqual(peek(), 0);
+ const backup = path.join(dir, 'community-before.db');
+ const applied = run(...base, '--apply', '--backup', backup);
+ assert.strictEqual(applied.status, 0, applied.stderr);
+ assert.match(applied.stdout, /Backup: .*community-before\.db \(integrity ok; 0 comments in 0 threads\)/);
+ assert.match(applied.stdout, /APPLIED/);
+ assert.ok(fs.existsSync(backup));
+ const imported = peek();
+ assert.ok(imported > 0);
+ const again = run(...base, '--apply', '--backup', backup);
+ assert.strictEqual(again.status, 2, 'an existing backup file is never overwritten');
+ const again2 = run(...base, '--apply', '--backup', path.join(dir, 'community-before-2.db'));
+ assert.strictEqual(again2.status, 0, again2.stderr);
+ assert.match(again2.stdout, /new 0, already imported/);
+ assert.strictEqual(peek(), imported);
+ const missing = run('--live-db', path.join(dir, 'nope.db'), '--community-db', communityPath);
+ assert.strictEqual(missing.status, 2);
+ });
+
+ liveRo.close();
+ db.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ done();
+})();