openvibers/openvibe.wiki Commits

A page's discussion thread is hidden when the page stops being public

OP OpenVibers committed 23 Sept 2026 · 98b4c5b627 · 5 files, +218 −4

Unpublishing, deleting or making a page (or its space) members-only or private left its Community
thread readable by anyone with the thread id. sync() now notes when a page stops or starts being
public (published + effectively public) and, after the write commits, sets the page's thread to
hidden or public with PUT /api/v1/comments/threads/:id/visibility (community.comment.moderate,
audience openvibe.community). Same rule as OpenVibe.Blog's effects.after. The call is best effort:
it runs after the outermost transaction returns, never inside it; a Community failure is logged
and never fails the write; a page that never had a thread calls nothing.

test/discussion-visibility.test.js covers unpublish/publish, page and space visibility, delete,
Community down, and that the call happens outside the transaction.

Changes

ddiff --git a/.env.example b/.env.example
iindex be1feb0..6c0cae6 100644
---- a/.env.example
++++ b/.env.example
@@@ -36,7 +36,8 @@ OV_OAUTH_CLIENT_SECRET=
#EVENTS_URL=http://127.0.0.1:4300
#EVENTS_RELAY_INTERVAL_MS=2000
--# OpenVibe.Community — discussion threads (community.comment.write).
++# OpenVibe.Community — discussion threads (community.comment.write; community.comment.moderate
++# hides the thread of a page that is unpublished, deleted or no longer public).
#OV_COMMUNITY_URL=https://openvibe.community
#OV_COMMUNITY_INTERNAL_URL=http://127.0.0.1:4200
ddiff --git a/server/index.js b/server/index.js
iindex e8ab258..fbed931 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -21,7 +21,7 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fe
const db = openDb(config.dbPath);
const stores = createStores(db, { now });
const platform = createPlatform({ config, db, fetchImpl, tokens, now, log });
-- const svc = createWikiService({ db, stores, outbox: platform.outbox, config, now, log });
++ const svc = createWikiService({ db, stores, outbox: platform.outbox, config, community: platform.community, now, log });
const reconciled = svc.reconcileIndex();
if (reconciled.sent) log.log(`[Wiki] re-sent ${reconciled.sent} Search document(s) whose indexability changed`);
const keys = createKeyStore({ config, fetchImpl, log, publicKey });
ddiff --git a/server/integrations/platform.js b/server/integrations/platform.js
iindex f129b3e..7cd034b 100644
---- a/server/integrations/platform.js
++++ b/server/integrations/platform.js
@@@ -3,6 +3,7 @@
* Clients for the services Wiki composes (roadmap §29): Events (outbox relay), Community
* (discussion threads), Sources (citation items) and Media (attachment checks). Each call uses a
* Network client-credentials token for svc:wiki, minted per audience by the SDK token client.
++ * Hiding the thread of a page that stopped being public needs community.comment.moderate.
*
* Every integration is optional. Missing configuration or an unreachable service is an explicit
* failure (an error with a stable code, or `configured: false`), never invented data.
@@@ -92,6 +93,30 @@ function createPlatform({ config, db, fetchImpl = globalThis.fetch, tokens = nul
}
return body;
},
++ /**
++ * Hide a page's thread (the page was unpublished, deleted or stopped being public) or show
++ * it again ('public'). Needs community.comment.moderate. Throws on failure; the caller
++ * treats it as best effort.
++ */
++ async setThreadVisibility(threadId, visibility) {
++ if (!community.configured) throw new IntegrationError(503, 'discussion.unavailable', 'OpenVibe.Community is not configured');
++ if (visibility !== 'public' && visibility !== 'hidden') throw new IntegrationError(422, 'discussion.invalid_visibility', 'visibility is public or hidden');
++ let res;
++ try {
++ res = await fetchImpl(`${communityBase}/api/v1/comments/threads/${encodeURIComponent(threadId)}/visibility`, {
++ method: 'PUT',
++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await tokenClient.authHeaders({ audience: 'openvibe.community', scope: 'community.comment.moderate' })) },
++ body: JSON.stringify({ visibility }),
++ signal: AbortSignal.timeout(5000),
++ });
++ } catch (err) {
++ throw new IntegrationError(503, 'discussion.unavailable', `Community did not answer: ${err && err.message}`);
++ }
++ const body = await res.json().catch(() => null);
++ if (res.status === 401) forAudience('openvibe.community').invalidate();
++ if (!res.ok) throw new IntegrationError(res.status >= 500 ? 503 : res.status, (body && body.code) || 'discussion.unavailable', (body && (body.detail || body.error)) || `Community answered ${res.status}`);
++ return true;
++ },
};
// ── Sources: citation items (sources.item@1) ──
ddiff --git a/server/wiki/service.js b/server/wiki/service.js
iindex e6eb5ea..3e5e6b7 100644
---- a/server/wiki/service.js
++++ b/server/wiki/service.js
@@@ -7,6 +7,8 @@
* Every write runs in one SQLite transaction together with the events it causes (transactional
* outbox): wiki.space.updated, wiki.revision.created, wiki.page.published|updated|unpublished|deleted,
* wiki.watch.triggered, and the Search index events wiki.index_document.upserted|deleted.
++ * After the commit (never inside it), a page that stops or starts being public has its Community
++ * discussion thread hidden or shown again (best effort).
*
* Methods take an actor (server/wiki/access.js) and throw WikiError (status + stable code).
*/
@@@ -63,7 +65,7 @@ function disclosureFor(rec, review) {
};
}
--function createWikiService({ db, stores, outbox, config, now = () => Date.now(), log = console }) {
++function createWikiService({ db, stores, outbox, config, community = null, now = () => Date.now(), log = console }) {
const { revisions, citations, redirects, reviews, attachments, discussions, scheduler, sequencer } = stores;
const access = createAccess(db);
const origin = config.baseUrl;
@@@ -124,7 +126,48 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
const pagePath = (space, page) => `/w/${encodeURIComponent(space.slug)}/${encodeURIComponent(page.slug)}`;
const pageUrl = (space, page) => seo.canonicalUrl(origin, pagePath(space, page));
const fail = (status, code, message, extra) => { throw new WikiError(status, code, message, extra); };
-- const tx = (fn) => db.transaction(fn)();
++
++ // Work that must wait until the write has committed (never inside the transaction). Queued by
++ // afterCommit() and run once the outermost tx() returns; dropped when the transaction throws.
++ let commitQueue = null;
++ function tx(fn) {
++ if (commitQueue) return db.transaction(fn)();
++ commitQueue = [];
++ let out;
++ try {
++ out = db.transaction(fn)();
++ } catch (err) {
++ commitQueue = null;
++ throw err;
++ }
++ const queue = commitQueue;
++ commitQueue = null;
++ for (const job of queue) {
++ try { job(); } catch (err) { log.warn(`[Wiki] after-commit: ${err && err.message}`); }
++ }
++ return out;
++ }
++ function afterCommit(job) {
++ if (commitQueue) commitQueue.push(job);
++ else job();
++ }
++
++ /**
++ * Keep a page's Community discussion thread in step with the page: hidden when it is
++ * unpublished, deleted or stops being public, shown again when it is public again. Best effort
++ * after the commit (needs community.comment.moderate); a failure never fails the write. A page
++ * that never had a thread (threads exist only for public pages) calls nothing.
++ */
++ function followThread(pageId, visibility) {
++ if (!community || typeof community.setThreadVisibility !== 'function') return;
++ afterCommit(() => {
++ const known = discussions.get(pageId);
++ if (!known) return;
++ Promise.resolve()
++ .then(() => community.setThreadVisibility(known.threadId, visibility))
++ .catch((err) => log.warn(`[Wiki] could not set the discussion thread of ${pageId} to ${visibility}: ${err && err.message}`));
++ });
++ }
function actorId(actor) {
if (!actor) return 'svc:wiki';
@@@ -263,6 +306,7 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
* After any change to a page: send Search the current document or a tombstone when it differs
* from the last one sent (members/private pages are always tombstones: never in search), and the
* product event when the publication state moved. Both go to the outbox in the caller's transaction.
++ * When the page stops (or starts) being public, its discussion thread follows after the commit.
*/
function sync(before, pageId, actor, { updatedIfIndexChanged = false } = {}) {
const page = q.pageById.get(pageId);
@@@ -287,6 +331,9 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
if (doc.revision !== sentBefore) emit(hooks.indexEvent({ document: doc, now: now() }));
const after = { state, visibility: eff, revision: page.published_revision };
++ const wasPublic = Boolean(before) && before.state === 'published' && before.visibility === 'public';
++ const isPublic = state === 'published' && eff === 'public';
++ if (wasPublic !== isPublic) followThread(page.id, isPublic ? 'public' : 'hidden');
let action = hooks.actionFor(before && before.state ? before : null, after);
// A review changes only the gate decision: announce it when what Search holds changed.
if (!action && updatedIfIndexChanged && state === 'published' && doc.revision !== sentBefore) action = 'updated';
ddiff --git a/test/discussion-visibility.test.js b/test/discussion-visibility.test.js
nnew file mode 100644
iindex 0000000..fc59e5d
---- /dev/null
++++ b/test/discussion-visibility.test.js
@@@ -0,0 +1,141 @@
++'use strict';
++/**
++ * A page's Community discussion thread follows the page: hidden when the page is unpublished,
++ * deleted or stops being public (page or space visibility), shown again when it is public again.
++ * The call (PUT /api/v1/comments/threads/:id/visibility, community.comment.moderate) runs after the
++ * write commits, is best effort, and a Community failure never fails the write.
++ */
++const assert = require('assert');
++const H = require('./helpers');
++
++(async () => {
++ const calls = [];
++ let threadIds = 0;
++ let communityDown = false;
++ const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
++ let h;
++ const fetchStub = async (url, init = {}) => {
++ const u = new URL(url);
++ const method = init.method || 'GET';
++ if (u.host !== 'community.internal') return json(404, { error: 'stub: no route' });
++ if (u.pathname === '/api/v1/comments/threads/resolve') {
++ threadIds += 1;
++ return json(201, { thread: { id: threadIds, visibility: 'public', comment_count: 0 }, created: true });
++ }
++ const vis = u.pathname.match(/^\/api\/v1\/comments\/threads\/(\d+)\/visibility$/);
++ if (vis && method === 'PUT') {
++ const headers = Object.fromEntries(new Headers(init.headers || {}).entries());
++ calls.push({ thread: vis[1], body: JSON.parse(init.body), headers, inTransaction: h.db.inTransaction });
++ if (communityDown) return json(503, { code: 'unavailable' });
++ return json(200, { thread: { id: Number(vis[1]), visibility: JSON.parse(init.body).visibility } });
++ }
++ const th = u.pathname.match(/^\/api\/v1\/comments\/threads\/(\d+)$/);
++ if (th && method === 'GET') return json(200, { thread: { id: Number(th[1]), visibility: 'public', comment_count: 0 }, comments: [], next_cursor: null });
++ return json(404, { error: 'stub: no route' });
++ };
++ const scopes = [];
++ const tokens = {
++ getToken: async () => 'stub-token',
++ authHeaders: async (ctx = {}) => { scopes.push(ctx); return { Authorization: 'Bearer stub-token' }; },
++ invalidate() {},
++ };
++ h = await H.boot({ fetch: fetchStub, tokens, env: { OV_COMMUNITY_INTERNAL_URL: 'http://community.internal' } });
++ const tok = H.userToken({ subject: H.subject() });
++ const settle = async (n) => { for (let i = 0; i < 50 && calls.length < n; i++) await new Promise((r) => setTimeout(r, 10)); await new Promise((r) => setTimeout(r, 20)); };
++ const last = () => calls[calls.length - 1];
++
++ try {
++ let r = await H.req(h, 'POST', '/api/v1/spaces', { token: tok, body: { name: 'Talk', slug: 'talk' } });
++ assert.strictEqual(r.status, 201, r.text);
++ r = await H.req(h, 'POST', '/api/v1/spaces/talk/pages', { token: tok, body: { title: 'Discussed', body: H.LONG } });
++ assert.strictEqual(r.status, 201, r.text);
++ const pageId = r.json.page.id;
++ r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/publish`, { token: tok, body: {} });
++ assert.strictEqual(r.status, 200, r.text);
++ await settle(0);
++ assert.strictEqual(calls.length, 0, 'publishing a page that has no thread yet calls nothing');
++
++ // Rendering the public page resolves its thread (id 1).
++ r = await H.req(h, 'GET', '/w/talk/discussed');
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(h.db.prepare('SELECT thread_id FROM wiki_discussion_refs WHERE entity_id = ?').get(pageId).thread_id, '1');
++
++ // Unpublished: hidden. After the commit, with a moderate token.
++ r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/unpublish`, { token: tok, body: {} });
++ assert.strictEqual(r.status, 200, r.text);
++ await settle(1);
++ assert.strictEqual(calls.length, 1);
++ assert.deepStrictEqual({ thread: last().thread, body: last().body }, { thread: '1', body: { visibility: 'hidden' } });
++ assert.strictEqual(last().headers.authorization, 'Bearer stub-token');
++ assert.strictEqual(last().inTransaction, false, 'called after the write committed, never inside the transaction');
++ assert.ok(scopes.some((s) => s.audience === 'openvibe.community' && s.scope === 'community.comment.moderate'));
++
++ // Published again: shown.
++ r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/publish`, { token: tok, body: {} });
++ assert.strictEqual(r.status, 200, r.text);
++ await settle(2);
++ assert.strictEqual(calls.length, 2);
++ assert.deepStrictEqual(last().body, { visibility: 'public' });
++
++ // A change that keeps the page public (a move) calls nothing.
++ r = await H.req(h, 'PATCH', `/api/v1/pages/${pageId}`, { token: tok, body: { slug: 'discussed-page' } });
++ assert.strictEqual(r.status, 200, r.text);
++ await settle(3);
++ assert.strictEqual(calls.length, 2);
++
++ // Made members-only, then private (still not public: one call), then public again.
++ r = await H.req(h, 'PATCH', `/api/v1/pages/${pageId}`, { token: tok, body: { visibility: 'members' } });
++ assert.strictEqual(r.status, 200, r.text);
++ await settle(3);
++ assert.strictEqual(calls.length, 3);
++ assert.deepStrictEqual(last().body, { visibility: 'hidden' });
++ r = await H.req(h, 'PATCH', `/api/v1/pages/${pageId}`, { token: tok, body: { visibility: 'private' } });
++ assert.strictEqual(r.status, 200, r.text);
++ await settle(4);
++ assert.strictEqual(calls.length, 3, 'members → private is not a change of public-ness');
++ r = await H.req(h, 'PATCH', `/api/v1/pages/${pageId}`, { token: tok, body: { visibility: 'public' } });
++ await settle(4);
++ assert.strictEqual(calls.length, 4);
++ assert.deepStrictEqual(last().body, { visibility: 'public' });
++
++ // The space stops being public: every public page's thread is hidden; public again: shown.
++ r = await H.req(h, 'PATCH', '/api/v1/spaces/talk', { token: tok, body: { visibility: 'members' } });
++ assert.strictEqual(r.status, 200, r.text);
++ await settle(5);
++ assert.strictEqual(calls.length, 5);
++ assert.deepStrictEqual(last().body, { visibility: 'hidden' });
++ r = await H.req(h, 'PATCH', '/api/v1/spaces/talk', { token: tok, body: { visibility: 'public' } });
++ await settle(6);
++ assert.strictEqual(calls.length, 6);
++ assert.deepStrictEqual(last().body, { visibility: 'public' });
++
++ // Community is down: the write still succeeds (best effort).
++ communityDown = true;
++ r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/unpublish`, { token: tok, body: {} });
++ assert.strictEqual(r.status, 200, r.text);
++ await settle(7);
++ assert.strictEqual(calls.length, 7);
++ assert.strictEqual(h.svc.pageById(pageId).state, 'unpublished');
++ communityDown = false;
++ r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/publish`, { token: tok, body: {} });
++ await settle(8);
++ assert.deepStrictEqual(last().body, { visibility: 'public' });
++
++ // Deleted: hidden.
++ r = await H.req(h, 'DELETE', `/api/v1/pages/${pageId}`, { token: tok });
++ assert.strictEqual(r.status, 200, r.text);
++ await settle(9);
++ assert.strictEqual(calls.length, 9);
++ assert.deepStrictEqual(last().body, { visibility: 'hidden' });
++ assert.ok(calls.every((c) => c.thread === '1' && c.inTransaction === false));
++
++ // A failed write calls nothing.
++ r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/unpublish`, { token: tok, body: {} });
++ assert.ok(r.status >= 400);
++ await settle(10);
++ assert.strictEqual(calls.length, 9);
++ console.log('discussion visibility ok');
++ } finally {
++ await h.stop();
++ }
++})().catch((err) => { console.error(err); process.exit(1); });