openvibers/openvibe.community Commits
Platform blocks (WS-E task 5, Contracts 0.49.0): Community consumes network.block.changed into a newest-revision projection and refuses (403 community.blocked, plain message on the no-JS forms) a reply in a thread whose author blocked you, a reply to a comment whose author blocked you, and a comment on a paste or post whose owner blocked you
Changes
@@@ -455,6 +455,20 @@ per source service/type/id).keyset cursor), and the `/pulse` page. AI items carry `label: 'AI'` and never an actor —they are never attributed to a person (roadmap §33); system items name no one either.++## Platform blocks++++People block each other once, on OpenVibe.Network (roadmap WS-E task 5, Contracts 0.49.0), and Community++honours it (`server/identity/blocks.js`): nobody replies in a forum thread whose author blocked them, replies++to a comment whose author blocked them (or to a reply that joins that comment), or comments on a paste (paste++API or comment thread) or forum post whose owner blocked them. The API answers 403 `community.blocked` (a++problem under `/api/v1`, `{ error, code }` under `/api/pastes`); the no-JS forms show the message with the++draft kept. Anonymous comments carry no subject and so are not affected; moderation never is.++++The blocks come from `network.block.changed` (POST `/internal/events`, `server/pulse/consumer.js`) into the++`network_blocks` projection, newest revision per pair. Boot creates the missing subscription++(`startSubscriptions`, `COMMUNITY_EVENTS_SECRET`, `EVENTS_URL`); a new subscription gets no history, so++blocks made before it existed need a replay from OpenVibe.Events.++## CapabilitiesCommunity checks service tokens against these capabilities (manifests in@@@ -468,7 +482,7 @@ Community checks service tokens against these capabilities (manifests in| `community.pulse.write` | active in contracts (v0.7.0) | publishing to Pulse || `community.post.create` | active in contracts (v0.7.0) | forum writes |--This repository pins `openvibe-contracts` v0.33.0, which knows every id above, so they all go++This repository pins `openvibe-contracts` v0.49.0, which knows every id above, so they all gothrough the library's `capabilities.check`. `server/identity/capabilities.js` still decides an idthe installed contracts do not know locally, with the library's own matching rule (the exact idor a `prefix.*` grant).@@@ -17,7 +17,7 @@"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.44.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"},@@@ -1057,9 +1057,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.44.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.44.0",-- "integrity": "sha512-VMjnJSwU2dnk9KstCHQDKfY6UysSk2fWK1wdLp15Via49/hbeaXRTa4XIjO3d82fYizj0rJpi9GrK+AJRskjtQ==",++ "version": "0.49.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",++ "integrity": "sha512-lapsLoBm3ZpndgiOMELdCBh2PJtuuyqj6SUhQsfwJkxnLdQfyhXfRFgiuLh+iQrg4kqtG9611191Mp8zAfk7fQ==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -26,7 +26,7 @@"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.44.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"},@@@ -16,6 +16,8 @@* - 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).++ * Platform blocks (identity/blocks.js): no reply to a comment whose author blocked you, no comment++ * on a Community paste or post whose owner blocked you (403 community.blocked).* - edit: the comment's author only (edited_at records it).* - delete: the comment's author, or a moderator.* - one comment with its thread (getComment): services only — comment ids are sequential, so a@@@ -37,6 +39,7 @@ const { fail, isoTime } = require('../http/v1');const { createAuthors } = require('../identity/authors');const { createPersonLimiter } = require('../limits');const { discussionModerator: moderator } = require('../identity/capabilities');++const blocks = require('../identity/blocks');/*** The entity types a browser may open a thread for (services with community.comment.write: any).@@@ -153,6 +156,14 @@ function createCommentService({ db, network = null, pastesLocal = false, limitsconst signedInOnly = (t) => (SIGNED_IN_ONLY[t.ref_service] || []).includes(t.ref_type);++ /** Who owns a thread's Community entity (a paste's owner, a forum post's author), or null. */++ function entityOwner(t) {++ if (t.ref_service !== 'community') return null;++ if (t.ref_type === 'paste' && pastesLocal) { const p = pasteStore.getBySlug(db, t.ref_id); return p ? p.owner_subject || null : null; }++ if (t.ref_type === 'post' && /^\d{1,15}$/.test(String(t.ref_id))) { const r = db.prepare('SELECT author_subject FROM posts WHERE id = ?').get(Number(t.ref_id)); return r ? r.author_subject || null : null; }++ return null;++ }++function cleanMessage(raw) {const message = String(raw == null ? '' : raw).replace(/\u0000/g, '').trim();if (!message) fail(400, 'comment.empty', 'Comment cannot be empty');@@@ -224,9 +235,10 @@ function createCommentService({ db, network = null, pastesLocal = false, limitsif (t.visibility === 'locked' && !moderator(v)) fail(403, 'thread.locked', 'This comment thread is locked');const message = cleanMessage(body.message);++ let parent = null;let parentId = null;if (body.parent_id != null && body.parent_id !== '') {-- const parent = /^\d{1,15}$/.test(String(body.parent_id)) ? store.getComment(db, Number(body.parent_id)) : null;++ parent = /^\d{1,15}$/.test(String(body.parent_id)) ? store.getComment(db, Number(body.parent_id)) : null;if (!parent || parent.thread_id !== t.id || parent.deleted_at) fail(400, 'comment.invalid_parent', 'Invalid parent comment');// One level of nesting: a reply to a reply joins the top-level comment's replies.parentId = parent.parent_id || parent.id;@@@ -234,6 +246,13 @@ function createCommentService({ db, network = null, pastesLocal = false, limitsconst origin = v.origin === 'ai' ? 'ai' : 'user';const author = origin === 'ai' ? null : (v.subject || null);++ // Platform blocks: no reply to a comment whose author blocked you (the one answered, and the++ // top-level comment it joins), no comment on a paste or post whose owner blocked you.++ if (parent) {++ const top = parent.parent_id ? store.getComment(db, parent.parent_id) : null;++ blocks.refuseIfBlocked(db, [parent.author_subject, top && top.author_subject], author, 'reply to this comment');++ }++ blocks.refuseIfBlocked(db, [entityOwner(t)], author, `comment on this ${t.ref_type}`, 'its owner');if (!author && origin !== 'ai' && !moderator(v) && signedInOnly(t)) fail(401, 'auth.required', 'Sign in to comment here');let anonName = null;if (!author && origin !== 'ai') {@@@ -10,7 +10,8 @@* Writers: a person (browser JWT subject, or a service with community.post.create naming them in* X-OV-Subject), or AI output from a service (X-OV-Origin: ai — stored with origin 'ai'* and no author, labelled as AI). Anonymous visitors read; they do not post.-- * Locked threads take no replies or votes except from moderators. Authors edit and delete their++ * Locked threads take no replies or votes except from moderators. Nobody replies in a thread whose++ * author blocked them on the network (identity/blocks.js). Authors edit and delete their* own posts; moderators (identity/capabilities.js discussionModerator) any, and pin/lock.* Edits keep every revision in post_versions.*@@@ -38,6 +39,7 @@ const { renderMarkdown, markdownToText } = require('../render/markdown');const { isUserSubject } = require('../vip');const { stripImageMetadata } = require('../media/strip-metadata');const reactions = require('./reactions');++const blocks = require('../identity/blocks');const STYLES = ['feed', 'forum'];const SPACE_SLUG = /^[a-z0-9][a-z0-9-]{1,39}$/;@@@ -736,6 +738,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vimayPostIn(v, space);await requireMembership(v, space, thread);if (thread.locked && !moderator(v)) fail(403, 'thread.locked', 'This thread is locked');++ // Platform blocks: nobody replies in a thread whose author blocked them.++ blocks.refuseIfBlocked(db, [thread.author_subject], w.author, 'reply in this thread');const text = cleanBody(body.body != null ? body.body : body.body_markdown);const images = claimable(w, body.attachments);const pasteRows = claimPastes(body.pastes);nnew file mode 100644@@@ -0,0 +1,68 @@++'use strict';++/**++ * Platform blocks (roadmap WS-E task 5; Contracts 0.49.0 network.block.changed).++ *++ * People block each other once, on OpenVibe.Network, and Community honours it: nobody replies in a forum++ * thread whose author blocked them, replies to a comment whose author blocked them, or comments on a paste++ * (or a forum post's comment thread) whose owner blocked them. Network announces every change as++ * network.block.changed (blocker, blocked, active, a per-pair revision); ../pulse/consumer.js applies each++ * one here, keeping only the newest revision per (blocker, blocked), so a late or replayed event never++ * undoes a newer one. Moderation (hide, lock, delete, statuses) is never affected by a block.++ */++const { validate } = require('openvibe-contracts');++const { fail } = require('../http/v1');++++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const ready = new WeakSet();++++function ensureSchema(db) {++ if (ready.has(db)) return;++ db.exec(`CREATE TABLE IF NOT EXISTS network_blocks (++ blocker_subject TEXT NOT NULL,++ blocked_subject TEXT NOT NULL,++ active INTEGER NOT NULL,++ revision INTEGER NOT NULL,++ updated_at INTEGER NOT NULL,++ PRIMARY KEY (blocker_subject, blocked_subject)++ );++ CREATE INDEX IF NOT EXISTS idx_network_blocks_blocked ON network_blocks(blocked_subject, active);`);++ ready.add(db);++}++++/** The payload of a network.block.changed envelope from Network, or an 'ignored:*' reason. */++function payloadOf(event) {++ if (!event || event.source !== 'network') return 'ignored:source';++ const p = event.payload;++ if (!p || typeof p !== 'object' || !validate('network.block.changed@1', p).valid) return 'ignored:payload';++ return p;++}++++/** Apply one change when it is newer than what is kept for its pair. → 'blocks:blocked' | 'blocks:unblocked' | 'blocks:unchanged' */++function apply(db, p, now = Date.now()) {++ ensureSchema(db);++ const r = db.prepare(`INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, ?, ?, ?)++ ON CONFLICT(blocker_subject, blocked_subject) DO UPDATE SET active = excluded.active, revision = excluded.revision, updated_at = excluded.updated_at++ WHERE excluded.revision > network_blocks.revision`).run(p.blocker, p.blocked, p.active ? 1 : 0, p.revision, now);++ if (!r.changes) return 'blocks:unchanged';++ return p.active ? 'blocks:blocked' : 'blocks:unblocked';++}++++/** Did `blocker` block `blocked` (subjects)? */++function hasBlocked(db, blocker, blocked) {++ if (!SUBJECT_RE.test(String(blocker || '')) || !SUBJECT_RE.test(String(blocked || '')) || blocker === blocked) return false;++ ensureSchema(db);++ return !!db.prepare('SELECT 1 FROM network_blocks WHERE blocker_subject = ? AND blocked_subject = ? AND active = 1').get(blocker, blocked);++}++++/**++ * Refuse (403 community.blocked) when any of `owners` blocked `writer`. `what` names the thing in the++ * message: "You cannot <what>: its author blocked you".++ */++function refuseIfBlocked(db, owners, writer, what, whose = 'its author') {++ if (!writer) return;++ for (const owner of owners) {++ if (hasBlocked(db, owner, writer)) fail(403, 'community.blocked', `You cannot ${what}: ${whose} blocked you`);++ }++}++++module.exports = { ensureSchema, payloadOf, apply, hasBlocked, refuseIfBlocked };@@@ -21,6 +21,7 @@ const events = require('../events');const store = require('./store');const { stripImageMetadata } = require('../media/strip-metadata');const { capabilities } = require('openvibe-contracts');++const blocks = require('../identity/blocks');const DEFAULT_LIMITS = {maxSizeKb: 512,@@@ -551,11 +552,15 @@ function createPasteService({ db, network = null, media = null, config = {}, limconst rateKey = author ? `s:${author}` : `ip:${ctx.ip || 'unknown'}`;if (limited(v)) commentRateCheck(rateKey, message);const parentId = body.parent_id ? parseInt(body.parent_id, 10) : null;++ let parent = null;if (parentId) {-- const parent = store.getComment(db, parentId);++ parent = store.getComment(db, parentId);if (!parent || parent.paste_id !== p.id) fail(400, 'Invalid parent comment');if (parent.parent_id) fail(400, 'Cannot reply to a reply — reply to the original comment instead');}++ // Platform blocks: no comment on a paste whose owner blocked you, no reply to a comment whose author did.++ if (author && blocks.hasBlocked(db, p.owner_subject, author)) fail(403, 'You cannot comment on this paste: its owner blocked you', { code: 'community.blocked' });++ if (author && parent && blocks.hasBlocked(db, parent.author_subject, author)) fail(403, 'You cannot reply to this comment: its author blocked you', { code: 'community.blocked' });const c = store.createComment(db, { paste_id: p.id, author_subject: author, anon_name: anonName, parent_id: parentId, message });if (limited(v)) commentRecorded(rateKey, message);return { comment: shapeComment(c, await projectionsFor([author])) };@@@ -15,6 +15,9 @@* And VIP convergence: vip.membership.changed (source vip) drops the member's cached members-only* answers for that creator at once (the VIP gate's cache handleEvent) instead of waiting out its TTL.*++ * And platform blocks (WS-E task 5): network.block.changed (source network) updates the network_blocks++ * projection (../identity/blocks.js; the newest revision per pair wins) that replies and comments honour.++ ** Exactly once: the openvibe-sdk inbox claims (consumer, event_id) in the same transaction as the Pulse* write. Signature v2 only (parseDelivery requireV2) under COMMUNITY_EVENTS_SECRET (comma-separated for* rotation, 32+ characters each); unset = 503. Loopback only: a request carrying a forwarding header@@@ -24,9 +27,10 @@ const express = require('express');const { http, serviceAuth } = require('openvibe-contracts');const { parseDelivery, createInbox } = require('openvibe-sdk/events');const store = require('./store');++const blocks = require('../identity/blocks');const CONSUMER = 'community';--const TOPICS = Object.freeze(['live.stream.started', 'blog.post.published', 'wiki.page.published', 'news.story.published', 'vip.membership.changed', 'network.user.token_valid_after']);++const TOPICS = Object.freeze(['live.stream.started', 'blog.post.published', 'wiki.page.published', 'news.story.published', 'vip.membership.changed', 'network.user.token_valid_after', 'network.block.changed']);const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;const clean = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);const httpsUrl = (u) => (typeof u === 'string' && /^https:\/\/[a-z0-9.-]+\.[a-z]{2,}(\/|$)/i.test(u) ? u.slice(0, 500) : null);@@@ -85,6 +89,19 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =if (r.duplicate) stats.duplicates++; else stats.applied++;return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });}++ if (event.event_type === 'network.block.changed') {++ const p = blocks.payloadOf(event);++ if (typeof p === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: p }); }++ try {++ const r = inbox.once(CONSUMER, event.event_id, () => ({ outcome: blocks.apply(db, p, now()) }));++ if (r.duplicate) stats.duplicates++; else stats.applied++;++ return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });++ } catch (err) {++ stats.failed++;++ log.error(`[Pulse consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);++ return problem(500, 'community.event_failed', 'processing failed; it will be retried');++ }++ }if (event.event_type === 'vip.membership.changed') {if (event.source !== 'vip' || !vipCache) { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: event.source !== 'vip' ? 'ignored:source' : 'ignored:no_gate' }); }const r = inbox.once(CONSUMER, event.event_id, () => ({ outcome: vipCache.handleEvent(event) ? 'vip:invalidated' : 'vip:unchanged' }));nnew file mode 100644@@@ -0,0 +1,144 @@++'use strict';++/**++ * Platform blocks in Community (WS-E task 5, Contracts 0.49.0 network.block.changed): a signed delivery++ * updates the projection (newest revision per pair wins; redeliveries, older revisions, other sources and++ * malformed payloads change nothing). While alex has blocked sam, sam cannot reply in alex's forum threads,++ * reply to alex's comments (directly, or through a reply that joins alex's comment), comment on alex's++ * paste (comment threads and the paste API) or reply to alex's paste comments: 403 community.blocked in++ * the API, the plain message on the no-JS forms. Others are untouched; an unblock lifts it.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++const { boot, check, done } = require('./helpers/app');++++const SECRET = `whsec_${'cd'.repeat(32)}`;++++(async () => {++ process.env.COMMUNITY_EVENTS_SECRET = SECRET;++ const t = await boot({++ authority: 'community', pasteLimits: { cooldownSeconds: 0, commentCooldownSeconds: 0 },++ appOpts: {++ commentLimits: { comments: { cooldownSec: 0, perMinute: 1000 } },++ forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 },++ },++ });++ const net = t.network;++ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });++ const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });++ const kim = net.addUser({ network_user_id: 11, username: 'kim', display_name: 'Kim' });++ const jwtOf = (u, id) => net.sign({ id, subject_id: u.subject_id, username: u.username, display_name: u.display_name, role: 'user' });++ const alexJwt = jwtOf(alex, 7), samJwt = jwtOf(sam, 9), kimJwt = jwtOf(kim, 11);++ const call = (path, { method = 'GET', cookie, json, form } = {}) => {++ const headers = {};++ let body;++ if (json !== undefined) { headers['content-type'] = 'application/json'; body = JSON.stringify(json); }++ if (form !== undefined) { headers['content-type'] = 'application/x-www-form-urlencoded'; body = new URLSearchParams(form).toString(); }++ return t.get(path, { method, headers, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });++ };++ const deliver = (event) => {++ const body = JSON.stringify({ event, seq: 1 });++ return fetch(`${t.base}/internal/events`, { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, SECRET) } }).then(async (r) => ({ status: r.status, json: await r.json() }));++ };++ const block = (blocker, blocked, active, revision, over = {}) => ({++ event_id: ids.newId('event'), event_type: 'network.block.changed', version: 1, source: 'network', visibility: 'internal', timestamp: new Date().toISOString(),++ actor: { type: 'user', id: blocker }, subject: { type: 'user', id: blocker },++ payload: { blocker, blocked, active, revision, at: new Date().toISOString() }, ...over,++ });++++ let threadSlug, alexPaste, kimPaste, streamThread, pasteThread, alexComment, kimReply;++++ await check('Community subscribes to network.block.changed; the projection keeps the newest revision per pair', async () => {++ assert.ok(require('../server/pulse/consumer').TOPICS.includes('network.block.changed'));++ const first = block(alex.subject_id, sam.subject_id, true, 3);++ let r = await deliver(first);++ assert.deepStrictEqual([r.status, r.json.outcome], [200, 'blocks:blocked']);++ assert.strictEqual((await deliver(first)).json.duplicate, true, 'a redelivery is a no-op');++ r = await deliver(block(alex.subject_id, sam.subject_id, false, 2));++ assert.strictEqual(r.json.outcome, 'blocks:unchanged', 'an older revision never wins');++ r = await deliver(block(alex.subject_id, sam.subject_id, false, 9, { source: 'live' }));++ assert.strictEqual(r.json.outcome, 'ignored:source');++ const bad = block(alex.subject_id, sam.subject_id, false, 9); bad.payload.blocked = 'sam';++ assert.strictEqual((await deliver(bad)).json.outcome, 'ignored:payload');++ const blocks = require('../server/identity/blocks');++ assert.ok(blocks.hasBlocked(t.db, alex.subject_id, sam.subject_id));++ assert.ok(!blocks.hasBlocked(t.db, sam.subject_id, alex.subject_id), 'one direction');++ });++++ await check('forum: no reply in a thread whose author blocked you (API problem and no-JS form); others reply', async () => {++ const th = await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: alexJwt, json: { title: 'Alex asks', body: 'what do you think?' } });++ assert.strictEqual(th.status, 201, th.text);++ threadSlug = th.json().thread.slug;++ let r = await call(`/api/v1/spaces/general/threads/${threadSlug}/posts`, { method: 'POST', cookie: samJwt, json: { body: 'let me in' } });++ assert.strictEqual(r.status, 403);++ assert.match(r.headers.get('content-type'), /application\/problem\+json/);++ assert.deepStrictEqual([r.json().code, r.json().detail], ['community.blocked', 'You cannot reply in this thread: its author blocked you']);++ r = await call(`/api/v1/spaces/general/threads/${threadSlug}/posts`, { method: 'POST', cookie: kimJwt, json: { body: 'kim here' } });++ assert.strictEqual(r.status, 201, r.text);++ // The no-JS form: the thread page again, the plain message in the reply form, the draft kept.++ r = await call(`/s/general/t/${threadSlug}/reply`, { method: 'POST', cookie: samJwt, form: { body: 'my <draft>' } });++ assert.strictEqual(r.status, 403);++ assert.ok(r.text.includes('<p class="alert alert-error" role="alert">You cannot reply in this thread: its author blocked you</p>'), 'the message in the form');++ assert.ok(r.text.includes('my <draft></textarea>'), 'the draft kept, escaped');++ // Sam's own thread: alex (the blocker) is not blocked by sam, so alex may reply there.++ const own = await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Sam asks', body: 'anyone?' } });++ r = await call(`/api/v1/spaces/general/threads/${own.json().thread.slug}/posts`, { method: 'POST', cookie: alexJwt, json: { body: 'me' } });++ assert.strictEqual(r.status, 201);++ });++++ await check('comment threads: no reply to a comment whose author blocked you, nor to a reply that joins it', async () => {++ streamThread = (await call('/api/v1/comments/threads/resolve', { method: 'POST', cookie: kimJwt, json: { ref: { service: 'live', type: 'stream', id: '77' } } })).json().thread;++ const post = (who, json) => call(`/api/v1/comments/threads/${streamThread.id}/comments`, { method: 'POST', cookie: who, json });++ alexComment = (await post(alexJwt, { message: 'alex says' })).json().comment;++ kimReply = (await post(kimJwt, { message: 'kim answers', parent_id: alexComment.id })).json().comment;++ let r = await post(samJwt, { message: 'sam answers alex', parent_id: alexComment.id });++ assert.deepStrictEqual([r.status, r.json().code, r.json().detail], [403, 'community.blocked', 'You cannot reply to this comment: its author blocked you']);++ r = await post(samJwt, { message: 'sam answers kim', parent_id: kimReply.id });++ assert.strictEqual(r.status, 403, 'a reply to a reply joins alex\'s comment');++ r = await post(samJwt, { message: 'sam on the stream' });++ assert.strictEqual(r.status, 201, 'the thread itself is nobody\'s');++ const samTop = r.json().comment;++ r = await post(kimJwt, { message: 'kim answers sam', parent_id: samTop.id });++ assert.strictEqual(r.status, 201);++ r = await post(alexJwt, { message: 'alex answers sam', parent_id: samTop.id });++ assert.strictEqual(r.status, 201, 'the blocker is not blocked');++ });++++ await check('pastes: no comment on a paste whose owner blocked you (comment threads and the paste API), no reply to their comments', async () => {++ alexPaste = (await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { content: 'alex paste' } })).json().slug;++ kimPaste = (await call('/api/pastes', { method: 'POST', cookie: kimJwt, json: { content: 'kim paste' } })).json().slug;++ // The paste API (the paste page's comments).++ let r = await call(`/api/pastes/${alexPaste}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'hi alex' } });++ assert.deepStrictEqual([r.status, r.json()], [403, { error: 'You cannot comment on this paste: its owner blocked you', code: 'community.blocked' }]);++ r = await call(`/api/pastes/${alexPaste}/comments`, { method: 'POST', cookie: kimJwt, json: { message: 'nice' } });++ assert.strictEqual(r.status, 201, r.text);++ const onKim = (await call(`/api/pastes/${kimPaste}/comments`, { method: 'POST', cookie: alexJwt, json: { message: 'alex on kim' } })).json().comment;++ r = await call(`/api/pastes/${kimPaste}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'sam replies', parent_id: onKim.id } });++ assert.deepStrictEqual([r.status, r.json().code], [403, 'community.blocked'], 'alex\'s comment on kim\'s paste');++ r = await call(`/api/pastes/${kimPaste}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'sam on kim' } });++ assert.strictEqual(r.status, 201);++ // A comment thread opened on alex's paste.++ pasteThread = (await call('/api/v1/comments/threads/resolve', { method: 'POST', cookie: kimJwt, json: { ref: { service: 'community', type: 'paste', id: alexPaste } } })).json().thread;++ r = await call(`/api/v1/comments/threads/${pasteThread.id}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'hi' } });++ assert.deepStrictEqual([r.status, r.json().detail], [403, 'You cannot comment on this paste: its owner blocked you']);++ // Its no-JS page shows the message in the form.++ r = await call(`/c/${pasteThread.id}`, { method: 'POST', cookie: samJwt, form: { message: 'from the page' } });++ assert.strictEqual(r.status, 403);++ assert.ok(r.text.includes('You cannot comment on this paste: its owner blocked you'));++ assert.ok(r.text.includes('from the page</textarea>'));++ r = await call(`/c/${pasteThread.id}`, { method: 'POST', cookie: kimJwt, form: { message: 'kim from the page' } });++ assert.strictEqual(r.status, 303);++ });++++ await check('an unblock lifts it', async () => {++ const r = await deliver(block(alex.subject_id, sam.subject_id, false, 4));++ assert.strictEqual(r.json.outcome, 'blocks:unblocked');++ assert.strictEqual((await call(`/api/v1/spaces/general/threads/${threadSlug}/posts`, { method: 'POST', cookie: samJwt, json: { body: 'hello again' } })).status, 201);++ assert.strictEqual((await call(`/api/v1/comments/threads/${streamThread.id}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'hi alex', parent_id: alexComment.id } })).status, 201);++ assert.strictEqual((await call(`/api/pastes/${alexPaste}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'hi alex' } })).status, 201);++ assert.strictEqual((await call(`/api/v1/comments/threads/${pasteThread.id}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'hi' } })).status, 201);++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -455,6 +455,20 @@ per source service/type/id).
@@ -455,6 +455,20 @@ per source service/type/id).
keyset cursor), and the `/pulse` page. AI items carry `label: 'AI'` and never an actor —
keyset cursor), and the `/pulse` page. AI items carry `label: 'AI'` and never an actor —
they are never attributed to a person (roadmap §33); system items name no one either.
they are never attributed to a person (roadmap §33); system items name no one either.
+## Platform blocks
+
+People block each other once, on OpenVibe.Network (roadmap WS-E task 5, Contracts 0.49.0), and Community
+honours it (`server/identity/blocks.js`): nobody replies in a forum thread whose author blocked them, replies
+to a comment whose author blocked them (or to a reply that joins that comment), or comments on a paste (paste
+API or comment thread) or forum post whose owner blocked them. The API answers 403 `community.blocked` (a
+problem under `/api/v1`, `{ error, code }` under `/api/pastes`); the no-JS forms show the message with the
+draft kept. Anonymous comments carry no subject and so are not affected; moderation never is.
+
+The blocks come from `network.block.changed` (POST `/internal/events`, `server/pulse/consumer.js`) into the
+`network_blocks` projection, newest revision per pair. Boot creates the missing subscription
+(`startSubscriptions`, `COMMUNITY_EVENTS_SECRET`, `EVENTS_URL`); a new subscription gets no history, so
+blocks made before it existed need a replay from OpenVibe.Events.
+
## Capabilities
## Capabilities
Community checks service tokens against these capabilities (manifests in
Community checks service tokens against these capabilities (manifests in
@@ -468,7 +482,7 @@ Community checks service tokens against these capabilities (manifests in
@@ -468,7 +482,7 @@ Community checks service tokens against these capabilities (manifests in
| `community.pulse.write` | active in contracts (v0.7.0) | publishing to Pulse |
| `community.pulse.write` | active in contracts (v0.7.0) | publishing to Pulse |
| `community.post.create` | active in contracts (v0.7.0) | forum writes |
| `community.post.create` | active in contracts (v0.7.0) | forum writes |
-This repository pins `openvibe-contracts` v0.33.0, which knows every id above, so they all go
+This repository pins `openvibe-contracts` v0.49.0, which knows every id above, so they all go
through the library's `capabilities.check`. `server/identity/capabilities.js` still decides an id
through the library's `capabilities.check`. `server/identity/capabilities.js` still decides an id
the installed contracts do not know locally, with the library's own matching rule (the exact id
the installed contracts do not know locally, with the library's own matching rule (the exact id
or a `prefix.*` grant).
or a `prefix.*` grant).
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"highlight.js": "^11.11.1",
"highlight.js": "^11.11.1",
"multer": "^2.4.0",
"multer": "^2.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.44.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
},
},
@@ -1057,9 +1057,9 @@
@@ -1057,9 +1057,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.44.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.44.0",
- "integrity": "sha512-VMjnJSwU2dnk9KstCHQDKfY6UysSk2fWK1wdLp15Via49/hbeaXRTa4XIjO3d82fYizj0rJpi9GrK+AJRskjtQ==",
+ "version": "0.49.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
+ "integrity": "sha512-lapsLoBm3ZpndgiOMELdCBh2PJtuuyqj6SUhQsfwJkxnLdQfyhXfRFgiuLh+iQrg4kqtG9611191Mp8zAfk7fQ==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"highlight.js": "^11.11.1",
"highlight.js": "^11.11.1",
"multer": "^2.4.0",
"multer": "^2.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.44.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
},
},
@@ -16,6 +16,8 @@
@@ -16,6 +16,8 @@
* - comment: people (browser or service X-OV-Subject), anonymous with an anon_name, or AI output
* - 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).
* 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).
* Threads of the types in SIGNED_IN_ONLY take no anonymous comments (the owner product's rule).
+ * Platform blocks (identity/blocks.js): no reply to a comment whose author blocked you, no comment
+ * on a Community paste or post whose owner blocked you (403 community.blocked).
* - edit: the comment's author only (edited_at records it).
* - edit: the comment's author only (edited_at records it).
* - delete: the comment's author, or a moderator.
* - delete: the comment's author, or a moderator.
* - one comment with its thread (getComment): services only — comment ids are sequential, so a
* - one comment with its thread (getComment): services only — comment ids are sequential, so a
@@ -37,6 +39,7 @@ const { fail, isoTime } = require('../http/v1');
@@ -37,6 +39,7 @@ const { fail, isoTime } = require('../http/v1');
const { createAuthors } = require('../identity/authors');
const { createAuthors } = require('../identity/authors');
const { createPersonLimiter } = require('../limits');
const { createPersonLimiter } = require('../limits');
const { discussionModerator: moderator } = require('../identity/capabilities');
const { discussionModerator: moderator } = require('../identity/capabilities');
+const blocks = require('../identity/blocks');
/**
/**
* The entity types a browser may open a thread for (services with community.comment.write: any).
* The entity types a browser may open a thread for (services with community.comment.write: any).
@@ -153,6 +156,14 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
@@ -153,6 +156,14 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
const signedInOnly = (t) => (SIGNED_IN_ONLY[t.ref_service] || []).includes(t.ref_type);
const signedInOnly = (t) => (SIGNED_IN_ONLY[t.ref_service] || []).includes(t.ref_type);
+ /** Who owns a thread's Community entity (a paste's owner, a forum post's author), or null. */
+ function entityOwner(t) {
+ if (t.ref_service !== 'community') return null;
+ if (t.ref_type === 'paste' && pastesLocal) { const p = pasteStore.getBySlug(db, t.ref_id); return p ? p.owner_subject || null : null; }
+ if (t.ref_type === 'post' && /^\d{1,15}$/.test(String(t.ref_id))) { const r = db.prepare('SELECT author_subject FROM posts WHERE id = ?').get(Number(t.ref_id)); return r ? r.author_subject || null : null; }
+ return null;
+ }
+
function cleanMessage(raw) {
function cleanMessage(raw) {
const message = String(raw == null ? '' : raw).replace(/\u0000/g, '').trim();
const message = String(raw == null ? '' : raw).replace(/\u0000/g, '').trim();
if (!message) fail(400, 'comment.empty', 'Comment cannot be empty');
if (!message) fail(400, 'comment.empty', 'Comment cannot be empty');
@@ -224,9 +235,10 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
@@ -224,9 +235,10 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
if (t.visibility === 'locked' && !moderator(v)) fail(403, 'thread.locked', 'This comment thread is locked');
if (t.visibility === 'locked' && !moderator(v)) fail(403, 'thread.locked', 'This comment thread is locked');
const message = cleanMessage(body.message);
const message = cleanMessage(body.message);
+ let parent = null;
let parentId = null;
let parentId = null;
if (body.parent_id != null && body.parent_id !== '') {
if (body.parent_id != null && body.parent_id !== '') {
- const parent = /^\d{1,15}$/.test(String(body.parent_id)) ? store.getComment(db, Number(body.parent_id)) : null;
+ parent = /^\d{1,15}$/.test(String(body.parent_id)) ? store.getComment(db, Number(body.parent_id)) : null;
if (!parent || parent.thread_id !== t.id || parent.deleted_at) fail(400, 'comment.invalid_parent', 'Invalid parent comment');
if (!parent || parent.thread_id !== t.id || parent.deleted_at) fail(400, 'comment.invalid_parent', 'Invalid parent comment');
// One level of nesting: a reply to a reply joins the top-level comment's replies.
// One level of nesting: a reply to a reply joins the top-level comment's replies.
parentId = parent.parent_id || parent.id;
parentId = parent.parent_id || parent.id;
@@ -234,6 +246,13 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
@@ -234,6 +246,13 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
const origin = v.origin === 'ai' ? 'ai' : 'user';
const origin = v.origin === 'ai' ? 'ai' : 'user';
const author = origin === 'ai' ? null : (v.subject || null);
const author = origin === 'ai' ? null : (v.subject || null);
+ // Platform blocks: no reply to a comment whose author blocked you (the one answered, and the
+ // top-level comment it joins), no comment on a paste or post whose owner blocked you.
+ if (parent) {
+ const top = parent.parent_id ? store.getComment(db, parent.parent_id) : null;
+ blocks.refuseIfBlocked(db, [parent.author_subject, top && top.author_subject], author, 'reply to this comment');
+ }
+ blocks.refuseIfBlocked(db, [entityOwner(t)], author, `comment on this ${t.ref_type}`, 'its owner');
if (!author && origin !== 'ai' && !moderator(v) && signedInOnly(t)) fail(401, 'auth.required', 'Sign in to comment here');
if (!author && origin !== 'ai' && !moderator(v) && signedInOnly(t)) fail(401, 'auth.required', 'Sign in to comment here');
let anonName = null;
let anonName = null;
if (!author && origin !== 'ai') {
if (!author && origin !== 'ai') {
@@ -10,7 +10,8 @@
@@ -10,7 +10,8 @@
* Writers: a person (browser JWT subject, or a service with community.post.create naming them in
* Writers: a person (browser JWT subject, or a service with community.post.create naming them in
* X-OV-Subject), or AI output from a service (X-OV-Origin: ai — stored with origin 'ai'
* X-OV-Subject), or AI output from a service (X-OV-Origin: ai — stored with origin 'ai'
* and no author, labelled as AI). Anonymous visitors read; they do not post.
* and no author, labelled as AI). Anonymous visitors read; they do not post.
- * Locked threads take no replies or votes except from moderators. Authors edit and delete their
+ * Locked threads take no replies or votes except from moderators. Nobody replies in a thread whose
+ * author blocked them on the network (identity/blocks.js). Authors edit and delete their
* own posts; moderators (identity/capabilities.js discussionModerator) any, and pin/lock.
* own posts; moderators (identity/capabilities.js discussionModerator) any, and pin/lock.
* Edits keep every revision in post_versions.
* Edits keep every revision in post_versions.
*
*
@@ -38,6 +39,7 @@ const { renderMarkdown, markdownToText } = require('../render/markdown');
@@ -38,6 +39,7 @@ const { renderMarkdown, markdownToText } = require('../render/markdown');
const { isUserSubject } = require('../vip');
const { isUserSubject } = require('../vip');
const { stripImageMetadata } = require('../media/strip-metadata');
const { stripImageMetadata } = require('../media/strip-metadata');
const reactions = require('./reactions');
const reactions = require('./reactions');
+const blocks = require('../identity/blocks');
const STYLES = ['feed', 'forum'];
const STYLES = ['feed', 'forum'];
const SPACE_SLUG = /^[a-z0-9][a-z0-9-]{1,39}$/;
const SPACE_SLUG = /^[a-z0-9][a-z0-9-]{1,39}$/;
@@ -736,6 +738,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -736,6 +738,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
mayPostIn(v, space);
mayPostIn(v, space);
await requireMembership(v, space, thread);
await requireMembership(v, space, thread);
if (thread.locked && !moderator(v)) fail(403, 'thread.locked', 'This thread is locked');
if (thread.locked && !moderator(v)) fail(403, 'thread.locked', 'This thread is locked');
+ // Platform blocks: nobody replies in a thread whose author blocked them.
+ blocks.refuseIfBlocked(db, [thread.author_subject], w.author, 'reply in this thread');
const text = cleanBody(body.body != null ? body.body : body.body_markdown);
const text = cleanBody(body.body != null ? body.body : body.body_markdown);
const images = claimable(w, body.attachments);
const images = claimable(w, body.attachments);
const pasteRows = claimPastes(body.pastes);
const pasteRows = claimPastes(body.pastes);
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+'use strict';
+/**
+ * Platform blocks (roadmap WS-E task 5; Contracts 0.49.0 network.block.changed).
+ *
+ * People block each other once, on OpenVibe.Network, and Community honours it: nobody replies in a forum
+ * thread whose author blocked them, replies to a comment whose author blocked them, or comments on a paste
+ * (or a forum post's comment thread) whose owner blocked them. Network announces every change as
+ * network.block.changed (blocker, blocked, active, a per-pair revision); ../pulse/consumer.js applies each
+ * one here, keeping only the newest revision per (blocker, blocked), so a late or replayed event never
+ * undoes a newer one. Moderation (hide, lock, delete, statuses) is never affected by a block.
+ */
+const { validate } = require('openvibe-contracts');
+const { fail } = require('../http/v1');
+
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const ready = new WeakSet();
+
+function ensureSchema(db) {
+ if (ready.has(db)) return;
+ db.exec(`CREATE TABLE IF NOT EXISTS network_blocks (
+ blocker_subject TEXT NOT NULL,
+ blocked_subject TEXT NOT NULL,
+ active INTEGER NOT NULL,
+ revision INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ PRIMARY KEY (blocker_subject, blocked_subject)
+ );
+ CREATE INDEX IF NOT EXISTS idx_network_blocks_blocked ON network_blocks(blocked_subject, active);`);
+ ready.add(db);
+}
+
+/** The payload of a network.block.changed envelope from Network, or an 'ignored:*' reason. */
+function payloadOf(event) {
+ if (!event || event.source !== 'network') return 'ignored:source';
+ const p = event.payload;
+ if (!p || typeof p !== 'object' || !validate('network.block.changed@1', p).valid) return 'ignored:payload';
+ return p;
+}
+
+/** Apply one change when it is newer than what is kept for its pair. → 'blocks:blocked' | 'blocks:unblocked' | 'blocks:unchanged' */
+function apply(db, p, now = Date.now()) {
+ ensureSchema(db);
+ const r = db.prepare(`INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT(blocker_subject, blocked_subject) DO UPDATE SET active = excluded.active, revision = excluded.revision, updated_at = excluded.updated_at
+ WHERE excluded.revision > network_blocks.revision`).run(p.blocker, p.blocked, p.active ? 1 : 0, p.revision, now);
+ if (!r.changes) return 'blocks:unchanged';
+ return p.active ? 'blocks:blocked' : 'blocks:unblocked';
+}
+
+/** Did `blocker` block `blocked` (subjects)? */
+function hasBlocked(db, blocker, blocked) {
+ if (!SUBJECT_RE.test(String(blocker || '')) || !SUBJECT_RE.test(String(blocked || '')) || blocker === blocked) return false;
+ ensureSchema(db);
+ return !!db.prepare('SELECT 1 FROM network_blocks WHERE blocker_subject = ? AND blocked_subject = ? AND active = 1').get(blocker, blocked);
+}
+
+/**
+ * Refuse (403 community.blocked) when any of `owners` blocked `writer`. `what` names the thing in the
+ * message: "You cannot <what>: its author blocked you".
+ */
+function refuseIfBlocked(db, owners, writer, what, whose = 'its author') {
+ if (!writer) return;
+ for (const owner of owners) {
+ if (hasBlocked(db, owner, writer)) fail(403, 'community.blocked', `You cannot ${what}: ${whose} blocked you`);
+ }
+}
+
+module.exports = { ensureSchema, payloadOf, apply, hasBlocked, refuseIfBlocked };
@@ -21,6 +21,7 @@ const events = require('../events');
@@ -21,6 +21,7 @@ const events = require('../events');
const store = require('./store');
const store = require('./store');
const { stripImageMetadata } = require('../media/strip-metadata');
const { stripImageMetadata } = require('../media/strip-metadata');
const { capabilities } = require('openvibe-contracts');
const { capabilities } = require('openvibe-contracts');
+const blocks = require('../identity/blocks');
const DEFAULT_LIMITS = {
const DEFAULT_LIMITS = {
maxSizeKb: 512,
maxSizeKb: 512,
@@ -551,11 +552,15 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -551,11 +552,15 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
const rateKey = author ? `s:${author}` : `ip:${ctx.ip || 'unknown'}`;
const rateKey = author ? `s:${author}` : `ip:${ctx.ip || 'unknown'}`;
if (limited(v)) commentRateCheck(rateKey, message);
if (limited(v)) commentRateCheck(rateKey, message);
const parentId = body.parent_id ? parseInt(body.parent_id, 10) : null;
const parentId = body.parent_id ? parseInt(body.parent_id, 10) : null;
+ let parent = null;
if (parentId) {
if (parentId) {
- const parent = store.getComment(db, parentId);
+ parent = store.getComment(db, parentId);
if (!parent || parent.paste_id !== p.id) fail(400, 'Invalid parent comment');
if (!parent || parent.paste_id !== p.id) fail(400, 'Invalid parent comment');
if (parent.parent_id) fail(400, 'Cannot reply to a reply — reply to the original comment instead');
if (parent.parent_id) fail(400, 'Cannot reply to a reply — reply to the original comment instead');
}
}
+ // Platform blocks: no comment on a paste whose owner blocked you, no reply to a comment whose author did.
+ if (author && blocks.hasBlocked(db, p.owner_subject, author)) fail(403, 'You cannot comment on this paste: its owner blocked you', { code: 'community.blocked' });
+ if (author && parent && blocks.hasBlocked(db, parent.author_subject, author)) fail(403, 'You cannot reply to this comment: its author blocked you', { code: 'community.blocked' });
const c = store.createComment(db, { paste_id: p.id, author_subject: author, anon_name: anonName, parent_id: parentId, message });
const c = store.createComment(db, { paste_id: p.id, author_subject: author, anon_name: anonName, parent_id: parentId, message });
if (limited(v)) commentRecorded(rateKey, message);
if (limited(v)) commentRecorded(rateKey, message);
return { comment: shapeComment(c, await projectionsFor([author])) };
return { comment: shapeComment(c, await projectionsFor([author])) };
@@ -15,6 +15,9 @@
@@ -15,6 +15,9 @@
* And VIP convergence: vip.membership.changed (source vip) drops the member's cached members-only
* And VIP convergence: vip.membership.changed (source vip) drops the member's cached members-only
* answers for that creator at once (the VIP gate's cache handleEvent) instead of waiting out its TTL.
* answers for that creator at once (the VIP gate's cache handleEvent) instead of waiting out its TTL.
*
*
+ * And platform blocks (WS-E task 5): network.block.changed (source network) updates the network_blocks
+ * projection (../identity/blocks.js; the newest revision per pair wins) that replies and comments honour.
+ *
* Exactly once: the openvibe-sdk inbox claims (consumer, event_id) in the same transaction as the Pulse
* Exactly once: the openvibe-sdk inbox claims (consumer, event_id) in the same transaction as the Pulse
* write. Signature v2 only (parseDelivery requireV2) under COMMUNITY_EVENTS_SECRET (comma-separated for
* write. Signature v2 only (parseDelivery requireV2) under COMMUNITY_EVENTS_SECRET (comma-separated for
* rotation, 32+ characters each); unset = 503. Loopback only: a request carrying a forwarding header
* rotation, 32+ characters each); unset = 503. Loopback only: a request carrying a forwarding header
@@ -24,9 +27,10 @@ const express = require('express');
@@ -24,9 +27,10 @@ const express = require('express');
const { http, serviceAuth } = require('openvibe-contracts');
const { http, serviceAuth } = require('openvibe-contracts');
const { parseDelivery, createInbox } = require('openvibe-sdk/events');
const { parseDelivery, createInbox } = require('openvibe-sdk/events');
const store = require('./store');
const store = require('./store');
+const blocks = require('../identity/blocks');
const CONSUMER = 'community';
const CONSUMER = 'community';
-const TOPICS = Object.freeze(['live.stream.started', 'blog.post.published', 'wiki.page.published', 'news.story.published', 'vip.membership.changed', 'network.user.token_valid_after']);
+const TOPICS = Object.freeze(['live.stream.started', 'blog.post.published', 'wiki.page.published', 'news.story.published', 'vip.membership.changed', 'network.user.token_valid_after', 'network.block.changed']);
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
const clean = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
const clean = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
const httpsUrl = (u) => (typeof u === 'string' && /^https:\/\/[a-z0-9.-]+\.[a-z]{2,}(\/|$)/i.test(u) ? u.slice(0, 500) : null);
const httpsUrl = (u) => (typeof u === 'string' && /^https:\/\/[a-z0-9.-]+\.[a-z]{2,}(\/|$)/i.test(u) ? u.slice(0, 500) : null);
@@ -85,6 +89,19 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =
@@ -85,6 +89,19 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =
if (r.duplicate) stats.duplicates++; else stats.applied++;
if (r.duplicate) stats.duplicates++; else stats.applied++;
return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
}
}
+ if (event.event_type === 'network.block.changed') {
+ const p = blocks.payloadOf(event);
+ if (typeof p === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: p }); }
+ try {
+ const r = inbox.once(CONSUMER, event.event_id, () => ({ outcome: blocks.apply(db, p, now()) }));
+ if (r.duplicate) stats.duplicates++; else stats.applied++;
+ return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
+ } catch (err) {
+ stats.failed++;
+ log.error(`[Pulse consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);
+ return problem(500, 'community.event_failed', 'processing failed; it will be retried');
+ }
+ }
if (event.event_type === 'vip.membership.changed') {
if (event.event_type === 'vip.membership.changed') {
if (event.source !== 'vip' || !vipCache) { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: event.source !== 'vip' ? 'ignored:source' : 'ignored:no_gate' }); }
if (event.source !== 'vip' || !vipCache) { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: event.source !== 'vip' ? 'ignored:source' : 'ignored:no_gate' }); }
const r = inbox.once(CONSUMER, event.event_id, () => ({ outcome: vipCache.handleEvent(event) ? 'vip:invalidated' : 'vip:unchanged' }));
const r = inbox.once(CONSUMER, event.event_id, () => ({ outcome: vipCache.handleEvent(event) ? 'vip:invalidated' : 'vip:unchanged' }));
new file mode 100644
new file mode 100644
@@ -0,0 +1,144 @@
@@ -0,0 +1,144 @@
+'use strict';
+/**
+ * Platform blocks in Community (WS-E task 5, Contracts 0.49.0 network.block.changed): a signed delivery
+ * updates the projection (newest revision per pair wins; redeliveries, older revisions, other sources and
+ * malformed payloads change nothing). While alex has blocked sam, sam cannot reply in alex's forum threads,
+ * reply to alex's comments (directly, or through a reply that joins alex's comment), comment on alex's
+ * paste (comment threads and the paste API) or reply to alex's paste comments: 403 community.blocked in
+ * the API, the plain message on the no-JS forms. Others are untouched; an unblock lifts it.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+const { boot, check, done } = require('./helpers/app');
+
+const SECRET = `whsec_${'cd'.repeat(32)}`;
+
+(async () => {
+ process.env.COMMUNITY_EVENTS_SECRET = SECRET;
+ const t = await boot({
+ authority: 'community', pasteLimits: { cooldownSeconds: 0, commentCooldownSeconds: 0 },
+ appOpts: {
+ commentLimits: { comments: { cooldownSec: 0, perMinute: 1000 } },
+ forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 },
+ },
+ });
+ const net = t.network;
+ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
+ const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
+ const kim = net.addUser({ network_user_id: 11, username: 'kim', display_name: 'Kim' });
+ const jwtOf = (u, id) => net.sign({ id, subject_id: u.subject_id, username: u.username, display_name: u.display_name, role: 'user' });
+ const alexJwt = jwtOf(alex, 7), samJwt = jwtOf(sam, 9), kimJwt = jwtOf(kim, 11);
+ const call = (path, { method = 'GET', cookie, json, form } = {}) => {
+ const headers = {};
+ let body;
+ if (json !== undefined) { headers['content-type'] = 'application/json'; body = JSON.stringify(json); }
+ if (form !== undefined) { headers['content-type'] = 'application/x-www-form-urlencoded'; body = new URLSearchParams(form).toString(); }
+ return t.get(path, { method, headers, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });
+ };
+ const deliver = (event) => {
+ const body = JSON.stringify({ event, seq: 1 });
+ return fetch(`${t.base}/internal/events`, { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, SECRET) } }).then(async (r) => ({ status: r.status, json: await r.json() }));
+ };
+ const block = (blocker, blocked, active, revision, over = {}) => ({
+ event_id: ids.newId('event'), event_type: 'network.block.changed', version: 1, source: 'network', visibility: 'internal', timestamp: new Date().toISOString(),
+ actor: { type: 'user', id: blocker }, subject: { type: 'user', id: blocker },
+ payload: { blocker, blocked, active, revision, at: new Date().toISOString() }, ...over,
+ });
+
+ let threadSlug, alexPaste, kimPaste, streamThread, pasteThread, alexComment, kimReply;
+
+ await check('Community subscribes to network.block.changed; the projection keeps the newest revision per pair', async () => {
+ assert.ok(require('../server/pulse/consumer').TOPICS.includes('network.block.changed'));
+ const first = block(alex.subject_id, sam.subject_id, true, 3);
+ let r = await deliver(first);
+ assert.deepStrictEqual([r.status, r.json.outcome], [200, 'blocks:blocked']);
+ assert.strictEqual((await deliver(first)).json.duplicate, true, 'a redelivery is a no-op');
+ r = await deliver(block(alex.subject_id, sam.subject_id, false, 2));
+ assert.strictEqual(r.json.outcome, 'blocks:unchanged', 'an older revision never wins');
+ r = await deliver(block(alex.subject_id, sam.subject_id, false, 9, { source: 'live' }));
+ assert.strictEqual(r.json.outcome, 'ignored:source');
+ const bad = block(alex.subject_id, sam.subject_id, false, 9); bad.payload.blocked = 'sam';
+ assert.strictEqual((await deliver(bad)).json.outcome, 'ignored:payload');
+ const blocks = require('../server/identity/blocks');
+ assert.ok(blocks.hasBlocked(t.db, alex.subject_id, sam.subject_id));
+ assert.ok(!blocks.hasBlocked(t.db, sam.subject_id, alex.subject_id), 'one direction');
+ });
+
+ await check('forum: no reply in a thread whose author blocked you (API problem and no-JS form); others reply', async () => {
+ const th = await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: alexJwt, json: { title: 'Alex asks', body: 'what do you think?' } });
+ assert.strictEqual(th.status, 201, th.text);
+ threadSlug = th.json().thread.slug;
+ let r = await call(`/api/v1/spaces/general/threads/${threadSlug}/posts`, { method: 'POST', cookie: samJwt, json: { body: 'let me in' } });
+ assert.strictEqual(r.status, 403);
+ assert.match(r.headers.get('content-type'), /application\/problem\+json/);
+ assert.deepStrictEqual([r.json().code, r.json().detail], ['community.blocked', 'You cannot reply in this thread: its author blocked you']);
+ r = await call(`/api/v1/spaces/general/threads/${threadSlug}/posts`, { method: 'POST', cookie: kimJwt, json: { body: 'kim here' } });
+ assert.strictEqual(r.status, 201, r.text);
+ // The no-JS form: the thread page again, the plain message in the reply form, the draft kept.
+ r = await call(`/s/general/t/${threadSlug}/reply`, { method: 'POST', cookie: samJwt, form: { body: 'my <draft>' } });
+ assert.strictEqual(r.status, 403);
+ assert.ok(r.text.includes('<p class="alert alert-error" role="alert">You cannot reply in this thread: its author blocked you</p>'), 'the message in the form');
+ assert.ok(r.text.includes('my <draft></textarea>'), 'the draft kept, escaped');
+ // Sam's own thread: alex (the blocker) is not blocked by sam, so alex may reply there.
+ const own = await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Sam asks', body: 'anyone?' } });
+ r = await call(`/api/v1/spaces/general/threads/${own.json().thread.slug}/posts`, { method: 'POST', cookie: alexJwt, json: { body: 'me' } });
+ assert.strictEqual(r.status, 201);
+ });
+
+ await check('comment threads: no reply to a comment whose author blocked you, nor to a reply that joins it', async () => {
+ streamThread = (await call('/api/v1/comments/threads/resolve', { method: 'POST', cookie: kimJwt, json: { ref: { service: 'live', type: 'stream', id: '77' } } })).json().thread;
+ const post = (who, json) => call(`/api/v1/comments/threads/${streamThread.id}/comments`, { method: 'POST', cookie: who, json });
+ alexComment = (await post(alexJwt, { message: 'alex says' })).json().comment;
+ kimReply = (await post(kimJwt, { message: 'kim answers', parent_id: alexComment.id })).json().comment;
+ let r = await post(samJwt, { message: 'sam answers alex', parent_id: alexComment.id });
+ assert.deepStrictEqual([r.status, r.json().code, r.json().detail], [403, 'community.blocked', 'You cannot reply to this comment: its author blocked you']);
+ r = await post(samJwt, { message: 'sam answers kim', parent_id: kimReply.id });
+ assert.strictEqual(r.status, 403, 'a reply to a reply joins alex\'s comment');
+ r = await post(samJwt, { message: 'sam on the stream' });
+ assert.strictEqual(r.status, 201, 'the thread itself is nobody\'s');
+ const samTop = r.json().comment;
+ r = await post(kimJwt, { message: 'kim answers sam', parent_id: samTop.id });
+ assert.strictEqual(r.status, 201);
+ r = await post(alexJwt, { message: 'alex answers sam', parent_id: samTop.id });
+ assert.strictEqual(r.status, 201, 'the blocker is not blocked');
+ });
+
+ await check('pastes: no comment on a paste whose owner blocked you (comment threads and the paste API), no reply to their comments', async () => {
+ alexPaste = (await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { content: 'alex paste' } })).json().slug;
+ kimPaste = (await call('/api/pastes', { method: 'POST', cookie: kimJwt, json: { content: 'kim paste' } })).json().slug;
+ // The paste API (the paste page's comments).
+ let r = await call(`/api/pastes/${alexPaste}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'hi alex' } });
+ assert.deepStrictEqual([r.status, r.json()], [403, { error: 'You cannot comment on this paste: its owner blocked you', code: 'community.blocked' }]);
+ r = await call(`/api/pastes/${alexPaste}/comments`, { method: 'POST', cookie: kimJwt, json: { message: 'nice' } });
+ assert.strictEqual(r.status, 201, r.text);
+ const onKim = (await call(`/api/pastes/${kimPaste}/comments`, { method: 'POST', cookie: alexJwt, json: { message: 'alex on kim' } })).json().comment;
+ r = await call(`/api/pastes/${kimPaste}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'sam replies', parent_id: onKim.id } });
+ assert.deepStrictEqual([r.status, r.json().code], [403, 'community.blocked'], 'alex\'s comment on kim\'s paste');
+ r = await call(`/api/pastes/${kimPaste}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'sam on kim' } });
+ assert.strictEqual(r.status, 201);
+ // A comment thread opened on alex's paste.
+ pasteThread = (await call('/api/v1/comments/threads/resolve', { method: 'POST', cookie: kimJwt, json: { ref: { service: 'community', type: 'paste', id: alexPaste } } })).json().thread;
+ r = await call(`/api/v1/comments/threads/${pasteThread.id}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'hi' } });
+ assert.deepStrictEqual([r.status, r.json().detail], [403, 'You cannot comment on this paste: its owner blocked you']);
+ // Its no-JS page shows the message in the form.
+ r = await call(`/c/${pasteThread.id}`, { method: 'POST', cookie: samJwt, form: { message: 'from the page' } });
+ assert.strictEqual(r.status, 403);
+ assert.ok(r.text.includes('You cannot comment on this paste: its owner blocked you'));
+ assert.ok(r.text.includes('from the page</textarea>'));
+ r = await call(`/c/${pasteThread.id}`, { method: 'POST', cookie: kimJwt, form: { message: 'kim from the page' } });
+ assert.strictEqual(r.status, 303);
+ });
+
+ await check('an unblock lifts it', async () => {
+ const r = await deliver(block(alex.subject_id, sam.subject_id, false, 4));
+ assert.strictEqual(r.json.outcome, 'blocks:unblocked');
+ assert.strictEqual((await call(`/api/v1/spaces/general/threads/${threadSlug}/posts`, { method: 'POST', cookie: samJwt, json: { body: 'hello again' } })).status, 201);
+ assert.strictEqual((await call(`/api/v1/comments/threads/${streamThread.id}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'hi alex', parent_id: alexComment.id } })).status, 201);
+ assert.strictEqual((await call(`/api/pastes/${alexPaste}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'hi alex' } })).status, 201);
+ assert.strictEqual((await call(`/api/v1/comments/threads/${pasteThread.id}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'hi' } })).status, 201);
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });