openvibers/openvibe.community Commits

Comment threads cannot be enumerated: every thread gets an unguessable access id (cth_ + 128 random bits, filled in for existing threads on boot, unique index); browsers address threads only by it and get it as the thread id from resolve, while the sequential id keeps working for services (Blog, Wiki, Deals, Reviews resolve by EntityRef with a service token and store it), so walking ids no longer reveals the refs and comments of unlisted or private entities

OP OpenVibers committed 23 Sept 2026 · faa08c2441 · 6 files, +151 −23


Changes

ddiff --git a/README.md b/README.md
iindex 1e52383..c6e21ab 100644
---- a/README.md
++++ b/README.md
@@@ -168,6 +168,11 @@ Who may do what:
`X-OV-Origin: ai` (stored with origin `ai` and no author, shown as "OpenVibe AI").
`community.comment.moderate` moderates — as itself (no `X-OV-Subject`), or for a person it
vouches is staff with `X-OV-Staff: 1`.
++- **Thread ids**: every thread has an unguessable access id (`cth_` + 22 characters). Browsers
++ get it as the thread's `id` (from resolve) and can address a thread only by it; the sequential
++ id works for services only (their resolve answer carries it as `id`, plus `access_id` to hand to
++ a browser). Nobody can walk thread ids to find the refs and comments of entities they were not
++ given.
- **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.
ddiff --git a/server/comments/api.js b/server/comments/api.js
iindex bf71fe2..33b2527 100644
---- a/server/comments/api.js
++++ b/server/comments/api.js
@@@ -5,6 +5,7 @@
*
* POST /threads/resolve { ref: EntityRef } → get-or-create (201 created, 200 existing)
* GET /threads/:id thread + first page (?after=<id>&sort=old|new&limit=, ?parent=<id>)
++ * :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
* DELETE /:commentId author or moderator
ddiff --git a/server/comments/service.js b/server/comments/service.js
iindex 8eeeb45..667a715 100644
---- a/server/comments/service.js
++++ b/server/comments/service.js
@@@ -9,7 +9,10 @@
* service's capability for the route):
* - resolve: services with community.comment.write may resolve any ref; browsers (signed in or
* not) only refs of the types in BROWSER_REF_TYPES. Community's own refs must exist.
-- * - read: anyone, except hidden threads (moderators only; everyone else gets 404).
++ * - read: anyone, except hidden threads (moderators only; everyone else gets 404). Browsers
++ * address a thread only by its unguessable access_id (cth_…, what resolve hands them as
++ * `id`); the sequential id works for services only, so nobody can walk thread ids and learn
++ * 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).
* - delete: the comment's author, or a moderator.
@@@ -42,6 +45,7 @@ const BROWSER_REF_TYPES = {
};
const VISIBILITIES = ['public', 'hidden', 'locked'];
const MAX_MESSAGE = 5000;
++const ACCESS_ID_RE = /^cth_[A-Za-z0-9_-]{22}$/;
const PAGE = 30;
const REPLY_PAGE = 20;
@@@ -56,17 +60,21 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
return ref;
}
++ /** The id a viewer addresses the thread by: services keep the sequential id they store; browsers get the access id. */
++ const threadIdFor = (t, v) => (v && v.kind === 'service' ? t.id : t.access_id);
++
function shapeThread(t, v) {
-- if (t.visibility === 'hidden' && !moderator(v)) return { id: t.id, ref: refOf(t), visibility: 'hidden', comment_count: null };
-- return { id: t.id, ref: refOf(t), visibility: t.visibility, comment_count: t.comment_count, created_at: isoTime(t.created_at), updated_at: isoTime(t.updated_at) };
++ const id = threadIdFor(t, v);
++ if (t.visibility === 'hidden' && !moderator(v)) return { id, access_id: t.access_id, ref: refOf(t), visibility: 'hidden', comment_count: null };
++ return { id, access_id: t.access_id, ref: refOf(t), visibility: t.visibility, comment_count: t.comment_count, created_at: isoTime(t.created_at), updated_at: isoTime(t.updated_at) };
}
-- function shapeComment(c, v, projections, votes) {
++ function shapeComment(c, v, projections, votes, threadId) {
const deleted = !!c.deleted_at;
const a = deleted ? null : authors.author(c.author_subject, c.origin, projections);
const out = {
id: c.id,
-- thread_id: c.thread_id,
++ thread_id: threadId,
parent_id: c.parent_id || null,
origin: c.origin,
author: a,
@@@ -81,29 +89,37 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
updated_at: isoTime(c.updated_at),
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));
++ if (c.replies) out.replies = c.replies.map((r) => shapeComment(r, v, projections, votes, threadId));
return out;
}
-- async function shapeComments(rows, v) {
++ async function shapeComments(rows, v, t) {
const all = [];
for (const r of rows) { all.push(r); if (r.replies) all.push(...r.replies); }
const projections = await authors.projectionsFor(all.map((c) => c.author_subject));
const votes = myVotes(db, 'comment', all.map((c) => c.id), v && v.subject);
-- return rows.map((r) => shapeComment(r, v, projections, votes));
++ return rows.map((r) => shapeComment(r, v, projections, votes, threadIdFor(t, v)));
++ }
++
++ /** A thread by the id this viewer may use: the access id for anyone, the sequential id for services only. */
++ function threadById(v, id) {
++ const s = String(id == null ? '' : id);
++ if (ACCESS_ID_RE.test(s)) return store.getThreadByAccessId(db, s);
++ if (v && v.kind === 'service' && /^\d{1,15}$/.test(s)) return store.getThread(db, Number(s));
++ return null;
}
/** The thread, or 404 — hidden threads look missing to everyone but moderators. */
-- function visibleThread(v, id) {
-- const t = /^\d{1,15}$/.test(String(id)) ? store.getThread(db, Number(id)) : null;
++ function visible(v, t) {
if (!t || (t.visibility === 'hidden' && !moderator(v))) fail(404, 'thread.not_found', 'Comment thread not found');
return t;
}
++ const visibleThread = (v, id) => visible(v, threadById(v, id));
function visibleComment(v, id) {
const c = /^\d{1,15}$/.test(String(id)) ? store.getComment(db, Number(id)) : null;
if (!c || c.deleted_at) fail(404, 'comment.not_found', 'Comment not found');
-- return { comment: c, thread: visibleThread(v, c.thread_id) };
++ return { comment: c, thread: visible(v, store.getThread(db, c.thread_id)) };
}
/** Community's own entities must exist (and be visible) before anyone opens a thread on them. */
@@@ -163,7 +179,7 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
} else {
({ rows, hasMore } = store.listTopLevel(db, t.id, { after, sort: q.sort === 'new' ? 'new' : 'old', limit, replyLimit: REPLY_PAGE }));
}
-- const comments = await shapeComments(rows, v);
++ const comments = await shapeComments(rows, v, t);
return {
thread: shapeThread(t, v),
comments,
@@@ -203,7 +219,7 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
commentLimiter.check(key, message);
const c = store.insertComment(db, { thread_id: t.id, parent_id: parentId, author_subject: author, anon_name: anonName, origin, message });
commentLimiter.record(key, message);
-- return { comment: (await shapeComments([c], v))[0] };
++ return { comment: (await shapeComments([c], v, t))[0] };
},
/** DELETE /comments/:id — the author or a moderator. */
@@@ -234,7 +250,7 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators change a thread\'s visibility');
const visibility = body.visibility;
if (!VISIBILITIES.includes(visibility)) fail(400, 'thread.invalid_visibility', `visibility must be one of ${VISIBILITIES.join(', ')}`);
-- const t = /^\d{1,15}$/.test(String(id)) ? store.getThread(db, Number(id)) : null;
++ const t = threadById(v, id);
if (!t) fail(404, 'thread.not_found', 'Comment thread not found');
return { thread: shapeThread(store.setThreadVisibility(db, t.id, visibility), v) };
},
ddiff --git a/server/comments/store.js b/server/comments/store.js
iindex 1ac124b..d733b9c 100644
---- a/server/comments/store.js
++++ b/server/comments/store.js
@@@ -9,11 +9,17 @@
* soft (message scrubbed); a deleted top-level comment that still has replies stays as a
* tombstone so the replies keep their context.
*/
++const { newThreadAccessId } = require('../db');
function getThread(db, id) {
return db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(id) || null;
}
++/** A thread by the unguessable handle browsers use (server/db.js: access_id). */
++function getThreadByAccessId(db, accessId) {
++ return db.prepare('SELECT * FROM comment_threads WHERE access_id = ?').get(String(accessId)) || null;
++}
++
function getThreadByRef(db, ref) {
return db.prepare('SELECT * FROM comment_threads WHERE ref_service = ? AND ref_type = ? AND ref_id = ?')
.get(ref.service, ref.type, String(ref.id)) || null;
@@@ -26,9 +32,9 @@ function getThreadByRef(db, ref) {
*/
function resolveThread(db, ref, { label = null, createdBy = null } = {}) {
return db.transaction(() => {
-- const info = db.prepare(`INSERT INTO comment_threads (ref_service, ref_type, ref_id, ref_label, created_by) VALUES (?, ?, ?, ?, ?)
++ const info = db.prepare(`INSERT INTO comment_threads (ref_service, ref_type, ref_id, ref_label, created_by, access_id) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(ref_service, ref_type, ref_id) DO NOTHING`)
-- .run(ref.service, ref.type, String(ref.id), label, createdBy);
++ .run(ref.service, ref.type, String(ref.id), label, createdBy, newThreadAccessId());
if (!info.changes && label) {
db.prepare('UPDATE comment_threads SET ref_label = ?, updated_at = CURRENT_TIMESTAMP WHERE ref_service = ? AND ref_type = ? AND ref_id = ? AND COALESCE(ref_label, \'\') <> ?')
.run(label, ref.service, ref.type, String(ref.id), label);
@@@ -99,4 +105,4 @@ function listReplies(db, parentId, { after = null, limit = 50 } = {}) {
return { rows, hasMore };
}
--module.exports = { getThread, getThreadByRef, resolveThread, setThreadVisibility, getComment, insertComment, softDeleteComment, listTopLevel, listReplies };
++module.exports = { getThread, getThreadByAccessId, getThreadByRef, resolveThread, setThreadVisibility, getComment, insertComment, softDeleteComment, listTopLevel, listReplies };
ddiff --git a/server/db.js b/server/db.js
iindex 3912e39..5a53f83 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -11,6 +11,7 @@
*
* Schema creation is idempotent (CREATE … IF NOT EXISTS) and runs on open.
*/
++const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const Database = require('better-sqlite3');
@@@ -137,6 +138,7 @@ CREATE TABLE IF NOT EXISTS comment_threads (
created_by TEXT, -- subject or service principal that first resolved it
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
++ access_id TEXT, -- unguessable handle for browsers (migrate() fills old rows, unique index)
UNIQUE (ref_service, ref_type, ref_id)
);
@@@ -323,10 +325,33 @@ function openDb(file) {
db.pragma('foreign_keys = ON');
db.pragma('busy_timeout = 5000');
db.exec(SCHEMA);
++ migrate(db);
registerFunctions(db);
return db;
}
++/**
++ * Additive changes to tables that already exist (CREATE TABLE IF NOT EXISTS never alters one).
++ * Idempotent: every boot checks and fills in only what is missing.
++ *
++ * 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.
++ */
++function migrate(db) {
++ const cols = new Set(db.prepare('PRAGMA table_info(comment_threads)').all().map((c) => c.name));
++ if (!cols.has('access_id')) db.exec('ALTER TABLE comment_threads ADD COLUMN access_id TEXT');
++ const missing = db.prepare('SELECT id FROM comment_threads WHERE access_id IS NULL').all();
++ if (missing.length) {
++ const set = db.prepare('UPDATE comment_threads SET access_id = ? WHERE 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)');
++}
++
++/** cth_ + 22 base64url characters (16 random bytes). */
++function newThreadAccessId() { return `cth_${crypto.randomBytes(16).toString('base64url')}`; }
++
let _shared = null;
/** The process-wide database at COMMUNITY_DB_PATH (opened on first use). */
function getDb() {
@@@ -334,4 +359,4 @@ function getDb() {
return _shared;
}
--module.exports = { openDb, getDb, registerFunctions, SCHEMA };
++module.exports = { openDb, getDb, registerFunctions, SCHEMA, newThreadAccessId };
ddiff --git a/test/comments.test.js b/test/comments.test.js
iindex 1c24c6c..90d9997 100644
---- a/test/comments.test.js
++++ b/test/comments.test.js
@@@ -21,7 +21,7 @@ const { checkCapability } = require('../server/identity/capabilities');
const svc = (cap, extra = {}) => net.signService({ cap, ...extra });
const WRITE = 'community.comment.write', MOD = 'community.comment.moderate';
-- const call = (path, { method = 'GET', token, cookie, headers = {}, json, ip } = {}) => {
++ const call0 = (path, { method = 'GET', token, cookie, headers = {}, json, ip } = {}) => {
const h = { ...headers };
if (token) h.authorization = `Bearer ${token}`;
if (ip) h['x-forwarded-for'] = ip;
@@@ -29,6 +29,7 @@ const { checkCapability } = require('../server/identity/capabilities');
if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }
return t.get(path, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });
};
++ const call = call0;
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 });
@@@ -108,7 +109,7 @@ const { checkCapability } = require('../server/identity/capabilities');
assert.strictEqual(c.anon_name, null);
assert.strictEqual(c.origin, 'user');
assert.strictEqual(c.can_delete, true);
-- assert.strictEqual(t.db.prepare('SELECT comment_count FROM comment_threads WHERE id = ?').get(vod.id).comment_count, 1);
++ assert.strictEqual(t.db.prepare('SELECT comment_count FROM comment_threads WHERE access_id = ?').get(vod.id).comment_count, 1);
assert.strictEqual((await post(vod.id, { message: ' ' }, { cookie: alexJwt })).status, 400);
assert.strictEqual((await post(vod.id, { message: 'x'.repeat(5001) }, { cookie: alexJwt })).status, 400);
assert.strictEqual((await post(99999, { message: 'hi' }, { cookie: alexJwt })).status, 404);
@@@ -199,13 +200,13 @@ const { checkCapability } = require('../server/identity/capabilities');
await check('delete: author or staff; others 403; tombstone keeps replies; counts follow', async () => {
const top = (await post(vod.id, { message: 'to be deleted' }, { cookie: alexJwt })).json().comment;
await post(vod.id, { message: 'a reply that stays', parent_id: top.id }, { cookie: samJwt });
-- const before = t.db.prepare('SELECT comment_count FROM comment_threads WHERE id = ?').get(vod.id).comment_count;
++ const before = t.db.prepare('SELECT comment_count FROM comment_threads WHERE access_id = ?').get(vod.id).comment_count;
assert.strictEqual((await call(`/api/v1/comments/${top.id}`, { method: 'DELETE', cookie: samJwt })).status, 403);
assert.strictEqual((await call(`/api/v1/comments/${top.id}`, { method: 'DELETE' })).status, 401);
assert.strictEqual((await call(`/api/v1/comments/${top.id}`, { method: 'DELETE', token: svc([WRITE]), headers: { 'x-ov-subject': sam.subject_id } })).status, 403);
const ok = await call(`/api/v1/comments/${top.id}`, { method: 'DELETE', cookie: alexJwt });
assert.strictEqual(ok.status, 200, ok.text);
-- assert.strictEqual(t.db.prepare('SELECT comment_count FROM comment_threads WHERE id = ?').get(vod.id).comment_count, before - 1);
++ assert.strictEqual(t.db.prepare('SELECT comment_count FROM comment_threads WHERE access_id = ?').get(vod.id).comment_count, before - 1);
assert.strictEqual(t.db.prepare('SELECT message FROM comments WHERE id = ?').get(top.id).message, '', 'scrubbed');
const page = (await call(`/api/v1/comments/threads/${vod.id}?limit=100`)).json();
const tomb = page.comments.find((c) => c.id === top.id);
@@@ -246,7 +247,7 @@ const { checkCapability } = require('../server/identity/capabilities');
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, ref: { service: 'live', type: 'clip', id: 'vis-1' }, visibility: 'hidden', comment_count: null });
++ assert.deepStrictEqual(again, { id: th.id, access_id: th.id, ref: { service: 'live', type: 'clip', 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);
@@@ -267,6 +268,80 @@ const { checkCapability } = require('../server/identity/capabilities');
assert.strictEqual((await call(`/api/v1/comments/threads/${vod.id}`, { token: svc(['community.pulse.write']) })).status, 403, 'reads need a comment capability');
});
++ await check('thread ids cannot be enumerated: browsers need the access id, sequential ids are for services', async () => {
++ // Every request from its own address: this test makes more calls than the /api/ read limiter allows one.
++ let n = 0;
++ const ipOf = () => `198.18.${(n >> 8) & 255}.${(n++ & 255)}`;
++ const call = (p, o = {}) => call0(p, { ip: ipOf(), ...o });
++ const post = (id, json, who = {}) => call(`/api/v1/comments/threads/${id}/comments`, { method: 'POST', json, ...who });
++ const resolve = (ref, who = {}) => call('/api/v1/comments/threads/resolve', { method: 'POST', json: { ref }, ...who });
++ // A service resolves (Blog, Wiki, Deals, Live): it gets the sequential id it stores, plus the access id.
++ const svcW = svc([WRITE]);
++ const made = await resolve({ service: 'wiki', type: 'page', id: 'pg_private_space_1' }, { token: svcW });
++ assert.strictEqual(made.status, 201, made.text);
++ const th = made.json().thread;
++ assert.ok(Number.isInteger(th.id), 'services keep the sequential id');
++ assert.match(th.access_id, /^cth_[A-Za-z0-9_-]{22}$/);
++ assert.strictEqual((await post(th.id, { message: 'said by a service' }, { token: svcW, headers: { 'x-ov-subject': alex.subject_id } })).status, 201);
++ const bySvc = await call(`/api/v1/comments/threads/${th.id}`, { token: svcW });
++ assert.strictEqual(bySvc.status, 200, 'services still read by sequential id');
++ assert.strictEqual(bySvc.json().comments[0].thread_id, th.id);
++ assert.strictEqual((await call(`/api/v1/comments/threads/${th.access_id}`, { token: svcW })).status, 200, 'and by access id');
++
++ // Browsers walking ids 1..N find nothing, signed in or not, staff included.
++ const max = t.db.prepare('SELECT MAX(id) AS m FROM comment_threads').get().m;
++ for (let id = 1; id <= max; id++) {
++ for (const who of [{}, { cookie: samJwt }, { token: alexJwt }, { cookie: adminJwt }]) {
++ const r = await call(`/api/v1/comments/threads/${id}`, who);
++ assert.strictEqual(r.status, 404, `GET /threads/${id} as ${JSON.stringify(Object.keys(who))}`);
++ assert.strictEqual(r.json().code, 'thread.not_found');
++ }
++ }
++ assert.strictEqual((await post(th.id, { message: 'guessing' }, { cookie: samJwt })).status, 404);
++ assert.strictEqual((await post(th.id, { message: 'guessing' })).status, 404);
++ assert.strictEqual((await call(`/api/v1/comments/threads/${th.id}/visibility`, { method: 'PUT', json: { visibility: 'locked' }, cookie: adminJwt })).status, 404);
++ for (const bad of ['cth_short', `${th.access_id}x`, `cth_${'A'.repeat(22)}`, '0x1', ' 1']) {
++ assert.strictEqual((await call(`/api/v1/comments/threads/${encodeURIComponent(bad)}`)).status, 404, bad);
++ }
++
++ // ...but a browser handed the access id (by resolve, or by the product's page) reads and writes as before.
++ const byBrowser = await call(`/api/v1/comments/threads/${th.access_id}`, { cookie: samJwt });
++ assert.strictEqual(byBrowser.status, 200, byBrowser.text);
++ assert.strictEqual(byBrowser.json().thread.id, th.access_id);
++ 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 });
++ 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);
++ assert.ok(all.every(Boolean) && new Set(all).size === all.length, 'every thread has its own');
++ });
++
++ await check('threads made before access ids get one on boot (migration is 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-mig-')), 'old.db');
++ const old = new Database(file);
++ old.exec(`CREATE TABLE comment_threads (id INTEGER PRIMARY KEY AUTOINCREMENT, ref_service TEXT NOT NULL, ref_type TEXT NOT NULL, ref_id TEXT NOT NULL, ref_label TEXT,
++ visibility TEXT NOT NULL DEFAULT 'public' CHECK(visibility IN ('public', 'hidden', 'locked')), comment_count INTEGER NOT NULL DEFAULT 0, created_by TEXT,
++ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE (ref_service, ref_type, ref_id))`);
++ for (let i = 0; i < 5; i++) old.prepare("INSERT INTO comment_threads (ref_service, ref_type, ref_id) VALUES ('live', 'vod', ?)").run(String(i));
++ old.close();
++ const db = openDb(file);
++ const rows = db.prepare('SELECT id, access_id FROM comment_threads').all();
++ assert.strictEqual(rows.length, 5);
++ for (const r of rows) assert.match(r.access_id, /^cth_[A-Za-z0-9_-]{22}$/);
++ assert.strictEqual(new Set(rows.map((r) => r.access_id)).size, 5);
++ db.close();
++ const db2 = openDb(file);
++ assert.deepStrictEqual(db2.prepare('SELECT id, access_id FROM comment_threads').all(), rows, 'a second boot changes nothing');
++ assert.throws(() => db2.prepare('UPDATE comment_threads SET access_id = ? WHERE id = ?').run(rows[0].access_id, rows[1].id), /UNIQUE/);
++ db2.close();
++ fs.rmSync(path.dirname(file), { recursive: true, force: true });
++ });
++
await check('CORS: OpenVibe origins get Bearer-only CORS headers; others none; preflight answers 204', async () => {
const pre = await call(`/api/v1/comments/threads/${vod.id}/comments`, { method: 'OPTIONS', headers: { origin: 'https://openvibe.live', 'access-control-request-method': 'POST' } });
assert.strictEqual(pre.status, 204);