openvibers/openvibe.community Commits

Community: threads are addressed by access id only, services included (C-14 step 3) (#64)

OP OpenVibers committed 11 Oct 2026 · aaf5847309 · 3 files, +9 −13

This closes compatibility register C-14, step 3 of 3: a comment thread is addressed by its access id only, services included.

**How we got here (all on 2026-10-11):**
1. **Live#96:** Live addresses threads by access id and accepts resolves with no numeric id.
2. **#63:** services are handed the access id.
3. **Backfill:** every stored numeric id became its thread's access id, mapped through `comment_threads`:
   - Blog 49, Wiki 10 and Deals 758 rows; News and Reviews had none.
   - 0 numeric ids are left, and each table was backed up first (`/var/backups/openvibe/c14-thread-ids-2026-10-11`).
   - A Deals offer page renders its thread through the access id.

**Callers checked:** every repository that calls `comments/threads` or `openvibe-publishing/discussion`: Blog, Deals, News, Reviews, Wiki, plus Live's `comments-client`.

**Change:** `threadById` takes an access id only. A sequential id answers 404 for everyone, as it already did for browsers. Internal lookups by foreign key (a comment's own thread, forum crossposts) are unchanged.

**Tests:** `comments.test.js` and `security-private.test.js` assert that a sequential id opens nothing for a service either.

`ov test`: the covering files pass.

Changes

ddiff --git a/server/comments/service.js b/server/comments/service.js
iindex 769bc82..44c72f2 100644
---- a/server/comments/service.js
++++ b/server/comments/service.js
@@@ -13,10 +13,9 @@
* Community pastes the viewer may not see (pastes/service.js's rule: a private paste is its
* owner's and staff's only, a deleted or burned one nobody's — 404, checked on every call, so a
* paste made private later takes its thread with it). Everyone is handed a thread's unguessable
-- * access_id (cth_…, what resolve answers as `id`), services too since compatibility register
-- * C-14. Browsers address a thread only by it, so nobody can walk thread ids and learn the refs
-- * (unlisted paste slugs, private pages) and comments of entities they were not given; services
-- * may still send the sequential id they stored before C-14 until those rows are backfilled.
++ * access_id (cth_…, what resolve answers as `id`) and addresses the thread only by it, services
++ * included (compatibility register C-14), 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).
* Threads of the types in SIGNED_IN_ONLY take no anonymous comments (the owner product's rule).
@@@ -124,13 +123,10 @@ function createCommentService({ db, network = null, limits = {} } = {}) {
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; a service may still send the sequential id
-- * it stored before C-14 (refused once the callers' rows are backfilled, C-14 step 3). */
++ /** A thread by its access id, the only id anyone addresses a thread by (C-14). */
async function threadById(v, id) {
const s = String(id == null ? '' : id);
-- if (ACCESS_ID_RE.test(s)) return await store.getThreadByAccessId(db, s);
-- if (v && v.kind === 'service' && /^\d{1,15}$/.test(s)) return await store.getThread(db, Number(s));
-- return null;
++ return ACCESS_ID_RE.test(s) ? await store.getThreadByAccessId(db, s) : null;
}
/** The thread, or 404 — hidden threads look missing to everyone but moderators. */
ddiff --git a/test/comments.test.js b/test/comments.test.js
iindex 34f9c74..862df1f 100644
---- a/test/comments.test.js
++++ b/test/comments.test.js
@@@ -286,9 +286,9 @@ const { checkCapability } = require('../server/identity/capabilities');
const bySvc = await call(`/api/v1/comments/threads/${th.id}`, { token: svcW });
assert.strictEqual(bySvc.status, 200, 'services read by access id');
assert.strictEqual(bySvc.json().comments[0].thread_id, th.access_id, 'and comments name the thread by it');
-- // Until the callers' stored rows are backfilled (C-14 step 3), a service may still send the sequential id.
++ // The sequential id opens nothing, for a service either (C-14).
const seq = (await t.db.prepare('SELECT id FROM comment_threads WHERE access_id = ?').get(th.access_id)).id;
-- assert.strictEqual((await call(`/api/v1/comments/threads/${seq}`, { token: svcW })).status, 200, 'a stored sequential id still reads');
++ assert.strictEqual((await call(`/api/v1/comments/threads/${seq}`, { token: svcW })).status, 404, 'a sequential id opens nothing, for a service either');
// Browsers walking ids 1..N find nothing, signed in or not, staff included.
const max = (await t.db.prepare('SELECT MAX(id) AS m FROM comment_threads').get()).m;
ddiff --git a/test/security-private.test.js b/test/security-private.test.js
iindex 8124440..69a697a 100644
---- a/test/security-private.test.js
++++ b/test/security-private.test.js
@@@ -104,7 +104,7 @@ const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR
ok(await send('PUT', `/api/v1/comments/threads/${hidden.access_id}/visibility`, { who: boss, json: { visibility: 'hidden' } }), 'hide the thread');
const vod = ok(await send('POST', '/api/v1/comments/threads/resolve', { ...liveSvc, json: { ref: { service: 'live', type: 'vod', id: '502' } } }), 'vod thread').thread;
const vodComment = ok(await send('POST', `/api/v1/comments/threads/${vod.id}/comments`, { ...liveSvc, headers: { 'x-ov-subject': alex.subject_id }, json: { message: M.VODCOMMENT } }), 'vod comment').comment;
-- // The sequential ids, which services stored before C-14 (they are handed the access id now): never a browser's way in.
++ // The sequential ids: nobody's way in (C-14), not a browser's and not a service's.
const seqOf = async (accessId) => (await t.db.prepare('SELECT id FROM comment_threads WHERE access_id = ?').get(accessId)).id;
const vodSeq = await seqOf(vod.access_id);
const hiddenSeq = await seqOf(hidden.access_id);
@@@ -204,7 +204,7 @@ const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR
}
assert.ok((await send('GET', `/api/v1/comments/threads/${hidden.access_id}`, { who: boss })).text.includes(M.HIDDENCOMMENT), 'moderators read hidden threads');
assert.ok((await send('GET', `/api/v1/comments/threads/${vod.id}`, liveSvc)).text.includes(M.VODCOMMENT), 'Live reads by the access id it is handed');
-- assert.ok((await send('GET', `/api/v1/comments/threads/${vodSeq}`, liveSvc)).text.includes(M.VODCOMMENT), 'and by the sequential id it stored before C-14');
++ assert.strictEqual((await send('GET', `/api/v1/comments/threads/${vodSeq}`, liveSvc)).status, 404, 'the sequential id opens nothing, for Live either (C-14)');
assert.ok((await send('GET', `/api/v1/comments/threads/${vod.access_id}`, { who: sam })).text.includes(M.VODCOMMENT), 'whoever Live gave the access id reads it');
});