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

OP OpenVibers committed 25 Sept 2026 · 0cce476189 · 9 files, +281 −10


Changes

ddiff --git a/README.md b/README.md
iindex ab32de0..9e25027 100644
---- a/README.md
++++ b/README.md
@@@ -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.
++
## Capabilities
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.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
the installed contracts do not know locally, with the library's own matching rule (the exact id
or a `prefix.*` grant).
ddiff --git a/package-lock.json b/package-lock.json
iindex ba48f1e..d3eb080 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 5839eea..2b34080 100644
---- a/package.json
++++ b/package.json
@@@ -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"
},
ddiff --git a/server/comments/service.js b/server/comments/service.js
iindex 1da80ea..1b7c4fb 100644
---- a/server/comments/service.js
++++ b/server/comments/service.js
@@@ -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, limits
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) {
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, limits
if (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, limits
const 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') {
ddiff --git a/server/forum/service.js b/server/forum/service.js
iindex ec24ddb..aa6a132 100644
---- a/server/forum/service.js
++++ b/server/forum/service.js
@@@ -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, vi
mayPostIn(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);
ddiff --git a/server/identity/blocks.js b/server/identity/blocks.js
nnew file mode 100644
iindex 0000000..73009e0
---- /dev/null
++++ b/server/identity/blocks.js
@@@ -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 };
ddiff --git a/server/pastes/service.js b/server/pastes/service.js
iindex e9712a7..5067ce3 100644
---- a/server/pastes/service.js
++++ b/server/pastes/service.js
@@@ -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 = {}, lim
const 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])) };
ddiff --git a/server/pulse/consumer.js b/server/pulse/consumer.js
iindex f312ae3..bc4f9a6 100644
---- a/server/pulse/consumer.js
++++ b/server/pulse/consumer.js
@@@ -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' }));
ddiff --git a/test/blocks.test.js b/test/blocks.test.js
nnew file mode 100644
iindex 0000000..92a829a
---- /dev/null
++++ b/test/blocks.test.js
@@@ -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 &lt;draft&gt;</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); });