openvibers/openvibe.wiki Commits
A page's discussion thread is hidden when the page stops being public
Changes
@@@ -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@@@ -21,7 +21,7 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.feconst 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 });@@@ -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) ──@@@ -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';nnew file mode 100644@@@ -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); });
@@ -36,7 +36,8 @@ OV_OAUTH_CLIENT_SECRET=
@@ -36,7 +36,8 @@ OV_OAUTH_CLIENT_SECRET=
#EVENTS_URL=http://127.0.0.1:4300
#EVENTS_URL=http://127.0.0.1:4300
#EVENTS_RELAY_INTERVAL_MS=2000
#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_URL=https://openvibe.community
#OV_COMMUNITY_INTERNAL_URL=http://127.0.0.1:4200
#OV_COMMUNITY_INTERNAL_URL=http://127.0.0.1:4200
@@ -21,7 +21,7 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fe
@@ -21,7 +21,7 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fe
const db = openDb(config.dbPath);
const db = openDb(config.dbPath);
const stores = createStores(db, { now });
const stores = createStores(db, { now });
const platform = createPlatform({ config, db, fetchImpl, tokens, now, log });
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();
const reconciled = svc.reconcileIndex();
if (reconciled.sent) log.log(`[Wiki] re-sent ${reconciled.sent} Search document(s) whose indexability changed`);
if (reconciled.sent) log.log(`[Wiki] re-sent ${reconciled.sent} Search document(s) whose indexability changed`);
const keys = createKeyStore({ config, fetchImpl, log, publicKey });
const keys = createKeyStore({ config, fetchImpl, log, publicKey });
@@ -3,6 +3,7 @@
@@ -3,6 +3,7 @@
* Clients for the services Wiki composes (roadmap §29): Events (outbox relay), Community
* 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
* (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.
* 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
* 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.
* 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
@@ -92,6 +93,30 @@ function createPlatform({ config, db, fetchImpl = globalThis.fetch, tokens = nul
}
}
return body;
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) ──
// ── Sources: citation items (sources.item@1) ──
@@ -7,6 +7,8 @@
@@ -7,6 +7,8 @@
* Every write runs in one SQLite transaction together with the events it causes (transactional
* 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,
* 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.
* 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).
* Methods take an actor (server/wiki/access.js) and throw WikiError (status + stable code).
*/
*/
@@ -63,7 +65,7 @@ function disclosureFor(rec, review) {
@@ -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 { revisions, citations, redirects, reviews, attachments, discussions, scheduler, sequencer } = stores;
const access = createAccess(db);
const access = createAccess(db);
const origin = config.baseUrl;
const origin = config.baseUrl;
@@ -124,7 +126,48 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
@@ -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 pagePath = (space, page) => `/w/${encodeURIComponent(space.slug)}/${encodeURIComponent(page.slug)}`;
const pageUrl = (space, page) => seo.canonicalUrl(origin, pagePath(space, page));
const pageUrl = (space, page) => seo.canonicalUrl(origin, pagePath(space, page));
const fail = (status, code, message, extra) => { throw new WikiError(status, code, message, extra); };
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) {
function actorId(actor) {
if (!actor) return 'svc:wiki';
if (!actor) return 'svc:wiki';
@@ -263,6 +306,7 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
@@ -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
* 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
* 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.
* 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 } = {}) {
function sync(before, pageId, actor, { updatedIfIndexChanged = false } = {}) {
const page = q.pageById.get(pageId);
const page = q.pageById.get(pageId);
@@ -287,6 +331,9 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
@@ -287,6 +331,9 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
if (doc.revision !== sentBefore) emit(hooks.indexEvent({ document: doc, now: now() }));
if (doc.revision !== sentBefore) emit(hooks.indexEvent({ document: doc, now: now() }));
const after = { state, visibility: eff, revision: page.published_revision };
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);
let action = hooks.actionFor(before && before.state ? before : null, after);
// A review changes only the gate decision: announce it when what Search holds changed.
// A review changes only the gate decision: announce it when what Search holds changed.
if (!action && updatedIfIndexChanged && state === 'published' && doc.revision !== sentBefore) action = 'updated';
if (!action && updatedIfIndexChanged && state === 'published' && doc.revision !== sentBefore) action = 'updated';
new file mode 100644
new file mode 100644
@@ -0,0 +1,141 @@
@@ -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); });