openvibers/openvibe.community Commits

Live's VOD and clip comments become Community threads (Wave 5 exit: Live and a second product share one thread)

OP OpenVibers committed 23 Sept 2026 · 7d7cc3daa5 · 14 files, +969 −22

- Only services open live/vod and live/clip threads: a private VOD or clip is missing to everyone but its owners and staff, which only Live can decide, so browsers can no longer resolve those refs (live stream/channel stay browser-resolvable). These threads take no anonymous comments (Live's rule): 401 auth.required.
- GET /api/v1/comments/:commentId (services only: one comment with its thread and ref, so an owner product can check what it edits or deletes; browsers get 404 because comment ids are sequential) and PATCH /:commentId (the author only, sets edited_at; comments carry edited_at and can_edit). comments.edited_at is added to existing databases on boot.
- /c/:accessId: a comment thread's own page, the same thread the owner product embeds, newest first, ?after= for older, signed-in people comment with the no-JS form (same-origin check), only by the unguessable access id, noindex; hidden threads are 404.
- scripts/import-live-comments.js (server/comments/live-import.js): Live's old comments rows into their threads. Dry run by default (rolled back, exact report); --apply requires --backup <new file>, an online backup that is integrity-checked first; ledger in legacy_id_map (live/comment) so re-runs import nothing twice; authors from Live's linked_accounts.subject_id and the Network identity map, unmapped/ambiguous authors and replies under held comments held in import_hold and listed; deleted rows, replies under deleted ones, orphans and empty messages excluded; prints read = imported + held + excluded. Refuses a database that predates edited_at instead of migrating it from a dry run.
- README: the endpoints, the page, how to check that Live and Community show the same thread, and the exact production import commands.

Changes

ddiff --git a/README.md b/README.md
iindex e33e7cb..53c643a 100644
---- a/README.md
++++ b/README.md
@@@ -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` from
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
-- 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
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.
@@@ -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 the
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
const 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 templates
render/forum.js spaces / threads / thread / new-thread templates
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/highlight.js highlight.js wrapper, language list, download extensions
seo.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 vhost
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)
docs/capabilities-proposal/ Wave 5 capability manifests (released in openvibe-contracts v0.7.0)
```
ddiff --git a/public/css/community.css b/public/css/community.css
iindex 3129ba9..b1a7e08 100644
---- a/public/css/community.css
++++ b/public/css/community.css
@@@ -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; }
ddiff --git a/scripts/import-live-comments.js b/scripts/import-live-comments.js
nnew file mode 100755
iindex 0000000..bcb79eb
---- /dev/null
++++ b/scripts/import-live-comments.js
@@@ -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); });
ddiff --git a/server/app.js b/server/app.js
iindex 3fa65a4..81313fc 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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;
ddiff --git a/server/comments/api.js b/server/comments/api.js
iindex 33b2527..999b03e 100644
---- a/server/comments/api.js
++++ b/server/comments/api.js
@@@ -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, resolveLimiter
router.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 || {})));
ddiff --git a/server/comments/live-import.js b/server/comments/live-import.js
nnew file mode 100644
iindex 0000000..53a78da
---- /dev/null
++++ b/server/comments/live-import.js
@@@ -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 };
ddiff --git a/server/comments/routes.js b/server/comments/routes.js
nnew file mode 100644
iindex 0000000..7a1f370
---- /dev/null
++++ b/server/comments/routes.js
@@@ -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 };
ddiff --git a/server/comments/service.js b/server/comments/service.js
iindex 667a715..1da80ea 100644
---- a/server/comments/service.js
++++ b/server/comments/service.js
@@@ -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, limits
reply_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, limits
return {
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, limits
async 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, limits
const 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, limits
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. */
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 };
ddiff --git a/server/comments/store.js b/server/comments/store.js
iindex d733b9c..5bc6b7b 100644
---- a/server/comments/store.js
++++ b/server/comments/store.js
@@@ -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 };
ddiff --git a/server/db.js b/server/db.js
iindex 5a53f83..222bcfd 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -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). */
ddiff --git a/server/http/v1.js b/server/http/v1.js
iindex 824f334..9adb40c 100644
---- a/server/http/v1.js
++++ b/server/http/v1.js
@@@ -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');
}
ddiff --git a/server/render/comments.js b/server/render/comments.js
nnew file mode 100644
iindex 0000000..0fc7eb8
---- /dev/null
++++ b/server/render/comments.js
@@@ -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 };
ddiff --git a/test/comments.test.js b/test/comments.test.js
iindex 90d9997..e55e5a5 100644
---- a/test/comments.test.js
++++ b/test/comments.test.js
@@@ -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 below
await 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();
})();
ddiff --git a/test/import-live-comments.test.js b/test/import-live-comments.test.js
nnew file mode 100644
iindex 0000000..90b68bf
---- /dev/null
++++ b/test/import-live-comments.test.js
@@@ -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();
++})();