openvibers/openvibe.community Commits

Community publishes events (openvibe-contracts 0.35.0): community.paste.created|updated|deleted, community.thread.created, community.post.created and community.comment.created, each queued in the transaction of the write through the openvibe-sdk outbox and relayed with Community's service token. Payloads carry ids, owner/author subject, visibility and the public URL, never a title, body or content; items that are not public (unlisted pastes, members-only or staff spaces, hidden threads) travel as internal events without a URL. Off unless EVENTS_URL and the client secret are set. test/events.test.js

OP OpenVibers committed 24 Sept 2026 · c8a70c78aa · 8 files, +254 −15


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex f9015fa..f2a8257 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,8 @@
"helmet": "^8.1.0",
"highlight.js": "^11.11.1",
"multer": "^2.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.6.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1"
},
"engines": {
@@@ -1056,9 +1057,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.33.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
-- "integrity": "sha512-7q8NUhcPxgb8lxrVdC3pFbiPdjzliJM1mMP/1G5a2PmOr2tJVAXwYa8+a8YgCJvYeMSeMHWijLkIEiZ9IhllOg==",
++ "version": "0.35.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0",
++ "integrity": "sha512-5jkH9Gt9MVbYaxo/Fi1VSH0RwdPkbary7SOm2m9e5EvXYu8L/cJHMm6KrMKxa7MV6TJCATDeRpGLDOCaUeZmSw==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
@@@ -1071,6 +1072,15 @@
"node": ">=20"
}
},
++ "node_modules/openvibe-sdk": {
++ "version": "0.6.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.6.0",
++ "integrity": "sha512-v6PFh8Xf/bE1XDbuSkbVXVC5cN+q9fkwQi/qc9kmiGPr9Zze47ydS+qS0i3RXUmQF2v0/BdCzAMcowsdzf1ezQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=20"
++ }
++ },
"node_modules/openvibe-shared": {
"version": "1.5.1",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1",
ddiff --git a/package.json b/package.json
iindex 2ca5699..fd3adbf 100644
---- a/package.json
++++ b/package.json
@@@ -2,14 +2,19 @@
"name": "openvibe-community",
"version": "1.0.0",
"private": true,
-- "description": "OpenVibe.Community — the people of OpenVibe. Home of pastes, the forum (spaces, threads, posts), the comment threads other OpenVibe products embed, and Pulse; submissions to follow. Part of the OpenVibe network (identity by OpenVibe.Network; pastes via OpenVibe.Live until PASTES_AUTHORITY=community makes this site their authority).",
++ "description": "OpenVibe.Community \u2014 the people of OpenVibe. Home of pastes, the forum (spaces, threads, posts), the comment threads other OpenVibe products embed, and Pulse; submissions to follow. Part of the OpenVibe network (identity by OpenVibe.Network; pastes via OpenVibe.Live until PASTES_AUTHORITY=community makes this site their authority).",
"main": "server/index.js",
"scripts": {
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"test": "node test/run.js"
},
-- "keywords": ["openvibe", "community", "pastes", "pastebin"],
++ "keywords": [
++ "openvibe",
++ "community",
++ "pastes",
++ "pastebin"
++ ],
"author": "OpenVibe Community",
"license": "MIT",
"dependencies": {
@@@ -21,7 +26,8 @@
"helmet": "^8.1.0",
"highlight.js": "^11.11.1",
"multer": "^2.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.6.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1"
},
"engines": {
ddiff --git a/server/comments/store.js b/server/comments/store.js
iindex 5bc6b7b..0111761 100644
---- a/server/comments/store.js
++++ b/server/comments/store.js
@@@ -59,7 +59,10 @@ function insertComment(db, { thread_id, parent_id = null, author_subject = null,
.run(thread_id, parent_id, author_subject, anon_name, origin, message);
db.prepare('UPDATE comment_threads SET comment_count = comment_count + 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(thread_id);
if (parent_id) db.prepare('UPDATE comments SET reply_count = reply_count + 1 WHERE id = ?').run(parent_id);
-- return getComment(db, info.lastInsertRowid);
++ const comment = getComment(db, info.lastInsertRowid);
++ const cthread = db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(thread_id);
++ if (cthread) require('../events').commentCreated(comment, cthread); // community.comment.created
++ return comment;
})();
}
ddiff --git a/server/events.js b/server/events.js
nnew file mode 100644
iindex 0000000..edde218
---- /dev/null
++++ b/server/events.js
@@@ -0,0 +1,115 @@
++'use strict';
++/**
++ * Community → OpenVibe.Events (contracts community.*@1, openvibe-contracts 0.35.0).
++ *
++ * community.paste.created | updated | deleted server/pastes/store.js
++ * community.thread.created, community.post.created server/forum/store.js
++ * community.comment.created server/comments/store.js
++ *
++ * Each event is written to Community's own outbox inside the SAME transaction as the change (the SDK
++ * outbox refuses anything else), and a relay publishes it with Community's service token (audience
++ * openvibe.events, capability events.event.publish). Events down: rows wait and are retried; a write
++ * never waits on Events. Payloads carry ids, the owner/author subject, visibility and the public URL,
++ * never a body, title or content. Envelope visibility is public only for public items.
++ *
++ * Off unless EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set (EVENTS_PUBLISH=off disables it).
++ */
++const { createClient } = require('openvibe-sdk/core');
++const { createServiceTokenClient } = require('openvibe-sdk/auth');
++const { createEventsClient, createOutbox } = require('openvibe-sdk/events');
++const config = require('./config');
++
++let outbox = null;
++const stats = { queued: 0, lastError: null };
++const PRUNE_EVERY_MS = 6 * 60 * 60 * 1000;
++
++function init(db, { eventsUrl = process.env.EVENTS_URL, clientSecret = config.oauth && config.oauth.clientSecret, fetchImpl, intervalMs } = {}) {
++ if (outbox) return outbox;
++ if (process.env.EVENTS_PUBLISH === 'off' || !eventsUrl || !clientSecret) return null;
++ const tokens = createServiceTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: (config.oauth && config.oauth.clientId) || 'community', clientSecret, fetch: fetchImpl });
++ const client = createClient({ baseUrls: { events: String(eventsUrl).replace(/\/+$/, '') }, tokenProvider: tokens, fetch: fetchImpl, retries: 0 });
++ outbox = createOutbox(db, {
++ events: createEventsClient(client, { source: 'community' }),
++ intervalMs: intervalMs || 2000,
++ onError: (err) => { const m = err && err.message; if (m !== stats.lastError) console.warn('[Events] publish failed (will retry):', m); stats.lastError = m; },
++ });
++ outbox.ensureSchema();
++ outbox.start();
++ const prune = setInterval(() => { try { outbox.prune(); } catch { /* next time */ } }, PRUNE_EVERY_MS);
++ if (prune.unref) prune.unref();
++ console.log(`[Events] community → ${eventsUrl} (${outbox.pending()} pending)`);
++ return outbox;
++}
++
++const subjectRef = (sub) => (sub && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(sub) ? { type: 'user', id: sub } : (sub && /^gst_/.test(sub) ? { type: 'guest', id: sub } : null));
++
++/**
++ * Queue one event. MUST run inside the transaction that makes the change; throws if the insert fails,
++ * so the change rolls back with it. A no-op (null) while publishing is off.
++ */
++function record(eventType, subject, payload, { isPublic = false, actor = null } = {}) {
++ if (!outbox) return null;
++ const env = outbox.enqueue({
++ event_type: eventType,
++ actor: actor || { type: 'service', id: 'community' },
++ subject,
++ visibility: isPublic ? 'public' : 'internal',
++ priority: 'low',
++ payload,
++ });
++ stats.queued++;
++ setImmediate(() => outbox && outbox.kick());
++ return env;
++}
++
++// ── Builders (read the row as it is after the change) ─────────────────────────────
++const pasteUrl = (p) => (p.visibility === 'public' && !p.deleted_at ? `${config.baseUrl}/p/${encodeURIComponent(p.slug)}` : null);
++const actorOf = (sub) => { const r = subjectRef(sub); return r ? { type: r.type, id: r.id } : null; };
++
++function pasteCreated(p) {
++ return record('community.paste.created', { type: 'paste', id: p.slug }, {
++ paste_id: p.slug, type: p.type === 'screenshot' ? 'screenshot' : 'paste', origin: ['user', 'ai', 'import'].includes(p.origin) ? p.origin : 'user',
++ visibility: p.visibility || 'public', owner: subjectRef(p.owner_subject), url: pasteUrl(p),
++ }, { isPublic: p.visibility === 'public', actor: actorOf(p.owner_subject) });
++}
++function pasteUpdated(p, changed) {
++ const list = [...new Set(changed)].filter((c) => ['title', 'content', 'language', 'visibility', 'is_nsfw', 'pinned'].includes(c));
++ if (!list.length) return null;
++ return record('community.paste.updated', { type: 'paste', id: p.slug, revision: Number(p.revision) || 1 }, {
++ paste_id: p.slug, changed: list, visibility: p.deleted_at ? 'deleted' : (p.visibility || 'public'), revision: Number(p.revision) || 1, url: pasteUrl(p),
++ }, { isPublic: p.visibility === 'public' });
++}
++function pasteDeleted(slug) {
++ return record('community.paste.deleted', { type: 'paste', id: slug }, { paste_id: slug }, { isPublic: true });
++}
++// A thread or post is public only in a public space and outside a members-only thread (staff spaces count as members).
++const forumVisibility = (thread, spaceVisibility) => (thread.members_only_owner || spaceVisibility !== 'public' ? 'members' : 'public');
++function threadCreated(thread, spaceSlug, spaceVisibility = 'public') {
++ const visibility = forumVisibility(thread, spaceVisibility);
++ return record('community.thread.created', { type: 'thread', id: String(thread.id) }, {
++ thread_id: Number(thread.id), space: spaceSlug, author: subjectRef(thread.author_subject), visibility,
++ url: visibility === 'public' ? `${config.baseUrl}/s/${encodeURIComponent(spaceSlug)}/t/${encodeURIComponent(thread.slug)}` : null,
++ }, { isPublic: visibility === 'public', actor: actorOf(thread.author_subject) });
++}
++function postCreated(post, thread, spaceSlug, spaceVisibility = 'public') {
++ const visibility = forumVisibility(thread, spaceVisibility);
++ return record('community.post.created', { type: 'post', id: String(post.id) }, {
++ post_id: Number(post.id), thread_id: Number(thread.id), space: spaceSlug, author: subjectRef(post.author_subject), visibility,
++ url: visibility === 'public' ? `${config.baseUrl}/s/${encodeURIComponent(spaceSlug)}/t/${encodeURIComponent(thread.slug)}#p${post.id}` : null,
++ }, { isPublic: visibility === 'public', actor: actorOf(post.author_subject) });
++}
++function commentCreated(comment, cthread) {
++ const visibility = ['public', 'hidden', 'locked'].includes(cthread.visibility) ? cthread.visibility : 'public';
++ return record('community.comment.created', { type: 'comment', id: String(comment.id) }, {
++ comment_id: Number(comment.id), thread_access_id: String(cthread.access_id), ref: { service: cthread.ref_service, type: cthread.ref_type, id: String(cthread.ref_id) },
++ author: subjectRef(comment.author_subject), visibility, url: visibility === 'hidden' ? null : `${config.baseUrl}/c/${encodeURIComponent(cthread.access_id)}`,
++ }, { isPublic: visibility !== 'hidden', actor: actorOf(comment.author_subject) });
++}
++
++function status() {
++ if (!outbox) return { enabled: false };
++ return { enabled: true, pending: outbox.pending(), rejected: outbox.rejected(), queued_since_boot: stats.queued, last_error: stats.lastError };
++}
++function _reset() { if (outbox) outbox.stop(); outbox = null; stats.queued = 0; stats.lastError = null; }
++
++module.exports = { init, record, pasteCreated, pasteUpdated, pasteDeleted, threadCreated, postCreated, commentCreated, status, _reset };
ddiff --git a/server/forum/store.js b/server/forum/store.js
iindex 9982dc4..2fcb387 100644
---- a/server/forum/store.js
++++ b/server/forum/store.js
@@@ -65,7 +65,10 @@ function createThread(db, { space_id, title, author_subject = null, origin = 'us
const threadId = info.lastInsertRowid;
const p = db.prepare('INSERT INTO posts (thread_id, author_subject, origin, is_opening, body_markdown) VALUES (?, ?, ?, 1, ?)')
.run(threadId, author_subject, origin, body_markdown);
-- return { thread: getThread(db, threadId), post: getPost(db, p.lastInsertRowid) };
++ const thread = getThread(db, threadId);
++ const space = db.prepare('SELECT slug, visibility FROM spaces WHERE id = ?').get(space_id);
++ require('../events').threadCreated(thread, space ? space.slug : String(space_id), space ? space.visibility : 'members'); // community.thread.created
++ return { thread, post: getPost(db, p.lastInsertRowid) };
})();
}
@@@ -136,7 +139,11 @@ function addPost(db, { thread_id, author_subject = null, origin = 'user', body_m
const info = db.prepare('INSERT INTO posts (thread_id, author_subject, origin, body_markdown) VALUES (?, ?, ?, ?)')
.run(thread_id, author_subject, origin, body_markdown);
db.prepare('UPDATE threads SET reply_count = reply_count + 1, last_activity_at = CURRENT_TIMESTAMP WHERE id = ?').run(thread_id);
-- return getPost(db, info.lastInsertRowid);
++ const post = getPost(db, info.lastInsertRowid);
++ const thread = getThread(db, thread_id);
++ const space = thread && db.prepare('SELECT slug, visibility FROM spaces WHERE id = ?').get(thread.space_id);
++ if (thread) require('../events').postCreated(post, thread, space ? space.slug : String(thread.space_id), space ? space.visibility : 'members'); // community.post.created
++ return post;
})();
}
ddiff --git a/server/index.js b/server/index.js
iindex 0f56ad2..3c707dc 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -8,6 +8,8 @@ const config = require('./config');
const { createApp } = require('./app');
const app = createApp();
++// Community → OpenVibe.Events (server/events.js): off unless EVENTS_URL and the client secret are set.
++try { require('./events').init(require('./db').getDb()); } catch (err) { console.warn('[Events] not started:', err.message); }
const server = app.listen(config.port, config.host, () => {
console.log(`[Community] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}`);
console.log(config.pastesAuthority === 'community'
ddiff --git a/server/pastes/store.js b/server/pastes/store.js
iindex 13c2940..92e7aa2 100644
---- a/server/pastes/store.js
++++ b/server/pastes/store.js
@@@ -126,9 +126,13 @@ const INSERT_COLUMNS = ['slug', 'owner_subject', 'origin', 'type', 'title', 'con
/** Insert a new paste (fields already validated); returns the stored row. */
function insertPaste(db, fields) {
const cols = INSERT_COLUMNS.filter((c) => fields[c] !== undefined);
-- const info = db.prepare(`INSERT INTO pastes (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`)
-- .run(...cols.map((c) => fields[c]));
-- return getById(db, info.lastInsertRowid);
++ return db.transaction(() => {
++ const info = db.prepare(`INSERT INTO pastes (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`)
++ .run(...cols.map((c) => fields[c]));
++ const row = getById(db, info.lastInsertRowid);
++ require('../events').pasteCreated(row); // community.paste.created, in this transaction
++ return row;
++ })();
}
/**
@@@ -164,6 +168,8 @@ function updatePaste(db, id, patch, editedBy = null) {
db.prepare('INSERT INTO paste_versions (paste_id, revision, title, content, language, edited_by) VALUES (?, ?, ?, ?, ?, ?)')
.run(id, next.revision, next.title, next.content, next.language, editedBy);
}
++ const changed = ['title', 'content', 'language', 'visibility', 'is_nsfw', 'pinned'].filter((k) => patch[k] !== undefined && patch[k] !== cur[k]);
++ if (changed.length) require('../events').pasteUpdated(next, changed);
return next;
})();
}
@@@ -174,8 +180,13 @@ function listVersions(db, pasteId) {
/** Soft delete: the row stays (slug + legacy id reserved), its content and image link do not. */
function softDelete(db, id) {
-- return db.prepare(`UPDATE pastes SET deleted_at = CURRENT_TIMESTAMP, content = NULL, screenshot_url = NULL, metadata = NULL,
++ return db.transaction(() => {
++ const cur = getById(db, id);
++ const n = db.prepare(`UPDATE pastes SET deleted_at = CURRENT_TIMESTAMP, content = NULL, screenshot_url = NULL, metadata = NULL,
ai_summary = NULL, ai_tags = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL`).run(id).changes;
++ if (n && cur) require('../events').pasteDeleted(cur.slug); // community.paste.deleted
++ return n;
++ })();
}
/**
@@@ -301,7 +312,12 @@ function setScreenshot(db, pasteId, url, mediaRef) {
}
function setVisibility(db, pasteId, visibility) {
-- return db.prepare('UPDATE pastes SET visibility = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL').run(visibility, pasteId).changes;
++ return db.transaction(() => {
++ const cur = getById(db, pasteId);
++ const r = db.prepare('UPDATE pastes SET visibility = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL').run(visibility, pasteId);
++ if (r.changes && cur && cur.visibility !== visibility) require('../events').pasteUpdated(getById(db, pasteId), ['visibility']);
++ return r.changes;
++ })();
}
/** Media's admin stats shape. */
ddiff --git a/test/events.test.js b/test/events.test.js
nnew file mode 100644
iindex 0000000..4824dac
---- /dev/null
++++ b/test/events.test.js
@@@ -0,0 +1,80 @@
++'use strict';
++/**
++ * Community's events: every write queues its community.* event in the same transaction (a failed write
++ * queues nothing), payloads validate against openvibe-contracts and never carry content, and items
++ * that are not public travel as internal events without a URL.
++ */
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const { openDb } = require('../server/db');
++const events = require('../server/events');
++const pastes = require('../server/pastes/store');
++const forum = require('../server/forum/store');
++const comments = require('../server/comments/store');
++
++const db = openDb(':memory:');
++// A relay that never publishes during the test: the rows stay in the outbox for inspection.
++events.init(db, { eventsUrl: 'http://127.0.0.1:9', clientSecret: 'test-secret', intervalMs: 3_600_000, fetchImpl: async () => { throw new Error('offline'); } });
++const queued = () => db.prepare("SELECT envelope FROM event_outbox ORDER BY id").all().map((r) => JSON.parse(r.envelope));
++const USR = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++const ok = (env) => { const r = contracts.validate(`${env.event_type}@1`, env.payload); assert.ok(r.valid, `${env.event_type}: ${JSON.stringify(r.errors)}`); };
++
++let n = 0;
++function check(name, fn) { fn(); n++; console.log(` ✓ ${name}`); }
++
++check('a paste create, edit, visibility change and delete each queue one event, never the content', () => {
++ const p = pastes.insertPaste(db, { slug: 'amber-fox-42', owner_subject: USR, origin: 'user', type: 'paste', title: 'secret title', content: 'secret body', language: 'text', visibility: 'public' });
++ pastes.updatePaste(db, p.id, { content: 'edited body' }, USR);
++ pastes.setVisibility(db, p.id, 'unlisted');
++ pastes.softDelete(db, p.id);
++ const evs = queued();
++ assert.deepStrictEqual(evs.map((e) => e.event_type), ['community.paste.created', 'community.paste.updated', 'community.paste.updated', 'community.paste.deleted']);
++ evs.forEach(ok);
++ assert.ok(!JSON.stringify(evs).includes('secret'), 'no title or content in any event');
++ assert.strictEqual(evs[0].visibility, 'public');
++ assert.strictEqual(evs[0].payload.url, 'https://openvibe.community/p/amber-fox-42'.replace('https://openvibe.community', require('../server/config').baseUrl));
++ assert.deepStrictEqual(evs[1].payload.changed, ['content']);
++ assert.strictEqual(evs[2].visibility, 'internal', 'an unlisted paste is not a public event');
++ assert.strictEqual(evs[2].payload.url, null);
++ assert.deepStrictEqual(evs[0].actor, { type: 'user', id: USR });
++});
++
++check('a write that fails queues nothing (the event is in its transaction)', () => {
++ const before = queued().length;
++ assert.throws(() => pastes.insertPaste(db, { slug: 'amber-fox-42', owner_subject: USR, type: 'paste', content: 'dup', visibility: 'public' }), /UNIQUE/);
++ assert.strictEqual(queued().length, before);
++});
++
++check('threads and posts: public spaces only are public events; staff spaces are members-only', () => {
++ db.prepare("INSERT OR IGNORE INTO spaces (slug, name, visibility) VALUES ('general', 'General', 'public'), ('staff', 'Staff', 'staff')").run();
++ const general = db.prepare("SELECT id FROM spaces WHERE slug = 'general'").get().id;
++ const staff = db.prepare("SELECT id FROM spaces WHERE slug = 'staff'").get().id;
++ const before = queued().length;
++ const { thread } = forum.createThread(db, { space_id: general, title: 'Hello there', author_subject: USR, body_markdown: 'first post body' });
++ forum.addPost(db, { thread_id: thread.id, author_subject: USR, body_markdown: 'a reply body' });
++ forum.createThread(db, { space_id: staff, title: 'Staff only', author_subject: USR, body_markdown: 'hidden body' });
++ const evs = queued().slice(before);
++ assert.deepStrictEqual(evs.map((e) => e.event_type), ['community.thread.created', 'community.post.created', 'community.thread.created']);
++ evs.forEach(ok);
++ assert.strictEqual(evs[0].visibility, 'public');
++ assert.ok(/\/s\/general\/t\//.test(evs[0].payload.url));
++ assert.strictEqual(evs[2].visibility, 'internal');
++ assert.strictEqual(evs[2].payload.visibility, 'members');
++ assert.strictEqual(evs[2].payload.url, null);
++ assert.ok(!JSON.stringify(evs).includes('body'), 'no post body');
++});
++
++check('a comment on a Live VOD queues community.comment.created with its ref', () => {
++ const before = queued().length;
++ const t = comments.resolveThread(db, { service: 'live', type: 'vod', id: '906' }).thread;
++ comments.insertComment(db, { thread_id: t.id, author_subject: USR, message: 'nice stream' });
++ const evs = queued().slice(before);
++ assert.deepStrictEqual(evs.map((e) => e.event_type), ['community.comment.created']);
++ evs.forEach(ok);
++ assert.deepStrictEqual(evs[0].payload.ref, { service: 'live', type: 'vod', id: '906' });
++ assert.ok(!JSON.stringify(evs).includes('nice stream'));
++});
++
++events._reset();
++console.log(`community events: ${n} checks passed`);
++process.exit(0);